v.0.11.4
This commit is contained in:
+95
-4
@@ -18,6 +18,12 @@ from .store import AlertStore
|
||||
|
||||
|
||||
SENSITIVE_PORTS = {21, 22, 23, 25, 110, 135, 139, 445, 1433, 3306, 3389, 5432, 6379, 8291, 9200, 27017}
|
||||
NDR_BEACON_MAX_KEYS = 32768
|
||||
NDR_STATE_MAX_SUBJECTS = 8192
|
||||
NDR_BASELINE_CACHE_MAX = 50000
|
||||
NDR_BLOCK_ATTEMPT_MAX = 10000
|
||||
NDR_STATE_IDLE_SECONDS = 7200
|
||||
NDR_BLOCK_ATTEMPT_TTL_SECONDS = 86400
|
||||
LOCAL_STAGE_BY_SID = {
|
||||
1000101: "credential-access", 1000102: "credential-access", 1000103: "credential-access",
|
||||
1000104: "recon", 1000105: "recon", 1000106: "initial-access",
|
||||
@@ -211,7 +217,11 @@ class NDRAnalyzer:
|
||||
self._signals = 0
|
||||
self._ioc_hits = 0
|
||||
self._behavior_hits = 0
|
||||
self._beacon: dict[tuple[str, str], collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=12))
|
||||
# Remote destinations are high-cardinality on real networks (CDNs,
|
||||
# crawlers, cloud APIs). Keep all runtime correlation state explicitly
|
||||
# bounded so a long-running sensor cannot retain one object per peer
|
||||
# forever.
|
||||
self._beacon: collections.OrderedDict[tuple[str, str], collections.deque[float]] = collections.OrderedDict()
|
||||
self._scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
|
||||
self._out_scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
|
||||
self._egress: dict[str, collections.deque[tuple[float, int, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=512))
|
||||
@@ -220,7 +230,13 @@ class NDRAnalyzer:
|
||||
self._dns_tunnel: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=96))
|
||||
self._identity_changes: dict[str, collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=8))
|
||||
self._cooldown: dict[tuple[str, str], float] = {}
|
||||
self._block_attempted: set[int] = set()
|
||||
self._block_attempted: collections.OrderedDict[int, float] = collections.OrderedDict()
|
||||
# baseline_touch() used to perform a SELECT/UPDATE/COMMIT for every EVE
|
||||
# record, even if an asset had already used the same app/port/domain.
|
||||
# A bounded process-local LRU preserves first-seen detection while
|
||||
# removing that repeated SQLite write amplification.
|
||||
self._baseline_seen: collections.OrderedDict[tuple[str, str, str], None] = collections.OrderedDict()
|
||||
self._last_state_cleanup = 0.0
|
||||
self._routeros_inventory_syncs = 0
|
||||
self._routeros_inventory_assets = 0
|
||||
self._routeros_inventory_last_at = ""
|
||||
@@ -249,6 +265,19 @@ class NDRAnalyzer:
|
||||
"routeros_inventory_syncs": self._routeros_inventory_syncs,
|
||||
"routeros_inventory_assets": self._routeros_inventory_assets,
|
||||
"routeros_inventory_last_at": self._routeros_inventory_last_at,
|
||||
"state_entries": {
|
||||
"beacon": len(self._beacon),
|
||||
"scan": len(self._scan),
|
||||
"out_scan": len(self._out_scan),
|
||||
"egress": len(self._egress),
|
||||
"dga": len(self._dga),
|
||||
"nxdomain": len(self._nxdomain),
|
||||
"dns_tunnel": len(self._dns_tunnel),
|
||||
"identity_changes": len(self._identity_changes),
|
||||
"cooldown": len(self._cooldown),
|
||||
"baseline_lru": len(self._baseline_seen),
|
||||
"block_attempted": len(self._block_attempted),
|
||||
},
|
||||
}
|
||||
|
||||
def sync_routeros_inventory(self) -> dict[str, int]:
|
||||
@@ -301,6 +330,7 @@ class NDRAnalyzer:
|
||||
self._queue.task_done()
|
||||
|
||||
def _process(self, record: dict[str, Any], alert_id: int | None) -> None:
|
||||
self._prune_state(_dt(record.get("timestamp")))
|
||||
subject = self._subject(record)
|
||||
if subject:
|
||||
asset = self.store.observe_asset(record)
|
||||
@@ -357,7 +387,15 @@ class NDRAnalyzer:
|
||||
if fingerprint:
|
||||
values.append(("client-fingerprint", fingerprint[:160], 30))
|
||||
for kind, value, risk in values:
|
||||
baseline_key = (subject, kind, value)
|
||||
if baseline_key in self._baseline_seen:
|
||||
self._baseline_seen.move_to_end(baseline_key)
|
||||
continue
|
||||
is_new, _ = self.store.baseline_touch(subject, kind, value, str(record.get("timestamp") or ""))
|
||||
self._baseline_seen[baseline_key] = None
|
||||
self._baseline_seen.move_to_end(baseline_key)
|
||||
while len(self._baseline_seen) > NDR_BASELINE_CACHE_MAX:
|
||||
self._baseline_seen.popitem(last=False)
|
||||
if is_new and observations >= self.behavior_min_observations:
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "behavior-change", risk, f"New {kind} for established asset: {value}")
|
||||
@@ -369,7 +407,15 @@ class NDRAnalyzer:
|
||||
port = int(record.get("dest_port") or 0)
|
||||
if record.get("direction") == "outbound" and dest:
|
||||
key = (subject, dest)
|
||||
dq = self._beacon[key]; dq.append(now)
|
||||
dq = self._beacon.get(key)
|
||||
if dq is None:
|
||||
dq = collections.deque(maxlen=12)
|
||||
self._beacon[key] = dq
|
||||
else:
|
||||
self._beacon.move_to_end(key)
|
||||
dq.append(now)
|
||||
while len(self._beacon) > NDR_BEACON_MAX_KEYS:
|
||||
self._beacon.popitem(last=False)
|
||||
if port in SENSITIVE_PORTS:
|
||||
scan = self._out_scan[subject]; scan.append((now, dest, port))
|
||||
while scan and scan[0][0] < now - 60: scan.popleft()
|
||||
@@ -465,7 +511,10 @@ class NDRAnalyzer:
|
||||
if self.auto_block and combined_risk >= self.auto_block_risk and incident_id not in self._block_attempted:
|
||||
target = self._remote_target(record, subject)
|
||||
if target and self.routeros.configured:
|
||||
self._block_attempted.add(incident_id)
|
||||
self._block_attempted[incident_id] = _dt(record.get("timestamp"))
|
||||
self._block_attempted.move_to_end(incident_id)
|
||||
while len(self._block_attempted) > NDR_BLOCK_ATTEMPT_MAX:
|
||||
self._block_attempted.popitem(last=False)
|
||||
result = self.routeros.block_ip(target, self.block_timeout, f"MikroSuricata NDR risk {combined_risk}: {summary}"[:220])
|
||||
if result.success:
|
||||
self.store.mark_incident_blocked(incident_id, target)
|
||||
@@ -475,6 +524,48 @@ class NDRAnalyzer:
|
||||
except Exception as exc:
|
||||
print(f"[ndr] forensic PCAP capture failed: {exc}", flush=True)
|
||||
|
||||
def _prune_state(self, now: float) -> None:
|
||||
current = time.monotonic()
|
||||
if current - self._last_state_cleanup < 60:
|
||||
return
|
||||
self._last_state_cleanup = current
|
||||
stale_before = now - NDR_STATE_IDLE_SECONDS
|
||||
|
||||
for key in list(self._beacon):
|
||||
values = self._beacon.get(key)
|
||||
if not values or values[-1] < stale_before:
|
||||
self._beacon.pop(key, None)
|
||||
|
||||
keyed_deques = (
|
||||
self._scan,
|
||||
self._out_scan,
|
||||
self._egress,
|
||||
self._dga,
|
||||
self._nxdomain,
|
||||
self._dns_tunnel,
|
||||
self._identity_changes,
|
||||
)
|
||||
for mapping in keyed_deques:
|
||||
for key, values in list(mapping.items()):
|
||||
if not values:
|
||||
mapping.pop(key, None)
|
||||
continue
|
||||
latest = values[-1]
|
||||
latest_ts = latest if isinstance(latest, (int, float)) else latest[0]
|
||||
if float(latest_ts) < stale_before:
|
||||
mapping.pop(key, None)
|
||||
while len(mapping) > NDR_STATE_MAX_SUBJECTS:
|
||||
mapping.pop(next(iter(mapping)), None)
|
||||
|
||||
for key, expires_at in list(self._cooldown.items()):
|
||||
if expires_at <= now:
|
||||
self._cooldown.pop(key, None)
|
||||
|
||||
block_before = now - NDR_BLOCK_ATTEMPT_TTL_SECONDS
|
||||
for incident_id, attempted_at in list(self._block_attempted.items()):
|
||||
if attempted_at < block_before:
|
||||
self._block_attempted.pop(incident_id, None)
|
||||
|
||||
def _subject(self, record: dict[str, Any]) -> str:
|
||||
src = str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")
|
||||
dst = str(record.get("dest_ip") or "")
|
||||
|
||||
Reference in New Issue
Block a user