Paper2Agent_decoupleRpy / scripts /security_scan.sh
Annie Voigt
chore(security): make SBOM generation deterministic
b30c429
Raw
History Blame Contribute Delete
9.43 kB
#!/usr/bin/env bash
# ADR-0014 security scan β€” the single shared definition run by CI, the pre-push
# hook, and `make security-scan`. Four stages, deploy-model-agnostic:
#
# 1. Dependencies β€” pip-audit against requirements.txt (+ CycloneDX SBOM)
# 2. Static β€” bandit over src/ (baseline-suppressed accepted findings)
# 3. Secrets β€” gitleaks over the working tree AND full git history
# 4. Sandbox image β€” trivy over docker/sandbox.Dockerfile (ADR-0007)
#
# Design goals:
# - No global installs, no repo mutation. pip-audit runs in a cached venv under
# security/.audit-venv; bandit/pip-audit run via `uvx` when available.
# - Graceful skip (not failure) when an optional scanner is absent, so the same
# script runs on a dev laptop and in CI. Missing tools are reported loudly.
# - Committable artifacts in security/ so every run leaves a reviewable trail.
#
# Exit code: non-zero if ANY enabled stage reports findings (so CI / the pre-push
# hook can gate on it). Skipped stages (tool absent) do not fail the run unless
# SECURITY_SCAN_STRICT=1, which turns skips into failures (use in CI).
#
# Usage: scripts/security_scan.sh [requirements.txt]
set -uo pipefail
cd "$(dirname "$0")/.."
REQ="${1:-requirements.txt}"
OUT="security"
mkdir -p "$OUT"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
STRICT="${SECURITY_SCAN_STRICT:-0}"
RC=0 # overall exit code
SKIPS=() # human-readable list of skipped stages
note() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
fail() { printf '\033[31mFINDINGS:\033[0m %s\n' "$*"; RC=1; }
skip() { printf '\033[33mSKIP:\033[0m %s\n' "$*"; SKIPS+=("$*"); [[ "$STRICT" == "1" ]] && RC=1; return 0; }
ok() { printf '\033[32mOK:\033[0m %s\n' "$*"; }
[[ -f "$REQ" ]] || { echo "error: '$REQ' not found" >&2; exit 2; }
# --------------------------------------------------------------------------- #
# Stage 1a β€” CycloneDX SBOM (from pinned requirements; no install)
# --------------------------------------------------------------------------- #
note "SBOM (CycloneDX) -> $OUT/sbom.json"
if command -v uv >/dev/null 2>&1; then
if uvx --from cyclonedx-bom cyclonedx-py requirements "$REQ" --of JSON -o "$OUT/sbom.json"; then
# The SBOM is COMMITTED (it is the reviewable dependency baseline), so its
# content must be a pure function of $REQ. cyclonedx-py stamps two volatile
# fields on every run β€” metadata.timestamp and a random serialNumber β€” which
# made an unchanged dependency set still show up as a dirty tree after each
# scan, on every clone, forever. Normalising them here means `git diff` on
# this file now means exactly one thing: the dependencies actually changed.
# - timestamp: dropped (optional in CycloneDX 1.6). Generation time is
# already recorded by the commit, and pinning a fixed fake
# date would be a lie rather than a normalisation.
# - serialNumber: content-addressed (UUIDv5 over the sorted component
# purls), so it keeps its "unique id per distinct BOM"
# meaning while staying stable across identical runs.
# Also writes a trailing newline, which cyclonedx-py omits and the
# end-of-file pre-commit hook would otherwise re-add on every commit.
if python3 - "$OUT/sbom.json" <<'PY'
import json, sys, uuid
path = sys.argv[1]
with open(path) as fh:
bom = json.load(fh)
bom.get("metadata", {}).pop("timestamp", None)
purls = sorted(
c.get("purl") or f"{c.get('name')}@{c.get('version')}"
for c in bom.get("components", [])
)
bom["serialNumber"] = "urn:uuid:" + str(
uuid.uuid5(uuid.NAMESPACE_URL, "cyclonedx-sbom:" + "\n".join(purls))
)
with open(path, "w") as fh:
json.dump(bom, fh, indent=2, sort_keys=True)
fh.write("\n")
PY
then ok "SBOM written (normalised β€” diffs only on real dependency changes)"
else fail "SBOM normalisation errored"
fi
else
fail "SBOM generation errored"
fi
else
skip "SBOM β€” uv not found (https://docs.astral.sh/uv/)"
fi
# --------------------------------------------------------------------------- #
# Stage 1b β€” pip-audit (known CVEs)
# --------------------------------------------------------------------------- #
# The first-party biodata-registry git+ line is not a PyPI CVE target and HF's
# git server rejects pip's partial clone, so strip it before auditing.
note "pip-audit (known CVEs in $REQ; git dep excluded)"
REQ_NOGIT="$(mktemp)"; trap 'rm -f "$REQ_NOGIT"' EXIT
grep -v "git+" "$REQ" > "$REQ_NOGIT"
# pip-audit resolves pins in a venv matching its interpreter, so we need a Python
# >=3.11 whose venv/ensurepip actually works (uv's standalone builds have a
# broken ensurepip on macOS β€” that is why we discover a system Python here rather
# than using `uvx pip-audit`).
PYBIN=""
for cand in python3.13 python3.12 python3.11 python3; do
command -v "$cand" >/dev/null 2>&1 || continue
td="$(mktemp -d)"; if "$cand" -m venv "$td/probe" >/dev/null 2>&1; then PYBIN="$cand"; rm -rf "$td"; break; fi
rm -rf "$td"
done
if [[ -z "$PYBIN" ]]; then
skip "pip-audit β€” no Python >=3.11 with a working venv found"
else
VENV="$OUT/.audit-venv"
if [[ ! -x "$VENV/bin/pip-audit" ]]; then
echo " setting up audit venv ($PYBIN)"
"$PYBIN" -m venv "$VENV" && "$VENV/bin/python" -m pip install -q --upgrade pip pip-audit
fi
"$VENV/bin/python" -m pip_audit --requirement "$REQ_NOGIT" --no-deps \
--format columns | tee "$OUT/pip-audit-$STAMP.txt"
AUDIT_RC=${PIPESTATUS[0]}
"$VENV/bin/python" -m pip_audit --requirement "$REQ_NOGIT" --no-deps \
--format json --output "$OUT/pip-audit-$STAMP.json" >/dev/null 2>&1 || true
if [[ "$AUDIT_RC" -eq 0 ]]; then
ok "pip-audit β€” no known vulnerabilities"
else
fail "pip-audit reported vulnerabilities (see $OUT/pip-audit-$STAMP.txt). Some"
echo " pins (gradio, mcp) are constrained by the HF sdk_version; triage"
echo " each in security/ACCEPTED-FINDINGS.md."
fi
fi
# --------------------------------------------------------------------------- #
# Stage 2 β€” bandit (static analysis over src/, baseline-suppressed)
# --------------------------------------------------------------------------- #
note "bandit (static analysis over src/)"
BASELINE="$OUT/bandit-baseline.json"
BANDIT=(); if command -v bandit >/dev/null 2>&1; then BANDIT=(bandit); \
elif command -v uvx >/dev/null 2>&1; then BANDIT=(uvx bandit); fi
if [[ ${#BANDIT[@]} -eq 0 ]]; then
skip "bandit β€” not installed and uvx unavailable (pip install bandit)"
else
BARGS=(-r src/ -ll -c bandit.yaml)
[[ -f "$BASELINE" ]] && BARGS+=(-b "$BASELINE")
"${BANDIT[@]}" "${BARGS[@]}" -f json -o "$OUT/bandit-$STAMP.json" -q
BRC=$?
"${BANDIT[@]}" "${BARGS[@]}" -q 2>/dev/null | tail -n 20 || true
if [[ "$BRC" -eq 0 ]]; then
ok "bandit β€” no new findings beyond the accepted baseline"
else
fail "bandit reported NEW findings (see $OUT/bandit-$STAMP.json). Fix, or if"
echo " intended, annotate inline (# nosec Bxxx) and regenerate the"
echo " baseline: make security-baseline."
fi
fi
# --------------------------------------------------------------------------- #
# Stage 3 β€” gitleaks (secrets: working tree AND full history)
# --------------------------------------------------------------------------- #
note "gitleaks (secrets β€” working tree + full git history)"
if command -v gitleaks >/dev/null 2>&1; then
gitleaks detect --config .gitleaks.toml --redact --no-banner \
--report-format json --report-path "$OUT/gitleaks-$STAMP.json"
if [[ $? -eq 0 ]]; then
ok "gitleaks β€” no secrets detected in tree or history"
else
fail "gitleaks detected potential secrets (see $OUT/gitleaks-$STAMP.json)."
echo " If a real secret leaked, ROTATE it (ADR-0012/0013) β€” removing"
echo " the commit is not enough."
fi
else
skip "gitleaks β€” not installed (brew install gitleaks)"
fi
# --------------------------------------------------------------------------- #
# Stage 4 β€” trivy (sandbox image / Dockerfile vuln scan, ADR-0007)
# --------------------------------------------------------------------------- #
note "trivy (sandbox Dockerfile β€” ADR-0007)"
DOCKERFILE="docker/sandbox.Dockerfile"
if [[ ! -f "$DOCKERFILE" ]]; then
skip "trivy β€” $DOCKERFILE not found"
elif command -v trivy >/dev/null 2>&1; then
# config scan catches Dockerfile misconfig; a full image fs scan runs in CI
# against the built image. HIGH/CRITICAL gate only.
trivy config --severity HIGH,CRITICAL --exit-code 1 \
--format json --output "$OUT/trivy-$STAMP.json" "$DOCKERFILE"
if [[ $? -eq 0 ]]; then
ok "trivy β€” no HIGH/CRITICAL Dockerfile misconfigurations"
else
fail "trivy flagged the sandbox Dockerfile (see $OUT/trivy-$STAMP.json)."
fi
else
skip "trivy β€” not installed (brew install trivy)"
fi
# --------------------------------------------------------------------------- #
# Summary
# --------------------------------------------------------------------------- #
note "summary"
if [[ ${#SKIPS[@]} -gt 0 ]]; then
echo "Skipped stages (install the tool to enable):"
for s in "${SKIPS[@]}"; do echo " - $s"; done
fi
if [[ "$RC" -eq 0 ]]; then
ok "security scan passed (all enabled stages clean)."
else
echo "Security scan FAILED (rc=$RC) β€” triage above. Artifacts in $OUT/."
fi
exit "$RC"