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) } } // Security review 2026-10-17, finding 1: an allow-list entry holds only for its image and the image's own start. The // same allowed socket under a swapped image, or with an entrypoint or command added, is refused. func TestAdmit_TheAllowedSettingIsBoundToItsImageAndStart(t *testing.T) { for name, compose := range map[string]string{ "another image": strings.Replace(homepage, "ghcr.io/gethomepage/homepage:v1.13.2", "docker.io/evil/homepage:v1.13.2", 1), "added entrypoint": strings.Replace(homepage, " volumes:", " entrypoint: [\"sh\", \"-c\", \"id\"]\n volumes:", 1), "added command": strings.Replace(homepage, " volumes:", " command: sh -c id\n volumes:", 1), } { t.Run(name, func(t *testing.T) { if err := admit(t, "homepage", compose, env()); err == nil { t.Fatal("admitted") } }) } // Positive control: a new TAG or a digest of the same repository is still homepage. bumped := strings.Replace(homepage, "homepage:v1.13.2", "homepage:v1.14.0@sha256:"+strings.Repeat("a", 64), 1) if err := admit(t, "homepage", bumped, env()); err != nil { t.Fatalf("a version bump of the allowed image was refused: %v", err) } } // Finding 2: on the box, a bind source inside a root that is a symlink OUT of it is outside. func TestAdmit_ASymlinkOutOfARootIsOutside(t *testing.T) { root := t.TempDir() ns := filepath.Join(root, "mnt", "felhom-drives", "d", "felhom-data") if err := os.MkdirAll(filepath.Join(ns, "userdata", "media"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink("/etc", filepath.Join(ns, "userdata", "escape")); err != nil { t.Fatal(err) } // The roots must be under the drive namespace by NAME; the temp dir stands in via a Resolve that maps the names. e := map[string]string{"HDD_PATH": "/mnt/felhom-drives/d/felhom-data", "USERDATA_PATH": "/mnt/felhom-drives/d/felhom-data/userdata"} p := Policy{Resolve: func(s string) (string, error) { r, err := filepath.EvalSymlinks(filepath.Join(root, s)) if err != nil { return "", err } if rel, rerr := filepath.Rel(root, r); rerr == nil && !strings.HasPrefix(rel, "..") { return "/" + rel, nil } return r, nil }} esc := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/escape:/x\n" if err := Admit("app", []byte(esc), e, p); err == nil { t.Fatal("a symlink to /etc inside the userdata root was admitted") } ok := "services:\n app:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/media:/m\n - ${USERDATA_PATH}/not-yet:/n\n" if err := Admit("app", []byte(ok), e, p); err != nil { t.Fatalf("a real directory (and one not created yet) inside the root was refused: %v", err) } } // Finding 3: the classes the first version missed. func TestAdmit_TheLaterClasses(t *testing.T) { for name, add := range map[string]string{ "cgroup_parent": " cgroup_parent: /\n", "runtime": " runtime: runc-unsafe\n", "gpus": " gpus: all\n", "device request": " deploy:\n resources:\n reservations:\n devices:\n - capabilities: [gpu]\n", "provider": " provider:\n type: model\n", "post_start": " post_start:\n - command: id\n privileged: true\n", } { t.Run(name, func(t *testing.T) { compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+add, 1) if err := admit(t, "app", compose, env()); err == nil { t.Fatal("admitted") } }) } } // Finding 4: a data root lives under the drive namespace or the system data path, not anywhere under /mnt. func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) { e := env() e["HDD_PATH"] = "/mnt/sys_other/x" if err := admit(t, "app", base, e); err == nil { t.Fatal("a root outside /mnt/felhom-drives/ and the system data path was accepted") } }