diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..2577592 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# F∆I Platform — commit-msg hook. +# Validates the commit message: Conventional Commits subject, DCO +# sign-off, no Claude co-author trailer, no banned phrases. +exec "$(git rev-parse --show-toplevel)/tools/security/check-staged.sh" message "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..d8022a4 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# F∆I Platform — pre-commit hook. +# Activated via `tools/install-hooks.sh` (sets core.hooksPath=.githooks). +# Scans staged files for secrets, banned phrases, forbidden filenames. +# Same script runs in Forgejo CI — see .forgejo/workflows/ci.yml. +exec "$(git rev-parse --show-toplevel)/tools/security/check-staged.sh" content diff --git a/.security-allow b/.security-allow new file mode 100644 index 0000000..c2cee2e --- /dev/null +++ b/.security-allow @@ -0,0 +1,15 @@ +# Studio-specific paths that legitimately mention banned phrases or +# confidentiality terms, beyond the universal `tools/security/` and +# `.githooks/` excludes baked into the security script. +# +# `tools/today/` — the Today-Hero proposal pipeline runs an LLM under +# a prompt that explicitly lists banned words; the +# accept-script implements a banned-words gate. Both +# files MUST contain the verbatim words. +# `lib/data/today_story_loader.dart` — the runtime banned-words gate +# that double-checks accepted stories at load time. +# `docs/today-pipeline.md` — the operator-facing policy doc that +# documents the gate, including the verbatim list. +tools/today/ +lib/data/today_story_loader.dart +docs/today-pipeline.md diff --git a/tools/install-hooks.sh b/tools/install-hooks.sh new file mode 100755 index 0000000..d5299a0 --- /dev/null +++ b/tools/install-hooks.sh @@ -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." diff --git a/tools/security/check-staged.sh b/tools/security/check-staged.sh new file mode 100755 index 0000000..2a90a42 --- /dev/null +++ b/tools/security/check-staged.sh @@ -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."