Execute security, abuse, load, and leakage tests against the RC (#40) #91

Merged
timmy merged 1 commits from timmy/40-security-rc-evidence into main 2026-10-30 12:02:44 +00:00
Member

What this does

Executes the attack pass the ticket asks for, as an executable suite instead of a checklist, and gives the result a rule that can block the release.

  • scripts/security_attack_suite.mjs starts the real server.mjs on a free loopback port, points it at a counting stub worker, and drives it with synthetic hostile bytes. Nine cases: oversized body, HTML polyglot dressed as image/jpeg, undecodable bytes, missing consent, non-conforming worker answer, byte-identical replay, concurrent burst, captured-log scan, and deletion (out of surface, counted only against tests/training-contribution.test.js). It writes a JSON report, prints VERDICT:, and exits 1 when blocked.
  • src/release-security-gate.js owns the rules: nearest-rank percentiles, per-case pass/fail derivation that names the field that broke (status 400 expected, observed 200), and — any failing P0 case blocks RC approval; a required class with no verified evidence blocks it too; a report that still holds image bytes refuses to be written.
  • docs/RC-SECURITY-EVIDENCE.md is the procedure and the rule, not a stored "all clear".
  • CI regenerates the evidence on every change (npm run test:security-attack).
  • Demo: the recording now runs the suite on camera and prints the verdict it derived, in the same command-line card pattern as the calibration gate.

The run (from this branch, on 65a0258 + this commit)

ingress-oversized-body    P0  pass
ingress-polyglot-html     P0  fail — status 400 expected, observed 503; providerCalls 0 expected, observed 1
ingress-garbage-bytes     P0  fail — status 400 expected, observed 503; providerCalls 0 expected, observed 1
consent-required          P0  pass
model-output-malformed    P0  pass
replay-duplicate-request  P2  pass
flood-concurrency         P1  fail — refused true expected, observed false
log-leak-scan             P0  pass
deletion-withdrawal       P0  covered-elsewhere
load: 8 requests · p50 24ms · p95 31ms · errors 0 · abstentions 8
VERDICT: BLOCKED

This PR does not claim the boundary is secure, and it should not be read as an RC green light. Two P0 findings are real on this tree: bytes are still trusted from the data-URL label, so an HTML polyglot and 64 random bytes both reach the stub worker (counted, not inferred) and only fail afterwards on schema. That fix is #16 (PR #82). Queue bounding is #17 (PR #64) and is held here as P1. The gate says BLOCKED because that is what it observed.

TDD evidence

  • tests/release-security-gate.test.js — written first, watched fail (Cannot find module .../src/release-security-gate.js, then evaluateCase is not defined, then buildReport is not defined) before each function existed. 12/12 pass now.
  • tests/security-attack.acceptance.mjs — written before the runner, watched fail (ENOENT .../report.json, no runner in the tree). It does not trust the runner's own summary: every case status is re-derived from the recorded observation with evaluateCase, and the report verdict is re-derived with releaseVerdict, so a case cannot be marked pass while observing a mismatch. 8/8 pass.
  • tests/ci-workflow.test.js — assertion added first, watched fail, then the workflow step added.
  • tests/release-demo.test.js — assertion added first, watched fail, then the scene added.
  • Hold-out, not RED: the consent-required, ingress-oversized-body, and model-output-malformed assertions describe refusals that already worked on this tree. They are regression locks for behavior this PR did not create; the gate and suite logic above is the part that was driven RED→GREEN.

Verification

  • npm test → 244/244 pass (was 230; +14)
  • node tests/security-attack.acceptance.mjs → 8/8
  • npm run check:syntax → clean (new module, runner, and acceptance added to the gate)
  • Full demo re-recorded from this tree against a real local server: exit 0, 456s VP8, 404x720, 21.6 MB, decodes with ffprobe. The new card passed the on-screen proof (nothing clipped) and every caption passed its painted-band check, including the four new captions.
  • Diff scanned for secrets, tokens, weights, real medical images, and generated evidence: none. The suite writes to artifacts/security/ and that path is gitignored so a report with a photo in it cannot be committed by accident.

Not in this PR

  • No UI change, so nothing user-visible is claimed for the app itself; the visible change is the evidence card in the demo, which shows the real verdict.
  • No blocking wiring into scripts/build_release.py. RC approval (#42) has to read artifacts/security/rc-attack-report.json; making the builder hard-fail would stop the morning release job for work that is already in review under #16/#17.
  • No merge. Not merging, no RC approval, no deploy.

Closes #40

## What this does Executes the attack pass the ticket asks for, as an executable suite instead of a checklist, and gives the result a rule that can block the release. - **`scripts/security_attack_suite.mjs`** starts the real `server.mjs` on a free loopback port, points it at a **counting stub worker**, and drives it with synthetic hostile bytes. Nine cases: oversized body, HTML polyglot dressed as `image/jpeg`, undecodable bytes, missing consent, non-conforming worker answer, byte-identical replay, concurrent burst, captured-log scan, and deletion (out of surface, counted only against `tests/training-contribution.test.js`). It writes a JSON report, prints `VERDICT:`, and exits `1` when blocked. - **`src/release-security-gate.js`** owns the rules: nearest-rank percentiles, per-case pass/fail derivation that names the field that broke (`status 400 expected, observed 200`), and — any failing P0 case blocks RC approval; a required class with no verified evidence blocks it too; a report that still holds image bytes refuses to be written. - **`docs/RC-SECURITY-EVIDENCE.md`** is the procedure and the rule, not a stored "all clear". - **CI** regenerates the evidence on every change (`npm run test:security-attack`). - **Demo**: the recording now runs the suite on camera and prints the verdict it derived, in the same command-line card pattern as the calibration gate. ## The run (from this branch, on `65a0258` + this commit) ``` ingress-oversized-body P0 pass ingress-polyglot-html P0 fail — status 400 expected, observed 503; providerCalls 0 expected, observed 1 ingress-garbage-bytes P0 fail — status 400 expected, observed 503; providerCalls 0 expected, observed 1 consent-required P0 pass model-output-malformed P0 pass replay-duplicate-request P2 pass flood-concurrency P1 fail — refused true expected, observed false log-leak-scan P0 pass deletion-withdrawal P0 covered-elsewhere load: 8 requests · p50 24ms · p95 31ms · errors 0 · abstentions 8 VERDICT: BLOCKED ``` **This PR does not claim the boundary is secure, and it should not be read as an RC green light.** Two P0 findings are real on this tree: bytes are still trusted from the data-URL label, so an HTML polyglot and 64 random bytes both reach the stub worker (counted, not inferred) and only fail afterwards on schema. That fix is #16 (PR #82). Queue bounding is #17 (PR #64) and is held here as P1. The gate says BLOCKED because that is what it observed. ## TDD evidence - `tests/release-security-gate.test.js` — written first, watched fail (`Cannot find module .../src/release-security-gate.js`, then `evaluateCase is not defined`, then `buildReport is not defined`) before each function existed. 12/12 pass now. - `tests/security-attack.acceptance.mjs` — written before the runner, watched fail (`ENOENT .../report.json`, no runner in the tree). It does not trust the runner's own summary: every case status is re-derived from the recorded observation with `evaluateCase`, and the report verdict is re-derived with `releaseVerdict`, so a case cannot be marked pass while observing a mismatch. 8/8 pass. - `tests/ci-workflow.test.js` — assertion added first, watched fail, then the workflow step added. - `tests/release-demo.test.js` — assertion added first, watched fail, then the scene added. - Hold-out, not RED: the `consent-required`, `ingress-oversized-body`, and `model-output-malformed` assertions describe refusals that already worked on this tree. They are regression locks for behavior this PR did not create; the gate and suite logic above is the part that was driven RED→GREEN. ## Verification - `npm test` → **244/244 pass** (was 230; +14) - `node tests/security-attack.acceptance.mjs` → 8/8 - `npm run check:syntax` → clean (new module, runner, and acceptance added to the gate) - Full demo re-recorded from this tree against a real local server: **exit 0**, 456s VP8, 404x720, 21.6 MB, decodes with `ffprobe`. The new card passed the on-screen proof (nothing clipped) and every caption passed its painted-band check, including the four new captions. - Diff scanned for secrets, tokens, weights, real medical images, and generated evidence: none. The suite writes to `artifacts/security/` and that path is gitignored so a report with a photo in it cannot be committed by accident. ## Not in this PR - No UI change, so nothing user-visible is claimed for the app itself; the visible change is the evidence card in the demo, which shows the real verdict. - No blocking wiring into `scripts/build_release.py`. RC approval (#42) has to read `artifacts/security/rc-attack-report.json`; making the builder hard-fail would stop the morning release job for work that is already in review under #16/#17. - No merge. Not merging, no RC approval, no deploy. Closes #40
rockachopa added 1 commit 2026-10-30 11:13:23 +00:00
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.
rockachopa added the type/securitypriority/P0area/privacyarea/release labels 2026-10-30 11:13:29 +00:00
timmy merged commit 0bf8230280 into main 2026-10-30 12:02:44 +00:00
Sign in to join this conversation.