File size: 10,979 Bytes
377417d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17acd0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377417d
17acd0c
377417d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
211
212
213
214
215
216
217
218
219
220
221
222
#!/usr/bin/env bash
# ADR-0014 security scan β€” this repo's copy of the shared definition (authored in
# DecoupleRpy_Agent; ported from pdac-subtype-agent). Same stages everywhere, run by
# the pre-push hook and `make security-scan`:
#
#   1. Dependencies   β€” pip-audit over the FULL resolved dependency tree (+ SBOM)
#   2. Static         β€” bandit over the app code (baseline-suppressed accepted findings)
#   3. Secrets        β€” gitleaks over the working tree AND full git history
#   4. Sandbox image  β€” trivy over docker/sandbox.Dockerfile (skips where absent)
#
# Differences from the sibling copies, both deliberate:
#
#   - Stage 1 audits the RESOLVED tree, not the lines in requirements.txt. The
#     siblings run `pip-audit --no-deps`, which is right for them because their
#     requirements.txt is a compiled lockfile pinning every transitive dependency.
#     Here it is a short hand-written list, so --no-deps would audit only the top-level
#     pins and never see a transitive CVE β€” exactly the class that hit anyio on
#     2026-09-21. So a hand-written file is first resolved with uv for the Space's own
#     python_version, plus the gradio HF installs from README `sdk_version`; a file that
#     is already a uv lock is audited as-is.
#   - bandit targets are discovered, not listed: `src/` plus every root-level module,
#     so a new top-level file cannot silently fall outside the scan.
#
# Not audited: HF's generic SDK base layer (datasets, huggingface-hub, …), which HF
# installs and caches outside this repo. Anything we depend on through it that
# matters must be floored in requirements.in (see its security-floors block).
#
# Design goals (unchanged from the shared definition):
#   - No global installs, no repo mutation beyond security/ artifacts.
#   - Graceful skip (not failure) when an optional scanner is absent; loud about it.
#     SECURITY_SCAN_STRICT=1 turns skips into failures.
#   - Artifacts land in $SECURITY_SCAN_OUT (default: security/). The pre-push hook
#     scans a throwaway checkout of the PUSHED commit and points this at the real
#     repo, so reports and the cached audit venv survive the checkout's removal.
#
# Exit code: non-zero if ANY enabled stage reports findings.
# Usage:  scripts/security_scan.sh [requirements.txt]
set -uo pipefail

cd "$(dirname "$0")/.."
REQ="${1:-requirements.txt}"
OUT="${SECURITY_SCAN_OUT:-security}"
mkdir -p "$OUT"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
STRICT="${SECURITY_SCAN_STRICT:-0}"

RC=0
SKIPS=()
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

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; }

# README front matter is what HF builds from β€” read it rather than restating it.
front() { awk '/^---$/{n++; next} n==1' README.md 2>/dev/null | sed -n "s/^$1:[[:space:]]*//p" | tr -d '"' | head -1; }
PYV="$(front python_version)"; PYV="${PYV:-3.10}"   # HF's default when unset
SDKV="$(front sdk_version)"

# --------------------------------------------------------------------------- #
# Stage 1a β€” the full dependency lock that stage 1 audits
# --------------------------------------------------------------------------- #
note "dependency tree (python $PYV, linux${SDKV:+, gradio $SDKV from sdk_version})"
LOCK="$TMP/lock.txt"
if head -3 "$REQ" | grep -q 'uv pip compile'; then
  cp "$REQ" "$LOCK"; ok "$REQ is already a compiled lock β€” auditing it as-is"
elif command -v uv >/dev/null 2>&1; then
  { cat "$REQ"; [[ -n "$SDKV" ]] && echo "gradio[oauth,mcp]==$SDKV"; } > "$TMP/in.txt"
  if uv pip compile "$TMP/in.txt" -o "$LOCK" --python-version "$PYV" \
       --python-platform linux --no-header --quiet 2>"$TMP/uv.err"; then
    ok "resolved $(grep -cE '^[A-Za-z0-9_.-]+==' "$LOCK") packages (incl. transitive)"
  else
    fail "could not resolve $REQ for python $PYV:"; sed 's/^/         /' "$TMP/uv.err"; LOCK=""
  fi
else
  skip "dependency resolution β€” uv not found; transitive deps will NOT be audited"
  cp "$REQ" "$LOCK"
fi

# --------------------------------------------------------------------------- #
# Stage 1b β€” CycloneDX SBOM (of the resolved tree)
# --------------------------------------------------------------------------- #
note "SBOM (CycloneDX) -> $OUT/sbom.json"
if [[ -z "$LOCK" ]]; then
  skip "SBOM β€” no resolved dependency tree"
elif command -v uvx >/dev/null 2>&1; then
  uvx --from cyclonedx-bom cyclonedx-py requirements "$LOCK" --of JSON -o "$OUT/sbom.json" \
    >/dev/null 2>&1 && ok "SBOM written" || fail "SBOM generation errored"
else
  skip "SBOM β€” uvx not found"
fi

