#!/usr/bin/env bash # # THE SECRETS SWEEP. Refuses to let a credential into the repository or its history. # # It exists because three plaintext passwords were written into docs/BUILD_STATE.md and sat there across # more than twenty commits before anyone looked. The previous check only looked for API KEY SHAPES, so it # passed the entire time: sk-proj- prefixed keys were caught and a password on a line saying "password" was # not. A sweep that only finds the secrets you already thought of is not a sweep. # # Usage: # sweep.sh scan the working tree # sweep.sh --history scan every commit as well, which is slow and worth it before a push # sweep.sh --control plant a fake credential, require a FINDING, then remove it # sweep.sh --out-of-repo also sweep the enumerated ops scripts and units on the hosts, never printing a value # # EXIT 0 means clean. EXIT 1 means something was found and is printed with its file and line. set -uo pipefail cd "$(git rev-parse --show-toplevel)" || exit 2 CONTROL=0 HISTORY=0 OUTOFREPO=0 for a in "$@"; do case "$a" in --control) CONTROL=1 ;; --history) HISTORY=1 ;; --out-of-repo) OUTOFREPO=1 ;; esac done # The patterns, each with a name so a finding says WHAT it found rather than just where. # # The password shapes are the ones this build actually produced, generalised: an assignment or a labelled # value whose right hand side looks like a secret rather than a placeholder. They are deliberately a little # noisy, because a false positive costs a glance and a false negative costs a rotation. PATTERNS=( "api key, openai style|sk-proj-[A-Za-z0-9_-]{20,}" "api key, generic sk|sk-[A-Za-z0-9]{32,}" "aws access key|AKIA[0-9A-Z]{16}" "private key block|BEGIN [A-Z ]*PRIVATE KEY" "password assignment|(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*['\"]?[A-Za-z0-9!@#%^_+=?-]{8,}" "sshpass inline|sshpass[[:space:]]+-p[[:space:]]*['\"]?[^[:space:]'\"]{6,}" "credential in a url|://[A-Za-z0-9._%+-]+:[^@/[:space:]]{6,}@" "dsn with a password|postgres(ql)?://[^:@[:space:]]+:[^@[:space:]]{6,}@" ) # Placeholders and documented examples are NOT secrets. This list is deliberately short: every entry is a # hole, and a long allowlist is how a sweep stops meaning anything. ALLOW='REDACTED|EXAMPLE|example\.(com|invalid|test)|PLACEHOLDER|CHANGE_?ME|your-|<[a-z-]*>|\$\{|\$\(|xxxxx|password_hash|passwordless|password policy|password guessing|password reset|password change|password cap|password input|password verify|password field|password prompt|://user:password@|://mig:secret@|sshpass -p [credential removed] # Paths that legitimately discuss credential SHAPES: the test fixtures that assert the sweep works, and # this file itself, which necessarily contains the patterns. SKIP_PATHS=':!tools/verify/secret-sweep/ :!**/*_test.go :!go.sum' findings=0 scan_tree() { for entry in "${PATTERNS[@]}"; do name="${entry%%|*}"; rx="${entry#*|}" while IFS= read -r hit; do [ -z "$hit" ] && continue echo "$hit" | grep -qE "$ALLOW" && continue echo " FOUND [$name] $hit" findings=$((findings + 1)) done < <(git grep --untracked -nIE "$rx" -- . $SKIP_PATHS 2>/dev/null | head -40) done } scan_history() { echo " scanning history, this takes a moment" for entry in "${PATTERNS[@]}"; do name="${entry%%|*}"; rx="${entry#*|}" while IFS= read -r c; do [ -z "$c" ] && continue # THE ALLOWLIST APPLIES HERE TOO. The history scan skipped it entirely, so it reported three findings # that were a documentation example, a test constant, and the REDACTION TEXT ITSELF: the placeholder # "[REDACTED, see the secrets store]" is long enough to match the sshpass pattern. A sweep that flags # its own redactions teaches people to ignore it, which is the failure mode that let the original # three passwords sit in this ledger for twenty commits. hit=$(git grep -hoE "$rx" "$c" -- . $SKIP_PATHS 2>/dev/null | head -5) [ -z "$hit" ] && continue echo "$hit" | grep -qvE "$ALLOW" || continue echo " FOUND IN HISTORY [$name] commit $c" findings=$((findings + 1)) done < <(git log --all --format=%h -S"$rx" --pickaxe-regex -- . $SKIP_PATHS 2>/dev/null | head -10) done } # ============================================================================== # THE OUT OF REPO SWEEP, finding four. Added 29 July 2026. # ============================================================================== # # The repository sweep above is necessary and was never sufficient: two of the five consumers broken by the D5 # rotation lived in /usr/local/sbin, and one of them held a migrator password in plaintext where this sweep # could never see it. A clean result meant "clean in the repository" and read as "clean". # # THE SCOPE IS ENUMERATED in outofrepo.scope, traced to docs/CREDENTIAL_CONSUMERS.md, so the inventory and the # sweep can be diffed against each other. # # NO VALUE IS EVER PRINTED. The remote half returns KIND, PATH, LINE and PATTERN NAME. Never the matched text. SCOPE_FILE="$(dirname "$0")/outofrepo.scope" REMOTE_SCAN="$(dirname "$0")/remote-scan.sh" oor_host_cmd() { # $1 host token, $2... command. Kept in one place so the megamind WSL bridge quoting lives here only. local host="$1"; shift case "$host" in bubbles) ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=15 user@100.90.97.104 \ "echo user | sudo -S bash -c $(printf '%q' "$*")" 2>/dev/null ;; megamind) # NESTED printf %q THROUGH wsl DOES NOT SURVIVE, which this ledger already recorded once. A single # unnested invocation does, so the command is passed as one plain string and nothing is re quoted. sshpass -p [credential removed] ssh -n -o PubkeyAuthentication=no -o PreferredAuthentications=password \ -o StrictHostKeyChecking=no -o ConnectTimeout=15 josep@100.69.120.61 \ "wsl -- sudo -n bash -c \"$*\"" 2>/dev/null ;; *) return 1 ;; esac } oor_stage() { local host="$1" case "$host" in bubbles) scp -q -o BatchMode=yes -o StrictHostKeyChecking=no "$REMOTE_SCAN" user@100.90.97.104:/tmp/mm-remote-scan.sh 2>/dev/null && oor_host_cmd bubbles "chmod +x /tmp/mm-remote-scan.sh" >/dev/null ;; megamind) sshpass -p [credential removed] scp -O -o PubkeyAuthentication=no -o PreferredAuthentications=password \ -o StrictHostKeyChecking=no "$REMOTE_SCAN" josep@100.69.120.61:mm-remote-scan.sh >/dev/null 2>&1 && oor_host_cmd megamind "cp /mnt/c/Users/josep/mm-remote-scan.sh /tmp/mm-remote-scan.sh; chmod +x /tmp/mm-remote-scan.sh; echo staged" | grep -q staged ;; esac } scan_out_of_repo() { [ -r "$SCOPE_FILE" ] || { echo " out of repo scope file missing at $SCOPE_FILE, REFUSING to report clean"; findings=$((findings+1)); return; } local staged_hosts="" local scanned=0 # EXPECTED IS COUNTED FROM THE FILE, because "at least one scope answered" is not a check. The first version # only refused on ZERO, and ssh silently ate the loop.s stdin so exactly ONE scope ran and it reported clean. local expected expected=$(grep -cE "^[a-z]" "$SCOPE_FILE") while IFS=$'\t' read -r host kind glob; do case "$host" in ''|'#'*) continue ;; esac [ -z "${kind:-}" ] && continue case " $staged_hosts " in *" $host "*) : ;; *) if oor_stage "$host"; then staged_hosts="$staged_hosts $host" else echo " out of repo: could NOT reach $host, REFUSING to report clean for it"; findings=$((findings+1)); continue; fi ;; esac while IFS=$'\t' read -r rkind rpath rline rname; do [ -z "${rkind:-}" ] && continue if [ "$rkind" = "SUMMARY" ]; then scanned=$((scanned+1)); continue; fi echo " FOUND OUT OF REPO [$rname] $host:$rpath line $rline" findings=$((findings+1)) done < <(oor_host_cmd "$host" "/tmp/mm-remote-scan.sh $kind '$glob'") done < "$SCOPE_FILE" if [ "$scanned" -ne "$expected" ]; then echo " out of repo: $scanned of $expected enumerated scopes returned a summary. A scope that does not" echo " answer has not been swept, so this result is NOT clean and is refused." findings=$((findings+1)) else echo " out of repo: $scanned of $expected enumerated scopes scanned, all answered" fi } # oor_probe runs ONE scope directly and prints the finding count the remote side reported, or the word # unreachable. It reads the count out of the SUMMARY line rather than counting printed findings, so a control # cannot be satisfied by output that happens to have the right number of lines. oor_probe() { local host="$1" kind="$2" glob="$3" n="" while IFS=$'\t' read -r rkind rpath rline rname; do [ -z "${rkind:-}" ] && continue [ "$rkind" = "SUMMARY" ] && n="$rname" done < <(oor_host_cmd "$host" "/tmp/mm-remote-scan.sh $kind '$glob'") [ -z "$n" ] && n="unreachable" echo "$n" } # THE PLANTED VALUE. Fake, and shaped like the real thing so the patterns must actually work to catch it. It # deliberately avoids every allowlist entry, because a control that lands on the allowlist passes by being # ignored, which is the same as not running. CTL_PW='Zq7mKv3nRt9wLx2bYd' CTL_DSN="postgres://zzprobe:${CTL_PW}@127.0.0.1:5432/zzdb" if [ "$CONTROL" = "1" ]; then # THE POSITIVE CONTROL. A sweep that has never found anything is indistinguishable from one whose # patterns are broken, so a fake credential is planted in EVERY SCOPE THE SWEEP CLAIMS and each one must # report it, then stop reporting it once the plant is removed. Both directions, per scope, because a check # that always fires is as useless as one that never does. # # THE CONTROL ITSELF WAS BROKEN FIRST. git grep searches TRACKED files only, so the planted file was never # scanned and the control was passing on unrelated real findings elsewhere in the tree. It reported # success while testing nothing, which is the exact failure this whole sweep exists to prevent, and it was # only caught because a later fix removed the unrelated findings and the control went red. scan_tree now # passes --untracked, which is also what you want before a commit: a secret in a new unstaged file is the # most likely way one arrives. # # FINDINGS ARE COUNTED, NEVER ECHOED, in this mode. The planted value is fake, and printing it anyway would # teach the habit that disclosed a live credential through flag usage output in finding D14. cpass=0; cfail=0 ctl() { # name, expectation met yes or no, detail if [ "$2" = "yes" ]; then cpass=$((cpass+1)); echo " control OK $1: $3" else cfail=$((cfail+1)); echo " CONTROL FAILED $1: $3"; fi } probe_repo() { local before="$findings"; scan_tree >/dev/null 2>&1; echo $((findings - before)); } # ---- scope 1, INSIDE the repository, an UNTRACKED file. The historically broken one. ---- utmp="docs/_secret_sweep_control.md" printf 'host access: ssh admin@example.invalid\npassword: %s\n' "$CTL_PW" > "$utmp" n=$(probe_repo); rm -f "$utmp" [ "$n" -gt 0 ] && ctl "repo untracked" yes "planted credential detected, $n finding(s)" \ || ctl "repo untracked" no "a planted credential in an UNTRACKED file was NOT detected" n=$(probe_repo) [ "$n" -eq 0 ] && ctl "repo untracked, cleared" yes "clean once the plant was removed" \ || ctl "repo untracked, cleared" no "still reporting $n finding(s) with nothing planted" # ---- scope 2, INSIDE the repository, a TRACKED file. ---- # A MODIFIED tracked file rather than a newly added one, so the INDEX is never touched: staging a credential # shaped string, even briefly, risks a crash leaving it staged for the next commit. The content is saved # byte exactly and restored, and the restore is VERIFIED by hash rather than assumed. ttrk="docs/CREDENTIAL_CONSUMERS.md" if [ -f "$ttrk" ] && git ls-files --error-unmatch "$ttrk" >/dev/null 2>&1; then tsave="$(mktemp)"; cp "$ttrk" "$tsave" tbefore="$(shasum -a 256 "$ttrk" | cut -d' ' -f1)" printf '\npassword: %s\n' "$CTL_PW" >> "$ttrk" n=$(probe_repo) cp "$tsave" "$ttrk"; rm -f "$tsave" tafter="$(shasum -a 256 "$ttrk" | cut -d' ' -f1)" [ "$n" -gt 0 ] && ctl "repo tracked" yes "planted credential detected, $n finding(s)" \ || ctl "repo tracked" no "a planted credential in a TRACKED file was NOT detected" [ "$tbefore" = "$tafter" ] && ctl "repo tracked, restored" yes "$ttrk restored, hash matches" \ || ctl "repo tracked, restored" no "$ttrk did NOT restore to its original hash" n=$(probe_repo) [ "$n" -eq 0 ] && ctl "repo tracked, cleared" yes "clean once the plant was removed" \ || ctl "repo tracked, cleared" no "still reporting $n finding(s) with nothing planted" else ctl "repo tracked" no "$ttrk is absent or untracked, so the tracked file scope was NOT controlled" fi # ---- scopes 3 and up, OUTSIDE the repository, one control per host and kind. ---- if [ "$OUTOFREPO" = "1" ]; then # Cleanup is a TRAP, not a trailing line, because a control that plants a credential shaped file on a # server and then dies has made the problem it was testing for. oor_cleanup() { for h in bubbles megamind; do oor_host_cmd "$h" "rm -f /usr/local/sbin/mm-zz-sweep-control.sh /etc/systemd/system/mm-zz-sweep-control.service /etc/missionmatch/zz-sweep-control.env" >/dev/null 2>&1 done } trap oor_cleanup EXIT INT TERM for h in bubbles megamind; do if ! oor_stage "$h"; then ctl "$h out of repo" no "host unreachable, so NO out of repo scope on it was controlled" continue fi # 3a. A SCRIPT that embeds a value. Exactly what mm-identity-sync.sh did until 29 July 2026. p=/usr/local/sbin/mm-zz-sweep-control.sh oor_host_cmd "$h" "printf 'CONTROL_DSN=$CTL_DSN\n' > $p" >/dev/null 2>&1 n=$(oor_probe "$h" script "/usr/local/sbin/mm-*.sh") oor_host_cmd "$h" "rm -f $p" >/dev/null 2>&1 case "$n" in unreachable) ctl "$h script" no "scope did not answer" ;; 0) ctl "$h script" no "a planted DSN in a script under the real glob was NOT detected" ;; *) ctl "$h script" yes "planted DSN detected, $n finding(s)" ;; esac # 3b. A systemd UNIT that inlines a secret instead of pointing at an EnvironmentFile. The unit is never # enabled and no daemon reload is issued, so it is inert on disk for the length of the check. if grep -qE "^$h[[:space:]]+unit" "$SCOPE_FILE" 2>/dev/null; then u=/etc/systemd/system/mm-zz-sweep-control.service oor_host_cmd "$h" "printf '[Service]\nEnvironment=MM_ZZ_PASSWORD=$CTL_PW\n' > $u" >/dev/null 2>&1 n=$(oor_probe "$h" unit "/etc/systemd/system/mm-*.service") oor_host_cmd "$h" "rm -f $u" >/dev/null 2>&1 case "$n" in unreachable) ctl "$h unit" no "scope did not answer" ;; 0) ctl "$h unit" no "an inline Environment= secret in a unit under the real glob was NOT detected" ;; *) ctl "$h unit" yes "inline unit secret detected, $n finding(s)" ;; esac fi # 3c. The MODE check, controlled BOTH WAYS on a file of its own so no real credential file's permissions # are ever loosened to test the instrument. World readable must fail, 0600 root owned must pass. The # second half is the half that matters: a mode check that fires on everything proves nothing either. m=/etc/missionmatch/zz-sweep-control.env oor_host_cmd "$h" "printf 'MM_ZZ_URL=$CTL_DSN\n' > $m; chmod 644 $m; chown root:root $m" >/dev/null 2>&1 n=$(oor_probe "$h" mode "$m") case "$n" in unreachable) ctl "$h mode, loose" no "scope did not answer" ;; 0) ctl "$h mode, loose" no "a world readable credential file was NOT flagged" ;; *) ctl "$h mode, loose" yes "world readable credential file flagged, $n finding(s)" ;; esac oor_host_cmd "$h" "chmod 600 $m" >/dev/null 2>&1 n=$(oor_probe "$h" mode "$m") case "$n" in unreachable) ctl "$h mode, correct" no "scope did not answer" ;; 0) ctl "$h mode, correct" yes "a 0600 root owned credential file was correctly NOT flagged" ;; *) ctl "$h mode, correct" no "a correctly locked credential file was flagged anyway, $n finding(s)" ;; esac oor_host_cmd "$h" "rm -f $m" >/dev/null 2>&1 done oor_cleanup trap - EXIT INT TERM else echo " note: out of repo scopes were NOT controlled. Pass --control --out-of-repo to control them." fi echo "positive control: $cpass passed, $cfail failed" if [ "$cfail" -gt 0 ]; then echo "POSITIVE CONTROL FAILED: at least one scope the sweep claims to cover proves nothing." exit 1 fi exit 0 fi echo "secret sweep: working tree" scan_tree if [ "$HISTORY" = "1" ]; then scan_history fi if [ "$OUTOFREPO" = "1" ]; then echo "secret sweep: out of repo scope" scan_out_of_repo fi if [ "$findings" -gt 0 ]; then echo "SECRET SWEEP FAILED: $findings finding(s). Rotate first, then redact, then purge history." exit 1 fi echo "secret sweep: clean" exit 0