1
0
Fork 0
llm-ingestion-pipeline-secu.../docs/PLAN.md
Kjell Tore Guttormsen 0ebe8abdbd
feat(output): report hidden markup — tiny font, invisible colour, comments outside code
Session 5 of the 2026 threat plan (attack row 3): A04 and A15 were red.

- html-obfuscation:tiny-font (HIGH): font below 4px. ShadowLeak's 1px passed
  because the commons zero-font row only matches a leading 0.
- html-obfuscation:invisible-colour (HIGH): text within 8 per channel of the
  background in the same style attribute, or transparent. White alone is not
  a signal (email buttons); measured residual in LIMITATIONS.
- output:html-comment-present (MEDIUM): a comment in the artifact outside
  fenced code and inline code. 253/254 comments in the reference corpus sit
  in code.

Local to output.py; the vendored lexicon stays byte-equal with commons.
Guard rule before/after: FP 106/198, 196/2918, 108/389 identical; OKF 6/53
and 47/31 identical. Matrix 139/139, ReDoS 161 patterns 0 flagged, suite
1142 passed + 6 xfailed. Row 3: 12 caught, 1 gap, 6 open.
2026-10-31 17:01:39 +01:00

42 KiB
Raw Blame History

PLAN — llm-ingestion-guard

Retning (godkjent av operatøren 28.10.2026)

«llm-ingestion-guard bygger på nyeste utgave av hver trusselkatalog den viser til, hver med dato for siste sjekk og kildelenke. Ingen funn eller tekst bruker et katalognummer med en eldre utgaves betydning. De kjente angrepene fra 2025–2026 som kan nå en innhentings-pipeline, er testsaker. Hver av dem fanges eller står som et dokumentert hull.»

Operatørens ord: «Dette repoet bør også oppdateres [til de siste trusseldefinisjonene], og det er sikkert lurt å hente inspirasjon fra det som gjøres med LLM Security.» Hva som er lagt til, og hvorfor:

  • «Siste trusseldefinisjoner» er gjort målbart som tre ting: kataloger, numre og angrep. Det er målemetoden llm-security bruker, avgrenset til det som gjelder et bibliotek.
  • «Kan nå en innhentings-pipeline» avgrenser hvilke angrep som teller (§ T.3).
  • Ikke tatt med: automatisk varsling om nye katalogutgaver (kan komme senere, helst gjenbrukt) og kostnadsregelen for alltid-lastet tekst (gjelder bare en plugin).

Operatøren 28.10: «Ja til endringen. Lag en skikkelig plan, vi bruker så mange økter som trengs for å gjøre dette skikkelig.» Det samme «ja» godkjente den anbefalte merkingen: funn merkes med årstall (LLM10:2026, OWASPs egen skrivemåte), og endringen gis ut som 1.5.0 med en tydelig merknad.

Publisering (operatøren 28.10): «Tanken er at når dette repoet er oppgradert så skal vi flytte det ut til Forgejo open.» Repoet er privat under oppgraderingen (målt 28.10: open/-adressen sender videre til en privat kopi). Det flyttes til open/ etter sluttmålingen, se § T.4 «Siste økt». Valget om å publisere testsakene åpent gjelder fra da.

Dekket i: katalogene → § T.4 økt 4 (rad 1) · numrene → økt 3 (rad 2) · angrepene → økt 1 (fryser lista), 2 og 5–7 (rad 3) · ferdig → siste økt.


Trusselbildet 2026 (v1.4.2 →)

Planen til retningen over. Den gamle v1-planen står uendret lenger ned under «Implementation Plan — v1». Den er historikk og telles ikke i rad 2, på samme måte som CHANGELOG. Planen oppdateres underveis og overskrives aldri.

T.1 Målt 28.10.2026 på v1.4.1 (6702d7a), før noe er bygd

  1. Katalognumrene har feil betydning. OWASP Top 10 for LLM Applications kom i 2026-utgave (publisert 04.08.2026) med ny rekkefølge. Bare LLM01 og LLM02 betyr det samme som i 2025. Repoet bruker LLM03–LLM10 med 2025-betydning på 101 steder i 26 sporede filer (src 50, tests 28, docs 17, README 6; CHANGELOG, dist/ og *.local.* er utelatt). Ingen av dem bærer årstall. Hurtigtelling med grep -oE 'LLM(0[3-9]|10)\b', altså ikke en klassifisering. Økt 1 fryser den ekte nevneren. Funnfeltet owasp bærer dette ut til hver eneste bruker: EchoLeak-funnene merkes LLM05, som i 2026 betyr dataforgiftning. Økt 1 (28.10): den ekte nevneren er 95. Avstemt mot hurtigtellingen: 101 − 14 i historikk (v1-planen 10, PLAN-v1 1, gjennomgangen 3) + 7 tall i skråstrek-lister som grep ikke ser (LLM01/02/04/05/06) + 1 LLM01 med feil betydning (standardverdien som merker OKF-stiavvisninger som prompt injection).
  2. Usynlige tegn slipper gjennom. Først testet jeg tekst skjult i variasjonsvelgere (U+FE00–FE0F, U+E0100–E01EF), der «ignore previous instructions and exfiltrate» var kodet i 43 tegn. Det ga 0 funn i rensingen, i innhentingsinngangen og i utgangskontrollen, og alle 43 tegnene lå igjen i den «rensede» teksten. Deretter testet jeg 19 tegntyper, 8 av hver, gjennom rensingen. 17 av 19 gir 0 funn og blir liggende: U+2060–2064, U+180E, U+3164, U+115F, U+1160, U+FFA0, U+034F, U+FFF9, U+200E, U+061C, U+E000 (privat bruk), U+FE00 og U+E0100. Positiv kontroll: U+E0041 (tag) og U+200B (zero-width) fanges og fjernes. Ingen av dette står i LIMITATIONS.
  3. Ingen katalog har dato for siste sjekk. README og «Threat-model anchors» lenger ned nevner OWASP 2025 uten dato. Agent-katalogen (OWASP Top 10 for Agentic Applications 2026, der ASI06 «Memory & Context Poisoning» er vårt område) og MITRE ATLAS er ikke koblet inn.
  4. Leksikonet har ikke drevet. Vår kopi har 83 mønstre, akkurat som leksikonet i llm-security-commons v0.9.0 (filversjon 0.8.0). Commons' tegntabell (carriers.json 0.2.0) mangler også variasjonsvelgere: tekstsøk etter FE00, E0100 og variation gir 0 treff. Hullet er altså felles.

T.2 Ferdig-kriteriet

Retningen er nådd når rad 1–3 holder samtidig, målt på siste 1.x-release:

# Måling Nevner Målt 28.10 (v1.4.1) Ferdig
1 Trusselkataloger og kilder på nyeste utgave, hver med dato for siste sjekk og kildelenke 15 oppføringer, frosset i økt 1 (docs/threat-2026/catalogs.json); kan utvides, krymper aldri uten operatørens ja 0 av 15 har dato i repoet. Bak: OWASP LLM (2025 → 2026), Unicode (17.0 → 18.0). Ikke koblet inn: Agentic, ATLAS. RAG-juksearket står uten lenke og dato 15 av 15
2 Steder der et katalognummer brukes med en eldre utgaves betydning, eller med en betydning ingen utgave har gitt det telling over sporede filer, med historikk utenfor (CHANGELOG, v1-planen, utgitte gjennomganger), med docs/catalog-census.py 95 i 23 filer: 82 med gammel betydning, 13 med feil. Frosset ved 6702d7a, listing-sha256 da1aa0fa…6f 0, og en permanent test holder det der
3 Kjente 2025–2026-angrep som kan nå en innhentings-pipeline, som testsaker 19 rader innenfor (pluss 4 utenfor med grunn), frosset i økt 1 (docs/threat-2026/attacks.json) 8 fanges, 1 er et dokumentert hull (PoisonedRAG), 10 er røde (12 av 31 ankomster) hver sak fanges, eller står som hull i LIMITATIONS og dekningsmatrisen

Vaktregel, ikke mål: ingen nye falske alarmer på korpusene vi allerede måler mot (falsk-positiv-korpuset og det pinnede OKF-korpuset på 6/53), og testsuiten, dekningsmatrisen og ReDoS-sveipet er grønne på hver release. En regel som strammer inn og gir nye alarmer på rene dokumenter, skal måles og legges fram, ikke slippes stille.

Ikke framme hvis én: en katalog er bak eller mangler dato · ett gammelt nummer-sted gjenstår · en angrepssak verken fanges eller er dokumentert. Underveis: går to økter på rad uten at et av tallene flytter seg, legges det fram for operatøren.

T.3 Hva som teller som «kan nå en innhentings-pipeline»

Angrepet teller når nyttelasten kommer fram som innhold som en pipeline henter eller mottar og skriver til et lager en modell eller agent senere leser. Det teller også når nyttelasten rir på modellens transformerte output før den lagres. Det teller ikke når angrepet krever kodekjøring på verten, tilgang til modellvektene eller en kompromittert pakke (kjernen er stdlib-only). Grensetilfeller legges fram for operatøren i økt 1 og avgjøres ikke stille.

Frosset 28.10 (økt 1): docs/threat-2026/attacks.json, med 19 rader innenfor og 4 utenfor. Hver rad har primærkilde, dato og mekanisme, og hver ankomst er en test i tests/test_attacks_2026.py. Operatøren avgjorde grensetilfellene 28.10:

  • Angrep der nyttelasten kommer inn som innhold, men skaden krevde agentverktøy, er med. Testen er nyttelasten slik den kommer inn: Comment and Control, ShadowLeak, Comet og GitHub-issue-flyten.
  • ToxicSkills er med, men bare tekstdelen.
  • MemoryTrap er ute, fordi det krever kodekjøring via npm postinstall (Cisco 01.04.2026, verifisert). Unit 42s minneforgiftning via sammendrag er ASI06-raden.
  • Seks tillegg er tatt inn: CopyPasta, Invitation Is All You Need, GeminiJack, Unit 42 ×2 og Comet.
  • To kilder som bare finnes fra 2024 er ute (SpAIware og ASCII-smugling mot Copilot). PoisonedRAG står.

Rettelser fra verifiseringen: CamoLeak har ingen CVE. CopyPasta-nyttelasten ligger i README, ikke i en lisensfil. GlassWorm er en utvidelse (pakke) og står utenfor, men teknikken er rad A01.

Kandidatlista slik den sto før verifiseringen (historikk):

  • Smugling i variasjonsvelgere og andre usynlige tegn. Målt rød 28.10.
  • EchoLeak, CVE-2025-32711 (null-klikk-eksfiltrasjon via markdown i output). Dekket i dag.
  • ShadowLeak, CamoLeak, ForcedLeak, AgentFlayer: eksfiltrasjon eller injeksjon via dokumenter, e-post eller kontakter som en agent leser.
  • Forgiftede minnefiler (MemoryTrap-klassen): innhold skrevet til agentens minne, som er OWASP ASI06.
  • Skill-innhold med skjulte instruksjoner (ToxicSkills-klassen).
  • Injeksjon via issue- og PR-tekst som en agent henter (Comment and Control).
  • Faktaforgiftning (PoisonedRAG). Allerede dokumentert som hull. Saken skal bekrefte at hullet står.
  • Skjult tekst i opplastede dokumenter (HTML/CSS-skjult, hvit tekst).

