feat(audit): P82 GF-1-Pager + P87 Konfidenz-Score pro Finding
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 18s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 18s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
P82 — gf_one_pager.py: kompakte 5-Bullet-Kurzfassung ganz oben in der Mail. Score (gross + Farbe), Delta-zu-Vorlauf, Top-Findings nach HIGH/MEDIUM sortiert mit zustaendiger Rolle (DSB / Marketing / IT / Legal / Web-Team) und Klassifizierungsbits aus dem Wizard. Sachlicher Ton — keine 4%-Drohung, '4-8 Wochen' als realistischer Zeitrahmen. Eingehaengt vor Critical-Findings-Block in Mail-Composition und Replay-Pipeline. P87 — finding_confidence.py: 13 Regex-Regeln liefern (confidence_pct, reason) pro Finding-Label. Direkt im DOM beobachtbar = 95-98%, Library-Mismatch = 82%, Textmuster-Match auf Pflichtangaben = 75-88%. Im 1-Pager als kleines '(NN% Konfidenz)'-Tag mit Reason-Tooltip hinter jedem Finding gerendert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1049,6 +1049,7 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
|||||||
|
|
||||||
# P102: Cookie-Klassifikations-Pruefung (deklariert vs Library)
|
# P102: Cookie-Klassifikations-Pruefung (deklariert vs Library)
|
||||||
library_mismatch_html = ""
|
library_mismatch_html = ""
|
||||||
|
mismatches: list[dict] = []
|
||||||
try:
|
try:
|
||||||
from compliance.services.cookie_library_mismatch import (
|
from compliance.services.cookie_library_mismatch import (
|
||||||
detect_mismatches, build_mismatch_block_html,
|
detect_mismatches, build_mismatch_block_html,
|
||||||
@@ -1080,8 +1081,25 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("P102 mismatch detection failed: %s", e)
|
logger.warning("P102 mismatch detection failed: %s", e)
|
||||||
|
|
||||||
|
# P82: GF-1-Pager ganz oben in der Mail — 5-Bullet-Zusammenfassung
|
||||||
|
# damit die GF nicht 124k Char lesen muss.
|
||||||
|
gf_one_pager_html = ""
|
||||||
|
try:
|
||||||
|
from compliance.services.gf_one_pager import build_gf_one_pager_html
|
||||||
|
gf_one_pager_html = build_gf_one_pager_html(
|
||||||
|
site_name=site_name_for_exec,
|
||||||
|
scorecard=scorecard,
|
||||||
|
previous_scorecard=prev_scorecard,
|
||||||
|
banner_result=banner_result,
|
||||||
|
library_mismatch_findings=mismatches,
|
||||||
|
scan_context=req.scan_context,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("P82 GF-1-pager skipped: %s", e)
|
||||||
|
|
||||||
full_html = (
|
full_html = (
|
||||||
critical_html + scope_disclaimer_html + exec_summary_html
|
gf_one_pager_html
|
||||||
|
+ critical_html + scope_disclaimer_html + exec_summary_html
|
||||||
+ cookie_arch_html + summary_html + scanned_html + profile_html
|
+ cookie_arch_html + summary_html + scanned_html + profile_html
|
||||||
+ scorecard_html + redundancy_html
|
+ scorecard_html + redundancy_html
|
||||||
+ providers_html + banner_deep_html + library_mismatch_html
|
+ providers_html + banner_deep_html + library_mismatch_html
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ def replay_from_snapshot(
|
|||||||
section_sizes: dict[str, int] = {}
|
section_sizes: dict[str, int] = {}
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
||||||
|
# P82: GF-1-Pager zuerst (5-Bullet-Summary)
|
||||||
|
try:
|
||||||
|
from compliance.services.gf_one_pager import build_gf_one_pager_html
|
||||||
|
gf_html = build_gf_one_pager_html(
|
||||||
|
site_name=site_label or "",
|
||||||
|
scorecard=None, # Snapshot enthaelt keine MC-Scorecard
|
||||||
|
banner_result=banner_result,
|
||||||
|
library_mismatch_findings=None, # wird unten gefuellt
|
||||||
|
scan_context=snap.get("scan_context"),
|
||||||
|
)
|
||||||
|
parts.append(gf_html)
|
||||||
|
section_sizes["gf_one_pager"] = len(gf_html)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Replay: GF-1-pager failed: %s", e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from compliance.api.agent_doc_check_critical import build_critical_findings_html
|
from compliance.api.agent_doc_check_critical import build_critical_findings_html
|
||||||
critical_html = build_critical_findings_html(banner_result, None, results) or ""
|
critical_html = build_critical_findings_html(banner_result, None, results) or ""
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
P87 — Konfidenz-Score pro Finding.
|
||||||
|
|
||||||
|
Nicht jedes HIGH-Finding ist gleich sicher. "Kein Reject-Button im Banner"
|
||||||
|
ist faktisch direkt beobachtbar (Confidence ~98%). "DSE enthaelt keinen
|
||||||
|
DSB-Kontakt" ist ein Textmuster-Match und kann False-Positive sein
|
||||||
|
(Confidence ~70%). "Cookie X als essential deklariert, Library sagt
|
||||||
|
marketing" haengt von Library-Qualitaet ab (Confidence ~80%).
|
||||||
|
|
||||||
|
Liefert pro Finding-Label ein (confidence_pct, reason) Paar. Wird im
|
||||||
|
Mail-Render als kleine graue Klammer hinter dem Severity-Pill angezeigt:
|
||||||
|
"HOCH (95% Konfidenz: Direkt im DOM beobachtet)".
|
||||||
|
|
||||||
|
Keine ML — nur regelbasiert. Eine zentrale Stelle damit alle Render-
|
||||||
|
Stellen einheitlich klassifizieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# (regex, confidence_pct, reason)
|
||||||
|
# Reihenfolge wichtig: spezifischere Patterns zuerst.
|
||||||
|
_RULES: list[tuple[re.Pattern, int, str]] = [
|
||||||
|
# 1) Direkt im DOM / im Cookie-Jar beobachtet — sehr hohe Sicherheit
|
||||||
|
(re.compile(r"reject[- ]?button.*(fehlt|nicht.*vorhanden)", re.I), 98,
|
||||||
|
"Direkt im Banner-DOM ueberprueft"),
|
||||||
|
(re.compile(r"(anpassen|einstellungen|customize).*button.*fehlt", re.I), 95,
|
||||||
|
"Initial-Banner-DOM ueberprueft"),
|
||||||
|
(re.compile(r"cookie.*vor.*einwilligung.*gesetzt", re.I), 96,
|
||||||
|
"Cookie-Jar vor Akzeptieren beobachtet"),
|
||||||
|
(re.compile(r"(tracking|marketing).*ohne.*einwilligung", re.I), 92,
|
||||||
|
"Network-Calls vor Akzeptieren beobachtet"),
|
||||||
|
|
||||||
|
# 2) Library-Mismatches — abhaengig von Library-Qualitaet
|
||||||
|
(re.compile(r"deklariert als.*library.*sagt", re.I), 82,
|
||||||
|
"Vergleich mit ~2.300-Cookie-Library + Open-Cookie-DB"),
|
||||||
|
(re.compile(r"library.*marketing", re.I), 82,
|
||||||
|
"Cookie-Library-Klassifikation"),
|
||||||
|
|
||||||
|
# 3) Pflichtangaben-Checks (Impressum/AGB/DSE) — Textmuster, MEDIUM-Sicherheit
|
||||||
|
(re.compile(r"impressum.*(fehlt|unvollstaendig)", re.I), 88,
|
||||||
|
"Pattern-Match auf Impressums-Pflichtfelder (§ 5 TMG)"),
|
||||||
|
(re.compile(r"dsb.*(fehlt|nicht.*genannt)", re.I), 75,
|
||||||
|
"Textmuster-Suche; DSB kann ueber Impressum referenziert sein"),
|
||||||
|
(re.compile(r"drittland.*(fehlt|nicht.*genannt|ohne.*hinweis)", re.I), 80,
|
||||||
|
"Pattern-Match auf typische Drittland-Klauseln"),
|
||||||
|
(re.compile(r"widerruf.*(fehlt|unvollstaendig)", re.I), 85,
|
||||||
|
"Pattern-Match auf Widerrufsbelehrungs-Pflichtfelder"),
|
||||||
|
|
||||||
|
# 4) Anti-Auditing-Detection — heuristisch
|
||||||
|
(re.compile(r"anti[- ]?audit", re.I), 70,
|
||||||
|
"Skript-Domain-Heuristik; manuelle Pruefung empfohlen"),
|
||||||
|
|
||||||
|
# 5) Generische Konsistenz-Findings (DSE vs. Banner vs. Cookie-Liste)
|
||||||
|
(re.compile(r"banner.*nennt.*\d+.*cmp.*\d+", re.I), 90,
|
||||||
|
"Quantitativer Vergleich zwischen Banner-Text und CMP-Payload"),
|
||||||
|
|
||||||
|
# 6) Klassifikations- / Kontext-Findings (Wizard-getrieben)
|
||||||
|
(re.compile(r"(branchen|scope).*passt.*nicht", re.I), 88,
|
||||||
|
"Wizard-Klassifikation + MC-scope_doc_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_DEFAULT_CONFIDENCE = 78
|
||||||
|
_DEFAULT_REASON = (
|
||||||
|
"Standard-Regelpruefung; Bestaetigung mit DSB / interner Doku empfohlen"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_finding(label: str) -> tuple[int, str]:
|
||||||
|
"""Returns (confidence_pct, reason) for a finding label."""
|
||||||
|
if not label:
|
||||||
|
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
|
||||||
|
for pat, conf, reason in _RULES:
|
||||||
|
if pat.search(label):
|
||||||
|
return conf, reason
|
||||||
|
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
|
||||||
|
|
||||||
|
|
||||||
|
def confidence_pill_html(label: str) -> str:
|
||||||
|
"""Returns an inline HTML snippet '(NN% Konfidenz: ...)' or empty."""
|
||||||
|
conf, reason = score_finding(label)
|
||||||
|
return (
|
||||||
|
f' <span style="color:#94a3b8;font-size:10px" title="{reason}">'
|
||||||
|
f'({conf}% Konfidenz)</span>'
|
||||||
|
)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
P82 — GF-1-Pager (Geschaeftsfuehrer-Kurzfassung).
|
||||||
|
|
||||||
|
Eine kompakte 5-7-Bullet-Zusammenfassung ganz oben in der Mail. GF liest
|
||||||
|
sonst die 124k-Char-Komplettpruefung nicht. Ton sachlich, keine Panik
|
||||||
|
(Memory: feedback_breakpilot_tonalitaet).
|
||||||
|
|
||||||
|
Bildet ab:
|
||||||
|
- Compliance-Score + Vergleichswert (wenn Vorlauf vorhanden)
|
||||||
|
- Top-3 priorisierte Themen (HIGH oder kritisches MEDIUM)
|
||||||
|
- Aufwand-Schaetzung (4-8 Wochen) + Wer-macht-was (DSB / IT / Marketing)
|
||||||
|
- Realer Risiko-Hinweis (ohne 4%-Weltumsatz-Drohung)
|
||||||
|
|
||||||
|
Wird VOR Critical-Findings und Exec-Summary gerendert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_AREA_LABEL = {
|
||||||
|
"banner": "Cookie-Banner",
|
||||||
|
"cookie": "Cookie-Richtlinie",
|
||||||
|
"dse": "Datenschutzerklaerung",
|
||||||
|
"impressum": "Impressum",
|
||||||
|
"agb": "AGB",
|
||||||
|
"library_mismatch": "Cookie-Klassifikation",
|
||||||
|
"vendor": "Vendor-Liste / VVT",
|
||||||
|
"consent": "Einwilligung",
|
||||||
|
"rights": "Betroffenenrechte",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_finding(item: dict) -> dict:
|
||||||
|
sev = str(item.get("severity") or item.get("level") or "").upper()
|
||||||
|
if sev not in ("HIGH", "MEDIUM", "LOW"):
|
||||||
|
sev = "MEDIUM"
|
||||||
|
label = (item.get("label") or item.get("title")
|
||||||
|
or item.get("check") or item.get("name") or "").strip()
|
||||||
|
if not label:
|
||||||
|
return {}
|
||||||
|
area = (item.get("area") or item.get("doc_type") or item.get("category") or "").lower()
|
||||||
|
return {
|
||||||
|
"severity": sev,
|
||||||
|
"label": label[:200],
|
||||||
|
"area": _AREA_LABEL.get(area, area.replace("_", " ").title() or "Allgemein"),
|
||||||
|
"owner": item.get("owner") or _guess_owner(label, area),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_owner(label: str, area: str) -> str:
|
||||||
|
"""Heuristik: wer ist der wahrscheinliche Ansprechpartner."""
|
||||||
|
lab = label.lower()
|
||||||
|
if any(w in lab for w in ("banner", "cookie", "consent",
|
||||||
|
"einwilligung", "tracking")):
|
||||||
|
return "DSB + Marketing/CMP-Admin"
|
||||||
|
if any(w in lab for w in ("vendor", "avv", "auftragsverarbeitung",
|
||||||
|
"drittland", "schrems")):
|
||||||
|
return "DSB + Einkauf/Legal"
|
||||||
|
if any(w in lab for w in ("impressum", "agb", "widerruf", "kontakt")):
|
||||||
|
return "Legal + Web-Team"
|
||||||
|
if any(w in lab for w in ("dsfa", "dsr", "loeschfrist", "art. 15",
|
||||||
|
"auskunft", "betroffenenrecht")):
|
||||||
|
return "DSB"
|
||||||
|
if any(w in lab for w in ("tom", "verschluesselung", "backup",
|
||||||
|
"incident", "logging")):
|
||||||
|
return "IT-Security + DSB"
|
||||||
|
if area in ("banner", "cookie"):
|
||||||
|
return "DSB + Marketing"
|
||||||
|
return "DSB"
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_top_findings(
|
||||||
|
banner_result: dict | None,
|
||||||
|
scorecard: dict | None,
|
||||||
|
library_mismatch_findings: list[dict] | None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
out: list[dict] = []
|
||||||
|
|
||||||
|
# 1) Banner deep-check findings (HIGH zuerst)
|
||||||
|
if banner_result:
|
||||||
|
for ph in (banner_result.get("phases") or {}).values():
|
||||||
|
if not isinstance(ph, dict):
|
||||||
|
continue
|
||||||
|
for f in (ph.get("findings") or []):
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
n = _normalize_finding({**f, "area": "banner"})
|
||||||
|
if n:
|
||||||
|
out.append(n)
|
||||||
|
|
||||||
|
# 2) Library-Mismatch HIGH (Marketing-Cookies als essential deklariert)
|
||||||
|
for mm in (library_mismatch_findings or []):
|
||||||
|
if isinstance(mm, dict) and mm.get("severity") == "HIGH":
|
||||||
|
out.append({
|
||||||
|
"severity": "HIGH",
|
||||||
|
"label": f'Cookie "{mm.get("cookie","?")}" als '
|
||||||
|
f'{mm.get("declared_category","?")} deklariert, '
|
||||||
|
f'tatsaechlicher Zweck typischerweise '
|
||||||
|
f'{mm.get("library_category","?")}',
|
||||||
|
"area": _AREA_LABEL["library_mismatch"],
|
||||||
|
"owner": "DSB + Marketing/CMP-Admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3) Scorecard FAILs (MC-Audit)
|
||||||
|
if scorecard:
|
||||||
|
for entry in (scorecard.get("failed") or scorecard.get("items") or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
n = _normalize_finding(entry)
|
||||||
|
if n and n["severity"] == "HIGH":
|
||||||
|
out.append(n)
|
||||||
|
|
||||||
|
# Sort: HIGH first, then MEDIUM, stable order. Dedup by label.
|
||||||
|
seen: set[str] = set()
|
||||||
|
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
||||||
|
out.sort(key=lambda f: order.get(f["severity"], 3))
|
||||||
|
dedup: list[dict] = []
|
||||||
|
for f in out:
|
||||||
|
key = f["label"].lower()[:80]
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
dedup.append(f)
|
||||||
|
if len(dedup) >= limit:
|
||||||
|
break
|
||||||
|
return dedup
|
||||||
|
|
||||||
|
|
||||||
|
def _score_color(score: float | int | None) -> str:
|
||||||
|
if score is None:
|
||||||
|
return "#64748b"
|
||||||
|
try:
|
||||||
|
s = float(score)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "#64748b"
|
||||||
|
if s >= 80:
|
||||||
|
return "#16a34a"
|
||||||
|
if s >= 60:
|
||||||
|
return "#ca8a04"
|
||||||
|
return "#dc2626"
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_html(curr: float | None, prev: float | None) -> str:
|
||||||
|
if curr is None or prev is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
d = float(curr) - float(prev)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
if abs(d) < 0.5:
|
||||||
|
return (
|
||||||
|
' <span style="color:#64748b;font-size:11px">'
|
||||||
|
'(unveraendert ggue. letztem Lauf)</span>'
|
||||||
|
)
|
||||||
|
arrow = "↑" if d > 0 else "↓"
|
||||||
|
color = "#16a34a" if d > 0 else "#dc2626"
|
||||||
|
return (
|
||||||
|
f' <span style="color:{color};font-size:11px">'
|
||||||
|
f'{arrow} {abs(d):.1f} Punkte ggue. letztem Lauf</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_gf_one_pager_html(
|
||||||
|
site_name: str,
|
||||||
|
scorecard: dict | None = None,
|
||||||
|
previous_scorecard: dict | None = None,
|
||||||
|
banner_result: dict | None = None,
|
||||||
|
library_mismatch_findings: list[dict] | None = None,
|
||||||
|
scan_context: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""5-7-Bullet-Zusammenfassung. Leere Top-Findings: nur Status-Bullet."""
|
||||||
|
score = None
|
||||||
|
if scorecard:
|
||||||
|
score = scorecard.get("compliance_score") or scorecard.get("score")
|
||||||
|
prev_score = None
|
||||||
|
if previous_scorecard:
|
||||||
|
prev_score = (previous_scorecard.get("compliance_score")
|
||||||
|
or previous_scorecard.get("score"))
|
||||||
|
|
||||||
|
top = _collect_top_findings(
|
||||||
|
banner_result=banner_result,
|
||||||
|
scorecard=scorecard,
|
||||||
|
library_mismatch_findings=library_mismatch_findings,
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
n_high = sum(1 for f in top if f["severity"] == "HIGH")
|
||||||
|
n_med = sum(1 for f in top if f["severity"] == "MEDIUM")
|
||||||
|
|
||||||
|
if score is not None:
|
||||||
|
score_str = f'{float(score):.0f}/100'
|
||||||
|
else:
|
||||||
|
score_str = "—"
|
||||||
|
score_color = _score_color(score)
|
||||||
|
|
||||||
|
ctx_line = ""
|
||||||
|
if scan_context:
|
||||||
|
bits: list[str] = []
|
||||||
|
if scan_context.get("industry"):
|
||||||
|
bits.append(scan_context["industry"])
|
||||||
|
if scan_context.get("business_model"):
|
||||||
|
bits.append(scan_context["business_model"].upper())
|
||||||
|
if scan_context.get("employee_count"):
|
||||||
|
bits.append(f'{scan_context["employee_count"]} MA')
|
||||||
|
if bits:
|
||||||
|
ctx_line = (
|
||||||
|
'<div style="font-size:11px;color:#64748b;margin-bottom:6px">'
|
||||||
|
f'Klassifizierung: {" · ".join(bits)}'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
bullets: list[str] = []
|
||||||
|
sev_pill = {
|
||||||
|
"HIGH": '<span style="background:#fee2e2;color:#991b1b;'
|
||||||
|
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||||
|
'font-weight:600">HOCH</span>',
|
||||||
|
"MEDIUM": '<span style="background:#fef3c7;color:#92400e;'
|
||||||
|
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||||
|
'font-weight:600">MITTEL</span>',
|
||||||
|
"LOW": '<span style="background:#dbeafe;color:#1e40af;'
|
||||||
|
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||||
|
'font-weight:600">NIEDRIG</span>',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
from compliance.services.finding_confidence import confidence_pill_html
|
||||||
|
except Exception:
|
||||||
|
def confidence_pill_html(_label: str) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for f in top:
|
||||||
|
bullets.append(
|
||||||
|
f'<li style="margin-bottom:4px;font-size:12px;line-height:1.45">'
|
||||||
|
f'{sev_pill.get(f["severity"], "")} <strong>{f["area"]}:</strong> '
|
||||||
|
f'{f["label"]}'
|
||||||
|
f'{confidence_pill_html(f["label"])} '
|
||||||
|
f'<span style="color:#64748b">— typisch zustaendig: '
|
||||||
|
f'{f["owner"]}</span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||||
|
'max-width:760px;margin:0 auto 16px;padding:18px 20px;'
|
||||||
|
'background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px">'
|
||||||
|
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
|
||||||
|
'letter-spacing:1.4px;margin-bottom:4px;font-weight:600">'
|
||||||
|
f'Kurzfassung fuer die Geschaeftsfuehrung — {site_name or "—"}'
|
||||||
|
'</div>'
|
||||||
|
+ ctx_line +
|
||||||
|
'<div style="display:flex;align-items:baseline;gap:14px;'
|
||||||
|
'margin:8px 0 14px;flex-wrap:wrap">'
|
||||||
|
f'<div style="font-size:28px;font-weight:700;color:{score_color}">'
|
||||||
|
f'{score_str}</div>'
|
||||||
|
'<div style="font-size:11px;color:#64748b">'
|
||||||
|
f'Compliance-Score{_delta_html(score, prev_score)}</div>'
|
||||||
|
f'<div style="margin-left:auto;font-size:11px;color:#475569">'
|
||||||
|
f'<strong>{n_high}</strong> hoch · '
|
||||||
|
f'<strong>{n_med}</strong> mittel'
|
||||||
|
'</div></div>'
|
||||||
|
'<div style="font-size:11px;color:#475569;margin-bottom:6px;'
|
||||||
|
'font-weight:600;text-transform:uppercase;letter-spacing:1px">'
|
||||||
|
'Was kurzfristig angegangen werden sollte'
|
||||||
|
'</div>'
|
||||||
|
'<ul style="margin:0 0 12px 18px;padding:0">'
|
||||||
|
+ "".join(bullets) +
|
||||||
|
'</ul>'
|
||||||
|
'<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>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user