audit: Tier-1 findings (both repos) + CTRL-001 traversal evidence test

Read-only deep sweep checkpoint. Tier 1 complete for controller+agent:
- CTRL-001 (High): app-import path traversal via unvalidated manifest.AppName (failing evidence test)
- AGENT-001/002/003, CTRL-002 (Medium): wipe TOCTOU, blkid-error classify, decrypt-before-MAC
- 12 Low/Info hardening items + invariant checklist + contract/dead-code/test-gap sections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 17:35:29 +02:00
parent eea235bd69
commit 1f06029b70
2 changed files with 498 additions and 0 deletions
@@ -0,0 +1,67 @@
package appexport
import (
"path/filepath"
"strings"
"testing"
)
// TestImportManifestAppNameTraversal is an AUDIT evidence test for finding
// [CTRL-001] (commit eea235b). It demonstrates that an imported bundle's
// manifest.AppName — which is fully attacker-controlled JSON inside the .fab —
// is used verbatim as a path segment in executeImport:
//
// stackDir := filepath.Join(stacksDir, manifest.AppName) // restore.go:339
// os.MkdirAll(stackDir, 0755) // restore.go:365
// composePath := filepath.Join(stackDir, "docker-compose.yml") // restore.go:401
//
// UnmarshalManifest performs NO validation of AppName (manifest.go:36-42), and
// no IsValidStackName/sanitizer exists in the package. A name containing ".."
// therefore escapes the stacks base directory.
//
// This test asserts the SAFE invariant ("the resolved stack dir must stay under
// the stacks base"). It FAILS at the recorded commit, which is the evidence the
// guard is missing. Do NOT "fix" the bug by weakening this test — the fix is to
// reject traversal AppNames in UnmarshalManifest / before the join.
func TestImportManifestAppNameTraversal(t *testing.T) {
const stacksDir = "/data/stacks" // stand-in for provider.GetStacksBaseDir()
cases := []struct {
name string
appName string
}{
{"parent-escape", "../evil"},
{"deep-escape", "../../etc/cron.d/x"},
{"absolute", "/etc/cron.d/x"},
}
base := filepath.Clean(stacksDir)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Exactly mirror restore.go:339.
stackDir := filepath.Join(stacksDir, tc.appName)
// The invariant the importer SHOULD enforce: stackDir stays under base.
if stackDir != base && !strings.HasPrefix(stackDir, base+string(filepath.Separator)) {
t.Fatalf("CTRL-001: manifest.AppName %q escapes stacks base: filepath.Join(%q, AppName) = %q (outside %q). "+
"executeImport then os.MkdirAll's and writes app.yaml/docker-compose.yml there with no validation.",
tc.appName, stacksDir, stackDir, base)
}
})
}
}
// TestUnmarshalManifestDoesNotValidateAppName documents that the only manifest
// parse entrypoint accepts a hostile AppName without complaint — the missing
// chokepoint for [CTRL-001].
func TestUnmarshalManifestDoesNotValidateAppName(t *testing.T) {
raw := []byte(`{"version":1,"app_name":"../../escape","display_name":"x"}`)
m, err := UnmarshalManifest(raw)
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
if strings.Contains(m.AppName, "..") {
t.Fatalf("CTRL-001: UnmarshalManifest returned a traversal AppName %q with no rejection; "+
"a sanitizer (single safe path segment, allowlist) is missing", m.AppName)
}
}