1
0
Fork 0

release(0.3.3): the lexicon ReDoS fix, and a correction to 0.3.2's claim

Version synced across the four locked points (pyproject, __version__, README
badge, README install pin) + CHANGELOG.

Corrections this release carries, both measured rather than reasoned:

- docs/LIMITATIONS.md said the script-tag change removed "the last"
  quadratic-backtracking site on the output path. It did not. Corrected, with
  the 334.7s gate measurement that falsifies it.
- README's coverage line claimed 126/126 classes; the matrix reports 128/128.
  Stale since before v0.3.2. Test badge was 642, actual 666.

New residual recorded (LIMITATIONS, now 30 items, README synced): the sweep
flags on timing above a 1.5ms noise floor at N=8000, so an arm hiding under it
could still cost ~23s at the cap. What this supports is "no arm worse than ~23s",
not "no quadratic arm remains" -- and the blind spot is demonstrated, since a
generic-payload pass found only one of the two patterns.

666 tests green, coverage matrix 128/128 with 6/6 gaps holding, exit 0.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 21:51:00 +02:00
commit 701a4a47c7
5 changed files with 105 additions and 8 deletions

View file

@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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.992.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

View file

@ -1,9 +1,9 @@
# llm-ingestion-guard
![Version](https://img.shields.io/badge/version-0.3.2-blue)
![Version](https://img.shields.io/badge/version-0.3.3-blue)
![Status](https://img.shields.io/badge/status-alpha-orange)
![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![Tests](https://img.shields.io/badge/tests-642_passing-green)
![Tests](https://img.shields.io/badge/tests-666_passing-green)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
**Write-time ingestion is the trust boundary that query-time guardrails
@ -42,7 +42,7 @@ may still change. There are real limitations, stated plainly below; read them.
Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag:
```bash
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.2"
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.3"
```
The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or
@ -143,7 +143,7 @@ driven by a **live payload** in the coverage matrix — run it to watch all 134
in your own environment:
```bash
python -m llm_ingestion_guard.coverage # 126/126 classes; exit 0 = all as documented
python -m llm_ingestion_guard.coverage # 128/128 classes; exit 0 = all as documented
```
| Anchor | Attack classes it stops (representative) |
@ -215,7 +215,7 @@ a green scan means safe content. The highest-impact items:
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`),
pure beaconing, and short opaque URL segments.
**Full list — 29 items, each with the mechanism, plus the out-of-scope boundary:**
**Full list — 30 items, each with the mechanism, plus the out-of-scope boundary:**
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
consumer corpora, including the false positives the URL-shape rule actually produces.

View file

@ -249,7 +249,12 @@ items; this is the full list, each with the mechanism.
document *about* XSS is flagged alongside a document that *carries* it. This
is a deliberate trade made twice over: requiring the closing tag was a
fail-open (an unclosed `<script>alert(1)` was silently missed by *this label*)
and it was the last quadratic-backtracking site on the output path.
and it removed a quadratic-backtracking site on the output path. **It was not
the last one** — 0.3.2 said so and that claim was wrong. The 0.3.3 sweep of
all 83 lexicon patterns found two more, and because `scan_lexicon` runs on the
output path too, they were reachable through `scan_output`: `"[" * 100_000`
took 334.7s through the gate. The claim was too broad because the sweep behind
it drove `[` only through `scan_active_content`, never through the lexicon.
**Measured, both claims are narrower than they read.** The new label costs no
consumer a disposition: any text containing a literal `<script>` already
produced `active:raw-html` at HIGH on 0.3.1 — so the same prose disposed
@ -275,6 +280,19 @@ items; this is the full list, each with the mechanism.
`PRESET_USER_UPLOAD` it still `fail_secure`s. The credential is never silently
missed; on one preset it is held for review instead of halted.
- **The ReDoS sweep of the lexicon has a measured sensitivity floor, not a clean
bill of health.** All 83 patterns were swept arm by arm — payloads synthesised
per run from each pattern's own skeleton, so `[`, `[system]` and `[system](`
are each probed separately rather than relying on generic units. Two patterns
were quadratic and both are fixed. But the sweep flags on *timing*, and it
ignores measurements below a 1.5 ms noise floor at N=8000. A quadratic arm
sitting just under that floor would still cost **up to ~23 s** at the
1 000 000-char cap the gate accepts. So the claim this sweep supports is
"no arm worse than ~23 s at the cap", not "no quadratic arm remains". The
method's blind spot is real and was demonstrated in this very sweep: a
generic-payload pass found only one of the two patterns, and the second
surfaced only after the payloads were generated per run.
## The six documented gaps (tracked by the coverage matrix)
These are asserted to *still hold* by `tests/test_coverage_matrix.py` — a closed gap

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "llm-ingestion-guard"
version = "0.3.2"
version = "0.3.3"
description = "A minimal, dependency-light defensive layer for LLM ingestion pipelines — the write-time siblings of query-time chatbot guardrails."
readme = "README.md"
requires-python = ">=3.10"

View file

@ -59,7 +59,7 @@ from .grounding import (
)
from . import okf
__version__ = "0.3.2"
__version__ = "0.3.3"
# --- §6 bookends: the two library-side halves around the transform ---------