# --------------------------------------------------------------------------- #
# Stage 1c β€” pip-audit (known CVEs)
# --------------------------------------------------------------------------- #
# biodata-registry is a first-party wheel hosted on HF, not a PyPI package, so it has
# no advisories to look up; strip it (and any URL/git line) before auditing.
note "pip-audit (known CVEs; first-party HF-wheel dep excluded)"
PYBIN=""
for cand in python3.13 python3.12 python3.11 python3; do
  command -v "$cand" >/dev/null 2>&1 || continue
  if "$cand" -m venv "$TMP/probe-$cand" >/dev/null 2>&1; then PYBIN="$cand"; break; fi
done
if [[ -z "$LOCK" ]]; then
  : # already reported as a finding above
elif [[ -z "$PYBIN" ]]; then
  skip "pip-audit β€” no Python >=3.11 with a working venv found"
else
  grep -vE 'git\+|biodata-registry|https?://' "$LOCK" > "$TMP/audit.txt"
  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
  # Recorded, expiring acceptances (security/accepted-vulns.txt; justified in
  # security/ACCEPTED-FINDINGS.md). An EXPIRED entry fails the scan: re-triage, never extend quietly.
  IGNORES=()
  if [[ -f security/accepted-vulns.txt ]]; then
    TODAY="$(date -u +%Y-%m-%d)"
    while read -r vid expires _rest; do
      [[ -z "${vid:-}" || "$vid" == \#* ]] && continue
      exp="${expires#expires=}"
      if [[ "$exp" < "$TODAY" ]]; then
        fail "accepted finding $vid expired on $exp β€” re-triage it (security/ACCEPTED-FINDINGS.md)"
        continue
      fi
      IGNORES+=(--ignore-vuln "$vid")
    done < security/accepted-vulns.txt
    [[ ${#IGNORES[@]} -gt 0 ]] && echo "    accepting $(( ${#IGNORES[@]} / 2 )) recorded finding(s) until their expiry (security/accepted-vulns.txt)"
  fi
  # `${IGNORES[@]+…}`: macOS ships bash 3.2, where an empty array under `set -u` is unbound.
  "$VENV/bin/python" -m pip_audit --requirement "$TMP/audit.txt" --no-deps --disable-pip \
    ${IGNORES[@]+"${IGNORES[@]}"} --format columns 2>/dev/null | tee "$OUT/pip-audit-$STAMP.txt"
  AUDIT_RC=${PIPESTATUS[0]}
  if [[ "$AUDIT_RC" -eq 0 ]]; then
    ok "pip-audit β€” no known vulnerabilities in the resolved tree"
  else
    fail "pip-audit reported vulnerabilities (see $OUT/pip-audit-$STAMP.txt)."
    echo "         Fix by pinning or flooring the package in requirements.txt, or record a"
    echo "         justified acceptance in security/ACCEPTED-FINDINGS.md."
  fi
fi

# --------------------------------------------------------------------------- #
# Stage 2 β€” bandit (static analysis, baseline-suppressed)
# --------------------------------------------------------------------------- #
TARGETS=(); [[ -d src ]] && TARGETS+=(src)
while IFS= read -r f; do TARGETS+=("$f"); done < <(git ls-files '*.py' 2>/dev/null | grep -v /)
note "bandit (static analysis over: ${TARGETS[*]})"
BASELINE="security/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"
elif [[ ${#TARGETS[@]} -eq 0 ]]; then
  skip "bandit β€” no Python code found"
else
  BARGS=(-r "${TARGETS[@]}" -ll -c bandit.yaml)
  [[ -f "$BASELINE" ]] && BARGS+=(-b "$BASELINE")
  "${BANDIT[@]}" "${BARGS[@]}" -f json -o "$OUT/bandit-$STAMP.json" -q 2>/dev/null
  BRC=$?
  "${BANDIT[@]}" "${BARGS[@]}" -q 2>/dev/null | tail -n 25 || 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), document it in"
    echo "         security/ACCEPTED-FINDINGS.md, and run: 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 --log-level warn \
    --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 (HF_TOKEN / research_agent_token /"
    echo "         ANTHROPIC_API_KEY are HF Space secrets) β€” removing the commit is not enough."
  fi
else
  skip "gitleaks β€” not installed (brew install gitleaks)"
fi

# --------------------------------------------------------------------------- #
# Stage 4 β€” trivy (sandbox image) β€” N/A unless the repo has one
# --------------------------------------------------------------------------- #
note "trivy (sandbox Dockerfile)"
DOCKERFILE="docker/sandbox.Dockerfile"
if [[ ! -f "$DOCKERFILE" ]]; then
  skip "trivy β€” $DOCKERFILE not found (no sandboxed-exec image in this repo)"
elif command -v trivy >/dev/null 2>&1; then
  trivy config --severity HIGH,CRITICAL --exit-code 1 \
    --format json --output "$OUT/trivy-$STAMP.json" "$DOCKERFILE"
  [[ $? -eq 0 ]] && ok "trivy β€” no HIGH/CRITICAL Dockerfile misconfigurations" \
                 || fail "trivy flagged the sandbox Dockerfile (see $OUT/trivy-$STAMP.json)."
else
  skip "trivy β€” not installed (brew install trivy)"
fi

# --------------------------------------------------------------------------- #
# Summary
# --------------------------------------------------------------------------- #
note "summary"
if [[ ${#SKIPS[@]} -gt 0 ]]; then
  echo "Skipped stages:"; 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"