v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
|
||||
"entries": [
|
||||
{
|
||||
"app": "homepage",
|
||||
"service": "homepage",
|
||||
"setting": "bind /var/run/docker.sock -> /var/run/docker.sock ro",
|
||||
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
|
||||
"doc": "https://gethomepage.dev/configs/docker/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
// Package privpolicy is the box-side catalog allow-list (R-971, operator decision D2 = A, 2026-10-17;
|
||||
// felhom.eu `02-controller-module-map.md` „The catalog allow-list").
|
||||
//
|
||||
// A push to the catalog's `main` reaches every installed app within about 15 minutes. Before this package nothing on
|
||||
// the box refused a dangerous container setting, so whoever could push to the catalog could take every guest. Now
|
||||
// every path that writes an app's compose asks Admit first, on the RENDERED compose (after variable substitution),
|
||||
// and writes nothing when it refuses. A setting in one of the classes below is refused unless THIS app's service has
|
||||
// exactly that setting on the allow-list compiled into the controller (allowlist.json, go:embed) — a change in Gitea
|
||||
// cannot add to it. An allowed setting that changes (a read-only socket becoming read-write) is a different setting,
|
||||
// so it is refused too.
|
||||
//
|
||||
// The classes (each a Violation.Setting prefix):
|
||||
//
|
||||
// privileged, cap_add, device, device_cgroup_rule, network_mode, pid, ipc, uts, userns_mode, cgroup,
|
||||
// security_opt (every value except no-new-privileges), sysctl, volumes_from,
|
||||
// bind (a host path outside the box's data roots, the Docker socket anywhere, mount propagation),
|
||||
// volume (a named volume with a driver, driver_opts, external or an explicit name — each can alias a host path or
|
||||
// another stack's volume), config_file / secret_file / env_file (a host file outside the stack directory),
|
||||
// build, include, extends_file,
|
||||
// interpolated (a ${VAR} inside any of the keys above — a value the catalog's own deploy fields could later change)
|
||||
//
|
||||
// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each
|
||||
// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables,
|
||||
// and only as its leading token.
|
||||
//
|
||||
// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate,
|
||||
// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope.
|
||||
//
|
||||
// Pinned by privpolicy_test.go (one refused case per class, a positive control per class, the allow-list's exact-
|
||||
// match rule) and stacks.TestAllowList_IsWiredIntoEveryComposeWriter (the structural guard).
|
||||
package privpolicy
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed allowlist.json
|
||||
var allowListJSON []byte
|
||||
|
||||
// AllowListJSON is the compiled-in allow-list, byte for byte (the catalog keeps an identical copy for its CI gate;
|
||||
// TestAllowList_CatalogCopyIsIdentical pins the two).
|
||||
func AllowListJSON() []byte { return append([]byte(nil), allowListJSON...) }
|
||||
|
||||
// Entry is one allowed setting.
|
||||
type Entry struct {
|
||||
App string `json:"app"`
|
||||
Service string `json:"service"`
|
||||
Setting string `json:"setting"`
|
||||
Why string `json:"why"`
|
||||
Doc string `json:"doc"`
|
||||
}
|
||||
|
||||
type allowList struct {
|
||||
Version int `json:"version"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
var allowed = mustLoad(allowListJSON)
|
||||
|
||||
func mustLoad(b []byte) map[string]bool {
|
||||
var l allowList
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
panic("privpolicy: allowlist.json does not parse: " + err.Error())
|
||||
}
|
||||
m := map[string]bool{}
|
||||
for _, e := range l.Entries {
|
||||
m[key(e.App, e.Service, e.Setting)] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
|
||||
|
||||
// Entries returns the allow-list (for the doc and the app page).
|
||||
func Entries() []Entry {
|
||||
var l allowList
|
||||
_ = json.Unmarshal(allowListJSON, &l)
|
||||
return l.Entries
|
||||
}
|
||||
|
||||
// Violation is one setting in a class. Service is "" for a top-level definition (a volume, include).
|
||||
type Violation struct {
|
||||
Service string
|
||||
Setting string
|
||||
}
|
||||
|
||||
func (v Violation) String() string {
|
||||
if v.Service == "" {
|
||||
return v.Setting
|
||||
}
|
||||
return v.Service + ": " + v.Setting
|
||||
}
|
||||
|
||||
// RefusedError lists what was refused, in plain words. The app keeps its last good compose.
|
||||
type RefusedError struct {
|
||||
App string
|
||||
Violations []Violation
|
||||
}
|
||||
|
||||
func (e *RefusedError) Error() string {
|
||||
parts := make([]string, len(e.Violations))
|
||||
for i, v := range e.Violations {
|
||||
parts[i] = v.String()
|
||||
}
|
||||
return fmt.Sprintf("the catalog definition of %s asks for container rights the box does not allow: %s", e.App, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// Policy carries what the box knows about its own paths.
|
||||
type Policy struct {
|
||||
SystemDataPath string // paths.system_data_path — a data root may live under it (or under /mnt/)
|
||||
}
|
||||
|
||||
// RootVars are the only variables a bind source may use.
|
||||
var RootVars = []string{"HDD_PATH", "USERDATA_PATH", "IMPORT_PATH"}
|
||||
|
||||
// PlaceholderEnv is the env a template is checked with when no app env exists yet (the sync of an app that is not
|
||||
// installed, the catalog CI). Same shape as the controller gives; the catalog gate uses the same three values.
|
||||
func PlaceholderEnv() map[string]string {
|
||||
return map[string]string{
|
||||
"HDD_PATH": "/mnt/felhom-drives/check/felhom-data",
|
||||
"USERDATA_PATH": "/mnt/felhom-drives/check/felhom-data/userdata",
|
||||
"IMPORT_PATH": "/mnt/felhom-drives/check/felhom-data/userdata/import",
|
||||
"DOMAIN": "example.invalid",
|
||||
"SUBDOMAIN": "check",
|
||||
}
|
||||
}
|
||||
|
||||
// Admit is the one question every compose writer asks. nil = write it; *RefusedError = write nothing; any other
|
||||
// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a
|
||||
// definition it has checked).
|
||||
func Admit(app string, compose []byte, env map[string]string, p Policy) error {
|
||||
vs, err := Check(compose, env, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
|
||||
}
|
||||
var refused []Violation
|
||||
for _, v := range vs {
|
||||
if !allowed[key(app, v.Service, v.Setting)] {
|
||||
refused = append(refused, v)
|
||||
}
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return &RefusedError{App: app, Violations: refused}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check lists every setting in a class, allowed or not, sorted.
|
||||
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(compose, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &checker{env: env, p: p}
|
||||
c.roots = c.validRoots()
|
||||
if _, ok := doc["include"]; ok {
|
||||
c.add("", "include")
|
||||
}
|
||||
if vols, ok := doc["volumes"].(map[string]any); ok {
|
||||
names := sortedKeys(vols)
|
||||
for _, n := range names {
|
||||
def, _ := vols[n].(map[string]any)
|
||||
for _, k := range []string{"driver", "driver_opts", "external", "name"} {
|
||||
if v, has := def[k]; has && !(k == "driver" && c.render(v) == "local") {
|
||||
c.add("", fmt.Sprintf("volume %s %s %s", n, k, c.canon(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, top := range []string{"configs", "secrets"} {
|
||||
defs, _ := doc[top].(map[string]any)
|
||||
for _, n := range sortedKeys(defs) {
|
||||
def, _ := defs[n].(map[string]any)
|
||||
if f, has := def["file"]; has && !c.insideStack(c.render(f)) {
|
||||
c.add("", fmt.Sprintf("%s_file %s %s", strings.TrimSuffix(top, "s"), n, c.shown(f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
svcs, _ := doc["services"].(map[string]any)
|
||||
for _, name := range sortedKeys(svcs) {
|
||||
sd, _ := svcs[name].(map[string]any)
|
||||
c.service(name, sd)
|
||||
}
|
||||
sort.Slice(c.out, func(i, j int) bool {
|
||||
if c.out[i].Service != c.out[j].Service {
|
||||
return c.out[i].Service < c.out[j].Service
|
||||
}
|
||||
return c.out[i].Setting < c.out[j].Setting
|
||||
})
|
||||
return c.out, nil
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
env map[string]string
|
||||
p Policy
|
||||
roots []string
|
||||
out []Violation
|
||||
svc string
|
||||
}
|
||||
|
||||
func (c *checker) add(svc, setting string) {
|
||||
c.out = append(c.out, Violation{Service: svc, Setting: setting})
|
||||
}
|
||||
|
||||
// scalarKeys are the classes whose whole value is the setting.
|
||||
var scalarKeys = []string{"network_mode", "pid", "ipc", "uts", "userns_mode", "cgroup"}
|
||||
|
||||
// privilegedKeys are every key whose value must be literal (no ${VAR}).
|
||||
var privilegedKeys = []string{"privileged", "cap_add", "devices", "device_cgroup_rules", "network_mode", "pid", "ipc",
|
||||
"uts", "userns_mode", "cgroup", "security_opt", "sysctls", "volumes_from"}
|
||||
|
||||
func (c *checker) service(name string, sd map[string]any) {
|
||||
c.svc = name
|
||||
// A ${VAR} in a privileged key is refused as such, and the key is NOT rendered: its value could be a secret, and a
|
||||
// refusal names what it refused (logs, the operator event).
|
||||
interp := map[string]bool{}
|
||||
for _, k := range privilegedKeys {
|
||||
if v, has := sd[k]; has && hasInterp(v) {
|
||||
c.add(name, "interpolated "+k)
|
||||
interp[k] = true
|
||||
}
|
||||
}
|
||||
sd = withoutKeys(sd, interp)
|
||||
if v, has := sd["privileged"]; has && truthy(c.render(v)) {
|
||||
c.add(name, "privileged true")
|
||||
}
|
||||
for _, v := range list(sd["cap_add"]) {
|
||||
c.add(name, "cap_add "+strings.TrimPrefix(strings.ToUpper(c.render(v)), "CAP_"))
|
||||
}
|
||||
for _, v := range list(sd["devices"]) {
|
||||
c.add(name, "device "+c.canon(v))
|
||||
}
|
||||
for _, v := range list(sd["device_cgroup_rules"]) {
|
||||
c.add(name, "device_cgroup_rule "+c.render(v))
|
||||
}
|
||||
for _, k := range scalarKeys {
|
||||
if v, has := sd[k]; has {
|
||||
c.add(name, k+" "+c.render(v))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["security_opt"]) {
|
||||
s := c.render(v)
|
||||
if noNewPrivileges(s) {
|
||||
continue // a hardening, not a privilege
|
||||
}
|
||||
c.add(name, "security_opt "+s)
|
||||
}
|
||||
switch sy := sd["sysctls"].(type) {
|
||||
case map[string]any:
|
||||
for _, k := range sortedKeys(sy) {
|
||||
c.add(name, fmt.Sprintf("sysctl %s=%s", k, c.render(sy[k])))
|
||||
}
|
||||
case []any:
|
||||
for _, v := range sy {
|
||||
c.add(name, "sysctl "+c.render(v))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["volumes_from"]) {
|
||||
c.add(name, "volumes_from "+c.render(v))
|
||||
}
|
||||
if _, has := sd["build"]; has {
|
||||
c.add(name, "build")
|
||||
}
|
||||
if ex, ok := sd["extends"].(map[string]any); ok {
|
||||
if f, has := ex["file"]; has {
|
||||
c.add(name, "extends_file "+c.shown(f))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["env_file"]) {
|
||||
raw := v
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
raw = m["path"]
|
||||
}
|
||||
if !c.insideStack(c.render(raw)) {
|
||||
c.add(name, "env_file "+c.shown(raw))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["volumes"]) {
|
||||
c.volume(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
// volume classifies one service volume entry (short or long syntax).
|
||||
// withoutKeys returns sd without the named keys (a shallow copy; sd itself is not changed).
|
||||
func withoutKeys(sd map[string]any, drop map[string]bool) map[string]any {
|
||||
if len(drop) == 0 {
|
||||
return sd
|
||||
}
|
||||
out := make(map[string]any, len(sd))
|
||||
for k, v := range sd {
|
||||
if !drop[k] {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) volume(svc string, v any) {
|
||||
var rawSrc, src, dst, mode, prop string
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) < 2 {
|
||||
return // an anonymous volume: a container path only
|
||||
}
|
||||
rawSrc = parts[0]
|
||||
src, dst = c.render(parts[0]), c.render(parts[1])
|
||||
mode = "rw"
|
||||
if len(parts) > 2 {
|
||||
for _, o := range strings.Split(parts[2], ",") {
|
||||
switch o {
|
||||
case "ro":
|
||||
mode = "ro"
|
||||
case "shared", "rshared", "slave", "rslave":
|
||||
prop = o
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isPathSource(src) && !isPathSource(rawSrc) {
|
||||
return // a named volume (its definition is checked at the top level)
|
||||
}
|
||||
case map[string]any:
|
||||
typ := c.render(t["type"])
|
||||
rawSrc = fmt.Sprint(t["source"])
|
||||
src, dst = c.render(t["source"]), c.render(t["target"])
|
||||
mode = "rw"
|
||||
if truthy(c.render(t["read_only"])) {
|
||||
mode = "ro"
|
||||
}
|
||||
if b, ok := t["bind"].(map[string]any); ok {
|
||||
if p := c.render(b["propagation"]); p == "shared" || p == "rshared" || p == "slave" || p == "rslave" {
|
||||
prop = p
|
||||
}
|
||||
}
|
||||
switch typ {
|
||||
case "volume", "tmpfs", "image":
|
||||
return
|
||||
case "bind":
|
||||
default:
|
||||
c.add(svc, "volume type "+typ)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
shown := src
|
||||
if !rootLed(rawSrc) {
|
||||
shown = rawSrc // the template text: a rendered non-root variable could be a secret
|
||||
}
|
||||
setting := fmt.Sprintf("bind %s -> %s %s", shown, dst, mode)
|
||||
if prop != "" {
|
||||
setting += " " + prop
|
||||
}
|
||||
if !rootLed(rawSrc) || prop != "" || isSocket(src) || !c.underRoot(src) {
|
||||
c.add(svc, setting)
|
||||
}
|
||||
}
|
||||
|
||||
// rootLed reports whether a TEMPLATE bind source uses no variable, or exactly one root variable as its leading token.
|
||||
func rootLed(raw string) bool {
|
||||
if !strings.Contains(strings.ReplaceAll(raw, "$$", ""), "$") {
|
||||
return true // a literal path (judged by underRoot)
|
||||
}
|
||||
for _, r := range RootVars {
|
||||
for _, form := range []string{"${" + r + "}", "$" + r} {
|
||||
if rest, ok := strings.CutPrefix(raw, form); ok && (rest == "" || strings.HasPrefix(rest, "/")) &&
|
||||
!strings.Contains(strings.ReplaceAll(rest, "$$", ""), "$") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathSource(s string) bool {
|
||||
return strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "~") || strings.HasPrefix(s, "$")
|
||||
}
|
||||
|
||||
func isSocket(p string) bool {
|
||||
return strings.HasSuffix(p, ".sock") || strings.Contains(p, "docker.sock")
|
||||
}
|
||||
|
||||
// validRoots are the root values that are absolute, clean, not "/" and under /mnt/ or the system data path.
|
||||
func (c *checker) validRoots() []string {
|
||||
var out []string
|
||||
for _, r := range RootVars {
|
||||
v := c.env[r]
|
||||
if v == "" || !strings.HasPrefix(v, "/") || filepath.Clean(v) != v || v == "/" || hasDotDot(v) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(v, "/mnt/") || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) underRoot(p string) bool {
|
||||
if !strings.HasPrefix(p, "/") || hasDotDot(p) {
|
||||
return false
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
for _, r := range c.roots {
|
||||
if within(p, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func within(p, root string) bool { return p == root || strings.HasPrefix(p, root+"/") }
|
||||
|
||||
func hasDotDot(p string) bool {
|
||||
for _, s := range strings.Split(p, "/") {
|
||||
if s == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func noNewPrivileges(s string) bool {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "=", ":"), " ", "")
|
||||
return s == "no-new-privileges" || s == "no-new-privileges:true"
|
||||
}
|
||||
|
||||
func truthy(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "yes", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func list(v any) []any {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
return t
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []any{t}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
// canon renders a scalar, or a structure as compact JSON with rendered strings (stable key order).
|
||||
func (c *checker) canon(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return c.render(t)
|
||||
case map[string]any, []any:
|
||||
b, _ := json.Marshal(c.renderTree(t))
|
||||
return string(b)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) renderTree(v any) any {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return c.render(t)
|
||||
case map[string]any:
|
||||
m := map[string]any{}
|
||||
for k, x := range t {
|
||||
m[k] = c.renderTree(x)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = c.renderTree(x)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// shown is a value as a refusal may print it: rendered when it holds no variable, the template text when it does.
|
||||
func (c *checker) shown(v any) string {
|
||||
if hasInterp(v) {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return c.render(v)
|
||||
}
|
||||
|
||||
func hasInterp(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.Contains(strings.ReplaceAll(t, "$$", ""), "$")
|
||||
case map[string]any:
|
||||
for k, x := range t {
|
||||
if hasInterp(k) || hasInterp(x) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, x := range t {
|
||||
if hasInterp(x) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var interpRe = regexp.MustCompile(`\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// render substitutes compose's variable forms ($$, ${V}, ${V:-d}, ${V-d}, ${V:?e}, ${V?e}, ${V:+a}, ${V+a}, $V) from
|
||||
// the env. A non-string scalar is printed as YAML would read it.
|
||||
func (c *checker) render(v any) string {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return interpRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
if m == "$$" {
|
||||
return "$"
|
||||
}
|
||||
g := interpRe.FindStringSubmatch(m)
|
||||
name, op, arg := g[1], g[2], g[3]
|
||||
if name == "" {
|
||||
name = g[4]
|
||||
}
|
||||
val, set := c.env[name]
|
||||
switch op {
|
||||
case ":-":
|
||||
if val == "" {
|
||||
return arg
|
||||
}
|
||||
case "-":
|
||||
if !set {
|
||||
return arg
|
||||
}
|
||||
case ":+":
|
||||
if val != "" {
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
case "+":
|
||||
if set {
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
// controllerInfra are the stacks the controller renders itself (out of this check's scope — and never a catalog
|
||||
// template's to overwrite).
|
||||
var controllerInfra = map[string]bool{"traefik": true, "cloudflared": true, "filebrowser": true, "samba": true, "felhom-controller": true}
|
||||
|
||||
// IsControllerInfra reports whether name is one of the controller's own infra stacks.
|
||||
func IsControllerInfra(name string) bool { return controllerInfra[strings.ToLower(name)] }
|
||||
@@ -0,0 +1,269 @@
|
||||
package privpolicy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var pol = Policy{SystemDataPath: "/var/lib/felhom"}
|
||||
|
||||
func env() map[string]string {
|
||||
return map[string]string{
|
||||
"HDD_PATH": "/mnt/felhom-drives/adatlemez/felhom-data",
|
||||
"USERDATA_PATH": "/mnt/felhom-drives/adatlemez/felhom-data/userdata",
|
||||
"IMPORT_PATH": "/var/lib/felhom/data/userdata/import",
|
||||
"SUBDOMAIN": "x", "DOMAIN": "example.hu",
|
||||
}
|
||||
}
|
||||
|
||||
// base is a real-shaped app: a root-led bind, a named volume, a hardening security_opt.
|
||||
const base = `services:
|
||||
app:
|
||||
image: example/app:1.0
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- ${USERDATA_PATH}/media:/media:ro
|
||||
- ${HDD_PATH}/appdata/app:/config
|
||||
- ${IMPORT_PATH}/app:/consume
|
||||
volumes:
|
||||
app_data:
|
||||
`
|
||||
|
||||
func admit(t *testing.T, app, compose string, e map[string]string) error {
|
||||
t.Helper()
|
||||
return Admit(app, []byte(compose), e, pol)
|
||||
}
|
||||
|
||||
func TestAdmit_TheBaseShapeIsAdmitted(t *testing.T) {
|
||||
if err := admit(t, "app", base, env()); err != nil {
|
||||
t.Fatalf("a normal app was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One ATTACK per class, each added to the admitted base: refused, and the refusal names the setting.
|
||||
// RED-PROOF (P2/redproof-privpolicy.txt): with Admit returning nil, every case fails.
|
||||
func TestAdmit_EveryClassIsRefused(t *testing.T) {
|
||||
cases := map[string]struct{ add, want string }{
|
||||
"privileged": {" privileged: true\n", "privileged true"},
|
||||
"cap_add": {" cap_add: [SYS_ADMIN]\n", "cap_add SYS_ADMIN"},
|
||||
"devices": {" devices: [\"/dev/sda:/dev/sda\"]\n", "device /dev/sda:/dev/sda"},
|
||||
"device_cgroup_rule": {" device_cgroup_rules: [\"b 8:* rmw\"]\n", "device_cgroup_rule b 8:* rmw"},
|
||||
"network_mode host": {" network_mode: host\n", "network_mode host"},
|
||||
"network_mode ctr": {" network_mode: \"container:felhom-controller\"\n", "network_mode container:felhom-controller"},
|
||||
"pid": {" pid: host\n", "pid host"},
|
||||
"ipc": {" ipc: host\n", "ipc host"},
|
||||
"uts": {" uts: host\n", "uts host"},
|
||||
"userns_mode": {" userns_mode: host\n", "userns_mode host"},
|
||||
"cgroup": {" cgroup: host\n", "cgroup host"},
|
||||
"seccomp off": {" security_opt: [\"seccomp=unconfined\"]\n", "security_opt seccomp=unconfined"},
|
||||
"apparmor off": {" security_opt: [\"apparmor:unconfined\"]\n", "security_opt apparmor:unconfined"},
|
||||
"sysctls": {" sysctls:\n kernel.core_pattern: x\n", "sysctl kernel.core_pattern=x"},
|
||||
"volumes_from": {" volumes_from: [\"container:felhom-controller\"]\n", "volumes_from container:felhom-controller"},
|
||||
"build": {" build: /\n", "build"},
|
||||
"env_file outside": {" env_file: /opt/docker/felhom-controller/controller.yaml\n", "env_file /opt/docker/felhom-controller/controller.yaml"},
|
||||
"extends file": {" extends:\n file: /etc/x.yml\n service: y\n", "extends_file /etc/x.yml"},
|
||||
"interpolated": {" privileged: ${PRIV}\n", "interpolated privileged"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+c.add, 1)
|
||||
// security_opt is already in base: replace it rather than duplicate the key
|
||||
if strings.HasPrefix(c.add, " security_opt") {
|
||||
compose = strings.Replace(base, " security_opt:\n - no-new-privileges:true\n", c.add, 1)
|
||||
}
|
||||
err := admit(t, "app", compose, env())
|
||||
var re *RefusedError
|
||||
if !errors.As(err, &re) {
|
||||
t.Fatalf("not refused: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("refusal %q does not name %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Bind sources: the root rule, read on the TEMPLATE and on the RENDERED value.
|
||||
func TestAdmit_Binds(t *testing.T) {
|
||||
refuse := map[string]string{
|
||||
"host root": " - /:/host",
|
||||
"docker socket": " - /var/run/docker.sock:/var/run/docker.sock",
|
||||
"socket under a root": " - ${HDD_PATH}/docker.sock:/s",
|
||||
"etc": " - /etc:/etc:ro",
|
||||
"relative stack dir": " - ./:/stack",
|
||||
"dotdot after a root": " - ${HDD_PATH}/../../../etc:/x",
|
||||
"another variable": " - ${OTHER}/x:/x",
|
||||
"root then a variable": " - ${HDD_PATH}${OTHER}:/x",
|
||||
"rshared propagation": " - ${HDD_PATH}/x:/x:rshared",
|
||||
"long syntax socket": " - type: bind\n source: /run/docker.sock\n target: /s",
|
||||
"long syntax npipe": " - type: npipe\n source: x\n target: /s",
|
||||
"literal outside roots": " - /mnt/other-drive/x:/x",
|
||||
}
|
||||
for name, line := range refuse {
|
||||
t.Run("refused/"+name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
|
||||
if err := admit(t, "app", compose, env()); err == nil {
|
||||
t.Fatalf("%s was admitted", line)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Positive controls: a literal path INSIDE a root, a long-syntax root bind, a tmpfs.
|
||||
for name, line := range map[string]string{
|
||||
"literal inside a root": " - /mnt/felhom-drives/adatlemez/felhom-data/userdata/x:/x",
|
||||
"long syntax root bind": " - type: bind\n source: ${USERDATA_PATH}/media\n target: /m\n read_only: true",
|
||||
"tmpfs": " - type: tmpfs\n target: /tmp",
|
||||
} {
|
||||
t.Run("admitted/"+name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
|
||||
if err := admit(t, "app", compose, env()); err != nil {
|
||||
t.Fatalf("%s was refused: %v", line, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ROOTS are checked too: a HDD_PATH of "/" (or anything not under /mnt/ or the system data path) is no root, so
|
||||
// every bind under it is refused — env cannot widen the box's data roots.
|
||||
func TestAdmit_ARootValueOutsideTheBoxIsNoRoot(t *testing.T) {
|
||||
for _, bad := range []string{"/", "/etc", "/mnt/../etc", "relative/x", "/var/lib/felhomX"} {
|
||||
e := env()
|
||||
e["HDD_PATH"] = bad
|
||||
if err := admit(t, "app", base, e); err == nil {
|
||||
t.Fatalf("HDD_PATH=%q was accepted as a data root", bad)
|
||||
}
|
||||
}
|
||||
e := env()
|
||||
e["HDD_PATH"] = "/var/lib/felhom/data" // the system-data fallback is a root
|
||||
if err := admit(t, "app", base, e); err != nil {
|
||||
t.Fatalf("the system-data root was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level definitions that can alias a host path or another stack's volume.
|
||||
func TestAdmit_TopLevelAliases(t *testing.T) {
|
||||
for name, tail := range map[string]string{
|
||||
"driver_opts bind": " app_data:\n driver_opts:\n type: none\n o: bind\n device: /\n",
|
||||
"external": " app_data:\n external: true\n",
|
||||
"explicit name": " app_data:\n name: felhom-controller_data\n",
|
||||
"plugin driver": " app_data:\n driver: rexray\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, "volumes:\n app_data:\n", "volumes:\n"+tail, 1)
|
||||
if err := admit(t, "app", compose, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, add := range map[string]string{
|
||||
"include": "include:\n - /etc/other.yml\n",
|
||||
"config file": "configs:\n c:\n file: /etc/shadow\n",
|
||||
"secret file": "secrets:\n s:\n file: ../../x\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := admit(t, "app", base+add, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := admit(t, "app", base+"configs:\n c:\n file: ./conf/app.ini\n", env()); err != nil {
|
||||
t.Fatalf("a config file inside the stack dir was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const homepage = `services:
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:v1.13.2
|
||||
volumes:
|
||||
- homepage_config:/app/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
volumes:
|
||||
homepage_config:
|
||||
`
|
||||
|
||||
// The allow-list is EXACT: homepage's read-only socket is allowed for homepage's service only; the same socket
|
||||
// read-write, under another service, or in another app is refused.
|
||||
func TestAdmit_TheAllowListIsExact(t *testing.T) {
|
||||
if err := admit(t, "homepage", homepage, env()); err != nil {
|
||||
t.Fatalf("the allowed homepage socket was refused: %v", err)
|
||||
}
|
||||
for name, c := range map[string]struct{ app, compose string }{
|
||||
"read-write": {"homepage", strings.Replace(homepage, "docker.sock:ro", "docker.sock", 1)},
|
||||
"another service": {"homepage", strings.Replace(homepage, " homepage:\n image", " sidecar:\n image", 1)},
|
||||
"another app": {"glance", homepage},
|
||||
"plus privileged": {"homepage", strings.Replace(homepage, " volumes:", " privileged: true\n volumes:", 1)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, c.compose, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The check fails CLOSED: a compose it cannot read is refused, not admitted.
|
||||
func TestAdmit_UnreadableIsRefused(t *testing.T) {
|
||||
if err := admit(t, "app", "services: [::", env()); err == nil {
|
||||
t.Fatal("an unparseable compose was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
// The renderer follows compose's forms.
|
||||
func TestRender_ComposeForms(t *testing.T) {
|
||||
c := &checker{env: map[string]string{"A": "a", "E": ""}}
|
||||
for in, want := range map[string]string{
|
||||
"${A}": "a", "$A/x": "a/x", "${B:-d}": "d", "${E:-d}": "d", "${E-d}": "", "${B-d}": "d",
|
||||
"${A:+y}": "y", "${E:+y}": "", "${E+y}": "y", "$$A": "$A", "${A:?boom}": "a",
|
||||
} {
|
||||
if got := c.render(in); got != want {
|
||||
t.Errorf("render(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every template and ladder step in the catalog passes on a placeholder env — the catalog as it is today needs
|
||||
// exactly the allow-list it has. Runs where the sibling clone is present (the workspace); CI has none.
|
||||
func TestAllowList_TheWholeCatalogPasses(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "templates")
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("no sibling catalog clone at %s — checked in the workspace, not in CI", root)
|
||||
}
|
||||
n := 0
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Name() != "docker-compose.yml" && !(filepath.Base(filepath.Dir(p)) == "steps" && strings.HasSuffix(p, ".yml")) {
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
app := strings.Split(rel, string(filepath.Separator))[0]
|
||||
b, _ := os.ReadFile(p)
|
||||
if err := Admit(app, b, PlaceholderEnv(), pol); err != nil {
|
||||
t.Errorf("%s: %v", rel, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if n < 100 {
|
||||
t.Fatalf("only %d catalog files found — the walk did not see the catalog", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog's CI copy of the list is byte-identical (the box's copy is the authority; the catalog's is an early
|
||||
// warning). Where the sibling is absent (CI), the catalog's own gate runs the same comparison when it can.
|
||||
func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
|
||||
p := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "scripts", "privilege-allowlist.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("no sibling catalog clone (%v) — checked in the workspace, not in CI", err)
|
||||
}
|
||||
if !bytes.Equal(b, AllowListJSON()) {
|
||||
t.Fatalf("%s differs from internal/privpolicy/allowlist.json — change both in the same session", p)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user