From ae681e3ecd335f22d872d2d9899bdea1aad725ea Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:21:35 +0800 Subject: [PATCH] release: privacy scan of signed macOS artifacts leaves out Apple code signatures only (copies with codesign --remove-signature on every Mach-O, the DMG's koly signature blob zeroed); any other hit still fails; tests and docs --- CHANGELOG.md | 3 + docs/desktop-release.md | 7 ++ tests/mac-signature-scan.test.ts | 79 +++++++++++++++++++++ tools/desktop-release.ts | 24 ++++++- tools/mac-signature-scan.ts | 113 +++++++++++++++++++++++++++++++ tools/privacy-scan.ts | 5 +- 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 tests/mac-signature-scan.test.ts create mode 100644 tools/mac-signature-scan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf2279..efa261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ prompt of `storebound desktop` crashed on Windows). Node at the question with "console is not async iterable" (Windows also hit a libuv assertion at exit): prompts now read stdin through `rt.readLine` (node:readline, closed after one line, EOF is "no"). A test keeps Bun-only stdin iteration out of cli/, and the Windows release job runs the Node prompt smoke on the runner. +- **Release privacy scan of signed macOS builds leaves out Apple code signatures only.** The Developer ID certificate + in a signature names the developer; the .app, DMG and updater bundle are scanned as copies without signatures, and + any hit elsewhere still fails the release. ## 0.8.2 ยท 2026-10-14 diff --git a/docs/desktop-release.md b/docs/desktop-release.md index c0f42de..713a20b 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -110,6 +110,13 @@ it, never copy it to a runner. The Forgejo workflow builds unsigned installers, or 7-Zip) and NSIS (7-Zip). - **Debug files:** .dSYM, .pdb and source maps fail the scan. - **Exit codes:** 1 when it finds something, 2 when it cannot open an archive. A term is never printed in full. +- **Signed macOS artifacts are scanned without their code signatures** (`tools/mac-signature-scan.ts`, used by + `desktop-release.ts` for the .app, the DMG and the updater bundle). A Developer ID signature embeds the signing + certificate, and its subject is the developer's legal name, which may be in the terms file. That name may appear + inside Apple code signatures and nowhere else, so the scan reads copies: `codesign --remove-signature` on every + Mach-O file of the copied app (also the app copied out of the mounted DMG and the one unpacked from the updater + bundle), and the DMG's own signature blob (the range the UDIF `koly` trailer names) zeroed in memory. Every other + byte is scanned as it ships; a hit outside a signature still fails the release (tests/mac-signature-scan.test.ts). **Path-free builds.** `tools/desktop-release.ts` builds with `--remap-path-prefix` and `-ffile-prefix-map`, which turn home into `~`, the checkout into `/src`, the cargo and rustup homes into `/cargo` and `/rustup`, and diff --git a/tests/mac-signature-scan.test.ts b/tests/mac-signature-scan.test.ts new file mode 100644 index 0000000..efb7b7a --- /dev/null +++ b/tests/mac-signature-scan.test.ts @@ -0,0 +1,79 @@ +// The privacy scan of signed macOS artifacts leaves out Apple code signatures only (tools/mac-signature-scan.ts): +// a term inside a signature passes, the same term anywhere else (a resource, the binary's own bytes) fails. +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { blankDmgSignature, dmgSignatureRange, isMachO, scanSignedMac } from '../tools/mac-signature-scan'; +import { type Report, scanPath, type Term } from '../tools/privacy-scan'; + +const TERM = 'Qzvfakecorp'; +const terms: Term[] = [{ text: TERM, short: false }]; +const mac = process.platform === 'darwin'; +const dir = mkdtempSync(join(tmpdir(), 'sb-macsig-test-')); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +const empty = (): Report => ({ files: 0, bytes: 0, hits: [], errors: [] }); +const ok = (cmd: string[]) => { const r = spawnSync(cmd[0]!, cmd.slice(1), { encoding: 'utf8' }); if (r.status !== 0) throw new Error(`${cmd.join(' ')}: ${r.stderr}`); }; + +/** A fake app whose executable is signed with the term in its signature (the identifier lives in the CodeDirectory). */ +function fakeApp(name: string, resource?: string): string { + const app = join(dir, name, 'Fake.app'); + mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true }); + mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true }); + copyFileSync('/usr/bin/true', join(app, 'Contents', 'MacOS', 'fake')); + ok(['codesign', '-f', '-s', '-', '-i', `com.${TERM}.fake`, join(app, 'Contents', 'MacOS', 'fake')]); + writeFileSync(join(app, 'Contents', 'Resources', 'about.txt'), resource ?? 'nothing private here\n'); + return app; +} + +describe.skipIf(!mac)('signed macOS artifacts: signatures left out of the privacy scan', () => { + test('the term only in a code signature: the plain scan finds it, the signed-mac scan passes', () => { + const app = fakeApp('sig-only'); + const bin = join(app, 'Contents', 'MacOS', 'fake'); + expect(isMachO(readFileSync(bin).subarray(0, 4))).toBe(true); + const plain = empty(); + scanPath(app, terms, 'Fake.app', plain); + expect(plain.hits.length).toBeGreaterThan(0); + const r = scanSignedMac(app, terms, empty()); + expect(r.errors).toEqual([]); + expect(r.hits).toEqual([]); + // the original stays signed (only a copy is stripped) + ok(['codesign', '--verify', bin]); + }); + + test('the same term in a resource file still fails', () => { + const app = fakeApp('in-resource', `made by ${TERM}\n`); + const r = scanSignedMac(app, terms, empty()); + expect(r.hits.map(h => h.file)).toEqual(['Fake.app/Contents/Resources/about.txt']); + }); + + test('updater .app.tar.gz: the unpacked app is stripped, a resource hit fails', () => { + const good = fakeApp('tar-good'), bad = fakeApp('tar-bad', `${TERM}\n`); + const tg = join(dir, 'good.app.tar.gz'), tb = join(dir, 'bad.app.tar.gz'); + ok(['tar', '-czf', tg, '-C', join(good, '..'), 'Fake.app']); + ok(['tar', '-czf', tb, '-C', join(bad, '..'), 'Fake.app']); + expect(scanSignedMac(tg, terms, empty()).hits).toEqual([]); + expect(scanSignedMac(tb, terms, empty()).hits.map(h => h.file)).toEqual(['bad.app.tar.gz!/Fake.app/Contents/Resources/about.txt']); + }); + + test('DMG: its own signature blob is zeroed, the term in the volume fails', () => { + const src = join(dir, 'dmg-src'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'readme.txt'), 'hello\n'); + const dmg = join(dir, 'x.dmg'); + ok(['hdiutil', 'create', '-quiet', '-fs', 'HFS+', '-srcfolder', src, '-format', 'UDZO', '-ov', dmg]); + ok(['codesign', '-f', '-s', '-', '-i', `com.${TERM}.dmg`, dmg]); + const data = readFileSync(dmg); + const range = dmgSignatureRange(data); + expect(range).not.toBeNull(); + expect(data.subarray(range!.offset, range!.offset + range!.length).includes(Buffer.from(TERM))).toBe(true); + expect(Buffer.from(blankDmgSignature(data)).includes(Buffer.from(TERM))).toBe(false); + expect(scanSignedMac(dmg, terms, empty()).hits).toEqual([]); + + writeFileSync(join(src, 'readme.txt'), `by ${TERM}\n`); + const dmg2 = join(dir, 'y.dmg'); + ok(['hdiutil', 'create', '-quiet', '-fs', 'HFS+', '-srcfolder', src, '-format', 'UDZO', '-ov', dmg2]); + expect(scanSignedMac(dmg2, terms, empty()).hits.map(h => h.file)).toEqual(['y.dmg!/readme.txt']); + }, 60_000); +}); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 68519ac..a30ee8d 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -41,7 +41,8 @@ import { verify as verifySig } from '../cli/lib/minisign'; import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars'; import { versions } from './desktop-version'; import { upsert as forgejoUpsert } from './forgejo-release'; -import { scan } from './privacy-scan'; +import { isMacArtifact, scanSignedMac } from './mac-signature-scan'; +import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan'; export const ROOT = resolve(import.meta.dir, '..'); const APP_DIR = join(ROOT, 'apps/desktop'); @@ -187,8 +188,27 @@ function signFile(file: string): string { return sig; } +/** + * The privacy scan of release artifacts. Signed macOS artifacts (.app, .dmg, .app.tar.gz) are scanned without their + * Apple code signatures (tools/mac-signature-scan.ts): the Developer ID certificate in them names the developer, and + * that name may appear there only. Every other byte is scanned; a hit outside a signature fails the release. + */ function privacyGate(paths: string[]): { files: number; bytes: number; hits: number } { - const r = scan(paths); + const mac = process.platform === 'darwin' ? paths.filter(isMacArtifact) : []; + const r = scan(paths.filter(p => !mac.includes(p))); + if (mac.length) { + const termsFile = process.env[TERMS_ENV] ?? DEFAULT_TERMS; + const terms = allTerms(termsFile); + const m: Report = { files: 0, bytes: 0, hits: [], errors: [] }; + for (const p of mac) { + if (!existsSync(p)) { m.errors.push(`${p}: does not exist`); continue; } + try { scanSignedMac(p, terms, m); } catch (e) { m.errors.push((e as Error).message); } + } + r.files += m.files; r.bytes += m.bytes; r.hits.push(...m.hits); r.errors.push(...m.errors); + r.terms = terms.length; + if (!r.termsFile && existsSync(termsFile)) r.termsFile = termsFile; + say(`privacy: ${mac.length} signed macOS artifact(s) scanned without their code signatures (the Developer ID certificate may name the developer there only)`); + } for (const h of r.hits) console.error(`privacy: ${h.file} @${h.offset}${h.encoding ? ` (${h.encoding})` : ''}: ${h.what}`); for (const e of r.errors) console.error(`privacy: could not open ${e}`); if (r.hits.length || r.errors.length) fail(`privacy scan failed (${r.hits.length} hits, ${r.errors.length} errors): nothing may ship`); diff --git a/tools/mac-signature-scan.ts b/tools/mac-signature-scan.ts new file mode 100644 index 0000000..e7a2d77 --- /dev/null +++ b/tools/mac-signature-scan.ts @@ -0,0 +1,113 @@ +// Privacy scan of signed macOS artifacts with Apple's code signatures left out. +// +// A Developer ID signature embeds the signing certificate, and its subject is the developer's legal (company) name. +// That name may appear there and nowhere else. So the signed .app, DMG and updater bundle are scanned as copies with +// the code signatures removed; every other byte is scanned as it ships, and a hit outside a signature still fails. +// - Mach-O files (main binary, sidecar, helpers, dylibs): `codesign --remove-signature` on a copy; +// - the DMG's own bytes: the code-signature SuperBlob the UDIF trailer ("koly") points to is zeroed in memory +// (codesign cannot remove a DMG signature); its volume is copied out of a read-only mount and stripped like an app; +// - the updater .app.tar.gz: its compressed bytes as they are, then the unpacked app, stripped. +import { spawnSync } from 'node:child_process'; +import { closeSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { type Report, scanBuffer, scanPath, type Term } from './privacy-scan'; + +const MACHO = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca']); +const SUPERBLOB = 0xfade0cc0; + +export function isMachO(head: Uint8Array): boolean { + return head.length >= 4 && MACHO.has(Buffer.from(head.subarray(0, 4)).toString('hex')); +} + +function head4(file: string): Uint8Array { + const fd = openSync(file, 'r'); + try { const b = Buffer.alloc(4); const n = readSync(fd, b, 0, 4, 0); return b.subarray(0, n); } finally { closeSync(fd); } +} + +function sh(cmd: string[], what: string): void { + const r = spawnSync(cmd[0]!, cmd.slice(1), { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`${what}: ${basename(cmd[0]!)} failed: ${(r.stderr || '').trim().split('\n').at(-1)}`); +} + +/** Removes the code signature of every Mach-O file under dir (a copy); returns how many were stripped. */ +export function stripMachOSignatures(dir: string): number { + let n = 0; + const walk = (p: string) => { + const st = lstatSync(p); + if (st.isSymbolicLink()) return; + if (st.isDirectory()) { for (const e of readdirSync(p)) walk(join(p, e)); return; } + if (!st.isFile() || st.size < 4 || !isMachO(head4(p))) return; + const r = spawnSync('codesign', ['--remove-signature', p], { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' }); + // an unsigned Mach-O (nothing to remove) is fine; anything else is an error + if (r.status !== 0 && !/not signed|no signature/i.test(r.stderr || '')) throw new Error(`${p}: codesign --remove-signature failed: ${(r.stderr || '').trim()}`); + if (r.status === 0) n++; + }; + walk(dir); + return n; +} + +/** The code-signature range of a UDIF disk image (koly trailer, offset 0x128), or null. */ +export function dmgSignatureRange(data: Uint8Array): { offset: number; length: number } | null { + if (data.length < 512) return null; + const k = Buffer.from(data.buffer, data.byteOffset + data.length - 512, 512); + if (k.toString('latin1', 0, 4) !== 'koly') return null; + const offset = Number(k.readBigUInt64BE(0x128)), length = Number(k.readBigUInt64BE(0x130)); + if (!length || offset + length > data.length - 512 || offset < 0) return null; + if (Buffer.from(data.buffer, data.byteOffset + offset, 4).readUInt32BE(0) !== SUPERBLOB) return null; + return { offset, length }; +} + +/** A copy of a DMG's bytes with its code-signature SuperBlob zeroed (unchanged when it has none). */ +export function blankDmgSignature(data: Uint8Array): Uint8Array { + const r = dmgSignatureRange(data); + if (!r) return data; + const out = Buffer.from(data); + out.fill(0, r.offset, r.offset + r.length); + return out; +} + +const tmp = (p: string) => mkdtempSync(join(tmpdir(), p)); + +/** Scans one signed macOS artifact (.app folder, .dmg, .app.tar.gz) without its code signatures; other paths as usual. */ +export function scanSignedMac(path: string, terms: Term[], report: Report): Report { + if (process.platform !== 'darwin') throw new Error('the signed macOS scan needs macOS (codesign, hdiutil, ditto)'); + const name = basename(path); + const work = tmp('sb-privacy-mac-'); + try { + if (lstatSync(path).isDirectory()) { + const copy = join(work, name); + sh(['ditto', path, copy], name); + stripMachOSignatures(copy); + return scanPath(copy, terms, name, report); + } + if (/\.dmg$/i.test(name)) { + const data = readFileSync(path); + report.files++; + report.bytes += data.length; + report.hits.push(...scanBuffer(name, blankDmgSignature(data), terms)); + const mnt = join(work, 'mnt'), vol = join(work, 'vol'); + sh(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noverify', '-noautoopen', '-mountpoint', mnt, path], name); + try { sh(['ditto', mnt, vol], name); } finally { spawnSync('hdiutil', ['detach', mnt, '-force'], { stdio: 'ignore' }); } + stripMachOSignatures(vol); + return scanPath(vol, terms, `${name}!`, report); + } + if (/\.(tar\.gz|tgz)$/i.test(name)) { + const data = readFileSync(path); + report.files++; + report.bytes += data.length; + report.hits.push(...scanBuffer(name, data, terms)); + const x = join(work, 'x'); + sh(['mkdir', '-p', x], name); + sh(['tar', '-xzf', path, '-C', x], name); + stripMachOSignatures(x); + return scanPath(x, terms, `${name}!`, report); + } + return scanPath(path, terms, name, report); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +/** True for the artifacts scanSignedMac handles specially. */ +export const isMacArtifact = (path: string): boolean => /\.app\/?$|\.dmg$|\.app\.tar\.gz$/i.test(path); diff --git a/tools/privacy-scan.ts b/tools/privacy-scan.ts index 7cffca5..8ca90a5 100644 --- a/tools/privacy-scan.ts +++ b/tools/privacy-scan.ts @@ -247,8 +247,11 @@ export function scanPath(path: string, terms: Term[], shown = basename(path), re return report; } +/** Every term a scan looks for: the terms file, this machine's names (auto) and scrub's private-name rule. */ +export const allTerms = (termsFile: string | null, auto = true): Term[] => [...collectTerms(termsFile, auto), ...scrubTerms()]; + export function scan(paths: string[], termsFile: string | null = process.env[TERMS_ENV] ?? DEFAULT_TERMS, auto = true): Report & { terms: number; termsFile: string | null } { - const terms = [...collectTerms(termsFile, auto), ...scrubTerms()]; + const terms = allTerms(termsFile, auto); const report: Report = { files: 0, bytes: 0, hits: [], errors: [] }; for (const p of paths) { if (!existsSync(p)) { report.errors.push(`${p}: does not exist`); continue; }