feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Restricted Compose policy
|
||||
|
||||
`Check(manifest, entrypointBytes)` is a pure, offline policy check. The CLI
|
||||
`check-package` first calls `appbundle.Verify`, then passes the authenticated
|
||||
entrypoint snapshot here. It never reopens the entrypoint, runs Compose, reads
|
||||
`.env`, resolves templates, or contacts a daemon. Direct internal callers must
|
||||
perform the same integrity/trust check first.
|
||||
|
||||
Profile: `isolated-compose-v1`. This is an initial restricted backend profile,
|
||||
not the finished application's Compose contract. It deliberately rejects public
|
||||
routing, secrets/config injection, environment settings, healthchecks, dependency
|
||||
ordering and application-specific privilege exceptions until adapters exist.
|
||||
Existing YAML packages can still pass `verify-package`; that does not mean they
|
||||
pass `check-package`. Only JSON entrypoint content is accepted by this policy.
|
||||
|
||||
## Exact shape
|
||||
|
||||
All fields below are mandatory, all unlisted fields are rejected:
|
||||
|
||||
```json
|
||||
{
|
||||
"services": {
|
||||
"api": {
|
||||
"image": "example/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"user": "1000:1000",
|
||||
"read_only": true,
|
||||
"cap_drop": ["ALL"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"restart": "no",
|
||||
"networks": ["backend"],
|
||||
"volumes": [
|
||||
{"type": "volume", "source": "data", "target": "/data", "read_only": false}
|
||||
]
|
||||
}
|
||||
},
|
||||
"networks": {"backend": {"internal": true}},
|
||||
"volumes": {"data": {}}
|
||||
}
|
||||
```
|
||||
|
||||
The example digest is synthetic, not an installable release.
|
||||
|
||||
- 4 MiB input limit. Shared strict decoder rejects duplicate, case-alias,
|
||||
unknown, missing and null fields, including typed map values.
|
||||
- 1–32 services, exactly matching manifest component names and pinned images.
|
||||
- UID and GID must be canonical positive uint32 decimals, excluding 4294967295.
|
||||
No root, account-name lookup, interpolation or inherited user defaults.
|
||||
- Restart must be `no` or `unless-stopped`; root filesystem must be read-only,
|
||||
all capabilities dropped and privilege escalation disabled.
|
||||
- Exactly one network: `backend`, with `internal: true`. No default network,
|
||||
external network, host networking, published port or arbitrary router label.
|
||||
- At most 128 plain named volumes; every declaration must be mounted exactly
|
||||
once. Empty volume maps/lists are permitted for stateless services. No external
|
||||
names, drivers, driver options, bind mounts or cross-service sharing.
|
||||
- Mount paths must be absolute canonical ASCII paths, at most 240 bytes, with
|
||||
no root, overlapping mount or system-tree mount. Denied trees: /proc, /sys,
|
||||
/dev, /etc, /run, /var/run, /bin, /sbin, /usr, /lib, /lib64 (including ancestors).
|
||||
- No command overrides, hooks, build, include, extends, profile, socket access,
|
||||
devices or arbitrary privilege additions. Unknown future keys also fail closed.
|
||||
|
||||
## What passing does not prove
|
||||
|
||||
This check does not authenticate a publisher, validate image contents or mount
|
||||
destinations inside an image, provision usable volume ownership, reserve resource
|
||||
names, verify engine/Compose compatibility, limit resource consumption, prove
|
||||
application readiness, or provide backup/restore. Image defaults and existing
|
||||
Docker resources must still be validated by future adapters and preflight.
|
||||
|
||||
Future execution must bind an explicit instance project name, verify resource
|
||||
ownership under the host lock, and use the exact checked snapshot without extra
|
||||
Compose files, ambient overrides or subsequent interpolation. An integrity hash
|
||||
and this restricted policy are not authorization to run a deployment.
|
||||
|
||||
References checked during implementation:
|
||||
[Compose services](https://docs.docker.com/reference/compose-file/services/),
|
||||
[Compose networks](https://docs.docker.com/reference/compose-file/networks/),
|
||||
[Compose config](https://docs.docker.com/reference/cli/docker/compose/config/).
|
||||
No real Docker/Compose execution has been validated in this batch.
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package composepolicy validates a deliberately restricted, offline Compose
|
||||
// profile. Passing this policy never authorizes execution or proves image safety.
|
||||
package composepolicy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server-deploy/internal/appbundle"
|
||||
"server-deploy/internal/wire"
|
||||
)
|
||||
|
||||
const Profile = "isolated-compose-v1"
|
||||
|
||||
type document struct {
|
||||
Services map[string]service `json:"services"`
|
||||
Networks map[string]network `json:"networks"`
|
||||
Volumes map[string]struct{} `json:"volumes"`
|
||||
}
|
||||
type network struct {
|
||||
Internal bool `json:"internal"`
|
||||
}
|
||||
type service struct {
|
||||
Image string `json:"image"`
|
||||
User string `json:"user"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
CapDrop []string `json:"cap_drop"`
|
||||
SecurityOpt []string `json:"security_opt"`
|
||||
Restart string `json:"restart"`
|
||||
Networks []string `json:"networks"`
|
||||
Volumes []mount `json:"volumes"`
|
||||
}
|
||||
type mount struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
||||
var pinnedImage = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$`)
|
||||
var targetPath = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
|
||||
|
||||
// Check consumes only the authenticated entrypoint bytes and manifest supplied
|
||||
// by appbundle.Verify. It neither reads files nor renders/interpolates templates.
|
||||
// All fields in the profile are mandatory; unknown Compose features fail closed.
|
||||
func Check(manifest appbundle.Manifest, data []byte) error {
|
||||
reject := errors.New("Compose document rejected by restricted policy")
|
||||
var d document
|
||||
if wire.Decode(bytes.NewReader(data), &d, 4<<20) != nil {
|
||||
return reject
|
||||
}
|
||||
if len(manifest.Components) < 1 || len(manifest.Components) > 32 || len(d.Services) != len(manifest.Components) {
|
||||
return reject
|
||||
}
|
||||
if len(d.Networks) != 1 || !d.Networks["backend"].Internal || len(d.Volumes) > 128 {
|
||||
return reject
|
||||
}
|
||||
images := make(map[string]string, len(manifest.Components))
|
||||
for _, c := range manifest.Components {
|
||||
if !identifier.MatchString(c.Name) || !pinnedImage.MatchString(c.Image) || images[c.Name] != "" {
|
||||
return reject
|
||||
}
|
||||
images[c.Name] = c.Image
|
||||
}
|
||||
for name := range d.Volumes {
|
||||
if !identifier.MatchString(name) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
used := make(map[string]bool)
|
||||
for name, s := range d.Services {
|
||||
if images[name] == "" || s.Image != images[name] || !nonRootUser(s.User) || !s.ReadOnly {
|
||||
return reject
|
||||
}
|
||||
if !only(s.CapDrop, "ALL") || !only(s.SecurityOpt, "no-new-privileges:true") || !only(s.Networks, "backend") {
|
||||
return reject
|
||||
}
|
||||
if s.Restart != "unless-stopped" && s.Restart != "no" {
|
||||
return reject
|
||||
}
|
||||
if len(s.Volumes) > 128 {
|
||||
return reject
|
||||
}
|
||||
targets := make([]string, 0, len(s.Volumes))
|
||||
for _, v := range s.Volumes {
|
||||
if _, exists := d.Volumes[v.Source]; !exists || used[v.Source] || v.Type != "volume" {
|
||||
return reject
|
||||
}
|
||||
if len(v.Target) > 240 || !targetPath.MatchString(v.Target) || path.Clean(v.Target) != v.Target || v.Target == "/" {
|
||||
return reject
|
||||
}
|
||||
// Deny runtime/system trees as well as overlapping mounts. Only
|
||||
// application-data destinations belong in this initial profile.
|
||||
for _, protected := range []string{"/proc", "/sys", "/dev", "/etc", "/run", "/var/run", "/bin", "/sbin", "/usr", "/lib", "/lib64"} {
|
||||
if overlaps(v.Target, protected) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
for _, previous := range targets {
|
||||
if overlaps(v.Target, previous) {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
targets = append(targets, v.Target)
|
||||
used[v.Source] = true
|
||||
}
|
||||
}
|
||||
if len(used) != len(d.Volumes) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func only(values []string, expected string) bool { return len(values) == 1 && values[0] == expected }
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
func nonRootUser(value string) bool {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
n, err := strconv.ParseUint(part, 10, 32)
|
||||
if err != nil || n == 0 || n == 4294967295 || strconv.FormatUint(n, 10) != part {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package composepolicy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/appbundle"
|
||||
)
|
||||
|
||||
func fixture() (appbundle.Manifest, map[string]any) {
|
||||
image := "example/api@sha256:" + strings.Repeat("a", 64)
|
||||
m := appbundle.Manifest{Components: []appbundle.Component{{Name: "api", Image: image}}}
|
||||
d := map[string]any{
|
||||
"services": map[string]any{"api": map[string]any{
|
||||
"image": image, "user": "1000:1000", "read_only": true,
|
||||
"cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"},
|
||||
"restart": "unless-stopped", "networks": []string{"backend"},
|
||||
"volumes": []any{map[string]any{"type": "volume", "source": "data", "target": "/data", "read_only": false}},
|
||||
}},
|
||||
"networks": map[string]any{"backend": map[string]any{"internal": true}},
|
||||
"volumes": map[string]any{"data": map[string]any{}},
|
||||
}
|
||||
return m, d
|
||||
}
|
||||
|
||||
func TestAcceptRestrictedService(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
if err := Check(m, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectPrivilegeAndExternalInputs(t *testing.T) {
|
||||
for _, field := range []string{"privileged", "build", "container_name", "network_mode", "pid", "ipc", "userns_mode", "devices", "cap_add", "env_file", "environment", "extends", "ports", "labels", "use_api_socket", "post_start", "pre_stop", "command", "entrypoint", "volumes_from", "develop", "provider"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
m, d := fixture()
|
||||
d["services"].(map[string]any)["api"].(map[string]any)[field] = "secret-sentinel"
|
||||
raw, _ := json.Marshal(d)
|
||||
err := Check(m, raw)
|
||||
if err == nil || strings.Contains(err.Error(), "secret-sentinel") {
|
||||
t.Fatal("forbidden field accepted or echoed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsafeValuesAndReferences(t *testing.T) {
|
||||
mutations := map[string]func(map[string]any, map[string]any){
|
||||
"image drift": func(d, s map[string]any) { s["image"] = "example/api:latest" },
|
||||
"root": func(d, s map[string]any) { s["user"] = "0:0" },
|
||||
"named user": func(d, s map[string]any) { s["user"] = "root" },
|
||||
"writable root": func(d, s map[string]any) { s["read_only"] = false },
|
||||
"caps": func(d, s map[string]any) { s["cap_drop"] = []string{} },
|
||||
"escalation": func(d, s map[string]any) { s["security_opt"] = []string{"seccomp:unconfined"} },
|
||||
"implicit network": func(d, s map[string]any) { s["networks"] = []string{} },
|
||||
"other network": func(d, s map[string]any) { s["networks"] = []string{"default"} },
|
||||
"external network": func(d, s map[string]any) {
|
||||
d["networks"] = map[string]any{"backend": map[string]any{"internal": true, "external": true}}
|
||||
},
|
||||
"outbound network": func(d, s map[string]any) {
|
||||
d["networks"] = map[string]any{"backend": map[string]any{"internal": false}}
|
||||
},
|
||||
"bind": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["type"] = "bind" },
|
||||
"host source": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["source"] = "/var/run/docker.sock" },
|
||||
"missing volume": func(d, s map[string]any) { d["volumes"] = map[string]any{} },
|
||||
"volume driver": func(d, s map[string]any) {
|
||||
d["volumes"] = map[string]any{"data": map[string]any{"driver_opts": map[string]string{"device": "/"}}}
|
||||
},
|
||||
"root mount": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/" },
|
||||
"traversal": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/data/../etc" },
|
||||
"interpolation": func(d, s map[string]any) { s["user"] = "${UID}:1000" },
|
||||
"include": func(d, s map[string]any) { d["include"] = []string{"/secret"} },
|
||||
"null service": func(d, s map[string]any) { d["services"].(map[string]any)["api"] = nil },
|
||||
"missing security": func(d, s map[string]any) { delete(s, "security_opt") },
|
||||
"null volume": func(d, s map[string]any) { d["volumes"] = map[string]any{"data": nil} },
|
||||
"extra service": func(d, s map[string]any) { d["services"].(map[string]any)["rogue"] = s },
|
||||
"no services": func(d, s map[string]any) { d["services"] = map[string]any{} },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, d := fixture()
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
mutate(d, s)
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("unsafe document accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectMalformedDocument(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
for _, input := range []string{"services: {}", string(raw) + "{}", strings.Replace(string(raw), `"read_only":true`, `"read_only":true,"read_only":false`, 1), strings.Repeat(" ", 4<<20) + string(raw)} {
|
||||
if Check(m, []byte(input)) == nil {
|
||||
t.Fatal("malformed input accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountAndIdentityBoundaries(t *testing.T) {
|
||||
for _, target := range []string{"/proc/x", "/var", "/var/run/docker.sock", "/sys", "/dev", "/etc", "/run", "/usr/local", "/lib", "/lib64", "/bin", "/sbin", "/data/", "/data//x", "/data/$HOME", `C:\data`, "/" + strings.Repeat("a", 241)} {
|
||||
m, d := fixture()
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
s["volumes"].([]any)[0].(map[string]any)["target"] = target
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Errorf("accepted protected/noncanonical target %q", target)
|
||||
}
|
||||
}
|
||||
for _, user := range []string{"1000:0", "01:1000", "+1:1000", "-1:1000", "4294967295:1000", "4294967296:1000", "1", "1:2:3"} {
|
||||
m, d := fixture()
|
||||
d["services"].(map[string]any)["api"].(map[string]any)["user"] = user
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Errorf("accepted ambiguous/root identity %q", user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoServicesRequireExactManifestAndSeparateVolumes(t *testing.T) {
|
||||
m, d := fixture()
|
||||
raw, _ := json.Marshal(d)
|
||||
var copyDoc map[string]any
|
||||
if err := json.Unmarshal(raw, ©Doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := copyDoc["services"].(map[string]any)["api"].(map[string]any)
|
||||
second["volumes"].([]any)[0].(map[string]any)["source"] = "db-data"
|
||||
d["services"].(map[string]any)["db"] = second
|
||||
d["volumes"].(map[string]any)["db-data"] = map[string]any{}
|
||||
m.Components = append(m.Components, appbundle.Component{Name: "db", Image: m.Components[0].Image})
|
||||
raw, _ = json.Marshal(d)
|
||||
if err := Check(m, raw); err != nil {
|
||||
t.Fatal("valid separate-service volumes rejected", err)
|
||||
}
|
||||
second["volumes"].([]any)[0].(map[string]any)["source"] = "data"
|
||||
delete(d["volumes"].(map[string]any), "db-data")
|
||||
raw, _ = json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("shared service data accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlappingAndUnusedVolumesRejected(t *testing.T) {
|
||||
for _, target := range []string{"/data", "/data/nested", "/other"} {
|
||||
m, d := fixture()
|
||||
d["volumes"].(map[string]any)["second"] = map[string]any{}
|
||||
if target != "/other" {
|
||||
s := d["services"].(map[string]any)["api"].(map[string]any)
|
||||
s["volumes"] = append(s["volumes"].([]any), map[string]any{"type": "volume", "source": "second", "target": target, "read_only": false})
|
||||
}
|
||||
raw, _ := json.Marshal(d)
|
||||
if Check(m, raw) == nil {
|
||||
t.Fatal("overlapping or unused volume accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user