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
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user