Download runtime/python/dibaguard/decide.py from Dibachain/DibaGuard: direct link, hf CLI and curl.
- Browser
- Download file 21.5 kB
-
https://huggingface.co/Dibachain/DibaGuard/resolve/main/runtime/python/dibaguard/decide.py
- Command line
-
hf download hf://Dibachain/DibaGuard/runtime/python/dibaguard/decide.py
-
curl -L -o decide.py https://huggingface.co/Dibachain/DibaGuard/resolve/main/runtime/python/dibaguard/decide.py
21.5 kB
| """Decision layer: calibrated probabilities -> guaranteed alarm budget, drift detection, entity risk, site pressure. | |
| * Temperature: per-group temperature scaling of logits (fitted by NLL on a calibration split). | |
| * ConformalFPR (Mondrian): per group (site / endpoint group / source type) the alarm threshold is the conformal | |
| quantile of that group's benign scores, so P(alarm | benign) <= alpha holds for exchangeable benign traffic with a | |
| finite-sample guarantee - not a vendor-chosen cut-off. pvalue() gives the conformal p-value of any score. | |
| * DriftMartingale: power martingale over the benign p-values; by Ville's inequality it exceeds 1/delta with | |
| probability <= delta while traffic stays exchangeable, so crossing it means "recalibrate" (normal traffic changed). | |
| * EntityRisk: per-entity risk in [0, 1] that rises with each suspicious event (r <- 1 - (1-r)(1-s), s = p x severity) | |
| and decays with half-life `half_life_s`; bounded memory (LRU over `max_entities`). | |
| * SitePressure: exponentially weighted share of attack traffic, overall and per family. | |
| All O(1) or O(log n) per event, numpy only. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from bisect import bisect_left, insort | |
| from collections import OrderedDict | |
| import numpy as np | |
| class Temperature: | |
| def __init__(self): | |
| self.t: dict = {} | |
| def _nll(logits: np.ndarray, y: np.ndarray, t: float) -> float: | |
| z = logits / t | |
| if z.ndim == 1: # binary: one logit per row | |
| return float(np.mean(np.logaddexp(0, z) - y * z)) | |
| z = z - z.max(1, keepdims=True) | |
| return float(np.mean(np.log(np.exp(z).sum(1)) - z[np.arange(len(y)), y])) | |
| def fit(self, group: str, logits, y) -> float: | |
| logits, y = np.asarray(logits, np.float64), np.asarray(y) | |
| grid = np.exp(np.linspace(math.log(0.05), math.log(20), 121)) | |
| best = min(grid, key=lambda t: self._nll(logits, y, t)) | |
| lo, hi = best / 1.06, best * 1.06 # refine around the grid point | |
| for _ in range(40): | |
| m1, m2 = lo + (hi - lo) / 3, hi - (hi - lo) / 3 | |
| if self._nll(logits, y, m1) < self._nll(logits, y, m2): | |
| hi = m2 | |
| else: | |
| lo = m1 | |
| self.t[group] = float((lo + hi) / 2) | |
| return self.t[group] | |
| def __call__(self, group: str, logits): | |
| return np.asarray(logits, np.float64) / self.t.get(group, 1.0) | |
| class ConformalFPR: | |
| """Benign calibration scores per group; higher score = more suspicious.""" | |
| def __init__(self, max_per_group: int = 20000, min_n: int = 200): | |
| self.cal: dict = {} | |
| self.max_per_group, self.min_n = max_per_group, min_n | |
| def add(self, group: str, scores) -> None: | |
| arr = self.cal.setdefault(group, []) | |
| new = np.asarray(scores, np.float64).ravel() | |
| if len(new) > 64: # bulk: one sorted merge instead of len(new) list insertions | |
| arr = self.cal[group] = np.sort(np.concatenate([np.asarray(arr, np.float64), new])).tolist() | |
| else: | |
| for s in new: | |
| insort(arr, float(s)) | |
| if len(arr) > self.max_per_group: # keep an evenly thinned sorted sample (quantiles preserved) | |
| self.cal[group] = arr[:: math.ceil(len(arr) / self.max_per_group)] | |
| def n(self, group: str) -> int: | |
| return len(self.cal.get(group, ())) | |
| def _group(self, group: str, fallback: str | None) -> list: | |
| arr = self.cal.get(group) | |
| if (arr is None or len(arr) < self.min_n) and fallback is not None: | |
| arr = self.cal.get(fallback) | |
| if not arr: | |
| raise KeyError(f"no calibration scores for group {group!r}") | |
| return arr | |
| def pvalue(self, group: str, score: float, fallback: str | None = "*") -> float: | |
| """(1 + #{benign >= score}) / (n + 1): small = unlike the group's benign traffic.""" | |
| arr = self._group(group, fallback) | |
| return (1 + len(arr) - bisect_left(arr, float(score) - _tol(score))) / (len(arr) + 1) | |
| def threshold(self, group: str, alpha: float, fallback: str | None = "*", confidence: float | None = None) -> float: | |
| """Alarm threshold for false-alarm rate alpha on benign traffic. | |
| confidence=None: marginal guarantee E[FPR] <= alpha (the ceil((n+1)(1-alpha))-th order statistic). | |
| confidence=c : PAC guarantee P(FPR <= alpha) >= c over the draw of the calibration set. With the k-th order | |
| statistic, FPR > alpha exactly when at most n-k calibration scores lie above the true | |
| (1-alpha)-quantile, a Binomial(n, alpha) event; k is the smallest order with that probability | |
| <= 1-c. Returns inf when n is too small (see n_needed).""" | |
| arr = self._group(group, fallback) | |
| n = len(arr) | |
| if confidence is None: | |
| k = math.ceil((n + 1) * (1 - alpha)) | |
| if k > n and n >= ConformalFPR.n_needed(alpha, 0.5): | |
| k = n # the maximum holds FPR <= alpha with probability >= 1/2 (see n_needed) | |
| else: | |
| m = _binom_max_m(n, alpha, 1 - confidence) | |
| if m < 0: | |
| return float("inf") | |
| k = n - m | |
| return float("inf") if k > n else arr[k - 1] + _tol(arr[k - 1]) | |
| def n_needed(alpha: float, confidence: float) -> int: | |
| """Fewest benign calibration scores for which the PAC threshold exists: (1-alpha)^n <= 1-confidence.""" | |
| return math.ceil(math.log(1 - confidence) / math.log(1 - alpha)) | |
| def alarm(self, group: str, score: float, alpha: float, fallback: str | None = "*") -> bool: | |
| return self.pvalue(group, score, fallback) <= alpha | |
| def _binom_max_m(n: int, p: float, delta: float) -> int: | |
| """Largest m with P(Binomial(n, p) <= m) <= delta (-1 if none), by summing the pmf in log space.""" | |
| lp, lq = math.log(p), math.log1p(-p) | |
| cdf, m = 0.0, -1 | |
| for j in range(n + 1): | |
| cdf += math.exp(math.lgamma(n + 1) - math.lgamma(j + 1) - math.lgamma(n - j + 1) + j * lp + (n - j) * lq) | |
| if cdf > delta: | |
| return m | |
| m = j | |
| return m | |
| class DriftMartingale: | |
| """Power martingale M_t = prod eps * p_i^(eps-1) over p-values of traffic believed benign.""" | |
| def __init__(self, eps: float = 0.92, delta: float = 1e-3): | |
| self.eps, self.delta, self.log_m, self.n = eps, delta, 0.0, 0 | |
| def update(self, p: float) -> bool: | |
| self.log_m += math.log(self.eps) + (self.eps - 1) * math.log(max(p, 1e-12)) | |
| self.log_m = max(self.log_m, 0.0) # restart from 1 when evidence fades (detects later shifts too) | |
| self.n += 1 | |
| return self.drifted | |
| def drifted(self) -> bool: | |
| return self.log_m >= math.log(1 / self.delta) | |
| def reset(self) -> None: | |
| self.log_m, self.n = 0.0, 0 | |
| def _tol(v): | |
| """scores equal up to float32 rounding are equal: model scores are float32 and come out a unit in the last place | |
| apart depending on the batch they were computed in. Against a reference full of identical values (every new | |
| client's first request scores the same) a one-ulp difference was an alarm (p = 1 / (n + 1))""" | |
| return 1e-5 * np.maximum(1.0, np.abs(v)) | |
| def _late(clock: float, t: float, tau: float) -> float: | |
| """weight of an event at time t in a decayed counter whose clock is already at `clock` (1 when not late): an | |
| out-of-order event is counted as what it is worth now - neither rewinding the clock nor counted as fresh""" | |
| return 1.0 if t >= clock else math.exp(-(clock - t) / tau) | |
| def evict(od: "OrderedDict", keep, look: int = 8) -> None: | |
| """Drop one entry of a bounded per-source table, least recently used first - except that an entry which has earned | |
| state (keep(value)) goes to the back of the queue instead, up to `look` times. A spray of fresh one-event sources | |
| (spoofed, or addresses rotated inside an IPv6 /64) then evicts other fresh sources, not the attacker's record it | |
| was sent to flush; a table full of earned entries still evicts its oldest.""" | |
| for _ in range(look): | |
| k, v = od.popitem(last=False) | |
| if not keep(v): | |
| return | |
| od[k] = v | |
| od.popitem(last=False) | |
| class EntityRisk: | |
| def __init__(self, half_life_s: float = 600.0, max_entities: int = 1_000_000): | |
| self.tau = half_life_s / math.log(2) | |
| self.max = max_entities | |
| self.s: "OrderedDict[str, list]" = OrderedDict() # key -> [risk, last_t, events, alerts] | |
| def update(self, key: str, t: float, p_attack: float, severity: float = 1.0, alert: bool = False) -> float: | |
| st = self.s.get(key) | |
| if st is None: | |
| st = [0.0, t, 0, 0] | |
| self.s[key] = st | |
| if len(self.s) > self.max: | |
| evict(self.s, lambda v: v[0] > 0.2 or v[3] > 0) | |
| else: | |
| self.s.move_to_end(key) | |
| st[0] *= math.exp(-max(0.0, t - st[1]) / self.tau) | |
| sv = min(1.0, max(0.0, p_attack * severity)) * _late(st[1], t, self.tau) | |
| st[0] = 1 - (1 - st[0]) * (1 - sv) | |
| st[1], st[2], st[3] = max(st[1], t), st[2] + 1, st[3] + int(alert) # a late (older) event never rewinds the clock | |
| return st[0] | |
| def risk(self, key: str, t: float) -> float: | |
| st = self.s.get(key) | |
| return 0.0 if st is None else st[0] * math.exp(-max(0.0, t - st[1]) / self.tau) | |
| def top(self, t: float, k: int = 10) -> list: | |
| return sorted(((self.risk(k_, t), k_) for k_ in self.s), reverse=True)[:k] | |
| class SitePressure: | |
| """Exponentially weighted share of attack traffic (time constant `window_s`), overall and per family.""" | |
| def __init__(self, window_s: float = 300.0): | |
| self.tau, self.t, self.w, self.a, self.fam = window_s, None, 0.0, 0.0, {} | |
| def update(self, t: float, p_attack: float, family: str | None = None) -> float: | |
| if self.t is not None: | |
| k = math.exp(-max(0.0, t - self.t) / self.tau) | |
| self.w *= k | |
| self.a *= k | |
| for f in self.fam: | |
| self.fam[f] *= k | |
| wt = 1.0 if self.t is None else _late(self.t, t, self.tau) | |
| self.t = t if self.t is None else max(self.t, t) | |
| self.w += wt | |
| self.a += wt * p_attack | |
| if family: | |
| self.fam[family] = self.fam.get(family, 0.0) + wt * p_attack | |
| return self.pressure | |
| def pressure(self) -> float: | |
| return self.a / self.w if self.w else 0.0 | |
| def by_family(self) -> dict: | |
| return {f: v / self.w for f, v in sorted(self.fam.items(), key=lambda x: -x[1])} if self.w else {} | |
| def event_score(p_attack: float, severity: float) -> int: | |
| """0-100 'how much attack' for one event: probability weighted toward severe families.""" | |
| return int(round(100 * p_attack * (0.5 + 0.5 * severity))) | |
| class RollingReference: | |
| """Deployment-faithful calibration reference: the last `keep` time chunks of `window_s` seconds of traffic that | |
| was *not* alarmed (self-cleaning, no labels), as sorted score arrays. Thresholds / p-values come from the | |
| merged chunks; the chunk being filled is never used (it would include the row under test). For feature-level | |
| anomaly (quantile p-values), the same object stores per-feature sorted columns.""" | |
| def __init__(self, window_s: float = 1800.0, keep: int = 2, max_rows: int = 200_000): | |
| self.window_s, self.keep, self.max_rows = window_s, keep, max_rows | |
| self.done: list = [] # finished chunks: sorted arrays (scores) or [n, F] column-sorted arrays | |
| self.cur: list = [] # raw rows of the chunk being filled | |
| self.cur_k: int | None = None | |
| self._merged = None | |
| self.base = None # the site's one-off calibration (diversity); recent chunks add recency | |
| def add(self, t: float, value) -> None: | |
| k = int(t // self.window_s) | |
| if self.cur_k is None: | |
| self.cur_k = k | |
| if k != self.cur_k: | |
| self._close() | |
| self.cur_k = k | |
| if len(self.cur) < self.max_rows: | |
| self.cur.append(value) | |
| def _close(self) -> None: | |
| if self.cur: | |
| a = np.asarray(self.cur, np.float64) | |
| self.done.append(np.sort(a, axis=0)) | |
| self.done = self.done[-self.keep:] | |
| self._merged = None | |
| self.cur = [] | |
| def set_base(self, values) -> None: | |
| self.base = np.sort(np.asarray(values, np.float64), axis=0) if len(values) else None | |
| self._merged = None | |
| def ref(self): | |
| parts = ([self.base] if self.base is not None else []) + self.done | |
| if self._merged is None and parts: | |
| self._merged = np.sort(np.concatenate(parts, axis=0), axis=0) | |
| return self._merged | |
| def n(self) -> int: | |
| r = self.ref() | |
| return 0 if r is None else len(r) | |
| def threshold(self, alpha: float) -> float: | |
| r = self.ref() | |
| if r is None or r.ndim != 1: | |
| return float("inf") | |
| k = math.ceil((len(r) + 1) * (1 - alpha)) | |
| if k > len(r) and len(r) >= ConformalFPR.n_needed(alpha, 0.5): | |
| # fewer than 1/alpha scores, but enough for the maximum to hold FPR <= alpha with probability >= 1/2: the | |
| # engine treats such a reference as usable, so it must not answer 'no threshold' (inf = never alarm) | |
| k = len(r) | |
| return float("inf") if k > len(r) else float(r[k - 1]) + _tol(r[k - 1]) | |
| def pvalues(self, scores) -> np.ndarray: | |
| r = self.ref() | |
| s = np.asarray(scores, np.float64) | |
| if r is None: | |
| return np.ones_like(s) | |
| return (1 + len(r) - np.searchsorted(r, s - _tol(s), side="left")) / (len(r) + 1) | |
| def feature_pvalues(self, x) -> np.ndarray: | |
| """x [n, F] -> upper-tail per-feature empirical p-values (1 + #ref >= x) / (n + 1), Bonferroni over F, min -> [n].""" | |
| return feature_pvalues(self.ref(), x) | |
| def feature_scores(self, x) -> np.ndarray: | |
| """Fisher-style anomaly score: sum over features of -log upper-tail p (conformally calibrated by Guard).""" | |
| return feature_scores(self.ref(), x) | |
| def feature_scores(ref, x) -> np.ndarray: | |
| """x [n, F] vs a column-sorted reference [m, F]: sum_j -log((1 + #ref_j >= x_j) / (m + 1)). A source that is | |
| extreme on many attack-direction features scores far above a benign one that is unusual on a single feature - | |
| the reason the min-p rule (one rare event = alarm) is not used.""" | |
| x = np.asarray(x, np.float64) | |
| if ref is None or ref.ndim != 2: | |
| return np.zeros(len(x)) | |
| m, F = ref.shape | |
| p = (1 + m - np.stack([np.searchsorted(ref[:, j], x[:, j] - _tol(x[:, j]), side="left") for j in range(F)], 1)) / (m + 1) | |
| return -np.log(p).sum(1) | |
| def feature_pvalues(ref, x) -> np.ndarray: | |
| x = np.asarray(x, np.float64) | |
| if ref is None or ref.ndim != 2: | |
| return np.ones(len(x)) | |
| n, F = ref.shape | |
| hi = (1 + n - np.stack([np.searchsorted(ref[:, j], x[:, j] - _tol(x[:, j]), side="left") for j in range(F)], 1)) / (n + 1) | |
| return np.minimum(1.0, hi * F).min(1) | |
| class SourceEvidence: | |
| """Per-source accumulated evidence: E <- E * exp(-dt / tau) + (-log p) over the source's flows. One scanner, one | |
| brute-forcer or one flood crosses a threshold within a handful of flows; a benign client, whose p-values are | |
| uniform, never accumulates. The unit judged is the (source, minute): the reference is the maximum E inside | |
| each benign source-minute of the site's normal traffic (conformal threshold on those maxima).""" | |
| def __init__(self, tau_s: float = 120.0, max_entities: int = 500_000): | |
| self.tau, self.max = tau_s, max_entities | |
| self.e: "OrderedDict[object, list]" = OrderedDict() # src -> [E, last_t, minute, minute_max] | |
| def update(self, src, t: float, logp: float) -> float: | |
| st = self.e.get(src) | |
| if st is None: | |
| st = [0.0, t, int(t // 60), 0.0] | |
| self.e[src] = st | |
| if len(self.e) > self.max: | |
| evict(self.e, lambda v: v[0] > 1.0) | |
| else: | |
| self.e.move_to_end(src) | |
| st[0] *= math.exp(-max(0.0, t - st[1]) / self.tau) | |
| st[0] = max(0.0, st[0] + logp * _late(st[1], t, self.tau)) # CUSUM: floored at 0 (a benign source drifts back) | |
| if t < st[1]: # a late (older) event: counted at its decayed weight, never rewinding the | |
| t = st[1] # clock - a rewind would make the next event decay everything accumulated | |
| st[1] = t | |
| m = int(t // 60) | |
| if m != st[2]: | |
| st[2], st[3] = m, 0.0 | |
| st[3] = max(st[3], st[0]) | |
| return st[0] | |
| def evidence(self, src) -> float: | |
| st = self.e.get(src) | |
| return 0.0 if st is None else st[0] | |
| class GlobalRate: | |
| """Site-wide failure rate per key (an HTTP path, or a (dst, port) service), as an exponentially-decayed count. | |
| Distributed campaigns - credential stuffing or scanning spread over hundreds of low-volume source IPs, where no | |
| single source accumulates evidence - show up only in the aggregate: one key suddenly fails far more than its | |
| calibrated baseline. The response is a challenge on the contributing cohort, never a block, so benign users of a | |
| briefly-hot path are only challenged, not blocked.""" | |
| def __init__(self, tau_s: float = 120.0, max_keys: int = 200_000, base_tau_s: float = 3600.0, rel: float = 4.0, | |
| hot_learn: float = 0.1): | |
| self.tau, self.max = tau_s, max_keys | |
| # the key's own baseline: the same failures counted over a long horizon. A busy login path that always sees | |
| # some failures (or a large site whose 0.5% failed connects add up to a few per second) is hot only well above | |
| # its own normal, not above an absolute floor (measured: 96% of a busy site's benign failed connects alarmed | |
| # with the floor alone). While hot, the baseline learns at `hot_learn` of the rate: a campaign is not adopted | |
| # within minutes, a lasting benign level shift is, within hours | |
| self.base_tau, self.rel, self.hot_learn = base_tau_s, rel, hot_learn | |
| self.c: "OrderedDict[object, list]" = OrderedDict() # key -> [rate, last_t, long-horizon count] | |
| self.ref = RollingReference(1800.0) # benign per-minute peak failure rate per key | |
| def _bump(self, key, t: float, inc: float, floor: float | None = None) -> tuple[float, bool]: | |
| """-> (decayed count on the short horizon, whether it is over floor + rel x the key's own expected count)""" | |
| st = self.c.get(key) | |
| if st is None: | |
| st = [0.0, t, 0.0] | |
| self.c[key] = st | |
| if len(self.c) > self.max: | |
| self.c.popitem(last=False) | |
| else: | |
| self.c.move_to_end(key) | |
| if len(st) < 3: # restored from a snapshot without the baseline | |
| st.append(st[0] * self.base_tau / self.tau) | |
| dt = max(0.0, t - st[1]) | |
| st[0] *= math.exp(-dt / self.tau) | |
| st[2] *= math.exp(-dt / self.base_tau) | |
| if t < st[1]: # a late event: its weight at the key's clock | |
| inc *= _late(st[1], t, self.tau) | |
| st[0] += inc | |
| st[1] = max(st[1], t) | |
| hot = floor is not None and inc > 0 and st[0] > floor + self.rel * st[2] * self.tau / self.base_tau | |
| st[2] += inc * (self.hot_learn if hot else 1.0) | |
| return st[0], hot | |
| def expected(self, key, t: float) -> float: | |
| """the key's own normal on the short horizon (its long-horizon count scaled to tau), decayed to t""" | |
| st = self.c.get(key) | |
| if st is None or len(st) < 3: | |
| return 0.0 | |
| return st[2] * math.exp(-max(0.0, t - st[1]) / self.base_tau) * self.tau / self.base_tau | |
| def calibrate(self, keys, t, failed) -> None: | |
| """benign window -> the reference of per-(key, minute) peak failure rates, and every key's own baseline""" | |
| import numpy as np | |
| peaks: dict = {} | |
| tmp = GlobalRate(self.tau, self.max, self.base_tau, self.rel, self.hot_learn) | |
| for i in range(len(t)): | |
| r, _ = tmp._bump(keys[i], float(t[i]), 1.0 if failed[i] else 0.0) | |
| k = (keys[i], int(t[i] // 60)) | |
| peaks[k] = max(peaks.get(k, 0.0), r) | |
| self.ref.set_base(np.fromiter(peaks.values(), float) if peaks else np.zeros(1)) | |
| for key, st in tmp.c.items(): | |
| self.c[key] = st | |
| self.c.move_to_end(key) | |
| while len(self.c) > self.max: | |
| self.c.popitem(last=False) | |
| def score(self, keys, t, failed, floor: float = 10.0, rates: bool = False): | |
| """-> boolean array: this failed request is to a key whose site-wide decayed failure rate exceeds `floor` plus | |
| `rel` times the key's own expected rate (a distributed campaign). Challenge-only: a benign path rarely sees that | |
| many failures within ~tau, a credential-stuffing / scanning cohort does. rates=True also returns the decayed | |
| rate at each event (for callers that combine the absolute floor with what they know of the source).""" | |
| import numpy as np | |
| hot = np.zeros(len(t), bool) | |
| r = np.zeros(len(t)) | |
| for i in range(len(t)): | |
| r[i], hot[i] = self._bump(keys[i], float(t[i]), 1.0 if failed[i] else 0.0, floor) | |
| return (hot, r) if rates else hot | |