diff --git a/creds-manifests/vm-secret-locations b/creds-manifests/vm-secret-locations index dea6d31..1519567 100644 --- a/creds-manifests/vm-secret-locations +++ b/creds-manifests/vm-secret-locations @@ -26,8 +26,14 @@ # tenant surface. `ssh-target=local` is load-bearing -- no tenant VM, tenant project, or # tenant-operated host is reachable from this file, and none may ever be added to it. # -# FORMAT. Whitespace-separated: +# FORMAT. Whitespace-separated: # host-role the role token used by creds-matrix.tsv (jumphost|headend|netbox|...) +# site-key region-qualified site this location belongs to, or `-` if not site-scoped. +# ADDED 2026-07-26 (committee finding): without it the checker matched +# credentials on (role, basename) alone, so a file in ONE site's folder +# satisfied the row for EVERY site -- which masked SEC-021's dc0 +# opnsense-api.txt absence. A per-DC credential must be resolved against its +# OWN site's location or the check is decorative. # ssh-target `local` = the host this runs on (the jumphost), else an ssh alias from # the D-126 ssh_config fragment (scripts/site-ssh-config.sh) -- these are # MEASURED as-built aliases, never invented hostnames @@ -39,36 +45,48 @@ # skip. Several of these belong to deployment stages this rehearsal has not reached. # --- jumphost (vcloud): the SEC-009 convention ------------------------------- -jumphost local ~/vr1-office1-creds/* -jumphost local ~/vr1-dc0-creds/* -jumphost local ~/vr1-dc1-creds/* +jumphost vr1-office1 local ~/vr1-office1-creds/* +jumphost vr1-dc0 local ~/vr1-dc0-creds/* +jumphost vr1-dc1 local ~/vr1-dc1-creds/* # --- jumphost: OUTSIDE the SEC-009 convention (research FINDING 2) ----------- # Named in CLAUDE.md as secret locations, so KNOWN -- simply outside the control until now. -jumphost local ~/vault-init/* -jumphost local ~/octavia-pki/* -jumphost local ~/tenant-*/* +jumphost - local ~/vault-init/* +jumphost - local ~/octavia-pki/* +jumphost - local ~/tenant-*/* # The PKI overlay lands INSIDE the repo clone (gitignored). `` is substituted at # runtime from the checker's own location -- the repo has been renamed once already # (D-110) and its name must never be hardcoded into a command or a data file. -jumphost local /overlays/octavia-pki.yaml +jumphost - local /overlays/octavia-pki.yaml # DOCFIX-175: the Office1 MAAS API key reaches tfstate in PLAINTEXT. A secret-at-rest # location with no mint of its own -- declared so the copy is audited, not just the mint. -jumphost local /opentofu/terraform.tfstate +jumphost vr1-office1 local /opentofu/terraform.tfstate +# Committee finding: the tfstate SIBLINGS carry the same plaintext key and were unaudited. +jumphost vr1-office1 local /opentofu/terraform.tfstate.backup +jumphost vr1-office1 local /opentofu/terraform.tfstate.pre-* +# CLAUDE.md names ~/as-executed/ as credential material; it was missing from this list. +jumphost - local ~/as-executed/* +# The Octavia PKI artifacts are written into THREE SUBDIRECTORIES (phase-01:288), so the +# bare ~/octavia-pki/* pattern matched ZERO of the 8 artifacts incl. two CA private keys. +jumphost - local ~/octavia-pki/issuing-ca/* +jumphost - local ~/octavia-pki/controller-ca/* +jumphost - local ~/octavia-pki/controller/* +# Predicted Stage-5 exposure (SEC-023): phase-03 writes $HOME/admin-openrc. +jumphost - local ~/admin-openrc # --- headend (voffice1): the SEC-022 shadow stores + the region secrets dir --- # The region dir is root-owned; the sweep reads METADATA ONLY (existence, mode, digest) # and never file content. -headend voffice1 ~/vr1-dc0-creds/* -headend voffice1 ~/vr1-dc1-creds/* -headend voffice1 /root/maas-secrets/* +headend vr1-dc0 voffice1 ~/vr1-dc0-creds/* +headend vr1-dc1 voffice1 ~/vr1-dc1-creds/* +headend vr1-office1 voffice1 /root/maas-secrets/* # MEASURED 2026-07-26: the CLI profile, the Juju client store, the MAAS snap ssh dir (which # holds the per-DC power keys) and the rack-enrollment secret all live OUTSIDE the region # secrets dir. Each was an inferred path in the first matrix draft; these are the real ones. -headend voffice1 ~/.maas.cli -headend voffice1 ~/.local/share/juju/* -headend voffice1 /var/snap/maas/current/root/.ssh/* -headend voffice1 /var/snap/maas/common/maas/secret +headend vr1-office1 voffice1 ~/snap/maas/current/.maascli.db +headend - voffice1 ~/.local/share/juju/* +headend - voffice1 /var/snap/maas/current/root/.ssh/* +headend vr1-office1 voffice1 /var/snap/maas/common/maas/secret # --- netbox VM: the founding SEC-009 miss ------------------------------------ -netbox office1-netbox /root/netbox-secrets/* +netbox vr1-office1 office1-netbox /root/netbox-secrets/* diff --git a/docs/changelog-20260726-d137-tier1.md b/docs/changelog-20260726-d137-tier1.md index 32f5ee8..b002397 100644 --- a/docs/changelog-20260726-d137-tier1.md +++ b/docs/changelog-20260726-d137-tier1.md @@ -360,3 +360,60 @@ SEC-018/-019 record it as fact on 2026-07-24. **Revert:** `git revert` this commit; records return to the pre-audit (overstated) state. + +## Item 14 -- phase 2: the eight confirmed false-greens, fixed and regression-locked + +Every fix below closes a defect the committee REPRODUCED. Harness 35 -> 43 cases +(`tests/creds-matrix`), creds-audit 13 -> 15. Gauntlet ALL GREEN (80), repo-lint 0-fail. + +1. **Tier 2 is now scoped by (host-role, SITE-KEY), not host-role alone.** + `vm-secret-locations` gains a `site-key` column. Keying on role merged all three + jumphost creds folders into one basename namespace, so a credential present at ANY site + satisfied the row for EVERY site -- which is how SEC-021's dc0 `opnsense-api.txt` + absence passed a green sweep while tier 1 flagged it. E3 was weakened symmetrically. + Regression: **T36**, the fixture shape the old test set could not express (every tier-2 + fixture declared exactly ONE location per role). +2. **`probe_remote` no longer reports UNREACHABLE for a location it read.** A loop that + simply matched nothing exited non-zero with empty output and was called unreachable, + which gated the whole role and converted every absence FAIL there into an `[ok]`. Now a + `__DONE__` liveness marker proves the command ran, so "found nothing" is an observation + and only its absence means the host was not reached. +3. **Literal-file locations get absent/unreadable detection.** `_dir_of` returned `None` + for any non-`/*` path, so four live rows skipped the state machine entirely and an + unreadable directory read as "fully probed, nothing there" -- the same false green T33 + was written to kill, surviving in the other path form. Regression: **T41**. +4. **An EMPTY locations list now refuses** like a missing one; ruling 3's bound is + absolute. Regression: **T37**. +5. **Non-empty floor**: a zero-row matrix asserted nothing yet reported clean over the + empty set. Regression: **T38**. +6. **Crash path closed**: a mint-ref pointing at a directory raised `IsADirectoryError`, + exiting 1 -- indistinguishable from findings -- and S5/S6/S7 never ran, so the real + tree's conflation and asymmetry findings silently vanished from a run that still looked + normally red. +7. **Mode enforcement no longer custody-gated.** Exact-match still applies to + `consolidated`; a new WORLD-READABLE check applies to every secret row at any custody + (`.pub` halves excluded -- public by design). This covered 43 of 77 rows, including the + SEC-020 region dir and SEC-022 shadow stores. Regression: **T39**. +8. **S5's second direction is tested.** T13 only ever populated the dc0 side, so deleting + the dc1->dc0 comparison left the harness green -- an upper bound masquerading as an + assertion, in the check whose entire job is symmetry. Regression: **T40**. + +**Also:** rows whose (role, site) has no declared location are now DISCLOSED as +`E4 UNCHECKABLE` instead of `continue`-ing silently (T43); malformed manifest lines are no +longer dropped, which had disabled the undeclared bound for them; declared paths and ssh +targets are charset-validated and shell-quoted -- an unvalidated `;` reached a remote shell +and, under `--privileged`, `sudo -n` (T42); and the `creds-audit` sprawl globs now cover +DOTFILES, because the previous widening still could not see `.admin.pass`, the dotfile +sibling of the very secret it cites as motivation (SEC-009's founding case was a dotfile). + +**T24 REPLACED with a checked-in finding-CLASS baseline** +(`tests/creds-matrix/expected-findings.txt` + `classify-findings.py`). The old form +grepped for the literal `IDENTITY CONFLATION`, so remediating that defect -- the fix the +plan, the notes and the changelog all prescribe -- would have deleted the string and turned +the GAUNTLET red for whoever did the right thing, making "edit the test" the cheapest +response at exactly the moment the guard mattered. Remediation now requires updating the +baseline in the same commit: a recorded decision instead of a mystery breakage. Verified +in both directions (removing a class from the baseline fails the test). + +**Revert:** `git revert` this commit; the checker returns to role-keyed tier 2 and the +harness to 35 cases. diff --git a/scripts/creds-audit.sh b/scripts/creds-audit.sh index 01d67c5..558e8ce 100755 --- a/scripts/creds-audit.sh +++ b/scripts/creds-audit.sh @@ -74,12 +74,22 @@ # for 12 days), any bare SSH private key, any PEM or `.key`, a `.apikey`, or the # `~/admin-openrc` that phase-03 is PREDICTED to write at Stage 5. A sprawl check that # cannot see the exposures already on the register is not a control. +# +# DOTFILES ADDED 2026-07-26 (committee finding): the widening was still a depth-1 glob over +# NON-dot names, so `.admin.pass` -- the dotfile sibling of the very secret the widening +# cites as its motivation -- stayed invisible. SEC-009's founding case was `.vr1-netbox.env`, +# a dotfile, so this class is not hypothetical. STILL NOT COVERED and logged, not fixed: +# secrets nested in a SUBDIRECTORY outside a `*-creds/` folder -- that needs a walk, not a +# glob, and a walk needs a declared bound (D-137 ruling 3) rather than an ad-hoc recursion. shopt -s nullglob for path in "$CREDS_ROOT"/*.env "$CREDS_ROOT"/.*.env "$CREDS_ROOT"/*appcred* \ "$CREDS_ROOT"/*-cred*.txt "$CREDS_ROOT"/*authkey* "$CREDS_ROOT"/*.token \ "$CREDS_ROOT"/*.pass "$CREDS_ROOT"/*.apikey "$CREDS_ROOT"/*.key \ "$CREDS_ROOT"/*.pem "$CREDS_ROOT"/*_ed25519 "$CREDS_ROOT"/*_rsa \ - "$CREDS_ROOT"/*openrc*; do + "$CREDS_ROOT"/*openrc* \ + "$CREDS_ROOT"/.*.pass "$CREDS_ROOT"/.*.apikey "$CREDS_ROOT"/.*.key \ + "$CREDS_ROOT"/.*.pem "$CREDS_ROOT"/.*_ed25519 "$CREDS_ROOT"/.*_rsa \ + "$CREDS_ROOT"/.*.token "$CREDS_ROOT"/.*openrc*; do [ -f "$path" ] || continue fail "SPRAWL: sensitive file loose in $CREDS_ROOT: $(basename "$path") (belongs in a *-creds/ folder)" done diff --git a/scripts/creds-matrix.py b/scripts/creds-matrix.py index 7619a8e..b906e09 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -117,6 +117,10 @@ out[parts[0]] = (parts[1], parts[2]) elif len(parts) == 2: out[parts[0]] = (parts[1], "-") + else: + # Do NOT drop it: a bare filename with no mode used to vanish, so the + # undeclared-file bound never fired for it -- exactly T8's defect class. + out[parts[0]] = ("?", "?") return out @@ -134,6 +138,12 @@ # --------------------------------------------------------------------------- tier 1 def s1_schema(rows, oks, fails): bad = 0 + if not rows: + # A register with no rows asserts nothing, yet every check below reports clean + # over an empty set. That is the both-bounds failure applied to the input itself. + fails.append("S1 FLOOR: the matrix has ZERO rows -- an empty register cannot be " + "clean, it is unpopulated") + return for r in rows: where = "matrix:%d %s" % (r.lineno, r.id) if r.cardinality not in CARDINALITY: @@ -305,8 +315,17 @@ fails.append("matrix:%d %s mint-ref target does not exist: %s" % (r.lineno, r.id, path)); bad += 1 continue - with open(full) as fh: - n = sum(1 for _ in fh) + if not os.path.isfile(full): + fails.append("matrix:%d %s mint-ref target is not a file: %s" + % (r.lineno, r.id, path)); bad += 1 + continue + try: + with open(full, errors="replace") as fh: + n = sum(1 for _ in fh) + except OSError as exc: + fails.append("matrix:%d %s mint-ref target unreadable (%s): %s" + % (r.lineno, r.id, exc.__class__.__name__, path)); bad += 1 + continue if int(line) > n: fails.append("matrix:%d %s mint-ref %s points past EOF (file has %d lines)" % (r.lineno, r.id, ref, n)); bad += 1 @@ -403,8 +422,18 @@ # --------------------------------------------------------------------------- tier 2 +# A declared path is interpolated into a remote shell command, so its charset is a +# security boundary, not a style rule: ruling 3 calls the bound ABSOLUTE, and an +# unvalidated `;` or `$(...)` in this file would execute on the remote host -- under +# `sudo -n` when --privileged is passed. Shell metacharacters are rejected outright and +# the path is additionally shell-quoted at use. +SAFE_PATH_RE = re.compile(r"^[A-Za-z0-9_@%+=:,./~*-]+$") +SAFE_TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$") # no leading '-', no ssh options + + def load_locations(path, repo, fails): - """The ruling-3 declared-location list. Returns [(role, ssh_target, path)] or None.""" + """The ruling-3 declared-location list. + Returns [(role, site, ssh_target, path)], or None if the file is ABSENT.""" if not os.path.exists(path): return None out = [] @@ -413,13 +442,24 @@ if not text: continue parts = text.split() - if len(parts) != 3: - fails.append("vm-secret-locations:%d has %d field(s), want 3 " - "( )" % (n, len(parts))) + if len(parts) != 4: + fails.append("vm-secret-locations:%d has %d field(s), want 4 " + "( )" % (n, len(parts))) continue - role, target, p = parts + role, site, target, p = parts + if site != "-" and not SITE_KEY_RE.match(site): + fails.append("vm-secret-locations:%d site-key '%s' is not region-qualified" + % (n, site)); continue + if not SAFE_TARGET_RE.match(target): + fails.append("vm-secret-locations:%d ssh-target '%s' has unsafe characters " + "-- it is passed to ssh argv" % (n, target)); continue # The repo has been renamed once (D-110); its name is never hardcoded. - out.append((role, target, p.replace("", repo))) + p = p.replace("", repo) + if not SAFE_PATH_RE.match(p): + fails.append("vm-secret-locations:%d path '%s' contains a shell metacharacter " + "-- rejected: this string reaches a remote shell" % (n, p)) + continue + out.append((role, site, target, p)) return out @@ -435,7 +475,17 @@ # about a directory it was never able to open. The region secrets dir (SEC-020) is exactly # such a directory. "Could not look" must never be recorded as "nothing there". def _dir_of(pattern): - return os.path.dirname(pattern) if pattern.endswith("/*") else None + """The directory whose readability decides this location's state. + + For `dir/*` that is the directory. For a LITERAL FILE path it is the containing + directory -- previously this returned None for literal paths, so they skipped the + absent/unreadable determination entirely and an unreadable directory read as + 'fully probed, nothing there' (committee finding: the same false green T33 was + written to kill, surviving in the other path form).""" + # Both forms want the same thing: dirname("a/b/*") == "a/b" (the directory whose + # readability we test) and dirname("a/b/file") == "a/b" (the file's parent). An + # earlier rewrite rstripped "/*" first and climbed one level too far. + return os.path.dirname(pattern) def probe_local(pattern): @@ -474,16 +524,25 @@ surface. It still reads metadata only: mode and name, never content.""" d = _dir_of(pattern) probe = "" - if d: + if d and not glob.has_magic(d): # See probe_local: "not a directory" is only ABSENT when the parent is - # traversable; otherwise the truth is that we could not look. + # traversable; otherwise the truth is that we could not look. Guarded by + # has_magic because a multi-word glob expansion makes `[ -d ... ]` a syntax + # error, which silently disabled this whole state machine (committee finding). + q = shlex.quote(d) probe = ('p=$(dirname %s); ' 'if [ ! -d %s ]; then ' ' if [ -r "$p" ] && [ -x "$p" ]; then echo "__STATE__ absent"; ' ' else echo "__STATE__ unreadable"; fi; ' 'elif [ ! -r %s ] || [ ! -x %s ]; then echo "__STATE__ unreadable"; fi; ' - % (d, d, d, d)) - cmd = probe + 'for f in %s; do [ -f "$f" ] && stat -c "%%n %%a" "$f"; done' % pattern + % (q, q, q, q)) + # __DONE__ is the liveness marker. Without it, a loop that simply matched nothing + # exits non-zero with empty output and was reported as UNREACHABLE -- which gated the + # whole role and converted every absence FAIL at that role into an [ok]. Reaching + # __DONE__ proves the command ran on the host, so "found nothing" is a real + # observation and only its ABSENCE means we could not reach the host. + cmd = probe + ('for f in %s; do [ -f "$f" ] && stat -c "%%n %%a" "$f"; done; ' + 'echo __DONE__' % pattern) if privileged: cmd = "sudo -n sh -c " + shlex.quote(cmd) try: @@ -494,13 +553,17 @@ if p.returncode == 255: # ssh transport failure return ("unreachable", {}) found = {} + done = False for line in p.stdout.splitlines(): if line.startswith("__STATE__ "): return (line.split(None, 1)[1].strip(), {}) + if line.strip() == "__DONE__": + done = True + continue bits = line.rsplit(None, 1) if len(bits) == 2: found[os.path.basename(bits[0])] = bits[1] - if p.returncode != 0 and not found: + if not done: return ("unreachable", {}) return ("ok", found) @@ -509,11 +572,17 @@ oks, fails): """EXISTENCE: everything expected is present at its expected role, and nothing undeclared sits at any declared location -- including the remote ones (ruling 3).""" - by_role = {} - for role, target, pat in locations: - by_role.setdefault(role, []).append((target, pat)) + # SCOPE = (host-role, site-key), NOT host-role alone. Keying on role alone merged all + # three jumphost creds folders into one basename namespace, so a credential present at + # ANY site satisfied the row for EVERY site -- which is how SEC-021's dc0 + # opnsense-api.txt absence passed a green sweep while tier 1 flagged it. Four committee + # lenses converged on this. A location with site `-` is not site-scoped and serves rows + # that are likewise `-`. + by_scope = {} + for role, site, target, pat in locations: + by_scope.setdefault((role, site), []).append((target, pat)) - observed = {} # role -> {basename: mode} + observed = {} # (role, site) -> {basename: mode} # A role is COMPLETE only if EVERY declared location for it was probed. Absence can # only be asserted over a fully-probed role: with one location skipped, the other # locations' listings would "cover" the role and manufacture false EXPECTED-BUT-ABSENT @@ -521,14 +590,15 @@ # first live sweep, where skipping the region secrets dir made every credential in it # report as missing from the headend. incomplete = set() - for role, _t, _p in locations: - observed.setdefault(role, {}) + for role, site, _t, _p in locations: + observed.setdefault((role, site), {}) skipped_remote = 0 - for role, target, pat in locations: + for role, site, target, pat in locations: + scope = (role, site) if target != "local": if not use_remote: skipped_remote += 1 - incomplete.add(role) + incomplete.add(scope) continue state, seen = probe_remote(target, pat) if state == "unreadable" and use_privileged: @@ -547,7 +617,7 @@ # "absent" is a CONCLUSIVE observation -- we looked and the location is not there, # so it hides nothing. Only "could not look" states gate the role. if state in ("unreadable", "unreachable"): - incomplete.add(role) + incomplete.add(scope) if state == "absent": oks.append("E0 %s location '%s' does not exist -- SKIPPED (typically a mint " "stage this deployment has not reached)" % (role, pat)) @@ -563,7 +633,7 @@ oks.append("E0 %s (%s) UNREACHABLE -- location '%s' SKIPPED explicitly; " "an unreachable host is never a pass" % (role, target, pat)) continue - observed.setdefault(role, {}).update(seen) + observed.setdefault(scope, {}).update(seen) if skipped_remote: oks.append("E0 %d remote location(s) SKIPPED -- rerun with --remote to include " @@ -574,19 +644,33 @@ absent = 0 deferred = 0 gated = 0 + undeclarable = [] for r in rows: - if not r.has_artifact() or r.host_role not in by_role: + if not r.has_artifact(): continue - if r.host_role not in observed: - continue # that role was skipped/unreachable already - if r.filename in observed[r.host_role]: + scope = (r.host_role, r.site_key) + if scope not in by_scope: + # NOT silently skipped: a row whose scope has no declared location can never + # be checked, and the module's contract is that nothing passes quietly. + undeclarable.append("%s '%s' (%s/%s)" + % (r.id, r.filename, r.host_role, r.site_key)) + continue + if r.filename in observed.get(scope, {}): + got = observed[scope][r.filename] want = derived_mode(r.filename) - got = observed[r.host_role][r.filename] if r.custody == "consolidated" and got != want: - fails.append("E2 MODE: %s '%s' at %s is %s, want %s" - % (r.id, r.filename, r.host_role, got, want)) + fails.append("E2 MODE: %s '%s' at %s/%s is %s, want %s" + % (r.id, r.filename, r.host_role, r.site_key, got, want)) + elif r.principal != "-" and want == "600" and int(got, 8) & 0o007: + # World-readable SECRET, at ANY custody (a `.pub` half derives 644 and is + # public by design, so it is excluded). The exact-mode check above only + # covers `consolidated` rows, which left 43 of 77 rows -- including the + # SEC-020 region dir and the SEC-022 shadow stores, the two that motivated + # remote probing -- with no mode check at all. + fails.append("E2 WORLD-READABLE: %s '%s' at %s/%s is mode %s" + % (r.id, r.filename, r.host_role, r.site_key, got)) continue - if r.host_role in incomplete: + if scope in incomplete: gated += 1 continue if r.mint_stage in pending_stages: @@ -597,22 +681,23 @@ absent += 1 fails.append("E1 EXPECTED-BUT-ABSENT: %s '%s' expected at %s, not found " "(mint-stage %s%s)" - % (r.id, r.filename, r.host_role, r.mint_stage, + % (r.id, r.filename, "%s/%s" % (r.host_role, r.site_key), + r.mint_stage, "" if r.sec_ref == "-" else ", " + r.sec_ref)) # bound 2: nothing undeclared at a declared location declared = {} for r in rows: if r.has_artifact(): - declared.setdefault(r.host_role, set()).add(r.filename) + declared.setdefault((r.host_role, r.site_key), set()).add(r.filename) undeclared = 0 - for role in sorted(observed): - for name in sorted(observed[role]): - if name not in declared.get(role, set()): + for scope in sorted(observed): + for name in sorted(observed[scope]): + if name not in declared.get(scope, set()): undeclared += 1 - fails.append("E3 UNDECLARED: '%s' present at %s but in NO matrix row -- " - "an undeclared secret is the exact class creds-audit cannot " - "see" % (name, role)) + fails.append("E3 UNDECLARED: '%s' present at %s/%s but in NO matrix row " + "for that site -- an undeclared secret is the exact class " + "creds-audit cannot see" % (name, scope[0], scope[1])) if deferred: oks.append("E1 %d expected artifact(s) deferred as not-yet-minted (--pending-stage)" @@ -620,11 +705,16 @@ if gated: oks.append("E1 %d expected artifact(s) NOT JUDGED -- their role (%s) has at least " "one location that could not be probed, so absence cannot be asserted " - "over it" % (gated, ", ".join(sorted(incomplete)))) + "over it" % (gated, ", ".join("%s/%s" % c + for c in sorted(incomplete)))) + if undeclarable: + fails.append("E4 UNCHECKABLE: %d row(s) have no declared location for their " + "(role, site) and can never be verified -- add a location row or " + "correct the matrix: %s" % (len(undeclarable), "; ".join(undeclarable))) if not (absent or undeclared): oks.append("E1/E3 existence: every expected artifact present and nothing " "undeclared, across %d fully-probed role(s)" - % len([r for r in observed if r not in incomplete])) + % len([c for c in observed if c not in incomplete])) def main(): @@ -690,6 +780,11 @@ if locations is None: fails.append("tier 2: no declared-location list at %s -- ruling 3 requires one " "before any discovery may run" % args.locations) + elif not locations: + fails.append("tier 2: the declared-location list at %s is EMPTY -- ruling 3's " + "bound is absolute, so an unpopulated list REFUSES like a missing " + "one rather than affirming existence over zero locations" + % args.locations) else: tier2_existence(rows, locations, args.remote, args.privileged, set(args.pending_stage), oks, fails) diff --git a/tests/creds-audit/run-tests.sh b/tests/creds-audit/run-tests.sh index b980e81..3b56886 100755 --- a/tests/creds-audit/run-tests.sh +++ b/tests/creds-audit/run-tests.sh @@ -71,7 +71,7 @@ # Each fixture is a real exposure class, not a hypothetical: admin.pass is the SEC-020 # secret that sat undetected for 12 days, and admin-openrc is the exposure phase-03 is # PREDICTED to create at Stage 5. -for probe in admin.pass:T8 id_ed25519:T9 tenant-ca.pem:T10 svc.apikey:T11 admin-openrc:T12; do +for probe in admin.pass:T8 id_ed25519:T9 tenant-ca.pem:T10 svc.apikey:T11 admin-openrc:T12 .admin.pass:T14 .svc_ed25519:T15; do fname="${probe%%:*}"; tag="${probe##*:}" R="$(mkenv)"; printf 'x\n' > "$R/$fname"; rc=$(run "$R") [ "$rc" = 1 ] && grep -q "SPRAWL: sensitive file loose" "$R/out" \ diff --git a/tests/creds-matrix/classify-findings.py b/tests/creds-matrix/classify-findings.py new file mode 100644 index 0000000..d38b1a9 --- /dev/null +++ b/tests/creds-matrix/classify-findings.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Reduce a creds-matrix run to its FINDING CLASSES, one per line, sorted+unique. + +Used by the harness's expected-findings baseline. Classes, not instances: the baseline +must survive a row being added or renamed, but must CHANGE when a class of defect is +fixed or introduced -- so that remediating a defect requires updating the baseline in the +same commit, rather than turning the gauntlet red for the person who fixed it. +""" +import sys, re +KEYS = ("EXPECTED-BUT-ABSENT", "UNEXPECTED-EXTRA", "FIELD DRIFT", "ASYMMETRY", + "IDENTITY CONFLATION", "UNDECLARED", "UNCHECKABLE", "WORLD-READABLE", + "MODE", "UNREADABLE", "ESCALATION FAILED", "FLOOR") +out = set() +for line in sys.stdin: + if "[FAIL]" not in line: + continue + body = line.split("[FAIL]", 1)[1].strip() + code = re.match(r"([SE]\d)\b", body) + key = next((k for k in KEYS if k in body), None) + if code and key: + out.add("%s %s" % (code.group(1), key)) + elif code: + out.add(code.group(1) + " other") + else: + out.add("schema/row defect") +for c in sorted(out): + print(c) diff --git a/tests/creds-matrix/expected-findings.txt b/tests/creds-matrix/expected-findings.txt new file mode 100644 index 0000000..9057f56 --- /dev/null +++ b/tests/creds-matrix/expected-findings.txt @@ -0,0 +1,3 @@ +S2 EXPECTED-BUT-ABSENT +S5 ASYMMETRY +S6 IDENTITY CONFLATION diff --git a/tests/creds-matrix/run-tests.sh b/tests/creds-matrix/run-tests.sh index 144e4f2..0ddcf69 100644 --- a/tests/creds-matrix/run-tests.sh +++ b/tests/creds-matrix/run-tests.sh @@ -51,7 +51,7 @@ local d="$1"; mkdir -p "$d/store" printf 'x\n' > "$d/store/alpha_ed25519"; chmod 600 "$d/store/alpha_ed25519" printf 'x\n' > "$d/store/alpha_ed25519.pub"; chmod 644 "$d/store/alpha_ed25519.pub" - printf 'jumphost local %s/store/*\n' "$d" > "$d/locations" + printf 'jumphost vr1-office1 local %s/store/*\n' "$d" > "$d/locations" } row() { printf '%s\n' "$1" >> "$2/matrix.tsv"; } note() { printf '\n## %s\nfixture note.\n' "$1" >> "$2/notes.md"; } @@ -99,7 +99,7 @@ row 'vault-shares singleton - jumphost vault-init none service off-manifest-known vr0-phase02 operator-terminal - -' "$D" row 'vault-root singleton - jumphost vault-init none service off-manifest-known vr0-phase02 operator-terminal - -' "$D" run "$D" -[ "$RC" = 0 ] \ +[ "$RC" = 0 ] && grep -q '^PASS: creds-matrix' "$D/out" && ! grep -q 'FAIL.*duplicate' "$D/out" \ && ok "T6 two identities sharing one file -> allowed (Vault init regression)" \ || { no "T6 distinct identities may share a file (rc=$RC)"; sed 's/^/ /' "$D/out"; } @@ -179,7 +179,7 @@ row 'folder-readme per-site vr1-office1 jumphost README none - consolidated - - - -' "$D" printf 'README 600 local\n' >> "$D/creds-manifests/vr1-office1.manifest" run "$D" -[ "$RC" = 0 ] \ +[ "$RC" = 0 ] && ! grep -q 'IDENTITY CONFLATION' "$D/out" \ && ok "T16 a principal='-' index file -> skipped by ruling 5, not faulted" \ || { no "T16 principal '-' must be skipped (rc=$RC)"; sed 's/^/ /' "$D/out"; } @@ -240,14 +240,26 @@ && ok "T23 --render emits a GENERATED banner and the derived modes" \ || { no "T23 --render output shape (rc=$RC)"; sed 's/^/ /' "$D/out"; } -# T24 -- the REAL tree must be RED, and red for the RIGHT reasons. A checker that goes -# green on today's tree is wrong (plan: EXPECTED RED BY DESIGN). Guards against someone -# "fixing" the register by deleting the rows that fail. -python3 "$CHECK" > "$TMPROOT/real.out" 2>&1; RRC=$? -[ "$RRC" = 1 ] && grep -q 'IDENTITY CONFLATION' "$TMPROOT/real.out" \ - && grep -q 'EXPECTED-BUT-ABSENT' "$TMPROOT/real.out" \ - && ok "T24 the real tree is RED with the ruling-5 conflation + SEC-021 absences intact" \ - || { no "T24 the real tree must stay RED by design (rc=$RRC)"; sed 's/^/ /' "$TMPROOT/real.out"; } +# T24 -- the REAL tree must be RED, and red for the RIGHT CLASSES. Compared against a +# checked-in baseline of finding CLASSES rather than literal strings: the old form grepped +# for "IDENTITY CONFLATION", so REMEDIATING that defect -- the fix the plan, the notes and +# the changelog all prescribe -- would have deleted the string and turned the GAUNTLET red +# for whoever did the right thing. The cheapest response at that moment is to edit the +# test, which is exactly when the "do not go green by deleting rows" guard stops existing. +# With a baseline, remediation REQUIRES updating expected-findings.txt in the same commit: +# a recorded decision instead of a mystery breakage. +python3 "$CHECK" 2>&1 | python3 "$HERE/classify-findings.py" > "$TMPROOT/actual.txt"; RRC=${PIPESTATUS[0]} +if diff -u "$HERE/expected-findings.txt" "$TMPROOT/actual.txt" > "$TMPROOT/diff.txt" 2>&1; then + [ "$RRC" = 1 ] \ + && ok "T24 the real tree is RED with exactly the baselined finding classes" \ + || no "T24 the real tree must exit 1 (rc=$RRC)" +else + no "T24 finding CLASSES drifted from tests/creds-matrix/expected-findings.txt -- if you" + echo " FIXED a defect, update the baseline in the SAME commit; if a NEW class" + echo " appeared, that is a regression. Diff (expected vs actual):" + sed 's/^/ /' "$TMPROOT/diff.txt" +fi + # ---- tier 2 (EXISTENCE) --------------------------------------------------------------- @@ -294,7 +306,7 @@ # T31 -- remote locations are opt-in, and their omission must be VISIBLE. A local-only # run that quietly ignored the headend would reproduce the SEC-022 blind spot exactly. D=$(mkenv); mkloc "$D" -printf 'headend some-host /root/secrets/*\n' >> "$D/locations" +printf 'headend vr1-office1 some-host /root/secrets/*\n' >> "$D/locations" run "$D" --tier2 [ "$RC" = 0 ] && grep -q 'remote location(s) SKIPPED' "$D/out" \ && ok "T31 remote locations omitted without --remote -> announced, not silently dropped" \ @@ -303,7 +315,7 @@ # T32 -- an unreachable host must SKIP explicitly. Treating 'could not look' as 'nothing # there' is the false-green that makes a credential audit worthless. D=$(mkenv); mkloc "$D" -printf 'headend no-such-host.invalid /root/secrets/*\n' >> "$D/locations" +printf 'headend vr1-office1 no-such-host.invalid /root/secrets/*\n' >> "$D/locations" run "$D" --tier2 --remote [ "$RC" = 0 ] && grep -q 'UNREACHABLE' "$D/out" \ && grep -q 'never a pass' "$D/out" \ @@ -316,7 +328,7 @@ # region secrets dir (SEC-020) is exactly such a directory. D=$(mkenv); mkloc "$D"; mkdir -p "$D/locked"; printf 'x\n' > "$D/locked/hidden.key" chmod 000 "$D/locked" -printf 'jumphost local %s/locked/*\n' "$D" >> "$D/locations" +printf 'jumphost vr1-office1 local %s/locked/*\n' "$D" >> "$D/locations" run "$D" --tier2 chmod 755 "$D/locked" [ "$RC" = 1 ] && grep -q 'E0 UNREADABLE' "$D/out" \ @@ -325,12 +337,97 @@ # T34 -- a declared location that simply does not exist yet is a SKIP, not a failure. D=$(mkenv); mkloc "$D" -printf 'jumphost local %s/not-created-yet/*\n' "$D" >> "$D/locations" +printf 'jumphost vr1-office1 local %s/not-created-yet/*\n' "$D" >> "$D/locations" run "$D" --tier2 [ "$RC" = 0 ] && grep -q 'does not exist -- SKIPPED' "$D/out" \ && ok "T34 a not-yet-created declared location -> explicit SKIP, not a failure" \ || { no "T34 absent location handling (rc=$RC)"; sed 's/^/ /' "$D/out"; } +# ---- committee-derived regressions (2026-07-26) --------------------------------------- + +# T36 -- THE SITE COLLISION. Two sites, one role, the SAME basename: dc1 has the file, +# dc0 does not. Keyed on role alone, dc1's copy satisfied dc0's row and the absence was +# invisible -- which is exactly how SEC-021's dc0 opnsense-api.txt passed a green sweep. +# The old fixtures declared only ONE location per role, so this was structurally +# unreachable by the test set. +D=$(mkenv); mkloc "$D" +mkdir -p "$D/dc0store" "$D/dc1store" +printf 'x\n' > "$D/dc1store/shared.txt"; chmod 600 "$D/dc1store/shared.txt" +printf 'jumphost vr1-dc0 local %s/dc0store/*\n' "$D" >> "$D/locations" +printf 'jumphost vr1-dc1 local %s/dc1store/*\n' "$D" >> "$D/locations" +row 'shared-cred per-DC vr1-dc0 jumphost shared.txt api service off-manifest-known stage3 operator-terminal - -' "$D" +row 'shared-cred per-DC vr1-dc1 jumphost shared.txt api service off-manifest-known stage3 operator-terminal - -' "$D" +run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "E1 EXPECTED-BUT-ABSENT: shared-cred 'shared.txt' expected at jumphost/vr1-dc0" "$D/out" \ + && ok "T36 same basename, two sites, absent at one -> E1 fires for THAT site (site-collision)" \ + || { no "T36 site-scoped existence must fire (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T37 -- ruling 3's bound is ABSOLUTE, so an EMPTY list must refuse exactly like a missing +# one. Previously an empty file parsed to [] (not None), skipped the refusal, and then +# affirmed existence over zero probed locations. +D=$(mkenv); mkloc "$D"; : > "$D/locations"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'is EMPTY' "$D/out" \ + && ok "T37 an EMPTY declared-location list -> REFUSES, never affirms over zero locations" \ + || { no "T37 empty locations must refuse (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T38 -- a register with no rows asserts nothing, but every check reported clean over the +# empty set and the run exited 0. +D=$(mkenv); printf '# all rows removed\n' > "$D/matrix.tsv"; run "$D" +[ "$RC" = 1 ] && grep -q 'S1 FLOOR' "$D/out" \ + && ok "T38 a ZERO-row matrix -> FLOOR failure, not a clean run" \ + || { no "T38 empty matrix must not be clean (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T39 -- mode enforcement used to apply only to custody=consolidated, leaving 43 of 77 +# rows -- including the SEC-020 region dir and the SEC-022 shadow stores -- unchecked. +D=$(mkenv); mkloc "$D" +printf 'x\n' > "$D/store/exposed_ed25519"; chmod 644 "$D/store/exposed_ed25519" +row 'exposed singleton vr1-office1 jumphost exposed_ed25519 ssh service off-manifest-known stage2 operator-terminal - -' "$D" +run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'E2 WORLD-READABLE' "$D/out" \ + && ok "T39 a world-readable secret at NON-consolidated custody -> caught" \ + || { no "T39 world-readable secrets must be caught at any custody (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T40 -- S5's SECOND direction. T13 only ever populated the dc0 side, so deleting the +# dc1->dc0 comparison left the harness fully green: an upper bound masquerading as an +# assertion, in the check whose entire job is symmetry. +D=$(mkenv) +row 'dc1-only per-DC vr1-dc1 jumphost vr1-dc1-only_ed25519 ssh service off-manifest-known stage3 operator-terminal - -' "$D" +printf 'vr1-dc1-only_ed25519 600 local\n' > "$D/creds-manifests/vr1-dc1.manifest" +run "$D" +[ "$RC" = 1 ] && grep -q 'S5 ASYMMETRY: vr1-dc1' "$D/out" \ + && ok "T40 a dc1-ONLY per-DC credential -> ASYMMETRY fires on the second direction too" \ + || { no "T40 S5 must assert BOTH directions (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T41 -- literal-file locations previously skipped the absent/unreadable determination +# entirely, so an unreadable directory read as "fully probed, nothing there". +D=$(mkenv); mkloc "$D"; mkdir -p "$D/lockedf"; printf 'x\n' > "$D/lockedf/k.key"; chmod 000 "$D/lockedf" +printf 'jumphost vr1-office1 local %s/lockedf/k.key\n' "$D" >> "$D/locations" +run "$D" --tier2; chmod 755 "$D/lockedf" +[ "$RC" = 1 ] && grep -q 'E0 UNREADABLE' "$D/out" \ + && ok "T41 an unreadable dir behind a LITERAL-file location -> UNREADABLE, not silent ok" \ + || { no "T41 literal paths need state detection too (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T42 -- the declared path reaches a remote shell, so its charset is a security boundary: +# ruling 3 calls the bound absolute, and an unvalidated `;` would execute on the host -- +# under sudo when --privileged is passed. +D=$(mkenv); mkloc "$D" +printf 'jumphost vr1-office1 local /tmp/x;id\n' >> "$D/locations" +run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'shell metacharacter' "$D/out" \ + && ok "T42 a shell metacharacter in a declared path -> REJECTED before any probe" \ + || { no "T42 unsafe declared paths must be rejected (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T43 -- a row whose (role, site) has no declared location can never be verified. It used +# to `continue` with no counter and no [ok], contradicting the module's own contract that +# nothing passes silently. +D=$(mkenv); mkloc "$D" +row 'orphan-scope singleton vr1-office1 unit thing.txt api service off-manifest-known stage6 operator-terminal - -' "$D" +run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'E4 UNCHECKABLE' "$D/out" \ + && ok "T43 a row with no declared location for its scope -> disclosed, not silently skipped" \ + || { no "T43 uncheckable rows must be disclosed (rc=$RC)"; sed 's/^/ /' "$D/out"; } + + echo if [ "$F" = 0 ]; then echo "creds-matrix: $P/$P PASS"; exit 0; fi echo "creds-matrix: FAILURES: $F (passed $P)"; exit 1