Files
felhom-controller/controller/internal/privpolicy/privpolicy_test.go
T
admin 6d90374f63
gates / gates (push) Successful in 45s
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)
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
2026-10-17 16:34:12 +02:00

270 lines
11 KiB
Go

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)
}
}