ReviewOS

also looking at this

craft-native/craft

Gate SBOM findings on whether they ship

#321
Merged glennmichael123 wants to merge fix/sbom-shipping-scope into main
5 files +230 -1
.github/workflows/ci.ymlmodified+1-1
Changes to .github/workflows/ci.yml
@@ -283,7 +283,7 @@ jobs:
283283 run: zig fmt --check src/ build.zig
284284
285285 - 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
287287
288288 - name: Audit Zig C imports
289289 run: bun run verify:cimports
.github/workflows/sbom.ymlmodified+6-0
Changes to .github/workflows/sbom.yml
@@ -129,6 +129,12 @@ jobs:
129129 env:
130130 CRAFT_FAIL_HIGH: ${{ inputs.enforce_high || false }}
131131
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
132138 - name: Upload scan results
133139 if: ${{ always() }}
134140 uses: actions/upload-artifact@v4
docs/VULNERABILITY_SCANNING.mdmodified+26-0
Changes to docs/VULNERABILITY_SCANNING.md
@@ -25,6 +25,32 @@ advisories. Native binary scanning records exact hashes and detected components,
2525but static Zig code may not be identifiable from the binary alone; retain the
2626full-source review.
2727
28## Shipping scope for SBOM findings
29
30The full-repository scan inventories the whole checkout, which includes the benchmark
31comparison apps and the development install. Every High finding in it today originates
32there: the Electron and Electrobun benchmark lockfiles, and the Go runtime compiled
33into the TypeScript compiler binary under `node_modules`.
34
35`scripts/sbom-scope.ts` decides by location. A High or Critical finding whose every
36recorded path lies under a prefix that publishes nothing — `/benchmarks/`,
37`/node_modules/`, `/examples/`, `/artifacts/`, `/.github/` — is tooling inventory, and
38is reported with its package list. Anything else fails the scan, including a finding
39with no recorded location, since an unlocatable High deserves review rather than a
40pass. A package appearing both in a benchmark and in Craft counts as Craft's.
41
42This runs unconditionally, unlike the `enforce_high` input, which remains available as
43a stricter opt-in that fails on any High regardless of where it sits.
44
45A path rule rather than a list of retained findings is deliberate: benchmark lockfiles
46and `node_modules` versions change constantly, so entries pinned to a package and
47version would go stale within days and the check guarding them would fire on every
48benchmark bump. Lockfile advisories are different — they are few and slow-moving — and
49they do use reviewed retentions, below.
50
51The shipped half of the division is `scripts/scan-release-artifacts.ts`, which scans
52the exact release archives with their resolved production dependencies.
53
2854## Reviewed advisory retention
2955
3056Lockfile advisories are decided rather than counted. `scripts/advisory-policy.ts`
scripts/sbom-scope.test.tsadded+62-0
Changes to scripts/sbom-scope.test.ts
@@ -0,0 +1,62 @@
1import { expect, test } from 'bun:test'
2import { assertShippingScope, collectFindings, isToolingOnly, reviewScope } from './sbom-scope'
3
4const 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
9test('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
15test('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
20test('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
25test('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
30test('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
36test('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
45test('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
51test('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
58test('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})
scripts/sbom-scope.tsadded+135-0
Changes to scripts/sbom-scope.ts
@@ -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. */
31export const toolingPrefixes = [
32 '/benchmarks/',
33 '/node_modules/',
34 '/examples/',
35 '/artifacts/',
36 '/.github/',
37]
38
39export type Finding = {
40 package: string
41 version: string
42 severity: string
43 id: string
44 locations: string[]
45}
46
47const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const
48
49/** Severities Craft answers for. Below these, the report is inventory either way. */
50const 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 */
58export 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 */
92export 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
97export 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
104export 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
114export 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
125if (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}