SmartestHome/digest-engine/ingest/opnsense_ids.py

301 lines
12 KiB
Python

"""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]