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:
parent
61d7d521e4
commit
0eab4345bf
14 changed files with 265 additions and 40 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
docs/PLAN.md
43
docs/PLAN.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue