Add security-review gate (review.sh + scanners + CI backstop) #5

Merged
amoussa1229 merged 2 commits from security-review-gate into main 2026-06-15 19:59:00 +00:00
6 changed files with 400 additions and 0 deletions

View file

@ -0,0 +1,54 @@
# Phase 3 — Path B deployment (R720 host + CI backstop)
Status: **design + scripts; NOT deployed.** Needs the R720 (hostname SH-NVR, deprecated NVR, no GPU)
and Adam's hands-on involvement. Nothing here has been run against the box. See memory
`project-security-review-agent`.
## What Phase 3 delivers
1. The orchestrator hosted on the R720 as an always-on service (the Path B execution engine).
2. A **headless agentic runner** (`run_headless.py`) so the detector fan-out + proof-or-kill verifier
can run unattended (interactive `/sh-security-review` can't run in CI/cron).
3. CI backstop (`ci/security-review.yml`) running the same `review.sh` on every PR.
4. Nightly full-repo sweep + planted canary vulns + two-model disagreement on criticals.
5. Budget/telemetry ceiling against the Max-20x **$200/mo Agent SDK credit pool**
(see memory `reference-claude-subscription-billing`).
## The load-bearing piece to BUILD first: `run_headless.py`
The interactive skill orchestrates subagents via the Claude Code Agent tool (Max-covered). Headless,
that must become explicit API/SDK calls. `run_headless.py` should:
- take `--scope` + target dir, read the in-scope files;
- run the 6 detectors (injection/authz/secrets-crypto/iac-iam/web-client/logic) as parallel calls,
each fresh context, using the SAME prompts as `~/.claude/commands/sh-security-review.md`;
- run the proof-or-kill verifier pass;
- emit the finding schema JSON that `review.sh --agent-findings` consumes.
- **Billing:** authenticate via the **Claude Agent SDK with the subscription** to spend the $200/mo
pool before API overflow (NOT a raw key) — see the billing memory. Non-Claude cross-family tiebreak
stays on Bedrock/API. ⚠️ This code cannot be validated until it runs with real creds; do not mark
done until tested.
## R720 host setup (Windows Server 2022, no GPU)
Recommended: run the Python orchestrator in **WSL2 or Docker** rather than native Windows Python.
1. `git clone https://github.com/amoussa1229/orchestrator` onto the box.
2. Python 3.12 + `pip install -r requirements.txt` (+ the Agent SDK).
3. Install the deterministic scanners (semgrep/gitleaks/checkov/cfn-lint/pip-audit) on the box.
4. Secrets on the box (NOT in git): Agent SDK subscription auth, Bedrock creds, Slack token, repo PATs.
5. Run as a service (NSSM on Windows, or systemd inside WSL2). Expose to the harness as an
MCP/HTTP service (mirror the existing unifi MCP pattern) OR keep it as a local scheduled job.
6. Scheduler: nightly full-repo sweep → `review.sh` → Slack report (ALARM-style, see
memory `feedback_cloudwatch_alarms`: only notify on findings, not clean runs).
## Anti-complacency reinforcements (Phase 3)
- **Canary vulns:** keep the testbed's planted vulns in a fixture the nightly sweep also scans; if the
agent ever misses a known canary, that's a complacency signal → alert.
- **Two-model disagreement:** on confirmed criticals, run the cross-family reviewer (orchestrator
GPT-4.1) and flag disagreement for human review.
- **Budget guard:** track output tokens vs the $200/mo pool; stop/alert before overflow.
## Open dependencies (Adam)
- R720 reachable + WSL2/Docker chosen.
- GitHub repo/org secrets: `ANTHROPIC_API_KEY` (or SDK auth), set `vars.SECURITY_REVIEW_AGENTIC=1`
once `run_headless.py` is tested.
- Decide: promote `ci/security-review.yml` into Sea-Haven-Industries/.github as a reusable workflow
(per engineering-handbook/cicd.md) vs per-repo copy.
- checkov severity filtering (it emitted 51-55 best-practice mediums on payments-dashboard — tune
before nightly or the Slack reports are noise).

32
security-review/README.md Normal file
View 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.

View file

@ -0,0 +1,61 @@
# Sea Haven security-review CI backstop (Phase 3).
# Drop into a target repo as .github/workflows/security-review.yml, OR (preferred, per
# engineering-handbook/cicd.md) promote into Sea-Haven-Industries/.github as a reusable workflow.
#
# This is the UNBYPASSABLE deterministic backstop: local hooks can be skipped with --no-verify,
# this cannot. It runs the SAME review.sh as local. The agentic detector/verifier pass (Path B)
# is gated behind SECURITY_REVIEW_AGENTIC=1 and requires the headless orchestrator runner +
# ANTHROPIC creds (see DEPLOY-R720.md) — until that exists, CI runs the scanners-only gate.
name: security-review
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
security-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install scanners
run: |
python3 -m pip install --quiet pipx && python3 -m pipx ensurepath
pipx install semgrep >/dev/null
pipx install checkov >/dev/null
pipx install pip-audit >/dev/null
pip install --quiet cfn-lint
# gitleaks binary
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Fetch review.sh
run: |
# Pin to the orchestrator repo / a release artifact. Placeholder: vendor a copy or curl a tag.
git clone --depth 1 https://github.com/amoussa1229/orchestrator /tmp/orch
chmod +x /tmp/orch/security-review/review.sh
- name: Run gate (scanners; agentic if enabled)
env:
SECURITY_REVIEW_AGENTIC: ${{ vars.SECURITY_REVIEW_AGENTIC }} # set to 1 once headless runner exists
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
SCOPE="${SECURITY_REVIEW_SCOPE:-src scripts template.yaml}"
if [ "${SECURITY_REVIEW_AGENTIC:-0}" = "1" ]; then
python3 /tmp/orch/security-review/run_headless.py --scope "$SCOPE" --out /tmp/agent.json .
/tmp/orch/security-review/review.sh --scope "$SCOPE" --agent-findings /tmp/agent.json \
--suppressions .security-review/suppressions.json --json-out /tmp/review.json .
else
/tmp/orch/security-review/review.sh --scope "$SCOPE" --scanners-only \
--suppressions .security-review/suppressions.json --json-out /tmp/review.json .
fi
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: security-review-findings
path: /tmp/review.json

View 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"

View 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)."

227
security-review/review.sh Executable file
View file

@ -0,0 +1,227 @@
#!/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
# 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