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.
This commit is contained in:
parent
c23ea679e4
commit
7e5ce1f5b2
4 changed files with 260 additions and 0 deletions
32
security-review/README.md
Normal file
32
security-review/README.md
Normal file
|
|
@ -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 <repo>` to gate.
|
||||
- **Pre-commit:** `install-hooks.sh <repo>` — 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.
|
||||
14
security-review/hooks/pre-commit
Executable file
14
security-review/hooks/pre-commit
Executable file
|
|
@ -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"
|
||||
12
security-review/install-hooks.sh
Executable file
12
security-review/install-hooks.sh
Executable file
|
|
@ -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)."
|
||||
202
security-review/review.sh
Executable file
202
security-review/review.sh
Executable file
|
|
@ -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
|
||||
Reference in a new issue