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

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