Files
timmy-talking-turd/tests/security-attack.acceptance.mjs
timmy f0a9a734db
Quality gates / quality (pull_request) Canceled after 0s
feat(security): attack the RC before users do and gate approval on P0 findings (#40)
Add an executable abuse suite that drives the real server with synthetic hostile bytes while a
counting stub worker records every call that reaches the model boundary, plus the verdict rule that
turns those observations into a release decision.

- src/release-security-gate.js: nearest-rank latency percentiles, per-case pass/fail derivation that
  names the field that broke, and the rule that any failing P0 case blocks RC approval while a
  required class with no verified evidence blocks it too. Reports are redacted and fail closed.
- scripts/security_attack_suite.mjs: oversized body, HTML polyglot as JPEG, undecodable bytes,
  missing consent, non-conforming worker answer, byte-identical replay, concurrent burst, captured
  log scan, and an out-of-surface deletion case counted only against a real test file. Writes the
  report, prints the verdict, exits 1 when blocked.
- docs/RC-SECURITY-EVIDENCE.md: procedure, rule, and the current finding stated plainly.
- Demo: a real run of the suite on camera, printing the verdict it derived instead of a flattering
  one; CI regenerates the same evidence on every change.

The suite currently returns VERDICT: BLOCKED: bytes are trusted from the data-URL label, so an HTML
polyglot still reaches the worker and only fails afterwards on schema. That is a finding against
#16, not a passing check, and it is why the gate treats a failing case as a block.
2026-10-30 11:12:52 +00:00

90 lines
4.3 KiB
JavaScript

// Acceptance: the RC attack suite must drive the real server and then tell the truth about what it
// saw. Nothing here trusts the runner's own summary line: every case status is re-derived from the
// recorded observation with the same rule the release gate uses, and the report is scanned for photo
// bytes. If the suite ever marks a case pass while observing a mismatch, this test fails.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { containsImageBytes, evaluateCase, releaseVerdict } from '../src/release-security-gate.js';
const repoRoot = new URL('..', import.meta.url).pathname;
async function runSuite() {
const directory = await mkdtemp(join(tmpdir(), 'timmy-rc-attack-'));
const reportPath = join(directory, 'report.json');
const run = await new Promise(resolve => {
execFile(process.execPath, ['scripts/security_attack_suite.mjs', '--json', reportPath], {
cwd: repoRoot,
timeout: 180_000,
}, (error, stdout, stderr) => resolve({
code: error?.code ?? 0,
stdout,
stderr,
}));
});
const report = JSON.parse(await readFile(reportPath, 'utf8'));
await rm(directory, { recursive: true, force: true });
return { run, report };
}
const suite = await runSuite();
const byId = new Map(suite.report.cases.map(item => [item.id, item]));
test('the suite exits non-zero exactly when its own verdict blocks the release', () => {
assert.ok([0, 1].includes(suite.run.code), `unexpected exit ${suite.run.code}: ${suite.run.stderr}`);
assert.match(suite.run.stdout, new RegExp(`VERDICT: ${suite.report.verdict}`));
assert.equal(suite.run.code, suite.report.verdict === 'BLOCKED' ? 1 : 0);
});
test('every reported case status is re-derivable from the observation it recorded', () => {
for (const item of suite.report.cases.filter(entry => entry.surface !== 'out-of-surface')) {
const derived = evaluateCase(item);
assert.equal(item.status, derived.status, `${item.id} claims ${item.status} but observed ${JSON.stringify(item.observed)} against ${JSON.stringify(item.expected)}`);
}
assert.equal(suite.report.verdict, releaseVerdict({ cases: suite.report.cases }).verdict);
});
test('the suite covers the abuse classes the ticket names, not a convenient subset', () => {
const required = ['oversized', 'polyglot', 'malformed', 'flood', 'log', 'deletion', 'replay'];
const categories = new Set(suite.report.cases.map(item => item.category));
for (const category of required) assert.ok(categories.has(category), `no case covers ${category}`);
assert.ok(suite.report.cases.length >= 8, `only ${suite.report.cases.length} cases ran`);
});
test('a request without consent is refused and the worker is never called', () => {
const consent = byId.get('consent-required');
assert.equal(consent.observed.status, 400);
assert.equal(consent.observed.providerCalls, 0);
assert.equal(consent.status, 'pass');
});
test('an oversized body is refused before any provider work', () => {
const oversized = byId.get('ingress-oversized-body');
assert.equal(oversized.observed.status, 413);
assert.equal(oversized.observed.providerCalls, 0);
});
test('a provider answer that breaks the schema fails closed without quoting the provider', () => {
const malformed = byId.get('model-output-malformed');
assert.equal(malformed.observed.status, 503);
assert.equal(malformed.observed.providerTextLeaked, false);
assert.ok(!JSON.stringify(malformed).includes('garbage-completion-token'), 'the report must not quote the provider');
});
test('the captured server log and the report itself carry no photo bytes', () => {
assert.equal(byId.get('log-leak-scan').observed.imageBytesInLog, false);
assert.equal(containsImageBytes(JSON.stringify(suite.report)), false);
});
test('the load phase recorded percentiles, errors, and abstentions without any image body', () => {
const load = suite.report.load;
assert.ok(load.requests >= 8, `only ${load.requests} load requests ran`);
assert.equal(typeof load.p50, 'number');
assert.equal(typeof load.p95, 'number');
assert.equal(typeof load.abstentions, 'number');
assert.equal(containsImageBytes(JSON.stringify(load)), false);
});