Skip to content

v2.2.0 — Phase 3 red-team security batch

Choose a tag to compare

@franciszver franciszver released this 27 Jul 18:34
· 17 commits to main since this release
7b3c9cf

Closes out the Phase 3 red-team security batch and the follow-up work it generated. 21 PRs, 115 commits since v2.1.0.

The short version: four red-team findings were filed against v2.1.0. Working them surfaced sixteen more defects — every one found by the review gates, not in production — and produced two measured decisions not to ship a fix.

Security fixes

  • #168 — the default bearer-token validator accepted any non-empty token. Now fail-closed, with permissive behaviour only behind an explicit dev flag. Took four review rounds: the first fix returned 500 instead of 401 (a fingerprinting oracle plus pre-auth outbound amplification), and the second swallowed the error so the agent answered with zero patient evidence rather than failing loudly.
  • #176GET /review was unauthenticated and rendered clinician free text verbatim; a probe returned MRN and patient name with no auth. Found by the #168 review, not by the finding that prompted it. Fixed by redaction rather than gating, because /review has no proxy and gating would 401 the only way to view the page.
  • #180 / #185 — feedback had no ownership check on target_correlation_id, so a caller with any valid token could attach forged text to another clinician's trace. Now bound to OpenEMR's signature-verified sub claim under per-user tokens, with an explicit regime column so pre-existing rows can never become accidentally claimable.
  • #177 — unauthenticated /chat triggered an outbound OAuth fetch before validation. Now structurally unreachable pre-auth; the timing oracle measured closed (2.14s → 0.0021s).
  • #167 / #173 — unbounded /chat message length, no conversation eviction, and no pre-parse body-size limit. All bounded, with mem_limit and a restart policy on the agent container.
  • #174 / #182 — every conversation retained a full copy of every other patient's name (the dominant memory term, ~3GB worst case). Replaced with a TTL'd cache keyed by authenticated principal, bounded by retained rows rather than entry count.
  • #204 — document ingestion's vision path resolved to a text-only model by default; the VLM was a per-call override documented only in a demo script. Now a dedicated setting with a fail-closed capability check.

Measured decisions not to ship

  • #169 — a status-contradiction gate was built and declined. Four adversarial passes produced four new defect classes with the rate not decaying, and the settling finding was that identifier anchoring false-blocks a correct claim about an active formulation of a discontinued base drug — the most common real medication-list transition. The strict-xfail tripwire on main is the standing record that the gap is known and undefended.
  • #192 — nonce-fencing was built for both LLM judges, measured, and declined: neutral on the judge that ships enabled, 2.4× worse on the other. What ships instead is the measurement — a 152-payload injection battery run live against the shipped model. Across 190 draws per configuration, no payload could push a judge toward SUPPORTED; the exploitable direction is fail-closed (it strips correct claims rather than certifying false ones). Recorded with its limits: the zero is confounded with scenario distance, and 5 draws per payload gives a ~45% per-payload detection floor.

Infrastructure

  • #186 — the Jest suite had been red on main and was not run by CI at all. Green, and now a required status check.
  • #184 / #196 — the OpenAPI contract test failed on hosts for an environmental reason, making real drift locally indistinguishable from noise. Normalised, and dependencies now carry both floors and ecosystem-appropriate ceilings so a stale venv cannot silently resolve the version that caused it.
  • #199 / #201 / #203 — issue references inherited from Phase 1's tracker that resolved to different issues here, and docstrings asserting premises the code had already disproved.

Known limits, stated plainly

  • semantic_support ships enabled with a soft-instruction injection defence. That is now measured rather than assumed, and the battery is committed so a judge-model change can be re-measured rather than re-argued.
  • Dependency bounds narrow the resolution window; they do not pin it, and transitive dependencies remain unbounded.
  • #206 is open: a fully-failed ingestion returns normally with zero facts and no exception, so it is indistinguishable from an empty document. Deferred to Phase 3 with its own measurement.

Full test state at tag: 1369 passed, 1 skipped, 12 xfailed (agent), 383 passed, 15 xfailed (evals), mypy clean.