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.
4.2 KiB
RC Security & Abuse Evidence (#40)
The release candidate is attacked before users get to it. This page is the procedure and the rule, not a stored result: a pasted-in "all clear" is exactly the kind of evidence that stops being true after the next commit.
Run it
node scripts/security_attack_suite.mjs --json artifacts/security/rc-attack-report.json
Exit codes are part of the contract:
| Exit | Meaning |
|---|---|
0 |
VERDICT: GO — every case matched its expectation. |
1 |
VERDICT: BLOCKED — at least one P0 case failed, or a required category has no executable evidence. |
2 |
The suite could not complete (target never became ready, vision disabled, no log captured). |
The suite is a child process of nothing: it starts its own copy of server.mjs on a free loopback
port, points it at a counting stub worker, and drives it with synthetic hostile bytes. It never
contacts a real model provider, never uses a real medical image, and never touches a live user's data.
What it does and does not prove
- It proves what this HTTP boundary did with the bytes it was handed, and whether the worker behind it was called. The worker is a stub, so "the provider saw it" is a counted fact, not an inference.
- It does not measure model quality, clinical accuracy, or the safety of the medical/non-diagnostic boundary. Those live in the vision, escalation, and clinical-review issues.
- Latency numbers describe the loopback boundary against a stub. They are not a model benchmark.
Cases
| Case | Class | Severity | Expectation |
|---|---|---|---|
ingress-oversized-body |
oversized | P0 | 413, worker never called |
ingress-polyglot-html |
polyglot | P0 | 400, worker never called |
ingress-garbage-bytes |
truncated | P0 | 400, worker never called |
consent-required |
consent | P0 | 400, worker never called |
model-output-malformed |
malformed | P0 | 503 neutral copy, provider text never echoed |
replay-duplicate-request |
replay | P2 | identical answer, nothing stored server-side |
flood-concurrency |
flood | P1 | burst meets a bound (429/overload), not an open pipe |
log-leak-scan |
log | P0 | captured server log carries no image bytes |
deletion-withdrawal |
deletion | P0 | out of surface — counted only when tests/training-contribution.test.js exists |
The rule
src/release-security-gate.js owns the verdict, so it is testable rather than asserted:
- A case passes only when every expected field matches the observation. A partial match fails and
names the field that broke (
status 400 expected, observed 200). - Any failing P0 case blocks RC approval. P1/P2 failures are recorded as holds.
- A required class with no executable evidence is
unprovenand blocks the release. Coverage may be borrowed from another suite (out-of-surface+ an evidence path) only after the runner verified that file exists — otherwise it blocks. - The report is redacted before it is written, and assembly fails closed if any image bytes survive redaction. Evidence that carries a photo is worse than no evidence.
Current state (read the run, not this line)
The suite is expected to come back BLOCKED until the ingress hardening in #16 lands: bytes are still trusted from the data-URL label, so an HTML file dressed as a JPEG reaches the worker and only fails afterwards on schema. That is the finding, and it is why the release gate treats a failing case as a block instead of a warning. Queue bounding is #17 and is held here as P1.
Deletion is governed by the contribution ledger, not by this HTTP boundary; the case stays
covered-elsewhere against tests/training-contribution.test.js and blocks if that file disappears.
Evidence hygiene
- The report is written to
artifacts/security/and must never be committed with a real photo in it. - The captured server log is scanned by the same detector (
containsImageBytes) before it is quoted. - Error bodies are recorded as a 120-character snippet; the suite refuses to write a report that still contains a data URI or a long base64 run.