#!/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"