#!/usr/bin/env python3 """Whitelist management for the intrusion-detection system. Owns provider CIDR constants, remote download/refresh logic, and CIDR extraction/validation. Imported by `utils.py` which provides the convenience lookups `whitelist_of()` and `is_whitelisted()`. Whitelist loading order (per provider): 1. Local file in `data//` (if present and readable) 2. Remote download (if network available) 3. Built-in hardcoded fallback """ from __future__ import annotations import ipaddress import os import re from typing import Any from .constants import ( CLOUDFLARE_URL_V4, CLOUDFLARE_URL_V6, CLOUDFLARE_V4_FILE, CLOUDFLARE_V6_FILE, OPENFACTORY_FILE, OPENFACTORY_URL, PALOALTO_FILE, PALOALTO_URL, ) # --------------------------------------------------------------------------- # CIDR extraction regex # --------------------------------------------------------------------------- CIDR_RE = re.compile( r"(? CIDR ranges (v4 + v6 mixed, split at render time) WHITELISTS: dict[str, list[str]] = { "Openfactory": OPENFACTORY_IPV4, "Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6, "PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6, } #: pre-parsed networks, so matching does not re-parse CIDRs per IP WHITELIST_NETS: dict[str, tuple[Any, ...]] = { name: tuple(ipaddress.ip_network(c) for c in cidrs) for name, cidrs in WHITELISTS.items() } #: guard so refresh runs at most once per process _refreshed = False # --------------------------------------------------------------------------- # CIDR extraction / validation # --------------------------------------------------------------------------- def extract_cidrs(text: str) -> list[str]: """Extract valid CIDR strings from plain text, CSV, or HTML.""" seen: set[str] = set() cidrs: list[str] = [] for match in CIDR_RE.finditer(text): candidate = match.group(1).strip() try: network = ipaddress.ip_network(candidate, strict=False) except ValueError: continue rendered = str(network) if rendered not in seen: seen.add(rendered) cidrs.append(rendered) return cidrs # --------------------------------------------------------------------------- # Local file loading # --------------------------------------------------------------------------- def _read_local_file(path: str) -> list[str]: """Read a local whitelist file and extract valid CIDRs.""" try: with open(path, "r", encoding="utf-8", errors="replace") as f: return extract_cidrs(f.read()) except Exception: return [] def _load_local_whitelists() -> dict[str, list[str]]: """Load whitelist CIDRs from local data/ files. Returns a dict of {name: [CIDR strings]}. Only keys with successfully parsed CIDRs are included. """ out: dict[str, list[str]] = {} cidrs = _read_local_file(OPENFACTORY_FILE) if cidrs: out["Openfactory"] = cidrs cf_v4 = _read_local_file(CLOUDFLARE_V4_FILE) cf_v6 = _read_local_file(CLOUDFLARE_V6_FILE) if cf_v4 or cf_v6: out["Cloudflare"] = cf_v4 + cf_v6 cidrs = _read_local_file(PALOALTO_FILE) if cidrs: out["PaloAlto"] = cidrs return out # --------------------------------------------------------------------------- # HTTP download helpers # --------------------------------------------------------------------------- def _download_text(url: str) -> str: """Download a text file from a URL, return as string.""" import urllib.request request = urllib.request.Request( url, headers={ "User-Agent": "Mozilla/5.0 (compatible; intrusion-detection/1.0)", "Accept": "text/plain,text/csv,text/html;q=0.9,*/*;q=0.8", }, ) with urllib.request.urlopen(request) as response: return response.read().decode("utf-8", errors="replace") def _download_cidrs(url: str) -> list[str]: """Download a whitelist source and extract valid CIDRs from it.""" return extract_cidrs(_download_text(url)) # --------------------------------------------------------------------------- # Refresh pipeline # --------------------------------------------------------------------------- def _download_whitelists() -> dict[str, list[str]]: """Download the latest whitelists from the Internet and save to local files. Returns a dict of {name: [CIDR strings]}. Only keys with successfully parsed CIDRs are included; failures are logged and skipped so built-in fallbacks remain untouched. Saves each download to the corresponding local file on success. """ out: dict[str, list[str]] = {} try: text = _download_text(OPENFACTORY_URL) cidrs = extract_cidrs(text) if cidrs: out["Openfactory"] = cidrs _save_local_file(OPENFACTORY_FILE, text) except Exception as e: print(f"Error downloading Openfactory whitelist: {e}") try: text_v4 = _download_text(CLOUDFLARE_URL_V4) text_v6 = _download_text(CLOUDFLARE_URL_V6) cidrs = extract_cidrs(text_v4) + extract_cidrs(text_v6) if cidrs: out["Cloudflare"] = cidrs _save_local_file(CLOUDFLARE_V4_FILE, text_v4) _save_local_file(CLOUDFLARE_V6_FILE, text_v6) except Exception as e: print(f"Error downloading Cloudflare whitelist: {e}") # Do not download PaloAlto whitelist for now, as it is not a reliable source of CIDRs and often returns HTML instead of plain text. # try: # text = _download_text(PALOALTO_URL) # cidrs = extract_cidrs(text) # if cidrs and len(cidrs) > 0: # out["PaloAlto"] = cidrs # _save_local_file(PALOALTO_FILE, text) # except Exception as e: # print(f"Error downloading PaloAlto whitelist: {e}") return out def _save_local_file(path: str, text: str) -> None: """Save downloaded text to a local file, creating parent dirs as needed.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(text) except Exception as e: print(f"Error saving whitelist file {path}: {e}") def update_whitelists() -> None: """Load whitelists (local files first, then remote, then built-in fallback). Priority per provider: 1. Local file in data// 2. Remote download (saves to local file on success) 3. Built-in hardcoded constants (always available) """ global WHITELISTS, WHITELIST_NETS # Start from built-in fallbacks, then layer local, then remote WHITELISTS = { "Openfactory": OPENFACTORY_IPV4, "Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6, "PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6, } # Layer 1: local files local = _load_local_whitelists() if local: WHITELISTS.update(local) # Layer 2: remote download (highest priority, saves local) remote = _download_whitelists() if remote: WHITELISTS.update(remote) # Rebuild parsed networks WHITELIST_NETS = { name: tuple(ipaddress.ip_network(c) for c in cidrs) for name, cidrs in WHITELISTS.items() } def refresh_whitelists(force: bool = False) -> bool: """Refresh provider whitelists once per process, keeping built-in fallbacks. Returns True if a refresh was attempted, False if already done. """ global _refreshed if _refreshed and not force: return False update_whitelists() _refreshed = True return True