also looking at this
fix(security): validate vulnerability scans and pin scanner downloads
#300
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.
| @@ -9,6 +9,9 @@ with valid database metadata. Missing output, malformed JSON, unexpected report | ||
| 9 | 9 | shapes and scanner failures fail the job; none are interpreted as zero findings. |
| 10 | 10 | The text report and severity counts are derived from the same JSON scan. |
| 11 | 11 | Artifacts are retained on failure when the scanner produced diagnostic output. |
| 12 | Each attempt removes previous JSON and text reports before validating its input. | |
| 13 | A failed rerun therefore cannot attach a previous successful summary alongside | |
| 14 | new diagnostics. Keep the input SBOM separate from the scanner's output files. | |
| 12 | 15 | |
| 13 | 16 | The existing severity policy is unchanged: Critical findings fail the check; |
| 14 | 17 | High findings emit an explicit warning and remain release-review items. A green |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { afterEach, expect, test } from 'bun:test' |
| 2 | import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import { scanSbom, summarizeVulnerabilities } from './scan-sbom' |
| @@ -32,14 +32,18 @@ function fixture(source: string) { | ||
| 32 | 32 | test('scanner execution failures cannot pass even with an old valid report', () => { |
| 33 | 33 | const f = fixture('process.exit(7)') |
| 34 | 34 | writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report())) |
| 35 | writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary') | |
| 35 | 36 | expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('exit code 7') |
| 37 | expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false) | |
| 36 | 38 | }) |
| 37 | 39 | |
| 38 | 40 | test('successful exit without valid output cannot reuse stale or malformed reports', () => { |
| 39 | 41 | 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], "{}")']) { |
| 40 | 42 | const f = fixture(source) |
| 41 | 43 | writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report())) |
| 44 | writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary') | |
| 42 | 45 | expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow() |
| 46 | expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false) | |
| 43 | 47 | } |
| 44 | 48 | }) |
| 45 | 49 | |
| @@ -49,4 +53,26 @@ test('valid scanner output produces a matching text report; empty SBOMs are refu | ||
| 49 | 53 | expect(readFileSync(join(f.root, 'vulnerabilities.txt'), 'utf8')).toContain('High\tfixture') |
| 50 | 54 | writeFileSync(f.sbom, JSON.stringify({ bomFormat: 'CycloneDX', components: [] })) |
| 51 | 55 | 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 | ||
| 60 | test('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 | ||
| 71 | test('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) | |
| 52 | 78 | }) |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 2 | import { join, resolve } from 'node:path' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | 3 | |
| 4 | 4 | const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const |
| 5 | 5 | type Severity = typeof severities[number] |
| @@ -28,18 +28,23 @@ export function summarizeVulnerabilities(report: unknown) { | ||
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | export 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 }) | |
| 31 | 38 | // Validate input before invoking the scanner. Missing inventory must not look clean. |
| 32 | 39 | const inventory = JSON.parse(readFileSync(sbom, 'utf8')) |
| 33 | 40 | if (inventory.bomFormat !== 'CycloneDX' || !Array.isArray(inventory.components) || !inventory.components.length) |
| 34 | 41 | throw new Error('A populated CycloneDX SBOM is required for vulnerability scanning') |
| 35 | 42 | mkdirSync(output, { recursive: true }) |
| 36 | const reportPath = join(output, 'vulnerabilities.json') | |
| 37 | rmSync(reportPath, { force: true }) | |
| 38 | 43 | const scan = Bun.spawnSync([executable, `sbom:${resolve(sbom)}`, '--output', 'json', '--file', reportPath], { stdout: 'inherit', stderr: 'inherit' }) |
| 39 | 44 | if (scan.exitCode !== 0) |
| 40 | 45 | throw new Error(`Grype failed with exit code ${scan.exitCode}; vulnerability counts are unavailable`) |
| 41 | 46 | const summary = summarizeVulnerabilities(JSON.parse(readFileSync(reportPath, 'utf8'))) |
| 42 | writeFileSync(join(output, 'vulnerabilities.txt'), summary.text) | |
| 47 | writeFileSync(textPath, summary.text) | |
| 43 | 48 | return summary |
| 44 | 49 | } |
| 45 | 50 | |