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.

.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -271,6 +271,9 @@ jobs:
271271 working-directory: packages/zig
272272 run: zig fmt --check src/ build.zig
273273
274 - name: Test failure-safe vulnerability scanning
275 run: bun test scripts/scan-sbom.test.ts
276
274277 - name: Audit Zig C imports
275278 run: bun run verify:cimports
276279
.github/workflows/sbom.ymlmodified+21-56
Changes to .github/workflows/sbom.yml
@@ -33,8 +33,13 @@ jobs:
3333
3434 - name: Install SBOM tools
3535 run: |
36 # Install Syft for comprehensive SBOM (works with bun.lock)
37 curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
36 set -euo pipefail
37 mkdir -p "$RUNNER_TEMP/syft"
38 cd "$RUNNER_TEMP/syft"
39 curl --fail --location --retry 3 --output syft.tar.gz https://github.com/anchore/syft/releases/download/v1.52.0/syft_1.52.0_linux_amd64.tar.gz
40 echo 'caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d syft.tar.gz' | sha256sum --check --strict
41 tar -xzf syft.tar.gz syft
42 echo "$RUNNER_TEMP/syft" >> "$GITHUB_PATH"
3843
3944 - name: Generate comprehensive SBOM (Syft)
4045 run: |
@@ -228,11 +233,13 @@ jobs:
228233 output-file: sbom/github-sbom.json
229234
230235 vulnerability-scan:
231 # name: SBOM Vulnerability Scan
232236 needs: generate
233237 runs-on: ubuntu-latest
234238
235239 steps:
240 - uses: actions/checkout@v6
241 - uses: oven-sh/setup-bun@v2
242
236243 - name: Download SBOM
237244 uses: actions/download-artifact@v4
238245 with:
@@ -241,64 +248,22 @@ jobs:
241248
242249 - name: Install Grype
243250 run: |
244 curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
251 set -euo pipefail
252 mkdir -p "$RUNNER_TEMP/grype"
253 cd "$RUNNER_TEMP/grype"
254 curl --fail --location --retry 3 --output grype.tar.gz https://github.com/anchore/grype/releases/download/v0.119.0/grype_0.119.0_linux_amd64.tar.gz
255 echo '3fa2dc4b924621ab65404cf08d0b8438d896d80ab949c9d5a4ca283c36004c9b grype.tar.gz' | sha256sum --check --strict
256 tar -xzf grype.tar.gz grype
257 echo "$RUNNER_TEMP/grype" >> "$GITHUB_PATH"
245258
246 - name: Scan SBOM for vulnerabilities
247 id: scan
248 run: |
249 mkdir -p scan-results
250
251 # Scan the full SBOM
252 if [ -f "sbom/full-sbom.cyclonedx.json" ]; then
253 grype sbom:sbom/full-sbom.cyclonedx.json \
254 --output json \
255 --file scan-results/vulnerabilities.json \
256 || true
257
258 grype sbom:sbom/full-sbom.cyclonedx.json \
259 --output table \
260 --file scan-results/vulnerabilities.txt \
261 || true
262 fi
263
264 # Count vulnerabilities by severity
265 if [ -f "scan-results/vulnerabilities.json" ]; then
266 CRITICAL=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' scan-results/vulnerabilities.json 2>/dev/null || echo "0")
267 HIGH=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' scan-results/vulnerabilities.json 2>/dev/null || echo "0")
268 MEDIUM=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' scan-results/vulnerabilities.json 2>/dev/null || echo "0")
269 LOW=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' scan-results/vulnerabilities.json 2>/dev/null || echo "0")
270
271 echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
272 echo "high=$HIGH" >> $GITHUB_OUTPUT
273 echo "medium=$MEDIUM" >> $GITHUB_OUTPUT
274 echo "low=$LOW" >> $GITHUB_OUTPUT
275 fi
276
277 - name: Generate vulnerability report
278 run: |
279 echo "## Vulnerability Scan Results" >> $GITHUB_STEP_SUMMARY
280 echo "" >> $GITHUB_STEP_SUMMARY
281 echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY
282 echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
283 echo "| Critical | $ |" >> $GITHUB_STEP_SUMMARY
284 echo "| High | $ |" >> $GITHUB_STEP_SUMMARY
285 echo "| Medium | $ |" >> $GITHUB_STEP_SUMMARY
286 echo "| Low | $ |" >> $GITHUB_STEP_SUMMARY
287
288 if [ "$" -gt 0 ] || [ "$" -gt 0 ]; then
289 echo "" >> $GITHUB_STEP_SUMMARY
290 echo "⚠️ **Action Required:** Critical or High severity vulnerabilities found!" >> $GITHUB_STEP_SUMMARY
291 fi
259 - name: Scan and validate vulnerability report
260 run: bun scripts/scan-sbom.ts sbom/full-sbom.cyclonedx.json scan-results
292261
293262 - name: Upload scan results
263 if: ${{ always() }}
294264 uses: actions/upload-artifact@v4
295265 with:
296266 name: vulnerability-scan
297267 path: scan-results/
268 if-no-files-found: warn
298269 retention-days: 90
299
300 - name: Fail on critical vulnerabilities
301 if: steps.scan.outputs.critical > 0
302 run: |
303 echo "::error::Critical vulnerabilities found in dependencies!"
304 exit 1
docs/VULNERABILITY_SCANNING.mdadded+23-0
Changes to docs/VULNERABILITY_SCANNING.md
@@ -0,0 +1,23 @@
1# Vulnerability scan interpretation
2
3The SBOM workflow scans the repository inventory, including development tools,
4benchmark comparison applications and CI dependencies. It is not a runtime-only
5inventory and a match does not by itself establish reachability in a shipped app.
6
7The scanner must complete successfully and produce a populated-inventory report
8with valid database metadata. Missing output, malformed JSON, unexpected report
9shapes and scanner failures fail the job; none are interpreted as zero findings.
10The text report and severity counts are derived from the same JSON scan.
11Artifacts are retained on failure when the scanner produced diagnostic output.
12
13The existing severity policy is unchanged: Critical findings fail the check;
14High findings emit an explicit warning and remain release-review items. A green
15scan is **not** release approval. Issue [#279](https://github.com/craft-native/craft/issues/279)
16tracks the reviewed High/Critical policy and shipping-scope analysis; issue
17[#276](https://github.com/craft-native/craft/issues/276) tracks locked dependency
18advisories. No baseline or vulnerability exemption is added by this workflow.
19
20Syft and Grype Linux x64 downloads are pinned by release version and SHA-256 in
21`.github/workflows/sbom.yml`. Tool updates must update both the asset URL and its
22digest using the upstream release metadata, then run the scanner regression
23tests and a real workflow scan. Mutable installer scripts are not executed.
scripts/scan-sbom.test.tsadded+52-0
Changes to scripts/scan-sbom.test.ts
@@ -0,0 +1,52 @@
1import { afterEach, expect, test } from 'bun:test'
2import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { scanSbom, summarizeVulnerabilities } from './scan-sbom'
6
7const roots: string[] = []
8afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
9const report = (matches: unknown[] = []) => ({ descriptor: { name: 'grype', version: '0.119.0', db: { status: { valid: true } } }, matches })
10const match = (severity: string) => ({ vulnerability: { severity, id: 'CVE-2026-1234' }, artifact: { name: 'fixture', version: '1.0.0' } })
11
12test('counts all supported severities without mistaking absent results for zero', () => {
13 expect(summarizeVulnerabilities(report()).counts.Critical).toBe(0)
14 const result = summarizeVulnerabilities(report(['Critical', 'High', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'].map(match)))
15 expect(result.counts).toEqual({ Critical: 1, High: 2, Medium: 1, Low: 1, Negligible: 1, Unknown: 1 })
16 expect(result.text).toContain('High\tfixture\t1.0.0\tCVE-2026-1234')
17 for (const invalid of [null, {}, { matches: [] }, { ...report(), matches: null }, { ...report(), descriptor: { name: 'grype', version: '0.119.0' } }, report([{}]), report([match('new severity')])])
18 expect(() => summarizeVulnerabilities(invalid)).toThrow()
19})
20
21function fixture(source: string) {
22 const root = mkdtempSync(join(tmpdir(), 'craft-grype-test-'))
23 roots.push(root)
24 const executable = join(root, 'grype')
25 const sbom = join(root, 'sbom.json')
26 writeFileSync(sbom, JSON.stringify({ bomFormat: 'CycloneDX', components: [{ name: 'fixture' }] }))
27 writeFileSync(executable, `#!${process.execPath}\n${source}\n`)
28 chmodSync(executable, 0o755)
29 return { root, executable, sbom }
30}
31
32test('scanner execution failures cannot pass even with an old valid report', () => {
33 const f = fixture('process.exit(7)')
34 writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report()))
35 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('exit code 7')
36})
37
38test('successful exit without valid output cannot reuse stale or malformed reports', () => {
39 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 const f = fixture(source)
41 writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report()))
42 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow()
43 }
44})
45
46test('valid scanner output produces a matching text report; empty SBOMs are refused', () => {
47 const f = fixture(`await Bun.write(process.argv[process.argv.indexOf('--file') + 1], ${JSON.stringify(JSON.stringify(report([match('High')])))});`)
48 expect(scanSbom(f.sbom, f.root, f.executable).counts.High).toBe(1)
49 expect(readFileSync(join(f.root, 'vulnerabilities.txt'), 'utf8')).toContain('High\tfixture')
50 writeFileSync(f.sbom, JSON.stringify({ bomFormat: 'CycloneDX', components: [] }))
51 expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('populated CycloneDX')
52})
scripts/scan-sbom.tsadded+59-0
Changes to scripts/scan-sbom.ts
@@ -0,0 +1,59 @@
1import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
3
4const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const
5type Severity = typeof severities[number]
6
7export function summarizeVulnerabilities(report: unknown) {
8 const document = report as any
9 if (document?.descriptor?.name !== 'grype' || typeof document.descriptor.version !== 'string' || !document.descriptor.version.trim())
10 throw new Error('Missing Grype scanner identity')
11 if (document.descriptor.db?.status?.valid !== true)
12 throw new Error('Grype vulnerability database is missing or invalid')
13 if (!Array.isArray(document.matches))
14 throw new Error('Grype report must contain a matches array; absent results are not a clean scan')
15 const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 }
16 const lines = ['Severity\tPackage\tVersion\tVulnerability']
17 for (const match of document.matches) {
18 const severity = match?.vulnerability?.severity as Severity
19 if (!severities.includes(severity))
20 throw new Error(`Unknown vulnerability severity: ${JSON.stringify(severity)}`)
21 const fields = [severity, match?.artifact?.name, match?.artifact?.version, match?.vulnerability?.id]
22 if (fields.some(value => typeof value !== 'string' || !value.trim()))
23 throw new Error('Malformed Grype match: package name/version and vulnerability id are required')
24 counts[severity]++
25 lines.push(fields.map(value => value.replace(/[\r\n\t]/g, ' ')).join('\t'))
26 }
27 return { counts, text: `${lines.join('\n')}\n` }
28}
29
30export function scanSbom(sbom: string, output: string, executable = 'grype') {
31 // Validate input before invoking the scanner. Missing inventory must not look clean.
32 const inventory = JSON.parse(readFileSync(sbom, 'utf8'))
33 if (inventory.bomFormat !== 'CycloneDX' || !Array.isArray(inventory.components) || !inventory.components.length)
34 throw new Error('A populated CycloneDX SBOM is required for vulnerability scanning')
35 mkdirSync(output, { recursive: true })
36 const reportPath = join(output, 'vulnerabilities.json')
37 rmSync(reportPath, { force: true })
38 const scan = Bun.spawnSync([executable, `sbom:${resolve(sbom)}`, '--output', 'json', '--file', reportPath], { stdout: 'inherit', stderr: 'inherit' })
39 if (scan.exitCode !== 0)
40 throw new Error(`Grype failed with exit code ${scan.exitCode}; vulnerability counts are unavailable`)
41 const summary = summarizeVulnerabilities(JSON.parse(readFileSync(reportPath, 'utf8')))
42 writeFileSync(join(output, 'vulnerabilities.txt'), summary.text)
43 return summary
44}
45
46if (import.meta.main) {
47 try {
48 const { counts } = scanSbom(process.argv[2] || 'sbom/full-sbom.cyclonedx.json', process.argv[3] || 'scan-results')
49 const summary = ['## Vulnerability scan results', '', '| Severity | Count |', '| --- | --- |', ...severities.map(severity => `| ${severity} | ${counts[severity]} |`), '', 'Policy: Critical findings fail this check. High findings remain unresolved release-review items (#279); this report is not a release approval.', ''].join('\n')
50 console.log(summary)
51 if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
52 if (counts.High > 0) console.warn('::warning::High vulnerability findings require review under #279')
53 if (counts.Critical > 0) throw new Error('Critical vulnerabilities found in dependencies')
54 }
55 catch (error) {
56 console.error(error instanceof Error ? error.message : String(error))
57 process.exitCode = 1
58 }
59}