Quality gates / quality (pull_request) Canceled after 0s
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.
105 lines
5.5 KiB
JavaScript
105 lines
5.5 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { buildReport, containsImageBytes, evaluateCase, latencyPercentiles, redactImageBytes, releaseVerdict } from '../src/release-security-gate.js';
|
|
|
|
test('latency percentiles use nearest-rank so p95 is an observed sample, not an interpolation', () => {
|
|
const samples = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
assert.deepEqual(latencyPercentiles(samples), { count: 10, p50: 50, p95: 100, max: 100 });
|
|
});
|
|
|
|
// A case is a promise the suite makes before it runs: this input must land here, and the provider
|
|
// must (or must not) see exactly this many calls. The verdict is derived, never typed.
|
|
const polyglot = {
|
|
id: 'ingress-polyglot-html',
|
|
severity: 'P0',
|
|
expected: { status: 400, providerCalls: 0 },
|
|
observed: { status: 400, providerCalls: 0 },
|
|
};
|
|
|
|
test('a case whose observation matches its expectation passes', () => {
|
|
assert.equal(evaluateCase(polyglot).status, 'pass');
|
|
});
|
|
|
|
test('a case that reached the provider is a failure that names the field that broke', () => {
|
|
const result = evaluateCase({ ...polyglot, observed: { status: 200, providerCalls: 1 } });
|
|
assert.equal(result.status, 'fail');
|
|
assert.match(result.mismatch, /status 400 expected, observed 200/);
|
|
assert.match(result.mismatch, /providerCalls 0 expected, observed 1/);
|
|
});
|
|
|
|
test('a P0 failure blocks the release, and a P1 failure only holds it', () => {
|
|
const leaked = { ...polyglot, observed: { status: 200, providerCalls: 1 } };
|
|
const blocked = releaseVerdict({ cases: [leaked, { ...polyglot, id: 'flood', severity: 'P1', observed: { status: 200, providerCalls: 12 } }] });
|
|
assert.equal(blocked.verdict, 'BLOCKED');
|
|
assert.deepEqual(blocked.blocking.map(item => item.id), ['ingress-polyglot-html']);
|
|
assert.deepEqual(blocked.holds.map(item => item.id), ['flood']);
|
|
});
|
|
|
|
test('a suite with no failures is a GO, and counts say how many cases were proven', () => {
|
|
const verdict = releaseVerdict({ cases: [polyglot] });
|
|
assert.equal(verdict.verdict, 'GO');
|
|
assert.deepEqual(verdict.blocking, []);
|
|
assert.equal(verdict.proven, 1);
|
|
});
|
|
|
|
test('a required category that no case exercised blocks the release instead of passing by omission', () => {
|
|
const deletion = { id: 'deletion-withdrawal', severity: 'P0', surface: 'out-of-surface', evidence: 'tests/training-contribution.test.js', evidenceVerified: false };
|
|
const verdict = releaseVerdict({ cases: [polyglot, deletion] });
|
|
assert.equal(verdict.verdict, 'BLOCKED');
|
|
assert.deepEqual(verdict.unproven.map(item => item.id), ['deletion-withdrawal']);
|
|
});
|
|
|
|
test('coverage borrowed from another test file only counts once the runner verified that file exists', () => {
|
|
const verified = { id: 'deletion-withdrawal', severity: 'P0', surface: 'out-of-surface', evidence: 'tests/training-contribution.test.js', evidenceVerified: true };
|
|
const verdict = releaseVerdict({ cases: [polyglot, verified] });
|
|
assert.equal(verdict.verdict, 'GO');
|
|
assert.deepEqual(verdict.coveredElsewhere, ['deletion-withdrawal']);
|
|
});
|
|
|
|
// The report and the server log are shipped as release evidence. Neither may carry a photo.
|
|
const JPEG_DATA_URI = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAJ/9oACAEBAAA/AKp//9k=';
|
|
|
|
test('a data URI or a long base64 run is recognised as image bytes', () => {
|
|
assert.equal(containsImageBytes(`posted ${JPEG_DATA_URI} to the worker`), true);
|
|
assert.equal(containsImageBytes('chunk ' + 'A'.repeat(64) + 'yQ'.repeat(200)), true);
|
|
});
|
|
|
|
test('ordinary log lines, hex digests, and short tokens are not image bytes', () => {
|
|
assert.equal(containsImageBytes('Timmy is listening on http://127.0.0.1:4173 · vision configured · Hermes locked and ready'), false);
|
|
assert.equal(containsImageBytes('sha256 ' + 'a1b2c3d4'.repeat(8)), false);
|
|
assert.equal(containsImageBytes('case ingress-polyglot-html observed status 200'), false);
|
|
});
|
|
|
|
test('redaction removes the bytes and keeps the sentence around them honest', () => {
|
|
const redacted = redactImageBytes(`provider received ${JPEG_DATA_URI} at 12:01`);
|
|
assert.equal(containsImageBytes(redacted), false);
|
|
assert.match(redacted, /\[redacted image bytes\]/);
|
|
assert.match(redacted, /^provider received .* at 12:01$/);
|
|
});
|
|
|
|
test('the report carries the verdict, the target it ran against, and the measured load numbers', () => {
|
|
const report = buildReport({
|
|
generatedAt: '2026-10-30T10:48:00.000Z',
|
|
target: { baseUrl: 'http://127.0.0.1:4173', provider: 'stub counting worker' },
|
|
cases: [polyglot],
|
|
load: { requests: 8, errors: 0, abstentions: 8, p50: 12, p95: 40, max: 40 },
|
|
});
|
|
assert.equal(report.schema, 'timmy-rc-security-attack/1');
|
|
assert.equal(report.verdict, 'GO');
|
|
assert.equal(report.generatedAt, '2026-10-30T10:48:00.000Z');
|
|
assert.equal(report.target.provider, 'stub counting worker');
|
|
assert.equal(report.load.p95, 40);
|
|
assert.equal(containsImageBytes(JSON.stringify(report)), false);
|
|
});
|
|
|
|
test('a case that quotes a response body cannot smuggle the photo into the report', () => {
|
|
const report = buildReport({
|
|
generatedAt: '2026-10-30T10:48:00.000Z',
|
|
target: { baseUrl: 'http://127.0.0.1:4173', provider: 'stub' },
|
|
cases: [{ ...polyglot, observed: { status: 400, providerCalls: 0 }, snippet: `refused ${JPEG_DATA_URI}` }],
|
|
load: null,
|
|
});
|
|
assert.equal(containsImageBytes(JSON.stringify(report)), false);
|
|
assert.match(JSON.stringify(report), /redacted image bytes/);
|
|
});
|