1
0
Fork 0

feat(output): decode hex-wrapped credentials at the persist gate

Gap 1 closed. The output gate finds runs of 32+ hex digits
(calibration.HEX_WRAP_MIN_LEN), decodes both byte alignments, keeps text
that is at least 80% printable (the base64 bar, now one shared helper in
entropy) and runs the credential patterns on it. A hex-wrapped AWS key
surfaces as decoded:egress:aws-access-key-id with "in hex run @ N" as
evidence; the value is never in it. Credentials only: a hex-wrapped
injection is not decoded.

Measured before and after on the same trees: all 3583 clean documents
(three FP populations and the 78 pinned OKF concepts) are identical line
for line under both presets (disposition, labels, severity, count,
offset); OKF import stays 47/31. They and 569 READMEs hold 2403 hex runs
of 32+ digits, none of which decodes to text. scan_output CPU 122.9 s
before, 123.4 s after; one solid hex run at the 1M cap 4.2 s -> 5.8 s.

14 new tests (10 red for the right reason first), 6 of 6 mutations red.
Matrix 146/146 + 5/5 gaps, suite 1274 passed, ReDoS sweep 169 patterns.
This commit is contained in:
Kjell Tore Guttormsen 2026-11-01 17:06:44 +01:00
commit 0eab4345bf
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 265 additions and 40 deletions

View file

@ -5,6 +5,41 @@ 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 — the output gate decodes hex-wrapped credentials (gap 1 closed)
A credential written out as hex used to pass the output gate: `entropy` decodes
base64 blobs for the rescan, not hex. The gate now finds runs of 32 or more hex
digits (16 bytes), decodes them, and runs the credential patterns on the text, so a
hex-wrapped AWS key surfaces as `decoded:egress:aws-access-key-id`, the label a
base64-wrapped one already gets. The evidence says `in hex run @ <offset>`, the
offset is where the run starts, and the value is never in the evidence.
- Both byte alignments of a run are decoded, so one stray hex digit before or after
the credential (or an odd-length run) does not hide it. A `0x` prefix needs
nothing.
- The decoded bytes must be at least 80% printable, the bar a base64 blob's
plaintext already meets. The check is now one shared helper in `entropy`.
- Credentials only. A hex-wrapped injection is not decoded (one wrapped in base64
is). Hex split by separators (`41:4b`, `\x41`), other encodings and nested wraps
stay a documented boundary.
- The floor is `calibration.HEX_WRAP_MIN_LEN = 32`.
Documented gap 1 is closed: the coverage matrix asserts it as caught (the
`hex-wrapped` case, which checks the finding came from the hex run), and `tests/test_gap_register.py` records it.
**If you gate on `decoded:egress:*`:** a document with a hex-wrapped credential
now gets that finding and the disposition that comes with it. 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)
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. Those documents and 569 package READMEs hold 2,403 hex runs of 32 or more
digits (checksums and ids), and none of them decodes to text. `scan_output` over
the 3,583 took 122.9 s of CPU before and 123.4 s after. At the 1,000,000-character
cap, one solid hex run costs 5.8 s against 4.2 s before.
## [1.9.0] — 2026-11-01
### Changed — a lexicon finding counts every match (gap 4 closed)

View file

@ -91,12 +91,16 @@ avsnitt som kombinerer minst 2 av 4 klasser: adresse til modellen, utsettelse (
now on», «if the user later says»), minneskriving og hemmelighold. Én klasse alene er
vanlig prosa. Regelen bor i `output.py`, ikke i leksikonet. Et forfilter med nøkkelord
hopper over tekst der ingen ikke-adresse-klasse kan treffe. A12 (oppgave uten markør)
er det sjuende dokumenterte hullet. ReDoS-sveipet går nå inn i dict-er (168 mønstre).
er det sjuende dokumenterte hullet. ReDoS-sveipet går nå inn i dict-er.
**Antall treff (1.9.0):** et leksikonfunn er fortsatt ett funn per mønster-id med
første offset, men `count` er nå antall treff (hull 4 lukket). Tellingen er det
høyeste antallet treff i én enkelt variant (rå, dekodet, foldet, rot13). Variantene
summeres ikke, så `count` er en nedre grense. `count` når aldri disposisjonen. Hull 2,
3, 5 og 7 står med målt begrunnelse i LIMITATIONS, og 1 og 6 er åpne.
3, 5 og 7 står med målt begrunnelse i LIMITATIONS.
**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.
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for
byggerekkefølgen.

