llm-ingestion-pipeline-secu.../README.md
Kjell Tore Guttormsen 5397ba15a1 fix(security): harden 5 adversarial-review findings (M1/M2/M3 + m4/m6) via TDD
Pre-release hardening from an independent adversarial review; each fixed
test-first (failing test -> fix -> green). 214 tests pass.

- entropy (M1): decode-and-rescan now runs BEFORE false-positive suppression,
  so an SRI/media-prefixed injection blob is still decoded and lexicon-rescanned.
  Suppression gates only the entropy finding, never the decode.
- output/disposition (M3): the invisible-carrier invariant now holds on the
  persist gate. scan_output flags zero-width/BIDI presence and disposition
  treats those + lexicon:unicode-tags-present as any-tier carriers, so a carrier
  in model output fails secure even under a trusted policy.
- contract (M2): assert_credential_allowlist catches a bare <PROVIDER>_KEY
  (e.g. STRIPE_KEY) that the old regex silently missed (fail-open). Deliberately
  broad: also flags PARTITION_KEY/SORT_KEY as loud, allowlistable FPs -- fail-loud
  beats fail-silent for an isolation control.
- disposition (m6): guard runs decide inside its guarded block -> total
  fail-closed even on a malformed report.
- output (m4): egress placeholder suppression anchors word markers (example,
  todo, ...) to a word boundary, closing a fail-open where a real secret merely
  containing such a word was suppressed.

