#!/usr/bin/env bash # F∆I Platform — staged-content security check. # Runs from .githooks/pre-commit, .githooks/commit-msg, and Forgejo CI. # # Modes: # content — scan staged files (filename + diff) for secrets, # confidential terms, banned phrases, forbidden files # message FILE — scan a commit-message file for DCO sign-off, # Conventional Commit subject, no Claude trailer # ci [BASE] — scan all changes between BASE..HEAD # (default BASE: origin/main) # # Exits non-zero on any violation; prints all violations before exiting # so the operator sees the full picture in one pass. set -uo pipefail cd "$(git rev-parse --show-toplevel)" MODE="${1:-content}" FAILED=0 if [ -t 1 ]; then RED=$'\033[31m'; YEL=$'\033[33m'; GRN=$'\033[32m'; RST=$'\033[0m' else RED=''; YEL=''; GRN=''; RST='' fi fail() { printf "%s✗ %s%s\n" "$RED" "$*" "$RST" >&2; FAILED=1; } pass() { printf "%s✓ %s%s\n" "$GRN" "$*" "$RST"; } # ─── Patterns ──────────────────────────────────────────────────────────── # Secrets — case-sensitive. Patterns are deliberately narrow to avoid # false-positives on plain SHA-1s (40 hex), commit refs, etc. Each entry # requires either a known prefix or a context keyword. SECRET_PATTERNS=( 'ghp_[A-Za-z0-9]{36}' # GitHub PAT 'gho_[A-Za-z0-9]{36}' # GitHub OAuth 'ghs_[A-Za-z0-9]{36}' # GitHub server-to-server 'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key 'sk-[A-Za-z0-9]{48,}' # OpenAI API key 'AKIA[0-9A-Z]{16}' # AWS access key id '(token|Bearer)[ =]"?[a-f0-9]{40}\b' # Forgejo / Gitea PAT in # header / URL / env 'FORGEJO_TOKEN[= ]"?[a-f0-9]{40}' # Forgejo PAT in env-var '-----BEGIN [A-Z ]*PRIVATE KEY-----' # Private keys (RSA, EC, …) ) # Filenames that should never be committed. FORBIDDEN_FILE_PATTERNS=( '(^|/)\.env$' '(^|/)\.env\.local$' '(^|/)credentials\.(json|yaml|yml|toml)$' '\.(pem|p12|pfx|key)$' '(^|/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$' ) # Filenames matching FORBIDDEN_FILE_PATTERNS but explicitly allowed. FILE_ALLOWLIST=( '\.env\.example$' '\.env\.template$' '\.env\.sample$' 'tests?/.*\.(pem|key)$' # Test fixtures 'fixtures?/.*\.(pem|key)$' ) # Confidential terms are loaded at runtime from a file outside # the repo (~/.fai-security/banned-terms.txt by default; override # with $FAI_BANNED_TERMS_FILE). The list itself is private — the # whole point of the gate is to keep certain strings out of any # committed artefact, so the list of strings to gate on must not # live in a committed artefact either. # # File format: one regex per line. Lines starting with `#` and # blank lines are ignored. Each line is matched as a # case-insensitive grep -E pattern with `-i`; use `\b…\b` for # word boundaries. # # Bootstrap on a fresh machine: # mkdir -p ~/.fai-security # chmod 700 ~/.fai-security # printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt # chmod 600 ~/.fai-security/banned-terms.txt # # When the file is missing the gate logs a warning but does NOT # fail — running on a fresh checkout where the operator hasn't # yet bootstrapped the list shouldn't block them. Real CI runs # explicitly verify the file is present and refuse otherwise # (see scan_ci_mode below). CONFIDENTIAL_TERMS=() _load_confidential_terms() { local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}" if [ ! -f "$file" ]; then return 1 fi while IFS= read -r line; do # Strip trailing comments + leading/trailing whitespace. line="${line%%#*}" line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [ -z "$line" ] && continue CONFIDENTIAL_TERMS+=("$line") done < "$file" return 0 } if ! _load_confidential_terms; then printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \ "${YEL:-}" "${RST:-}" >&2 fi # Memory: feedback_no_marketing_speak.md. BANNED_PHRASES=( '\bviral\b' '\bkiller (feature|app)\b' '\bjust works\b' '\bpowerful\b' '\bgame[- ]changer\b' '\brevolutionary\b' '\bworld[- ]class\b' '\bblazingly fast\b' '\blightning[- ]fast\b' ) # ─── Helpers ───────────────────────────────────────────────────────────── scan_secrets() { # Case-sensitive — secret prefixes have known casing. local input="$1" for pat in "${SECRET_PATTERNS[@]}"; do local hits hits=$(printf "%s" "$input" | grep -E -- "$pat" 2>/dev/null || true) if [ -n "$hits" ]; then fail "secret pattern matched: $pat" printf "%s\n" "$hits" | head -3 | sed 's/^/ /' >&2 fi done } scan_terms_ci() { # Case-insensitive — banned terms / confidentiality. local label="$1"; local input="$2"; shift 2 for pat in "$@"; do local hits hits=$(printf "%s" "$input" | grep -i -E -- "$pat" 2>/dev/null || true) if [ -n "$hits" ]; then fail "$label: $pat" printf "%s\n" "$hits" | head -3 | sed 's/^/ /' >&2 fi done } is_allowlisted_file() { local f="$1" for ap in "${FILE_ALLOWLIST[@]}"; do if printf "%s" "$f" | grep -E -- "$ap" >/dev/null; then return 0 fi done return 1 } scan_filenames() { local files="$1" [ -z "$files" ] && return while IFS= read -r f; do [ -z "$f" ] && continue is_allowlisted_file "$f" && continue for fp in "${FORBIDDEN_FILE_PATTERNS[@]}"; do if printf "%s" "$f" | grep -E -- "$fp" >/dev/null; then fail "forbidden file in stage: $f (matches $fp)" fi done done <<< "$files" } scan_diff() { local diff="$1" [ -z "$diff" ] && return scan_secrets "$diff" # `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion # for arrays that may be empty (CONFIDENTIAL_TERMS is empty # when the operator hasn't bootstrapped ~/.fai-security/). scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"} scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}" } # ─── Modes ─────────────────────────────────────────────────────────────── # The security script itself stores the patterns as regex strings, so its # own diff would self-flag on every commit. Exclude the security tooling # directories from the content scan. Repos with additional meta-files that # legitimately mention banned terms (a banned-words gate in code, a policy # doc, an LLM prompt template that filters such terms) extend this list # via a `.security-allow` file at the repo root: one pathspec per line, # blank lines and `#` comments ignored. EXCLUDES=(":(exclude)tools/security/" ":(exclude).githooks/") if [ -f ".security-allow" ]; then while IFS= read -r line; do line="${line%%#*}" line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [ -z "$line" ] && continue EXCLUDES+=(":(exclude)$line") done < ".security-allow" fi scan_content_mode() { local files diff files=$(git diff --cached --name-only --diff-filter=ACMR) diff=$(git diff --cached --no-color -U0 -- . "${EXCLUDES[@]}" \ | grep -E '^\+[^+]' || true) scan_filenames "$files" scan_diff "$diff" } scan_message_mode() { local msgfile="${1:-}" if [ -z "$msgfile" ] || [ ! -f "$msgfile" ]; then fail "commit-message file not found: $msgfile" return fi # Strip git's commentary lines. local msg subject msg=$(grep -v '^#' "$msgfile") subject=$(printf "%s" "$msg" | head -1) # Skip merge / fixup / squash / revert — git generates these and the # subject can't always be reshaped to Conventional Commits. case "$subject" in "Merge "*|"fixup! "*|"squash! "*|"Revert "*) return ;; esac if ! printf "%s" "$subject" \ | grep -E '^(feat|fix|docs|refactor|test|chore|perf|build|ci|style)(\([a-z0-9_,-]+\))?!?: .+' \ >/dev/null; then fail "subject is not Conventional Commits: $subject" fail " expected: type(scope): message (type: feat|fix|docs|refactor|test|chore|perf|build|ci|style)" fi if ! printf "%s" "$msg" | grep -E '^Signed-off-by: .+ <.+@.+>$' >/dev/null; then fail "missing DCO sign-off line (use git commit -s)" fi if printf "%s" "$msg" | grep -i 'co-authored-by:.*claude' >/dev/null; then fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)" fi scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"} scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}" } scan_ci_mode() { local base="${1:-origin/main}" if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then base=$(git rev-list --max-parents=0 HEAD | tail -1) fi # In CI the confidentiality list is non-optional: a missing # file means an unbootstrapped runner is reviewing changes, # and we'd be silently letting confidential terms through. # The deploy step that wires up the runner is responsible for # placing the file at $FAI_BANNED_TERMS_FILE. if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist" fail " bootstrap the runner with a private confidentiality list before re-running" return fi local files diff files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD) diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \ | grep -E '^\+[^+]' || true) scan_filenames "$files" scan_diff "$diff" } case "$MODE" in content) scan_content_mode ;; message) scan_message_mode "${2:-}" ;; ci) scan_ci_mode "${2:-}" ;; *) echo "usage: $0 {content | message FILE | ci [BASE_REF]}" >&2 exit 2 ;; esac if [ "$FAILED" -ne 0 ]; then printf "\n%sSecurity check failed.%s\n" "$RED" "$RST" >&2 printf "Edit the staged files / commit message and re-stage.\n" >&2 printf "%sIf a match is a verified false positive, --no-verify bypasses the hook.\n" "$YEL" >&2 printf "Never bypass for real secrets — rotate them first.%s\n" "$RST" >&2 exit 1 fi pass "Security check passed."