Testsakene publiseres åpent sammen med koden, slik som dagens korpus (operatøren 28.10). Grunnen er at brukerne selv skal kunne etterprøve hva som fanges og hva som er hull. Nyttelastene er inerte: bare kjente teknikker, *.test-domener og ingen fungerende kjede mot et navngitt produkt.

T.4 Øktene

Hver økt er én leveranse, kan gis ut for seg og følger TDD med rødt før grønt. Nummereringen er rekkefølgen, og hver økt starter fra STATE.md.

Økt 1: måling, rød før noe bygges. Ingen produktkode.

  • Rad 1: Lag katalogregisteret som data i repoet. For hver kilde repoet viser til (OWASP LLM Top 10, OWASP Agentic Top 10, MITRE ATLAS, OWASP RAG Security Cheat Sheet, Unicode-versjonen som tegntabellene måles mot, og de andre i «Threat-model anchors»): utgaven som brukes, nyeste utgave, sjekkdato og kildelenke. Hver utgave verifiseres mot utgiveren. En kopi som er verifisert et annet sted, er et premiss og ikke et måleresultat.
  • Rad 2: Lag et telleskript som klassifiserer hvert sted som gammel betydning (kan mappes mekanisk, § T.5) eller feil betydning (krever en vurdering; for eksempel bruker OKF-avvisninger av YAML-former i dag LLM10). Frys nevneren med sha256.
  • Rad 3: Verifiser kandidatene i § T.3, legg fram grensetilfellene og frys lista. Skriv hver sak som test, markert som forventet rød der den ikke fanges i dag.
  • Tegnnevneren til økt 2: Unicodes egen Default_Ignorable_Code_Point-mengde (DerivedCoreProperties.txt, versjonen pinnet i registeret) pluss privat bruk- områdene. Det er en prinsipiell nevner i stedet for en håndplukket liste. Gjort 28.10: docs/threat-2026/codepoints.json (Unicode 18.0.0, kildefilene sha256-pinnet). Operatøren la til formattegnene (Cf), fordi U+FFF9 ble målt rødt, men ikke er DICP. Nevneren er DICP 4174 ∪ Cf 170 ∪ privat bruk 137 468 = 141 674 tegn. 32 Cf-tegn ligger utenfor DICP, blant dem arabiske talltegn (U+0600–0605) som vises. Økt 2 må unnta dem på kontekst.

Økt 2: usynlige tegn. Gis ut som 1.4.2 (deteksjonsatferd, ingen ny API).

  • Hvert tegn i nevneren fanges og fjernes, eller er unntatt med en navngitt grunn på samme måte som ZWJ i dag: det dømmes på kontekst, ikke identitet. Kjente legitime bruk som må overleve: VS16 og VS15 etter emoji, ideografiske varianter (U+E0100–E01EF etter et CJK-tegn, én velger per tegn), ZWNJ i persisk og indiske skrifter (allerede en dokumentert rest), og LRM/RLM/ALM i høyre-til-venstre-tekst. En rekke av velgere er bærer uansett hva den står etter.
  • Både innhentingssiden og utgangskontrollen, med samme predikat på begge sider (som for ZWJ).
  • Vaktregelen er måling på falsk-positiv-korpuset og OKF-korpuset før og etter.
  • Etterpå sendes målingen og kontekstregelen til llm-security-commons som innspill til tegntabellen. Commons eier sin tabell, og vi skriver aldri dit. Gjort 28.10 (1.4.2): alle 141 674 tegn i nevneren fanges utenfor kontekst, på begge sider med samme predikat (testet tegn for tegn). A01 og A02 er grønne, så rad 3 står på 10 fanget, 1 hull og 8 røde rader (8 av 31 ankomster, var 12). Operatøren avgjorde de tre åpne unntakene etter anbefaling: privat bruk fanges helt, arabiske talltegn unntas foran et siffer, og planen om én ideografisk velger per CJK-tegn står. Hva hvert unntak lar stå åpent, står i LIMITATIONS. Målt før bygging: av 3 505 dokumenter i falsk-positiv-populasjonene og 78 i OKF-korpuset bærer bare 348 VS16 etter emoji og 1 ZWJ mellom emoji noe fra nevneren. ZWNJ-regelen er målt mot 9 Wikipedia-artikler (fa, hi, ml, ur, he, ar). Der hadde 4 666 av 4 784 skrifttegn på begge sider, og resten sto ved markup eller virama. Derfor er regelen «én side», og artikler med funn gikk fra 6/9 til 2/9. Ett tillegg til planen: leksikonraden for orddeling dømmer nå ZWNJ med det samme predikatet. Ellers ville persisk fortsatt fått MEDIUM på hvert ord, selv med tegnet i behold. Selve regex-en er uendret og byte-lik med commons. Vaktregelen er målt før og etter: OKF er identisk (6/53, import 47/31), og falsk-positiv-tallene står i CHANGELOG for 1.4.2.

