#!/usr/bin/env python3 """Prüfwerkzeug für die Vermietano-KI-Kennzeichnung (Art. 50 Abs. 2 KI-VO). Dieses Werkzeug beantwortet EINE Frage zu einer Datei: Trägt sie eine Vermietano-Provenienzmarkierung, und was sagt sie aus? python3 pruefe_provenienz.py exposee.pdf python3 pruefe_provenienz.py nachricht.eml --json python3 pruefe_provenienz.py --formate Die Spezifikation des geprüften Formats steht in ``docs/recht/ki-provenienz-schema.md`` und ist veröffentlicht — dieses Werkzeug ist eine Referenzumsetzung, keine Voraussetzung. Wer das Format selbst lesen will, kann das: Es ist Klartext. ⚖️ Warum es dieses Werkzeug gibt. Der Code of Practice verlangt eine Detection Solution, die für Deployer, Integratoren, Endnutzer, Behörden, Forschung und Medien nutzbar ist. Die Kanzlei hat am 14.08.2026 klargestellt: Ein betriebener Cloud-Dienst ist dafür NICHT nötig — entscheidend ist die tatsächliche Prüfbarkeit. Eine lokale Anwendung genügt. Die sieben Mindestanforderungen (dort P1–P7) sind hier umgesetzt und im Code an der jeweiligen Stelle vermerkt. ⚖️ **P7 — dieses Werkzeug speichert nichts.** Es liest die übergebene Datei, schreibt keine Datei, legt keinen Cache an und baut keine Netzwerkverbindung auf. Das ist kein Nebenaspekt: Wer ein Dokument prüft, gibt uns damit keine Kopie davon. Der Wächter ``test_pruefer_schreibt_nichts`` hält das fest. Abhängigkeiten: Für ``.eml`` nur die Python-Standardbibliothek. Für PDF wird ``pypdf`` (BSD) benötigt; fehlt es, sagt das Werkzeug das deutlich, statt eine fehlende Markierung zu behaupten. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Version DIESES Werkzeugs — nicht zu verwechseln mit der Schemaversion des # geprüften Formats. Die Kanzlei verlangt, dass der Download eine Versionsnummer # und eine Prüfsumme trägt: Wer ein Ergebnis zitiert, muss sagen können, mit # welchem Stand er geprüft hat. Bei jeder Änderung erhöhen; die Prüfsumme auf # der Veröffentlichungsseite zieht ein Wächter-Test nach. WERKZEUG_VERSION = "1.0.0" # ── Was dieses Werkzeug kennt (P2 + P6) ───────────────────────────────────── # P6: Formate und Markierungsmethoden werden ausgewiesen, nicht stillschweigend # vorausgesetzt. Ein Nutzer muss erkennen können, wofür eine Antwort gilt. UNTERSTUETZTE_FORMATE = { ".pdf": "Info-Dict-Schlüssel /vermietano:ki-provenienz (+ …-zeitpunkt)", ".eml": "Kopfzeile X-AI-Provenance", } # P2: Nur diese Schemaversionen kann dieses Werkzeug deuten. Eine unbekannte # Version ist ausdrücklich NICHT „keine Markierung" — sie ist eine Markierung, # die dieses Werkzeug nicht verifizieren kann (siehe NICHT_VERIFIZIERBAR). BEKANNTE_SCHEMAVERSIONEN = ("v1",) # Statuswerte, die eine externe Kennzeichnung tragen. Andere Werte gehören nicht # in eine ausgelieferte Markierung — träfe man sie an, stimmte etwas nicht. # # ⚠️ ``HUMAN_EDITED`` gehört seit dem 14.08.2026 dazu. Der Wert steht für einen # älteren Datensatz mit KI-Ursprung, der von Hand überarbeitet wurde; wird er # heute erneut ausgegeben, wird die Ausgabe vorsorglich markiert. Wer diese # Zeile mit dem Backend auseinanderlaufen lässt, baut den schlimmsten Fehler, # den dieses Werkzeug haben kann: Es meldete eine korrekt markierte Datei als # „nicht verifizierbar" — und der Prüfende zöge daraus den Schluss, wir hätten # manipuliert. Die maßgebliche Liste steht in # ``backend/app/core/ki_provenienz.EXTERN_KENNZEICHNEN``. KENNZEICHNUNGS_STATUS = ("AI_GENERATED", "AI_ASSISTED", "HUMAN_EDITED") # ── Die drei möglichen Antworten (P5) ─────────────────────────────────────── # ⚖️ P5 ist die Anforderung, die am leichtesten übersehen wird: Ein Prüfwerkzeug # muss zwischen „keine Markierung gefunden" und „Markierung gefunden, aber nicht # verifizierbar" unterscheiden. Beides als „nein" auszugeben wäre eine falsche # Entlastung — eine beschädigte oder manipulierte Markierung sähe dann aus wie # ein sauberes menschliches Dokument. ERKANNT = "markierung_erkannt" KEINE = "keine_markierung" NICHT_VERIFIZIERBAR = "nicht_verifizierbar" _FELD = re.compile(r"^([a-z]+)=(.*)$") class Befund: """Ergebnis einer Prüfung. Bewusst eine kleine Klasse statt eines Tupels, damit ein Aufrufer die Felder benennen kann.""" def __init__( self, ergebnis: str, *, datei: str, format_: str | None = None, rohwert: str | None = None, felder: dict | None = None, zeitpunkt: str | None = None, begruendung: str = "", ) -> None: self.ergebnis = ergebnis self.datei = datei self.format = format_ self.rohwert = rohwert self.felder = felder or {} self.zeitpunkt = zeitpunkt self.begruendung = begruendung def als_dict(self) -> dict: return { "ergebnis": self.ergebnis, "datei": self.datei, "format": self.format, "rohwert": self.rohwert, "felder": self.felder, "zeitpunkt": self.zeitpunkt, "begruendung": self.begruendung, } def deute_markierung(wert: str) -> tuple[str, dict, str]: """Zerlegt einen Markierungswert in seine Felder (P3) und prüft ihn (P2). Rückgabe: (Ergebnis, Felder, Begründung). Ein Wert, der aussieht wie unsere Markierung, sich aber nicht deuten lässt, wird NICHT_VERIFIZIERBAR — nie KEINE. Das ist der Kern von P5. """ teile = [t for t in wert.split(";") if t.strip()] if not teile or teile[0].strip() != "vermietano": return ( NICHT_VERIFIZIERBAR, {}, "Der Wert steht an der richtigen Stelle, beginnt aber nicht mit " "'vermietano' — fremde oder beschädigte Markierung.", ) felder: dict[str, str] = {} for teil in teile[1:]: treffer = _FELD.match(teil.strip()) if not treffer: return ( NICHT_VERIFIZIERBAR, felder, f"Unlesbarer Abschnitt in der Markierung: {teil.strip()!r}.", ) felder[treffer.group(1)] = treffer.group(2) fehlend = [f for f in ("status", "function", "schema") if f not in felder] if fehlend: return ( NICHT_VERIFIZIERBAR, felder, f"Pflichtfelder fehlen: {', '.join(fehlend)}.", ) if felder["schema"] not in BEKANNTE_SCHEMAVERSIONEN: # ⚖️ Bewusst nicht ERKANNT: Eine spätere Schemaversion kann Bedeutungen # ändern. Dieses Werkzeug darf über einen Inhalt, den es nicht kennt, # keine Aussage treffen. Der Nutzer erfährt, welche Version fehlt. return ( NICHT_VERIFIZIERBAR, felder, f"Unbekannte Schemaversion {felder['schema']!r}. Dieses Werkzeug " f"kennt: {', '.join(BEKANNTE_SCHEMAVERSIONEN)}. Neuere Fassung " "besorgen oder die veröffentlichte Spezifikation lesen.", ) if felder["status"] not in KENNZEICHNUNGS_STATUS: return ( NICHT_VERIFIZIERBAR, felder, f"Status {felder['status']!r} gehört nicht in eine ausgelieferte " "Markierung — die Datei wurde nachträglich verändert oder stammt " "nicht aus einem regulären Ausgabeweg.", ) return ERKANNT, felder, "" # ── Formatzugriffe ────────────────────────────────────────────────────────── def _lies_pdf(pfad: Path) -> Befund: try: from pypdf import PdfReader except ImportError: return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), format_="pdf", begruendung="Für PDF wird 'pypdf' benötigt (pip install pypdf). " "Ohne die Bibliothek kann dieses Werkzeug NICHT ausschließen, dass " "eine Markierung vorhanden ist.", ) try: meta = PdfReader(str(pfad)).metadata or {} except Exception as fehler: # noqa: BLE001 — jede Lesefehlerart zählt gleich return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), format_="pdf", begruendung=f"PDF nicht lesbar ({type(fehler).__name__}). Eine " "beschädigte Datei ist kein Nachweis für eine fehlende Markierung.", ) roh = meta.get("/vermietano:ki-provenienz") zeit = meta.get("/vermietano:ki-provenienz-zeitpunkt") if roh is None: return Befund( KEINE, datei=str(pfad), format_="pdf", zeitpunkt=str(zeit) if zeit is not None else None, begruendung="Kein Provenienz-Schlüssel im Info-Dict. Das PDF trägt " "keine Vermietano-Kennzeichnung.", ) ergebnis, felder, grund = deute_markierung(str(roh)) return Befund( ergebnis, datei=str(pfad), format_="pdf", rohwert=str(roh), felder=felder, zeitpunkt=str(zeit) if zeit is not None else None, begruendung=grund, ) def _lies_eml(pfad: Path) -> Befund: import email from email import policy try: nachricht = email.message_from_bytes(pfad.read_bytes(), policy=policy.default) except Exception as fehler: # noqa: BLE001 return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), format_="eml", begruendung=f"Nachricht nicht lesbar ({type(fehler).__name__}).", ) werte = nachricht.get_all("X-AI-Provenance") or [] if not werte: return Befund( KEINE, datei=str(pfad), format_="eml", begruendung="Keine X-AI-Provenance-Kopfzeile. Die Nachricht trägt " "keine Vermietano-Kennzeichnung.\nHinweis: Kopfzeilen können auf " "dem Transportweg verloren gehen — insbesondere bei Weiterleitung " "oder Formatumwandlung. Ein 'nein' bezieht sich immer nur auf die " "vorliegende Datei, nicht auf das ursprünglich Versendete.", ) if len(werte) > 1: return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), format_="eml", rohwert=" | ".join(str(w) for w in werte), begruendung="Mehrere Provenienz-Kopfzeilen. Eine Nachricht trägt " "genau eine; mehrere deuten auf Zusammenführung oder Manipulation.", ) ergebnis, felder, grund = deute_markierung(str(werte[0])) return Befund( ergebnis, datei=str(pfad), format_="eml", rohwert=str(werte[0]), felder=felder, begruendung=grund, ) def pruefe(pfad: Path) -> Befund: """Prüft eine Datei und liefert genau eines der drei Ergebnisse (P1/P5).""" if not pfad.exists(): return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), begruendung="Datei nicht gefunden.", ) endung = pfad.suffix.lower() if endung not in UNTERSTUETZTE_FORMATE: # P6: kein stilles „keine Markierung" für ein Format, das wir gar nicht # lesen. Der Nutzer erfährt, was das Werkzeug kann. return Befund( NICHT_VERIFIZIERBAR, datei=str(pfad), format_=endung.lstrip(".") or None, begruendung=f"Format {endung or '(ohne Endung)'} wird nicht " f"unterstützt. Unterstützt: {', '.join(sorted(UNTERSTUETZTE_FORMATE))}.", ) return _lies_pdf(pfad) if endung == ".pdf" else _lies_eml(pfad) # ── Ausgabe ───────────────────────────────────────────────────────────────── _UEBERSCHRIFT = { ERKANNT: "MARKIERUNG ERKANNT", KEINE: "KEINE MARKIERUNG", NICHT_VERIFIZIERBAR: "NICHT VERIFIZIERBAR", } _STATUS_KLARTEXT = { "AI_GENERATED": "von KI erzeugt", "AI_ASSISTED": "mit KI-Unterstützung bearbeitet", "HUMAN_EDITED": "KI-Ursprung, von einem Menschen wesentlich überarbeitet", } def als_text(befund: Befund) -> str: zeilen = [_UEBERSCHRIFT[befund.ergebnis], f"Datei: {befund.datei}"] if befund.ergebnis == ERKANNT: status = befund.felder.get("status", "") zeilen.append(f"Status: {status} — {_STATUS_KLARTEXT.get(status, 'unbekannt')}") zeilen.append(f"Funktion: {befund.felder.get('function', '')}") zeilen.append(f"Schema: {befund.felder.get('schema', '')}") if befund.zeitpunkt: zeilen.append(f"Zeitpunkt: {befund.zeitpunkt}") # ⚖️ Ehrlichkeitshinweis: Das Werkzeug prüft, ob die Markierung DA und # deutbar ist — nicht, ob sie echt ist. Eine Integritätssicherung gibt # es noch nicht (Matrix M4). Das darf ein Nutzer nicht verwechseln. zeilen.append( "\nHinweis: Geprüft wurde das Vorhandensein und die Deutbarkeit der " "Markierung. Sie ist derzeit NICHT kryptografisch gesichert — dieses " "Ergebnis belegt keine Unverändertheit der Datei." ) if befund.begruendung: zeilen.append("\n" + befund.begruendung) return "\n".join(zeilen) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Prüft, ob eine Datei eine Vermietano-KI-Kennzeichnung trägt.", epilog="Spezifikation: docs/recht/ki-provenienz-schema.md. " "Das Werkzeug speichert nichts und geht nicht ins Netz.", ) parser.add_argument("datei", nargs="?", help="zu prüfende Datei (.pdf oder .eml)") parser.add_argument("--json", action="store_true", help="Ergebnis als JSON") parser.add_argument( "--formate", action="store_true", help="unterstützte Formate und Markierungsmethoden ausgeben (P6)", ) args = parser.parse_args(argv) if args.formate: if args.json: print( json.dumps( { "werkzeug_version": WERKZEUG_VERSION, "formate": UNTERSTUETZTE_FORMATE, "schemaversionen": list(BEKANNTE_SCHEMAVERSIONEN), "status_werte": list(KENNZEICHNUNGS_STATUS), }, ensure_ascii=False, indent=2, ) ) else: print(f"Prüfwerkzeug Version {WERKZEUG_VERSION}") print("\nUnterstützte Formate und Markierungsmethoden:") for endung, methode in sorted(UNTERSTUETZTE_FORMATE.items()): print(f" {endung} {methode}") print(f"\nBekannte Schemaversionen: {', '.join(BEKANNTE_SCHEMAVERSIONEN)}") print(f"Gekennzeichnete Status: {', '.join(KENNZEICHNUNGS_STATUS)}") return 0 if not args.datei: parser.error("Bitte eine Datei angeben oder --formate verwenden.") befund = pruefe(Path(args.datei)) print( json.dumps(befund.als_dict(), ensure_ascii=False, indent=2) if args.json else als_text(befund) ) # Rückgabecodes für Skripte: 0 markiert, 1 unmarkiert, 2 nicht verifizierbar. # Getrennt, damit ein Prüflauf die dritte Antwort nicht als „unmarkiert" # verbucht (P5 gilt auch für den Rückgabewert). return {ERKANNT: 0, KEINE: 1, NICHT_VERIFIZIERBAR: 2}[befund.ergebnis] if __name__ == "__main__": sys.exit(main())