diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 9384273..dcc5acb 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -644,8 +644,8 @@ angewendet — falsch-positive Alarme durch dynamische CMS-Inhalte werden so unt | Kaputte **externe** Links (4xx/5xx) | Ja, mit `check-ext-links` | Kein Score, nur Report | | UA-basiertes Cloaking (Googlebot sieht anderen Inhalt) | Ja, mit `cloak-check` | Score 40–60 | | Veränderungen in JS/CSS/Bildern (Inhalt) | Ja, mit `check-assets` | Score 20–40 | -| Problematischer Text (Pornografie, Propaganda, Spam, Off-Topic) | Ja, mit KI-Analyse | Score 20–50, auf Gelb gedeckelt | -| Problematische Bildinhalte + Text im Bild (OCR) | Ja, mit KI-Analyse | Score ≥ 40, auf Gelb gedeckelt | +| Problematischer Text (Pornografie, Propaganda, Spam, Off-Topic) | Ja, mit KI-Analyse | Gelb; Pornografie/strafbar bei hoher Schwere+Konfidenz → Rot | +| Problematische Bildinhalte + Text im Bild (OCR) | Ja, mit KI-Analyse | Gelb; Pornografie/strafbar bei hoher Schwere+Konfidenz → Rot | | IP-basiertes Cloaking | Nein | Nur Google Search Console kann das erkennen | | Serverseitige Code-Änderungen | Nein | Nur sichtbare HTML-Ausgabe | @@ -871,10 +871,25 @@ Dadurch kann die Analyse bei **jedem** Scan mitlaufen und kostet an den meisten ### Wirkung auf die Ampel -KI-Funde sind **auf Gelb gedeckelt** — sie lösen niemals allein ROT aus. ROT bleibt den -harten Integritäts-Signalen (Webshell, versteckte Inhalte) vorbehalten. Nur Funde mit -ausreichender Sicherheit (Konfidenz ≥ `ai_confidence_min`, Standard 0,7) und mindestens -mittlerer Schwere werden gewertet — das hält Fehlalarme niedrig. +KI-Funde sind grundsätzlich **auf Gelb gedeckelt** — mit einer bewussten Ausnahme: die +**schwerwiegendsten Kategorien** (Pornografie, strafbar/diffamierend) lösen bei **hoher +Schwere + hoher Konfidenz** ROT aus. Alle anderen Kategorien (Spam, themenfremde Werbung, +Propaganda, Widerspruch) bleiben höchstens Gelb. Nur Funde mit ausreichender Sicherheit +(Konfidenz ≥ `ai_confidence_min`, Standard 0,7) und mindestens mittlerer Schwere werden +überhaupt gewertet — das hält Fehlalarme niedrig. + +Die ROT-Eskalation ist konfigurierbar: + +```yaml +ai_analysis: + red_categories: ["pornography", "defamation_illegal"] # nur diese dürfen Rot auslösen + red_min_severity: "high" # mindestens diese Schwere + red_min_confidence: 0.9 # mindestens diese Konfidenz +``` + +Mit `red_categories: []` bleibt die KI wie früher immer höchstens Gelb. Hintergrund: KI- +Konfidenzen sind nicht perfekt kalibriert, daher dürfen nur die gravierendsten Fälle die +volle ROT-Dringlichkeit („Dienstleister informieren, nichts freigeben") auslösen. Fehlt der API-Key oder schlägt eine Abfrage fehl, wird die Analyse **übersprungen** und der Scan läuft unverändert weiter. Die KI kann den normalen Betrieb nie blockieren. diff --git a/README.md b/README.md index c528494..c7d4b98 100644 --- a/README.md +++ b/README.md @@ -233,8 +233,11 @@ Verdikt im Cache (`data/ai_ledger.json`) → **kein API-Call**. Nur neue oder ge Inhalte lösen eine (günstige) OpenRouter-Abfrage aus. Dadurch läuft die Analyse bei jedem Scan mit und kostet an den meisten Tagen 0 €. -KI-Funde sind **auf Gelb gedeckelt** — sie lösen nie allein ROT aus (das bleibt harten -Integritäts-Signalen vorbehalten). Fehlt der API-Key oder schlägt eine Abfrage fehl, wird +KI-Funde sind **auf Gelb gedeckelt** — mit einer Ausnahme: die schwerwiegendsten Kategorien +(Pornografie, strafbar/diffamierend) lösen bei **hoher Schwere + hoher Konfidenz** ROT aus. +Alle übrigen Kategorien (Spam, Off-Topic, Propaganda, Widerspruch) bleiben höchstens Gelb. +Steuerbar über `red_categories` / `red_min_severity` / `red_min_confidence` (leere +`red_categories: []` = nie Rot aus KI). Fehlt der API-Key oder schlägt eine Abfrage fehl, wird die Analyse übersprungen und der Scan läuft unverändert weiter. **„Nicht geprüft" ≠ „sauber":** Kann ein Inhalt trotz Modell-Eskalation und Wiederholung diff --git a/meine-seite.de/config.yaml b/meine-seite.de/config.yaml index f865fd0..a60bb59 100644 --- a/meine-seite.de/config.yaml +++ b/meine-seite.de/config.yaml @@ -140,6 +140,11 @@ ai_analysis: # einzelne Schlüsselwörter. Bitte ausfüllen, wenn enabled: true. site_context: "" ai_confidence_min: 0.7 + # KI-Funde sind auf Gelb gedeckelt — AUSSER diese schwerwiegenden Kategorien lösen + # bei hoher Schwere + hoher Konfidenz ROT aus. Leere Liste [] = immer max. Gelb. + red_categories: ["pornography", "defamation_illegal"] + red_min_severity: "high" + red_min_confidence: 0.9 attempt_timeout: 30 # Zeitlimit je Modell-Versuch (Sek.) → Auslöser der Eskalation max_retries: 1 # Wiederholungen der ganzen Modell-Kette bei Komplettausfall retry_backoff_seconds: 2.0 diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index 7a3a602..449a658 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -116,8 +116,10 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict: def score_ai_findings(ai_result: dict, cfg: dict) -> dict: """ - Bewertet KI-Funde additiv. Das Level ist hart auf 'yellow' gedeckelt — - KI allein löst niemals ROT aus (ROT bleibt harten Integritäts-Signalen). + Bewertet KI-Funde additiv. Das Level ist auf 'yellow' gedeckelt — AUSSER + schwerwiegende Kategorien (red_categories, default Pornografie/strafbar) lösen + bei hoher Schwere + hoher Konfidenz ROT aus. Die Punktsumme selbst ergibt nie + Rot; nur dieser kategoriebasierte Override tut das. Returns {score, level, reasons, exit_code}. """ @@ -151,6 +153,22 @@ def score_ai_findings(ai_result: dict, cfg: dict) -> dict: if cfg.get("ai_analysis", {}).get("unchecked_level") == "yellow" and level == "green": level, exit_code = "yellow", 1 + # Grave-Override: nur die schwersten Kategorien lösen ROT aus (Vorrang vor gelb). + ai_cfg = cfg.get("ai_analysis", {}) + red_cats = set(ai_cfg.get("red_categories", [])) + red_min_sev = _SEVERITY_ORDER.get(ai_cfg.get("red_min_severity", "high"), 3) + red_min_conf = ai_cfg.get("red_min_confidence", 0.9) + for f in ai_result.get("findings", []): + if (f.get("category") in red_cats + and _SEVERITY_ORDER.get(f.get("severity", "none"), 0) >= red_min_sev + and f.get("confidence", 0.0) >= red_min_conf): + level, exit_code = "red", 2 + reasons.append( + f"Schwerwiegender KI-Fund ({f['category']}, {f.get('confidence', 0):.0%}) " + f"auf {f.get('url', '?')} → ROT" + ) + break + return {"score": score, "level": level, "reasons": reasons, "exit_code": exit_code} diff --git a/scanner/config.py b/scanner/config.py index dd1a826..a1d3ddd 100644 --- a/scanner/config.py +++ b/scanner/config.py @@ -87,6 +87,11 @@ DEFAULT_CONFIG: dict = { "api_key_env": "OPENROUTER_API_KEY", "site_context": "", # Themenbeschreibung der Website (Pflicht wenn enabled) "ai_confidence_min": 0.7, # Mindest-Konfidenz, damit ein Fund gewertet wird + # KI-Funde sind auf Gelb gedeckelt — AUSSER diese schwerwiegenden Kategorien + # lösen bei hoher Schwere + hoher Konfidenz ROT aus. Leere Liste = immer max. Gelb. + "red_categories": ["pornography", "defamation_illegal"], + "red_min_severity": "high", + "red_min_confidence": 0.9, "request_timeout": 60, # Ceiling (auch für Asset-Fetch der Bilder) "attempt_timeout": 30, # Zeitlimit je Modell-Versuch → Eskalation bei Langsamkeit "max_retries": 1, # Wiederholungen der ganzen Modell-Kette bei Komplettausfall diff --git a/tests/test_ai_analyzer.py b/tests/test_ai_analyzer.py index ff24787..bb41cf6 100644 --- a/tests/test_ai_analyzer.py +++ b/tests/test_ai_analyzer.py @@ -233,15 +233,15 @@ class TestScoring: out = score_ai_findings({"findings": findings}, _cfg()) assert out["level"] == "yellow" - def test_score_capped_at_yellow_never_red(self): - # Zwei schwere Funde → Summe ≥ rot-Schwelle, aber Cap hält es bei gelb. + def test_score_sum_alone_never_red(self): + # Zwei nicht-gravierende Funde → Summe ≥ rot-Schwelle, aber Punktsumme macht nie Rot. findings = [ - {"kind": "text", "url": "u1", "category": "pornography", "severity": "high", "confidence": 0.95}, - {"kind": "text", "url": "u2", "category": "defamation_illegal", "severity": "high", "confidence": 0.95}, + {"kind": "text", "url": "u1", "category": "off_topic_commercial", "severity": "high", "confidence": 0.95}, + {"kind": "text", "url": "u2", "category": "off_topic_commercial", "severity": "high", "confidence": 0.95}, ] out = score_ai_findings({"findings": findings}, _cfg()) assert out["score"] >= 60 # Punktsumme über rot-Schwelle - assert out["level"] == "yellow" # trotzdem nur gelb + assert out["level"] == "yellow" # trotzdem nur gelb (kein Grave-Override) assert out["exit_code"] == 1 def test_image_gets_at_least_suspicious_image_points(self): @@ -267,6 +267,43 @@ class TestScoring: assert any("nicht geprüft" in r for r in out["reasons"]) +class TestRedGate: + def _find(self, cat, sev, conf, kind="text"): + return {"kind": kind, "url": "u", "category": cat, "severity": sev, "confidence": conf} + + def test_pornography_high_conf_is_red(self): + out = score_ai_findings({"findings": [self._find("pornography", "high", 0.95)]}, _cfg()) + assert out["level"] == "red" + assert out["exit_code"] == 2 + + def test_defamation_illegal_high_conf_is_red(self): + out = score_ai_findings({"findings": [self._find("defamation_illegal", "high", 0.95)]}, _cfg()) + assert out["level"] == "red" + + def test_pornography_image_is_red(self): + out = score_ai_findings( + {"findings": [self._find("pornography", "high", 0.95, kind="image")]}, _cfg()) + assert out["level"] == "red" + + def test_severity_below_high_stays_yellow(self): + out = score_ai_findings({"findings": [self._find("pornography", "medium", 0.95)]}, _cfg()) + assert out["level"] == "yellow" + + def test_confidence_below_threshold_stays_yellow(self): + out = score_ai_findings({"findings": [self._find("pornography", "high", 0.8)]}, _cfg()) + assert out["level"] == "yellow" + + def test_non_grave_category_stays_yellow(self): + out = score_ai_findings({"findings": [self._find("off_topic_commercial", "high", 1.0)]}, _cfg()) + assert out["level"] == "yellow" + + def test_empty_red_categories_restores_old_capping(self): + cfg = _cfg() + cfg["ai_analysis"]["red_categories"] = [] + out = score_ai_findings({"findings": [self._find("pornography", "high", 1.0)]}, cfg) + assert out["level"] == "yellow" + + # --------------------------------------------------------------------------- # Model escalation chain # ---------------------------------------------------------------------------