#!/usr/bin/env bash # secret-scan.sh — commit-time secret guard. FAILS (exit 1) on a credential-shaped # string, so a build cannot go green with a credential committed to it. # # Usage: # scripts/secret-scan.sh # scan the whole git-tracked tree (default) # scripts/secret-scan.sh --tree # scripts/secret-scan.sh --path DIR # scan an arbitrary directory (git not required) # scripts/secret-scan.sh --staged # scan added lines in the index (pre-commit) # scripts/secret-scan.sh --diff REF # scan added lines since REF (e.g. origin/master) # --quiet only print the verdict and findings, no per-mode banner # # Exit codes: 0 clean, 1 credential found, 2 usage/config error. # # Patterns live in scripts/secret-patterns.tsv # Exceptions live in scripts/secret-allowlist.tsv (every entry carries a reason; # a missing reason is a hard error, so the guard fails closed). # # Dependencies are deliberately bash + coreutils + grep + sed/awk + git. The # Gitea Actions runner executes job steps INSIDE the runner container, which # has no node and no python by default: keep this script free of both. set -uo pipefail SELF_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) ROOT=$(cd -- "$SELF_DIR/.." && pwd) PATTERNS_FILE="$SELF_DIR/secret-patterns.tsv" ALLOWLIST_FILE="$SELF_DIR/secret-allowlist.tsv" # The guard's own definition files are not scannable content: the pattern list # necessarily contains the pattern text, and the allowlist necessarily contains # the allowed literals. Narrow, exact-path exclusion — not a wildcard. SELF_FILES=( "scripts/secret-scan.sh" "scripts/secret-patterns.tsv" "scripts/secret-allowlist.tsv" ) MODE="tree" PATH_DIR="" DIFF_REF="" QUIET=0 usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 2 } while [ $# -gt 0 ]; do case "$1" in --tree) MODE="tree" ;; --path) MODE="path"; PATH_DIR="${2:-}"; shift ;; --staged) MODE="staged" ;; --diff) MODE="diff"; DIFF_REF="${2:-}"; shift ;; --quiet) QUIET=1 ;; -h|--help) usage ;; *) echo "secret-scan: unknown argument '$1'" >&2; usage ;; esac shift done [ -f "$PATTERNS_FILE" ] || { echo "secret-scan: missing $PATTERNS_FILE" >&2; exit 2; } [ -f "$ALLOWLIST_FILE" ] || { echo "secret-scan: missing $ALLOWLIST_FILE" >&2; exit 2; } if [ "$MODE" = "path" ] && [ -z "$PATH_DIR" ]; then echo "secret-scan: --path needs a directory" >&2; exit 2 fi if [ "$MODE" = "diff" ] && [ -z "$DIFF_REF" ]; then echo "secret-scan: --diff needs a base ref" >&2; exit 2 fi # ── Load patterns ────────────────────────────────────────────────────────── RULE_IDS=() RULE_RES=() RULE_DESCS=() RULE_CHECKS=() COMBINED="" while IFS=$'\t' read -r id re desc check; do case "$id" in ''|'#'*) continue ;; esac [ -n "$re" ] || continue RULE_IDS+=("$id"); RULE_RES+=("$re"); RULE_DESCS+=("$desc"); RULE_CHECKS+=("${check:-}") if [ -z "$COMBINED" ]; then COMBINED="($re)"; else COMBINED="$COMBINED|($re)"; fi done < "$PATTERNS_FILE" if [ "${#RULE_IDS[@]}" -eq 0 ]; then echo "secret-scan: no patterns loaded from $PATTERNS_FILE" >&2; exit 2 fi # ── Load allowlist (fails closed on a missing reason) ────────────────────── AL_RULES=() AL_GLOBS=() AL_LITS=() AL_REASONS=() AL_LINENO=0 while IFS=$'\t' read -r rule glob lit reason; do AL_LINENO=$((AL_LINENO + 1)) case "$rule" in ''|'#'*) continue ;; esac if [ -z "$glob" ] || [ -z "$lit" ] || [ -z "$reason" ]; then echo "secret-scan: ❌ $ALLOWLIST_FILE:$AL_LINENO — allowlist entry needs ; reason-based exceptions only, refusing to run" >&2 exit 2 fi AL_RULES+=("$rule"); AL_GLOBS+=("$glob"); AL_LITS+=("$lit"); AL_REASONS+=("$reason") done < "$ALLOWLIST_FILE" # nocasematch is toggled only around the regex test; path globs must stay # case-sensitive, so it is never left on. MATCH="" regex_match() { # regex_match -> MATCH holds the matched text local re="$1" text="$2" shopt -s nocasematch if [[ $text =~ $re ]]; then MATCH="${BASH_REMATCH[0]}" shopt -u nocasematch return 0 fi shopt -u nocasematch MATCH="" return 1 } allowlisted() { # allowlisted local rule="$1" path="$2" text="$3" i for i in "${!AL_RULES[@]}"; do [ "${AL_RULES[$i]}" = "$rule" ] || [ "${AL_RULES[$i]}" = "*" ] || continue # The unquoted RHS is deliberate: is a bash glob, not a literal. # shellcheck disable=SC2053 [[ $path == ${AL_GLOBS[$i]} ]] || continue [[ $text == *"${AL_LITS[$i]}"* ]] || continue return 0 done return 1 } mask_value() { # mask_value — never echo a credential to logs. # Print only the part of the line BEFORE the match, then : the match # itself and everything after it (which may include a value the rule's regex # stopped short of, e.g. `credentials:` followed by a backticked password) is # never written to stdout. local text="$1" m="$2" if [ -n "$m" ] && [[ $text == *"$m"* ]]; then printf '%s' "${text%%"$m"*}" else printf '%s' "$text" fi } FINDINGS=0 SUPPRESSED=0 INERT=0 SCANNED=0 # value_is_inert — true when a matched assignment value # is plainly not a credential: empty, an env/command reference, a path, dotted # code access, a short or single-class identifier (a variable or key NAME, not a # value), a well-known placeholder word, or a value the file deliberately # truncates with '…' / '...' (a redacted prefix is not a usable credential). # Deliberately does NOT know the words "synthetic" or "example": a fabricated # example must be an explicit allowlist entry. value_is_inert() { local v="$1" rest="$2" case "$rest" in '…'*|'...'*) return 0 ;; esac v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}" case "$v" in ''|\$*|\{*|'<'*|'%'*|'('*|'/'*|'\\'*) return 0 ;; not-needed|no-key-required|none|null|true|false|redacted|placeholder|example|dummy|changeme|change-me|your-key|your_key|key|token|secret|password) return 0 ;; esac # dotted code access: os.environ.get / process.env.ZULIP_API_KEY / cfg.a if [[ $v =~ ^[a-z_][a-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ ]]; then return 0; fi # bare identifier (no punctuation beyond _): a NAME, not a value. A real # secret in this shape is long and mixes letters with digits. if [[ $v =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then [ "${#v}" -lt 20 ] && return 0 [[ $v =~ [0-9] ]] || return 0 return 1 fi return 1 } report_finding() { # report_finding local path="$1" line="$2" text="$3" i val for i in "${!RULE_IDS[@]}"; do regex_match "${RULE_RES[$i]}" "$text" || continue SCANNED=$((SCANNED + 1)) if [ "${RULE_CHECKS[$i]}" = "value" ]; then val="${MATCH#*[:=]}" val="${val# }" if value_is_inert "$val" "${text#*"$MATCH"}"; then INERT=$((INERT + 1)) continue fi fi if allowlisted "${RULE_IDS[$i]}" "$path" "$text"; then SUPPRESSED=$((SUPPRESSED + 1)) continue fi FINDINGS=$((FINDINGS + 1)) printf ' ❌ %s:%s [%s] %s\n' "$path" "$line" "${RULE_IDS[$i]}" "${RULE_DESCS[$i]}" printf ' | %s\n' "$(mask_value "$text" "$MATCH")" done } self_excluded() { # self_excluded local p="$1" s for s in "${SELF_FILES[@]}"; do [ "$p" = "$s" ] && return 0 done return 1 } # ── Collect candidate lines and scan them ───────────────────────────────── if [ "$MODE" = "tree" ] || [ "$MODE" = "path" ]; then if [ "$MODE" = "tree" ]; then BASE="$ROOT" git -C "$BASE" rev-parse --git-dir >/dev/null 2>&1 || { echo "secret-scan: --tree needs a git checkout (use --path DIR)" >&2; exit 2; } mapfile -d '' candidate < <(git -C "$BASE" ls-files -z 2>/dev/null) if [ "${#candidate[@]}" -eq 0 ]; then echo "secret-scan: ❌ no tracked files — refusing to report clean" >&2; exit 2 fi else BASE=$(cd -- "$PATH_DIR" 2>/dev/null && pwd) || { echo "secret-scan: --path '$PATH_DIR' is not a directory" >&2; exit 2; } mapfile -t candidate < <(cd -- "$BASE" && find . -type f -not -path './.git/*' | sed 's|^\./||') if [ "${#candidate[@]}" -eq 0 ]; then echo "secret-scan: ❌ no files under $BASE — refusing to report clean" >&2; exit 2 fi fi [ "$QUIET" -eq 1 ] || echo "── secret scan ($MODE): ${#candidate[@]} files under $BASE ──" for rel in "${candidate[@]}"; do [ -f "$BASE/$rel" ] || continue self_excluded "$rel" && continue while IFS= read -r hit; do [ -n "$hit" ] || continue report_finding "$rel" "${hit%%:*}" "${hit#*:}" done < <(grep -nEIi -e "$COMBINED" "$BASE/$rel" 2>/dev/null || true) done else # --staged / --diff: only ADDED lines, with the post-change line number. if [ "$MODE" = "staged" ]; then [ "$QUIET" -eq 1 ] || echo "── secret scan: added lines in the index ──" DIFF_TEXT=$(git -C "$ROOT" diff --cached --unified=0 --no-color -- . 2>/dev/null) else [ "$QUIET" -eq 1 ] || echo "── secret scan: added lines since $DIFF_REF ──" DIFF_TEXT=$(git -C "$ROOT" diff --unified=0 --no-color "$DIFF_REF"...HEAD 2>/dev/null \ || git -C "$ROOT" diff --unified=0 --no-color "$DIFF_REF"..HEAD 2>/dev/null) fi if [ -z "$DIFF_TEXT" ]; then [ "$QUIET" -eq 1 ] || echo " (no added lines)" fi while IFS=$'\t' read -r rel line text; do [ -n "$rel" ] || continue self_excluded "$rel" && continue report_finding "$rel" "$line" "$text" done < <(printf '%s\n' "$DIFF_TEXT" | awk ' /^\+\+\+ / { f=$2; sub(/^b\//,"",f); next } /^@@ / { if (match($0, /\+[0-9]+/)) ln=substr($0, RSTART+1, RLENGTH-1)+0; next } (/^\+/ && !/^\+\+\+/) { print f "\t" ln "\t" substr($0,2); ln++; next } ') fi # ── Verdict ──────────────────────────────────────────────────────────────── if [ "$FINDINGS" -gt 0 ]; then echo "" echo "❌ SECRET SCAN FAILED — $FINDINGS credential-shaped string(s) in ${MODE} content." echo " Fix: remove the credential and read it from the vault/env." echo " Only a deliberate synthetic example may be added to scripts/secret-allowlist.tsv," echo " one entry per file/rule/literal, with a reason. Never allowlist a live credential." exit 1 fi echo "✅ secret scan clean (${MODE}; ${SUPPRESSED} allowlisted exception(s), ${INERT} inert value(s) ignored)" exit 0