From a51a3f326eb4a8185b36d145086bb9419c98a6fe Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Date: Mon, 27 Jul 2026 11:28:38 -0300 Subject: [PATCH 1/8] chore(security): automated monthly SBOM & VEX report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the validated supply-chain automation to strucpp (public repo — no AI triage; the Claude step is skipped gracefully when no token is present, and the deterministic SBOM + report + PR still run). Regenerates the SBOM (CycloneDX+ SPDX), scans OSV honoring the VEX baseline, renders the report, opens a monthly PR into security//. 0 advisories require action; baseline suppresses the 54 known not-affected IDs (build/test tooling + lodash-es via chevrotain). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AejAiL4tfXCyMhwtjY4vFT --- .github/workflows/security-monthly.yml | 134 ++++++++++++ .gitignore | 3 + osv-scanner.toml | 280 +++++++++++++++++++++++++ scripts/build-report.mjs | 190 +++++++++++++++++ scripts/cdx-to-spdx.mjs | 128 +++++++++++ scripts/gen-osv-ignores.mjs | 59 ++++++ scripts/generate-sbom.sh | 63 ++++++ scripts/scan-vulns.mjs | 27 +++ security/README.md | 52 +++++ security/report-config.json | 41 ++++ 10 files changed, 977 insertions(+) create mode 100644 .github/workflows/security-monthly.yml create mode 100644 osv-scanner.toml create mode 100644 scripts/build-report.mjs create mode 100644 scripts/cdx-to-spdx.mjs create mode 100644 scripts/gen-osv-ignores.mjs create mode 100755 scripts/generate-sbom.sh create mode 100644 scripts/scan-vulns.mjs create mode 100644 security/README.md create mode 100644 security/report-config.json diff --git a/.github/workflows/security-monthly.yml b/.github/workflows/security-monthly.yml new file mode 100644 index 00000000..905e4548 --- /dev/null +++ b/.github/workflows/security-monthly.yml @@ -0,0 +1,134 @@ +name: Security — monthly SBOM & VEX report + +# Runs on GitHub's servers (not on anyone's laptop). Every month it regenerates +# the SBOM, scans dependencies, has Claude triage any NEW advisory and write the +# report from the versioned template, drops everything into security//, +# and opens a PR for the team to review. Nothing is merged automatically. +# +# Auth: Claude runs on your Claude subscription via CLAUDE_CODE_OAUTH_TOKEN +# (from `claude setup-token`) — no pay-per-token API key. See security/README.md. + +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 + id-token: write # required by claude-code-action (OIDC) + +concurrency: + group: security-monthly + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + # Mapped here because the `secrets` context is NOT allowed in a step-level + # `if:` — the Claude step gates on `env.CLAUDE_CODE_OAUTH_TOKEN` instead. + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + 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: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + - name: Scan + run: | + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . || true + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + + # ---- judgment: Claude triages the delta + updates the report data ---- + # Runs on YOUR subscription (no API key). Skipped gracefully if the token + # isn't configured yet — the deterministic report still builds below. + - name: Claude — triage new advisories & update report data + if: ${{ env.CLAUDE_CODE_OAUTH_TOKEN != '' }} + continue-on-error: true + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: > + You are updating this repository's monthly security report DATA only. + Inputs: the fresh scan at /tmp/osv.json and the SBOM under sbom/. + For every advisory present in /tmp/osv.json that is NOT already listed + in osv-scanner.toml, read the source code and decide reachability + (is the vulnerable function called? is its input attacker-controlled?). + Then: + (a) if NOT exploitable, add an [[IgnoredVulns]] entry to osv-scanner.toml + with a CISA VEX reason and an ignoreUntil date ~3 months out; + (b) if exploitable, add/refresh the corresponding entry in + security/report-config.json (affected[]) with the fix version; + (c) keep the counts and the notAffected[]/mitigated[] arrays in + security/report-config.json consistent with the scan. + EDIT ONLY these two files: osv-scanner.toml and + security/report-config.json. Do not touch anything else. If there are + no new advisories, make no changes. + + # ---- deterministic: render the report from the (possibly updated) data ---- + - 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" \ + --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. + "${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \ + --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: + 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) were triaged by Claude and reflected in `report-config.json` / `osv-scanner.toml` — **review those diffs**. + + Nothing is merged automatically. Approve to archive this month's snapshot. 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/build-report.mjs b/scripts/build-report.mjs new file mode 100644 index 00000000..3a160314 --- /dev/null +++ b/scripts/build-report.mjs @@ -0,0 +1,190 @@ +#!/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); + +const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); +const rich = (s) => String(s ?? ''); // config strings may contain / — keep as-is in HTML +const stripTags = (s) => String(s ?? '').replace(/<[^>]+>/g, ''); + +// ========================= MARKDOWN ========================= +const md = []; +md.push(`# ${cfg.title} — Software Supply Chain Security Report (SBOM & VEX)\n`); +md.push('| | |'); +md.push('|---|---|'); +md.push(`| **Product** | ${cfg.title} (\`${cfg.product}\`) — ${cfg.subtitle} · v${cfg.version} |`); +md.push(`| **Report type** | Software Bill of Materials (SBOM) & Vulnerability Exploitability eXchange (VEX) |`); +md.push(`| **Assessment date** | ${date} · Report version 1.0 |`); +md.push(`| **Prepared by** | Autonomy Logic — Engineering / Product Security |`); +md.push(`| **Classification** | Confidential |`); +md.push(`| **Security contact** | ${cfg.securityContact} |`); +md.push('\n---\n'); +md.push('## Executive Summary\n'); +md.push(`This report documents the third-party software composition and known-vulnerability posture of **${cfg.title}**. It is aligned with U.S. Executive Order 14028, the NTIA *Minimum Elements for an SBOM*, and the CISA Vulnerability Exploitability eXchange (VEX) guidance.\n`); +md.push(`> **Headline posture.** ${stripTags(cfg.headline)}\n`); +md.push('### Key metrics\n'); +md.push('| Metric | Value |'); +md.push('|---|---|'); +md.push(`| Components inventoried (full transitive graph) | **${componentCount}** |`); +md.push(`| Raw advisories detected | ${cfg.advisories.total} (${cfg.advisories.critical} critical · ${cfg.advisories.high} high · ${cfg.advisories.moderate} moderate · ${cfg.advisories.low} low) |`); +md.push(`| **AFFECTED — action required** | **${cfg.counts.affected.n}** (${cfg.counts.affected.sev}) |`); +md.push(`| AFFECTED — mitigating controls in place | ${cfg.counts.mitigated.n} (${cfg.counts.mitigated.sev}) |`); +md.push(`| **NOT AFFECTED** | **${cfg.counts.notAffected.n}** (${cfg.counts.notAffected.pct}) |`); +md.push('\n## 1. Scope & System Description\n'); +md.push(stripTags(cfg.scope) + '\n'); +md.push(`> **Scope note.** ${stripTags(cfg.scopeNote)}\n`); +md.push('## 2. Methodology\n'); +md.push(stripTags(cfg.methodologyNote) + ' For each relevant package, source code was analyzed to determine whether the vulnerable code path is invoked and whether its input is attacker-controlled.\n'); +md.push('## 3. Software Bill of Materials Summary\n'); +md.push('| License | Components |'); +md.push('|---|---|'); +for (const [l, n] of topLicenses) md.push(`| ${l} | ${n} |`); +md.push(`\n**License finding:** ${stripTags(cfg.licenseNote)}\n`); +md.push('## 4. Findings Requiring Remediation (Affected)\n'); +if (cfg.affected.length) { + md.push('| Priority | Component | Installed | Fixed in | Severity | Reachability rationale |'); + md.push('|---|---|---|---|---|---|'); + for (const f of cfg.affected) md.push(`| ${f.priority} | \`${f.component}\` | ${f.installed} | ${f.fixedIn} | ${f.severity} | ${stripTags(f.rationale)} |`); +} else md.push('**None.**'); +md.push('\n## 5. Not Affected — VEX Justifications\n'); +md.push('| VEX justification (CISA) | Count | Representative components | Basis |'); +md.push('|---|---|---|---|'); +for (const n of cfg.notAffected) md.push(`| \`${n.justification}\` | ${n.count} | ${stripTags(n.components)} | ${stripTags(n.basis)} |`); +if (cfg.criticalNote) md.push(`\n**On critical severity.** ${stripTags(cfg.criticalNote)}\n`); +md.push('\n## 6. Mitigated Findings\n'); +md.push('| Component | Advisories | Existing control |'); +md.push('|---|---|---|'); +for (const m of cfg.mitigated) md.push(`| \`${m.component}\` | ${m.advisories} | ${stripTags(m.control)} |`); +md.push('\n## 7. Remediation Plan\n'); +for (const r of cfg.remediation) md.push(`- ${stripTags(r)}`); +md.push('\n## 8. Secure Development & Supply-Chain Practices\n'); +md.push('| Practice | Status |'); +md.push('|---|---|'); +for (const p of cfg.practices) md.push(`| ${p.practice} | ${p.status} |`); +md.push('\n## 9. Attached Artifacts\n'); +md.push('| Artifact | Format | Purpose |'); +md.push('|---|---|---|'); +md.push(`| \`sbom/${cfg.sbomBasename}.cdx.json\` | CycloneDX 1.6 | Canonical machine-readable SBOM |`); +md.push(`| \`sbom/${cfg.sbomBasename}.spdx.json\` | SPDX 2.3 (ISO/IEC 5962) | Procurement / compliance SBOM |`); +md.push(`| \`sbom/${cfg.sbomBasename}.components.csv\` | CSV | Human-readable component inventory (${componentCount} rows) |`); +md.push(`| \`sbom/vulnerabilities.csv\` | CSV | Full annotated advisory register (${cfg.advisories.total} rows) |`); +md.push(`\n---\n\n*Prepared by Autonomy Logic Engineering. Assessment date ${date}. Regenerate per release.*\n`); +const mdOut = md.join('\n'); + +// ========================= HTML ========================= +const sevBadge = (s) => s; // severity strings already human +const row = (cells) => `${cells.map((c) => `${c}`).join('')}`; +const th = (cells) => `${cells.map((c) => `${c}`).join('')}`; +const badge = (txt, cls) => `${txt}`; + +const html = ` + +${esc(cfg.title)} — Security Report (SBOM & VEX) + +