View file

@ -216,7 +216,7 @@ python -m llm_ingestion_guard.coverage # 145/145 classes; exit 0 = all as doc
| 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-*wrapped* secret (decoded → `decoded:egress:*`) |
| **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) |
| **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 |
@ -314,11 +314,11 @@ 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.
- **Six documented gaps** the coverage matrix keeps honest: hex-wrapped secret
egress, 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. A seventh,
lexicon dedup (`count=1`), was closed in 1.9.0.
- **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.
- **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

@ -222,8 +222,9 @@ Conceding these is itself a control. The full list is in the guard's `README.md`
- **Text-only, extracted-text-only.** No file parsing in the core; what survives
text extraction (macros, OLE objects, OCR-embedded instructions, render/font
stego, encrypted files) is out of scope beyond the sanitizer's character layer.
- **Secret egress: base64-wrapped is caught, hex-wrapped is not** (a documented
boundary — decode the transport layer first if you need it scanned).
- **Secret egress: base64- and hex-wrapped are caught (hex since 1.10.0); hex with
separators, other encodings and nested wraps are not** (a documented boundary —
decode the transport layer first if you need it scanned).
## 9. Where to read more (in the guard repo)

View file

@ -623,11 +623,14 @@ items; this is the full list, each with the mechanism.
the most common shape in real documentation. The residual: a *clicked* link to an
attacker-controlled page can have its `location.hash` read by that page's script,
so a fragment payload on a link (not an image) is uncovered.
- **Secret egress: base64-wrapped is caught, hex-wrapped is not.** The output gate
decodes base64 blobs and re-scans the plaintext, so a base64-*wrapped* secret
surfaces as `decoded:egress:*`. `entropy` exposes decoded plaintext for base64
only, so hex (and other encodings, or nested wraps) is a deliberate boundary —
decode the transport layer first if you need it scanned.
- **Secret egress: base64- and hex-wrapped are caught, other wraps are not.** The
output gate decodes base64 blobs and, since 1.10.0, hex runs of 32 characters or
more (both byte alignments, so one stray digit cannot hide the credential), and
re-scans the plaintext, so a wrapped secret surfaces as `decoded:egress:*`. Hex
split by separators (`41:4b`, `41 4b`, `\x41`), other encodings and nested wraps
are a deliberate boundary — decode the transport layer first if you need it
scanned. The hex plaintext goes to the credential patterns only: an injection
wrapped in hex is not decoded (one wrapped in base64 is).
- **Prose that merely mentions `<script>` fires `hybrid-xss:script-tag`.** The
pattern matches the opening tag and no longer requires `</script>`, so a
@ -686,7 +689,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 168 compiled patterns across all eleven regex-bearing modules are
health.** All 169 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
@ -816,8 +819,11 @@ today (WARN) that the rule would stop. Because those 3,583 hold no external imag
569 package READMEs (from installed npm and PyPI packages, deduplicated by sha256)
were measured too.
1. **Hex-wrapped secret egress** — `entropy` decodes base64 only (above). Open.
Measured to close without new alarms; next in line.
1. **Hex-wrapped secret egress** — **closed in 1.10.0.** The output gate decodes
hex runs of 32 characters or more and runs the credential patterns on the text
(see "Secret egress" above). Measured as built: every one of the 3,583 documents
keeps its disposition, labels and severities. They and the 569 READMEs hold
2,403 hex runs this long (checksums and ids), and none decodes to text.
2. **Semantic / factual poisoning** — invisible to token analysis (above).
**Measured reason:** the cheapest rule, a factual claim of the form "is made of"
or "was completed in", gave 0 new alarms on the 3,583 and fired on 3 of the 569

View file

