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"
|