fix: volle Bildabdeckung — kein dauerhaft ungeprüftes Bild mehr

Sicherheitslücke: Die Bildanalyse baute keine Abdeckung auf. Nach dem ersten
Budget-Fenster (max_images_per_scan) wurden nur noch Bilder auf GEÄNDERTEN
Seiten oder bereits geflaggte nachgeholt. Bilder auf stabilen Seiten jenseits
des ersten Fensters blieben dauerhaft ungeprüft (real: 10 von 201 analysiert).
Ein Angreifer hätte so ein strafbares Logo (z. B. Hakenkreuz) auf einer
stabilen Firmenseite platzieren können, ohne dass die KI es je inhaltlich prüft.

Fix: Jeder Scan holt zusätzlich noch nicht analysierte Bilder (URL nicht im
Ledger) bis zum Budget nach → volle Abdeckung baut sich über mehrere Scans auf
(wie beim Text). Priorität: geänderte/geflaggte Bilder zuerst, dann Restbudget
für die Abdeckung. Byte-Änderungen bekannter Bilder deckt check-assets ab.

Bewusste Entscheidung GEGEN ai_analysis.image.exclude_paths: ein Pfad-Ausschluss
(z. B. /content/companies/) wäre genau der blinde Fleck — strafbare Logos würden
durchrutschen. Stattdessen: initiales "Gemecker" anhören, legitime Logos einmalig
per ai-dismiss akzeptieren (Quittung am Byte-Hash → ausgetauschtes Bild flaggt neu).

Tests: 255 grün (+1: Abdeckung baut sich über mehrere Scans bis 100% auf).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 04:02:35 +02:00
commit 6c7e69e635
3 changed files with 53 additions and 8 deletions

View file

@ -1012,6 +1012,25 @@ Wirkung: Der Erst-Scan einer Site (der den ganzen Bestand prüft) wird deutlich
rate-limited Free-Modelle sofort übersprungen werden und die verbleibenden Anfragen nebenläufig
laufen.
### Vollständige Bildabdeckung (kein blinder Fleck)
Pro Scan werden maximal `image.max_images_per_scan` Bilder geprüft (Kostendeckel). Damit jenseits
dieses Fensters **kein Bild dauerhaft ungeprüft** bleibt — sonst könnte ein Angreifer z. B. ein
strafbares Logo auf einer ansonsten stabilen Seite platzieren —, holt **jeder** Scan zusätzlich
noch nicht analysierte Bilder nach, bis der gesamte Bildbestand abgedeckt ist. Reihenfolge:
zuerst Bilder auf geänderten Seiten und bereits geflaggte, dann mit dem Restbudget der noch
unbekannte Rest.
**Initiale Einrichtung großer Bildbestände:** Hat eine Site sehr viele Bilder (z. B. 200), dauert
die volle Abdeckung mehrere Scans. Zum schnelleren Aufbau kann `max_images_per_scan` vorübergehend
erhöht werden. In dieser Phase markiert die KI auch legitime Inhalte (z. B. Marken-/Partnerlogos
auf Firmenseiten) — diese werden einmalig mit `ai-dismiss --hash <fp>` akzeptiert. Da die Quittung
am **Byte-Hash** hängt, wird ein später ausgetauschtes Bild (andere Bytes → neuer Hash) automatisch
neu geprüft und nicht stillschweigend mitakzeptiert.
Hinweis zur Byte-Änderung bekannter Bilder auf stabilen Seiten: Diese deckt die Datei-Prüfung
(`check-assets`) ab — sie schlägt an, wenn sich der Inhalt einer bekannten Datei ändert.
Audio- und Video-Prüfung sind als abschaltbare Erweiterungspunkte vorbereitet
(`ai_analysis.audio` / `ai_analysis.video`, default deaktiviert) und können aktiviert werden,
sobald eine Website solche Mediendateien direkt einbindet.

View file

@ -363,7 +363,6 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
if not current_img_urls:
return False
has_image_entries = any(e.get("kind") == "image" for e in entries.values())
changed = {pd["url"] for pd in diff.get("page_diffs", [])} | set(diff.get("new_internal_urls", []))
changed_img_urls = _all_image_urls(snap, only_pages=changed)
# Markierte Bilder erneut holen: ersetztes Bild → neue Bytes → Neubewertung
@ -373,13 +372,18 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
if e.get("kind") == "image" and e.get("category", "clean") != "clean"
and not e.get("dismissed") and e.get("url") in current_img_urls
}
# Noch nie analysierte Bilder (URL nicht im Ledger): bauen über mehrere Scans die
# VOLLE Abdeckung auf — sonst bliebe jedes Bild jenseits des ersten Budget-Fensters
# ein dauerhafter blinder Fleck (z. B. ein eingeschleustes strafbares Logo auf einer
# ansonsten stabilen Seite). Bytes-Änderungen bekannter Bilder deckt check-assets ab.
known_img_urls = {e["url"] for e in entries.values() if e.get("kind") == "image"}
coverage_urls = current_img_urls - known_img_urls
if has_image_entries:
fetch_urls = sorted((changed_img_urls | flagged_img_urls) & current_img_urls)
else:
fetch_urls = sorted(current_img_urls) # Erstlauf: vollständiger Sweep
fetch_urls = fetch_urls[: img_cfg.get("max_images_per_scan", 15)]
# Priorität: geänderte/geflaggte Bilder zuerst, dann mit dem Restbudget Abdeckung aufbauen.
priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls)
coverage = sorted(coverage_urls - set(priority))
budget = img_cfg.get("max_images_per_scan", 15)
fetch_urls = (priority + coverage)[:budget]
if not fetch_urls:
return False

View file

@ -208,6 +208,28 @@ class TestImages:
assert res["findings"][0]["kind"] == "image"
assert res["findings"][0]["asset_url"] == "https://x.de/bild.jpg"
def test_image_coverage_builds_over_scans(self, tmp_path, monkeypatch):
"""Bilder jenseits des Budgets dürfen kein blinder Fleck sein:
jeder Scan deckt weitere, noch nie analysierte Bilder ab."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = self._cfg_img()
cfg["ai_analysis"]["image"]["max_images_per_scan"] = 2
# 5 Bilder auf einer Seite, Budget 2 → volle Abdeckung über 3 Scans
imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(5)]
snap = _snap("kurz", img=imgs)
hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None}
for i in range(5)}
analysed = set()
for _ in range(3): # drei aufeinanderfolgende Scans
with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
run_ai_analysis(cfg, bm, snap, {})
analysed = {e["url"] for e in bm.load_ai_ledger()["entries"].values()
if e.get("kind") == "image"}
assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt
def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)