Docs: CHANGELOG Security subsection; README honest-limit for lexicon dedup (m5,
documented tradeoff, not fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-05 10:45:05 +02:00

139 lines
7.1 KiB
Markdown

# llm-ingestion-guard
![Version](https://img.shields.io/badge/version-0.1.0-blue)
![Status](https://img.shields.io/badge/status-alpha-orange)
![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![Tests](https://img.shields.io/badge/tests-201_passing-green)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
A reusable, minimal, dependency-light defensive layer for **LLM ingestion
pipelines** — the write-time siblings of query-time chatbot guardrails.
Where mature guardrails (LLM Guard, NeMo Guardrails, Rebuff, Vigil, …) sit
between a user and a model at query time, this library hardens the other shape:
untrusted content flowing through an LLM enrichment/summarization/extraction step
into a **persisted, downstream-consumed artifact** (RAG corpus, knowledge base,
wiki). It packages the architectural contract — sanitize → fence → tool-less
quarantined transform → per-stage capability isolation → scan output before
commit → fail-secure — as composable, stdlib-first, framework-agnostic code.
The gap it fills is **not** "no one detects injection." It is a small *library*
(not a hosted service, not a fine-tuned model) that packages the **write-time
ingestion contract** — the part query-time tooling structurally cannot see,
because a poisoned artifact committed at write time is read by a *downstream*
agent whose guardrail never sees where it came from.
**Status:** `v0.1`, alpha. The stdlib-only core is built and tested — ten
detector/contract modules and the top-level wiring, exercised by an end-to-end
showcase and adversarial + false-positive corpora. The public API may still
change. There are real limitations, stated plainly below; read them.
## Install
```bash
pip install llm-ingestion-guard # stdlib-only core, zero dependencies
```
Optional ML/judge detectors live behind extras (`[ml]`, `[judge]`) and are not
required — the core is deterministic and dependency-free.
## Quickstart — the two bookends
The library never makes the model call itself. It gives you the two library-side
halves around your own **tool-less** transform:
```python
from llm_ingestion_guard import (
prepare_input, screen_output, Disposition, PRESET_USER_UPLOAD,
)
prepared = prepare_input(untrusted_content) # §6 1-2: sanitize + fence
enriched = your_model(prepared.fenced) # §6 3: tool-less — YOUR call
decision = screen_output(enriched, PRESET_USER_UPLOAD) # §6 6-7: scan + dispose
if decision.disposition is Disposition.FAIL_SECURE:
alert(gate_code=decision.reasons) # §6 8: minimal payload, no content
raise SystemExit # §6 7: halt — never persist
```
`screen_output` fails **closed**: if the scanner itself errors on crafted input,
the disposition is `FAIL_SECURE`, never a silent persist. Pass
`transform_failed=True` when your model call raised or fell back — a scan hit
together with a transform failure is treated as a probable forced-fallback attack
and halts regardless of trust tier.
Every primitive is also exported for pipelines that compose the checklist
themselves — `sanitize`, `scan_lexicon`, `scan_entropy`, `scan_output`,
`neutralize`, the `decide` / `guard` disposition machinery, and the contract
asserters `assert_tool_less` / `assert_credential_allowlist` / `scoped_env`. See
[the end-to-end showcase](tests/test_showcase.py) for a full worked pipeline.
## The reusable contract (adopt-this checklist)
The actual product is this checklist, encoded as code you wire in order:
1. **Sanitize before fence.** Strip carrier classes (zero-width, BIDI,
Unicode-tag, HTML comment, `data:`) from untrusted input first.
2. **Fence untrusted input.** Spotlight-mark it in a randomized per-call
delimiter; strip attacker fence markers from the payload.
3. **Tool-less transform.** Call the model with zero tools. A successful
injection then has nothing to act with.
4. **Per-stage capability isolation.** The enrichment stage holds only the model
key; the publish stage holds only the publish credential; no stage holds both.
5. **Treat output as data.** Parse to a frozen schema; reject on structural
violation. The output never reaches a shell, git, or a filesystem path.
6. **Scan output before persist.** Run the lexicon + entropy over the emitted
text. Verbatim-carried payloads and model-emitted instructions are caught here.
7. **Fail-secure on compound signals.** Injection hit + transform failure = halt
+ alert, never a silent verbatim commit.
8. **Minimal alert payloads.** Alert with a gate code + run ID, never content.
Steps 1-2 are `prepare_input`; steps 6-7 are `screen_output`; steps 3-5 are
yours; the contract asserters harden step 3-4.
## Honest limitations (shipped as a control)
Conceding these plainly is itself a control — it prevents the false assurance
that a green scan means safe content:
- **Structural unsolvability at the text layer.** Pattern/lexicon detection is
bypassable in isolation; character-injection and novel phrasings evade it. The
*contract* (tool-less transform, capability isolation, fail-secure) is what
carries the security — the lexicon is defense-in-depth, not a wall.
- **Semantic / factual poisoning is invisible** to lexicon + entropy: a
factually false claim in clean prose carries no suspicious token. The
`grounding` module ships only a `SourceGroundingCheck` *seam* — the deterministic
core does not judge semantics; a `[judge]` implementation must be plugged in.
- **Adversarial-ML evasion** can survive normalization; **tokenizer mismatch**
between scanner and model leaves gaps.
- **Latent / dormant memory poisoning** is not judgeable at write time.
- **Insider in-place edits** by a trusted author are out of the untrusted-content
threat model.
- **Text-only.** The core is `text -> findings`: it parses no files (no
`pypdf`/`python-docx`/archive deps). Extract text first, then scan it with the
high-untrust upload provenance. OCR-embedded instructions and multimodal stego
in images/PDFs are out of scope beyond the sanitizer's character-layer stripping.
- **Lexicon findings are deduplicated by pattern id** — `count=1` and the first
offset are reported, so the same class matched across several channels/variants
collapses to one finding at its first location. This keeps reports readable, but
a caller that counts occurrences or needs every offset of a repeated pattern sees
only the first: a deliberate readability tradeoff, not full positional coverage.
## Out-of-scope (documented boundary)
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
steganography; query-time / runtime guardrails; semantic factuality verification.
## Design & threat model
- [Design brief](docs/BRIEF.md) — what this repo contains and why.
- [Build plan](docs/PLAN.md) — module build order and the reuse map.
The contract is extracted from a working reference implementation (the
`claude-code-llm-wiki` Stage B enrichment pipeline). Threat-model anchors: OWASP
LLM Top-10 2025 (LLM01/02/04/05/06 strongest, LLM08 boundary, LLM09/10),
PoisonedRAG, guardrail-evasion (arXiv 2504.11168), EchoLeak (CVE-2025-32711).
## License
MIT — see [LICENSE](LICENSE).