gstack/test/egress-receipt.test.ts

259 lines
12 KiB
TypeScript

/**
* Egress receipts — chain, fail-closed, verify, shell bridge. Free tier, no network.
*
* THREAT MODEL: the egress ledger is forensic observability — it records
* ATTEMPTED egress so accidents are auditable; it is not an exfiltration
* control.
*
* Pins the auditor contract:
* - receipt-before-send fail-closed (EGRESS_RECEIPT_FAILED, no ledger = no send)
* - content-free lines chained by prev = sha256(previous raw line)
* - tail-read correctness (last line found without loading the whole file)
* - abandoned-lock reclaim (stale lock dir >10s old is removed, not fatal)
* - WARN-at-size (one self-explanatory stderr warning per process >25MB)
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import {
EGRESS_RECEIPT_FAILED,
LEDGER_WARN_BYTES,
egressLedgerPath,
ledgerSizeWarning,
listReceipts,
resetLedgerSizeWarningForTests,
sha256Hex,
verifyLedger,
writeOutcome,
writeReceipt,
} from '../lib/egress-receipt';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-'));
});
afterEach(() => {
try { fs.chmodSync(path.join(home, 'security'), 0o700); } catch {} // undo fail-closed fixtures
fs.rmSync(home, { recursive: true, force: true });
});
describe('egress receipt library', () => {
test('receipts chain: prev = sha256 of the previous raw line, "" for line 1', () => {
writeReceipt({ home, sink: 'a', host: 'h1', payloadClass: 'c', bytes: 3, sha256: sha256Hex('abc'), consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h2', payloadClass: 'c', bytes: 0, sha256: null, consent: 'k=v' });
const lines = fs.readFileSync(egressLedgerPath(home), 'utf-8').trim().split('\n');
expect(lines.length).toBe(2);
const first = JSON.parse(lines[0]);
const second = JSON.parse(lines[1]);
expect(first.prev).toBe('');
expect(second.prev).toBe(sha256Hex(lines[0]));
expect(first.sha256).toBe(sha256Hex('abc'));
expect(second.sha256).toBeNull();
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('ledger is 0600 and the security dir 0700', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).mode & 0o777).toBe(0o600);
expect(fs.statSync(path.dirname(ledger)).mode & 0o777).toBe(0o700);
});
test('fail-closed: unwritable security dir throws typed EGRESS_RECEIPT_FAILED', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod is advisory there
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
fs.chmodSync(path.join(home, 'security'), 0o500);
try {
expect(() =>
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow();
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
} catch (err: any) {
expect(err.code).toBe(EGRESS_RECEIPT_FAILED);
}
} finally {
fs.chmodSync(path.join(home, 'security'), 0o700);
}
});
test('outcome records join back onto their receipt in listReceipts', () => {
const { id } = writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeOutcome({ home, receipt: id, status: 204 });
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].status).toBe('204');
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('tampering with a middle line breaks verification at that line', () => {
for (let i = 0; i < 3; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'telemetry=community' });
}
const ledger = egressLedgerPath(home);
const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n');
lines[1] = lines[1].replace('community', 'communitX');
fs.writeFileSync(ledger, `${lines.join('\n')}\n`);
// Line 2's edited bytes no longer hash to line 3's recorded prev.
expect(verifyLedger(home)).toMatchObject({ ok: false, brokenLine: 3 });
});
test('validation rejects garbage before touching the ledger', () => {
expect(() => writeReceipt({ home, sink: '', host: 'h', payloadClass: 'c', consent: 'k' } as any)).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', bytes: -1 })).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', sha256: 'nothex' })).toThrow();
expect(fs.existsSync(egressLedgerPath(home))).toBe(false);
});
test('abandoned lock: a stale lock dir (>10s-old mtime) is reclaimed by the next writer', () => {
// First write creates the security dir so the lock path's parent exists.
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
const old = new Date(Date.now() - 60_000);
fs.utimesSync(lock, old, old);
const started = Date.now();
const { id } = writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
expect(id).toMatch(/^[0-9a-f]{64}$/);
// Spin budget is 2.5s; reclaim happens right after budget exhaustion.
expect(Date.now() - started).toBeLessThan(10_000);
expect(fs.existsSync(lock)).toBe(false);
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('a fresh (recent-mtime) lock held past the budget fails closed instead of being stolen', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
// Keep the mtime fresh so the reclaim path never fires: refresh it in the
// background while the writer spins out its 2.5s budget.
const refresher = setInterval(() => {
const now = new Date();
try { fs.utimesSync(lock, now, now); } catch { /* test teardown race */ }
}, 1000);
try {
expect(() =>
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow(/locked/);
} finally {
clearInterval(refresher);
fs.rmdirSync(lock);
}
}, 15_000);
test('tail-read: last line is found correctly on a multi-record ledger larger than the tail window', () => {
// 30 records ≈ 9KB > the 4KB tail window, so the append path must find
// the true last line from a partial read.
for (let i = 0; i < 30; i += 1) {
writeReceipt({
home,
sink: `sink-${i}`,
host: 'h',
payloadClass: `class-${'x'.repeat(200)}-${i}`,
consent: 'k=v',
});
}
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).size).toBeGreaterThan(4096);
const result = verifyLedger(home);
expect(result).toMatchObject({ ok: true, count: 30 });
});
test('chain verify stays intact across 100+ records', () => {
for (let i = 0; i < 120; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'k=v' });
if (i % 10 === 0) writeOutcome({ home, receipt: 'f'.repeat(64), status: 200 });
}
const result = verifyLedger(home);
expect(result.ok).toBe(true);
expect(result.count).toBe(132);
expect(listReceipts(home).length).toBe(120);
});
test('WARN-at-size: >25MB ledger emits one self-explanatory stderr warning per process', () => {
const ledger = egressLedgerPath(home);
fs.mkdirSync(path.dirname(ledger), { recursive: true, mode: 0o700 });
// Grow the file past the threshold with valid-looking filler; the warning
// keys off file size only.
const filler = `${JSON.stringify({ type: 'egress', pad: 'x'.repeat(1024) })}\n`;
const chunk = filler.repeat(1024); // ~1MB
const writes = Math.ceil(LEDGER_WARN_BYTES / chunk.length) + 1;
for (let i = 0; i < writes; i += 1) fs.appendFileSync(ledger, chunk);
expect(fs.statSync(ledger).size).toBeGreaterThan(LEDGER_WARN_BYTES);
resetLedgerSizeWarningForTests();
const captured: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
(process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; };
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
} finally {
(process.stderr as any).write = originalWrite;
}
const warnings = captured.filter((c) => c.includes('egress ledger is large'));
expect(warnings.length).toBe(1); // once per process, not per write
// Self-explanatory shape: what the ledger is, how to inspect, what's coming.
expect(warnings[0]).toContain('ATTEMPTS to send off-machine');
expect(warnings[0]).toContain('gstack-egress list');
expect(warnings[0]).toContain('rotation');
expect(warnings[0]).toContain(ledger);
// verifyLedger surfaces the same warning as data.
const message = ledgerSizeWarning(ledger, fs.statSync(ledger).size);
expect(message).toContain('MB');
expect(verifyLedger(home).sizeWarning).toBe(message);
});
});
describe('gstack-egress-receipt shell bridge', () => {
const bin = path.join(ROOT, 'bin', 'gstack-egress-receipt');
test('write hashes the exact payload file, prints the receipt id; outcome joins', () => {
const payload = path.join(home, 'payload.json');
fs.writeFileSync(payload, '[{"v":1}]');
const write = spawnSync(bin, ['write', '--sink', 'telemetry-sync', '--host', '127.0.0.1:8399',
'--class', 'telemetry-events', '--payload-file', payload, '--consent', 'telemetry=community'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const id = write.stdout.trim();
expect(id).toMatch(/^[0-9a-f]{64}$/);
const outcome = spawnSync(bin, ['outcome', id, '204'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(outcome.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].bytes).toBe(9);
expect(receipts[0].sha256).toBe(sha256Hex('[{"v":1}]'));
expect(receipts[0].status).toBe('204');
});
test('--no-payload records sha256:null (git-class: a subprocess owns the bytes)', () => {
const write = spawnSync(bin, ['write', '--sink', 'brain-sync', '--host', 'github.com',
'--class', 'git-push', '--no-payload', '--consent', 'artifacts_sync_mode=auto'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].sha256).toBeNull();
expect(receipts[0].bytes).toBe(0);
});
test('write exits 3 with EGRESS_RECEIPT_FAILED when the ledger is unwritable', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return;
fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 });
const write = spawnSync(bin, ['write', '--sink', 's', '--host', 'h', '--class', 'c', '--no-payload'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(3);
expect(write.stderr).toContain('EGRESS_RECEIPT_FAILED');
fs.chmodSync(path.join(home, 'security'), 0o700);
});
});