feat: Cloaking-Erkennung per Doppel-Crawl (Browser-UA vs. Googlebot-UA)

Neuer Befehl `python -m scanner cloak-check`:
- Crawlt die Website zweimal: einmal mit Browser-UA, einmal als Googlebot
- Vergleicht Text, Links und Response-Header (Vary: User-Agent)
- Scoring: Bot-Only-Link 60 Pkt (RED), extra Text >100 Zeichen 40 Pkt,
  Vary: User-Agent ohne Inhaltsdiff 25 Pkt
- Eigener Report unter reports/<ts>_cloak/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-12 04:02:24 +02:00
commit 0302d469b1
5 changed files with 336 additions and 1 deletions

View file

@ -31,7 +31,7 @@ from .checker import (
)
from .config import load_config, resolve_paths
from .crawler import Crawler
from .differ import compare_snapshots, score_diff
from .differ import compare_cloaking, compare_snapshots, score_cloak_diff, score_diff
from .extractor import extract_page
@ -39,6 +39,9 @@ from .extractor import extract_page
# Logging
# ---------------------------------------------------------------------------
GOOGLEBOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
def _setup_logging(logs_dir: str, verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logs_path = Path(logs_dir)
@ -294,6 +297,99 @@ def _render_markdown(r: dict) -> str:
return "\n".join(lines) + "\n"
def _write_cloak_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
out = Path(reports_dir) / f"{ts}_cloak"
out.mkdir(parents=True, exist_ok=True)
json_path = out / "report.json"
md_path = out / "report.md"
json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
md_path.write_text(_render_cloak_markdown(report), encoding="utf-8")
return json_path, md_path
def _render_cloak_markdown(r: dict) -> str:
a = r.get("assessment", {})
cd = r.get("cloak_diff", {})
level = a.get("level", "?").upper()
score = a.get("score", 0)
level_badge = {"GREEN": "[OK]", "YELLOW": "[WARNUNG]", "RED": "[ALARM]"}.get(level, level)
lines = [
f"# Cloaking-Check — {r.get('target', '?')}",
"",
f"**Erstellt:** {r.get('generated_at', '?')} ",
f"**Level:** {level_badge} ",
f"**Score:** {score}",
"",
"## Zusammenfassung",
"",
"| Kennzahl | Wert |",
"|---|---|",
f"| Geprüfte Seiten | {cd.get('pages_checked', '?')} |",
f"| Seiten mit Unterschieden | {len(cd.get('findings', []))} |",
"",
"## Risikobewertung",
"",
]
for reason in a.get("reasons", []):
lines.append(f"- {reason}")
if not a.get("reasons"):
lines.append("- Keine Cloaking-Anzeichen gefunden.")
if cd.get("findings"):
lines += ["", "## Auffällige Seiten", ""]
for page in cd["findings"]:
lines += [f"### {page['url']}", ""]
if page.get("vary_ua"):
lines.append("- `Vary: User-Agent` Header gesetzt")
if page.get("extra_links"):
lines.append(f"- Bot-Only-Links ({len(page['extra_links'])}):")
for link in page["extra_links"]:
lines.append(f" - `{link}`")
if page.get("extra_text_chars", 0) > 50:
lines.append(f"- Bot sieht +{page['extra_text_chars']} Zeichen mehr Text")
if page.get("text_diff"):
lines += ["", "```diff"]
lines += page["text_diff"][:20]
if len(page["text_diff"]) > 20:
lines.append(f"... ({len(page['text_diff']) - 20} weitere Zeilen)")
lines.append("```")
lines.append("")
lines += ["", "---", "", "## Nächste Schritte", ""]
lev = a.get("level", "green")
if lev == "red":
lines += [
"1. **Sofort**: Seiten manuell vergleichen: `curl -A 'Googlebot' <URL> | grep -i hidden`",
"2. Server-Logs auf Bot-IP-Ranges prüfen.",
"3. CMS-Plugins / Theme-Dateien auf injizierte Cloaking-Logik untersuchen.",
"4. Nach Bereinigung: `python -m scanner cloak-check` erneut ausführen.",
]
elif lev == "yellow":
lines += [
"1. Markierte Seiten prüfen: `curl -s -A 'Googlebot' <URL> > /tmp/bot.html && diff <(curl -s <URL>) /tmp/bot.html`",
"2. Bei bestätigtem Cloaking: Hoster kontaktieren.",
]
else:
lines += ["Keine Aktion erforderlich."]
return "\n".join(lines) + "\n"
def _print_cloak_summary(assessment: dict, cloak_diff: dict) -> None:
level = assessment.get("level", "?").upper()
score = assessment.get("score", 0)
print()
print(f"=== Cloaking-Check: {level} (Score {score}) ===")
for reason in assessment.get("reasons", []):
print(f" * {reason}")
if not assessment.get("reasons"):
print(" Keine Cloaking-Anzeichen gefunden.")
print(f" Geprüfte Seiten: {cloak_diff.get('pages_checked', 0)}")
print(f" Seiten mit Unterschied: {len(cloak_diff.get('findings', []))}")
def _latest_report_path(reports_dir: str) -> Path | None:
rd = Path(reports_dir)
if not rd.exists():
@ -600,6 +696,40 @@ def cmd_status(args: argparse.Namespace, cfg: dict) -> int:
return 0
# ---------------------------------------------------------------------------
# Cloaking check
# ---------------------------------------------------------------------------
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)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"target": cfg["target"],
"type": "cloak-check",
"assessment": assessment,
"cloak_diff": cloak_diff,
}
json_path, md_path = _write_cloak_report(report, cfg["reports_dir"])
logger.info("Cloak-Report: %s", md_path)
_print_cloak_summary(assessment, cloak_diff)
return assessment["exit_code"]
# ---------------------------------------------------------------------------
# Output helper
# ---------------------------------------------------------------------------
@ -653,6 +783,8 @@ def _build_parser() -> argparse.ArgumentParser:
sub.add_parser("status", help="Übersichtsstatus anzeigen")
sub.add_parser("cloak-check", help="Doppel-Crawl: Browser-UA vs. Googlebot-UA (Cloaking-Erkennung)")
return p
@ -678,6 +810,7 @@ def main() -> None:
"approve": cmd_approve,
"report": cmd_report,
"status": cmd_status,
"cloak-check": cmd_cloak_check,
}
handler = commands.get(args.command)
if not handler:

