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"