feat(audit): A Audit-Transparenz + B Tabellen-Parse + D HTML-Tables aus DOM
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-python-backend (push) Successful in 45s
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 20s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

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 <table>-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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-21 20:21:28 +02:00
parent e411c4f0d3
commit cb5dad1a2f
5 changed files with 382 additions and 16 deletions
@@ -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:
@@ -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'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
f'{f.get("legal_basis","")}</div>'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#fee2e2;border:1px solid #fecaca;border-radius:8px">'
'<div style="font-size:11px;color:#991b1b;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Audit-Vorbehalt — Datenlage unvollstaendig</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(findings)} Punkt'
f'{"e" if len(findings) != 1 else ""} bei denen der Audit selbst '
f'an Grenzen gestossen ist</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'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.'
'</p>'
'<ul style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)
@@ -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(
'<li style="margin-bottom:4px;font-size:12px;color:#475569">'
'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer '
'die geprueften Dokumente keine HIGH-Findings produziert. '
'Details im weiteren Verlauf der Mail.</li>'
)
if audit_warn:
bullets.append(
'<li style="margin-bottom:4px;font-size:12px;color:#991b1b">'
'<strong>Audit selbst war unvollstaendig</strong> — siehe '
'roten Audit-Vorbehalt-Block weiter unten. Eine pauschale '
'"alles ok"-Aussage ist auf Basis dieser Datenlage nicht '
'moeglich.</li>'
)
else:
bullets.append(
'<li style="margin-bottom:4px;font-size:12px;color:#475569">'
'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer '
'die geprueften Dokumente keine HIGH-Findings produziert. '
'Details im weiteren Verlauf der Mail.</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
@@ -279,13 +304,22 @@ def build_gf_one_pager_html(
'<div style="font-size:11px;color:#475569;line-height:1.5;'
'padding:8px 10px;background:#fff;border:1px solid #e2e8f0;'
'border-radius:4px">'
'<strong>Realistische Einordnung:</strong> 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.'
'</div>'
+ (
'<strong style="color:#991b1b">Wichtig — Audit unvollstaendig:'
'</strong> 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
'<strong>Realistische Einordnung:</strong> 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.'
)
+ '</div>'
'</div>'
)
+5
View File
@@ -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
],
+26
View File
@@ -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