also looking at this
fix(security): validate vulnerability scans and pin scanner downloads
#300
5 files
+192
-56
| @@ -271,6 +271,9 @@ jobs: | ||
| 271 | 271 | working-directory: packages/zig |
| 272 | 272 | run: zig fmt --check src/ build.zig |
| 273 | 273 | |
| 274 | - name: Test failure-safe vulnerability scanning | |
| 275 | run: bun test scripts/scan-sbom.test.ts | |
| 276 | ||
| 274 | 277 | - name: Audit Zig C imports |
| 275 | 278 | run: bun run verify:cimports |
| 276 | 279 | |
| @@ -33,8 +33,13 @@ jobs: | ||
| 33 | 33 | |
| 34 | 34 | - name: Install SBOM tools |
| 35 | 35 | 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" | |
| 38 | 43 | |
| 39 | 44 | - name: Generate comprehensive SBOM (Syft) |
| 40 | 45 | run: | |
| @@ -228,11 +233,13 @@ jobs: | ||
| 228 | 233 | output-file: sbom/github-sbom.json |
| 229 | 234 | |
| 230 | 235 | vulnerability-scan: |
| 231 | # name: SBOM Vulnerability Scan | |
| 232 | 236 | needs: generate |
| 233 | 237 | runs-on: ubuntu-latest |
| 234 | 238 | |
| 235 | 239 | steps: |
| 240 | - uses: actions/checkout@v6 | |
| 241 | - uses: oven-sh/setup-bun@v2 | |
| 242 | ||
| 236 | 243 | - name: Download SBOM |
| 237 | 244 | uses: actions/download-artifact@v4 |
| 238 | 245 | with: |
| @@ -241,64 +248,22 @@ jobs: | ||
| 241 | 248 | |
| 242 | 249 | - name: Install Grype |
| 243 | 250 | 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" | |
| 245 | 258 | |
| 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 | |
| 292 | 261 | |
| 293 | 262 | - name: Upload scan results |
| 263 | if: ${{ always() }} | |
| 294 | 264 | uses: actions/upload-artifact@v4 |
| 295 | 265 | with: |
| 296 | 266 | name: vulnerability-scan |
| 297 | 267 | path: scan-results/ |
| 268 | if-no-files-found: warn | |
| 298 | 269 | 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 | |
| @@ -0,0 +1,26 @@ | ||
| 1 | # Vulnerability scan interpretation | |
| 2 | ||
| 3 | The SBOM workflow scans the repository inventory, including development tools, | |
| 4 | benchmark comparison applications and CI dependencies. It is not a runtime-only | |
| 5 | inventory and a match does not by itself establish reachability in a shipped app. | |
| 6 | ||
| 7 | The scanner must complete successfully and produce a populated-inventory report | |
| 8 | with valid database metadata. Missing output, malformed JSON, unexpected report | |
| 9 | shapes and scanner failures fail the job; none are interpreted as zero findings. | |
| 10 | The text report and severity counts are derived from the same JSON scan. | |
| 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. | |
| 15 | ||
| 16 | The existing severity policy is unchanged: Critical findings fail the check; | |
| 17 | High findings emit an explicit warning and remain release-review items. A green | |
| 18 | scan is **not** release approval. Issue [#279](https://github.com/craft-native/craft/issues/279) | |
| 19 | tracks the reviewed High/Critical policy and shipping-scope analysis; issue | |
| 20 | [#276](https://github.com/craft-native/craft/issues/276) tracks locked dependency | |
| 21 | advisories. No baseline or vulnerability exemption is added by this workflow. | |
| 22 | ||
| 23 | Syft and Grype Linux x64 downloads are pinned by release version and SHA-256 in | |
| 24 | `.github/workflows/sbom.yml`. Tool updates must update both the asset URL and its | |
| 25 | digest using the upstream release metadata, then run the scanner regression | |
| 26 | tests and a real workflow scan. Mutable installer scripts are not executed. | |
| @@ -0,0 +1,78 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { scanSbom, summarizeVulnerabilities } from './scan-sbom' | |
| 6 | ||
| 7 | const roots: string[] = [] | |
| 8 | afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) | |
| 9 | const report = (matches: unknown[] = []) => ({ descriptor: { name: 'grype', version: '0.119.0', db: { status: { valid: true } } }, matches }) | |
| 10 | const match = (severity: string) => ({ vulnerability: { severity, id: 'CVE-2026-1234' }, artifact: { name: 'fixture', version: '1.0.0' } }) | |
| 11 | ||
| 12 | test('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 | ||
| 21 | function 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 | ||
| 32 | test('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 | writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary') | |
| 36 | expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow('exit code 7') | |
| 37 | expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false) | |
| 38 | }) | |
| 39 | ||
| 40 | test('successful exit without valid output cannot reuse stale or malformed reports', () => { | |
| 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], "{}")']) { | |
| 42 | const f = fixture(source) | |
| 43 | writeFileSync(join(f.root, 'vulnerabilities.json'), JSON.stringify(report())) | |
| 44 | writeFileSync(join(f.root, 'vulnerabilities.txt'), 'stale successful summary') | |
| 45 | expect(() => scanSbom(f.sbom, f.root, f.executable)).toThrow() | |
| 46 | expect(existsSync(join(f.root, 'vulnerabilities.txt'))).toBe(false) | |
| 47 | } | |
| 48 | }) | |
| 49 | ||
| 50 | test('valid scanner output produces a matching text report; empty SBOMs are refused', () => { | |
| 51 | const f = fixture(`await Bun.write(process.argv[process.argv.indexOf('--file') + 1], ${JSON.stringify(JSON.stringify(report([match('High')])))});`) | |
| 52 | expect(scanSbom(f.sbom, f.root, f.executable).counts.High).toBe(1) | |
| 53 | expect(readFileSync(join(f.root, 'vulnerabilities.txt'), 'utf8')).toContain('High\tfixture') | |
| 54 | writeFileSync(f.sbom, JSON.stringify({ bomFormat: 'CycloneDX', components: [] })) | |
| 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) | |
| 78 | }) | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | const severities = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'] as const | |
| 5 | type Severity = typeof severities[number] | |
| 6 | ||
| 7 | export 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 | ||
| 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 }) | |
| 38 | // Validate input before invoking the scanner. Missing inventory must not look clean. | |
| 39 | const inventory = JSON.parse(readFileSync(sbom, 'utf8')) | |
| 40 | if (inventory.bomFormat !== 'CycloneDX' || !Array.isArray(inventory.components) || !inventory.components.length) | |
| 41 | throw new Error('A populated CycloneDX SBOM is required for vulnerability scanning') | |
| 42 | mkdirSync(output, { recursive: true }) | |
| 43 | const scan = Bun.spawnSync([executable, `sbom:${resolve(sbom)}`, '--output', 'json', '--file', reportPath], { stdout: 'inherit', stderr: 'inherit' }) | |
| 44 | if (scan.exitCode !== 0) | |
| 45 | throw new Error(`Grype failed with exit code ${scan.exitCode}; vulnerability counts are unavailable`) | |
| 46 | const summary = summarizeVulnerabilities(JSON.parse(readFileSync(reportPath, 'utf8'))) | |
| 47 | writeFileSync(textPath, summary.text) | |
| 48 | return summary | |
| 49 | } | |
| 50 | ||
| 51 | if (import.meta.main) { | |
| 52 | try { | |
| 53 | const { counts } = scanSbom(process.argv[2] || 'sbom/full-sbom.cyclonedx.json', process.argv[3] || 'scan-results') | |
| 54 | 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') | |
| 55 | console.log(summary) | |
| 56 | if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) | |
| 57 | if (counts.High > 0) console.warn('::warning::High vulnerability findings require review under #279') | |
| 58 | if (counts.Critical > 0) throw new Error('Critical vulnerabilities found in dependencies') | |
| 59 | } | |
| 60 | catch (error) { | |
| 61 | console.error(error instanceof Error ? error.message : String(error)) | |
| 62 | process.exitCode = 1 | |
| 63 | } | |
| 64 | } | |