poc2_worked

This commit is contained in:
Mateusz Gruszczyński
2026-08-15 18:29:36 +02:00
parent fc3a2944b2
commit 71b6c0d86f
62 changed files with 9112 additions and 375 deletions
+753 -11
View File
@@ -7,9 +7,12 @@ import threading
from datetime import datetime, timedelta, timezone
from typing import Any
from .adaptive import score_rule
from .mitre import classify as classify_mitre, merge as merge_mitre
class AlertStore:
SCHEMA_VERSION = 4
SCHEMA_VERSION = 11
def __init__(self, path: str) -> None:
self.path = path
@@ -45,10 +48,117 @@ class AlertStore:
severity INTEGER,
action TEXT,
blocked INTEGER NOT NULL DEFAULT 0,
incident_id INTEGER,
risk_score INTEGER NOT NULL DEFAULT 0,
block_target TEXT,
block_reason TEXT,
raw_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS traffic_snapshots (
window_seconds INTEGER PRIMARY KEY,
generated_at TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS web_sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
csrf_token TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assets (
ip TEXT PRIMARY KEY,
mac TEXT,
hostname TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
observations INTEGER NOT NULL DEFAULT 0,
bytes_total INTEGER NOT NULL DEFAULT 0,
alert_count INTEGER NOT NULL DEFAULT 0,
incident_count INTEGER NOT NULL DEFAULT 0,
risk_score INTEGER NOT NULL DEFAULT 0,
last_event_type TEXT,
last_app_proto TEXT,
identity_source TEXT,
protocols_json TEXT NOT NULL DEFAULT '[]',
ports_json TEXT NOT NULL DEFAULT '[]',
domains_json TEXT NOT NULL DEFAULT '[]',
fingerprints_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS asset_baseline (
asset_ip TEXT NOT NULL,
kind TEXT NOT NULL,
value TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
seen_count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(asset_ip, kind, value)
);
CREATE TABLE IF NOT EXISTS threat_iocs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
indicator TEXT NOT NULL,
indicator_type TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
confidence INTEGER NOT NULL DEFAULT 80,
severity INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT,
last_hit_at TEXT,
hit_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(indicator_type, indicator)
);
CREATE TABLE IF NOT EXISTS ndr_incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_ip TEXT NOT NULL,
opened_at TEXT NOT NULL,
last_seen TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
risk_score INTEGER NOT NULL DEFAULT 0,
severity INTEGER NOT NULL DEFAULT 3,
event_count INTEGER NOT NULL DEFAULT 0,
alert_count INTEGER NOT NULL DEFAULT 0,
ioc_hits INTEGER NOT NULL DEFAULT 0,
behavior_hits INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT 0,
block_target TEXT,
summary TEXT NOT NULL DEFAULT '',
stages_json TEXT NOT NULL DEFAULT '[]',
signals_json TEXT NOT NULL DEFAULT '[]',
flow_ids_json TEXT NOT NULL DEFAULT '[]',
community_ids_json TEXT NOT NULL DEFAULT '[]',
destinations_json TEXT NOT NULL DEFAULT '[]',
mitre_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS ndr_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES ndr_incidents(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
kind TEXT NOT NULL,
stage TEXT NOT NULL DEFAULT '',
risk INTEGER NOT NULL DEFAULT 0,
summary TEXT NOT NULL,
src_ip TEXT,
dest_ip TEXT,
signature_id INTEGER,
flow_id TEXT,
community_id TEXT,
details_json TEXT NOT NULL DEFAULT '{}',
mitre_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT 'ok',
remote_ip TEXT NOT NULL DEFAULT '',
details_json TEXT NOT NULL DEFAULT '{}'
);
"""
)
# Existing 0.3.x databases do not have last_seen/hit_count. Add
@@ -62,6 +172,17 @@ class AlertStore:
CREATE INDEX IF NOT EXISTS idx_alerts_blocked ON alerts(blocked);
CREATE INDEX IF NOT EXISTS idx_alerts_src_ip ON alerts(src_ip);
CREATE INDEX IF NOT EXISTS idx_alerts_dest_ip ON alerts(dest_ip);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_assets_last_seen ON assets(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_assets_risk ON assets(risk_score DESC);
CREATE INDEX IF NOT EXISTS idx_asset_baseline_asset ON asset_baseline(asset_ip, kind);
CREATE INDEX IF NOT EXISTS idx_iocs_enabled ON threat_iocs(enabled, indicator_type);
CREATE INDEX IF NOT EXISTS idx_iocs_expires ON threat_iocs(expires_at);
CREATE INDEX IF NOT EXISTS idx_ndr_incidents_last_seen ON ndr_incidents(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_ndr_incidents_subject ON ndr_incidents(subject_ip, status, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_ndr_events_incident ON ndr_events(incident_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, timestamp DESC);
"""
)
self._conn.execute(
@@ -69,24 +190,201 @@ class AlertStore:
"last_seen=COALESCE(last_seen,timestamp), hit_count=COALESCE(hit_count,1)"
)
self._normalise_existing_timestamps()
self._purge_expired_sessions_locked()
if previous_version < self.SCHEMA_VERSION:
self._compact_existing_incidents(300)
if previous_version < 11:
self._backfill_mitre_locked()
self._conn.execute(f"PRAGMA user_version={self.SCHEMA_VERSION}")
self._conn.commit()
def _migrate_columns(self) -> None:
columns = {
str(row["name"])
for row in self._conn.execute("PRAGMA table_info(alerts)").fetchall()
def save_traffic_snapshot(self, window_seconds: int, payload: dict[str, Any]) -> None:
window_seconds = int(window_seconds)
if window_seconds <= 0:
raise ValueError("window_seconds must be positive")
generated_at = datetime.now(timezone.utc).isoformat()
stored = dict(payload)
stored["window_seconds"] = window_seconds
stored["generated_at"] = generated_at
raw = json.dumps(stored, ensure_ascii=False, separators=(",", ":"))
with self._lock:
self._conn.execute(
"""
INSERT INTO traffic_snapshots(window_seconds, generated_at, payload_json)
VALUES (?, ?, ?)
ON CONFLICT(window_seconds) DO UPDATE SET
generated_at=excluded.generated_at,
payload_json=excluded.payload_json
""",
(window_seconds, generated_at, raw),
)
self._conn.commit()
def traffic_snapshot(self, window_seconds: int) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute(
"SELECT generated_at, payload_json FROM traffic_snapshots WHERE window_seconds=?",
(int(window_seconds),),
).fetchone()
if row is None:
return None
try:
payload = json.loads(str(row["payload_json"]))
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
payload.setdefault("generated_at", row["generated_at"])
payload["persisted_snapshot"] = True
return payload
def traffic_snapshot_status(self) -> dict[str, Any]:
with self._lock:
rows = self._conn.execute(
"SELECT window_seconds, generated_at FROM traffic_snapshots ORDER BY window_seconds"
).fetchall()
return {
"windows": [
{"window_seconds": int(row["window_seconds"]), "generated_at": row["generated_at"]}
for row in rows
]
}
additions = {
def clear_traffic_snapshots(self) -> int:
with self._lock:
count = int(self._conn.execute("SELECT COUNT(*) FROM traffic_snapshots").fetchone()[0])
self._conn.execute("DELETE FROM traffic_snapshots")
self._conn.commit()
return count
def create_web_session(
self,
token_hash: str,
username: str,
csrf_token: str,
expires_at: datetime,
) -> None:
now = datetime.now(timezone.utc).isoformat()
expiry = expires_at.astimezone(timezone.utc).isoformat()
with self._lock:
self._purge_expired_sessions_locked()
self._conn.execute(
"""
INSERT OR REPLACE INTO web_sessions(
token_hash, username, csrf_token, created_at, expires_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(token_hash, username, csrf_token, now, expiry, now),
)
self._conn.commit()
def get_web_session(self, token_hash: str, *, touch: bool = True) -> dict[str, Any] | None:
now = datetime.now(timezone.utc)
with self._lock:
row = self._conn.execute(
"""
SELECT token_hash, username, csrf_token, created_at, expires_at, last_seen_at
FROM web_sessions WHERE token_hash=?
""",
(token_hash,),
).fetchone()
if row is None:
return None
expires_at = _parse_timestamp(row["expires_at"])
if expires_at <= now:
self._conn.execute("DELETE FROM web_sessions WHERE token_hash=?", (token_hash,))
self._conn.commit()
return None
if touch:
last_seen_at = now.isoformat()
self._conn.execute(
"UPDATE web_sessions SET last_seen_at=? WHERE token_hash=?",
(last_seen_at, token_hash),
)
self._conn.commit()
else:
last_seen_at = str(row["last_seen_at"])
return {
"username": str(row["username"]),
"csrf_token": str(row["csrf_token"]),
"created_at": str(row["created_at"]),
"expires_at": expires_at.isoformat(),
"last_seen_at": last_seen_at,
}
def delete_web_session(self, token_hash: str) -> None:
with self._lock:
self._conn.execute("DELETE FROM web_sessions WHERE token_hash=?", (token_hash,))
self._conn.commit()
def purge_expired_sessions(self) -> int:
with self._lock:
count = self._purge_expired_sessions_locked()
self._conn.commit()
return count
def _purge_expired_sessions_locked(self) -> int:
cutoff = datetime.now(timezone.utc).isoformat()
cursor = self._conn.execute("DELETE FROM web_sessions WHERE expires_at<=?", (cutoff,))
return int(cursor.rowcount)
def _migrate_columns(self) -> None:
def add_missing(table: str, additions: dict[str, str]) -> None:
columns = {
str(row["name"])
for row in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
}
for name, definition in additions.items():
if name not in columns:
self._conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
add_missing("alerts", {
"first_seen": "TEXT",
"last_seen": "TEXT",
"hit_count": "INTEGER NOT NULL DEFAULT 1",
}
for name, definition in additions.items():
if name not in columns:
self._conn.execute(f"ALTER TABLE alerts ADD COLUMN {name} {definition}")
"incident_id": "INTEGER",
"risk_score": "INTEGER NOT NULL DEFAULT 0",
})
add_missing("ndr_incidents", {
"mitre_json": "TEXT NOT NULL DEFAULT '[]'",
})
add_missing("ndr_events", {
"mitre_json": "TEXT NOT NULL DEFAULT '[]'",
})
def _backfill_mitre_locked(self) -> None:
rows = self._conn.execute(
"SELECT id,incident_id,stage,summary,src_ip,dest_ip,details_json,mitre_json FROM ndr_events"
).fetchall()
incident_map: dict[int, list[dict[str, str]]] = {}
for row in rows:
current = _json_objects(row["mitre_json"] or "[]")
try:
details = json.loads(row["details_json"] or "{}")
except (TypeError, ValueError, json.JSONDecodeError):
details = {}
if not isinstance(details, dict):
details = {}
record = dict(details)
record.setdefault("src_ip", row["src_ip"])
record.setdefault("dest_ip", row["dest_ip"])
mapped = merge_mitre(current, classify_mitre(str(row["stage"] or ""), str(row["summary"] or ""), record))
if mapped != current:
self._conn.execute(
"UPDATE ndr_events SET mitre_json=? WHERE id=?",
(json.dumps(mapped, ensure_ascii=False, separators=(",", ":")), int(row["id"])),
)
incident_id = int(row["incident_id"])
incident_map[incident_id] = merge_mitre(incident_map.get(incident_id, []), mapped)
for incident_id, mapped in incident_map.items():
row = self._conn.execute("SELECT mitre_json FROM ndr_incidents WHERE id=?", (incident_id,)).fetchone()
if row is None:
continue
merged = merge_mitre(_json_objects(row["mitre_json"] or "[]"), mapped)
self._conn.execute(
"UPDATE ndr_incidents SET mitre_json=? WHERE id=?",
(json.dumps(merged, ensure_ascii=False, separators=(",", ":")), incident_id),
)
def _normalise_existing_timestamps(self) -> None:
rows = self._conn.execute(
@@ -298,7 +596,7 @@ class AlertStore:
SELECT id, timestamp, first_seen, last_seen, hit_count,
src_ip, src_port, dest_ip, dest_port, proto,
signature_id, signature, category, severity, action,
blocked, block_target, block_reason
blocked, block_target, block_reason, incident_id, risk_score
FROM alerts ORDER BY COALESCE(last_seen,timestamp) DESC, id DESC LIMIT ?
""",
(limit,),
@@ -431,6 +729,350 @@ class AlertStore:
self._conn.commit()
return count
def observe_asset(self, record: dict[str, Any], *, risk_score: int = 0, incident: bool = False) -> dict[str, Any] | None:
ip = _local_subject(record)
if not ip:
return None
now = _normalise_timestamp(record.get("timestamp"))
mac = _asset_mac(record, ip)
hostname = str(record.get("dhcp_hostname") or "")[:255]
app_proto = str(record.get("app_proto") or "")[:48].lower()
event_type = str(record.get("type") or "")[:32]
bytes_count = max(_as_int(record.get("bytes")) or 0, 0)
port = _asset_dest_port(record, ip)
domain = str(record.get("dns_query") or record.get("tls_sni") or record.get("quic_sni") or record.get("http_host") or "")[:255].lower().rstrip(".")
fingerprints = [str(record.get(k) or "")[:160] for k in ("tls_ja4", "tls_ja3", "quic_ja4", "quic_ja3", "ssh_hassh_client")]
fingerprints = [x for x in fingerprints if x]
is_alert = 1 if event_type == "alert" else 0
with self._lock:
old = self._conn.execute("SELECT * FROM assets WHERE ip=?", (ip,)).fetchone()
protocols = _json_set(old["protocols_json"] if old else "[]")
ports = _json_set(old["ports_json"] if old else "[]")
domains = _json_set(old["domains_json"] if old else "[]")
fps = _json_set(old["fingerprints_json"] if old else "[]")
if app_proto:
protocols.add(app_proto)
if port:
ports.add(str(port))
if domain:
domains.add(domain)
fps.update(fingerprints)
# Keep bounded identity metadata. Baseline details live in asset_baseline.
protocols = set(sorted(protocols)[:64])
ports = set(sorted(ports, key=lambda x: int(x) if x.isdigit() else 65536)[:128])
domains = set(sorted(domains)[-128:])
fps = set(sorted(fps)[-128:])
previous_mac = str(old["mac"] or "") if old else ""
new_mac = mac or previous_mac
new_hostname = hostname or (str(old["hostname"] or "") if old else "")
new_risk = max(int(old["risk_score"] or 0) if old else 0, max(0, min(100, int(risk_score))))
self._conn.execute(
"""
INSERT INTO assets(ip,mac,hostname,first_seen,last_seen,observations,bytes_total,alert_count,incident_count,risk_score,last_event_type,last_app_proto,identity_source,protocols_json,ports_json,domains_json,fingerprints_json)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET
mac=excluded.mac, hostname=excluded.hostname, last_seen=excluded.last_seen,
observations=assets.observations+1, bytes_total=assets.bytes_total+excluded.bytes_total,
alert_count=assets.alert_count+excluded.alert_count,
incident_count=assets.incident_count+excluded.incident_count,
risk_score=MAX(assets.risk_score, excluded.risk_score),
last_event_type=excluded.last_event_type, last_app_proto=excluded.last_app_proto,
identity_source=CASE WHEN excluded.identity_source<>'' THEN excluded.identity_source ELSE assets.identity_source END,
protocols_json=excluded.protocols_json, ports_json=excluded.ports_json,
domains_json=excluded.domains_json, fingerprints_json=excluded.fingerprints_json
""",
(
ip,new_mac,new_hostname,now,now,1,bytes_count,is_alert,1 if incident else 0,new_risk,event_type,app_proto,
"dhcp" if hostname or record.get("dhcp_client_mac") else "arp" if record.get("arp_src_mac") else "ethernet" if mac else "eve",
_json_dump_set(protocols),_json_dump_set(ports),_json_dump_set(domains),_json_dump_set(fps),
),
)
self._conn.commit()
row = self._conn.execute("SELECT * FROM assets WHERE ip=?", (ip,)).fetchone()
result = dict(row) if row else None
if result:
result["mac_changed"] = bool(previous_mac and mac and previous_mac.lower() != mac.lower())
result["previous_mac"] = previous_mac
return result
def baseline_touch(self, asset_ip: str, kind: str, value: str, timestamp: str) -> tuple[bool, int]:
if not asset_ip or not kind or not value:
return False, 0
timestamp = _normalise_timestamp(timestamp)
with self._lock:
row = self._conn.execute(
"SELECT seen_count FROM asset_baseline WHERE asset_ip=? AND kind=? AND value=?",
(asset_ip, kind, value),
).fetchone()
is_new = row is None
if row is None:
self._conn.execute(
"INSERT INTO asset_baseline(asset_ip,kind,value,first_seen,last_seen,seen_count) VALUES(?,?,?,?,?,1)",
(asset_ip, kind, value, timestamp, timestamp),
)
count = 1
else:
count = int(row["seen_count"] or 0) + 1
self._conn.execute(
"UPDATE asset_baseline SET last_seen=?, seen_count=? WHERE asset_ip=? AND kind=? AND value=?",
(timestamp, count, asset_ip, kind, value),
)
self._conn.commit()
return is_new, count
def asset_observation_count(self, asset_ip: str) -> int:
with self._lock:
row = self._conn.execute("SELECT observations FROM assets WHERE ip=?", (asset_ip,)).fetchone()
return int(row["observations"] or 0) if row else 0
def assets(self, limit: int = 250) -> list[dict[str, Any]]:
limit = min(max(int(limit), 1), 1000)
with self._lock:
rows = self._conn.execute("SELECT * FROM assets ORDER BY risk_score DESC,last_seen DESC LIMIT ?", (limit,)).fetchall()
result = []
for row in rows:
item = dict(row)
for key in ("protocols_json","ports_json","domains_json","fingerprints_json"):
item[key.removesuffix("_json")] = sorted(_json_set(item.pop(key, "[]")))
result.append(item)
return result
def raise_asset_risk(self, asset_ip: str, risk_score: int) -> None:
with self._lock:
self._conn.execute(
"UPDATE assets SET risk_score=MAX(risk_score,?) WHERE ip=?",
(max(0, min(100, int(risk_score))), str(asset_ip)[:64]),
)
self._conn.commit()
def add_ioc(self, indicator: str, indicator_type: str, *, source: str = "manual", confidence: int = 80, severity: int = 1, note: str = "", expires_at: str | None = None) -> int:
indicator_type = str(indicator_type).strip().lower()
indicator = _normalise_ioc(indicator, indicator_type)
if indicator_type not in {"ip","domain","sha256","ja3","ja4","hassh"}:
raise ValueError("unsupported IOC type")
if not indicator:
raise ValueError("indicator is required")
now = datetime.now(timezone.utc).isoformat()
with self._lock:
self._conn.execute(
"""
INSERT INTO threat_iocs(indicator,indicator_type,source,confidence,severity,enabled,note,created_at,expires_at)
VALUES(?,?,?,?,?,1,?,?,?)
ON CONFLICT(indicator_type,indicator) DO UPDATE SET source=excluded.source,confidence=excluded.confidence,severity=excluded.severity,enabled=1,note=excluded.note,expires_at=excluded.expires_at
""",
(indicator,indicator_type,str(source)[:120],max(0,min(100,int(confidence))),max(1,min(4,int(severity))),str(note)[:500],now,expires_at),
)
self._conn.commit()
row = self._conn.execute("SELECT id FROM threat_iocs WHERE indicator_type=? AND indicator=?", (indicator_type,indicator)).fetchone()
return int(row["id"])
def list_iocs(self, limit: int = 1000, *, enabled_only: bool = False) -> list[dict[str, Any]]:
limit = min(max(int(limit), 1), 5000)
now = datetime.now(timezone.utc).isoformat()
where = "WHERE enabled=1 AND (expires_at IS NULL OR expires_at>?)" if enabled_only else ""
params: tuple[Any, ...] = (now, limit) if enabled_only else (limit,)
sql = f"SELECT * FROM threat_iocs {where} ORDER BY enabled DESC,severity ASC,confidence DESC,id DESC LIMIT ?"
with self._lock:
rows = self._conn.execute(sql, params).fetchall()
result=[]
for row in rows:
item=dict(row); item["enabled"]=bool(item["enabled"]); result.append(item)
return result
def remove_ioc(self, ioc_id: int) -> bool:
with self._lock:
cur=self._conn.execute("DELETE FROM threat_iocs WHERE id=?", (int(ioc_id),)); self._conn.commit()
return bool(cur.rowcount)
def mark_ioc_hit(self, ioc_id: int, timestamp: str) -> None:
with self._lock:
self._conn.execute("UPDATE threat_iocs SET hit_count=hit_count+1,last_hit_at=? WHERE id=?", (_normalise_timestamp(timestamp),int(ioc_id)))
self._conn.commit()
def correlate_signal(self, signal: dict[str, Any], window_seconds: int = 1800) -> int:
subject_ip = str(signal.get("subject_ip") or "")[:64]
if not subject_ip:
raise ValueError("subject_ip is required")
ts = _normalise_timestamp(signal.get("timestamp"))
cutoff = (_parse_timestamp(ts) - timedelta(seconds=max(60,int(window_seconds)))).isoformat()
risk = max(0,min(100,int(signal.get("risk") or 0)))
stage = str(signal.get("stage") or "")[:64]
kind = str(signal.get("kind") or "signal")[:64]
summary = str(signal.get("summary") or kind)[:500]
flow_id = str(signal.get("flow_id") or "")[:64]
community_id = str(signal.get("community_id") or "")[:128]
dest_ip = str(signal.get("dest_ip") or "")[:64]
mitre = [dict(item) for item in (signal.get("mitre") or []) if isinstance(item, dict)]
with self._lock:
row = self._conn.execute(
"SELECT * FROM ndr_incidents WHERE subject_ip=? AND status='open' AND last_seen>=? ORDER BY last_seen DESC,id DESC LIMIT 1",
(subject_ip, cutoff),
).fetchone()
if row is None:
stages=set(); signals=[]; flows=set(); communities=set(); destinations=set();
if stage: stages.add(stage)
signals.append(summary)
if flow_id: flows.add(flow_id)
if community_id: communities.add(community_id)
if dest_ip and dest_ip != subject_ip: destinations.add(dest_ip)
cursor=self._conn.execute(
"""INSERT INTO ndr_incidents(subject_ip,opened_at,last_seen,title,risk_score,severity,event_count,alert_count,ioc_hits,behavior_hits,summary,stages_json,signals_json,flow_ids_json,community_ids_json,destinations_json,mitre_json)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(subject_ip,ts,ts,str(signal.get("title") or summary)[:220],risk,_risk_severity(risk),1,1 if kind=="alert" else 0,1 if kind=="ioc" else 0,1 if kind=="behavior" else 0,summary,_json_dump_set(stages),json.dumps(signals,ensure_ascii=False),_json_dump_set(flows),_json_dump_set(communities),_json_dump_set(destinations),json.dumps(mitre,ensure_ascii=False,separators=(",",":"))),
)
incident_id=int(cursor.lastrowid)
else:
incident_id=int(row["id"])
stages=_json_set(row["stages_json"]); signals=_json_list(row["signals_json"]); flows=_json_set(row["flow_ids_json"]); communities=_json_set(row["community_ids_json"]); destinations=_json_set(row["destinations_json"]); mitre=merge_mitre(_json_objects(row["mitre_json"]), mitre)
if stage: stages.add(stage)
if summary and summary not in signals: signals=(signals+[summary])[-20:]
if flow_id: flows.add(flow_id)
if community_id: communities.add(community_id)
if dest_ip and dest_ip != subject_ip: destinations.add(dest_ip)
stage_bonus=10 if len(stages)>=2 else 0
stage_bonus+=10 if len(stages)>=3 else 0
combined=max(int(row["risk_score"] or 0), min(100,risk+stage_bonus))
title=str(row["title"] or signal.get("title") or summary)[:220]
if risk >= int(row["risk_score"] or 0): title=str(signal.get("title") or summary)[:220]
self._conn.execute(
"""UPDATE ndr_incidents SET last_seen=?,title=?,risk_score=?,severity=?,event_count=event_count+1,alert_count=alert_count+?,ioc_hits=ioc_hits+?,behavior_hits=behavior_hits+?,summary=?,stages_json=?,signals_json=?,flow_ids_json=?,community_ids_json=?,destinations_json=?,mitre_json=? WHERE id=?""",
(ts,title,combined,_risk_severity(combined),1 if kind=="alert" else 0,1 if kind=="ioc" else 0,1 if kind=="behavior" else 0,summary,_json_dump_set(stages),json.dumps(signals,ensure_ascii=False),_json_dump_set(flows),_json_dump_set(communities),_json_dump_set(destinations),json.dumps(mitre,ensure_ascii=False,separators=(",",":")),incident_id),
)
self._conn.execute(
"""INSERT INTO ndr_events(incident_id,timestamp,kind,stage,risk,summary,src_ip,dest_ip,signature_id,flow_id,community_id,details_json,mitre_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(incident_id,ts,kind,stage,risk,summary,str(signal.get("src_ip") or "")[:64],dest_ip,_as_int(signal.get("signature_id")),flow_id,community_id,json.dumps(signal.get("details") or {},ensure_ascii=False,separators=(",",":")),json.dumps(mitre,ensure_ascii=False,separators=(",",":"))),
)
# Bound evidence rows per incident without losing the incident summary.
self._conn.execute("DELETE FROM ndr_events WHERE incident_id=? AND id NOT IN (SELECT id FROM ndr_events WHERE incident_id=? ORDER BY id DESC LIMIT 200)", (incident_id,incident_id))
self._conn.commit()
return incident_id
def link_alert_incident(self, alert_id: int, incident_id: int, risk_score: int) -> None:
with self._lock:
self._conn.execute("UPDATE alerts SET incident_id=?,risk_score=MAX(COALESCE(risk_score,0),?) WHERE id=?", (int(incident_id),max(0,min(100,int(risk_score))),int(alert_id)))
self._conn.commit()
def mark_incident_blocked(self, incident_id: int, target: str) -> None:
with self._lock:
self._conn.execute("UPDATE ndr_incidents SET blocked=1,block_target=? WHERE id=?", (str(target)[:64],int(incident_id))); self._conn.commit()
def set_ndr_incident_status(self, incident_id: int, status: str) -> bool:
status = str(status or "").strip().lower()
if status not in {"open", "acknowledged", "closed"}:
raise ValueError("status must be open, acknowledged or closed")
with self._lock:
cursor = self._conn.execute(
"UPDATE ndr_incidents SET status=? WHERE id=?",
(status, int(incident_id)),
)
self._conn.commit()
return int(cursor.rowcount) > 0
def ndr_incident(self, incident_id: int) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute("SELECT * FROM ndr_incidents WHERE id=?", (int(incident_id),)).fetchone()
if row is None:
return None
item = dict(row); item["blocked"] = bool(item["blocked"])
for key in ("stages_json", "signals_json", "flow_ids_json", "community_ids_json", "destinations_json"):
out = key.removesuffix("_json"); item[out] = _json_list(item.pop(key, "[]"))
item["mitre"] = _json_objects(item.pop("mitre_json", "[]"))
return item
def recent_ndr_incidents(self, limit: int = 100) -> list[dict[str, Any]]:
limit=min(max(int(limit),1),500)
with self._lock:
rows=self._conn.execute("SELECT * FROM ndr_incidents ORDER BY last_seen DESC,id DESC LIMIT ?", (limit,)).fetchall()
result=[]
for row in rows:
item=dict(row); item["blocked"]=bool(item["blocked"])
for key in ("stages_json","signals_json","flow_ids_json","community_ids_json","destinations_json"):
out=key.removesuffix("_json"); item[out]=_json_list(item.pop(key,"[]"))
item["mitre"]=_json_objects(item.pop("mitre_json","[]"))
result.append(item)
return result
def ndr_incident_events(self, incident_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self._lock:
rows=self._conn.execute("SELECT * FROM ndr_events WHERE incident_id=? ORDER BY timestamp DESC,id DESC LIMIT ?", (int(incident_id),min(max(int(limit),1),200))).fetchall()
result=[]
for row in rows:
item=dict(row)
try: item["details"]=json.loads(item.pop("details_json") or "{}")
except (ValueError,TypeError,json.JSONDecodeError): item["details"]={}
item["mitre"]=_json_objects(item.pop("mitre_json","[]"))
result.append(item)
return result
def ndr_summary(self) -> dict[str, Any]:
with self._lock:
row=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN status='open' THEN 1 ELSE 0 END) open_count,SUM(CASE WHEN risk_score>=80 THEN 1 ELSE 0 END) criticalish,MAX(risk_score) max_risk FROM ndr_incidents").fetchone()
assets=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN risk_score>=60 THEN 1 ELSE 0 END) risky FROM assets").fetchone()
iocs=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN enabled=1 THEN 1 ELSE 0 END) enabled,SUM(hit_count) hits FROM threat_iocs").fetchone()
return {"incidents":int(row["total"] or 0),"open_incidents":int(row["open_count"] or 0),"high_risk_incidents":int(row["criticalish"] or 0),"max_risk":int(row["max_risk"] or 0),"assets":int(assets["total"] or 0),"risky_assets":int(assets["risky"] or 0),"iocs":int(iocs["total"] or 0),"enabled_iocs":int(iocs["enabled"] or 0),"ioc_hits":int(iocs["hits"] or 0)}
def rule_intelligence(self, hours: int = 24, limit: int = 100) -> dict[str, Any]:
hours = min(max(int(hours), 1), 24 * 30)
limit = min(max(int(limit), 1), 500)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
with self._lock:
rows = self._conn.execute(
"""
SELECT signature_id, MAX(signature) signature, MAX(category) category,
MIN(COALESCE(severity,4)) severity,
COUNT(*) rows, SUM(COALESCE(hit_count,1)) hits,
COUNT(DISTINCT NULLIF(src_ip,'')) unique_src,
COUNT(DISTINCT NULLIF(dest_ip,'')) unique_dst,
COUNT(DISTINCT incident_id) incidents,
SUM(CASE WHEN blocked=1 THEN 1 ELSE 0 END) blocked,
MIN(COALESCE(first_seen,timestamp)) first_seen,
MAX(COALESCE(last_seen,timestamp)) last_seen
FROM alerts
WHERE signature_id IS NOT NULL AND COALESCE(last_seen,timestamp)>=?
GROUP BY signature_id
ORDER BY hits DESC, last_seen DESC
LIMIT ?
""",
(cutoff, limit),
).fetchall()
scored = [score_rule(dict(row)) for row in rows]
return {
"window_hours": hours,
"rules": scored,
"noisy": sum(1 for row in scored if row["recommendation"] == "limit"),
"review": sum(1 for row in scored if row["recommendation"] == "review"),
}
def audit(self, username: str, action: str, *, target: str = "", result: str = "ok", remote_ip: str = "", details: dict[str, Any] | None = None) -> int:
now = datetime.now(timezone.utc).isoformat()
payload = json.dumps(details or {}, ensure_ascii=False, separators=(",", ":"))
with self._lock:
cur = self._conn.execute(
"INSERT INTO audit_log(timestamp,username,action,target,result,remote_ip,details_json) VALUES(?,?,?,?,?,?,?)",
(now, str(username or "")[:120], str(action or "")[:160], str(target or "")[:300], str(result or "")[:32], str(remote_ip or "")[:64], payload[:12000]),
)
self._conn.execute(
"DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 10000)"
)
self._conn.commit()
return int(cur.lastrowid)
def audit_events(self, limit: int = 200) -> list[dict[str, Any]]:
limit = min(max(int(limit), 1), 1000)
with self._lock:
rows = self._conn.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC,id DESC LIMIT ?", (limit,)
).fetchall()
out = []
for row in rows:
item = dict(row)
try:
item["details"] = json.loads(item.pop("details_json") or "{}")
except (TypeError, ValueError, json.JSONDecodeError):
item["details"] = {}
out.append(item)
return out
def vacuum(self) -> None:
with self._lock:
self._conn.execute("VACUUM")
@@ -458,6 +1100,106 @@ def _normalise_timestamp(value: Any) -> str:
return datetime.now(timezone.utc).isoformat()
def _json_set(raw: Any) -> set[str]:
try:
value = json.loads(str(raw or "[]"))
except (ValueError, TypeError, json.JSONDecodeError):
return set()
return {str(x) for x in value if str(x)} if isinstance(value, list) else set()
def _json_list(raw: Any) -> list[str]:
try:
value = json.loads(str(raw or "[]"))
except (ValueError, TypeError, json.JSONDecodeError):
return []
return [str(x) for x in value if str(x)] if isinstance(value, list) else []
def _json_dump_set(values: set[str]) -> str:
return json.dumps(sorted(values), ensure_ascii=False, separators=(",", ":"))
def _json_objects(raw: Any) -> list[dict[str, str]]:
try:
value = json.loads(str(raw or "[]"))
except (ValueError, TypeError, json.JSONDecodeError):
return []
if not isinstance(value, list):
return []
out: list[dict[str, str]] = []
for item in value:
if isinstance(item, dict):
out.append({str(k): str(v) for k, v in item.items() if v not in (None, "")})
return out
def _local_subject(record: dict[str, Any]) -> str:
direction = str(record.get("direction") or "")
if direction in {"outbound", "internal"}:
return str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")[:64]
if direction == "inbound":
return str(record.get("dest_ip") or "")[:64]
return str(record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")[:64]
def _asset_mac(record: dict[str, Any], ip: str) -> str:
if str(record.get("dhcp_assigned_ip") or "") == ip:
return str(record.get("dhcp_client_mac") or "")[:32]
if str(record.get("arp_src_ip") or "") == ip:
return str(record.get("arp_src_mac") or "")[:32]
if str(record.get("src_ip") or "") == ip:
return str(record.get("ether_src") or "")[:32]
if str(record.get("dest_ip") or "") == ip:
return str(record.get("ether_dest") or "")[:32]
return ""
def _asset_dest_port(record: dict[str, Any], ip: str) -> int | None:
if str(record.get("src_ip") or "") == ip:
return _as_int(record.get("dest_port"))
return None
def _risk_severity(risk: int) -> int:
if risk >= 80: return 1
if risk >= 55: return 2
if risk >= 30: return 3
return 4
def _normalise_ioc(indicator: str, indicator_type: str) -> str:
value = str(indicator or "").strip()
if indicator_type == "ip":
try:
return str(__import__("ipaddress").ip_address(value))
except ValueError:
raise ValueError("invalid IP IOC")
if indicator_type == "domain":
value = value.lower().rstrip(".")
if value.startswith("*."):
value = value[2:]
if not value or "." not in value or any(ch.isspace() for ch in value):
raise ValueError("invalid domain IOC")
return value
if indicator_type == "sha256":
value=value.lower()
if len(value)!=64 or any(c not in "0123456789abcdef" for c in value):
raise ValueError("invalid SHA256 IOC")
return value
if indicator_type in {"ja3", "hassh"}:
value = value.lower()
if len(value) != 32 or any(c not in "0123456789abcdef" for c in value):
raise ValueError(f"invalid {indicator_type.upper()} IOC")
return value
if indicator_type == "ja4":
value = value.lower()
if len(value) < 20 or len(value) > 96 or any(c.isspace() for c in value):
raise ValueError("invalid JA4 IOC")
return value
return value.lower()
def _file_size(path: str) -> int:
try:
return int(os.path.getsize(path))