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)
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.
## 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
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.mjsstarts the realserver.mjson 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 asimage/jpeg, undecodable bytes, missing consent, non-conforming worker answer, byte-identical replay, concurrent burst, captured-log scan, and deletion (out of surface, counted only againsttests/training-contribution.test.js). It writes a JSON report, printsVERDICT:, and exits1when blocked.src/release-security-gate.jsowns 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.mdis the procedure and the rule, not a stored "all clear".npm run test:security-attack).The run (from this branch, on
65a0258+ this commit)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, thenevaluateCase is not defined, thenbuildReport 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 withevaluateCase, and the report verdict is re-derived withreleaseVerdict, 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.consent-required,ingress-oversized-body, andmodel-output-malformedassertions 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/8npm run check:syntax→ clean (new module, runner, and acceptance added to the gate)ffprobe. The new card passed the on-screen proof (nothing clipped) and every caption passed its painted-band check, including the four new captions.artifacts/security/and that path is gitignored so a report with a photo in it cannot be committed by accident.Not in this PR
scripts/build_release.py. RC approval (#42) has to readartifacts/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.Closes #40