Økt 3: katalognumre med årstall (rad 2 → 0). Gis ut som 1.5.0.

  • Alle funn bærer LLMxx:2026. Mapping fra 2025-betydning, verifisert i økt 1: LLM04 → LLM05:2026 · LLM05 → LLM10:2026 · LLM06 → LLM03:2026 · LLM08 → LLM09:2026 · LLM09 → LLM07:2026 · LLM10 → LLM06:2026 · LLM01 og LLM02 er uendret i betydning og får årstall.
  • Teknisk valg (kan avvises med grunn): leksikondataene holdes byte-like med commons, og årstallet settes på ett sted der funnet lages. Når commons publiserer numre med årstall, tas de inn.
  • Stedene med feil betydning rettes etter vurdering, hver med begrunnelse i commit.
  • Den permanente testen feiler på et katalognummer uten årstall, eller med et årstall som ikke er nyeste, i sporede filer utenom historikk.
  • CHANGELOG-merknaden sier rett ut at en bruker som filtrerer på "LLM05", mister treff. Konsumentene varsles. Gjort 31.10 (1.5.0): rad 2 står på 0 (var 95). Den frosne nevneren er uendret ved 6702d7a (95, da1aa0fa…6f), og tellingen over arbeidstreet gir 0 gammel og 0 feil. Den permanente testen er tests/test_catalog_numbers.py. Den feiler på nummer med gammel eller feil betydning, på nummer uten årstall (unntatt leksikondataene fra commons) og på funn fra matrisen som mangler nyeste årstall. Hva som er «nyeste», leses fra katalogregisteret. Produsentene navngir risikoen, ikke nummeret: én tabell (_owasp.py) eier numrene. Leksikonet er byte-likt med commons og får årstallet når det lastes. Et nummer derfra som har flyttet seg mellom utgavene, avvises i stedet for å gjettes. De 13 stedene med feil betydning er vurdert slik:
  • Faktaforgiftning (PoisonedRAG) er LLM05:2026 (2 steder: matriseraden og testen som følger den). Det er 2026-tekstens eget eksempel 6, «RAG Knowledge Base Poisoning».
  • De andre 11 stedene er fail-secure (README og matrisen), parserflate (4), URL-validering av mottatt metadata (4) og standardverdien som merket OKF-stiavvisninger som prompt injection (1 sted, 5 rader). De får ikke noe nummer, fordi ingen utgave beskriver dem. Et tvilsomt nummer ville vært den samme feilen på nytt. I matrisen er det 14 rader uten nummer, og hver av dem er festet i testen. Selvsikkerhet beholder LLM06:2026. 2026-teksten er lest hos utgiveren: eksempel 1 («input flood … exploiting processing inefficiencies … render the system unresponsive») og eksempel 3 (applikasjonssidig re-chunking) dekker inputtak og ReDoS. README-ankeret «LLM09» (Misinformation) er tatt ut. Det var faktaforgiftnings-raden, og grounding-kroken er for den.

Økt 4: katalogregisteret i bruk (rad 1). Ren dokumentasjon, gis ut med neste release.

  • README-delen «What it protects against» skrives om på 2026-utgaven og kobles til ASI06 og relevante ATLAS-teknikker. Hver kobling verifiseres mot katalogen, og alt som ikke er verifisert, utelates.
  • En test feiler når registeret mangler dato eller lenke. Den feiler ikke på alder: automatisk varsling er utenfor retningen.
  • Gjort 31.10: rad 1: 16 av 16. Alle 15 er sjekket på nytt hos utgiveren. Bare ATLAS hadde flyttet seg: v2026.10 kom 28.10 kl. 22:55 UTC, etter økt 1. AML.T0057 finnes ikke lenger, så den er ikke koblet inn. CleanBase sto i BRIEF uten å være i registeret, og er lagt til (15 → 16). README kobler ASI06 og sju ATLAS-teknikker, hver med det vakta ikke stopper. Målt: tekst gjemt med hvit eller 1px skrift gir 0 funn, mens null skriftstørrelse, base64 og rot13 fanges. Unicode-målingen for ZWJ er tatt på nytt på 18.0 (1614 sekvenser, 122/122). «458» kom fra Pythons Unicode 16.0, og riktig tall for 18.0 er 406. tests/test_catalog_register.py holder rad 1. Utenfor tellingen (candidates): «AI Agent Traps» (SSRN svarer 403, ikke verifisert) og OKF- spesifikasjonen (operatørens beslutning).

Økt 5…N: angrepssakene (rad 3). Lista fra økt 1 tas i bolker. Hver bolk blir enten deteksjon (TDD, vaktregelen holder) eller en dokumentert rest i LIMITATIONS og dekningsmatrisen (status gap, slik matrisen allerede gjør). Hver bolk er en minor- eller patch-release. Antall økter avhenger av lista og fastsettes etter økt 1. Fastsatt av operatøren 28.10 («Ja til din anbefaling»): tre bolker, A01 og A02 tas i økt 2:

  • Økt 5, skjult markup: A04 (tekst i 1px og hvitt; zero-font krever en størrelse som starter på 0) og A15 utgang (HTML-kommentar i modellens output gir 0 funn). Gjort 31.10: A04 og A15 (begge ankomster) fanges, så rad 3 har nå 12 fanget, 1 hull og 6 røde (A05, A08, A09, A12, A16, A19). html-obfuscation:tiny-font (under 4px) og html-obfuscation:invisible-colour (samme farge som bakgrunnen i samme attributt, eller gjennomsiktig) bor i output.py, fordi leksikonet er byte-likt med commons. Hvit tekst alene er bevisst ikke et signal (e-postknapper). Det er målt at ren prosa i hvitt gir 0 funn, og det er ført i LIMITATIONS. output:html-comment-present (MEDIUM) teller bare kommentarer utenfor kode. I referansekorpuset står 253 av 254 kommentarer i kode.
  • Økt 6, rekkefølge-bæring: A05 utgang. Hvert bilde bærer ingen data, men rekkefølgen gjør det. Det graderes LOW og gir WARN.
  • Økt 7, instruksjoner i ren prosa: A08, A09, A12, A16 og A19. Ingen av dem har en markør. Her er det mest sannsynlig at flere ender som dokumenterte hull fordi vaktregelen ikke holder. Det skal måles, ikke antas.

