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,15 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
|
||||
"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\"]",
|
||||
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
|
||||
"doc": "https://gethomepage.dev/configs/docker/"
|
||||
}
|
||||
]
|
||||
"version": 2,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. EMPTY since R-981 (2026-10-18): homepage's Docker socket was used by no Felhom feature and is gone, so no app has an exception. appdata_folders (R-1142): an app's bind root under ${HDD_PATH} is appdata/<its own folder>/, the folder being its name unless listed here. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md and audits/night-2026-10-18/P1/",
|
||||
"entries": [],
|
||||
"appdata_folders": {
|
||||
"paperless-ngx": "paperless"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
//
|
||||
// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each
|
||||
// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables,
|
||||
// and only as its leading token.
|
||||
// and only as its leading token. Under HDD_PATH the root is the app's OWN folder, ${HDD_PATH}/appdata/<folder>/ (R-1142):
|
||||
// <folder> is the app's name, or its entry in the list's appdata_folders (paperless-ngx keeps appdata/paperless) — so a
|
||||
// catalog push cannot hand one app another app's files.
|
||||
//
|
||||
// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate,
|
||||
// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope.
|
||||
@@ -69,11 +71,14 @@ type Entry struct {
|
||||
type allowList struct {
|
||||
Version int `json:"version"`
|
||||
Entries []Entry `json:"entries"`
|
||||
// AppdataFolders names an app's folder under ${HDD_PATH}/appdata/ when it is not the app's own name (R-1142). It is
|
||||
// compiled in with the entries for the same reason: the catalog — the threat — must not be able to name the folder.
|
||||
AppdataFolders map[string]string `json:"appdata_folders"`
|
||||
}
|
||||
|
||||
var allowed = mustLoad(allowListJSON)
|
||||
var allowed, appdataFolders = mustLoad(allowListJSON)
|
||||
|
||||
func mustLoad(b []byte) map[string]Entry {
|
||||
func mustLoad(b []byte) (map[string]Entry, map[string]string) {
|
||||
var l allowList
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
panic("privpolicy: allowlist.json does not parse: " + err.Error())
|
||||
@@ -85,7 +90,29 @@ func mustLoad(b []byte) map[string]Entry {
|
||||
}
|
||||
m[key(e.App, e.Service, e.Setting)] = e
|
||||
}
|
||||
return m
|
||||
for app, f := range l.AppdataFolders {
|
||||
if !oneSegment(f) {
|
||||
panic("privpolicy: allowlist.json appdata folder of " + app + " is not one folder name: " + f)
|
||||
}
|
||||
}
|
||||
return m, l.AppdataFolders
|
||||
}
|
||||
|
||||
// oneSegment: a single folder name — no separator, not "." or "..", not empty.
|
||||
func oneSegment(s string) bool {
|
||||
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\$")
|
||||
}
|
||||
|
||||
// AppdataFolder is the app's own folder under ${HDD_PATH}/appdata/ (R-1142): its appdata_folders entry, else its name.
|
||||
// "" when the app has no usable name — then HDD_PATH gives it no root at all (fail closed).
|
||||
func AppdataFolder(app string) string {
|
||||
if f, ok := appdataFolders[app]; ok {
|
||||
return f
|
||||
}
|
||||
if !oneSegment(app) {
|
||||
return ""
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
|
||||
@@ -156,7 +183,7 @@ func PlaceholderEnv() map[string]string {
|
||||
// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a
|
||||
// definition it has checked).
|
||||
func Admit(app string, compose []byte, env map[string]string, p Policy) error {
|
||||
vs, err := Check(compose, env, p)
|
||||
vs, err := Check(app, compose, env, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
|
||||
}
|
||||
@@ -230,13 +257,13 @@ func imageRepo(ref string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// Check lists every setting in a class, allowed or not, sorted.
|
||||
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
// Check lists every setting in a class, allowed or not, sorted. app decides the HDD_PATH root (R-1142).
|
||||
func Check(app string, compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(compose, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &checker{env: env, p: p}
|
||||
c := &checker{env: env, p: p, app: app}
|
||||
c.roots = c.validRoots()
|
||||
if _, ok := doc["include"]; ok {
|
||||
c.add("", "include")
|
||||
@@ -276,6 +303,7 @@ func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error)
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
app string
|
||||
env map[string]string
|
||||
p Policy
|
||||
roots []string
|
||||
@@ -516,7 +544,12 @@ func (c *checker) validRoots() []string {
|
||||
// round 3: HDD_PATH is the drive's whole namespace — its backups/ (recovery units carry app secrets)
|
||||
// and every other top-level folder are siblings an app has no business binding. Only appdata/ is a
|
||||
// root; the household's folders come through USERDATA_PATH.
|
||||
v = filepath.Join(v, "appdata")
|
||||
// R-1142: and only the app's OWN folder in it — appdata/<other app>/ is another app's files.
|
||||
f := AppdataFolder(c.app)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
v = filepath.Join(v, "appdata", f)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
@@ -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