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:
parent
b8028ba870
commit
e45b74ff59
5 changed files with 105 additions and 8 deletions
79
CHANGELOG.md
79
CHANGELOG.md
|
|
@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [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.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
|
## [0.3.2] — 2026-07-31
|
||||||
|
|
||||||
> **Denial-of-service fix. Upgrade from 0.3.1.** The output gate could be made to
|
> **Denial-of-service fix. Upgrade from 0.3.1.** The output gate could be made to
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -1,9 +1,9 @@
|
||||||
# llm-ingestion-guard
|
# llm-ingestion-guard
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
**Write-time ingestion is the trust boundary that query-time guardrails
|
**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:
|
Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag:
|
||||||
|
|
||||||
```bash
|
```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
|
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:
|
in your own environment:
|
||||||
|
|
||||||
```bash
|
```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) |
|
| 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`),
|
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`),
|
||||||
pure beaconing, and short opaque URL segments.
|
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
|
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
|
||||||
consumer corpora, including the false positives the URL-shape rule actually produces.
|
consumer corpora, including the false positives the URL-shape rule actually produces.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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*)
|
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
|
**Measured, both claims are narrower than they read.** The new label costs no
|
||||||
consumer a disposition: any text containing a literal `<script>` already
|
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
|
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
|
`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.
|
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)
|
## The six documented gaps (tracked by the coverage matrix)
|
||||||
|
|
||||||
These are asserted to *still hold* by `tests/test_coverage_matrix.py` — a closed gap
|
These are asserted to *still hold* by `tests/test_coverage_matrix.py` — a closed gap
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "llm-ingestion-guard"
|
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."
|
description = "A minimal, dependency-light defensive layer for LLM ingestion pipelines — the write-time siblings of query-time chatbot guardrails."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ from .grounding import (
|
||||||
)
|
)
|
||||||
from . import okf
|
from . import okf
|
||||||
|
|
||||||
__version__ = "0.3.2"
|
__version__ = "0.3.3"
|
||||||
|
|
||||||
|
|
||||||
# --- §6 bookends: the two library-side halves around the transform ---------
|
# --- §6 bookends: the two library-side halves around the transform ---------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue