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'
' + '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.' + '
' + '