v0.328.0 (unreleased): security review round 2 — nearest-ancestor symlink resolve (fail closed), any extends refused, the entry binds a literal image and its exact healthcheck, no variable in a bind's options
gates / gates (push) Successful in 44s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 16:46:32 +02:00
parent 24d937d6e4
commit 36c4aa4e69
4 changed files with 132 additions and 12 deletions
+64 -11
View File
@@ -33,7 +33,9 @@ package privpolicy
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
@@ -57,8 +59,11 @@ type Entry struct {
// Image is the image REPOSITORY (no tag, no digest) the service must run for the entry to hold. Without it a push
// could keep the allowed setting and swap the image under it (security review 2026-10-17, finding 1).
Image string `json:"image"`
Why string `json:"why"`
Doc string `json:"doc"`
// Healthcheck is the exact `healthcheck.test` (compact JSON) the service may carry — the test runs a command inside
// the container holding the allowed right, so a changed one is a changed start (round 2). "" = no healthcheck.
Healthcheck string `json:"healthcheck"`
Why string `json:"why"`
Doc string `json:"doc"`
}
type allowList struct {
@@ -166,10 +171,15 @@ func Admit(app string, compose []byte, env map[string]string, p Policy) error {
// Finding 1: the entry holds only for the image it names, and only while the service runs that image's own
// start — a changed entrypoint or command would run anything with the allowed right.
f := svcs[v.Service]
if f.repo != e.Image {
switch {
case f.imageInterp:
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a variable in its image"})
case f.repo != e.Image:
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with image " + f.repo + " (allowed only for " + e.Image + ")"})
} else if f.startOverride {
case f.startOverride:
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a changed entrypoint or command"})
case f.healthcheck != e.Healthcheck:
refused = append(refused, Violation{Service: v.Service, Setting: v.Setting + " with a changed healthcheck"})
}
}
if len(refused) > 0 {
@@ -180,7 +190,9 @@ func Admit(app string, compose []byte, env map[string]string, p Policy) error {
type facts struct {
repo string // the rendered image's repository (no tag, no digest)
imageInterp bool // the image reference holds a ${VAR}: a deploy-field default could change it later
startOverride bool // entrypoint or command set
healthcheck string // compact JSON of healthcheck.test, "" when none
}
// serviceFacts reads, per service, what an allow-list entry is bound to.
@@ -194,7 +206,14 @@ func serviceFacts(compose []byte, env map[string]string) map[string]facts {
sd, _ := v.(map[string]any)
_, ep := sd["entrypoint"]
_, cmd := sd["command"]
out[name] = facts{repo: imageRepo(c.render(sd["image"])), startOverride: ep || cmd}
hc := ""
if h, ok := sd["healthcheck"].(map[string]any); ok {
if t, has := h["test"]; has {
b, _ := json.Marshal(t)
hc = string(b)
}
}
out[name] = facts{repo: imageRepo(c.render(sd["image"])), imageInterp: hasInterp(sd["image"]), startOverride: ep || cmd, healthcheck: hc}
}
return out
}
@@ -351,10 +370,17 @@ func (c *checker) service(name string, sd map[string]any) {
c.add(name, hook+" "+c.canon(v))
}
}
if ex, ok := sd["extends"].(map[string]any); ok {
// Any `extends` (round 2): a second service extending an allowed one INHERITS its rights — its socket bind would
// reach a service and an image nobody allowed, and the raw file this check reads would not show it.
switch ex := sd["extends"].(type) {
case map[string]any:
if f, has := ex["file"]; has {
c.add(name, "extends_file "+c.shown(f))
} else {
c.add(name, "extends "+c.shown(ex["service"]))
}
case string:
c.add(name, "extends "+c.shown(ex))
}
for _, v := range list(sd["env_file"]) {
raw := v
@@ -394,6 +420,13 @@ func (c *checker) volume(svc string, v any) {
return // an anonymous volume: a container path only
}
rawSrc = parts[0]
if isPathSource(rawSrc) && ((len(parts) > 1 && !rootLed(parts[1])) || (len(parts) > 2 && hasInterp(strings.Join(parts[2:], ":")))) {
// round 2: a ${VAR} in the options (or a non-root one in the target) could turn on propagation later — the
// options are literal, the target may use a root variable only (the *arr apps mount ${USERDATA_PATH}/media
// at the same path inside).
c.add(svc, "bind "+t+" with a variable in its target or options")
return
}
src, dst = c.render(parts[0]), c.render(parts[1])
mode = "rw"
if len(parts) > 2 {
@@ -500,11 +533,13 @@ func (c *checker) underRoot(p string) bool {
if !in || c.p.Resolve == nil {
return in
}
// On the box: an existing source is judged by where it really points. A source that does not exist yet (the
// deploy belt creates it as a directory) is judged by its name; a root that cannot be resolved is no root.
rp, err := c.p.Resolve(p)
if err != nil {
return true
// On the box: a source is judged by where it really points. A path that does not exist yet (the deploy belt — or
// Docker — creates it) is judged through its nearest EXISTING ancestor, so a symlink higher up cannot carry a
// not-yet-created directory out of the root (security review 2026-10-17, round 2). Any resolve error other than
// "does not exist" refuses (fail closed).
rp, ok := c.resolveExisting(p)
if !ok {
return false
}
for _, r := range c.roots {
rr, rerr := c.p.Resolve(r)
@@ -515,6 +550,24 @@ func (c *checker) underRoot(p string) bool {
return false
}
// resolveExisting resolves the nearest existing ancestor of p and re-joins the rest.
func (c *checker) resolveExisting(p string) (string, bool) {
rest := ""
for q := p; ; q = filepath.Dir(q) {
r, err := c.p.Resolve(q)
if err == nil {
return filepath.Join(r, rest), true
}
if !errors.Is(err, fs.ErrNotExist) {
return "", false
}
if q == "/" || q == "." {
return "", false
}
rest = filepath.Join(filepath.Base(q), rest)
}
}
// insideStack: a relative path that stays inside the stack directory.
func (c *checker) insideStack(p string) bool {
return p != "" && !strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "~") && !hasDotDot(p)