From f21dc121d3f8385106536fc826959502e4e040c9 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 21:45:44 +0100 Subject: [PATCH 1/6] fix(931): a missing semgrep fails the security-scan release gate instead of warning and exiting 0 The SAST half is now required: require_semgrep exits 2 naming the tool before the image pull; ETV_SCAN_SKIP_SEMGREP=1 stays the only skip. --selftest pins absent, opt-out and present. docs/ci-cd.md states the invariant, not the stale host claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- docs/ci-cd.md | 9 ++++++--- scripts/security-scan.sh | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index d089d939d..4d028d053 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2660,9 +2660,12 @@ running product from outside our C#/review stack. `/api/v1` operation, injecting `X-Api-Key` on every request via a ZAP replacer rule so it reaches the `[RequiresAuthentication]` + `RequireKeyForReads` surface (not just the `/app` shell an unauthenticated spider sees). Then a **semgrep** SAST cross-check (`p/security-audit` + `p/secrets` + `p/csharp`). The - container is torn down on exit. **semgrep runs only if it is installed on that host**, and otherwise the - script prints a WARN and still exits 0. As of 2026-10-27 bumblebee has no semgrep, so run it separately - against a `git archive` of the tagged commit (the Mac has it) and triage both halves. + container is torn down on exit. **The SAST half is required**: a host without + semgrep on `PATH` fails the script up front (exit 2, naming the tool) before the image is pulled, and the + only skip is the explicit opt-out `ETV_SCAN_SKIP_SEMGREP=1` (DAST only; a gate run that sets it has not + run the SAST half, so run semgrep separately and say so in the release record). `--selftest` pins + the missing, opted-out and present cases (ersatztv#931). A non-interactive ssh shell needs semgrep on + its `PATH` (`~/.zshenv`, not `.zshrc`). - **Where/when.** Runs on a **docker host** with registry access (bumblebee, as the script's own header names, or jazz), like `migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / `8411`). It is a **manual release-gate**, deliberately **not** a per-PR CI job — it needs docker + a booted image, takes diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 9af0ef3a3..5d49eb7b1 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -21,7 +21,7 @@ # 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) +# ETV_SCAN_SKIP_SEMGREP=1 skip the SAST pass (DAST only) — the ONLY skip; a missing semgrep otherwise exits 2 # # 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 @@ -50,6 +50,16 @@ classify_zap_exit() { esac } +# require_semgrep — the SAST half is part of the gate: a missing semgrep is an ERROR naming the tool, never a +# warning that still exits 0 (ersatztv#931 — a v26.16.0 release gate could pass with its SAST half never run). +# The only skip is the explicit opt-out ETV_SCAN_SKIP_SEMGREP=1. Standalone so `--selftest` can exercise it. +require_semgrep() { + [ "${ETV_SCAN_SKIP_SEMGREP:-0}" = "1" ] && return 0 + command -v semgrep >/dev/null && return 0 + echo "error: semgrep not found on PATH — the SAST half of the release gate cannot run. Install semgrep, or set ETV_SCAN_SKIP_SEMGREP=1 to skip it explicitly (DAST only)." >&2 + return 2 +} + 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). @@ -70,6 +80,24 @@ if [ "${1:-}" = "--selftest" ]; then selftest_check 124 1 "TIMEOUT" selftest_check 3 1 "TOOL ERROR" selftest_check 77 1 "TOOL ERROR" + # semgrep gate (ersatztv#931): absent -> non-zero naming the tool; opt-out -> 0; present -> 0. + semgrep_check() { + local label="$1" want_status="$2" want_substr="$3" got_status err + err="$(require_semgrep 2>&1 >/dev/null)" && got_status=0 || got_status=$? + if [ "$got_status" != "$want_status" ] || [[ "$err" != *"$want_substr"* ]]; then + echo "SELFTEST FAIL: semgrep gate ($label) -> status=$got_status msg='$err' (want status=$want_status, msg containing '$want_substr')" >&2 + return 1 + else + echo "selftest ok: semgrep gate ($label) -> status=$got_status" + fi + } + sg_dir="$(mktemp -d)"; sg_empty="$(mktemp -d)" + printf '#!/bin/sh\n' > "$sg_dir/semgrep"; chmod +x "$sg_dir/semgrep" + (PATH="$sg_empty"; unset ETV_SCAN_SKIP_SEMGREP; semgrep_check "absent" 2 "semgrep") || selftest_failed=1 + (PATH="$sg_empty"; ETV_SCAN_SKIP_SEMGREP=0; semgrep_check "absent, skip=0" 2 "semgrep") || selftest_failed=1 + (PATH="$sg_empty"; ETV_SCAN_SKIP_SEMGREP=1; semgrep_check "absent, explicit opt-out" 0 "") || selftest_failed=1 + (PATH="$sg_dir"; unset ETV_SCAN_SKIP_SEMGREP; semgrep_check "present" 0 "") || selftest_failed=1 + rm -rf "$sg_dir" "$sg_empty" if [ "$selftest_failed" = 0 ]; then echo "selftest: all ZAP exit classifications OK"; exit 0 else @@ -83,6 +111,9 @@ OUT="${ETV_SCAN_OUT:-/tmp/etv-scan-out}" TARGET_NAME="etv-secscan-target" ZAP_IMAGE="ghcr.io/zaproxy/zaproxy:stable" +# Fail fast, before the image pull and the multi-minute ZAP run, if the SAST half cannot run. +require_semgrep || exit $? + 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)" @@ -145,8 +176,6 @@ 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)" From 3f7febf656673d9ebb84c26bdaa9de3e213727b9 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 22:19:27 +0100 Subject: [PATCH 2/6] fix(931): pin the script's own exit in --selftest and fail the gate on a semgrep that errors Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- docs/ci-cd.md | 5 +++-- scripts/security-scan.sh | 46 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 4d028d053..99fafbd6e 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2663,8 +2663,9 @@ running product from outside our C#/review stack. container is torn down on exit. **The SAST half is required**: a host without semgrep on `PATH` fails the script up front (exit 2, naming the tool) before the image is pulled, and the only skip is the explicit opt-out `ETV_SCAN_SKIP_SEMGREP=1` (DAST only; a gate run that sets it has not - run the SAST half, so run semgrep separately and say so in the release record). `--selftest` pins - the missing, opted-out and present cases (ersatztv#931). A non-interactive ssh shell needs semgrep on + run the SAST half, so run semgrep separately and say so in the release record). A semgrep that is installed but exits non-zero (bad config, + registry unreachable) also fails the script, since findings alone exit 0. `--selftest` pins the + missing, opted-out, present and failing cases through the script's own exit status (ersatztv#931). A non-interactive ssh shell needs semgrep on its `PATH` (`~/.zshenv`, not `.zshrc`). - **Where/when.** Runs on a **docker host** with registry access (bumblebee, as the script's own header names, or jazz), like `migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / `8411`). It is a diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 5d49eb7b1..7fc98f564 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -60,6 +60,14 @@ require_semgrep() { return 2 } +# run_semgrep ROOT — the SAST pass. Without --error semgrep exits 0 on findings (they print above), so ANY +# non-zero exit means semgrep itself failed (bad config, registry unreachable, crash): the SAST half did not +# run, which must fail the gate exactly like a missing semgrep (ersatztv#931). Returns semgrep's own status. +run_semgrep() { + semgrep scan --config p/security-audit --config p/secrets --config p/csharp \ + --exclude web/node_modules --exclude design-system --metrics off --quiet "$1" +} + 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). @@ -97,7 +105,34 @@ if [ "${1:-}" = "--selftest" ]; then (PATH="$sg_empty"; ETV_SCAN_SKIP_SEMGREP=0; semgrep_check "absent, skip=0" 2 "semgrep") || selftest_failed=1 (PATH="$sg_empty"; ETV_SCAN_SKIP_SEMGREP=1; semgrep_check "absent, explicit opt-out" 0 "") || selftest_failed=1 (PATH="$sg_dir"; unset ETV_SCAN_SKIP_SEMGREP; semgrep_check "present" 0 "") || selftest_failed=1 - rm -rf "$sg_dir" "$sg_empty" + # End-to-end through the real main path (pins the call sites, not just the helper). Absent semgrep + no + # docker on PATH: must exit 2 with the semgrep message BEFORE the docker check (which also exits 2). + out="$(env -u ETV_SCAN_SKIP_SEMGREP PATH="$sg_empty" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? + if [ "$rc" = 2 ] && [[ "$out" == *"error: semgrep not found"* ]]; then + echo "selftest ok: script, semgrep absent -> exit 2 naming semgrep" + else + echo "SELFTEST FAIL: script with semgrep absent -> rc=$rc out='$out' (want rc=2 naming semgrep)" >&2; selftest_failed=1 + fi + out="$(ETV_SCAN_SKIP_SEMGREP=1 PATH="$sg_empty" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? + if [ "$rc" = 2 ] && [[ "$out" == *"docker not found"* ]] && [[ "$out" != *"semgrep not found"* ]]; then + echo "selftest ok: script, explicit opt-out -> gate skipped (stops at docker check)" + else + echo "SELFTEST FAIL: script with opt-out -> rc=$rc out='$out' (want to pass the semgrep gate)" >&2; selftest_failed=1 + fi + # Full run with shimmed docker/curl/timeout and a semgrep that FAILS (exit 2): the script must exit non-zero. + sg_shim="$(mktemp -d)"; sg_out="$(mktemp -d)" + printf '#!/bin/sh\n[ "$1" = exec ] && echo shimkey\nexit 0\n' > "$sg_shim/docker" + printf '#!/bin/sh\necho 200\n' > "$sg_shim/curl" + printf '#!/bin/sh\nshift\nexec "$@"\n' > "$sg_shim/timeout" + printf '#!/bin/sh\necho "fake semgrep: registry unreachable" >&2\nexit 2\n' > "$sg_shim/semgrep" + chmod +x "$sg_shim"/* + out="$(env -u ETV_SCAN_SKIP_SEMGREP PATH="$sg_shim:$PATH" ETV_SCAN_OUT="$sg_out" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? + if [ "$rc" != 0 ] && [[ "$out" == *"semgrep failed"* ]]; then + echo "selftest ok: script, semgrep failing -> exit $rc naming semgrep" + else + echo "SELFTEST FAIL: script with failing semgrep -> rc=$rc (want non-zero naming semgrep)" >&2; selftest_failed=1 + fi + rm -rf "$sg_dir" "$sg_empty" "$sg_shim" "$sg_out" if [ "$selftest_failed" = 0 ]; then echo "selftest: all ZAP exit classifications OK"; exit 0 else @@ -179,9 +214,12 @@ if [ "${ETV_SCAN_SKIP_SEMGREP:-0}" = "1" ]; then 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)" + sg_rc=0 + run_semgrep "$REPO_ROOT" || sg_rc=$? + if [ "$sg_rc" != 0 ]; then + echo "error: semgrep failed (exit $sg_rc) — the SAST half did not run; this is a gate failure, not a finding" >&2 + [ "$zap_class_status" != 0 ] || zap_class_status=2 + fi fi exit "$zap_class_status" From e04f75e9ec4cb3946707994c68923d48e79a86b4 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 22:45:01 +0100 Subject: [PATCH 3/6] docs(931): name the host that can pass the SAST gate and the script's own exit 2 Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- docs/ci-cd.md | 4 +++- scripts/security-scan.sh | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 99fafbd6e..0bbb5b0eb 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2667,7 +2667,7 @@ running product from outside our C#/review stack. registry unreachable) also fails the script, since findings alone exit 0. `--selftest` pins the missing, opted-out, present and failing cases through the script's own exit status (ersatztv#931). A non-interactive ssh shell needs semgrep on its `PATH` (`~/.zshenv`, not `.zshrc`). -- **Where/when.** Runs on a **docker host** with registry access (bumblebee, as the script's own header names, or jazz), like +- **Where/when.** Runs on a **docker host** with registry access (bumblebee, which has semgrep on its non-interactive `PATH`; a host without semgrep fails the gate, so jazz needs it installed first), like `migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / `8411`). It is a **manual release-gate**, deliberately **not** a per-PR CI job — it needs docker + a booted image, takes several minutes, and is noisy (expect to tune, not take raw). The continuous layer is the per-PR @@ -2686,6 +2686,8 @@ running product from outside our C#/review stack. `FAIL-NEW: 0` and two known/expected warning classes — the shell result looked like a failure though the release gate had actually passed. Run `scripts/security-scan.sh --selftest` for a docker-free regression check of the classification logic. + **The script's own exit 2 is not ZAP's WARN**: it means the SAST half did not run (semgrep missing + before the scan, or present and erroring after it) and is release-blocking. - **Triage.** Triage each WARN/FAIL finding false-positive vs real. Real, in-scope, go-live-blocking findings get fixed (e.g. the security headers from the #319 baseline; the Microsoft.OpenApi pin above); LAN-expected noise (Private-IP disclosure) is revisited only for genuine remote exposure. nuclei diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 7fc98f564..11a4e8325 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -33,6 +33,8 @@ # 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. +# The one script-level exit 2 is NOT ZAP's WARN: it means the SAST half did not run (semgrep missing, or +# present and erroring) and IS release-blocking. set -euo pipefail From 2e4de813099be854b33ce31dc0802f80373bb8ca Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 23:26:22 +0100 Subject: [PATCH 4/6] fix(931): pin the main-path semgrep block in --selftest and state every exit the script produces Round-3 review found two clause mutants of the main path surviving the selftest: the sg_rc check forced true (M9) and the main-path opt-out widened to any non-empty value (M10). The present case was pinned only through require_semgrep, and no full shimmed run used a semgrep that succeeds. --selftest now runs the script end to end with shimmed docker/curl/timeout (ZAP clean) and a semgrep shim that records each call: succeeding (unset and =0) -> exit 0 with semgrep run; failing -> exit 2; explicit opt-out with semgrep present -> exit 0 with semgrep not run. Witnessed red: M9 fails two cases, M10 fails the skip=0 case, and forcing the main-path skip off (`if false`) fails the opt-out case; M1-M8 stay red. The header's exit contract claimed exit 2 always meant the SAST half did not run, but a missing docker also exits 2. It now lists 0/1/2/other, with 2 as "a scanner half did not run", and the host line states the requirement instead of naming bumblebee. Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- scripts/security-scan.sh | 51 +++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 11a4e8325..dd9f00c6a 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -10,7 +10,8 @@ # 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 — +# It runs on a docker host with registry access and semgrep on its non-interactive PATH (docs/ci-cd.md → +# Security scanning → Where/when), 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. @@ -31,10 +32,14 @@ # 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. -# The one script-level exit 2 is NOT ZAP's WARN: it means the SAST half did not run (semgrep missing, or -# present and erroring) and IS release-blocking. +# classify_zap_exit() (below) is the single place this contract is encoded. The SCRIPT's own exit is not +# ZAP's raw code; every non-zero exit is release-blocking: +# 0 ZAP clean or WARN-only, AND the SAST half ran clean (or was opted out: ETV_SCAN_SKIP_SEMGREP=1) +# 1 ZAP FAIL / timeout / tool error, or the throwaway target never came up or its key was unreadable +# 2 NOT ZAP's WARN: a scanner half did not run — semgrep missing (checked before the scan) or erroring +# (after it), or docker missing. If ZAP already classified non-zero, that status stands and the +# semgrep error is printed alongside it. +# other a setup command failed under `set -e` (e.g. `docker pull`), exiting with that command's status set -euo pipefail @@ -121,22 +126,40 @@ if [ "${1:-}" = "--selftest" ]; then else echo "SELFTEST FAIL: script with opt-out -> rc=$rc out='$out' (want to pass the semgrep gate)" >&2; selftest_failed=1 fi - # Full run with shimmed docker/curl/timeout and a semgrep that FAILS (exit 2): the script must exit non-zero. + # Full runs through the MAIN-path SAST block, with shimmed docker/curl/timeout (ZAP reports clean, so the + # script's exit is decided by the semgrep half alone). The semgrep shim records each call in + # $ETV_SCAN_OUT/semgrep.called and exits $SG_SHIM_RC, so a case can assert both the exit and whether + # SAST actually ran. These pin the main-path opt-out test and the sg_rc check, not just the helper. sg_shim="$(mktemp -d)"; sg_out="$(mktemp -d)" printf '#!/bin/sh\n[ "$1" = exec ] && echo shimkey\nexit 0\n' > "$sg_shim/docker" printf '#!/bin/sh\necho 200\n' > "$sg_shim/curl" printf '#!/bin/sh\nshift\nexec "$@"\n' > "$sg_shim/timeout" - printf '#!/bin/sh\necho "fake semgrep: registry unreachable" >&2\nexit 2\n' > "$sg_shim/semgrep" + printf '#!/bin/sh\necho called >> "$ETV_SCAN_OUT/semgrep.called"\n[ "${SG_SHIM_RC:-0}" = 0 ] || echo "fake semgrep: registry unreachable" >&2\nexit "${SG_SHIM_RC:-0}"\n' > "$sg_shim/semgrep" chmod +x "$sg_shim"/* - out="$(env -u ETV_SCAN_SKIP_SEMGREP PATH="$sg_shim:$PATH" ETV_SCAN_OUT="$sg_out" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? - if [ "$rc" != 0 ] && [[ "$out" == *"semgrep failed"* ]]; then - echo "selftest ok: script, semgrep failing -> exit $rc naming semgrep" - else - echo "SELFTEST FAIL: script with failing semgrep -> rc=$rc (want non-zero naming semgrep)" >&2; selftest_failed=1 - fi + # full_check LABEL SG_SHIM_RC SKIP WANT_RC WANT_CALLED WANT_SUBSTR — SKIP is "unset" or the value to export. + full_check() { + local label="$1" sg_shim_rc="$2" skip="$3" want_rc="$4" want_called="$5" want_substr="$6" out rc called + rm -f "$sg_out/semgrep.called" + if [ "$skip" = unset ]; then + out="$(env -u ETV_SCAN_SKIP_SEMGREP SG_SHIM_RC="$sg_shim_rc" PATH="$sg_shim:$PATH" ETV_SCAN_OUT="$sg_out" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? + else + out="$(ETV_SCAN_SKIP_SEMGREP="$skip" SG_SHIM_RC="$sg_shim_rc" PATH="$sg_shim:$PATH" ETV_SCAN_OUT="$sg_out" "$BASH" "${BASH_SOURCE[0]}" 2>&1)" && rc=0 || rc=$? + fi + if [ -e "$sg_out/semgrep.called" ]; then called=1; else called=0; fi + if [ "$rc" = "$want_rc" ] && [ "$called" = "$want_called" ] && [[ "$out" == *"$want_substr"* ]]; then + echo "selftest ok: script, $label -> exit $rc, semgrep called=$called" + else + echo "SELFTEST FAIL: script, $label -> rc=$rc called=$called (want rc=$want_rc called=$want_called, output containing '$want_substr')" >&2 + selftest_failed=1 + fi + } + full_check "semgrep succeeds" 0 unset 0 1 "==> semgrep SAST cross-check" + full_check "semgrep succeeds, skip=0" 0 0 0 1 "==> semgrep SAST cross-check" + full_check "semgrep fails" 2 unset 2 1 "error: semgrep failed (exit 2)" + full_check "explicit opt-out, semgrep present" 0 1 0 0 "semgrep SAST skipped (ETV_SCAN_SKIP_SEMGREP=1)" rm -rf "$sg_dir" "$sg_empty" "$sg_shim" "$sg_out" if [ "$selftest_failed" = 0 ]; then - echo "selftest: all ZAP exit classifications OK"; exit 0 + echo "selftest: all ZAP exit classifications and semgrep-gate cases OK"; exit 0 else echo "selftest: FAILURES ABOVE" >&2; exit 1 fi From 0e64328ca25f1a8a1e4f51ea894d9e6fbeca570b Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 23:26:32 +0100 Subject: [PATCH 5/6] docs(931): state the scan host's requirement instead of naming hosts, and every script exit Round-3 review: the Security scanning section named bumblebee (has semgrep) and jazz (needs it) with no date or source, the Exit-code contract still said the script exits 0 for clean/WARN and 1 for FAIL, the exit-2 sentence said only the SAST half, and the --selftest sentence claimed more main-path coverage than it pinned. - Security scanning drops host names: the requirement is a docker host with registry access, a checkout of the commit under test (semgrep scans the checkout the script lives in), and semgrep on the non-interactive PATH, or the opt-out plus a separate SAST run. - The Exit-code contract lists 0/1/2/other; 2 is "a scanner half did not run" (semgrep missing or erroring, or docker missing). The --selftest sentence names exactly the cases it pins, now including the main-path ones. - The Hosts table and release step 2 sent the scan to jazz. That dates from d6aec6de7 (2026-07-20), which moved it there with the transcoders, but the scan boots its own throwaway target from the registry and needs nothing jazz-local. Step 2 now states the requirement, with the one measured host dated and sourced (bumblebee, 2026-10-27, server-management#1072). jazz had no semgrep when measured 2026-10-27, so following the old step would fail the gate with exit 2. - Edited prose rewrapped to the section's ~105 columns. Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- docs/ci-cd.md | 59 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 0bbb5b0eb..176281373 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -9,7 +9,7 @@ server-management#481. | Host | IP | Role | |---|---|---| -| **jazz** | 192.168.1.29 | **Docker host for the media transcoders** — prod `ersatztv` (8409), `ersatztv-test` (8410), Jellyfin. Release scans (`security-scan.sh`) and the prod-copy `migration-smoke.sh` run here. | +| **jazz** | 192.168.1.29 | **Docker host for the media transcoders** — prod `ersatztv` (8409), `ersatztv-test` (8410), Jellyfin. The prod-copy `migration-smoke.sh` runs here. The release scan (`security-scan.sh`) boots its own throwaway target, so it needs a docker host that meets its requirements (Security scanning → Where/when), not this one. | | **bumblebee** | 192.168.1.99 | CI runners (`bumblebee-runner`, `small-runner`), plus every other Docker stack. All the memory/lane measurements below were taken here. | | **ci-runner** | VM 127 (pve4) | The other `ubuntu-latest` CI runner. | @@ -116,8 +116,12 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada > protection is never left off) — and record the outcome on `release.tag-protection-v-star`, > which is waiting for it. 2. Wait for tag CI to build `:prod` + the immutable `:` + `:` images. Run - `scripts/security-scan.sh` on jazz against **the immutable `:` image**, not a - moving tag, and triage every ZAP/semgrep finding. + `scripts/security-scan.sh` against **the immutable `:` image**, not a moving tag, from a + checkout of the tagged commit on a docker host whose non-interactive `PATH` has semgrep: check with + `ssh command -v semgrep` first (bumblebee passed that check on 2026-10-27, + server-management#1072). A host without semgrep fails the gate with exit 2; the only alternative is + `ETV_SCAN_SKIP_SEMGREP=1` plus a separate semgrep run against the tagged commit, recorded as such. + Triage every ZAP/semgrep finding. Requirements: Security scanning → Where/when. 3. Only after the candidate passes, manually `DeployStack jazz-media` and observe its pre-deploy output. Prod's compose deliberately follows floating `:prod` (Timothy's 2026-07-11 decision), so no CI push or pin bump is needed. @@ -2660,19 +2664,21 @@ running product from outside our C#/review stack. `/api/v1` operation, injecting `X-Api-Key` on every request via a ZAP replacer rule so it reaches the `[RequiresAuthentication]` + `RequireKeyForReads` surface (not just the `/app` shell an unauthenticated spider sees). Then a **semgrep** SAST cross-check (`p/security-audit` + `p/secrets` + `p/csharp`). The - container is torn down on exit. **The SAST half is required**: a host without - semgrep on `PATH` fails the script up front (exit 2, naming the tool) before the image is pulled, and the - only skip is the explicit opt-out `ETV_SCAN_SKIP_SEMGREP=1` (DAST only; a gate run that sets it has not - run the SAST half, so run semgrep separately and say so in the release record). A semgrep that is installed but exits non-zero (bad config, - registry unreachable) also fails the script, since findings alone exit 0. `--selftest` pins the - missing, opted-out, present and failing cases through the script's own exit status (ersatztv#931). A non-interactive ssh shell needs semgrep on - its `PATH` (`~/.zshenv`, not `.zshrc`). -- **Where/when.** Runs on a **docker host** with registry access (bumblebee, which has semgrep on its non-interactive `PATH`; a host without semgrep fails the gate, so jazz needs it installed first), like - `migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / `8411`). It is a - **manual release-gate**, deliberately **not** a per-PR CI job — it needs docker + a booted image, takes - several minutes, and is noisy (expect to tune, not take raw). The continuous layer is the per-PR - white-box gates + the weekly `dependency-scan`; this is the per-release black-box pass. Re-run it each - release and before any change to the exposure posture. + container is torn down on exit. **The SAST half is required** (ersatztv#931): with no semgrep on `PATH` + the script exits 2 naming the tool, before the image is pulled. The only skip is the explicit opt-out + `ETV_SCAN_SKIP_SEMGREP=1` (any other value, `0` included, does not skip). A run that sets it is DAST + only and has not passed the SAST half: run semgrep separately and say so in the release record. A + semgrep that is installed but exits non-zero (bad config, registry unreachable) also fails the script, + since findings alone exit 0. +- **Where/when.** Runs on a **docker host** with registry access, from a checkout of the commit under test + (semgrep scans the checkout the script lives in), whose **non-interactive** `PATH` has semgrep: the + `PATH` that `ssh command -v semgrep` sees, which for zsh comes from `~/.zshenv`, not `.zshrc`. A + host without it fails the gate by construction; the alternative is the opt-out plus a separate SAST + run. Like `migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / + `8411`). It is a **manual release-gate**, deliberately **not** a per-PR CI job — it needs docker + a + booted image, takes several minutes, and is noisy (expect to tune, not take raw). The continuous layer + is the per-PR white-box gates + the weekly `dependency-scan`; this is the per-release black-box pass. + Re-run it each release and before any change to the exposure posture. - **Exit-code contract (ersatztv#338).** `zap-api-scan.py`'s raw exit code is NOT a simple pass/fail — it conflates a clean run with a warnings-only run unless you know its wrapper contract: **0** clean (no FAIL or WARN alerts), **2** WARN-only (triage required, but **not** release-blocking), **1** FAIL (at @@ -2680,14 +2686,19 @@ running product from outside our C#/review stack. hung post-scan cleanup (the report written before the hang is still usable — triage it), any other code means the scanner/tool itself errored (not a scan result at all). `scripts/security-scan.sh` encodes this in `classify_zap_exit()` and prints an unambiguous `==> ZAP result: ...` line; the script's own exit status reflects that classification (0 for - clean/WARN, 1 for FAIL/timeout/tool-error) rather than ZAP's raw code, so a warnings-only run no longer - reads as a failed scan. Found when the v26.8.0 release scan (#335) returned raw exit 2 for a report with - `FAIL-NEW: 0` and two known/expected warning classes — the shell result looked like a failure though the - release gate had actually passed. Run `scripts/security-scan.sh --selftest` for a docker-free regression - check of the classification logic. - **The script's own exit 2 is not ZAP's WARN**: it means the SAST half did not run (semgrep missing - before the scan, or present and erroring after it) and is release-blocking. + TIMEOUT|TOOL ERROR> ...` line. The script's **own** exit is not ZAP's raw code, so a warnings-only run + no longer reads as a failed scan. Found when the v26.8.0 release scan (#335) returned raw exit 2 for a + report with `FAIL-NEW: 0` and two known/expected warning classes — the shell result looked like a + failure though the release gate had actually passed. Every non-zero script exit is release-blocking: + **0** ZAP clean or WARN-only and the SAST half ran clean (or was opted out); **1** ZAP FAIL, timeout or + tool error, or the throwaway target never came up or its key was unreadable; **2** — *not* ZAP's WARN — + a scanner half did not run: semgrep missing (before the scan) or erroring (after it), or docker + missing; if ZAP already classified non-zero, that status stands and the semgrep error prints beside + it; **any other** code is a setup command (e.g. `docker pull`) failing under `set -e`. Run + `scripts/security-scan.sh --selftest` for a docker-free regression check: it pins every ZAP + classification, and runs the script itself with shimmed tools (ZAP reporting clean) to pin the + semgrep gate — missing → exit 2 before docker is checked, erroring → 2, succeeding → 0 with semgrep + run, opt-out → 0 with semgrep not run (`=0` still runs it). It exercises no real ZAP or semgrep. - **Triage.** Triage each WARN/FAIL finding false-positive vs real. Real, in-scope, go-live-blocking findings get fixed (e.g. the security headers from the #319 baseline; the Microsoft.OpenApi pin above); LAN-expected noise (Private-IP disclosure) is revisited only for genuine remote exposure. nuclei From f71e2fd0d083ad226c553b1df454c81487cd7d83 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 27 Oct 2026 23:27:03 +0100 Subject: [PATCH 6/6] docs(931): a failing setup command's exit is its own status, and the Hosts row names no excluded host The exit list gave "any other code" to a set -e setup failure, but `docker pull` usually exits 1, which collides with the ZAP FAIL code; it now says such a failure exits with that command's own status. The jazz Hosts row said the scan runs "not [on] this one", which would stay wrong once jazz gets semgrep; it now says any docker host that meets the requirements. Co-Authored-By: Claude Opus 5.5 Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 --- docs/ci-cd.md | 4 ++-- scripts/security-scan.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 176281373..22e272dad 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -9,7 +9,7 @@ server-management#481. | Host | IP | Role | |---|---|---| -| **jazz** | 192.168.1.29 | **Docker host for the media transcoders** — prod `ersatztv` (8409), `ersatztv-test` (8410), Jellyfin. The prod-copy `migration-smoke.sh` runs here. The release scan (`security-scan.sh`) boots its own throwaway target, so it needs a docker host that meets its requirements (Security scanning → Where/when), not this one. | +| **jazz** | 192.168.1.29 | **Docker host for the media transcoders** — prod `ersatztv` (8409), `ersatztv-test` (8410), Jellyfin. The prod-copy `migration-smoke.sh` runs here. The release scan (`security-scan.sh`) boots its own throwaway target from the registry, so it runs on any docker host that meets its requirements (Security scanning → Where/when). | | **bumblebee** | 192.168.1.99 | CI runners (`bumblebee-runner`, `small-runner`), plus every other Docker stack. All the memory/lane measurements below were taken here. | | **ci-runner** | VM 127 (pve4) | The other `ubuntu-latest` CI runner. | @@ -2694,7 +2694,7 @@ running product from outside our C#/review stack. tool error, or the throwaway target never came up or its key was unreadable; **2** — *not* ZAP's WARN — a scanner half did not run: semgrep missing (before the scan) or erroring (after it), or docker missing; if ZAP already classified non-zero, that status stands and the semgrep error prints beside - it; **any other** code is a setup command (e.g. `docker pull`) failing under `set -e`. Run + it. A setup command that fails under `set -e` (e.g. `docker pull`) exits with its own status. Run `scripts/security-scan.sh --selftest` for a docker-free regression check: it pins every ZAP classification, and runs the script itself with shimmed tools (ZAP reporting clean) to pin the semgrep gate — missing → exit 2 before docker is checked, erroring → 2, succeeding → 0 with semgrep diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index dd9f00c6a..2809ef1ae 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -39,7 +39,7 @@ # 2 NOT ZAP's WARN: a scanner half did not run — semgrep missing (checked before the scan) or erroring # (after it), or docker missing. If ZAP already classified non-zero, that status stands and the # semgrep error is printed alongside it. -# other a setup command failed under `set -e` (e.g. `docker pull`), exiting with that command's status +# A setup command that fails under `set -e` (e.g. `docker pull`) exits with that command's own status. set -euo pipefail