PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 18s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
The 2026-09-17 purge removed six live credentials that had sat in this repo for weeks, several in .md prose. Nothing blocked that class of commit, so a warning in a stream nobody reads was the only signal. This adds a guard that fails the build instead of warning. Guard - scripts/secret-scan.sh: bash + coreutils + grep/sed/awk + git only (the Gitea Actions runner executes job steps inside the runner container — BusyBox grep, no node/python). Modes: --tree (git-tracked, default), --path DIR (no git), --staged (pre-commit), --diff REF. Exit 1 on a finding, 2 on config error. - scripts/secret-patterns.tsv: checked-in pattern list — sk-, sk-or-v1-, sk_live_, literal Bearer tokens, PVEAPIToken=, raw Authorization values, PEM private-key blocks, prose credential lines, and password/api_key/secret/token assignments carrying a literal value. Prose is scanned exactly like code. - scripts/secret-allowlist.tsv: one entry per deliberate synthetic example, each with a reason. A missing reason is a hard error (fail closed). The 2026-09-17 purge's `«vault: ...»` placeholders are listed explicitly rather than filtered by a general "vault"/"synthetic" rule, so a new occurrence still needs a reviewed, reasoned entry. - A small inert-value classifier drops env refs, paths, dotted code access, variable names and right-truncated redactions; it does not know the words "synthetic"/"example", so a fabrication is always an explicit exception. - Findings are printed with the credential masked; a scan never echoes a full secret into the log. Wiring - .gitea/workflows/pr-pipeline.yaml lint job: explicit "Committed-credential scan" step plus the self-test. A finding fails the required `pr-pipeline / lint` context, which the merge gate depends on. - scripts/prose-lint.sh (the local gate): a "Secret scan" section, so `bash scripts/prose-lint.sh` before pushing is equivalent to CI. Tests - tests/test_secret_scan.sh: 20 cases. Plants pattern-matching fixtures in temp trees (outside every allowlisted path) and asserts the guard FAILS, including the --staged commit-time path; asserts the tree is quiet; asserts allowlisted text at an unlisted path still fails (path-explicit, not word-based); asserts a reasonless allowlist entry exits 2. Verified: guard run against 8245716^ (the pre-fix revision, before the purge) fails on the real OpenRouter/LiteLLM/Zulip/Proxmox/Stirling credentials; guard run over the current tree is clean.
270 lines
10 KiB
Bash
Executable File
270 lines
10 KiB
Bash
Executable File
#!/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 <rule> <path-glob> <literal> <reason>; 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 <regex> <text> -> 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 <rule> <path> <text>
|
|
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: <path-glob> 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 <text> <match> — never echo a credential to logs.
|
|
# Print only the part of the line BEFORE the match, then <redacted>: 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<redacted>' "${text%%"$m"*}"
|
|
else
|
|
printf '%s' "$text"
|
|
fi
|
|
}
|
|
|
|
FINDINGS=0
|
|
SUPPRESSED=0
|
|
INERT=0
|
|
SCANNED=0
|
|
|
|
# value_is_inert <value> <text-after-match> — 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 <path> <line> <text>
|
|
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 <repo-relative-path>
|
|
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
|