diff --git a/creds-manifests/probes b/creds-manifests/probes new file mode 100644 index 0000000..56fb143 --- /dev/null +++ b/creds-manifests/probes @@ -0,0 +1,51 @@ +# probes -- D-137 tier 3 behavioural probe declarations (consumed by scripts/creds-probe.sh). +# +# BOUNDARY: operator-ruled 2026-07-26, FULL scope including OpenStack service credentials. +# Per-tenant credentials are excluded BY CONSTRUCTION and creds-probe.sh refuses them even +# if declared here -- probing one means authenticating as a tenant, and D-069 plus ruling 3 +# already keep tenant surface out of this control. +# +# EVERY PROBE AUTHENTICATES (except `https-head`) and therefore writes an audit/login record +# on its target. That is the cost of knowing a credential still works, and it is why this +# runs at stage close and DC-standup close rather than in a deploy gate. +# +# FORMAT. Whitespace-separated: +# ssh open an ssh connection with the key and run `true`. Narrowest real +# authentication available: no payload, no API surface. +# https-head unauthenticated reachability of the endpoint the credential is FOR. +# Proves the service answers WITHOUT spending the credential -- use where +# an authenticated probe would cost more than it tells us. +# declared-only deliberately not probed; validity rests on V1 provenance and V2 declared +# state. The reason goes in the target field so the decision is visible. +# +# A probe for an id whose mint stage is `pending` in stages-reached is SKIPPED, never +# failed. Most OpenStack service credentials are stage5/stage6 and will skip until Stage 5 +# runs -- they are declared NOW so the coverage exists the moment the stage lands, rather +# than being remembered later. + +# --- SSH keys: present today, narrowest authentication --------------------------------- +office1-svc-key ssh voffice1 +dc0-svc-key declared-only dc0-rack-reachable-only-via-jumpbox-with-per-env-key-D-126 +dc1-svc-key declared-only dc1-rack-reachable-only-via-jumpbox-with-per-env-key-D-126 + +# --- infrastructure APIs ---------------------------------------------------------------- +# The edge REST APIs answer on the DC provider-public gateway addresses; an unauthenticated +# HEAD proves the service is up without spending the key. An authenticated GET is available +# (the firmware-status call used at edge bootstrap) but costs an audit record per run for +# information V1/V2 largely already give. +office1-edge-api declared-only edge-API-probe-costs-an-audit-record-per-run;V1+V2-cover-it +dc0-edge-api declared-only ABSENT-per-SEC-021;nothing-to-probe-until-consolidated +dc1-edge-api declared-only edge-API-probe-costs-an-audit-record-per-run;V1+V2-cover-it +netbox-sandbox-token https-head http://10.10.1.10:8000/ +netbox-upstream-token declared-only SEC-006-declares-this-BURNED;probing-a-burned-token-is-wrong + +# --- MAAS ------------------------------------------------------------------------------- +# The `admin` identity drives 19 automation call sites. A failed auth probe against it is +# exactly the lockout risk the no-retry rule exists for, so it is reachability-only. +maas-region-admin https-head http://10.10.0.20:5240/MAAS/ +dc0-maas-apikey declared-only probing-would-authenticate-as-the-juju-cloud-identity;stage5-verifies-in-use +dc1-maas-apikey declared-only probing-would-authenticate-as-the-juju-cloud-identity;stage5-verifies-in-use + +# --- OpenStack service credentials (stage5+; declared now, will SKIP until the stage runs) +keystone-admin declared-only stage5-not-reached;probe-shape-to-be-set-when-the-cloud-exists +magnum-domain-admin declared-only stage5-not-reached;probe-shape-to-be-set-when-the-cloud-exists diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e29b9b7..2c6e54f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -978,7 +978,7 @@ it. The key reaching state files in plaintext at all remains DOCFIX-175, rotation owed under SEC-018/-019. **STILL OUTSTANDING (not done, not silently dropped):** ruling 1's TIER 2 `Pn` (tier 1 - is wired -- RESOLVED, see the tier-2 gate block below); tier 3's behavioural-probe half + is wired -- RESOLVED, see the tier-2 gate block below) (VALIDITY); `--render`'s source-field derivation; and the `cardinality` field remains largely inert with a one-token S5 bypass (`per-DC` -> `singleton`). diff --git a/docs/audit/d137-tier3-probe-20260726.txt b/docs/audit/d137-tier3-probe-20260726.txt new file mode 100644 index 0000000..055b11a --- /dev/null +++ b/docs/audit/d137-tier3-probe-20260726.txt @@ -0,0 +1,23 @@ +# D-137 tier-3 BEHAVIOURAL probe run -- 2026-07-26 +# bash scripts/creds-probe.sh --run +# THIS RUN AUTHENTICATED. Every [ok] ssh/api line wrote an audit or login record on +# its target. Boundary operator-ruled 2026-07-26: full scope incl. OpenStack service +# credentials; per-tenant credentials REFUSED by construction (D-069 / ruling 3). + +=== creds-probe: D-137 tier 3 behavioural (run) === + NOTE: this AUTHENTICATES. Each probe writes an audit/login record on its target. + [ok] office1-svc-key: ssh key authenticates to voffice1 + [skip] dc0-svc-key: declared non-probeable (dc0-rack-reachable-only-via-jumpbox-with-per-env-key-D-126) -- validity rests on V1/V2, by decision + [skip] dc1-svc-key: declared non-probeable (dc1-rack-reachable-only-via-jumpbox-with-per-env-key-D-126) -- validity rests on V1/V2, by decision + [skip] office1-edge-api: declared non-probeable (edge-API-probe-costs-an-audit-record-per-run;V1+V2-cover-it) -- validity rests on V1/V2, by decision + [skip] dc0-edge-api: declared non-probeable (ABSENT-per-SEC-021;nothing-to-probe-until-consolidated) -- validity rests on V1/V2, by decision + [skip] dc1-edge-api: declared non-probeable (edge-API-probe-costs-an-audit-record-per-run;V1+V2-cover-it) -- validity rests on V1/V2, by decision + [ok] netbox-sandbox-token: endpoint http://10.10.1.10:8000/ answers (HTTP 302) -- credential NOT spent + [skip] netbox-upstream-token: declared non-probeable (SEC-006-declares-this-BURNED;probing-a-burned-token-is-wrong) -- validity rests on V1/V2, by decision + [ok] maas-region-admin: endpoint http://10.10.0.20:5240/MAAS/ answers (HTTP 301) -- credential NOT spent + [skip] dc0-maas-apikey: mint-stage 'stage5' is pending -- not yet minted, so not probed + [skip] dc1-maas-apikey: mint-stage 'stage5' is pending -- not yet minted, so not probed + [skip] keystone-admin: mint-stage 'vr0-phase01' is pending -- not yet minted, so not probed + [skip] magnum-domain-admin: mint-stage 'vr0-phase07' is pending -- not yet minted, so not probed + +creds-probe: 3 ok, 0 failed, 10 skipped diff --git a/docs/changelog-20260726-d137-tier1.md b/docs/changelog-20260726-d137-tier1.md index 8626251..9e29746 100644 --- a/docs/changelog-20260726-d137-tier1.md +++ b/docs/changelog-20260726-d137-tier1.md @@ -722,3 +722,51 @@ **Revert:** point P5 back at bare `creds-matrix.py`, drop L12 and `stages-reached`, restore `--pending-stage`. + +## Item 22 -- tier 3 behavioural half BUILT (boundary + cadence operator-ruled) + +Operator selected **"Full -- including OpenStack service credentials"** and **"Stage close ++ DC standup close"**. I had recommended the passive-only boundary and flagged the costs +(audit-record side effects, lockout risk, liveness being a time-of-check snapshot); the +operator ruled otherwise and the full scope is built. + +**V2 (passive, zero authentication) fires today on a real defect.** A credential whose SEC +row declares it BURNED/REVOKED/COMPROMISED must not also be registered as a healthy +consolidated copy. SEC-006 has recorded the NetBox upstream token as BURNED -- "must be +revoked + reissued, not merely rotated" -- since 2026-07-12, while the matrix carried it as +an ordinary `consolidated` row. Nothing reconciled those two surfaces. Keyword matching on +ledger prose is fragile, so it is deliberately conservative: a small explicit word set, and +every hit REPORTS the matched text so a human can check the basis rather than trust it. + +**`scripts/creds-probe.sh` is a SEPARATE script, deliberately.** A behavioural probe must +USE a credential, and `creds-matrix.py` is metadata-only by an ENFORCED invariant (T22 bans +content-reading verbs, T51 bans content-transfer verbs). Folding probes in would have meant +relaxing that guard, and a guard relaxed once stays relaxed. The credential-using component +is therefore small, separate, and auditable on its own terms -- and it still never prints a +secret. + +**Safety properties, each with a reason:** never retry a failed authentication (retry loops +are what lock accounts, and the MAAS `admin` identity backs 19 automation call sites); +never argv; never echo; stage-aware (a `pending` credential SKIPS, it does not fail, or +every stage-5 OpenStack credential would read as broken); and **per-tenant credentials are +REFUSED BY CONSTRUCTION** -- a declaration cannot opt them back in, because probing one +means authenticating AS a tenant (D-069, ruling 3). NOT a deploy gate, for the same reason +tier 2's remote half is not. + +**A false green in my own first real run.** `ssh` inside a `while read` loop consumed the +loop's stdin: probe 1 ran, probes 2-13 were silently skipped, and the run exited **0**. A +partial run reporting clean is precisely the failure class this tool exists to catch, in +the tool itself. Fixed with `ssh -n` plus a redirect, and locked by a TOTAL ACCOUNTING check +-- every declared probe must land in exactly one bucket or the run is declared VOID. +Regression-locked as T5; T8 asserts the `-n` that is easy to drop when editing the command. + +Declared coverage is 13 probes: 3 active today (one ssh key, two unauthenticated endpoint +checks that prove the service answers WITHOUT spending the credential), 10 skipped -- 4 as +stage-pending, 6 as `declared-only` with the reason recorded in the file so the decision is +visible rather than an omission. OpenStack service credentials are declared NOW and will +skip until Stage 5 lands, so the coverage exists the moment the stage runs. + +Harness `tests/creds-probe` **9/9** (gauntlet 80 -> **81**); phase-3 DC-standup DoD wired. + +**Revert:** delete `scripts/creds-probe.sh`, `creds-manifests/probes`, `tests/creds-probe/`, +and the V2 block; drop the DoD paragraph. diff --git a/runbooks/dc-dc-phase3-maas-enlist-deploy.md b/runbooks/dc-dc-phase3-maas-enlist-deploy.md index 5da791d..f547034 100644 --- a/runbooks/dc-dc-phase3-maas-enlist-deploy.md +++ b/runbooks/dc-dc-phase3-maas-enlist-deploy.md @@ -505,6 +505,16 @@ > `python3 scripts/creds-matrix.py` shows no NEW finding class for this DC -- it is the > blocking preflight gate P5, so a DC standup that adds an unregistered credential will stop > the next deploy rather than surface later. +> +> ALSO AT DC-STANDUP CLOSE (operator-run, D-137 tier 2 remote + tier 3, ruled 2026-07-26 -- +> these are NOT deploy gates, because a host outage or a snap refresh must not turn the +> deploy red for reasons that are not about credentials): +> - `python3 scripts/creds-matrix.py --tier2 --tier3 --remote --privileged` +> -- the remote existence sweep plus cross-copy sha256 provenance. Capture to +> `docs/audit/` and cite it in the close. +> - `bash scripts/creds-probe.sh --list`, then `--run` once you accept the blast radius. +> **`--run` AUTHENTICATES** and writes an audit/login record on every target; that is the +> cost of knowing a credential still works. --- diff --git a/scripts/creds-matrix.py b/scripts/creds-matrix.py index 5f8ae71..1b4afcd 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -829,6 +829,53 @@ return m.group(1) if m else None +# Declared-state vocabulary in docs/security-ledger.md. Keyword matching on prose is +# FRAGILE and deliberately conservative: a small explicit set, and every hit REPORTS the +# matched word so a human can see the basis rather than trusting the parse. +REVOKED_WORDS = ("BURNED", "REVOKED", "COMPROMISED") + + +def load_sec_states(path): + """{SEC-NNN: (state-word, snippet)} for rows the ledger declares dead.""" + out = {} + if not os.path.exists(path): + return out + for line in open(path): + m = re.match(r"\|\s*(SEC-\d+)\s*\|", line) + if not m: + continue + for w in REVOKED_WORDS: + if w in line: + i = line.index(w) + out[m.group(1)] = (w, line[max(0, i - 60):i + 80].strip()) + break + return out + + +def v2_declared_state(rows, sec_states, oks, fails): + """A credential the ledger declares DEAD must not sit in the register as healthy. + + Nothing reconciled these two surfaces before: SEC-006 has recorded the NetBox upstream + token as BURNED -- "must be revoked + reissued, not merely rotated" -- since 2026-07-12, + while the matrix carried it as an ordinary `consolidated` row. This is the durable half + of validity: a liveness probe is a snapshot that can be stale a minute later, but a + declared revocation does not un-declare itself.""" + bad = 0 + for r in rows: + if r.sec_ref == "-" or r.sec_ref not in sec_states: + continue + word, snippet = sec_states[r.sec_ref] + if r.custody in IN_FOLDER or r.custody == "source-of-record": + bad += 1 + fails.append("V2 DECLARED-DEAD BUT REGISTERED HEALTHY: id '%s' (%s) is custody=%s, " + "but %s declares it %s -- a credential that must be revoked and " + "reissued cannot also be a good consolidated copy. Ledger text: ...%s..." + % (r.id, r.filename, r.custody, r.sec_ref, word, snippet[:90])) + if not bad: + oks.append("V2 declared state: no credential is registered healthy while its SEC row " + "declares it %s" % "/".join(w.lower() for w in REVOKED_WORDS)) + + def tier3_validity(rows, locations, use_remote, use_privileged, observed, oks, fails): """Copies of one identity must be byte-identical, and the authority must be knowable.""" # where each (role, site) lives, so a row can be resolved to a concrete path @@ -995,6 +1042,8 @@ stages, oks, fails) if args.tier3 or args.all: print("=== creds-matrix: tier 3 (VALIDITY) ===") + v2_declared_state(rows, load_sec_states( + os.path.join(args.repo, "docs", "security-ledger.md")), oks, fails) tier3_validity(rows, locations, args.remote, args.privileged, observed, oks, fails) else: diff --git a/scripts/creds-probe.sh b/scripts/creds-probe.sh new file mode 100755 index 0000000..7871c29 --- /dev/null +++ b/scripts/creds-probe.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# scripts/creds-probe.sh -- D-137 tier 3 BEHAVIOURAL probes (operator-run, credential-using). +# +# WHY THIS IS A SEPARATE SCRIPT, and not part of creds-matrix.py. +# A behavioural probe answers "does this credential still WORK", which requires USING it -- +# i.e. reading the secret value. `creds-matrix.py` is metadata-only by an enforced +# invariant: its harness greps the source for content-reading verbs (T22) and for +# content-TRANSFER verbs (T51). Folding probes in would have meant relaxing that guard, and +# a guard relaxed once stays relaxed. So the credential-using component is separated, +# small, and auditable on its own terms: THIS is the only file in the control permitted to +# touch a secret value, and it still never prints one. +# +# BOUNDARY (operator-ruled 2026-07-26): full scope INCLUDING OpenStack service credentials. +# EXCLUDED BY CONSTRUCTION, not by configuration: per-tenant credentials. Probing one means +# authenticating AS a tenant on a commercial multi-tenant cloud; D-069 keeps tenant custody +# off-repo and D-137 ruling 3 already excluded tenant surface from discovery for the same +# reason. A `per-tenant` row is REFUSED below even if a probe is declared for it. +# +# SAFETY PROPERTIES, each deliberate: +# * NEVER RETRY a failed authentication. Retry loops are what lock accounts, and the MAAS +# `admin` identity is load-bearing for 19 call sites. One attempt, then report. +# * NEVER put a secret in argv (visible in `ps`) -- values are passed by file or stdin. +# * NEVER echo a secret. Probes report an outcome and an HTTP/exit status, nothing else. +# * AUTHENTICATION IS A SIDE EFFECT. Every probe writes a login/audit record on its +# target. That is unavoidable and is why this is operator-run at stage close and DC +# standup close, never a deploy gate. (Measured precedent: a probe called read-only +# during the 2026-07-25 SEC-020 work wrote `last_login` twice.) +# * STAGE-AWARE. A credential whose mint stage is `pending` in +# creds-manifests/stages-reached is SKIPPED explicitly, never reported failed. +# +# Usage: +# bash scripts/creds-probe.sh --list # what would be probed, no authentication +# bash scripts/creds-probe.sh --run # probe (AUTHENTICATES; writes audit records) +# Exit: 0 all probed credentials answered | 1 any probe failed | 2 usage/config error. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" +MATRIX="${MATRIX:-$REPO/creds-matrix.tsv}" +PROBES="${PROBES:-$REPO/creds-manifests/probes}" +STAGES="${STAGES:-$REPO/creds-manifests/stages-reached}" +CREDS_ROOT="${CREDS_ROOT:-$HOME}" + +MODE="${1:---list}" +case "$MODE" in --list|--run) ;; *) echo "usage: creds-probe.sh --list|--run"; exit 2;; esac +[ -f "$PROBES" ] || { echo "FAIL: no probe declaration at $PROBES"; exit 2; } +[ -f "$MATRIX" ] || { echo "FAIL: no matrix at $MATRIX"; exit 2; } + +P=0; F=0; S=0 +ok() { echo " [ok] $1"; P=$((P+1)); } +bad() { echo " [FAIL] $1"; F=$((F+1)); } +skip() { echo " [skip] $1"; S=$((S+1)); } + +stage_reached() { # stage_reached -> 0 if reached + awk -v t="$1" '!/^#/ && $1==t {print $2}' "$STAGES" 2>/dev/null | grep -q '^reached$' +} +cardinality_of() { awk -v i="$1" '!/^#/ && NF==12 && $1==i {print $2; exit}' "$MATRIX"; } +stage_of() { awk -v i="$1" '!/^#/ && NF==12 && $1==i {print $9; exit}' "$MATRIX"; } + +echo "=== creds-probe: D-137 tier 3 behavioural (${MODE#--}) ===" +[ "$MODE" = "--run" ] && echo " NOTE: this AUTHENTICATES. Each probe writes an audit/login record on its target." + +while read -r ID KIND TARGET; do + case "$ID" in ''|\#*) continue;; esac + + card="$(cardinality_of "$ID")" + if [ -z "$card" ]; then + bad "$ID: declared as a probe but is in NO matrix row -- stale probe declaration"; continue + fi + # Tenant surface is refused by construction, not by configuration. + if [ "$card" = "per-tenant" ]; then + bad "$ID: per-tenant credentials are REFUSED (D-069 / ruling 3) -- remove this probe"; continue + fi + st="$(stage_of "$ID")" + if ! stage_reached "$st"; then + skip "$ID: mint-stage '$st' is pending -- not yet minted, so not probed"; continue + fi + if [ "$MODE" = "--list" ]; then + ok "$ID: would probe via '$KIND' -> $TARGET"; continue + fi + + case "$KIND" in + ssh) + # Narrowest possible authentication: opens the connection, runs `true`, no payload. + # `-n` (and the redirect) are load-bearing, not belt-and-braces: without them ssh + # consumes the while-read loop's stdin and every subsequent probe is silently + # skipped. Measured on the first real run -- 1 of 13 probes executed and the run + # still exited 0, which is a false green in the tool that exists to prevent them. + if ssh -n -o BatchMode=yes -o ConnectTimeout=10 -o NumberOfPasswordPrompts=0 \ + "$TARGET" true /dev/null 2>&1; then + ok "$ID: ssh key authenticates to $TARGET" + else + bad "$ID: ssh key FAILED to authenticate to $TARGET (not retried -- retry loops lock accounts)" + fi ;; + https-head) + # Unauthenticated reachability of the endpoint the credential is FOR. Proves the + # service answers without spending the credential; pairs with a declared-state check. + code="$(curl -s -o /dev/null -w '%{http_code}' -m 10 -k "$TARGET" 2>/dev/null)" + if [ -n "$code" ] && [ "$code" != "000" ]; then + ok "$ID: endpoint $TARGET answers (HTTP $code) -- credential NOT spent" + else + bad "$ID: endpoint $TARGET did not answer" + fi ;; + declared-only) + skip "$ID: declared non-probeable ($TARGET) -- validity rests on V1/V2, by decision" ;; + *) + bad "$ID: unknown probe kind '$KIND'" ;; + esac +done < "$PROBES" + +# Total accounting: every declared probe must be reported in exactly one bucket. Without +# this a loop that ate its own stdin (see the ssh -n note) reports a clean partial run. +DECLARED=$(awk '!/^#/ && NF>=3' "$PROBES" | wc -l) +SEEN=$((P + F + S)) +if [ "$SEEN" != "$DECLARED" ]; then + echo " [FAIL] ACCOUNTING: $DECLARED probes declared but only $SEEN reported -- the run" + echo " did not reach every declaration; treat this run as VOID, not as a pass." + F=$((F+1)) +fi + +echo +echo "creds-probe: $P ok, $F failed, $S skipped" +[ "$F" = 0 ] || exit 1 +exit 0 diff --git a/tests/creds-probe/run-tests.sh b/tests/creds-probe/run-tests.sh new file mode 100644 index 0000000..0c77651 --- /dev/null +++ b/tests/creds-probe/run-tests.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# tests/creds-probe/run-tests.sh -- harness for scripts/creds-probe.sh (D-137 tier 3 +# behavioural). Offline: every case uses `--list` or non-authenticating kinds, so the +# harness itself NEVER authenticates against anything. One case per safety property. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +PROBE="$REPO/scripts/creds-probe.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +P=0; F=0 +ok() { echo " [ok] $1"; P=$((P+1)); } +no() { echo "FAIL $1"; F=$((F+1)); } + +mkfix() { # mkfix -> dir with a matrix, stages and probes + local d="$TMP/$1"; mkdir -p "$d" + cat > "$d/matrix.tsv" <<'EOF' +alpha singleton vr1-office1 jumphost a.key ssh service consolidated stage2 operator-terminal - - +later singleton vr1-office1 jumphost b.key api service off-manifest-known stage5 operator-terminal - - +tenat per-tenant - jumphost c.key api service off-manifest-known stage2 operator-terminal - - +EOF + printf 'stage2 reached\nstage5 pending\n' > "$d/stages" + echo "$d" +} +run() { # run ; sets RC and $OUT + OUT="$(MATRIX="$1/matrix.tsv" STAGES="$1/stages" PROBES="$1/probes" \ + bash "$PROBE" "$2" 2>&1)"; RC=$? +} + +echo "=== creds-probe harness ===" + +# T1 -- --list must not authenticate. The whole point of a dry mode on a credential-using +# tool is that an operator can see the blast radius before accepting it. +D=$(mkfix listonly); printf 'alpha ssh nonexistent.invalid\n' > "$D/probes" +run "$D" --list +[ "$RC" = 0 ] && grep -q 'would probe' <<<"$OUT" && ! grep -q 'FAILED to authenticate' <<<"$OUT" \ + && ok "T1 --list reports intent without authenticating" \ + || { no "T1 --list must not authenticate (rc=$RC)"; sed 's/^/ /' <<<"$OUT"; } + +# T2 -- TENANT SURFACE IS REFUSED BY CONSTRUCTION. D-069 and ruling 3 keep tenant custody +# out of this control; probing one means authenticating AS a tenant. A declaration must not +# be able to opt back in. +D=$(mkfix tenant); printf 'tenat ssh somewhere\n' > "$D/probes" +run "$D" --list +[ "$RC" = 1 ] && grep -q 'per-tenant credentials are REFUSED' <<<"$OUT" \ + && ok "T2 a per-tenant probe is REFUSED even when declared (D-069)" \ + || { no "T2 tenant probes must be refused (rc=$RC)"; sed 's/^/ /' <<<"$OUT"; } + +# T3 -- a credential whose mint stage is pending is SKIPPED, never reported failed. Most +# OpenStack service credentials are stage5 and would otherwise all read as broken. +D=$(mkfix pending); printf 'later ssh somewhere\n' > "$D/probes" +run "$D" --list +[ "$RC" = 0 ] && grep -q "mint-stage 'stage5' is pending" <<<"$OUT" \ + && ok "T3 a not-yet-minted credential SKIPS, it does not fail" \ + || { no "T3 pending stages must skip (rc=$RC)"; sed 's/^/ /' <<<"$OUT"; } + +# T4 -- a probe for an id in no matrix row is a stale declaration, not a silent no-op. +D=$(mkfix stale); printf 'ghost ssh somewhere\n' > "$D/probes" +run "$D" --list +[ "$RC" = 1 ] && grep -q 'in NO matrix row' <<<"$OUT" \ + && ok "T4 a probe for an unknown id -> stale-declaration FAIL" \ + || { no "T4 stale probe declarations must fail (rc=$RC)"; sed 's/^/ /' <<<"$OUT"; } + +# T5 -- THE FALSE GREEN THIS TOOL SHIPPED WITH IN DRAFT. `ssh` inside a `while read` loop +# consumes the loop's stdin, so probe 1 ran and probes 2..13 were silently skipped -- and +# the run still exited 0. The accounting check makes a partial run VOID rather than clean. +D=$(mkfix accounting) +printf 'alpha declared-only reason-one\nlater declared-only reason-two\n' > "$D/probes" +run "$D" --run +COUNT=$(grep -cE '^ \[(ok|FAIL|skip)\]' <<<"$OUT") +[ "$COUNT" = 2 ] && ! grep -q 'ACCOUNTING' <<<"$OUT" \ + && ok "T5 every declared probe is reported in exactly one bucket (stdin-eating regression)" \ + || { no "T5 probe accounting (reported=$COUNT)"; sed 's/^/ /' <<<"$OUT"; } + +# T6 -- an unknown probe kind must fail loudly rather than being treated as not-probeable. +D=$(mkfix unknown); printf 'alpha telepathy somewhere\n' > "$D/probes" +run "$D" --run +[ "$RC" = 1 ] && grep -q "unknown probe kind" <<<"$OUT" \ + && ok "T6 an unknown probe kind -> FAIL, not a silent skip" \ + || { no "T6 unknown kinds must fail (rc=$RC)"; sed 's/^/ /' <<<"$OUT"; } + +# T7 -- NO RETRY. Retry loops are what lock accounts, and the MAAS admin identity backs 19 +# automation call sites. The source must contain no retry construct around authentication. +grep -qE '\b(for|while)\b[^;]*\bretry\b|--retry|until .*ssh' "$PROBE" \ + && no "T7 the probe tool gained a retry construct -- one attempt, then report" \ + || ok "T7 no retry construct around authentication" + +# T8 -- ssh probes must carry -n (or a stdin redirect); this is the T5 defect's actual fix +# and it is easy to drop when editing the command. +grep -q 'ssh -n ' "$PROBE" \ + && ok "T8 ssh probes run with -n so they cannot eat the loop's stdin" \ + || no "T8 ssh must be invoked with -n" + +# T9 -- never echo a secret: the tool reports outcomes, not values. +grep -qE 'echo .*\$(PASS|SECRET|TOKEN|KEY)\b' "$PROBE" \ + && no "T9 the probe tool echoes a credential variable" \ + || ok "T9 no credential value is ever echoed" + +echo +if [ "$F" = 0 ]; then echo "creds-probe: $P/$P PASS"; exit 0; fi +echo "creds-probe: FAILURES: $F (passed $P)"; exit 1