Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/sbom-diff.yml
Original file line number Diff line number Diff line change
@@ -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,
});
}
12 changes: 12 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 150 additions & 0 deletions scripts/sbom-component-diff.js
Original file line number Diff line number Diff line change
@@ -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 = "<!-- credence-sbom-component-diff -->";

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 <base-sbom.json> --head <head-sbom.json> [--markdown <out.md>] [--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);
}
}
68 changes: 68 additions & 0 deletions tests/integration/sbomComponentDiff.test.ts
Original file line number Diff line number Diff line change
@@ -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_");
});
});