#!/usr/bin/env bash
# secret-leak-scanner.sh -- PreToolUse hook
# Scans file write content for API keys, tokens, passwords, .env patterns.
# Prevents accidental secret commits.
# Exit code 2 = block. Exit code 0 = allow.
#
# Part of @connexum/ai-governance

set -euo pipefail

INPUT=$(cat)

# AIAG-018: enforce 1MB input size limit
if [ "${#INPUT}" -gt 1048576 ]; then
  echo "BLOCKED: Hook input exceeds 1MB. Possible injection attempt." >&2
  exit 2
fi

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty' 2>/dev/null)

# Only gate write tools and git commands
case "$TOOL_NAME" in
  Write|Edit|NotebookEdit) ;;
  Bash)
    # Check for git add of sensitive files
    COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // empty' 2>/dev/null)
    if [ -z "$COMMAND" ]; then
      exit 0
    fi
    # Block git add of .env files
    if echo "$COMMAND" | grep -qE "git\s+add\s+.*\.env\b"; then
      echo "BLOCKED: Attempted to stage .env file for commit. Secrets must not be committed." >&2
      exit 2
    fi
    # Block git add of common credential files
    SENSITIVE_FILES="credentials\.json|service-account\.json|\.pem$|\.key$|id_rsa|id_ed25519"
    if echo "$COMMAND" | grep -qE "git\s+add\s+.*($SENSITIVE_FILES)"; then
      echo "BLOCKED: Attempted to stage sensitive credential file for commit." >&2
      exit 2
    fi
    exit 0
    ;;
  *) exit 0 ;;
esac

# Get content being written
CONTENT=$(echo "$TOOL_INPUT" | jq -r '.content // .new_string // empty' 2>/dev/null)
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty' 2>/dev/null)

if [ -z "$CONTENT" ]; then
  exit 0
fi

# Note: .sh and .md files are NOT exempt from secret scanning.
# All files are scanned equally for API keys and secrets.
# PHI/PII scanning (below) excludes test and hook files to avoid false positives.
FILENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "")

# Secret patterns (high confidence only to avoid false positives)
# Format: regex | description
SECRET_PATTERNS=(
  "sk-[a-zA-Z0-9]{20,}:Anthropic/OpenAI API key"
  "AKIA[0-9A-Z]{16}:AWS Access Key ID"
  "ghp_[a-zA-Z0-9]{36}:GitHub Personal Access Token"
  "gho_[a-zA-Z0-9]{36}:GitHub OAuth Token"
  "glpat-[a-zA-Z0-9_-]{20,}:GitLab Personal Access Token"
  "xoxb-[0-9]{10,}-[a-zA-Z0-9]{20,}:Slack Bot Token"
  "xoxp-[0-9]{10,}-[a-zA-Z0-9]{20,}:Slack User Token"
  "SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}:SendGrid API Key"
  "sq0atp-[a-zA-Z0-9_-]{22}:Square Access Token"
  "sk_live_[a-zA-Z0-9]{24,}:Stripe Live Secret Key"
  "rk_live_[a-zA-Z0-9]{24,}:Stripe Live Restricted Key"
  "pk_live_[a-zA-Z0-9]{24,}:Stripe Live Publishable Key"
  "-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----:Private Key"
  # F-NEW-CRIT-36 residual fix (2026-05-09): the PHI/PII array below was
  # fixed in 2026-05-02 to use POSIX ERE [[:space:]] instead of PCRE \s,
  # but these two SECRET_PATTERNS entries kept the PCRE form. Under
  # `grep -E` (POSIX ERE), \s is literal "backslash-s" and matches
  # nothing, so DATABASE_URL leaks were not blocked. Same applies to
  # RESEND_API_KEY. Pattern bodies also had \s in the negated class
  # ([^\"'\s]); replaced with explicit space + tab.
  "DATABASE_URL[[:space:]]*=[[:space:]]*postgres(ql)?://[^\"' \t]+@[^\"' \t]+:Database connection string with credentials"
  "RESEND_API_KEY[[:space:]]*=[[:space:]]*re_[a-zA-Z0-9]{20,}:Resend API Key"
)

