// 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 }