The audit passed for months while two live Anthropic keys and the fleet SSH password sat in tracked files. Three independent reasons: - ALLOW_PATTERNS was matched against the whole "file:line:content" string, not the path, so bare words like "test", "demo" and "example" dropped any hit whose *content* merely mentioned them. - `\.md$` was in that same allowlist and `--include` never listed *.md or *.yml, so docs and CI workflows — where every real leak has lived — were never scanned at all. - The false-positive filter spelled the single-quote class `\x27\x27`, which GNU grep does not expand in an ERE, so the empty-string rule never fired. Now: scans tracked files via `git ls-files` (exactly the set that would be published), covers md/yml/mjs/kt/toml, allowlists by path only, and adds patterns for credentialed URLs and inline `sshpass -p`. Test fixtures under testdata/ are exempted narrowly rather than by substring. Verified by planting canary secrets in docs/api-reference.md and .gitea/workflows/build-iso.yml — both file types the old version ignored — and confirming the audit fails on them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
Bash
Executable File
143 lines
5.4 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
# SEC-202: Secrets audit — checks for hardcoded credentials in the codebase.
|
|
# Scans source files for common secret patterns.
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
PASS=0
|
|
FAIL=0
|
|
RESULTS=()
|
|
|
|
log() { echo -e "\033[1;34m[AUDIT]\033[0m $*"; }
|
|
pass() { echo -e "\033[1;32m[PASS]\033[0m $*"; PASS=$((PASS + 1)); RESULTS+=("PASS: $*"); }
|
|
fail() { echo -e "\033[1;31m[FAIL]\033[0m $*"; FAIL=$((FAIL + 1)); RESULTS+=("FAIL: $*"); }
|
|
|
|
# Patterns to search for (case insensitive)
|
|
PATTERNS=(
|
|
"password\s*=\s*['\"][^'\"]*['\"]"
|
|
"api_key\s*=\s*['\"][^'\"]*['\"]"
|
|
"secret\s*=\s*['\"][^'\"]*['\"]"
|
|
"private_key\s*=\s*['\"][^'\"]*['\"]"
|
|
"sk-ant-[A-Za-z0-9_-]{20,}"
|
|
"AKIA[A-Z0-9]{16}"
|
|
"ghp_[a-zA-Z0-9]{36}"
|
|
"glpat-[a-zA-Z0-9_-]{20}"
|
|
# Credentialed URLs: scheme://user:pass@host
|
|
"://[A-Za-z0-9_.-]+:[A-Za-z0-9_.@!%-]{8,}@"
|
|
# sshpass with an inline literal
|
|
"sshpass\s+-p\s*['\"][^'\"]+['\"]"
|
|
)
|
|
|
|
# Path allowlist — anchored to the PATH only, never to line content.
|
|
# The old version allow-matched the whole "file:line:content" string against
|
|
# bare words like "test" and "\.md$", so any hit whose path or text contained
|
|
# "test"/"demo"/"example" was silently dropped, and *.md was never scanned at
|
|
# all. That is why live API keys and node passwords survived this audit.
|
|
ALLOW_PATHS="(^|/)node_modules/|(^|/)(dist|target|\.git)/|\.example($|\.)|(^|/)package-lock\.json$|(^|/)Cargo\.lock$|(^|/)scripts/audit-secrets\.sh$"
|
|
|
|
# File types to scan. Markdown and YAML are in scope: docs and CI workflows are
|
|
# where the real leaks have historically lived.
|
|
SCAN_EXTS='\.(rs|ts|vue|js|mjs|cjs|json|sh|py|md|ya?ml|toml|kt|java|gradle|env)$'
|
|
|
|
main() {
|
|
log "=== Secrets Audit ==="
|
|
echo ""
|
|
|
|
# 1. Check for .env files in version control
|
|
log "1. Checking for .env files in git..."
|
|
local env_files
|
|
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
|
|
if [ -z "$env_files" ]; then
|
|
pass "No .env files tracked in git"
|
|
else
|
|
fail "Found .env files in git: $env_files"
|
|
fi
|
|
|
|
# 2. Check .gitignore includes sensitive patterns
|
|
log "2. Checking .gitignore coverage..."
|
|
local gitignore="$REPO_ROOT/.gitignore"
|
|
if [ -f "$gitignore" ]; then
|
|
local has_env has_key
|
|
has_env=$(grep -c '\.env' "$gitignore" || echo 0)
|
|
has_key=$(grep -c 'credentials\|\.key\|\.pem' "$gitignore" || echo 0)
|
|
if [ "$has_env" -gt 0 ]; then
|
|
pass ".gitignore covers .env files"
|
|
else
|
|
fail ".gitignore missing .env pattern"
|
|
fi
|
|
else
|
|
fail "No .gitignore found"
|
|
fi
|
|
|
|
# 3. Scan source for hardcoded credentials
|
|
log "3. Scanning source for hardcoded secrets..."
|
|
local found_secrets=0
|
|
# Scan TRACKED files only — that is exactly the set that would be published.
|
|
local scan_files
|
|
scan_files=$(cd "$REPO_ROOT" && git ls-files | grep -E "$SCAN_EXTS" | grep -vE "$ALLOW_PATHS" || echo "")
|
|
if [ -z "$scan_files" ]; then
|
|
fail "No tracked files matched the scan set (is this a git repo?)"
|
|
return 1
|
|
fi
|
|
for pattern in "${PATTERNS[@]}"; do
|
|
local matches
|
|
matches=$(cd "$REPO_ROOT" && echo "$scan_files" | tr '\n' '\0' \
|
|
| xargs -0 grep -niE "$pattern" 2>/dev/null || echo "")
|
|
if [ -n "$matches" ]; then
|
|
# Filter out false positives: empty strings, variable indirection, and
|
|
# scrubbed <PLACEHOLDER> tokens. NOTE: the previous version wrote the
|
|
# single-quote class as \x27\x27, which GNU grep does not expand in an
|
|
# ERE — so the empty-string rule silently never matched. Use a literal
|
|
# quote via a shell variable instead.
|
|
local q="'"
|
|
local real_matches
|
|
real_matches=$(echo "$matches" | grep -vE "\"\"|${q}${q}|<[A-Z_]+>|None|null|undefined|TODO|placeholder|Option<|\\\$\{[A-Za-z0-9_]+(:-[^}]*)?\}|\\\$[A-Za-z0-9_]+|TestPassword|password123|entertoexit|…|\\.\\.\\." || echo "")
|
|
if [ -n "$real_matches" ]; then
|
|
echo " WARNING: Pattern '$pattern' found:"
|
|
echo "$real_matches" | head -5 | sed 's/^/ /'
|
|
found_secrets=$((found_secrets + 1))
|
|
fi
|
|
fi
|
|
done
|
|
if [ "$found_secrets" -eq 0 ]; then
|
|
pass "No hardcoded secrets found in source"
|
|
else
|
|
fail "Found $found_secrets secret pattern matches (review above)"
|
|
fi
|
|
|
|
# 4. Check deploy-config is gitignored
|
|
log "4. Checking deploy-config.sh is gitignored..."
|
|
if cd "$REPO_ROOT" && git check-ignore scripts/deploy-config.sh > /dev/null 2>&1; then
|
|
pass "scripts/deploy-config.sh is gitignored"
|
|
elif [ -f "$REPO_ROOT/scripts/deploy-config.sh" ]; then
|
|
fail "scripts/deploy-config.sh exists but is NOT gitignored"
|
|
else
|
|
pass "scripts/deploy-config.sh does not exist (using env vars)"
|
|
fi
|
|
|
|
# 5. Check for credential files in repo
|
|
log "5. Checking for credential files..."
|
|
local cred_files
|
|
# `testdata/` holds throwaway keypairs generated for unit tests (appgate TLS);
|
|
# they are not credentials for anything real. Narrow, path-anchored exemption.
|
|
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts|sh)$|(^|/)testdata/' || echo "")
|
|
if [ -z "$cred_files" ]; then
|
|
pass "No credential files tracked in git"
|
|
else
|
|
fail "Credential files in git: $cred_files"
|
|
fi
|
|
|
|
echo ""
|
|
log "=== RESULTS ==="
|
|
for r in "${RESULTS[@]}"; do
|
|
echo " $r"
|
|
done
|
|
echo ""
|
|
log "Pass: $PASS | Fail: $FAIL"
|
|
|
|
[ $FAIL -gt 0 ] && exit 1
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|