Files
felhom-agent/internal/pbsdr/wrapper_read_test.go
T
admin b2ca63ee9f v0.91.0 — the DR tier can no longer be applied and dead at the same time (R-39 + R-50b(a))
Closes the agent half of R-39's fleet fix. Requires hub >=0.68.0 for the re-arm signal;
that hub is safe for 0.90.0 agents (unknown key dropped), so it deploys first.

Three compounding defects let a box report `applied` while every PBS request 401'd:

1. The re-key was INVISIBLE. An ep0 re-issue rotates the secret of an existing token, so
   token_id/fingerprint/datastore/namespace come back byte-identical and the descriptor
   content hash never moved — the converged agent short-circuited and never consumed the
   fresh secret. WirePBSDR.SecretGeneration (field-exact with the hub) is what moves the
   hash now, because descriptorHash marshals this struct.

2. The agent could not READ its own credential. It writes /etc/pve/priv/storage/<id>.pw
   through the root wrapper, but that dir is 0700 root:www-data and the wrapper had no
   read verb — so the target resolver got "permission denied" every cycle, warned, and
   skipped. The one loop that could have caught the 401 was blind BY CONSTRUCTION. Adds a
   narrow `read` verb (+ exactly one sudoers line, + a pbsdr-read capability row): one
   secret to stdout, no network, no mutation, never in argv (sudo logs argv), traversal
   refused by the id grammar, the dir allowlist AND a resolved-path prefix assertion.

3. Nothing probed AUTHENTICATION. pbs.ProbeAuth (GET /version + an ErrUnauthorized
   sentinel) runs on the 15-minute collect path and its verdict becomes a loud
   `auth_failed` the hub escalates to a fresh mint. /version needs no datastore, namespace
   or privilege, so a 401 means the CREDENTIAL is bad; 403 is deliberately NOT treated as
   unauthorized, since re-keying a too-narrow token would mint forever without fixing
   anything. A transport error is UNKNOWN, never a rejection — otherwise every network
   blip burns a credential. Recovery self-clears.

R-50b(a): the report now carries the installed wrapper's sha256 so drift against the
vouched manifest value is answerable. Empty = unknown, never drift.

Three red-proofs, all at the assertion level. Removing SecretGeneration fails the re-arm
test with "consume calls=1, want 2". Swallowing the probe result leaves State:applied
AuthFailed:false — the July-18 shape exactly. Notably, deleting the wrapper's id charset
guard alone does NOT open a traversal hole (readlink + the prefix assertion still catch
it), so the isolating red-proof removes BOTH and shows the out-of-tree secret printed —
the layering is real, and a single-guard red-proof would have passed vacuously.
2026-07-21 10:12:31 +02:00

245 lines
9.2 KiB
Go

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)
}
}