${esc(cfg.title)}

+

Software Supply Chain Security Report — SBOM & VEX

+ +${row(['Product', `${esc(cfg.title)} (${esc(cfg.product)}) — ${esc(cfg.subtitle)} · v${esc(cfg.version)}`])} +${row(['Report type', 'Software Bill of Materials (SBOM) & Vulnerability Exploitability eXchange (VEX)'])} +${row(['Assessment date', `${esc(date)} · Report version 1.0`])} +${row(['Prepared by', 'Autonomy Logic — Engineering / Product Security'])} +${row(['Classification', 'Confidential'])} +${row(['Security contact', esc(cfg.securityContact)])} +
+

Executive Summary

+

This report documents the third-party software composition and known-vulnerability posture of ${esc(cfg.title)}. It is aligned with U.S. Executive Order 14028, the NTIA Minimum Elements for an SBOM, and the CISA VEX guidance.

+
Headline posture. ${rich(cfg.headline)}
+

Key metrics

+${th(['Metric', 'Value'])} +${row(['Components inventoried (full transitive graph)', `${componentCount}`])} +${row(['Raw advisories detected', `${cfg.advisories.total} (${cfg.advisories.critical} critical · ${cfg.advisories.high} high · ${cfg.advisories.moderate} moderate · ${cfg.advisories.low} low)`])} +${row([badge('AFFECTED — action required', 'b-red'), `${cfg.counts.affected.n} (${cfg.counts.affected.sev})`])} +${row([badge('AFFECTED — mitigated', 'b-amber'), `${cfg.counts.mitigated.n} (${cfg.counts.mitigated.sev})`])} +${row([badge('NOT AFFECTED', 'b-green'), `${cfg.counts.notAffected.n} (${cfg.counts.notAffected.pct})`])} +
+

1. Scope & System Description

${rich(cfg.scope)}

+
Scope note. ${rich(cfg.scopeNote)}
+

2. Methodology

