ReviewOS

also looking at this

craft-native/craft

Gate releases on shipped artifact findings

#316
Merged glennmichael123 wants to merge codex/gate-shipped-artifacts into main
11 files +454 -25
.github/actions/setup-release-scanners/action.ymladded+35-0
Changes to .github/actions/setup-release-scanners/action.yml
@@ -0,0 +1,35 @@
1name: Set up release scanners
2description: Install pinned Syft and Grype binaries for release artifact checks
3runs:
4 using: composite
5 steps:
6 - name: Install Syft and Grype
7 shell: bash
8 run: |
9 set -euo pipefail
10 case "$RUNNER_OS" in
11 Linux)
12 syft_platform=linux_amd64
13 grype_platform=linux_amd64
14 syft_hash=caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d
15 grype_hash=3fa2dc4b924621ab65404cf08d0b8438d896d80ab949c9d5a4ca283c36004c9b
16 ;;
17 macOS)
18 syft_platform=darwin_arm64
19 grype_platform=darwin_arm64
20 syft_hash=014d561b6d13059124155f74a6c5a9a99501f5e209313638dd884f39eb418ee6
21 grype_hash=500c9b2b6c089d21481815f57a553fabbd441ec7d1e79d95e3aaf40c3bfc7e36
22 ;;
23 *) echo "Unsupported scanner platform: $RUNNER_OS" >&2; exit 1 ;;
24 esac
25 mkdir -p "$RUNNER_TEMP/release-scanners"
26 cd "$RUNNER_TEMP/release-scanners"
27 curl --fail --location --retry 3 --output syft.tar.gz \
28 "https://github.com/anchore/syft/releases/download/v1.52.0/syft_1.52.0_${syft_platform}.tar.gz"
29 echo "$syft_hash syft.tar.gz" | shasum -a 256 --check
30 tar -xzf syft.tar.gz syft
31 curl --fail --location --retry 3 --output grype.tar.gz \
32 "https://github.com/anchore/grype/releases/download/v0.119.0/grype_0.119.0_${grype_platform}.tar.gz"
33 echo "$grype_hash grype.tar.gz" | shasum -a 256 --check
34 tar -xzf grype.tar.gz grype
35 echo "$RUNNER_TEMP/release-scanners" >> "$GITHUB_PATH"
.github/workflows/ci.ymlmodified+1-1
Changes to .github/workflows/ci.yml
@@ -278,7 +278,7 @@ jobs:
278278 run: zig fmt --check src/ build.zig
279279
280280 - name: Test failure-safe vulnerability scanning
281 run: bun test scripts/scan-sbom.test.ts
281 run: bun test scripts/scan-sbom.test.ts scripts/scan-release-artifacts.test.ts
282282
283283 - name: Audit Zig C imports
284284 run: bun run verify:cimports
.github/workflows/release.ymlmodified+31-9
Changes to .github/workflows/release.yml
@@ -26,7 +26,9 @@ jobs:
2626 needs: validate-release
2727 uses: ./.github/workflows/sbom.yml
2828 with:
29 enforce_high: true
29 # Keep the full-source report for review. The High gate runs against
30 # the artifacts and production dependencies prepared for publication.
31 enforce_high: false
3032
3133 pantry:
3234 # The reusable SBOM workflow includes the vulnerability scan. Publishing
@@ -305,7 +307,13 @@ jobs:
305307 --timeout 30m
306308 codesign --verify --deep --strict "$binary"
307309 rm "$archive"
308 done
310
311 - uses: ./.github/actions/setup-release-scanners
312
313 - name: Scan binaries prepared for publication
314 env:
315 CRAFT_PLATFORM: ${{ runner.os == 'macOS' && 'macos' || 'linux' }}
316 run: bun scripts/scan-release-artifacts.ts "$CRAFT_PLATFORM" packages/zig/zig-out "$RUNNER_TEMP/native-release-scan"
309317
310318 # ── Publish & Release ──────────────────────────────
311319 # Pantry action handles everything:
@@ -520,9 +528,10 @@ jobs:
520528 # Nothing here consumes the binary job's artifacts: this checks out, runs
521529 # `bun install`, and publishes packages built from TypeScript. Signing a Zig
522530 # binary and publishing an SDK are independent, and the workflow now says
523 # so. `!cancelled()` rather than `always()` so a cancelled run still stops.
524 needs: [validate-release, pantry]
525 if: ${{ !cancelled() && needs.validate-release.result == 'success' }}
531 # so. The full-source scan's Critical gate still applies to both paths.
532 # `!cancelled()` rather than `always()` so a cancelled run still stops.
533 needs: [validate-release, release-sbom, pantry]
534 if: ${{ !cancelled() && needs.validate-release.result == 'success' && needs.release-sbom.result == 'success' }}
526535 runs-on: ubuntu-latest
527536 permissions:
528537 contents: write
@@ -535,16 +544,29 @@ jobs:
535544
536545 - name: Setup Pantry (provides Bun, Zig, etc.)
537546 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
547 - uses: actions/setup-node@v6
548 with:
549 node-version: '24'
550 registry-url: https://registry.npmjs.org
551 package-manager-cache: false
538552 - name: Install Dependencies
539553 run: bun install --frozen-lockfile
540554
555 - name: Install npm CLI with trusted publishing support
556 run: npm install --global npm@11.15.0
557
558 - uses: ./.github/actions/setup-release-scanners
559
541560 - name: Test package regressions
542561 run: bun run test:packages
543562
544563 - name: Build and verify every npm artifact
545 run: bun run verify:npm
564 run: bun scripts/npm-packages.ts --archive-dir "$RUNNER_TEMP/craft-npm-archives"
565
566 - name: Scan packed npm artifacts and production dependencies
567 run: bun scripts/scan-release-artifacts.ts npm "$RUNNER_TEMP/craft-npm-archives" "$RUNNER_TEMP/npm-release-scan"
546568
547 - name: Publish to npm
548 run: pantry publish --npm --access public
569 - name: Publish scanned npm archives
570 run: bun scripts/publish-npm-archives.ts "$RUNNER_TEMP/craft-npm-archives"
549571 env:
550 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
572 NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docs/VULNERABILITY_SCANNING.mdmodified+8-3
Changes to docs/VULNERABILITY_SCANNING.md
@@ -13,12 +13,17 @@ Each attempt removes previous JSON and text reports before validating its input.
1313A failed rerun therefore cannot attach a previous successful summary alongside
1414new diagnostics. Keep the input SBOM separate from the scanner's output files.
1515
16The existing severity policy is unchanged: Critical findings fail the check;
17High findings emit an explicit warning and remain release-review items. A green
16Critical findings fail the full-repository scan and block both native and npm
17publishing. High findings in that broad inventory emit an explicit warning and
18remain release-review items. Before publication, a separate scan blocks High
19or Critical findings in the built native binaries and in the exact packed npm
20archives with their resolved production dependencies. A green full-repository
1821scan is **not** release approval. Issue [#279](https://github.com/craft-native/craft/issues/279)
1922tracks the reviewed High/Critical policy and shipping-scope analysis; issue
2023[#276](https://github.com/craft-native/craft/issues/276) tracks locked dependency
21advisories. No baseline or vulnerability exemption is added by this workflow.
24advisories. Native binary scanning records exact hashes and detected components,
25but static Zig code may not be identifiable from the binary alone; retain the
26full-source review. No baseline or vulnerability exemption is added by this workflow.
2227
2328Syft and Grype Linux x64 downloads are pinned by release version and SHA-256 in
2429`.github/workflows/sbom.yml`. Tool updates must update both the asset URL and its
scripts/npm-packages.test.tsmodified+21-1
Changes to scripts/npm-packages.test.ts
@@ -1,5 +1,9 @@
11import { expect, test } from 'bun:test'
2import { manifestTargets, verifyArchiveTargets } from './npm-packages'
2import { createHash } from 'node:crypto'
3import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { manifestTargets, verifyArchiveTargets, verifyNpmPackages } from './npm-packages'
37
48test('rejects absent built entries even when the manifest advertises them', () => {
59 const pkg = { name: '@craft-native/react', main: 'dist/index.js', exports: { '.': { import: './dist/index.mjs', types: './dist/index.d.ts' } } }
@@ -31,3 +35,19 @@ test('fails closed for targets that escape the package or need wildcard handling
3135 for (const target of ['../outside.js', '/outside.js', './dist/../outside.js', '.\\outside.js', './dist/*.js'])
3236 expect(() => manifestTargets({ name: 'fixture', exports: target })).toThrow()
3337})
38
39test('exports every verified npm archive with its exact digest', async () => {
40 const root = mkdtempSync(join(tmpdir(), 'craft-npm-archive-test-'))
41 const output = join(root, 'archives')
42 try {
43 await verifyNpmPackages(false, output)
44 const manifest = JSON.parse(readFileSync(join(output, 'manifest.json'), 'utf8')) as { packages: { name: string, file: string, sha256: string }[] }
45 expect(manifest.packages).toHaveLength(8)
46 for (const archive of manifest.packages) {
47 const path = join(output, archive.file)
48 expect(existsSync(path)).toBe(true)
49 expect(createHash('sha256').update(readFileSync(path)).digest('hex')).toBe(archive.sha256)
50 }
51 }
52 finally { rmSync(root, { recursive: true, force: true }) }
53}, 120_000)
scripts/npm-packages.tsmodified+27-6
Changes to scripts/npm-packages.ts
@@ -1,9 +1,11 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
1import { createHash } from 'node:crypto'
2import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
23import { tmpdir } from 'node:os'
3import { dirname, join, resolve } from 'node:path'
4import { dirname, join, resolve, sep } from 'node:path'
45
56type Manifest = {
67 name: string
8 version: string
79 private?: boolean
810 scripts?: Record<string, string>
911 peerDependencies?: Record<string, string>
@@ -76,10 +78,18 @@ function importSpecifiers(pkg: Manifest): string[] {
7678 return [pkg.name]
7779}
7880
79export async function verifyNpmPackages(buildOnly = false): Promise<void> {
81export async function verifyNpmPackages(buildOnly = false, archiveDir?: string): Promise<void> {
8082 const packages = publicPackages()
8183 if (packages.length === 0)
8284 throw new Error('No public npm workspaces found')
85 if (buildOnly && archiveDir) throw new Error('Cannot export archives in build-only mode')
86 const output = archiveDir ? resolve(archiveDir) : undefined
87 if (output) {
88 if (output === root || output.startsWith(`${root}${sep}`))
89 throw new Error('Archive output must be outside the repository')
90 mkdirSync(output, { recursive: true })
91 if (readdirSync(output).length) throw new Error('Archive output must be empty')
92 }
8393 const temp = mkdtempSync(join(tmpdir(), 'craft-npm-verify-'))
8494 try {
8595 for (const { dir, pkg } of packages) {
@@ -95,12 +105,17 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> {
95105 return
96106
97107 const specifiers: string[] = []
108 const archives: { name: string, version: string, file: string, sha256: string }[] = []
98109 for (const [index, { dir, pkg }] of packages.entries()) {
99 const archive = join(temp, `${index}.tgz`)
110 const file = `${index}.tgz`
111 const archive = join(output ?? temp, file)
100112 run([process.execPath, 'pm', 'pack', '--filename', archive], dir)
101113 const entries = run(['tar', '-tzf', archive], temp).trim().split('\n')
102114 const packed: Manifest = JSON.parse(run(['tar', '-xOzf', archive, 'package/package.json'], temp))
103115 verifyArchiveTargets(packed, entries)
116 if (packed.name !== pkg.name || packed.version !== pkg.version)
117 throw new Error(`${pkg.name}: packed identity differs from the source manifest`)
118 archives.push({ name: packed.name, version: packed.version, file, sha256: createHash('sha256').update(readFileSync(archive)).digest('hex') })
104119 const installed = join(temp, 'node_modules', pkg.name)
105120 mkdirSync(installed, { recursive: true })
106121 run(['tar', '-xzf', archive, '--strip-components=1', '-C', installed], temp)
@@ -133,6 +148,8 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> {
133148 '--target', 'esnext', '--skipLibCheck', '--ignoreConfig',
134149 '--typeRoots', join(root, 'node_modules/@types'),
135150 ], temp)
151 if (output)
152 writeFileSync(join(output, 'manifest.json'), `${JSON.stringify({ packages: archives }, null, 2)}\n`)
136153 console.log(`Verified ${packages.length} npm archives and ${specifiers.length} installed import/type entry points.`)
137154 }
138155 finally {
@@ -140,5 +157,9 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> {
140157 }
141158}
142159
143if (import.meta.main)
144 await verifyNpmPackages(Bun.argv.includes('--build-only'))
160if (import.meta.main) {
161 const index = Bun.argv.indexOf('--archive-dir')
162 if (index >= 0 && (!Bun.argv[index + 1] || Bun.argv[index + 1]!.startsWith('--')))
163 throw new Error('--archive-dir requires an output path')
164 await verifyNpmPackages(Bun.argv.includes('--build-only'), index >= 0 ? Bun.argv[index + 1] : undefined)
165}
scripts/publish-npm-archives.tsadded+42-0
Changes to scripts/publish-npm-archives.ts
@@ -0,0 +1,42 @@
1import { join, resolve } from 'node:path'
2import { readArchiveManifest } from './scan-release-artifacts'
3
4type Command = (args: string[]) => { exitCode: number | null, stdout: string, stderr: string }
5
6const npmCommand: Command = args => {
7 const result = Bun.spawnSync(['npm', ...args], { stdout: 'pipe', stderr: 'pipe' })
8 return { exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() }
9}
10
11export function publishNpmArchives(directory: string, command: Command = npmCommand, dryRun = false): void {
12 const root = resolve(directory)
13 const archives = readArchiveManifest(root)
14 for (const archive of archives) {
15 const path = join(root, archive.file)
16 if (!dryRun) {
17 const lookup = command(['view', `${archive.name}@${archive.version}`, 'version', '--json'])
18 if (lookup.exitCode === 0) {
19 if (JSON.parse(lookup.stdout) !== archive.version)
20 throw new Error(`${archive.name}: npm returned an unexpected published version`)
21 console.log(`${archive.name}@${archive.version} already published; skipping`)
22 continue
23 }
24 if (!lookup.stderr.includes('E404'))
25 throw new Error(`${archive.name}: npm version lookup failed: ${lookup.stderr}`)
26 }
27 // A failed or changed archive must never be published after the scan.
28 readArchiveManifest(root)
29 const args = ['publish', path, '--access', 'public', '--ignore-scripts', '--provenance']
30 if (dryRun) args.push('--dry-run')
31 const publish = command(args)
32 if (publish.exitCode !== 0)
33 throw new Error(`${archive.name}: npm publish failed: ${publish.stderr}`)
34 console.log(`${dryRun ? 'Dry-run checked' : 'Published'} ${archive.name}@${archive.version}`)
35 }
36}
37
38if (import.meta.main) {
39 const directory = Bun.argv[2]
40 if (!directory) throw new Error('Usage: publish-npm-archives.ts <archive-dir> [--dry-run]')
41 publishNpmArchives(directory, npmCommand, Bun.argv.includes('--dry-run'))
42}
scripts/release-workflow.test.tsmodified+18-4
Changes to scripts/release-workflow.test.ts
@@ -19,8 +19,10 @@ const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ??
1919test('every publishing path depends on release identity validation', () => {
2020 for (const name of ['pantry', 'npm', 'release-sbom', 'verify-release', 'verify-macos-downloads'])
2121 expect(needs(release.jobs[name])).toContain('validate-release')
22 for (const name of ['npm', 'verify-release', 'verify-macos-downloads'])
22 for (const name of ['verify-release', 'verify-macos-downloads'])
2323 expect(release.jobs[name].if).toBe("$")
24 expect(release.jobs.npm.if).toBe("$")
25 expect(needs(release.jobs.npm)).toContain('release-sbom')
2426})
2527
2628test('registry indexing waits for the final manifest, macOS downloads and attached SBOMs', () => {
@@ -34,16 +36,28 @@ test('registry indexing waits for the final manifest, macOS downloads and attach
3436
3537test('npm publication follows artifact validation, and macOS downloads run on both matching architectures', () => {
3638 const npm = release.jobs.npm.steps!.map(step => step.run ?? '')
37 const verify = npm.indexOf('bun run verify:npm')
39 const verify = npm.indexOf('bun scripts/npm-packages.ts --archive-dir "$RUNNER_TEMP/craft-npm-archives"')
40 const scan = npm.indexOf('bun scripts/scan-release-artifacts.ts npm "$RUNNER_TEMP/craft-npm-archives" "$RUNNER_TEMP/npm-release-scan"')
41 const publish = npm.indexOf('bun scripts/publish-npm-archives.ts "$RUNNER_TEMP/craft-npm-archives"')
3842 expect(verify).toBeGreaterThan(-1)
39 expect(npm.indexOf('pantry publish --npm --access public')).toBeGreaterThan(verify)
43 expect(scan).toBeGreaterThan(verify)
44 expect(publish).toBeGreaterThan(scan)
45 expect(steps(release.jobs.npm)).not.toContain('pantry publish --npm')
46 expect(release.jobs.npm.steps!.find(step => step.name === 'Publish scanned npm archives')?.env?.NODE_AUTH_TOKEN).toBe('$')
47 const native = release.jobs.pantry.steps!
48 expect(native.findIndex(step => step.name === 'Scan binaries prepared for publication')).toBeGreaterThan(native.findIndex(step => step.name === 'Notarize macOS binaries'))
49 expect(native.findIndex(step => step.name === 'Publish & Release')).toBeGreaterThan(native.findIndex(step => step.name === 'Scan binaries prepared for publication'))
50 expect(native.find(step => step.name === 'Scan binaries prepared for publication')?.run)
51 .toBe('bun scripts/scan-release-artifacts.ts "$CRAFT_PLATFORM" packages/zig/zig-out "$RUNNER_TEMP/native-release-scan"')
52 expect(native.filter(step => step.uses === './.github/actions/setup-release-scanners')).toHaveLength(1)
53 expect(release.jobs.npm.steps!.filter(step => step.uses === './.github/actions/setup-release-scanners')).toHaveLength(1)
4054 expect(release.jobs['verify-macos-downloads'].strategy?.matrix.platform).toEqual([
4155 { os: 'macos-15', name: 'darwin-arm64' },
4256 { os: 'macos-15-intel', name: 'darwin-x64' },
4357 ])
4458 expect(steps(release.jobs['verify-macos-downloads'])).toContain('scripts/verify-macos-release.ts')
4559 expect(release.jobs['release-sbom'].uses).toBe('./.github/workflows/sbom.yml')
46 expect(release.jobs['release-sbom'].with?.enforce_high).toBe(true)
60 expect(release.jobs['release-sbom'].with?.enforce_high).toBe(false)
4761 expect(needs(release.jobs.pantry)).toContain('release-sbom')
4862 expect(steps(release.jobs.pantry)).toContain('bun install --frozen-lockfile')
4963 expect(steps(release.jobs.npm)).toContain('bun install --frozen-lockfile')
scripts/scan-release-artifacts.test.tsadded+98-0
Changes to scripts/scan-release-artifacts.test.ts
@@ -0,0 +1,98 @@
1import { afterEach, expect, test } from 'bun:test'
2import { createHash } from 'node:crypto'
3import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { publishNpmArchives } from './publish-npm-archives'
7import { expectedNpmPackages, nativeInventory, readArchiveManifest, summarizeBunAudit } from './scan-release-artifacts'
8
9const roots: string[] = []
10afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
11const fixture = () => {
12 const root = mkdtempSync(join(tmpdir(), 'craft-release-artifacts-test-'))
13 roots.push(root)
14 return root
15}
16
17function archives(root: string) {
18 const packages = expectedNpmPackages().map((name, index) => {
19 const file = `${index}.tgz`
20 const bytes = `${name}@1.2.3`
21 writeFileSync(join(root, file), bytes)
22 return { name, version: '1.2.3', file, sha256: createHash('sha256').update(bytes).digest('hex') }
23 })
24 writeFileSync(join(root, 'manifest.json'), JSON.stringify({ packages }))
25 return packages
26}
27
28test('release manifest requires every public archive and unchanged bytes', () => {
29 const root = fixture()
30 const packages = archives(root)
31 expect(readArchiveManifest(root)).toHaveLength(packages.length)
32 writeFileSync(join(root, packages[0]!.file), 'changed')
33 expect(() => readArchiveManifest(root)).toThrow('hash mismatch')
34 writeFileSync(join(root, packages[0]!.file), `${packages[0]!.name}@1.2.3`)
35 packages[0]!.file = '../escape.tgz'
36 writeFileSync(join(root, 'manifest.json'), JSON.stringify({ packages }))
37 expect(() => readArchiveManifest(root)).toThrow('Malformed')
38 packages.shift()
39 writeFileSync(join(root, 'manifest.json'), JSON.stringify({ packages }))
40 expect(() => readArchiveManifest(root)).toThrow('does not cover every')
41})
42
43test('Bun audit parser fails closed and only High or Critical blocks', () => {
44 expect(summarizeBunAudit({})).toEqual({ Critical: 0, High: 0 })
45 expect(summarizeBunAudit({ svelte: [{ severity: 'moderate' }], unsafe: [{ severity: 'high' }, { severity: 'critical' }] }))
46 .toEqual({ Critical: 1, High: 1 })
47 for (const invalid of [null, [], { package: null }, { package: [{}] }, { package: [{ severity: 'unknown' }] }])
48 expect(() => summarizeBunAudit(invalid)).toThrow()
49})
50
51test('release scanner rejects extra arguments before scanning', () => {
52 const result = Bun.spawnSync([
53 process.execPath,
54 join(import.meta.dir, 'scan-release-artifacts.ts'),
55 'linux', '/missing', '/missing', 'done',
56 ], { stdout: 'pipe', stderr: 'pipe' })
57 expect(result.exitCode).not.toBe(0)
58 expect(result.stderr.toString()).toContain('Usage: scan-release-artifacts.ts')
59})
60
61function binary(root: string, path: string, magic: string) {
62 const absolute = join(root, path)
63 mkdirSync(join(absolute, '..'), { recursive: true })
64 const bytes = Buffer.alloc(2048)
65 Buffer.from(magic, 'hex').copy(bytes)
66 writeFileSync(absolute, bytes)
67}
68
69test('native inventory binds exact binaries and Zig runtime version', () => {
70 const root = fixture()
71 binary(root, 'bin/craft', '7f454c46')
72 binary(root, 'cross/windows-x64/craft.exe', '4d5a')
73 const inventory = nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')
74 expect(inventory.components.map(component => component.name)).toEqual(['craft-linux-x64', 'craft-windows-x64', 'zig-std'])
75 expect(inventory.components[0]?.hashes).toEqual([{ alg: 'SHA-256', content: createHash('sha256').update(readFileSync(join(root, 'bin/craft'))).digest('hex') }])
76 writeFileSync(join(root, 'cross/windows-x64/craft.exe'), 'not a binary')
77 expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('missing or empty')
78 binary(root, 'cross/windows-x64/craft.exe', '7f454c46')
79 expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('unexpected executable format')
80})
81
82test('publisher uses only hash-verified archives and skips an existing version', () => {
83 const root = fixture()
84 const packages = archives(root)
85 const calls: string[][] = []
86 publishNpmArchives(root, (args) => {
87 calls.push(args)
88 if (args[0] === 'view')
89 return args[1] === `${packages[0]!.name}@1.2.3`
90 ? { exitCode: 0, stdout: '"1.2.3"', stderr: '' }
91 : { exitCode: 1, stdout: '', stderr: 'E404' }
92 return { exitCode: 0, stdout: '', stderr: '' }
93 })
94 expect(calls.filter(args => args[0] === 'publish')).toHaveLength(packages.length - 1)
95 expect(calls.find(args => args[0] === 'publish')).toContain('--ignore-scripts')
96 expect(calls.find(args => args[0] === 'publish')).toContain('--provenance')
97 expect(calls.some(args => args[0] === 'publish' && args[1] === join(root, packages[0]!.file))).toBe(false)
98})
scripts/scan-release-artifacts.tsadded+172-0
Changes to scripts/scan-release-artifacts.ts
@@ -0,0 +1,172 @@
1import { createHash } from 'node:crypto'
2import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { enforceScanPolicy, scanSbom } from './scan-sbom'
6
7type Archive = { name: string, version: string, file: string, sha256: string }
8type Component = { name: string, version?: string, 'bom-ref'?: string, [key: string]: unknown }
9type Inventory = { bomFormat: string, components: Component[], [key: string]: unknown }
10
11const root = resolve(import.meta.dir, '..')
12const sha256 = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
13
14function run(args: string[], cwd = root) {
15 const result = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' })
16 if (result.exitCode !== 0)
17 throw new Error(`${args[0]} failed (${result.exitCode}): ${result.stderr.toString()}`)
18 return result.stdout.toString()
19}
20
21export function expectedNpmPackages(): string[] {
22 const names: string[] = []
23 for (const path of new Bun.Glob('packages/*/package.json').scanSync({ cwd: root })) {
24 const pkg = JSON.parse(readFileSync(join(root, path), 'utf8')) as { name?: string, private?: boolean }
25 if (!pkg.private) {
26 if (!pkg.name) throw new Error(`${path}: public package has no name`)
27 names.push(pkg.name)
28 }
29 }
30 return names.sort()
31}
32
33export function readArchiveManifest(directory: string, expected: string[] = expectedNpmPackages()): Archive[] {
34 const manifest = JSON.parse(readFileSync(join(directory, 'manifest.json'), 'utf8')) as { packages?: Archive[] }
35 if (!Array.isArray(manifest.packages) || manifest.packages.length !== expected.length)
36 throw new Error('Release archive manifest does not cover every public npm package')
37 const names = new Set<string>()
38 const files = new Set<string>()
39 for (const archive of manifest.packages) {
40 if (!archive || typeof archive.name !== 'string' || !archive.name || typeof archive.version !== 'string' || !archive.version
41 || typeof archive.file !== 'string' || !/^\d+\.tgz$/.test(archive.file)
42 || typeof archive.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(archive.sha256))
43 throw new Error('Malformed release archive manifest')
44 if (names.has(archive.name) || files.has(archive.file)) throw new Error('Duplicate release archive identity')
45 names.add(archive.name)
46 files.add(archive.file)
47 const path = join(directory, archive.file)
48 if (!existsSync(path) || !statSync(path).isFile() || sha256(path) !== archive.sha256)
49 throw new Error(`${archive.name}: release archive hash mismatch`)
50 }
51 if (expected.some(name => !names.has(name)))
52 throw new Error('Release archive manifest does not cover every public npm package')
53 return manifest.packages
54}
55
56export function summarizeBunAudit(report: unknown): { Critical: number, High: number } {
57 if (!report || typeof report !== 'object' || Array.isArray(report)) throw new Error('Invalid Bun audit report')
58 const counts = { Critical: 0, High: 0 }
59 for (const advisories of Object.values(report)) {
60 if (!Array.isArray(advisories)) throw new Error('Invalid Bun audit advisories')
61 for (const advisory of advisories) {
62 const severity = advisory?.severity
63 if (!['critical', 'high', 'moderate', 'low', 'info'].includes(severity))
64 throw new Error('Invalid Bun audit severity')
65 if (severity === 'critical') counts.Critical++
66 if (severity === 'high') counts.High++
67 }
68 }
69 return counts
70}
71
72function scanNpm(directory: string, output: string, syft: string, grype: string) {
73 const archives = readArchiveManifest(directory)
74 const consumer = mkdtempSync(join(tmpdir(), 'craft-release-consumer-'))
75 try {
76 const dependencies = Object.fromEntries(archives.map(({ name, file }) => [name, `file:${join(resolve(directory), file)}`]))
77 writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
78 run([process.execPath, 'install', '--ignore-scripts', '--save', '--save-text-lockfile'], consumer)
79 const audit = Bun.spawnSync([process.execPath, 'audit', '--production', '--json'], { cwd: consumer, stdout: 'pipe', stderr: 'pipe' })
80 const auditText = audit.stdout.toString()
81 let auditCounts: ReturnType<typeof summarizeBunAudit>
82 try {
83 const report = JSON.parse(auditText)
84 auditCounts = summarizeBunAudit(report)
85 if (audit.exitCode !== 0 && Object.keys(report).length === 0)
86 throw new Error('Bun audit failed without reporting any advisories')
87 }
88 catch { throw new Error(`Bun production audit did not return a valid report: ${audit.stderr.toString()}`) }
89 mkdirSync(output, { recursive: true })
90 writeFileSync(join(output, 'bun-audit.json'), auditText)
91
92 const sbom = join(output, 'npm.cyclonedx.json')
93 run([syft, join(consumer, 'node_modules'), '--select-catalogers', '+javascript-package-cataloger', '-o', `cyclonedx-json=${sbom}`])
94 const inventory = JSON.parse(readFileSync(sbom, 'utf8')) as Inventory
95 if (inventory.bomFormat !== 'CycloneDX' || !Array.isArray(inventory.components))
96 throw new Error('Invalid npm release inventory')
97 for (const archive of archives) {
98 if (!inventory.components.some(component => component.name === archive.name && component.version === archive.version))
99 throw new Error(`${archive.name}: package missing from installed release inventory`)
100 }
101 const summary = scanSbom(sbom, join(output, 'grype'), grype)
102 enforceScanPolicy(summary.counts, true)
103 if (auditCounts.Critical || auditCounts.High)
104 throw new Error('High or Critical findings in npm production dependencies block this release')
105 readArchiveManifest(directory)
106 console.log(`Scanned ${archives.length} packed npm packages and their resolved production dependencies`)
107 }
108 finally { rmSync(consumer, { recursive: true, force: true }) }
109}
110
111export function nativeInventory(binaryRoot: string, platform: 'macos' | 'linux', version: string, zigVersion: string, discovered: Component[][] = []): Inventory {
112 const entries = platform === 'macos'
113 ? [{ path: 'bin/craft', name: 'craft-darwin-arm64', magic: ['cffaedfe', 'feedfacf'] }, { path: 'cross/darwin-x64/craft', name: 'craft-darwin-x64', magic: ['cffaedfe', 'feedfacf'] }]
114 : [{ path: 'bin/craft', name: 'craft-linux-x64', magic: ['7f454c46'] }, { path: 'cross/windows-x64/craft.exe', name: 'craft-windows-x64', magic: ['4d5a'] }]
115 const binaries: Component[] = entries.map(({ path, name, magic }) => {
116 const absolute = join(binaryRoot, path)
117 if (!existsSync(absolute) || !statSync(absolute).isFile() || statSync(absolute).size < 1024)
118 throw new Error(`${name}: missing or empty release binary`)
119 const bytes = readFileSync(absolute)
120 if (!magic.some(prefix => bytes.subarray(0, prefix.length / 2).toString('hex') === prefix))
121 throw new Error(`${name}: unexpected executable format`)
122 return { 'bom-ref': `urn:craft:binary:${name}`, type: 'application', name, version,
123 hashes: [{ alg: 'SHA-256', content: createHash('sha256').update(bytes).digest('hex') }],
124 properties: [{ name: 'craft:artifact-path', value: path }],
125 }
126 })
127 const zig = { 'bom-ref': 'urn:craft:zig-std', type: 'library', name: 'zig-std', version: zigVersion }
128 return {
129 bomFormat: 'CycloneDX', specVersion: '1.6', version: 1,
130 metadata: { component: { type: 'application', name: 'craft-native-release', version } },
131 components: [...binaries, zig, ...discovered.flat()],
132 dependencies: binaries.map(binary => ({ ref: binary['bom-ref'], dependsOn: [zig['bom-ref']] })),
133 }
134}
135
136function scanNative(binaryRoot: string, platform: 'macos' | 'linux', output: string, syft: string, grype: string) {
137 mkdirSync(output, { recursive: true })
138 const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }
139 const zigVersion = Bun.JSONC.parse(readFileSync(join(root, 'pantry.jsonc'), 'utf8')).dependencies['ziglang.org'] as string
140 const paths = platform === 'macos' ? ['bin/craft', 'cross/darwin-x64/craft'] : ['bin/craft', 'cross/windows-x64/craft.exe']
141 const discovered = paths.map((path, index) => {
142 const report = join(output, `native-${index}.cyclonedx.json`)
143 run([syft, join(binaryRoot, path), '-o', `cyclonedx-json=${report}`])
144 const inventory = JSON.parse(readFileSync(report, 'utf8')) as Inventory
145 if (inventory.bomFormat !== 'CycloneDX' || (inventory.components !== undefined && !Array.isArray(inventory.components)))
146 throw new Error(`${path}: invalid binary scanner inventory`)
147 return inventory.components ?? []
148 })
149 const inventory = nativeInventory(binaryRoot, platform, version, zigVersion, discovered)
150 const sbom = join(output, 'native.cyclonedx.json')
151 writeFileSync(sbom, `${JSON.stringify(inventory, null, 2)}\n`)
152 const summary = scanSbom(sbom, join(output, 'grype'), grype)
153 enforceScanPolicy(summary.counts, true)
154 const after = nativeInventory(binaryRoot, platform, version, zigVersion)
155 for (const [index, binary] of after.components.slice(0, 2).entries()) {
156 if (JSON.stringify(binary.hashes) !== JSON.stringify(inventory.components[index]!.hashes))
157 throw new Error(`${binary.name}: release binary changed after scanning`)
158 }
159 console.log(`Scanned ${platform} release binaries and declared Zig runtime dependencies`)
160}
161
162if (import.meta.main) {
163 const args = Bun.argv.slice(2)
164 if (args.length !== 3 || args.some(arg => !arg))
165 throw new Error('Usage: scan-release-artifacts.ts <npm|macos|linux> <input> <output>')
166 const [mode, input, output] = args as [string, string, string]
167 const syft = process.env.CRAFT_SYFT ?? 'syft'
168 const grype = process.env.CRAFT_GRYPE ?? 'grype'
169 if (mode === 'npm') scanNpm(resolve(input), resolve(output), syft, grype)
170 else if (mode === 'macos' || mode === 'linux') scanNative(resolve(input), mode, resolve(output), syft, grype)
171 else throw new Error(`Unknown release artifact mode: ${mode}`)
172}
scripts/scan-sbom.tsmodified+1-1
Changes to scripts/scan-sbom.ts
@@ -58,7 +58,7 @@ if (import.meta.main) {
5858 const setting = process.env.CRAFT_FAIL_HIGH ?? 'false'
5959 if (!['true', 'false'].includes(setting)) throw new Error('CRAFT_FAIL_HIGH must be true or false')
6060 const { counts } = scanSbom(process.argv[2] || 'sbom/full-sbom.cyclonedx.json', process.argv[3] || 'scan-results')
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')
61 const summary = ['## Vulnerability scan results', '', '| Severity | Count |', '| --- | --- |', ...severities.map(severity => `| ${severity} | ${counts[severity]} |`), '', 'Policy: Critical findings fail every scan. High findings in this full-source scan remain review items; release artifacts and production dependencies have a separate High gate (#279).', ''].join('\n')
6262 console.log(summary)
6363 if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
6464 if (counts.High > 0 && setting === 'false') console.warn('::warning::High vulnerability findings require review under #279')