From cb5dad1a2f6f7480e876a674a01c4d6cd7389331 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 20:21:28 +0200 Subject: [PATCH] feat(audit): A Audit-Transparenz + B Tabellen-Parse + D HTML-Tables aus DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei zusammenhaengende Fixes fuer den VW-Befund (6 Vendors statt 100+): A — audit_quality_checks.py: drei systemische Vorbehalte die IMMER prominent gezeigt werden: * banner_detected=False trotz Cookie-Doc → HIGH 'CMP-Tool ungeladen' * cookie_doc >= 30k chars aber cmp_vendors < 15 → HIGH/MEDIUM 'Vendor-Liste auffaellig kurz fuer Doc-Groesse' * submitted URL aber 0/Mini-Text → MEDIUM 'URL nicht ladbar' Rote Audit-Vorbehalt-Box ueber dem GF-1-Pager. GF-Summary sagt 'Audit unvollstaendig' statt faelschlich 'Keine kritischen Themen'. gf_one_pager nimmt audit_quality_findings in top_findings auf (BEVOR andere Findings). B — cookies_table_parser laeuft jetzt auch auf gecrawltem Cookie-Doc- Text (nicht nur bei User-Paste). Wenn der dsi-discovery-Response Tab/ Pipe-getrennte Tabellen-Reihen liefert, parsen wir sie deterministisch. D — consent-tester/dsi-discovery extrahiert jetzt zusaetzlich zum Text die -Elemente aus dem DOM als list[str] (Tab-getrennt pro Zeile, mind. 2 Zellen, mind. 3 Zeilen, max 10 Tabellen pro Doc). Backend schleust diese als 'html_table'-cmp_payload ein und jagt sie zuerst durch cookies_table_parser → 100% deterministische Vendor-Extraktion ohne LLM. VW-Erwartung: aus der 65k-Cookie-Tabelle werden jetzt 30-50 Vendors deterministisch geparst statt 6 vom LLM-Cascade. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/agent_compliance_check_routes.py | 105 +++++++++- .../services/audit_quality_checks.py | 198 ++++++++++++++++++ .../compliance/services/gf_one_pager.py | 64 ++++-- consent-tester/main.py | 5 + consent-tester/services/dsi_discovery.py | 26 +++ 5 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 backend-compliance/compliance/services/audit_quality_checks.py diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index 861a80c1..273c4126 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -826,6 +826,73 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): logger.info("P57: added %d new vendors from Phase G (total: %d)", added, len(cmp_vendors)) + # D — HTML-Tabellen die der consent-tester aus dem DOM + # extrahiert hat: direkt deterministisch parsen (hoechste + # Genauigkeit, keine LLM-Halluzinationen). + for pl in (cookie_payloads or []): + if pl.get("kind") != "html_table": + continue + rows = pl.get("rows") or [] + if len(rows) < 3: + continue + try: + from compliance.services.cookies_table_parser import ( + parse_cookie_table as _parse_ct_d, + ) + table_text = "\n".join(rows) + d_vendors = _parse_ct_d(table_text) + if d_vendors: + existing_d = {(v.get("name") or "").strip().lower() + for v in cmp_vendors} + added_d = 0 + for v in d_vendors: + nm = (v.get("name") or "").strip() + if not nm or nm.lower() in existing_d: + continue + v.setdefault("source", "html_table_dom") + cmp_vendors.append(v) + existing_d.add(nm.lower()) + added_d += 1 + if added_d: + logger.info( + "D HTML-Table-DOM-Parse: +%d Vendors aus " + "%d-Zeilen-Tabelle (total: %d)", + added_d, len(rows), len(cmp_vendors), + ) + except Exception as e: + logger.warning("html_table parse failed: %s", e) + + # B — cookies_table_parser auch auf gecrawltem Cookie-Text + # (nicht nur bei User-Paste). Wenn der Crawler Tab/Pipe- + # getrennte Tabellen-Reihen erhalten hat, parsen wir sie + # deterministisch und mergen die Vendor-Records. + if cookie_text and len(cookie_text) >= 500: + try: + from compliance.services.cookies_table_parser import ( + parse_cookie_table as _parse_ct, + ) + crawled_table_vendors = _parse_ct(cookie_text) + if crawled_table_vendors: + existing = {(v.get("name") or "").strip().lower() + for v in cmp_vendors} + added_c = 0 + for v in crawled_table_vendors: + nm = (v.get("name") or "").strip() + if not nm or nm.lower() in existing: + continue + v.setdefault("source", "table_crawled") + cmp_vendors.append(v) + existing.add(nm.lower()) + added_c += 1 + if added_c: + logger.info( + "B Crawled-Tabellen-Parse: +%d Vendors " + "(total: %d)", + added_c, len(cmp_vendors), + ) + except Exception as e: + logger.warning("crawled-table-parse failed: %s", e) + # User-pasted Cookie-Tabelle (deterministisch, kein LLM): # die hat IMMER Vorrang weil 100% genau. if pasted_table_vendors: @@ -1324,10 +1391,32 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): banner_result=banner_result, library_mismatch_findings=mismatches, scan_context=req.scan_context, + audit_quality_findings=audit_quality_findings, ) except Exception as e: logger.warning("P82 GF-1-pager skipped: %s", e) + # A — Audit-Quality-Checks: Banner-Detect-Failure, Vendor-Extract + # auffaellig duenn, URL-Fetch fehlgeschlagen → IMMER prominent zeigen. + audit_quality_html = "" + audit_quality_findings: list[dict] = [] + try: + from compliance.services.audit_quality_checks import ( + run_all as run_audit_quality, build_audit_quality_block_html, + ) + cookie_text_for_aq = doc_texts.get("cookie") or "" + audit_quality_findings = run_audit_quality( + banner_result, cookie_text_for_aq, cmp_vendors, doc_entries, + ) + if audit_quality_findings: + audit_quality_html = build_audit_quality_block_html(audit_quality_findings) + logger.info( + "audit-quality: %d Vorbehalte erkannt", + len(audit_quality_findings), + ) + except Exception as e: + logger.warning("audit-quality-checks failed: %s", e) + # Doc-Input-Warnings — wenn User Text ins falsche Feld gepastet hat input_warn_html = "" try: @@ -1384,7 +1473,8 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): logger.warning("P84 diff-mode skipped: %s", e) full_html = ( - gf_one_pager_html + input_warn_html + bench_html + diff_html + gf_one_pager_html + audit_quality_html + input_warn_html + + bench_html + diff_html + critical_html + scope_disclaimer_html + exec_summary_html + cookie_arch_html + summary_html + scanned_html + profile_html + scorecard_html + redundancy_html @@ -1575,6 +1665,19 @@ async def _fetch_text(url: str, doc_type: str = "") -> tuple[str, list[dict]]: docs = payload.get("documents", []) cmp_payloads = payload.get("cmp_payloads") or [] cmp_cookie_text = payload.get("cmp_cookie_text") or "" + # D — wenn der consent-tester HTML-Tabellen aus dem DOM + # extrahiert hat, in die cmp_payloads als "generic_table" + # einschleusen damit das Backend sie via cookies_table_parser + # verarbeiten kann. + for doc in (docs or []): + for tbl in (doc.get("tables") or []): + if not tbl or len(tbl) < 3: + continue + cmp_payloads.append({ + "kind": "html_table", + "url": doc.get("url", ""), + "rows": tbl, + }) if docs: texts = [] for doc in docs: diff --git a/backend-compliance/compliance/services/audit_quality_checks.py b/backend-compliance/compliance/services/audit_quality_checks.py new file mode 100644 index 00000000..8470c0f8 --- /dev/null +++ b/backend-compliance/compliance/services/audit_quality_checks.py @@ -0,0 +1,198 @@ +""" +A — Audit-Transparenz / Audit-Quality-Checks. + +Wenn der Crawler nicht alles gefunden hat, MUSS die Mail das prominent +zeigen — sonst denkt der User 'alles gut' obwohl die Datenlage Luecken +hat. + +Erkennt 4 Quality-Failures: +1. banner_detected=False trotz vorhandenem Cookie-Doc → CMP-Tool ungeladen +2. cookie_doc >= 30k chars aber cmp_vendors < 10 → Vendor-Extract unvollstaendig +3. doc_text submitted aber 0 chars geladen → Crawler-Failure +4. cmp_vendors > 0 aber alle aus llm_cascade ohne Library-Match → vermutl. unvollstaendig + +Diese Findings landen IMMER im GF-1-Pager (auch wenn kein anderes +HIGH-Finding da ist) — sie sagen "die Datenlage ist unvollstaendig, +manuelle Pruefung empfohlen". +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def _word_count(text: str | None) -> int: + if not text: + return 0 + return len(text.split()) + + +def check_banner_not_detected( + banner_result: dict | None, + cookie_doc_text: str | None, +) -> dict | None: + """1) Banner nicht geladen aber Cookie-Doc vorhanden → CMP-Tool kaputt.""" + if not isinstance(banner_result, dict): + return None + detected = banner_result.get("banner_detected") + if detected is None or detected is True: + return None + if not cookie_doc_text or len(cookie_doc_text) < 5000: + return None + return { + "severity": "HIGH", + "code": "audit_banner_not_detected", + "label": "Audit-Vorbehalt: Cookie-Banner konnte vom Crawler nicht " + "geladen werden", + "area": "Cookie-Banner", + "owner": "DSB + Marketing/CMP-Admin", + "detail": ( + "Unser Crawler konnte das CMP-Tool dieser Site nicht analysieren — " + "weder Vendor-Liste noch Cookie-Verhalten konnten geprueft werden. " + "Moegliche Ursachen: Anti-Bot-Schutz (Akamai/Cloudflare/DataDome) " + "blockiert Playwright; das CMP-Skript laed nur fuer bestimmte " + "Geo-Regionen; ein neues CMP-Tool das wir noch nicht unterstuetzen. " + "Empfehlung: manuelle Pruefung des Banners durch DSB, alternativ " + "Cookie-Tabelle im Audit-Tool direkt einfuegen (Copy-Paste-Modus)." + ), + "legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht — der Audit-" + "Befund muss transparent zwischen 'geprueft & OK' und " + "'nicht pruefbar' unterscheiden.", + } + + +def check_vendor_extract_incomplete( + cookie_doc_text: str | None, + cmp_vendors: list | None, +) -> dict | None: + """2) Cookie-Doc gross aber wenig Vendors → Extract unvollstaendig.""" + wc = _word_count(cookie_doc_text) + n_vendors = len(cmp_vendors or []) + # Heuristik: Cookie-Doc >= 5000 Wörter (~30k chars) sollte zu mind. 15 + # Vendors fuehren. Wenn weniger → Vendor-Extraktion hat den Text nicht + # vollstaendig verarbeitet. + if wc < 5000 or n_vendors >= 15: + return None + # Verhaeltniszahl bilden — je groesser das Doc, desto auffaelliger + return { + "severity": "HIGH" if wc >= 8000 else "MEDIUM", + "code": "audit_vendor_extract_thin", + "label": ( + f"Audit-Vorbehalt: Cookie-Richtlinie hat {wc:,} Wörter, " + f"wir konnten aber nur {n_vendors} Vendor" + f"{'en' if n_vendors != 1 else ''} extrahieren" + ).replace(",", "."), + "area": "Vendor-Liste / VVT", + "owner": "DSB + Marketing", + "detail": ( + "Bei dieser Doc-Groesse erwarten wir typischerweise 20-50+ " + "Vendors in einer Cookie-Richtlinie. Die niedrige extrahierte " + "Zahl deutet auf eine Tabelle die unser LLM nicht vollstaendig " + "parsen konnte. Empfehlung: VVT-Tabelle mit DSB / Marketing " + "manuell abgleichen, oder die Cookie-Tabelle im Copy-Paste-Modus " + "neu einreichen — dort parsen wir Spalten deterministisch." + ), + "legal_basis": "Art. 13(1)(e) DSGVO — die Empfaengerliste muss " + "vollstaendig sein; ein unvollstaendiger Audit darf " + "nicht als vollstaendig dargestellt werden.", + } + + +def check_url_fetch_failed(doc_entries: list | None) -> list[dict]: + """3) Submitted URL aber 0 oder Mini-Text → Crawler-Failure pro Doc.""" + out: list[dict] = [] + for e in (doc_entries or []): + if not isinstance(e, dict): + continue + url = (e.get("url") or "").strip() + text = (e.get("text") or "").strip() + if not url or len(text) >= 200 or e.get("auto_discovered"): + continue + dt = e.get("doc_type", "doc") + rejected = e.get("rejected_url") or "" + out.append({ + "severity": "MEDIUM", + "code": f"audit_url_fetch_failed_{dt}", + "label": ( + f"Audit-Vorbehalt: {dt}-URL konnte nicht geladen werden " + f"({len(text)} Zeichen extrahiert)" + ), + "area": dt, + "owner": "DSB + Web-Team", + "detail": ( + f"Die eingegebene URL {url[:120]} lieferte weniger als 200 " + "Zeichen. Moegliche Ursachen: 404, JS-only Render, Anti-Bot, " + "Cookie-Wall. Auto-Discovery hat versucht eine Alternative " + "auf der Homepage zu finden — ohne Erfolg. Empfehlung: " + "korrekte URL pruefen oder den Text direkt einfuegen " + "(Copy-Paste-Modus)." + ), + "legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht.", + }) + return out + + +def run_all( + banner_result: dict | None, + cookie_doc_text: str | None, + cmp_vendors: list | None, + doc_entries: list | None, +) -> list[dict]: + findings: list[dict] = [] + try: + f1 = check_banner_not_detected(banner_result, cookie_doc_text) + if f1: + findings.append(f1) + except Exception as e: + logger.warning("audit_banner_not_detected failed: %s", e) + try: + f2 = check_vendor_extract_incomplete(cookie_doc_text, cmp_vendors) + if f2: + findings.append(f2) + except Exception as e: + logger.warning("audit_vendor_extract_thin failed: %s", e) + try: + findings.extend(check_url_fetch_failed(doc_entries)) + except Exception as e: + logger.warning("audit_url_fetch_failed failed: %s", e) + return findings + + +def build_audit_quality_block_html(findings: list[dict]) -> str: + if not findings: + return "" + items: list[str] = [] + for f in findings: + sev = f.get("severity", "MEDIUM") + sev_color = "#dc2626" if sev == "HIGH" else "#d97706" + items.append( + f'
  • ' + f'[{sev}] {f.get("label","")}' + f'
    {f.get("detail","")}
    ' + f'
    ' + f'{f.get("legal_basis","")}
    ' + f'
  • ' + ) + return ( + '
    ' + '
    ' + 'Audit-Vorbehalt — Datenlage unvollstaendig
    ' + f'

    ' + f'{len(findings)} Punkt' + f'{"e" if len(findings) != 1 else ""} bei denen der Audit selbst ' + f'an Grenzen gestossen ist

    ' + '

    ' + 'Die folgenden Punkte betreffen NICHT die Compliance Ihrer Website, ' + 'sondern die Vollstaendigkeit unserer Pruefung. Bei diesen Bereichen ' + 'sollten Sie den Audit nicht als "alles ok" werten, sondern manuell ' + 'oder im Copy-Paste-Modus nachpruefen.' + '

    ' + '
      ' + + "".join(items) + + '
    ' + ) diff --git a/backend-compliance/compliance/services/gf_one_pager.py b/backend-compliance/compliance/services/gf_one_pager.py index d10ebc41..b8cdd591 100644 --- a/backend-compliance/compliance/services/gf_one_pager.py +++ b/backend-compliance/compliance/services/gf_one_pager.py @@ -77,10 +77,23 @@ def _collect_top_findings( banner_result: dict | None, scorecard: dict | None, library_mismatch_findings: list[dict] | None, + audit_quality_findings: list[dict] | None = None, limit: int = 5, ) -> list[dict]: out: list[dict] = [] + # 0) Audit-Quality-Vorbehalte (Banner-Detect-Fail, Vendor-thin) zuerst — + # die sind WICHTIGER als alle anderen Findings weil sie den Audit + # selbst infrage stellen. + for aq in (audit_quality_findings or []): + if isinstance(aq, dict): + out.append({ + "severity": aq.get("severity", "HIGH"), + "label": aq.get("label", "Audit-Vorbehalt"), + "area": aq.get("area", "Audit-Qualitaet"), + "owner": aq.get("owner", "DSB + Web-Team"), + }) + # 1) Banner deep-check findings (HIGH zuerst) if banner_result: for ph in (banner_result.get("phases") or {}).values(): @@ -172,6 +185,7 @@ def build_gf_one_pager_html( banner_result: dict | None = None, library_mismatch_findings: list[dict] | None = None, scan_context: dict | None = None, + audit_quality_findings: list[dict] | None = None, ) -> str: """5-7-Bullet-Zusammenfassung. Leere Top-Findings: nur Status-Bullet.""" score = None @@ -186,8 +200,10 @@ def build_gf_one_pager_html( banner_result=banner_result, scorecard=scorecard, library_mismatch_findings=library_mismatch_findings, - limit=5, + audit_quality_findings=audit_quality_findings, + limit=6, ) + audit_warn = bool(audit_quality_findings) n_high = sum(1 for f in top if f["severity"] == "HIGH") n_med = sum(1 for f in top if f["severity"] == "MEDIUM") @@ -243,12 +259,21 @@ def build_gf_one_pager_html( ) if not bullets: - bullets.append( - '
  • ' - 'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer ' - 'die geprueften Dokumente keine HIGH-Findings produziert. ' - 'Details im weiteren Verlauf der Mail.
  • ' - ) + if audit_warn: + bullets.append( + '
  • ' + 'Audit selbst war unvollstaendig — siehe ' + 'roten Audit-Vorbehalt-Block weiter unten. Eine pauschale ' + '"alles ok"-Aussage ist auf Basis dieser Datenlage nicht ' + 'moeglich.
  • ' + ) + else: + bullets.append( + '
  • ' + 'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer ' + 'die geprueften Dokumente keine HIGH-Findings produziert. ' + 'Details im weiteren Verlauf der Mail.
  • ' + ) return ( '
    ' - 'Realistische Einordnung: Wir analysieren das ' - 'Aussenbild Ihrer Website automatisiert — einzelne Findings koennen ' - 'durch interne Dokumentation bereits abgedeckt sein. Empfohlenes ' - 'Vorgehen: priorisierte Punkte mit DSB / Marketing / IT in einem ' - 'Termin durchsprechen (4-8 Wochen sind ein realistischer Zeitrahmen ' - 'fuer die Umsetzung). Eine pauschale Bussgeld-Erwartung leiten wir ' - 'aus diesem Audit nicht ab.' - '
    ' + + ( + 'Wichtig — Audit unvollstaendig:' + ' An mindestens einer Stelle ist unser Crawler an ' + 'Grenzen gestossen (siehe roter Audit-Vorbehalt-Block weiter ' + 'unten). Diese Bereiche sollten manuell oder im Copy-Paste-Modus ' + 'nachgereicht werden, bevor eine belastbare Compliance-Aussage ' + 'getroffen wird.' + if audit_warn else + 'Realistische Einordnung: Wir analysieren das ' + 'Aussenbild Ihrer Website automatisiert — einzelne Findings ' + 'koennen durch interne Dokumentation bereits abgedeckt sein. ' + 'Empfohlenes Vorgehen: priorisierte Punkte mit DSB / Marketing / ' + 'IT in einem Termin durchsprechen (4-8 Wochen sind ein ' + 'realistischer Zeitrahmen fuer die Umsetzung). Eine pauschale ' + 'Bussgeld-Erwartung leiten wir aus diesem Audit nicht ab.' + ) + + '' '' ) diff --git a/consent-tester/main.py b/consent-tester/main.py index ab725e17..baa5bd7e 100644 --- a/consent-tester/main.py +++ b/consent-tester/main.py @@ -292,6 +292,10 @@ class DSIDocumentInfo(BaseModel): word_count: int = 0 text_preview: str = "" full_text: str = "" + # D — Tab-getrennte HTML-Tabellen aus dem DOM (z.B. Cookie-Tabellen). + # Pro Tabelle ein Array von Zeilen, jede Zeile Tab-getrennt. + # Backend nutzt das fuer deterministischen Cookie-Tabellen-Parse. + tables: list[list[str]] = [] class DSIDiscoveryResponse(BaseModel): @@ -347,6 +351,7 @@ async def dsi_discovery(req: DSIDiscoveryRequest): word_count=d.word_count, text_preview=d.text[:500] if d.text else "", full_text=d.text[:200000] if d.text else "", + tables=getattr(d, "tables", []) or [], ) for d in result.documents ], diff --git a/consent-tester/services/dsi_discovery.py b/consent-tester/services/dsi_discovery.py index 72111701..8d47e95b 100644 --- a/consent-tester/services/dsi_discovery.py +++ b/consent-tester/services/dsi_discovery.py @@ -159,6 +159,10 @@ class DiscoveredDSI: text: str = "" # Extracted full text sections: list[dict] = field(default_factory=list) # Parsed sections word_count: int = 0 + # D — Tab-getrennte HTML-Tabellen aus dem DOM. Pro Tabelle eine + # Liste von Zeilen, jede Zeile ein Tab-getrennter String. Erlaubt + # dem Backend deterministischen Cookie-Tabellen-Parse ohne LLM. + tables: list[list[str]] = field(default_factory=list) @dataclass class DSIDiscoveryResult: @@ -523,12 +527,34 @@ async def discover_dsi_documents( return (body.innerText || body.textContent || '').trim(); } """) + # D — HTML-Tabellen separat extrahieren. Pro Tabelle ein + # Array von Zeilen, jede Zeile ein Tab-getrennter String. + # Das erlaubt dem Backend deterministischen Spalten-Parse + # (cookies_table_parser) ohne LLM-Halluzinationen. + tables = await page.evaluate(""" + () => { + const out = []; + document.querySelectorAll('table').forEach(t => { + const rows = []; + t.querySelectorAll('tr').forEach(tr => { + const cells = []; + tr.querySelectorAll('th, td').forEach(c => { + cells.push((c.innerText || c.textContent || '').trim().replace(/\\s+/g, ' ')); + }); + if (cells.length >= 2) rows.push(cells.join('\\t')); + }); + if (rows.length >= 3) out.push(rows); + }); + return out; + } + """) if text and len(text) > 50: result.documents.append(DiscoveredDSI( title=title, url=href, source_url=url, language=lang, doc_type="cross_domain" if not _is_allowed_domain(href, base_domain) else "html_page", text=text[:200000], word_count=len(text.split()), + tables=(tables or [])[:10], )) # Recursive: search THIS page for more DSI links