diff --git a/creds-manifests/host-identity b/creds-manifests/host-identity new file mode 100644 index 0000000..c70c910 --- /dev/null +++ b/creds-manifests/host-identity @@ -0,0 +1,37 @@ +# host-identity -- which physical host each host-ROLE's `local` locations live on (D-137 tier 2). +# +# WHY THIS FILE EXISTS. `creds-manifests/vm-secret-locations` declares a location as +# ` `, and an ssh-target of `local` means "probe the +# filesystem of the machine this checker is running on". That is correct ONLY when the checker +# happens to be running on the host the role denotes. It is not self-checking, and the failure +# is silent and severe: +# +# MEASURED 2026-07-29. Running P5 on the Office1 headend reported 34 findings against the +# 7 the same tree produces on the jumphost. The 26 extra were pure artefact: every +# `jumphost ... local ... ~/vr1-dcN-creds/*` location was probed against voffice1's +# filesystem, which has its OWN `~/vr1-dcN-creds/` directories -- the SEC-022 headend shadow +# stores. So the checker compared the jumphost's expected credential set against a +# DIFFERENT host's directories and called the mismatches findings. +# +# That is the "could not look is never nothing there" rule inverted into something worse: +# it looked in the wrong place and reported the result as fact. Combined with P3/P4 being +# runnable ONLY on the headend (vcloud has no juju/maas/openstack binaries), the consequence +# was that NO SINGLE HOST produced a correct full preflight verdict. +# +# WHAT THE CHECKER DOES WITH THIS. For every location whose ssh-target is `local`, it compares +# this file's declared host for that role against the machine's own hostname. On a mismatch it +# does NOT probe: the scope is marked NOT JUDGED, exactly like an unreachable remote, so +# absence can never be asserted over it. On a match it probes as before. +# +# EXHAUSTIVE BY CONSTRUCTION. Every host-role that uses a `local` target in +# vm-secret-locations must appear here exactly once, and a role declared here but never used +# locally is a stale declaration. The checker FAILS on either rather than defaulting -- the +# same discipline as `stages-reached`, and for the same reason: both defaults are wrong in a +# dangerous direction. +# +# FORMAT. Whitespace-separated: +# hostname is compared against the machine's own `hostname` (socket.gethostname()), short +# form, case-insensitive. It is an IDENTITY, never a path or an ssh target. +# `#` comments and blank lines ignored. + +jumphost vcloud diff --git a/creds-manifests/vm-secret-locations b/creds-manifests/vm-secret-locations index 82107e0..e86d2b2 100644 --- a/creds-manifests/vm-secret-locations +++ b/creds-manifests/vm-secret-locations @@ -52,12 +52,13 @@ # --- 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 +# Both the overlay and the workspace are declared PER DC below; the old shared-scope +# `~/octavia-pki/*` and `/overlays/octavia-pki.yaml` entries were retired with the +# F1 rename of 2026-07-29 and would now match nothing. # 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 vr1-office1 local /opentofu/terraform.tfstate @@ -71,9 +72,19 @@ # job, not the matrix's. Recorded here so the omission is a decision, not an oversight. # 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/* +# Octavia PKI is PER-DC since 2026-07-29 (D-109 amendment / R7, F1). Each DC is a separate +# trust domain on disk, so the locations are declared per DC rather than as one shared pool: +# both DCs' workspaces contain identically-named basenames, and a shared `-` scope would +# collapse them into one namespace where a missing dc1 file is indistinguishable from a +# present dc0 one -- reintroducing exactly the blindness F2 removed from the matrix. +jumphost vr1-dc0 local ~/octavia-pki/vr1-dc0/issuing-ca/* +jumphost vr1-dc0 local ~/octavia-pki/vr1-dc0/controller-ca/* +jumphost vr1-dc0 local ~/octavia-pki/vr1-dc0/controller/* +jumphost vr1-dc0 local /overlays/vr1-dc0-octavia-pki.yaml +jumphost vr1-dc1 local ~/octavia-pki/vr1-dc1/issuing-ca/* +jumphost vr1-dc1 local ~/octavia-pki/vr1-dc1/controller-ca/* +jumphost vr1-dc1 local ~/octavia-pki/vr1-dc1/controller/* +jumphost vr1-dc1 local /overlays/vr1-dc1-octavia-pki.yaml # Predicted Stage-5 exposure (SEC-023): phase-03 writes $HOME/admin-openrc. jumphost - local ~/admin-openrc diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55d57dc..6d31ab2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2011,6 +2011,38 @@ DECLARATION. The register carries logical keys only and no values by its own design, and this edit added none, so it was completed with the file-edit tool rather than by circumventing the guard. + **F6 (HIGH) FOUND AND FIXED 2026-07-29 -- P5 was measuring the WRONG HOST'S FILESYSTEM and + reporting the result as fact.** This is what made the Stage-5 entry gate unreadable, and it is + worse than the "could not look is never nothing there" class the repo already knows: it looked + in the wrong place and asserted the answer. ROOT CAUSE: `creds-manifests/vm-secret-locations` + declares jumphost paths with ssh-target `local`, implemented as "the filesystem of whatever + machine this run is on". Run on the headend, every `jumphost ... ~/vr1-dcN-creds/*` location + was probed against **voffice1's OWN `~/vr1-dcN-creds/` directories -- the SEC-022 headend + shadow stores** -- so the jumphost's expected credential set was compared against a different + host's directories and the mismatches reported as findings. Measured: **34 findings on + voffice1 against the same tree's true 7 on vcloud, 26 pure artefact.** Combined with P3/P4 + being runnable ONLY on the headend (vcloud has no `juju`/`maas`/`openstack`), **no single host + produced a correct full preflight verdict** -- the entry gate for the deploy stage had no + trustworthy reading anywhere. + FIX: a declared binding `creds-manifests/host-identity` (role -> hostname), **exhaustive by + construction exactly like `stages-reached`** -- a role using `local` that is unbound FAILS, + and a bound role using no `local` target is a stale declaration; neither is defaulted, + because both defaults are wrong in a dangerous direction. On a hostname mismatch the location + is NOT PROBED and its scope joins the existing NOT-JUDGED set, so absence is never asserted + over it. **DEMONSTRATED BOTH WAYS plus all four refusal paths:** on the declared host the run + is unchanged at 91 rows / 7 findings (no regression); on a simulated headend run 14 locations + report NOT PROBED instead of manufacturing findings; absent-binding, unbound-role, + stale-declaration and malformed-line each REFUSE. Harness **65/65** (was 60), cases T60-T64 -- + **T60 exists specifically so T61 proves something**, since a gate that never probes would + otherwise always "pass". **THE HARNESS HAD THE SAME BUG AS THE THING IT TESTS:** its fixtures + fell through to the REAL repo's binding file -- the identical trap that once left V2 with zero + cases -- so `run()` now derives a fixture-scoped binding the way it already derives `stages`. + **ALSO FIXED, a coupling F1 created:** `vm-secret-locations` still pointed at the pre-rename + `~/octavia-pki/*` and `/overlays/octavia-pki.yaml`, which now match nothing. The Octavia + locations are declared PER DC -- deliberately not as one shared `-` scope, because both DCs' + workspaces hold identically-named basenames and a shared scope would collapse them into one + namespace where a missing dc1 file is indistinguishable from a present dc0 one, reintroducing + precisely the blindness F2 had just removed. **F3 -- both `~/octavia-pki/` and `overlays/octavia-pki.yaml` are ABSENT here** (existence checked, no contents read). So this is generation FROM SCRATCH for both DCs: there is nothing to reuse, which retires the reuse-vs-regenerate choice diff --git a/scripts/creds-matrix.py b/scripts/creds-matrix.py index dc95a92..2f7a202 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -35,7 +35,7 @@ --pending-stage vr0-phase01 ... # the full three-site existence sweep Exit: 0 clean, 1 findings, 2 usage/IO error. ASCII-only output. """ -import sys, os, re, glob, shlex, fnmatch, argparse, subprocess +import sys, os, re, glob, shlex, fnmatch, argparse, subprocess, socket COLUMNS = ("id", "cardinality", "site-key", "host-role", "filename", "access-type", "principal", "custody", "mint-stage", "mint-ref", "sec-ref", "notes-ref") @@ -486,6 +486,47 @@ return out +def load_host_identity(path, locations, fails): + """Which physical host each role's `local` locations live on. Returns {role: host} or None. + + An ssh-target of `local` means "this machine", which is only correct when the checker runs + on the host the ROLE denotes. Measured 2026-07-29: running on the headend probed the + jumphost's declared paths against the headend's own SEC-022 shadow stores and reported 26 + artefact findings as fact. Absence asserted over the wrong filesystem is worse than + "could not look" -- so the binding is declared, and unbound roles REFUSE. + + EXHAUSTIVE BY CONSTRUCTION, like load_stages: every role using a `local` target must be + declared, and a declared role never used locally is a stale declaration.""" + 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) != 2: + fails.append("host-identity:%d must be ' ', got: %s" + % (n, text[:50])); continue + role, host = parts + if role not in HOST_ROLE: + fails.append("host-identity:%d host-role '%s' invalid (must be a role token, " + "never a VM name)" % (n, role)); continue + if role in out: + fails.append("host-identity:%d duplicate declaration for role '%s'" + % (n, role)); continue + out[role] = host + local_roles = {role for role, _s, target, _p in locations if target == "local"} + for role in sorted(local_roles - set(out)): + fails.append("host-identity: role '%s' uses a `local` target in vm-secret-locations " + "but is NOT bound to a host -- bind it; the checker will not assume " + "that the machine it runs on is that role's host" % role) + for role in sorted(set(out) - local_roles): + fails.append("host-identity: role '%s' is bound to a host but uses no `local` " + "target -- stale declaration" % role) + return out + + def load_locations(path, repo, fails): """The ruling-3 declared-location list. Returns [(role, site, ssh_target, path)], or None if the file is ABSENT.""" @@ -627,7 +668,7 @@ def tier2_existence(rows, locations, use_remote, use_privileged, stages, - oks, fails): + host_identity, this_host, 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).""" # SCOPE = (host-role, site-key), NOT host-role alone. Keying on role alone merged all @@ -671,6 +712,26 @@ "unreadable with sudo -n (%s) -- still no conclusion " "about its contents" % (role, pat, pstate)) else: + # `local` is only meaningful ON the host the ROLE denotes. Probing it anywhere + # else compares this role's expected set against a DIFFERENT machine's + # filesystem and reports the mismatches as findings -- measured 2026-07-29 as + # 26 artefact findings when P5 ran on the headend instead of the jumphost. + # An unbound role is NOT probed either: the checker never assumes the machine + # it happens to run on is the right one. + declared = (host_identity or {}).get(role) + if declared is None: + incomplete.add(scope) + oks.append("E0 %s location '%s' NOT PROBED -- role '%s' has no host binding " + "in creds-manifests/host-identity, so `local` cannot be resolved; " + "absence is not asserted over it" % (role, pat, role)) + continue + if declared.split(".")[0].lower() != this_host.split(".")[0].lower(): + incomplete.add(scope) + oks.append("E0 %s location '%s' NOT PROBED -- role '%s' lives on '%s' and " + "this run is on '%s'; probing it here would measure a different " + "host's filesystem, so absence is not asserted over it" + % (role, pat, role, declared, this_host)) + continue state, seen = probe_local(pat) # "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. @@ -1004,6 +1065,11 @@ 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("--host-identity", + default=os.path.join(repo, "creds-manifests", "host-identity"), + help="role -> hostname binding for `local` locations (D-137 tier 2)") + ap.add_argument("--this-host", default=None, + help="override the detected hostname (harness fixtures only)") # V2 reads the SEC register. It was derived from --repo, which the harness must point at # the REAL repo for S4 mint-ref resolution -- so V2 was the one check with no fixture # path and therefore shipped with ZERO harness cases. This override is what makes it @@ -1078,8 +1144,17 @@ "cannot tell a MISSING credential from a NOT-YET-MINTED one, " "and it will not guess" % args.stages) stages = {} + host_identity = load_host_identity(args.host_identity, locations, fails) + if host_identity is None: + fails.append("tier 2: no host-identity declaration at %s -- without it the " + "checker cannot tell whether a `local` location belongs to the " + "machine it is running on, and it will not guess" + % args.host_identity) + host_identity = {} + this_host = args.this_host or socket.gethostname() + print(" (host: %s)" % this_host) observed = tier2_existence(rows, locations, args.remote, args.privileged, - stages, oks, fails) + stages, host_identity, this_host, oks, fails) if args.tier3 or args.all: print("=== creds-matrix: tier 3 (VALIDITY) ===") v2_declared_state(rows, load_sec_states(args.ledger), oks, fails) diff --git a/tests/creds-matrix/run-tests.sh b/tests/creds-matrix/run-tests.sh index 0d63218..aa7d218 100644 --- a/tests/creds-matrix/run-tests.sh +++ b/tests/creds-matrix/run-tests.sh @@ -51,9 +51,19 @@ # tier-3 cases read the REAL docs/security-ledger.md (because --repo must point at the real # repo for S4 mint-ref resolution), which is why V2 had no harness cases at all. [ -f "$d/ledger.md" ] || : > "$d/ledger.md" + # Derive the host-identity binding from THIS fixture's locations, same reasoning as stages: + # it must be exhaustive over the roles that use a `local` target and carry no unused role, + # so it has to track the fixture. WITHOUT this the fixtures fell through to the REAL repo's + # binding (--repo must point at the real repo for S4 mint-ref resolution) -- the identical + # trap that left V2 with no cases at all. Fixture host is a fixed token; cases testing the + # wrong-host path pass their own --this-host. + [ -f "$d/host-identity" ] || awk '!/^#/ && NF==4 && $3=="local" {print $1" fixturehost"}' \ + "$d/locations" | sort -u > "$d/host-identity" python3 "$CHECK" --matrix "$d/matrix.tsv" --manifest-dir "$d/creds-manifests" \ --notes "$d/notes.md" --repo "$REPO" --stages "$d/stages" \ - --locations "$d/locations" --ledger "$d/ledger.md" "$@" > "$d/out" 2>&1 + --locations "$d/locations" --ledger "$d/ledger.md" \ + --host-identity "$d/host-identity" --this-host "${THIS_HOST:-fixturehost}" \ + "$@" > "$d/out" 2>&1 RC=$? } # A tier-2 fixture: a declared LOCAL location holding the baseline keypair. @@ -629,6 +639,47 @@ && ok "T59 a deferral claim spanning two ledger cells does NOT count as ruled" \ || { no "T59 the deferral match must not cross a table cell (rc=$RC)"; sed 's/^/ /' "$D/out"; } +# --- host-identity: `local` is only meaningful ON the host the ROLE denotes ------------ +# Measured 2026-07-29: running P5 on the headend probed the JUMPHOST's declared paths against +# the headend's own SEC-022 shadow stores and reported 26 artefact findings as fact. Absence +# asserted over the wrong filesystem is worse than "could not look", so these cases pin it. + +# T60 -- the baseline is on the RIGHT host, so the location IS probed and the run is clean. +# Without this case T61 would prove nothing: a gate that never probes always "passes". +D=$(mkenv); mkloc "$D"; run "$D" --tier2 +[ "$RC" = 0 ] && ! grep -q 'NOT PROBED' "$D/out" \ + && ok "T60 on the declared host the local location IS probed" \ + || { no "T60 baseline must probe on the right host (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T61 -- the defect itself: on any OTHER host the location must NOT be probed, and absence +# must not be asserted over it. +D=$(mkenv); mkloc "$D"; THIS_HOST=someotherhost run "$D" --tier2 +grep -q 'NOT PROBED' "$D/out" && grep -q "this run is on 'someotherhost'" "$D/out" \ + && ok "T61 on a different host the local location is NOT PROBED, absence not asserted" \ + || { no "T61 a wrong-host local probe must be refused, not measured"; sed 's/^/ /' "$D/out"; } + +# T62 -- an ABSENT binding file REFUSES. "Could not look" is never "nothing there", and the +# checker must not fall back to assuming it is running on the right host. +D=$(mkenv); mkloc "$D"; run "$D" --tier2 --host-identity "$D/nonexistent" +[ "$RC" = 1 ] && grep -q 'no host-identity declaration' "$D/out" \ + && ok "T62 a missing host-identity declaration REFUSES rather than guessing" \ + || { no "T62 missing host-identity must refuse (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T63 -- a role that uses `local` but is UNBOUND is a hard fail, never a default. +D=$(mkenv); mkloc "$D"; printf '# deliberately empty\n' > "$D/host-identity"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "uses a \`local\` target .* but is NOT bound" "$D/out" \ + && ok "T63 an unbound role using a local target FAILS, and is not defaulted" \ + || { no "T63 unbound local role must fail (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T64 -- exhaustive in the other direction too: a bound role that uses no `local` target is a +# stale declaration. Same discipline as stages-reached, and the reason it is checked is that a +# stale binding silently survives the removal of the location it described. +D=$(mkenv); mkloc "$D" +printf 'jumphost fixturehost\nedge ghosthost\n' > "$D/host-identity"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "role 'edge' is bound to a host but uses no \`local\` target" "$D/out" \ + && ok "T64 a bound role with no local target is reported as a stale declaration" \ + || { no "T64 stale host binding must fail (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