# PHI/PII patterns (HIPAA, GDPR, PCI DSS).
#
# F-NEW-CRIT-36 (2026-05-02): patterns use POSIX ERE syntax so grep -E
# actually evaluates them. The original patterns used PCRE escapes
# (\b, \s) that POSIX ERE does NOT recognize. \b becomes literal
# 'backslash-b' and \s becomes literal 'backslash-s'. Result:
# patterns silently failed to match on customer's PHI write attempts.
# Fixed by replacing \s with [[:space:]] (POSIX whitespace class)
# and removing the non-portable \b word-boundary marker.
PHI_PII_PATTERNS=(
  "[0-9]{3}-[0-9]{2}-[0-9]{4}:SSN (Social Security Number)"
  "[0-9]{4}[[:space:]-]?[0-9]{4}[[:space:]-]?[0-9]{4}[[:space:]-]?[0-9]{4}:Possible credit card number (PCI DSS)"
  "MRN[:[:space:]]*[0-9]{6,}:Medical Record Number (HIPAA PHI)"
  "DOB[:[:space:]]*[0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4}:Date of Birth (PHI/PII)"
  "patient[_[:space:]]?(name|id|ssn|dob)[:[:space:]]*[A-Za-z0-9]:Patient identifier (HIPAA PHI)"
  "health[_[:space:]]?plan[_[:space:]]?(id|number)[:[:space:]]*[A-Za-z0-9]:Health Plan ID (HIPAA PHI)"
)

for entry in "${SECRET_PATTERNS[@]}"; do
  # F-NEW-CRIT-36: split on the LAST colon, not the first. Several
  # patterns contain `[:\s]` (bracket expression with embedded colon);
  # %%:* (longest-from-left) cuts INSIDE the bracket, producing an
  # invalid regex that grep silently ignores -> customer thinks they're
  # protected but secrets sail through. Use %:* (shortest-from-right)
  # so only the trailing description gets stripped.
  PATTERN="${entry%:*}"
  DESCRIPTION="${entry##*:}"

  if echo "$CONTENT" | grep -qE -- "$PATTERN"; then
    echo "BLOCKED: Secret detected in file write -- $DESCRIPTION" >&2
    echo "File: $FILE_PATH" >&2
    echo "Remove the secret and use environment variables instead." >&2
    exit 2
  fi
done