Siste økt: sluttmåling. Rad 1–3 telles på siste release med de frosne nevnerne. Bare når alle tre holder, er retningen nådd. Deretter flyttes repoet til open/. Operatøren flytter det på serveren. Økta gjør tre ting først. Den sjekker den offentlige flaten for private stier, koordineringsmetadata og økt-lenker, både i filene og i commit-meldingene. Den retter prosjektreglene, slik at de sier at repoet er offentlig igjen. Og den sørger for at installasjonsveiledningen peker dit repoet faktisk ligger. Etter flyttingen verifiseres README og installasjonen anonymt.

T.5 OWASP LLM Top 10: 2025 → 2026

Kilde: OWASPs 2026-utgave (PDF-innholdsfortegnelse, 2026/final/LLMxx_*.md og README i GenAI-Security-Project/GenAI-LLM-Top10). Hentet fra en kopi som ble verifisert 02.10.2026. Økt 1 re-verifiserer mot utgiveren før noe mappes. Re-verifisert 28.10 mot utgiveren: alle ti stemmer. Overskriftene i 2026/final/*.md er lest, og fillista er hentet live fra repoets API. Forordet bekrefter flyttene. OWASP skriver selv LLM01:2026. Detaljer og bevis står i docs/threat-2026/catalogs.json.

2025 2026
LLM01 Prompt Injection LLM01 Prompt Injection
LLM02 Sensitive Information Disclosure LLM02 Sensitive Information Disclosure
LLM03 Supply Chain LLM04 Supply Chain
LLM04 Data and Model Poisoning LLM05 Data and Model Poisoning
LLM05 Improper Output Handling LLM10 Improper Output Handling
LLM06 Excessive Agency LLM03 Excessive Agency
LLM07 System Prompt Leakage LLM08 Hidden Context Exposure
LLM08 Vector and Embedding Weaknesses LLM09 Vector and Embedding Weaknesses
LLM09 Misinformation LLM07 Misinformation
LLM10 Unbounded Consumption LLM06 Unbounded Consumption

T.6 Åpne spørsmål

  1. Publiseres testsakene åpent? Ja, operatøren 28.10 (§ T.3).
  2. Grensetilfellene i § T.3. Avgjort av operatøren 28.10 (§ T.3).
  3. Tegnnevneren. Utvidet med Cf, operatøren 28.10 (§ T.4 økt 1).
  4. Unntakene med uavklart kontekst. Avgjort av operatøren 28.10 (§ T.4 økt 2): privat bruk fanges, talltegn unntas foran siffer, CJK-velgeren står.

Implementation Plan — llm-ingestion-guard v1

Derived from docs/BRIEF.md plus a 2026-07-04 research pass (competitive landscape, novelty/gap refutation, security-coverage gaps). This plan folds the verified gaps into the v1 scope. The BRIEF is the design rationale; this is the actionable build.

Resolved decisions (2026-07-04)

  • Name: distribution llm-ingestion-guard, import llm_ingestion_guard. Repo directory stays llm-ingestion-pipeline-security.
  • License: MIT.
  • Lexicon storage: shared JSON data + thin Python loader (single source of truth, polyglot-ready for a later TS port).
  • Publish target: Forgejo open/ only (public). No PyPI, no GitHub. Install via pip install git+ssh://…/llm-ingestion-guard.git.
  • Language: Python-first, stdlib-only core; ML/judge detectors behind extras ([ml], [judge]), never in the core path.

Positioning — A: "Guard the artifact"

Query-time guardrails guard the answer. llm-ingestion-guard guards the artifact.

Lead with the contract + placement (write-path, pre-persist) + failure-semantics (fail-secure toward the artifact, not fail-open toward a user). Detection is the weakest, most-evadable layer — defense-in-depth, not the pitch.

Defensible claim, every qualifier load-bearing: a dependency-light, framework-agnostic library that packages the full write-time injection-containment contract (quarantine → per-stage capability isolation → scan-before-persist → fail-secure disposition) as composable code — the part query-time tooling and single-stage detectors leave to the integrator. Not "the first" (an unverifiable temporal claim); the load-bearing qualifier is the full four-part contract, verified against the 2026-07-15 survey (docs/BRIEF.md §11). Cite OWASP LLM08:2025 / RAG Security Cheat Sheet for legitimacy; reference Dual-LLM (Willison 2023) and CaMeL (DeepMind 2025) as architecture lineage — inspiration, not equivalence.

Claims we will NOT make (verified overclaim risks)

  • NOT "prevents RAG/knowledge poisoning" — cannot catch factual poisoning (PoisonedRAG: plausible false facts, no markers, low entropy).
  • NOT "blocks prompt injection" — pattern/entropy detection is bypassable (arXiv 2504.11168, up to 100% evasion). Detection is a signal feeding disposition, never a sole gate.
  • NOT "invented" tool-less quarantine / capability isolation — prior art (Dual-LLM, CaMeL).
  • NOT "the only" ingestion-security tool — PII-scoped libs (Philter, Presidio) and services (AWS, HiddenLayer) exist; we are the minimal-dep library for the injection-containment contract.
  • Positioned as complementary to query-time guardrails (LLM Guard, Lakera, LlamaFirewall), not a replacement.

Module set (v1)

Core (BRIEF §5): report, sanitize, fence, lexicon, entropy, contract, output, disposition.

Gap additions folded into v1 (from the security-coverage research):

  • neutralize (new) — opt-in pure defang helpers for model OUTPUT: markdown images, autolinks, [ref] links, data:/external URIs, raw HTML. Mutation is opt-in and kept separate from the report-only gate, so design principle 3 (pure detection) and 4 (disposition belongs to the caller) hold. Closes EchoLeak-class (CVE-2025-32711) persisted zero-click exfil — these are neither injection strings nor high-entropy, so lexicon+entropy alone miss them.
  • output extended — secret/PII egress patterns (OWASP LLM02); decode-and-rescan (run the lexicon over decoded base64/hex, not just flag blob presence).
  • fence hardened — randomized, unspoofable per-call delimiter; strip attacker fence markers from the payload first.
  • Scanner self-safety (OWASP LLM10) — input-size cap, ReDoS-safe patterns (bounded quantifiers + per-scan guard), decompression guard. A scanner that hangs on crafted input is the DoS.
  • grounding (interface now, impl later) — a SourceGroundingCheck protocol in core; ML/judge implementation behind the [judge] extra. The only structural handle on semantic poisoning that lexicon+entropy cannot see. Ship the seam + honest scope note.
  • Chunk-aware / sliding-window scan — option for split-payload evasion across chunk boundaries.
  • disposition presets — named source-trust tiers, including a high-untrust user-upload / open-source preset: QUARANTINE_REVIEW as the default and hard-fail on CRITICAL, not WARN. An automatic, unattended inbox that ingests arbitrary user uploads is the canonical high-untrust consumer (§4.7) — over-blocking one upload is far cheaper than persisting a poisoned one.

Build order (TDD — a failing test FIRST for each)

  1. report — findings / Report dataclass (shared return type)
  2. sanitize — carrier stripping (zero-width, BIDI, Unicode-tag, HTML comment, data:); byte-identical invariant on clean input, removes only
  3. entropy — shannon / base64-like / hex-blob; decode-and-rescan support
  4. lexicon — JSON data + loader + scan; ReDoS-safe; size cap; port from the llm-security JS seed (CRITICAL/HIGH/MEDIUM/HYBRID + normalization/homoglyph/rot13/ unicode-tag)
  5. fence — randomized delimiter; marker-strip
  6. neutralize — opt-in defang helpers
  7. output — compose lexicon+entropy over output; PII/secret egress; decode-and-rescan
  8. disposition — WARN | QUARANTINE_REVIEW | FAIL_SECURE; source-trust + intra-document provenance tiering (§4.7); compound-signal fail-secure (§4.6); fail-closed when the scanner itself errors
  9. contract — tool-less assert; per-stage credential allowlist; env-scoping helpers
  10. grounding — protocol/interface only in core
  11. Top-level __init__ wiring the §6 checklist; pyproject.toml; LICENSE; README (positioning + honest limitations); adversarial + false-positive corpora; the end-to-end showcase pipeline test (see Test strategy — built LAST)

Reuse map — llm-security v7.8.0 (MIT, same author)

Maximal reuse: most detection logic is a JS→Python port, not new code.

Our module Seed in llm-security Notes
lexicon scanners/lib/injection-patterns.mjs CRITICAL/HIGH/MEDIUM/HYBRID tables + scanForInjection (variant-set: raw / normalized / homoglyph-folded / rot13, dedup by label) + checkCognitiveLoadTrap. The load-bearing port.
sanitize scanners/unicode-scanner.mjs + string-utils.mjs zero-width / BIDI / Unicode-tag / homoglyph detection + stripBidiOverrides / decodeUnicodeTags / containsUnicodeTags.
normalization string-utils.mjs normalizeForScan decode chain (unicode-tags → bidi → HTML-entities → unicode/hex/url escapes → base64), foldHomoglyphs, rot13, collapseLetterSpacing. Feeds decode-and-rescan.
entropy scanners/entropy-scanner.mjs + string-utils.mjs shannonEntropy / isBase64Like / isHexBlob / tryDecodeBase64; length-calibrated thresholds; isFalsePositive suppression rules; DATA_URI_PREFIXES.
report scanners/lib/output.mjs + severity.mjs finding / scannerResult envelope; SEVERITY; riskScore / verdict / riskBand / owaspCategorize.
output secret/PII egress knowledge/secrets-patterns.md Ready secret regex set (AWS/Azure/GCP/GitHub/npm/OpenAI/Anthropic/PEM/DB-conn/password/JWT) + FP-suppression rules. Seeds the LLM02 egress gap directly.
tests (corpora) examples/prompt-injection-showcase/payloads.json Labeled adversarial + false-positive corpus (category/severity/expected). Maps ~1:1 to §9. Plus knowledge/{attack-mutations,attack-scenarios,signatures}.json.
wiring reference hooks/pre-prompt-inject-scan.mjs (input), post-mcp-verify.mjs (output gate) How the scan is invoked at input vs output — informs our output module + the §6 checklist.

Port caveats (JS→Python, stdlib-only):

  • /…/i → re.I; Buffer.from(s,'base64') → base64.b64decode; codePointAt/fromCodePoint → ord/chr.
  • TOKEN_RE = /[\p{L}\p{N}_]+/gu — Python re has no \p{L}; use \w under re.UNICODE (Py3 default) or str.isalnum(). No regex dependency.
  • ReDoS: some patterns (sub-agent spawn with nested .*?) backtrack; Python re has no timeout → enforce input-size cap + review/bound these patterns (the self-safety must-have).
  • Lexicon ships as JSON (regex source + label + severity), compiled by a thin Python loader — decouples data from engine for the future TS port.

Test strategy (BRIEF §9 + additions)

  • Adversarial corpus — one payload per lexicon class incl. obfuscated + multi-language; measure and report recall.
  • False-positive corpus — content legitimately discussing injection (security docs, changelogs); assert WARN-not-block default; hard-fail is an explicit opt-in.
  • Sanitizer invariant — clean input returns byte-identical with an all-zero report.
  • Contract asserters — a tool-carrying request and a credential-leaking stage env both raise; the happy path passes.
  • Self-safety — pathological/ReDoS-prone and oversize input return within a bound, never hang. Scope, measured 2026-07-31: the lexicon path is covered by test_redos_pathological_subagent_input_returns_fast (crafted against a known-bad nested .*?). The output path is covered by test_crafted_redos_payload_stays_bounded — 15 crafted payloads plus one through the composed gate. Writing them found the defect they were meant to rule out: 19 quadratic runs across 17 patterns in output (4), active_content (5 patterns / 7 runs) and the lexicon JSON (8), worst case ~5.7 hours at the 1_000_000-char cap the gate accepts. Note the shape, since it is not the textbook one: no nested quantifier is involved. A run in front of a required literal, reachable from a short anchor, is enough — crafted input repeats the anchor, never supplies the literal, and every start position rescans the tail. The earlier "output blob is slower than ordinary prose (0.93x/0.96x)" measurement stands and was never wrong; it simply measured throughput on a blob, which is a different question from what a crafted payload asks.
  • Neutralize — active-content output is defanged; clean output is byte-identical.
  • End-to-end showcase (the FINAL deliverable, built last). One realistic piece of ingested content that carries many vulnerabilities at once — visible
    • zero-width/BIDI/Unicode-tag stego carriers, homoglyph + whole-string-base64 + rot13-hidden injection, HTML-obfuscated and cognitive-load-buried payloads, secret/PII egress in the enriched output, and active-content exfil links — run through a mock ingestion pipeline (sanitize → lexicon+entropy+decode-rescan → output gate → disposition). Assert every planted vulnerability is caught and disposition fails secure. Doubles as the README's worked example. Inspiration: llm-security/examples/{prompt-injection-showcase,poisoned-claude-md,lethal-trifecta-walkthrough,toxic-agent-demo}.
  • No network in any test.

README "Honest limitations" (shipped as a control)

Concede plainly (the concession prevents false assurance, which is itself a control): structural unsolvability at the text layer; adversarial-ML evasion survives normalization; tokenizer mismatch; semantic/factual poisoning invisible to lexicon+entropy; latent/dormant memory poisoning not judgeable at write-time; insider in-place edits; text-only (no multimodal).

Out-of-scope (documented boundary)

Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal steganography; query-time/runtime guardrails; semantic factuality verification. The contract tool-less assertion is the write-time analogue of runtime least-privilege.

Text-extraction boundary. For pipelines that ingest arbitrary user uploads (an upload inbox that ingests automatically), the library stays text -> findings: it does not parse files (no pypdf/python-docx/archive deps in the core). The pipeline extracts text first, then scans the extracted text + the enriched output with provenance tagged as the high-untrust upload tier. Binary/multimodal carrier detection (OCR-embedded instructions in images/PDFs, stego) is out of scope beyond the sanitizer's character-layer stripping and the self-safety size/decompression guard — and must be conceded in the honest-limitations section, because a high-untrust, unattended pipeline is exactly where assuming uncovered-coverage is most dangerous.

Threat-model anchors

OWASP LLM Top-10 2025: LLM01, LLM02, LLM04, LLM05, LLM06 (strongest coverage), LLM08 (boundary), LLM09, LLM10. Research anchors: PoisonedRAG (arXiv 2402.07867), guardrail evasion (2504.11168), EchoLeak (CVE-2025-32711), RAGShield (2604.00387), CaMeL (2503.18813), Dual-LLM (Willison), and the litellm supply-chain compromise (corroborates the minimal-dependency thesis).

v0.2+ stream sequencing (revised 2026-07-06)

v0.1.0 and v0.2.0 are tagged: the format-agnostic text core (modules 1–11, 5397ba1 / released df30c7b) and the OKF adapter (stream 1, released 542ac92). The forward order was re-sequenced on 2026-07-06 after a ground-truth pass over the intended flagship consumer.

Finding that drove the change. The named flagship — portfolio-optimiser's "OKF-upload-inbox" — does not exist as a seam. Both optimiser siblings are frozen at their release milestone, carry their own OKF layer (navigate + materialize from trusted manifests), receive no external bundles, and take no dependency on this guard; the operator's own registered future work for them does not include it. Wiring an immature guard into two mature, spec-frozen repos would complicate them for a consumer that is not asking for it. Consumer integration is therefore deferred until the guard is mature; consumers are informed at the operator's timing, not pushed.

Revised streams:

  1. OKF v0.2 hardening — shipped (adapter + brief §8 tasks; T5b/B deferred to a consumer that owns corpus storage).
  2. Mature the guard here, keeping it Node-port-friendly — the near-term work. The lexicon is already shared JSON (polyglot-ready); keep the text -> findings surface clean and free of Python-only cleverness in the OKF layer, so stream 3 is a translation, not a redesign. The centrepiece is the OKF inbox showcase (below): the guard demonstrating its own flagship use case end-to-end, in-repo.
  3. Node/polyglot port — the strategic enabler of painless integration (many OKF consumers are Node/JS; the lexicon seed was .mjs). One polyglot repo over the shared JSON lexicon; never split §13.3. Its API/lexicon contract should be scan-informed (stream 4), not guessed.
  4. Pre-adaptation scan (operator-timed) — at the operator's chosen point, scan every repo and plugin that uses or plans Google OKF, then adapt the guard's surface in advance so a later integration is painless. The scan is the input to the "painless" guarantee: it grounds both the Node port's contract and any consumer-specific seams before they are locked.

Re-sequenced toward v1.0 (Fable cross-model review, 2026-07-09)

An independent cross-model review re-sequenced the forward path. Stream 2 is extended with a set of review-injected hardening + coverage sessions (output-gate coverage, OKF adapter hardening, egress decode-rescan, calibration-threshold consolidation, docs/version-sync, and a novelty-claim verification) that land before a v1.0 freeze. The Node/TS port (stream 3) starts only after that freeze — porting a frozen, scan-clean surface, never a moving target — over the shared JSON lexicon (never split). Streams 4 + consumer integration are operator-timed "ambitious extensions", not part of the v1.0 sequence.

The session-by-session plan and the review record live in docs/ on the Forgejo open/ mirror alongside the other design docs — docs/PLAN-v1.md (the v1.0 + Node session plan) and docs/review-2026-07.md (the review) — open/ being the sole sanctioned public surface (never GitHub).

The OKF inbox showcase (next concrete build, TDD)

The flagship artifact we hand a consumer later — an in-repo end-to-end demonstration of the mode-b receive/quarantine gate, mirroring tests/test_showcase.py but for a received external OKF bundle. It composes the public okf surface exactly as an "upload inbox" consumer would, so it doubles as the README's OKF worked example. Every test is authored by us — the point is to prove intent, not to coincidentally pass.

  • Composition (tests/test_okf_showcase.py): an _inbox(bundle) helper calling okf.import_bundle(bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC) and mapping the aggregate disposition — WARN → ADMIT, QUARANTINE_REVIEW → HOLD, FAIL_SECURE → REJECT, any error → REJECT (fail-secure default).
  • One poisoned bundle planting one attack per OKF surface at once, each with a label proving it was caught/rejected:
    • body injection (T1 scan) and frontmatter description injection (T1 whole-concept scan);
    • a non-https resource: URL (T3 → FAIL_SECURE);
    • a path-traversal concept key ../x.md (T4 → FAIL_SECURE) and a reserved-name shadow index.md / log.md (T4);
    • a dangerous frontmatter value (anchor / alias / explicit tag, T2 → FAIL_SECURE);
    • a dangerous-scheme cross-link [x](javascript:…) (T5a → links.rejected);
    • a dangling cross-link to an absent concept (§7.2 dormant signal → links.dangling);
    • a carrier/obfuscation-hidden body injection (zero-width / homoglyph / rot13 / whole-string base64) routed through scan_concept.
  • Assertions: aggregate disposition is FAIL_SECURE; every planted OKF vulnerability appears in the per-concept rejects / findings / link graph; a clean bundle admits (WARN, no rejects, no dangling); BundleResult.log() renders one line per concept with rejects marked. Detach-proof: neutering import_bundle to always-admit makes the poison assertions fail.
  • Honest scope: demonstrates the structural + known-pattern OKF surface only — semantic/factual poisoning stays out (README honest-limits), consistent with the core showcase.

Realistic upload formats — the two-stage inbox (extract → materialize → guard)

A human inbox does not receive tidy {path: text} dicts; it receives the files people actually drop: .txt, .md, Word .docx, Excel .xlsx, PowerPoint .pptx, .pdf, .csv, whole folders, and .zip archives. The showcase therefore has two stages, honouring the locked text-extraction boundary (§ "Text-extraction boundary"): the guard core never grows a file parser.

  1. Extract & materialize (the inbox front-end). Reads each dropped file, walks folders, and safely unpacks archives; extracts text via format libraries (python-docx, openpyxl, python-pptx; stdlib zipfile/csv); materializes the result into an OKF bundle {concept_path: text} with provenance (origin=EXTERNAL, source filename + type). This stage owns the container and format threats. These libraries are showcase/dev-scoped only — never core dependencies (which stays []). Promotion to an optional [extract] extra is a documented future option if a consumer wants turnkey extraction, not v1.
  2. Guard (import_bundle). Scans every extracted concept + the OKF structural gates → aggregate disposition. This stage owns the text/structural threats.

One representative planted vector per format (the point is where the payload hides — the place a human does not look):

Input Hidden vector planted Caught at
.txt raw injection + carrier (zero-width / homoglyph / rot13 / base64) guard scan
.md OKF frontmatter attack (T2) + body injection (T1) guard
.docx injection in a comment / hidden (vanish) run / core metadata property guard scan of extracted text
.xlsx formula injection (=cmd|'…', =HYPERLINK(…)) + hidden sheet / cell comment front-end + guard
.pptx injection in speaker notes / off-slide text box / image alt-text guard scan
.pdf injection in extracted / white-on-white text conceded — refused as an unsupported format, not extracted (see honest-scope below)
.csv formula injection (=, +, -, @ lead) front-end + guard
folder the OKF bundle-directory shape directly; one member path trips the path gate (T4) guard
.zip zip-slip entry ../../x.md (maps straight onto OKFPathError, T4) + zip-bomb (bounded by the size cap / a safe-extract limit, LLM10) + symlink entry front-end + guard

Assertions: each planted vector is caught at the correct stage; the front-end refuses zip-slip / zip-bomb / oversize fail-fast; the guard rejects injection/carrier/frontmatter/resource/link/path; a clean file of every accepted format admits (WARN). Detach-proof at both stages.

Honest scope for uploads (must be conceded — README honest-limits). What survives text extraction is out of scope: VBA/macros (.docm/.xlsm/.pptm), OLE / embedded objects, image-embedded instructions that need OCR, font/render steganography, and encrypted/password-protected files. The guard scans extracted text; binary-layer carriers need a separate scanner. .pdf is conceded as a format: a top-level .pdf drop is refused as unsupported rather than extracted, since a PDF parser + reportlab (fixtures only) is disproportionate for a dev-scoped showcase and its OCR / font-render stego carriers are out of scope regardless. A high-untrust, unattended inbox is exactly where assuming uncovered-coverage is most dangerous (§4.7), so the concession is itself a control.

Deferred, unchanged: T5b/B (the persisted cross-run link graph) waits for a consumer that owns corpus storage; §7.2 dormant cross-run links remain a documented residual until then.

Splittable early win (optional): text-only consumers can be wired at v0.1.0 today — for them the guard is already complete. Deferred with stream 2 by default; pull forward only on explicit request.