chore: remove Keller/ from repo and add to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-12 21:42:15 +02:00
commit b6838bb346
3 changed files with 3 additions and 1003 deletions

3
.gitignore vendored
View file

@ -22,6 +22,9 @@ env/
htmlcov/
.tox/
# Lokale Ablage
Keller/
# Site-Verzeichnisse komplett (Configs + Daten, lokal verwaltet)
bredelar.info/
jamulix.de/

View file

@ -1,249 +0,0 @@
Erstelle bitte ein produktionsreifes, möglichst schlankes Kommandozeilen-Kontroll-Programm in Python zur unabhängigen Überwachung der kleinen, statischen Website https://bredelar.info (einstellbar) gegen Manipulationen. Es soll manuell oder als Cron-Programm einsetzbar sein.
Ziel
Das Programm soll im Verantwortungsbereich des Betreibers laufen, nicht beim beauftragten Web-Dienstleister. Es soll regelmäßig, z. B. täglich per Cron um 03:00 Uhr (einstellbar) , die gesamte Web-Repräsentanz von bredelar.info überwachen und Alarm auslösen, wenn unerwartete Änderungen, neue Seiten, unerlaubte Links oder problematische Inhalte auftauchen. Der Fokus liegt auf robuster Erkennung bei möglichst geringem Betriebsaufwand. Die Website ist klein und soll klein bleiben.
Wichtige Rahmenbedingungen
- Die Website ist klein, vermutlich mit wenigen Seiten.
- Es gab bereits einen SEO-Spam-/Parasite-SEO-artigen Vorfall.
- Angreifer können Texte thematisch passend tarnen; reine Keyword-Heuristik reicht daher nicht.
- Das System soll unabhängig vom Dienstleister arbeiten.
- Das System soll auf einem kleinen Linux-VHost oder Minimalserver laufen können.
- Bevorzugte Sprachen/Tools: Python 3 oder Shell, aber bitte pragmatisch entscheiden.
- Ergebnis soll einfach installierbar, wartbar und nachvollziehbar sein.
- Keine unnötig komplexe Infrastruktur, keine Kubernetes-, Cloud-, Queue- oder Datenbank-Overkill-Lösung.
Die Lösung soll diese drei Kernprobleme adressieren
1. Integritätsprüfung / Diff-Monitoring
- Prüfe regelmäßig alle relevanten Seiten von bredelar.info.
- Erstelle Snapshots einer „gesunden“ Referenzversion.
- Vergleiche spätere Abrufe mit dieser Referenz.
- Alarmiere bei jeder relevanten Abweichung.
- Alarm muss detailliert sein: betroffene URL, Art der Änderung, alter/neuer Zustand, neue/entfernte Links, neue/entfernte Textelemente, neue/entfernte HTML-Elemente, Risiko-Einstufung.
- Änderungen durch legitime Pflege müssen ebenfalls erkannt werden; sie dürfen erst nach manueller Freigabe zur neuen Referenz/Baseline werden.
- Wichtig: Das System darf kompromittierte Inhalte niemals automatisch als neue „gesunde“ Basis übernehmen.
2. Link-Kontrolle / Whitelist
- Finde alle offenen und verdeckten Links auf der gesamten Web-Repräsentanz.
- Prüfe sichtbare Links, versteckte Links, Links in Metadaten, Canonical, hreflang, redirects, iframe/src, script/src, img/src, form action, meta refresh, JSON-LD, strukturierte Daten, noscript-Bereiche, Kommentare, data-Attribute, CSS-basierte Hinweise, falls sinnvoll.
- Führe eine Whitelist erlaubter externer Ziele und eine Whitelist erlaubter interner URL-Pfade.
- Jede neue externe Ziel-Domain oder unerwartete interne URL soll Alarm auslösen.
- SEO-Spam braucht Links; daher ist das ein zentraler Kontrollpunkt.
3. Problematische Inhalte
- Erkenne auch problematische Inhalte ohne Links, z. B. Pornografie, Dirty Talk, politische Propaganda, diffamierende oder strafbare Inhalte, widersprüchliche Aussagen, versteckte Spam-Texte oder thematisch unpassende kommerzielle Inhalte.
- Nutze dafür einfache robuste Regeln plus optional eine zusätzliche semantische/KI-gestützte Analyse.
- Aber: KI/semantische Analyse ist Ergänzung, nicht Kern. Kern bleibt Integritäts- und Link-Monitoring.
- Gute Angreifer können Dorf-/Heimat-Begriffe einbauen; daher bitte nicht auf simple White-/Blacklist-Keywords vertrauen.
Ganz wichtige Zusatzanforderung: „Gesunde“ Datenausgangslage und Pflege
Das System muss sauber mit einer Referenz-/Baseline-Verwaltung umgehen:
A. Initiale gesunde Ausgangslage erzeugen
- Implementiere einen expliziten Initialisierungsmodus.
- Das System soll die Domain crawlen, bekannte Seiten erfassen und daraus eine erste Referenz erzeugen.
- Diese Referenz darf nicht blind übernommen werden; es muss einen Review-/Freigabeprozess geben.
- Liefere dafür einen klaren Workflow:
1. Crawl der Domain
2. Fundliste aller internen Seiten
3. Extraktion aller externen Links
4. Erkennung möglicher Auffälligkeiten
5. Manuelle Prüfung
6. Erst nach Freigabe: Speichern als „gesunde“ Baseline
B. Pflege der gesunden Daten
- Änderungen an legitimen Inhalten sollen nicht automatisch in die Baseline einfließen.
- Stattdessen:
- Monitoring meldet Änderung
- Mensch prüft Änderung
- separater „approve baseline“-Schritt übernimmt die neue Version
- Baselines sollen versioniert und nachvollziehbar abgelegt werden.
- Es soll möglich sein, pro URL eine neue Referenz freizugeben.
- Es soll auch möglich sein, die gesamte Site-Referenz neu aufzubauen, wenn es große legitime Änderungen gibt.
- Es soll erkennbar bleiben, wer/was/wann eine Baseline freigegeben hat.
Crawler- und Erkennungslogik
Bitte entwerfe das System so, dass es nicht nur bekannte Seiten prüft, sondern die Domain aktiv crawlt:
- Start mit einer Seed-Liste
- Danach internes Follow aller erlaubten Links
- Begrenzung auf bredelar.info
- Erkennung neuer interner URLs
- Erkennung von Seiten, die plötzlich verschwunden sind
- Optional: robots.txt respektieren konfigurierbar
- Schutz gegen Crawl-Explosion, Parameter-Müll und Kalender-/Session-URLs
Das System soll auf mehreren Ebenen vergleichen:
- Roh-HTML-Diff
- bereinigter DOM-/Text-Diff
- Link-Diff
- URL-Diff der gefundenen internen Seiten
- Metadaten-Diff (title, meta description, canonical, hreflang, robots, og-tags)
- Hidden-Content-Indikatoren
- Optional Header-Checks
Wichtig: Normalisierung gegen Fehlalarme
Bitte implementiere vor dem Diff sinnvolle Normalisierung, damit harmlose Änderungen nicht dauernd Alarm auslösen:
- Whitespace normalisieren
- irrelevante Timestamps, Cache-Buster, Nonces, zufällige IDs, Tracking-Parameter optional ignorieren
- HTML in eine stabile Form bringen
- konfigurierbare Ignore-Regeln für Seitenbereiche oder Selektoren
- getrennte Betrachtung von Roh-HTML und sichtbar extrahiertem Inhalt
Hidden-/SEO-Spam-spezifische Checks
Bitte berücksichtige speziell typische SEO-Spam-/Parasite-SEO-Indikatoren:
- neue interne Unterseiten
- neue externe Links
- versteckte Links
- display:none / visibility:hidden / opacity:0 / off-screen positioning
- meta refresh
- unerwartete canonical-Tags
- unerwartete strukturierte Daten / JSON-LD mit fremden Entities
- starke Änderungen in Title/H1/H2
- längere neue Textblöcke
- eingebettete kommerzielle Blöcke innerhalb eigentlich lokaler Inhalte
- auffällige Script-/Iframe-Einbindungen
- Kommentar- oder noscript-Missbrauch
- falls praktikabel: einfache Scoring-Logik für Verdachtsfälle
Alarmierung
Das System soll Alarmmeldungen verschicken können, idealerweise modular:
- E-Mail als Mindeststandard
- optional Webhook/Signal/Matrix/Telegram/SMS als Erweiterung
- Alarmmeldungen sollen kompakt, aber forensisch brauchbar sein
- Beispielinhalt:
- Zeitpunkt
- URL
- Schweregrad
- neue interne URLs
- neue externe Domains
- Link-Diff
- Text-Diff
- Hidden-Content-Hinweise
- Empfehlung: nur beobachten / prüfen / sofort handeln
Technische Zielarchitektur
Bitte schlage eine minimalistische, aber saubere Struktur vor, z. B.:
- ein Hauptprogramm mit Subcommands oder mehrere kleine Skripte
- Cron-tauglich
- Konfigurationsdateien in YAML oder JSON
- lokale Dateistruktur für:
- seeds
- erlaubte interne URLs
- erlaubte externe Links/Domains
- snapshots
- baselines
- reports
- state
- logs
- bitte ohne unnötige externe Dienste, wenn nicht zwingend erforderlich
Gewünschte Artefakte
Ich möchte von dir eine vollständige, direkt nutzbare Lösung erzeugt bekommen. Bitte liefere:
1. Architekturüberblick
- kurz und präzise
- welche Komponenten gibt es und warum
2. Bedrohungsmodell
- was die Lösung erkennt
- was sie nicht erkennt
- welche Annahmen gelten
3. Projektstruktur
- konkrete Dateibaum-Struktur
4. Konfigurationsdateien
- vollständig, mit sinnvollen Beispielen
- z. B. config.yaml, seeds.yaml, allowed_external_domains.yaml, allowed_internal_paths.yaml, ignore_rules.yaml
5. Vollständigen Quellcode
- möglichst in Python 3
- sauber strukturiert
- modular
- mit CLI-Subcommands wie z. B.:
- init-baseline
- crawl
- check
- approve
- report
- robuste Fehlerbehandlung
- keine unnötige Magie
- gut lesbar
6. HTML-Parsing und Extraktion
- stabile Extraktion von:
- sichtbarem Text
- Links
- Assets/Referenzen
- Metadaten
- Hidden-Content-Hinweisen
- bitte geeignete Bibliotheken wählen und begründen
7. Diff-Logik
- wie Roh-HTML-, Text-, Link- und URL-Diffs erzeugt werden
- Ausgabe menschenlesbar und maschinenlesbar
8. Alarmierungsmodul
- zunächst mindestens E-Mail
- optional Webhook-Schnittstelle abstrahiert
- Beispielkonfiguration
9. Cron-Setup
- konkrete Cron-Beispiele
- z. B. täglicher Check 03:00 Uhr
- Log-Rotation oder Umgang mit Logs
10. Workflow-Dokumentation
- Erstinitialisierung der „gesunden“ Daten
- Review/Freigabe
- täglicher Betrieb
- Umgang mit legitimen Änderungen
- Incident-Fall: was tun bei Alarm
11. Sicherheits- und Qualitätsverbesserungen
- sinnvolle optionale Erweiterungen
- aber nur pragmatisch, nicht überengineert
12. Tests
- mindestens ein paar einfache Unit- oder Integrations-Tests
- insbesondere für:
- URL-Normalisierung
- Link-Extraktion
- Hidden-Content-Erkennung
- Baseline-Freigabeprozess
13. Beispielausgabe
- Beispiel für normalen Bericht ohne Auffälligkeiten
- Beispiel für Alarm bei neuer SEO-Spam-Seite
- Beispiel für Alarm bei neuem externen Link
- Beispiel für Alarm bei legitimer Änderung, die anschließend freigegeben wird
Wichtige Designprinzipien
- So einfach wie möglich, aber nicht einfacher.
- Kleine Website, kleine Lösung.
- Hohe Nachvollziehbarkeit.
- Keine automatische Selbstheilung.
- Keine automatische Baseline-Aktualisierung.
- Lieber wenige robuste Regeln als viele fragile.
- Unabhängige Kontrolle des Dienstleisters.
- Gute Wartbarkeit für technisch versierte Betreiber.
Wichtige technische Präferenzen
- Linux-tauglich
- Python 3 bevorzugt
- Wenige, verbreitete Bibliotheken
- Keine schweren externen Datenbanken, wenn Dateispeicherung reicht
- UTF-8 überall
- Saubere Trennung zwischen Konfiguration, Zustand, Referenzdaten und Reports
Bitte beginne mit:
1. einer kurzen Zielarchitektur,
2. danach der konkreten Projektstruktur,
3. dann allen Dateien mit vollständigem Inhalt,
4. und zum Schluss einer knappen Betriebsanleitung.
Wichtig:
- Bitte keine abstrakte Beratung allein, sondern vollständige umsetzbare Artefakte.
- Falls du Annahmen treffen musst, triff pragmatische Annahmen und dokumentiere sie.
- Der Code soll direkt als Ausgangspunkt verwendbar sein.

