feat: Laientauglichkeit — Auto-Modus, Klartext-Ausgabe, einfachere Doku
Ziel: ein einziger täglicher Befehl genügt, jede Meldung ist auch ohne IT-Wissen verständlich. Auto-Modus (ein Cron-Job genügt für alles): - data/state.json merkt sich, wann die Wochen-Prüfungen zuletzt liefen (baseline.py: load_state/save_state/is_check_due/mark_check_run) - `scan` führt fällige Zusatzprüfungen (Tarnung/externe Links/Dateien) automatisch mit aus; Gesamt-Ampel = schlechtestes Teilergebnis; EIN kombinierter Report + EIN Alert - neuer Config-Abschnitt periodic_checks (Default: wöchentlich, an) Klartext (scanner/plain.py): - Ampel 🟢/🟡/🔴 + Sätze ohne Fachbegriffe, aus den strukturierten Befunden - Report: Klartext oben, "Technische Details (für Ihren Dienstleister)" unten - Terminal-Ausgabe und E-Mail (alerter.py) ebenso umgestellt Komfort: - approve --all/--rebuild legt die Datei-Überwachung automatisch mit an (Opt-out: --skip-assets); approve-assets bleibt für den Sonderfall - status zeigt Ampel zuoberst + wann die Wochen-Prüfungen zuletzt liefen - Onboarding-Text in einfacher Sprache, wenn noch kein Vergleichsstand existiert Doku: README + BEDIENUNGSANLEITUNG mit "In 3 Schritten"-Einstieg. Tests: test_state.py, test_plain.py (Fachbegriff-Assertion), Auto-Asset- Baseline-Test. 165 Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
54ff1a6060
commit
bed20db8bd
11 changed files with 856 additions and 202 deletions
|
|
@ -42,6 +42,19 @@ from .differ import (
|
|||
score_diff,
|
||||
)
|
||||
from .extractor import extract_page
|
||||
from .plain import ampel, ampel_zeile, klartext_befunde, was_tun
|
||||
|
||||
# Reihenfolge der Risiko-Level, um das schlechteste zu bestimmen
|
||||
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}
|
||||
|
||||
|
||||
def _worst_level(levels: list[str]) -> str:
|
||||
"""Gibt das schwerwiegendste Level aus einer Liste zurück (green<yellow<red)."""
|
||||
worst = "green"
|
||||
for lvl in levels:
|
||||
if _LEVEL_ORDER.get(lvl, 0) > _LEVEL_ORDER.get(worst, 0):
|
||||
worst = lvl
|
||||
return worst
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -109,8 +122,11 @@ def _build_report(
|
|||
missing_sec_headers: dict,
|
||||
crawl_errors: list[dict],
|
||||
snap_dir: Path,
|
||||
cloak_diff: dict | None = None,
|
||||
asset_diff: dict | None = None,
|
||||
ext_links: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"target": target,
|
||||
"snapshot_dir": str(snap_dir),
|
||||
|
|
@ -120,6 +136,14 @@ def _build_report(
|
|||
"missing_security_headers": missing_sec_headers,
|
||||
"crawl_errors": crawl_errors,
|
||||
}
|
||||
# Optionale Ergebnisse der wöchentlichen Zusatzprüfungen (nur wenn gelaufen)
|
||||
if cloak_diff is not None:
|
||||
report["cloak_diff"] = cloak_diff
|
||||
if asset_diff is not None:
|
||||
report["asset_diff"] = asset_diff
|
||||
if ext_links is not None:
|
||||
report["ext_links"] = ext_links
|
||||
return report
|
||||
|
||||
|
||||
def _write_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
|
||||
|
|
@ -140,14 +164,38 @@ def _write_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
|
|||
def _render_markdown(r: dict) -> str:
|
||||
a = r.get("assessment", {})
|
||||
d = r.get("diff", {})
|
||||
level = a.get("level", "?").upper()
|
||||
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)
|
||||
|
||||
# ---- Klartext-Teil (für den Betreiber) ----
|
||||
lines = [
|
||||
f"# Integrity Report — {r.get('target', '?')}",
|
||||
f"# Prüfbericht für Ihre Website — {r.get('target', '?')}",
|
||||
"",
|
||||
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")
|
||||
):
|
||||
lines.append(f"- {satz}")
|
||||
lines += ["", "### Was sollten Sie tun?", ""]
|
||||
for schritt in was_tun(lvl):
|
||||
lines.append(f"- {schritt}")
|
||||
|
||||
# ---- Technischer Teil (für den Dienstleister) ----
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"# Technische Details (für Ihren Dienstleister)",
|
||||
"",
|
||||
f"**Erstellt:** {r.get('generated_at', '?')} ",
|
||||
f"**Level:** {level_badge} ",
|
||||
f"**Score:** {score}",
|
||||
"",
|
||||
|
|
@ -280,6 +328,44 @@ def _render_markdown(r: dict) -> str:
|
|||
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']}`")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
|
|
@ -679,32 +765,12 @@ def cmd_crawl(args: argparse.Namespace, cfg: dict) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
|
||||
logger = logging.getLogger("scanner.check")
|
||||
bm = BaselineManager(cfg["data_dir"])
|
||||
|
||||
if not bm.baseline_exists():
|
||||
print(
|
||||
"FEHLER: Keine Baseline vorhanden. Zuerst `python -m scanner init` ausführen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
snap = bm.load_snapshot()
|
||||
if not snap:
|
||||
print(
|
||||
"FEHLER: Kein Snapshot vorhanden. Zuerst `python -m scanner crawl` ausführen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
baseline = bm.load_baseline()
|
||||
logger.info(
|
||||
"Vergleiche %d Baseline-Seiten mit %d Snapshot-Seiten ...",
|
||||
len(baseline["pages"]),
|
||||
len(snap["pages"]),
|
||||
)
|
||||
|
||||
def _run_content_check(cfg: dict, snap: dict, baseline: dict) -> tuple[dict, dict, list, dict]:
|
||||
"""
|
||||
Kern der Inhaltsprüfung: vergleicht Snapshot mit Baseline, prüft Whitelist,
|
||||
Security-Header und verdächtige Dateinamen. Schreibt/alarmiert NICHT.
|
||||
Returns (diff, assessment, whitelist_violations, missing_security_headers).
|
||||
"""
|
||||
diff = compare_snapshots(baseline["pages"], snap["pages"], cfg)
|
||||
|
||||
# Whitelist / header / webshell checks — only on successfully fetched HTML
|
||||
|
|
@ -728,7 +794,6 @@ def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
|
|||
if missing:
|
||||
missing_sec[page["url"]] = missing
|
||||
|
||||
# Webshell / backdoor filename in any referenced link
|
||||
for ltype, links in page.get("links", {}).items():
|
||||
if not isinstance(links, list):
|
||||
continue
|
||||
|
|
@ -742,7 +807,6 @@ def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
|
|||
|
||||
diff["suspicious_filenames"] = suspicious_filenames
|
||||
|
||||
# Score with whitelist-violation contribution
|
||||
for v in wl_violations:
|
||||
if v.get("type") == "unlisted_external_domain":
|
||||
existing = diff.get("new_external_domains", [])
|
||||
|
|
@ -752,39 +816,167 @@ def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
|
|||
diff["new_external_domains"] = existing
|
||||
|
||||
assessment = score_diff(diff, cfg)
|
||||
return diff, assessment, wl_violations, missing_sec
|
||||
|
||||
crawl_errors = snap.get("errors", [])
|
||||
|
||||
def _run_cloak_core(cfg: dict) -> tuple[dict, dict]:
|
||||
"""Doppel-Crawl Browser-UA vs. Googlebot-UA. Returns (cloak_diff, assessment)."""
|
||||
pages_normal, _ = _crawl_and_extract(cfg)
|
||||
bot_cfg = {**cfg, "user_agent": GOOGLEBOT_UA}
|
||||
pages_bot, _ = _crawl_and_extract(bot_cfg)
|
||||
normal_map = {p["url"]: p for p in pages_normal}
|
||||
bot_map = {p["url"]: p for p in pages_bot}
|
||||
cloak_diff = compare_cloaking(normal_map, bot_map, cfg)
|
||||
return cloak_diff, score_cloak_diff(cloak_diff, cfg)
|
||||
|
||||
|
||||
def _run_ext_links_core(cfg: dict, snap: dict) -> dict:
|
||||
"""Prüft externe <a>-Links auf Erreichbarkeit. Returns ext_links-Report-Dict."""
|
||||
ext_urls: dict[str, list[str]] = {}
|
||||
for page in snap["pages"].values():
|
||||
if page.get("status") != 200:
|
||||
continue
|
||||
for link in page.get("links", {}).get("a", []):
|
||||
if isinstance(link, dict) and link.get("class") == "external":
|
||||
url = link.get("url", "")
|
||||
if url:
|
||||
ext_urls.setdefault(url, []).append(page["url"])
|
||||
|
||||
if not ext_urls:
|
||||
return {"total": 0, "ok": 0, "broken": 0, "errors": 0,
|
||||
"broken_links": [], "error_links": []}
|
||||
|
||||
results = check_external_links(list(ext_urls.keys()), timeout=cfg.get("request_timeout", 10))
|
||||
broken = {url: r for url, r in results.items() if r.get("status") and r["status"] >= 400}
|
||||
conn_errors = {url: r for url, r in results.items() if r.get("error")}
|
||||
return {
|
||||
"total": len(results),
|
||||
"ok": len(results) - len(broken) - len(conn_errors),
|
||||
"broken": len(broken),
|
||||
"errors": len(conn_errors),
|
||||
"broken_links": [
|
||||
{"url": url, "status": r["status"], "pages": ext_urls[url]}
|
||||
for url, r in sorted(broken.items())
|
||||
],
|
||||
"error_links": [
|
||||
{"url": url, "error": r["error"], "pages": ext_urls[url]}
|
||||
for url, r in sorted(conn_errors.items())
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _run_assets_core(cfg: dict, bm: BaselineManager, snap: dict) -> tuple[dict, dict, bool]:
|
||||
"""Hasht Assets und vergleicht mit Baseline. Returns (asset_diff, assessment, has_baseline)."""
|
||||
asset_urls = _collect_asset_urls(snap)
|
||||
if not asset_urls:
|
||||
empty = {"changed": [], "new": [], "missing": [], "fetch_errors": [], "total_checked": 0}
|
||||
return empty, score_asset_diff(empty, cfg), bool(bm.load_asset_hashes())
|
||||
|
||||
raw_hashes = fetch_asset_hashes(list(asset_urls.keys()), timeout=cfg.get("request_timeout", 15))
|
||||
current_hashes = {url: {**r, "type": asset_urls[url]} for url, r in raw_hashes.items()}
|
||||
baseline_hashes = bm.load_asset_hashes()
|
||||
asset_diff = compare_asset_hashes(baseline_hashes, current_hashes)
|
||||
return asset_diff, score_asset_diff(asset_diff, cfg), bool(baseline_hashes)
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
|
||||
logger = logging.getLogger("scanner.check")
|
||||
bm = BaselineManager(cfg["data_dir"])
|
||||
|
||||
if not bm.baseline_exists():
|
||||
_print_kein_baseline_hinweis()
|
||||
return 1
|
||||
|
||||
snap = bm.load_snapshot()
|
||||
if not snap:
|
||||
print("Es gibt noch keinen aktuellen Stand zum Vergleichen.")
|
||||
print("Bitte zuerst ausführen: python -m scanner scan")
|
||||
return 1
|
||||
|
||||
baseline = bm.load_baseline()
|
||||
diff, assessment, wl_violations, missing_sec = _run_content_check(cfg, snap, baseline)
|
||||
|
||||
report = _build_report(
|
||||
cfg["target"], diff, assessment, wl_violations, missing_sec,
|
||||
crawl_errors, snap["dir"],
|
||||
snap.get("errors", []), snap["dir"],
|
||||
)
|
||||
json_path, md_path = _write_report(report, cfg["reports_dir"])
|
||||
logger.info("Report: %s", md_path)
|
||||
|
||||
_write_report(report, cfg["reports_dir"])
|
||||
_print_summary(assessment, diff)
|
||||
|
||||
send_alert(report, cfg)
|
||||
|
||||
return assessment["exit_code"]
|
||||
|
||||
|
||||
def cmd_scan(args: argparse.Namespace, cfg: dict) -> int:
|
||||
"""crawl + check."""
|
||||
"""crawl + check. Führt fällige Wochen-Prüfungen automatisch mit aus."""
|
||||
logger = logging.getLogger("scanner.scan")
|
||||
print(f"Scanne {cfg['target']} ...")
|
||||
print(f"Prüfe Ihre Website {cfg['target']} ...")
|
||||
pages, errors = _crawl_and_extract(cfg)
|
||||
bm = BaselineManager(cfg["data_dir"])
|
||||
snap_dir = bm.save_snapshot(pages, errors)
|
||||
logger.info("Snapshot: %s (%d Seiten, %d Fehler)", snap_dir, len(pages), len(errors))
|
||||
snap = bm.load_snapshot(bm.save_snapshot(pages, errors))
|
||||
logger.info("Snapshot: %d Seiten, %d Fehler", len(pages), len(errors))
|
||||
|
||||
if not bm.baseline_exists():
|
||||
print()
|
||||
print("Noch keine Baseline vorhanden — Scan abgeschlossen, kein Vergleich.")
|
||||
print("Führen Sie aus: python -m scanner approve --all")
|
||||
_print_kein_baseline_hinweis()
|
||||
return 0
|
||||
|
||||
return cmd_check(args, cfg)
|
||||
baseline = bm.load_baseline()
|
||||
diff, assessment, wl_violations, missing_sec = _run_content_check(cfg, snap, baseline)
|
||||
levels = [assessment["level"]]
|
||||
|
||||
# --- Fällige Wochen-Prüfungen automatisch mitlaufen lassen ---
|
||||
cloak_diff = asset_diff = ext_links = None
|
||||
pc = cfg.get("periodic_checks", {})
|
||||
if pc.get("enabled", True):
|
||||
interval = pc.get("interval_days", 7)
|
||||
if pc.get("cloak_check", True) and bm.is_check_due("cloak_check", interval):
|
||||
print(" Wöchentliche Tarnungs-Prüfung läuft mit ...")
|
||||
cloak_diff, cloak_assess = _run_cloak_core(cfg)
|
||||
levels.append(cloak_assess["level"])
|
||||
bm.mark_check_run("cloak_check")
|
||||
if pc.get("assets", True) and bm.is_check_due("assets", interval):
|
||||
print(" Wöchentliche Datei-Prüfung läuft mit ...")
|
||||
asset_diff, asset_assess, _ = _run_assets_core(cfg, bm, snap)
|
||||
levels.append(asset_assess["level"])
|
||||
bm.mark_check_run("assets")
|
||||
if pc.get("ext_links", True) and bm.is_check_due("ext_links", interval):
|
||||
print(" Wöchentliche Prüfung externer Links läuft mit ...")
|
||||
ext_links = _run_ext_links_core(cfg, snap) # informativ, hebt Level nicht an
|
||||
bm.mark_check_run("ext_links")
|
||||
|
||||
# Gesamt-Level = schlechtestes aus Inhalt + Cloaking + Datei-Check
|
||||
overall = _worst_level(levels)
|
||||
assessment = {**assessment, "level": overall,
|
||||
"exit_code": _LEVEL_ORDER[overall]}
|
||||
|
||||
report = _build_report(
|
||||
cfg["target"], diff, assessment, wl_violations, missing_sec,
|
||||
snap.get("errors", []), snap["dir"],
|
||||
cloak_diff=cloak_diff, asset_diff=asset_diff, ext_links=ext_links,
|
||||
)
|
||||
_write_report(report, cfg["reports_dir"])
|
||||
_print_summary(assessment, diff, cloak_diff, asset_diff, ext_links)
|
||||
send_alert(report, cfg)
|
||||
return assessment["exit_code"]
|
||||
|
||||
|
||||
def _establish_asset_baseline(cfg: dict, bm: BaselineManager, snap_dir: Path | None) -> None:
|
||||
"""Hasht die Dateien des freigegebenen Stands und legt sie als Vergleich an."""
|
||||
snap = bm.load_snapshot(snap_dir)
|
||||
if not snap:
|
||||
return
|
||||
asset_urls = _collect_asset_urls(snap)
|
||||
if not asset_urls:
|
||||
return
|
||||
print(f"Richte Datei-Überwachung ein ({len(asset_urls)} Dateien werden geprüft) ...")
|
||||
raw_hashes = fetch_asset_hashes(list(asset_urls.keys()), timeout=cfg.get("request_timeout", 15))
|
||||
hashes_to_save = {
|
||||
url: {**r, "type": asset_urls[url]}
|
||||
for url, r in raw_hashes.items()
|
||||
if r.get("sha256")
|
||||
}
|
||||
bm.save_asset_hashes(hashes_to_save)
|
||||
print(f"Datei-Überwachung eingerichtet: {len(hashes_to_save)} Dateien.")
|
||||
|
||||
|
||||
def cmd_approve(args: argparse.Namespace, cfg: dict) -> int:
|
||||
|
|
@ -792,30 +984,35 @@ def cmd_approve(args: argparse.Namespace, cfg: dict) -> int:
|
|||
bm = BaselineManager(cfg["data_dir"])
|
||||
snap_dir = Path(args.snapshot) if getattr(args, "snapshot", None) else None
|
||||
note = getattr(args, "note", "") or ""
|
||||
skip_assets = getattr(args, "skip_assets", False)
|
||||
|
||||
if getattr(args, "rebuild", False):
|
||||
approved = bm.rebuild_baseline(snap_dir=snap_dir, note=note)
|
||||
print(f"Baseline komplett neu aufgebaut: {len(approved)} URLs freigegeben.")
|
||||
print(f"Ihre Website wurde als neuer Vergleichsstand gespeichert ({len(approved)} Seiten).")
|
||||
if not skip_assets:
|
||||
_establish_asset_baseline(cfg, bm, snap_dir)
|
||||
return 0
|
||||
|
||||
if getattr(args, "all", False):
|
||||
approved = bm.approve_all(snap_dir=snap_dir, note=note)
|
||||
print(f"Alle {len(approved)} URLs freigegeben.")
|
||||
for u in approved:
|
||||
print(f" + {u}")
|
||||
print(f"Ihre Website wurde als Vergleichsstand gespeichert ({len(approved)} Seiten).")
|
||||
print("Ab jetzt meldet das Programm jede spätere Veränderung.")
|
||||
if not skip_assets:
|
||||
_establish_asset_baseline(cfg, bm, snap_dir)
|
||||
return 0
|
||||
|
||||
url = getattr(args, "url", None)
|
||||
if url:
|
||||
ok = bm.approve_url(url, snap_dir=snap_dir, note=note)
|
||||
if ok:
|
||||
print(f"Freigegeben: {url}")
|
||||
print(f"Diese Seite wurde als in Ordnung bestätigt: {url}")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER: URL nicht im Snapshot gefunden: {url}", file=sys.stderr)
|
||||
print(f"Diese Adresse wurde im letzten Stand nicht gefunden: {url}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("FEHLER: --url, --all oder --rebuild angeben.", file=sys.stderr)
|
||||
print("Bitte geben Sie an, was freigegeben werden soll: --all, --url <Adresse> oder --rebuild.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
|
|
@ -848,37 +1045,52 @@ def cmd_report(args: argparse.Namespace, cfg: dict) -> int:
|
|||
def cmd_status(args: argparse.Namespace, cfg: dict) -> int:
|
||||
bm = BaselineManager(cfg["data_dir"])
|
||||
|
||||
print(f"Ziel: {cfg['target']}")
|
||||
# Ampel zuoberst: letzter bekannter Zustand
|
||||
rd = Path(cfg["reports_dir"])
|
||||
last_report = None
|
||||
if rd.exists():
|
||||
dirs = sorted(d for d in rd.iterdir() if d.is_dir())
|
||||
for d in reversed(dirs):
|
||||
rp = d / "report.json"
|
||||
if rp.exists():
|
||||
last_report = json.loads(rp.read_text(encoding="utf-8"))
|
||||
break
|
||||
if last_report:
|
||||
lvl = last_report.get("assessment", {}).get("level", "green")
|
||||
print(ampel_zeile(lvl))
|
||||
else:
|
||||
print("Noch keine Prüfung durchgeführt.")
|
||||
print()
|
||||
|
||||
print(f"Ihre Website: {cfg['target']}")
|
||||
|
||||
if bm.baseline_exists():
|
||||
manifest = json.loads(
|
||||
(bm.baseline_dir / "manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
print(f"Baseline: {manifest.get('approved_at', '?')} (von: {manifest.get('approved_by', '?')})")
|
||||
print(f" Seiten: {len(manifest.get('urls', []))}")
|
||||
print(f"Vergleichsstand: vom {manifest.get('approved_at', '?')[:10]} "
|
||||
f"({len(manifest.get('urls', []))} Seiten)")
|
||||
if manifest.get("note"):
|
||||
print(f" Notiz: {manifest['note']}")
|
||||
print(f" Notiz: {manifest['note']}")
|
||||
else:
|
||||
print("Baseline: KEINE — 'python -m scanner init' ausführen")
|
||||
print("Vergleichsstand: noch keiner — bitte einmal `approve --all` ausführen")
|
||||
|
||||
snap_dir = bm.latest_snapshot_dir()
|
||||
if snap_dir:
|
||||
snap_manifest = json.loads((snap_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
print(f"Letzter Scan: {snap_manifest.get('timestamp', '?')} ({snap_manifest.get('page_count', '?')} Seiten)")
|
||||
print(f"Letzte Prüfung: {snap_manifest.get('timestamp', '?')}")
|
||||
else:
|
||||
print("Letzter Scan: KEINER")
|
||||
print("Letzte Prüfung: noch keine")
|
||||
|
||||
rd = Path(cfg["reports_dir"])
|
||||
if rd.exists():
|
||||
dirs = sorted(d for d in rd.iterdir() if d.is_dir())
|
||||
if dirs:
|
||||
rp = dirs[-1] / "report.json"
|
||||
if rp.exists():
|
||||
r = json.loads(rp.read_text(encoding="utf-8"))
|
||||
a = r.get("assessment", {})
|
||||
print(f"Letzter Report: {dirs[-1].name} — Level: {a.get('level','?').upper()} (Score: {a.get('score','?')})")
|
||||
else:
|
||||
print(f"Letzter Report: {dirs[-1].name}")
|
||||
# Wann laufen die Wochen-Prüfungen das nächste Mal?
|
||||
state = bm.load_state().get("last_runs", {})
|
||||
if state:
|
||||
labels = {"cloak_check": "Tarnungs-Prüfung", "assets": "Datei-Prüfung",
|
||||
"ext_links": "Externe Links"}
|
||||
print("Wöchentliche Zusatzprüfungen — zuletzt gelaufen:")
|
||||
for key, label in labels.items():
|
||||
ts = state.get(key, "noch nie")
|
||||
print(f" {label}: {ts[:10] if ts != 'noch nie' else ts}")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -888,19 +1100,10 @@ def cmd_status(args: argparse.Namespace, cfg: dict) -> int:
|
|||
|
||||
def cmd_cloak_check(args: argparse.Namespace, cfg: dict) -> int:
|
||||
logger = logging.getLogger("scanner.cloak")
|
||||
print(f"Cloaking-Check: {cfg['target']}")
|
||||
print(" Schritt 1/2: Crawl mit Browser-UA ...")
|
||||
pages_normal, _ = _crawl_and_extract(cfg)
|
||||
|
||||
print(" Schritt 2/2: Crawl mit Googlebot-UA ...")
|
||||
bot_cfg = {**cfg, "user_agent": GOOGLEBOT_UA}
|
||||
pages_bot, _ = _crawl_and_extract(bot_cfg)
|
||||
|
||||
normal_map = {p["url"]: p for p in pages_normal}
|
||||
bot_map = {p["url"]: p for p in pages_bot}
|
||||
|
||||
cloak_diff = compare_cloaking(normal_map, bot_map, cfg)
|
||||
assessment = score_cloak_diff(cloak_diff, cfg)
|
||||
print(f"Tarnungs-Prüfung für {cfg['target']} (zwei Durchläufe) ...")
|
||||
cloak_diff, assessment = _run_cloak_core(cfg)
|
||||
bm = BaselineManager(cfg["data_dir"])
|
||||
bm.mark_check_run("cloak_check")
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
|
@ -926,60 +1129,26 @@ def cmd_check_ext_links(args: argparse.Namespace, cfg: dict) -> int:
|
|||
|
||||
snap = bm.load_snapshot()
|
||||
if not snap:
|
||||
print(
|
||||
"FEHLER: Kein Snapshot vorhanden. Zuerst `python -m scanner crawl` ausführen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Es gibt noch keinen aktuellen Stand. Bitte zuerst: python -m scanner scan")
|
||||
return 1
|
||||
|
||||
# Collect unique external <a> links → pages that link to them
|
||||
ext_urls: dict[str, list[str]] = {}
|
||||
for page in snap["pages"].values():
|
||||
if page.get("status") != 200:
|
||||
continue
|
||||
for link in page.get("links", {}).get("a", []):
|
||||
if isinstance(link, dict) and link.get("class") == "external":
|
||||
url = link.get("url", "")
|
||||
if url:
|
||||
ext_urls.setdefault(url, []).append(page["url"])
|
||||
print("Prüfe die externen Links Ihrer Website ...")
|
||||
result = _run_ext_links_core(cfg, snap)
|
||||
bm.mark_check_run("ext_links")
|
||||
|
||||
if not ext_urls:
|
||||
print("Keine externen Links im letzten Snapshot gefunden.")
|
||||
if result["total"] == 0:
|
||||
print("Ihre Website verweist nicht auf andere Websites.")
|
||||
return 0
|
||||
|
||||
print(f"Prüfe {len(ext_urls)} externe Links ...")
|
||||
results = check_external_links(
|
||||
list(ext_urls.keys()),
|
||||
timeout=cfg.get("request_timeout", 10),
|
||||
)
|
||||
|
||||
broken = {url: r for url, r in results.items() if r.get("status") and r["status"] >= 400}
|
||||
conn_errors = {url: r for url, r in results.items() if r.get("error")}
|
||||
ok_count = len(results) - len(broken) - len(conn_errors)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"target": cfg["target"],
|
||||
"type": "ext-links",
|
||||
"total": len(results),
|
||||
"ok": ok_count,
|
||||
"broken": len(broken),
|
||||
"errors": len(conn_errors),
|
||||
"broken_links": [
|
||||
{"url": url, "status": r["status"], "pages": ext_urls[url]}
|
||||
for url, r in sorted(broken.items())
|
||||
],
|
||||
"error_links": [
|
||||
{"url": url, "error": r["error"], "pages": ext_urls[url]}
|
||||
for url, r in sorted(conn_errors.items())
|
||||
],
|
||||
**result,
|
||||
}
|
||||
|
||||
json_path, md_path = _write_ext_links_report(report, cfg["reports_dir"])
|
||||
logger.info("Ext-Links-Report: %s", md_path)
|
||||
|
||||
_write_ext_links_report(report, cfg["reports_dir"])
|
||||
_print_ext_links_summary(report)
|
||||
return 0 if not broken and not conn_errors else 1
|
||||
return 0 if not result["broken"] and not result["errors"] else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -992,34 +1161,16 @@ def cmd_check_assets(args: argparse.Namespace, cfg: dict) -> int:
|
|||
|
||||
snap = bm.load_snapshot()
|
||||
if not snap:
|
||||
print(
|
||||
"FEHLER: Kein Snapshot vorhanden. Zuerst `python -m scanner crawl` ausführen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Es gibt noch keinen aktuellen Stand. Bitte zuerst: python -m scanner scan")
|
||||
return 1
|
||||
|
||||
asset_urls = _collect_asset_urls(snap)
|
||||
if not asset_urls:
|
||||
print("Keine Assets im letzten Snapshot gefunden.")
|
||||
if not _collect_asset_urls(snap):
|
||||
print("Ihre Website enthält keine prüfbaren Dateien (Bilder/Skripte/Stylesheets).")
|
||||
return 0
|
||||
|
||||
print(f"Hashe {len(asset_urls)} Assets (script: {sum(1 for t in asset_urls.values() if t=='script')}, "
|
||||
f"css: {sum(1 for t in asset_urls.values() if t=='link_rel')}, "
|
||||
f"img: {sum(1 for t in asset_urls.values() if t=='img')}) ...")
|
||||
|
||||
raw_hashes = fetch_asset_hashes(
|
||||
list(asset_urls.keys()),
|
||||
timeout=cfg.get("request_timeout", 15),
|
||||
)
|
||||
# Attach link type to each result for scoring
|
||||
current_hashes = {
|
||||
url: {**r, "type": asset_urls[url]}
|
||||
for url, r in raw_hashes.items()
|
||||
}
|
||||
|
||||
baseline_hashes = bm.load_asset_hashes()
|
||||
asset_diff = compare_asset_hashes(baseline_hashes, current_hashes)
|
||||
assessment = score_asset_diff(asset_diff, cfg)
|
||||
print("Prüfe die Dateien Ihrer Website (Bilder/Skripte/Stylesheets) ...")
|
||||
asset_diff, assessment, has_baseline = _run_assets_core(cfg, bm, snap)
|
||||
bm.mark_check_run("assets")
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
|
@ -1028,15 +1179,13 @@ def cmd_check_assets(args: argparse.Namespace, cfg: dict) -> int:
|
|||
"assessment": assessment,
|
||||
"asset_diff": asset_diff,
|
||||
}
|
||||
json_path, md_path = _write_asset_report(report, cfg["reports_dir"])
|
||||
logger.info("Asset-Report: %s", md_path)
|
||||
|
||||
_write_asset_report(report, cfg["reports_dir"])
|
||||
_print_asset_summary(assessment, asset_diff)
|
||||
|
||||
if not baseline_hashes:
|
||||
if not has_baseline:
|
||||
print()
|
||||
print("Noch keine Asset-Baseline vorhanden.")
|
||||
print("Führen Sie aus: python -m scanner approve-assets")
|
||||
print("Hinweis: Es war noch keine Datei-Vergleichsgrundlage vorhanden.")
|
||||
print("Diese wird automatisch angelegt, sobald Sie mit `approve --all` freigeben.")
|
||||
|
||||
return assessment["exit_code"]
|
||||
|
||||
|
|
@ -1080,19 +1229,35 @@ def cmd_approve_assets(args: argparse.Namespace, cfg: dict) -> int:
|
|||
# Output helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_summary(assessment: dict, diff: dict) -> None:
|
||||
level = assessment.get("level", "?").upper()
|
||||
score = assessment.get("score", 0)
|
||||
def _print_kein_baseline_hinweis() -> None:
|
||||
"""Klartext-Anleitung, wenn noch kein Vergleichsstand (Baseline) existiert."""
|
||||
print("Es gibt noch keinen gespeicherten Vergleichsstand Ihrer Website.")
|
||||
print("Wenn Ihre Website gerade in Ordnung ist, speichern Sie diesen Zustand einmalig als")
|
||||
print("Vergleichsgrundlage. Ab dann meldet das Programm jede spätere Veränderung.")
|
||||
print()
|
||||
print(f"=== Ergebnis: {level} (Score {score}) ===")
|
||||
for reason in assessment.get("reasons", []):
|
||||
print(f" * {reason}")
|
||||
if not assessment.get("reasons"):
|
||||
print(" Keine Auffälligkeiten.")
|
||||
print(f" Neue URLs: {len(diff.get('new_internal_urls', []))}")
|
||||
print(f" Fehlende URLs: {len(diff.get('missing_internal_urls', []))}")
|
||||
print(f" Neue externe Domains:{len(diff.get('new_external_domains', []))}")
|
||||
print(f" Geänderte Seiten: {diff.get('changed_pages', 0)}")
|
||||
print("So geht's: python -m scanner approve --all")
|
||||
|
||||
|
||||
def _print_summary(assessment: dict, diff: dict,
|
||||
cloak_diff: dict | None = None,
|
||||
asset_diff: dict | None = None,
|
||||
ext_links: dict | None = None) -> None:
|
||||
level = assessment.get("level", "green")
|
||||
print()
|
||||
print(ampel_zeile(level))
|
||||
print()
|
||||
for satz in klartext_befunde(diff, cloak_diff, asset_diff, ext_links):
|
||||
print(f" • {satz}")
|
||||
print()
|
||||
for schritt in was_tun(level):
|
||||
print(f" → {schritt}")
|
||||
# Knappe technische Kennzahlen darunter (für den Dienstleister)
|
||||
print()
|
||||
print(f" — Technische Details: Score {assessment.get('score', 0)}, "
|
||||
f"{diff.get('changed_pages', 0)} geänderte Seite(n), "
|
||||
f"{len(diff.get('new_internal_urls', []))} neue / "
|
||||
f"{len(diff.get('missing_internal_urls', []))} fehlende Seite(n). "
|
||||
f"Vollständiger Bericht: python -m scanner report")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1123,6 +1288,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
ap.add_argument("--rebuild", action="store_true", help="Baseline komplett ersetzen")
|
||||
ap.add_argument("--snapshot", help="Pfad zu einem bestimmten Snapshot-Verzeichnis")
|
||||
ap.add_argument("--note", default="", help="Freitext-Notiz für die Baseline-Versionierung")
|
||||
ap.add_argument("--skip-assets", action="store_true",
|
||||
help="Datei-Überwachung NICHT automatisch mit anlegen")
|
||||
|
||||
rp = sub.add_parser("report", help="Letzten Report anzeigen")
|
||||
rp.add_argument("--format", choices=["md", "json"], default="md")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from email.message import EmailMessage
|
|||
|
||||
import requests
|
||||
|
||||
from .plain import ampel, klartext_befunde, was_tun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}
|
||||
|
|
@ -43,35 +45,52 @@ def send_alert(report: dict, cfg: dict) -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_subject(report: dict) -> str:
|
||||
level = report.get("assessment", {}).get("level", "?").upper()
|
||||
level = report.get("assessment", {}).get("level", "green")
|
||||
target = report.get("target", "?")
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
return f"[{level}] Integrity Alert: {target} — {ts}"
|
||||
symbol, _ = ampel(level)
|
||||
if level == "red":
|
||||
return f"{symbol} Ihre Website {target} braucht Aufmerksamkeit"
|
||||
if level == "yellow":
|
||||
return f"{symbol} Bitte einmal auf Ihrer Website {target} nachschauen"
|
||||
return f"{symbol} Ihre Website {target}: alles in Ordnung"
|
||||
|
||||
|
||||
def _format_body(report: dict) -> str:
|
||||
lines = [
|
||||
"=" * 60,
|
||||
"WEBSITE INTEGRITY ALERT",
|
||||
"=" * 60,
|
||||
f"Ziel: {report.get('target', '?')}",
|
||||
f"Zeitpunkt: {datetime.now(timezone.utc).isoformat()}",
|
||||
]
|
||||
|
||||
assessment = report.get("assessment", {})
|
||||
level = assessment.get("level", "?").upper()
|
||||
score = assessment.get("score", "?")
|
||||
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")
|
||||
):
|
||||
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 += [
|
||||
f"Level: {level}",
|
||||
f"Score: {score}",
|
||||
"",
|
||||
"=" * 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}")
|
||||
|
||||
diff = report.get("diff", {})
|
||||
|
||||
_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", []), "-")
|
||||
|
|
|
|||
|
|
@ -233,6 +233,50 @@ class BaselineManager:
|
|||
)
|
||||
logger.info("Asset hashes saved: %d entries", len(hashes))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run-state (drives auto-scheduling of the weekly checks)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def _state_path(self) -> Path:
|
||||
return self.data_dir / "state.json"
|
||||
|
||||
def load_state(self) -> dict:
|
||||
"""Load the run-state file. Returns {'last_runs': {}} if none exists."""
|
||||
if not self._state_path.exists():
|
||||
return {"last_runs": {}}
|
||||
try:
|
||||
data = json.loads(self._state_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {"last_runs": {}}
|
||||
data.setdefault("last_runs", {})
|
||||
return data
|
||||
|
||||
def save_state(self, state: dict) -> None:
|
||||
"""Persist the run-state file."""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._state_path.write_text(
|
||||
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
def is_check_due(self, name: str, interval_days: int) -> bool:
|
||||
"""True if a periodic check has never run or last ran >= interval_days ago."""
|
||||
last = self.load_state().get("last_runs", {}).get(name)
|
||||
if not last:
|
||||
return True
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last)
|
||||
except ValueError:
|
||||
return True
|
||||
age = datetime.now(timezone.utc) - last_dt
|
||||
return age.total_seconds() >= interval_days * 86400
|
||||
|
||||
def mark_check_run(self, name: str) -> None:
|
||||
"""Record that a periodic check just ran (now, UTC)."""
|
||||
state = self.load_state()
|
||||
state["last_runs"][name] = datetime.now(timezone.utc).isoformat()
|
||||
self.save_state(state)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ DEFAULT_CONFIG: dict = {
|
|||
"red": 60,
|
||||
"large_text_block_chars": 200,
|
||||
},
|
||||
"periodic_checks": {
|
||||
"enabled": True,
|
||||
"interval_days": 7,
|
||||
"cloak_check": True,
|
||||
"ext_links": True,
|
||||
"assets": True,
|
||||
},
|
||||
"alerting": {
|
||||
"min_level": "yellow",
|
||||
"email": {
|
||||
|
|
|
|||
179
scanner/plain.py
Normal file
179
scanner/plain.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""
|
||||
Klartext-Schicht: übersetzt technische Befunde in Sätze, die auch ein
|
||||
Website-Betreiber ohne IT-Wissen versteht.
|
||||
|
||||
Alle Texte hier sind bewusst laienverständlich. Fachbegriffe (Score, Canonical,
|
||||
JSON-LD, Meta-Refresh, Hash) gehören NICHT in dieses Modul — die technischen
|
||||
Details stehen weiterhin im Detail-Abschnitt des Reports.
|
||||
"""
|
||||
|
||||
# Ampel-Überschrift je Risiko-Level
|
||||
_AMPEL = {
|
||||
"green": ("🟢", "Alles in Ordnung."),
|
||||
"yellow": ("🟡", "Bitte einmal nachschauen."),
|
||||
"red": ("🔴", "Achtung — bitte zeitnah prüfen oder Ihren Dienstleister informieren."),
|
||||
}
|
||||
|
||||
|
||||
def ampel(level: str) -> tuple[str, str]:
|
||||
"""Symbol und Überschrift für ein Risiko-Level (green/yellow/red)."""
|
||||
return _AMPEL.get(level, ("⚪", "Status unbekannt."))
|
||||
|
||||
|
||||
def ampel_zeile(level: str) -> str:
|
||||
"""Einzeilige Ampel-Darstellung, z. B. '🟡 Bitte einmal nachschauen.'"""
|
||||
symbol, satz = ampel(level)
|
||||
return f"{symbol} {satz}"
|
||||
|
||||
|
||||
def _zaehle_seiten_befunde(diff: dict) -> dict:
|
||||
"""Zählt die Befund-Kategorien über alle geänderten Seiten zusammen."""
|
||||
z = {
|
||||
"hidden": 0, "meta_refresh": 0, "canonical": 0,
|
||||
"comment_links": 0, "scripts": 0, "grosser_text": 0,
|
||||
}
|
||||
for pd in diff.get("page_diffs", []):
|
||||
if pd.get("new_hidden_content"):
|
||||
z["hidden"] += 1
|
||||
if pd.get("new_meta_refresh"):
|
||||
z["meta_refresh"] += 1
|
||||
if pd.get("canonical_changed"):
|
||||
z["canonical"] += 1
|
||||
if pd.get("new_comment_links"):
|
||||
z["comment_links"] += 1
|
||||
if pd.get("new_inline_scripts"):
|
||||
z["scripts"] += 1
|
||||
if pd.get("added_chars", 0) > 200:
|
||||
z["grosser_text"] += 1
|
||||
return z
|
||||
|
||||
|
||||
def klartext_befunde(
|
||||
diff: dict | None = None,
|
||||
cloak_diff: dict | None = None,
|
||||
asset_diff: dict | None = None,
|
||||
ext_links: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Erzeugt eine Liste laienverständlicher Sätze aus den strukturierten Befunden.
|
||||
Leere Befunde → eine beruhigende Meldung.
|
||||
"""
|
||||
diff = diff or {}
|
||||
saetze: list[str] = []
|
||||
|
||||
# --- Eindeutige Angriffszeichen zuerst ---
|
||||
if diff.get("suspicious_filenames"):
|
||||
saetze.append(
|
||||
"Es wurde auf eine Datei mit verdächtigem Namen verlinkt, wie sie Angreifer "
|
||||
"für Schadprogramme verwenden. Das ist ein ernstes Warnzeichen."
|
||||
)
|
||||
|
||||
if cloak_diff:
|
||||
bot_links = sum(len(f.get("extra_links", [])) for f in cloak_diff.get("findings", []))
|
||||
bot_text = any(f.get("extra_text_chars", 0) > 100 for f in cloak_diff.get("findings", []))
|
||||
if bot_links:
|
||||
saetze.append(
|
||||
"Ihre Website zeigt der Google-Suchmaschine andere Links als normalen "
|
||||
"Besuchern. Das ist eine typische Methode von Spam-Angriffen."
|
||||
)
|
||||
elif bot_text:
|
||||
saetze.append(
|
||||
"Ihre Website zeigt der Google-Suchmaschine zusätzlichen Text, den normale "
|
||||
"Besucher nicht sehen. Bitte prüfen lassen."
|
||||
)
|
||||
|
||||
# --- Verdächtige Inhaltsänderungen ---
|
||||
z = _zaehle_seiten_befunde(diff)
|
||||
if z["hidden"]:
|
||||
saetze.append(
|
||||
f"Auf {z['hidden']} Seite(n) wurde unsichtbarer Text oder unsichtbare Links "
|
||||
"gefunden. So versteckt man üblicherweise Spam vor dem Auge des Besuchers."
|
||||
)
|
||||
if z["meta_refresh"]:
|
||||
saetze.append(
|
||||
f"{z['meta_refresh']} Seite(n) leiten Besucher jetzt automatisch auf eine "
|
||||
"andere Adresse weiter. Wenn Sie das nicht selbst eingerichtet haben, ist es verdächtig."
|
||||
)
|
||||
if z["scripts"]:
|
||||
saetze.append(
|
||||
f"Auf {z['scripts']} Seite(n) wurde neuer, auffälliger Programmcode eingebaut. "
|
||||
"Bitte von Ihrem Dienstleister prüfen lassen."
|
||||
)
|
||||
if z["comment_links"]:
|
||||
saetze.append(
|
||||
f"Auf {z['comment_links']} Seite(n) wurden versteckte Links im Quelltext gefunden."
|
||||
)
|
||||
if z["canonical"]:
|
||||
saetze.append(
|
||||
f"Bei {z['canonical']} Seite(n) wurde die hinterlegte 'Original-Adresse' geändert. "
|
||||
"Damit versuchen Angreifer manchmal, Ihre Seite bei Google umzuleiten."
|
||||
)
|
||||
|
||||
# --- Fremde Adressen / neue Seiten ---
|
||||
neue_domains = diff.get("new_external_domains", [])
|
||||
if neue_domains:
|
||||
beispiele = ", ".join(neue_domains[:3])
|
||||
mehr = f" (und {len(neue_domains) - 3} weitere)" if len(neue_domains) > 3 else ""
|
||||
saetze.append(
|
||||
f"Es tauchen Verweise auf neue fremde Internet-Adressen auf: {beispiele}{mehr}. "
|
||||
"Das kann harmlos sein (z. B. ein neuer Partner-Link) oder ein Hinweis auf einen Eingriff."
|
||||
)
|
||||
|
||||
neue_seiten = diff.get("new_internal_urls", [])
|
||||
if neue_seiten:
|
||||
saetze.append(
|
||||
f"Es sind {len(neue_seiten)} neue Unterseite(n) hinzugekommen. Haben Sie oder Ihr "
|
||||
"Dienstleister die angelegt?"
|
||||
)
|
||||
fehlende = diff.get("missing_internal_urls", [])
|
||||
if fehlende:
|
||||
saetze.append(
|
||||
f"{len(fehlende)} Seite(n), die es früher gab, sind nicht mehr auffindbar."
|
||||
)
|
||||
|
||||
if z["grosser_text"]:
|
||||
saetze.append(
|
||||
f"Auf {z['grosser_text']} Seite(n) wurde ein größerer Textblock neu hinzugefügt."
|
||||
)
|
||||
|
||||
# --- Datei-Veränderungen (Assets) ---
|
||||
if asset_diff and asset_diff.get("changed"):
|
||||
n = len(asset_diff["changed"])
|
||||
saetze.append(
|
||||
f"{n} Datei(en) (z. B. Bilder, Skripte oder Stylesheets) haben sich verändert. "
|
||||
"Nach einem geplanten Update ist das normal — sonst bitte prüfen lassen."
|
||||
)
|
||||
|
||||
# --- Kaputte Links (rein informativ) ---
|
||||
neue_kaputt = diff.get("new_broken_urls", [])
|
||||
if neue_kaputt:
|
||||
saetze.append(
|
||||
f"{len(neue_kaputt)} interne Link(s) führen ins Leere (Seite nicht gefunden)."
|
||||
)
|
||||
if ext_links and ext_links.get("broken"):
|
||||
saetze.append(
|
||||
f"{ext_links['broken']} Link(s) zu anderen Websites funktionieren nicht mehr. "
|
||||
"Das ist meist harmlos — die fremde Seite wurde gelöscht oder umbenannt."
|
||||
)
|
||||
|
||||
if not saetze:
|
||||
saetze.append("Es hat sich nichts Verdächtiges verändert.")
|
||||
|
||||
return saetze
|
||||
|
||||
|
||||
def was_tun(level: str) -> list[str]:
|
||||
"""Konkrete, laienverständliche Handlungsempfehlung je Level."""
|
||||
if level == "red":
|
||||
return [
|
||||
"Öffnen Sie Ihre Website im Browser und schauen Sie, ob etwas Fremdes zu sehen ist.",
|
||||
"Informieren Sie die Person oder Firma, die Ihre Website betreut.",
|
||||
"Geben Sie nichts frei, solange der Verdacht nicht geklärt ist.",
|
||||
]
|
||||
if level == "yellow":
|
||||
return [
|
||||
"Schauen Sie sich die unten genannten Punkte einmal an.",
|
||||
"Wenn die Änderung von Ihnen oder Ihrem Dienstleister gewollt war, ist alles in Ordnung.",
|
||||
"Gewollte Änderungen bestätigen Sie mit: python -m scanner approve --all",
|
||||
]
|
||||
return ["Sie müssen nichts tun."]
|
||||
Loading…
Add table
Add a link
Reference in a new issue