#!/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 <token> -> 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 >/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