#!/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