diff --git a/.github/workflows/security-monthly.yml b/.github/workflows/security-monthly.yml new file mode 100644 index 00000000..216834c7 --- /dev/null +++ b/.github/workflows/security-monthly.yml @@ -0,0 +1,130 @@ +name: Security — monthly SBOM & VEX report + +# Runs on GitHub's servers (not on anyone's laptop). Every month it regenerates +# the SBOM, scans dependencies, renders the +# report from the versioned template, drops everything into security//, +# and opens a PR for the team to review. Nothing is merged automatically. +# + +on: + schedule: + - cron: "0 6 1 * *" # 06:00 UTC on the 1st of every month + workflow_dispatch: {} # manual "Run workflow" button + +permissions: + contents: write + pull-requests: write + +concurrency: + group: security-monthly + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + steps: + - name: Checkout (with submodules for vendored C libs) + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Month stamp + id: m + run: echo "month=$(date -u +%Y-%m)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + - name: Enable pnpm + run: corepack enable + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + # ---- deterministic: SBOM (CycloneDX + SPDX + components.csv) ---- + - name: Generate SBOM + run: bash scripts/generate-sbom.sh + + # ---- deterministic: vulnerability scan (OSV, honoring the VEX baseline) ---- + - name: Install osv-scanner + run: | + OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64 + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS + ( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - ) + install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner + - name: Scan + run: | + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . + rc=$? + set -e + # osv-scanner: 0 = no vulns, 1 = vulns found. Any other code is a scanner + # failure — do NOT let scan-vulns turn it into an empty (clean) register. + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed (exit $rc)"; exit 1; fi + [ -s /tmp/osv.json ] || echo '{"results":[]}' > /tmp/osv.json + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + # also an UNfiltered scan, so the report's headline numbers are derived + # live (raw total vs. suppressed vs. surfacing) instead of hard-coded. + set +e + osv-scanner scan --recursive --format=json --output=/tmp/osv-raw.json . + rc2=$? + set -e + if [ "$rc2" != "0" ] && [ "$rc2" != "1" ]; then echo "::error::osv-scanner (raw) failed (exit $rc2)"; exit 1; fi + [ -s /tmp/osv-raw.json ] || echo '{"results":[]}' > /tmp/osv-raw.json + + - name: Build report (md + html) + run: | + NAME=$(node -p "require('./package.json').name") + node scripts/build-report.mjs \ + --config security/report-config.json \ + --cdx "sbom/$NAME.cdx.json" \ + --osv-raw /tmp/osv-raw.json \ + --osv-delta /tmp/osv.json \ + --out "security/${{ steps.m.outputs.month }}" \ + --date "${{ steps.m.outputs.month }}" + + - name: Render PDF + uses: browser-actions/setup-chrome@v1 + id: chrome + - name: Assemble dated folder + run: | + NAME=$(node -p "require('./package.json').name") + MONTH="${{ steps.m.outputs.month }}" + DIR="security/$MONTH"; mkdir -p "$DIR/sbom" + cp "sbom/$NAME".cdx.json "sbom/$NAME".spdx.json "sbom/$NAME".components.csv sbom/vulnerabilities.csv "$DIR/sbom/" + REPORT="$DIR/$(ls "$DIR" | grep -E 'Security-Report\.html$')" + # --no-sandbox / --disable-dev-shm-usage: Chrome's zygote sandbox aborts + # (SIGABRT) on GitHub runners; required for headless Chrome in CI. + # --disable-javascript: the report HTML is a static document written from + # report-config.json (AI-authored) — no JS should ever run while rendering + # it with local file:// access. Defense-in-depth on top of the HTML escaping. + "${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \ + --disable-javascript --disable-gpu --no-pdf-header-footer \ + --run-all-compositor-stages-before-draw --virtual-time-budget=5000 \ + --print-to-pdf="${REPORT%.html}.pdf" "file://$PWD/$REPORT" + ln -sfn "$MONTH" security/latest + + # ---- delivery: open the PR for review ---- + - name: Open Pull Request + uses: peter-evans/create-pull-request@v6 + with: + # A PR opened with the default GITHUB_TOKEN does NOT trigger other + # workflows (so the gate/lint/tests would never run on the monthly PR). + # Set a SECURITY_BOT_TOKEN secret (a GitHub App installation token or a + # fine-grained PAT with contents+PR write) to make checks run; it falls + # back to GITHUB_TOKEN if unset (PR still opens, just without checks). + token: ${{ secrets.SECURITY_BOT_TOKEN || github.token }} + base: development + branch: chore/security-${{ steps.m.outputs.month }} + title: "chore(security): monthly SBOM & VEX report — ${{ steps.m.outputs.month }}" + labels: supply-chain, security + commit-message: "chore(security): SBOM & VEX report ${{ steps.m.outputs.month }}" + body: | + Automated monthly supply-chain snapshot for **${{ github.event.repository.name }}** — `security/${{ steps.m.outputs.month }}/`. + + - SBOM regenerated (CycloneDX + SPDX) from the current lockfile. + - Dependencies scanned against OSV (same source as Dependabot), honoring `osv-scanner.toml` (the VEX baseline). + - New advisories (if any) surface in `vulnerabilities.csv` for human review (no AI triage on this public repo). + + Nothing is merged automatically. Approve to archive this month's snapshot. diff --git a/.github/workflows/security-pr-archive.yml b/.github/workflows/security-pr-archive.yml new file mode 100644 index 00000000..03118ad9 --- /dev/null +++ b/.github/workflows/security-pr-archive.yml @@ -0,0 +1,88 @@ +name: Security — archive SBOM on merge + +# Runs when a commit lands on the default branch (a PR merged, or a direct push). +# It regenerates the SBOM + raw scan for that exact state and stores them on a +# dedicated ORPHAN branch `security-archive` under -/, giving a +# permanent per-merge supply-chain trail WITHOUT bloating the code branch's +# history or slowing clones. +# +# Why `push` (not pull_request): the push event's GITHUB_TOKEN is always writable +# — so this also works for merged fork PRs — and it only fires for the default +# branch. We archive the SBOM + raw scan output only (no rendered "report"), so +# there is never a stale/false attestation committed anywhere. + +on: + push: + branches: [development] + +permissions: + contents: write + +concurrency: + group: security-archive + cancel-in-progress: false + +jobs: + archive: + runs-on: ubuntu-latest + steps: + - name: Checkout (post-merge state) + uses: actions/checkout@v4 + with: + fetch-depth: 2 # enough to read the merge/commit subject + submodules: recursive + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + - name: Enable pnpm + run: corepack enable + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + - name: Install osv-scanner + run: | + OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64 + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS + ( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - ) + install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner + + - name: Generate SBOM + scan (SBOM & raw scan only — no rendered report) + run: | + bash scripts/generate-sbom.sh + NAME=$(basename "$(ls sbom/*.cdx.json | head -1)" .cdx.json) + echo "NAME=$NAME" >> "$GITHUB_ENV" + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . + rc=$? + set -e + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed (exit $rc)"; exit 1; fi + [ -s /tmp/osv.json ] || echo '{"results":[]}' > /tmp/osv.json + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + + - name: Publish snapshot to the security-archive orphan branch + env: + GH_TOKEN: ${{ github.token }} + run: | + MSG=$(git log -1 --format=%s) + PR=$(printf '%s' "$MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#') + if [ -n "$PR" ]; then ID="pr-$PR"; else ID="commit-$(git rev-parse --short HEAD)"; fi + DIR="${ID}-$(date -u +%Y-%m-%d)" + URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if git clone --depth 1 --branch security-archive "$URL" /tmp/arch 2>/dev/null; then + : + else + git clone --depth 1 "$URL" /tmp/arch + git -C /tmp/arch checkout --orphan security-archive + git -C /tmp/arch rm -rf . >/dev/null 2>&1 || true + fi + mkdir -p "/tmp/arch/$DIR" + cp "sbom/$NAME.cdx.json" "sbom/$NAME.spdx.json" "sbom/$NAME.components.csv" sbom/vulnerabilities.csv "/tmp/arch/$DIR/" + git -C /tmp/arch -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" add "$DIR" + if git -C /tmp/arch diff --cached --quiet; then + echo "No SBOM changes to archive for $DIR." + else + git -C /tmp/arch -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(security): SBOM snapshot ${DIR}" + git -C /tmp/arch push "$URL" HEAD:security-archive + fi diff --git a/.github/workflows/security-pr-gate.yml b/.github/workflows/security-pr-gate.yml new file mode 100644 index 00000000..6949252a --- /dev/null +++ b/.github/workflows/security-pr-gate.yml @@ -0,0 +1,129 @@ +name: Security — PR gate + +# Runs on every pull request. Scans the BASE and the HEAD of the PR and blocks +# ONLY on security advisories the PR *introduces* (present in head, absent in +# base) at or above the severity threshold — pre-existing issues never block. +# Both scans honor osv-scanner.toml (the VEX baseline), so a justified new +# suppression in the PR clears the gate. +# +# The job never writes CODE to the repo (contents: read), so the check is present +# on every commit and is safe to require in branch protection. It DOES post a +# single sticky PR comment (pull-requests: write) with the actionable result, so +# the author sees what to fix without digging into the check log. The per-PR SBOM +# snapshot is archived on MERGE by security-pr-archive.yml. +# +# To ENFORCE the block, mark the "gate" job a required status check in branch +# protection for the default branch. + +on: + pull_request: + +permissions: + contents: read + pull-requests: write # post/update the result comment (never pushes code) + +concurrency: + group: security-pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + # Block when the PR introduces a NEW advisory at or above this severity. + GATE_THRESHOLD: HIGH + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + + - name: Unit tests (security scripts) + run: node --test scripts/__tests__/*.test.mjs + + - name: Install osv-scanner + run: | + OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64 + curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS + ( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - ) + install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner + + # Both scans use the HEAD osv-scanner.toml so a justified suppression added + # in the PR is honored on both sides. + - name: Scan HEAD + run: | + cp osv-scanner.toml /tmp/head-config.toml 2>/dev/null || true + CFG=""; [ -f /tmp/head-config.toml ] && CFG="--config=/tmp/head-config.toml" + set +e + osv-scanner scan $CFG --recursive --format=json --output=/tmp/head.json . + rc=$? + set -e + # osv-scanner: 0 = no vulns, 1 = vulns found. ANY other code is an + # operational failure — fail the gate CLOSED, never pass a broken scan. + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then + echo "::error::osv-scanner failed to scan HEAD (exit $rc)"; exit 1 + fi + [ -s /tmp/head.json ] || echo '{"results":[]}' > /tmp/head.json + + - name: Scan BASE + run: | + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}" + git worktree add -f /tmp/base "${{ github.event.pull_request.base.sha }}" + CFG=""; [ -f /tmp/head-config.toml ] && CFG="--config=/tmp/head-config.toml" + set +e + osv-scanner scan $CFG --recursive --format=json --output=/tmp/base.json /tmp/base + rc=$? + set -e + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then + echo "::error::osv-scanner failed to scan BASE (exit $rc)"; exit 1 + fi + [ -s /tmp/base.json ] || echo '{"results":[]}' > /tmp/base.json + + # THE GATE — non-zero exit here fails the check and (with branch protection) + # blocks the merge. It also writes /tmp/gate-comment.md and /tmp/gate-status. + - name: Evaluate — block on newly-introduced advisories + run: | + BASE="${{ github.event.pull_request.base.sha }}" + # Run the decision script AND read the threshold from BASE, not HEAD: a PR + # must not be able to weaken its own gate by editing pr-gate-diff.mjs or + # GATE_THRESHOLD in the same PR (pull_request runs the merge-ref code). + # Falls back to the HEAD copy only when base has no gate yet (first landing). + if git cat-file -e "$BASE:scripts/pr-gate-diff.mjs" 2>/dev/null; then + git show "$BASE:scripts/pr-gate-diff.mjs" > /tmp/pr-gate-diff.base.mjs + BT=$(git show "$BASE:.github/workflows/security-pr-gate.yml" 2>/dev/null \ + | sed -nE 's/^[[:space:]]*GATE_THRESHOLD:[[:space:]]*"?([A-Za-z]+)"?.*/\1/p' | head -1) + echo "Evaluating with the base copy of the gate (threshold=${BT:-$GATE_THRESHOLD})." + else + cp scripts/pr-gate-diff.mjs /tmp/pr-gate-diff.base.mjs + echo "::warning::base has no pr-gate-diff.mjs yet (first introduction) — using the HEAD copy." + fi + node /tmp/pr-gate-diff.base.mjs /tmp/base.json /tmp/head.json "${BT:-$GATE_THRESHOLD}" + + # Post/update ONE sticky comment on the PR with the actionable result. + # Runs even when the gate failed (always()); never flips the verdict + # (continue-on-error) — the pass/fail is decided by the step above. + - name: Comment result on the PR + if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + STATUS=$(cat /tmp/gate-status 2>/dev/null || echo clean) + CID=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq '.[] | select(.body | contains("")) | .id' | head -1) + if [ "$STATUS" = "clean" ] && [ -z "$CID" ]; then + echo "Clean and no existing comment — nothing to post."; exit 0 + fi + if [ -n "$CID" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$CID" -F body=@/tmp/gate-comment.md >/dev/null && echo "Updated comment $CID" + else + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/gate-comment.md && echo "Created comment" + fi diff --git a/.gitignore b/.gitignore index a43ed6e9..23e18064 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,6 @@ temp/ # Generated test output headers tests/**/*.hpp + +# transient SBOM build output (canonical copy lives in security//sbom/) +/sbom/ diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 00000000..cfd11f26 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,280 @@ +# osv-scanner suppression baseline for strucpp = our VEX "not affected" +# decisions from the SBOM & Vulnerability Report. STruCpp is distributed as a +# self-contained binary; only the chevrotain runtime subtree ships. Build/test +# tooling is component_not_present; lodash-es (via chevrotain) is not-in-path. +# The real shipped runtime subtree is left UNSUPPRESSED so a future advisory in +# it surfaces for triage. +# +# Regenerated from a live osv-scanner scan of the CycloneDX SBOM. +# Each entry expires (ignoreUntil) so suppressions are re-reviewed quarterly. +# Full rationale: security//STruCpp-Security-Report.md. + +[[IgnoredVulns]] +id = "GHSA-22p9-wv53-3rq4" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [linkify-it] — see security report" + +[[IgnoredVulns]] +id = "GHSA-23c5-xmqv-rm74" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [minimatch] — see security report" + +[[IgnoredVulns]] +id = "GHSA-23hp-3jrh-7fpw" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-25h7-pfq9-p65f" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [flatted] — see security report" + +[[IgnoredVulns]] +id = "GHSA-2g4f-4pwh-qvx6" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [ajv] — see security report" + +[[IgnoredVulns]] +id = "GHSA-35p6-xmwp-9g52" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-3jxr-9vmj-r5cp" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [brace-expansion] — see security report" + +[[IgnoredVulns]] +id = "GHSA-3ppc-4f35-3m26" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [minimatch] — see security report" + +[[IgnoredVulns]] +id = "GHSA-3v7f-55p6-f55p" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [picomatch] — see security report" + +[[IgnoredVulns]] +id = "GHSA-48c2-rrv3-qjmp" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [yaml] — see security report" + +[[IgnoredVulns]] +id = "GHSA-4c8g-83qw-93j6" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [fast-uri] — see security report" + +[[IgnoredVulns]] +id = "GHSA-4w7w-66w2-5vf9" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vite] — see security report" + +[[IgnoredVulns]] +id = "GHSA-52cp-r559-cp3m" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [js-yaml] — see security report" + +[[IgnoredVulns]] +id = "GHSA-5xrq-8626-4rwp" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vitest] — see security report" + +[[IgnoredVulns]] +id = "GHSA-67mh-4wv8-2f99" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [esbuild] — see security report" + +[[IgnoredVulns]] +id = "GHSA-6g55-p6wh-862q" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [postcss] — see security report" + +[[IgnoredVulns]] +id = "GHSA-6v5v-wf23-fmfq" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [markdown-it] — see security report" + +[[IgnoredVulns]] +id = "GHSA-7r86-cg39-jmmj" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [minimatch] — see security report" + +[[IgnoredVulns]] +id = "GHSA-8x88-c5mf-7j5w" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-c2c7-rcm5-vvqj" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [picomatch] — see security report" + +[[IgnoredVulns]] +id = "GHSA-f23m-r3pf-42rh" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: chevrotain uses lodash-es only for internal parser data structures; the vulnerable functions (_.template, _.unset/_.omit) are never reached by Structured Text input [lodash] — see security report" + +[[IgnoredVulns]] +id = "GHSA-f886-m6hf-6m8v" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [brace-expansion] — see security report" + +[[IgnoredVulns]] +id = "GHSA-fx2h-pf6j-xcff" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vite] — see security report" + +[[IgnoredVulns]] +id = "GHSA-g7r4-m6w7-qqqr" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [esbuild] — see security report" + +[[IgnoredVulns]] +id = "GHSA-g8m3-5g58-fq7m" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-gvwx-54wh-qm9j" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-h67p-54hq-rp68" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [js-yaml] — see security report" + +[[IgnoredVulns]] +id = "GHSA-hm92-r4w5-c3mj" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-hmw2-7cc7-3qxx" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [form-data] — see security report" + +[[IgnoredVulns]] +id = "GHSA-jxxr-4gwj-5jf2" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [brace-expansion] — see security report" + +[[IgnoredVulns]] +id = "GHSA-mh99-v99m-4gvg" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [brace-expansion] — see security report" + +[[IgnoredVulns]] +id = "GHSA-mw96-cpmx-2vgc" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [rollup] — see security report" + +[[IgnoredVulns]] +id = "GHSA-p88m-4jfj-68fv" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-p9ff-h696-f583" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vite] — see security report" + +[[IgnoredVulns]] +id = "GHSA-ph9p-34f9-6g65" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tmp] — see security report" + +[[IgnoredVulns]] +id = "GHSA-pr7r-676h-xcf6" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-q3j6-qgpj-74h6" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [fast-uri] — see security report" + +[[IgnoredVulns]] +id = "GHSA-q8mj-m7cp-5q26" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [qs] — see security report" + +[[IgnoredVulns]] +id = "GHSA-qx2v-qp2m-jg93" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [postcss] — see security report" + +[[IgnoredVulns]] +id = "GHSA-r28c-9q8g-f849" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [postcss] — see security report" + +[[IgnoredVulns]] +id = "GHSA-r292-9mhp-454m" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-r5fr-rjxr-66jc" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: chevrotain uses lodash-es only for internal parser data structures; the vulnerable functions (_.template, _.unset/_.omit) are never reached by Structured Text input [lodash] — see security report" + +[[IgnoredVulns]] +id = "GHSA-rf6f-7fwh-wjgh" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [flatted] — see security report" + +[[IgnoredVulns]] +id = "GHSA-v245-v573-v5vm" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [linkify-it] — see security report" + +[[IgnoredVulns]] +id = "GHSA-v2hh-gcrm-f6hx" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [fast-uri] — see security report" + +[[IgnoredVulns]] +id = "GHSA-v2wj-q39q-566r" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vite] — see security report" + +[[IgnoredVulns]] +id = "GHSA-v39h-62p7-jpjc" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [fast-uri] — see security report" + +[[IgnoredVulns]] +id = "GHSA-v6wh-96g9-6wx3" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [vite] — see security report" + +[[IgnoredVulns]] +id = "GHSA-vmf3-w455-68vh" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-vmh5-mc38-953g" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-vxpw-j846-p89q" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [undici] — see security report" + +[[IgnoredVulns]] +id = "GHSA-w5hq-g745-h8pq" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [uuid] — see security report" + +[[IgnoredVulns]] +id = "GHSA-w8wr-v893-vjvp" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: development / build / test tooling (or companion VS Code extension dependency), not part of the distributed compiler binary — only the chevrotain runtime subtree ships [tar] — see security report" + +[[IgnoredVulns]] +id = "GHSA-xxjr-mmjv-4gpg" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: chevrotain uses lodash-es only for internal parser data structures; the vulnerable functions (_.template, _.unset/_.omit) are never reached by Structured Text input [lodash-es] — see security report" diff --git a/scripts/__tests__/security-scripts.test.mjs b/scripts/__tests__/security-scripts.test.mjs new file mode 100644 index 00000000..4341dd4c --- /dev/null +++ b/scripts/__tests__/security-scripts.test.mjs @@ -0,0 +1,170 @@ +// Unit tests for the merge-deciding / compliance scripts. Zero-dependency +// (node:test), run with: node --test scripts/__tests__/ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPTS = join(dirname(fileURLToPath(import.meta.url)), '..'); +const dir = mkdtempSync(join(tmpdir(), 'sec-test-')); +const w = (name, obj) => { const p = join(dir, name); writeFileSync(p, JSON.stringify(obj)); return p; }; +const adv = (id, sev, extra = {}) => ({ id, ...(sev ? { database_specific: { severity: sev } } : {}), ...extra }); +const scan = (...pkgs) => ({ results: [{ packages: pkgs.map(([name, vulns]) => ({ package: { name, version: '1' }, vulnerabilities: vulns })) }] }); + +function gate(base, head, threshold = 'HIGH') { + const env = { ...process.env, GATE_COMMENT_FILE: join(dir, 'c.md'), GATE_STATUS_FILE: join(dir, 's') }; + return spawnSync('node', [join(SCRIPTS, 'pr-gate-diff.mjs'), base, head, threshold], { env, encoding: 'utf8' }); +} + +test('gate: MEDIUM does not block (normalized to MODERATE)', () => { + const r = gate(w('b.json', { results: [] }), w('h.json', scan(['m', [adv('CVE-MED', 'MEDIUM')]]))); + assert.equal(r.status, 0, r.stderr); +}); +test('gate: HIGH blocks', () => { + const r = gate(w('b.json', { results: [] }), w('h.json', scan(['h', [adv('CVE-HI', 'HIGH')]]))); + assert.equal(r.status, 1); +}); +test('gate: same CVE on a NEW package is introduced (blocks); on the same package it is not', () => { + const base = w('b.json', scan(['shared', [adv('CVE-X', 'HIGH')]])); + const headNew = w('hn.json', scan(['shared', [adv('CVE-X', 'HIGH')]], ['newpkg', [adv('CVE-X', 'HIGH')]])); + assert.equal(gate(base, headNew).status, 1, 'new package must block'); + const headSame = w('hs.json', scan(['shared', [adv('CVE-X', 'HIGH')]])); + assert.equal(gate(base, headSame).status, 0, 'pre-existing must not block'); +}); +test('gate: fails CLOSED (exit 2) on missing or shapeless scan input', () => { + assert.equal(gate(join(dir, 'nope.json'), w('h.json', { results: [] })).status, 2, 'missing base'); + assert.equal(gate(w('bad.json', { garbage: true }), w('h.json', { results: [] })).status, 2, 'no results array'); +}); +test('gate: CVSS 3.1 vector (9.8) scored as blocking', () => { + const head = w('h.json', scan(['c', [adv('CVE-CVSS', null, { severity: [{ type: 'CVSS_V3', score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H' }] })]])); + assert.equal(gate(w('b.json', { results: [] }), head).status, 1); +}); +test('gate: PYSEC+GHSA alias group counts once at its real (max) severity, not a double HIGH', () => { + // osv-scanner emits the PYSEC and GHSA records for one issue separately; the + // PYSEC one carries no severity (would fall back to approx HIGH and block). + const head = w('h.json', { results: [{ packages: [{ + package: { name: 'filelock', version: '3.19.1' }, + vulnerabilities: [ + { id: 'PYSEC-2026-1374' }, + { id: 'GHSA-qmgc-5h2g-mvrw', database_specific: { severity: 'MODERATE' } }, + ], + groups: [{ ids: ['PYSEC-2026-1374', 'GHSA-qmgc-5h2g-mvrw'], max_severity: '5.3' }], + }] }] }); + const r = gate(w('b.json', { results: [] }), head); // threshold HIGH + assert.equal(r.status, 0, 'a MODERATE group must not block at HIGH, and PYSEC must not double-count as HIGH'); + assert.match(r.stdout, /introduced by this PR \(1\)/, 'the aliased pair counts once'); + assert.match(r.stdout, /\[MODERATE\] filelock/); + assert.doesNotMatch(r.stdout, /PYSEC-2026-1374/, 'group represented by its GHSA id, not a duplicate PYSEC row'); +}); +test('gate: PYSEC-only group with a real max_severity of HIGH still blocks', () => { + const head = w('h.json', { results: [{ packages: [{ + package: { name: 'x', version: '1' }, + vulnerabilities: [{ id: 'PYSEC-2026-9999' }], + groups: [{ ids: ['PYSEC-2026-9999'], max_severity: '8.1' }], + }] }] }); + assert.equal(gate(w('b.json', { results: [] }), head).status, 1, 'a genuine HIGH must still block'); +}); + +function spdx(cdx) { + const out = join(dir, 'o.spdx.json'); + const r = spawnSync('node', [join(SCRIPTS, 'cdx-to-spdx.mjs'), w('in.cdx.json', cdx), out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return JSON.parse(readFileSync(out, 'utf8')); +} +test('cdx-to-spdx: no duplicate SPDXID when a component appears twice', () => { + const doc = spdx({ + metadata: { component: { 'bom-ref': 'root@1', name: 'root', components: [{ 'bom-ref': 'dup@1', name: 'dup', version: '1' }] } }, + components: [{ 'bom-ref': 'dup@1', name: 'dup', version: '1' }, { 'bom-ref': 'solo@2', name: 'solo', version: '2' }], + }); + const ids = doc.packages.map((p) => p.SPDXID); + assert.equal(new Set(ids).size, ids.length, 'SPDXIDs must be unique'); +}); +test('cdx-to-spdx: multiple licenses join with OR (dual-licensed), not AND', () => { + const doc = spdx({ components: [{ 'bom-ref': 'x@1', name: 'x', version: '1', licenses: [{ license: { id: 'MIT' } }, { license: { id: 'GPL-3.0-only' } }] }] }); + const x = doc.packages.find((p) => p.name === 'x'); + assert.equal(x.licenseDeclared, 'MIT OR GPL-3.0-only'); +}); + +// ---------- build-report: B1 (VEX numbers derive + reconcile) ---------- +const MIN_CFG = { + product: 'p', title: 'P', subtitle: 's', version: '1', sbomBasename: 'p', securityContact: 'x@y', + advisories: { total: 0, critical: 0, high: 0, moderate: 0, low: 0 }, + counts: { affected: { n: 0, sev: '' }, mitigated: { n: 0, sev: '' }, notAffected: { n: 0, pct: '0%' } }, + headline: 'h', scope: 's', scopeNote: 's', methodologyNote: 'm', licenseNote: 'l', + affected: [], mitigated: [], + notAffected: [{ justification: 'component_not_present / x', count: '?', components: 'c', basis: 'b' }], + remediation: ['r'], practices: [{ practice: 'p', status: 'In place' }], +}; +function report({ baselineToml, raw, delta, cfg = MIN_CFG }) { + const cdxP = w('r.cdx.json', { components: [] }); + const cfgP = w('cfg.json', cfg); + const a = ['--config', cfgP, '--cdx', cdxP, '--out', dir, '--date', '2026-08']; + if (raw) a.push('--osv-raw', w('raw.json', raw)); + if (delta) a.push('--osv-delta', w('delta.json', delta)); + if (baselineToml != null) { const p = join(dir, 'osv.toml'); writeFileSync(p, baselineToml); a.push('--baseline', p); } + const r = spawnSync('node', [join(SCRIPTS, 'build-report.mjs'), ...a], { encoding: 'utf8' }); + return { ...r, md: r.status === 0 ? readFileSync(join(dir, 'P-Security-Report.md'), 'utf8') : '' }; +} +const entry = (id, reason) => `\n[[IgnoredVulns]]\nid = "${id}"\nignoreUntil = "2026-10-01T00:00:00Z"\nreason = "${reason}"\n`; + +test('build-report: reconciles raw = surfacing + suppressed, derives §5 count', () => { + const raw = scan(['x', [adv('GHSA-A', 'CRITICAL')]], ['y', [adv('GHSA-B', 'HIGH')]], ['z', [adv('GHSA-C', 'HIGH')]]); + const delta = scan(['z', [adv('GHSA-C', 'HIGH')]]); // A,B suppressed; C surfaces + const baseline = entry('GHSA-A', 'VEX not_affected/component_not_present [x / critical]: dev-only') + + entry('GHSA-B', 'VEX affected/mitigated [y / high]: control'); + const r = report({ baselineToml: baseline, raw, delta }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.md, /Not affected — suppressed by the VEX baseline \| 1/); + assert.match(r.md, /Affected, mitigated[^|]*\| 1/); + assert.match(r.md, /requires triage \| 1/); + assert.match(r.md, /component_not_present \/ x` \| 1 /); // §5 count derived, not "?" +}); +test('build-report: fails CLOSED when a suppressed advisory has no VEX entry', () => { + const raw = scan(['x', [adv('GHSA-A', 'HIGH')]], ['z', [adv('GHSA-C', 'HIGH')]]); + const delta = scan(['z', [adv('GHSA-C', 'HIGH')]]); // A suppressed but not in baseline + const r = report({ baselineToml: entry('GHSA-OTHER', 'VEX not_affected/component_not_present [q / low]: x'), raw, delta }); + assert.equal(r.status, 1, 'must refuse to render when numbers do not reconcile'); +}); +test('build-report: matches a GHSA advisory to its CVE-keyed baseline entry (alias)', () => { + const raw = scan(['x', [adv('GHSA-A', 'HIGH', { aliases: ['CVE-2026-1'] })]], ['z', [adv('GHSA-C', 'HIGH')]]); + const delta = scan(['z', [adv('GHSA-C', 'HIGH')]]); + const baseline = entry('CVE-2026-1', 'VEX not_affected/component_not_present [x / high]: dev-only'); + assert.equal(report({ baselineToml: baseline, raw, delta }).status, 0); +}); +test('build-report: surfacing>0 with affected:[] never says "None" — DRAFT banner + surfacing table', () => { + const one = scan(['p', [adv('GHSA-S', 'HIGH', { summary: 'boom' })]]); + const r = report({ raw: one, delta: one }); // 1 surfacing, nothing suppressed, cfg.affected == [] + assert.equal(r.status, 0, r.stderr); + assert.match(r.md, /DRAFT — 1 advisory surfacing/); + assert.match(r.md, /GHSA-S/, 'the surfacing advisory must be listed in §4'); + assert.doesNotMatch(r.md, /\*\*None\.\*\*/, 'must not claim None while an advisory surfaces'); +}); +test('build-report: surfacing==0 renders §4 None and no DRAFT banner', () => { + const raw = scan(['p', [adv('GHSA-S', 'HIGH')]]); + const r = report({ raw, delta: { results: [] } }); // all suppressed, 0 surfacing + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.md, /DRAFT/); + assert.match(r.md, /\*\*None\.\*\*/); +}); + +// ---------- gen-osv-ignores: B2 (distinct mitigated status, CISA enum required) ---------- +function genOsv(csv) { + const p = join(dir, 'v.csv'); writeFileSync(p, csv); + return spawnSync('node', [join(SCRIPTS, 'gen-osv-ignores.mjs'), p, '2026-10-01'], { encoding: 'utf8' }); +} +test('gen-osv-ignores: mitigated → affected/mitigated (distinct from not_affected)', () => { + const out = genOsv('advisory,package,severity,verdict,basis\nCVE-1,dompurify,high,mitigated,allow-list').stdout; + assert.match(out, /VEX affected\/mitigated \[dompurify \/ high\]/); + assert.doesNotMatch(out, /not_affected/); +}); +test('gen-osv-ignores: not_affected without a CISA enum stays visible (not suppressed)', () => { + const r = genOsv('advisory,package,verdict,basis\nCVE-2,foo,not_affected,we think it is fine\nCVE-3,bar,not_affected,vulnerable_code_not_in_execute_path'); + assert.doesNotMatch(r.stdout, /CVE-2/); + assert.match(r.stdout, /CVE-3.*[\s\S]*vulnerable_code_not_in_execute_path/); +}); +test('gen-osv-ignores: affected/requires-action row is never suppressed', () => { + assert.doesNotMatch(genOsv('advisory,package,verdict,basis\nCVE-9,node-forge,affected,upgrade').stdout, /CVE-9/); +}); diff --git a/scripts/build-report.mjs b/scripts/build-report.mjs new file mode 100644 index 00000000..49c6d05c --- /dev/null +++ b/scripts/build-report.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node +// build-report.mjs — render the Security Report (Markdown + HTML) deterministically +// from structured data, so the monthly output is IDENTICAL in shape every run and +// never drifts from the SBOM. +// +// node scripts/build-report.mjs \ +// --config security/report-config.json \ +// --cdx sbom/.cdx.json \ +// --out security/ \ +// --date 2026-08 +// +// Component count and license distribution are computed live from the CycloneDX +// SBOM; everything else (VEX triage, narrative) comes from the config, which is +// what Claude updates when a new advisory appears. + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; + +const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, arr) => { + if (v.startsWith('--')) a.push([v.slice(2), arr[i + 1]]); + return a; +}, [])); +const cfg = JSON.parse(readFileSync(args.config, 'utf8')); +const cdx = JSON.parse(readFileSync(args.cdx, 'utf8')); +const date = args.date || 'unknown'; +const outDir = args.out || '.'; +mkdirSync(outDir, { recursive: true }); + +// --- live metrics from the SBOM --- +const comps = cdx.components || []; +const componentCount = comps.length; +const licAgg = {}; +for (const c of comps) { + for (const l of (c.licenses || [])) { + const id = l.license?.id || l.expression || l.license?.name || 'Unlicensed'; + licAgg[id] = (licAgg[id] || 0) + 1; + } +} +const topLicenses = Object.entries(licAgg).sort((a, b) => b[1] - a[1]).slice(0, 8); + +// --- live advisory metrics from the actual scan(s), when provided ----------- +// Keeps the headline honest: the numbers reflect THIS run's scan, not a static +// value in the config. --osv-raw = unfiltered scan; --osv-delta = scan WITH the +// osv-scanner.toml VEX baseline applied. Falls back to the config numbers if no +// scan is passed (e.g. an ad-hoc local render). +const loadScan = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }; +function idSet(scan) { + const bySev = { CRITICAL: 0, HIGH: 0, MODERATE: 0, LOW: 0 }; + const ids = new Set(); + const aliases = new Map(); // id -> [aliases]; OSV reports GHSA ids, the baseline keys on CVEs + for (const r of (scan?.results || [])) for (const p of (r.packages || [])) for (const v of (p.vulnerabilities || [])) { + if (!v.id || ids.has(v.id)) continue; ids.add(v.id); + aliases.set(v.id, v.aliases || []); + let s = (v.database_specific?.severity || '').toUpperCase(); + if (s === 'MEDIUM') s = 'MODERATE'; + if (!(s in bySev)) s = 'MODERATE'; + bySev[s]++; + } + return { ids, total: ids.size, bySev, aliases }; +} +const sevStr = (b) => `${b.CRITICAL} critical · ${b.HIGH} high · ${b.MODERATE} moderate · ${b.LOW} low`; + +// Parse the VEX baseline (osv-scanner.toml) into id -> { status, justification }. +// Reasons are written in a machine-readable form by gen-osv-ignores.mjs / reclassify: +// VEX not_affected/ [pkg / sev]: ... +// VEX affected/mitigated [pkg / sev]: ... +function parseBaseline(p) { + const map = new Map(); + const src = readFileSync(p, 'utf8'); + for (const block of src.split(/\n(?=\[\[IgnoredVulns\]\])/)) { + if (!block.includes('[[IgnoredVulns]]')) continue; + const id = (block.match(/id\s*=\s*"([^"]+)"/) || [])[1]; + const reason = (block.match(/reason\s*=\s*"([^"]*)"/) || [])[1] || ''; + const m = reason.match(/VEX\s+(not_affected|affected)\/(\S+)/); + if (id && m) map.set(id, { status: m[1], justification: m[2] }); + } + return map; +} + +const rawScan = args['osv-raw'] ? loadScan(args['osv-raw']) : null; +const deltaScan = args['osv-delta'] ? loadScan(args['osv-delta']) : null; +const baseline = args.baseline ? parseBaseline(args.baseline) : null; + +// derived = the single source of truth for the report's numbers when a live scan +// is available. Everything reconciles by construction; a mismatch fails the build. +let derived = null; +let advisoryRows; +if (rawScan && deltaScan) { + const raw = idSet(rawScan), delta = idSet(deltaScan); + const suppressedIds = [...raw.ids].filter((id) => !delta.ids.has(id)); + const suppressed = suppressedIds.length; + + if (baseline) { + // Split the suppressed set into CISA buckets straight from the baseline. + const byJust = {}; + let mitigated = 0; + const unexplained = []; + for (const id of suppressedIds) { + const candidates = [id, ...(raw.aliases.get(id) || [])]; // match GHSA id or its CVE alias + const c = candidates.map((k) => baseline.get(k)).find(Boolean); + if (!c) { unexplained.push(id); continue; } + if (c.status === 'affected' && c.justification === 'mitigated') mitigated++; + else byJust[c.justification] = (byJust[c.justification] || 0) + 1; + } + const notAffected = Object.values(byJust).reduce((a, b) => a + b, 0); + + // Fail-closed reconciliation. A published compliance artifact must add up. + const errs = []; + if (unexplained.length) errs.push(`${unexplained.length} suppressed advisories have no VEX entry in the baseline (e.g. ${unexplained.slice(0, 3).join(', ')})`); + if (notAffected + mitigated !== suppressed) errs.push(`buckets (${notAffected} not-affected + ${mitigated} mitigated) != ${suppressed} suppressed`); + if (delta.total + suppressed !== raw.total) errs.push(`surfacing (${delta.total}) + suppressed (${suppressed}) != raw (${raw.total})`); + if (errs.length) { + console.error('build-report: VEX numbers do not reconcile — refusing to render:\n - ' + errs.join('\n - ')); + process.exit(1); + } + derived = { raw, delta, suppressed, notAffected, mitigated, byJust }; + } + + const pct = raw.total ? Math.round(100 * suppressed / raw.total) : 0; + advisoryRows = [ + { label: `Raw advisories detected (this scan · ${date})`, value: `${raw.total} (${sevStr(raw.bySev)})` }, + ...(derived ? [ + { label: 'Not affected — suppressed by the VEX baseline', value: `${derived.notAffected} (${raw.total ? Math.round(100 * derived.notAffected / raw.total) : 0}%)`, badge: 'b-green' }, + { label: 'Affected, mitigated — suppressed with a compensating control', value: `${derived.mitigated}`, badge: 'b-amber' }, + ] : [ + { label: 'Not applicable — suppressed by the VEX baseline', value: `${suppressed} (${pct}%)`, badge: 'b-green' }, + ]), + { label: 'Surfacing after suppression — requires triage', value: `${delta.total} (${sevStr(delta.bySev)})`, badge: delta.total ? 'b-red' : 'b-green' }, + ]; +} else { + // Same four-bucket shape as the derived path (raw = surfacing + mitigated + + // not-affected), so a local render never contradicts a CI render. + advisoryRows = [ + { label: 'Raw advisories detected', value: `${cfg.advisories.total} (${cfg.advisories.critical} critical · ${cfg.advisories.high} high · ${cfg.advisories.moderate} moderate · ${cfg.advisories.low} low)` }, + { label: 'Not affected — suppressed by the VEX baseline', value: `${cfg.counts.notAffected.n} (${cfg.counts.notAffected.pct})`, badge: 'b-green' }, + { label: 'Affected, mitigated — suppressed with a compensating control', value: `${cfg.counts.mitigated.n} (${cfg.counts.mitigated.sev})`, badge: 'b-amber' }, + { label: 'Surfacing after suppression — requires triage', value: `${cfg.counts.affected.n} (${cfg.counts.affected.sev})`, badge: 'b-red' }, + ]; +} + +// §5/§6/§9 counts come from `derived` when a scan+baseline are present, so the body +// tables can never contradict the headline. cfg values remain the fallback. +const justCount = (justification) => { + if (!derived) return null; + // config justification strings may prefix/annotate the enum — match by containment + for (const [enumKey, n] of Object.entries(derived.byJust)) if (justification.includes(enumKey)) return n; + return 0; +}; +// The advisory register (vulnerabilities.csv) is produced from the DELTA scan, so +// its row count must come from the delta scan whenever a live scan is available — +// never from the static config (which drifts). Independent of the VEX baseline. +const registerRows = deltaScan ? idSet(deltaScan).total : cfg.advisories.total; + +// The narrative sections (§4 affected, headline, criticalNote, mitigated) come +// from the config, which is NOT auto-updated once AI triage is removed. If the +// live scan surfaces advisories the config doesn't reflect, the document must not +// claim "none require remediation": it renders a DRAFT banner and lists the +// surfacing advisories from the scan, so live metrics and static narrative can +// never disagree in a published attestation. +function surfacingRows(scan) { + const seen = new Set(); const out = []; + for (const r of (scan?.results || [])) for (const p of (r.packages || [])) for (const v of (p.vulnerabilities || [])) { + const pkg = p.package?.name || '?'; + const key = `${v.id}|${pkg}`; + if (!v.id || seen.has(key)) continue; seen.add(key); + let sev = (v.database_specific?.severity || '').toUpperCase(); + if (sev === 'MEDIUM') sev = 'MODERATE'; + out.push({ id: v.id, pkg, sev: sev || 'UNKNOWN', summary: String(v.summary || '').replace(/\s+/g, ' ').slice(0, 140) }); + } + return out.sort((a, b) => a.pkg.localeCompare(b.pkg) || a.id.localeCompare(b.id)); +} +const surfacing = deltaScan ? surfacingRows(deltaScan) : []; +const isDraft = surfacing.length > 0; +const draftLine = `DRAFT — ${surfacing.length} advisor${surfacing.length === 1 ? 'y' : 'ies'} surfacing above the VEX baseline await triage. Sections 4–7 reflect the last triaged state and may be stale until a reviewer updates report-config.json.`; + +const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); +// report-config.json is written by the AI triage step, so its strings are +// UNTRUSTED. Escape everything, then re-enable only a small set of attribute-less +// formatting tags. This blocks