Files
Triple-C/scripts/scan-secrets.sh
T

112 lines
5.4 KiB
Bash
Raw Permalink Normal View History

#!/bin/sh
# Refuse to let a live credential into the repository.
#
# Written after one got in: `the_custom_env_fingerprint_never_carries_the_value`
# used the maintainer's real Gitea site-admin token as its fixture. It survived
# 92 commits and fourteen days in a public mirror, past five audit rounds and two
# independent reviews — because every one of those looked at the code under
# change, and this sat in a test nobody had reason to open. A grep would have
# caught it on the first day. This is that grep.
#
# Usage:
# scan-secrets.sh --staged what `git commit` is about to record (the hook)
# scan-secrets.sh --range A..B every line added between two commits (CI)
# scan-secrets.sh --tracked every tracked file, as it stands now
#
# Exit 0 clean, 1 on a finding, 2 on misuse.
#
# ## Why it scans *added lines* and not the whole file
#
# The repository already contains long opaque strings — 317 literals of 32+
# characters, almost all of them legitimate. A scanner that failed on those would
# be turned off within a day, which is the normal way this kind of check dies.
# Judging only what a commit *adds* keeps the signal where a person can act on it.
#
# ## Escape hatch
#
# A line carrying `pragma: allowlist secret` is skipped. Deliberately wordy: it
# should be uncomfortable enough to type that it is read as a claim, and it
# leaves something greppable behind.
set -eu
MODE="${1:---staged}"
RANGE="${2:-}"
case "$MODE" in
--staged) ADDED=$(git diff --cached --unified=0 --no-color -- . 2>/dev/null || true) ;;
--range) [ -n "$RANGE" ] || { echo "scan-secrets: --range needs A..B" >&2; exit 2; }
ADDED=$(git diff --unified=0 --no-color "$RANGE" -- . 2>/dev/null || true) ;;
--tracked)
# `grep -Iq .` first: without it a binary blob's bytes reach the
# rules below, and GNU grep answers "binary file matches" instead
# of the line — so a real finding inside one would be reported as
# a sentence nobody can act on, and a stray NUL can end the scan
# early. Text files only; binaries are not where source secrets
# live, and `--staged` never sees them either (git emits
# "Binary files differ", not content).
ADDED=$(git ls-files -z \
| xargs -0 -I{} sh -c 'grep -Iq . "{}" 2>/dev/null && sed "s/^/+/" "{}" 2>/dev/null' \
|| true) ;;
*) echo "scan-secrets: unknown mode $MODE" >&2; exit 2 ;;
esac
# Only added lines; drop diff headers (+++ b/path) so a filename never matches.
CANDIDATES=$(printf '%s\n' "$ADDED" \
| grep '^+' \
| grep -v '^+++' \
| grep -v 'pragma: allowlist secret' \
|| true)
[ -n "$CANDIDATES" ] || exit 0
FOUND=0
report() {
FOUND=1
printf '\n %s\n' "$1"
printf '%s\n' "$2" | sed 's/^/ /' | cut -c1-160
}
# --- Rule 1: vendor-issued credentials. Shape alone identifies these, so there
# --- is no false-positive story to tell and no identifier context needed.
VENDOR=$(printf '%s\n' "$CANDIDATES" | grep -nE \
'gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ya29\.[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|dckr_pat_[A-Za-z0-9_-]{20,}' \
|| true)
[ -n "$VENDOR" ] && report "A vendor-issued credential (its prefix identifies the provider):" "$VENDOR"
# --- Rule 2: private key material.
KEYS=$(printf '%s\n' "$CANDIDATES" | grep -nE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----' || true)
[ -n "$KEYS" ] && report "Private key material:" "$KEYS"
# --- Rule 3: an opaque literal assigned to a secret-shaped name.
#
# This is the rule that would have caught the Gitea token — `let secret =
# "<40 hex>"`. Both halves are required, and that is what keeps it usable:
# the *name* must read as a credential, and the *whole literal* must be hex or
# base64 with no word structure. `"aws-secret-access-key"` is a keychain key
# name sitting right next to the word `secret`, and its hyphens are what keep it
# out; measured against the tree, name-proximity alone flagged four lines of
# which three were that shape.
OPAQUE=$(printf '%s\n' "$CANDIDATES" | grep -niE \
'(secret|token|api[_-]?key|apikey|passwo?rd|passwd|credential|auth[_-]?(key|token))[^A-Za-z0-9]{0,12}[:=][^"'"'"']{0,12}["'"'"']([0-9a-fA-F]{32,}|[A-Za-z0-9+/]{40,}={0,2})["'"'"']' \
|| true)
[ -n "$OPAQUE" ] && report "An opaque literal assigned to a secret-shaped name:" "$OPAQUE"
if [ "$FOUND" -eq 1 ]; then
cat >&2 <<'MSG'
────────────────────────────────────────────────────────────────────────
Refusing the commit: it adds something shaped like a live credential.
If it IS live: do not amend and move on. Rotate it first — anything that
reaches a branch is on the mirror, and the mirror is public.
If it is genuinely not a secret — a fixture, a public key id, test data —
make the literal obviously fake ("not-a-real-token-0000…"), or append
`pragma: allowlist secret` to the line to say so on the record.
────────────────────────────────────────────────────────────────────────
MSG
exit 1
fi
exit 0