package pbsdr import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // R-39 leg (b), Scenario D — the `read` verb, tested as the ARTIFACT it is. // // This verb is the one that EXFILTRATES a file: the agent writes the PBS token secret through the // root wrapper but could never read it back (/etc/pve/priv is 0700 root:www-data), so its verify // loop was permanently blind to an `applied`-but-401 tier. Giving it a read path is right, but it // means the wrapper now has a verb whose whole job is to print a secret — so its refusals are // security-critical and are executed here under a real bash, not pattern-matched. // // COMPANION RED-PROOF (run + recorded): delete the storage-id charset guard at the top of the // wrapper → TestWrapperRead_RefusesTraversal FAILS (the traversal case is no longer refused). func wrapperPath(t *testing.T) string { t.Helper() p := filepath.Join("..", "..", "configs", "felhom-pbs-apply") if _, err := os.Stat(p); err != nil { t.Fatalf("wrapper not found: %v", err) } return p } // runWrapper executes the real script under bash and returns stdout, combined stderr and the code. func runWrapper(t *testing.T, args ...string) (string, string, int) { t.Helper() cmd := exec.Command("bash", append([]string{wrapperPath(t)}, args...)...) var out, errb strings.Builder cmd.Stdout = &out cmd.Stderr = &errb err := cmd.Run() code := 0 if ee, ok := err.(*exec.ExitError); ok { code = ee.ExitCode() } else if err != nil { t.Fatalf("run wrapper: %v", err) } return out.String(), errb.String(), code } // Every traversal-shaped input is refused, non-zero, and prints NOTHING on stdout — a refusal that // still emitted bytes would be the leak this test exists to prevent. func TestWrapperRead_RefusesTraversal(t *testing.T) { cases := []struct { name string args []string }{ {"dotdot id", []string{"read", "../../etc/shadow", "/etc/pve/priv/storage"}}, {"id with slash", []string{"read", "a/b", "/etc/pve/priv/storage"}}, {"id starting with a dot", []string{"read", ".hidden", "/etc/pve/priv/storage"}}, {"secret dir outside the allowlist", []string{"read", "felhom-pbs", "/tmp"}}, {"secret dir traversal", []string{"read", "felhom-pbs", "/var/lib/felhom-agent/../../etc"}}, {"etc passwd as a dir", []string{"read", "passwd", "/etc"}}, {"missing secret-dir arg", []string{"read", "felhom-pbs"}}, {"too many args", []string{"read", "felhom-pbs", "/etc/pve/priv/storage", "extra"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { stdout, stderr, code := runWrapper(t, tc.args...) if code == 0 { t.Errorf("ACCEPTED a traversal-shaped read (rc=0): %v", tc.args) } if strings.TrimSpace(stdout) != "" { t.Errorf("a refused read still wrote to stdout (%q) — that is the leak", stdout) } if !strings.Contains(stderr, "REFUSED") { t.Errorf("refusal not announced on stderr: %q", stderr) } }) } } // The happy path: a secret under an allowed dir is printed verbatim to STDOUT and nowhere else. // // val_sdir hard-codes the /var/lib/felhom-agent prefix, which a test user cannot create. Rather than // SKIP (a skipped test proves nothing) or weaken the production allowlist with a test escape hatch, // these two cases run a COPY of the script with that one prefix constant relocated into t.TempDir(). // Only the allowlisted location moves; every guard — the id grammar, the traversal refusal, the // resolved-path prefix assertion — is the real code. The refusal tests above still run the // unmodified script. func relocatedWrapper(t *testing.T, base string) string { t.Helper() raw, err := os.ReadFile(wrapperPath(t)) if err != nil { t.Fatal(err) } src := strings.ReplaceAll(string(raw), "/var/lib/felhom-agent", base) if src == string(raw) { t.Fatal("relocation matched nothing — val_sdir no longer pins /var/lib/felhom-agent; revisit this test") } dst := filepath.Join(t.TempDir(), "felhom-pbs-apply") if err := os.WriteFile(dst, []byte(src), 0o755); err != nil { t.Fatal(err) } return dst } func runScript(t *testing.T, script string, args ...string) (string, string, int) { t.Helper() cmd := exec.Command("bash", append([]string{script}, args...)...) var out, errb strings.Builder cmd.Stdout = &out cmd.Stderr = &errb err := cmd.Run() code := 0 if ee, ok := err.(*exec.ExitError); ok { code = ee.ExitCode() } else if err != nil { t.Fatalf("run: %v", err) } return out.String(), errb.String(), code } func TestWrapperRead_PrintsSecretToStdoutOnly(t *testing.T) { base := t.TempDir() script := relocatedWrapper(t, base) dir := filepath.Join(base, "pbs") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } const secret = "tok-secret-abcdef0123456789" if err := os.WriteFile(filepath.Join(dir, "felhom-pbs.pw"), []byte(secret+"\n"), 0o600); err != nil { t.Fatal(err) } stdout, stderr, code := runScript(t, script, "read", "felhom-pbs", dir) if code != 0 { t.Fatalf("read failed rc=%d stderr=%q", code, stderr) } if strings.TrimSpace(stdout) != secret { t.Errorf("stdout = %q, want the secret verbatim", stdout) } if strings.Contains(stderr, secret) { t.Error("the secret leaked onto stderr, where sudo and the journal would capture it") } // The relocated copy must STILL refuse traversal — proving the guards travelled with it and the // happy path above is not passing because the checks were relocated away. if _, _, rc := runScript(t, script, "read", "../../etc/shadow", dir); rc == 0 { t.Error("the relocated copy accepted a traversal id — the guards did not travel") } } // A missing secret file is a clean refusal, never an empty success (an empty secret would build a // client that 401s and be misdiagnosed as a revoked credential). func TestWrapperRead_MissingFileRefuses(t *testing.T) { base := t.TempDir() script := relocatedWrapper(t, base) dir := filepath.Join(base, "pbs") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } stdout, stderr, code := runScript(t, script, "read", "felhom-pbs", dir) if code == 0 { t.Error("a missing secret file must refuse, not succeed with empty output") } if strings.TrimSpace(stdout) != "" { t.Errorf("stdout = %q, want empty", stdout) } if !strings.Contains(stderr, "REFUSED") { t.Errorf("stderr = %q, want a REFUSED line", stderr) } } // The read verb must remain read-ONLY: no pvesm/pveum mutation may appear in its block. func TestWrapperRead_IsSideEffectFree(t *testing.T) { raw, err := os.ReadFile(wrapperPath(t)) if err != nil { t.Fatal(err) } block := readVerbBlock(t, string(raw)) for _, forbidden := range []string{"pvesm ", "pveum ", "install ", "rm ", "place_copies"} { if strings.Contains(block, forbidden) { t.Errorf("the read verb performs a side effect (%q) — it must only print a file", forbidden) } } } // readVerbBlock isolates the `read)` case body, comment lines stripped (the WHY note names the very // traversal strings under test, and a naive scan would flag the explanation as the defect — the // vacuous-pass trap the reconcile guard already documents). func readVerbBlock(t *testing.T, src string) string { t.Helper() start := strings.Index(src, "\nread)\n") if start < 0 { t.Fatal("could not locate the read) block in configs/felhom-pbs-apply") } rest := src[start+len("\nread)\n"):] end := strings.Index(rest, "\n ;;") if end < 0 { t.Fatal("could not locate the end of the read) block") } var code []string for _, line := range strings.Split(rest[:end], "\n") { if strings.HasPrefix(strings.TrimSpace(line), "#") { continue } code = append(code, line) } return strings.Join(code, "\n") } // The prefix assertion, exercised against a file that ACTUALLY EXISTS outside the secret dir. // // The earlier traversal cases are refused by whichever guard fires first, so they cannot tell us // which one is load-bearing — and indeed deleting the id charset guard alone does not open a hole, // because readlink -f plus the prefix assertion still catch it. That layering is the design, but it // means a single-guard red-proof passes vacuously. This case isolates the LAST line of defence: a // real secret file one directory up, reachable only if BOTH the charset guard and the prefix // assertion are gone. // // COMPANION RED-PROOF (run + recorded): delete the id charset guard AND the `case "$resolved" in // "$sdir"/*)` prefix assertion → this test FAILS by printing the out-of-tree secret. func TestWrapperRead_PrefixAssertionStopsEscapeToARealFile(t *testing.T) { base := t.TempDir() script := relocatedWrapper(t, base) dir := filepath.Join(base, "pbs") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } // A juicy file one level ABOVE the secret dir, inside the allowlisted prefix (so val_sdir is not // the guard doing the work here). const stolen = "NOT-FOR-THE-AGENT-0123456789" if err := os.WriteFile(filepath.Join(base, "elsewhere.pw"), []byte(stolen), 0o600); err != nil { t.Fatal(err) } stdout, stderr, code := runScript(t, script, "read", "../elsewhere", dir) if code == 0 || strings.Contains(stdout, stolen) { t.Errorf("the read verb escaped its secret dir and printed a file it must never reach.\n"+ " rc=%d stdout=%q", code, stdout) } if !strings.Contains(stderr, "REFUSED") { t.Errorf("escape not refused loudly: stderr=%q", stderr) } }