also looking at this
Gate SBOM findings on whether they ship
#321
5 files
+230
-1
| @@ -283,7 +283,7 @@ jobs: | ||
| 283 | 283 | run: zig fmt --check src/ build.zig |
| 284 | 284 | |
| 285 | 285 | - name: Test failure-safe vulnerability scanning |
| 286 | run: bun test scripts/scan-sbom.test.ts scripts/scan-release-artifacts.test.ts | |
| 286 | run: bun test scripts/scan-sbom.test.ts scripts/scan-release-artifacts.test.ts scripts/sbom-scope.test.ts | |
| 287 | 287 | |
| 288 | 288 | - name: Audit Zig C imports |
| 289 | 289 | run: bun run verify:cimports |
| @@ -129,6 +129,12 @@ jobs: | ||
| 129 | 129 | env: |
| 130 | 130 | CRAFT_FAIL_HIGH: ${{ inputs.enforce_high || false }} |
| 131 | 131 | |
| 132 | # Always on, unlike enforce_high. Fails when a High or Critical finding is | |
| 133 | # not confined to paths that publish nothing, and prints the tooling | |
| 134 | # inventory otherwise so the count stays visible rather than silent. | |
| 135 | - name: Require High findings to be tooling-only | |
| 136 | run: bun scripts/sbom-scope.ts scan-results/vulnerabilities.json | |
| 137 | ||
| 132 | 138 | - name: Upload scan results |
| 133 | 139 | if: ${{ always() }} |
| 134 | 140 | uses: actions/upload-artifact@v4 |
| @@ -25,6 +25,32 @@ advisories. Native binary scanning records exact hashes and detected components, | ||
| 25 | 25 | but static Zig code may not be identifiable from the binary alone; retain the |
| 26 | 26 | full-source review. |
| 27 | 27 | |
| 28 | ## Shipping scope for SBOM findings | |
| 29 | ||
| 30 | The full-repository scan inventories the whole checkout, which includes the benchmark | |
| 31 | comparison apps and the development install. Every High finding in it today originates | |
| 32 | there: the Electron and Electrobun benchmark lockfiles, and the Go runtime compiled | |
| 33 | into the TypeScript compiler binary under `node_modules`. | |
| 34 | ||
| 35 | `scripts/sbom-scope.ts` decides by location. A High or Critical finding whose every | |
| 36 | recorded path lies under a prefix that publishes nothing — `/benchmarks/`, | |
| 37 | `/node_modules/`, `/examples/`, `/artifacts/`, `/.github/` — is tooling inventory, and | |
| 38 | is reported with its package list. Anything else fails the scan, including a finding | |
| 39 | with no recorded location, since an unlocatable High deserves review rather than a | |
| 40 | pass. A package appearing both in a benchmark and in Craft counts as Craft's. | |
| 41 | ||
| 42 | This runs unconditionally, unlike the `enforce_high` input, which remains available as | |
| 43 | a stricter opt-in that fails on any High regardless of where it sits. | |
| 44 | ||
| 45 | A path rule rather than a list of retained findings is deliberate: benchmark lockfiles | |
| 46 | and `node_modules` versions change constantly, so entries pinned to a package and | |
| 47 | version would go stale within days and the check guarding them would fire on every | |
| 48 | benchmark bump. Lockfile advisories are different — they are few and slow-moving — and | |
| 49 | they do use reviewed retentions, below. | |
| 50 | ||
| 51 | The shipped half of the division is `scripts/scan-release-artifacts.ts`, which scans | |
| 52 | the exact release archives with their resolved production dependencies. | |
| 53 | ||
| 28 | 54 | ## Reviewed advisory retention |
| 29 | 55 | |
| 30 | 56 | Lockfile advisories are decided rather than counted. `scripts/advisory-policy.ts` |
| @@ -0,0 +1,62 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { assertShippingScope, collectFindings, isToolingOnly, reviewScope } from './sbom-scope' | |
| 3 | ||
| 4 | const match = (name: string, severity: string, paths: string[], id = 'GHSA-test') => ({ | |
| 5 | vulnerability: { id, severity }, | |
| 6 | artifact: { name, version: '1.0.0', locations: paths.map(path => ({ path })) }, | |
| 7 | }) | |
| 8 | ||
| 9 | test('a benchmark-only High is inventory, not a failure', () => { | |
| 10 | const review = assertShippingScope({ matches: [match('electron', 'High', ['/benchmarks/apps/electron/bun.lock'])] }) | |
| 11 | expect(review.tooling).toHaveLength(1) | |
| 12 | expect(review.shipped).toHaveLength(0) | |
| 13 | }) | |
| 14 | ||
| 15 | test('the Go runtime inside the compiler binary is inventory', () => { | |
| 16 | const review = assertShippingScope({ matches: [match('stdlib', 'High', ['/node_modules/@typescript/typescript-linux-x64/lib/tsc'], 'GO-2026-5026')] }) | |
| 17 | expect(review.tooling).toHaveLength(1) | |
| 18 | }) | |
| 19 | ||
| 20 | test('a High in Craft source fails, and the message locates it', () => { | |
| 21 | expect(() => assertShippingScope({ matches: [match('someLib', 'High', ['/packages/typescript/package.json'])] })) | |
| 22 | .toThrow('/packages/typescript/package.json') | |
| 23 | }) | |
| 24 | ||
| 25 | test('a package in both a benchmark and Craft is Craft\'s problem', () => { | |
| 26 | expect(() => assertShippingScope({ matches: [match('minimatch', 'High', ['/benchmarks/apps/electron/bun.lock', '/packages/zig/build.zig.zon'])] })) | |
| 27 | .toThrow('not confined to tooling paths') | |
| 28 | }) | |
| 29 | ||
| 30 | test('an unlocatable High is never waved through', () => { | |
| 31 | expect(isToolingOnly({ package: 'x', version: '1', severity: 'High', id: 'y', locations: [] })).toBe(false) | |
| 32 | expect(() => assertShippingScope({ matches: [match('mystery', 'High', [])] })) | |
| 33 | .toThrow('no recorded location') | |
| 34 | }) | |
| 35 | ||
| 36 | test('Medium and below are inventory wherever they sit', () => { | |
| 37 | const review = reviewScope(collectFindings({ matches: [ | |
| 38 | match('lodash', 'Medium', ['/packages/typescript/package.json']), | |
| 39 | match('lodash', 'Low', ['/packages/typescript/package.json']), | |
| 40 | ] })) | |
| 41 | expect(review.shipped).toHaveLength(0) | |
| 42 | expect(review.tooling).toHaveLength(0) | |
| 43 | }) | |
| 44 | ||
| 45 | test('Critical is gated exactly like High', () => { | |
| 46 | expect(() => assertShippingScope({ matches: [match('bad', 'Critical', ['/packages/zig/src/main.zig'])] })) | |
| 47 | .toThrow('High or Critical') | |
| 48 | expect(assertShippingScope({ matches: [match('bad', 'Critical', ['/benchmarks/x/bun.lock'])] }).tooling).toHaveLength(1) | |
| 49 | }) | |
| 50 | ||
| 51 | test('a missing or malformed report is never a clean scan', () => { | |
| 52 | expect(() => collectFindings(null)).toThrow('expected an object') | |
| 53 | expect(() => collectFindings({})).toThrow('absent results are not a clean scan') | |
| 54 | expect(() => collectFindings({ matches: [{ vulnerability: { id: 'x', severity: 'Spicy' }, artifact: { name: 'a' } }] })).toThrow('Invalid Grype severity') | |
| 55 | expect(() => collectFindings({ matches: [{ vulnerability: { severity: 'High' }, artifact: { name: 'a' } }] })).toThrow('vulnerability id and package name are required') | |
| 56 | }) | |
| 57 | ||
| 58 | test('an empty report passes and reports nothing', () => { | |
| 59 | const review = assertShippingScope({ matches: [] }) | |
| 60 | expect(review.tooling).toHaveLength(0) | |
| 61 | expect(review.shipped).toHaveLength(0) | |
| 62 | }) | |
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * Decides which vulnerability findings are Craft's to answer for. | |
| 3 | * | |
| 4 | * The full-repository SBOM covers the checkout, which means it inventories the | |
| 5 | * benchmark comparison apps and the development install as well as Craft. Every | |
| 6 | * High finding in it today comes from one of those: the Electron and Electrobun | |
| 7 | * benchmark lockfiles, and the Go runtime compiled into the TypeScript compiler | |
| 8 | * binary under `node_modules`. None reaches a published artifact. | |
| 9 | * | |
| 10 | * Counting them together produced the worst of both readings. The scan could not | |
| 11 | * fail on High without going permanently red over a benchmark's dependencies, so | |
| 12 | * High was left behind an opt-in flag and the job stayed green no matter what | |
| 13 | * arrived — the state #279 describes. | |
| 14 | * | |
| 15 | * Scope is the thing that separates them, so scope is what this decides. A | |
| 16 | * finding located only under a path that ships nothing is tooling inventory and | |
| 17 | * is reported. Anything else is Craft's, and fails. | |
| 18 | * | |
| 19 | * Deliberately not a retention list. Benchmark lockfiles and `node_modules` | |
| 20 | * versions move constantly, so a list of package-and-version entries would be | |
| 21 | * stale within days and its own ratchet would cry wolf every time a benchmark | |
| 22 | * dependency bumped. A path rule stays true across those bumps. | |
| 23 | * | |
| 24 | * The shipped side has its own gate: `scripts/scan-release-artifacts.ts` scans | |
| 25 | * the exact release archives with their resolved production dependencies. This | |
| 26 | * is the repository-wide half, and the two together are the division of labour | |
| 27 | * #279 asked for. | |
| 28 | */ | |
| 29 | ||
| 30 | /** Prefixes that ship nothing: a finding located only here is inventory. */ | |
| 31 | export const toolingPrefixes = [ | |
| 32 | '/benchmarks/', | |
| 33 | '/node_modules/', | |
| 34 | '/examples/', | |
| 35 | '/artifacts/', | |
| 36 | '/.github/', | |
| 37 | ] | |
| 38 | ||
| 39 | export type Finding = { | |
| 40 | package: string | |
| 41 | version: string | |
| 42 | severity: string | |
| 43 | id: string | |
| 44 | locations: string[] | |
| 45 | } | |
| 46 | ||
| 47 | const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const | |
| 48 | ||
| 49 | /** Severities Craft answers for. Below these, the report is inventory either way. */ | |
| 50 | const gated = ['Critical', 'High'] | |
| 51 | ||
| 52 | /** | |
| 53 | * Flatten a Grype report. | |
| 54 | * | |
| 55 | * Shares `scan-sbom.ts`'s refusal to read an absent or malformed result as a | |
| 56 | * clean scan: a scanner that fell over must not look like good news. | |
| 57 | */ | |
| 58 | export function collectFindings(report: unknown): Finding[] { | |
| 59 | if (!report || typeof report !== 'object' || Array.isArray(report)) | |
| 60 | throw new Error('Invalid Grype report: expected an object') | |
| 61 | const matches = (report as { matches?: unknown }).matches | |
| 62 | if (!Array.isArray(matches)) | |
| 63 | throw new Error('Invalid Grype report: matches must be an array; absent results are not a clean scan') | |
| 64 | return matches.map((match) => { | |
| 65 | const vulnerability = (match as { vulnerability?: Record<string, unknown> })?.vulnerability ?? {} | |
| 66 | const artifact = (match as { artifact?: Record<string, unknown> })?.artifact ?? {} | |
| 67 | const severity = vulnerability.severity | |
| 68 | if (typeof severity !== 'string' || !severities.includes(severity as typeof severities[number])) | |
| 69 | throw new Error(`Invalid Grype severity: ${JSON.stringify(severity)}`) | |
| 70 | if (typeof vulnerability.id !== 'string' || typeof artifact.name !== 'string') | |
| 71 | throw new Error('Malformed Grype match: vulnerability id and package name are required') | |
| 72 | const locations = Array.isArray(artifact.locations) | |
| 73 | ? artifact.locations.map(entry => (entry as { path?: unknown })?.path).filter((path): path is string => typeof path === 'string') | |
| 74 | : [] | |
| 75 | return { | |
| 76 | package: artifact.name, | |
| 77 | version: typeof artifact.version === 'string' ? artifact.version : '', | |
| 78 | severity, | |
| 79 | id: vulnerability.id, | |
| 80 | locations, | |
| 81 | } | |
| 82 | }) | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * True when every location is under a prefix that ships nothing. | |
| 87 | * | |
| 88 | * "Every", not "any": a package present both in a benchmark and in Craft is | |
| 89 | * Craft's problem. A finding with no location at all is not classifiable and so | |
| 90 | * is never tooling — an unlocatable High should be looked at, not waved through. | |
| 91 | */ | |
| 92 | export function isToolingOnly(finding: Finding): boolean { | |
| 93 | if (!finding.locations.length) return false | |
| 94 | return finding.locations.every(path => toolingPrefixes.some(prefix => path.startsWith(prefix))) | |
| 95 | } | |
| 96 | ||
| 97 | export type ScopeReview = { | |
| 98 | /** Gated severities that reach a published artifact, or cannot be located. */ | |
| 99 | shipped: Finding[] | |
| 100 | /** Gated severities confined to paths that ship nothing. */ | |
| 101 | tooling: Finding[] | |
| 102 | } | |
| 103 | ||
| 104 | export function reviewScope(findings: Finding[]): ScopeReview { | |
| 105 | const review: ScopeReview = { shipped: [], tooling: [] } | |
| 106 | for (const finding of findings) { | |
| 107 | if (!gated.includes(finding.severity)) continue | |
| 108 | if (isToolingOnly(finding)) review.tooling.push(finding) | |
| 109 | else review.shipped.push(finding) | |
| 110 | } | |
| 111 | return review | |
| 112 | } | |
| 113 | ||
| 114 | export function assertShippingScope(report: unknown): ScopeReview { | |
| 115 | const review = reviewScope(collectFindings(report)) | |
| 116 | if (review.shipped.length) { | |
| 117 | const detail = review.shipped | |
| 118 | .map(f => `${f.severity} ${f.package}@${f.version} ${f.id} (${f.locations.join(', ') || 'no recorded location'})`) | |
| 119 | .join('\n - ') | |
| 120 | throw new Error(`${review.shipped.length} High or Critical findings are not confined to tooling paths:\n - ${detail}`) | |
| 121 | } | |
| 122 | return review | |
| 123 | } | |
| 124 | ||
| 125 | if (import.meta.main) { | |
| 126 | const [path] = Bun.argv.slice(2) | |
| 127 | if (!path) | |
| 128 | throw new Error('Usage: bun scripts/sbom-scope.ts <grype-report.json>') | |
| 129 | const review = assertShippingScope(await Bun.file(path).json()) | |
| 130 | const byPackage = new Map<string, number>() | |
| 131 | for (const finding of review.tooling) | |
| 132 | byPackage.set(`${finding.package}@${finding.version}`, (byPackage.get(`${finding.package}@${finding.version}`) ?? 0) + 1) | |
| 133 | const inventory = [...byPackage].map(([name, count]) => `${name} (${count})`).join(', ') | |
| 134 | console.log(`No High or Critical finding reaches a published artifact. Tooling inventory: ${review.tooling.length} findings across ${byPackage.size} packages${inventory ? ` — ${inventory}` : ''}`) | |
| 135 | } | |