79 lines
4.4 KiB
TypeScript
79 lines
4.4 KiB
TypeScript
// 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);
|
|
});
|