feat: schwerwiegende KI-Funde dürfen ROT auslösen (konfigurierbar)
Bisher war das KI-Level hart auf Gelb gedeckelt — auch injizierte
Pornografie oder strafbare/diffamierende Inhalte lösten nie Rot aus.
Das unterschätzt echte Notfälle (Sicherheits- + Rechts-/Reputationsrisiko).
Neu: kategoriebasierter Grave-Override in score_ai_findings. Nur Kategorien
aus red_categories (default ["pornography","defamation_illegal"]) lösen Rot
aus, und nur bei Schwere ≥ red_min_severity (high) UND Konfidenz ≥
red_min_confidence (0.9). Alle anderen Kategorien (Spam, Off-Topic,
Propaganda, Widerspruch) bleiben höchstens Gelb. Die Punktsumme allein
ergibt weiterhin nie Rot. red_categories: [] stellt das alte Verhalten her.
Begründung für die konservative Schwelle: LLM-Konfidenzen sind nicht
perfekt kalibriert — nur die gravierendsten Fälle dürfen die volle
ROT-Dringlichkeit ("Dienstleister informieren, nichts freigeben") tragen.
Rot fließt über die bestehende _worst_level-Verdrahtung automatisch ins
Gesamt-Level + Exit-Code 2; gilt auch für Bildfunde (OCR).
Tests: 237 grün (+7 Rot-Gate, bestehender Cap-Test auf Punktsumme-Logik
umgestellt). Config + Template + README + Bedienungsanleitung aktualisiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d06e3d3dfd
commit
a8dcaabf7c
6 changed files with 98 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue