Download runtime/python/dibaguard/engine.py from DibaAi/DibaGuard-Open: direct link, hf CLI and curl.
- Browser
- Download file 75.2 kB
-
https://huggingface.co/DibaAi/DibaGuard-Open/resolve/main/runtime/python/dibaguard/engine.py
- Command line
-
hf download hf://DibaAi/DibaGuard-Open/runtime/python/dibaguard/engine.py
-
curl -L -o engine.py https://huggingface.co/DibaAi/DibaGuard-Open/resolve/main/runtime/python/dibaguard/engine.py
75.2 kB
| """Guard: the one engine - flows and HTTP requests in, calibrated verdicts, entity risk and incident reports out. | |
| Cascade per flow (time order is the only requirement): | |
| tier 0 EntityState: per-source / per-destination decayed counters (ns) every flow | |
| tier 1 entity detector (oblivious trees on the stream features, ~2 us) every flow | |
| tier 2 SeqNet: causal GRU over the source's recent flows, site-conditioned warm sources only | |
| A source is *warm* while its tier-1 logit was above the low-water mark recently; the GRU state of a source that | |
| just warmed up starts from zero - which is exactly how the network was trained (fixed windows from a zero state), | |
| so the cascade loses nothing a training window would have had. A cold source costs tiers 0 + 1 only. | |
| HTTP: HttpModel2 (bag + MLP conditioned on this site's benign profile) per request, and the request also enters | |
| tier 0 as a flow-like event (client -> host, 'port' = path hash, 'no reply' = 4xx/5xx), so enumeration, brute | |
| force and scanners show up in the entity stream even when every single request looks harmless. | |
| Decisions: each tier has its own conformal threshold at alpha / tiers from the site's own benign scores (shipped | |
| thresholds until the site has enough), temperature-calibrated probabilities, severity from the family head, the | |
| 0-100 event score, decaying per-entity risk, site pressure, and a drift martingale on tier-1 p-values. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import os | |
| import zlib | |
| from collections import OrderedDict | |
| import numpy as np | |
| from dibaguard.campaign import AUTH_PORTS, CampaignTracker, http_fingerprint | |
| from dibaguard.decide import _tol, ConformalFPR, DriftMartingale, EntityRisk, GlobalRate, RollingReference, SitePressure, SourceEvidence, event_score | |
| from dibaguard.families import severity | |
| from dibaguard.flows import entity as ent | |
| from dibaguard.flows import seqnet | |
| from dibaguard.flows.trees import Oblivious | |
| from dibaguard.http.model import HttpModel2 | |
| ANOM_IDX = [ent.feature_names().index(n) for n in ent.ANOMALY_SITE] | |
| DST_FEATS = ["dst_flows_h1", "dst_noreply_h1", "dst_newsrc_h1", "dst_flows_h2"] | |
| DST_IDX = [ent.feature_names().index(n) for n in DST_FEATS] | |
| DST_NEWSRC = ent.feature_names().index("dst_newsrc_h1") | |
| SRC_RATE10 = ent.feature_names().index("src_flows_h1") # the source's own flows within ~10 s | |
| # services where a steady stream of short repeated sessions is password guessing, never a busy client (directory and | |
| # file services - SMB, LDAP, Kerberos - are chatty by nature and are left to the campaign / evidence tiers) | |
| NEVER_EXCUSED = [21, 22, 23, 25, 110, 143, 465, 587, 993, 995, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 9200, 27017] | |
| SRC_NOREPLY2, SRC_SHORT2, SRC_RST2, SRC_SYN2 = (ent.feature_names().index(f"src_{k}_h2") for k in ("noreply", "short", "rst", "synonly")) | |
| SRC_FLOWS3, SRC_REPEAT3 = (ent.feature_names().index(f"src_{k}_h3") for k in ("flows", "repeat")) | |
| SRC_NEWPORT3, SRC_NOREPLY3, SRC_SYN3 = (ent.feature_names().index(f"src_{k}_h3") for k in ("newport", "noreply", "synonly")) | |
| # interactive logins: no legitimate client opens short sessions to them many times a minute for many minutes (databases, | |
| # mail and caches are left out - an application without a connection pool does exactly that) | |
| INTERACTIVE_LOGIN = [21, 22, 23, 3389, 5900] | |
| AGE_EDGES = (1.5, 5.5, 20.5, 100.5, 500.5) # flows ever seen from the source: 1 | 2-5 | 6-20 | 21-100 | 101-500 | 500+ | |
| def _member(values, members) -> np.ndarray: | |
| """np.isin for keys (addresses, ids): one hash lookup per value. np.isin on object / string arrays falls back to | |
| an O(n x m) loop - measured 21 s for 400k buffer rows against a 5k-source set, 17.5 min inside one re-calibration""" | |
| s = members if isinstance(members, (set, frozenset)) else set(members) | |
| return np.fromiter((v in s for v in np.asarray(values, dtype=object).tolist()), bool, len(values)) | |
| def clip_upper(z, frac: float = 0.01) -> np.ndarray: | |
| """a reference's scores with its far upper outliers clipped to a fence. With n < 1/alpha the conformal threshold | |
| is the reference's maximum, so one attack request inside a bootstrap would *be* the threshold. Clipped: scores | |
| beyond q99 + 3 (q99 - q50) (at most `frac` of the rows, else the top `frac`), so an attacker can lift the | |
| threshold at most to a fence the benign bulk sets; the reference keeps its size (alpha stays reachable)""" | |
| z = np.asarray(z, np.float64) | |
| if len(z) < 50: | |
| return z | |
| q50, q99 = np.quantile(z, [0.5, 0.99]) | |
| fence = q99 + 3.0 * max(q99 - q50, 1e-6) | |
| if (z > fence).sum() > frac * len(z): | |
| fence = float(np.quantile(z, 1.0 - frac)) | |
| return np.minimum(z, fence) | |
| def age_bucket(n_flows) -> np.ndarray: | |
| """true source age (flows ever seen, from EntityState) -> bucket; a client's first flows are compared only with | |
| other clients' first flows, a client with 500+ flows with other established clients""" | |
| return np.searchsorted(np.asarray(AGE_EDGES), np.asarray(n_flows, np.float64), side="right").astype(np.int8) | |
| BOT_UA = ("gptbot", "bytespider", "ccbot", "claudebot", "anthropic-ai", "google-extended", "python-requests", "curl/", "wget/", | |
| "scrapy", "go-http-client", "java/", "libwww", "httpclient", "okhttp", "masscan", "zgrab", "nikto", "sqlmap", "nmap") | |
| FLOW_FIELDS = ("t", "src", "dst", "dport", "proto", "bytes_in", "pkts_in", "bytes_out", "pkts_out", "client_flags", | |
| "server_flags", "flags", "duration_ms") | |
| _NF_OF = {"dport": "L4_DST_PORT", "proto": "PROTOCOL", "bytes_in": "IN_BYTES", "pkts_in": "IN_PKTS", "bytes_out": "OUT_BYTES", | |
| "pkts_out": "OUT_PKTS", "client_flags": "CLIENT_TCP_FLAGS", "server_flags": "SERVER_TCP_FLAGS", "flags": "TCP_FLAGS", | |
| "duration_ms": "FLOW_DURATION_MILLISECONDS"} | |
| def _col(flows, name, n, default=0.0): | |
| v = flows.get(name) if isinstance(flows, dict) else (flows[name] if name in flows else None) | |
| return np.full(n, default) if v is None else np.asarray(v) | |
| class Guard: | |
| def __init__(self, tier1: Oblivious, seq: seqnet.SeqNet | None = None, http: HttpModel2 | None = None, | |
| alpha: float = 1e-3, warm_logit: float | None = None, warm_flows: int = 64, | |
| entity_half_life_s: float = 600.0, pressure_window_s: float = 300.0, max_entities: int = 500_000, | |
| rolling_s: float = 1800.0, anomaly: bool = True, tau_s: float = 120.0, mondrian_min: int = 2000, | |
| warmup_s: float = 3600.0, recal_s: float = 1800.0, recal_window_s: float = 7200.0, recal_max_rows: int = 400_000): | |
| self.tier1, self.seq, self.http, self.alpha = tier1, seq, http, alpha | |
| names = ent.feature_names() | |
| self.t1_idx = [names.index(n) for n in tier1.names] if tier1.names and set(tier1.names) <= set(names) else list(range(len(names))) | |
| self.state = ent.EntityState(max_entities=max_entities) | |
| self.temp1 = float(tier1.meta.get("temperature", 1.0)) | |
| self.temp2 = float(seq.info.get("temperature", 1.0)) if seq else 1.0 | |
| self.temph = float(http.info.get("temperature", 1.0)) if http else 1.0 | |
| self.shipped = {k: {float(a): float(v) for a, v in m.items()} for k, m in ( | |
| ("tier1", tier1.meta.get("thresholds", {})), ("seq", seq.info.get("thresholds", {}) if seq else {}), | |
| ("http", http.info.get("thresholds", {}) if http else {}))} | |
| self.site = ConformalFPR(max_per_group=200_000, min_n=1) | |
| # rolling self-cleaning references: previous chunks of non-alarmed traffic (scores per tier, entity features for | |
| # the site-only anomaly tier) - what the site looks like *now*, no labels needed | |
| self.rolling = {k: RollingReference(rolling_s) for k in ("tier1", "seq", "http", "sess")} | |
| self.anom = RollingReference(rolling_s) if anomaly else None # entity features of normal traffic | |
| self.anom_s = RollingReference(rolling_s) if anomaly else None # the anomaly score itself on normal traffic | |
| self.anom_age = [RollingReference(rolling_s) for _ in range(len(AGE_EDGES) + 1)] if anomaly else None # per source age | |
| self.seq_age = [RollingReference(rolling_s) for _ in range(len(AGE_EDGES) + 1)] # per source age | |
| # real-world verdict unit: per-source accumulated evidence, judged per source-minute (see SourceEvidence) | |
| self.evidence = SourceEvidence(tau_s, max_entities) | |
| self.ev_ref = RollingReference(rolling_s) # benign source-minute maxima of E | |
| self.evidence_slow = SourceEvidence(1800.0, max_entities) # long horizon: slow scans over ~30 min | |
| self.ev_slow_ref = RollingReference(rolling_s) | |
| self.evid_drift = 0.0 # benign mean of the per-flow increment (CUSUM baseline) | |
| # destination side: distributed / volumetric floods - no single source stands out, but one destination is | |
| # overwhelmed by many (often new) sources. A per-destination anomaly, accumulated per destination-minute. | |
| self.dst_anom = RollingReference(rolling_s) # benign dst-feature matrix (for the Fisher score) | |
| self.dst_anom_s = RollingReference(rolling_s) # benign dst Fisher scores (for p-values) | |
| self.dst_evidence = SourceEvidence(tau_s, max_entities) # keyed by destination | |
| self.dst_drift = 0.0 # CUSUM allowance for destination increments | |
| self.dst_rate_hi = np.inf # benign per-destination rate, upper tail (floods are volumetric) | |
| self.dst_ev_ref = RollingReference(rolling_s) # benign destination-minute maxima | |
| self._anchor = None # first calibration, kept immutable | |
| self.drift_events: list = [] # drift guard verdicts per epoch | |
| self._alarm_src: "OrderedDict[str, list]" = OrderedDict() # decayed alarm count per source (reference hygiene) | |
| self._contrib: "OrderedDict[str, list]" = OrderedDict() # decayed reference contributions per source | |
| self.global_http = GlobalRate(120.0) # site-wide failure rate per HTTP path (distributed campaigns) | |
| self.global_floor = 10.0 # decayed failures to one path within ~tau -> a campaign | |
| self.global_scan = GlobalRate(120.0) # site-wide SYN-only / no-reply flow rate (distributed scans) | |
| self.global_scan_floor = 60.0 | |
| self.campaign = CampaignTracker() # coordinated low-and-slow campaigns, per fingerprint | |
| self.campaign_http = CampaignTracker() | |
| self.mondrian_min = mondrian_min | |
| self.warmup_s = warmup_s # the counters need their longest horizon to reach steady state before they are a reference | |
| self._minute_max: dict = {} | |
| self.recal_s, self.recal_window_s, self.recal_max_rows = recal_s, recal_window_s, recal_max_rows | |
| self._buffer = {"t": np.zeros(0), "src": np.zeros(0, dtype=object), "dst": np.zeros(0, dtype=object), "z1": np.zeros(0), | |
| "z2": np.zeros(0), "svc": np.zeros(0, np.int32), "xa": np.zeros((0, len(ANOM_IDX)), np.float32), | |
| "xd": np.zeros((0, len(DST_IDX)), np.float32), "age": np.zeros(0, np.int8)} | |
| self.last_recal = 0.0 | |
| self.roll_groups: dict = {} # Mondrian (service x age) rolling references of tier-1 scores | |
| self.entities = EntityRisk(half_life_s=entity_half_life_s, max_entities=max_entities) | |
| self.pressure = SitePressure(window_s=pressure_window_s) | |
| self.drift = DriftMartingale() | |
| # src -> (state [layers, d], flows left warm), least recently warmed first. Bounded: a source that warms once | |
| # and never returns (a one-shot scanner among hundreds of thousands) is evicted, not kept forever | |
| self.gru: "OrderedDict[object, tuple]" = OrderedDict() | |
| self.gru_max = max(10_000, max_entities // 5) | |
| self.warm_flows = warm_flows | |
| self.warm_logit = warm_logit if warm_logit is not None else self.shipped["tier1"].get(1e-2, 0.0) | |
| self.site_http = http.site if http else None | |
| self.site_http_by_host: dict = {} # many sites behind one layer: one HTTP profile + reference per Host | |
| self.http_ref_by_host: dict = {} | |
| self.gamma, self.beta = seq.context(seq.w.get("ctx_pool_default")) if seq else (None, None) | |
| self.log: list = [] # recent alarms for the report | |
| self.counts = {"flows": 0, "http": 0, "alarms": 0, "seq_runs": 0} | |
| def load(cls, model_dir: str, **kw) -> "Guard": | |
| mono = os.path.join(model_dir, "seq_v1", "nf_entity_mono.npz") # monotone prior on attack-direction features | |
| t1 = Oblivious.load(mono if os.path.exists(mono) else os.path.join(model_dir, "entity_v1", "nf_entity_detector.npz")) | |
| sp = os.path.join(model_dir, "seq_v1", "seqnet.npz") | |
| hp = os.path.join(model_dir, "http_v2", "http_tier1_v2.npz") | |
| return cls(t1, seqnet.SeqNet.load(sp) if os.path.exists(sp) else None, HttpModel2.load(hp) if os.path.exists(hp) else None, **kw) | |
| # ------------------------------------------------------------ thresholds | |
| def n_tiers(self) -> int: | |
| return 1 + (self.seq is not None) + (self.anom is not None) | |
| def threshold(self, tier: str) -> tuple[float, str]: | |
| a = self.alpha / self.n_tiers() if tier != "http" else self.alpha | |
| need = ConformalFPR.n_needed(a, 0.5) | |
| if tier in self.rolling and self.rolling[tier].n() >= need: | |
| return self.rolling[tier].threshold(a), "site+rolling" | |
| if self.site.n(tier) >= need: | |
| return self.site.threshold(tier, a, fallback=None), "site" | |
| sh = self.shipped[tier] | |
| if a in sh: | |
| return sh[a], "shipped" | |
| return float(min(sh.values(), default=0.0)), "shipped-nearest" | |
| # ------------------------------------------------------------ flows | |
| def _features(self, flows: dict, n: int): | |
| t = _col(flows, "t", n).astype(np.float64) | |
| src, dst = _col(flows, "src", n, "?"), _col(flows, "dst", n, "?") | |
| cols = {"gap_s": None} | |
| nf = {_NF_OF[k]: _col(flows, k, n, 0.0).astype(np.float64) for k in _NF_OF} | |
| syn_only = ent.tcp_syn_only(nf["CLIENT_TCP_FLAGS"], nf["SERVER_TCP_FLAGS"]) & (nf["PROTOCOL"] == 6) | |
| rst = ent.tcp_rst(nf["TCP_FLAGS"]) | |
| short = (nf["FLOW_DURATION_MILLISECONDS"] < ent.SHORT_MS) & (nf["IN_PKTS"] <= 2) | |
| out: list = [] | |
| up, table = self.state.update, self.state.src | |
| ages = np.empty(n, np.int64) | |
| for i in range(n): | |
| up(float(t[i]), src[i], dst[i], int(nf["L4_DST_PORT"][i]), float(nf["IN_BYTES"][i]), float(nf["IN_PKTS"][i]), | |
| float(nf["OUT_PKTS"][i]), bool(syn_only[i]), bool(rst[i]), bool(short[i]), out) | |
| ages[i] = table[src[i]].n | |
| xe = np.asarray(out, np.float32).reshape(n, -1) | |
| cols["_age"] = ages | |
| for j, name in enumerate(ent.feature_names()): | |
| cols[name] = xe[:, j] | |
| cols.update(nf) | |
| return t, src, dst, xe, cols | |
| def calibrate_flows(self, benign_flows: dict) -> dict: | |
| """This site's own normal traffic (time-ordered, no attack labels): every reference of every tier is rebuilt | |
| from it. Entity state is updated as well (the site's baseline becomes the stream's memory). Later, the engine | |
| re-calibrates itself every `recal_s` seconds from the traffic it has seen (sources with accumulated evidence | |
| left out), so references never drift away from what the site looks like now - and every reference of an | |
| epoch is built from the same rows, so scores and their references always share one scale.""" | |
| n = len(_col(benign_flows, "t", 0)) if "t" in benign_flows else len(next(iter(benign_flows.values()))) | |
| t, src, dst, xe, cols = self._features(benign_flows, n) | |
| z1 = self.tier1.raw(xe[:, self.t1_idx])[:, 0] | |
| svc = ent.service_key(cols["L4_DST_PORT"], cols["PROTOCOL"]) | |
| z2 = np.full(n, np.nan) | |
| if self.seq is not None: | |
| x, port, proto = seqnet.event_features(cols, "nf") | |
| run, _ = self._warm_mask(src, z1) # the same selection as live traffic | |
| z2 = self._seq_scores(src, x, port, proto, run) if run.any() else z2 | |
| self.gru.clear() # calibration streams start every source from a zero state | |
| self.site.add("tier1", z1) | |
| if self.seq is not None and np.isfinite(z2).any(): | |
| self.site.add("seq", z2[np.isfinite(z2)]) | |
| ck, cf, _ = self._campaign_keys(cols) | |
| self.campaign.calibrate(ck, src, t, cf) | |
| self.global_scan.calibrate(["scan"] * n, t, self._scanlike(cols)) # the site's own failed-connect level | |
| self._pending, self._pending_n = [], 0 | |
| self._buffer = {"t": t, "src": np.asarray(src, dtype=object), "dst": np.asarray(dst, dtype=object), "z1": z1, "z2": z2, "svc": svc, | |
| "xa": xe[:, ANOM_IDX].astype(np.float32), "xd": xe[:, DST_IDX].astype(np.float32), "age": age_bucket(cols["_age"])} | |
| self.warmup_short = bool(n and (t >= t[0] + self.warmup_s).sum() < 1000) | |
| self._calibrate_refs(self._buffer, warmup=0.0 if self.warmup_short else self.warmup_s) | |
| if self._anchor is None and n: # the first clean calibration anchors every later epoch | |
| keep = np.sort(np.random.default_rng(0).choice(n, min(n, 80_000), replace=False)) | |
| self._anchor = {k: (self._buffer[k][keep] if isinstance(self._buffer[k], np.ndarray) else self._buffer[k]) for k in self._buffer} | |
| self.last_recal = float(t[-1]) if n else 0.0 | |
| return {k: self.site.n(k) for k in ("tier1", "seq")} | |
| def _calibrate_refs(self, buf: dict, warmup: float = 0.0) -> None: | |
| """Rebuild every reference from one window of (mostly benign) traffic: tier-1 by service x age, anomaly | |
| features and scores by age, sequence scores by age, and the evidence reference out of sample (the last | |
| quarter of the window scored against references from the first three quarters). Steady-state references | |
| (old sources) skip the first `warmup` seconds; young sources are reference material from the first flow.""" | |
| t, src, z1, z2, svc, xa = (buf[k] for k in ("t", "src", "z1", "z2", "svc", "xa")) | |
| dst, xd = buf.get("dst"), buf.get("xd") | |
| n = len(t) | |
| if n == 0: | |
| return | |
| age = self._age_of(buf) | |
| keep = (t >= t[0] + warmup) | (age < len(AGE_EDGES)) | |
| def build(m): | |
| self.rolling["tier1"].set_base(z1[m]) | |
| self.roll_groups = {} | |
| for key, mm in self._group_keys(svc[m], age[m]): | |
| self.roll_groups[key] = RollingReference(self.rolling["tier1"].window_s) | |
| self.roll_groups[key].set_base(z1[m][mm]) | |
| if self.anom is not None: | |
| self.anom.set_base(xa[m]) | |
| sc = self.anom.feature_scores(xa[m]) | |
| self.anom_s.set_base(sc) | |
| for b in range(len(AGE_EDGES) + 1): | |
| self.anom_age[b].set_base(sc[age[m] == b] if (age[m] == b).any() else np.zeros(0)) | |
| if self.seq is not None: | |
| ok = m & ~np.isnan(z2) | |
| self.rolling["seq"].set_base(z2[ok]) | |
| for b in range(len(AGE_EDGES) + 1): | |
| self.seq_age[b].set_base(z2[ok & (age == b)] if (ok & (age == b)).any() else np.zeros(0)) | |
| q = t[0] + 0.75 * (t[-1] - t[0]) | |
| build(keep & (t < q)) | |
| self._calibrate_evidence(t, src, z1, svc, xa, age, t_from=q) | |
| if xd is not None and dst is not None: | |
| if len(xd): | |
| self.dst_rate_hi = float(np.quantile(xd[keep, 0], 0.999)) if keep.any() else float(np.quantile(xd[:, 0], 0.999)) | |
| self.dst_anom.set_base(xd[keep & (t < q)]) | |
| self.dst_anom_s.set_base(self._dst_scores(xd[keep & (t < q)])) | |
| self._calibrate_dst_evidence(t, dst, xd, t_from=q) | |
| build(keep) | |
| def _age_of(self, buf: dict) -> np.ndarray: | |
| return buf["age"] if "age" in buf else np.zeros(len(buf["t"]), np.int8) + len(AGE_EDGES) | |
| def _remember(self, t, src, dst, z1, z2, svc, xa, xd, age, ok) -> None: | |
| """keep the recent traffic (except flows that raised any tier) for the next re-calibration epoch. O(batch): the | |
| rows are queued and folded into the buffer (window cut, subsample, per-source cap) only every ~50k rows and | |
| before a re-calibration or a snapshot - doing it on every call cost ~20-50 ms per call (measured: 48 flows/s | |
| at batch 1)""" | |
| new = {"t": t[ok], "src": np.asarray(src, dtype=object)[ok], "dst": np.asarray(dst, dtype=object)[ok], "z1": z1[ok], | |
| "z2": z2[ok], "svc": svc[ok], "xa": xa[ok].astype(np.float32), "xd": xd[ok].astype(np.float32), "age": age[ok]} | |
| self.__dict__.setdefault("_pending", []).append(new) | |
| self._pending_n = self.__dict__.get("_pending_n", 0) + len(new["t"]) | |
| if len(t): # the stream's clock (its newest rows may all have been left out) | |
| self._buf_now = max(self.__dict__.get("_buf_now", -np.inf), float(t[-1])) | |
| if self._pending_n >= 50_000: | |
| self._flush_buffer() | |
| def _flush_buffer(self) -> None: | |
| pend = self.__dict__.get("_pending") | |
| if not pend: | |
| return | |
| self._pending, self._pending_n = [], 0 | |
| b = self._buffer | |
| if "age" not in b: | |
| b["age"] = np.full(len(b["t"]), len(AGE_EDGES), np.int8) | |
| for k in b: | |
| b[k] = np.concatenate([b[k]] + [p[k] for p in pend]) | |
| t = b["t"] | |
| now = max(float(t[-1]) if len(t) else -np.inf, self.__dict__.get("_buf_now", -np.inf)) | |
| cutoff = now - self.recal_window_s | |
| m = b["t"] >= cutoff | |
| if m.sum() > self.recal_max_rows: # bounded memory: a random subset, time order kept | |
| idx = np.flatnonzero(m) | |
| idx = np.sort(np.random.default_rng(int(now) % 100_003).choice(idx, self.recal_max_rows, replace=False)) | |
| m = np.zeros(len(m), bool) | |
| m[idx] = True | |
| for k in b: | |
| b[k] = b[k][m] | |
| # no single source may write more than ~1% of the next reference (an un-alarmed slow attacker would otherwise | |
| # become its tail); a busy benign source (a NAT gateway, a CDN edge) is subsampled, not excluded | |
| n_b = len(b["src"]) | |
| if n_b > 20_000: | |
| u, inv, cnt = np.unique(b["src"].astype(str), return_inverse=True, return_counts=True) | |
| cap = max(200, int(0.01 * n_b)) | |
| if (cnt > cap).any(): | |
| keep = np.ones(n_b, bool) | |
| rng = np.random.default_rng(n_b) | |
| for j in np.flatnonzero(cnt > cap): | |
| idx = np.flatnonzero(inv == j) | |
| keep[rng.choice(idx, len(idx) - cap, replace=False)] = False | |
| for k in b: | |
| b[k] = b[k][keep] | |
| def recalibrate_now(self, now: float | None = None) -> bool: | |
| """operator-triggered: rebuild every reference from the recent window at once (accept the current traffic as normal)""" | |
| self._flush_buffer() | |
| b = self._buffer | |
| if len(b["t"]) < 1000: | |
| return False | |
| self.last_recal = -np.inf | |
| self._maybe_recalibrate(float(b["t"][-1]) if now is None else now) | |
| return True | |
| def _sustained(self, srcs, t, alarm, k: float = 3.0) -> set: | |
| """sources whose alarms keep coming (a decayed count over the re-calibration window reaching k): they are | |
| kept out of every reference, while a source with a single alarm - the benign tail - is not""" | |
| out, tau = set(), self.recal_window_s | |
| for i in np.flatnonzero(np.asarray(alarm)): | |
| s, ti = str(srcs[i]), float(t[i]) | |
| st = self._alarm_src.get(s) | |
| if st is None: | |
| st = self._alarm_src[s] = [0.0, ti] | |
| if len(self._alarm_src) > 200_000: | |
| self._alarm_src.popitem(last=False) | |
| st[0] = st[0] * math.exp(-max(0.0, ti - st[1]) / tau) + 1.0 | |
| st[1] = ti | |
| if st[0] >= k: | |
| out.add(s) | |
| return out | |
| def _may_contribute(self, src: str, t: float, ref_n: int) -> bool: | |
| """no single source may write more than ~1% of a reference (at least 30 entries): a scraper or a scanner | |
| that is not alarmed yet would otherwise become the reference's tail within minutes and never alarm again; | |
| a very busy benign client is merely capped, not excluded""" | |
| st = self._contrib.get(src) | |
| tau = self.rolling["http"].window_s | |
| if st is None: | |
| st = self._contrib[src] = [0.0, t] | |
| if len(self._contrib) > 200_000: | |
| self._contrib.popitem(last=False) | |
| st[0] *= math.exp(-max(0.0, t - st[1]) / tau) | |
| st[1] = t | |
| if st[0] >= max(30.0, 0.01 * ref_n): | |
| return False | |
| st[0] += 1.0 | |
| return True | |
| def sustained_now(self, src: str, t: float, k: float = 3.0) -> bool: | |
| st = self._alarm_src.get(str(src)) | |
| return bool(st and st[0] * math.exp(-max(0.0, t - st[1]) / self.recal_window_s) >= k) | |
| def _drift_guard(self, b: dict, now: float) -> dict: | |
| """real drift or a slow poisoning? Before the recent window becomes the reference, its upper tail is compared | |
| with the anchor's: if more traffic than expected sits above the anchor's 99th percentile, the question is *who* | |
| put it there. A site that changed (a release, a new client population) shifts the tail across many sources - | |
| adopted, and reported. A handful of sources dragging the tail up epoch after epoch is an attacker teaching | |
| the reference to accept them (a 'boiling frog') - their rows are kept out and they are logged.""" | |
| a = self._anchor | |
| if a is None or "z1" not in a or len(a["z1"]) < 1000 or len(b["t"]) < 1000: | |
| return b | |
| q = float(np.quantile(a["z1"], 0.99)) | |
| # sources the anchor already knew are part of the site's normal: their tail is drift, not poisoning | |
| if not hasattr(self, "_anchor_src"): | |
| self._anchor_src = set(np.asarray(a["src"], dtype=object).tolist()) | |
| known = _member(b["src"], self._anchor_src) if self._anchor_src else np.zeros(len(b["src"]), bool) | |
| tail = (b["z1"] > q) & ~known | |
| frac = float(tail.mean()) | |
| ev = {"t": now, "tail_share": round(frac, 4), "expected": 0.01} | |
| if frac <= 0.03: | |
| return b | |
| srcs, counts = np.unique(np.asarray(b["src"], dtype=object)[tail], return_counts=True) | |
| order = np.argsort(-counts) | |
| top = srcs[order[:20]] | |
| conc = float(counts[order[:20]].sum() / max(1, counts.sum())) | |
| ev.update(tail_sources=int(len(srcs)), top20_share=round(conc, 3)) | |
| if conc > 0.5 or len(srcs) < 30: | |
| keep = ~_member(b["src"], set(top.tolist())) | |
| b = {k: (v[keep] if isinstance(v, np.ndarray) and len(v) == len(keep) else v) for k, v in b.items()} | |
| ev["verdict"] = "concentrated: held out of the reference (suspected slow poisoning)" | |
| for s in top[:20]: | |
| self._log("flow", now, str(s), "-", 0, "attack_generic", 60, 7, | |
| evidence=f"drift guard: this source drives {int(counts[srcs == s][0])} of the window's {int(tail.sum())} " | |
| f"tail flows (anchor q99); kept out of the reference") | |
| else: | |
| ev["verdict"] = "broad: adopted as site drift" | |
| self.drift_events.append(ev) | |
| if len(self.drift_events) > 500: | |
| del self.drift_events[:100] | |
| return b | |
| def _maybe_recalibrate(self, now: float) -> None: | |
| if now - self.last_recal < self.recal_s: | |
| return | |
| self._flush_buffer() | |
| b = self._buffer | |
| if len(b["t"]) >= 5000: | |
| b = self._drift_guard(b, now) | |
| if self._anchor is not None: # anchor + recent window: the reference can track the site but | |
| merged = {k: (np.concatenate([self._anchor[k], b[k]]) if isinstance(b.get(k), np.ndarray) and k in self._anchor else b.get(k)) for k in b} | |
| else: | |
| merged = b | |
| self._calibrate_refs(merged, warmup=0.0) | |
| self.last_recal = now | |
| self.counts["recalibrations"] = self.counts.get("recalibrations", 0) + 1 | |
| def _group_keys(svc, age): | |
| """(key, mask) for every Mondrian cell present: service x age, then service, then age""" | |
| out = [] | |
| for g in np.unique(svc): | |
| for b in np.unique(age[svc == g]): | |
| out.append((f"{int(g)}/{int(b)}", (svc == g) & (age == b))) | |
| out.append((f"{int(g)}", svc == g)) | |
| for b in np.unique(age): | |
| out.append((f"age{int(b)}", age == b)) | |
| return out | |
| def _tier1_pvalues(self, z1, svc, age) -> np.ndarray: | |
| """tier-1 score -> p-value against the site's reference of the same service x source age; a client's first | |
| flows are judged only against other clients' first flows, and with no such reference yet they yield no | |
| evidence at all (abstain)""" | |
| n = len(z1) | |
| p1 = np.ones(n) | |
| for g in np.unique(svc): | |
| for b in np.unique(age[svc == g]): | |
| m = (svc == g) & (age == b) | |
| keys = (f"{int(g)}/{int(b)}", f"age{int(b)}") + (() if b < len(AGE_EDGES) else (f"{int(g)}", "*")) | |
| for key in keys: | |
| ref_g = self.rolling["tier1"] if key == "*" else self.roll_groups.get(key) | |
| if ref_g is not None and ref_g.n() >= self.mondrian_min: | |
| p1[m] = ref_g.pvalues(z1[m]) | |
| break | |
| return p1 | |
| def _anomaly_pvalues(self, sc, age) -> np.ndarray: | |
| """anomaly score -> p-value against sources of the same age. When a young bucket's own reference is too thin | |
| (sparsely-populated ages, e.g. sources with 2-20 flows), it is pooled with its neighbouring young buckets | |
| instead of abstaining - otherwise a low-and-slow scanner living in a thin bucket would never accumulate any | |
| evidence. Only a source's first flow (bucket 0) still abstains; newborn suppression + block_ok keep benign safe.""" | |
| young = age < len(AGE_EDGES) | |
| pa = np.where(young, 1.0, self.anom_s.pvalues(sc)) if self.anom_s.n() else np.ones(len(sc)) | |
| pooled_cache: dict = {} | |
| for b in range(len(AGE_EDGES) + 1): | |
| m = age == b | |
| if not m.any(): | |
| continue | |
| if self.anom_age[b].n() >= self.mondrian_min: | |
| pa[m] = self.anom_age[b].pvalues(sc[m]) | |
| elif 1 <= b < len(AGE_EDGES): | |
| lo, hi = max(1, b - 1), min(len(AGE_EDGES), b + 1) | |
| key = (lo, hi) | |
| if key not in pooled_cache: | |
| refs = [self.anom_age[k].ref() for k in range(lo, hi + 1) if self.anom_age[k].n()] | |
| pooled_cache[key] = np.sort(np.concatenate(refs)) if refs else None | |
| ref = pooled_cache[key] | |
| if ref is not None and len(ref) >= self.mondrian_min: | |
| pa[m] = (1 + len(ref) - np.searchsorted(ref, sc[m] - _tol(sc[m]), side="left")) / (len(ref) + 1) | |
| return pa | |
| def _calibrate_evidence(self, t, src, z1, svc, xa, age, t_from: float = -np.inf) -> None: | |
| """benign source-minute maxima of the accumulated evidence over the window -> reference (evidence accumulates | |
| over every flow; only minutes at or after t_from - scored out of sample - enter the reference)""" | |
| p1 = self._tier1_pvalues(z1, svc, age) | |
| p = p1 | |
| if self.anom is not None and self.anom_s.n() >= 200: | |
| p = np.minimum(1.0, 2.0 * np.minimum(p1, self._anomaly_pvalues(self.anom.feature_scores(xa), age))) | |
| lpall = -np.log(np.maximum(p, 1e-12)) | |
| sus_all = np.where(p < 0.5, lpall, 0.0) | |
| _pos = sus_all[sus_all > 0] | |
| # CUSUM allowance k = mean + std of the benign increment: benign has a negative drift (bounded excursions at any | |
| # request rate); an attack's increments (-log p of ~7-12) still climb fast | |
| self.evid_drift = float(_pos.mean() + _pos.std()) if len(_pos) > 1 else 0.0 | |
| ev = SourceEvidence(self.evidence.tau, self.evidence.max) | |
| ev_s = SourceEvidence(self.evidence_slow.tau, self.evidence_slow.max) | |
| mx: dict = {} | |
| mxs: dict = {} | |
| for i in range(len(t)): | |
| inc = (sus_all[i] - self.evid_drift) if sus_all[i] > 0 else -self.evid_drift * 0.0 # zero-mean under benign | |
| e = ev.update(src[i], float(t[i]), inc) | |
| es = ev_s.update(src[i], float(t[i]), inc) | |
| if t[i] < t_from: | |
| continue | |
| k = (src[i], int(t[i] // 60)) | |
| mx[k] = max(mx.get(k, 0.0), e) | |
| ks = (src[i], int(t[i] // 1800)) | |
| mxs[ks] = max(mxs.get(ks, 0.0), es) | |
| self.ev_ref.set_base(np.fromiter(mx.values(), np.float64)) | |
| self.ev_slow_ref.set_base(np.fromiter(mxs.values(), np.float64)) | |
| def _warm_mask(self, src, z1) -> tuple[np.ndarray, set]: | |
| """which flows the sequence net runs on: a source is warm for `warm_flows` flows after its tier-1 score last | |
| crossed the low-water mark. The same rule selects the calibration flows, so the GRU reference is drawn from | |
| the same population as the live scores (warm sources are not a random sample).""" | |
| n = len(src) | |
| run, cold = np.zeros(n, bool), set() | |
| for i in range(n): | |
| s = src[i] | |
| g = self.gru.get(s) | |
| if z1[i] > self.warm_logit: | |
| g = (g[0] if g is not None else np.zeros((self.seq.layers, self.seq.d), np.float32), self.warm_flows) | |
| if g is not None and g[1] > 0: | |
| run[i] = True | |
| self.gru[s] = (g[0], g[1] - 1) | |
| self.gru.move_to_end(s) | |
| if len(self.gru) > self.gru_max: | |
| old, _ = self.gru.popitem(last=False) | |
| cold.discard(old) | |
| elif g is not None: | |
| cold.add(s) # deleted after this batch is scored (its earlier flows may still be pending) | |
| return run, cold | |
| def _dst_scores(self, xd) -> np.ndarray: | |
| """Fisher anomaly of a destination's (flows, no-reply share, new-source share, slow flows) vs the site's | |
| benign destinations - high when one destination is being flooded, especially by new sources.""" | |
| from dibaguard.decide import feature_scores | |
| return feature_scores(self.dst_anom.ref(), np.asarray(xd, np.float64)) if self.dst_anom.n() else np.zeros(len(xd)) | |
| def _calibrate_dst_evidence(self, t, dst, xd, t_from: float = -np.inf) -> None: | |
| sc = self._dst_scores(xd) | |
| ev = SourceEvidence(self.dst_evidence.tau, self.dst_evidence.max) | |
| mx: dict = {} | |
| pv = self.dst_anom_s.pvalues(sc) if self.dst_anom_s.n() else np.ones(len(sc)) | |
| lp = -np.log(np.maximum(pv, 1e-12)) | |
| pos = lp[pv < 0.5] | |
| self.dst_drift = float(pos.mean() + pos.std()) if len(pos) > 1 else 0.0 | |
| for i in range(len(t)): | |
| e = ev.update(dst[i], float(t[i]), float(lp[i] - self.dst_drift) if pv[i] < 0.5 else 0.0) | |
| if t[i] < t_from: | |
| continue | |
| k = (dst[i], int(t[i] // 60)) | |
| mx[k] = max(mx.get(k, 0.0), e) | |
| self.dst_ev_ref.set_base(np.fromiter(mx.values(), np.float64)) | |
| def _seq_scores(self, src, x, port, proto, run: np.ndarray) -> np.ndarray: | |
| """GRU steps for the flows flagged in `run`, in time order per source; batched by rounds of distinct sources.""" | |
| m = self.seq | |
| z = np.full(len(src), np.nan, np.float32) | |
| idx = np.flatnonzero(run) | |
| if len(idx) == 0: | |
| return z | |
| e = m.embed(x[idx], port[idx], proto[idx]) | |
| pending: dict = {} | |
| for k, i in enumerate(idx): | |
| pending.setdefault(src[i], []).append(k) | |
| rounds = max(len(v) for v in pending.values()) | |
| keys = list(pending) | |
| for r in range(rounds): | |
| live = [s for s in keys if r < len(pending[s])] | |
| ks = np.array([pending[s][r] for s in live]) | |
| zero = np.zeros((m.layers, m.d), np.float32) | |
| st = np.stack([self.gru.get(s, (zero, 0))[0] for s in live], 1) # [layers, n, d] | |
| st = m.gru_step(e[ks], st) | |
| zz = m.head(st[-1], self.gamma, self.beta) | |
| for j, s in enumerate(live): | |
| self.gru[s] = (st[:, j], self.gru.get(s, (zero, 0))[1]) | |
| z[idx[ks]] = zz[:, 0] | |
| self._fam = getattr(self, "_fam", {}) | |
| for j, s in enumerate(live): | |
| self._fam[idx[ks[j]]] = int(zz[j, 1:].argmax()) if zz.shape[1] > 1 else -1 | |
| self.counts["seq_runs"] += len(idx) | |
| return z | |
| def flows(self, flows: dict) -> dict: | |
| """Time-ordered batch (dict of arrays with FLOW_FIELDS) -> per-flow verdicts.""" | |
| n = len(_col(flows, "t", 0)) if "t" in flows else len(next(iter(flows.values()))) | |
| t, src, dst, xe, cols = self._features(flows, n) | |
| z1 = self.tier1.raw(xe[:, self.t1_idx])[:, 0] | |
| p1 = 1 / (1 + np.exp(-z1 / self.temp1)) | |
| thr1, src1 = self.threshold("tier1") | |
| a_tier = self.alpha / (2 * self.n_tiers()) # the per-flow tiers share half of the budget, the evidence tier has the other half | |
| svc = ent.service_key(cols["L4_DST_PORT"], cols["PROTOCOL"]) | |
| ages = age_bucket(cols["_age"]) | |
| pv1 = self._tier1_pvalues(z1, svc, ages) # Mondrian by service x age; abstains when uncalibrated | |
| alarm = pv1 < a_tier | |
| p, tier, fam = p1.copy(), np.full(n, 1, np.int8), np.full(n, "benign", dtype=object) | |
| z2 = np.full(n, np.nan, np.float32) | |
| if self.seq is not None: | |
| run, cold = self._warm_mask(src, z1) | |
| if run.any(): | |
| self._fam = {} | |
| x, port, proto = seqnet.event_features(cols, "nf") | |
| z2 = self._seq_scores(src, x, port, proto, run) | |
| p2 = 1 / (1 + np.exp(-np.nan_to_num(z2, nan=-30.0) / self.temp2)) | |
| zz = np.nan_to_num(z2, nan=-30.0) | |
| pv2 = np.ones(n) # judged against sources of the same age, else abstain | |
| for b in range(len(AGE_EDGES) + 1): | |
| m = run & (ages == b) | |
| if m.any() and self.seq_age[b].n() >= self.mondrian_min: | |
| pv2[m] = self.seq_age[b].pvalues(zz[m]) | |
| hit = run & (pv2 < a_tier) & (ages >= 2) # the sequence net needs a few flows of history | |
| alarm |= hit | |
| better = run & (p2 > p) | |
| p[better], tier[better] = p2[better], 2 | |
| for i in np.flatnonzero(hit): | |
| k = self._fam.get(i, -1) | |
| fam[i] = self.seq.families[k + 1] if 0 <= k + 1 < len(self.seq.families) and self.seq.families else "attack_generic" | |
| for s in cold: | |
| self.gru.pop(s, None) | |
| anomaly_s = np.zeros(n) | |
| if self.anom is not None and self.anom_s.n() >= ConformalFPR.n_needed(a_tier, 0.5): | |
| # site-only tier: how far outside this site's own normal behaviour the source is; its score is calibrated | |
| # like every other tier, against sources of the same age (a client's first flows vs other first flows) | |
| anomaly_s = self.anom.feature_scores(xe[:, ANOM_IDX]) | |
| pa = self._anomaly_pvalues(anomaly_s, ages) | |
| hit = pa < a_tier | |
| alarm |= hit | |
| tier[hit & (tier == 1) & ~(pv1 < a_tier)] = 3 | |
| p = np.maximum(p, np.where(hit, 1.0 - pa, 0.0)) | |
| # evidence tier (the real-world verdict): accumulate -log p per source, judge the source-minute | |
| pflow = np.minimum(1.0, 2.0 * np.minimum(pv1, self._anomaly_pvalues(anomaly_s, ages) if self.anom is not None and self.anom_s.n() >= 200 else 1.0)) | |
| evid = np.zeros(n) | |
| evid_s = np.zeros(n) | |
| for i in range(n): | |
| inc = (float(-np.log(max(pflow[i], 1e-12))) - self.evid_drift) if pflow[i] < 0.5 else 0.0 | |
| evid[i] = self.evidence.update(src[i], float(t[i]), inc) | |
| evid_s[i] = self.evidence_slow.update(src[i], float(t[i]), inc) | |
| a_ev = self.alpha / 2 # the destination tier's share | |
| a_ev2 = self.alpha / 4 # split between the fast and the slow source-evidence horizons | |
| thr_e = self.ev_ref.threshold(a_ev2) if self.ev_ref.n() >= ConformalFPR.n_needed(a_ev2, 0.5) else float("inf") | |
| thr_es = self.ev_slow_ref.threshold(a_ev2) if self.ev_slow_ref.n() >= max(200, ConformalFPR.n_needed(a_ev2, 0.5)) else float("inf") | |
| hit_e = (evid > thr_e) | (evid_s > thr_es) # fast burst or slow accumulation over ~30 min | |
| tier[hit_e & ~alarm] = 4 | |
| alarm |= hit_e | |
| p = np.maximum(p, np.where(hit_e, 1.0 - np.minimum(self.ev_ref.pvalues(evid), self.ev_slow_ref.pvalues(evid_s)), 0.0)) | |
| # destination-flood tier: a destination overwhelmed by many (often new) sources - distributed / volumetric | |
| hit_d = np.zeros(n, bool) | |
| piling = np.zeros(n, bool) | |
| if self.dst_anom.n() >= self.mondrian_min and self.dst_ev_ref.n() >= ConformalFPR.n_needed(a_ev, 0.5): | |
| dsc = self._dst_scores(xe[:, DST_IDX]) | |
| dpv = self.dst_anom_s.pvalues(dsc) | |
| dev = np.zeros(n) | |
| for i in range(n): | |
| dev[i] = self.dst_evidence.update(dst[i], float(t[i]), | |
| float(-np.log(max(dpv[i], 1e-12)) - self.dst_drift) if dpv[i] < 0.5 else 0.0) | |
| thr_d = self.dst_ev_ref.threshold(a_ev) | |
| flooded = dev > thr_d # the destination is under a flood right now | |
| # flag only the flows piling on (new to the destination), so the destination's established benign clients | |
| # are not blamed for a flood aimed at their server | |
| volumetric = xe[:, DST_IDX[0]] > 1.5 * self.dst_rate_hi # the destination's rate is far over its site norm | |
| # the *source* must be piling on itself (several flows within ~10 s - a flooding bot does, a visitor who | |
| # opened one page of the attacked server does not): the destination's new-source share is the same number | |
| # for every flow to it and cannot tell them apart. (A single off-profile flow is not enough: among 100k | |
| # benign flows a few reach any per-flow p-value by chance.) | |
| piling = xe[:, SRC_RATE10] >= 3.0 | |
| # piling on = many new sources (a DDoS), or - for a pool of bots that keeps reconnecting, no longer 'new' after | |
| # its first contact - a volume far over the destination's norm carried by young sources (an old, trusted | |
| # machine's heavy job never qualifies) | |
| extreme = (xe[:, DST_IDX[0]] > 2.5 * self.dst_rate_hi) & (cols["_age"] < 100) | |
| many_new = xe[:, DST_NEWSRC] > 0.3 | |
| hit_d = flooded & volumetric & (many_new | extreme) & (pflow < 0.5) | |
| # on volume alone (the bot pool) a source is acted on only if it floods itself; a new visitor who is busy for | |
| # a moment on the attacked server is watched | |
| piling = (piling & many_new) | (xe[:, SRC_RATE10] >= 10.0) | |
| tier[hit_d & ~alarm] = 5 | |
| alarm |= hit_d | |
| p = np.maximum(p, np.where(hit_d, 1.0 - dpv, 0.0)) | |
| elif np.isfinite(self.dst_rate_hi): | |
| # the destination evidence reference is still filling (a small network has few destinations): until then | |
| # only the unmistakable flood counts - three times the destination's calibrated peak rate, carried by young | |
| # sources that pile on themselves | |
| piling = xe[:, SRC_RATE10] >= 10.0 | |
| hit_d = (xe[:, DST_IDX[0]] > 3.0 * self.dst_rate_hi) & (cols["_age"] < 100) & (pflow < 0.5) | |
| tier[hit_d & ~alarm] = 5 | |
| alarm |= hit_d | |
| p = np.maximum(p, np.where(hit_d, 0.99, 0.0)) | |
| # a source's first flows carry no behaviour to judge; behavioural tiers (1 Mondrian / 2 seq / 3 anomaly / | |
| # 4 evidence) abstain until it has a short history. The supervised prior firing hard (z1 well over its | |
| # shipped 1e-4 threshold) and the destination-flood tier still act, so blatant first-flow attacks are kept. | |
| newborn = cols["_age"] < 3 | |
| prior_hard = z1 > self.shipped["tier1"].get(1e-4, np.inf) | |
| self_evident = pflow < a_tier # a single flow so far off-profile it is actionable on its own | |
| suppress = newborn & ~prior_hard & ~self_evident & (tier != 5) | |
| alarm &= ~suppress | |
| tier[suppress] = 1 | |
| # block_ok: alarms safe to auto-block. A newborn source raising only a soft behavioural tier (1 Mondrian / | |
| # 3 anomaly) is watched / challenged, never blocked on its first flows; everything with history or a hard | |
| # signal (evidence 4, sequence 2, flood 5, self-evident, or the prior firing hard) is block-safe. | |
| soft_newborn = alarm & newborn & np.isin(tier, [1, 3]) & ~self_evident & ~prior_hard | |
| block_ok = alarm & ~soft_newborn | |
| # a flow alarmed only because its destination is flooded is acted on only when its source piles on itself: | |
| # a visitor who opened one page of the attacked server is watched, never rate-limited | |
| block_ok &= ~(hit_d & ~piling) # whichever tier raised it: its destination is flooded, the source did not flood | |
| if alarm.any(): # transparent family from the stream counters; the GRU head only fills gaps | |
| rules = ent.family_rules(xe[alarm], np.asarray(cols["L4_DST_PORT"])[alarm]) | |
| cur = fam[alarm] | |
| fam[alarm] = np.where(rules != "attack_generic", rules, np.where(cur == "benign", "attack_generic", cur)) | |
| # a flooded destination names the flow only when the flood is what raised it, or the source itself piles on: | |
| # a slow scanner whose probe lands on a host someone else is flooding is still a scanner (named 'dos', it was | |
| # rate-limited instead of blocked) | |
| by_flood = hit_d & ((tier == 5) | piling) | |
| fam[by_flood] = np.where(xe[by_flood, DST_NEWSRC] > 0.3, "ddos", "dos") | |
| sev = np.array([severity(f) if a else 0.0 for f, a in zip(fam, alarm)]) | |
| pe = np.where(alarm, p, np.minimum(p, 0.5)) | |
| score = np.array([event_score(float(a), float(b)) for a, b in zip(pe, sev)], np.int16) | |
| # the traffic of this batch (sources with accumulated evidence left out) is tomorrow's reference: it is | |
| # remembered and, every recal_s seconds, every reference is rebuilt from the recent window at once | |
| # global distributed-scan tier: SYN-only / no-reply short flows are the scan signature (benign traffic has ~none); | |
| # when their site-wide decayed rate spikes, the cohort is a distributed scan even if each source is low-volume. | |
| scanlike = self._scanlike(cols) | |
| camp_scan, scan_rate = self.global_scan.score(["scan"] * n, t, scanlike, floor=self.global_scan_floor, rates=True) | |
| # a milder surge (over the floor and 1.5x the site's own level, short of the 4x the whole cohort needs): only a | |
| # young source that does nothing but fail is the scan's. Never the floor alone: a network whose normal is | |
| # full of young unanswered sources (DNS clients whose replies the sensor misses, internet background noise) | |
| # sits over it all day (measured on real benign traffic: ~half of all source-minutes alarmed) | |
| mild = scan_rate > self.global_scan_floor + 1.5 * self.global_scan.expected("scan", float(t[-1]) if n else 0.0) | |
| young_failing = (cols["_age"] < 20) & (np.maximum(xe[:, SRC_NOREPLY2], xe[:, SRC_SYN2]) > 0.8) & mild | |
| # even while a site-wide surge is on, the flows flagged are those of scan-shaped sources (young, or failing at | |
| # least half the time): an established client's one failed connect during someone else's scan is collateral | |
| camp_scan &= (cols["_age"] < 50) | (np.maximum(xe[:, SRC_NOREPLY2], xe[:, SRC_SYN2]) > 0.5) | |
| camp_scan |= scanlike & young_failing | |
| if camp_scan.any(): | |
| newc = camp_scan & ~alarm | |
| tier[newc] = 5 | |
| alarm |= camp_scan | |
| fam[camp_scan & (fam == "benign")] = "portscan" | |
| # campaign tier: a fingerprint (service x failed-connect / short-auth-session) with far more distinct failing | |
| # sources than its own history - members are the sources that failed on it and never succeeded | |
| ck, cf, authlike = self._campaign_keys(cols) | |
| # a completed connection to a port answers for that port's failed-connect fingerprint too (the two are separate | |
| # keys): without it every ordinary client with one failed connect became a member of a live scan campaign - | |
| # and block-safe (measured on SIMARGL 2021 benign traffic during its scan: 20.7% of benign source-minutes) | |
| okc = np.flatnonzero(~cf) | |
| if len(okc): | |
| self.campaign.credit([("probe", ck[i][1]) for i in okc], np.asarray(src)[okc], np.asarray(t)[okc]) | |
| camp = self.campaign.update(ck, src, t, cf) | |
| camp_ev: dict = {} | |
| # a member is a source shaped like a campaign's: young, or failing at least half the time - an established | |
| # client that mostly succeeds is not one of a botnet's addresses because one connect of it failed meanwhile | |
| shaped = (cols["_age"] < 50) | (np.maximum(xe[:, SRC_NOREPLY2], xe[:, SRC_SYN2]) > 0.5) | |
| member = np.array([c is not None for c in camp], bool) & shaped | |
| if member.any(): | |
| tier[member & ~alarm] = 6 | |
| alarm |= member | |
| fam[member] = np.where(authlike[member], "bruteforce", "portscan") | |
| # a member of an ICMP campaign (no service, port 0) is alarmed, not block-safe: echo records without a reply | |
| # are common in benign traffic (one-way exports, filtered replies) and blocking a ping-sweep's cohort buys | |
| # little (measured on SIMARGL 2021 during its scan: ~12k benign ICMP records block-safe otherwise) | |
| block_ok = block_ok | (member & (np.asarray(cols["L4_DST_PORT"]).astype(np.int64) != 0)) | |
| for i in np.flatnonzero(member): | |
| key, k_now, mu = camp[i] | |
| camp_ev[i] = (f"campaign: {k_now} distinct sources failing on {key[1]}/{key[0]} this window (normal ~{mu:.0f}); " | |
| f"this source only ever failed there") | |
| score = np.where(member, np.maximum(score, 75), score).astype(np.int16) | |
| # slow scan: over the 30-minute horizon the source keeps reaching (host, port) pairs it never used and nearly | |
| # every attempt fails - port walking, however slow (a probe every 20 s never adds up on the 2-minute horizon). | |
| # Like the guessing rule below, only for a source the site has not known for long (< 500 flows): an established | |
| # host whose lookups go unanswered in the flow records (DNS to many resolvers, CICFlowMeter time-outs) walks | |
| # 'new' endpoints too (measured on CSE-CIC-IDS2018: ~11k benign records otherwise); the evidence tiers judge it | |
| slow_scan = ((xe[:, SRC_FLOWS3] >= 20.0) & (xe[:, SRC_NEWPORT3] >= 0.8) | |
| & (np.maximum(xe[:, SRC_NOREPLY3], xe[:, SRC_SYN3]) >= 0.8) & self._scanlike(cols) & (cols["_age"] < 500)) | |
| if slow_scan.any(): | |
| tier[slow_scan & ~alarm] = 4 | |
| alarm |= slow_scan | |
| fam[slow_scan & np.isin(fam, ["benign", "attack_generic"])] = "portscan" | |
| block_ok = block_ok | slow_scan | |
| pe = np.where(slow_scan, np.maximum(pe, 0.99), pe) | |
| sev = np.where(slow_scan, np.maximum(sev, severity("portscan")), sev) | |
| for i in np.flatnonzero(slow_scan): | |
| camp_ev.setdefault(i, f"slow scan: {xe[i, SRC_FLOWS3]:.0f} attempts in ~30 min, {100 * xe[i, SRC_NEWPORT3]:.0f}% to " | |
| f"endpoints it never used, {100 * max(xe[i, SRC_NOREPLY3], xe[i, SRC_SYN3]):.0f}% failing") | |
| score = np.where(slow_scan, np.maximum(score, 75), score).astype(np.int16) | |
| # sustained guessing: one source opening short sessions to an interactive login service again and again for | |
| # minutes - 15 tries a minute for half an hour - is password guessing even when no single flow is unusual and | |
| # the record shows no failure (the handshake completes, the server just says no). >= 120 flows on the 30-min | |
| # horizon is >= ~0.2 per second for >= 10 minutes; nearly all of them to an endpoint the source already used | |
| # Only for a source the site has not known for long (< 500 flows): a guesser is caught long before that (after | |
| # ~120 tries), while an established internal client whose long sessions the flow exporter cuts into many short | |
| # records is not a guesser (measured on CSE-CIC-IDS2018: ~82k benign RDP records otherwise) | |
| guess = (authlike & np.isin(np.asarray(cols["L4_DST_PORT"]).astype(np.int64), INTERACTIVE_LOGIN) | |
| & (xe[:, SRC_FLOWS3] >= 120.0) & (xe[:, SRC_REPEAT3] >= 0.8) & (cols["_age"] < 500)) | |
| if guess.any(): | |
| tier[guess & ~alarm] = 4 | |
| alarm |= guess | |
| fam[guess] = "bruteforce" | |
| block_ok = block_ok | guess | |
| pe = np.where(guess, np.maximum(pe, 0.99), pe) | |
| sev = np.where(guess, np.maximum(sev, severity("bruteforce")), sev) | |
| for i in np.flatnonzero(guess): | |
| camp_ev.setdefault(i, f"sustained guessing: {xe[i, SRC_FLOWS3]:.0f} short sessions in ~30 min, " | |
| f"{100 * xe[i, SRC_REPEAT3]:.0f}% to the same login service") | |
| score = np.where(guess, np.maximum(score, 80), score).astype(np.int16) | |
| # a source or destination that raised any alarm this batch is excluded from the reference *entirely* (every | |
| # one of its flows), so a partially-detected attack cannot seep into the self-training and loosen thresholds | |
| # whole-source exclusion only for sources whose alarms name a recognisable attack shape (port walking, repeated | |
| # failures, floods ...); an un-named alarm on a busy-but-ordinary source is more likely drift, and the reference | |
| # must be allowed to learn it (else a wrongly-flagged benign source stays flagged forever) | |
| # an established source (hundreds of flows of history) whose flows show no failure at all - every connect | |
| # answered, none cut short, none reset, none half-open - and that is only named for repeating itself is a | |
| # busy legitimate machine whose pace changed (a SCADA poller, a monitoring agent, a sync job), not a brute | |
| # forcer: it stays in the reference (else the alarm becomes permanent) and is watched, never auto-blocked | |
| # (a workstation's DNS / NTP exchanges are 'short' flows - under 10 ms, one or two packets - and no failure) | |
| fail_like = (xe[:, SRC_NOREPLY2] > 0.2) | (xe[:, SRC_RST2] > 0.2) | (xe[:, SRC_SYN2] > 0.2) | |
| # (never on an authentication service: a long-running password guesser completes its handshakes too) | |
| excusable = ((cols["_age"] >= 500) & ~fail_like & np.isin(fam, ["bruteforce", "attack_generic"]) & ~member | |
| & ~np.isin(np.asarray(cols["L4_DST_PORT"]).astype(np.int64), NEVER_EXCUSED)) | |
| block_ok &= ~excusable | |
| # and when only the statistical tiers (prior, sequence, evidence) object to it - its pace, not its shape - it is | |
| # not an alarm at all; a change of shape (tier 3), a flood (5), a campaign (6) still is | |
| quiet = excusable & np.isin(tier, [1, 2, 4]) | |
| if quiet.any(): | |
| alarm &= ~quiet | |
| fam[quiet] = "benign" | |
| pe = np.where(quiet, np.minimum(p, 0.5), pe) | |
| sev = np.where(quiet, 0.0, sev) | |
| score = np.where(quiet, 0, score).astype(np.int16) | |
| named = alarm & ~np.isin(fam, ["attack_generic", "benign"]) & ~excusable | |
| bad_src = set(np.asarray(src)[named].tolist()) | |
| # what the next reference may learn from. A sporadic un-named alarm stays in: at the alpha budget it *is* the | |
| # benign tail - leaving every alarm out truncates the tail each epoch, the threshold drops, more benign flows | |
| # alarm, and the false-alarm rate ratchets up hour after hour (measured 1.8% -> 21% of benign source-minutes | |
| # over six hours). A source alarming again and again is not the tail but a weakly-detected attacker: all of | |
| # its flows stay out, or it would teach the reference to accept it (measured: an evolving scan 1.0 -> 0). | |
| bad_src |= self._sustained(src, t, alarm & ~excusable) | |
| ok = ~named & ~_member(src, bad_src) if bad_src else ~named | |
| self._remember(t, src, dst, z1, z2, svc, xe[:, ANOM_IDX], xe[:, DST_IDX], ages, ok) | |
| if n: | |
| self._maybe_recalibrate(float(t[-1])) | |
| risk = np.array([self.entities.update(str(s), float(ti), float(pi), float(si), bool(al)) | |
| for s, ti, pi, si, al in zip(src, t, pe, sev, alarm)]) | |
| for i in range(n): | |
| self.pressure.update(float(t[i]), float(pe[i]) if alarm[i] else 0.0, fam[i] if alarm[i] else None) | |
| if self.site.n("tier1") >= 200: | |
| self.drift.update(self.site.pvalue("tier1", float(z1[i]), fallback=None)) | |
| if alarm[i]: | |
| self._log("flow", float(t[i]), str(src[i]), str(dst[i]), int(cols["L4_DST_PORT"][i]), fam[i], int(score[i]), int(tier[i]), | |
| evidence=camp_ev.get(i, ""), soft=not bool(block_ok[i]) or fam[i] == "attack_generic") # un-named deviation: low priority | |
| self.counts["flows"] += n | |
| self.counts["alarms"] += int(alarm.sum()) | |
| return {"p_attack": p, "alarm": alarm, "family": fam, "severity": sev, "score": score, "tier": tier, | |
| "entity_risk": risk, "z_tier1": z1, "z_seq": z2, "anomaly_score": anomaly_s, "evidence": evid, "threshold_evidence": thr_e, | |
| "block_ok": block_ok, "entity_x": xe, | |
| "flow_p": pflow, "threshold_tier1": thr1, "threshold_source": src1, | |
| "site_pressure": self.pressure.pressure, "drift": self.drift.drifted} | |
| def _scanlike(cols) -> np.ndarray: | |
| """SYN-only or unanswered short flows: the scan signature (benign traffic has few)""" | |
| synflag = ent.tcp_syn_only(cols["CLIENT_TCP_FLAGS"], cols["SERVER_TCP_FLAGS"]) & (np.asarray(cols["PROTOCOL"]) == 6) | |
| return (np.asarray(synflag) | (np.asarray(cols["OUT_PKTS"]) <= 0)) & (np.asarray(cols["IN_PKTS"]) <= 3) | |
| def _campaign_keys(cols) -> tuple[list, np.ndarray, np.ndarray]: | |
| """flow fingerprint for the campaign tier: (failure kind, service port); failed = a SYN-only / unanswered probe, | |
| or a short session on an authentication service (a guessed password closes the connection within seconds)""" | |
| port = np.asarray(cols["L4_DST_PORT"]).astype(np.int64) | |
| syn = ent.tcp_syn_only(cols["CLIENT_TCP_FLAGS"], cols["SERVER_TCP_FLAGS"]) & (np.asarray(cols["PROTOCOL"]) == 6) | |
| probe = (np.asarray(syn) | (np.asarray(cols["OUT_PKTS"]) <= 0)) & (np.asarray(cols["IN_PKTS"]) <= 3) | |
| authlike = ~probe & np.isin(port, list(AUTH_PORTS)) & (np.asarray(cols["FLOW_DURATION_MILLISECONDS"]) < 3000) & (np.asarray(cols["IN_PKTS"]) <= 40) | |
| keys = [("probe" if p else "auth", int(pt)) for p, pt in zip(probe, port)] | |
| return keys, probe | authlike, authlike | |
| # ------------------------------------------------------------ http | |
| def _http_key(self, text): | |
| return zlib.crc32(text.split(" | ")[0].encode("utf-8", "replace")) & 0xFFFFFFFF | |
| def calibrate_http(self, texts: list[str], deepest=None, host: str | None = None) -> dict: | |
| """Benign requests of a site (a few hundred): site profile + site threshold. With `host`, the profile and the | |
| reference belong to that Host only (a WAF in front of many sites keeps one per site); the first one calibrated | |
| also becomes the default for hosts not yet profiled.""" | |
| if self.http is None: | |
| return {} | |
| deepest = deepest if deepest is not None else [0] * len(texts) | |
| prof = self.http.site_profile(texts, deepest) | |
| z = self.http.logits(texts, deepest, prof)[:, 0] | |
| z = clip_upper(z) | |
| if host is not None: | |
| self.site_http_by_host[host] = prof | |
| ref = RollingReference(self.rolling["http"].window_s) | |
| ref.set_base(z) | |
| self.http_ref_by_host[host] = ref | |
| if host is None or self.site.n("http") < 200: | |
| self.site_http = prof | |
| self.site.cal.pop("http", None) | |
| self.site.add("http", z) | |
| self.rolling["http"].set_base(self.site.cal["http"]) | |
| return {"http": self.site.n("http"), "profile_rows": len(texts), "host": host, "hosts": len(self.site_http_by_host)} | |
| def http_requests(self, texts: list[str], deepest=None, client=None, host=None, status=None, t=None) -> dict: | |
| n = len(texts) | |
| deepest = deepest if deepest is not None else [0] * n | |
| hosts_in = ["" for _ in range(n)] if host is None else [str(h) for h in host] | |
| z = np.zeros(n, np.float32) | |
| if self.http is None: | |
| # no request model (an open build ships without one): the learned per-request tier abstains - the | |
| # signatures, the SQL-context analysis, DbNet and the session tier still judge; nothing is "benign by model" | |
| thr, srct = np.inf, "no-model" | |
| thr_row = np.full(n, np.inf) | |
| else: | |
| thr, srct = self.threshold("http") | |
| thr_row = np.full(n, thr, np.float64) | |
| by_host: dict = {} | |
| for i, h in enumerate(hosts_in): # one pass (a loop per host was O(requests x hosts)) | |
| by_host.setdefault(h, []).append(i) | |
| for h, idx in by_host.items(): # each request is judged against its own site's profile and reference | |
| prof = self.site_http_by_host.get(h, self.site_http) | |
| z[idx] = self.http.logits([texts[i] for i in idx], [deepest[i] for i in idx], prof)[:, 0] | |
| ref = self.http_ref_by_host.get(h) | |
| if ref is not None and ref.n() >= ConformalFPR.n_needed(self.alpha, 0.5): | |
| thr_row[idx] = ref.threshold(self.alpha) | |
| p = 1 / (1 + np.exp(-z / self.temph)) | |
| alarm = z > thr_row | |
| fam = np.where(alarm, "webattack", "benign").astype(object) | |
| sess = np.zeros(n, bool) | |
| z1_s, thr_s, fails_s, rate_s = None, 0.0, None, None | |
| if client is not None: | |
| # the request as an event of the client's stream (tier 0): enumeration / brute force / scanners show up here | |
| tt0 = np.zeros(n) if t is None else np.asarray(t, np.float64) | |
| hosts0 = ["web"] * n if host is None else list(host) | |
| st0 = [0] * n if status is None else list(status) | |
| out0: list = [] | |
| ages = np.empty(n, np.int64) | |
| for i in range(n): | |
| port = zlib.crc32(texts[i].split(" | ")[0].encode("utf-8", "replace")) & 0xFFFF | |
| failed = int(st0[i]) >= 400 if st0[i] else False | |
| self.state.update(float(tt0[i]), client[i], hosts0[i], port, float(len(texts[i])), 1.0, 0.0 if failed else 1.0, False, False, failed, out0) | |
| ages[i] = self.state.src[client[i]].n | |
| xe = np.asarray(out0, np.float32).reshape(n, -1) | |
| z1 = self.tier1.raw(xe[:, self.t1_idx])[:, 0] | |
| # the session tier has its own reference: request streams are not flow streams (a visitor reading ten | |
| # product pages walks ten 'ports'), so the flow tier's threshold would call every browser a scanner. | |
| # Until the site's own session reference is filled, only streams that are mostly failing can alarm. | |
| fails = xe[:, ent.feature_names().index("src_noreply_h2")] | |
| a_s = self.alpha / 2 | |
| ref_s = self.rolling["sess"] | |
| if ref_s.n() >= ConformalFPR.n_needed(a_s, 0.5): | |
| thr1 = ref_s.threshold(a_s) | |
| sess = z1 > thr1 | |
| else: | |
| thr1, _ = self.threshold("tier1") | |
| sess = (z1 > thr1) & (fails > 0.5) | |
| # an established machine client polling the same endpoints at a modest pace with every request answered (a | |
| # partner integration, a monitor, a sync job) is not an attack shape: the session tier stays quiet for it | |
| # (the request model and the signatures still judge each request) | |
| f_ = ent.feature_names() | |
| steady = ((ages >= 30) & (fails <= 0.05) & (xe[:, f_.index("src_newport_h2")] <= 0.2) | |
| & (xe[:, f_.index("src_flows_h1")] < 20.0) & (z <= thr_row)) | |
| sess &= ~steady | |
| z1_s, thr_s, fails_s = z1, thr1, fails | |
| rate_s = xe[:, ent.feature_names().index("src_flows_h1")] | |
| alarm |= sess | |
| if sess.any(): | |
| # in a request stream the 'port' is the path: port walking = enumeration / scraping, repetition of one | |
| # path with failures = credential stuffing, a flood = L7 DoS; a bot user-agent names itself | |
| # HTTP-specific family from the client's own stream: path diversity (the "new port" share is the share | |
| # of never-seen paths), failure share (4xx/5xx = no reply), repetition of one path, and rate. | |
| f = ent.feature_names() | |
| xs = xe[sess] | |
| newp, fail, rep, rate10 = (xs[:, f.index(k)] for k in ("src_newport_h2", "src_noreply_h2", "src_repeat_h2", "src_flows_h1")) | |
| ua_bot = np.array([any(b in texts[i].lower() for b in BOT_UA) for i in np.flatnonzero(sess)]) | |
| hfam = np.full(len(xs), "bot", dtype=object) # default: automated client | |
| hfam[(newp > 0.5) & (fail > 0.1)] = "scan" # walking ids / paths and failing: enumeration | |
| hfam[(newp > 0.5) & (fail <= 0.1) & ~ua_bot] = "bot" # walking paths, all served: scraping | |
| hfam[(rep > 0.5) & (fail > 0.3)] = "bruteforce" # the same endpoint failing again and again | |
| hfam[(rate10 >= 100) & (newp <= 0.2)] = "dos" # an L7 flood on few paths | |
| fam[sess] = hfam | |
| # confidence of a session verdict: how far past its calibrated threshold the stream is - against the | |
| # site's reference when there is one, else a margin over the threshold (never the single request's p) | |
| if self.site.n("tier1"): | |
| p_s = np.array([1.0 - self.site.pvalue("tier1", float(v), fallback=None) for v in z1[sess]]) | |
| else: | |
| p_s = 0.5 + 0.5 / (1.0 + np.exp(-(z1[sess] - thr1))) | |
| p[sess] = np.maximum(p[sess], p_s) | |
| sev = np.array([severity(f) if a else 0.0 for f, a in zip(fam, alarm)]) | |
| pe = np.where(alarm, p, np.minimum(p, 0.5)) | |
| score = np.array([event_score(float(a), float(b)) for a, b in zip(pe, sev)], np.int16) | |
| # block_ok: a session-stream alarm (the client has a history) or a request far past threshold is block-safe; | |
| # a single request only mildly over the model threshold is challenged, not blocked (first-contact safety) | |
| # first-contact HTTP is challenged, never hard-blocked. A session alarm blocks only when the client's requests | |
| # are largely failing (4xx/5xx -> no reply): a benign user browsing many distinct pages (all 200) trips the | |
| # path-diversity ("new port") signal but gets replies, so it is challenged, not blocked; enumeration / | |
| # credential stuffing / scanners fail most requests and are blocked. Hard payload signatures block in the layer. | |
| _nr = ent.feature_names().index("src_noreply_h2") | |
| fail_share = xe[:, _nr] if sess.any() else np.zeros(n) | |
| block_ok = alarm & sess & (fail_share > 0.3) | |
| # global-aggregate tier: a path whose site-wide failure rate is far over its benign baseline is a distributed | |
| # campaign (credential stuffing / scanning spread over many low-volume IPs). The cohort is challenged (never | |
| # blocked), so a benign user of a briefly-hot path only sees a JS challenge. Self-calibrated from benign traffic. | |
| campaign = np.zeros(n, bool) | |
| if client is not None and status is not None: | |
| keys = [self._http_key(x) for x in texts] | |
| tt0 = np.zeros(n) if t is None else np.asarray(t, np.float64) | |
| failed0 = np.array([int(status[i]) >= 400 for i in range(n)]) | |
| campaign = self.global_http.score(keys, tt0, failed0, floor=self.global_floor) | |
| # campaign tier (relative to each path's own history): distinct failing clients per window, CUSUM over windows | |
| hosts0 = [""] * n if host is None else [str(h) for h in host] | |
| camp = self.campaign_http.update([http_fingerprint(texts[i], hosts0[i]) for i in range(n)], client, tt0, failed0) | |
| member = np.array([c is not None for c in camp], bool) | |
| # only campaign-shaped clients are its cohort: brand new (a stuffer's address sends a request or two) or | |
| # failing most of the time - a user who hit one stale link or mistyped a password while a path was hot, and | |
| # is otherwise served, is not challenged for it (harm run: ~150 users per 1,000 per day before) | |
| campaign = (campaign | member) & ((ages < 3) | (fails > 0.5)) | |
| alarm |= campaign | |
| fam[campaign & (fam == "benign")] = "bruteforce" | |
| # campaign members are left out of block_ok so the layer challenges (not blocks) them | |
| risk = None | |
| if client is not None: | |
| tt = np.zeros(n) if t is None else np.asarray(t, np.float64) | |
| hosts = ["web"] * n if host is None else list(host) | |
| risk = np.zeros(n) | |
| for i in range(n): | |
| port = zlib.crc32(texts[i].split(" | ")[0].encode("utf-8", "replace")) & 0xFFFF | |
| risk[i] = self.entities.update(str(client[i]), float(tt[i]), float(pe[i]), float(sev[i]), bool(alarm[i])) | |
| self.pressure.update(float(tt[i]), float(pe[i]) if alarm[i] else 0.0, fam[i] if alarm[i] else None) | |
| if alarm[i]: | |
| self._log("http-session" if sess[i] and z[i] <= thr else "http", float(tt[i]), str(client[i]), hosts[i], port, fam[i], int(score[i]), 1, | |
| texts[i][:200], soft=not bool(block_ok[i] or sess[i] or campaign[i])) | |
| if t is not None: | |
| # what the references learn: the benign tail stays in (requests just over a threshold are, at alpha, mostly | |
| # benign - excluding them ratchets the threshold down epoch after epoch); strong outliers and every request | |
| # of a client alarming again and again stay out (else a slow scraper teaches the reference to accept it) | |
| tt1 = np.asarray(t, np.float64) | |
| bad = self._sustained(client, tt1, alarm) if client is not None else set() | |
| for i in range(n): | |
| # a client alarming again and again stays out only while it also *behaves* like an attacker (most of | |
| # its requests failing, or a bot's rate): a person who hit a few stale links is the benign tail | |
| attacky = fails_s is not None and (fails_s[i] > 0.5 or rate_s[i] >= 5.0) | |
| if client is not None and ((attacky and (str(client[i]) in bad or self.sustained_now(str(client[i]), float(tt1[i])))) | |
| or not self._may_contribute(str(client[i]), float(tt1[i]), self.rolling["sess"].n())): | |
| continue | |
| if z[i] <= thr_row[i] + 3.0: | |
| self.rolling["http"].add(float(tt1[i]), float(z[i])) | |
| r_h = self.http_ref_by_host.get(hosts_in[i]) | |
| if r_h is not None: | |
| r_h.add(float(tt1[i]), float(z[i])) | |
| if client is not None and z1_s is not None and z1_s[i] <= thr_s + 3.0 and not (sess[i] and fails_s[i] > 0.3): | |
| self.rolling["sess"].add(float(tt1[i]), float(z1_s[i])) | |
| self.counts["http"] += n | |
| self.counts["alarms"] += int(alarm.sum()) | |
| return {"p_attack": p, "alarm": alarm, "family": fam, "severity": sev, "score": score, "entity_risk": risk, "block_ok": block_ok, | |
| "behaviour": sess | campaign, | |
| "threshold": thr, "threshold_source": srct, "site_pressure": self.pressure.pressure} | |
| # ------------------------------------------------------------ bookkeeping | |
| def _log(self, kind, t, src, dst, port, fam, score, tier, evidence: str = "", soft: bool = False) -> None: | |
| self.log.append({"kind": kind, "t": t, "src": src, "dst": dst, "port": port, "family": fam, "score": score, | |
| "tier": tier, "evidence": evidence, "soft": soft}) | |
| if len(self.log) > 50_000: | |
| del self.log[:10_000] | |
| def status(self) -> dict: | |
| return {"counts": dict(self.counts), "site_scores": {k: self.site.n(k) for k in ("tier1", "seq", "http")}, | |
| "rolling_scores": {k: v.n() for k, v in self.rolling.items()}, "anomaly_reference": self.anom_s.n() if self.anom else 0, | |
| "evidence_reference": self.ev_ref.n(), "evidence_slow_reference": self.ev_slow_ref.n(), "dst_evidence_reference": self.dst_ev_ref.n(), "warmup_short": getattr(self, "warmup_short", False), "mondrian_groups": sum(1 for v in self.roll_groups.values() if v.n() >= self.mondrian_min), | |
| "thresholds": {k: self.threshold(k) for k in ("tier1",) + (("seq",) if self.seq else ()) + (("http",) if self.http else ())}, | |
| "warm_sources": len(self.gru), "site_pressure": self.pressure.pressure, "drift": self.drift.drifted, | |
| "drift_guard": self.drift_events[-5:], "campaigns": self.campaign.active()[:5] + self.campaign_http.active()[:5], | |
| "models": {"tier1": self.tier1.meta.get("version"), "seq": self.seq.info.get("version") if self.seq else None, | |
| "http": self.http.info.get("version") if self.http else None}} | |