Project-Template/docs/architecture/scripts/secrets.sh

167 lines
6.1 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Credentials, before they are committed.
#
# ## Why this and not a generic scanner
#
# A general-purpose secret scanner knows about AWS keys and GitHub tokens. It
# does not know that *this* deployment issues `pllc_agent_<hex>`, or that its
# encrypted envelopes start `v2:<keyid>:`, or which of its environment variables
# hold a password. The project does know, and usually writes it down twice: once
# in whatever redacts its logs, and once in whatever redacts its outbound
# messages.
#
# So this reads the project's own patterns where they exist — point
# `SECRETS_PATTERN_FILE` at the module holding them — and falls back to a
# conservative built-in set. A scanner tuned to the shapes a project actually
# issues catches the leak a generic one misses, and stays quiet the rest of the
# time.
#
# ## What it scans
#
# By default the **staged diff**, which is the only moment a commit can still be
# stopped cheaply. `--tracked` scans every tracked file instead, which is what
# you want once, on adoption, to find what is already in the history's tip.
#
# bash scripts/secrets.sh # staged changes (use in pre-commit)
# bash scripts/secrets.sh --tracked # everything tracked, for an audit
# SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh
# bash scripts/secrets.sh --allow docs/examples/
#
# ## What it cannot do
#
# It reads the working tree and the index. **A secret already committed is still
# in the history** after you delete it, and this will not tell you that — the
# fix there is a rotation, not a scan. Rotate first, then clean up.
#
# Exit codes: 0 nothing found. 1 a candidate found. 2 nothing was scanned.
# ---------------------------------------------------------------------------
set -uo pipefail
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
printf 'secrets: not a git repository.\n' >&2
exit 2
}
say() { printf 'secrets: %s\n' "$*" >&2; }
MODE="staged"
ALLOW=()
while [ $# -gt 0 ]; do
case "$1" in
--tracked) MODE="tracked"; shift ;;
--staged) MODE="staged"; shift ;;
--allow) ALLOW+=("${2:-}"); shift 2 ;;
*) say "unknown argument: $1"; exit 2 ;;
esac
done
# The built-in set. Deliberately shapes that are *structurally* credential-like
# rather than words that merely appear near credentials — `password` in a
# sentence is not a leak, and a scanner that says it is gets muted.
PATTERNS=(
'//[^/@[:space:]:]+:[^/@[:space:]]+@' # user:pass@host in a URL
'[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+'
'\b(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{20,}' # an authorization header
# Anchored to the start of a line or an `export`, because unanchored it
# matched `access_token = $1` in SQL and `apiKey=` in a property list — three
# findings in src/ that were column names, not credentials.
'(^|export )[A-Z][A-Z0-9_]*(SECRET|TOKEN|PASSWORD|API_KEY|PASSWD)[A-Z0-9_]*=[^[:space:]"'"'"']{8,}'
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
'\bghp_[A-Za-z0-9]{20,}' # GitHub
'\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack
'\bAKIA[0-9A-Z]{16}\b' # AWS access key id
)
# The project's own shapes, if it has written them down. A `pllc_agent_<hex>`
# token is invisible to every generic scanner and obvious to the module that
# redacts it.
if [ -n "${SECRETS_PATTERN_FILE:-}" ] && [ -f "$SECRETS_PATTERN_FILE" ]; then
loaded=0
# Anchored on the closing `/flags,` and greedy to it, rather than on "no
# commas". The first version used `[^,]+`, which cannot cross the comma inside
# a bounded quantifier — so `pllc_[a-z]+_[0-9a-f]{8,}` was silently dropped
# along with every other `{n,}` pattern: four of six on the file this was
# written against, while the script printed that it had loaded them.
while IFS= read -r found; do
[ -n "$found" ] || continue
PATTERNS+=("$found")
loaded=$((loaded + 1))
done < <(
sed -nE 's/.*\[\/(.+)\/[gimsuy]*,[[:space:]]*".*/\1/p' "$SECRETS_PATTERN_FILE" 2>/dev/null || true
)
# The count, never a bare reassurance. "Loaded project patterns" over an empty
# list is the same lie as a green test run that executed nothing.
if [ "$loaded" -gt 0 ]; then
say "loaded $loaded project pattern(s) from $SECRETS_PATTERN_FILE"
else
say "WARNING: $SECRETS_PATTERN_FILE yielded no patterns — scanning with the"
say " built-in set only. Check the file holds regex literals."
fi
fi
if [ "$MODE" = "staged" ]; then
# Added lines only. A removed line containing a token is somebody deleting
# one, which is the opposite of a leak.
CONTENT="$(git diff --cached --unified=0 --no-color | grep '^+' | grep -v '^+++' || true)"
WHAT="staged changes"
else
CONTENT=""
WHAT="tracked files"
while IFS= read -r file; do
skip=""
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
case "$file" in *"$allowed"*) skip="yes" ;; esac
done
[ -n "$skip" ] && continue
[ -f "$file" ] || continue
# Text only; a webp full of bytes will match anything.
file "$file" 2>/dev/null | grep -q "text" || continue
CONTENT+="$(sed "s|^|${file}: |" "$file")"$'\n'
done < <(git ls-files)
fi
if [ -z "$CONTENT" ]; then
say "nothing to scan in $WHAT."
exit 2
fi
found=0
for pattern in "${PATTERNS[@]}"; do
while IFS= read -r hit; do
[ -n "$hit" ] || continue
skip=""
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
case "$hit" in *"$allowed"*) skip="yes" ;; esac
done
[ -n "$skip" ] && continue
# The line is printed truncated and the match is never echoed in full — a
# scanner that prints the secret it found has published it to the terminal
# scrollback, the CI log, and wherever that log is shipped.
printf ' %.120s…\n' "$hit"
found=$((found + 1))
done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20)
done
if [ "$found" -gt 0 ]; then
say "$found candidate credential(s) in $WHAT."
say "If one is real: rotate it first. Deleting the line does not remove it"
say "from a commit that already exists, and the scan cannot see history."
exit 1
fi
say "no credential shapes in $WHAT."