@ -283,6 +283,49 @@ skjøtene raden melder. Matrisesaken står nå som fanget (`lexicon:count`), og
ligger fortsatt ubehandlet i innboksen deres. Rad 1 er ikke framme før jobben deres
har kjørt lista vår.
## V.9 Økt 4, hull 1 lukket (01.11.2026, release 1.10.0)
**Hull 1 (heks-innpakket hemmelighet):** utgangsporten finner løp på minst 32
heks-sifre (`calibration.HEX_WRAP_MIN_LEN`), dekoder dem og kjører
hemmelighetsmønstrene på klarteksten. Funnet heter `decoded:egress:*`, som for
base64, og beviset sier `in hex run @ <offset>`. Matrisesaken `hex-wrapped` står nå
som fanget og sjekker at funnet kom fra heks-løpet, ikke fra en base64-dekoding.
`tests/test_gap_register.py` har `CLOSED = {"dedup": "count", "hex-wrapped": "hex-wrapped"}`.
**To avvik fra regelen målt i § V.6**, begge for å stå imot en enkel omgåelse:
- Begge byte-justeringene dekodes. Med bare én ville ett løst heks-siffer foran
nøkkelen (eller et oddetall-løp) gjemt den.
- Kravet er minst 80 % skrivbare tegn, samme krav som base64-klarteksten møter (én
delt hjelper i `entropy`), ikke 100 %. Med 100 % ville én søppelbyte foran nøkkelen
gjemt den.
Begge målt som bygget (under), så § V.6-tallet er ikke gjenbrukt.
Bare hemmelighetsmønstrene kjøres på klarteksten. En heks-innpakket injeksjon dekodes
ikke (en base64-innpakket gjør det). Det står i LIMITATIONS.
- **TDD:** 14 nye tester i `tests/test_output.py` erstatter den gamle hull-testen. De ti
som krever et funn var røde av riktig grunn (ingen funn), de fire negative holdt
allerede. Kalibreringstesten (`HEX_WRAP_MIN_LEN` frosset og lest fra
`calibration`) ble skrevet før konstanten, men ikke kjørt rød da. Den er rød med
33. Netto +14 tester. Mutasjonssjekk: bare justering 0, gulv 34, gulv 30, 100 % skrivbart,
steget fjernet og bevis-suffikset fjernet gir hver minst én rød test (6 av 6).
Gulv 30 overlevde først, og gulvtesten fikk en sak som dekker den.
- **Vaktregelen:** målt før og etter på samme trær. Alle 3 583 dokumenter har samme
disposisjon under begge forhåndsinnstillingene og samme etiketter,
alvorlighetsgrader, `count` og offset (utdataene er identiske linje for linje).
OKF-importen står på 47 fail-secure / 31 warn, uendret. 0 nye alarmer.
- **Overflaten:** de 3 583 og de 569 README-ene har **2 403** heks-løp på minst 32
tegn i **2 351** dokumenter (5 + 2 311 + 4 + 0 + 31; sjekksummer og id-er). **0**
dekodes til tekst, så regelen får aldri noe å skanne. Positiv kontroll: matrisetekstens
løp dekodes og fyrer, og et løp med vanlig tekst dekodes uten å fyre.
- **Ytelse:** `scan_output` over de 3 583 tok 122,9 s CPU før og 123,4 s etter. Ved
taket på 1 000 000 tegn koster ett sammenhengende heks-løp 5,8 s mot 4,2 s før,
og 31-tegns løp (under gulvet) 4,5 s mot 4,2 s.
- **ReDoS:** sveipet teller 169 mønstre (nytt: `_HEX_RUN_RE`). Fire kjøringer ga 1, 0,
0 og 0 tidsflagg (den kjente støyen), og output-tabellen alene 0 av 14.
- **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).
## 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

@ -38,6 +38,15 @@ ENTROPY_MEDIUM_H, ENTROPY_MEDIUM_LEN = 4.7, 40
ENTROPY_BASE64_FLOOR_LEN = 100
ENTROPY_HEX_FLOOR_LEN = 64
# Shortest hex run the output gate decodes and rescans for credentials (gap 1,
# 1.10.0). 32 characters is 16 bytes, shorter than any credential the egress set
# reports on its own. Lower than ENTROPY_HEX_FLOOR_LEN on purpose: this is not a
# finding about the run, only a decode attempt, and a run that decodes to
# printable text with no credential in it reports nothing. Measured as built
# (docs/PLAN.md § V.9): the 3,583 clean documents and 569 READMEs hold 2,403 hex
# runs this long, and none of them decodes to text.
HEX_WRAP_MIN_LEN = 32
# --- lexicon: self-safety + variant thresholds ------------------------------
# Input-size cap (OWASP LLM06:2026): large enough for a real ingested document;
# beyond it the scanner reads the prefix and flags, so every sub-scanner sees a

