From adfaa32a4210d22d7861049e05f208d845074874 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 16:24:16 +0200 Subject: [PATCH] ci: gate on known advisories in the .NET dependency tree (RB-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent existed for the backend, so the entire .NET dependency tree — direct and transitive — was unscanned (BIO-016 lists it first under "Absent"). The ticket's literal wording would not have worked. `dotnet list package --vulnerable` is a reporting command: it prints the advisory table and exits 0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 — severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list package --vulnerable` would have added a line that reads like coverage in a compliance review and enforces nothing, which is worse than leaving the gap visible. scripts/dotnet-audit.sh runs the scan and matches "has the following vulnerable packages" — the exact sentence dotnet prints per project on a hit. One script, two callers (ci.yml and ci-local.sh), so the workflow and the local gate cannot drift apart. No severity threshold and no suppression list: picking either before a real advisory forces the question would be guessing at a policy nobody needs yet. Secret scanning, BIO-016's other named absence, stays on the checklist. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 ++ .../refactor-backlog/implementation/rb-14.md | 57 +++++++++++++++++++ scripts/ci-local.sh | 1 + scripts/dotnet-audit.sh | 25 ++++++++ 4 files changed, 88 insertions(+) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md create mode 100755 scripts/dotnet-audit.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011aab4..21fedab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,11 @@ jobs: # run manually against backend/openzaak/ (see its README), never in CI. - run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration" if: needs.changes.outputs.backend == 'true' + # RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency + # tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate, + # because `dotnet list package --vulnerable` exits 0 even on a High advisory. + - run: ./scripts/dotnet-audit.sh + if: needs.changes.outputs.backend == 'true' e2e: needs: changes diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md new file mode 100644 index 0000000..89b6c71 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md @@ -0,0 +1,57 @@ +# RB-14 — scan the .NET dependency tree for known advisories + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016 · `99-backlog.md` RB-14 + +## What was wrong + +`npm audit --omit=dev` gates the shipped frontend bundle. Nothing equivalent existed for the +backend, so **the entire .NET dependency tree — direct and transitive — was unscanned.** BIO-016 +lists it first under "Absent". + +## The trap the ticket walked into + +The backlog row says: `dotnet list package --vulnerable --include-transitive` **as a failing +step**. Implemented literally, that step cannot fail. `dotnet list package --vulnerable` is a +_reporting_ command: it prints the advisory table and exits 0 regardless. + +Verified rather than assumed — a throwaway project with `System.Net.Http 4.3.0`: + +``` +Project `vulntest` has the following vulnerable packages + > System.Net.Http 4.3.0 4.3.0 High https://github.com/advisories/GHSA-7jgj-8wvc-jh57 +EXITCODE=0 +``` + +A **High** severity advisory, exit code **0**. A bare `- run: dotnet list package --vulnerable` +would have added a line to `ci.yml` that reads like coverage in a compliance review and enforces +nothing — which is worse than leaving the gap visible. + +## What changed + +| File | Change | +| -------------------------- | -------------------------------------------------------------------------- | +| `scripts/dotnet-audit.sh` | **new** — runs the scan, matches its output, exits 1 on a hit | +| `.github/workflows/ci.yml` | new backend step calling the script (same `changes.outputs.backend` guard) | +| `scripts/ci-local.sh` | new `backend dependency audit` step calling the same script | + +**One script, two callers**, rather than the same four lines pasted into a workflow and a shell +script that would then drift. The guard matches `has the following vulnerable packages` — the +exact sentence `dotnet list` prints per project on a hit; the clean case prints +`has no vulnerable packages given the current sources` instead. + +## Verification + +- Against the real solution: passes, both projects clean (exit 0). +- Against the marker sentence `dotnet list` actually emits: the guard fires and exits 1. +- The exit-0-on-High behaviour that motivates the whole script is reproduced above. + +## Residual + +`--include-transitive` means a vulnerable package pulled in by a dependency turns CI red with no +direct upgrade available. The fix in that case is a direct `PackageReference` pinning a patched +version; the script's failure message says so. There is deliberately **no severity threshold and +no suppression list** — adding one before a real advisory forces the question would be guessing +at a policy nobody has needed yet. + +Secret scanning (gitleaks/trufflehog), BIO-016's other named absence, is **not** in this ticket +and remains on the pre-production checklist. diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index f5f1eb4..d80e2ac 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -29,6 +29,7 @@ step "test (vitest + coverage)"; npm run test:coverage step "build --localize (nl+en)"; npx ng build ssp --localize; npx ng build behandelportal --localize step "npm audit (shipped deps)"; npm audit --omit=dev step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration" ) +step "backend dependency audit"; ./scripts/dotnet-audit.sh step "showcase snippets drift"; npm run gen:snippets; git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts step "behaviour spec drift"; npm run gen:behaviour-spec; git diff --exit-code libs/shared/docs/behaviour-spec.mdx step "api-client drift"; npm run gen:api; git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json diff --git a/scripts/dotnet-audit.sh b/scripts/dotnet-audit.sh new file mode 100755 index 0000000..7774c93 --- /dev/null +++ b/scripts/dotnet-audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fail if any NuGet package (direct or transitive) has a known advisory — the .NET half of +# `npm audit --omit=dev`, which only ever covered the frontend (RB-14/BIO-016). +# +# `dotnet list package --vulnerable` is a REPORTING command: it prints the advisory table and +# still exits 0. Verified against a deliberately vulnerable project — System.Net.Http 4.3.0, +# GHSA-7jgj-8wvc-jh57, severity High, exit code 0. So `- run: dotnet list package --vulnerable` +# on its own is a gate that enforces nothing, which is worse than no gate: it reads like +# coverage in the workflow file. Matching its output is what makes it block. +# +# Shared by .github/workflows/ci.yml and scripts/ci-local.sh so the two cannot drift. +set -euo pipefail +cd "$(dirname "$0")/.." + +report=$(dotnet list backend/BigRegister.slnx package --vulnerable --include-transitive) +echo "$report" + +# The exact sentence `dotnet list` prints per project when it finds something; the clean case +# prints "has no vulnerable packages given the current sources" instead. +if grep -q "has the following vulnerable packages" <<<"$report"; then + echo + echo "✖ Vulnerable NuGet packages found (table above)." >&2 + echo " Transitive hits can be pinned with a direct PackageReference to a patched version." >&2 + exit 1 +fi