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