also looking at this
fix(release): prevent the standalone macOS CLI sandbox crash
#291
5 files
+120
-31
| @@ -67,6 +67,10 @@ jobs: | ||
| 67 | 67 | working-directory: packages/zig |
| 68 | 68 | run: zig build test |
| 69 | 69 | |
| 70 | - name: Verify standalone macOS signing entitlements | |
| 71 | if: runner.os == 'macOS' | |
| 72 | run: bun test scripts/macos-release.test.ts | |
| 73 | ||
| 70 | 74 | - name: Check binary size |
| 71 | 75 | if: runner.os == 'Linux' |
| 72 | 76 | run: | |
| @@ -237,14 +237,9 @@ jobs: | ||
| 237 | 237 | |
| 238 | 238 | - name: Sign macOS binary |
| 239 | 239 | if: runner.os == 'macOS' && env.SIGNING_AVAILABLE == 'true' |
| 240 | env: | |
| 241 | # Default to the strict distribution entitlements; CI can opt in to | |
| 242 | # the dev plist (sandbox off, library validation off) by setting | |
| 243 | # CRAFT_ENTITLEMENTS=dev for local debugging — never for release. | |
| 244 | ENTITLEMENTS_VARIANT: ${{ vars.CRAFT_ENTITLEMENTS || 'distribution' }} | |
| 245 | 240 | run: | |
| 246 | 241 | set -euo pipefail |
| 247 | ENTITLEMENTS="scripts/entitlements-${ENTITLEMENTS_VARIANT}.plist" | |
| 242 | ENTITLEMENTS="scripts/entitlements-distribution.plist" | |
| 248 | 243 | if [ ! -f "$ENTITLEMENTS" ]; then |
| 249 | 244 | echo "::error file=$ENTITLEMENTS::missing — required for hardened-runtime signing" |
| 250 | 245 | exit 1 |
| @@ -253,12 +248,21 @@ jobs: | ||
| 253 | 248 | for binary in \ |
| 254 | 249 | packages/zig/zig-out/bin/craft \ |
| 255 | 250 | packages/zig/zig-out/cross/darwin-x64/craft; do |
| 256 | codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" \ | |
| 251 | codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" \ | |
| 257 | 252 | --entitlements "$ENTITLEMENTS" "$binary" |
| 258 | 253 | codesign --verify --deep --strict "$binary" |
| 259 | 254 | done |
| 260 | 255 | echo "macOS ARM64 and x64 binaries signed successfully" |
| 261 | 256 | |
| 257 | - name: Verify standalone macOS launch and signatures | |
| 258 | if: runner.os == 'macOS' | |
| 259 | env: | |
| 260 | VERSION: ${{ steps.version.outputs.version }} | |
| 261 | run: | | |
| 262 | bun test scripts/macos-release.test.ts | |
| 263 | bun scripts/verify-macos-release.ts packages/zig/zig-out/bin/craft "$VERSION" | |
| 264 | bun scripts/verify-macos-release.ts packages/zig/zig-out/cross/darwin-x64/craft "$VERSION" --no-execute | |
| 265 | ||
| 262 | 266 | - name: Notarize macOS binaries |
| 263 | 267 | if: runner.os == 'macOS' && env.SIGNING_AVAILABLE == 'true' |
| 264 | 268 | run: | |
| @@ -399,6 +403,35 @@ jobs: | ||
| 399 | 403 | cat /tmp/index.out || true |
| 400 | 404 | fi |
| 401 | 405 | |
| 406 | verify-macos-downloads: | |
| 407 | name: verify downloaded ${{ matrix.platform.name }} | |
| 408 | needs: [pantry] | |
| 409 | if: ${{ !cancelled() }} | |
| 410 | strategy: | |
| 411 | fail-fast: false | |
| 412 | matrix: | |
| 413 | platform: | |
| 414 | - { os: macos-15, name: darwin-arm64 } | |
| 415 | - { os: macos-15-intel, name: darwin-x64 } | |
| 416 | runs-on: ${{ matrix.platform.os }} | |
| 417 | permissions: | |
| 418 | contents: read | |
| 419 | steps: | |
| 420 | - uses: actions/checkout@v6 | |
| 421 | - name: Setup Pantry | |
| 422 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 | |
| 423 | - name: Verify the final downloadable executable | |
| 424 | env: | |
| 425 | GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
| 426 | TAG: ${{ github.ref_name }} | |
| 427 | PLATFORM: ${{ matrix.platform.name }} | |
| 428 | run: | | |
| 429 | set -euo pipefail | |
| 430 | DOWNLOAD_DIR=$(mktemp -d) | |
| 431 | gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "craft-$PLATFORM.zip" --dir "$DOWNLOAD_DIR" | |
| 432 | ditto -x -k "$DOWNLOAD_DIR/craft-$PLATFORM.zip" "$DOWNLOAD_DIR/extracted" | |
| 433 | bun scripts/verify-macos-release.ts "$DOWNLOAD_DIR/extracted/craft" "${TAG#v}" | |
| 434 | ||
| 402 | 435 | npm: |
| 403 | 436 | name: npm |
| 404 | 437 | # Ordered after the binaries, not gated on them. |
| @@ -1,36 +1,20 @@ | ||
| 1 | 1 | <?xml version="1.0" encoding="UTF-8"?> |
| 2 | 2 | <!-- |
| 3 | Distribution entitlements for the Craft binary shipped to end users. | |
| 3 | Distribution entitlements for the standalone Craft CLI installed by Pantry. | |
| 4 | 4 | |
| 5 | We deliberately request the *minimum* set of capabilities here — the dev | |
| 6 | plist (`entitlements-dev.plist`) is what disables the sandbox and library | |
| 7 | validation for local-iteration ergonomics. End users should never run with | |
| 8 | those flags turned off, since that disables the macOS exploit-mitigation | |
| 9 | story we otherwise rely on. | |
| 5 | App Sandbox requires a bundle identity. This raw executable has no embedded | |
| 6 | Info.plist and must accept user-supplied paths from the shell; enabling App | |
| 7 | Sandbox here traps in libsecinit before main. Hardened runtime is enabled | |
| 8 | separately by codesign's runtime option, and library validation stays on. | |
| 10 | 9 | |
| 11 | If your app needs extra capabilities (camera, microphone, user-selected | |
| 12 | files, etc.), generate a custom plist via | |
| 13 | `generateEntitlements({ camera: true, ... })` from the SDK or fork this | |
| 14 | file in your own project. | |
| 10 | Application bundles have their own identity and entitlement policy, generated | |
| 11 | by the SDK. This plist is only for the standalone CLI release artifact. | |
| 15 | 12 | --> |
| 16 | 13 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 17 | 14 | <plist version="1.0"> |
| 18 | 15 | <dict> |
| 19 | <!-- Run inside the macOS app sandbox. --> | |
| 20 | <key>com.apple.security.app-sandbox</key> | |
| 21 | <true/> | |
| 22 | ||
| 23 | <!-- WebKit needs JIT for modern JS performance. Limited to the runtime | |
| 24 | WebView, not arbitrary code. --> | |
| 16 | <!-- Allow the runtime's JIT under hardened runtime. --> | |
| 25 | 17 | <key>com.apple.security.cs.allow-jit</key> |
| 26 | 18 | <true/> |
| 27 | ||
| 28 | <!-- Allow outbound HTTP. Required for the auto-updater and fetch API. --> | |
| 29 | <key>com.apple.security.network.client</key> | |
| 30 | <true/> | |
| 31 | ||
| 32 | <!-- Allow user-selected files (open dialog returns sandbox-scoped URLs). --> | |
| 33 | <key>com.apple.security.files.user-selected.read-write</key> | |
| 34 | <true/> | |
| 35 | 19 | </dict> |
| 36 | 20 | </plist> |
| @@ -0,0 +1,28 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { verifyMacosRelease } from './verify-macos-release' | |
| 6 | ||
| 7 | test.skipIf(process.platform !== 'darwin')('distribution entitlements allow a standalone signed CLI to start', () => { | |
| 8 | const temp = mkdtempSync(join(tmpdir(), 'craft-cli-signing-')) | |
| 9 | const run = (args: string[]): string => { | |
| 10 | const result = Bun.spawnSync(args, { cwd: temp, stdout: 'pipe', stderr: 'pipe' }) | |
| 11 | expect({ exitCode: result.exitCode, signal: result.signalCode, stderr: result.stderr.toString() }) | |
| 12 | .toMatchObject({ exitCode: 0 }) | |
| 13 | return result.stdout.toString() | |
| 14 | } | |
| 15 | try { | |
| 16 | writeFileSync(join(temp, 'main.c'), '#include <stdio.h>\nint main(void) { puts("craft version 7.8.9"); return 0; }\n') | |
| 17 | const binary = join(temp, 'craft') | |
| 18 | run(['xcrun', 'clang', 'main.c', '-o', binary]) | |
| 19 | run(['codesign', '--force', '--sign', '-', '--options', 'runtime', '--entitlements', resolve(import.meta.dir, 'entitlements-distribution.plist'), binary]) | |
| 20 | run(['codesign', '--verify', '--deep', '--strict', binary]) | |
| 21 | expect(run([binary, '--version']).trim()).toBe('craft version 7.8.9') | |
| 22 | expect(() => verifyMacosRelease(binary, '7.8.9')).not.toThrow() | |
| 23 | expect(() => verifyMacosRelease(binary, '7.8.10')).toThrow('Expected craft version 7.8.10') | |
| 24 | } | |
| 25 | finally { | |
| 26 | rmSync(temp, { recursive: true, force: true }) | |
| 27 | } | |
| 28 | }, 30_000) | |
| @@ -0,0 +1,40 @@ | ||
| 1 | import { resolve } from 'node:path' | |
| 2 | ||
| 3 | function command(args: string[]): string { | |
| 4 | const result = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }) | |
| 5 | if (result.exitCode !== 0) | |
| 6 | throw new Error(`${args.join(' ')} failed (${result.signalCode ?? result.exitCode})\n${result.stdout}\n${result.stderr}`) | |
| 7 | return `${result.stdout}\n${result.stderr}` | |
| 8 | } | |
| 9 | ||
| 10 | export function verifyMacosRelease(binary: string, version: string, execute = true): void { | |
| 11 | if (process.platform !== 'darwin') | |
| 12 | throw new Error('macOS release verification requires macOS trust services') | |
| 13 | command(['codesign', '--verify', '--deep', '--strict', '--verbose=4', binary]) | |
| 14 | const metadata = command(['codesign', '--display', '--verbose=4', binary]) | |
| 15 | if (!metadata.includes('Signature=adhoc')) { | |
| 16 | if (!metadata.includes('Authority=Developer ID Application:') || !metadata.includes('Timestamp=')) | |
| 17 | throw new Error('Developer ID release must have a certificate chain and secure timestamp') | |
| 18 | if (!/flags=.*\bruntime\b/.test(metadata)) | |
| 19 | throw new Error('Developer ID release must enable hardened runtime') | |
| 20 | } | |
| 21 | const entitlements = command(['codesign', '--display', '--entitlements', '-', '--xml', binary]) | |
| 22 | for (const key of ['com.apple.security.app-sandbox', 'com.apple.security.cs.disable-library-validation', 'com.apple.security.cs.allow-unsigned-executable-memory']) { | |
| 23 | const escaped = key.replaceAll('.', '\\.') | |
| 24 | if (new RegExp(`<key>${escaped}</key>\\s*<true\\s*/>`).test(entitlements)) | |
| 25 | throw new Error(`Standalone release must not enable ${key}`) | |
| 26 | } | |
| 27 | if (execute) { | |
| 28 | const output = command([binary, '--version']) | |
| 29 | if (output.trim().split('\n')[0] !== `craft version ${version}`) | |
| 30 | throw new Error(`Expected craft version ${version}, received: ${output}`) | |
| 31 | } | |
| 32 | console.log(`Verified ${binary}${execute ? ` launches as ${version}` : ' signature and entitlements'}`) | |
| 33 | } | |
| 34 | ||
| 35 | if (import.meta.main) { | |
| 36 | const [binary, version, option] = Bun.argv.slice(2) | |
| 37 | if (!binary || !version || (option !== undefined && option !== '--no-execute')) | |
| 38 | throw new Error('Usage: bun scripts/verify-macos-release.ts <binary> <version> [--no-execute]') | |
| 39 | verifyMacosRelease(resolve(binary), version, option !== '--no-execute') | |
| 40 | } | |