File size: 9,434 Bytes
9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 b30c429 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc 9060e80 d90d9bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | #!/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"
|