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"