diff --git a/.github/workflows/security-monthly.yml b/.github/workflows/security-monthly.yml new file mode 100644 index 00000000..f128fede --- /dev/null +++ b/.github/workflows/security-monthly.yml @@ -0,0 +1,132 @@ +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. +# +# Python variant: the SBOM is built from a clean venv (cyclonedx-py) and OSV +# scans the resulting CycloneDX SBOM. There is no AI triage on this public repo — +# advisories that surface above the VEX baseline are shown in the PR for a human. + +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 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Month stamp + id: m + run: echo "month=$(date -u +%Y-%m)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: { node-version: "22" } + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: { python-version: "3.12" } + + # ---- deterministic: SBOM from a clean venv (CycloneDX + SPDX + CSV) ---- + # SBOM_PIP_ARGS forces wheels for native deps where needed (set per repo). + - name: Generate SBOM + run: bash scripts/generate-sbom.sh + + # ---- deterministic: vulnerability scan (OSV over the SBOM, 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: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.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 + # unfiltered scan too, so the report headline numbers are derived live + set +e + osv-scanner scan --format=json --output=/tmp/osv-raw.json "sbom/$NAME.cdx.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('./security/report-config.json').sbomBasename") + 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 \ + --baseline osv-scanner.toml \ + --out "security/${{ steps.m.outputs.month }}" \ + --date "${{ steps.m.outputs.month }}" + + - name: Render PDF + uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1 + id: chrome + - name: Assemble dated folder + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + 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/" + # Deterministic path (build-report.mjs derives the same basename from the + # report title) instead of ls|grep, which without pipefail yields "$DIR/" + # on no match and fails Chrome with an opaque error. + REPORT="$DIR/$(node -p "require('./security/report-config.json').title.replace(/[^A-Za-z0-9]+/g,'-')")-Security-Report.html" + [ -f "$REPORT" ] || { echo "::error::report HTML not found at $REPORT"; exit 1; } + # --no-sandbox / --disable-dev-shm-usage: Chrome's zygote sandbox aborts + # (SIGABRT) on GitHub runners; required for headless Chrome in CI. + # --blink-settings=scriptEnabled=false: the report HTML is a static document + # written from report-config.json — no JS should run while rendering it with + # local file:// access (the old --disable-javascript switch is a silent no-op + # in modern Chromium). Defense-in-depth on top of the HTML escaping. + "${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \ + --blink-settings=scriptEnabled=false --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@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6 + with: + # A PR opened with GITHUB_TOKEN does not trigger other workflows; set a + # SECURITY_BOT_TOKEN (GitHub App / fine-grained PAT) to make checks run. + token: ${{ secrets.SECURITY_BOT_TOKEN || github.token }} + base: main + 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 a clean virtual environment. + - 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..2ee8ae18 --- /dev/null +++ b/.github/workflows/security-pr-archive.yml @@ -0,0 +1,87 @@ +name: Security — archive SBOM on merge + +# Python variant. Runs when a commit lands on the default branch (a PR merged, or +# a direct push). Regenerates the SBOM + raw scan for that state and stores them +# on a dedicated ORPHAN branch `security-archive` under -/ — a permanent +# per-merge trail WITHOUT bloating the code branch or slowing clones. +# +# `push` (not pull_request): the token is always writable (works for merged fork +# PRs) and it only fires for the default branch. SBOM + raw scan only (no rendered +# report) — nothing that could become a stale/false attestation. + +on: + push: + branches: [main] + +permissions: + contents: write + +concurrency: + group: security-archive + cancel-in-progress: false + +env: + # Repos with native deps set this (e.g. orchestrator-agent: "--only-binary av"). + SBOM_PIP_ARGS: "" + +jobs: + archive: + runs-on: ubuntu-latest + steps: + - name: Checkout (post-merge state) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: { node-version: "22" } + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + echo "NAME=$NAME" >> "$GITHUB_ENV" + bash scripts/generate-sbom.sh + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.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..6f72e850 --- /dev/null +++ b/.github/workflows/security-pr-gate.yml @@ -0,0 +1,133 @@ +name: Security — PR gate + +# Python variant. Runs on every pull request and blocks ONLY on advisories the +# PR *introduces* (present in head, absent in base) at/above the threshold — +# pre-existing issues never block. Because requirements.txt is unpinned, the diff +# is computed from RESOLVED SBOMs (cyclonedx-py in a clean venv). If the PR does +# not touch a dependency manifest, no new dependency is possible, so we skip the +# (expensive) base resolve and pass. +# +# The job never writes CODE (contents: read) — safe to require in branch +# protection. It posts one sticky PR comment (pull-requests: write) with the +# actionable result. The per-PR SBOM snapshot is archived on MERGE by +# security-pr-archive.yml. + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: security-pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + GATE_THRESHOLD: HIGH + # Repos with native deps set this (e.g. orchestrator-agent: "--only-binary av"). + SBOM_PIP_ARGS: "" + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: { node-version: "22" } + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: { python-version: "3.12" } + + - 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 + + - name: Did this PR change any dependency manifest? + id: deps + run: | + BASE="${{ github.event.pull_request.base.sha }}" + git fetch --no-tags --depth=1 origin "$BASE" 2>/dev/null || true + # Includes the Python plugin manifests: their deps ship in the deployed + # product, so a PR adding a vulnerable plugin dependency must trigger a + # base resolve rather than passing without a diff. + CHANGED=$(git diff --name-only "$BASE" HEAD -- requirements.txt requirements-dev.txt pyproject.toml poetry.lock Pipfile Pipfile.lock 'core/src/drivers/plugins/python/*/requirements.txt' 2>/dev/null || true) + if [ -n "$CHANGED" ]; then echo "changed=true" >> "$GITHUB_OUTPUT"; else echo "changed=false" >> "$GITHUB_OUTPUT"; fi + echo "manifests changed: ${CHANGED:-}" + + - name: Resolve & scan HEAD SBOM + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + bash scripts/generate-sbom.sh + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --format=json --output=/tmp/head.json "sbom/$NAME.cdx.json" + rc=$? + set -e + # osv-scanner: 0 = no vulns, 1 = vulns found. Any other code = failure → + # fail the gate CLOSED, never pass a broken scan. + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed on HEAD (exit $rc)"; exit 1; fi + [ -s /tmp/head.json ] || echo '{"results":[]}' > /tmp/head.json + + - name: Resolve & scan BASE SBOM (only if manifests changed) + run: | + if [ "${{ steps.deps.outputs.changed }}" != "true" ]; then + echo "No dependency manifest changed — base == head, nothing new can be introduced." + cp /tmp/head.json /tmp/base.json + exit 0 + fi + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + BASE="${{ github.event.pull_request.base.sha }}" + # Restore the BASE version of EVERY changed manifest (root + Python plugins) + # so the base SBOM reflects base deps; generate-sbom.sh resolves all of them. + MANIFESTS=$(git diff --name-only "$BASE" HEAD -- requirements.txt requirements-dev.txt 'core/src/drivers/plugins/python/*/requirements.txt' 2>/dev/null || true) + BAK=/tmp/manifest-bak; rm -rf "$BAK"; mkdir -p "$BAK" + for f in $MANIFESTS; do + mkdir -p "$BAK/$(dirname "$f")" + cp "$f" "$BAK/$f" 2>/dev/null || true + git show "$BASE:$f" > "$f" 2>/dev/null || : > "$f" + done + bash scripts/generate-sbom.sh + cp "sbom/$NAME.cdx.json" /tmp/base.cdx.json + for f in $MANIFESTS; do cp "$BAK/$f" "$f" 2>/dev/null || git checkout -- "$f" 2>/dev/null || true; done + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + set +e + osv-scanner scan $CFG --format=json --output=/tmp/base.json /tmp/base.cdx.json + rc=$? + set -e + if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed on BASE (exit $rc)"; exit 1; fi + [ -s /tmp/base.json ] || echo '{"results":[]}' > /tmp/base.json + bash scripts/generate-sbom.sh # restore HEAD sbom/ (overwritten by the base resolve) + + - name: Evaluate — block on newly-introduced advisories + run: node scripts/pr-gate-diff.mjs /tmp/base.json /tmp/head.json "${GATE_THRESHOLD}" + + - 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 aafd6a9c..a4c8bf73 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ core/src/drivers/plugins/native/ethercat/libs/soem/cmake/CYGWIN.cmake *.o *.so .DS_Store + +# 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..28c1ced3 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,17 @@ +# osv-scanner suppression baseline for openplc-runtime (VEX "not affected"/mitigated +# decisions from the SBOM & Vulnerability Report). +# +# Intentionally EMPTY. requirements.txt is unpinned and CI resolves it into a clean +# venv, so the scanned dependency set is the currently-resolved one — which has no +# known advisory. The previous entries were suppressing IDs that no longer resolve +# (all were reported "unused ignores"); a stale suppression can only fail OPEN (a +# revived vulnerable version would stay hidden until its ignoreUntil), so they are +# removed rather than kept. The live stack is left UNSUPPRESSED, so any advisory +# that surfaces in a future scan is shown for human review (no AI triage on this +# public repo). SOEM (C, EtherCAT) is a submodule tracked apart. +# +# When a real advisory surfaces and is triaged "not affected"/"mitigated", add an +# [[IgnoredVulns]] entry carrying a CISA VEX status + justification, e.g.: +# node scripts/gen-osv-ignores.mjs >> osv-scanner.toml +# VEX not_affected/ [pkg]: +# VEX affected/mitigated [pkg]: 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