diff --git a/creds-manifests/vm-secret-locations b/creds-manifests/vm-secret-locations new file mode 100644 index 0000000..9cc2ecf --- /dev/null +++ b/creds-manifests/vm-secret-locations @@ -0,0 +1,67 @@ +# vm-secret-locations -- the ABSOLUTE bound on credential discovery (D-137 ruling 3). +# +# RULING 3, 2026-07-26, operator utterance "Declared locations only": this list bounds +# `--remote` ABSOLUTELY. The sweep visits these locations and NOTHING else. It never walks +# a host looking for secrets, never expands beyond a listed path, and never touches a +# tenant surface -- D-069 (tenant credential custody stays off-repo) is preserved BY +# CONSTRUCTION rather than by the sweep's good behaviour. +# +# ADDING A ROW HERE IS DEFINITION-OF-DONE FOR ANY NEW MINT SITE. A location that is not +# listed is not audited, and an unaudited location is how SEC-022 happened. +# +# WHY EACH CLASS IS PRESENT (the ruling's faithful-implementation note): +# - per-site creds folders on the JUMPHOST -- the SEC-009 convention itself +# - per-site creds folders on the HEADEND -- SEC-022, the shadow stores that were +# structurally invisible: creds-audit resolves the folder against $HOME on whatever +# host it runs on and contains no ssh at all, so a headend-resident store could never +# be seen. D-128 puts Plane-2 execution on the headend, so these keep appearing. +# - the region secrets dir -- SEC-020, where admin.pass sat +# root-only for 12 days while the audit read CLEAN +# - the three dirs outside the SEC-009 convention (research FINDING 2) +# - the PKI overlay inside the repo clone -- a CA private key plus a plaintext +# passphrase, gitignored, in a repo SEC-004 records as currently PUBLIC +# +# ON THE TENANT DIRECTORIES, which look like a contradiction and are not: the per-tenant +# directories listed below are LOCAL operator-held copies on the jumphost. They are NOT a +# 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: +# host-role the role token used by creds-matrix.tsv (jumphost|headend|netbox|...) +# 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 +# path an absolute path or a `~`-relative one, expanded on the TARGET host. +# A trailing `/*` means "the files directly in this directory". +# `#` comments and blank lines ignored. NO FIELD MAY CONTAIN WHITESPACE. +# +# A listed location that does not exist is NOT a failure -- it is reported as an explicit +# 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: 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-*/* +# 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 +# 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 + +# --- 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/* + +# --- netbox VM: the founding SEC-009 miss ------------------------------------ +netbox office1-netbox /root/netbox-secrets/* diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 042d927..1149a3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -841,6 +841,22 @@ red, so wiring it hard-fails `preflight.sh` until the credential defects are remediated; wiring-as-ruled is the default and deferring until after per-row remediation is the departure. Not decided; nothing wired either way. + **TIER 2 TOOLING BUILT 2026-07-26, NOT YET RUN LIVE.** Shipped: + `creds-manifests/vm-secret-locations` (ruling 3's absolute bound -- jumphost creds + folders, the headend shadow stores of SEC-022, the region secrets dir of SEC-020, the + three dirs outside the SEC-009 convention, the in-clone PKI overlay, and the DOCFIX-175 + plaintext tfstate; tenant dirs are LOCAL-only so no tenant surface is reachable and + D-069 holds by construction); `creds-matrix.py --tier2 [--remote]` (E1 expected-but- + absent / E2 mode / E3 undeclared-at-a-declared-location, with an unreachable host + SKIPPED explicitly because "could not look" must never read as "nothing there", and a + `--pending-stage` selector so a not-yet-reached mint stage defers instead of failing -- + the CALLER supplies it, the script carries no status claim, GA-R1); `creds-audit.sh` + sprawl globs WIDENED for the SEC-023 blind spots (`admin.pass`, `*.apikey`, `*.key`, + `*.pem`, `*_ed25519`, `*_rsa`, `*openrc*`) -- the old six patterns could not have seen + the SEC-020 secret or the predicted Stage-5 `~/admin-openrc`. Harnesses + **creds-matrix 33/33** and **creds-audit 13/13** (was 7); gauntlet ALL GREEN (80), + repo-lint 0-fail. The LIVE three-site sweep (the plan's acceptance test for SEC-021's + on-disk half, SEC-022 and SEC-023) is the next gated step and has NOT run. ## 9. Additional defect found while authoring (FIXED in sweep Batch 0.3, 2026-07-19 -- wrap-aware exclusion, GA-F15; history below) diff --git a/docs/changelog-20260726-d137-tier1.md b/docs/changelog-20260726-d137-tier1.md index 716badd..98446f0 100644 --- a/docs/changelog-20260726-d137-tier1.md +++ b/docs/changelog-20260726-d137-tier1.md @@ -144,12 +144,72 @@ Going green is a REMEDIATION project, separate from this build and gated per row. +--- + +# TIER 2 (EXISTENCE) -- tooling built, live sweep NOT run + +## Item 6 -- `creds-manifests/vm-secret-locations` (ruling 3) + +The absolute bound on discovery. Includes every class the ruling's faithful-implementation +note names, plus two the note did not: the in-clone PKI overlay and the DOCFIX-175 +plaintext `terraform.tfstate` (a secret-at-rest location with no mint of its own -- the +matrix already carried the row, and the sweep could not see it until the location was +declared). + +**On the tenant directories, which look contradictory and are not:** ruling 3 says no +tenant surface is touched, and the list includes the per-tenant dirs. Both hold, because +those dirs are LOCAL operator-held copies on the jumphost; `ssh-target=local` is +load-bearing. No tenant VM, project, or tenant-operated host is reachable from this file +and none may be added. D-069 is preserved by construction, not by good behaviour. + +`` is substituted at runtime from the checker's own location -- the repo has been +renamed once (D-110) and its name is never hardcoded into a data file. + +**Revert:** delete the file; tier 2 then refuses to run (by design, T30). + +## Item 7 -- tier-2 EXISTENCE checks in `creds-matrix.py` + +E1 expected-but-absent, E2 mode, E3 undeclared-at-a-declared-location. `--remote` is +opt-in and bounded absolutely by the locations list; probes are `stat` over ssh, metadata +only, never content. + +**Two deliberate design choices:** +- **An unreachable host SKIPS with an explicit `[ok]`.** Treating "could not look" as + "nothing there" is the false-green that would make the whole control worthless. T32. +- **`--pending-stage` is supplied by the CALLER, not the script.** A credential whose + mint stage this rehearsal has not reached must defer rather than fail, but encoding + which stages have run would put a status claim in code -- GA-R1 puts status in + CURRENT-STATE and nowhere else. T27. + +**Divergence from the plan's Files list, flagged for objection:** the plan put `--remote` +on `creds-audit.sh`. It is implemented in `creds-matrix.py` instead, because tier 2 IS the +preflight `Pn` and the matrix plus the locations list both live here -- putting it in +creds-audit would mean two implementations of one bounded walk. `creds-audit.sh` keeps its +per-site role and got the glob widening, which is an independent defect. Ruling 3 does not +name a script, only the bound. + +## Item 8 -- `creds-audit.sh` sprawl globs widened (SEC-023) + +Added `*.pass`, `*.apikey`, `*.key`, `*.pem`, `*_ed25519`, `*_rsa`, `*openrc*`. The +original six patterns had measured blind spots: they would not have matched `admin.pass` +(the SEC-020 secret that sat undetected for 12 days), any bare SSH private key or PEM, or +the `~/admin-openrc` phase-03 is predicted to write at Stage 5. Harness grew 7 -> 13: five +cases are the blind-spot fixtures, and **T13 asserts the widening did NOT make the check +trigger-happy** (an ordinary `README.md`/`notes.txt` in the home root is still CLEAN). + +**Revert:** restore the six-pattern glob line and drop harness T8-T13. + +--- + ## NOT done in this session (stated so the boundary is unambiguous) -- Tier 2 (EXISTENCE) and tier 3 (VALIDITY) -- not built. `--all` names them as unbuilt - with an explicit `[ok]`; they never pass silently. -- `creds-manifests/vm-secret-locations` (ruling 3) -- not written. Two `--render` source - fields depend on it and are explicitly skipped as pending, not silently matched. +- **The LIVE tier-2 sweep has NOT run.** The tooling exists and is harnessed, but no + remote host has been probed, so SEC-021's on-disk half, SEC-022's shadow stores and + SEC-023's sprawl are NOT yet reproduced as live findings. The plan's acceptance test is + therefore still only partly met. Next gated step. +- Tier 3 (VALIDITY) -- not built. `--all` names it unbuilt with an explicit `[ok]`. +- `--render`'s source-field derivation from the locations list -- not wired. Two fields + are explicitly skipped as pending, not silently matched. - The preflight `Pn` of ruling 1 -- **not wired**, and this is an OPEN QUESTION for the operator, not an omission: wiring it blocking (the default, since it is what was ruled) hard-fails `preflight.sh` until remediation; deferring it until after per-row diff --git a/scripts/creds-audit.sh b/scripts/creds-audit.sh index 009f030..01d67c5 100755 --- a/scripts/creds-audit.sh +++ b/scripts/creds-audit.sh @@ -68,9 +68,18 @@ done # 4. sprawl: sensitive-looking files loose in CREDS_ROOT, OUTSIDE any *-creds/ folder +# +# GLOBS WIDENED 2026-07-26 (D-137 / SEC-023). The original six patterns had measured blind +# spots: they would not have matched `admin.pass` (the SEC-020 secret that sat undetected +# 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. 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; do + "$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 [ -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 2ab7baa..0c37f34 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -32,7 +32,7 @@ python3 scripts/creds-matrix.py --render # print manifests rendered from the matrix Exit: 0 clean, 1 findings, 2 usage/IO error. ASCII-only output. """ -import sys, os, re, argparse +import sys, os, re, glob, argparse, subprocess COLUMNS = ("id", "cardinality", "site-key", "host-role", "filename", "access-type", "principal", "custody", "mint-stage", "mint-ref", "sec-ref", "notes-ref") @@ -247,9 +247,9 @@ fails.append("S3 %s render drift: '%s' source checked-in=%s rendered=%s" % (site, fname, manifest[fname][1], source)) if pending: - oks.append("S3 render: %d source field(s) SKIPPED pending tier 2 -- they need the " - "declared path from creds-manifests/vm-secret-locations (ruling 3), " - "which is not built yet" % pending) + oks.append("S3 render: %d source field(s) SKIPPED -- rendering them needs the " + "declared path from creds-manifests/vm-secret-locations (ruling 3); the " + "list now EXISTS but the source-field derivation is not wired" % pending) if not drift: # SCOPE, stated so this [ok] cannot be over-read: S3 compares # PARSED ROW FIELDS (mode, source) for rows present on both sides. It does NOT @@ -399,6 +399,132 @@ % len(used)) +# --------------------------------------------------------------------------- tier 2 +def load_locations(path, repo, fails): + """The ruling-3 declared-location list. Returns [(role, ssh_target, path)] or None.""" + if not os.path.exists(path): + return None + out = [] + for n, line in enumerate(open(path), 1): + text = line.split("#", 1)[0].strip() + 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))) + continue + role, target, p = parts + # The repo has been renamed once (D-110); its name is never hardcoded. + out.append((role, target, p.replace("", repo))) + return out + + +def probe_local(pattern): + """{basename: mode} for files matching a local pattern. METADATA ONLY.""" + found = {} + for f in glob.glob(os.path.expanduser(pattern)): + if os.path.isfile(f): + found[os.path.basename(f)] = oct(os.stat(f).st_mode & 0o777)[2:] + return found + + +def probe_remote(target, pattern): + """{basename: mode} over ssh. METADATA ONLY -- `stat`, never file content. + Returns None if the host is unreachable, so the caller can skip EXPLICITLY.""" + cmd = 'for f in %s; do [ -f "$f" ] && stat -c "%%n %%a" "$f"; done' % pattern + try: + p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", + target, cmd], capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return None + if p.returncode != 0 and not p.stdout.strip(): + return None + found = {} + for line in p.stdout.splitlines(): + bits = line.rsplit(None, 1) + if len(bits) == 2: + found[os.path.basename(bits[0])] = bits[1] + return found + + +def tier2_existence(rows, locations, use_remote, pending_stages, 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)) + + observed = {} # role -> {basename: mode} + skipped_remote = unreachable = 0 + for role, target, pat in locations: + if target != "local": + if not use_remote: + skipped_remote += 1 + continue + seen = probe_remote(target, pat) + if seen is None: + unreachable += 1 + oks.append("E0 %s (%s) UNREACHABLE -- location '%s' SKIPPED explicitly; " + "an unreachable host is never a pass" % (role, target, pat)) + continue + else: + seen = probe_local(pat) + observed.setdefault(role, {}).update(seen) + + if skipped_remote: + oks.append("E0 %d remote location(s) SKIPPED -- rerun with --remote to include " + "the headend shadow stores (SEC-022) and the region secrets dir " + "(SEC-020)" % skipped_remote) + + # bound 1: every expected artifact present at its expected role + absent = 0 + deferred = 0 + for r in rows: + if not r.has_artifact() or r.host_role not in by_role: + continue + if r.host_role not in observed: + continue # that role was skipped/unreachable already + if r.filename in observed[r.host_role]: + 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)) + continue + if r.mint_stage in pending_stages: + # NOT-YET-EXPECTED, declared by the CALLER. The script carries no status + # claim of its own (GA-R1: status lives in CURRENT-STATE, nowhere else). + deferred += 1 + continue + 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, + "" 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) + undeclared = 0 + for role in sorted(observed): + for name in sorted(observed[role]): + if name not in declared.get(role, 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)) + + if deferred: + oks.append("E1 %d expected artifact(s) deferred as not-yet-minted (--pending-stage)" + % deferred) + if not (absent or undeclared): + oks.append("E1/E3 existence: every expected artifact present and nothing " + "undeclared, across %d probed role(s)" % len(observed)) + + def main(): ap = argparse.ArgumentParser(description="D-137 credential-matrix checker") here = os.path.dirname(os.path.abspath(__file__)) @@ -409,6 +535,17 @@ ap.add_argument("--repo", default=repo) ap.add_argument("--dc", default=None, help="narrow per-DC checks to one region-qualified DC") ap.add_argument("--all", action="store_true", help="run every implemented tier") + ap.add_argument("--locations", + default=os.path.join(repo, "creds-manifests", "vm-secret-locations")) + ap.add_argument("--tier2", action="store_true", + help="run tier 2 (EXISTENCE) -- local locations only unless --remote") + ap.add_argument("--remote", action="store_true", + help="include ssh locations, bounded ABSOLUTELY by vm-secret-locations " + "(ruling 3). Metadata only; no file content is ever read.") + ap.add_argument("--pending-stage", action="append", default=[], metavar="STAGE", + help="a mint-stage this deployment has NOT reached; its expected " + "artifacts are deferred, not failed. Supply from CURRENT-STATE -- " + "this script carries no status claim of its own (GA-R1).") ap.add_argument("--render", action="store_true", help="print manifests rendered from the matrix (does not write)") args = ap.parse_args() @@ -441,11 +578,21 @@ s6_principal_invariant(rows, oks, fails) s7_notes_ref(rows, notes, args.notes, oks, fails) + if args.tier2 or args.all: + print("=== creds-matrix: tier 2 (EXISTENCE) ===") + locations = load_locations(args.locations, args.repo, fails) + 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) + else: + tier2_existence(rows, locations, args.remote, set(args.pending_stage), + oks, fails) + else: + # SELF-SKIP WITH AN EXPLICIT [ok]: a tier that did not run must be visible. + oks.append("tier 2 (EXISTENCE) NOT RUN -- pass --tier2 (add --remote for the " + "headend shadow stores and the region secrets dir)") + if args.all: - # SELF-SKIP WITH AN EXPLICIT [ok]: an unbuilt tier must be visible, never silent. - oks.append("tier 2 (EXISTENCE) NOT BUILT -- needs creds-manifests/vm-secret-locations " - "and creds-audit --remote; SEC-022/-023 and SEC-021's on-disk half cannot " - "be reproduced until it exists") oks.append("tier 3 (VALIDITY) NOT BUILT -- needs cross-host sha256 provenance " "comparison (never content transfer) and per-row behavioral probes") diff --git a/tests/creds-audit/run-tests.sh b/tests/creds-audit/run-tests.sh index 7318a57..b980e81 100755 --- a/tests/creds-audit/run-tests.sh +++ b/tests/creds-audit/run-tests.sh @@ -67,6 +67,25 @@ || { no "T6 a non-0700 folder should fail (rc=$rc)"; sed 's/^/ /' "$R/out"; } rm -rf "$R" +# T8-T12: SPRAWL BLIND SPOTS the original six globs could not see (D-137 / SEC-023). +# 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 + fname="${probe%%:*}"; tag="${probe##*:}" + R="$(mkenv)"; printf 'x\n' > "$R/$fname"; rc=$(run "$R") + [ "$rc" = 1 ] && grep -q "SPRAWL: sensitive file loose" "$R/out" \ + && ok "$tag a loose '$fname' in the home root -> SPRAWL (was a blind spot)" \ + || { no "$tag '$fname' must be caught as sprawl (rc=$rc)"; sed 's/^/ /' "$R/out"; } + rm -rf "$R" +done + +# T13: widening must not make the check trigger-happy -- an ordinary file is NOT sprawl. +R="$(mkenv)"; printf 'notes\n' > "$R/README.md"; printf 'x\n' > "$R/notes.txt"; rc=$(run "$R") +[ "$rc" = 0 ] && ok "T13 ordinary non-secret files in the home root -> still CLEAN" \ + || { no "T13 widened globs must not false-positive (rc=$rc)"; sed 's/^/ /' "$R/out"; } +rm -rf "$R" + # T7: the audit reads NO secret content -- no content-reading verbs in the script grep -qE '\b(cat|head|tail|less|more|od|xxd|hexdump)\b' "$AUDIT" \ && no "T7 the audit contains a content-reading verb -- it must inspect metadata only" \ diff --git a/tests/creds-matrix/run-tests.sh b/tests/creds-matrix/run-tests.sh index d0d32a0..39ae751 100644 --- a/tests/creds-matrix/run-tests.sh +++ b/tests/creds-matrix/run-tests.sh @@ -42,9 +42,17 @@ run() { # run [extra args...] -> sets RC, writes $1/out local d="$1"; shift python3 "$CHECK" --matrix "$d/matrix.tsv" --manifest-dir "$d/creds-manifests" \ - --notes "$d/notes.md" --repo "$REPO" "$@" > "$d/out" 2>&1 + --notes "$d/notes.md" --repo "$REPO" \ + --locations "$d/locations" "$@" > "$d/out" 2>&1 RC=$? } +# A tier-2 fixture: a declared LOCAL location holding the baseline keypair. +mkloc() { # mkloc + 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" +} row() { printf '%s\n' "$1" >> "$2/matrix.tsv"; } note() { printf '\n## %s\nfixture note.\n' "$1" >> "$2/notes.md"; } @@ -206,13 +214,18 @@ && ok "T20 a filename='-' row (credential with no host artifact) -> skipped, not faulted" \ || { no "T20 no-artifact rows must be skipped (rc=$RC)"; sed 's/^/ /' "$D/out"; } -# T21 -- an unbuilt tier must announce itself. A silent pass is the exact false-green -# this design exists to prevent. -D=$(mkenv); run "$D" --all -[ "$RC" = 0 ] && grep -q 'tier 2 (EXISTENCE) NOT BUILT' "$D/out" \ - && grep -q 'tier 3 (VALIDITY) NOT BUILT' "$D/out" \ - && ok "T21 --all names tiers 2 and 3 as NOT BUILT -> no silent pass" \ - || { no "T21 unbuilt tiers must self-announce (rc=$RC)"; sed 's/^/ /' "$D/out"; } +# T21 -- a tier that did not run must announce itself. A silent pass is the exact +# false-green this design exists to prevent. Tier 2 is built, so --all RUNS it and only +# tier 3 self-announces; a default run announces tier 2 as NOT RUN. +D=$(mkenv); mkloc "$D"; run "$D" --all +[ "$RC" = 0 ] && grep -q 'tier 3 (VALIDITY) NOT BUILT' "$D/out" \ + && grep -q 'tier 2 (EXISTENCE)' "$D/out" \ + && ok "T21a --all runs tier 2 and names tier 3 NOT BUILT -> no silent pass" \ + || { no "T21a --all tier reporting (rc=$RC)"; sed 's/^/ /' "$D/out"; } +D=$(mkenv); mkloc "$D"; run "$D" +[ "$RC" = 0 ] && grep -q 'tier 2 (EXISTENCE) NOT RUN' "$D/out" \ + && ok "T21b a default run announces tier 2 as NOT RUN -> never a silent skip" \ + || { no "T21b default run must announce tier 2 (rc=$RC)"; sed 's/^/ /' "$D/out"; } # T22 -- METADATA ONLY. The checker must never gain a content-reading verb; it inspects # existence, mode, and declared provenance. Mirrors tests/creds-audit/run-tests.sh:71-73. @@ -236,6 +249,67 @@ && 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"; } +# ---- tier 2 (EXISTENCE) --------------------------------------------------------------- + +# T25 -- the tier-2 baseline: everything expected is present at its declared location. +D=$(mkenv); mkloc "$D"; run "$D" --tier2 +[ "$RC" = 0 ] && grep -q 'E1/E3 existence' "$D/out" \ + && ok "T25 every expected artifact present at a declared location -> tier 2 clean" \ + || { no "T25 tier-2 baseline must pass (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T26 -- the register's whole reason for existing: a credential that SHOULD be there and +# is not. Absence is invisible to a discovery sweep; only an expected-state register sees it. +D=$(mkenv); mkloc "$D"; rm "$D/store/alpha_ed25519"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "E1 EXPECTED-BUT-ABSENT: alpha-key 'alpha_ed25519'" "$D/out" \ + && ok "T26 an expected credential missing from disk -> E1 EXPECTED-BUT-ABSENT" \ + || { no "T26 absence must be detectable (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T27 -- a credential whose mint-stage has not been reached is DEFERRED, not failed -- +# and the caller declares that, never the script (GA-R1: no status claim lives in code). +D=$(mkenv); mkloc "$D"; rm "$D/store/alpha_ed25519"; run "$D" --tier2 --pending-stage stage2 +[ "$RC" = 0 ] && grep -q 'deferred as not-yet-minted' "$D/out" \ + && ok "T27 --pending-stage defers a not-yet-minted credential instead of failing it" \ + || { no "T27 pending-stage deferral (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T28 -- the SEC-020/-022 class from the other side: a real secret sitting at a declared +# location that no matrix row accounts for. +D=$(mkenv); mkloc "$D"; printf 'x\n' > "$D/store/stray.apikey"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "E3 UNDECLARED: 'stray.apikey'" "$D/out" \ + && ok "T28 an undeclared secret at a declared location -> E3 UNDECLARED" \ + || { no "T28 undeclared detection (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T29 -- a group-readable private key is an exposure, not a cosmetic difference. +D=$(mkenv); mkloc "$D"; chmod 640 "$D/store/alpha_ed25519"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'E2 MODE' "$D/out" \ + && ok "T29 a private key at the wrong mode -> E2 MODE FAIL" \ + || { no "T29 mode enforcement (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T30 -- ruling 3 is absolute: with no declared-location list there is no bound, so +# discovery must REFUSE to run rather than fall back to walking anything. +D=$(mkenv); mkloc "$D"; rm "$D/locations"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q 'no declared-location list' "$D/out" \ + && ok "T30 a missing locations list -> tier 2 REFUSES to run (ruling 3 is absolute)" \ + || { no "T30 must refuse unbounded discovery (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# 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" +run "$D" --tier2 +[ "$RC" = 0 ] && grep -q 'remote location(s) SKIPPED' "$D/out" \ + && ok "T31 remote locations omitted without --remote -> announced, not silently dropped" \ + || { no "T31 remote omission must be visible (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# 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" +run "$D" --tier2 --remote +[ "$RC" = 0 ] && grep -q 'UNREACHABLE' "$D/out" \ + && grep -q 'never a pass' "$D/out" \ + && ok "T32 an unreachable host -> explicit UNREACHABLE skip, never counted as clean" \ + || { no "T32 unreachable must skip loudly (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