v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s

R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx ->
paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063:
finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409
come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format
refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z.
Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
admin committed 2026-10-17 23:11:56 +02:00
1 parent 5d67041d6d
commit 2fe634141f
20 files changed
+504 -78

No files matched your search

@@ -176,6 +176,21 @@ func TestAdmit_TopLevelAliases(t *testing.T) {
}
}
// testAllowList is the shape the box had until R-981 (one entry, homepage's read-only socket). The compiled list is
// empty since then; the entry-binding rules are still pinned on this test list, so a future entry is held to them.
const testAllowList = `{"version":2,"entries":[{"app":"homepage","service":"homepage",
"setting":"bind /var/run/docker.sock -> /var/run/docker.sock ro","image":"ghcr.io/gethomepage/homepage",
"healthcheck":"[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]"}],
"appdata_folders":{"paperless-ngx":"paperless"}}`
// withTestAllowList swaps the compiled list for testAllowList for one test.
func withTestAllowList(t *testing.T) {
t.Helper()
oldA, oldF := allowed, appdataFolders
allowed, appdataFolders = mustLoad([]byte(testAllowList))
t.Cleanup(func() { allowed, appdataFolders = oldA, oldF })
}
const homepage = `services:
homepage:
image: ghcr.io/gethomepage/homepage:v1.13.2
@@ -191,6 +206,7 @@ volumes:
// 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) {
withTestAllowList(t)
if err := admit(t, "homepage", homepage, env()); err != nil {
t.Fatalf("the allowed homepage socket was refused: %v", err)
}
@@ -273,6 +289,7 @@ func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
// 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) {
withTestAllowList(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),
@@ -353,6 +370,7 @@ func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) {
// Round 2 of the security review: the gaps found in the fixes.
func TestAdmit_Round2(t *testing.T) {
withTestAllowList(t)
refuse := map[string]struct{ app, compose string }{
"a second service extends the allowed one": {"homepage", strings.Replace(homepage, "volumes:\n homepage_config:", " evil:\n image: evil/x:1\n extends:\n service: homepage\nvolumes:\n homepage_config:", 1)},
"the allowed service's healthcheck runs a shell": {"homepage", strings.Replace(homepage, `["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]`, `["CMD-SHELL", "id"]`, 1)},
@@ -438,3 +456,81 @@ func TestAdmit_RootsThatDoNotExistYet(t *testing.T) {
t.Fatalf("binds under roots that do not exist yet were refused: %v", err)
}
}
// R-981 (2026-10-18): homepage's Docker socket served no Felhom feature (its rendered docker.yaml is empty —
// audits/night-2026-10-18/P1/r981-homepage-before.txt), so the compiled list has NO exception: the socket is refused
// for homepage too, read-only or not. The catalog template no longer mounts it (TheWholeCatalogPasses).
// RED-PROOF: with the old one-entry allowlist.json compiled in, this test fails („homepage's socket was admitted").
func TestAllowList_HasNoExceptionAndRefusesHomepagesSocket(t *testing.T) {
if n := len(Entries()); n != 0 {
t.Fatalf("the compiled allow-list has %d entries; R-981 left it empty", n)
}
if err := admit(t, "homepage", homepage, env()); err == nil {
t.Fatal("homepage's socket was admitted")
}
without := strings.Replace(homepage, " - /var/run/docker.sock:/var/run/docker.sock:ro\n", "", 1)
if err := admit(t, "homepage", without, env()); err != nil {
t.Fatalf("homepage without the socket was refused: %v", err)
}
}
// R-1142: under ${HDD_PATH} an app reaches its OWN folder only — appdata/<its name>/, or the folder the list names
// (paperless-ngx keeps appdata/paperless). Another app's folder, a name that only starts like its own, and a bind
// for an app with no usable name are refused.
// RED-PROOF: with validRoots' root back at ${HDD_PATH}/appdata (the v0.328.0 shape), the three „another app" cases
// are admitted and the test fails.
func TestAdmit_AnotherAppsAppdataFolderIsRefused(t *testing.T) {
bind := func(line string) string {
return "services:\n app:\n image: x/y:1\n volumes:\n - " + line + "\n"
}
refuse := map[string]struct{ app, line string }{
"another app's folder": {"immich", "${HDD_PATH}/appdata/nextcloud:/data"},
"another app's sub-folder": {"romm", "${HDD_PATH}/appdata/paperless/media:/m"},
"the whole appdata": {"immich", "${HDD_PATH}/appdata:/all"},
"a name that starts like its own": {"immich", "${HDD_PATH}/appdata/immich-other:/x"},
"the slug where a folder is set": {"paperless-ngx", "${HDD_PATH}/appdata/paperless-ngx:/x"},
"a literal path to another app": {"immich", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/x"},
"no app name": {"", "${HDD_PATH}/appdata/x:/x"},
"an app name with a slash": {"a/b", "${HDD_PATH}/appdata/a/b:/x"},
}
for name, c := range refuse {
t.Run("refused/"+name, func(t *testing.T) {
if err := admit(t, c.app, bind(c.line), env()); err == nil {
t.Fatalf("%s binding %s was admitted", c.app, c.line)
}
})
}
for name, c := range map[string]struct{ app, line string }{
"its own folder": {"immich", "${HDD_PATH}/appdata/immich:/usr/src/app/upload"},
"its own sub-folder": {"romm", "${HDD_PATH}/appdata/romm/resources:/romm/resources"},
"the listed folder": {"paperless-ngx", "${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media"},
"a literal path to its own": {"nextcloud", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/d"},
"USERDATA is still shared": {"immich", "${USERDATA_PATH}/media/photos:/external/photos:ro"},
} {
t.Run("admitted/"+name, func(t *testing.T) {
if err := admit(t, c.app, bind(c.line), env()); err != nil {
t.Fatalf("%s binding %s was refused: %v", c.app, c.line, err)
}
})
}
}
// The folder map loads only single folder names (a list entry naming a path would hand out a wider root).
func TestAllowList_AppdataFolderMustBeOneName(t *testing.T) {
for _, bad := range []string{"", ".", "..", "a/b", "../x", "${X}"} {
func() {
defer func() {
if recover() == nil {
t.Errorf("appdata folder %q was loaded", bad)
}
}()
mustLoad([]byte(`{"version":2,"entries":[],"appdata_folders":{"app":"` + bad + `"}}`))
}()
}
if got := AppdataFolder("paperless-ngx"); got != "paperless" {
t.Errorf("AppdataFolder(paperless-ngx) = %q", got)
}
if got := AppdataFolder("immich"); got != "immich" {
t.Errorf("AppdataFolder(immich) = %q", got)
}
}