ci(hooks): pre-commit security check (mirror of fai/platform)

Mirrors the hook + script from `fai/platform`@1ebf893 verbatim
so commits to fai_studio go through the same gate: secrets,
forbidden filenames, confidentiality references, marketing-
speak, DCO sign-off, Conventional Commits subject, no Claude
co-author trailer.

A `.security-allow` file at the repo root extends the script's
universal excludes with three Studio-specific paths whose
content legitimately includes the filtered terms — the
Today-Hero proposal pipeline (LLM prompt + accept gate), its
runtime loader equivalent, and its operator-facing policy doc.

No CI mirror yet — Studio doesn't have a Forgejo workflow
(Flutter-on-DinD is a chunk of work). The local hook is the
gate for now; CI mirror follows when Flutter CI lands.

Activate once with `bash tools/install-hooks.sh`.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 13:32:26 +02:00
parent c389876a2f
commit 01f3f773cf
5 changed files with 290 additions and 0 deletions

18
tools/install-hooks.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# F∆I Platform — one-time hook activation for this clone.
# Points git at the versioned `.githooks/` directory so pre-commit and
# commit-msg checks run on every commit (including amends and rebases).
#
# Run once after cloning:
# bash tools/install-hooks.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
git config core.hooksPath .githooks
chmod +x .githooks/* tools/security/*.sh tools/install-hooks.sh
echo "✓ core.hooksPath = .githooks"
echo " Active hooks:"
ls -1 .githooks/ | sed 's/^/ /'
echo ""
echo "Next commit will be gated by tools/security/check-staged.sh."

246
tools/security/check-staged.sh Executable file
View 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."