{ "version": "0.3.0", "id": "active-content", "description": "Active content in persisted text: constructs that fetch, render or execute on their own when a renderer touches them. The EchoLeak class (CVE-2025-32711). Report-only data: no construct here is defined as something to remove.", "owasp": "LLM05", "detector": "active_content", "label_format": "active:{class}", "pattern_id_space": { "$comment": "Stable, commons-owned identifier for each construct, so that a conformance fixture can name an active-content finding. Unlike the injection lexicon's id space, nothing was constructed here: `label_format` and the `constructs` keys were already extracted verbatim from the seed runtime, and their concatenation IS what that runtime emits. This block states an id space that the file already had implicitly rather than introducing one.", "field": "\"active:\" + the key of the entry in `constructs`", "ids": [ "active:autolink", "active:data-uri", "active:markdown-image", "active:markdown-link", "active:raw-html", "active:raw-html-link", "active:reference-link" ], "ids_added_in_0_3_0": { "$comment": "One id was ADDED, and no id was changed or removed, which is what keeps this a minor bump. What did narrow is `active:raw-html`'s membership — see the top-level `behaviour_change` block, which a consumer upgrading from 0.2.0 must read.", "added": [ "active:raw-html-link" ], "adopted_verbatim": "Not named here. The seed runtime shipped the class string `raw-html-link` in its 0.7.0 release, and this file's `field` rule turns it into the id mechanically, exactly as it did for the original six. No naming decision was taken and neither runtime was asked for one — unlike `codepoints/carriers.json`, where the id had to be named because the runtimes disagreed. Only one runtime implements this table, so there is no second namer to consult." }, "verified": { "method": "The `constructs` keys were compared to the class strings the seed runtime passes to its finding emitter: the `_flag(cls, …)` call sites in active_content.py, whose emitter builds `label=f\"active:{cls}\"`. Set-equal, exact strings, no normalisation applied.", "recheck_0_3_0": { "$comment": "Re-run for the seventh id rather than extrapolated from the six. At v0.7.0 the raw-HTML pass no longer reaches its emitter through a `_flag` call site with a literal class argument — it collects into a two-key dict and loops `for cls in (\"raw-html\", \"raw-html-link\")`, so the comparison method above had to be re-established rather than repeated.", "date": "2026-08-13", "at_commit": "be9759b", "source_version": "0.7.0", "method": "The seven `constructs` keys were compared to the seven class strings reachable at the emitter: the five literal `_flag(...)` call sites (`markdown-image`, `markdown-link`, `reference-link`, `autolink`, `data-uri`) plus the two-member loop tuple that drives the raw-HTML pass, cross-checked against `active_tag_class`'s three possible non-None return values (`\"raw-html\"`, `\"raw-html-link\"`, and `\"raw-html\"` again from the event-handler and outside-the-name-set branches). Set-equal, exact strings.", "measured_through_the_entry_point_too": "Not only read off the source. Each of the seven ids was produced by running the seed runtime's own payload for it through `scan_output(text, source=Source.OUTPUT)` at this commit; all seven labels came back verbatim. The payloads were extracted from `coverage.py`'s AST rather than retyped." }, "line_numbers": { "$comment": "Given per commit rather than once, because the two differ and a reader checking against this file's provenance pin would otherwise land on the wrong lines. The 23-line insert between the two commits shifts everything below it by 21.", "de09711": "_flag call sites at 337, 341, 345, 349, 363, 369; emitter at 309 — where the comparison was run", "0bf0729": "the same six call sites at 316, 320, 324, 328, 342, 348; emitter at 288 — the commit `provenance` pins", "symbols": "`_flag` and the `label=f\"active:{cls}\"` assignment inside it — stable across both, unlike the line numbers" }, "date": "2026-08-10", "at_commit": "de09711", "at_commit_note": "Named because that is where the comparison was actually run, which is NOT the commit this file's provenance pins (0bf0729). active_content.py did change over that range: `git diff 0bf0729..de09711` adds 23 lines. Measured, not assumed - the change is a self-safety length cap (a new `max_scan_chars` parameter, and an `active:oversize-input` finding when the input exceeds it). It adds no construct, removes none, and leaves every `_flag` call site and every construct pattern untouched, so the six-way comparison above holds at both commits. The drift is recorded rather than smoothed over: a reader checking this file against 0bf0729 will see a different active_content.py and is entitled to know why that does not invalidate the check." }, "not_every_active_label": { "$comment": "The seed runtime emits at least one `active:`-prefixed label that is NOT in this id space, and a consumer that matches by prefix rather than by membership will mis-scope it.", "known_example": "active:oversize-input", "what_it_is": "A self-safety flag (OWASP LLM10) raised when the input exceeds the runtime's scan cap, announcing that the tail went unread. It reports the detector's own coverage, not a construct found in the text, which is why it is not a member of this table.", "rule": "Membership in this id space is by presence in `ids`, never by the `active:` prefix. A finding carrying that prefix but absent from `ids` does not belong to this data file, and is therefore OUT of scope for a conformance case scoped here - see spec/conformance-corpus.md section 4.", "introduced": "Between 0bf0729 and de09711; absent at the commit this file's provenance pins." }, "stability": "An id is a stable identifier. Changing one is a BREAKING change for any consumer matching on it, and requires a major bump of this file.", "single_runtime": { "$comment": "This id space is ratified by ONE runtime, not two, and that is a weaker status than the injection lexicon's - stated here rather than left to be discovered by a consumer who assumes the two files carry equal weight.", "implemented_by": [ "llm_ingestion_guard" ], "not_implemented_by": [ "llm_security" ], "absence_evidence": "Measured in llm-security at b0de0ca, not assumed: a case-insensitive tree-wide search for an active-content detector, class table or emitted `active:` label returns no implementation - only prose in `knowledge/` and an entropy-scanner test that mentions the markdown-image construct. `git log -S 'active_content'` and `git log -S 'active:markdown'` over `--all` return zero commits, so the table was never present and later removed either.", "not_a_coverage_claim": "That runtime having no active-content TABLE is not the same as it being blind to these constructs. Its entropy scanner reaches markdown-image URLs from a different angle (rule E18, host- and query-aware). What is absent is this table, which is all `not_implemented_by` asserts.", "conformance_consequence": "Under spec/conformance-corpus.md section 1.1, a runtime that does not declare this file reports cases scoped to it as `not-applicable` - neither pass nor error." }, "aliases": { "$comment": "No `aliases` object is carried per construct, and the omission is deliberate rather than pending. The lexicon needs aliases because two runtimes name the same pattern differently; here the sole implementing runtime's emitted label IS the id, so an alias would be a copy of the key. A second runtime adopting this table registers its alias then - see the lexicon's aliases block for the shape." } }, "$comment": "Extracted verbatim, without behaviour change, from llm-ingestion-pipeline-security v0.3.4 (commit 0bf0729, 2026-08-03): src/llm_ingestion_guard/active_content.py (pattern table, pass order, URL-shape analysis, defang) and src/llm_ingestion_guard/calibration.py (per-construct severities and opacity floors). That module documents itself as the canonical home of this table with the Node port sharing its severities, which is why the guard is the seed source here rather than llm-security. As of 0.3.0 the raw-HTML CLASSIFIER alone is re-pinned forward to v0.7.0 — see `provenance.raw_html_branch_repinned`.", "provenance": { "source_repo": "llm-ingestion-pipeline-security", "source_version": "0.3.4", "source_commit": "0bf0729", "source_date": "2026-08-03", "source_files": [ "src/llm_ingestion_guard/active_content.py", "src/llm_ingestion_guard/calibration.py" ], "verified": true, "raw_html_branch_repinned": { "$comment": "Two pins, not one, because this file is a hybrid as of 0.3.0 and a single pin would misdescribe both halves. Everything above stays at 0.3.4 / 0bf0729; the raw-HTML classifier (`html`, `gates.active_tag`, the `raw-html` and `raw-html-link` construct entries and the raw-HTML part of `pass_order`) is pinned at v0.7.0 / be9759b.", "source_version": "0.7.0", "source_commit": "be9759b4b3bdb7e6b013fd8e3b4f4f0344ee5833", "source_date": "2026-08-13", "measured_from": "A scratch tree extracted with `git archive v0.7.0`, so the TAG was read rather than whatever the working copy happened to be on — its HEAD was two commits further along at the time (5667063).", "why_the_whole_classifier_and_not_just_the_new_id": "Publishing `active:raw-html-link` on top of the 0.3.4 predicate would have shipped a contract that looks complete and is wrong. Under the 0.3.4 rule a bare `` is active by NAME, and a consumer implementing from that hybrid would emit `active:raw-html-link` for it where the seed runtime at v0.7.0 emits nothing. `active_tag_class` is one function with three branches; the carrier split, the no-URL narrowing and the 0.6.0 external-target requirement cannot be stated separately.", "drift_measured_not_assumed": { "method": "Every regex, severity, tag list and numeric floor this file publishes was compared field-by-field against the v0.7.0 module by importing it and reading the compiled objects — after stripping Python's inline-flag rendering and applying this file's own declared `redundant-quote-escape` normalisation, so a spelling difference could not masquerade as drift.", "date": "2026-08-13", "result": "Exactly ONE published field had drifted: `html.active_tags`. Every construct pattern, every severity, both gate patterns, the whole of `url_shape`, `defang`, `evidence` and the opacity floors are byte-identical at 0.3.4 and at 0.7.0. That is why the re-pin is scoped to the classifier rather than applied to the file as a whole.", "the_one_drift": "`html.active_tags` published the MUTATOR's name set (23 entries, including `base`). At 0.3.4 there was only one set and the name was unambiguous; 0.6.0 split the predicate in two, and the SCANNER's set — which is the one this file's `active_tag` gate means — drops `base` to 22. The published value was therefore naming the wrong set of the two from 0.6.0 onward. Corrected in 0.3.0, with the mutator set kept under `html.mutator_tags` rather than deleted, because a consumer that also defangs needs it and it is the value this file used to publish." } } }, "dialect": { "name": "ecmascript-named-groups", "$comment": "Patterns are written in the ECMAScript / PCRE spelling of named groups. Flags are declared in a `flags` field and never inlined as `(?m)` / `(?i)`, because inline flags are not portable and not always position-independent.", "flags": { "i": "case-insensitive", "m": "multiline: ^ and $ match at line boundaries" }, "features_used": [ "named capture groups: (?...)", "negative lookbehind: (?` to `(?P`. Nothing else changes; `re` supports the lookarounds used here.", "Engines without lookbehind (RE2, Go `regexp`, most POSIX): `markdown-link`, `data-uri` and the dot-defang rule cannot be expressed as written. A consumer on such an engine MUST report the pattern as unsupported rather than skip it silently — a skipped pattern is an invisible false negative.", "`\\w` appears once, in the `reference-link` scheme run. Python's `\\w` is Unicode-aware on `str`, ECMAScript's is ASCII-only. RFC 3986 restricts a URI scheme to ASCII, so no reachable input distinguishes them; the difference is recorded rather than normalised away, to keep the pattern byte-identical to its source." ] }, "normalisations": [ { "id": "redundant-quote-escape", "affects": ["markdown-image", "markdown-link", "raw-html", "data-uri", "defang.url_in_text_pattern"], "from": "\\\"", "to": "\"", "reason": "The source patterns are Python raw strings, so `\\\"` reaches the engine as backslash-quote — a redundant escape of a character that needs none. Python `re` and ECMAScript without flags accept it, but ECMAScript REJECTS it under the `u` and `v` flags (verified: `new RegExp('\\\\\"', 'u')` throws `Invalid escape`), which would lock every unicode-mode consumer out of five patterns.", "equivalence_evidence": "Differential match-set comparison against the source objects over an adversarial 30-input corpus (bare quotes, escaped quotes, markdown titles containing quotes, quoted and unquoted HTML attributes, quote runs of length 1-5): 5 patterns x 30 inputs, zero differences in match offsets or captured text.", "byte_identical_to_source": false } ], "redos_note": "Every character run in this table excludes the character that opens its own anchor (`[` for the markdown forms, `<` for the autolink and the raw tag). That exclusion is load-bearing, not cosmetic: without it, crafted input that repeats the anchor and never supplies the required closing literal makes every start position rescan the tail — quadratic. Measured on the source implementation before the exclusions: `` and an ``.", "rule": "Run the `raw-html` pattern ONCE. For each match, classify it with the `active_tag` gate, which returns `raw-html`, `raw-html-link`, or inert. Append the match to that class's bucket and mask it. Inert matches are NOT masked — they are left in the working text, exactly as the seed runtime leaves them.", "then": "After the single scan, emit one finding per NON-EMPTY bucket, in the order `raw-html` then `raw-html-link`. A document holding both a `