View file

@ -373,10 +373,13 @@ def _reserved_body_scanned_probe():
# If the guard ever closes one of these, the probe flips and the doc must change.
def _hex_wrapped_secret_gap():
def _hex_wrapped_secret_probe():
# Gap 1 until 1.10.0. The label is shared with the base64-wrapped case, so the
# probe asserts the finding came from the hex run, not from a base64 decode.
report = scan_output("archived reference blob: " + _HEX_WRAPPED_SECRET, source=Source.OUTPUT)
caught = any(f.label == "decoded:egress:aws-access-key-id" for f in report.findings)
return (not caught), "hex-wrapped secret not decoded (base64-only) — documented"
hits = [f for f in report.findings if f.label == "decoded:egress:aws-access-key-id"
and "hex run" in (f.evidence or "")]
return bool(hits), f"hex-wrapped AWS key -> {len(hits)} decoded:egress finding from the hex run"
def _semantic_poisoning_gap():
@ -533,6 +536,9 @@ def _build_cases() -> list[Case]:
"reference data: " + _B64_INJECTION + " end"),
_scan_case("decoded", "base64-wrapped secret (LLM02:2026)", "decoded:egress:aws-access-key-id",
"archived blob: " + _B64_WRAPPED_SECRET, owasp=_owasp.SENSITIVE_INFORMATION_DISCLOSURE),
_predicate_case("decoded", "hex-wrapped secret (LLM02:2026)", "hex-wrapped",
_hex_wrapped_secret_probe, owasp=_owasp.SENSITIVE_INFORMATION_DISCLOSURE,
note="hex runs of 32+ chars decoded, both alignments; credentials only"),
]
# 4. Active content — the EchoLeak / LLM10:2026 class. Payloads are exfil-SHAPED
@ -651,9 +657,6 @@ def _build_cases() -> list[Case]:
# 9. Documented residual gaps (Table B) — honest boundary, asserted to hold.
cases += [
_predicate_case("gap", "hex-wrapped secret egress", "hex-wrapped",
_hex_wrapped_secret_gap, status="gap", owasp=_owasp.SENSITIVE_INFORMATION_DISCLOSURE,
note="entropy exposes base64 plaintext only; decode transport first"),
_predicate_case("gap", "semantic / factual poisoning", "semantic",
_semantic_poisoning_gap, status="gap",
owasp=_owasp.DATA_AND_MODEL_POISONING,

View file

@ -133,6 +133,16 @@ def try_decode_base64(s: str) -> str | None:
raw = base64.b64decode(padded, validate=False)
except ValueError: # binascii.Error is a ValueError subclass
return None
return _printable_text(raw)
def _printable_text(raw: bytes) -> str | None:
"""``raw`` as text if it is >= 80% printable ASCII, else ``None``.
The bar a decoded blob must clear before it is rescanned. Shared by the
base64 decode above and the output gate's hex decode, so both encodings are
held to the same notion of "decodes to text".
"""
if not raw:
return None
decoded = raw.decode("utf-8", errors="replace")

View file

@ -20,8 +20,11 @@ input-side scanners do not cover:
credential — is hidden *inside* this blob". Findings from the decoded
plaintext are re-labelled ``decoded:<label>`` (e.g.
``decoded:egress:aws-access-key-id``) and carry the blob's offset in the
original text. (Scope: base64 only — ``entropy`` exposes decoded plaintext
for base64, not hex; a hex-*wrapped* secret stays a documented honest-limit.)
original text. ``entropy`` exposes decoded plaintext for base64 only, so
hex runs of :data:`~llm_ingestion_guard.calibration.HEX_WRAP_MIN_LEN`
characters or more are decoded here and fed to ``scan_secret_egress`` alone
(since 1.10.0): a hex-*wrapped* credential surfaces as ``decoded:egress:*``,
while a hex-wrapped injection is not decoded.
4. **Secret / credential egress** (:func:`scan_secret_egress`, OWASP LLM02:2026 —
Sensitive Information Disclosure) — cloud/provider API keys, PEM private-key
headers, DB connection strings, JWTs, and labelled password/secret/api-key
@ -70,12 +73,13 @@ from typing import Optional, Union
from .active_content import scan_active_content
from .calibration import (
HIDDEN_COLOUR_TOLERANCE,
HEX_WRAP_MIN_LEN,
HIDDEN_FONT_MAX_PX,
MAX_CONNSTR_VALUE,
MAX_STYLE_ATTR_CHARS,
PROSE_INSTRUCTION_MIN_CLASSES,
)
from .entropy import scan_entropy
from .entropy import _printable_text, scan_entropy
from .lexicon import MAX_SCAN_CHARS, scan_lexicon
from . import _owasp
from .report import Finding, Report, Severity, Source
@ -617,6 +621,34 @@ def _scan_prose_instruction(text: str, source: Source) -> Report:
return report
# --- hex-wrapped credentials (gap 1, 1.10.0) -----------------------------------
# A maximal run of hex digits. No lookaround and one bounded class, so finditer is
# linear: a run shorter than the floor fails within HEX_WRAP_MIN_LEN steps.
_HEX_RUN_RE = re.compile(r"[0-9A-Fa-f]{%d,}" % HEX_WRAP_MIN_LEN)
def _hex_decoded_runs(text: str) -> list[tuple[int, str]]:
"""``(offset, plaintext)`` for each hex run that decodes to printable text.
Both byte alignments of the run are tried, so one stray hex digit on either
side (or an odd length) cannot shift the credential out of view. A ``0x``
prefix needs nothing: the ``x`` already ends the run before it. The decoded
bytes meet the same printable bar a base64 blob's plaintext does.
"""
out: list[tuple[int, str]] = []
for match in _HEX_RUN_RE.finditer(text):
run = match.group()
for start in (0, 1):
body = run[start:]
body = body[: len(body) - len(body) % 2]
if len(body) < HEX_WRAP_MIN_LEN:
continue
plain = _printable_text(bytes.fromhex(body))
if plain is not None:
out.append((match.start(), plain))
return out
def scan_output(
text: str,
source: Source = Source.OUTPUT,
@ -657,8 +689,7 @@ def scan_output(
# the hiding blob. Feeding the egress set here (not only the lexicon) is
# what catches a base64-*wrapped* secret: the plaintext credential reaches
# scan_secret_egress as a decoded:egress:* finding instead of vanishing.
# (Scope: base64 only — entropy exposes decoded plaintext for base64, not
# hex; a hex-wrapped secret stays a documented honest-limit.)
# entropy exposes decoded plaintext for base64 only; hex is step 3b.
for blob in entropy_result.decoded:
hidden = scan_lexicon(blob.decoded, source, max_scan_chars).findings
leaked = scan_secret_egress(blob.decoded, source).findings
@ -672,6 +703,20 @@ def scan_output(
)
)
# 3b. The same egress rescan for a hex-*wrapped* credential (gap 1 until
# 1.10.0): entropy exposes plaintext for base64 only, so hex runs are
# decoded here. Credentials only: a hex-wrapped injection is not decoded.
for offset, plain in _hex_decoded_runs(scan_text):
for finding in scan_secret_egress(plain, source).findings:
report.add(
replace(
finding,
label=f"decoded:{finding.label}",
offset=offset,
evidence=f"{finding.evidence} (in hex run @ {offset})",
)
)
# 4. Secret / credential egress (OWASP LLM02:2026).
report.extend(scan_secret_egress(scan_text, source).findings)

View file

@ -41,6 +41,14 @@ def test_output_selfsafety_frozen():
assert cal.MAX_STYLE_ATTR_CHARS == 2048
def test_hex_wrap_floor_frozen_and_sourced():
# Gap 1 (1.10.0): the shortest hex run the output gate decodes and rescans
# for credentials. 32 characters is 16 bytes.
from llm_ingestion_guard import output
assert cal.HEX_WRAP_MIN_LEN == 32
assert output.HEX_WRAP_MIN_LEN is cal.HEX_WRAP_MIN_LEN
def test_hidden_markup_thresholds_frozen():
assert cal.HIDDEN_FONT_MAX_PX == 4.0
assert cal.HIDDEN_COLOUR_TOLERANCE == 8

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

View file

@ -24,7 +24,7 @@ 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"}
CLOSED: dict[str, str] = {"dedup": "count", "hex-wrapped": "hex-wrapped"}
def _rows() -> list[dict[str, str]]:

View file

@ -152,14 +152,75 @@ def test_base64_wrapped_secret_evidence_never_leaks_the_value():
assert AWS_KEY not in (finding.evidence or ""), "decoded evidence leaked the secret"
def test_hex_wrapped_secret_is_a_documented_restgap():
# Honest-limit (deliberate boundary, not a silent miss): entropy only exposes
# decoded plaintext for base64, not hex, so a hex-wrapped secret is NOT caught.
# Documented in README honest-limits; asserted here so the boundary is explicit.
def _hex_egress(text: str) -> list:
return [f for f in scan_output(text).findings
if f.label.startswith("decoded:egress:")]
def test_hex_wrapped_secret_is_caught():
# Gap 1 until 1.10.0: a hex run of 32+ characters that decodes to printable
# text is fed to scan_secret_egress, as a base64 blob's plaintext already is.
hexed = AWS_KEY.encode().hex()
report = scan_output("archived reference blob: " + hexed)
assert not any(f.label == "decoded:egress:aws-access-key-id"
for f in report.findings)
labels = {f.label for f in _hex_egress("archived reference blob: " + hexed)}
assert "decoded:egress:aws-access-key-id" in labels
@pytest.mark.parametrize("shape", ["upper", "0x", "odd-tail", "odd-head", "framed"])
def test_hex_wrapped_secret_is_caught_in_every_hex_shape(shape):
# Case, a 0x prefix and one stray hex digit on either side must not hide the
# credential: both byte alignments of the run are decoded.
hexed = AWS_KEY.encode().hex()
run = {"upper": hexed.upper(), "0x": "0x" + hexed, "odd-tail": hexed + "a",
"odd-head": "a" + hexed, "framed": "a" + hexed + "b"}[shape]
labels = {f.label for f in _hex_egress("blob " + run + " end")}
assert "decoded:egress:aws-access-key-id" in labels, shape
def test_hex_wrapped_secret_survives_a_junk_byte_in_front():
# A non-printable byte ahead of the credential is still under the same 80%
# printable bar a base64 blob's plaintext meets.
hexed = "00" + GITHUB_PAT.encode().hex()
labels = {f.label for f in _hex_egress("blob " + hexed)}
assert "decoded:egress:github-pat-classic" in labels
def test_hex_wrapped_secret_evidence_never_leaks_the_value_or_its_hex():
hexed = AWS_KEY.encode().hex()
found = _hex_egress("archived reference blob: " + hexed)
assert found, "hex-wrapped AWS key was not surfaced"
for finding in found:
evidence = finding.evidence or ""
assert AWS_KEY not in evidence and hexed not in evidence
assert "hex run" in evidence
def test_hex_wrapped_secret_offset_points_at_the_run():
prefix = "archived reference blob: "
found = _hex_egress(prefix + AWS_KEY.encode().hex())
assert [f.offset for f in found] == [len(prefix)]
def test_hex_run_floor_is_32_characters():
# 15 bytes -> 30 hex characters: below the floor, not decoded. One byte more
# reaches it. The plaintext is a labelled password the egress set reports.
below = ("pass='" + "k9Qz7wLp" + "'").encode().hex()
at = ("pass='" + "k9Qz7wLpX" + "'").encode().hex()
assert len(below) == 30 and len(at) == 32
assert _hex_egress("value " + below) == []
# Framed to a 32-character run, the credential still spans only 30: the floor
# holds for the decoded alignment, not just for the run.
assert _hex_egress("value a" + below + "b") == []
assert {f.label for f in _hex_egress("value " + at)} == {"decoded:egress:config-password"}
@pytest.mark.parametrize("text", [
"sha256 " + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"commit " + "da39a3ee5e6b4b0d3255bfef95601890afd80709" + " and md5 " + "d41d8cd98f00b204e9800998ecf8427e",
"plain " + b"an ordinary sentence with no credential in it".hex(),
"binary " + bytes(range(0, 256, 3)).hex(),
])
def test_hex_runs_without_a_credential_give_no_decoded_finding(text):
assert _hex_egress(text) == []
def test_aggregates_lexicon_and_egress_findings():