Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
159 lines
8.8 KiB
Bash
Executable File
159 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/security-scan.sh — out-of-ecosystem, black-box security scan of a release image (ersatztv#314).
|
|
#
|
|
# WHY: every OTHER security check we run is in-ecosystem / white-box (SonarAnalyzer, NetArchTest, the
|
|
# adversarial fork + Codex review passes, the api-docs/format/decisions CI gates, `dotnet list package
|
|
# --vulnerable`). They reason about our source the way we do, so they share our blind spots. This drives
|
|
# the RUNNING product from outside our C#/review stack — OWASP ZAP as an authenticated DAST against every
|
|
# `/api/v1` endpoint, plus semgrep as a SAST cross-check — to catch the classes structural review misses:
|
|
# missing/incorrect security headers, an endpoint that forgot its auth attribute, verb tampering,
|
|
# injection reflections, known-CVE fingerprints. It is a #197 exit criterion — a HARD GATE before remote
|
|
# exposure — and should be re-run each release (see docs/ci-cd.md → Security scanning).
|
|
#
|
|
# It runs on the docker host (bumblebee) against a THROWAWAY container booted from the image under test —
|
|
# NEVER the deployed prod (or test) container: the authenticated active scan sends attack payloads to
|
|
# write endpoints (creating/deleting rows, triggering playout builds), so it must target a disposable
|
|
# instance with a fresh empty config volume. The container is torn down on exit.
|
|
#
|
|
# Usage:
|
|
# scripts/security-scan.sh [IMAGE] [PORT]
|
|
# IMAGE container image to scan (default: 192.168.1.95:3000/timothy/ersatztv:latest)
|
|
# PORT host port for the throwaway app (default: 8411)
|
|
# Env:
|
|
# ETV_SCAN_OUT host dir for reports (default: /tmp/etv-scan-out)
|
|
# ETV_SCAN_SKIP_SEMGREP=1 skip the SAST pass (DAST only)
|
|
#
|
|
# Reports land in $ETV_SCAN_OUT: zap-api-report.{html,md,json}. zap-api-scan.py's exit code is NOT a
|
|
# simple pass/fail — its wrapper contract (ersatztv#338, found via the #335 exact-image release scan
|
|
# returning 2 for a clean-enough report):
|
|
# 0 clean — no FAIL or WARN alerts
|
|
# 2 WARN — only WARN-level alerts (triage, but NOT release-blocking) — do not fail the job on this
|
|
# 1 FAIL — at least one FAIL-level alert — release-blocking
|
|
# 124 our own `timeout` wrapper killed a hung wrapper (see the NOTE below) — report is still usable
|
|
# other the scanner/tool itself errored (bad args, crash) — not a scan result at all
|
|
# classify_zap_exit() (below) is the single place this contract is encoded; the script's own final exit
|
|
# code reflects the CLASSIFICATION (0 for clean/WARN, 1 for FAIL/timeout/tool-error), not ZAP's raw code.
|
|
|
|
set -euo pipefail
|
|
|
|
# classify_zap_exit RC — sets ZAP_CLASS_MSG to an unambiguous one-line classification of ZAP's raw exit
|
|
# code and returns the exit status this SCRIPT should use for that outcome (see contract above). Kept as
|
|
# a standalone function (not inlined) so `--selftest` can exercise it without docker/ZAP.
|
|
classify_zap_exit() {
|
|
local rc="$1"
|
|
case "$rc" in
|
|
0) ZAP_CLASS_MSG="PASS — no FAIL or WARN alerts"; return 0 ;;
|
|
2) ZAP_CLASS_MSG="WARN — WARN-level alerts only; triage required, NOT release-blocking"; return 0 ;;
|
|
1) ZAP_CLASS_MSG="FAIL — at least one FAIL-level alert; release-blocking, do not ship"; return 1 ;;
|
|
124) ZAP_CLASS_MSG="TIMEOUT — wrapper hit ETV_SCAN_TIMEOUT; triage the partial report before shipping"; return 1 ;;
|
|
*) ZAP_CLASS_MSG="TOOL ERROR (raw exit $rc) — scanner itself failed, this is not a scan result"; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
if [ "${1:-}" = "--selftest" ]; then
|
|
# Lightweight regression check for the classification contract above (ersatztv#338 Done-when) — no
|
|
# docker/ZAP required, so it can run anywhere (incl. off bumblebee, incl. in per-PR CI if ever added).
|
|
selftest_failed=0
|
|
selftest_check() {
|
|
local rc="$1" want_status="$2" want_substr="$3" got_status
|
|
if classify_zap_exit "$rc"; then got_status=0; else got_status=$?; fi
|
|
if [ "$got_status" != "$want_status" ] || [[ "$ZAP_CLASS_MSG" != *"$want_substr"* ]]; then
|
|
echo "SELFTEST FAIL: rc=$rc -> status=$got_status msg='$ZAP_CLASS_MSG' (want status=$want_status, msg containing '$want_substr')" >&2
|
|
selftest_failed=1
|
|
else
|
|
echo "selftest ok: rc=$rc -> status=$got_status ($ZAP_CLASS_MSG)"
|
|
fi
|
|
}
|
|
selftest_check 0 0 "PASS"
|
|
selftest_check 2 0 "WARN"
|
|
selftest_check 1 1 "FAIL"
|
|
selftest_check 124 1 "TIMEOUT"
|
|
selftest_check 3 1 "TOOL ERROR"
|
|
selftest_check 77 1 "TOOL ERROR"
|
|
if [ "$selftest_failed" = 0 ]; then
|
|
echo "selftest: all ZAP exit classifications OK"; exit 0
|
|
else
|
|
echo "selftest: FAILURES ABOVE" >&2; exit 1
|
|
fi
|
|
fi
|
|
|
|
IMAGE="${1:-192.168.1.95:3000/timothy/ersatztv:latest}"
|
|
PORT="${2:-8411}"
|
|
OUT="${ETV_SCAN_OUT:-/tmp/etv-scan-out}"
|
|
TARGET_NAME="etv-secscan-target"
|
|
ZAP_IMAGE="ghcr.io/zaproxy/zaproxy:stable"
|
|
|
|
command -v docker >/dev/null || { echo "error: docker not found — run this on the docker host (bumblebee)" >&2; exit 2; }
|
|
|
|
CFG="$(mktemp -d /tmp/etv-secscan-cfg-XXXXXX)"
|
|
cleanup() {
|
|
# Reap the ZAP scanner container too — in the trap (not just inline after the scan) so an interrupt
|
|
# (SIGINT/SIGTERM) or a `timeout` kill mid-scan doesn't leave it running.
|
|
docker ps --filter ancestor="$ZAP_IMAGE" -q | xargs -r docker kill >/dev/null 2>&1 || true
|
|
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
|
rm -rf "$CFG" 2>/dev/null || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
echo "==> pulling $IMAGE"
|
|
docker pull "$IMAGE" >/dev/null
|
|
|
|
echo "==> booting throwaway scan target ($TARGET_NAME) on :$PORT with a fresh config volume"
|
|
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
|
docker run -d --name "$TARGET_NAME" -p "$PORT:8409" -v "$CFG":/config "$IMAGE" >/dev/null
|
|
|
|
echo -n "==> waiting for the app to serve /app/ "
|
|
ready=0
|
|
for _ in $(seq 1 60); do
|
|
if [ "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/app/" 2>/dev/null)" = "200" ]; then
|
|
ready=1; echo "ready"; break
|
|
fi
|
|
echo -n "."; sleep 3
|
|
done
|
|
[ "$ready" = 1 ] || { echo "FAILED: app never became ready"; docker logs "$TARGET_NAME" 2>&1 | tail -30; exit 1; }
|
|
|
|
# The machine key is generated at /config/api.key (0600, root-owned on the host mount) — read it from
|
|
# inside the container. ZAP injects it as X-Api-Key on every request via a replacer rule so the scan
|
|
# reaches the authenticated (`RequiresAuthentication` + RequireKeyForReads) surface, not just the /app shell.
|
|
KEY="$(docker exec "$TARGET_NAME" cat /config/api.key 2>/dev/null)"
|
|
[ -n "$KEY" ] || { echo "FAILED: could not read /config/api.key"; exit 1; }
|
|
echo "==> got machine key (${#KEY} chars); it will be injected as X-Api-Key on every request"
|
|
|
|
mkdir -p "$OUT"; chmod 777 "$OUT"
|
|
|
|
# zap-api-scan.py imports the OpenAPI spec (served as a static file at /openapi/v1.json) so it exercises
|
|
# every declared /api/v1 operation — not just what a spider finds — then runs passive + active scanners.
|
|
# The reports are written when the scan finishes but BEFORE the wrapper exits; zap-api-scan has been seen
|
|
# to hang in post-scan cleanup (draining the passive queue / holding a connection to a streaming endpoint),
|
|
# so the whole run is wrapped in `timeout` — a hung wrapper is killed and the already-written report is
|
|
# still used. ETV_SCAN_TIMEOUT (default 45m) bounds it.
|
|
echo "==> running authenticated OWASP ZAP API scan (this can take several minutes)"
|
|
set +e
|
|
timeout "${ETV_SCAN_TIMEOUT:-45m}" docker run --rm --network host -v "$OUT":/zap/wrk:rw "$ZAP_IMAGE" \
|
|
zap-api-scan.py -t "http://localhost:$PORT/openapi/v1.json" -f openapi \
|
|
-r zap-api-report.html -w zap-api-report.md -J zap-api-report.json \
|
|
-z "replacer.full_list(0).description=apikey;replacer.full_list(0).enabled=true;replacer.full_list(0).matchtype=REQ_HEADER;replacer.full_list(0).matchstr=X-Api-Key;replacer.full_list(0).regex=false;replacer.full_list(0).replacement=$KEY"
|
|
zap_rc=$?
|
|
[ "$zap_rc" = 124 ] && echo "NOTE: ZAP wrapper hit the ${ETV_SCAN_TIMEOUT:-45m} timeout (usually a post-scan cleanup hang) — the report written before the hang is still valid; triage it."
|
|
classify_zap_exit "$zap_rc"
|
|
zap_class_status=$?
|
|
set -e
|
|
# (The ZAP container + target + temp dir are reaped by cleanup() on the EXIT trap — every path, incl.
|
|
# a `timeout` kill or Ctrl-C mid-scan.)
|
|
echo "==> ZAP report: $OUT/zap-api-report.html (+ .md/.json)"
|
|
echo "==> ZAP result: $ZAP_CLASS_MSG (raw exit $zap_rc)"
|
|
|
|
if [ "${ETV_SCAN_SKIP_SEMGREP:-0}" = "1" ]; then
|
|
echo "==> semgrep SAST skipped (ETV_SCAN_SKIP_SEMGREP=1)"
|
|
elif ! command -v semgrep >/dev/null; then
|
|
echo "WARN: semgrep not installed — SAST cross-check SKIPPED (install semgrep for full coverage)" >&2
|
|
else
|
|
echo "==> semgrep SAST cross-check (repo root)"
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
semgrep scan --config p/security-audit --config p/secrets --config p/csharp \
|
|
--exclude web/node_modules --exclude design-system --metrics off --quiet \
|
|
"$REPO_ROOT" || echo "(semgrep reported findings — triage in the report above)"
|
|
fi
|
|
|
|
exit "$zap_class_status"
|