diff --git a/.github/workflows/sbom-diff.yml b/.github/workflows/sbom-diff.yml new file mode 100644 index 00000000..93d80661 --- /dev/null +++ b/.github/workflows/sbom-diff.yml @@ -0,0 +1,80 @@ +name: SBOM Component Diff + +on: + pull_request: + branches: [main, develop] + +permissions: + contents: read + pull-requests: write + +jobs: + comment-sbom-diff: + runs-on: ubuntu-latest + + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + path: head + + - name: Checkout PR base + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: head/package-lock.json + + - name: Generate head SBOM + working-directory: head + run: | + npm ci + npx @cyclonedx/cyclonedx-npm --output-file ../sbom-head.json --ignore-npm-errors + + - name: Generate base SBOM + working-directory: base + run: | + npm ci + npx @cyclonedx/cyclonedx-npm --output-file ../sbom-base.json --ignore-npm-errors + + - name: Render SBOM component diff + run: node head/scripts/sbom-component-diff.js --base sbom-base.json --head sbom-head.json --markdown sbom-diff.md --summary + + - name: Post SBOM component diff comment + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const body = fs.readFileSync("sbom-diff.md", "utf8"); + const marker = body.split("\n", 1)[0]; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } diff --git a/docs/security.md b/docs/security.md index 473e418f..97fbd85c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -204,6 +204,18 @@ The pipeline runs two complementary scanning engines: A custom-built, fully unit-tested severity gate engine (`scripts/security-gate.ts`) parses the output of both tools. If any vulnerability is found matching or exceeding the configured severity threshold (default is **HIGH**), the script prints the offending advisory details and exits with `1`, **failing the build**. +### Pull Request SBOM Component Diff + +`.github/workflows/sbom-diff.yml` runs on pull requests to `main` and `develop`. The workflow checks out both the pull request head and base commit, generates CycloneDX SBOM files for each dependency graph, and runs `scripts/sbom-component-diff.js` to compare their component lists. + +The workflow posts or updates a single pull request comment named **SBOM component changes**. The comment shows the count and names of components added by the pull request and components removed relative to the base branch, giving operators and downstream consumers a quick supply-chain review surface without downloading SBOM artifacts. + +Developers can run the same local smoke check with: + +```bash +npm run sbom:check +``` + ### Response SLA Matrix Vulnerabilities discovered in production dependencies must be triaged, patched, and resolved according to the following strict SLA timeline: diff --git a/package.json b/package.json index b831a4da..dd299cf7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "test:coverage": "vitest run --coverage", "coverage": "vitest run --coverage", "coverage:audit": "vitest run --config vitest.audit.config.ts --coverage", + "security:scan": "npm audit --omit=dev", + "sbom:check": "npx @cyclonedx/cyclonedx-npm --output-file sbom.json --ignore-npm-errors && node scripts/sbom-component-diff.js --base sbom.json --head sbom.json --summary", "migrate:create": "node-pg-migrate create -j ts -m src/migrations", "migrate": "node-pg-migrate -m dist/migrations up", "migrate:down": "node-pg-migrate -m dist/migrations down", diff --git a/scripts/sbom-component-diff.js b/scripts/sbom-component-diff.js new file mode 100644 index 00000000..3b77c10b --- /dev/null +++ b/scripts/sbom-component-diff.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { basename } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const SBOM_DIFF_COMMENT_MARKER = ""; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function componentKey(component) { + if (component.purl) { + return component.purl; + } + + const group = component.group ? `${component.group}/` : ""; + const version = component.version ? `@${component.version}` : ""; + return `${component.type ?? "component"}:${group}${component.name}${version}`; +} + +function componentLabel(component) { + const group = component.group ? `${component.group}/` : ""; + const version = component.version ? `@${component.version}` : ""; + return `${group}${component.name}${version}`; +} + +function indexComponents(bom) { + const components = Array.isArray(bom?.components) ? bom.components : []; + return new Map( + components + .filter((component) => component && component.name) + .map((component) => [ + componentKey(component), + { + key: componentKey(component), + label: componentLabel(component), + name: component.name, + version: component.version ?? "", + type: component.type ?? "library", + }, + ]), + ); +} + +export function diffSbomComponents(baseBom, headBom) { + const baseComponents = indexComponents(baseBom); + const headComponents = indexComponents(headBom); + + const added = []; + const removed = []; + + for (const [key, component] of headComponents) { + if (!baseComponents.has(key)) { + added.push(component); + } + } + + for (const [key, component] of baseComponents) { + if (!headComponents.has(key)) { + removed.push(component); + } + } + + const byLabel = (left, right) => left.label.localeCompare(right.label); + return { + added: added.sort(byLabel), + removed: removed.sort(byLabel), + }; +} + +function renderList(components) { + if (components.length === 0) { + return "_None_"; + } + + return components + .map((component) => `- \`${component.label}\` (${component.type})`) + .join("\n"); +} + +export function renderSbomDiffMarkdown(diff) { + return `${SBOM_DIFF_COMMENT_MARKER} +## SBOM component changes + +| Added | Removed | +| ---: | ---: | +| ${diff.added.length} | ${diff.removed.length} | + +### Added components +${renderList(diff.added)} + +### Removed components +${renderList(diff.removed)} +`; +} + +function parseArgs(argv) { + const args = { + base: undefined, + head: undefined, + markdown: undefined, + summary: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--summary") { + args.summary = true; + continue; + } + + if (arg.startsWith("--")) { + const key = arg.slice(2); + args[key] = argv[index + 1]; + index += 1; + } + } + + if (!args.base || !args.head) { + throw new Error("Usage: sbom-component-diff --base --head [--markdown ] [--summary]"); + } + + return args; +} + +export function runCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const diff = diffSbomComponents(readJson(args.base), readJson(args.head)); + const markdown = renderSbomDiffMarkdown(diff); + + if (args.markdown) { + writeFileSync(args.markdown, markdown); + } else { + process.stdout.write(markdown); + } + + if (args.summary && process.env.GITHUB_STEP_SUMMARY) { + writeFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, { flag: "a" }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + runCli(); + } catch (error) { + console.error(`${basename(process.argv[1])}: ${error.message}`); + process.exit(1); + } +} diff --git a/tests/integration/sbomComponentDiff.test.ts b/tests/integration/sbomComponentDiff.test.ts new file mode 100644 index 00000000..3a2b89f9 --- /dev/null +++ b/tests/integration/sbomComponentDiff.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + SBOM_DIFF_COMMENT_MARKER, + diffSbomComponents, + renderSbomDiffMarkdown, +} from "../../scripts/sbom-component-diff.js"; + +describe("SBOM component diff", () => { + it("reports added and removed components by stable component identity", () => { + const baseBom = { + components: [ + { + type: "library", + name: "kept", + version: "1.0.0", + purl: "pkg:npm/kept@1.0.0", + }, + { + type: "library", + name: "removed", + version: "1.0.0", + purl: "pkg:npm/removed@1.0.0", + }, + ], + }; + const headBom = { + components: [ + { + type: "library", + name: "kept", + version: "1.0.0", + purl: "pkg:npm/kept@1.0.0", + }, + { + type: "library", + name: "added", + version: "2.0.0", + purl: "pkg:npm/added@2.0.0", + }, + ], + }; + + const diff = diffSbomComponents(baseBom, headBom); + + expect(diff.added.map((component) => component.label)).toEqual(["added@2.0.0"]); + expect(diff.removed.map((component) => component.label)).toEqual(["removed@1.0.0"]); + }); + + it("renders an updatable pull request comment body", () => { + const markdown = renderSbomDiffMarkdown({ + added: [ + { + key: "pkg:npm/added@2.0.0", + label: "added@2.0.0", + name: "added", + version: "2.0.0", + type: "library", + }, + ], + removed: [], + }); + + expect(markdown).toContain(SBOM_DIFF_COMMENT_MARKER); + expect(markdown).toContain("| 1 | 0 |"); + expect(markdown).toContain("`added@2.0.0`"); + expect(markdown).toContain("_None_"); + }); +});