feat: initial implementation of website integrity scanner

Python-Package zur unabhängigen Überwachung statischer Websites gegen
SEO-Spam und unbefugte Manipulationen. Läuft außerhalb des Hosters.

Kernfunktionen:
- Reiner Python-Crawler (bs4+lxml), kein wget
- Baseline-Management mit manuellem Freigabe-Workflow (niemals automatisch)
- Text-/Link-/Meta-Diff mit Risiko-Scoring (grün/gelb/rot)
- Hidden-Content-Erkennung (CSS inline, noscript, Kommentar-Links)
- Whitelist für externe Domains und interne Pfade
- E-Mail + Webhook Alarmierung
- CLI: init, crawl, check, scan, approve, report, status
- 79 Unit-Tests (pytest), alle grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-12 00:51:41 +02:00
commit 98e8d11eb5
24 changed files with 4295 additions and 0 deletions

0
scanner/__init__.py Normal file
View file

672
scanner/__main__.py Normal file
View file

@ -0,0 +1,672 @@
"""
integrity-scanner CLI entry point.
Usage:
python -m scanner <subcommand> [options]
Subcommands:
init First-time crawl + baseline setup workflow
crawl Crawl site, save snapshot (no comparison)
check Compare latest snapshot against baseline, report + alert
scan crawl + check in one step (use this for cron)
approve Approve snapshot as new baseline
report Display / export latest report
status Show baseline age, last scan, open changes
"""
import argparse
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from .alerter import send_alert
from .baseline import BaselineManager
from .checker import (
canonical_is_hijacked,
check_jsonld_types,
check_links_against_whitelist,
check_security_headers,
is_suspicious_url,
load_whitelist,
)
from .config import load_config, resolve_paths
from .crawler import Crawler
from .differ import compare_snapshots, normalize_text, score_diff
from .extractor import extract_page
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
def _setup_logging(logs_dir: str, verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logs_path = Path(logs_dir)
logs_path.mkdir(parents=True, exist_ok=True)
log_file = logs_path / "scanner.log"
fmt = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
handlers: list[logging.Handler] = [
logging.StreamHandler(sys.stderr),
logging.FileHandler(log_file, encoding="utf-8"),
]
logging.basicConfig(level=level, format=fmt, handlers=handlers, force=True)
# ---------------------------------------------------------------------------
# Crawl + extract helpers
# ---------------------------------------------------------------------------
def _crawl_and_extract(cfg: dict) -> tuple[list[dict], list[dict]]:
"""Run crawler, extract each HTML page. Returns (pages, errors)."""
logger = logging.getLogger(__name__)
crawler = Crawler(cfg)
raw = crawler.crawl()
pages: list[dict] = []
for raw_page in raw["pages"]:
if raw_page.get("html"):
extracted = extract_page(raw_page["html"], raw_page["url"], cfg)
page = {**raw_page, **extracted}
else:
page = dict(raw_page)
pages.append(page)
logger.info("Extracted %d pages", len(pages))
return pages, raw["errors"]
# ---------------------------------------------------------------------------
# Whitelist loading
# ---------------------------------------------------------------------------
def _load_whitelists(cfg: dict) -> tuple[set[str], set[str]]:
config_dir = Path(cfg["config_dir"])
allowed_ext = load_whitelist(config_dir / "allowed_external.yaml")
allowed_int = load_whitelist(config_dir / "allowed_paths.yaml")
return allowed_ext, allowed_int
# ---------------------------------------------------------------------------
# Report building
# ---------------------------------------------------------------------------
def _build_report(
target: str,
diff: dict,
assessment: dict,
whitelist_violations: list[dict],
missing_sec_headers: dict,
crawl_errors: list[dict],
snap_dir: Path,
) -> dict:
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"target": target,
"snapshot_dir": str(snap_dir),
"assessment": assessment,
"diff": diff,
"whitelist_violations": whitelist_violations,
"missing_security_headers": missing_sec_headers,
"crawl_errors": crawl_errors,
}
def _write_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) / ts
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_markdown(report), encoding="utf-8")
return json_path, md_path
def _render_markdown(r: dict) -> str:
a = r.get("assessment", {})
d = r.get("diff", {})
level = a.get("level", "?").upper()
score = a.get("score", 0)
level_badge = {"GREEN": "[OK]", "YELLOW": "[WARNUNG]", "RED": "[ALARM]"}.get(level, level)
lines = [
f"# Integrity Report — {r.get('target', '?')}",
"",
f"**Erstellt:** {r.get('generated_at', '?')} ",
f"**Level:** {level_badge} ",
f"**Score:** {score}",
"",
"## Zusammenfassung",
"",
f"| Kennzahl | Wert |",
f"|---|---|",
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"| 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}")
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', '?')}")
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.",
"3. Nach Bereinigung: `python -m scanner approve --rebuild`",
]
elif lev == "yellow":
lines += [
"1. Alle markierten URLs manuell prüfen.",
"2. Legitime Änderungen freigeben: `python -m scanner approve --url <URL>`",
"3. Alle freigeben (wenn geprüft): `python -m scanner approve --all`",
]
else:
lines += ["Keine Aktion erforderlich."]
return "\n".join(lines) + "\n"
def _latest_report_path(reports_dir: str) -> Path | None:
rd = Path(reports_dir)
if not rd.exists():
return None
dirs = sorted(d for d in rd.iterdir() if d.is_dir())
if not dirs:
return None
return dirs[-1] / "report.md"
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def cmd_init(args: argparse.Namespace, cfg: dict) -> int:
logger = logging.getLogger("scanner.init")
print("=== Initialisierung: Erster Crawl ===")
print(f"Ziel: {cfg['target']}")
pages, errors = _crawl_and_extract(cfg)
if not pages:
print("FEHLER: Kein Seiten gecrawlt.", file=sys.stderr)
return 1
bm = BaselineManager(cfg["data_dir"])
snap_dir = bm.save_snapshot(pages)
print(f"Snapshot gespeichert: {snap_dir}")
print(f" Seiten: {len(pages)}")
print(f" Fehler: {len(errors)}")
print()
allowed_ext, allowed_int = _load_whitelists(cfg)
base_netloc = urlparse(cfg["target"]).netloc
print("=== Gefundene interne URLs ===")
for p in pages:
print(f" {p['url']}")
print()
print("=== Externe Links (alle) ===")
seen_domains: set[str] = set()
for p in pages:
for ltype, links in p.get("links", {}).items():
if not isinstance(links, list):
continue
for l in links:
if isinstance(l, dict) and l.get("class") == "external":
domain = urlparse(l.get("url", "")).netloc
if domain and domain not in seen_domains:
seen_domains.add(domain)
in_wl = " [in Whitelist]" if domain in allowed_ext else " [NICHT in Whitelist]"
print(f" {domain}{in_wl}")
print()
auffaelligkeiten = 0
for p in pages:
if p.get("hidden_content"):
print(f" [!] Hidden Content auf {p['url']}: {len(p['hidden_content'])} Fund(e)")
auffaelligkeiten += len(p["hidden_content"])
if p.get("inline_scripts"):
print(f" [!] Verdächtige Inline-Scripts auf {p['url']}: {len(p['inline_scripts'])} Fund(e)")
auffaelligkeiten += len(p["inline_scripts"])
if p.get("links", {}).get("meta_refresh"):
print(f" [!] Meta-Refresh auf {p['url']}")
auffaelligkeiten += 1
if p.get("comment_links"):
print(f" [!] Links in HTML-Kommentaren auf {p['url']}")
auffaelligkeiten += len(p["comment_links"])
if errors:
print()
print("=== Crawl-Fehler ===")
for e in errors:
print(f" {e['url']}: {e['error']}")
print()
if auffaelligkeiten:
print(f"[WARNUNG] {auffaelligkeiten} potenzielle Auffälligkeit(en) gefunden.")
print(" Bitte alle URLs oben manuell prüfen, bevor Sie freigeben.")
else:
print("[OK] Keine offensichtlichen Auffälligkeiten gefunden.")
print()
print("=== Naechste Schritte ===")
print(" 1. Prüfen Sie alle oben aufgelisteten externen Links und Auffälligkeiten.")
print(" 2. Wenn alles sauber ist, Baseline freigeben:")
print(" python -m scanner approve --all")
print(" 3. Danach ist der tägliche Betrieb möglich:")
print(" python -m scanner scan")
return 0
def cmd_crawl(args: argparse.Namespace, cfg: dict) -> int:
logger = logging.getLogger("scanner.crawl")
print(f"Crawle {cfg['target']} ...")
pages, errors = _crawl_and_extract(cfg)
bm = BaselineManager(cfg["data_dir"])
snap_dir = bm.save_snapshot(pages)
print(f"Snapshot: {snap_dir}")
print(f" Seiten: {len(pages)}")
print(f" Fehler: {len(errors)}")
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"]),
)
diff = compare_snapshots(baseline["pages"], snap["pages"], cfg)
# Whitelist checks on all current pages
allowed_ext, allowed_int = _load_whitelists(cfg)
base_netloc = urlparse(cfg["target"]).netloc
wl_violations: list[dict] = []
missing_sec: dict = {}
for page in snap["pages"].values():
violations = check_links_against_whitelist(page, allowed_ext, base_netloc)
if violations:
wl_violations.extend({"page": page["url"], **v} for v in violations)
hdrs = page.get("response_headers", {})
missing = check_security_headers(hdrs, cfg.get("security_headers", []))
if missing:
missing_sec[page["url"]] = missing
# Unexpected JSON-LD types (absolute check, not just diff-based)
unexp = check_jsonld_types(page, cfg.get("allowed_jsonld_types", []))
for j in unexp:
if not any(
pd.get("url") == page["url"] and j in pd.get("new_jsonld", [])
for pd in diff.get("page_diffs", [])
):
# Add as new_jsonld to the page_diff if not already there
pd_for_url = next(
(pd for pd in diff.get("page_diffs", []) if pd["url"] == page["url"]),
None,
)
if pd_for_url:
pd_for_url.setdefault("new_jsonld", []).append({**j, "unexpected": True})
# Score with whitelist-violation contribution
for v in wl_violations:
if v.get("type") == "unlisted_external_domain":
existing = diff.get("new_external_domains", [])
domain = v.get("domain", "")
if domain and domain not in existing:
existing.append(domain)
diff["new_external_domains"] = existing
assessment = score_diff(diff, cfg)
crawl_errors: list[dict] = []
if getattr(args, "with_errors", False):
# errors were not persisted in snapshot, skip
pass
report = _build_report(
cfg["target"], diff, assessment, wl_violations, missing_sec,
crawl_errors, snap["dir"],
)
json_path, md_path = _write_report(report, cfg["reports_dir"])
logger.info("Report: %s", md_path)
_print_summary(assessment, diff)
send_alert(report, cfg)
return assessment["exit_code"]
def cmd_scan(args: argparse.Namespace, cfg: dict) -> int:
"""crawl + check."""
logger = logging.getLogger("scanner.scan")
print(f"Scanne {cfg['target']} ...")
pages, errors = _crawl_and_extract(cfg)
bm = BaselineManager(cfg["data_dir"])
snap_dir = bm.save_snapshot(pages)
logger.info("Snapshot: %s (%d Seiten, %d Fehler)", snap_dir, 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")
return 0
return cmd_check(args, cfg)
def cmd_approve(args: argparse.Namespace, cfg: dict) -> int:
logger = logging.getLogger("scanner.approve")
bm = BaselineManager(cfg["data_dir"])
snap_dir = Path(args.snapshot) if getattr(args, "snapshot", None) else None
note = getattr(args, "note", "") or ""
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.")
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}")
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}")
return 0
else:
print(f"FEHLER: URL nicht im Snapshot gefunden: {url}", file=sys.stderr)
return 1
print("FEHLER: --url, --all oder --rebuild angeben.", file=sys.stderr)
return 1
def cmd_report(args: argparse.Namespace, cfg: dict) -> int:
fmt = getattr(args, "format", "md")
rd = Path(cfg["reports_dir"])
if not rd.exists():
print("Noch keine Reports vorhanden.")
return 0
dirs = sorted(d for d in rd.iterdir() if d.is_dir())
if not dirs:
print("Noch keine Reports vorhanden.")
return 0
latest = dirs[-1]
if fmt == "json":
p = latest / "report.json"
else:
p = latest / "report.md"
if not p.exists():
print(f"Report-Datei nicht gefunden: {p}", file=sys.stderr)
return 1
print(p.read_text(encoding="utf-8"))
return 0
def cmd_status(args: argparse.Namespace, cfg: dict) -> int:
bm = BaselineManager(cfg["data_dir"])
print(f"Ziel: {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', []))}")
if manifest.get("note"):
print(f" Notiz: {manifest['note']}")
else:
print("Baseline: KEINE — 'python -m scanner init' 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)")
else:
print("Letzter Scan: KEINER")
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}")
return 0
# ---------------------------------------------------------------------------
# Output helper
# ---------------------------------------------------------------------------
def _print_summary(assessment: dict, diff: dict) -> None:
level = assessment.get("level", "?").upper()
score = assessment.get("score", 0)
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)}")
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="scanner",
description="Website-Integrity-Scanner für statische Webseiten",
)
p.add_argument("--config", default="config.yaml", help="Pfad zur config.yaml")
p.add_argument("--verbose", "-v", action="store_true", help="Debug-Ausgabe")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("init", help="Erstcrawl + Baseline-Workflow starten")
sub.add_parser("crawl", help="Nur crawlen, Snapshot speichern")
sub.add_parser("check", help="Snapshot vs. Baseline vergleichen")
sub.add_parser("scan", help="crawl + check (für Cron)")
ap = sub.add_parser("approve", help="Snapshot als neue Baseline freigeben")
ap.add_argument("--all", action="store_true", help="Alle URLs freigeben")
ap.add_argument("--url", help="Einzelne URL freigeben")
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")
rp = sub.add_parser("report", help="Letzten Report anzeigen")
rp.add_argument("--format", choices=["md", "json"], default="md")
sub.add_parser("status", help="Übersichtsstatus anzeigen")
return p
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
cfg = load_config(args.config)
base_dir = Path(args.config).parent.resolve()
resolve_paths(cfg, base_dir)
_setup_logging(cfg["logs_dir"], args.verbose)
commands = {
"init": cmd_init,
"crawl": cmd_crawl,
"check": cmd_check,
"scan": cmd_scan,
"approve": cmd_approve,
"report": cmd_report,
"status": cmd_status,
}
handler = commands.get(args.command)
if not handler:
parser.print_help()
sys.exit(1)
sys.exit(handler(args, cfg))
if __name__ == "__main__":
main()

