also looking at this
fix(ci): gate release publishing on SBOM scan
#314
5 files
+44
-9
| @@ -25,9 +25,13 @@ jobs: | ||
| 25 | 25 | release-sbom: |
| 26 | 26 | needs: validate-release |
| 27 | 27 | uses: ./.github/workflows/sbom.yml |
| 28 | with: | |
| 29 | enforce_high: true | |
| 28 | 30 | |
| 29 | 31 | pantry: |
| 30 | needs: [validate-release] | |
| 32 | # The reusable SBOM workflow includes the vulnerability scan. Publishing | |
| 33 | # must wait for its existing Critical gate, not merely attach it afterward. | |
| 34 | needs: [validate-release, release-sbom] | |
| 31 | 35 | name: pantry (${{ matrix.platform.name }}) |
| 32 | 36 | strategy: |
| 33 | 37 | fail-fast: false |
| @@ -65,7 +69,7 @@ jobs: | ||
| 65 | 69 | run: ${{ matrix.platform.deps }} |
| 66 | 70 | |
| 67 | 71 | - name: Install Dependencies |
| 68 | run: bun install | |
| 72 | run: bun install --frozen-lockfile | |
| 69 | 73 | |
| 70 | 74 | - name: Extract Version |
| 71 | 75 | id: version |
| @@ -532,7 +536,7 @@ jobs: | ||
| 532 | 536 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 533 | 537 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 |
| 534 | 538 | - name: Install Dependencies |
| 535 | run: bun install | |
| 539 | run: bun install --frozen-lockfile | |
| 536 | 540 | |
| 537 | 541 | - name: Test package regressions |
| 538 | 542 | run: bun run test:packages |
| @@ -4,6 +4,11 @@ on: | ||
| 4 | 4 | push: |
| 5 | 5 | branches: [main] |
| 6 | 6 | workflow_call: |
| 7 | inputs: | |
| 8 | enforce_high: | |
| 9 | description: Fail the release scan when High findings remain | |
| 10 | type: boolean | |
| 11 | default: false | |
| 7 | 12 | workflow_dispatch: |
| 8 | 13 | |
| 9 | 14 | concurrency: |
| @@ -26,7 +31,7 @@ jobs: | ||
| 26 | 31 | bun-version: latest |
| 27 | 32 | |
| 28 | 33 | - name: Install dependencies |
| 29 | run: bun install | |
| 34 | run: bun install --frozen-lockfile | |
| 30 | 35 | |
| 31 | 36 | - name: Install SBOM tools |
| 32 | 37 | run: | |
| @@ -121,6 +126,8 @@ jobs: | ||
| 121 | 126 | |
| 122 | 127 | - name: Scan and validate vulnerability report |
| 123 | 128 | run: bun scripts/scan-sbom.ts sbom/full-sbom.cyclonedx.json scan-results |
| 129 | env: | |
| 130 | CRAFT_FAIL_HIGH: ${{ inputs.enforce_high || false }} | |
| 124 | 131 | |
| 125 | 132 | - name: Upload scan results |
| 126 | 133 | if: ${{ always() }} |
| @@ -7,7 +7,8 @@ type Job = { | ||
| 7 | 7 | needs?: string | string[] |
| 8 | 8 | if?: string |
| 9 | 9 | uses?: string |
| 10 | steps?: { name?: string, run?: string, uses?: string }[] | |
| 10 | with?: { enforce_high?: boolean } | |
| 11 | steps?: { name?: string, run?: string, uses?: string, env?: Record<string, string> }[] | |
| 11 | 12 | strategy?: { matrix: { platform: { name: string, os: string }[] } } |
| 12 | 13 | } |
| 13 | 14 | const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> } |
| @@ -41,6 +42,10 @@ test('npm publication follows artifact validation, and macOS downloads run on bo | ||
| 41 | 42 | ]) |
| 42 | 43 | expect(steps(release.jobs['verify-macos-downloads'])).toContain('scripts/verify-macos-release.ts') |
| 43 | 44 | expect(release.jobs['release-sbom'].uses).toBe('./.github/workflows/sbom.yml') |
| 45 | expect(release.jobs['release-sbom'].with?.enforce_high).toBe(true) | |
| 46 | expect(needs(release.jobs.pantry)).toContain('release-sbom') | |
| 47 | expect(steps(release.jobs.pantry)).toContain('bun install --frozen-lockfile') | |
| 48 | expect(steps(release.jobs.npm)).toContain('bun install --frozen-lockfile') | |
| 44 | 49 | expect(needs(release.jobs['attach-release-sbom'])).toContain('release-sbom') |
| 45 | 50 | expect(steps(release.jobs['attach-release-sbom'])).toContain('cmp "sbom/$document" "$VERIFY_DIR/$document"') |
| 46 | 51 | }) |
| @@ -66,6 +71,7 @@ test('a registry network failure remains a visible best-effort warning', () => { | ||
| 66 | 71 | test('SBOM generation validates required documents before uploading artifacts', () => { |
| 67 | 72 | const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/sbom.yml'), 'utf8')) as { jobs: Record<string, Job> } |
| 68 | 73 | const generate = workflow.jobs.generate.steps! |
| 74 | expect(generate.find(step => step.name === 'Install dependencies')?.run).toBe('bun install --frozen-lockfile') | |
| 69 | 75 | const validation = generate.findIndex(step => step.name === 'Validate SBOMs') |
| 70 | 76 | const upload = generate.findIndex(step => step.uses?.startsWith('actions/upload-artifact@')) |
| 71 | 77 | expect(validation).toBeGreaterThan(-1) |
| @@ -74,4 +80,6 @@ test('SBOM generation validates required documents before uploading artifacts', | ||
| 74 | 80 | expect(command).toContain('bun scripts/verify-sbom.ts sbom "v$VERSION"') |
| 75 | 81 | expect(command).toContain("require('./package.json').version") |
| 76 | 82 | expect(command).not.toContain('||') |
| 83 | const scan = workflow.jobs['vulnerability-scan'].steps!.find(step => step.name === 'Scan and validate vulnerability report') | |
| 84 | expect(scan?.env?.CRAFT_FAIL_HIGH).toBe('$') | |
| 77 | 85 | }) |
| @@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test' | ||
| 2 | 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 | import { scanSbom, summarizeVulnerabilities } from './scan-sbom' | |
| 5 | import { enforceScanPolicy, scanSbom, summarizeVulnerabilities } from './scan-sbom' | |
| 6 | 6 | |
| 7 | 7 | const roots: string[] = [] |
| 8 | 8 | afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) |
| @@ -18,6 +18,15 @@ test('counts all supported severities without mistaking absent results for zero' | ||
| 18 | 18 | expect(() => summarizeVulnerabilities(invalid)).toThrow() |
| 19 | 19 | }) |
| 20 | 20 | |
| 21 | test('High findings block release scans and Critical findings block every scan', () => { | |
| 22 | const high = summarizeVulnerabilities(report([match('High')])).counts | |
| 23 | expect(() => enforceScanPolicy(high, false)).not.toThrow() | |
| 24 | expect(() => enforceScanPolicy(high, true)).toThrow('High vulnerabilities block this release') | |
| 25 | const critical = summarizeVulnerabilities(report([match('Critical')])).counts | |
| 26 | expect(() => enforceScanPolicy(critical, false)).toThrow('Critical vulnerabilities') | |
| 27 | expect(() => enforceScanPolicy(critical, true)).toThrow('Critical vulnerabilities') | |
| 28 | }) | |
| 29 | ||
| 21 | 30 | function fixture(source: string) { |
| 22 | 31 | const root = mkdtempSync(join(tmpdir(), 'craft-grype-test-')) |
| 23 | 32 | roots.push(root) |
| @@ -27,6 +27,11 @@ export function summarizeVulnerabilities(report: unknown) { | ||
| 27 | 27 | return { counts, text: `${lines.join('\n')}\n` } |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | export function enforceScanPolicy(counts: ReturnType<typeof summarizeVulnerabilities>['counts'], failHigh: boolean): void { | |
| 31 | if (counts.Critical > 0) throw new Error('Critical vulnerabilities found in dependencies') | |
| 32 | if (failHigh && counts.High > 0) throw new Error('High vulnerabilities block this release') | |
| 33 | } | |
| 34 | ||
| 30 | 35 | export function scanSbom(sbom: string, output: string, executable = 'grype') { |
| 31 | 36 | const reportPath = resolve(output, 'vulnerabilities.json') |
| 32 | 37 | const textPath = resolve(output, 'vulnerabilities.txt') |
| @@ -50,12 +55,14 @@ export function scanSbom(sbom: string, output: string, executable = 'grype') { | ||
| 50 | 55 | |
| 51 | 56 | if (import.meta.main) { |
| 52 | 57 | try { |
| 58 | const setting = process.env.CRAFT_FAIL_HIGH ?? 'false' | |
| 59 | if (!['true', 'false'].includes(setting)) throw new Error('CRAFT_FAIL_HIGH must be true or false') | |
| 53 | 60 | 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') | |
| 61 | const summary = ['## Vulnerability scan results', '', '| Severity | Count |', '| --- | --- |', ...severities.map(severity => `| ${severity} | ${counts[severity]} |`), '', 'Policy: Critical findings fail every scan. High findings fail release scans; they remain review items on main (#279).', ''].join('\n') | |
| 55 | 62 | console.log(summary) |
| 56 | 63 | 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') | |
| 64 | if (counts.High > 0 && setting === 'false') console.warn('::warning::High vulnerability findings require review under #279') | |
| 65 | enforceScanPolicy(counts, setting === 'true') | |
| 59 | 66 | } |
| 60 | 67 | catch (error) { |
| 61 | 68 | console.error(error instanceof Error ? error.message : String(error)) |