fix(lexicon): converge script-tag on its source — re-extraction, not revision

hybrid-xss:script-tag drops the closing-tag requirement, becoming byte-identical
to llm-security's RegExp.source at b1ba1fb (injection-patterns.mjs:170). Verified
by compiling both, not by reading them.

Why this does not breach behaviour-preservation: that invariant forbids commons
from CORRECTING seed data it believes is wrong. It does not forbid re-reading the
source after the source itself moved and its owner asked. This file's declared
provenance is llm-security's injection table, and being loadable verbatim by that
table's owner is the one thing it exists to do. The standing alternative was a
per-pattern override in their loader — a published core its own source repository
could not load as published.

Direction is what makes it safe: the new form matches a strict superset of the
old, so it can add matches and cannot introduce a false negative. The reverse
change would not have been adoptable on this reasoning.

NOT decided by the 2-of-3 majority across the three ports. A count of
implementations is not a mandate over detection data; the provenance argument
would hold with the guard on either side. Operator decision, 2026-08-11, on
llm-security's blocking coord request (their 10:46:28Z message, step-4 swap
parked behind it).

Measured collateral: none. Full corpus run under both patterns — 84/84
lexicon-scoped cases pass under 0.7.0, and exactly one case's finding set differs
between the forms. The widening added no finding to any other case's input.

source_fidelity keeps patterns_byte_identical_to_source at 83 and gains the field
it was missing: byte_identical_against_commit: b1ba1fb. Against the original
extraction commit b0de0ca the count is now 82/83, recorded explicitly.
post_extraction_drift is marked resolved and carries before/after pattern text,
so a consumer diffing against either commit has a coordinate for what it finds.

The divergence row is closed on the closing-tag axis after reversing twice in
three days. What stays open is the one-byte span difference: the guard excludes
`<` from its negated class and the other two do not, so `<script <x>` matches
commons and llm-security but not the guard. Measured by compiling all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuudQLGMyMenuyeZk8fKs5
This commit is contained in:
Kjell Tore Guttormsen 2026-08-11 13:39:01 +02:00
commit c1b2385de6
2 changed files with 56 additions and 13 deletions

View file

@ -145,11 +145,39 @@ guard's excluded-character class also excludes `<`, `llm-security`'s does not. T
difference is unresolved on both sides (see *Open questions* in the coordination thread); it difference is unresolved on both sides (see *Open questions* in the coordination thread); it
does not reopen the closing-tag question, which is settled. does not reopen the closing-tag question, which is settled.
Commons still carries the pre-`90f576f` form, unchanged, per this repository's **Commons converged in lexicon `0.7.0` (2026-08-11), and this row is closed on the closing-tag
behaviour-preservation invariant — commons does not follow either upstream regex change axis.** Through `0.6.0` commons was the sole diverger of the three: both `llm-security` and the
without an operator decision to re-extract. **Commons is now the sole diverger of the three** guard matched `<script src=x.js>` and unclosed `<script>alert(1)`, and commons' extracted pattern
on the closing-tag requirement: both `llm-security` and the guard match `<script src=x.js>` still required `</script>`. Commons now carries `<script\b[^>]*>` — byte-identical to
and unclosed `<script>alert(1)`; commons' extracted pattern still requires `</script>`. `llm-security`'s `RegExp.prototype.source` at `b1ba1fb`, verified by compiling both.
The decision was **re-extraction, not revision**, and the distinction is what made it available
at all under the behaviour-preservation invariant. That invariant forbids commons from correcting
seed data it believes is wrong; it does not forbid re-reading the source after the source itself
moved and its owner asked. The lexicon's declared provenance is `llm-security`'s injection table,
and being loadable verbatim by that table's owner is the one thing it exists to do — the standing
alternative was a per-pattern override in `llm-security`'s own loader, i.e. a published core that
its source repository could not load as published. Operator decision, 2026-08-11, on
`llm-security`'s blocking coord request. Explicitly **not** decided by the 2-of-3 majority: a
count of implementations is not a mandate over detection data, and the provenance argument would
have held with the guard on either side.
The direction matters for what it cannot break: the new form matches a strict superset of the old
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.
**What is still open is the one-byte span difference**, and convergence did not touch it. The
guard excludes `<` from its negated class and the other two do not, so:
| Witness | commons `0.7.0` / `llm-security` | guard |
| --- | --- | --- |
| `<script>steal()</script>` | match | match |
| `<script src=x.js>` | match | match |
| `<script>alert(1)` | match | match |
| `<script <x>` | **match** | **no match** |
Measured by compiling all three forms, not reasoned from the character classes. The last row is
the whole residual divergence and neither side has claimed it.
## Why they diverge: two different ReDoS mitigations of one table ## Why they diverge: two different ReDoS mitigations of one table