183
scanner/alerter.py Normal file
View file

@ -0,0 +1,183 @@
"""
Alert dispatch: e-mail (smtplib) and HTTP webhook.
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
logger = logging.getLogger(__name__)
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}
def send_alert(report: dict, cfg: dict) -> None:
"""Send alert if the report's level is at or above min_level."""
alerting = cfg.get("alerting", {})
level = report.get("assessment", {}).get("level", "green")
min_level = alerting.get("min_level", "yellow")
if _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 1):
logger.debug("Alert suppressed: level=%s < min_level=%s", level, min_level)
return
subject = _make_subject(report)
body = _format_body(report)
email_cfg = alerting.get("email", {})
if email_cfg.get("enabled"):
_send_email(subject, body, email_cfg)
webhook_cfg = alerting.get("webhook", {})
if webhook_cfg.get("enabled") and webhook_cfg.get("url"):
_send_webhook(webhook_cfg["url"], report, subject)
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
def _make_subject(report: dict) -> str:
level = report.get("assessment", {}).get("level", "?").upper()
target = report.get("target", "?")
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
return f"[{level}] Integrity Alert: {target}{ts}"
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", "?")
lines += [
f"Level: {level}",
f"Score: {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", []), "-")
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']}")
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")
# ---------------------------------------------------------------------------
# Transport
# ---------------------------------------------------------------------------
def _send_email(subject: str, body: str, cfg: dict) -> None:
password = os.environ.get(cfg.get("smtp_password_env", "SCANNER_SMTP_PASSWORD"), "")
recipients = cfg.get("to", [])
if not recipients:
logger.warning("E-Mail alert enabled but no recipients configured.")
return
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = cfg.get("from", "scanner@localhost")
msg["To"] = ", ".join(recipients)
msg.set_content(body)
host = cfg.get("smtp_host", "localhost")
port = cfg.get("smtp_port", 587)
use_tls = cfg.get("smtp_tls", True)
try:
with smtplib.SMTP(host, port) as server:
if use_tls:
server.starttls()
user = cfg.get("smtp_user", "")
if user and password:
server.login(user, password)
server.send_message(msg)
logger.info("Alert e-mail sent to %s", recipients)
except Exception as exc:
logger.error("E-mail alert failed: %s", exc)
def _send_webhook(url: str, report: dict, subject: str) -> None:
payload = {
"text": subject,
"target": report.get("target"),
"level": report.get("assessment", {}).get("level"),
"score": report.get("assessment", {}).get("score"),
"reasons": report.get("assessment", {}).get("reasons", []),
"new_external_domains": report.get("diff", {}).get("new_external_domains", []),
"new_internal_urls": report.get("diff", {}).get("new_internal_urls", []),
"missing_internal_urls": report.get("diff", {}).get("missing_internal_urls", []),
"changed_pages": report.get("diff", {}).get("changed_pages", 0),
}
try:
resp = requests.post(url, json=payload, timeout=10)
logger.info("Webhook delivered: HTTP %s", resp.status_code)
except Exception as exc:
logger.error("Webhook alert failed: %s", exc)

