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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user