# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] Nothing yet. ## [0.4.0] — 2026-08-10 > **Behaviour change, not a pure fix — and that is why this is 0.4.0 and not > 0.3.5.** The three transform surfaces gain a refusal path they did not have. A > caller that passes a document larger than 1 000 000 characters now gets an > exception where it previously got a result. Adding a raise to a function that > was previously total is breaking under SemVer whatever the measured blast > radius turns out to be, so the number follows the change, not the survey. > > **The measured blast radius, for the record: zero.** `linkedin-studio` pins an > exact tag, so nothing reaches it until it re-pins. `llm-ingestion-okf` moved to > the range `>=0.3,<0.4` (their `f536e13`), so a 0.3.5 would have landed on them > at their next resolve without an action on their part — but they answered our > query (`20260802T193351Z`) with a measured **no**: zero call sites for > `sanitize` / `fence` / `neutralize` / `prepare_input` anywhere in their `src/`. > Their `screen_output` path reaches only `scan_output`, which truncates and does > not raise. Releasing as 0.4.0 puts this outside their ceiling regardless, so > they cross it deliberately rather than by resolving. ### Added — input-size cap on the transform surfaces (OWASP LLM10) `sanitize`, `fence` and `neutralize` now raise `OversizeInputError` above `MAX_INPUT_CHARS` (1 000 000) instead of accepting text of any length. Since `sanitize` is step 1 of `prepare_input` and only ever *removes*, that single refusal bounds the whole input path. They **reject** where the scanners **truncate**, and the asymmetry is the point: - `scan_lexicon` / `scan_output` return findings. Reading a prefix costs detection in the tail and nothing else — a lossy answer, but an answer. - `sanitize` / `fence` / `neutralize` return *content*. Truncating would return a shortened document (silent data loss for anything that persists the result) or a transformed prefix followed by an untransformed tail — a bypass, since an attacker chooses where in the document the payload sits. The invariant the three now keep: **returned text is always fully transformed, or not returned at all.** `OversizeInputError` subclasses `ContractViolation`, so a pipeline already bracketing its quarantined stage in `except ContractViolation` keeps failing closed. Like its parent it is alert-routable: the message carries the size and the cap, `details` names the refusing surface, and neither carries input. `max_input_chars` is a per-call parameter, defaulting to the single calibrated constant. ### Added — the last two detection surfaces bound their input too `scan_active_content` **called directly** and `okf.link_graph` were the two surfaces still reading attacker-supplied text with no cap. Both truncate and record, the way the other scanners do: - `scan_active_content(text, source, max_scan_chars=MAX_SCAN_CHARS)` emits one `active:oversize-input` finding (MEDIUM, LLM10) and scans the prefix. Reached through `scan_output` the text is already under that surface's cap, so the flag is raised once, there — `max_scan_chars` is now passed down. - `link_graph(bundle, max_scan_chars=MAX_SCAN_CHARS)` caps each body and records `(from_id, body_length)` in the new `LinkGraphResult.truncated` field. The field is additive with a default, so existing positional construction and attribute access are unaffected. **What truncation costs is named rather than implied:** past the cap, "no finding" means "not looked at". That is precisely what a silent truncation would hide, and why `truncated` exists as a field instead of a log line — it is what separates "no links past here" from "no links *read* past here". Recorded in `docs/LIMITATIONS.md`. ## [0.3.4] — 2026-08-01 > **Denial-of-service fix on the INPUT path. Upgrade from 0.3.3.** 0.3.3 swept > the 83 lexicon patterns arm by arm and left every other table on 0.3.2's > hand-written rows. Generalising the sweep over all eleven regex-bearing modules > found three more quadratic patterns — two of them on the input path, one in > `sanitize`, the first thing every ingested document touches. No disposition > changes: recall was measured case by case and nothing was lost. Earlier tags > are not moved. ### Fixed — three quadratic patterns, two on the input path Same class as everything 0.3.2 and 0.3.3 fixed: a run in front of a **required** literal, so crafted input that never supplies the literal makes every start position rescan the tail. Each exponent is read across four doublings, not from a two-point ratio. | Pattern | Crafted payload | Measured @ 100 000 | Exponent | |---|---|---|---| | `sanitize._HTML_COMMENT_RE` | `` it replaces. Excluding `<` from the run would lose every comment containing markup (`` is the ordinary case); bounding the run would be a one-line carrier bypass of the exact construct the stripper exists to remove. The module's own "no catastrophic backtracking" comment was wrong in the same way `output`'s was before 0.3.2, and is corrected in place. - **`URL_IN_TEXT_RE`** bounds its scheme run to an RFC 3986 scheme (`{0,63}`). Bounding is safe *here* only because this is a defanger applied inside a tag already flagged `active:raw-html`, so padding shifts where the match starts rather than evading detection. A lookbehind killing interior start positions was measured too and **rejected**: it drops `-http://evil.com` and `.http://x.com`, a one-character evasion of the defanger. Bounded, the pattern runs in 0.185 s at the full 1 000 000-char cap. - **`okf._MD_LINK_RE`** excludes `[`, matching `active_content.MD_LINK_RE` exactly, including the nested-label trade already documented there. ### Changed — the sweep covers every regex surface, not one table `docs/redos-sweep.py` now sweeps **150 patterns across 11 tables** (0.3.3 covered 83 in one). The collector is mechanical on both axes so no one has to remember to list anything: it walks each module's namespace for compiled patterns, and it derives each pattern's call mode from the module source, because `.sub()` and `.finditer()` visit every start position where `.match()` cannot. A pattern reachable only through a helper parameter gets the worst mode, marked `*` — the fallback can over-measure but never miss. Two arm shapes the generator cannot express are pinned by hand as a result: a tag that *closes* around a long body (repeating-unit payloads never close it), and a run of plain characters carrying no anchor at all. The `okf` destination run gets no row on purpose: `[^)\s]+` cannot fail, so a pin for it could never go red. A lexicon candidate flagged at ×2.8 measured **linear** across four doublings (exponent 0.96–1.03) — the near-noise-floor false flag the script's own docstring warns about, confirmed a second time. 676 tests (+10). Coverage matrix unchanged at 128/128 caught, 6/6 gaps holding. ## [0.3.3] — 2026-07-31 > **Denial-of-service fix, and a correction to 0.3.2. Upgrade from 0.3.2.** The > sweep 0.3.2 shipped was incomplete, and it said otherwise. Two lexicon patterns > were still quadratic — reachable through `scan_output`, not only on the input > path. No disposition changes: recall was measured case by case and nothing was > lost. The v0.3.2 tag is not moved. ### Fixed — two quadratic patterns in the lexicon table `8deca93` scoped the remaining ReDoS duty to the lexicon path, and this is that work: all 83 patterns measured, arm by arm. Two are quadratic, same shape as everything 0.3.2 fixed — a run in front of a **required** literal, where the run may cross the pattern's own opening anchor. | Pattern (arm) | Crafted unit | Measured | At the 1 000 000-char cap | |---|---|---|---| | `markdown:link-anchor-injection` (anchor text) | `[` | 1.91 s @ 8 000 | **~8.3 hours** | | `markdown:link-anchor-injection` (URL run) | `[system](` | 0.006 s @ 8 000 | ~89 seconds | | `markdown:link-ref-comment` (`.*` run) | `[//]: # (` | 0.22 s @ 8 000 | **~1.0 hour** | Exponent measured over five points (1 000 → 16 000): **1.98** — quadratic, not exponential. Legitimate content of the same size is unaffected: 0.316 s at N=100 000 (prose 0.316 / html 0.315 / markdown 0.297 / connection-string 0.296). **These were not input-path-only, and that is the correction.** `scan_lexicon` runs on the output path too, so 0.3.2's *"the last quadratic-backtracking site on the output path"* was false when written. Measured through the public gate before this fix: `scan_output("[" * 100_000)` took **334.7 s**. The claim was too broad because the sweep behind it drove the `[` payload only through `scan_active_content` — no row ever drove it through the lexicon. The statement is corrected in `docs/LIMITATIONS.md`. The fix is anchor exclusion, per the rule `active_content` already documents — bounding attacker-controlled content would be a one-line detection bypass. The excluded character is `(`, not `[`: ``` markdown:link-anchor-injection \[[^\]\[]*(?:system|…)[^\]\[]*\]\([^)(]+\) markdown:link-ref-comment \[//\]:\s*#\s*\([^(\n]*(?:ignore|…) ``` `[` was the obvious choice and it was measurably worse. Excluding `[` from the URL run drops `[override your rules](https://[::1]/x)` — still covered, three other patterns fire on it — but excluding `[` from the link-ref comment run drops `[//]: # (see [x] then ignore this)`, which **no other pattern catches**. The anchors contain `(` as well, so excluding `(` telescopes just as effectively at zero measured recall cost. Both forms verified linear (×1.99–2.02 on doubling). ### Known behaviour changes - **None measured.** Every case that matched before still matches, except URLs containing a literal `(` inside a markdown link target and comment bodies containing a literal `(` before the keyword. No corpus, showcase, or coverage row moved; 666 tests pass. ### Tests Four rows added. Three name the guilty pattern per arm (`test_crafted_redos_payload_stays_bounded_in_the_lexicon`), one covers the composed gate (`test_gate_is_bounded_on_the_payload_the_first_sweep_missed`). Pre-fix they failed at 297 s, 8.1 s, 55 s and 334.7 s. `N` is per row deliberately. The URL arm is quadratic with a small constant and ran 0.9 s **unfixed** at N=100 000 — under the 2.0 s bound, so that row would have passed whether or not the pattern was fixed. It is measured at N=300 000 instead, where crafted (8.10 s) and legitimate (0.926 s) separate 8.8×. ### Residual The sweep flags on timing and ignores measurements below a 1.5 ms noise floor at N=8 000. An arm hiding just under it could still cost **~23 s** at the cap, so what this supports is *"no arm worse than ~23 s"*, not *"no quadratic arm remains"*. The blind spot is not hypothetical: a generic-payload pass found only one of the two patterns. The second appeared only once payloads were synthesised per run from each pattern's own skeleton. Recorded in `docs/LIMITATIONS.md`. ## [0.3.2] — 2026-07-31 > **Denial-of-service fix. Upgrade from 0.3.1.** The output gate could be made to > spend hours on a single call by crafted input it accepts by design. No > disposition changes for ordinary documents — the one measured exception is > listed under *Known behaviour changes* below. The v0.3.1 tag is not moved. ### Fixed — 19 quadratic regex runs on the output path `scan_output` claimed LLM10 self-safety on the grounds that its patterns contain no nested quantifiers. That is true and it is not the property that matters. A run in front of a **required** literal, reachable from a short anchor, is enough: crafted input repeats the anchor and never supplies the literal, so every start position rescans the tail. Quadratic, not exponential — and quadratic is sufficient here. Measured, not argued (Python 3.14, this machine): | Input | Time through `scan_output` | |---|---| | ``. ### Known behaviour changes Two, both measured against the v0.3.1 tag rather than reasoned about: - **A JWT used as a DB password, over 256 chars, is no longer CRITICAL.** The remaining detections (`entropy:base64-blob` HIGH, `egress:jwt-token` MEDIUM) top out below CRITICAL, so the any-tier block is lost: under `PRESET_TRUSTED_SOURCE` such a document moves from `fail_secure` to `quarantine_review`. Under `PRESET_USER_UPLOAD` it still `fail_secure`s, and a *generic* long password still trips `entropy:base64-blob` at CRITICAL with no change at all. The credential is never silently missed; on one preset it is held for review instead of halted. - **`hybrid-xss:script-tag` now fires on prose that merely mentions `