#!/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)$'
)

# Confidential terms are loaded at runtime from a file outside
# the repo (~/.fai-security/banned-terms.txt by default; override
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
# whole point of the gate is to keep certain strings out of any
# committed artefact, so the list of strings to gate on must not
# live in a committed artefact either.
#
# File format: one regex per line. Lines starting with `#` and
# blank lines are ignored. Each line is matched as a
# case-insensitive grep -E pattern with `-i`; use `\b…\b` for
# word boundaries.
#
# Bootstrap on a fresh machine:
#   mkdir -p ~/.fai-security
#   chmod 700 ~/.fai-security
#   printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
#   chmod 600 ~/.fai-security/banned-terms.txt
#
# When the file is missing the gate logs a warning but does NOT
# fail — running on a fresh checkout where the operator hasn't
# yet bootstrapped the list shouldn't block them. Real CI runs
# explicitly verify the file is present and refuse otherwise
# (see scan_ci_mode below).
CONFIDENTIAL_TERMS=()
_load_confidential_terms() {
  local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}"
  if [ ! -f "$file" ]; then
    return 1
  fi
  while IFS= read -r line; do
    # Strip trailing comments + leading/trailing whitespace.
    line="${line%%#*}"
    line="${line#"${line%%[![:space:]]*}"}"
    line="${line%"${line##*[![:space:]]}"}"
    [ -z "$line" ] && continue
    CONFIDENTIAL_TERMS+=("$line")
  done < "$file"
  return 0
}
if ! _load_confidential_terms; then
  printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \
    "${YEL:-}" "${RST:-}" >&2
fi

# 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"
  # `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion
  # for arrays that may be empty (CONFIDENTIAL_TERMS is empty
  # when the operator hasn't bootstrapped ~/.fai-security/).
  scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${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[@]+"${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
  # In CI the confidentiality list is non-optional: a missing
  # file means an unbootstrapped runner is reviewing changes,
  # and we'd be silently letting confidential terms through.
  # The deploy step that wires up the runner is responsible for
  # placing the file at $FAI_BANNED_TERMS_FILE.
  if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then
    fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist"
    fail "  bootstrap the runner with a private confidentiality list before re-running"
    return
  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."
