mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-09-30 04:33:13 +00:00
review.sh only applied suppressions when handed an explicit --suppressions
FILE, so only the pre-push hook resolved them. Every other entry point (the
Open SWE daily-report automation, nightly sweep, on-demand/CI, agent runs)
called review.sh without it and therefore suppressed nothing, re-surfacing
every already-adjudicated false positive as HIGH.
When --suppressions is not passed, resolve by repo basename and MERGE both
suppression locations (machine-level first, wins id collisions):
- machine-level: ${SH_SECURITY_SUPPRESSIONS_DIR:-~/.config/sea-haven/security-review}/<basename>/suppressions.json
- repo-local: <repo>/.security-review/suppressions.json
An explicit --suppressions still overrides, so the hook and existing callers
are unaffected. Degrades gracefully off-Mac (repo-local only); fail-safe on an
unparseable file (suppresses nothing → blocks).
SECURITY (/sh-security-review, 2026-07-13): fan-out + proof-or-kill confirmed
one HIGH — the git-tracked repo-local suppressions.json lets anyone who can
commit to a scanned repo suppress a real finding and PASS an automated run
(verified by an actual exploit run; same posture nightly_sweep already had).
ACCEPTED-RISK per Adam on the condition that the automated scanners only ever
target trusted repos (no unreviewed untrusted contributions). Documented in the
auto-resolve block, README trust-model note, and a hard warning in
sweep-targets.txt. Four other candidates downgraded to low/pre-existing.
Verified: machine-level and repo-local both auto-resolve and suppress; explicit
--suppressions override still blocks; simulated off-Mac host keeps repo-local
and correctly re-blocks machine-level-only FPs.
297 lines
19 KiB
Bash
Executable file
297 lines
19 KiB
Bash
Executable file
#!/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
|