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
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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user