View file

@ -33,6 +33,9 @@ DEFAULT_CONFIG: dict = {
"comment_links": 25,
"new_broken_link": 20,
"suspicious_filename": 60,
"cloaking_extra_link": 60,
"cloaking_extra_text": 40,
"cloaking_vary_ua": 25,
},
"thresholds": {
"yellow": 20,

View file

@ -377,6 +377,96 @@ def score_diff(diff: dict, cfg: dict) -> dict:
return {"score": score, "level": level, "reasons": reasons, "exit_code": exit_code}
# ---------------------------------------------------------------------------
# Cloaking detection
# ---------------------------------------------------------------------------
def compare_cloaking(normal_pages: dict, bot_pages: dict, cfg: dict) -> dict:
"""
Compare pages crawled with a normal browser UA vs. Googlebot UA.
Returns findings where the bot version contains extra content.
"""
findings: list[dict] = []
common = sorted(set(normal_pages) & set(bot_pages))
for url in common:
normal = normal_pages[url]
bot = bot_pages[url]
# Vary: User-Agent signals server-side content negotiation
vary = bot.get("response_headers", {}).get("Vary", "")
has_vary_ua = "user-agent" in vary.lower()
# Text diff
normal_text = normalize_text(normal.get("text", ""), cfg)
bot_text = normalize_text(bot.get("text", ""), cfg)
text_changed = text_hash(normal_text) != text_hash(bot_text)
text_diff = diff_text(normal_text, bot_text) if text_changed else []
extra_chars = sum(
len(line[1:]) for line in text_diff
if line.startswith("+") and not line.startswith("+++")
)
# Links only in bot version
extra_links: list[str] = []
for ltype in ("a", "script", "iframe"):
ld = diff_link_set(
normal.get("links", {}).get(ltype, []),
bot.get("links", {}).get(ltype, []),
)
extra_links.extend(ld["added"])
if extra_links or extra_chars > 50 or has_vary_ua:
findings.append({
"url": url,
"vary_ua": has_vary_ua,
"extra_links": extra_links,
"extra_text_chars": extra_chars,
"text_diff": text_diff[:40],
})
return {
"findings": findings,
"pages_checked": len(common),
}
def score_cloak_diff(cloak_diff: dict, cfg: dict) -> dict:
"""Score the cloaking check result."""
sc = cfg.get("scoring", {})
thr = cfg.get("thresholds", {"yellow": 20, "red": 60})
score = 0
reasons: list[str] = []
def add(pts: int, msg: str) -> None:
nonlocal score
score += pts
reasons.append(f"{msg} (+{pts})")
for page in cloak_diff.get("findings", []):
url = page["url"]
for link in page.get("extra_links", []):
add(sc.get("cloaking_extra_link", 60), f"{url}: Bot-Only-Link: {link}")
if page.get("extra_text_chars", 0) > 100:
add(sc.get("cloaking_extra_text", 40),
f"{url}: Bot sieht +{page['extra_text_chars']} Zeichen mehr Text")
# Vary: User-Agent ohne sonstigen Unterschied — informativer Hinweis
if page.get("vary_ua") and not page.get("extra_links") and page.get("extra_text_chars", 0) <= 100:
add(sc.get("cloaking_vary_ua", 25), f"{url}: Vary: User-Agent Header")
yellow = thr.get("yellow", 20)
red = thr.get("red", 60)
if score >= red:
level, exit_code = "red", 2
elif score >= yellow:
level, exit_code = "yellow", 1
else:
level, exit_code = "green", 0
return {"score": score, "level": level, "reasons": reasons, "exit_code": exit_code}
def _fingerprint(obj: dict | list | str) -> str:
"""Stable hash for deduplicating diff entries."""
import json as _json