Files

136 lines
4.1 KiB
Go

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