feat(lexicon): both unbounded rows narrow to [^><]*, and the mechanism is new here
injection-lexicon.json 0.7.0 -> 0.8.0. hybrid-xss:script-tag and
hybrid-xss:iframe-src drop the `>`-only negated class for `[^><]*`.
Both were quadratic in scan length on input repeating the tag prefix without
ever supplying a `>`: every occurrence is a match start and `[^>]*` runs to end
of input from each one. Node v25.8.2, 16k/32k/64k/128k/256k chars - script-tag
32.65/113.36/479.02/1988.83/7772.25 ms, iframe-src
39.23/131.76/574.94/2469.55/9449.94 ms, x4 per doubling. Under `[^><]*` the same
inputs cost 0.08-0.66 ms and 0.10-1.00 ms. Flat, not merely faster.
This is NOT re-extraction and it is not commons correcting seed data. The
dependency direction inverted: at llm-security be14867 their four injection
tables are built from this file and hold zero local regex literals - measured on
their published surface at 47905da, their vendored lexicon confirmed
byte-identical to 0.7.0. There is no upstream literal left to re-read, so
re-extraction was unavailable as a mechanism. They re-measured rather than
accepting our report, rejected [^>]{0,256} because a bound is paddable and
[^>]{1,256} because it drops bare `<script>` along with two corpus cases, chose
[^><]*, and asked us to carry it.
Recorded in a new source_fidelity.owner_directed_changes block, deliberately not
in post_extraction_drift - that block's shape asserts the source moved and
commons followed, which would be false in the one way that matters now that the
source reads commons.
Not majority. The guard reached [^><] first and independently (cff0437), so all
three agree, but 3-of-3 is not what moved this value. The justification is the
one that kept us on [^>] through 0.7.0 and is quoted in the divergence doc: this
file tracks its declared source, and the declared source chose.
Cost, stated: `<script <x>` and `<script<div>` stop matching. Measured over all
90 conformance cases rather than the 4 citing these ids - 0 lost, 0 gained. The
dropped class is real and unwitnessed by the corpus.
count_against_llm_security_head is now undefined rather than 83: its comparison
target no longer exists. A diff against their vendored copy is a subtree
freshness check, not an independent agreement measurement, and the field says so.
Verified against the SHIPPED file, patterns read from the JSON and never from
literals in the harness: 83/83 compile in Node bare and `u`; whole-table ReDoS
budget at the guard's 512 KB cap, per-pattern unit derived from each pattern's
own literal prefix, slowest of 83 is 1.90 ms against a 250 ms budget; all 4
corpus cases citing the changed ids still match. Gate proven discriminating, not
assumed: the pre-0.8.0 forms measure 37 374 ms and 44 514 ms on that same check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLEZ4XCSnSrQUFA8SzkQB4
This commit is contained in:
parent
d467324380
commit
2eee7e19ee
2 changed files with 66 additions and 8 deletions
|
|
@ -5,6 +5,11 @@ records a measured disagreement between two ports of one source table, so the de
|
||||||
taken where each table is tested. Under this repository's behaviour-preservation invariant,
|
taken where each table is tested. Under this repository's behaviour-preservation invariant,
|
||||||
a divergence found here is **reported, not fixed**.
|
a divergence found here is **reported, not fixed**.
|
||||||
|
|
||||||
|
That invariant held through the closure recorded below. Two rows this document measured did change
|
||||||
|
in `lexicon/injection-lexicon.json` `0.8.0` — and not because this document found them wrong. The
|
||||||
|
finding was routed to the runtime that owns the value, that runtime re-measured it, decided, and
|
||||||
|
asked commons to carry the result. The measurement travelled; the authority did not.
|
||||||
|
|
||||||
Produced 2026-08-09. Every number below came from a command; the scripts live in the session
|
Produced 2026-08-09. Every number below came from a command; the scripts live in the session
|
||||||
scratchpad rather than in this repository, because executable code here would breach the
|
scratchpad rather than in this repository, because executable code here would breach the
|
||||||
charter. They are reproducible from the method column.
|
charter. They are reproducible from the method column.
|
||||||
|
|
@ -166,11 +171,16 @@ The direction matters for what it cannot break: the new form matches a strict su
|
||||||
one, so relative to `0.6.0` it can add matches and cannot add a false negative. The reverse change
|
one, so relative to `0.6.0` it can add matches and cannot add a false negative. The reverse change
|
||||||
would not have been adoptable on the same reasoning.
|
would not have been adoptable on the same reasoning.
|
||||||
|
|
||||||
**What is still open is the one-byte span difference**, and convergence did not touch it. The
|
**The one-byte span difference is CLOSED as of commons `0.8.0`** — all three runtimes now carry
|
||||||
guard excludes `<` from its negated class and the other two do not. The three forms turn out to
|
`[^><]*`. What follows is the account of the divergence while it was open, kept because the table
|
||||||
be **totally ordered by what they match** — each a strict superset of the next:
|
below is the measurement that made the closure decidable, and because a consumer diffing against
|
||||||
|
`v0.3.0` will find the two changed rows and needs the coordinate.
|
||||||
|
|
||||||
| Witness | commons `0.7.0` / `llm-security` `[^>]` | guard `[^><]` | commons ≤ `0.6.0` |
|
While it was open, the guard excluded `<` from its negated class and the other two did not. The
|
||||||
|
three forms turn out to be **totally ordered by what they match** — each a strict superset of the
|
||||||
|
next:
|
||||||
|
|
||||||
|
| Witness | commons `0.7.0` / `llm-security` `[^>]` | guard `[^><]`, and commons from `0.8.0` | commons ≤ `0.6.0` |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `<script>steal()</script>` | match | match | match |
|
| `<script>steal()</script>` | match | match | match |
|
||||||
| `<script src=x.js>` | match | match | no match |
|
| `<script src=x.js>` | match | match | no match |
|
||||||
|
|
@ -198,6 +208,23 @@ commons shipped first. That order is deliberate rather than an oversight: a reas
|
||||||
argument is a reason for the *guard's own port*, and commons would still track its declared
|
argument is a reason for the *guard's own port*, and commons would still track its declared
|
||||||
source. Put to the guard as an open question 2026-08-11; unanswered at time of writing.
|
source. Put to the guard as an open question 2026-08-11; unanswered at time of writing.
|
||||||
|
|
||||||
|
**How it resolved, and why the resolution used the same rule rather than overturning it.** Both
|
||||||
|
halves of that open question came back the same day. The guard answered that `[^><]` is a
|
||||||
|
*measured* ReDoS fix on their side (their commit `cff0437`), not an aesthetic choice — so the
|
||||||
|
"reason that beats this argument" the paragraph above asked for turned out to exist. And commons
|
||||||
|
then measured its own copy and found the same defect in it: `<script\b[^>]*>` and
|
||||||
|
`<iframe\b[^>]*src…` are both quadratic in scan length, the second one having been named by nobody
|
||||||
|
until it was counted. `llm-security` re-measured rather than taking the report, reached `[^><]*`
|
||||||
|
independently, and asked commons to carry it — because their table is now *built from this file*,
|
||||||
|
so there is no local literal left for them to change without forking.
|
||||||
|
|
||||||
|
The paragraph above said commons "would still track its declared source." That is exactly what
|
||||||
|
`0.8.0` does: the declared source chose `[^><]*` and commons followed. The rule did not bend to
|
||||||
|
accommodate a better argument — the argument won *at the source*, which is the only place this
|
||||||
|
repository lets an argument decide a `pattern` value. The recall cost stated in the two bold rows
|
||||||
|
of the table is now paid deliberately: measured across all 90 conformance cases, zero lost a match
|
||||||
|
and zero gained one, so the dropped classes remain real and remain unwitnessed by the corpus.
|
||||||
|
|
||||||
## Why they diverge: two different ReDoS mitigations of one table
|
## Why they diverge: two different ReDoS mitigations of one table
|
||||||
|
|
||||||
This is not drift, and framing it as a bug in either repository would be wrong.
|
This is not drift, and framing it as a bug in either repository would be wrong.
|
||||||
|
|
@ -287,6 +314,16 @@ What this does and does not license:
|
||||||
had the source been the guard's — that was true before this measurement and stays true after.
|
had the source been the guard's — that was true before this measurement and stays true after.
|
||||||
- It does mean the finding is **owed to the owning runtime**, which is `llm-security` for both
|
- It does mean the finding is **owed to the owning runtime**, which is `llm-security` for both
|
||||||
patterns. Sent 2026-08-11, with these numbers and the `iframe-src` row they did not have.
|
patterns. Sent 2026-08-11, with these numbers and the `iframe-src` row they did not have.
|
||||||
|
|
||||||
|
**Outcome, same day:** the owning runtime reproduced both rows on its own vendored copy,
|
||||||
|
measured the exposure at its real entry-point caps (which the numbers above deliberately do not
|
||||||
|
cover — a pattern cost is not an exposure), judged it unacceptable, chose `[^><]*` over
|
||||||
|
`[^>]{0,256}` on the grounds that a bound is paddable, and asked commons to carry the change.
|
||||||
|
Commons `0.8.0` carries it. Note what did **and did not** license that: the measurement above
|
||||||
|
still licenses nothing on its own, and the two bullets stand as written. What licensed the
|
||||||
|
change is the *owner's decision*, arriving through the one channel this repository accepts for
|
||||||
|
moving a `pattern` value. Had they declined, the rows would still read `[^>]*` today and this
|
||||||
|
document would record a permanent divergence instead.
|
||||||
- The guard's own honest disclosure belongs on the record too: their suite passes under both
|
- The guard's own honest disclosure belongs on the record too: their suite passes under both
|
||||||
forms (717 green), because the DoS row that ought to discriminate uses `'<script>'` as its
|
forms (717 green), because the DoS row that ought to discriminate uses `'<script>'` as its
|
||||||
repeating unit, which supplies the `>` and never exercises the run. The invariant is
|
repeating unit, which supplies the `>` and never exercises the run. The invariant is
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"id": "injection-lexicon",
|
"id": "injection-lexicon",
|
||||||
"description": "Prompt-injection pattern lexicon: the four pattern families a detector matches against untrusted text, and the severity family each belongs to. Data only. The variant building that feeds these patterns (normalisation, homoglyph folding, rot13, unicode-tag escalation) and the buried-payload heuristic are engine behaviour and are deliberately NOT described here.",
|
"description": "Prompt-injection pattern lexicon: the four pattern families a detector matches against untrusted text, and the severity family each belongs to. Data only. The variant building that feeds these patterns (normalisation, homoglyph folding, rot13, unicode-tag escalation) and the buried-payload heuristic are engine behaviour and are deliberately NOT described here.",
|
||||||
"owasp": "LLM01",
|
"owasp": "LLM01",
|
||||||
|
|
@ -144,9 +144,30 @@
|
||||||
],
|
],
|
||||||
"count_against_llm_security_head_as_of_2026_08_11": 83,
|
"count_against_llm_security_head_as_of_2026_08_11": 83,
|
||||||
"count_against_llm_security_head_note": "b1ba1fb was llm-security's public-remote HEAD when this was measured, 2026-08-11, and after the 0.7.0 re-extraction the count against it is 83/83. Was 82/83 in versions 0.6.0 and earlier. Not re-checked automatically; re-verify before citing against a later HEAD.",
|
"count_against_llm_security_head_note": "b1ba1fb was llm-security's public-remote HEAD when this was measured, 2026-08-11, and after the 0.7.0 re-extraction the count against it is 83/83. Was 82/83 in versions 0.6.0 and earlier. Not re-checked automatically; re-verify before citing against a later HEAD.",
|
||||||
|
"count_against_llm_security_head_is_now_undefined": "This metric HAS NO TARGET from 0.8.0 onward, and reporting a number for it would be reporting a comparison that cannot be made. It compared commons' patterns against llm-security's local regex literals; at their commit `be14867` those literals were removed and the tables are built from this file instead. Measured on their published surface at `47905da`: `scanners/lib/injection-patterns.mjs` contains zero `pattern: /.../` literals. A comparison against their vendored COPY of this file is a subtree-freshness check, not an independent agreement measurement, and must not be cited as though it were the latter.",
|
||||||
"count_against_extraction_commit": 82,
|
"count_against_extraction_commit": 82,
|
||||||
"count_against_extraction_commit_note": "From 0.7.0 this file is 82/83 byte-identical to b0de0ca, the commit it was originally extracted from. That number will not improve: the re-extracted pattern is deliberately the newer form.",
|
"count_against_extraction_commit_note": "From 0.7.0 this file is 82/83 byte-identical to b0de0ca, the commit it was originally extracted from. That number will not improve: the re-extracted pattern is deliberately the newer form.",
|
||||||
"residual_divergence_vs_guard": "Closing the llm-security divergence does NOT make this pattern universal. The guard's port is <script\\b[^><]*> - its negated class also excludes `<` - so commons and the guard now agree on SHAPE (opening tag only) and still differ by one byte in the excluded set. Consequence, measured on construction rather than assumed: an input like `<script <x>` matches the commons/llm-security form and not the guard's. That row stays open in docs/lexicon-port-divergence.md as `span differs`, and neither side has claimed it."
|
"residual_divergence_vs_guard": "CLOSED IN 0.8.0, and the account through 0.7.0 is preserved here because a consumer diffing against v0.3.0 needs it. That account read: 'Closing the llm-security divergence does NOT make this pattern universal. The guard's port is <script\\b[^><]*> - its negated class also excludes `<` - so commons and the guard now agree on SHAPE (opening tag only) and still differ by one byte in the excluded set. Consequence, measured on construction rather than assumed: an input like `<script <x>` matches the commons/llm-security form and not the guard's.' That one-byte difference no longer exists: see `owner_directed_changes` below, which moved commons to `[^><]*` on both unbounded rows. The `span differs` row in docs/lexicon-port-divergence.md is retired by that change, not by anyone conceding the argument."
|
||||||
|
},
|
||||||
|
"owner_directed_changes": {
|
||||||
|
"$comment": "A THIRD mechanism, distinct from both `retracted` (this file described its own provenance wrongly) and `post_extraction_drift` (the source moved and this file re-read it). Here the source's OWNER decided a value and asked this file to carry it, because the dependency direction has inverted: as of llm-security `be14867`, their four injection tables are built from THIS file and hold zero local regex literals - measured here on their published surface at `47905da`, and their vendored copy of `lexicon/injection-lexicon.json` was confirmed byte-identical to commons 0.7.0. Re-extraction is therefore not available as a mechanism for these rows: there is no upstream literal left to re-read. Recording this as drift would have been false in the specific way that matters - it would say the source moved and commons followed, when the source now reads commons.",
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"pattern_ids": [
|
||||||
|
"hybrid-xss:script-tag",
|
||||||
|
"hybrid-xss:iframe-src"
|
||||||
|
],
|
||||||
|
"version": "0.8.0",
|
||||||
|
"date": "2026-08-11",
|
||||||
|
"change": "The unbounded negated class between the tag name and the rest of the pattern was narrowed from `[^>]*` to `[^><]*` on both rows: `<script\\b[^>]*>` became `<script\\b[^><]*>`, and `<iframe\\b[^>]*src\\s*=\\s*[\"'][^\"']*(?:javascript:|data:text\\/html)` became the same with `[^><]*`.",
|
||||||
|
"why": "ReDoS. Both forms are quadratic in scan length on input that repeats the literal tag prefix without ever supplying a `>`: each occurrence is a match start and `[^>]*` scans to end of input from each one. Measured here in Node v25.8.2 on the repeated-prefix unit, x4 per doubling for both patterns: script-tag 32.65 / 113.36 / 479.02 / 1988.83 / 7772.25 ms and iframe-src 39.23 / 131.76 / 574.94 / 2469.55 / 9449.94 ms at 16k / 32k / 64k / 128k / 256k chars. Under `[^><]*` the same inputs cost 0.08-0.66 ms and 0.10-1.00 ms - flat, not merely faster.",
|
||||||
|
"recall_cost": "One input class stops matching: content carrying a literal `<` between the tag name and the `>`, such as `<script <x>`. Measured over ALL 90 conformance cases rather than only the four that cite these ids: 0 cases lost a match and 0 gained one, under both patterns. The dropped class is real but unwitnessed by the corpus.",
|
||||||
|
"why_not_bounded": "`[^>]{0,256}` was on the table and is linear, but a bound is paddable - an attacker who pads the attribute run past the bound falls out of the pattern, which is the attribute-padding evasion class llm-security already carries open as v8.x-A3. `[^>]{1,256}` is worse than a trade: it requires at least one character before the `>`, so it stops matching bare `<script>` and drops `hybrid-xss__script-tag` and `active__raw-html` from the corpus. That is llm-security's measurement, reproduced here as the reason the six bounded html-obfuscation rows are NOT the analogue to reach for. `[^><]*` is linear and has no bound to pad past, so it is better on both axes.",
|
||||||
|
"decided_by": "llm-security, the declared source of this table, in their coord message 2026-08-11T15:59:49Z, having re-measured rather than accepted commons' report; carried here on operator decision, 2026-08-11.",
|
||||||
|
"provenance_reasoning": "The justification is the SAME one that kept commons on `[^>]` through 0.7.0, applied after the facts moved. docs/lexicon-port-divergence.md recorded at 0.7.0 that commons carried `[^>]` because the declared provenance is llm-security's table and not because that regex won on merit, and stated explicitly that commons would still track its declared source. The declared source has now chosen `[^><]*`. Not merit, and NOT majority: the guard reached `[^><]` first and independently (their commit `cff0437`), so all three runtimes now agree - but a 3-of-3 count is not what decided this, and would not have been sufficient on its own.",
|
||||||
|
"consumer_impact": "This changes pattern TEXT, so it is not a no-op release of the v0.3.1 class. Any consumer asserting byte-identity of this file against commons v0.3.0 will go red by construction. Pattern ids, labels, aliases, family membership and the 83 count are all unchanged."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"families": [
|
"families": [
|
||||||
|
|
@ -977,7 +998,7 @@
|
||||||
{
|
{
|
||||||
"id": "hybrid-xss:script-tag",
|
"id": "hybrid-xss:script-tag",
|
||||||
"label": "hybrid-xss: <script> tag in content (agent context XSS)",
|
"label": "hybrid-xss: <script> tag in content (agent context XSS)",
|
||||||
"pattern": "<script\\b[^>]*>",
|
"pattern": "<script\\b[^><]*>",
|
||||||
"flags": "i",
|
"flags": "i",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"llm_security": "hybrid-xss: <script> tag in content (agent context XSS)",
|
"llm_security": "hybrid-xss: <script> tag in content (agent context XSS)",
|
||||||
|
|
@ -1007,7 +1028,7 @@
|
||||||
{
|
{
|
||||||
"id": "hybrid-xss:iframe-src",
|
"id": "hybrid-xss:iframe-src",
|
||||||
"label": "hybrid-xss: iframe with executable src (agent context XSS)",
|
"label": "hybrid-xss: iframe with executable src (agent context XSS)",
|
||||||
"pattern": "<iframe\\b[^>]*src\\s*=\\s*[\"'][^\"']*(?:javascript:|data:text\\/html)",
|
"pattern": "<iframe\\b[^><]*src\\s*=\\s*[\"'][^\"']*(?:javascript:|data:text\\/html)",
|
||||||
"flags": "i",
|
"flags": "i",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"llm_security": "hybrid-xss: iframe with executable src (agent context XSS)",
|
"llm_security": "hybrid-xss: iframe with executable src (agent context XSS)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue