Node 24 is the only runtime — drop bun from nix-build skill instructions (use `npm run --workspace=...`) and from lockfile-skip globs in the secret/ base64 scanners. flake.nix dev shell already lost bun in the prior snapshot commit. End-user-facing package-manager.ts still supports bun by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
7.5 KiB
Bash
Executable file
222 lines
7.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Secret scanner — detects hardcoded credentials in staged/changed files.
|
|
# Usage:
|
|
# scripts/secret-scan.sh # scan staged files (pre-commit mode)
|
|
# scripts/secret-scan.sh --diff HEAD # scan diff against HEAD (CI mode)
|
|
# scripts/secret-scan.sh --file path # scan a specific file
|
|
#
|
|
# Works on macOS (BSD grep) and Linux (GNU grep) — uses only ERE patterns.
|
|
|
|
set -euo pipefail
|
|
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m' # No Color
|
|
|
|
IGNOREFILE=".secretscanignore"
|
|
EXIT_CODE=0
|
|
|
|
# ── Pattern definitions ────────────────────────────────────────────────
|
|
# Each entry: "LABEL:::FLAGS:::REGEX"
|
|
# FLAGS: "" for default, "i" for case-insensitive (-i flag)
|
|
# All patterns use POSIX ERE (grep -E), no PCRE required.
|
|
PATTERNS=(
|
|
# AWS
|
|
"AWS Access Key::::::AKIA[0-9A-Z]{16}"
|
|
|
|
# Generic API keys / tokens (quoted strings that look like keys)
|
|
"Generic API Key:::i:::(api[_-]?key|apikey|api[_-]?secret)[[:space:]]*[:=][[:space:]]*['\"][0-9a-zA-Z_./-]{20,}['\"]"
|
|
"Generic Secret:::i:::(secret|token|password|passwd|pwd|credential)[[:space:]]*[:=][[:space:]]*['\"][^[:space:]'\"]{8,}['\"]"
|
|
"Authorization Header:::i:::(authorization|bearer)[[:space:]]*[:=][[:space:]]*['\"][^[:space:]'\"]{8,}['\"]"
|
|
|
|
# Private keys
|
|
"Private Key::::::-----BEGIN[[:space:]]+(RSA|DSA|EC|OPENSSH|PGP)[[:space:]]+PRIVATE[[:space:]]+KEY-----"
|
|
|
|
# Connection strings
|
|
"Database URL:::i:::(mysql|postgres|postgresql|mongodb|redis|amqp|mssql)://[^[:space:]'\"]{8,}"
|
|
|
|
# GitHub / GitLab tokens
|
|
"GitHub Token::::::gh[pousr]_[0-9a-zA-Z]{36,}"
|
|
"GitLab Token::::::glpat-[0-9a-zA-Z-]{20,}"
|
|
|
|
# Slack
|
|
"Slack Token::::::xox[baprs]-[0-9a-zA-Z-]{10,}"
|
|
"Slack Webhook::::::hooks\.slack\.com/services/T[0-9A-Z]{8,}/B[0-9A-Z]{8,}/[0-9a-zA-Z]{20,}"
|
|
|
|
# Google
|
|
"Google API Key::::::AIza[0-9A-Za-z_-]{35}"
|
|
|
|
# Stripe
|
|
"Stripe Key::::::[sr]k_(live|test)_[0-9a-zA-Z]{20,}"
|
|
|
|
# npm token
|
|
"npm Token::::::npm_[0-9a-zA-Z]{36,}"
|
|
|
|
# Hex-encoded secrets (high-entropy, 32+ hex chars assigned to a variable)
|
|
"Hex Secret:::i:::(secret|key|token|password)[[:space:]]*[:=][[:space:]]*['\"]?[0-9a-f]{32,}['\"]?"
|
|
|
|
# Hardcoded passwords in config-like files
|
|
"Hardcoded Password:::i:::password[[:space:]]*[:=][[:space:]]*['\"][^'\"]{4,}['\"]"
|
|
)
|
|
|
|
# ── Load ignorefile ────────────────────────────────────────────────────
|
|
load_ignore_patterns() {
|
|
local ignore_patterns=()
|
|
if [[ -f "$IGNOREFILE" ]]; then
|
|
while IFS= read -r line; do
|
|
# skip blank lines and comments
|
|
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
|
ignore_patterns+=("$line")
|
|
done < "$IGNOREFILE"
|
|
fi
|
|
echo "${ignore_patterns[@]+"${ignore_patterns[@]}"}"
|
|
}
|
|
|
|
is_ignored() {
|
|
local file="$1" line_content="$2"
|
|
local ignore_patterns
|
|
read -ra ignore_patterns <<< "$(load_ignore_patterns)"
|
|
|
|
for pattern in "${ignore_patterns[@]+"${ignore_patterns[@]}"}"; do
|
|
# Pattern can be "filepath:pattern" or just "pattern"
|
|
if [[ "$pattern" == *:* ]]; then
|
|
local ignore_file="${pattern%%:*}"
|
|
local ignore_regex="${pattern#*:}"
|
|
if [[ "$file" == $ignore_file ]] && echo "$line_content" | grep -qiE "$ignore_regex" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
else
|
|
if echo "$line_content" | grep -qiE "$pattern" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── Determine files to scan ───────────────────────────────────────────
|
|
get_files() {
|
|
if [[ "${1:-}" == "--diff" ]]; then
|
|
local ref="${2:-HEAD}"
|
|
git diff --name-only --diff-filter=ACMR "$ref" 2>/dev/null || true
|
|
elif [[ "${1:-}" == "--file" ]]; then
|
|
echo "${2:-}"
|
|
else
|
|
# Pre-commit mode: staged files only
|
|
git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ── File-type filter (skip binaries and known safe files) ─────────────
|
|
should_scan() {
|
|
local file="$1"
|
|
# Skip binary extensions
|
|
case "$file" in
|
|
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.svg|*.woff|*.woff2|*.ttf|*.eot|\
|
|
*.zip|*.tar|*.gz|*.tgz|*.bz2|*.7z|*.rar|\
|
|
*.exe|*.dll|*.so|*.dylib|*.o|*.a|\
|
|
*.pdf|*.doc|*.docx|*.xls|*.xlsx|\
|
|
*.lock|package-lock.json|pnpm-lock.yaml|\
|
|
*.min.js|*.min.css|*.map|\
|
|
*.node|*.wasm)
|
|
return 1 ;;
|
|
esac
|
|
# Skip known non-secret files
|
|
case "$file" in
|
|
.secretscanignore|.gitignore|.gitattributes|LICENSE*|CHANGELOG*|*.md)
|
|
return 1 ;;
|
|
esac
|
|
# Skip node_modules, dist, coverage
|
|
case "$file" in
|
|
node_modules/*|dist/*|coverage/*|.sf/*)
|
|
return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# ── Get content to scan ───────────────────────────────────────────────
|
|
get_content() {
|
|
local file="$1"
|
|
if [[ "${SCAN_MODE:-staged}" == "staged" ]]; then
|
|
# For pre-commit, scan the staged version
|
|
git show ":$file" 2>/dev/null || cat "$file" 2>/dev/null || true
|
|
else
|
|
cat "$file" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ── Main scan ─────────────────────────────────────────────────────────
|
|
SCAN_MODE="staged"
|
|
FILES_ARG=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--diff) SCAN_MODE="diff"; FILES_ARG=("--diff" "${2:-HEAD}"); shift 2 ;;
|
|
--file) SCAN_MODE="file"; FILES_ARG=("--file" "$2"); shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
FILES=$(get_files "${FILES_ARG[@]+"${FILES_ARG[@]}"}")
|
|
FINDINGS=0
|
|
|
|
if [[ -z "$FILES" ]]; then
|
|
echo "secret-scan: no files to scan"
|
|
exit 0
|
|
fi
|
|
|
|
while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
should_scan "$file" || continue
|
|
|
|
content=$(get_content "$file")
|
|
[[ -z "$content" ]] && continue
|
|
|
|
for entry in "${PATTERNS[@]}"; do
|
|
label="${entry%%:::*}"
|
|
rest="${entry#*:::}"
|
|
flags="${rest%%:::*}"
|
|
regex="${rest#*:::}"
|
|
|
|
# Build grep flags
|
|
grep_flags="-nE"
|
|
if [[ "$flags" == *i* ]]; then
|
|
grep_flags="-niE"
|
|
fi
|
|
|
|
matches=$(echo "$content" | grep $grep_flags -e "$regex" 2>/dev/null || true)
|
|
|
|
if [[ -n "$matches" ]]; then
|
|
while IFS= read -r match_line; do
|
|
[[ -z "$match_line" ]] && continue
|
|
line_num="${match_line%%:*}"
|
|
line_content="${match_line#*:}"
|
|
|
|
# Check ignorefile
|
|
if is_ignored "$file" "$line_content"; then
|
|
continue
|
|
fi
|
|
|
|
# Mask the actual secret value in output
|
|
echo -e "${RED}[SECRET DETECTED]${NC} ${YELLOW}${label}${NC}"
|
|
echo " File: $file:$line_num"
|
|
echo " Line: $(echo "$line_content" | head -c 120)..."
|
|
echo ""
|
|
FINDINGS=$((FINDINGS + 1))
|
|
EXIT_CODE=1
|
|
done <<< "$matches"
|
|
fi
|
|
done
|
|
done <<< "$FILES"
|
|
|
|
if [[ $FINDINGS -gt 0 ]]; then
|
|
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
echo -e "${RED}Found $FINDINGS potential secret(s) in staged files.${NC}"
|
|
echo -e "${RED}Commit blocked. Remove the secrets or add exceptions${NC}"
|
|
echo -e "${RED}to .secretscanignore if these are false positives.${NC}"
|
|
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
else
|
|
echo "secret-scan: no secrets detected ✓"
|
|
fi
|
|
|
|
exit $EXIT_CODE
|