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
169 lines
6.8 KiB
Go
169 lines
6.8 KiB
Go
package stacks
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that
|
|
// writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count),
|
|
// the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg.
|
|
//
|
|
// 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a
|
|
// ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` —
|
|
// either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer
|
|
// fails this test until someone decides which it is.
|
|
// 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask
|
|
// the gate before their first mutation.
|
|
//
|
|
// RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call
|
|
// from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as
|
|
// an unknown writer.
|
|
func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) {
|
|
type rule struct {
|
|
gate string // "" = exempt
|
|
why string // exempt: the reason
|
|
before string // gated: the write the gate must precede (the first call of this name)
|
|
}
|
|
writers := map[string]rule{
|
|
"internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"},
|
|
"internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"},
|
|
"cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"},
|
|
"internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"},
|
|
"internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"},
|
|
"internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"},
|
|
"internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"},
|
|
"internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"},
|
|
"internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"},
|
|
"internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"},
|
|
"internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"},
|
|
}
|
|
decisions := []struct{ fn, gate, before string }{
|
|
{"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"},
|
|
{"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"},
|
|
{"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"},
|
|
{"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"},
|
|
{"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"},
|
|
}
|
|
|
|
writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true,
|
|
"Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true}
|
|
root := filepath.Join("..", "..")
|
|
calls := map[string][]string{} // "dir/file.go:Func" → called names in source order
|
|
var found []string
|
|
for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport",
|
|
"internal/web", "internal/api", "cmd/controller"} {
|
|
files, _ := filepath.Glob(filepath.Join(root, dir, "*.go"))
|
|
if len(files) == 0 {
|
|
t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir)
|
|
}
|
|
for _, f := range files {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, f, nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, d := range file.Decls {
|
|
fd, ok := d.(*ast.FuncDecl)
|
|
if !ok || fd.Body == nil {
|
|
continue
|
|
}
|
|
id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name
|
|
names, compose, writesFile := []string{}, false, false
|
|
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
|
switch v := n.(type) {
|
|
case *ast.SelectorExpr:
|
|
if v.Sel.Name == "ComposePath" {
|
|
compose = true
|
|
}
|
|
case *ast.BasicLit:
|
|
if v.Value == `"docker-compose.yml"` {
|
|
compose = true
|
|
}
|
|
case *ast.CallExpr:
|
|
name := ""
|
|
switch fn := v.Fun.(type) {
|
|
case *ast.Ident:
|
|
name = fn.Name
|
|
case *ast.SelectorExpr:
|
|
name = fn.Sel.Name
|
|
}
|
|
if name == "ComposePathIn" || name == "AppliedComposePath" {
|
|
compose = true
|
|
}
|
|
if writes[name] {
|
|
writesFile = true
|
|
}
|
|
if name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
calls[id] = names
|
|
if compose && writesFile {
|
|
found = append(found, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Discovery.
|
|
sort.Strings(found)
|
|
for _, id := range found {
|
|
r, known := writers[id]
|
|
if !known {
|
|
t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+
|
|
"(admitCompose before the write) or add it to `writers` as exempt with the reason", id)
|
|
continue
|
|
}
|
|
if r.gate == "" {
|
|
continue
|
|
}
|
|
assertCalledBefore(t, id, calls[id], r.gate, r.before)
|
|
}
|
|
for id := range writers {
|
|
if _, ok := calls[id]; !ok {
|
|
t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id)
|
|
}
|
|
}
|
|
// 2. Decision points.
|
|
for _, d := range decisions {
|
|
c, ok := calls[d.fn]
|
|
if !ok {
|
|
t.Errorf("fixture drift: %s no longer exists", d.fn)
|
|
continue
|
|
}
|
|
assertCalledBefore(t, d.fn, c, d.gate, d.before)
|
|
}
|
|
}
|
|
|
|
func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) {
|
|
t.Helper()
|
|
gi, ai := -1, -1
|
|
for i, c := range calls {
|
|
if c == gate && gi < 0 {
|
|
gi = i
|
|
}
|
|
if c == act && ai < 0 {
|
|
ai = i
|
|
}
|
|
}
|
|
switch {
|
|
case gi < 0:
|
|
t.Errorf("%s: the allow-list gate %q is never called", id, gate)
|
|
case ai < 0:
|
|
t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act)
|
|
case gi > ai:
|
|
t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act)
|
|
}
|
|
}
|