also looking at this
Gate releases on shipped artifact findings
#316
11 files
+454
-25
| @@ -0,0 +1,35 @@ | ||
| 1 | name: Set up release scanners | |
| 2 | description: Install pinned Syft and Grype binaries for release artifact checks | |
| 3 | runs: | |
| 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" | |
| @@ -278,7 +278,7 @@ jobs: | ||
| 278 | 278 | run: zig fmt --check src/ build.zig |
| 279 | 279 | |
| 280 | 280 | - 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 | |
| 282 | 282 | |
| 283 | 283 | - name: Audit Zig C imports |
| 284 | 284 | run: bun run verify:cimports |
| @@ -26,7 +26,9 @@ jobs: | ||
| 26 | 26 | needs: validate-release |
| 27 | 27 | uses: ./.github/workflows/sbom.yml |
| 28 | 28 | 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 | |
| 30 | 32 | |
| 31 | 33 | pantry: |
| 32 | 34 | # The reusable SBOM workflow includes the vulnerability scan. Publishing |
| @@ -305,7 +307,13 @@ jobs: | ||
| 305 | 307 | --timeout 30m |
| 306 | 308 | codesign --verify --deep --strict "$binary" |
| 307 | 309 | 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" | |
| 309 | 317 | |
| 310 | 318 | # ── Publish & Release ────────────────────────────── |
| 311 | 319 | # Pantry action handles everything: |
| @@ -520,9 +528,10 @@ jobs: | ||
| 520 | 528 | # Nothing here consumes the binary job's artifacts: this checks out, runs |
| 521 | 529 | # `bun install`, and publishes packages built from TypeScript. Signing a Zig |
| 522 | 530 | # 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' }} | |
| 526 | 535 | runs-on: ubuntu-latest |
| 527 | 536 | permissions: |
| 528 | 537 | contents: write |
| @@ -535,16 +544,29 @@ jobs: | ||
| 535 | 544 | |
| 536 | 545 | - name: Setup Pantry (provides Bun, Zig, etc.) |
| 537 | 546 | 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 | |
| 538 | 552 | - name: Install Dependencies |
| 539 | 553 | run: bun install --frozen-lockfile |
| 540 | 554 | |
| 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 | ||
| 541 | 560 | - name: Test package regressions |
| 542 | 561 | run: bun run test:packages |
| 543 | 562 | |
| 544 | 563 | - 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" | |
| 546 | 568 | |
| 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" | |
| 549 | 571 | env: |
| 550 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 572 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| @@ -13,12 +13,17 @@ Each attempt removes previous JSON and text reports before validating its input. | ||
| 13 | 13 | A failed rerun therefore cannot attach a previous successful summary alongside |
| 14 | 14 | new diagnostics. Keep the input SBOM separate from the scanner's output files. |
| 15 | 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 | |
| 16 | Critical findings fail the full-repository scan and block both native and npm | |
| 17 | publishing. High findings in that broad inventory emit an explicit warning and | |
| 18 | remain release-review items. Before publication, a separate scan blocks High | |
| 19 | or Critical findings in the built native binaries and in the exact packed npm | |
| 20 | archives with their resolved production dependencies. A green full-repository | |
| 18 | 21 | scan is **not** release approval. Issue [#279](https://github.com/craft-native/craft/issues/279) |
| 19 | 22 | tracks the reviewed High/Critical policy and shipping-scope analysis; issue |
| 20 | 23 | [#276](https://github.com/craft-native/craft/issues/276) tracks locked dependency |
| 21 | advisories. No baseline or vulnerability exemption is added by this workflow. | |
| 24 | advisories. Native binary scanning records exact hashes and detected components, | |
| 25 | but static Zig code may not be identifiable from the binary alone; retain the | |
| 26 | full-source review. No baseline or vulnerability exemption is added by this workflow. | |
| 22 | 27 | |
| 23 | 28 | Syft and Grype Linux x64 downloads are pinned by release version and SHA-256 in |
| 24 | 29 | `.github/workflows/sbom.yml`. Tool updates must update both the asset URL and its |
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | import { manifestTargets, verifyArchiveTargets } from './npm-packages' | |
| 2 | import { createHash } from 'node:crypto' | |
| 3 | import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { manifestTargets, verifyArchiveTargets, verifyNpmPackages } from './npm-packages' | |
| 3 | 7 | |
| 4 | 8 | test('rejects absent built entries even when the manifest advertises them', () => { |
| 5 | 9 | 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 | ||
| 31 | 35 | for (const target of ['../outside.js', '/outside.js', './dist/../outside.js', '.\\outside.js', './dist/*.js']) |
| 32 | 36 | expect(() => manifestTargets({ name: 'fixture', exports: target })).toThrow() |
| 33 | 37 | }) |
| 38 | ||
| 39 | test('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) | |
| @@ -1,9 +1,11 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' | |
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' | |
| 2 | 3 | import { tmpdir } from 'node:os' |
| 3 | import { dirname, join, resolve } from 'node:path' | |
| 4 | import { dirname, join, resolve, sep } from 'node:path' | |
| 4 | 5 | |
| 5 | 6 | type Manifest = { |
| 6 | 7 | name: string |
| 8 | version: string | |
| 7 | 9 | private?: boolean |
| 8 | 10 | scripts?: Record<string, string> |
| 9 | 11 | peerDependencies?: Record<string, string> |
| @@ -76,10 +78,18 @@ function importSpecifiers(pkg: Manifest): string[] { | ||
| 76 | 78 | return [pkg.name] |
| 77 | 79 | } |
| 78 | 80 | |
| 79 | export async function verifyNpmPackages(buildOnly = false): Promise<void> { | |
| 81 | export async function verifyNpmPackages(buildOnly = false, archiveDir?: string): Promise<void> { | |
| 80 | 82 | const packages = publicPackages() |
| 81 | 83 | if (packages.length === 0) |
| 82 | 84 | 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 | } | |
| 83 | 93 | const temp = mkdtempSync(join(tmpdir(), 'craft-npm-verify-')) |
| 84 | 94 | try { |
| 85 | 95 | for (const { dir, pkg } of packages) { |
| @@ -95,12 +105,17 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> { | ||
| 95 | 105 | return |
| 96 | 106 | |
| 97 | 107 | const specifiers: string[] = [] |
| 108 | const archives: { name: string, version: string, file: string, sha256: string }[] = [] | |
| 98 | 109 | 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) | |
| 100 | 112 | run([process.execPath, 'pm', 'pack', '--filename', archive], dir) |
| 101 | 113 | const entries = run(['tar', '-tzf', archive], temp).trim().split('\n') |
| 102 | 114 | const packed: Manifest = JSON.parse(run(['tar', '-xOzf', archive, 'package/package.json'], temp)) |
| 103 | 115 | 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') }) | |
| 104 | 119 | const installed = join(temp, 'node_modules', pkg.name) |
| 105 | 120 | mkdirSync(installed, { recursive: true }) |
| 106 | 121 | run(['tar', '-xzf', archive, '--strip-components=1', '-C', installed], temp) |
| @@ -133,6 +148,8 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> { | ||
| 133 | 148 | '--target', 'esnext', '--skipLibCheck', '--ignoreConfig', |
| 134 | 149 | '--typeRoots', join(root, 'node_modules/@types'), |
| 135 | 150 | ], temp) |
| 151 | if (output) | |
| 152 | writeFileSync(join(output, 'manifest.json'), `${JSON.stringify({ packages: archives }, null, 2)}\n`) | |
| 136 | 153 | console.log(`Verified ${packages.length} npm archives and ${specifiers.length} installed import/type entry points.`) |
| 137 | 154 | } |
| 138 | 155 | finally { |
| @@ -140,5 +157,9 @@ export async function verifyNpmPackages(buildOnly = false): Promise<void> { | ||
| 140 | 157 | } |
| 141 | 158 | } |
| 142 | 159 | |
| 143 | if (import.meta.main) | |
| 144 | await verifyNpmPackages(Bun.argv.includes('--build-only')) | |
| 160 | if (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 | } | |
| @@ -0,0 +1,42 @@ | ||
| 1 | import { join, resolve } from 'node:path' | |
| 2 | import { readArchiveManifest } from './scan-release-artifacts' | |
| 3 | ||
| 4 | type Command = (args: string[]) => { exitCode: number | null, stdout: string, stderr: string } | |
| 5 | ||
| 6 | const 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 | ||
| 11 | export 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 | ||
| 38 | if (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 | } | |
| @@ -19,8 +19,10 @@ const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? | ||
| 19 | 19 | test('every publishing path depends on release identity validation', () => { |
| 20 | 20 | for (const name of ['pantry', 'npm', 'release-sbom', 'verify-release', 'verify-macos-downloads']) |
| 21 | 21 | 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']) | |
| 23 | 23 | expect(release.jobs[name].if).toBe("$") |
| 24 | expect(release.jobs.npm.if).toBe("$") | |
| 25 | expect(needs(release.jobs.npm)).toContain('release-sbom') | |
| 24 | 26 | }) |
| 25 | 27 | |
| 26 | 28 | test('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 | ||
| 34 | 36 | |
| 35 | 37 | test('npm publication follows artifact validation, and macOS downloads run on both matching architectures', () => { |
| 36 | 38 | 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"') | |
| 38 | 42 | 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) | |
| 40 | 54 | expect(release.jobs['verify-macos-downloads'].strategy?.matrix.platform).toEqual([ |
| 41 | 55 | { os: 'macos-15', name: 'darwin-arm64' }, |
| 42 | 56 | { os: 'macos-15-intel', name: 'darwin-x64' }, |
| 43 | 57 | ]) |
| 44 | 58 | expect(steps(release.jobs['verify-macos-downloads'])).toContain('scripts/verify-macos-release.ts') |
| 45 | 59 | 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) | |
| 47 | 61 | expect(needs(release.jobs.pantry)).toContain('release-sbom') |
| 48 | 62 | expect(steps(release.jobs.pantry)).toContain('bun install --frozen-lockfile') |
| 49 | 63 | expect(steps(release.jobs.npm)).toContain('bun install --frozen-lockfile') |
| @@ -0,0 +1,98 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { createHash } from 'node:crypto' | |
| 3 | import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { publishNpmArchives } from './publish-npm-archives' | |
| 7 | import { expectedNpmPackages, nativeInventory, readArchiveManifest, summarizeBunAudit } from './scan-release-artifacts' | |
| 8 | ||
| 9 | const roots: string[] = [] | |
| 10 | afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) | |
| 11 | const fixture = () => { | |
| 12 | const root = mkdtempSync(join(tmpdir(), 'craft-release-artifacts-test-')) | |
| 13 | roots.push(root) | |
| 14 | return root | |
| 15 | } | |
| 16 | ||
| 17 | function 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 | ||
| 28 | test('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 | ||
| 43 | test('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 | ||
| 51 | test('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 | ||
| 61 | function 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 | ||
| 69 | test('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 | ||
| 82 | test('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 | }) | |
| @@ -0,0 +1,172 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { enforceScanPolicy, scanSbom } from './scan-sbom' | |
| 6 | ||
| 7 | type Archive = { name: string, version: string, file: string, sha256: string } | |
| 8 | type Component = { name: string, version?: string, 'bom-ref'?: string, [key: string]: unknown } | |
| 9 | type Inventory = { bomFormat: string, components: Component[], [key: string]: unknown } | |
| 10 | ||
| 11 | const root = resolve(import.meta.dir, '..') | |
| 12 | const sha256 = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex') | |
| 13 | ||
| 14 | function 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 | ||
| 21 | export 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 | ||
| 33 | export 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 | ||
| 56 | export 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 | ||
| 72 | function 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 | ||
| 111 | export 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 | ||
| 136 | function 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 | ||
| 162 | if (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 | } | |
| @@ -58,7 +58,7 @@ if (import.meta.main) { | ||
| 58 | 58 | const setting = process.env.CRAFT_FAIL_HIGH ?? 'false' |
| 59 | 59 | if (!['true', 'false'].includes(setting)) throw new Error('CRAFT_FAIL_HIGH must be true or false') |
| 60 | 60 | 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') | |
| 62 | 62 | console.log(summary) |
| 63 | 63 | if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) |
| 64 | 64 | if (counts.High > 0 && setting === 'false') console.warn('::warning::High vulnerability findings require review under #279') |