Confidence Blacklist #520
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Confidence Blacklist | |
| on: | |
| schedule: | |
| - cron: '45 0 * * *' # täglich 00:45 UTC (45 Min nach Combined – sicher nach Cache-Save) | |
| - cron: '45 3 * * *' # täglich 03:45 UTC (45 Min nach Combined-Lauf um 03:00) | |
| - cron: '45 6 * * *' # täglich 06:45 UTC (45 Min nach Combined-Lauf um 06:00) | |
| - cron: '45 9 * * *' # täglich 09:45 UTC | |
| - cron: '45 12 * * *' # täglich 12:45 UTC | |
| - cron: '45 15 * * *' # täglich 15:45 UTC | |
| - cron: '45 18 * * *' # täglich 18:45 UTC | |
| - cron: '45 21 * * *' # täglich 21:45 UTC | |
| workflow_dispatch: | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| permissions: | |
| contents: write | |
| actions: read | |
| issues: none | |
| packages: none | |
| pull-requests: none | |
| security-events: none | |
| concurrency: | |
| group: netshield-seen-db-writers | |
| cancel-in-progress: false | |
| jobs: | |
| update-confidence: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| steps: | |
| - name: Checkout Repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: true | |
| - name: Restore seen_db Cache | |
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: seen_db.json | |
| # FIX: restore-keys matcht alle Varianten die Combined und auto_feed_discovery schreiben. | |
| # Combined speichert: netshield-seen-db-v2-<run_id> | |
| # auto_feed_discovery speichert: netshield-seen-db-afd-save-<run_id> | |
| # Durch restore-keys holen wir immer den neuesten verfügbaren Cache. | |
| key: netshield-seen-db-v2-${{ github.run_id }} | |
| restore-keys: | | |
| netshield-seen-db-v2- | |
| netshield-seen-db-afd-save- | |
| netshield-seen-db-v1- | |
| netshield-seen-db- | |
| - name: Build Confidence Blacklists from seen_db | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| run: | | |
| python3 << 'EOF' | |
| import sys as _sys; _sys.path.insert(0, "scripts") | |
| from netshield_common import ( | |
| load_whitelist, load_fp_set, is_in_fp_set, | |
| is_valid_public_ipv4, is_valid_public_cidr, | |
| is_protected_entry, is_whitelisted, | |
| parse_entries as _parse_entries, calculate_confidence, | |
| safe_get_date, parse_date, sort_ips, write_ip_list, | |
| write_text_atomic, | |
| fetch_url, check_local_feed_age, | |
| IPV4_RE, CIDR_RE, TIMESTAMP_RE, | |
| ) | |
| # Init: Whitelist + FP-Set laden | |
| load_whitelist() | |
| load_fp_set() | |
| import json, os, sys, ipaddress, bisect | |
| from datetime import datetime, timezone, timedelta | |
| now = datetime.now(timezone.utc) | |
| now_str = now.strftime("%Y-%m-%d %H:%M UTC") | |
| DB_FILE = "seen_db.json" | |
| BLACKLIST = "combined_threat_blacklist_ipv4.txt" | |
| OUT_40 = "blacklist_confidence40_ipv4.txt" | |
| OUT_WATCH = "watchlist_confidence25to39_ipv4.txt" | |
| # ── Whitelist: Single Source of Truth aus whitelist.json ────────── | |
| # FIX SSOT1: Hardcoded DNS_WHITELIST und PROTECTED_CIDRS durch | |
| # whitelist.json ersetzt. Synchron mit update_combined_blacklist | |
| # und false_positive_checker. Neue Whitelist-Einträge wirken sofort. | |
| try: | |
| with open(".github/workflows/whitelist.json", encoding="utf-8") as _wl_f: | |
| _WHITELIST_ENTRIES = json.load(_wl_f)["entries"] | |
| except Exception as _wl_err: | |
| _msg = f"whitelist.json nicht ladbar: {_wl_err} – Confidence-Berechnung abgebrochen" | |
| print(f"::error file=update_confidence_blacklist.yml::{_msg}") | |
| print(f"FEHLER: {_msg}") | |
| sys.exit(1) | |
| protected_networks = [] | |
| for _entry in _WHITELIST_ENTRIES: | |
| try: | |
| protected_networks.append(ipaddress.ip_network(_entry, strict=False)) | |
| except Exception as _suppressed: | |
| print(f"WARN: suppressed Exception: {_suppressed}", file=sys.stderr) | |
| print(f"whitelist.json geladen: {len(protected_networks)} Einträge") | |
| # ── FP-Set laden (Defense-in-Depth) ────────────────────────────── | |
| # FIX SSOT2: false_positives_set.json wird von load_fp_set() oben | |
| # bereits in die globalen Strukturen von netshield_common geladen. | |
| # Hier nur Aliase auf die common-Strukturen anlegen, damit die | |
| # bisherige lokale Filter-Logik unverändert weiterläuft. | |
| # Frühere doppelte Lade-Logik entfernt (Single Source of Truth). | |
| import netshield_common as _nc | |
| _fp_ips = _nc._fp_ips | |
| _fp_networks = _nc._fp_networks | |
| print(f"false_positives_set.json (via common): " | |
| f"{len(_fp_ips)} IPs + {len(_fp_networks)} CIDRs geladen") | |
| # is_in_fp_set() → importiert aus netshield_common | |
| # is_protected_entry() → importiert aus netshield_common | |
| if not os.path.exists(DB_FILE): | |
| msg = f"{DB_FILE} nicht gefunden – bitte zuerst Update Combined Blacklist ausführen." | |
| print(f"::warning file=update_confidence_blacklist.yml::{msg}") | |
| print(f"FEHLER: {msg}") | |
| sys.exit(1) | |
| with open(DB_FILE) as f: | |
| try: | |
| db = json.load(f) | |
| except Exception as e: | |
| print(f"FEHLER: seen_db.json ist korrupt oder nicht lesbar: {e}") | |
| print("Behalte bestehende Confidence-Blacklists und breche ab.") | |
| sys.exit(1) # exit(1) damit GitHub Actions den Fehler im UI anzeigt | |
| print(f"seen_db geladen: {len(db)} IPs") | |
| # Alter der seen_db prüfen. | |
| # FIX SEEN-DB-AGE: Primär seen_db_meta.json verwenden (Stundengenauigkeit | |
| # via "updated"-Feld). Der vorherige Ansatz benutzte das aktuellste | |
| # last-Datum aus der DB; weil last nur tagesgenau ist (YYYY-MM-DD → | |
| # strptime ergibt Mitternacht UTC), zeigte er ab ~04:30 UTC jeden | |
| # Tag fälschlich >4,5h Alter, obwohl die Pipeline gerade erst | |
| # bestätigt hatte. Fallback auf den DB-Vergleich nur wenn Meta | |
| # fehlt/korrupt – Schwelle dort entsprechend großzügiger (24h+3h | |
| # Worst-Case durch Tagesgranularität). | |
| db_age_hours = None | |
| META_FILE = "seen_db_meta.json" | |
| if os.path.exists(META_FILE): | |
| try: | |
| with open(META_FILE) as _mf: | |
| _meta = json.load(_mf) | |
| _meta_upd = _meta.get("updated", "") | |
| if _meta_upd: | |
| _meta_dt = datetime.strptime(_meta_upd.replace(" UTC", ""), "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc) | |
| db_age_hours = (now - _meta_dt).total_seconds() / 3600 | |
| print(f"seen_db zuletzt aktualisiert: {_meta_upd} ({db_age_hours:.1f}h alt)") | |
| # Combined läuft alle 3h, plus Wallzeit (~30 min) plus | |
| # Cache-Save → 4,5h ist eine sichere Schwelle bei | |
| # echtem Timestamp. | |
| if db_age_hours > 4.5: | |
| print(f"WARNUNG: seen_db ist {db_age_hours:.1f}h alt – mindestens ein combined-Run wurde verpasst!") | |
| except Exception as _suppressed: | |
| print(f"WARN: seen_db_meta.json nicht lesbar – Fallback auf DB-last-Vergleich: {_suppressed}", file=sys.stderr) | |
| if db_age_hours is None: | |
| # Fallback: aktuellstes last-Datum aus DB. | |
| # Nur echte HQ-Bestätigungsdaten (last != 2000-01-01) berücksichtigen, | |
| # damit Watchlist-IPs (last="2000-01-01") kein falsches Alarm-Alter erzeugen. | |
| real_dates = [safe_get_date(d, "last") for d in db.values() if isinstance(d, dict) and safe_get_date(d, "last") != "2000-01-01"] | |
| newest_last = max(real_dates) if real_dates else None | |
| try: | |
| if newest_last: | |
| newest_dt = datetime.strptime(newest_last, "%Y-%m-%d").replace(tzinfo=timezone.utc) | |
| db_age_hours = (now - newest_dt).total_seconds() / 3600 | |
| print(f"seen_db aktuellster Eintrag (Fallback ohne Meta): {newest_last} ({db_age_hours:.1f}h alt)") | |
| # Schwelle für den Fallback großzügig: last ist nur tagesgenau, | |
| # bei 23:59 geschriebenem Eintrag und 27h später Messung wären | |
| # das echte 27h+ – alles darunter könnte legitim sein. | |
| if db_age_hours > 27: | |
| print(f"WARNUNG: seen_db ist {db_age_hours:.1f}h alt (Fallback) – Pipeline steht vermutlich still!") | |
| else: | |
| print("seen_db: Keine HQ-bestätigten Einträge vorhanden (nur Watchlist-IPs)") | |
| except Exception as _suppressed: | |
| print(f"WARN: suppressed Exception: {_suppressed}", file=sys.stderr) | |
| # FIX BUG-STALE-HARDCAP: Wenn seen_db wirklich stale ist, NICHT weiterrechnen. | |
| # Hintergrund: die WARN-Pfade oben warnen nur (4.5h Meta / 27h Fallback) und | |
| # lassen den Workflow weiterlaufen. Bei einer mehrtaegigen combined-Outage | |
| # (GitHub Actions down, oder mehrfacher combined-Crash) wuerde confidence | |
| # auf einem veralteten Snapshot conf40 + watchlist neu generieren — und damit | |
| # einen veralteten Stand als "frisch" ins Repo committen. Firewalls die conf40 | |
| # konsumieren wuerden veraltete Threats blockieren oder neue verpassen. | |
| # Bessere Strategie: bei extrem stalen Daten KEINEN Output schreiben. Die | |
| # alten committeten Dateien bleiben stehen — Konsumenten haben dann ehrlich | |
| # alte aber konsistente Daten, nicht frisch-aussehende veraltete. | |
| # Schwellen: | |
| # Meta-basiert (genau, minutengenau): 8h = 2 verpasste combined-Runs | |
| # Fallback (last-Datum, tagesgenau): 48h = 2 Tage Pipeline-Stillstand | |
| # Das gibt Glitches (1 verpasster Run, ~3-4h) genug Toleranz und schlaegt | |
| # erst bei echtem Pipeline-Problem zu. | |
| STALE_HARDCAP_META_H = 8.0 | |
| STALE_HARDCAP_FALLBACK_H = 48.0 | |
| if db_age_hours is not None: | |
| # Welcher Pfad lieferte db_age_hours? Meta hat Vorrang (gesetzt zuerst). | |
| # Wir unterscheiden anhand der Schwelle: war Meta-Pfad ueberhaupt aktiv? | |
| _used_fallback = not os.path.exists(META_FILE) | |
| _cap = STALE_HARDCAP_FALLBACK_H if _used_fallback else STALE_HARDCAP_META_H | |
| if db_age_hours > _cap: | |
| _stale_msg = ( | |
| f"seen_db ist {db_age_hours:.1f}h alt (Schwelle: {_cap:.0f}h, " | |
| f"{'Fallback' if _used_fallback else 'Meta'}-Quelle). " | |
| f"confidence-Workflow wird abgebrochen, damit keine veralteten " | |
| f"Daten als 'frisch' ins Repo committet werden. Bestehende " | |
| f"{OUT_40} + {OUT_WATCH} bleiben unveraendert. " | |
| f"Ursache vermutlich combined-Workflow-Stillstand: bitte " | |
| f"update_combined_blacklist.yml-Runs pruefen." | |
| ) | |
| print(f"::error file=update_confidence_blacklist.yml::{_stale_msg}") | |
| print(f"FEHLER: {_stale_msg}") | |
| sys.exit(1) | |
| if not os.path.exists(BLACKLIST): | |
| msg = f"{BLACKLIST} nicht gefunden – combined-Workflow muss zuerst laufen." | |
| print(f"::warning file=update_confidence_blacklist.yml::{msg}") | |
| print(f"FEHLER: {msg}") | |
| sys.exit(1) | |
| # combined_threat_blacklist_ipv4.txt wird bereits beim Schreiben in | |
| # update_combined_blacklist mit is_protected_entry() gefiltert → | |
| # erneute Prüfung hier wäre 502 Netzwerk-Checks × 4,5M IPs ≈ 10 Min Zeitverschwendung. | |
| # | |
| # FIX BUG-TRUNCATE-PARTS: Sobald die Vollliste >= 100 MB GitHub-Push- | |
| # Limit erreicht, schreibt update_combined_blacklist die Hauptdatei | |
| # truncatiert (nur die ersten N IPs) und verteilt die vollstaendige | |
| # Liste auf combined_threat_blacklist_ipv4_part*.txt. Wenn wir hier | |
| # nur die Hauptdatei lesen, verlieren IPs die ausschliesslich in den | |
| # Parts stehen ihren Confidence-Score und landen weder in | |
| # blacklist_confidence40 noch in der Watchlist – obwohl sie in | |
| # seen_db vorhanden sind. | |
| # Loesung: Hauptdatei + alle Parts einlesen, dedupen via set-Update. | |
| # Das ist auch unter Schwelle (Parts existieren nicht) safe – glob | |
| # matcht dann nichts. Solange Combined unter SPLIT_THRESHOLD bleibt, | |
| # ist die Hauptdatei ohnehin vollstaendig und der Loop ist ein No-Op. | |
| import glob as _glob | |
| combined_ips = set() | |
| _sources_read = [BLACKLIST] + sorted( | |
| _glob.glob("combined_threat_blacklist_ipv4_part*.txt")) | |
| for _src in _sources_read: | |
| if not os.path.exists(_src): | |
| continue | |
| with open(_src) as f: | |
| for line in f: | |
| s = line.strip() | |
| if s and not s.startswith("#"): | |
| combined_ips.add(s) | |
| if len(_sources_read) > 1: | |
| print(f"Combined Blacklist: {len(combined_ips)} IPs " | |
| f"(Hauptdatei + {len(_sources_read) - 1} Part(s))") | |
| else: | |
| print(f"Combined Blacklist: {len(combined_ips)} IPs") | |
| # ── FP-Vorfilter für combined_ips (einmalig, statt pro IP im Inner-Loop) ─ | |
| # combined_threat_blacklist_ipv4.txt ist zwar bereits mit is_in_fp_set() gefiltert, | |
| # aber es kann einen Timing-Gap geben (FP-Checker läuft nach Combined). | |
| # Statt is_in_fp_set(ip) im Inner-Loop (4,5M × ~2945 Netzwerk-Checks ≈ 47 Min), | |
| # filtern wir combined_ips EINMAL vorab via Binary-Search O(N × log K). | |
| # | |
| # FIX BUG-11: Intervalle VOR bisect mergen. Ohne Merge findet | |
| # bisect_right nur das zuletzt startende Intervall mit start <= ip. | |
| # Bei überlappenden FP-CIDRs würde die IP in einem Eltern-CIDR | |
| # übersehen. Merging garantiert: jeder IP-Treffer landet im | |
| # umschließenden Intervall. | |
| if _fp_ips: | |
| combined_ips -= _fp_ips | |
| if _fp_networks: | |
| _intervals_raw = sorted( | |
| (int(n.network_address), int(n.broadcast_address)) | |
| for n in _fp_networks | |
| ) | |
| # Intervall-Merge: überlappende/benachbarte Ranges zusammenführen | |
| _intervals = [] | |
| for _lo, _hi in _intervals_raw: | |
| if _intervals and _lo <= _intervals[-1][1] + 1: | |
| _intervals[-1] = (_intervals[-1][0], max(_intervals[-1][1], _hi)) | |
| else: | |
| _intervals.append((_lo, _hi)) | |
| _starts = [iv[0] for iv in _intervals] | |
| _fp_cidr_hits = set() | |
| for _ip in combined_ips: | |
| try: | |
| # FIX BUG-11 zusatz: bei CIDR-Einträgen das gesamte Intervall | |
| # gegen FP-Ranges prüfen, nicht nur die Netzadresse. | |
| if "/" in _ip: | |
| _net = ipaddress.ip_network(_ip, strict=False) | |
| _lo = int(_net.network_address) | |
| _hi = int(_net.broadcast_address) | |
| else: | |
| _lo = _hi = int(ipaddress.ip_address(_ip)) | |
| _pos = bisect.bisect_right(_starts, _hi) - 1 | |
| if _pos >= 0 and _intervals[_pos][1] >= _lo: | |
| _fp_cidr_hits.add(_ip) | |
| except Exception: | |
| pass | |
| if _fp_cidr_hits: | |
| combined_ips -= _fp_cidr_hits | |
| print(f"FP-Timing-Gap-Filter: {len(_fp_cidr_hits)} IPs aus combined_ips entfernt") | |
| print(f"Combined Blacklist nach FP-Filter: {len(combined_ips)} IPs") | |
| # ── Whitelist-Defense-in-Depth-Filter ───────────────────────────── | |
| # FIX BUG-WL1-PROPAGATION: combined_threat_blacklist_ipv4.txt wird | |
| # zwar im Upstream gefiltert, aber wenn der Upstream-Filter ausfällt | |
| # (z.B. BUG-WL1: load_whitelist() im Job-Step vergessen → is_whitelisted | |
| # liefert False → Filter wirkungslos), propagiert der Leak in die | |
| # Confidence-Blacklist OHNE dass dieser Workflow ihn bemerkt. | |
| # Genau das ist am 2026-04-26 08:37 UTC passiert: alle drei Output- | |
| # Dateien (combined, active, confidence40) enthielten dieselben | |
| # whitelisted Google-/Microsoft-IPs. | |
| # Lösung: Eigenständiger Whitelist-Filter via Merge+Bisect, identisch | |
| # zum FP-Timing-Gap-Filter darüber. Performance: O(N × log K) – bei | |
| # 4,5M IPs gegen ~437 gemergte Whitelist-Intervalle <1s. Defense-in- | |
| # Depth ist hier den Kosten wert. | |
| if protected_networks: | |
| _wl_intervals_raw = sorted( | |
| (int(n.network_address), int(n.broadcast_address)) | |
| for n in protected_networks | |
| ) | |
| _wl_intervals = [] | |
| for _lo, _hi in _wl_intervals_raw: | |
| if _wl_intervals and _lo <= _wl_intervals[-1][1] + 1: | |
| _wl_intervals[-1] = (_wl_intervals[-1][0], max(_wl_intervals[-1][1], _hi)) | |
| else: | |
| _wl_intervals.append((_lo, _hi)) | |
| _wl_starts = [iv[0] for iv in _wl_intervals] | |
| _wl_hits = set() | |
| for _ip in combined_ips: | |
| try: | |
| if "/" in _ip: | |
| _net = ipaddress.ip_network(_ip, strict=False) | |
| _lo = int(_net.network_address) | |
| _hi = int(_net.broadcast_address) | |
| else: | |
| _lo = _hi = int(ipaddress.ip_address(_ip)) | |
| _pos = bisect.bisect_right(_wl_starts, _hi) - 1 | |
| if _pos >= 0 and _wl_intervals[_pos][1] >= _lo: | |
| _wl_hits.add(_ip) | |
| except Exception: | |
| pass | |
| if _wl_hits: | |
| combined_ips -= _wl_hits | |
| # Lautes Logging: Wenn dieser Filter zuschlägt, liegt | |
| # upstream ein BUG-WL1-artiges Problem vor – das soll im | |
| # Workflow-Log auffallen, auch wenn die Filterung selbst | |
| # erfolgreich greift. | |
| _sample = sorted(_wl_hits)[:5] | |
| print(f"::warning file=update_confidence_blacklist.yml::" | |
| f"Whitelist-Leak im Upstream erkannt und gefiltert: " | |
| f"{len(_wl_hits)} IPs (Beispiele: {', '.join(_sample)}). " | |
| f"BUG-WL1-Klasse – Combined-Blacklist-Generator prüfen.") | |
| print(f"Whitelist-Defense-in-Depth: {len(_wl_hits)} IPs aus combined_ips entfernt") | |
| else: | |
| print(f"Whitelist-Defense-in-Depth: 0 Treffer (Upstream sauber)") | |
| print(f"Combined Blacklist nach Whitelist-Filter: {len(combined_ips)} IPs") | |
| # ══════════════════════════════════════════════════════════════════ | |
| # KONFIDENZ-MODELL | |
| # | |
| # blacklist_confidence40 = mittleres bis hohes Vertrauen (≥40 Punkte) | |
| # Mehr IPs als active_blacklist, geeignet für zusätzliche Filterregeln. | |
| # watchlist = neue/unsichere Bedrohungen (25–39 Punkte) | |
| # | |
| # Score setzt sich aus 4 unabhängigen Dimensionen zusammen: | |
| # | |
| # [A] QUELLEN-QUALITÄT (max. 40 Punkte) – wie vertrauenswürdig sind | |
| # die Feeds die diese IP gemeldet haben? | |
| # hq=True (je in einem HQ-Feed) → 40 | |
| # today_count >= 5 (heute 5+ Feeds) → 35 | |
| # today_count >= 3 (heute 3+ Feeds) → 28 | |
| # today_count >= 2 (heute 2+ Feeds) → 20 | |
| # feed_count >= 5 (akkum. 5+ Feeds) → 15 | |
| # feed_count >= 3 (akkum. 3+ Feeds) → 10 | |
| # feed_count >= 2 (akkum. 2 Feeds) → 5 | |
| # 1 Feed total → 0 | |
| # | |
| # [B] AKTUALITÄT (max. 30 Punkte) – wie frisch ist die letzte | |
| # *starke* Bestätigung? ("last" wird in update_combined_blacklist | |
| # ausschließlich gesetzt wenn mindestens 1 HQ-Feed die IP heute meldet) | |
| # last_seen ≤ 1 Tag → 30 | |
| # last_seen ≤ 3 Tage → 25 | |
| # last_seen ≤ 7 Tage → 20 | |
| # last_seen ≤ 14 Tage → 12 | |
| # last_seen ≤ 30 Tage → 6 | |
| # last_seen > 30 Tage → 0 | |
| # | |
| # [C] PERSISTENZ (max. 20 Punkte) – wurde die IP über mehrere Tage | |
| # unabhängig bestätigt? (days_seen = Anzahl verschiedener Tage | |
| # an denen "stark bestätigt" wurde) | |
| # days_seen >= 14 → 20 | |
| # days_seen >= 7 → 15 | |
| # days_seen >= 3 → 10 | |
| # days_seen >= 2 → 6 | |
| # days_seen == 1 → 2 | |
| # | |
| # [D] BEKANNT SEIT (max. 10 Punkte) – wie lange ist die IP schon | |
| # im System? | |
| # bekannt ≥ 90 Tage → 10 | |
| # bekannt ≥ 30 Tage → 6 | |
| # bekannt ≥ 14 Tage → 3 | |
| # bekannt < 14 Tage → 0 | |
| # | |
| # Gesamt max. 100 Punkte. | |
| # Schwellwerte: | |
| # conf >= 40 → blacklist_confidence40 (mittleres/hohes Vertrauen) | |
| # conf 25–39 → watchlist | |
| # conf < 25 → ignoriert | |
| # | |
| # Hinweis: active_blacklist_ipv4.txt (OPNsense) verwendet conf >= 65 | |
| # und ist damit deutlich restriktiver (nur echte HQ-Bedrohungen). | |
| # ══════════════════════════════════════════════════════════════════ | |
| confidence40 = [] | |
| confidence25 = [] | |
| skipped = 0 | |
| skipped_watchlist = 0 | |
| for ip, data in db.items(): | |
| try: | |
| if not isinstance(data, dict): | |
| skipped += 1 | |
| continue | |
| if ip not in combined_ips: | |
| # combined_ips wurde bereits beim Laden auf is_protected_entry() | |
| # und is_in_fp_set() vorgeprüft → keine weiteren Einzelchecks nötig. | |
| continue | |
| is_hq = data.get("hq", False) | |
| feed_count = len(data.get("feeds", [])) | |
| today_count = data.get("today_count", 0) | |
| # FIX BUG-DEADVAR: 'today_hq' aus seen_db wurde hier ausgelesen, | |
| # aber nirgends weiterverwendet (calculate_confidence nutzt | |
| # 'is_hq', nicht 'today_hq'). Refactoring-Rest aus einer | |
| # frueheren Score-Variante. Entfernt - die Variable ueberlebt | |
| # weiterhin in seen_db.json (Schreibseite), wird hier aber | |
| # nicht gelesen. | |
| days_seen = data.get("days_seen", 1) | |
| last_seen = safe_get_date(data, "last") | |
| first_seen = safe_get_date(data, "first", last_seen) | |
| try: | |
| last_dt = datetime.strptime(last_seen, "%Y-%m-%d").replace(tzinfo=timezone.utc) | |
| first_dt = datetime.strptime(first_seen, "%Y-%m-%d").replace(tzinfo=timezone.utc) | |
| except Exception: | |
| skipped += 1 | |
| continue | |
| # Sentinel-Guard: last="2000-01-01" markiert Einträge ohne echtes | |
| # "last seen"-Datum. Bei einer IP die gleichzeitig in combined_ips | |
| # steht ist das inkonsistent → keine verlässliche Score-Grundlage. | |
| # | |
| # FIX BUG#2 (erweitert): Der alte Guard `last=="2000-01-01" and | |
| # not is_hq` ließ den Fall is_hq=True + Sentinel-Datum durch. | |
| # Folge: score_a=40 (HQ) + score_d=10 (Sentinel-first triggert | |
| # vollen Alter-Bonus) + score_c≥2 = Score ≥52 → fälschlicher | |
| # confidence40-Eintrag. Der is_hq-Check ist entfernt: das | |
| # Sentinel-Datum allein ist das kanonische Ausschluss-Signal, | |
| # unabhängig von anderen Feldern. | |
| if last_seen == "2000-01-01": | |
| skipped_watchlist += 1 | |
| continue | |
| days_since_last = (now - last_dt).days | |
| # FIX BUG-2: first="2000-01-01" ist ein Sentinel, kein echtes Datum. | |
| # Ohne Neutralisierung ergibt (now - first_dt) ≈ 9600 Tage → | |
| # days_known ≥ 90 → voller Alter-Bonus +10. Eine Watchlist-IP | |
| # ohne verwertbares first-Datum bekäme Score-Inflation von 10 Punkten | |
| # für ein fiktives "26 Jahre bekannt"-Alter. Korrekt: days_known=0 | |
| # → Alter-Score 0, weil die Dimension keine verwertbare Info hat. | |
| if first_seen == "2000-01-01": | |
| days_known = 0 | |
| else: | |
| days_known = (now - first_dt).days + 1 | |
| # FIX DRY: Inline-Scoring entfernt, zentrale Funktion aus | |
| # netshield_common aufgerufen. Entspricht dem identischen | |
| # Fix in update_combined_blacklist.yml. Beseitigt das | |
| # Drift-Risiko (Score-Logik war vorher dreifach dupliziert) | |
| # und ist crash-sicher gegen korrupte seen_db-Werte. | |
| conf = calculate_confidence( | |
| is_hq=is_hq, | |
| today_count=today_count, | |
| feed_count=feed_count, | |
| days_since_last=days_since_last, | |
| days_seen=days_seen, | |
| days_known=days_known, | |
| ) | |
| # is_protected_entry()-Guard hier entfernt: Zugehörigkeit zu combined_ips | |
| # garantiert bereits dass die IP kein geschützter Eintrag ist. | |
| if conf >= 40: | |
| confidence40.append((ip, conf)) | |
| elif conf >= 25: | |
| confidence25.append((ip, conf)) | |
| except Exception as _corrupt: | |
| skipped += 1 | |
| print(f"WARN: Korrupter seen_db-Eintrag {ip}: {_corrupt}", file=sys.stderr) | |
| continue | |
| # FIX BUG-CACHE-DRIFT: Race-Schutz gegen veralteten seen_db-Cache. | |
| # Symptom: 5.921 brandneue HQ-IPs (first=last=heute) fehlten am | |
| # 2026-05-11 in conf40 obwohl sie in combined.txt + active.txt | |
| # standen. Empirisch verifiziert (Sim 4.137.805 vs Repo 4.131.884 | |
| # = exakt +5.921). Score-Berechnung war korrekt (72 in beiden | |
| # Workflows), alle Filter trafen 0 dieser IPs. Ursache: das oben | |
| # iterierte `db` enthielt sie nicht, weil der actions/cache/restore- | |
| # Step in diesem Workflow einen aelteren seen_db-Cache erwischte | |
| # als der combined-Workflow committed hatte (asynchrone Cache- | |
| # Replikation auf GitHub-Seite vs synchroner Repo-Push). | |
| # Asymmetrie: combined.txt im Repo ist der NEUERE Stand. Eine IP | |
| # in combined_ips aber nicht in db ist eindeutig "vom letzten | |
| # combined-Run als score-wuerdig anerkannt, aber im hiesigen Cache | |
| # noch nicht sichtbar". Sie muss in conf40. | |
| # Default-Score 72 = is_hq(40) + days_since=0(30) + days_seen=1(2) | |
| # + days_known=1(0). Entspricht exakt dem Score den eine brandneue | |
| # HQ-IP im naechsten regulaeren Lauf bekommt. Kein Alter-Bonus, | |
| # konservativ. | |
| # Cap bei 10% von combined_ips als Sicherheitsnetz: wenn der Cache | |
| # komplett kaputt waere (db={}), waeren ALLE ~5,1M IPs Orphans — | |
| # dann ist nicht "Cache-Drift", sondern "Cache-Total-Loss" und | |
| # der Leerungsschutz weiter unten greift sowieso. Bei plausibler | |
| # Drift sind es <0.5% (5.921 / 5.137.852 = 0,12%). | |
| _orphans = combined_ips - set(db.keys()) | |
| if _orphans: | |
| _orphan_pct = len(_orphans) / max(1, len(combined_ips)) * 100 | |
| _orphan_msg = ( | |
| f"Cache-Drift erkannt: {len(_orphans):,} IPs in combined.txt " | |
| f"aber nicht in seen_db ({_orphan_pct:.2f}% von combined). " | |
| f"actions/cache/restore lieferte vermutlich einen aelteren " | |
| f"Cache als der combined-Run committeted hat." | |
| ) | |
| if _orphan_pct > 10.0: | |
| # Keine Auto-Heilung – das ist Cache-Total-Loss, kein Drift. | |
| # Leerungsschutz unten greift, falls confidence40 dadurch | |
| # unter MIN_CONF40 faellt. | |
| print(f"::warning file=update_confidence_blacklist.yml::" | |
| f"{_orphan_msg} > 10% Schwelle – Auto-Heilung " | |
| f"deaktiviert, manueller Cache-Refresh noetig.") | |
| print(f"WARNUNG: {_orphan_msg}") | |
| else: | |
| print(f"::warning file=update_confidence_blacklist.yml::" | |
| f"{_orphan_msg} Auto-Heilung: alle Orphans mit " | |
| f"Default-HQ-Score 72 in conf40 aufgenommen.") | |
| print(f"INFO: {_orphan_msg}") | |
| _DEFAULT_ORPHAN_SCORE = 72 # is_hq(40)+frisch(30)+1xseen(2) | |
| for _orphan_ip in _orphans: | |
| confidence40.append((_orphan_ip, _DEFAULT_ORPHAN_SCORE)) | |
| print(f"Cache-Drift-Heilung: {len(_orphans):,} IPs nachgetragen") | |
| else: | |
| print(f"Cache-Drift-Check: 0 Orphans (combined.txt und seen_db konsistent)") | |
| confidence40.sort(key=lambda x: (-x[1], x[0])) | |
| confidence25.sort(key=lambda x: (-x[1], x[0])) | |
| print(f"Konfidenz ≥40 (→ confidence40-Datei): {len(confidence40)} IPs") | |
| print(f"Konfidenz 25-39 (→ watchlist): {len(confidence25)} IPs") | |
| print(f"Sentinel-Ausschluss (last=2000-01-01): {skipped_watchlist} IPs (kein verwertbares Datum)") | |
| print(f"Übersprungen: {skipped} IPs (Datumsfehler)") | |
| # ── Leerungsschutz confidence40 ─────────────────────────────────── | |
| # Verhindert das Überschreiben bei leerem/korruptem seen_db-Cache. | |
| # Schwelle identisch mit active_blacklist in update_combined_blacklist.yml. | |
| MIN_CONF40 = 100 | |
| if len(confidence40) < MIN_CONF40: | |
| msg = (f"Nur {len(confidence40)} IPs in confidence40 (< {MIN_CONF40}) – " | |
| f"Leerungsschutz aktiv, {OUT_40} wird NICHT überschrieben.") | |
| print(f"::warning file=update_confidence_blacklist.yml::{msg}") | |
| print(f"WARNUNG: {msg}") | |
| sys.exit(1) | |
| # FIX ATOMIC: write_text_atomic statt open("w") – garantiert dass | |
| # die Datei bei Runner-Kill/OOM komplett alt oder komplett neu bleibt, | |
| # niemals halb geschrieben. Reihenfolge ist nach Confidence-Score | |
| # absteigend (nicht nach IP), deshalb write_text_atomic statt | |
| # write_ip_list (das nach IP sortieren würde). | |
| # | |
| # FIX HARD-LIMIT-CONF40: Groesse VOR dem Schreiben schaetzen und | |
| # bei >= HARD_LIMIT_MB Truncate-Fallback + Parts anwenden, analog | |
| # zur Logik in update_combined_blacklist.yml. Ohne diesen Schutz | |
| # wuerde git push hart fehlschlagen sobald conf40 die 100 MB | |
| # GitHub-Push-Grenze reisst (derzeit ~57 MB, Wachstum proportional | |
| # zu combined). Hauptdatei bleibt der Legacy-Pfad (kompletter | |
| # Score-sortierter Inhalt solange unter 100 MB), Parts decken die | |
| # Vollstaendigkeit ab sobald Split-Schwelle erreicht ist. | |
| # Reihenfolge im File: Score-sortiert absteigend bleibt erhalten; | |
| # Parts werden in Score-Bereiche aufgeteilt (Part 1 = hoechste Scores). | |
| HARD_LIMIT_MB = 100 # GitHub Push-Limit | |
| TRUNCATE_TARGET_MB = 95 # Sicherheitspuffer unter HARD_LIMIT_MB | |
| SPLIT_THRESHOLD_MB = 90 # ab hier Parts erzeugen | |
| PART_TARGET_MB = 40 # Groesse pro Part | |
| # Sample-basierte Avg-Schaetzung (stratifiziert, vermeidet IP-Laengen- | |
| # Bias durch sortierte Reihenfolge). Identische Methode wie combined. | |
| _step = max(1, len(confidence40) // 10_000) | |
| _sample = confidence40[::_step][:10_000] | |
| _avg_line = (sum(len(ip) + 1 for ip, _ in _sample) / len(_sample)) if _sample else 16 | |
| _header_overhead = 512 | |
| _conf40_estimated_mb = ( | |
| len(confidence40) * _avg_line * 1.02 + _header_overhead | |
| ) / 1024 / 1024 | |
| print(f"\nGeschaetzte Groesse confidence40 (ungesplittet): {_conf40_estimated_mb:.1f} MB") | |
| if _conf40_estimated_mb >= HARD_LIMIT_MB: | |
| # Truncate: hoechste Scores zuerst, Rest in Parts | |
| _max_ips = int((TRUNCATE_TARGET_MB * 1024 * 1024 - _header_overhead) / _avg_line) | |
| _max_ips = max(1000, min(_max_ips, len(confidence40))) | |
| _truncated = confidence40[:_max_ips] | |
| _dropped = len(confidence40) - _max_ips | |
| _trunc_msg = ( | |
| f"Hauptdatei wuerde {_conf40_estimated_mb:.1f} MB " | |
| f"(>= {HARD_LIMIT_MB} MB GitHub-Limit) – TRUNCATE auf " | |
| f"{_max_ips:,} IPs (~{TRUNCATE_TARGET_MB} MB). " | |
| f"{_dropped:,} IPs ausschliesslich in Parts. Consumer auf Parts umstellen!" | |
| ) | |
| print(f"::warning file=update_confidence_blacklist.yml::{_trunc_msg}") | |
| print(f"WARNUNG: {_trunc_msg}") | |
| _conf40_body = ( | |
| f"# NETSHIELD Blacklist – Mittleres/Hohes Vertrauen (Score ≥40/100) [TRUNCATED]\n" | |
| f"# Aktualisiert: {now_str}\n" | |
| f"# Scoring: Quellen-Qualität(40) + Aktualität(30) + Persistenz(20) + Alter(10)\n" | |
| f"# Eintraege in dieser Datei: {_max_ips} (von {len(confidence40)} gesamt)\n" | |
| f"# WICHTIG: {_dropped} IPs NICHT in dieser Datei (GitHub-Hard-Limit 100 MB).\n" | |
| f"# Fuer vollstaendigen Schutz: blacklist_confidence40_ipv4_part*.txt verwenden.\n\n" | |
| + "".join(f"{ip}\n" for ip, conf in _truncated) | |
| ) | |
| else: | |
| _conf40_body = ( | |
| f"# NETSHIELD Blacklist – Mittleres/Hohes Vertrauen (Score ≥40/100)\n" | |
| f"# Aktualisiert: {now_str}\n" | |
| f"# Scoring: Quellen-Qualität(40) + Aktualität(30) + Persistenz(20) + Alter(10)\n" | |
| f"# Eintraege: {len(confidence40)}\n\n" | |
| + "".join(f"{ip}\n" for ip, conf in confidence40) | |
| ) | |
| write_text_atomic(OUT_40, _conf40_body) | |
| _conf40_main_mb = os.path.getsize(OUT_40) / 1024 / 1024 | |
| print(f"{OUT_40} (tatsaechlich): {_conf40_main_mb:.1f} MB") | |
| # ── Split-Logik fuer grosse Confidence40-Dateien ────────────────── | |
| # Identische Strategie wie in update_combined_blacklist.yml: | |
| # Split-Entscheidung auf Basis der VOLLSTAENDIGEN Liste (estimated_mb), | |
| # nicht der evtl. truncatierten Hauptdatei. Bei Truncate muessen | |
| # die Parts zwingend existieren, weil sie die einzige vollstaendige | |
| # Quelle sind. | |
| import glob as _glob_conf40 | |
| _existing_parts_conf40 = sorted( | |
| _glob_conf40.glob("blacklist_confidence40_ipv4_part*.txt")) | |
| if _conf40_estimated_mb >= SPLIT_THRESHOLD_MB: | |
| _num_parts = max(2, int((_conf40_estimated_mb / PART_TARGET_MB) + 0.5)) | |
| _chunk_size = (len(confidence40) + _num_parts - 1) // _num_parts | |
| print(f" -> Split aktiv: {_num_parts} Parts a ~{PART_TARGET_MB} MB " | |
| f"({_chunk_size:,} IPs/Part)") | |
| _new_parts = [] | |
| for _idx in range(_num_parts): | |
| _start = _idx * _chunk_size | |
| _end = min(_start + _chunk_size, len(confidence40)) | |
| if _start >= len(confidence40): | |
| break | |
| _part_name = f"blacklist_confidence40_ipv4_part{_idx+1}.txt" | |
| _part_body = ( | |
| f"# NETSHIELD Confidence-40 Blacklist – Part {_idx+1}/{_num_parts}\n" | |
| f"# Aktualisiert: {now_str}\n" | |
| f"# Score-Bereich: {confidence40[_start][1]} bis {confidence40[_end-1][1]}\n" | |
| f"# Eintraege: {_end - _start}\n" | |
| f"# Siehe README fuer Nutzung mit allen Parts.\n\n" | |
| + "".join(f"{ip}\n" for ip, conf in confidence40[_start:_end]) | |
| ) | |
| write_text_atomic(_part_name, _part_body) | |
| _new_parts.append(_part_name) | |
| print(f" {_part_name}: {_end - _start:,} IPs, " | |
| f"{os.path.getsize(_part_name)/1024/1024:.1f} MB") | |
| # Alte Parts entfernen, die nicht mehr erzeugt wurden | |
| for _old in _existing_parts_conf40: | |
| if _old not in _new_parts: | |
| try: | |
| os.unlink(_old) | |
| print(f" Alten Part entfernt: {_old}") | |
| except OSError: | |
| pass | |
| if _conf40_estimated_mb < HARD_LIMIT_MB: | |
| _split_msg = (f"Vollliste waere {_conf40_estimated_mb:.1f} MB " | |
| f"(>= {SPLIT_THRESHOLD_MB} MB). Parts erzeugt als Reserve.") | |
| print(f"::warning file=update_confidence_blacklist.yml::{_split_msg}") | |
| print(f"WARNUNG: {_split_msg}") | |
| else: | |
| # Unter Schwelle: Eventuell vorhandene alte Parts aufraeumen | |
| for _old in _existing_parts_conf40: | |
| try: | |
| os.unlink(_old) | |
| print(f" Alten Part entfernt (unter Schwelle): {_old}") | |
| except OSError: | |
| pass | |
| _watch_body = ( | |
| f"# NETSHIELD Watchlist – Niedriges Vertrauen (Score 25-39/100)\n" | |
| f"# Aktualisiert: {now_str}\n" | |
| f"# Eintraege: {len(confidence25)}\n\n" | |
| + "".join(f"{ip}\n" for ip, conf in confidence25) | |
| ) | |
| # ── Leerungsschutz Watchlist ────────────────────────────────────── | |
| # Wenn die Watchlist plötzlich leer/sehr klein ist (Score-Verteilung- | |
| # Edge-Case oder Pipeline-Anomalie), bestehende Datei behalten. | |
| # Schwelle bewusst niedrig: Watchlist ist informativ, gelegentliche | |
| # Schwankungen sind normal – aber 0 Einträge bei vorhandener | |
| # confidence40 ist verdächtig. | |
| MIN_WATCH = 10 | |
| if len(confidence25) < MIN_WATCH and os.path.exists(OUT_WATCH): | |
| _msg = (f"Nur {len(confidence25)} IPs in watchlist (< {MIN_WATCH}) – " | |
| f"behalte bestehende {OUT_WATCH} (Leerungsschutz)") | |
| print(f"::warning file=update_confidence_blacklist.yml::{_msg}") | |
| print(f"WARNUNG: {_msg}") | |
| else: | |
| write_text_atomic(OUT_WATCH, _watch_body) | |
| print(f"Fertig: {len(confidence40)} IPs (conf≥40) | {len(confidence25)} IPs (watchlist 25-39)") | |
| EOF | |
| - name: Commit | |
| if: always() | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| # FIX HARD-LIMIT-CONF40: Parts-Pattern mitnehmen damit der Commit | |
| # die durch Split entstandenen Parts erfasst und alte (geloeschte) | |
| # Parts als Removal committet werden (git add erfasst auch Deletes | |
| # bei Verwendung eines Patterns). | |
| git add blacklist_confidence40_ipv4.txt watchlist_confidence25to39_ipv4.txt | |
| git add -A blacklist_confidence40_ipv4_part*.txt 2>/dev/null || true | |
| if git diff --staged --quiet; then | |
| echo "Keine Änderungen" | |
| else | |
| git commit -m "Confidence Blacklist: $(date -u '+%Y-%m-%d %H:%M') UTC" | |
| for attempt in 1 2 3 4 5; do | |
| echo "Push-Versuch $attempt..." | |
| git fetch origin ${GITHUB_REF_NAME} | |
| git stash --include-untracked 2>/dev/null || true | |
| if git rebase -X theirs origin/${GITHUB_REF_NAME}; then | |
| git stash pop 2>/dev/null || true | |
| if git push origin HEAD:${GITHUB_REF_NAME}; then | |
| echo "Push erfolgreich (Versuch $attempt)" | |
| exit 0 | |
| fi | |
| else | |
| git stash pop 2>/dev/null || true | |
| git checkout --theirs blacklist_confidence40_ipv4.txt \ | |
| watchlist_confidence25to39_ipv4.txt 2>/dev/null || true | |
| git checkout --theirs blacklist_confidence40_ipv4_part*.txt 2>/dev/null || true | |
| git add blacklist_confidence40_ipv4.txt \ | |
| watchlist_confidence25to39_ipv4.txt 2>/dev/null || true | |
| git add -A blacklist_confidence40_ipv4_part*.txt 2>/dev/null || true | |
| GIT_EDITOR=true git rebase --continue 2>/dev/null || git rebase --skip | |
| fi | |
| sleep $((attempt * 3)) | |
| done | |
| echo "FEHLER: Push nach 5 Versuchen fehlgeschlagen" | |
| exit 1 | |
| fi |