From 7e5ce1f5b2f87ca36c7876aa202299cc2ecea433 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 15 Jun 2026 15:52:15 -0400 Subject: [PATCH 1/2] 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. --- security-review/README.md | 32 +++++ security-review/hooks/pre-commit | 14 +++ security-review/install-hooks.sh | 12 ++ security-review/review.sh | 202 +++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 security-review/README.md create mode 100755 security-review/hooks/pre-commit create mode 100755 security-review/install-hooks.sh create mode 100755 security-review/review.sh diff --git a/security-review/README.md b/security-review/README.md new file mode 100644 index 0000000..b542167 --- /dev/null +++ b/security-review/README.md @@ -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 ` to gate. +- **Pre-commit:** `install-hooks.sh ` — 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. diff --git a/security-review/hooks/pre-commit b/security-review/hooks/pre-commit new file mode 100755 index 0000000..242c568 --- /dev/null +++ b/security-review/hooks/pre-commit @@ -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" diff --git a/security-review/install-hooks.sh b/security-review/install-hooks.sh new file mode 100755 index 0000000..87b7668 --- /dev/null +++ b/security-review/install-hooks.sh @@ -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)." diff --git a/security-review/review.sh b/security-review/review.sh new file mode 100755 index 0000000..84398b9 --- /dev/null +++ b/security-review/review.sh @@ -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 -- 2.50.1 From f90f759e12dc5a2412a715b5f0684079d6fb257e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 15 Jun 2026 15:57:34 -0400 Subject: [PATCH 2/2] Tune checkov severity, wire npm audit, add CI backstop + R720 runbook checkov: high-signal exposure/access checks -> high, best-practice noise -> low (was 51 undifferentiated mediums). npm audit wired for Node dep CVEs. Report collapses the low/info tail to a count. Adds ci/security-review.yml (PR backstop) and DEPLOY-R720.md (Phase 3 host runbook). --- security-review/DEPLOY-R720.md | 54 +++++++++++++++++++++++ security-review/ci/security-review.yml | 61 ++++++++++++++++++++++++++ security-review/review.sh | 35 ++++++++++++--- 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 security-review/DEPLOY-R720.md create mode 100644 security-review/ci/security-review.yml diff --git a/security-review/DEPLOY-R720.md b/security-review/DEPLOY-R720.md new file mode 100644 index 0000000..24fde9e --- /dev/null +++ b/security-review/DEPLOY-R720.md @@ -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). diff --git a/security-review/ci/security-review.yml b/security-review/ci/security-review.yml new file mode 100644 index 0000000..d52d930 --- /dev/null +++ b/security-review/ci/security-review.yml @@ -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 diff --git a/security-review/review.sh b/security-review/review.sh index 84398b9..eb713f2 100755 --- a/security-review/review.sh +++ b/security-review/review.sh @@ -106,10 +106,14 @@ if command -v checkov >/dev/null; then 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 '[.[] | { + # 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: ((.severity // "MEDIUM") | ascii_downcase), + 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")" @@ -135,6 +139,24 @@ if command -v pip-audit >/dev/null; then 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; } @@ -184,10 +206,13 @@ 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") + 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) --" -- 2.50.1