136 lines
4.1 KiB
Go
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
|
|
}
|