|
| 1 | +#!/bin/bash |
| 2 | +# Shared helpers for the Claude Code security gate hooks (CodeMender and |
| 3 | +# Semgrep variants). Sourced, not executed directly. See threat_model.md |
| 4 | +# at the repo root for the design this implements (PASS / ADVISORY / |
| 5 | +# ERROR / BLOCKED outcomes, severity-based fail-open, and why `cm verify` |
| 6 | +# is never called on the happy path). |
| 7 | + |
| 8 | +# --- Config (override via env, or "env" in .claude/settings.json) --- |
| 9 | +: "${SECURITY_GATE_BLOCK_SEVERITY:=HIGH}" # findings at/above this rank block; below are advisory |
| 10 | +: "${SECURITY_GATE_ALLOW_ON_ERROR:=false}" # true = let the push through when the scanner itself fails to run |
| 11 | +: "${SECURITY_GATE_LARGE_FIX_LINES:=50}" # cm fix diffs bigger than this escalate instead of auto-committing |
| 12 | +: "${SECURITY_GATE_MAX_RETRIES:=1}" |
| 13 | +: "${SECURITY_GATE_TEST_CMD:=python3 -m unittest discover -s tests}" |
| 14 | +: "${SECURITY_GATE_NOTIFY_CMD:=}" # optional; receives a JSON event on stdin (e.g. a Slack/ticket webhook wrapper) |
| 15 | +: "${SECURITY_GATE_STATE_DB:=$HOME/.codemender/state.db}" |
| 16 | + |
| 17 | +_gate_repo_root() { |
| 18 | + git rev-parse --show-toplevel 2>/dev/null || pwd |
| 19 | +} |
| 20 | + |
| 21 | +: "${SECURITY_GATE_LOG:=$(_gate_repo_root)/.security-gate/findings-log.ndjson}" |
| 22 | + |
| 23 | +# --- Claude Code PreToolUse hook envelope --- |
| 24 | +allow() { |
| 25 | + jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}' |
| 26 | + exit 0 |
| 27 | +} |
| 28 | + |
| 29 | +deny() { |
| 30 | + local reason="$1" |
| 31 | + jq -n --arg reason "$reason" \ |
| 32 | + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}}' |
| 33 | + exit 0 |
| 34 | +} |
| 35 | + |
| 36 | +# --- Severity --- |
| 37 | +# Normalizes both CodeMender-style (CRITICAL/HIGH/MEDIUM/LOW) and |
| 38 | +# Semgrep-style (ERROR/WARNING/INFO) severities to a common 1-4 rank. |
| 39 | +# Unrecognized severities rank as 4 (blocking) - fail-safe, not fail-open, |
| 40 | +# for the one axis we can't verify against real tool output. |
| 41 | +severity_rank() { |
| 42 | + case "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" in |
| 43 | + CRITICAL) echo 4 ;; |
| 44 | + HIGH|ERROR) echo 3 ;; |
| 45 | + MEDIUM|WARNING) echo 2 ;; |
| 46 | + LOW|INFO) echo 1 ;; |
| 47 | + *) echo 4 ;; |
| 48 | + esac |
| 49 | +} |
| 50 | + |
| 51 | +is_blocking_severity() { |
| 52 | + [ "$(severity_rank "$1")" -ge "$(severity_rank "$SECURITY_GATE_BLOCK_SEVERITY")" ] |
| 53 | +} |
| 54 | + |
| 55 | +# --- Audit log --- |
| 56 | +# log_event EVENT TOOL EXTRA_JSON |
| 57 | +# EVENT: PASS | ADVISORY | ERROR | BLOCKED | FIXED |
| 58 | +# TOOL: codemender | semgrep |
| 59 | +# EXTRA_JSON: a jq object literal merged into the record (e.g. findings, reason) |
| 60 | +# Best-effort: a logging failure must never itself block or crash the hook. |
| 61 | +log_event() { |
| 62 | + local event="$1" tool="$2" extra="$3" |
| 63 | + # NOTE: intentionally not `extra="${3:-{}}"` - bash doesn't brace-match |
| 64 | + # inside a ${var:-word} default, so that idiom parses as `${3:-{}` |
| 65 | + # plus a stray literal `}` appended after every non-empty $3, producing |
| 66 | + # invalid JSON that jq would silently reject below. |
| 67 | + [ -z "$extra" ] && extra="{}" |
| 68 | + mkdir -p "$(dirname "$SECURITY_GATE_LOG")" 2>/dev/null || true |
| 69 | + if ! jq -n \ |
| 70 | + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| 71 | + --arg event "$event" \ |
| 72 | + --arg tool "$tool" \ |
| 73 | + --arg actor "$(git config user.email 2>/dev/null || echo unknown)" \ |
| 74 | + --arg commit "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ |
| 75 | + --argjson extra "$extra" \ |
| 76 | + '{ts:$ts, event:$event, tool:$tool, actor:$actor, commit:$commit} + $extra' \ |
| 77 | + >> "$SECURITY_GATE_LOG" 2>/dev/null; then |
| 78 | + echo "(warning: failed to write audit log entry to $SECURITY_GATE_LOG - continuing anyway, a logging failure must not block or silently pass a push)" >&2 |
| 79 | + fi |
| 80 | +} |
| 81 | + |
| 82 | +# notify EVENT MESSAGE EXTRA_JSON |
| 83 | +# Always prints a loud stderr banner (so it's visible in an interactive |
| 84 | +# session even without a notify command configured); also invokes |
| 85 | +# SECURITY_GATE_NOTIFY_CMD with a JSON payload on stdin if set, so another |
| 86 | +# team/channel can be looped in without depending on the developer to |
| 87 | +# relay it themselves. A failing notify command is logged, not fatal. |
| 88 | +notify() { |
| 89 | + local event="$1" message="$2" extra="$3" |
| 90 | + [ -z "$extra" ] && extra="{}" |
| 91 | + { |
| 92 | + echo "" |
| 93 | + echo "==== SECURITY GATE: $event ====" |
| 94 | + echo "$message" |
| 95 | + echo "================================" |
| 96 | + } >&2 |
| 97 | + if [ -n "$SECURITY_GATE_NOTIFY_CMD" ]; then |
| 98 | + local payload |
| 99 | + payload=$(jq -n --arg event "$event" --arg message "$message" --argjson extra "$extra" \ |
| 100 | + '{event:$event, message:$message} + $extra') |
| 101 | + if ! echo "$payload" | sh -c "$SECURITY_GATE_NOTIFY_CMD" >/dev/null 2>&1; then |
| 102 | + echo "(notify command failed - see $SECURITY_GATE_LOG for the record)" >&2 |
| 103 | + fi |
| 104 | + fi |
| 105 | +} |
| 106 | + |
| 107 | +# handle_scan_error TOOL REASON |
| 108 | +# The scanner itself couldn't produce a result (missing binary, auth |
| 109 | +# failure, crash, unparseable output) - this must never be treated the |
| 110 | +# same as "scan ran and found nothing" (see threat_model.md T1). |
| 111 | +handle_scan_error() { |
| 112 | + local tool="$1" reason="$2" |
| 113 | + log_event "ERROR" "$tool" "$(jq -n --arg r "$reason" '{reason:$r}')" |
| 114 | + notify "ERROR" "Security scan ($tool) could not run: $reason" "{}" |
| 115 | + if [ "$SECURITY_GATE_ALLOW_ON_ERROR" = "true" ]; then |
| 116 | + echo "SECURITY_GATE_ALLOW_ON_ERROR=true - allowing push despite scan failure (logged above)." >&2 |
| 117 | + allow |
| 118 | + fi |
| 119 | + deny "Security scan ($tool) failed to run: $reason |
| 120 | +Push blocked because the gate could not confirm the code is clean (not because a vulnerability was found). |
| 121 | +Set SECURITY_GATE_ALLOW_ON_ERROR=true to let scan failures through if that's the intended policy - not recommended by default. |
| 122 | +See $SECURITY_GATE_LOG for the record." |
| 123 | +} |
| 124 | + |
| 125 | +# Read a newline-separated file list into an array without word-splitting |
| 126 | +# on spaces (fixes a real bug in the previous `for f in $MODIFIED_FILES` |
| 127 | +# loop - filenames with spaces silently broke it). |
| 128 | +read_lines_into_array() { |
| 129 | + local __arr_name="$1" __input="$2" |
| 130 | + local __line |
| 131 | + eval "$__arr_name=()" |
| 132 | + while IFS= read -r __line; do |
| 133 | + [ -z "$__line" ] && continue |
| 134 | + eval "$__arr_name+=(\"\$__line\")" |
| 135 | + done <<< "$__input" |
| 136 | +} |
0 commit comments