"""OPNsense IDS ingestion — a Suricata alert summary for the preceding digest window. READ-ONLY INVARIANT: this module touches exactly two OPNsense API endpoints — `GET /api/ids/service/status` and `POST /api/ids/service/query_alerts` — and no others. `query_alerts` is a POST because OPNsense routes all filtered queries that way, not because it changes anything: it runs `/usr/local/opnsense/scripts/suricata/queryAlertLog.py`, which opens `/var/log/suricata/eve.json` and reads it backwards. Nothing here starts, stops, reconfigures, reloads or clears anything on the firewall, and nothing here may ever be changed to, per docs/project-plan.md Phase 12 step 8. The endpoints that *would* mutate state (`/ids/service/{start,stop,restart,reconfigure,reload_rules, update_rules,drop_alert_log}`) are named here only so it is obvious they are deliberately not used. Sourcing, all verified against OPNsense source 2026-07-28: Suricata is **core**, not a plugin. The premise that this needs `os-suricata` installed is wrong: the IDS module ships in opnsense/core (src/opnsense/mvc/app/controllers/OPNsense/IDS/) and the GUI lives at Services -> Intrusion Detection on a stock install. The only IDS-related plugins in opnsense/plugins are ruleset *content* packages (os-intrusion-detection-content-et-pro and friends); ET Open and the abuse.ch lists need no plugin at all. Alerts are queryable over the API. `POST /api/ids/service/query_alerts` takes `rowCount`, `current` (1-based page), `searchPhrase` and `fileid`, and returns `{"rows": [...], "total": n, "rowCount": n, "current": n}`. SSH or file access to /var/log/suricata/ is NOT required, which is why this is a pull over HTTPS like every other ingestion module here. There is **no server-side time filter**. `searchPhrase` is a substring match against the signature, action, source IP and destination IP only. Rows come back newest-first (the backend uses a reverse log reader), so the digest window is applied client-side by paging until a row falls out of it. That is also why `max_alerts_scanned` exists: a noisy WAN interface can produce more alerts in six hours than is sane to page through or to put in an LLM context. **Severity is not available.** queryAlertLog.py flattens each eve.json record down to `alert` (the signature text), `alert_sid` and `alert_action` before it returns, discarding `alert.severity` and `alert.category`. `get_alert_info` goes through the same flattening, so it does not help. Recovering severity would mean reading eve.json directly over SSH, which is a bigger access grant than this is worth. Alerts are therefore ranked by count, and the prompt is told it cannot see severity. Packet capture is deliberately not triggered from here. OPNsense does expose Interfaces: Diagnostics: Packet Capture over the API (`/api/diagnostics/packet_capture/...`), but `set`, `start`, `stop` and `remove` are all POSTs that write a job file to /tmp/captures and spawn tcpdump. Starting a capture is a write action on the firewall and is barred by the invariant above, quite apart from it being the wrong design — see the intrusion-detection section of digest-engine/README.md for how to keep a rotating local capture on OPNsense itself and merely *point* at it from the digest via `packet_capture_reference`. Configuration is a JSON file, not env, because the user asked for `IDSconf.json` specifically. Path comes from OPNSENSE_IDS_CONF_PATH (default /data/IDSconf.json) and the file is git-ignored; IDSconf.json.example is the committed template. """ import ipaddress import json import logging import math import os from collections import Counter from datetime import datetime, timedelta, timezone import requests LOG = logging.getLogger(__name__) DEFAULT_CONF_PATH = "/data/IDSconf.json" STATUS_PATH = "/api/ids/service/status" QUERY_ALERTS_PATH = "/api/ids/service/query_alerts" HTTP_TIMEOUT = 45 ROWS_PER_PAGE = 500 DEFAULT_MAX_ALERTS_SCANNED = 5000 DEFAULT_TOP_SIGNATURES = 8 DEFAULT_TOP_HOSTS = 5 def _load_config(path): try: with open(path, encoding="utf-8") as handle: config = json.load(handle) except FileNotFoundError: LOG.warning("opnsense_ids: %s not found, skipping (copy IDSconf.json.example there)", path) return None except Exception: LOG.warning("opnsense_ids: %s could not be parsed, skipping", path, exc_info=True) return None missing = [key for key in ("base_url", "api_key", "api_secret") if not str(config.get(key, "")).strip()] if missing: LOG.warning("opnsense_ids: %s is missing %s, skipping", path, ", ".join(missing)) return None return config def _parse_timestamp(raw): if not raw: return None try: parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) except ValueError: return None # Suricata always writes an offset, but a naive value would raise on the # comparison against the cutoff and lose the whole run over one bad line. return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) def _service_status(session, base_url, verify): response = session.get(base_url + STATUS_PATH, timeout=HTTP_TIMEOUT, verify=verify) response.raise_for_status() return response.json().get("status", "unknown") def _collect_alerts(session, base_url, verify, cutoff, max_pages): rows = [] for page in range(1, max_pages + 1): response = session.post( base_url + QUERY_ALERTS_PATH, data={"rowCount": ROWS_PER_PAGE, "current": page}, timeout=HTTP_TIMEOUT, verify=verify, ) response.raise_for_status() page_rows = response.json().get("rows") or [] if not page_rows: # An empty first page means the log holds no alerts at all. An empty # later page means eve.json ran out before the window did, i.e. it # rotated mid-window and older alerts are in a file this does not read. return rows, bool(rows) for row in page_rows: timestamp = _parse_timestamp(row.get("timestamp")) if timestamp is None: continue if timestamp < cutoff: return rows, False rows.append(row) if len(page_rows) < ROWS_PER_PAGE: return rows, True return rows, True def _host_role(ip): # is_global rather than "not is_private" so CGNAT and the reserved ranges land # on the household side of the split rather than being reported as the internet. try: return "remote" if ipaddress.ip_address(ip).is_global else "local" except ValueError: return None def _summarise(rows, truncated, status, config, window_start, window_end, lookback_hours): actions = Counter() interfaces = Counter() local_hosts = Counter() remote_hosts = Counter() signatures = {} for row in rows: action = row.get("alert_action") or "unknown" actions[action] += 1 interface = row.get("in_iface") if interface: interfaces[interface] += 1 for key in ("src_ip", "dest_ip"): ip = row.get(key) if not ip: continue role = _host_role(ip) if role == "local": local_hosts[ip] += 1 elif role == "remote": remote_hosts[ip] += 1 sid = row.get("alert_sid") entry = signatures.setdefault( sid, { "sid": sid, "signature": row.get("alert"), "count": 0, "actions": Counter(), "sources": Counter(), "destinations": Counter(), "last_seen": row.get("timestamp"), }, ) entry["count"] += 1 entry["actions"][action] += 1 if row.get("src_ip"): entry["sources"][row["src_ip"]] += 1 if row.get("dest_ip"): entry["destinations"][row["dest_ip"]] += 1 top_signatures = sorted(signatures.values(), key=lambda item: item["count"], reverse=True) top_signatures = top_signatures[: int(config.get("top_signatures", DEFAULT_TOP_SIGNATURES))] top_hosts = int(config.get("top_hosts", DEFAULT_TOP_HOSTS)) summary = { "source": "opnsense_ids", "category": "network_security", "observed_at": window_end.replace(microsecond=0).isoformat().replace("+00:00", "Z"), "window_start": window_start.replace(microsecond=0).isoformat().replace("+00:00", "Z"), "window_hours": lookback_hours, "ids_status": status, "alert_count": len(rows), "alerts_by_action": dict(actions), "interfaces": dict(interfaces), "top_signatures": [ { "sid": entry["sid"], "signature": entry["signature"], "count": entry["count"], "actions": dict(entry["actions"]), "top_sources": [ip for ip, _ in entry["sources"].most_common(3)], "top_destinations": [ip for ip, _ in entry["destinations"].most_common(3)], "last_seen": entry["last_seen"], } for entry in top_signatures ], "top_local_hosts": [{"ip": ip, "alerts": count} for ip, count in local_hosts.most_common(top_hosts)], "top_remote_hosts": [{"ip": ip, "alerts": count} for ip, count in remote_hosts.most_common(top_hosts)], "window_truncated": truncated, "caveat": ( "Suricata alerts are signature matches on traffic crossing the monitored " "interfaces, not confirmed compromise — false positives are normal and a " "single alert is not an incident. Severity is not exposed by the OPNsense " "API, so these are ranked by frequency only. Encrypted traffic is largely " "opaque to signature matching, and a quiet window is not evidence that " "nothing happened." ), } if truncated: summary["truncation_note"] = ( "More alerts existed in this window than were read (max_alerts_scanned " "reached, or the Suricata log rotated mid-window). Counts are a lower bound." ) reference = str(config.get("packet_capture_reference", "")).strip() if reference: summary["packet_capture_reference"] = reference return summary def fetch(lookback_hours): config = _load_config(os.environ.get("OPNSENSE_IDS_CONF_PATH", DEFAULT_CONF_PATH)) if config is None: return [] base_url = str(config["base_url"]).strip().rstrip("/") verify = config.get("verify_tls", True) if verify is False: LOG.warning("opnsense_ids: verify_tls is false, the API session is unauthenticated against MITM") window_end = datetime.now(timezone.utc) window_start = window_end - timedelta(hours=float(lookback_hours)) max_pages = max(1, math.ceil(int(config.get("max_alerts_scanned", DEFAULT_MAX_ALERTS_SCANNED)) / ROWS_PER_PAGE)) session = requests.Session() session.auth = (str(config["api_key"]).strip(), str(config["api_secret"]).strip()) try: status = _service_status(session, base_url, verify) except Exception: # Without the status call a run cannot tell "no alerts" from "Suricata was # off the whole window", which is the difference between reassuring and # meaningless. Carry on so the alert query still gets its chance. LOG.warning("opnsense_ids: service status query failed", exc_info=True) status = "unknown" try: rows, truncated = _collect_alerts(session, base_url, verify, window_start, max_pages) except Exception: LOG.warning("opnsense_ids: alert query failed, skipping", exc_info=True) return [] interface_filter = [str(item).strip() for item in config.get("interfaces", []) if str(item).strip()] if interface_filter: rows = [row for row in rows if row.get("in_iface") in interface_filter] summary = _summarise(rows, truncated, status, config, window_start, window_end, lookback_hours) LOG.info("opnsense_ids: %d alert(s) in the last %sh, IDS %s", summary["alert_count"], lookback_hours, status) return [summary]