ReviewOS

also looking at this

craft-native/craft

fix(security): validate vulnerability scans and pin scanner downloads

#300
Merged glennmichael123 wants to merge codex/failure-safe-vulnerability-scan into main
5 files +192 -56

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/VULNERABILITY_SCANNING.mdmodified+3-0
Changes to docs/VULNERABILITY_SCANNING.md
@@ -9,6 +9,9 @@ with valid database metadata. Missing output, malformed JSON, unexpected report
99shapes and scanner failures fail the job; none are interpreted as zero findings.
1010The text report and severity counts are derived from the same JSON scan.
1111Artifacts are retained on failure when the scanner produced diagnostic output.
12Each attempt removes previous JSON and text reports before validating its input.
13A failed rerun therefore cannot attach a previous successful summary alongside
14new diagnostics. Keep the input SBOM separate from the scanner's output files.
1215
1316The existing severity policy is unchanged: Critical findings fail the check;
1417High findings emit an explicit warning and remain release-review items. A green
scripts/scan-sbom.test.tsmodified+27-1
Changes to scripts/scan-sbom.test.ts
@@ -1,5 +1,5 @@
11import { afterEach, expect, test } from 'bun:test'
2import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import { scanSbom, summarizeVulnerabilities } from './scan-sbom'
@@ -32,14 +32,18 @@ function fixture(source: string) {
3232test('scanner execution failures cannot pass even with an old valid report', () => {
3333 const f = fixture('process.exit(7)')
3434 writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report()))
35 writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary')
3536 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('exit code 7')
37 expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false)
3638})
3739
3840test('successful exit without valid output cannot reuse stale or malformed reports', () => {
3941 for (const source of ['process.exit(0)', 'await Bun.write(process.argv[process.argv.indexOf("--file") + 1], "not json")', 'await Bun.write(process.argv[process.argv.indexOf("--file") + 1], "{}")']) {
4042 const f = fixture(source)
4143 writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report()))
44 writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary')
4245 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow()
46 expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false)
4347 }
4448})
4549
@@ -49,4 +53,26 @@ test('valid scanner output produces a matching text report; empty SBOMs are refu
4953 expect(readFileSync(join(f.root, 'vulnerabilities.txt'), 'utf8')).toContain('High\tfixture')
5054 writeFileSync(f.sbom, JSON.stringify({ bomFormat: 'CycloneDX', components: [] }))
5155 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('populated CycloneDX')
56 expect(existsSync(join(f.root, 'vulnerabilities.json'))).toBe(false)
57 expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false)
58})
59
60test('invalid input clears previous reports before failing', () => {
61 const f = fixture('throw new Error("scanner must not run")')
62 for (const input of ['{', '{}']) {
63 writeFileSync(f.sbom, input)
64 for (const name of ['vulnerabilities.json', 'vulnerabilities.txt']) writeFileSync(join(f.root, name), 'stale report')
65 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow()
66 expect(existsSync(join(f.root, 'vulnerabilities.json'))).toBe(false)
67 expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false)
68 }
69})
70
71test('refuses an input that aliases a scanner output without deleting it', () => {
72 const f = fixture('process.exit(0)')
73 const input = join(f.root, 'vulnerabilities.json')
74 const content = readFileSync(f.sbom, 'utf8')
75 writeFileSync(input, content)
76 expect(() => scanSbom(input, f.root, f.executable)).toThrow('must not be a scanner output')
77 expect(readFileSync(input, 'utf8')).toBe(content)
5278})
scripts/scan-sbom.tsmodified+9-4
Changes to scripts/scan-sbom.ts
@@ -1,5 +1,5 @@
11import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
2import { resolve } from 'node:path'
33
44const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const
55type Severity = typeof severities[number]
@@ -28,18 +28,23 @@ export function summarizeVulnerabilities(report: unknown) {
2828}
2929
3030export function scanSbom(sbom: string, output: string, executable = 'grype') {
31 const reportPath = resolve(output, 'vulnerabilities.json')
32 const textPath = resolve(output, 'vulnerabilities.txt')
33 if ([reportPath, textPath].includes(resolve(sbom)))
34 throw new Error('SBOM input must not be a scanner output file')
35 // Clear both previous outputs even when the new input fails validation.
36 // Failed runs may retain fresh scanner diagnostics, never an old clean summary.
37 for (const path of [reportPath, textPath]) rmSync(path, { force: true })
3138 // Validate input before invoking the scanner. Missing inventory must not look clean.
3239 const inventory = JSON.parse(readFileSync(sbom, 'utf8'))
3340 if (inventory.bomFormat !== 'CycloneDX' || !Array.isArray(inventory.components) || !inventory.components.length)
3441 throw new Error('A populated CycloneDX SBOM is required for vulnerability scanning')
3542 mkdirSync(output, { recursive: true })
36 const reportPath = join(output, 'vulnerabilities.json')
37 rmSync(reportPath, { force: true })
3843 const scan = Bun.spawnSync([executable, `sbom:${resolve(sbom)}`, '--output', 'json', '--file', reportPath], { stdout: 'inherit', stderr: 'inherit' })
3944 if (scan.exitCode !== 0)
4045 throw new Error(`Grype failed with exit code ${scan.exitCode}; vulnerability counts are unavailable`)
4146 const summary = summarizeVulnerabilities(JSON.parse(readFileSync(reportPath, 'utf8')))
42 writeFileSync(join(output, 'vulnerabilities.txt'), summary.text)
47 writeFileSync(textPath, summary.text)
4348 return summary
4449}
4550