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 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
This commit is contained in:
2026-10-30 00:03:24 +01:00
co-authored by Claude Opus 5.5
parent f21dc121d3
commit 3f7febf656
2 changed files with 45 additions and 6 deletions
+3 -2
View File
@@ -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
+42 -4
View File
@@ -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"