View file

@ -1,754 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin, urlparse
import requests
DEFAULT_ALLOWED = {
'html','htm','css','js','mjs','json','txt','xml','svg',
'png','jpg','jpeg','gif','webp','ico','map',
'woff','woff2','ttf','otf','eot','pdf','webmanifest'
}
SUSPICIOUS_NAME_RE = re.compile(
r'(shell|backdoor|cmd|upload|gate|mailer|wso|c99|r57|b374k)',
re.I
)
SUSPICIOUS_CONTENT_PATTERNS = [
r'eval\s*\(',
r'base64_decode',
r'shell_exec\s*\(',
r'passthru\s*\(',
r'system\s*\(',
r'exec\s*\(',
r'cmd\.exe',
r'powershell',
r'document\.createElement\s*\(\s*[\"\']script[\"\']\s*\)',
r'new\s+Image\s*\(\s*\)\.src',
r'fromCharCode',
r'atob\s*\(',
r'fetch\s*\(',
r'XMLHttpRequest'
]
HEADERS = {"User-Agent": "site-audit/4.0 (+security review)"}
class AssetParser(HTMLParser):
def __init__(self):
super().__init__()
self.links = []
self.inline_scripts = []
self.inline_script = []
self.in_script = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'a' and attrs.get('href'):
self.links.append(('a', attrs['href']))
elif tag == 'link' and attrs.get('href'):
self.links.append(('link', attrs['href']))
elif tag == 'script' and attrs.get('src'):
self.links.append(('script', attrs['src']))
elif tag == 'img' and attrs.get('src'):
self.links.append(('img', attrs['src']))
elif tag == 'source' and attrs.get('src'):
self.links.append(('source', attrs['src']))
elif tag == 'iframe' and attrs.get('src'):
self.links.append(('iframe', attrs['src']))
elif tag == 'script' and not attrs.get('src'):
self.in_script = True
self.inline_script = []
def handle_endtag(self, tag):
if tag == 'script' and self.in_script:
self.inline_scripts.append(''.join(self.inline_script))
self.in_script = False
def handle_data(self, data):
if self.in_script:
self.inline_script.append(data)
def sha256_file(path: Path):
h = hashlib.sha256()
with path.open('rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def ensure_dir(p: Path):
p.mkdir(parents=True, exist_ok=True)
def is_same_host(base, target):
b = urlparse(base)
t = urlparse(target)
return (not t.netloc) or (t.netloc == b.netloc)
def clean_url(url):
parsed = urlparse(url)
return parsed._replace(fragment='').geturl()
def mirror_with_wget(url: str, outdir: Path):
ensure_dir(outdir)
cmd = [
'wget',
'--mirror',
'--convert-links',
'--adjust-extension',
'--page-requisites',
'--no-parent',
'--directory-prefix',
str(outdir),
url,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
return {
'returncode': proc.returncode,
'stdout': proc.stdout[-4000:],
'stderr': proc.stderr[-4000:],
}
def collect_files(root: Path):
return sorted([p for p in root.rglob('*') if p.is_file()])
def scan_tree(root: Path, allowed_exts):
findings = defaultdict(list)
for p in collect_files(root):
rel = str(p.relative_to(root))
ext = p.suffix.lower().lstrip('.')
if ext and ext not in allowed_exts:
findings['unexpected_extensions'].append(rel)
if SUSPICIOUS_NAME_RE.search(p.name):
findings['suspicious_filenames'].append(rel)
try:
data = p.read_text(encoding='utf-8', errors='ignore')
except Exception:
continue
for pat in SUSPICIOUS_CONTENT_PATTERNS:
if re.search(pat, data, re.I):
findings['suspicious_content'].append(
{'file': rel, 'pattern': pat}
)
if '<script' in data.lower() and ('http://' in data.lower() or 'https://' in data.lower()):
findings['external_refs_in_text'].append(rel)
return findings
def normalize_internal_url(base_url: str, ref: str):
full = clean_url(urljoin(base_url, ref))
parsed = urlparse(full)
if parsed.scheme not in ('http', 'https'):
return None
return full
def should_crawl(url: str, base_url: str):
if not is_same_host(base_url, url):
return False
parsed = urlparse(url)
path = parsed.path.lower()
if path.endswith((
'.jpg','.jpeg','.png','.gif','.webp','.svg','.ico',
'.pdf','.zip','.woff','.woff2','.ttf','.otf','.eot',
'.mp4','.mp3','.webm','.avi','.mov'
)):
return False
return True
def crawl_site(start_url: str, max_pages: int = 200):
seen = set()
queue = [clean_url(start_url)]
pages = []
externals = []
page_errors = []
while queue and len(pages) < max_pages:
url = queue.pop(0)
if url in seen:
continue
seen.add(url)
try:
r = requests.get(url, headers=HEADERS, timeout=20)
status = r.status_code
ctype = r.headers.get('content-type', '')
final_url = clean_url(r.url)
if 'text/html' not in ctype:
pages.append({
'url': url,
'final_url': final_url,
'status': status,
'content_type': ctype,
'title': None,
'inline_hits': [],
'internal_links': [],
'external_links': [],
})
continue
parser = AssetParser()
parser.feed(r.text)
title = None
m = re.search(r'<title>(.*?)</title>', r.text, re.I | re.S)
if m:
title = re.sub(r'\s+', ' ', m.group(1)).strip()
internal_links = []
external_links = []
for kind, ref in parser.links:
full = normalize_internal_url(final_url, ref)
if not full:
continue
item = {'kind': kind, 'url': full}
if is_same_host(start_url, full):
internal_links.append(item)
if (
kind == 'a'
and should_crawl(full, start_url)
and full not in seen
and full not in queue
):
queue.append(full)
else:
external_links.append(item)
externals.append({'from': final_url, **item})
inline_hits = []
for idx, script in enumerate(parser.inline_scripts, 1):
for pat in SUSPICIOUS_CONTENT_PATTERNS:
if re.search(pat, script, re.I):
inline_hits.append({
'script_index': idx,
'pattern': pat
})
pages.append({
'url': url,
'final_url': final_url,
'status': status,
'content_type': ctype,
'title': title,
'inline_hits': inline_hits,
'internal_links': internal_links,
'external_links': external_links,
})
except Exception as e:
page_errors.append({'url': url, 'error': str(e)})
return {'pages': pages, 'externals': externals, 'page_errors': page_errors}
def extract_remote_assets(start_url: str):
r = requests.get(start_url, headers=HEADERS, timeout=20)
r.raise_for_status()
parser = AssetParser()
parser.feed(r.text)
assets = []
externals = []
for kind, ref in parser.links:
full = clean_url(urljoin(start_url, ref))
if is_same_host(start_url, full):
assets.append({'kind': kind, 'url': full})
else:
externals.append({'kind': kind, 'url': full})
inline_hits = []
for idx, script in enumerate(parser.inline_scripts, 1):
for pat in SUSPICIOUS_CONTENT_PATTERNS:
if re.search(pat, script, re.I):
inline_hits.append({'script_index': idx, 'pattern': pat})
return {
'page_url': start_url,
'assets': assets,
'externals': externals,
'inline_hits': inline_hits,
}
def fetch_headers(url: str):
try:
r = requests.get(url, headers=HEADERS, timeout=20)
headers = {k: v for k, v in r.headers.items()}
interesting_keys = [
'Content-Security-Policy',
'Strict-Transport-Security',
'X-Content-Type-Options',
'X-Frame-Options',
'Referrer-Policy',
'Permissions-Policy',
'Cache-Control',
'Content-Type',
'Server',
]
interesting = {
k: headers.get(k)
for k in interesting_keys
if headers.get(k) is not None
}
missing = [
k for k in [
'Content-Security-Policy',
'Strict-Transport-Security',
'X-Content-Type-Options',
'Referrer-Policy',
]
if k not in interesting
]
return {
'url': r.url,
'status': r.status_code,
'headers': interesting,
'missing_recommended': missing,
}
except Exception as e:
return {
'url': url,
'error': str(e),
'headers': {},
'missing_recommended': [],
}
def scan_dom_text_for_indicators(html: str):
indicators = []
checks = {
'base_tag_present': r'<base\b',
'meta_refresh_present': r'<meta[^>]+http-equiv=[\"\']refresh[\"\']',
'iframe_present': r'<iframe\b',
'data_uri_present': r'data:(?:text|application)/',
'inline_event_handlers': r'\bon(?:click|load|error|mouseover|submit)\s*=',
'obfuscated_long_hex': r'\b[a-f0-9]{80,}\b',
'long_base64_like': r'\b[A-Za-z0-9+/]{200,}={0,2}\b',
}
for name, pat in checks.items():
if re.search(pat, html, re.I | re.S):
indicators.append(name)
return indicators
def compare_with_baseline(current_hashes: dict, baseline_file: Path):
if not baseline_file or not baseline_file.exists():
return None
baseline = json.loads(baseline_file.read_text(encoding='utf-8'))
old_hashes = baseline.get('hashes', {})
added = sorted(set(current_hashes) - set(old_hashes))
removed = sorted(set(old_hashes) - set(current_hashes))
changed = sorted([
k for k in set(current_hashes).intersection(old_hashes)
if current_hashes[k] != old_hashes[k]
])
return {
'added': added,
'removed': removed,
'changed': changed,
'baseline_source': str(baseline_file),
}
def compare_with_local_dir(current_root: Path, local_dir: Path):
if not local_dir or not local_dir.exists():
return None
local_files = collect_files(local_dir)
local_hashes = {
str(p.relative_to(local_dir)): sha256_file(p)
for p in local_files
}
current_files = collect_files(current_root)
current_hashes = {
str(p.relative_to(current_root)): sha256_file(p)
for p in current_files
}
added = sorted(set(current_hashes) - set(local_hashes))
missing_on_server = sorted(set(local_hashes) - set(current_hashes))
changed = sorted([
k for k in set(current_hashes).intersection(local_hashes)
if current_hashes[k] != local_hashes[k]
])
return {
'local_dir': str(local_dir),
'added_on_server': added,
'missing_on_server': missing_on_server,
'changed': changed,
}
def export_baseline(outdir: Path, report: dict):
baseline = {
'target': report['target'],
'created_from': report['mirror_dir'],
'hashes': report['hashes'],
}
path = outdir / 'baseline_hashes.json'
path.write_text(json.dumps(baseline, indent=2, ensure_ascii=False),
encoding='utf-8')
return path
def score_report(report: dict):
score = 0
reasons = []
if report['summary'].get('unexpected_extensions', 0) > 0:
score += 30
reasons.append('unerwartete Dateiendungen gefunden')
if report['summary'].get('suspicious_filenames', 0) > 0:
score += 40
reasons.append('verdächtige Dateinamen gefunden')
if report['summary'].get('suspicious_content', 0) > 0:
score += 50
reasons.append('verdächtige Inhaltsmuster gefunden')
if report['summary'].get('dom_indicators', 0) > 0:
score += 10
reasons.append('DOM-Indikatoren erkannt')
if report['summary'].get('header_missing', 0) >= 2:
score += 10
reasons.append('mehrere empfohlene Security-Header fehlen')
if report.get('baseline_diff'):
delta = len(report['baseline_diff'].get('added', [])) + \
len(report['baseline_diff'].get('changed', []))
if delta > 0:
score += min(40, delta * 5)
reasons.append('Abweichungen zur Baseline gefunden')
if report.get('local_diff'):
delta = len(report['local_diff'].get('added_on_server', [])) + \
len(report['local_diff'].get('changed', []))
if delta > 0:
score += min(40, delta * 5)
reasons.append('Abweichungen zum lokalen Build gefunden')
if score >= 70:
level = 'red'
exit_code = 2
elif score >= 20:
level = 'yellow'
exit_code = 1
else:
level = 'green'
exit_code = 0
return {
'score': score,
'level': level,
'exit_code': exit_code,
'reasons': reasons,
}
def send_webhook(url: str, payload: dict):
try:
r = requests.post(url, json=payload, headers=HEADERS, timeout=20)
return {
'status_code': r.status_code,
'ok': r.ok,
'text': r.text[:500],
}
except Exception as e:
return {'ok': False, 'error': str(e)}
def write_report(outdir: Path, report: dict):
ensure_dir(outdir)
json_path = outdir / 'audit_report.json'
md_path = outdir / 'audit_report.md'
json_path.write_text(
json.dumps(report, indent=2, ensure_ascii=False),
encoding='utf-8'
)
lines = []
lines.append("# Website-Audit\n")
lines.append(f"- Ziel: {report['target']}\n")
lines.append(f"- Mirror-Verzeichnis: `{report['mirror_dir']}`\n")
lines.append(
f"- Dateien im Mirror: {report['summary']['file_count']}\n"
)
lines.append(
f"- Unerwartete Endungen: {report['summary']['unexpected_extensions']}\n"
)
lines.append(
f"- Verdächtige Dateinamen: {report['summary']['suspicious_filenames']}\n"
)
lines.append(
f"- Verdächtige Inhaltsmuster: {report['summary']['suspicious_content']}\n"
)
lines.append(
f"- Externe Ressourcen auf Startseite: {report['summary']['external_assets']}\n"
)
lines.append(
f"- Treffer in Inline-Skripten: {report['summary']['inline_hits']}\n"
)
lines.append(
f"- Gecrawlte Seiten: {report['summary']['crawled_pages']}\n"
)
lines.append(
f"- Externe Ressourcen im Crawl: {report['summary']['crawl_externals']}\n"
)
lines.append(
f"- Crawl-Fehler: {report['summary']['crawl_errors']}\n"
)
lines.append(
f"- Header-Warnungen: {report['summary']['header_missing']}\n"
)
lines.append(
f"- DOM-Indikatoren auf Startseite: {report['summary']['dom_indicators']}\n"
)
lines.append(
f"- Ampelstatus: {report['assessment']['level']}\n"
)
lines.append(
f"- Risikoscore: {report['assessment']['score']}\n"
)
if report.get('assessment'):
lines.append("\n## Bewertung\n")
lines.append(
f"- Level: `{report['assessment']['level']}`"
)
lines.append(
f"- Score: `{report['assessment']['score']}`"
)
lines.append(
f"- Exit-Code: `{report['assessment']['exit_code']}`"
)
for item in report['assessment'].get('reasons', []):
lines.append(f"- Grund: {item}")
if report.get('headers'):
lines.append("\n## Header-Check\n")
if report['headers'].get('error'):
lines.append(f"- Fehler: {report['headers']['error']}")
else:
lines.append(f"- URL: {report['headers']['url']}")
lines.append(f"- Status: {report['headers']['status']}")
for k, v in report['headers']['headers'].items():
lines.append(f"- {k}: `{v}`")
if report['headers']['missing_recommended']:
lines.append(
f"- Fehlend empfohlen: "
f"{', '.join(report['headers']['missing_recommended'])}"
)
if report['findings']['unexpected_extensions']:
lines.append("\n## Unerwartete Endungen\n")
for x in report['findings']['unexpected_extensions'][:200]:
lines.append(f"- `{x}`")
if report['findings']['suspicious_filenames']:
lines.append("\n## Verdächtige Dateinamen\n")
for x in report['findings']['suspicious_filenames'][:200]:
lines.append(f"- `{x}`")
if report['findings']['suspicious_content']:
lines.append("\n## Verdächtige Inhaltsmuster\n")
for x in report['findings']['suspicious_content'][:200]:
lines.append(f"- `{x['file']}` → `{x['pattern']}`")
if report['remote']['externals']:
lines.append("\n## Externe Ressourcen\n")
for x in report['remote']['externals'][:200]:
lines.append(f"- {x['kind']}: {x['url']}")
if report['remote']['inline_hits']:
lines.append("\n## Inline-Skript-Treffer\n")
for x in report['remote']['inline_hits'][:200]:
lines.append(
f"- Inline-Skript {x['script_index']} → `{x['pattern']}`"
)
if report.get('crawl', {}).get('pages'):
lines.append("\n## Gecrawlte Seiten\n")
for x in report['crawl']['pages'][:200]:
title = f"{x['title']}" if x.get('title') else ""
lines.append(
f"- {x['status']} {x['final_url']}{title}"
)
if report.get('crawl', {}).get('externals'):
lines.append("\n## Externe Ressourcen im Crawl\n")
for x in report['crawl']['externals'][:200]:
lines.append(
f"- {x['from']} -> {x['kind']}: {x['url']}"
)
if report.get('dom_indicators'):
lines.append("\n## DOM-Indikatoren\n")
for item in report['dom_indicators']:
lines.append(f"- {item}")
if report.get('baseline_diff'):
lines.append("\n## Baseline-Diff\n")
lines.append(
f"- Baseline: `{report['baseline_diff']['baseline_source']}`"
)
for key in ('added', 'removed', 'changed'):
if report['baseline_diff'][key]:
lines.append(
f"- {key}: {len(report['baseline_diff'][key])}"
)
for item in report['baseline_diff'][key][:200]:
lines.append(f" - `{item}`")
if report.get('local_diff'):
lines.append("\n## Vergleich mit lokalem Build\n")
lines.append(
f"- Lokal: `{report['local_diff']['local_dir']}`"
)
for key in (
'added_on_server',
'missing_on_server',
'changed',
):
if report['local_diff'][key]:
lines.append(
f"- {key}: {len(report['local_diff'][key])}"
)
for item in report['local_diff'][key][:200]:
lines.append(f" - `{item}`")
md_path.write_text('\n'.join(lines), encoding='utf-8')
baseline_path = export_baseline(outdir, report)
return json_path, md_path, baseline_path
def main():
ap = argparse.ArgumentParser(
description='Mirror + Scan für statische Websites'
)
ap.add_argument('url')
ap.add_argument('--out', default='output/site_audit')
ap.add_argument('--allow-ext', action='append', default=[])
ap.add_argument('--max-pages', type=int, default=200)
ap.add_argument('--baseline-json', default=None)
ap.add_argument('--local-dir', default=None)
ap.add_argument('--webhook-url', default=None)
ap.add_argument('--fail-on-yellow', action='store_true')
args = ap.parse_args()
outdir = Path(args.out)
mirror = outdir / 'mirror'
allowed = set(DEFAULT_ALLOWED)
for item in args.allow_ext:
for part in item.split(','):
part = part.strip().lower().lstrip('.')
if part:
allowed.add(part)
wget_result = mirror_with_wget(args.url, mirror)
remote = extract_remote_assets(args.url)
crawl = crawl_site(args.url, max_pages=args.max_pages)
headers_report = fetch_headers(args.url)
html = requests.get(args.url, headers=HEADERS, timeout=20).text
dom_indicators = scan_dom_text_for_indicators(html)
file_root_candidates = [
p for p in mirror.rglob('*')
if p.is_dir() and p.name == urlparse(args.url).netloc
]
file_root = file_root_candidates[0] if file_root_candidates else mirror
findings = scan_tree(file_root, allowed)
files = collect_files(file_root)
hashes = {
str(p.relative_to(file_root)): sha256_file(p)
for p in files
}
baseline_diff = (
compare_with_baseline(hashes, Path(args.baseline_json))
if args.baseline_json else None
)
local_diff = (
compare_with_local_dir(file_root, Path(args.local_dir))
if args.local_dir else None
)
report = {
'target': args.url,
'mirror_dir': str(file_root),
'wget': wget_result,
'summary': {
'file_count': len(files),
'unexpected_extensions':
len(findings['unexpected_extensions']),
'suspicious_filenames':
len(findings['suspicious_filenames']),
'suspicious_content':
len(findings['suspicious_content']),
'external_assets': len(remote['externals']),
'inline_hits': len(remote['inline_hits']),
'crawled_pages': len(crawl['pages']),
'crawl_externals': len(crawl['externals']),
'crawl_errors': len(crawl['page_errors']),
'header_missing':
len(headers_report.get('missing_recommended', [])),
'dom_indicators': len(dom_indicators),
},
'findings': findings,
'remote': remote,
'crawl': crawl,
'headers': headers_report,
'dom_indicators': dom_indicators,
'baseline_diff': baseline_diff,
'local_diff': local_diff,
'hashes': hashes,
}
assessment = score_report(report)
if args.fail_on_yellow and assessment['level'] == 'yellow':
assessment['exit_code'] = 2
report['assessment'] = assessment
if args.webhook_url:
report['webhook_result'] = send_webhook(
args.webhook_url,
{
'target': report['target'],
'summary': report['summary'],
'assessment': report['assessment'],
},
)
write_report(outdir, report)
sys.exit(report['assessment']['exit_code'])
if __name__ == '__main__':
main()