6d90374f63
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
270 lines
11 KiB
Go
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)
|
|
}
|
|
}
|