storebound/tests/privacy-scan.test.ts

82 lines
5 KiB
TypeScript

// tools/privacy-scan.ts: the release gate over every desktop artifact. Made-up terms only (the real ones live in a
// file outside the repository).
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { collectTerms, kindOf, mask, parseTerms, scan, scanBuffer, squashfsOffset, type Term, UPSTREAM_PATHS } from '../tools/privacy-scan';
const T: Term[] = [{ text: 'Quillfeather', short: false }, { text: 'qz', short: true }];
// home folders are put together at run time, so tools/scrub.ts (which forbids them in the repository) passes
const home = (root: string, name: string): string => `${root}${name}`;
describe('privacy scan', () => {
test('terms: comments and blanks skipped, never printed in full', () => {
expect(parseTerms('# names\nQuillfeather\n\n qz \n')).toEqual(['Quillfeather', 'qz']);
expect(mask('Quillfeather')).toBe('Q***');
const dir = mkdtempSync(join(tmpdir(), 'sb-terms-'));
writeFileSync(join(dir, 't.txt'), `Quillfeather\nCRT\n${['hi', 'crt.fyi'].join('@')}\n`);
// the release identity is never a forbidden term
expect(collectTerms(join(dir, 't.txt'), false).map(t => t.text)).toEqual(['Quillfeather']);
});
test('finds a term in any case, in UTF-8 and UTF-16LE', () => {
const hits = scanBuffer('bin', Buffer.concat([Buffer.from('xx QUILLFEATHER yy'), Buffer.from('built by quillFeather', 'utf16le')]), T);
expect(hits.map(h => h.encoding)).toEqual(['utf8', 'utf16le']);
expect(hits.every(h => h.what === 'term Q***')).toBe(true);
expect(scanBuffer('bin', Buffer.from('nothing to see'), T)).toEqual([]);
});
test('a short term counts only in a path', () => {
expect(scanBuffer('bin', Buffer.from('jazz quiz qz-mode'), T)).toEqual([]);
expect(scanBuffer('bin', Buffer.from('/opt/qz/src'), T).some(h => h.what === 'term q***')).toBe(true);
});
test('home folders of any account, generic ones allowed', () => {
const hits = scanBuffer('bin', Buffer.from(`${home('/Users/', 'someone')}/work /home/runner/work ${home('C:\\Users\\', 'other')}\\x /Users/you/app /home/$USER`), []);
expect(hits.map(h => h.what)).toEqual(['home folder of an account (s***)', 'home folder of an account (o***)']);
});
test('upstream GTK icon paths pass, the same account elsewhere does not', () => {
const gtk = UPSTREAM_PATHS.map(u => `inkscape:export-filename="${u}/x.png"`).join(' ');
expect(scanBuffer('libgtk-3.so.0', Buffer.from(gtk), [])).toEqual([]);
const other = UPSTREAM_PATHS[0]!.split('/').slice(0, 3).join('/') + '/secret/x';
expect(scanBuffer('bin', Buffer.from(other), []).length).toBe(1);
});
test('folders, archives and debug files', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-scan-'));
mkdirSync(join(dir, 'pkg/My.app/Contents'), { recursive: true });
writeFileSync(join(dir, 'pkg/My.app/Contents/clean.txt'), 'all good');
writeFileSync(join(dir, 'pkg/My.app/Contents/leak.bin'), Buffer.from(`panic at ${home('/Users/', 'quillfeather')}/src/main.rs`, 'utf8'));
mkdirSync(join(dir, 'pkg/My.app.dSYM'));
expect(spawnSync('tar', ['-czf', join(dir, 'bundle.tar.gz'), '-C', join(dir, 'pkg'), '.']).status).toBe(0);
const r = scan([join(dir, 'bundle.tar.gz')], null, false);
expect(r.errors).toEqual([]);
const shown = r.hits.map(h => `${h.file}: ${h.what}`);
expect(shown.some(s => s.startsWith('bundle.tar.gz!/') && s.includes('leak.bin') && s.includes('home folder'))).toBe(true);
expect(shown.some(s => s.includes('My.app.dSYM: debug file'))).toBe(true);
expect(r.files).toBeGreaterThanOrEqual(3);
const clean = scan([join(dir, 'pkg/My.app/Contents/clean.txt')], null, false);
expect(clean.hits).toEqual([]);
expect(scan([join(dir, 'missing.dmg')], null, false).errors).toHaveLength(1);
});
test('container kinds', () => {
expect(kindOf('a/storebound_0.1.0_universal.dmg', new Uint8Array())).toBe('dmg');
expect(kindOf('storebound_0.1.0_universal.app.tar.gz', new Uint8Array())).toBe('tar');
expect(kindOf('storebound_0.1.0_amd64.deb', new Uint8Array())).toBe('deb');
expect(kindOf('storebound_0.1.0_amd64.AppImage', new Uint8Array())).toBe('appimage');
expect(kindOf('storebound_0.1.0_x64-setup.exe', Buffer.from('MZ....Nullsoft Install System'))).toBe('nsis');
expect(kindOf('storebound-desktop.exe', Buffer.from('MZ'))).toBe('file');
});
test('AppImage: the squashfs starts where the ELF runtime ends, not at an "hsqs" constant inside the runtime', () => {
const img = Buffer.alloc(0x1000 + 16);
img.write('\x7fELF', 0, 'latin1'); img[4] = 2; img[5] = 1;
img.writeBigUInt64LE(0xc00n, 0x28); img.writeUInt16LE(64, 0x3a); img.writeUInt16LE(16, 0x3c); // 0xc00 + 64*16 = 0x1000
img.write('hsqs', 0x800, 'latin1'); img.write('hsqs', 0x1000, 'latin1');
expect(squashfsOffset(img)).toBe(0x1000);
const plain = Buffer.alloc(0x900); plain.write('hsqs', 0x800, 'latin1');
expect(squashfsOffset(plain)).toBe(0x800);
});
});