From d720db07dd164c25b481cb64a5be531040e01bc2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 13 Jun 2026 14:50:45 +0200 Subject: [PATCH] feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmen-tauglicher Bericht aus den Snapshot-Modulergebnissen (kein Re-Crawl, kein LLM): Einleitung, Testumfang+Methodik, Management-Summary (4-Status), Detail- befunde je Modul, Maßnahmen, Rechtlicher Hinweis. Co-Pilot-Tonalität, Tracking- statt Cookie-Rohzahl, Norm nur referenziert (kein Normtext). - audit_report.py: assemble_report (pur) + render_markdown + render_pdf (reportlab) - snapshot_check_routes: GET /report (struktur+md) + GET /report.pdf - Frontend: AuditReportTab + Proxys (report, report/pdf) + "Bericht"-Tab - Tests: 5 Assembler (compliance/tests → CI-geprüft) + 1 Vitest Co-Authored-By: Claude Opus 4.7 --- .../[snapshotId]/report/pdf/route.ts | 40 +++ .../snapshots/[snapshotId]/report/route.ts | 29 ++ .../sdk/agent/_components/AuditReportTab.tsx | 102 +++++++ .../__tests__/AuditReportTab.test.tsx | 31 +++ .../sdk/agent/snapshots/[snapshotId]/page.tsx | 6 + .../compliance/api/snapshot_check_routes.py | 71 +++++ .../compliance/services/audit_report.py | 258 ++++++++++++++++++ .../compliance/tests/test_audit_report.py | 65 +++++ 8 files changed, 602 insertions(+) create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/pdf/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/route.ts create mode 100644 admin-compliance/app/sdk/agent/_components/AuditReportTab.tsx create mode 100644 admin-compliance/app/sdk/agent/_components/__tests__/AuditReportTab.test.tsx create mode 100644 backend-compliance/compliance/services/audit_report.py create mode 100644 backend-compliance/compliance/tests/test_audit_report.py diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/pdf/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/pdf/route.ts new file mode 100644 index 00000000..8e23c055 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/pdf/route.ts @@ -0,0 +1,40 @@ +/** + * Audit-Report PDF — Proxy (streamt die PDF-Bytes durch) + * GET /api/sdk/v1/agent/snapshots/{snapshotId}/report/pdf + * → backend /api/compliance/agent/snapshots/{snapshotId}/report.pdf + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + try { + const res = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/report.pdf`, + { signal: AbortSignal.timeout(120_000) }, + ) + if (!res.ok) { + return NextResponse.json( + { error: `PDF fehlgeschlagen (${res.status})` }, { status: res.status }) + } + const buf = await res.arrayBuffer() + return new NextResponse(buf, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': + res.headers.get('content-disposition') || + 'attachment; filename="audit-report.pdf"', + }, + }) + } catch { + return NextResponse.json({ error: 'PDF fehlgeschlagen' }, { status: 503 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/route.ts new file mode 100644 index 00000000..2423b27f --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/report/route.ts @@ -0,0 +1,29 @@ +/** + * Audit-Report (strukturiert + Markdown) — Proxy + * GET /api/sdk/v1/agent/snapshots/{snapshotId}/report + * → backend /api/compliance/agent/snapshots/{snapshotId}/report + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + try { + const res = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/report`, + { signal: AbortSignal.timeout(120_000) }, + ) + const data = await res.json() + return NextResponse.json(data, { status: res.status }) + } catch { + return NextResponse.json( + { error: 'Report-Erzeugung fehlgeschlagen' }, { status: 503 }) + } +} diff --git a/admin-compliance/app/sdk/agent/_components/AuditReportTab.tsx b/admin-compliance/app/sdk/agent/_components/AuditReportTab.tsx new file mode 100644 index 00000000..3c1425e6 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AuditReportTab.tsx @@ -0,0 +1,102 @@ +'use client' + +/** + * AuditReportTab — rendert den deterministischen Audit-Textreport eines + * Snapshots (Sektionen aus /report, kein Re-Crawl) + Download als PDF/Markdown. + * Bewusst ohne Markdown-Lib + ohne dangerouslySetInnerHTML (Befundtexte können + * Site-Inhalte enthalten → XSS-sicher über React-Textknoten). + */ + +import React, { useEffect, useState } from 'react' + +type Section = { title: string; level: number; body?: string } +type Report = { meta?: Record; sections?: Section[]; totals?: Record } + +function Inline({ text }: { text: string }) { + // **fett** sicher rendern; _kursiv_-Marker entfernen. + const parts = text.split(/\*\*(.+?)\*\*/g) + return <>{parts.map((p, i) => (i % 2 + ? {p} + : {p.replace(/_/g, '')}))} +} + +function Body({ body }: { body: string }) { + const out: React.ReactNode[] = [] + let bullets: string[] = [] + const flush = (k: string) => { + if (bullets.length) { + const items = bullets + out.push(
    + {items.map((b, j) =>
  • )} +
) + bullets = [] + } + } + body.split('\n').map(l => l.trim()).filter(Boolean).forEach((l, i) => { + if (l.startsWith('- ')) { bullets.push(l.slice(2)) } + else { flush('p' + i); out.push(

) } + }) + flush('end') + return
{out}
+} + +export function AuditReportTab({ snapshotId }: { snapshotId: string }) { + const [rep, setRep] = useState(null) + const [md, setMd] = useState('') + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/report`) + .then(r => r.json()) + .then(d => { + if (cancelled) return + if (d?.error) setErr(d.error) + else { setRep(d.report); setMd(d.markdown || '') } + }) + .catch(e => { if (!cancelled) setErr(String(e)) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [snapshotId]) + + const downloadMd = () => { + const blob = new Blob([md], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'audit-report.md'; a.click() + URL.revokeObjectURL(url) + } + + if (loading) return
Bericht wird erstellt…
+ if (err || !rep) return
{err || 'Kein Bericht verfügbar.'}
+ + return ( +
+
+ + PDF herunterladen + + +
+
+ {(rep.sections || []).map((s, i) => s.level <= 2 ? ( +
+

{s.title}

+ {s.body && } +
+ ) : ( +
+

{s.title}

+ {s.body && } +
+ ))} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/AuditReportTab.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/AuditReportTab.test.tsx new file mode 100644 index 00000000..3cbb0be4 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/AuditReportTab.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { AuditReportTab } from '../AuditReportTab' + +function mockFetch(body: unknown) { + return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })) as unknown as typeof fetch +} + +describe('AuditReportTab', () => { + afterEach(() => { vi.restoreAllMocks() }) + + it('rendert Sektionen + Download-Buttons', async () => { + const data = { + report: { + sections: [ + { title: 'Einleitung', level: 2, body: 'Dieser Bericht fasst die Analyse zusammen.' }, + { title: 'Empfohlene Maßnahmen', level: 2, body: '- **[Hoch]** Tracking erst nach Consent laden.' }, + ], + }, + markdown: '# Bericht\n', + } + vi.stubGlobal('fetch', mockFetch(data)) + render() + expect(await screen.findByText('Einleitung')).toBeInTheDocument() + expect(screen.getByText('Empfohlene Maßnahmen')).toBeInTheDocument() + expect(screen.getByText(/Tracking erst nach Consent/)).toBeInTheDocument() + expect(screen.getByText('PDF herunterladen')).toBeInTheDocument() + expect(screen.getByText('Markdown herunterladen')).toBeInTheDocument() + }) +}) diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index 692f0c3d..17699e82 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -16,6 +16,7 @@ import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff' import { CookieResultView } from '../../_components/CookieResultView' import { AgentModuleTab } from '../../_components/AgentModuleTab' import { BrowserBehaviorView } from '../../_components/BrowserBehaviorView' +import { AuditReportTab } from '../../_components/AuditReportTab' export default function SnapshotDetail( { params }: { params: Promise<{ snapshotId: string }> }, @@ -64,6 +65,7 @@ export default function SnapshotDetail( ...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []), ...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []), ...(hasSite ? [{ key: 'browser', label: 'Browser-Verhalten' }] : []), + { key: 'bericht', label: 'Bericht' }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [snap]) @@ -142,6 +144,10 @@ export default function SnapshotDetail( {tab === 'browser' && ( )} + + {tab === 'bericht' && ( + + )} )} diff --git a/backend-compliance/compliance/api/snapshot_check_routes.py b/backend-compliance/compliance/api/snapshot_check_routes.py index 4f5c120b..2ae84461 100644 --- a/backend-compliance/compliance/api/snapshot_check_routes.py +++ b/backend-compliance/compliance/api/snapshot_check_routes.py @@ -215,3 +215,74 @@ async def snapshot_browser_behavior(snapshot_id: str): return {"browser_matrix": matrix} finally: db.close() + + +async def _gather_report(snapshot_id: str): + """Lädt den Snapshot + sammelt ALLE Modul-Ergebnisse (kein Re-Crawl) für den + Audit-Report. Gibt (meta, modules) zurück.""" + from database import SessionLocal + from compliance.services.check_snapshot import ( + load_snapshot, load_browser_matrix, + ) + from compliance.services.browser_cross_finding import build_cross_findings + db = SessionLocal() + try: + snap = load_snapshot(db, snapshot_id) + if not snap: + raise HTTPException(status_code=404, detail="snapshot not found") + meta = { + "site_label": snap.get("site_label"), + "site_domain": snap.get("site_domain"), + "created_at": snap.get("created_at"), + "check_id": snap.get("check_id"), + "scan_context": snap.get("scan_context"), + } + bm = load_browser_matrix(db, snapshot_id) + finally: + db.close() + docs = snap.get("doc_entries") or [] + + def _has(dt: str) -> bool: + return any(e.get("doc_type") == dt + and len(e.get("text") or e.get("content") or "") > 100 + for e in docs) + + modules: dict = {} + if snap.get("cmp_vendors"): + try: + modules["cookie"] = await snapshot_cookie_check(snapshot_id) + except Exception as e: + logger.warning("report cookie failed: %s", e) + for dt, agent in (("impressum", "impressum"), ("dse", "dse"), ("agb", "agb")): + if _has(dt): + try: + modules[dt] = await _run_doc_agent(snapshot_id, dt, agent) + except Exception as e: + logger.warning("report %s failed: %s", dt, e) + if bm: + modules["browser"] = {"browser_matrix": bm, + "cross_findings": build_cross_findings(bm)} + return meta, modules + + +@router.get("/snapshots/{snapshot_id}/report") +async def snapshot_report(snapshot_id: str): + """Deterministischer Audit-Textreport (strukturiert + Markdown), aus den + Modul-Ergebnissen des Snapshots — kein Re-Crawl, kein LLM.""" + from compliance.services.audit_report import assemble_report, render_markdown + meta, modules = await _gather_report(snapshot_id) + report = assemble_report(meta, modules) + return {"report": report, "markdown": render_markdown(report)} + + +@router.get("/snapshots/{snapshot_id}/report.pdf") +async def snapshot_report_pdf(snapshot_id: str): + """Druckfertiges PDF des Audit-Reports (reportlab).""" + from fastapi import Response + from compliance.services.audit_report import assemble_report, render_pdf + meta, modules = await _gather_report(snapshot_id) + pdf = render_pdf(assemble_report(meta, modules)) + dom = (meta.get("site_domain") or "report").replace("/", "_") + return Response( + content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="audit-{dom}.pdf"'}) diff --git a/backend-compliance/compliance/services/audit_report.py b/backend-compliance/compliance/services/audit_report.py new file mode 100644 index 00000000..9b61b2d0 --- /dev/null +++ b/backend-compliance/compliance/services/audit_report.py @@ -0,0 +1,258 @@ +"""Audit-Textreport — deterministischer Section-Assembler + Markdown-Renderer. + +Erzeugt aus den bereits vorhandenen Modul-Ergebnissen eines Snapshots (Cookie-, +Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen +Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je +Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung. + +Leitplanken (siehe [[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]): +- Co-Pilot-Ton: „Wir analysieren – Sie entscheiden mit DSB/Anwalt." Keine Panik, + keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie. +- Keine Normtexte reproduzieren — nur Norm-Bezug benennen. +- Applicability ≠ Compliance, Unknown ≠ Fail: Konfidenz + Status transparent. + +`assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern = +neue Sektion in `_build_sections` ergänzen. +""" + +from __future__ import annotations + +from typing import Any, Optional + +_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4} +_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel", + "LOW": "Niedrig", "INFO": "Hinweis"} +_MODULE_LABEL = { + "cookie": "Cookies & Tracking", "impressum": "Impressum", + "dse": "Datenschutzerklärung", "agb": "AGB", + "browser": "Browser-Verhalten (Cookie-Banner)", +} +_DISCLAIMER = ( + "Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet " + "Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine " + "Rechtsberatung: Die finale Bewertung und Freigabe treffen Sie gemeinsam " + "mit Ihrem Datenschutzbeauftragten bzw. Ihrer Anwältin/Ihrem Anwalt. " + "Befunde mit niedriger Konfidenz oder unklarer Datenlage sind als Hinweis " + "zu verstehen, nicht als festgestellter Verstoß." +) + + +def _norm_finding(f: Any) -> dict: + """Befund (dict/obj) → einheitliche Form für den Report.""" + if not isinstance(f, dict): + f = getattr(f, "__dict__", {}) or {} + sev = (f.get("severity") or "MEDIUM").upper() + return { + "title": f.get("title") or f.get("text") or f.get("message") or "Befund", + "severity": sev if sev in _SEV_ORDER else "MEDIUM", + "status": f.get("status") or "", + "legal_ref": f.get("legal_ref") or f.get("legal_reference") or "", + "measure": f.get("measure") or f.get("recommendation") or "", + "confidence": f.get("confidence"), + } + + +def _module_findings(mod: Optional[dict]) -> list[dict]: + if not mod: + return [] + out = [_norm_finding(f) for f in (mod.get("findings") or [])] + # Browser-Modul trägt seine Befunde in cross_findings. + for cf in (mod.get("cross_findings") or []): + n = _norm_finding(cf) + if cf.get("detail"): + n["title"] = f"{n['title']} — {cf['detail']}" + out.append(n) + return out + + +def _sev_counts(findings: list[dict]) -> dict: + c = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} + for f in findings: + c[f["severity"]] = c.get(f["severity"], 0) + 1 + return c + + +def _verdict(counts: dict) -> str: + if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3: + return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine " + "kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.") + if counts.get("HIGH"): + return ("Einzelne Punkte sollten priorisiert geprüft werden; das " + "Gesamtbild ist handhabbar.") + if counts.get("MEDIUM"): + return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand " + "nachschärfbar.") + return ("Im Prüfumfang wurden keine wesentlichen Auffälligkeiten " + "festgestellt.") + + +def assemble_report(meta: dict, modules: dict) -> dict: + """meta: {site_label, site_domain, created_at, check_id, scan_context}. + modules: {cookie|impressum|dse|agb|browser: }. + Rückgabe: {meta, generated_for, sections:[{title, level, body, findings?}]}.""" + present = [k for k in ("cookie", "impressum", "dse", "agb", "browser") + if modules.get(k)] + all_findings = [f for k in present for f in _module_findings(modules.get(k))] + counts = _sev_counts(all_findings) + return { + "meta": meta, + "modules_present": present, + "totals": {"findings": len(all_findings), "by_severity": counts}, + "sections": _build_sections(meta, modules, present, all_findings, counts), + } + + +def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]: + site = meta.get("site_label") or meta.get("site_domain") or "die Website" + when = (meta.get("created_at") or "")[:16].replace("T", " ") + sec: list[dict] = [] + + sec.append({"title": "Einleitung", "level": 2, "body": ( + f"Dieser Bericht fasst die automatisierte Compliance-Analyse von " + f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit " + f"BreakPilot am {when or 'dem angegebenen Datum'}. Ziel ist ein " + f"verständlicher Überblick über mögliche datenschutz- und " + f"informationsrechtliche Handlungsfelder als Grundlage für die " + f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")}) + + mods = ", ".join(_MODULE_LABEL[k] for k in present) or "—" + sec.append({"title": "Testumfang & Methodik", "level": 2, "body": ( + f"**Geprüfte Bereiche:** {mods}.\n\n" + f"**Vorgehen:** Automatisierte Erfassung der öffentlich erreichbaren " + f"Seiteninhalte und des Cookie-/Tracking-Verhaltens, Abgleich gegen eine " + f"kuratierte Wissensbasis sowie – beim Cookie-Banner – Messung des " + f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird " + f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind " + f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n" + f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; " + f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte " + f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, " + f"keine abschließende rechtliche Feststellung.")}) + + sev_line = " · ".join( + f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW") + if counts.get(k)) + sec.append({"title": "Management-Summary", "level": 2, "body": ( + f"{_verdict(counts)}\n\n" + f"**Befunde gesamt:** {len(all_findings)}" + f"{' (' + sev_line + ')' if sev_line else ''}.\n\n" + f"Die Einstufung folgt dem Prinzip *Anwendbarkeit ≠ Verstoß*: Ein Befund " + f"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko.")}) + + det: list[dict] = [{"title": "Detailbefunde", "level": 2, "body": ""}] + for k in present: + fs = sorted(_module_findings(modules.get(k)), + key=lambda f: _SEV_ORDER.get(f["severity"], 2)) + if not fs: + det.append({"title": _MODULE_LABEL[k], "level": 3, + "body": "Keine Auffälligkeiten im Prüfumfang."}) + continue + lines = [] + for f in fs: + extra = [] + if f["status"]: + extra.append(f"Status: {f['status']}") + if f["legal_ref"]: + extra.append(f["legal_ref"]) + tail = f" _( {' · '.join(extra)} )_" if extra else "" + lines.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}") + det.append({"title": _MODULE_LABEL[k], "level": 3, + "body": "\n".join(lines)}) + sec.extend(det) + + measures = [] + for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)): + if f["measure"]: + measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}") + seen, uniq = set(), [] + for m in measures: + if m not in seen: + seen.add(m) + uniq.append(m) + sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": ( + "\n".join(uniq) if uniq else + "Keine konkreten Maßnahmen erforderlich.")}) + + sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER}) + return sec + + +def render_markdown(report: dict) -> str: + meta = report.get("meta") or {} + site = meta.get("site_label") or meta.get("site_domain") or "Website" + when = (meta.get("created_at") or "")[:16].replace("T", " ") + out = [f"# Compliance-Audit-Bericht — {site}", ""] + sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "", + f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else ""] + out.append(" · ".join(s for s in sub if s)) + out.append("") + out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._") + out.append("") + for s in report.get("sections") or []: + out.append(f"{'#' * s.get('level', 2)} {s['title']}") + out.append("") + if s.get("body"): + out.append(s["body"]) + out.append("") + return "\n".join(out).strip() + "\n" + + +def render_pdf(report: dict) -> bytes: + """Druckfertiges PDF (reportlab). Wandelt die Sektionen in gestylte Absätze; + unterstützt **fett**, _kursiv_ und Aufzählungen aus den Markdown-Bodies.""" + import html + import re + from io import BytesIO + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import mm + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + + meta = report.get("meta") or {} + site = meta.get("site_label") or meta.get("site_domain") or "Website" + buf = BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=A4, topMargin=20 * mm, bottomMargin=18 * mm, + leftMargin=20 * mm, rightMargin=20 * mm, + title=f"Compliance-Audit-Bericht — {site}") + ss = getSampleStyleSheet() + h1 = ParagraphStyle("rh1", parent=ss["Title"], fontSize=18, spaceAfter=4) + h2 = ParagraphStyle("rh2", parent=ss["Heading2"], spaceBefore=12, + spaceAfter=4, textColor=colors.HexColor("#1d4ed8")) + h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2) + body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10, + leading=14, spaceAfter=4) + small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8, + textColor=colors.grey, spaceAfter=2) + + def _inl(t: str) -> str: + t = html.escape(t) + t = re.sub(r"\*\*(.+?)\*\*", r"\1", t) + t = re.sub(r"_(.+?)_", r"\1", t) + return t + + story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)] + sub = " · ".join(x for x in [ + meta.get("site_domain") or "", + (meta.get("created_at") or "")[:16].replace("T", " "), + f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "", + ] if x) + if sub: + story.append(Paragraph(html.escape(sub), small)) + story.append(Paragraph( + "Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small)) + story.append(Spacer(1, 8)) + for s in report.get("sections") or []: + style = {2: h2, 3: h3}.get(s.get("level", 2), h2) + story.append(Paragraph(_inl(s["title"]), style)) + for line in (s.get("body") or "").split("\n"): + line = line.strip() + if not line: + continue + if line.startswith("- "): + story.append(Paragraph("•  " + _inl(line[2:]), body)) + else: + story.append(Paragraph(_inl(line), body)) + doc.build(story) + return buf.getvalue() diff --git a/backend-compliance/compliance/tests/test_audit_report.py b/backend-compliance/compliance/tests/test_audit_report.py new file mode 100644 index 00000000..26aecadd --- /dev/null +++ b/backend-compliance/compliance/tests/test_audit_report.py @@ -0,0 +1,65 @@ +"""Audit-Report-Assembler (pur) — Sektionen, 4-Status-/Severity-Zählung, +Co-Pilot-Tonalität, kein Normtext.""" + +from compliance.services.audit_report import assemble_report, render_markdown + +META = {"site_label": "BMW", "site_domain": "bmw.de", + "created_at": "2026-06-11T14:15:00", "check_id": "508983ec"} +MODULES = { + "cookie": {"findings": [ + {"title": "Cookie als notwendig deklariert, real Marketing", + "severity": "HIGH", "legal_ref": "§ 25 TDDDG", + "measure": "Als einwilligungspflichtig (§ 25) einstufen."}, + {"title": "Laufzeit überschreitet Empfehlung", "severity": "LOW"}, + ]}, + "impressum": {"findings": [ + {"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM", + "status": "APPLICABLE", "recommendation": "Vertretungsberechtigte ergänzen."}, + ], "status": "APPLICABLE", "confidence": 0.8}, + "browser": {"cross_findings": [ + {"title": "Tracking vor der Einwilligung — in allen Browsern", + "severity": "HIGH", "detail": "Chrome + Firefox setzen Tracker vor Consent", + "measure": "Tracking-Skripte erst nach aktiver Einwilligung laden."}, + ]}, +} + + +def test_sections_present(): + r = assemble_report(META, MODULES) + titles = [s["title"] for s in r["sections"]] + for t in ["Einleitung", "Testumfang & Methodik", "Management-Summary", + "Detailbefunde", "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]: + assert t in titles, f"Sektion fehlt: {t}" + + +def test_severity_counts(): + r = assemble_report(META, MODULES) + c = r["totals"]["by_severity"] + assert c["HIGH"] == 2 and c["MEDIUM"] == 1 and c["LOW"] == 1 + assert r["totals"]["findings"] == 4 + + +def test_markdown_has_header_findings_and_copilot_disclaimer(): + md = render_markdown(assemble_report(META, MODULES)) + assert "Compliance-Audit-Bericht — BMW" in md + assert "Tracking vor der Einwilligung" in md # Browser-Cross-Finding + assert "Vertretungsberechtigte ergänzen" in md # Maßnahme aus recommendation + assert "DSB" in md and "Anwalt" in md # Co-Pilot-Disclaimer + assert "Wahrscheinlichkeit" in md # keine Garantie + assert "BreakPilot" in md + + +def test_empty_modules_graceful(): + r = assemble_report({"site_domain": "example.com"}, {}) + assert r["totals"]["findings"] == 0 + md = render_markdown(r) + assert "keine wesentlichen auffälligkeiten" in md.lower() + # Auch ohne Befunde: Disclaimer + Methodik vorhanden. + assert "Rechtlicher Hinweis" in md + + +def test_essential_cookie_framing_in_methodik(): + # Tonalität/Recht: technisch notwendige Cookies ausgenommen (§ 25 Abs. 2). + md = render_markdown(assemble_report(META, MODULES)) + assert "§ 25 Abs. 2" in md + assert "nicht-essentielle" in md.lower()