fix: Report und Alarm-Mail konsistent (gemeinsamer Renderer)

Behebt vier vom Nutzer gemeldete Inkonsistenzen zwischen Report und E-Mail:

1. E-Mail ⊊ Report: Es gab zwei divergierende Renderer (alerter._format_body
   und __main__._render_markdown). Jetzt EIN gemeinsamer Renderer
   (scanner/report.py:render_markdown), den beide nutzen → identischer Inhalt,
   keine Divergenz mehr möglich.

2. KI im Report unsichtbar: Der Report rief klartext_befunde OHNE ai_result auf
   (KI-Satz fehlte im "Was bedeutet das?") und die Zusammenfassungs-Tabelle
   hatte keine KI-Zeile. Jetzt: KI-Satz im Klartext + Zeilen "KI-Funde" und
   "KI nicht geprüft" in der Tabelle.

3. Fehlende URL in Kommandos: approve/report/ai-dismiss standen ohne Target da
   (nicht ausführbar). Neuer Helfer plain.scanner_cmd(target, rest) injiziert
   das Target überall → "python -m scanner https://site approve --all".

4. "Level: YELLOW" aber "Score: 0": Der angezeigte Score war nur der Inhalts-
   Diff-Score; KI-/Cloak-/Asset-Scores flossen ins Level, aber nicht in den
   Score. cmd_scan/cmd_check aggregieren jetzt alle Teil-Scores → Score und
   Level passen zusammen.

Tests: 254 grün (+4: Body==Report, Kommandos-mit-Target, Score-Anzeige,
Level-Badge). alerter._format_body delegiert nur noch an render_markdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 03:35:05 +02:00
commit 3ab313e733
5 changed files with 327 additions and 398 deletions

View file

@ -6,12 +6,12 @@ Alerts are only sent when the assessment level meets or exceeds min_level.
import logging
import os
import smtplib
from datetime import datetime, timezone
from email.message import EmailMessage
import requests
from .plain import ampel, klartext_befunde, was_tun
from .plain import ampel
from .report import render_markdown
logger = logging.getLogger(__name__)
@ -56,120 +56,9 @@ def _make_subject(report: dict) -> str:
def _format_body(report: dict) -> str:
assessment = report.get("assessment", {})
level = assessment.get("level", "green")
diff = report.get("diff", {})
symbol, headline = ampel(level)
# ---- Klartext-Teil (für den Betreiber) ----
lines = [
f"Prüfbericht für Ihre Website: {report.get('target', '?')}",
f"Zeitpunkt: {datetime.now(timezone.utc).strftime('%d.%m.%Y %H:%M Uhr')} (UTC)",
"",
f"{symbol} {headline}",
"",
"Was bedeutet das?",
]
for satz in klartext_befunde(
diff, report.get("cloak_diff"), report.get("asset_diff"),
report.get("ext_links"), report.get("ai_result"),
):
lines.append(f"{satz}")
lines += ["", "Was sollten Sie tun?"]
for schritt in was_tun(level):
lines.append(f"{schritt}")
# ---- Technischer Teil (für den Dienstleister) ----
lines += [
"",
"=" * 60,
"Technische Details (für Ihren Dienstleister)",
"=" * 60,
f"Level: {level.upper()} Score: {assessment.get('score', '?')}",
"",
"--- Gruende ---",
]
for reason in assessment.get("reasons", []):
lines.append(f" * {reason}")
_section(lines, "Neue externe Domains", diff.get("new_external_domains", []), "+")
_section(lines, "Neue interne URLs", diff.get("new_internal_urls", []), "+")
_section(lines, "Fehlende URLs", diff.get("missing_internal_urls", []), "-")
for pd in diff.get("page_diffs", [])[:10]:
lines += ["", f"--- Aenderung: {pd['url']} ---"]
if pd.get("text_diff"):
lines.append(f" Text-Diff: {pd['added_chars']} Zeichen zugefügt")
lines += [f" {l}" for l in pd["text_diff"][:30]]
if pd.get("new_hidden_content"):
lines.append(f" Hidden Content: {len(pd['new_hidden_content'])} neue(r) Fund(e)")
if pd.get("new_meta_refresh"):
lines.append(f" Meta-Refresh: {pd['new_meta_refresh']}")
if pd.get("canonical_changed"):
lines.append(f" Canonical geaendert: {pd.get('old_canonical')} -> {pd.get('new_canonical')}")
if pd.get("new_comment_links"):
lines.append(f" Kommentar-Links: {pd['new_comment_links']}")
# ---- KI-Inhaltsanalyse: jede beanstandete Datei mit URL + Begründung ----
ai = report.get("ai_result") or {}
if ai.get("findings"):
lines += ["", "--- KI-Inhaltsanalyse: beanstandete Inhalte ---"]
for f in ai["findings"]:
asset = f.get("asset_url")
ziel = f"{asset} (auf Seite {f['url']})" if asset else f["url"]
lines.append(f" [{f['kind']}] {ziel}")
lines.append(f" Befund: {f['category']} "
f"(Schwere {f['severity']}, Konfidenz {f.get('confidence', 0):.0%})")
if f.get("explanation"):
lines.append(f" Begründung: {f['explanation']}")
if f.get("fingerprint"):
lines.append(f" Quittieren: python -m scanner ai-dismiss --hash {f['fingerprint']}")
if ai.get("unchecked"):
lines += ["", "--- KI-Inhaltsanalyse: NICHT geprüfte Inhalte ---",
" Achtung: Der Prüfdienst war nicht erreichbar. „Nicht geprüft“ ≠ „sauber“ —"
" bitte beim nächsten Lauf erneut prüfen."]
for u in ai["unchecked"]:
ziel = u.get("asset_url") or u.get("url", "?")
lines.append(f" [{u.get('kind', '?')}] {ziel}")
lines += [
"",
"--- Empfehlung ---",
]
level_lower = assessment.get("level", "green")
if level_lower == "red":
lines += [
" SOFORT HANDELN: Website moeglicherweise kompromittiert.",
" Zugriff auf Hosting-Backend prüfen, Dateien vergleichen.",
]
elif level_lower == "yellow":
lines += [
" PRÜFEN: Unerwartete Aenderungen gefunden.",
" Alle markierten URLs manuell kontrollieren.",
" Falls legitim: python -m scanner approve --all",
]
else:
lines.append(" OK — nur zur Information.")
lines += [
"",
"--- Workflow ---",
" Details: python -m scanner report",
" Freigabe: python -m scanner approve --url <URL>",
" Rebuild: python -m scanner approve --rebuild",
"=" * 60,
]
return "\n".join(lines)
def _section(lines: list, title: str, items: list, prefix: str) -> None:
if not items:
return
lines += ["", f"--- {title} ---"]
for item in items[:50]:
lines.append(f" {prefix} {item}")
if len(items) > 50:
lines.append(f" ... und {len(items) - 50} weitere")
"""E-Mail-Body = derselbe Renderer wie der Dateibericht (eine Quelle der
Wahrheit). So enthalten Report und Mail garantiert denselben Inhalt."""
return render_markdown(report)
# ---------------------------------------------------------------------------