231
scanner/baseline.py Normal file
View file

@ -0,0 +1,231 @@
"""
Snapshot and baseline management.
Snapshots are timestamped crawl results. The baseline is a manually
approved reference state. Compromised content must never flow into the
baseline automatically every update requires an explicit `approve` call.
"""
import hashlib
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def url_key(url: str) -> str:
"""Stable filename-safe key for a URL (first 16 hex chars of SHA-256)."""
return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
class BaselineManager:
def __init__(self, data_dir: str | Path):
self.data_dir = Path(data_dir)
self.baseline_dir = self.data_dir / "baseline"
self.pages_dir = self.baseline_dir / "pages"
self.snapshots_dir = self.data_dir / "snapshots"
# ------------------------------------------------------------------
# Snapshot operations
# ------------------------------------------------------------------
def save_snapshot(self, pages: list[dict]) -> Path:
"""
Persist a crawl+extraction result as a timestamped snapshot.
Returns the snapshot directory path.
"""
ts = _utc_stamp()
snap_dir = self.snapshots_dir / ts
snap_pages_dir = snap_dir / "pages"
snap_pages_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"timestamp": ts,
"page_count": len(pages),
"urls": [p["url"] for p in pages],
}
(snap_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
for page in pages:
fname = url_key(page["url"]) + ".json"
(snap_pages_dir / fname).write_text(
json.dumps(page, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.info("Snapshot saved: %s (%d pages)", snap_dir, len(pages))
return snap_dir
def latest_snapshot_dir(self) -> Optional[Path]:
"""Return the most recent snapshot directory, or None."""
if not self.snapshots_dir.exists():
return None
dirs = sorted(
(d for d in self.snapshots_dir.iterdir() if d.is_dir()),
key=lambda d: d.name,
)
return dirs[-1] if dirs else None
def load_snapshot(self, snap_dir: Optional[Path] = None) -> dict:
"""
Load a snapshot (default: latest).
Returns {manifest, pages: {url: page_dict}, dir: Path}.
"""
if snap_dir is None:
snap_dir = self.latest_snapshot_dir()
if snap_dir is None or not snap_dir.exists():
return {}
manifest = json.loads((snap_dir / "manifest.json").read_text(encoding="utf-8"))
pages: dict[str, dict] = {}
pages_dir = snap_dir / "pages"
for f in pages_dir.glob("*.json"):
page = json.loads(f.read_text(encoding="utf-8"))
pages[page["url"]] = page
return {"manifest": manifest, "pages": pages, "dir": snap_dir}
# ------------------------------------------------------------------
# Baseline operations
# ------------------------------------------------------------------
def baseline_exists(self) -> bool:
return (self.baseline_dir / "manifest.json").exists()
def load_baseline(self) -> dict:
"""
Load the current approved baseline.
Returns {manifest, pages: {url: page_dict}}.
"""
if not self.baseline_exists():
return {"manifest": {}, "pages": {}}
manifest = json.loads(
(self.baseline_dir / "manifest.json").read_text(encoding="utf-8")
)
pages: dict[str, dict] = {}
if self.pages_dir.exists():
for f in self.pages_dir.glob("*.json"):
page = json.loads(f.read_text(encoding="utf-8"))
pages[page["url"]] = page
return {"manifest": manifest, "pages": pages}
# ------------------------------------------------------------------
# Approve operations (the only way baseline content is ever updated)
# ------------------------------------------------------------------
def approve_all(
self,
snap_dir: Optional[Path] = None,
note: str = "",
approved_by: str = "cli",
) -> list[str]:
"""
Approve every page in the snapshot as the new baseline.
Existing baseline pages NOT present in snapshot are kept unless
--rebuild is requested (caller responsibility to wipe baseline_dir first).
"""
snap = self.load_snapshot(snap_dir)
if not snap:
raise RuntimeError("No snapshot found to approve.")
self.pages_dir.mkdir(parents=True, exist_ok=True)
approved: list[str] = []
for url, page in snap["pages"].items():
fname = url_key(url) + ".json"
(self.pages_dir / fname).write_text(
json.dumps(page, indent=2, ensure_ascii=False), encoding="utf-8"
)
approved.append(url)
self._write_manifest(approved, note, approved_by, snap.get("dir"))
logger.info("Approved %d URLs into baseline.", len(approved))
return approved
def approve_url(
self,
url: str,
snap_dir: Optional[Path] = None,
note: str = "",
approved_by: str = "cli",
) -> bool:
"""Approve a single URL from snapshot into the baseline."""
snap = self.load_snapshot(snap_dir)
if url not in snap.get("pages", {}):
logger.error("URL not found in snapshot: %s", url)
return False
self.pages_dir.mkdir(parents=True, exist_ok=True)
fname = url_key(url) + ".json"
(self.pages_dir / fname).write_text(
json.dumps(snap["pages"][url], indent=2, ensure_ascii=False),
encoding="utf-8",
)
# Update manifest incrementally
if self.baseline_exists():
manifest = json.loads(
(self.baseline_dir / "manifest.json").read_text(encoding="utf-8")
)
else:
manifest = {"urls": []}
if url not in manifest.get("urls", []):
manifest.setdefault("urls", []).append(url)
manifest["last_updated"] = datetime.now(timezone.utc).isoformat()
manifest["last_note"] = note
manifest["last_approved_by"] = approved_by
(self.baseline_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.info("Approved single URL into baseline: %s", url)
return True
def rebuild_baseline(
self,
snap_dir: Optional[Path] = None,
note: str = "",
approved_by: str = "cli",
) -> list[str]:
"""
Wipe the entire baseline and replace it with the given snapshot.
Use when a major legitimate redesign has taken place.
"""
import shutil
if self.baseline_dir.exists():
shutil.rmtree(self.baseline_dir)
self.pages_dir.mkdir(parents=True, exist_ok=True)
return self.approve_all(snap_dir=snap_dir, note=note, approved_by=approved_by)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _write_manifest(
self,
urls: list[str],
note: str,
approved_by: str,
source_snap: Optional[Path],
) -> None:
self.baseline_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"approved_at": datetime.now(timezone.utc).isoformat(),
"approved_by": approved_by,
"note": note,
"source_snapshot": str(source_snap) if source_snap else "",
"urls": urls,
}
(self.baseline_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
def _utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")

164
scanner/checker.py Normal file
View file

@ -0,0 +1,164 @@
"""
Additional integrity checks beyond baseline diff:
- link whitelist enforcement
- security-header audit
- canonical hijack detection
- suspicious URL filenames (from v4 heritage)
"""
import re
from pathlib import Path
from urllib.parse import urlparse
import yaml
_SUSPICIOUS_FILENAME_RE = re.compile(
r"(shell|backdoor|cmd|upload|gate|mailer|wso|c99|r57|b374k)",
re.I,
)
# ---------------------------------------------------------------------------
# Whitelist checks
# ---------------------------------------------------------------------------
def load_whitelist(path: str | Path) -> set[str]:
"""Load a YAML list file into a set. Returns empty set if missing."""
p = Path(path)
if not p.exists():
return set()
data = yaml.safe_load(p.read_text(encoding="utf-8")) or []
if isinstance(data, list):
return {str(s).strip() for s in data if s}
return set()
def check_links_against_whitelist(
page: dict,
allowed_external_domains: set[str],
base_netloc: str,
) -> list[dict]:
"""
Return violations: external links to domains not in allowed_external_domains.
Also flags noscript links and comment links to external domains.
"""
violations: list[dict] = []
for link_type, links in page.get("links", {}).items():
if not isinstance(links, list):
continue
for link in links:
if not isinstance(link, dict):
continue
if link.get("class") != "external":
continue
domain = urlparse(link.get("url", "")).netloc
if domain and domain not in allowed_external_domains:
violations.append({
"type": "unlisted_external_domain",
"link_type": link_type,
"url": link.get("url", ""),
"domain": domain,
})
# Comment links
for href in page.get("comment_links", []):
domain = urlparse(href).netloc
if domain and domain != base_netloc and domain not in allowed_external_domains:
violations.append({
"type": "comment_link_to_unlisted_domain",
"link_type": "comment",
"url": href,
"domain": domain,
})
# noscript links
for hc in page.get("hidden_content", []):
if hc.get("type") == "noscript_links":
for href in hc.get("links", []):
domain = urlparse(href).netloc
if domain and domain != base_netloc and domain not in allowed_external_domains:
violations.append({
"type": "noscript_link_to_unlisted_domain",
"link_type": "noscript",
"url": href,
"domain": domain,
})
return violations
def check_internal_paths(
page: dict,
allowed_paths: set[str],
) -> list[dict]:
"""
Flag internal links to paths not in the allowed_paths whitelist.
If allowed_paths is empty, the check is skipped.
"""
if not allowed_paths:
return []
violations: list[dict] = []
for link in page.get("links", {}).get("a", []):
if link.get("class") != "internal":
continue
path = urlparse(link.get("url", "")).path
if path and path not in allowed_paths:
violations.append({
"type": "unlisted_internal_path",
"url": link.get("url", ""),
"path": path,
})
return violations
# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------
def check_security_headers(response_headers: dict, required: list[str]) -> list[str]:
"""Return list of missing recommended security headers."""
# Header lookup is case-insensitive
present = {k.lower() for k in response_headers}
return [h for h in required if h.lower() not in present]
# ---------------------------------------------------------------------------
# Canonical hijack
# ---------------------------------------------------------------------------
def canonical_is_hijacked(page: dict, base_netloc: str) -> bool:
"""
Return True if the canonical URL points to a different domain.
Canonical appearing in both <link rel=canonical> and metadata is checked.
"""
canonical = (
page.get("links", {}).get("canonical")
or page.get("metadata", {}).get("canonical")
)
if not canonical:
return False
netloc = urlparse(canonical).netloc
return bool(netloc) and netloc != base_netloc
# ---------------------------------------------------------------------------
# Suspicious filenames
# ---------------------------------------------------------------------------
def is_suspicious_url(url: str) -> bool:
"""Flag URLs with names typical for webshells / backdoors."""
filename = urlparse(url).path.rsplit("/", 1)[-1]
return bool(_SUSPICIOUS_FILENAME_RE.search(filename))
# ---------------------------------------------------------------------------
# JSON-LD type check
# ---------------------------------------------------------------------------
def check_jsonld_types(page: dict, allowed_types: list[str]) -> list[dict]:
"""Return JSON-LD entries whose @type is not in the allowed list."""
allowed = set(allowed_types)
return [
j for j in page.get("jsonld", [])
if j.get("type") not in allowed
]

110
scanner/config.py Normal file
View file

@ -0,0 +1,110 @@
"""Config loading with deep-merge against defaults."""
from pathlib import Path
import yaml
DEFAULT_CONFIG: dict = {
"target": "https://bredelar.info",
"user_agent": "integrity-scanner/1.0 (+security-monitoring)",
"request_timeout": 20,
"data_dir": "data",
"reports_dir": "reports",
"logs_dir": "logs",
"config_dir": "config",
"crawl": {
"max_pages": 200,
"delay_seconds": 1.0,
"respect_robots_txt": False,
"skip_extensions": [
".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico",
".pdf", ".zip", ".woff", ".woff2", ".ttf", ".otf", ".eot",
".mp4", ".mp3", ".webm", ".avi", ".mov",
],
},
"scoring": {
"new_external_domain": 50,
"new_internal_url": 30,
"missing_internal_url": 20,
"hidden_content": 40,
"unexpected_canonical": 35,
"meta_refresh": 40,
"unexpected_jsonld": 35,
"large_text_addition": 20,
"new_inline_script_suspicious": 30,
"missing_security_header": 5,
"suspicious_content_pattern": 50,
"comment_links": 25,
"noscript_links": 25,
"new_external_link_unlisted": 40,
},
"thresholds": {
"yellow": 20,
"red": 60,
"large_text_block_chars": 200,
},
"alerting": {
"min_level": "yellow",
"email": {
"enabled": False,
"smtp_host": "localhost",
"smtp_port": 587,
"smtp_tls": True,
"smtp_user": "",
"smtp_password_env": "SCANNER_SMTP_PASSWORD",
"from": "scanner@example.com",
"to": [],
},
"webhook": {
"enabled": False,
"url": "",
},
},
"normalization": {
"strip_html_comments": True,
"collapse_whitespace": True,
"ignore_selectors": [],
"ignore_patterns": [],
},
"allowed_jsonld_types": [
"Organization", "WebSite", "WebPage", "Article", "BreadcrumbList",
"ItemList", "LocalBusiness", "Place", "Person", "Event",
"FAQPage", "Question", "Answer", "ImageObject", "SiteLinksSearchBox",
"ContactPage", "AboutPage",
],
"security_headers": [
"Content-Security-Policy",
"Strict-Transport-Security",
"X-Content-Type-Options",
"Referrer-Policy",
],
}
def load_config(config_file: str = "config.yaml") -> dict:
"""Load config from YAML, deep-merged on top of defaults."""
cfg = _deep_copy(DEFAULT_CONFIG)
p = Path(config_file)
if p.exists():
with p.open(encoding="utf-8") as f:
user_cfg = yaml.safe_load(f) or {}
_deep_update(cfg, user_cfg)
return cfg
def resolve_paths(cfg: dict, base_dir: Path) -> dict:
"""Resolve relative paths in config against base_dir."""
for key in ("data_dir", "reports_dir", "logs_dir", "config_dir"):
cfg[key] = str(base_dir / cfg[key])
return cfg
def _deep_copy(d: dict) -> dict:
import copy
return copy.deepcopy(d)
def _deep_update(base: dict, override: dict) -> None:
for k, v in override.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_deep_update(base[k], v)
else:
base[k] = v

150
scanner/crawler.py Normal file
View file

@ -0,0 +1,150 @@
"""Pure-Python crawler — no wget dependency."""
import logging
import re
import time
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
# Query parameters that indicate calendar/session/tracking noise
_NOISE_PARAM_RE = re.compile(
r"^(year|month|day|date|week|session|token|sid|nonce|_|v|cache|utm_|fbclid)",
re.I,
)
def normalize_url(url: str) -> str | None:
"""
Normalise a URL: lowercase scheme+host, drop fragment, sort and filter
query params that look like cache-busters or tracking noise.
Returns None for non-http(s) URLs.
"""
try:
p = urlparse(url.strip())
if p.scheme not in ("http", "https"):
return None
netloc = p.netloc.lower()
path = p.path or "/"
# Remove fragment
p = p._replace(netloc=netloc, path=path, fragment="")
# Keep only non-noise query params, sorted for stable comparison
if p.query:
clean_params = sorted(
(k, v)
for k, vs in parse_qs(p.query, keep_blank_values=True).items()
for v in vs
if not _NOISE_PARAM_RE.match(k)
)
p = p._replace(query=urlencode(clean_params))
return urlunparse(p)
except Exception:
return None
def should_crawl(url: str, base_netloc: str, skip_extensions: list[str]) -> bool:
"""Return True if url should be followed during a crawl."""
p = urlparse(url)
if p.netloc != base_netloc:
return False
path_lower = p.path.lower()
for ext in skip_extensions:
if path_lower.endswith(ext):
return False
# Skip URLs whose path looks like calendar/session noise
if _NOISE_PARAM_RE.search(p.query):
return False
return True
class Crawler:
def __init__(self, cfg: dict):
self.target = cfg["target"].rstrip("/")
self.base_netloc = urlparse(self.target).netloc
self.max_pages: int = cfg["crawl"]["max_pages"]
self.delay: float = cfg["crawl"]["delay_seconds"]
self.skip_ext: list[str] = cfg["crawl"]["skip_extensions"]
self.timeout: int = cfg["request_timeout"]
self.headers = {"User-Agent": cfg["user_agent"]}
def crawl(self) -> dict:
"""
Crawl the target site breadth-first.
Returns a dict with:
- pages: list of page-dicts (url, final_url, status, html, headers, ...)
- errors: list of {url, error}
"""
seen: set[str] = set()
queue: list[str] = [normalize_url(self.target + "/") or self.target + "/"]
pages: list[dict] = []
errors: list[dict] = []
while queue and len(pages) < self.max_pages:
url = queue.pop(0)
if url in seen:
continue
seen.add(url)
if self.delay > 0 and pages:
time.sleep(self.delay)
logger.debug("Crawling %s", url)
try:
resp = requests.get(
url,
headers=self.headers,
timeout=self.timeout,
allow_redirects=True,
)
final_url = normalize_url(resp.url) or resp.url
content_type = resp.headers.get("content-type", "")
is_html = "text/html" in content_type and resp.status_code == 200
page: dict = {
"url": url,
"final_url": final_url,
"status": resp.status_code,
"content_type": content_type,
"response_headers": dict(resp.headers),
"html": resp.text if is_html else None,
"redirected": final_url != url,
}
pages.append(page)
if is_html:
for link_url in self._extract_follow_links(resp.text, final_url):
if link_url not in seen and link_url not in queue:
queue.append(link_url)
except requests.exceptions.RequestException as exc:
errors.append({"url": url, "error": str(exc)})
logger.warning("Error crawling %s: %s", url, exc)
logger.info(
"Crawl finished: %d pages, %d errors, %d queued but skipped",
len(pages),
len(errors),
len(queue),
)
return {
"target": self.target,
"pages": pages,
"errors": errors,
}
def _extract_follow_links(self, html: str, base_url: str) -> list[str]:
"""Extract internal <a href> links worth following."""
result = []
try:
soup = BeautifulSoup(html, "lxml")
for tag in soup.find_all("a", href=True):
href = tag["href"].strip()
abs_url = normalize_url(urljoin(base_url, href))
if abs_url and should_crawl(abs_url, self.base_netloc, self.skip_ext):
result.append(abs_url)
except Exception as exc:
logger.debug("Link extraction failed on %s: %s", base_url, exc)
return result

338
scanner/differ.py Normal file
View file

@ -0,0 +1,338 @@
"""
Diff logic: text, link, metadata, and URL-set comparison.
Scoring maps diff results to a risk level (green/yellow/red).
"""
import difflib
import hashlib
import re
from urllib.parse import urlparse
# ---------------------------------------------------------------------------
# Normalisation
# ---------------------------------------------------------------------------
def normalize_text(text: str, cfg: dict) -> str:
"""Apply noise-reduction before diffing."""
norm_cfg = cfg.get("normalization", {})
if norm_cfg.get("collapse_whitespace", True):
text = re.sub(r"\s+", " ", text).strip()
for pattern in norm_cfg.get("ignore_patterns", []):
text = re.sub(pattern, "[IGNORED]", text)
return text
def text_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Atomic diffs
# ---------------------------------------------------------------------------
def diff_text(old: str, new: str, context: int = 2) -> list[str]:
"""Unified diff of two normalised texts."""
return list(difflib.unified_diff(
old.splitlines(),
new.splitlines(),
fromfile="baseline",
tofile="current",
lineterm="",
n=context,
))
def diff_link_set(old_list: list[dict], new_list: list[dict]) -> dict:
"""Compare two link lists by URL."""
old_urls = {l["url"] for l in old_list if isinstance(l, dict) and "url" in l}
new_urls = {l["url"] for l in new_list if isinstance(l, dict) and "url" in l}
return {
"added": sorted(new_urls - old_urls),
"removed": sorted(old_urls - new_urls),
}
def diff_metadata(old: dict, new: dict) -> dict:
"""Compare metadata fields that matter for integrity monitoring."""
changes: dict = {}
for key in ("title", "description", "canonical", "robots"):
if old.get(key) != new.get(key):
changes[key] = {"old": old.get(key), "new": new.get(key)}
for heading in ("h1", "h2"):
old_set = set(old.get(heading, []))
new_set = set(new.get(heading, []))
if old_set != new_set:
changes[heading] = {
"added": sorted(new_set - old_set),
"removed": sorted(old_set - new_set),
}
return changes
def _all_external_domains(pages: dict) -> set[str]:
"""Collect every external domain referenced across all pages."""
domains: set[str] = set()
for page in pages.values():
for link_type, links in page.get("links", {}).items():
if not isinstance(links, list):
continue
for l in links:
if isinstance(l, dict) and l.get("class") == "external":
netloc = urlparse(l.get("url", "")).netloc
if netloc:
domains.add(netloc)
return domains
# ---------------------------------------------------------------------------
# Full snapshot comparison
# ---------------------------------------------------------------------------
def compare_snapshots(baseline_pages: dict, current_pages: dict, cfg: dict) -> dict:
"""
Compare two page sets (url page_dict) and produce a structured diff.
Returns a dict with:
new_internal_urls, missing_internal_urls,
new_external_domains, removed_external_domains,
page_diffs (list of per-page change records),
new_pages_analysis (freshly seen pages checked for immediate issues)
"""
b_urls = set(baseline_pages)
c_urls = set(current_pages)
new_urls = sorted(c_urls - b_urls)
missing_urls = sorted(b_urls - c_urls)
common_urls = sorted(b_urls & c_urls)
page_diffs: list[dict] = []
for url in common_urls:
old = baseline_pages[url]
new = current_pages[url]
old_text = normalize_text(old.get("text", ""), cfg)
new_text = normalize_text(new.get("text", ""), cfg)
text_changed = text_hash(old_text) != text_hash(new_text)
# Quick-skip if nothing changed (text + links + meta all identical)
if (
not text_changed
and old.get("links") == new.get("links")
and old.get("metadata") == new.get("metadata")
and old.get("hidden_content") == new.get("hidden_content")
and old.get("inline_scripts") == new.get("inline_scripts")
and old.get("jsonld") == new.get("jsonld")
):
continue
text_diff = diff_text(old_text, new_text) if text_changed else []
# Per-type link diff
link_diff: dict = {}
for ltype in ("a", "script", "iframe", "link_rel", "form"):
old_links = old.get("links", {}).get(ltype, [])
new_links = new.get("links", {}).get(ltype, [])
ld = diff_link_set(old_links, new_links)
if ld["added"] or ld["removed"]:
link_diff[ltype] = ld
meta_diff = diff_metadata(old.get("metadata", {}), new.get("metadata", {}))
# New hidden content (compare by fingerprint to avoid dupe noise)
old_hc_fps = {_fingerprint(h) for h in old.get("hidden_content", [])}
new_hidden = [
h for h in new.get("hidden_content", [])
if _fingerprint(h) not in old_hc_fps
]
# New suspicious inline scripts
old_scripts_fps = {_fingerprint(s) for s in old.get("inline_scripts", [])}
new_scripts = [
s for s in new.get("inline_scripts", [])
if _fingerprint(s) not in old_scripts_fps
]
# New JSON-LD
old_jld_fps = {_fingerprint(j) for j in old.get("jsonld", [])}
new_jsonld = [
j for j in new.get("jsonld", [])
if _fingerprint(j) not in old_jld_fps
]
# New meta-refresh targets
old_mr = set(old.get("links", {}).get("meta_refresh", []))
new_mr = [u for u in new.get("links", {}).get("meta_refresh", []) if u not in old_mr]
# New canonical
old_canonical = old.get("links", {}).get("canonical") or old.get("metadata", {}).get("canonical")
new_canonical = new.get("links", {}).get("canonical") or new.get("metadata", {}).get("canonical")
canonical_changed = old_canonical != new_canonical
# New comment links
old_cl = set(old.get("comment_links", []))
new_cl = [u for u in new.get("comment_links", []) if u not in old_cl]
if any([
text_diff, link_diff, meta_diff, new_hidden, new_scripts,
new_jsonld, new_mr, canonical_changed, new_cl,
]):
added_chars = sum(
len(line[1:]) for line in text_diff
if line.startswith("+") and not line.startswith("+++")
)
page_diffs.append({
"url": url,
"text_diff": text_diff,
"added_chars": added_chars,
"link_diff": link_diff,
"meta_diff": meta_diff,
"new_hidden_content": new_hidden,
"new_inline_scripts": new_scripts,
"new_jsonld": new_jsonld,
"new_meta_refresh": new_mr,
"canonical_changed": canonical_changed,
"old_canonical": old_canonical,
"new_canonical": new_canonical,
"new_comment_links": new_cl,
})
# Site-wide external domain diff
old_ext = _all_external_domains(baseline_pages)
new_ext = _all_external_domains(current_pages)
# Analysis of brand-new pages (no baseline to compare against)
new_pages_analysis = [
_quick_check_new_page(current_pages[url])
for url in new_urls
if url in current_pages
]
return {
"new_internal_urls": new_urls,
"missing_internal_urls": missing_urls,
"new_external_domains": sorted(new_ext - old_ext),
"removed_external_domains": sorted(old_ext - new_ext),
"page_diffs": page_diffs,
"new_pages_analysis": new_pages_analysis,
"total_pages_checked": len(common_urls),
"changed_pages": len(page_diffs),
}
def _quick_check_new_page(page: dict) -> dict:
"""Summarise a new (not in baseline) page for immediate risk indicators."""
return {
"url": page["url"],
"hidden_content": page.get("hidden_content", []),
"inline_scripts": page.get("inline_scripts", []),
"unexpected_jsonld": [j for j in page.get("jsonld", []) if j.get("unexpected")],
"meta_refresh": page.get("links", {}).get("meta_refresh", []),
"comment_links": page.get("comment_links", []),
"external_link_count": sum(
1 for links in page.get("links", {}).values()
if isinstance(links, list)
for l in links
if isinstance(l, dict) and l.get("class") == "external"
),
}
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
def score_diff(diff: dict, cfg: dict) -> dict:
"""
Map a diff result to a risk score and level.
Returns {score, level, reasons, exit_code}.
"""
sc = cfg.get("scoring", {})
thr = cfg.get("thresholds", {"yellow": 20, "red": 60})
large_text = thr.get("large_text_block_chars", 200)
score = 0
reasons: list[str] = []
def add(pts: int, msg: str) -> None:
nonlocal score
score += pts
reasons.append(f"{msg} (+{pts})")
for domain in diff.get("new_external_domains", []):
add(sc.get("new_external_domain", 50), f"Neue externe Domain: {domain}")
for url in diff.get("new_internal_urls", []):
add(sc.get("new_internal_url", 30), f"Neue interne URL: {url}")
for url in diff.get("missing_internal_urls", []):
add(sc.get("missing_internal_url", 20), f"Fehlende URL: {url}")
for pd in diff.get("page_diffs", []):
u = pd["url"]
for h in pd.get("new_hidden_content", []):
add(sc.get("hidden_content", 40),
f"{u}: Hidden Content [{', '.join(h.get('indicators', []))}]")
for mr in pd.get("new_meta_refresh", []):
add(sc.get("meta_refresh", 40), f"{u}: Meta-Refresh → {mr}")
for s in pd.get("new_inline_scripts", []):
add(sc.get("new_inline_script_suspicious", 30),
f"{u}: Verdächtiges Inline-Script [{', '.join(s.get('patterns', []))}]")
for j in pd.get("new_jsonld", []):
if j.get("unexpected"):
add(sc.get("unexpected_jsonld", 35),
f"{u}: Unerwartetes JSON-LD @type={j.get('type')}")
if pd.get("canonical_changed"):
add(sc.get("unexpected_canonical", 35),
f"{u}: Canonical geändert → {pd.get('new_canonical')}")
for cl in pd.get("new_comment_links", []):
add(sc.get("comment_links", 25), f"{u}: Link in HTML-Kommentar: {cl}")
if pd.get("added_chars", 0) > large_text:
add(sc.get("large_text_addition", 20),
f"{u}: +{pd['added_chars']} Zeichen neuer Text")
# New pages: immediate suspicious content counts too
for npa in diff.get("new_pages_analysis", []):
u = npa["url"]
if npa.get("hidden_content"):
add(sc.get("hidden_content", 40), f"{u} (neu): Hidden Content")
if npa.get("inline_scripts"):
add(sc.get("new_inline_script_suspicious", 30), f"{u} (neu): Verdächtiges Script")
if npa.get("meta_refresh"):
add(sc.get("meta_refresh", 40), f"{u} (neu): Meta-Refresh")
if npa.get("comment_links"):
add(sc.get("comment_links", 25), f"{u} (neu): Kommentar-Link")
if npa.get("unexpected_jsonld"):
add(sc.get("unexpected_jsonld", 35), f"{u} (neu): Unerwartetes JSON-LD")
yellow = thr.get("yellow", 20)
red = thr.get("red", 60)
if score >= red:
level = "red"
exit_code = 2
elif score >= yellow:
level = "yellow"
exit_code = 1
else:
level = "green"
exit_code = 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
return hashlib.md5( # noqa: S324 — not crypto, just dedup
_json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()

312
scanner/extractor.py Normal file
View file

@ -0,0 +1,312 @@
"""
HTML extraction with BeautifulSoup4+lxml.
Extracts: visible text, links (all types), metadata, hidden-content indicators,
inline script patterns, JSON-LD, and links buried in HTML comments.
"""
import json
import re
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup, Comment
# Inline-script patterns that warrant flagging
_SUSPICIOUS_INLINE = [
(r"eval\s*\(", "eval()"),
(r"atob\s*\(", "atob()"),
(r"fromCharCode", "fromCharCode"),
(r"document\.createElement\s*\(\s*[\"']script[\"']", "createElement(script)"),
(r"\bfetch\s*\(", "fetch()"),
(r"XMLHttpRequest", "XMLHttpRequest"),
(r"base64_decode", "base64_decode"),
]
# CSS-based hiding patterns (applied to inline style attributes)
_HIDDEN_STYLE_CHECKS = [
(r"display\s*:\s*none", "display:none"),
(r"visibility\s*:\s*hidden", "visibility:hidden"),
(r"opacity\s*:\s*0(?:\.0+)?(?!\d)", "opacity:0"),
(r"font-size\s*:\s*0", "font-size:0"),
(r"color\s*:\s*(?:white|#fff(?:fff)?|rgba?\(\s*255\s*,\s*255\s*,\s*255)", "white-text"),
(r"width\s*:\s*0(?:px)?[;\s]", "width:0"),
(r"height\s*:\s*0(?:px)?[;\s]", "height:0"),
(r"position\s*:\s*absolute", "position:absolute"), # combined with off-screen check below
(r"(?:left|top)\s*:\s*-\d{3,}", "off-screen-position"),
(r"clip\s*:\s*rect\s*\(\s*0\s*,?\s*0\s*,?\s*0\s*,?\s*0\s*\)", "clip:rect(0)"),
(r"text-indent\s*:\s*-\d{3,}", "text-indent-offscreen"),
]
def extract_page(html: str, url: str, cfg: dict | None = None) -> dict:
"""
Full extraction from page HTML.
Returns a structured dict with all data needed for diffing and checking.
"""
cfg = cfg or {}
soup = BeautifulSoup(html, "lxml")
# Remove ignored selectors before text extraction
ignore_selectors = cfg.get("normalization", {}).get("ignore_selectors", [])
for sel in ignore_selectors:
for el in soup.select(sel):
el.decompose()
return {
"url": url,
"text": _visible_text(soup),
"links": _extract_links(soup, url),
"metadata": _extract_metadata(soup),
"hidden_content": _find_hidden_content(soup),
"inline_scripts": _check_inline_scripts(soup),
"jsonld": _extract_jsonld(soup),
"comment_links": _find_comment_links(html, url),
}
# ---------------------------------------------------------------------------
# Text
# ---------------------------------------------------------------------------
def _visible_text(soup: BeautifulSoup) -> str:
"""Extract visible text — strips scripts, styles, noscript, head."""
work = soup.__copy__()
for tag in work(["script", "style", "noscript", "head", "meta", "link"]):
tag.decompose()
text = work.get_text(separator=" ")
return re.sub(r"\s+", " ", text).strip()
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
def _extract_links(soup: BeautifulSoup, base_url: str) -> dict:
"""
Extract all link categories:
a, link, script[src], img[src], iframe[src], form[action],
meta_refresh, canonical
Each entry: {url, class: 'internal'|'external', ...extra}
"""
base_netloc = urlparse(base_url).netloc
def classify(u: str) -> str:
return "internal" if urlparse(u).netloc == base_netloc else "external"
def resolve(href: str | None) -> str | None:
if not href:
return None
href = href.strip()
if href.startswith(("javascript:", "mailto:", "tel:", "data:", "#")):
return None
abs_url = urljoin(base_url, href)
p = urlparse(abs_url)
if p.scheme not in ("http", "https"):
return None
# Drop fragment
return abs_url.split("#")[0]
result: dict = {
"a": [],
"link_rel": [],
"script": [],
"img": [],
"iframe": [],
"form": [],
"meta_refresh": [],
"canonical": None,
}
for tag in soup.find_all("a", href=True):
u = resolve(tag["href"])
if u:
result["a"].append({
"url": u,
"text": tag.get_text(" ", strip=True)[:200],
"class": classify(u),
"rel": " ".join(tag.get("rel", [])),
})
for tag in soup.find_all("link", href=True):
u = resolve(tag["href"])
if not u:
continue
rel = " ".join(tag.get("rel", []))
if "canonical" in rel:
result["canonical"] = u
result["link_rel"].append({"url": u, "rel": rel, "class": classify(u)})
for tag in soup.find_all("script", src=True):
u = resolve(tag["src"])
if u:
result["script"].append({"url": u, "class": classify(u)})
for tag in soup.find_all("img", src=True):
u = resolve(tag["src"])
if u:
result["img"].append({"url": u, "class": classify(u)})
for tag in soup.find_all("iframe", src=True):
u = resolve(tag["src"])
if u:
result["iframe"].append({"url": u, "class": classify(u)})
for tag in soup.find_all("form", action=True):
u = resolve(tag["action"])
if u:
result["form"].append({"url": u, "class": classify(u)})
for tag in soup.find_all("meta"):
if tag.get("http-equiv", "").lower() == "refresh":
content = tag.get("content", "")
m = re.search(r"url\s*=\s*(.+)", content, re.I)
if m:
u = resolve(m.group(1).strip().strip("\"'"))
if u:
result["meta_refresh"].append(u)
return result
# ---------------------------------------------------------------------------
# Metadata
# ---------------------------------------------------------------------------
def _extract_metadata(soup: BeautifulSoup) -> dict:
"""Extract title, description, canonical, og-tags, robots, hreflang, H1/H2."""
meta: dict = {}
t = soup.find("title")
meta["title"] = t.get_text(strip=True) if t else None
canonical_tag = soup.find("link", rel="canonical")
if canonical_tag:
meta["canonical"] = canonical_tag.get("href", "").strip()
for tag in soup.find_all("meta"):
name = (tag.get("name") or "").lower().strip()
prop = (tag.get("property") or "").lower().strip()
content = tag.get("content", "")
if name == "description":
meta["description"] = content
elif name == "robots":
meta["robots"] = content
elif name == "keywords":
meta["keywords"] = content
elif prop.startswith("og:"):
meta[prop] = content
elif prop.startswith("twitter:"):
meta[prop] = content
hreflang = []
for tag in soup.find_all("link", rel="alternate"):
hl = tag.get("hreflang")
if hl:
hreflang.append({"lang": hl, "href": tag.get("href", "")})
meta["hreflang"] = hreflang
meta["h1"] = [h.get_text(" ", strip=True) for h in soup.find_all("h1")]
meta["h2"] = [h.get_text(" ", strip=True) for h in soup.find_all("h2")]
return meta
# ---------------------------------------------------------------------------
# Hidden content
# ---------------------------------------------------------------------------
def _find_hidden_content(soup: BeautifulSoup) -> list[dict]:
"""
Detect elements that are visually hidden but contain text or links.
Catches common SEO-spam hiding techniques.
"""
findings: list[dict] = []
for tag in soup.find_all(style=True):
style = tag.get("style", "")
matched_indicators = []
for pattern, label in _HIDDEN_STYLE_CHECKS:
if re.search(pattern, style, re.I):
matched_indicators.append(label)
if not matched_indicators:
continue
text = tag.get_text(" ", strip=True)[:300]
links = [a.get("href", "") for a in tag.find_all("a", href=True)]
if text or links:
findings.append({
"type": "inline_style_hidden",
"indicators": matched_indicators,
"tag": tag.name,
"text_preview": text,
"links": links,
})
# <noscript> with links
for tag in soup.find_all("noscript"):
links = re.findall(r'href=["\']([^"\']+)["\']', str(tag), re.I)
if links:
findings.append({
"type": "noscript_links",
"links": links,
"text_preview": tag.get_text(" ", strip=True)[:200],
})
return findings
# ---------------------------------------------------------------------------
# Inline scripts
# ---------------------------------------------------------------------------
def _check_inline_scripts(soup: BeautifulSoup) -> list[dict]:
"""Find inline <script> blocks that match suspicious patterns."""
findings: list[dict] = []
for i, tag in enumerate(soup.find_all("script"), 1):
if tag.get("src"):
continue
code = tag.get_text() or ""
hits = [label for pat, label in _SUSPICIOUS_INLINE if re.search(pat, code, re.I)]
if hits:
findings.append({
"script_index": i,
"patterns": hits,
"preview": code[:400],
})
return findings
# ---------------------------------------------------------------------------
# JSON-LD
# ---------------------------------------------------------------------------
def _extract_jsonld(soup: BeautifulSoup) -> list[dict]:
"""Extract JSON-LD blocks and flag unexpected @types."""
results: list[dict] = []
for tag in soup.find_all("script", type="application/ld+json"):
raw = tag.string or ""
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
results.append({"type": "PARSE_ERROR", "unexpected": True, "preview": raw[:200]})
continue
items = data if isinstance(data, list) else [data]
for item in items:
t = item.get("@type", "Unknown")
results.append({"type": t, "preview": json.dumps(item, ensure_ascii=False)[:500]})
return results
# ---------------------------------------------------------------------------
# Comment links
# ---------------------------------------------------------------------------
def _find_comment_links(html: str, base_url: str) -> list[str]:
"""Find URLs hidden inside HTML comments."""
found: list[str] = []
for comment in re.findall(r"<!--(.*?)-->", html, re.S):
for href in re.findall(r'href=["\']([^"\']+)["\']', comment, re.I):
if href.startswith(("http://", "https://", "/")):
found.append(urljoin(base_url, href))
return found