#!/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"; } # --- Auto-resolve suppressions when --suppressions was not passed --- # Mirrors the pre-push hook's resolution, but MERGES machine-level + repo-local # (the hook uses XOR: first match wins) and degrades gracefully. On hosts without # the machine-level dir — the Open SWE daily-report automation and the R720 VM, # which clone repos but cannot see ~/.config on Adam's Mac — only the tracked # repo-local file is used, so suppressions travel inside the cloned repo. On the # Mac both files are merged. An explicit --suppressions FILE still overrides this, # so the hook and any existing caller are unaffected. Fail-safe: if resolution or # the merge fails, SUPPRESSIONS stays empty and the gate suppresses nothing (blocks). # # SECURITY — ACCEPTED RISK (Adam, 2026-07-13, /sh-security-review confirmed HIGH): # The repo-local .security-review/suppressions.json is GIT-TRACKED, so anyone who # can land a commit in a scanned repo can ship a real finding together with a # suppression for it (scanner IDs are deterministic/precomputable) and make an # AUTOMATED bare-review.sh run PASS. This is the same trust posture nightly_sweep # already used. It is ACCEPTED on the condition that the automated scanners # (Open SWE daily report, nightly sweep) only ever target TRUSTED repos — no repo # that merges untrusted contributions without human review may be added to their # target lists. See sweep-targets.txt and memory reference_global_security_review_hook. # Do NOT relax that scoping without adding a trust check (signed/CODEOWNERS-verified # repo-local suppressions). if [ -z "$SUPPRESSIONS" ]; then _repo_root="$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null || echo "$TARGET")" _machine_sup="${SH_SECURITY_SUPPRESSIONS_DIR:-$HOME/.config/sea-haven/security-review}/$(basename "$_repo_root")/suppressions.json" _repo_sup="$_repo_root/.security-review/suppressions.json" _sup_files=() [ -f "$_machine_sup" ] && _sup_files+=("$_machine_sup") # machine-level first → wins id collisions [ -f "$_repo_sup" ] && _sup_files+=("$_repo_sup") if [ "${#_sup_files[@]}" -gt 0 ]; then _merged="$TMP/suppressions.merged.json" if jq -s '{suppressions: (map(.suppressions // []) | add)}' "${_sup_files[@]}" > "$_merged" 2>/dev/null; then SUPPRESSIONS="$_merged" echo "== Suppressions auto-resolved: ${_sup_files[*]} ==" >&2 else echo " [WARN] suppression file(s) present but unparseable; suppressing nothing: ${_sup_files[*]}" >&2 fi fi fi # 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 # Prune generated/vendored trees (cdk.out, node_modules, …): scanning synthesized output is # wrong and, on CDK repos, explodes the arg list / stalls the scanners. # `find -print0 | xargs -0 grep -lE` (was `… | xargs -I{} sh -c 'grep -l "{}"'`): the grep stage # is the one that overflows. `xargs -I{}` packs every matched path — long, absolute, deep-worktree # paths on this monorepo — into one assembled command and dies with "command line cannot be # assembled, too long", emitting zero findings and blocking the push. NUL-delimited `xargs -0 grep` # splits across invocations transparently (batches by ARG_MAX, never overflows), is safe for paths # with spaces/newlines, and `grep -l` reports the same matching files as the old per-file grep. # The first `xargs -I{} find {}` keeps the start path first (find needs it before the expression) # and is bounded by the scope-path count, so it is not an overflow risk. `--no-run-if-empty` is # GNU-only, so the trailing `|| true` absorbs grep's exit 1 on no-match / no-files, matching the # old per-file `… || true` so TPLS is "list of templates, or empty" and never fails the gate. TPLS="$(scope_paths | xargs -I{} find {} \( -type d \( -name cdk.out -o -name node_modules -o -name .git -o -name .claude -o -name .aws-sam -o -name .venv -o -name venv -o -name dist -o -name build \) -prune \) -o \( -type f \( -name '*.yaml' -o -name '*.yml' \) -print0 \) 2>/dev/null \ | xargs -0 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 --config p/javascript --json --metrics=off --exclude cdk.out --exclude node_modules --exclude .claude --exclude .venv --exclude venv --exclude .aws-sam --exclude dist --exclude build $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) — git-mode respects .gitignore (skips gitignored .env etc.) --- if command -v gitleaks >/dev/null; then GLALL="$TMP/gl.json"; echo '[]' > "$GLALL" if git -C "$TARGET" rev-parse --is-inside-work-tree >/dev/null 2>&1; then # Scan committed content at the repo root; gitignored files (e.g. a local .env with real # keys) are excluded by design, so the gate never false-blocks on them. Same JSON schema. gitleaks git "$TARGET" --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 else 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 fi 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 # --skip-path is a regex over the file path. Skip the ENTIRE cdk.out/ synth tree # plus other generated/vendored dirs. cdk.out/ is gitignored generated output, not # source — gating pushes on it false-blocks unrelated work (INFRA-144): checkov # raises CKV_AWS_111 (and similar) on CDK-generated roles (LogRetention, asset # publishing, bootstrap) that only exist post-synth and that a dev may have lying # around from a stale `cdk synth`. Authored IaC that checkov actually parses # (SAM/CFN template.yaml, Terraform) is TRACKED source and is still fully scanned; # CDK synth output is covered by the agentic /sh-security-review pass, not this # deterministic gate. Matches semgrep's `--exclude cdk.out` and cfn-lint's cdk.out # prune, so all three scanners now treat synth output consistently. if [ -d "$p" ]; then RAW="$(checkov -d "$p" --skip-path 'cdk\.out/' --skip-path node_modules --skip-path '\.claude' --skip-path '\.venv' --skip-path venv --skip-path '\.aws-sam' --skip-path dist --skip-path build -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 # High-signal checkov checks (exposure/access/wildcard) -> high; everything else -> low (informational). # checkov OSS rarely populates .severity, so without this every best-practice nit reads as medium and drowns signal. CKHI='["CKV_AWS_53","CKV_AWS_54","CKV_AWS_55","CKV_AWS_56","CKV_AWS_57","CKV_AWS_20","CKV_AWS_24","CKV_AWS_25","CKV_AWS_260","CKV_AWS_1","CKV_AWS_40","CKV_AWS_49","CKV_AWS_62","CKV_AWS_70","CKV_AWS_107","CKV_AWS_108","CKV_AWS_109","CKV_AWS_110","CKV_AWS_111"]' NORM="$(jq --argjson hi "$CKHI" '[.[] | { id: ("checkov-" + .check_id + "-" + ((.file_line_range[0]) // 0 | tostring)), title: (.check_id + ": " + (.check_name // "")), severity: (if .severity != null then (.severity|ascii_downcase) elif (.check_id as $c | $hi | index($c)) then "high" else "low" end), 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 # --- npm audit (vulnerable Node deps) — runs at TARGET root if package.json present --- if command -v npm >/dev/null; then if [ -f "$TARGET/package.json" ]; then RAW="$(cd "$TARGET" && npm audit --json 2>/dev/null || true)" if [ -n "$RAW" ] && echo "$RAW" | jq -e '.vulnerabilities' >/dev/null 2>&1; then NORM="$(echo "$RAW" | jq '[.vulnerabilities // {} | to_entries[] | .value as $v | { id: ("npmaudit-" + $v.name), title: ("vulnerable npm dep " + $v.name + " (" + ($v.range // "") + ")"), severity: ($v.severity | if .=="moderate" then "medium" elif .=="critical" then "critical" elif .=="high" then "high" elif .=="low" then "low" else "info" end), cwe: ([$v.via[]? | objects | .cwe[]?] | if length>0 then .[0] else "n/a" end), file: "package.json", line: null, category: "secrets-crypto", source: "npm-audit", status: "confirmed", data_flow: "known-vulnerable npm dependency", proof: {input: "install", outcome: (([$v.via[]? | objects | .title] | join("; "))[0:140])}}]')" add "$NORM"; echo " [npm-audit] $(echo "$NORM" | jq length) finding(s)" >&2 else echo " [npm-audit] 0 findings / no lockfile" >&2; fi else echo " [npm-audit] no package.json at target root" >&2; fi else note_missing npm-audit "install Node.js"; 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}[.severity] // 9; [ .[] | select(.status=="confirmed" and (.severity|IN("critical","high","medium"))) ] | sort_by(order) | .[] | " [\(.severity|ascii_upcase)] \(.cwe // "n/a") \(.file)\(if .line then ":"+( .line|tostring) else "" end) — \(.title // .id)"' echo "$DEDUP" | jq -r ' ([ .[] | select(.status=="confirmed" and .severity=="low") ] | length) as $lo | ([ .[] | select(.status=="confirmed" and .severity=="info") ] | length) as $in | if ($lo+$in)>0 then " (+\($lo) low, +\($in) info — informational, in JSON report only)" else empty end' 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