feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>; sections?: Section[]; totals?: Record<string, unknown> }
|
||||
|
||||
function Inline({ text }: { text: string }) {
|
||||
// **fett** sicher rendern; _kursiv_-Marker entfernen.
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g)
|
||||
return <>{parts.map((p, i) => (i % 2
|
||||
? <strong key={i}>{p}</strong>
|
||||
: <React.Fragment key={i}>{p.replace(/_/g, '')}</React.Fragment>))}</>
|
||||
}
|
||||
|
||||
function Body({ body }: { body: string }) {
|
||||
const out: React.ReactNode[] = []
|
||||
let bullets: string[] = []
|
||||
const flush = (k: string) => {
|
||||
if (bullets.length) {
|
||||
const items = bullets
|
||||
out.push(<ul key={'u' + k} className="list-disc ml-5 space-y-1">
|
||||
{items.map((b, j) => <li key={j} className="text-sm text-gray-700"><Inline text={b} /></li>)}
|
||||
</ul>)
|
||||
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(<p key={i} className="text-sm text-gray-700"><Inline text={l} /></p>) }
|
||||
})
|
||||
flush('end')
|
||||
return <div className="space-y-1.5">{out}</div>
|
||||
}
|
||||
|
||||
export function AuditReportTab({ snapshotId }: { snapshotId: string }) {
|
||||
const [rep, setRep] = useState<Report | null>(null)
|
||||
const [md, setMd] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(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 <div className="text-sm text-gray-500">Bericht wird erstellt…</div>
|
||||
if (err || !rep) return <div className="text-sm text-red-600">{err || 'Kein Bericht verfügbar.'}</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<a href={`/api/sdk/v1/agent/snapshots/${snapshotId}/report/pdf`}
|
||||
target="_blank" rel="noopener"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
<button onClick={downloadMd}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
Markdown herunterladen
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-white">
|
||||
{(rep.sections || []).map((s, i) => s.level <= 2 ? (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 border-b border-gray-100 pb-1">{s.title}</h2>
|
||||
{s.body && <Body body={s.body} />}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="space-y-1 ml-1">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{s.title}</h3>
|
||||
{s.body && <Body body={s.body} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<AuditReportTab snapshotId="abc" />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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' && (
|
||||
<BrowserBehaviorView snapshotId={snapshotId} />
|
||||
)}
|
||||
|
||||
{tab === 'bericht' && (
|
||||
<AuditReportTab snapshotId={snapshotId} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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"'})
|
||||
|
||||
@@ -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: <modul-ergebnis-dict oder None>}.
|
||||
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"<b>\1</b>", t)
|
||||
t = re.sub(r"_(.+?)_", r"<i>\1</i>", 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user