${rich(cfg.methodologyNote)} For each relevant package, source code was analyzed to determine whether the vulnerable code path is invoked and whether its input is attacker-controlled.

+

3. Software Bill of Materials Summary

+${th(['License', 'Components'])} +${topLicenses.map(([l, n]) => row([esc(l), String(n)])).join('\n')} +
+
License finding: ${rich(cfg.licenseNote)}
+

4. Findings Requiring Remediation (Affected)

+${cfg.affected.length ? `${th(['Priority', 'Component', 'Installed', 'Fixed in', 'Severity', 'Reachability rationale'])} +${cfg.affected.map((f) => row([f.priority, `${esc(f.component)}`, esc(f.installed), esc(f.fixedIn), f.severity, rich(f.rationale)])).join('\n')} +
` : '
None.
'} +

5. Not Affected — VEX Justifications

+${th(['VEX justification (CISA)', 'Count', 'Representative components', 'Basis'])} +${cfg.notAffected.map((n) => row([`${esc(n.justification)}`, n.count, rich(n.components), rich(n.basis)])).join('\n')} +
+${cfg.criticalNote ? `
On critical severity. ${rich(cfg.criticalNote)}
` : ''} +

6. Mitigated Findings

+${th(['Component', 'Advisories', 'Existing control'])} +${cfg.mitigated.map((m) => row([`${esc(m.component)}`, m.advisories, rich(m.control)])).join('\n')} +
+

7. Remediation Plan

    ${cfg.remediation.map((r) => `
  • ${rich(r)}
  • `).join('')}
+

8. Secure Development & Supply-Chain Practices

+${th(['Practice', 'Status'])} +${cfg.practices.map((p) => row([esc(p.practice), p.status === 'In place' ? badge('In place', 'b-green') : badge('Recommended', 'b-amber')])).join('\n')} +
+

9. Attached Artifacts

+${th(['Artifact', 'Format', 'Purpose'])} +${row([`sbom/${cfg.sbomBasename}.cdx.json`, 'CycloneDX 1.6', 'Canonical machine-readable SBOM'])} +${row([`sbom/${cfg.sbomBasename}.spdx.json`, 'SPDX 2.3 (ISO/IEC 5962)', 'Procurement / compliance SBOM'])} +${row([`sbom/${cfg.sbomBasename}.components.csv`, 'CSV', `Human-readable component inventory (${componentCount} rows)`])} +${row(['sbom/vulnerabilities.csv', 'CSV', `Full annotated advisory register (${cfg.advisories.total} rows)`])} +
+

Prepared by Autonomy Logic Engineering. Assessment date ${esc(date)}. Regenerate per release.

+`; + +const base = `${cfg.title.replace(/[^A-Za-z0-9]+/g, '-')}-Security-Report`; +writeFileSync(`${outDir}/${base}.md`, mdOut); +writeFileSync(`${outDir}/${base}.html`, html); +console.log(`Wrote ${outDir}/${base}.md and .html (${componentCount} components, ${topLicenses.length} license classes)`); diff --git a/scripts/cdx-to-spdx.mjs b/scripts/cdx-to-spdx.mjs new file mode 100644 index 00000000..926b252d --- /dev/null +++ b/scripts/cdx-to-spdx.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Convert a CycloneDX 1.6 BOM (produced by cdxgen) into a valid SPDX 2.3 JSON document. +// Keeps NTIA minimum elements: supplier, name, version, unique id (purl), relationships, author, timestamp. +import { readFileSync, writeFileSync } from 'node:fs'; + +const SRC = process.argv[2] || 'sbom/bom.cdx.json'; +const OUT = process.argv[3] || 'sbom/bom.spdx.json'; + +const cdx = JSON.parse(readFileSync(SRC, 'utf8')); +const created = cdx.metadata?.timestamp || new Date().toISOString(); + +// The actual SBOM generator(s), read from the CycloneDX metadata (cdxgen, +// cyclonedx-py, …) — never hardcoded. +const toolComps = cdx.metadata?.tools?.components + || (Array.isArray(cdx.metadata?.tools) ? cdx.metadata.tools : []); +const toolCreators = toolComps + .filter((t) => t && t.name) + .map((t) => `Tool: ${t.name}${t.version ? '-' + t.version : ''}`); + +// Flatten root + nested workspace components into the package list. +const comps = []; +const walk = (c) => { + if (!c) return; + comps.push(c); + (c.components || []).forEach(walk); +}; +walk(cdx.metadata?.component); +(cdx.components || []).forEach((c) => comps.push(c)); + +// Stable SPDXID per bom-ref/purl. +const idFor = new Map(); +let n = 0; +const spdxId = (ref) => { + if (idFor.has(ref)) return idFor.get(ref); + const safe = String(ref).replace(/[^a-zA-Z0-9.-]/g, '-').replace(/-+/g, '-'); + const id = `SPDXRef-Pkg-${++n}-${safe}`.slice(0, 200); + idFor.set(ref, id); + return id; +}; + +const licenseExpr = (c) => { + const ls = c.licenses || []; + if (!ls.length) return 'NOASSERTION'; + const parts = ls + .map((l) => l.license?.id || l.expression || null) + .filter(Boolean); + if (!parts.length) return 'NOASSERTION'; + const expr = parts.length === 1 ? parts[0] : parts.join(' AND '); + // Non-SPDX placeholders -> NOASSERTION + if (/SEE LICENSE|UNLICENSED|UNKNOWN/i.test(expr)) return 'NOASSERTION'; + return expr; +}; + +const packages = comps.map((c) => { + const ref = c['bom-ref'] || c.purl || `${c.group || ''}/${c.name}@${c.version || ''}`; + const pkg = { + name: (c.group ? `${c.group}/` : '') + c.name, + SPDXID: spdxId(ref), + versionInfo: c.version || 'NOASSERTION', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'NOASSERTION', + licenseDeclared: licenseExpr(c), + copyrightText: 'NOASSERTION', + supplier: c.publisher ? `Organization: ${c.publisher}` : 'NOASSERTION', + }; + if (c.purl) { + pkg.externalRefs = [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: c.purl, + }, + ]; + } + return pkg; +}); + +// Relationships: document DESCRIBES root; root/deps DEPENDS_ON edges from cdx.dependencies. +const rootRef = cdx.metadata?.component?.['bom-ref']; +const relationships = []; +if (rootRef && idFor.has(rootRef)) { + relationships.push({ + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: idFor.get(rootRef), + }); +} +for (const dep of cdx.dependencies || []) { + const from = idFor.get(dep.ref); + if (!from) continue; + for (const to of dep.dependsOn || []) { + const t = idFor.get(to); + if (!t) continue; + relationships.push({ + spdxElementId: from, + relationshipType: 'DEPENDS_ON', + relatedSpdxElement: t, + }); + } +} + +// Derive the document name/namespace from THIS SBOM's root component — never +// hardcode a product name (that mislabels every other product's SPDX). +const rootName = (cdx.metadata?.component?.name || 'unknown-project').replace(/[^a-zA-Z0-9._-]/g, '-'); +const serial = (cdx.serialNumber || `urn:uuid:${rootName}`).replace('urn:uuid:', ''); +const doc = { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: 'SPDXRef-DOCUMENT', + name: `${rootName}-SBOM`, + documentNamespace: `https://autonomylogic.com/spdx/${rootName}/${serial}`, + creationInfo: { + created, + // Derive the generator tool(s) from the source CycloneDX metadata instead of + // hardcoding (which would mis-attribute e.g. cyclonedx-py SBOMs to cdxgen). + creators: [ + 'Organization: Autonomy Logic', + ...toolCreators, + 'Tool: cdx-to-spdx', + ], + }, + packages, + relationships, +}; + +writeFileSync(OUT, JSON.stringify(doc, null, 2)); +console.log(`Wrote ${OUT}: ${packages.length} packages, ${relationships.length} relationships`); diff --git a/scripts/gen-osv-ignores.mjs b/scripts/gen-osv-ignores.mjs new file mode 100644 index 00000000..27ab502b --- /dev/null +++ b/scripts/gen-osv-ignores.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Generate osv-scanner.toml [[IgnoredVulns]] entries from a triaged +// vulnerabilities.csv. Rows classified "not affected" / "mitigated" (our VEX +// baseline) become suppressions so the monthly scan only surfaces NEW findings. +// Rows that require action (APLICA / affected) are intentionally NOT suppressed +// — they keep alerting until the dependency is bumped. +// +// Usage: +// node scripts/gen-osv-ignores.mjs sbom/vulnerabilities.csv 2026-10-01 >> osv-scanner.toml +// arg1 = CSV path +// arg2 = review-by date (YYYY-MM-DD) written as ignoreUntil +// +// The CSV must contain an advisory-id column (named cve / advisory / id) whose +// cells hold OSV ids (GHSA-*, CVE-*, PYSEC-*, possibly pipe-separated). If a +// verdict column is present (verdict / verdict_vex / reach), only not-affected +// rows are suppressed; otherwise every listed id is suppressed (review!). + +import { readFileSync } from 'node:fs'; + +const [csvPath, reviewDate = '1970-01-01'] = process.argv.slice(2); +if (!csvPath) { console.error('usage: gen-osv-ignores.mjs '); process.exit(1); } + +const rows = readFileSync(csvPath, 'utf8').trim().split('\n').map(parseCsvLine); +const header = rows.shift().map((h) => h.toLowerCase()); +const idCol = header.findIndex((h) => ['cve', 'advisory', 'id', 'advisory_id'].includes(h)); +const verdictCol = header.findIndex((h) => ['verdict', 'verdict_vex'].includes(h)); +const reasonCol = header.findIndex((h) => ['description', 'title', 'basis', 'verdict_vex', 'verdict'].includes(h)); +if (idCol < 0) { console.error('No advisory-id column (cve/advisory/id) found'); process.exit(1); } + +const isNotAffected = (v) => /not[_ ]?affected|mitig|\bn\/?a\b/i.test(v || ''); +const seen = new Set(); +let emitted = 0; + +for (const r of rows) { + const verdict = verdictCol >= 0 ? r[verdictCol] : ''; + if (verdictCol >= 0 && !isNotAffected(verdict)) continue; // keep actionable ones visible + const ids = String(r[idCol] || '').split('|').map((s) => s.trim()).filter((s) => /^(GHSA|CVE|PYSEC)-/i.test(s)); + const reason = (reasonCol >= 0 ? r[reasonCol] : verdict) || 'triaged not-affected'; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + console.log(`\n[[IgnoredVulns]]`); + console.log(`id = "${id}"`); + console.log(`ignoreUntil = "${reviewDate}T00:00:00Z"`); + console.log(`reason = ${JSON.stringify('VEX not_affected: ' + reason.replace(/\s+/g, ' ').slice(0, 160))}`); + emitted++; + } +} +console.error(`Emitted ${emitted} ignore entries from ${rows.length} rows.`); + +function parseCsvLine(line) { + const out = []; let cur = '', q = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (q) { if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; } + else { if (c === '"') q = true; else if (c === ',') { out.push(cur); cur = ''; } else cur += c; } + } + out.push(cur); return out; +} diff --git a/scripts/generate-sbom.sh b/scripts/generate-sbom.sh new file mode 100755 index 00000000..22d1a66f --- /dev/null +++ b/scripts/generate-sbom.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# generate-sbom.sh — Reproducible Software Bill of Materials for this project. +# +# Portable: derives the project name and package-manager from the repo itself +# (no hardcoded product name). Produces three artifacts under ./sbom/: +# - .cdx.json CycloneDX 1.6 (canonical, security/VEX-oriented — OWASP) +# - .spdx.json SPDX 2.3 (ISO/IEC 5962 — procurement/compliance) +# - .components.csv flattened component list (human-readable) +# +# Standards satisfied: NTIA "Minimum Elements for an SBOM" and EO 14028. +# Source of truth: the committed lockfile (fully-resolved dependency graph). +# +# Usage: ./scripts/generate-sbom.sh +# CI: run on every release tag; commit/attach the artifacts to the release. + +set -euo pipefail +cd "$(dirname "$0")/.." + +NAME="$(node -p "require('./package.json').name" 2>/dev/null || basename "$PWD")" +VERSION="$(node -p "require('./package.json').version || ''" 2>/dev/null || echo "")" + +# Auto-detect the package manager / cdxgen project type from the lockfile. +if [ -f pnpm-lock.yaml ]; then TYPE=pnpm +elif [ -f package-lock.json ]; then TYPE=npm +elif [ -f yarn.lock ]; then TYPE=yarn +else TYPE=js; fi + +OUT="sbom" +mkdir -p "$OUT" + +echo "==> Generating CycloneDX 1.6 SBOM (cdxgen, -t $TYPE) for ${NAME}@${VERSION:-unversioned}" +# cdxgen can exceed Node's default ~2 GB heap on a large pnpm monorepo (SIGABRT / +# exit 134 in CI). Raise the old-space limit; harmless on machines with less RAM. +export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=6144" +FETCH_LICENSE=true npx --yes @cyclonedx/cdxgen@11 \ + -t "$TYPE" \ + --spec-version 1.6 \ + -o "$OUT/$NAME.cdx.json" \ + --project-name "$NAME" \ + ${VERSION:+--project-version "$VERSION"} \ + . 2>/dev/null + +echo "==> Converting to SPDX 2.3" +node scripts/cdx-to-spdx.mjs "$OUT/$NAME.cdx.json" "$OUT/$NAME.spdx.json" + +echo "==> Emitting flattened CSV" +NAME="$NAME" OUT="$OUT" node -e ' +const fs=require("fs"); +const name=process.env.NAME, out=process.env.OUT; +const b=require(`./${out}/${name}.cdx.json`); +const esc=s=>{s=String(s==null?"":s);return /[",\n]/.test(s)?"\""+s.replace(/"/g,"\"\"")+"\"":s;}; +const rows=[["name","version","type","purl","license"]]; +for(const c of (b.components||[])){ + const lic=(c.licenses||[]).map(l=>l.license?.id||l.expression||l.license?.name||"").join(" / "); + rows.push([(c.group?c.group+"/":"")+c.name,c.version||"",c.type||"",c.purl||"",lic]); +} +fs.writeFileSync(`${out}/${name}.components.csv`,rows.map(r=>r.map(esc).join(",")).join("\n")); +console.log(`Wrote ${out}/${name}.components.csv:`,rows.length-1,"components"); +' + +echo "==> Done. Artifacts in ./$OUT/" +ls -la "$OUT" diff --git a/scripts/scan-vulns.mjs b/scripts/scan-vulns.mjs new file mode 100644 index 00000000..ff5269e1 --- /dev/null +++ b/scripts/scan-vulns.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// scan-vulns.mjs — flatten osv-scanner JSON into the annotated vulnerabilities.csv. +// node scripts/scan-vulns.mjs +// osv-scanner already honored osv-scanner.toml, so anything here that is NOT in the +// suppression baseline is, by definition, part of the monthly "delta". + +import { readFileSync, writeFileSync } from 'node:fs'; +const [src, out = 'sbom/vulnerabilities.csv'] = process.argv.slice(2); +let data = {}; +try { data = JSON.parse(readFileSync(src, 'utf8')); } catch { data = {}; } + +const esc = (s) => { s = String(s ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; +const sevOf = (v) => (v.database_specific?.severity) + || (Array.isArray(v.severity) && v.severity[0]?.score) || ''; +const rows = [['severity', 'package', 'version', 'advisory', 'fixed_in', 'summary', 'url']]; +for (const res of (data.results || [])) { + for (const p of (res.packages || [])) { + for (const v of (p.vulnerabilities || [])) { + const fixed = [...new Set((v.affected || []).flatMap((a) => (a.ranges || []) + .flatMap((r) => (r.events || []).filter((e) => e.fixed).map((e) => e.fixed))))].join(' | '); + rows.push([sevOf(v), p.package?.name || '', p.package?.version || '', v.id || '', + fixed, (v.summary || '').slice(0, 140), `https://osv.dev/${v.id}`]); + } + } +} +writeFileSync(out, rows.map((r) => r.map(esc).join(',')).join('\n')); +console.log(`Wrote ${out}: ${rows.length - 1} advisories (post-suppression delta).`); diff --git a/security/README.md b/security/README.md new file mode 100644 index 00000000..f260f3cd --- /dev/null +++ b/security/README.md @@ -0,0 +1,52 @@ +# Security — SBOM & Vulnerability reports (automated, monthly) + +This folder holds this repository's supply-chain security deliverable: a **dated +snapshot per month** with the SBOM (CycloneDX + SPDX), the component inventory, +the annotated vulnerability register (VEX), and the human-readable report. + +``` +security/ +├── 2026-07/ ← a monthly snapshot (immutable once merged) +│ ├── 00-READ-ME-FIRST.md +│ ├── STruCpp-Security-Report.md / .html / .pdf +│ └── sbom/ → *.cdx.json · *.spdx.json · *.components.csv · vulnerabilities.csv +├── latest → 2026-07 ← pointer to the current month +└── report-config.json ← report DATA (VEX triage + narrative) +``` + +## How it runs + +`.github/workflows/security-monthly.yml` runs on the **1st of each month** (and on +the manual **Run workflow** button). Per run it: regenerates the SBOM, scans +dependencies with **osv-scanner** (OSV = the same advisory source as Dependabot, +honoring the suppression baseline in `../osv-scanner.toml`), has **Claude triage +any new advisory** and update the report data, renders the report from +`report-config.json`, writes `security//`, and **opens a PR** for review. + +The report is **generated from data** (`report-config.json` + the live SBOM), so +it never drifts from the actual dependency graph. + +## One-time setup — run Claude on your subscription (no API key) + +Claude runs inside the workflow on **your Claude subscription** (Pro/Max), not a +pay-per-token API key: + +1. On your machine: `claude setup-token` → complete the browser login → copy the + token it prints (`CLAUDE_CODE_OAUTH_TOKEN`). +2. In the repo: **Settings → Secrets and variables → Actions → New repository + secret** → name `CLAUDE_CODE_OAUTH_TOKEN`, value = the token. +3. Done. The monthly workflow will authenticate as you. (If the secret is absent, + the workflow still runs and builds the report deterministically — it just + skips the AI triage step.) + +> To move billing off a personal account later (e.g. an org API key), replace the +> secret with `ANTHROPIC_API_KEY` and swap the one input in the workflow — nothing +> else changes. + +## Reviewing the monthly PR + +- **No new advisory:** confirm the summary, approve, merge (the snapshot is archived). +- **New advisory, not exploitable:** check Claude's justification in the + `osv-scanner.toml` diff, then merge. +- **New advisory, exploitable:** bump the dependency (separate PR) and merge the + report that documents it. diff --git a/security/report-config.json b/security/report-config.json new file mode 100644 index 00000000..357b266e --- /dev/null +++ b/security/report-config.json @@ -0,0 +1,41 @@ +{ + "product": "strucpp", + "title": "STruCpp", + "subtitle": "IEC 61131-3 Structured Text → C++ compiler / transpiler", + "version": "0.6.0", + "sbomBasename": "strucpp", + "ecosystem": "npm", + "securityContact": "Thiago Alves — thiago.alves@autonomylogic.com", + "advisorySource": "GitHub Advisory Database via npm audit (equivalent to Dependabot)", + "advisories": { "total": 29, "critical": 2, "high": 12, "moderate": 14, "low": 1 }, + "counts": { + "affected": { "n": 0, "sev": "none require action" }, + "mitigated": { "n": 0, "sev": "—" }, + "notAffected": { "n": 29, "pct": "100%" } + }, + "headline": "STruCpp is a TypeScript/Node.js command-line compiler distributed as a self-contained binary, with an unusually small third-party runtime footprint (a single direct runtime dependency, chevrotain). Of 29 unique advisories across the dependency tree, none require remediation and none are exploitable in the shipped compiler. Every advisory is either in build/test tooling that is not distributed, or in the one transitive runtime library (lodash-es) whose vulnerable functions are never called. Both critical-severity advisories are in development tooling only.", + "scope": "STruCpp is a command-line compiler written in TypeScript, executed on Node.js and distributed as a self-contained executable (built with pkg for Linux/Windows/macOS). It parses IEC 61131-3 Structured Text and emits C++. The repository also contains a first-party C/C++ runtime library (headers the emitted C++ links against) — Autonomy Logic's own source code, not a third-party dependency, covered by the Runtime Library Exception.", + "scopeNote": "This SBOM covers the Node.js/npm dependency graph of the compiler. The bundled Node.js runtime (embedded by pkg) and the first-party C/C++ runtime library are Autonomy Logic artifacts, assessed separately from third-party supply chain. A companion VS Code extension in the same repository is a distinct component.", + "methodologyNote": "Advisory data was obtained from the GitHub Advisory Database via npm audit (equivalent to GitHub Dependabot alerts). The compiler is bundled from a committed lockfile; development, build, and test dependencies are not part of the distributed binary — only the runtime dependency subtree (chevrotain and its transitives) ships. The compiler's input is a Structured Text source file, treated as untrusted.", + "licenseNote": "STruCpp itself is licensed GPL-3.0-or-later, with a GCC-style Runtime Library Exception (RLE). The compiler is open-source copyleft (distributing it or a modified version carries GPL-3.0 obligations, including source availability), while the RLE means C++ code produced by the compiler is not forced to be GPL — end users can compile and distribute proprietary PLC programs (mirroring GCC's libstdc++ exception). Copyright is held by Autonomy Logic / the OpenPLC Project. This is the product's intended license, surfaced for the acquirer's IP/legal review. The third-party dependency graph is fully permissive (MIT/ISC/Apache/BSD) — no copyleft is introduced through dependencies.", + "affected": [], + "mitigated": [], + "notAffected": [ + { "justification": "component_not_present", "count": "26", "components": "tar (1 Critical + high), vitest (1 Critical), minimatch, vite, esbuild, rollup, brace-expansion, js-yaml, picomatch, postcss, flatted, ajv, yaml", "basis": "Development, build, and test tooling (bundler, test runner, packager). Not part of the runtime dependency subtree; excluded from the distributed binary. Both critical advisories fall here." }, + { "justification": "vulnerable_code_not_in_execute_path", "count": "3", "components": "lodash-es (1 high + 2 moderate) — via chevrotain", "basis": "The only third-party runtime library with advisories. The vulnerable functions (_.template code injection; _.unset/_.omit prototype pollution) are never called: STruCpp does not import lodash directly, and chevrotain uses lodash-es only for internal parser data structures — the compiler's Structured Text input never reaches those functions." } + ], + "criticalNote": "Both critical advisories (tar, vitest) are in development/build tooling and are not part of the distributed compiler. Neither is present in the delivered product.", + "remediation": [ + "No action required for exploitable vulnerabilities — no advisory is both present in the distributed compiler and exploitable.", + "Hygiene: keep the bundled Node.js runtime (via pkg) current with Node security releases.", + "Legal: the first-party GPL-3.0-or-later + Runtime Library Exception license (§ license posture) is material to the acquirer's IP review." + ], + "practices": [ + { "practice": "Minimal runtime dependency surface (1 direct dependency)", "status": "In place" }, + { "practice": "Deterministic builds from a committed lockfile (npm ci)", "status": "In place" }, + { "practice": "Development/build tooling excluded from the distributed binary", "status": "In place" }, + { "practice": "Automated dependency updates (Dependabot / GitHub Advisory Database)", "status": "In place" }, + { "practice": "SBOM generated per release in CycloneDX and SPDX", "status": "In place" }, + { "practice": "Keep the bundled Node.js runtime (via pkg) current with Node security releases", "status": "Recommended" } + ] +} From ce2204f503b3c631051907c23b9aaf7c59e18ad2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Date: Fri, 31 Jul 2026 08:38:51 -0300 Subject: [PATCH 2/8] ci(security): PR gate v2 (reads-only + sticky comment) + archive on merge Gate now only scans/reports (contents: read) and posts one actionable sticky PR comment; per-PR SBOM archived on MERGE by security-pr-archive.yml. Validated on autonomy-edge. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AejAiL4tfXCyMhwtjY4vFT --- .github/workflows/security-pr-archive.yml | 68 +++++++++++++ .github/workflows/security-pr-gate.yml | 92 +++++++++++++++++ scripts/pr-gate-diff.mjs | 115 ++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 .github/workflows/security-pr-archive.yml create mode 100644 .github/workflows/security-pr-gate.yml create mode 100644 scripts/pr-gate-diff.mjs diff --git a/.github/workflows/security-pr-archive.yml b/.github/workflows/security-pr-archive.yml new file mode 100644 index 00000000..adf0313f --- /dev/null +++ b/.github/workflows/security-pr-archive.yml @@ -0,0 +1,68 @@ +name: Security — archive PR SBOM on merge + +# When a pull request is MERGED, generate the SBOM + report for the resulting +# state and commit it under security/pr--/ on the default branch, +# so the repo keeps a permanent, per-PR supply-chain history. Runs once per merge +# (not on synchronize), so it never loops and never touches the PR while it is +# under review. +# +# NOTE: this pushes directly to the default branch. If you later require PRs on +# the default branch in branch protection, switch this to open a PR instead. + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +concurrency: + group: security-pr-archive + cancel-in-progress: false + +jobs: + archive: + if: ${{ github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + steps: + - name: Checkout the default branch (post-merge state) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + 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: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + + - name: Generate SBOM + report and archive under security/pr--/ + run: | + bash scripts/generate-sbom.sh + NAME=$(basename "$(ls sbom/*.cdx.json | head -1)" .cdx.json) + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . || true + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + DATE=$(date -u +%Y-%m-%d) + PR="${{ github.event.pull_request.number }}" + DIR="security/pr-${PR}-${DATE}" + node scripts/build-report.mjs --config security/report-config.json --cdx "sbom/$NAME.cdx.json" --out "$DIR" --date "$DATE" + mkdir -p "$DIR/sbom" + cp "sbom/$NAME".cdx.json "sbom/$NAME".spdx.json "sbom/$NAME".components.csv sbom/vulnerabilities.csv "$DIR/sbom/" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$DIR" + if git diff --cached --quiet; then + echo "No SBOM to archive." + else + git commit -m "chore(security): SBOM snapshot for merged PR #${PR} (${DATE})" + git push origin "HEAD:${{ github.event.pull_request.base.ref }}" + fi diff --git a/.github/workflows/security-pr-gate.yml b/.github/workflows/security-pr-gate.yml new file mode 100644 index 00000000..c65195c1 --- /dev/null +++ b/.github/workflows/security-pr-gate.yml @@ -0,0 +1,92 @@ +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: Install osv-scanner + run: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /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" + osv-scanner scan $CFG --recursive --format=json --output=/tmp/head.json . || true + + - name: Scan BASE + run: | + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}" 2>/dev/null || true + git worktree add -f /tmp/base "${{ github.event.pull_request.base.sha }}" + CFG=""; [ -f /tmp/head-config.toml ] && CFG="--config=/tmp/head-config.toml" + osv-scanner scan $CFG --recursive --format=json --output=/tmp/base.json /tmp/base || true + + # 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: node scripts/pr-gate-diff.mjs /tmp/base.json /tmp/head.json "${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/scripts/pr-gate-diff.mjs b/scripts/pr-gate-diff.mjs new file mode 100644 index 00000000..8bec73c8 --- /dev/null +++ b/scripts/pr-gate-diff.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// pr-gate-diff.mjs — the PR security gate's decision. +// node scripts/pr-gate-diff.mjs [threshold] +// Both inputs are osv-scanner JSON outputs produced WITH --config=osv-scanner.toml +// (so the VEX baseline is already applied). We block ONLY on advisories that the +// PR INTRODUCES — present in head, absent in base — at or above +// (default HIGH). Pre-existing advisories never block. Exit 1 = block, 0 = pass. +// +// Side effect: writes a Markdown body to $GATE_COMMENT_FILE (default +// /tmp/gate-comment.md) and a one-word status (block|warn|clean) to +// $GATE_STATUS_FILE (default /tmp/gate-status), so the workflow can post a PR +// comment. The full detail is also printed to stdout (the check log). + +import { readFileSync, writeFileSync } from 'node:fs'; + +const [baseP, headP, thresholdArg] = process.argv.slice(2); +const THRESHOLD = (thresholdArg || 'HIGH').toUpperCase(); +const ORDER = ['LOW', 'MODERATE', 'HIGH', 'CRITICAL']; +const COMMENT_FILE = process.env.GATE_COMMENT_FILE || '/tmp/gate-comment.md'; +const STATUS_FILE = process.env.GATE_STATUS_FILE || '/tmp/gate-status'; +const MARKER = ''; + +const load = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; } }; + +// --- CVSS v3.x base-score computation (for entries that carry only a vector) --- +function cvss3Base(vector) { + const m = {}; + for (const kv of vector.split('/')) { const [k, v] = kv.split(':'); if (k && v) m[k] = v; } + const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[m.AV]; + const AC = { L: 0.77, H: 0.44 }[m.AC]; + const UI = { N: 0.85, R: 0.62 }[m.UI]; + const scopeChanged = m.S === 'C'; + const PR = (scopeChanged ? { N: 0.85, L: 0.68, H: 0.5 } : { N: 0.85, L: 0.62, H: 0.27 })[m.PR]; + const imp = { N: 0, L: 0.22, H: 0.56 }; + const C = imp[m.C], I = imp[m.I], A = imp[m.A]; + if ([AV, AC, UI, PR, C, I, A].some((x) => x === undefined)) return null; + const iss = 1 - (1 - C) * (1 - I) * (1 - A); + const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss; + if (impact <= 0) return 0; + const expl = 8.22 * AV * AC * PR * UI; + const roundup = (x) => Math.ceil(x * 10) / 10; + return roundup(Math.min((scopeChanged ? 1.08 : 1) * (impact + expl), 10)); +} +const bucketFromScore = (s) => (s >= 9 ? 'CRITICAL' : s >= 7 ? 'HIGH' : s >= 4 ? 'MODERATE' : s > 0 ? 'LOW' : 'LOW'); + +function severityOf(v) { + const word = (v.database_specific?.severity || '').toUpperCase(); + if (ORDER.includes(word)) return word === 'MEDIUM' ? 'MODERATE' : word; + for (const s of (v.severity || [])) { + if (typeof s.score === 'string' && s.score.startsWith('CVSS:3')) { + const sc = cvss3Base(s.score); + if (sc != null) return bucketFromScore(sc); + } + } + return 'HIGH'; // conservative: an unscored NEW advisory should surface, not slip through +} + +function fixedOf(v) { + const fixes = [...new Set((v.affected || []).flatMap((a) => (a.ranges || []) + .flatMap((r) => (r.events || []).filter((e) => e.fixed).map((e) => e.fixed))))]; + return fixes.join(', '); +} + +function index(data) { + const m = new Map(); + for (const r of (data.results || [])) for (const p of (r.packages || [])) for (const v of (p.vulnerabilities || [])) { + if (!v.id || m.has(v.id)) continue; + m.set(v.id, { sev: severityOf(v), pkg: p.package?.name || '?', ver: p.package?.version || '', fixed: fixedOf(v), summary: (v.summary || '').slice(0, 120) }); + } + return m; +} + +const base = index(load(baseP)); +const head = index(load(headP)); +const min = ORDER.indexOf(THRESHOLD); + +const introduced = [...head.entries()].filter(([id]) => !base.has(id)) + .map(([id, d]) => ({ id, ...d })) + .sort((a, b) => ORDER.indexOf(b.sev) - ORDER.indexOf(a.sev)); +const blocking = introduced.filter((a) => ORDER.indexOf(a.sev) >= min); + +// ---- stdout (the check log — full detail) ---- +if (introduced.length) { + console.log(`\nAdvisories introduced by this PR (${introduced.length}):`); + for (const a of introduced) console.log(` [${a.sev}] ${a.pkg}@${a.ver} ${a.id} ${a.summary}`); +} else { + console.log('No new advisories introduced by this PR.'); +} + +// ---- PR comment body ---- +const advLink = (id) => (id.startsWith('GHSA') ? `https://github.com/advisories/${id}` : `https://osv.dev/${id}`); +const rows = introduced.map((a) => `| ${a.sev} | \`${a.pkg}\` | ${a.ver} | [${a.id}](${advLink(a.id)}) | ${a.fixed || '—'} |`).join('\n'); +const table = introduced.length + ? `| Severity | Package | Version | Advisory | Fixed in |\n|---|---|---|---|---|\n${rows}` + : ''; +let status, body; +if (blocking.length) { + status = 'block'; + body = `${MARKER}\n## 🔴 Security gate — blocked\nThis PR introduces **${blocking.length}** new advisory(ies) at or above **${THRESHOLD}**, so the merge is blocked.\n\n${table}\n\n**How to unblock:**\n1. Upgrade the dependency to a fixed version (see *Fixed in*), or\n2. Remove the dependency, or\n3. If it is not exploitable in our usage, add a justified \`[[IgnoredVulns]]\` entry (CISA VEX reason) to \`osv-scanner.toml\`.\n\n_Only High/Critical block; Moderate/Low are shown for awareness. Full detail in the check log._`; +} else if (introduced.length) { + status = 'warn'; + body = `${MARKER}\n## 🟡 Security gate — passed (with notes)\nThis PR introduces new advisories, but none at or above **${THRESHOLD}**, so it does **not** block. Consider addressing them as hygiene.\n\n${table}\n\n_Full detail in the check log._`; +} else { + status = 'clean'; + body = `${MARKER}\n## ✅ Security gate — no new vulnerabilities\nThis PR does not introduce any new advisory versus the base branch.`; +} +writeFileSync(COMMENT_FILE, body); +writeFileSync(STATUS_FILE, status); + +// ---- verdict ---- +if (blocking.length) { + console.error(`\n❌ BLOCKED: this PR introduces ${blocking.length} new ${THRESHOLD}+ advisory(ies). Fix or remove the dependency, or add a justified suppression to osv-scanner.toml.`); + process.exit(1); +} +console.log(`\n✅ PASS: no new ${THRESHOLD}+ advisories introduced.`); From 972213c6e6535f35a8c1409f5577c62b57dcfe04 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Date: Tue, 4 Aug 2026 08:52:09 -0300 Subject: [PATCH 3/8] =?UTF-8?q?fix(security):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20close=206=20gate/report=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate fails closed on scanner error; MEDIUM no longer blocks as HIGH; CVSS 4.0 handled conservatively; diff keyed by advisory id + package. Report HTML escaped (+ PDF rendered with JS disabled). cdx-to-spdx dedupes packages and uses OR for dual licenses. gen-osv-ignores requires a verdict column. Validated on autonomy-edge. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AejAiL4tfXCyMhwtjY4vFT --- .github/workflows/security-monthly.yml | 5 +- .github/workflows/security-pr-gate.yml | 22 ++++++- scripts/build-report.mjs | 8 ++- scripts/cdx-to-spdx.mjs | 29 ++++++--- scripts/gen-osv-ignores.mjs | 22 +++++-- scripts/pr-gate-diff.mjs | 86 ++++++++++++++++++++------ 6 files changed, 134 insertions(+), 38 deletions(-) diff --git a/.github/workflows/security-monthly.yml b/.github/workflows/security-monthly.yml index 905e4548..f4fa92b8 100644 --- a/.github/workflows/security-monthly.yml +++ b/.github/workflows/security-monthly.yml @@ -110,8 +110,11 @@ jobs: 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-gpu --no-pdf-header-footer \ + --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 diff --git a/.github/workflows/security-pr-gate.yml b/.github/workflows/security-pr-gate.yml index c65195c1..c3aa5c9e 100644 --- a/.github/workflows/security-pr-gate.yml +++ b/.github/workflows/security-pr-gate.yml @@ -54,14 +54,30 @@ jobs: 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" - osv-scanner scan $CFG --recursive --format=json --output=/tmp/head.json . || true + 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 }}" 2>/dev/null || true + 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" - osv-scanner scan $CFG --recursive --format=json --output=/tmp/base.json /tmp/base || true + 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. diff --git a/scripts/build-report.mjs b/scripts/build-report.mjs index 3a160314..bddb4578 100644 --- a/scripts/build-report.mjs +++ b/scripts/build-report.mjs @@ -38,7 +38,13 @@ for (const c of comps) { const topLicenses = Object.entries(licAgg).sort((a, b) => b[1] - a[1]).slice(0, 8); const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); -const rich = (s) => String(s ?? ''); // config strings may contain / — keep as-is in HTML +// 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