View file

@ -1,5 +1,5 @@
{ {
"version": "0.6.0", "version": "0.7.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",
@ -76,10 +76,13 @@
"normalisations": [], "normalisations": [],
"normalisations_note": "Empty by result, not by omission: all 83 patterns are byte-identical to the source module, so there is no transformation to declare. Through version 0.3.0 this array held one entry; it was retracted as false in 0.4.0. See source_fidelity.", "normalisations_note": "Empty by result, not by omission: all 83 patterns are byte-identical to the source module, so there is no transformation to declare. Through version 0.3.0 this array held one entry; it was retracted as false in 0.4.0. See source_fidelity.",
"source_fidelity": { "source_fidelity": {
"$comment": "This file applies NO transformation to the pattern text. All 83 patterns are byte-identical to the RegExp literal source in llm-security/scanners/lib/injection-patterns.mjs at commit b0de0ca. The `normalisations` array is empty because there is nothing to declare, not because the question was skipped.", "$comment": "This file applies NO transformation to the pattern text. All 83 patterns are byte-identical to the RegExp literal source in llm-security/scanners/lib/injection-patterns.mjs - but READ THE COORDINATE BELOW, because it moved in version 0.7.0. Through 0.6.0 the coordinate was the original extraction commit b0de0ca. In 0.7.0 one pattern (hybrid-xss:script-tag) was RE-EXTRACTED at b1ba1fb, so the coordinate for a 83/83 claim is now b1ba1fb and the count against b0de0ca is 82/83. The `normalisations` array is empty because there is nothing to declare, not because the question was skipped.",
"patterns_total": 83, "patterns_total": 83,
"patterns_byte_identical_to_source": 83, "patterns_byte_identical_to_source": 83,
"method": "The module was imported in Node from a read-only clone of the public remote at b0de0ca, and every entry compared in array order on three axes: RegExp.prototype.source, RegExp.prototype.flags, and label. Family membership, array order and per-family counts were compared at the same time. Nothing was read by eye.", "byte_identical_against_commit": "b1ba1fb",
"byte_identical_against_commit_date": "2026-08-11",
"byte_identical_against_commit_$comment": "The missing coordinate on `patterns_byte_identical_to_source`, added in 0.7.0 when that count stopped referring to a single commit for the whole file's life. The key and its value are unchanged - 83 patterns, byte-identical - and this field states WHICH source commit they are identical TO. Against the original extraction commit b0de0ca the count is 82/83; the one difference is the re-extracted pattern recorded in post_extraction_drift.",
"method": "The module was imported in Node from a read-only clone of the public remote at b0de0ca, and every entry compared in array order on three axes: RegExp.prototype.source, RegExp.prototype.flags, and label. Family membership, array order and per-family counts were compared at the same time. Nothing was read by eye. In 0.7.0 the single re-extracted pattern was verified the same way against b1ba1fb (injection-patterns.mjs:170); the other 82 were not re-imported, so their identity rests on the b0de0ca run plus the measured fact that `git diff b0de0ca..b1ba1fb -- scanners/lib/injection-patterns.mjs` changes exactly one `{ pattern, label }` line - the script-tag one - and otherwise only adds five comment lines. Exactly one commit in that range touches the file at all (90f576f).",
"class_membership_verified": { "class_membership_verified": {
"zero_width_class": [ "zero_width_class": [
"U+200B", "U+200B",
@ -114,7 +117,8 @@
} }
], ],
"post_extraction_drift": { "post_extraction_drift": {
"$comment": "patterns_byte_identical_to_source above states fidelity AT the commit this file was extracted from (b0de0ca) and does not update when llm-security's source moves further - that is what behaviour-preservation means. Recorded here because llm-security has since moved on one pattern, and a consumer diffing this file against llm-security's current source would otherwise find an unexplained mismatch with no coordinate to resolve it against. See docs/lexicon-port-divergence.md for the full account.", "$comment": "The record of source drift AFTER the original extraction, and of how it was closed. Through 0.6.0 this block described an OPEN divergence: llm-security had moved one pattern and commons had not followed. In 0.7.0 that one pattern was re-extracted and the divergence is closed. The block is kept rather than deleted because a consumer diffing this file against b0de0ca still needs the coordinate that explains the one mismatch it will find.",
"status": "resolved in 0.7.0 by re-extraction",
"extracted_from_commit": "b0de0ca", "extracted_from_commit": "b0de0ca",
"extracted_from_commit_date": "2026-08-09", "extracted_from_commit_date": "2026-08-09",
"count_at_extraction": 83, "count_at_extraction": 83,
@ -124,14 +128,25 @@
"source_commit": "90f576f", "source_commit": "90f576f",
"source_commit_date": "2026-08-10", "source_commit_date": "2026-08-10",
"change": "llm-security dropped the closing-tag requirement: <script\\b[^>]*>[\\s\\S]*?<\\/script> became <script\\b[^>]*>, closing a recall hole where `<script>alert(1)` (unclosed) and `<script src=x.js>` produced no finding.", "change": "llm-security dropped the closing-tag requirement: <script\\b[^>]*>[\\s\\S]*?<\\/script> became <script\\b[^>]*>, closing a recall hole where `<script>alert(1)` (unclosed) and `<script src=x.js>` produced no finding.",
"commons_pattern_unchanged": true, "commons_pattern_unchanged": false,
"commons_pattern_before_0_7_0": "<script\\b[^>]*>[\\s\\S]*?<\\/script>",
"commons_pattern_from_0_7_0": "<script\\b[^>]*>",
"resolved_in_version": "0.7.0",
"resolved_date": "2026-08-11",
"resolved_at_source_commit": "b1ba1fb",
"resolved_how": "RE-EXTRACTION, not revision, and the distinction is the whole justification. This file's declared provenance is llm-security's injection table; being loadable verbatim by that table's owner is the single thing it exists to do. Behaviour-preservation forbids commons from CORRECTING seed data it believes is wrong - it does not forbid re-reading the source after the source itself moved and its owner asked. The alternative on offer was a per-pattern override in llm-security's own loader, which would have meant the published core was not verbatim-loadable by the repository it was extracted from.",
"direction_note": "Widening, not narrowing: the new form matches a strict superset of the old one. It cannot introduce a false negative relative to 0.6.0, only additional matches. That is why this direction was safe to adopt while the reverse would not have been.",
"decided_by": "operator decision, 2026-08-11, on a llm-security coord request (their message 2026-08-11T10:46:28Z, blocking their step-4 table swap). NOT decided by majority: the guard also drops the closing tag, but a 2-of-3 count is not a mandate over detection data. The reason is provenance, and it would hold with the guard on either side.",
"reconfirmed_no_further_drift_at": "b1ba1fb", "reconfirmed_no_further_drift_at": "b1ba1fb",
"reconfirmed_date": "2026-08-11", "reconfirmed_date": "2026-08-11",
"reconfirmed_method": "git diff 90f576f..b1ba1fb -- scanners/lib/injection-patterns.mjs on a fresh fetch of the public remote is empty for this pattern; no later commit touched it a second time." "reconfirmed_method": "git diff 90f576f..b1ba1fb -- scanners/lib/injection-patterns.mjs on a fresh fetch of the public remote is empty for this pattern; no later commit touched it a second time."
} }
], ],
"count_against_llm_security_head_as_of_2026_08_11": 82, "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. 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_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.",
"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."
} }
}, },
"families": [ "families": [
@ -962,7 +977,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[^>]*>[\\s\\S]*?<\\/script>", "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)",