From 7e5ce1f5b2f87ca36c7876aa202299cc2ecea433 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 15 Jun 2026 15:52:15 -0400 Subject: [PATCH] Add security-review gate (review.sh + scanners + pre-commit hook) Trigger-agnostic pure-code gate that merges deterministic-scanner findings (semgrep/gitleaks/checkov/cfn-lint/pip-audit) with agent findings from /sh-security-review, dedups, applies justification-required suppressions, and makes the block decision (exit 1 on confirmed critical/high). Phase 2 of the Sea Haven security-review agent; Path B (CI/headless) wiring lands in Phase 3. --- security-review/README.md | 32 +++++ security-review/hooks/pre-commit | 14 +++ security-review/install-hooks.sh | 12 ++ security-review/review.sh | 202 +++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 security-review/README.md create mode 100755 security-review/hooks/pre-commit create mode 100755 security-review/install-hooks.sh create mode 100755 security-review/review.sh diff --git a/security-review/README.md b/security-review/README.md new file mode 100644 index 0000000..b542167 --- /dev/null +++ b/security-review/README.md @@ -0,0 +1,32 @@ +# security-review + +The Sea Haven security-review gate. One pure-code script (`review.sh`), many triggers. +See memory `project-security-review-agent` for the full design. + +## Pieces +- `review.sh` — merges deterministic-scanner findings + agent findings, dedups, applies + suppressions (justification required), and makes the **block decision** (no agent decides). Exit 1 = BLOCK. +- `hooks/pre-commit` + `install-hooks.sh` — fast scanners-only hook for a target repo. +- The agentic detector/verifier pass is the interactive `/sh-security-review` slash command + (`~/.claude/commands/sh-security-review.md`), schema at `~/.claude/security-review/finding.schema.json`. + +## Triggers (one script, many entry points) +- **On-demand (primary):** run `/sh-security-review` in a Claude Code session (Max-covered), have it + write its schema JSON, then `review.sh --agent-findings out.json ` to gate. +- **Pre-commit:** `install-hooks.sh ` — fast deterministic scanners abort the commit early. +- **CI (Phase 3):** the same `review.sh` runs headless as the unbypassable backstop. + +## Scanners +`review.sh` runs whatever is installed and logs the rest with install commands (no silent skips). +Currently wired: `cfn-lint`. To give the deterministic layer teeth, install: +``` +pipx install semgrep # SAST: injection / authz / xss +brew install gitleaks # hardcoded secrets +pipx install pip-audit # vulnerable Python deps +pipx install checkov # IaC / IAM misconfig +``` +Each needs a small normalizer added to `review.sh` (map its JSON to the finding schema) when installed. + +## Status +review.sh gate validated against the local testbed: 16 findings, correct dedup of distinct +same-CWE findings, suppression-without-justification rejected and surfaced, BLOCK on confirmed crit/high. diff --git a/security-review/hooks/pre-commit b/security-review/hooks/pre-commit new file mode 100755 index 0000000..242c568 --- /dev/null +++ b/security-review/hooks/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Sea Haven security-review pre-commit hook: FAST deterministic scanners only (sub-30s). +# The full agentic review is the on-demand /sh-security-review slash command — run that before pushing. +# CI re-runs review.sh as the unbypassable backstop, so --no-verify here only skips local fast feedback. +set -euo pipefail +REPO_ROOT="$(git rev-parse --show-toplevel)" +REVIEW_SH="${SH_REVIEW_SH:-$HOME/Documents/repositories/orchestrator/security-review/review.sh}" +if [ ! -f "$REVIEW_SH" ]; then + echo "security-review: review.sh not found at $REVIEW_SH (set SH_REVIEW_SH to override) — skipping" >&2 + exit 0 +fi +# Nothing staged -> nothing to do. +git diff --cached --name-only --diff-filter=ACM | grep -q . || exit 0 +bash "$REVIEW_SH" --scanners-only "$REPO_ROOT" diff --git a/security-review/install-hooks.sh b/security-review/install-hooks.sh new file mode 100755 index 0000000..87b7668 --- /dev/null +++ b/security-review/install-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Install the Sea Haven security-review pre-commit hook into a target repo. +# Usage: install-hooks.sh /path/to/repo +set -euo pipefail +REPO="${1:?usage: install-hooks.sh /path/to/repo}" +SRC="$(cd "$(dirname "$0")" && pwd)/hooks/pre-commit" +[ -d "$REPO/.git" ] || { echo "not a git repo: $REPO" >&2; exit 1; } +HOOK="$REPO/.git/hooks/pre-commit" +if [ -f "$HOOK" ]; then echo "warning: existing pre-commit hook at $HOOK will be overwritten" >&2; fi +cp "$SRC" "$HOOK"; chmod +x "$HOOK" +echo "installed security-review pre-commit hook -> $HOOK" +echo "note: hook runs deterministic scanners only; full review is /sh-security-review (on demand)." diff --git a/security-review/review.sh b/security-review/review.sh new file mode 100755 index 0000000..84398b9 --- /dev/null +++ b/security-review/review.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# review.sh — Sea Haven security-review gate. Trigger-agnostic (pre-commit / pre-push / on-demand / CI). +# Pure code: merges deterministic-scanner findings + agent findings, applies suppressions, +# and decides block. NO agent makes the merge/block decision — this script does, so no agent +# can shrug off a confirmed critical. See memory project-security-review-agent. +# +# Usage: +# review.sh [--scope "src infra web"] [--agent-findings FILE] [--suppressions FILE] +# [--json-out FILE] [--scanners-only] [TARGET_DIR] +# +# Exit codes: 0 = pass, 1 = BLOCK (unsuppressed confirmed critical/high), 2 = usage/setup error. +set -euo pipefail +export PATH="$HOME/.local/bin:/opt/homebrew/bin:$PATH" # find pipx/brew-installed scanners regardless of caller env + +TARGET="."; SCOPE=""; AGENT_FINDINGS=""; SUPPRESSIONS=""; JSON_OUT=""; SCANNERS_ONLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --scope) SCOPE="$2"; shift 2;; + --agent-findings) AGENT_FINDINGS="$2"; shift 2;; + --suppressions) SUPPRESSIONS="$2"; shift 2;; + --json-out) JSON_OUT="$2"; shift 2;; + --scanners-only) SCANNERS_ONLY=1; shift;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) TARGET="$1"; shift;; + esac +done +command -v jq >/dev/null || { echo "review.sh: jq is required" >&2; exit 2; } +[ -d "$TARGET" ] || { echo "review.sh: target dir not found: $TARGET" >&2; exit 2; } +TARGET="$(cd "$TARGET" && pwd)" + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +ALL="$TMP/all.json"; echo '[]' > "$ALL" +add() { jq --argjson add "$1" '. + $add' "$ALL" > "$ALL.t" && mv "$ALL.t" "$ALL"; } + +# Map a scope list into find paths under TARGET (default: whole target). +scope_paths() { + if [ -n "$SCOPE" ]; then for d in $SCOPE; do [ -e "$TARGET/$d" ] && echo "$TARGET/$d"; done + else echo "$TARGET"; fi +} + +echo "== Deterministic scanners ==" >&2 +note_missing() { echo " [MISSING] $1 — not run. Install: $2" >&2; } + +# --- cfn-lint (installed): lint SAM/CFN templates. Normalize to findings. --- +if command -v cfn-lint >/dev/null; then + TPLS="$(scope_paths | xargs -I{} find {} -type f \( -name '*.yaml' -o -name '*.yml' \) 2>/dev/null \ + | xargs -I{} sh -c 'grep -lE "AWSTemplateFormatVersion|Transform: *AWS::Serverless" "{}" 2>/dev/null || true')" + if [ -n "$TPLS" ]; then + # shellcheck disable=SC2086 + RAW="$(cfn-lint -f json $TPLS 2>/dev/null || true)" + if [ -n "$RAW" ] && echo "$RAW" | jq -e 'type=="array"' >/dev/null 2>&1; then + NORM="$(echo "$RAW" | jq '[.[] | { + id: ("cfnlint-" + (.Rule.Id // "X") + "-" + ((.Location.Start.LineNumber // 0)|tostring)), + title: (.Rule.Id + ": " + (.Message // .Rule.Description // "")), + severity: (if (.Level=="Error") then "high" elif (.Level=="Warning") then "medium" else "low" end), + cwe: "n/a", file: (.Filename // ""), line: (.Location.Start.LineNumber // null), + category: "iac-iam", source: "cfn-lint", status: "confirmed", + data_flow: "cfn-lint rule violation", proof: {input: "deploy template", outcome: (.Message // "")} + }]')" + add "$NORM"; echo " [cfn-lint] $(echo "$NORM" | jq length) finding(s)" >&2 + else echo " [cfn-lint] 0 findings" >&2; fi + else echo " [cfn-lint] no CFN/SAM templates in scope" >&2; fi +else note_missing cfn-lint "pip install cfn-lint"; fi + +SCOPE_PATHS="$(scope_paths)" + +# --- semgrep (SAST: injection/authz/xss/secrets) --- +if command -v semgrep >/dev/null; then + # shellcheck disable=SC2086 + if SG="$(semgrep --config p/security-audit --config p/secrets --json --metrics=off $SCOPE_PATHS 2>/dev/null)"; then + NORM="$(echo "$SG" | jq '[.results[] | { + id: ("semgrep-" + (.check_id|split(".")|last) + "-" + (.start.line|tostring)), + title: ((.check_id|split(".")|last) + ": " + ((.extra.message // "")[0:120])), + severity: (.extra.severity | if .=="ERROR" then "high" elif .=="WARNING" then "medium" else "low" end), + cwe: ((.extra.metadata.cwe // []) | if length>0 then (.[0]|split(":")[0]) else "n/a" end), + file: .path, line: .start.line, category: "other", source: "semgrep", status: "confirmed", + data_flow: "semgrep rule match", proof: {input: "see rule", outcome: (.extra.message // "")}}]')" + add "$NORM"; echo " [semgrep] $(echo "$NORM" | jq length) finding(s)" >&2 + else echo " [semgrep] run failed" >&2; fi +else note_missing semgrep "brew install semgrep"; fi + +# --- gitleaks (hardcoded secrets) --- +if command -v gitleaks >/dev/null; then + GLALL="$TMP/gl.json"; echo '[]' > "$GLALL" + for p in $SCOPE_PATHS; do + gitleaks dir "$p" --report-format json --report-path "$TMP/gl1.json" >/dev/null 2>&1 || true + if [ -s "$TMP/gl1.json" ]; then + jq -s '.[0]+(.[1] // [])' "$GLALL" "$TMP/gl1.json" > "$GLALL.t" && mv "$GLALL.t" "$GLALL"; rm -f "$TMP/gl1.json" + fi + done + NORM="$(jq '[.[] | { + id: ("gitleaks-" + .RuleID + "-" + (.StartLine|tostring)), + title: ("secret: " + .Description), severity: "high", cwe: "CWE-798", + file: .File, line: .StartLine, category: "secrets-crypto", source: "gitleaks", status: "confirmed", + data_flow: "hardcoded secret in source", proof: {input: "read source", outcome: .Description}}]' "$GLALL")" + add "$NORM"; echo " [gitleaks] $(echo "$NORM" | jq length) finding(s)" >&2 +else note_missing gitleaks "brew install gitleaks"; fi + +# --- checkov (IaC/IAM misconfig) --- +if command -v checkov >/dev/null; then + CKALL="$TMP/ck.json"; echo '[]' > "$CKALL" + for p in $SCOPE_PATHS; do + if [ -d "$p" ]; then RAW="$(checkov -d "$p" -o json --compact --quiet 2>/dev/null || true)" + else RAW="$(checkov -f "$p" -o json --compact --quiet 2>/dev/null || true)"; fi + [ -z "$RAW" ] && continue + FC="$(echo "$RAW" | jq '[ (if type=="array" then .[] else . end).results.failed_checks // [] ] | add // []' 2>/dev/null || echo '[]')" + jq -s '.[0]+.[1]' "$CKALL" <(echo "$FC") > "$CKALL.t" && mv "$CKALL.t" "$CKALL" + done + NORM="$(jq '[.[] | { + id: ("checkov-" + .check_id + "-" + ((.file_line_range[0]) // 0 | tostring)), + title: (.check_id + ": " + (.check_name // "")), + severity: ((.severity // "MEDIUM") | ascii_downcase), + cwe: "n/a", file: (.file_abs_path // .file_path), line: (.file_line_range[0] // null), + category: "iac-iam", source: "checkov", status: "confirmed", + data_flow: "checkov policy violation", proof: {input: "deploy template", outcome: (.check_name // "")}}]' "$CKALL")" + add "$NORM"; echo " [checkov] $(echo "$NORM" | jq length) finding(s)" >&2 +else note_missing checkov "pipx install checkov"; fi + +# --- pip-audit (vulnerable Python deps) — runs on requirements*.txt in scope --- +if command -v pip-audit >/dev/null; then + REQS="$(for p in $SCOPE_PATHS; do find "$p" -maxdepth 3 -name 'requirements*.txt' 2>/dev/null; done)" + if [ -n "$REQS" ]; then + PAALL="$TMP/pa.json"; echo '[]' > "$PAALL" + for r in $REQS; do + RAW="$(pip-audit -r "$r" -f json 2>/dev/null || true)"; [ -z "$RAW" ] && continue + NORM="$(echo "$RAW" | jq --arg f "$r" '[ (.dependencies // .)[] | . as $d | ($d.vulns // [])[] | { + id: ("pipaudit-" + $d.name + "-" + .id), + title: ("vulnerable dep " + $d.name + " " + $d.version + " (" + .id + ")"), + severity: "high", cwe: "n/a", file: $f, line: null, + category: "secrets-crypto", source: "pip-audit", status: "confirmed", + data_flow: "known-vulnerable dependency", proof: {input: "install", outcome: (.description // .id)}}]' 2>/dev/null || echo '[]')" + jq -s '.[0]+.[1]' "$PAALL" <(echo "$NORM") > "$PAALL.t" && mv "$PAALL.t" "$PAALL" + done + add "$(cat "$PAALL")"; echo " [pip-audit] $(jq length "$PAALL") finding(s)" >&2 + else echo " [pip-audit] no requirements*.txt in scope" >&2; fi +else note_missing pip-audit "pipx install pip-audit"; fi + +# --- Agent findings (from interactive /sh-security-review, or Path B headless later) --- +if [ "$SCANNERS_ONLY" -eq 0 ] && [ -n "$AGENT_FINDINGS" ]; then + [ -f "$AGENT_FINDINGS" ] || { echo "review.sh: agent-findings file not found: $AGENT_FINDINGS" >&2; exit 2; } + AF="$(jq 'if type=="object" then (.findings // []) else . end' "$AGENT_FINDINGS")" + add "$AF"; echo "== Agent findings: $(echo "$AF" | jq length) ==" >&2 +fi + +# --- Normalize file paths to be repo-relative (scanners emit absolute) --- +TGT_ABS="$(cd "$TARGET" && pwd)" +jq --arg tgt "$TGT_ABS" '[.[] | .file |= ((. // "") | ltrimstr($tgt) | ltrimstr("/"))]' "$ALL" > "$ALL.t" && mv "$ALL.t" "$ALL" + +# --- Dedup by (file, cwe-or-id) keeping the highest severity --- +RANK='{"critical":4,"high":3,"medium":2,"low":1,"info":0,"unverified":-1}' +DEDUP="$(jq --argjson r "$RANK" ' + def rank: ($r[.severity] // 0); + group_by([.file, (if (.cwe // "n/a")=="n/a" then .id else .cwe end), (.line // 0)]) | map(max_by(rank))' "$ALL")" + +# --- Apply suppressions (require a written justification; surface them) --- +if [ -n "$SUPPRESSIONS" ] && [ -f "$SUPPRESSIONS" ]; then + DEDUP="$(jq --slurpfile s "$SUPPRESSIONS" ' + ($s[0].suppressions // []) as $sup + | map( . as $f + | ([ $sup[] | select(.id == $f.id) ] | first) as $m + | if $m then + if ($m.justification // "" | length) > 0 + then $f + {status:"suppressed", suppression_justification:$m.justification} + else $f + {status:"unverified", suppression_justification:"REJECTED: suppression missing justification"} + end + else $f end )' <<<"$DEDUP")" +fi + +# --- Gate (mechanical) --- +SUMMARY="$(jq -n --argjson f "$DEDUP" ' + def isconf(s): [ $f[] | select(.status=="confirmed" and .severity==s) ] | length; + { confirmed_critical: isconf("critical"), confirmed_high: isconf("high"), + confirmed_medium: isconf("medium"), + suppressed: ([ $f[] | select(.status=="suppressed") ] | length), + unverified: ([ $f[] | select(.status=="unverified") ] | length) } + | . + { block: ((.confirmed_critical + .confirmed_high) > 0) }')" + +OUT="$(jq -n --argjson findings "$DEDUP" --argjson summary "$SUMMARY" '{findings:$findings, summary:$summary}')" +[ -n "$JSON_OUT" ] && echo "$OUT" > "$JSON_OUT" + +# --- Human report --- +echo +echo "================ SECURITY REVIEW ================" +echo "$SUMMARY" | jq -r '" confirmed critical: \(.confirmed_critical) confirmed high: \(.confirmed_high) medium: \(.confirmed_medium) suppressed: \(.suppressed) unverified: \(.unverified)"' +echo "-------------------------------------------------" +echo "$DEDUP" | jq -r ' + def order: {critical:0,high:1,medium:2,low:3,info:4,unverified:5}[.severity] // 9; + sort_by(order) | .[] + | select(.status=="confirmed") + | " [\(.severity|ascii_upcase)] \(.cwe // "n/a") \(.file)\(if .line then ":"+( .line|tostring) else "" end) — \(.title // .id)"' +SUPN="$(echo "$SUMMARY" | jq '.suppressed')" +if [ "$SUPN" -gt 0 ]; then + echo " -- suppressed (logged) --" + echo "$DEDUP" | jq -r '.[] | select(.status=="suppressed") | " [SUPPRESSED] \(.file) \(.id) — \(.suppression_justification)"' +fi +echo "=================================================" + +if [ "$(echo "$SUMMARY" | jq '.block')" = "true" ]; then + echo "RESULT: BLOCK (unsuppressed confirmed critical/high)"; exit 1 +else + echo "RESULT: PASS"; exit 0 +fi