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>
277 lines
12 KiB
Python
277 lines
12 KiB
Python
"""
|
|
Gemeinsamer Report-Renderer.
|
|
|
|
EINE Quelle der Wahrheit für den Dateibericht (report.md) UND den E-Mail-Body.
|
|
So können Report und Alarm-Mail nicht mehr auseinanderlaufen. Alle Kommandos
|
|
enthalten das Target (direkt ausführbar); der angezeigte Score ist der bereits
|
|
aggregierte Gesamt-Score (Inhalt + KI + Zusatzprüfungen).
|
|
"""
|
|
from .plain import ampel, klartext_befunde, scanner_cmd, was_tun
|
|
|
|
|
|
def render_markdown(r: dict) -> str:
|
|
a = r.get("assessment", {})
|
|
d = r.get("diff", {})
|
|
target = r.get("target", "")
|
|
lvl = a.get("level", "green")
|
|
level = lvl.upper()
|
|
score = a.get("score", 0)
|
|
level_badge = {"GREEN": "[OK]", "YELLOW": "[WARNUNG]", "RED": "[ALARM]"}.get(level, level)
|
|
symbol, headline = ampel(lvl)
|
|
ai = r.get("ai_result")
|
|
|
|
# ---- Klartext-Teil (für den Betreiber) ----
|
|
lines = [
|
|
f"# Prüfbericht für Ihre Website — {target or '?'}",
|
|
"",
|
|
f"**Erstellt:** {r.get('generated_at', '?')}",
|
|
"",
|
|
f"## {symbol} {headline}",
|
|
"",
|
|
"### Was bedeutet das?",
|
|
"",
|
|
]
|
|
for satz in klartext_befunde(d, r.get("cloak_diff"), r.get("asset_diff"),
|
|
r.get("ext_links"), ai):
|
|
lines.append(f"- {satz}")
|
|
lines += ["", "### Was sollten Sie tun?", ""]
|
|
for schritt in was_tun(lvl, target):
|
|
lines.append(f"- {schritt}")
|
|
|
|
# ---- Technischer Teil (für den Dienstleister) ----
|
|
ai_funde = len(ai.get("findings", [])) if ai else 0
|
|
ai_ungeprueft = len(ai.get("unchecked", [])) if ai else 0
|
|
lines += [
|
|
"",
|
|
"---",
|
|
"",
|
|
"# Technische Details (für Ihren Dienstleister)",
|
|
"",
|
|
f"**Level:** {level_badge} ",
|
|
f"**Score:** {score}",
|
|
"",
|
|
"## Zusammenfassung",
|
|
"",
|
|
"| Kennzahl | Wert |",
|
|
"|---|---|",
|
|
f"| Geprüfte Seiten | {d.get('total_pages_checked', '?')} |",
|
|
f"| Geänderte Seiten | {d.get('changed_pages', 0)} |",
|
|
f"| Neue interne URLs | {len(d.get('new_internal_urls', []))} |",
|
|
f"| Fehlende URLs | {len(d.get('missing_internal_urls', []))} |",
|
|
f"| Neue externe Domains | {len(d.get('new_external_domains', []))} |",
|
|
f"| Kaputte Links (neu) | {len(d.get('new_broken_urls', []))} |",
|
|
f"| Kaputte Links (bekannt) | {len(d.get('known_broken_urls', []))} |",
|
|
f"| Unerwartetes JSON-LD | {len(d.get('unexpected_jsonld', []))} |",
|
|
f"| Verdächtige Dateinamen | {len(d.get('suspicious_filenames', []))} |",
|
|
f"| KI-Funde (Text/Bild) | {ai_funde} |",
|
|
f"| KI nicht geprüft | {ai_ungeprueft} |",
|
|
f"| Whitelist-Verstösse | {len(r.get('whitelist_violations', []))} |",
|
|
f"| Crawl-Fehler | {len(r.get('crawl_errors', []))} |",
|
|
"",
|
|
"## Risikobewertung",
|
|
"",
|
|
]
|
|
for reason in a.get("reasons", []):
|
|
lines.append(f"- {reason}")
|
|
if not a.get("reasons"):
|
|
lines.append("- Keine Auffälligkeiten.")
|
|
|
|
if d.get("new_external_domains"):
|
|
lines += ["", "## Neue externe Domains", ""]
|
|
for dom in d["new_external_domains"]:
|
|
lines.append(f"- `{dom}`")
|
|
|
|
if d.get("new_internal_urls"):
|
|
lines += ["", "## Neue interne URLs", ""]
|
|
for url in d["new_internal_urls"]:
|
|
lines.append(f"- {url}")
|
|
|
|
if d.get("missing_internal_urls"):
|
|
lines += ["", "## Fehlende URLs (waren in Baseline)", ""]
|
|
for url in d["missing_internal_urls"]:
|
|
lines.append(f"- {url}")
|
|
|
|
if d.get("page_diffs"):
|
|
lines += ["", "## Geänderte Seiten", ""]
|
|
for pd in d["page_diffs"]:
|
|
lines += [f"### {pd['url']}", ""]
|
|
if pd.get("added_chars"):
|
|
lines.append(f"- Neuer Text: +{pd['added_chars']} Zeichen")
|
|
if pd.get("meta_diff"):
|
|
for field, change in pd["meta_diff"].items():
|
|
if isinstance(change, dict) and "old" in change:
|
|
lines.append(f"- {field}: `{change['old']}` → `{change['new']}`")
|
|
else:
|
|
lines.append(f"- {field}: {change}")
|
|
if pd.get("canonical_changed"):
|
|
lines.append(
|
|
f"- Canonical geändert: `{pd.get('old_canonical')}` → `{pd.get('new_canonical')}`"
|
|
)
|
|
if pd.get("new_hidden_content"):
|
|
for h in pd["new_hidden_content"]:
|
|
lines.append(f"- Hidden Content ({h.get('type')}): `{', '.join(h.get('indicators', []))}`")
|
|
if pd.get("new_meta_refresh"):
|
|
for mr in pd["new_meta_refresh"]:
|
|
lines.append(f"- Meta-Refresh → `{mr}`")
|
|
if pd.get("new_comment_links"):
|
|
for cl in pd["new_comment_links"]:
|
|
lines.append(f"- Kommentar-Link: `{cl}`")
|
|
if pd.get("new_inline_scripts"):
|
|
for s in pd["new_inline_scripts"]:
|
|
lines.append(f"- Inline-Script: `{', '.join(s.get('patterns', []))}`")
|
|
if pd.get("link_diff"):
|
|
for ltype, ld in pd["link_diff"].items():
|
|
for added in ld.get("added", []):
|
|
lines.append(f"- {ltype} hinzugefügt: `{added}`")
|
|
for removed in ld.get("removed", []):
|
|
lines.append(f"- {ltype} entfernt: `{removed}`")
|
|
if pd.get("text_diff"):
|
|
lines += ["", "```diff"]
|
|
lines += pd["text_diff"][:40]
|
|
if len(pd["text_diff"]) > 40:
|
|
lines.append(f"... ({len(pd['text_diff']) - 40} weitere Zeilen)")
|
|
lines.append("```")
|
|
lines.append("")
|
|
|
|
if d.get("new_pages_analysis"):
|
|
lines += ["", "## Neue Seiten (Erstanalyse)", ""]
|
|
for npa in d["new_pages_analysis"]:
|
|
problems = (
|
|
npa.get("hidden_content")
|
|
or npa.get("inline_scripts")
|
|
or npa.get("meta_refresh")
|
|
or npa.get("unexpected_jsonld")
|
|
or npa.get("comment_links")
|
|
)
|
|
flag = " [VERDAECHTIG]" if problems else ""
|
|
lines.append(f"- {npa['url']}{flag}")
|
|
|
|
new_broken = d.get("new_broken_urls", [])
|
|
known_broken = d.get("known_broken_urls", [])
|
|
if new_broken or known_broken:
|
|
lines += ["", "## Kaputte Links", ""]
|
|
for entry in new_broken:
|
|
lines.append(f"- [NEU] HTTP {entry['status']} `{entry['url']}`")
|
|
for page in entry.get("pages", [])[:3]:
|
|
lines.append(f" - gefunden auf: {page}")
|
|
for entry in known_broken:
|
|
lines.append(f"- [bekannt, noch nicht behoben] HTTP {entry['status']} `{entry['url']}`")
|
|
for page in entry.get("pages", [])[:3]:
|
|
lines.append(f" - gefunden auf: {page}")
|
|
|
|
if d.get("unexpected_jsonld"):
|
|
lines += ["", "## Unerwartetes JSON-LD", ""]
|
|
for entry in d["unexpected_jsonld"]:
|
|
lines.append(f"- `{entry['url']}`: @type={entry['type']}")
|
|
|
|
if d.get("suspicious_filenames"):
|
|
lines += ["", "## Verdächtige Dateinamen", ""]
|
|
for entry in d["suspicious_filenames"]:
|
|
lines.append(f"- `{entry['page']}` → `{entry['url']}` ({entry['link_type']})")
|
|
|
|
if r.get("whitelist_violations"):
|
|
lines += ["", "## Whitelist-Verstösse", ""]
|
|
for v in r["whitelist_violations"][:50]:
|
|
lines.append(f"- [{v.get('type')}] {v.get('url', '')} (Domain: {v.get('domain', '')})")
|
|
|
|
if r.get("missing_security_headers"):
|
|
lines += ["", "## Fehlende Security-Header", ""]
|
|
for url, hdrs in r["missing_security_headers"].items():
|
|
for h in hdrs:
|
|
lines.append(f"- `{url}`: {h}")
|
|
|
|
if r.get("crawl_errors"):
|
|
lines += ["", "## Crawl-Fehler", ""]
|
|
for e in r["crawl_errors"][:20]:
|
|
lines.append(f"- `{e.get('url', '?')}`: {e.get('error', '?')}")
|
|
|
|
# ---- Ergebnisse der wöchentlichen Zusatzprüfungen (falls gelaufen) ----
|
|
cd = r.get("cloak_diff")
|
|
if cd is not None:
|
|
lines += ["", "## Tarnungs-Prüfung (Cloaking)", ""]
|
|
if cd.get("findings"):
|
|
for f in cd["findings"]:
|
|
lines.append(f"- `{f['url']}`")
|
|
if f.get("extra_links"):
|
|
for link in f["extra_links"]:
|
|
lines.append(f" - Nur für Google sichtbarer Link: `{link}`")
|
|
if f.get("extra_text_chars", 0) > 50:
|
|
lines.append(f" - Google sieht +{f['extra_text_chars']} Zeichen mehr Text")
|
|
if f.get("vary_ua"):
|
|
lines.append(" - Server liefert je nach Besucher anderen Inhalt (Vary: User-Agent)")
|
|
else:
|
|
lines.append("- Keine Auffälligkeiten.")
|
|
|
|
ad = r.get("asset_diff")
|
|
if ad is not None:
|
|
lines += ["", "## Datei-Prüfung (Bilder/Skripte/Stylesheets)", ""]
|
|
if ad.get("changed"):
|
|
for entry in ad["changed"]:
|
|
size_info = f" ({entry['size_diff']:+d} Bytes)" if entry.get("size_diff") else ""
|
|
lines.append(f"- Geändert [{entry['type']}]: `{entry['url']}`{size_info}")
|
|
else:
|
|
lines.append("- Keine Datei-Änderungen.")
|
|
if ad.get("fetch_errors"):
|
|
for entry in ad["fetch_errors"]:
|
|
lines.append(f"- Nicht prüfbar: `{entry['url']}` ({entry['error']})")
|
|
|
|
el = r.get("ext_links")
|
|
if el is not None:
|
|
lines += ["", "## Externe Links (Erreichbarkeit)", ""]
|
|
lines.append(f"- Geprüft: {el.get('total', 0)}, kaputt: {el.get('broken', 0)}, "
|
|
f"nicht erreichbar: {el.get('errors', 0)}")
|
|
for entry in el.get("broken_links", []):
|
|
lines.append(f"- HTTP {entry['status']}: `{entry['url']}`")
|
|
for page in entry.get("pages", [])[:3]:
|
|
lines.append(f" - gefunden auf: {page}")
|
|
for entry in el.get("error_links", []):
|
|
lines.append(f"- Nicht erreichbar: `{entry['url']}` ({entry.get('error', '?')})")
|
|
for page in entry.get("pages", [])[:3]:
|
|
lines.append(f" - gefunden auf: {page}")
|
|
|
|
if ai is not None:
|
|
lines += ["", "## KI-Inhaltsanalyse", ""]
|
|
if ai.get("skipped"):
|
|
lines.append(f"- Übersprungen: {ai['skipped']}")
|
|
else:
|
|
lines.append(
|
|
f"- {ai.get('checked', 0)} Inhalt(e) geprüft, "
|
|
f"{ai.get('cache_hits', 0)} aus Cache, {ai.get('api_calls', 0)} API-Abfrage(n)."
|
|
)
|
|
if ai.get("unchecked"):
|
|
lines.append(f"- ⚠ {len(ai['unchecked'])} Inhalt(e) **NICHT geprüft** "
|
|
"(KI-Dienst nicht erreichbar) — „nicht geprüft“ ≠ „sauber“:")
|
|
for u in ai["unchecked"][:20]:
|
|
ziel = u.get("asset_url") or u.get("url", "?")
|
|
lines.append(f" - [{u.get('kind', '?')}] `{ziel}`")
|
|
for f in ai.get("findings", []):
|
|
asset = f.get("asset_url")
|
|
ziel = f"`{asset}` (auf {f['url']})" if asset else f"`{f['url']}`"
|
|
lines.append(
|
|
f"- [{f['kind']}] {ziel}: **{f['category']}** "
|
|
f"(Schwere {f['severity']}, Konfidenz {f['confidence']:.0%})"
|
|
)
|
|
if f.get("explanation"):
|
|
lines.append(f" - Begründung: {f['explanation']}")
|
|
if f.get("fingerprint"):
|
|
lines.append(" - Quittieren bei Fehlalarm: "
|
|
f"`{scanner_cmd(target, 'ai-dismiss --hash ' + f['fingerprint'])}`")
|
|
|
|
lines += ["", "---", "", "## Nächste Schritte", ""]
|
|
lev = a.get("level", "green")
|
|
if lev == "red":
|
|
lines += [
|
|
"1. **Sofort**: Website-Backend überprüfen, Dateien mit Backup vergleichen.",
|
|
"2. Dienstleister kontaktieren.",
|
|
f"3. Nach Bereinigung: `{scanner_cmd(target, 'approve --rebuild')}`",
|
|
]
|
|
elif lev == "yellow":
|
|
lines += [
|
|
"1. Alle markierten URLs manuell prüfen.",
|
|
f"2. Legitime Änderungen freigeben: `{scanner_cmd(target, 'approve --url <URL>')}`",
|
|
f"3. Alle freigeben (wenn geprüft): `{scanner_cmd(target, 'approve --all')}`",
|
|
]
|
|
else:
|
|
lines += ["Keine Aktion erforderlich."]
|
|
|
|
return "\n".join(lines) + "\n"
|