ci/woodpecker/pr/ci Pipeline was successful
Blocker 2 (secret at rest on error paths): all staging now lives in ONE per-run mktemp -d removed by an EXIT/INT/TERM trap; a curl dying rc=7 mid-run (the reviewer's transport-failure case) leaves nothing behind. M10 pins it against a dying mock in an isolated TMPDIR; trap-removed mutant killed. Blocker 1 (bash -x trace channel, upheld above landed parity because this is the admin-token minter): secrets are assembled FILE-TO-FILE — stage_auth/stage_user take token/password FILE PATHS and build the curl configs with jq --rawfile; the password is generated straight into its staging file; bodies are composed by jq from the template + password file. No secret is ever expanded into a shell word a trace would print. M11 runs a real bash -x and asserts the admin-token value, the minted token value, and any password-shaped 32-char expansion are all absent; expansion mutant killed (measured: the mutant's trace shows '+ PW_VALUE=<32 chars>', the fixed script's trace shows paths only). Header comment corrected to state what is actually true, including the explicit note that detect-platform's gitea_write_auth_config still leaks under -x — that parity gap is now tracked as #1369, opened per review 263 and fred's ruling; issue-comment/pr-review/pr-edit left untouched in this PR.
248 lines
13 KiB
Bash
Executable File
248 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed
|
|
# brain home, no tea, no network, no real credentials.
|
|
#
|
|
# Pins:
|
|
# M1 the seat slot is written from the mint RESPONSE (token, granted scopes,
|
|
# principal), each file mode 600, and the identity check passes.
|
|
# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded;
|
|
# a missing admin token is reported per instance and exits nonzero.
|
|
# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL
|
|
# override MOSAIC_GITEA_URL_<INSTANCE> is honoured.
|
|
# M4 no admin seat configured is a usage error (rc=3), nothing written.
|
|
# M5 the admin token value never appears on stdout or stderr.
|
|
# M6 secrets never touch argv: no Authorization header, no -u user:pass, no
|
|
# inline --data JSON carrying the password, on any curl invocation; auth
|
|
# travels in --config files and bodies in --data @files (#1343 class,
|
|
# rev-security-01 review 259 blocker).
|
|
# M7 the scopes record discriminates: a requested-but-not-granted scope
|
|
# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE,
|
|
# and a mutant writing the requested set fails here (both reviewers).
|
|
# M8 hyphenated instance names resolve their override through the underscore
|
|
# variable, matching seat-logins.sh (SF3).
|
|
# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3),
|
|
# nothing written (framework-PR firewall answer).
|
|
# M10 no secret at rest after ANY exit, including transport failure mid-run
|
|
# (review 263 blocker 2): the staging dir is swept by the trap, so a curl
|
|
# that dies rc=7 on the admin POST leaves nothing behind.
|
|
# M11 a real `bash -x` trace of the whole run contains no secret VALUE —
|
|
# staging appears only as paths (review 263 blocker 1).
|
|
set -euo pipefail
|
|
|
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
|
SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$"
|
|
MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log"
|
|
cleanup() { rm -rf "$SANDBOX"; }
|
|
trap cleanup EXIT
|
|
fail() { echo "FAIL: $*"; exit 1; }
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
TARGET="$SCRIPT_DIR/mint-seat-credential.sh"
|
|
[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test"
|
|
|
|
mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox"
|
|
: > "$CALLS"
|
|
ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491"
|
|
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
|
|
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
|
|
|
|
# A PATH with only the mock bin plus the system tools the script needs, and no tea.
|
|
SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN"
|
|
for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat jq find wc; do
|
|
p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t"
|
|
done
|
|
export PATH="$MOCK_BIN:$SYS_BIN" CALLS
|
|
export MOSAIC_BRAIN_HOME="$BRAIN"
|
|
export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test"
|
|
export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test"
|
|
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
|
|
|
|
# --- mock curl: records method + URL + a REDACTED auth marker, answers minting --
|
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
|
#!/bin/bash
|
|
method=GET; url=""; out=""; wcode=0; auth=""; body=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-X) method="$2"; shift 2 ;;
|
|
-o) out="$2"; shift 2 ;;
|
|
-w) wcode=1; shift 2 ;;
|
|
--config)
|
|
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
|
|
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
|
|
shift 2 ;;
|
|
--data)
|
|
case "$2" in
|
|
@*) body="@file" ;;
|
|
*) body="inline" ;;
|
|
esac
|
|
shift 2 ;;
|
|
-H|-u) shift 2 ;;
|
|
http*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
|
|
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
|
|
case "$method $url" in
|
|
"GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet
|
|
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
|
|
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
|
|
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
|
|
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
|
|
*) emit '{}'; exit 0 ;;
|
|
esac
|
|
EOF
|
|
chmod +x "$MOCK_BIN/curl"
|
|
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock"
|
|
command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH"
|
|
|
|
run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
|
|
|
|
# M4: no admin seat configured.
|
|
rc=$(run newseat)
|
|
[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")"
|
|
grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT"
|
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat"
|
|
|
|
# M9: email domain is required, unset is a usage error, nothing written.
|
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat)
|
|
[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")"
|
|
grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN"
|
|
[ ! -s "$CALLS" ] || fail "M9: API called without an email domain"
|
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain"
|
|
|
|
# M1 + M3 + M5: mint on the single configured instance.
|
|
: > "$CALLS"
|
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
|
|
[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")"
|
|
SLOT="$BRAIN/fleet/agents/newseat/secrets"
|
|
[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response"
|
|
grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response"
|
|
[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong"
|
|
for suf in token scopes principal; do
|
|
m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600"
|
|
done
|
|
grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")"
|
|
grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")"
|
|
if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi
|
|
grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")"
|
|
if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi
|
|
|
|
# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT.
|
|
if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then
|
|
fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response"
|
|
fi
|
|
|
|
# M6: no secret ever travels argv — every call authenticates via --config
|
|
# (token header or user= basic-auth directive) and bodies go as --data @file.
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
*auth=NONE*) fail "M6: unauthenticated call: $line" ;;
|
|
*body=inline*) fail "M6: inline body (secret in argv risk): $line" ;;
|
|
esac
|
|
done < "$CALLS"
|
|
[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")"
|
|
grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")"
|
|
|
|
# M8: hyphenated instance name resolves its override via the underscore variable.
|
|
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
|
|
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
|
|
export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test"
|
|
: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat"
|
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat)
|
|
[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")"
|
|
grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")"
|
|
unset MOSAIC_GITEA_URL_MY_INST
|
|
|
|
# M2: admin token missing for the instance is reported, rc=1, nothing written.
|
|
rm -rf "$BRAIN/fleet/agents/newseat"
|
|
: > "$CALLS"
|
|
rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat)
|
|
[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc"
|
|
grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")"
|
|
[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")"
|
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token"
|
|
|
|
# M10: transport failure mid-run leaves NO secret at rest (review 263 blocker 2).
|
|
# A second mock that dies rc=7 on the admin POST; the trap must sweep the staging dir.
|
|
rm -rf "$BRAIN/fleet/agents/newseat"
|
|
M10_TMP="$SANDBOX/m10-tmp"; mkdir -p "$M10_TMP"
|
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
|
#!/bin/bash
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
http*) echo "$1" >> "${FAIL_URLS:?}"; exit 7 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
exit 7
|
|
EOF
|
|
chmod +x "$MOCK_BIN/curl"
|
|
export FAIL_URLS="$SANDBOX/failed-urls.txt"; : > "$FAIL_URLS"
|
|
BEFORE=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
|
|
rc=$(TMPDIR="$M10_TMP" MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
|
|
[ "$rc" != 0 ] || fail "M10: transport failure reported rc=0"
|
|
AFTER=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
|
|
[ "$AFTER" -le "$BEFORE" ] || fail "M10: staging left at rest after failure: $AFTER dir(s) under $M10_TMP"
|
|
[ -s "$FAIL_URLS" ] || fail "M10: mock never called"
|
|
|
|
# Restore the well-behaved mock for M11.
|
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
|
#!/bin/bash
|
|
method=GET; url=""; out=""; wcode=0; auth=""; body=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-X) method="$2"; shift 2 ;;
|
|
-o) out="$2"; shift 2 ;;
|
|
-w) wcode=1; shift 2 ;;
|
|
--config)
|
|
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
|
|
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
|
|
shift 2 ;;
|
|
--data)
|
|
case "$2" in
|
|
@*) body="@file" ;;
|
|
*) body="inline" ;;
|
|
esac
|
|
shift 2 ;;
|
|
-H|-u) shift 2 ;;
|
|
http*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
|
|
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
|
|
case "$method $url" in
|
|
"GET "*/api/v1/users/newseat) exit 22 ;;
|
|
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
|
|
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
|
|
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
|
|
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
|
|
*) emit '{}'; exit 0 ;;
|
|
esac
|
|
EOF
|
|
chmod +x "$MOCK_BIN/curl"
|
|
unset FAIL_URLS
|
|
|
|
# M11: a real bash -x trace of a full mint contains no secret VALUE (review 263
|
|
# blocker 1). Secrets are generated straight into staging files and moved only
|
|
# by jq reads, so only staging PATHS may appear. The mock above has no secrets,
|
|
# so this measures the SCRIPT's word handling: the admin token sentinel and the
|
|
# mint response token must not appear in the xtrace of a successful run.
|
|
rm -rf "$BRAIN/fleet/agents/newseat"
|
|
: > "$CALLS"
|
|
TRACE="$SANDBOX/trace.log"
|
|
MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha bash -x "$TARGET" newseat >"$SANDBOX/out11" 2>"$TRACE" || fail "M11: traced run failed"
|
|
if grep -qF "$ADMIN_TOKEN_VALUE" "$TRACE"; then fail "M11: admin token value appears in xtrace"; fi
|
|
if grep -qF 'minted-token-7f3a' "$TRACE"; then fail "M11: minted token value appears in xtrace"; fi
|
|
# Any password-shaped expansion: the password is 32 base64ish chars. A trace
|
|
# that expands it into a word (assignment or argument) prints exactly that
|
|
# shape; the clean script's trace contains no 32-char base64ish run at all
|
|
# (paths and URLs are the only long strings).
|
|
if grep -qE "[[:space:]=\"'][A-Za-z0-9]{32}([[:space:]\"']|$)" "$TRACE"; then
|
|
LEAK=$(grep -oE "[[:space:]=\"'][A-Za-z0-9]{32}" "$TRACE" | head -1)
|
|
fail "M11: password-like value expansion in xtrace: $LEAK"
|
|
fi
|
|
|
|
echo "mint-seat-credential regression harness passed"
|