#!/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_`, or that its # encrypted envelopes start `v2::`, 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_` # 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."