#!/usr/bin/env bash # --------------------------------------------------------------------------- # Refuse to commit credentials. # # This repository stores Pi configuration for several agent scenarios. Some of # the files it mirrors (models.json, auth.json, *.env) legitimately contain # plaintext API keys on the live host. Templates with ${PLACEHOLDER} markers # belong in git; rendered files with real values never do. # # Usage: # scripts/verify-no-secrets.sh # scan staged changes (pre-commit) # scripts/verify-no-secrets.sh --all # scan every tracked file # # Install as a hook: # ln -sf ../../scripts/verify-no-secrets.sh .git/hooks/pre-commit # --------------------------------------------------------------------------- set -uo pipefail # Resolve the repository root via git, not via BASH_SOURCE: when this script is # invoked through the .git/hooks/pre-commit symlink, dirname(BASH_SOURCE)/.. # resolves to .git/ rather than the work tree. REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "verify-no-secrets: not inside a git work tree" >&2 exit 1 } cd "$REPO_ROOT" || exit 1 MODE="${1:-staged}" if [ "$MODE" = "--all" ]; then mapfile -t FILES < <(git ls-files) else mapfile -t FILES < <(git diff --cached --name-only --diff-filter=ACMR) fi if [ "${#FILES[@]}" -eq 0 ]; then echo "verify-no-secrets: nothing to scan" exit 0 fi FAILED=0 fail() { printf '\033[31mBLOCKED\033[0m %s\n %s\n' "$1" "$2" >&2 FAILED=1 } # --- Rule 1: filenames that must never be committed ------------------------ FORBIDDEN_NAMES='(^|/)(models\.json|auth\.json|trust\.json|models-store\.json)$|(^|/)\.env$|\.env\.[^/]*$|(^|/)id_(ed25519|rsa)|\.(pem|p12)$|\.rendered(\..*)?$' # Inside secrets/ only documentation and placeholder scaffolding may be tracked. SECRETS_ALLOWED='(^|/)secrets/(\.gitkeep|README\.md|.*\.(example|template))$' for f in "${FILES[@]}"; do # Templates and examples are the intended way to track credential-shaped files. case "$f" in *.env.example|*.env.template|*.example|*.template) continue ;; esac if printf '%s' "$f" | grep -qP "$FORBIDDEN_NAMES"; then fail "$f" "filename is on the never-commit list" continue fi if printf '%s' "$f" | grep -qP '(^|/)secrets/' \ && ! printf '%s' "$f" | grep -qP "$SECRETS_ALLOWED"; then fail "$f" "files under secrets/ may only be .gitkeep, README.md, *.example or *.template" fi done # --- Rule 2: high-confidence credential content ---------------------------- # Only applied to text files. Placeholders are explicitly tolerated. PLACEHOLDER='\$\{[A-Z_][A-Z0-9_]*\}|<[A-Z_][A-Z0-9_]*>|REDACTED|CHANGE_?ME|EXAMPLE|xxx+|\.\.\.' declare -a PATTERNS=( 'sk-[A-Za-z0-9]{20,}' # OpenAI-style 'sk-ant-[A-Za-z0-9_-]{20,}' # Anthropic 'ghp_[A-Za-z0-9]{30,}' # GitHub PAT 'gho_[A-Za-z0-9]{30,}' 'github_pat_[A-Za-z0-9_]{30,}' 'AIza[A-Za-z0-9_-]{30,}' # Google 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack '[0-9]{8,10}:AA[A-Za-z0-9_-]{30,}' # Telegram bot token 'tvly-[A-Za-z0-9]{20,}' # Tavily '-----BEGIN [A-Z ]*PRIVATE KEY-----' ) # key-ish assignment with a long opaque value. The value must carry entropy (a # digit or an uppercase letter): real secrets are base64/hex/random, while # snake_case source identifiers like `token=extraction_token` are not, and used # to trip this rule once the Python backend was vendored into the repo. ASSIGN='(?i)(api[_-]?key|apikey|secret|token|password|passwd|access[_-]?key)["'"'"' ]*[:=]["'"'"' ]*(?-i:(?=[A-Za-z0-9/_+=-]{16,})(?=[A-Za-z0-9/_+=-]*[A-Z0-9]))[A-Za-z0-9/_+=-]{16,}' for f in "${FILES[@]}"; do [ -f "$f" ] || continue # skip binaries if ! grep -Iq . "$f" 2>/dev/null; then continue; fi for pat in "${PATTERNS[@]}"; do if hit=$(grep -nP "$pat" "$f" 2>/dev/null | head -1); then [ -n "$hit" ] && fail "$f" "credential pattern: ${hit:0:120}" fi done if hit=$(grep -nP "$ASSIGN" "$f" 2>/dev/null | grep -vP "$PLACEHOLDER" | head -1); then [ -n "$hit" ] && fail "$f" "credential-like assignment: ${hit:0:120}" fi done if [ "$FAILED" -ne 0 ]; then cat >&2 <<'MSG' verify-no-secrets: commit refused. If a match is a false positive, either - replace the value with a ${PLACEHOLDER} and keep the file as a *.template, or - move the file outside the repository, or - add a narrow exception to scripts/verify-no-secrets.sh with a comment explaining why the value is not a credential. Do not bypass with --no-verify. MSG exit 1 fi echo "verify-no-secrets: ${#FILES[@]} file(s) scanned, clean" # Generated regions must match the code that generates them. A stale tool list in # a system prompt tells the model about tools that do not exist, or hides ones # that do, and the symptom is an agent answering from memory. REPO="$(git rev-parse --show-toplevel)" if [ -x "$REPO/scripts/verify-generated.sh" ]; then "$REPO/scripts/verify-generated.sh" || exit 1 fi exit 0