ci(hooks): pre-commit security check (mirror of fai/platform)
Mirrors the hook + script from `fai/platform`@1ebf893 verbatim so commits to fai_dart_sdk go through the same gate: secrets, forbidden filenames, confidentiality references, marketing- speak, DCO sign-off, Conventional Commits subject, no Claude co-author trailer. No `.security-allow` needed here — the SDK has no banned-words gate or policy doc that mentions the filtered terms verbatim. A full-history dry-run came back clean. Activate once per clone with `bash tools/install-hooks.sh`. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
5c96d74744
commit
99ef83ccf0
4 changed files with 275 additions and 0 deletions
246
tools/security/check-staged.sh
Executable file
246
tools/security/check-staged.sh
Executable file
|
|
@ -0,0 +1,246 @@
|
|||
#!/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)$'
|
||||
)
|
||||
|
||||
# Memory: confidentiality.md — these are PRIVATE. Never in public
|
||||
# code / docs / commits / examples. Word-boundary matched to avoid
|
||||
# false-positives on substrings.
|
||||
CONFIDENTIAL_TERMS=(
|
||||
'\bITDZ\b'
|
||||
'\bKammergericht\b'
|
||||
'\bHTW\b'
|
||||
'J∆I'
|
||||
'\bJAI\b'
|
||||
)
|
||||
|
||||
# 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"
|
||||
scan_terms_ci "confidential term" "$diff" "${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[@]}"
|
||||
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
|
||||
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."
|
||||
Loading…
Add table
Add a link
Reference in a new issue