# T-040: If DataClassifier bridge is available, run full TypeScript scan
#
# AIAG-015: Sanitize HOOK_DIR before constructing the bridge path.
# HOOK_DIR arrives from the hook runner and must be an absolute path
# without `..` segments; otherwise an attacker controlling the env
# could point at a malicious script that would then be executed by tsx.
#
# AIAG-015 follow-up (2026-04-30): the structural check above is a
# TYPE check (abs/no-`..`), not an allowlist. An attacker who can set
# HOOK_DIR to ANY absolute clean path containing a hostile
# data-classifier-bridge.ts can still get arbitrary code execution.
# Two operator-controlled env vars now close that gap:
#
#   CXNI_HOOK_DIR_ALLOWLIST  Colon-separated list of trusted absolute
#                            HOOK_DIRs. If set, HOOK_DIR (canonicalized)
#                            must be exactly equal to one of the entries
#                            (also canonicalized). Mismatch -> SAFE_HOOK_DIR
#                            cleared, bridge skipped, warning emitted.
#   CXNI_HOOK_DIR_STRICT     "true" -> if CXNI_HOOK_DIR_ALLOWLIST is unset,
#                            refuse all HOOK_DIRs (fail-closed). Default
#                            "false" preserves backward compatibility for
#                            existing deployments and emits a one-time
#                            advisory to stderr when HOOK_DIR is used
#                            without an allowlist.
HOOK_DIR="${HOOK_DIR:-}"
SAFE_HOOK_DIR=""
if [[ -n "$HOOK_DIR" ]]; then
  case "$HOOK_DIR" in
    /*)
      # Absolute path. Reject any `..` segment.
      if [[ "$HOOK_DIR" != *..* ]]; then
        # Resolve to canonical form if realpath is available (hardens against symlinks).
        if command -v realpath &>/dev/null; then
          SAFE_HOOK_DIR="$(realpath -m "$HOOK_DIR" 2>/dev/null || echo "$HOOK_DIR")"
        else
          SAFE_HOOK_DIR="$HOOK_DIR"
        fi
      fi
      ;;
    *)
      # Relative path: refuse; too easy to poison via cwd.
      SAFE_HOOK_DIR=""
      ;;
  esac
fi

# AIAG-015 follow-up: enforce CXNI_HOOK_DIR_ALLOWLIST if set, or fail-closed
# under CXNI_HOOK_DIR_STRICT=true.
if [[ -n "$SAFE_HOOK_DIR" ]]; then
  HOOK_ALLOWLIST="${CXNI_HOOK_DIR_ALLOWLIST:-}"
  HOOK_STRICT="${CXNI_HOOK_DIR_STRICT:-false}"
  if [[ -n "$HOOK_ALLOWLIST" ]]; then
    # Canonicalize each allowlist entry and compare against SAFE_HOOK_DIR.
    HOOK_ALLOWED=0
    OLD_IFS="$IFS"
    IFS=':'
    for ENTRY in $HOOK_ALLOWLIST; do
      [[ -z "$ENTRY" ]] && continue
      if command -v realpath &>/dev/null; then
        CANON_ENTRY="$(realpath -m "$ENTRY" 2>/dev/null || echo "$ENTRY")"
      else
        CANON_ENTRY="$ENTRY"
      fi
      if [[ "$SAFE_HOOK_DIR" == "$CANON_ENTRY" ]]; then
        HOOK_ALLOWED=1
        break
      fi
    done
    IFS="$OLD_IFS"
    if [[ "$HOOK_ALLOWED" -ne 1 ]]; then
      echo "WARNING: HOOK_DIR '$SAFE_HOOK_DIR' is not in CXNI_HOOK_DIR_ALLOWLIST. Bridge skipped." >&2
      SAFE_HOOK_DIR=""
    fi
  elif [[ "$HOOK_STRICT" == "true" ]]; then
    echo "WARNING: HOOK_DIR set but CXNI_HOOK_DIR_ALLOWLIST is empty under STRICT mode. Bridge skipped." >&2
    SAFE_HOOK_DIR=""
  else
    # Backward-compat advisory: HOOK_DIR is honored but no allowlist is in
    # force. Recommend operators set CXNI_HOOK_DIR_ALLOWLIST in production.
    echo "ADVISORY: HOOK_DIR honored without an allowlist. Set CXNI_HOOK_DIR_ALLOWLIST in production deployments." >&2
  fi
fi

BRIDGE_SCRIPT=""
if [[ -n "$SAFE_HOOK_DIR" ]]; then
  BRIDGE_SCRIPT="$SAFE_HOOK_DIR/data-classifier-bridge.ts"
fi

# F-NEW-PERF-SLS-NODE-BRIDGE (2026-05-16): the bridge invocation below was
# the dominant cost of the Write+bridge path (~470ms cold start per call
# under `npx tsx`). The TypeScript transpile-on-each-call dwarfs the
# DataClassifier scan itself (~5-10ms). When the precompiled .js exists
# alongside the .ts (post-`npm run build`), use `node` directly: the same
# bridge logic runs as compiled JS in ~20ms cold start — a 23x speedup
# observed locally (`time` measurements at the head of this commit).
#
# Resolution order:
#   1. ${BRIDGE_SCRIPT%.ts}.js  (.js sibling — production / installed case)
#   2. dist/hooks/-equivalent  (dev case where HOOK_DIR points at src/hooks/
#                              and tsc has placed the compiled .js in dist/hooks/)
#
# Falls back to tsx on the .ts when no .js is found — preserves dev-mode
# behavior for un-built source trees. Same scan logic, same JSON output,
# same exit-code semantics.
#
# Security: the dev-case substitution only swaps the directory NAME
# ("/src/hooks/" -> "/dist/hooks/") inside a path that already passed
# AIAG-015 canonicalization and (optionally) the CXNI_HOOK_DIR_ALLOWLIST
# check. No `..` traversal is introduced. The .js path must still pass
# the [[ -f ... ]] file-existence check before invocation.
BRIDGE_JS=""
if [[ -n "$BRIDGE_SCRIPT" ]]; then
  CANDIDATE_SIBLING="${BRIDGE_SCRIPT%.ts}.js"
  if [[ -f "$CANDIDATE_SIBLING" ]]; then
    BRIDGE_JS="$CANDIDATE_SIBLING"
  elif [[ "$BRIDGE_SCRIPT" == */src/hooks/* ]]; then
    CANDIDATE_DIST="${BRIDGE_SCRIPT//\/src\/hooks\//\/dist\/hooks\/}"
    CANDIDATE_DIST="${CANDIDATE_DIST%.ts}.js"
    if [[ -f "$CANDIDATE_DIST" ]]; then
      BRIDGE_JS="$CANDIDATE_DIST"
    fi
  fi
fi

if [[ -n "$BRIDGE_JS" ]] && command -v node &>/dev/null; then
  # Fast path: precompiled JS via node-direct (~20ms cold start).
  BRIDGE_RESULT=$(echo "{\"content\":$(echo "$CONTENT" | jq -Rs .),\"file\":\"$FILE_PATH\"}" | node "$BRIDGE_JS" 2>/dev/null || true)
elif [[ -n "$BRIDGE_SCRIPT" ]] && [[ -f "$BRIDGE_SCRIPT" ]] && command -v npx &>/dev/null; then
  # Fallback: source .ts via tsx (~470ms cold start). Preserved so an
  # unbuilt source checkout still gets the full bridge scan.
  BRIDGE_RESULT=$(echo "{\"content\":$(echo "$CONTENT" | jq -Rs .),\"file\":\"$FILE_PATH\"}" | npx --quiet tsx "$BRIDGE_SCRIPT" 2>/dev/null || true)
else
  BRIDGE_RESULT=""
fi

if [[ -n "$BRIDGE_RESULT" ]]; then
  BRIDGE_ACTION=$(echo "$BRIDGE_RESULT" | jq -r '.action // empty' 2>/dev/null || true)
  if [[ "$BRIDGE_ACTION" == "BLOCK" ]]; then
    FINDING_COUNT=$(echo "$BRIDGE_RESULT" | jq -r '.findingCount // 0' 2>/dev/null || echo "0")
    echo "BLOCKED: DataClassifier detected $FINDING_COUNT sensitive data items" >&2
    echo "File: $FILE_PATH" >&2
    echo "$BRIDGE_RESULT" | jq -r '.findings[] | "  - \(.type) (\(.category)): \(.redacted)"' 2>/dev/null >&2 || true
    exit 2
  fi
fi

# Scan for PHI/PII in non-test, non-hook files (fallback regex scan)
case "$FILENAME" in
  *.test.*|*.spec.*|*.sh|*.md|data-classifier.ts|encryption-layer.ts)
    # Skip files that legitimately reference PHI patterns (tests, hooks, classifiers)
    ;;
  *)
    for entry in "${PHI_PII_PATTERNS[@]}"; do
      # F-NEW-CRIT-36: split on the LAST colon (see SECRET_PATTERNS loop above).
      # PHI patterns contain `[:\s]` bracket expressions; the old %%:* split
      # broke MRN/DOB/patient/health_plan patterns silently.
      PATTERN="${entry%:*}"
      DESCRIPTION="${entry##*:}"

      if echo "$CONTENT" | grep -qEi -- "$PATTERN"; then
        # PHI/PII detection: BLOCK by default to align with HIPAA pack requirements.
        echo "BLOCKED: Possible sensitive data detected -- $DESCRIPTION" >&2
        echo "File: $FILE_PATH" >&2
        echo "Ensure this data is redacted or encrypted before storage." >&2
        exit 2
      fi
    done
    ;;
esac

exit 0
