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

This commit is contained in:
CRTkafa 2026-10-14 08:21:35 +08:00
parent 3a27c78f49
commit ae681e3ecd
6 changed files with 228 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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);
});

View file

@ -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`);

113
tools/mac-signature-scan.ts Normal file
View file

@ -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);

View file

@ -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; }