agentmetry 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Detection engine — orders a session's events and runs the rule registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import SEVERITY_RANK, Detection
|
|
9
|
+
from .rules import HOST_REGISTRY, REGISTRY
|
|
10
|
+
from .yaml_rules import build_yaml_rules
|
|
11
|
+
|
|
12
|
+
_EPOCH = datetime.min.replace(tzinfo=timezone.utc)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _session_rules():
|
|
16
|
+
return [*REGISTRY, *build_yaml_rules()]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _event_ts(event: dict[str, Any]) -> datetime:
|
|
20
|
+
ts = event.get("timestamp_utc")
|
|
21
|
+
if not isinstance(ts, str):
|
|
22
|
+
return _EPOCH
|
|
23
|
+
try:
|
|
24
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
25
|
+
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
|
26
|
+
except ValueError:
|
|
27
|
+
return _EPOCH
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sorted(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
31
|
+
"""Time order, falling back to the order the caller supplied.
|
|
32
|
+
|
|
33
|
+
The tie-break used to end with `event_id`, which is a random UUID. Every
|
|
34
|
+
sequence rule in this package asks "did A happen before B", so on a
|
|
35
|
+
timestamp tie the answer was decided by a coin flip.
|
|
36
|
+
|
|
37
|
+
Ties are not rare. Windows clock granularity is around 15 ms, so two tool
|
|
38
|
+
calls from one agent turn routinely share a timestamp; this surfaced as a
|
|
39
|
+
Windows-only CI failure where `credential-read-then-cloud-api` fired or did
|
|
40
|
+
not depending on which UUID sorted first. The README claims ordering is
|
|
41
|
+
enforced by position rather than co-occurrence, and a random tie-break
|
|
42
|
+
quietly made that untrue.
|
|
43
|
+
|
|
44
|
+
`sorted` is stable, so omitting the UUID preserves the input order on ties.
|
|
45
|
+
Callers hand us events straight from the trail, which returns them ordered
|
|
46
|
+
by `timestamp_utc` then insert id, so input order *is* arrival order: the
|
|
47
|
+
best available evidence of what actually happened first.
|
|
48
|
+
"""
|
|
49
|
+
def _key(event: dict[str, Any]) -> tuple[datetime, int]:
|
|
50
|
+
seq = event.get("seq")
|
|
51
|
+
seq_int = seq if isinstance(seq, int) else 0
|
|
52
|
+
return (_event_ts(event), seq_int)
|
|
53
|
+
|
|
54
|
+
return sorted(events, key=_key)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_detections(events: list[dict[str, Any]]) -> list[Detection]:
|
|
58
|
+
"""Run every rule over one session's events, ranked most-severe first.
|
|
59
|
+
|
|
60
|
+
`events` should be the events for a single correlation_id; ordering is
|
|
61
|
+
handled here so callers can pass them straight from the trail.
|
|
62
|
+
"""
|
|
63
|
+
ordered = _sorted(events)
|
|
64
|
+
detections: list[Detection] = []
|
|
65
|
+
for rule in _session_rules():
|
|
66
|
+
detections.extend(rule(ordered))
|
|
67
|
+
detections.sort(key=lambda d: (SEVERITY_RANK.get(d.severity, 99), d.first_seen_utc))
|
|
68
|
+
return detections
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_host_detections(events: list[dict[str, Any]]) -> list[Detection]:
|
|
72
|
+
"""Run host-scoped rules over events aggregated by host_id."""
|
|
73
|
+
ordered = _sorted(events)
|
|
74
|
+
detections: list[Detection] = []
|
|
75
|
+
for rule in HOST_REGISTRY:
|
|
76
|
+
detections.extend(rule(ordered))
|
|
77
|
+
detections.sort(key=lambda d: (SEVERITY_RANK.get(d.severity, 99), d.first_seen_utc))
|
|
78
|
+
return detections
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Live detection — correlate as events arrive, not only when someone asks.
|
|
2
|
+
|
|
3
|
+
The rule engine is a pure function over a session's events. That is fine for a
|
|
4
|
+
forensic query, but a detection nobody sees is not a control: until this module
|
|
5
|
+
existed, a `credential-exfil` finding only appeared if an operator happened to
|
|
6
|
+
open that session in the dashboard. Nothing streamed, nothing reached a SIEM,
|
|
7
|
+
nothing alerted.
|
|
8
|
+
|
|
9
|
+
This keeps a bounded window of each live session, re-runs the rules on every
|
|
10
|
+
ingested event, and returns only detections that are *new* for that session —
|
|
11
|
+
so a firing rule is emitted once, as a canonical event, down the same sinks as
|
|
12
|
+
everything else.
|
|
13
|
+
|
|
14
|
+
Live state (event windows + emitted rule IDs) is checkpointed in SQLite so an
|
|
15
|
+
orchestrator restart does not re-alert or lose context for active sessions.
|
|
16
|
+
The JSONL trail remains authoritative; `GET /audit/detections/{id}` still
|
|
17
|
+
recomputes from it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from agentmetry.core.audit.identity import identity_fields
|
|
26
|
+
|
|
27
|
+
from .engine import run_detections, run_host_detections
|
|
28
|
+
from .live_store import get_live_store
|
|
29
|
+
from .models import Detection
|
|
30
|
+
|
|
31
|
+
def reset_live_state() -> None:
|
|
32
|
+
"""Test helper — clear live detection checkpoint."""
|
|
33
|
+
get_live_store().clear_all()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def observe(canonical: dict[str, Any]) -> list[Detection]:
|
|
37
|
+
"""Record an event and return detections that are NEW for its session.
|
|
38
|
+
|
|
39
|
+
Does NOT checkpoint here. The caller must call `mark_detection_emitted` only
|
|
40
|
+
after the detection is durably stored and forwarded, so a failed emit lets
|
|
41
|
+
the rule fire again on the next session event instead of being lost.
|
|
42
|
+
"""
|
|
43
|
+
corr = str(canonical.get("correlation_id") or "")
|
|
44
|
+
if not corr:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
store = get_live_store()
|
|
48
|
+
events = store.append_event(corr, canonical)
|
|
49
|
+
|
|
50
|
+
fresh: list[Detection] = []
|
|
51
|
+
seen_in_batch: set[str] = set()
|
|
52
|
+
for detection in run_detections(events):
|
|
53
|
+
# Dedup within this call (a rule may return several findings) without
|
|
54
|
+
# persisting — persistence is the caller's job, post-emit.
|
|
55
|
+
if detection.rule_id in seen_in_batch or store.is_emitted(corr, detection.rule_id):
|
|
56
|
+
continue
|
|
57
|
+
seen_in_batch.add(detection.rule_id)
|
|
58
|
+
fresh.append(detection)
|
|
59
|
+
return fresh
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def observe_host(canonical: dict[str, Any]) -> list[Detection]:
|
|
63
|
+
"""Record an event in the host-level window and return new host-scoped detections."""
|
|
64
|
+
host_id = str(canonical.get("host_id") or "")
|
|
65
|
+
if not host_id:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
store = get_live_store()
|
|
69
|
+
events = store.append_host_event(host_id, canonical)
|
|
70
|
+
|
|
71
|
+
fresh: list[Detection] = []
|
|
72
|
+
seen_in_batch: set[str] = set()
|
|
73
|
+
for detection in run_host_detections(events):
|
|
74
|
+
if detection.rule_id in seen_in_batch or store.is_host_emitted(host_id, detection.rule_id):
|
|
75
|
+
continue
|
|
76
|
+
seen_in_batch.add(detection.rule_id)
|
|
77
|
+
fresh.append(detection)
|
|
78
|
+
return fresh
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mark_host_detection_emitted(host_id: str, rule_id: str, emitted_at: str = "") -> None:
|
|
82
|
+
"""Checkpoint a host-level detection as emitted."""
|
|
83
|
+
if not host_id or not rule_id:
|
|
84
|
+
return
|
|
85
|
+
get_live_store().mark_host_emitted(host_id, rule_id, emitted_at=emitted_at)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mark_detection_emitted(correlation_id: str, rule_id: str, emitted_at: str = "") -> None:
|
|
89
|
+
"""Checkpoint a detection as emitted for its session.
|
|
90
|
+
|
|
91
|
+
Call ONLY after the detection event is durably stored and forwarded. Marking
|
|
92
|
+
is idempotent (INSERT OR IGNORE), so a re-fired detection that finally
|
|
93
|
+
succeeds does not double-alert.
|
|
94
|
+
"""
|
|
95
|
+
if not correlation_id or not rule_id:
|
|
96
|
+
return
|
|
97
|
+
get_live_store().mark_emitted(correlation_id, rule_id, emitted_at=emitted_at)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_detection_event(detection: Detection, source_event: dict[str, Any]) -> dict[str, Any]:
|
|
101
|
+
"""Wrap a Detection as a canonical event so it flows to every sink.
|
|
102
|
+
|
|
103
|
+
`action.outcome` carries the severity: a SIEM can alert on
|
|
104
|
+
`action.type:detection AND action.outcome:critical` without understanding
|
|
105
|
+
Agentmetry's rule vocabulary.
|
|
106
|
+
"""
|
|
107
|
+
source = source_event.get("source")
|
|
108
|
+
agent = source_event.get("agent")
|
|
109
|
+
return {
|
|
110
|
+
"schema_version": source_event.get("schema_version", "1.1.0"),
|
|
111
|
+
"event_id": str(uuid.uuid4()),
|
|
112
|
+
"session_id": source_event.get("session_id", ""),
|
|
113
|
+
"correlation_id": detection.correlation_id or source_event.get("correlation_id", ""),
|
|
114
|
+
"timestamp_utc": source_event.get("timestamp_utc", ""),
|
|
115
|
+
**identity_fields(),
|
|
116
|
+
"source_topic": f"detection/{detection.rule_id}",
|
|
117
|
+
"source": source if isinstance(source, dict) else {"tier": "detection", "app": "agentmetry"},
|
|
118
|
+
"initiator": source_event.get("initiator", {}),
|
|
119
|
+
"actor": source_event.get("actor", {}),
|
|
120
|
+
"action": {
|
|
121
|
+
"type": "detection",
|
|
122
|
+
"outcome": detection.severity,
|
|
123
|
+
"reason": detection.summary,
|
|
124
|
+
},
|
|
125
|
+
"agent": agent if isinstance(agent, dict) else {"name": "agentmetry"},
|
|
126
|
+
"detection": detection.as_dict(),
|
|
127
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""SQLite checkpoint for live detection — survives orchestrator restarts.
|
|
2
|
+
|
|
3
|
+
Persists per-session event windows (bounded) and emitted rule IDs so a restart
|
|
4
|
+
does not re-fire detections or lose correlation context for active sessions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_MAX_SESSIONS = 256
|
|
21
|
+
_MAX_EVENTS_PER_SESSION = 500
|
|
22
|
+
|
|
23
|
+
# How long a host-level detection stays suppressed after firing. Session marks
|
|
24
|
+
# are permanent (a session ends); host marks are not (a host does not), so
|
|
25
|
+
# without this a single firing silenced the rule on that machine forever.
|
|
26
|
+
HOST_EMIT_TTL_SECONDS = 6 * 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_expired(emitted_at: str, *, ttl_seconds: int = HOST_EMIT_TTL_SECONDS) -> bool:
|
|
30
|
+
"""True when a host checkpoint is old enough to re-arm.
|
|
31
|
+
|
|
32
|
+
An unparsable timestamp counts as expired: re-alerting is a smaller failure
|
|
33
|
+
than a rule that is silently off.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
ts = datetime.fromisoformat(emitted_at.replace("Z", "+00:00"))
|
|
37
|
+
except (ValueError, AttributeError):
|
|
38
|
+
return True
|
|
39
|
+
if ts.tzinfo is None:
|
|
40
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
41
|
+
return ts < datetime.now(timezone.utc) - timedelta(seconds=ttl_seconds)
|
|
42
|
+
|
|
43
|
+
_SCHEMA_SQL = """
|
|
44
|
+
CREATE TABLE IF NOT EXISTS live_session_meta (
|
|
45
|
+
correlation_id TEXT PRIMARY KEY,
|
|
46
|
+
last_touch REAL NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS live_events (
|
|
50
|
+
correlation_id TEXT NOT NULL,
|
|
51
|
+
seq INTEGER NOT NULL,
|
|
52
|
+
event_json TEXT NOT NULL,
|
|
53
|
+
PRIMARY KEY (correlation_id, seq)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_live_events_corr ON live_events(correlation_id);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS live_emitted (
|
|
59
|
+
correlation_id TEXT NOT NULL,
|
|
60
|
+
rule_id TEXT NOT NULL,
|
|
61
|
+
emitted_at TEXT NOT NULL DEFAULT '',
|
|
62
|
+
PRIMARY KEY (correlation_id, rule_id)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS live_host_meta (
|
|
66
|
+
host_id TEXT PRIMARY KEY,
|
|
67
|
+
last_touch REAL NOT NULL
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS live_host_events (
|
|
71
|
+
host_id TEXT NOT NULL,
|
|
72
|
+
seq INTEGER NOT NULL,
|
|
73
|
+
event_json TEXT NOT NULL,
|
|
74
|
+
PRIMARY KEY (host_id, seq)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_live_host_events_host ON live_host_events(host_id);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS live_host_emitted (
|
|
80
|
+
host_id TEXT NOT NULL,
|
|
81
|
+
rule_id TEXT NOT NULL,
|
|
82
|
+
emitted_at TEXT NOT NULL DEFAULT '',
|
|
83
|
+
PRIMARY KEY (host_id, rule_id)
|
|
84
|
+
);
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LiveDetectionStore:
|
|
89
|
+
"""Thread-safe SQLite store for live detection state."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, db_path: Path | str) -> None:
|
|
92
|
+
self._db_path = str(db_path)
|
|
93
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self._local = threading.local()
|
|
95
|
+
self._lock = threading.Lock()
|
|
96
|
+
self._init_schema()
|
|
97
|
+
|
|
98
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
99
|
+
conn = getattr(self._local, "conn", None)
|
|
100
|
+
if conn is None:
|
|
101
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
102
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
103
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
104
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
105
|
+
self._local.conn = conn
|
|
106
|
+
return conn
|
|
107
|
+
|
|
108
|
+
def _init_schema(self) -> None:
|
|
109
|
+
conn = self._get_conn()
|
|
110
|
+
conn.executescript(_SCHEMA_SQL)
|
|
111
|
+
conn.commit()
|
|
112
|
+
|
|
113
|
+
def clear_all(self) -> None:
|
|
114
|
+
"""Test helper — wipe all live detection state."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
conn = self._get_conn()
|
|
117
|
+
conn.execute("DELETE FROM live_events")
|
|
118
|
+
conn.execute("DELETE FROM live_emitted")
|
|
119
|
+
conn.execute("DELETE FROM live_session_meta")
|
|
120
|
+
conn.execute("DELETE FROM live_host_events")
|
|
121
|
+
conn.execute("DELETE FROM live_host_emitted")
|
|
122
|
+
conn.execute("DELETE FROM live_host_meta")
|
|
123
|
+
conn.commit()
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
conn = getattr(self._local, "conn", None)
|
|
127
|
+
if conn is not None:
|
|
128
|
+
conn.close()
|
|
129
|
+
self._local.conn = None
|
|
130
|
+
|
|
131
|
+
def append_event(self, correlation_id: str, event: dict[str, Any]) -> list[dict[str, Any]]:
|
|
132
|
+
"""Append an event and return the bounded session window."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
conn = self._get_conn()
|
|
135
|
+
# Wall clock, not monotonic: last_touch is persisted, and monotonic
|
|
136
|
+
# resets on reboot — pre-reboot sessions would outrank new ones and
|
|
137
|
+
# LRU eviction would evict the newest sessions first.
|
|
138
|
+
now = time.time()
|
|
139
|
+
conn.execute(
|
|
140
|
+
"""
|
|
141
|
+
INSERT INTO live_session_meta (correlation_id, last_touch)
|
|
142
|
+
VALUES (?, ?)
|
|
143
|
+
ON CONFLICT(correlation_id) DO UPDATE SET last_touch = excluded.last_touch
|
|
144
|
+
""",
|
|
145
|
+
(correlation_id, now),
|
|
146
|
+
)
|
|
147
|
+
row = conn.execute(
|
|
148
|
+
"SELECT COALESCE(MAX(seq), 0) FROM live_events WHERE correlation_id = ?",
|
|
149
|
+
(correlation_id,),
|
|
150
|
+
).fetchone()
|
|
151
|
+
next_seq = int(row[0]) + 1
|
|
152
|
+
conn.execute(
|
|
153
|
+
"INSERT INTO live_events (correlation_id, seq, event_json) VALUES (?, ?, ?)",
|
|
154
|
+
(correlation_id, next_seq, json.dumps(event, separators=(",", ":"), sort_keys=True)),
|
|
155
|
+
)
|
|
156
|
+
min_seq = max(1, next_seq - _MAX_EVENTS_PER_SESSION + 1)
|
|
157
|
+
conn.execute(
|
|
158
|
+
"DELETE FROM live_events WHERE correlation_id = ? AND seq < ?",
|
|
159
|
+
(correlation_id, min_seq),
|
|
160
|
+
)
|
|
161
|
+
self._evict_lru(conn)
|
|
162
|
+
conn.commit()
|
|
163
|
+
return self._load_events(conn, correlation_id)
|
|
164
|
+
|
|
165
|
+
def _load_events(self, conn: sqlite3.Connection, correlation_id: str) -> list[dict[str, Any]]:
|
|
166
|
+
rows = conn.execute(
|
|
167
|
+
"""
|
|
168
|
+
SELECT event_json FROM live_events
|
|
169
|
+
WHERE correlation_id = ?
|
|
170
|
+
ORDER BY seq ASC
|
|
171
|
+
""",
|
|
172
|
+
(correlation_id,),
|
|
173
|
+
).fetchall()
|
|
174
|
+
events: list[dict[str, Any]] = []
|
|
175
|
+
for (raw,) in rows:
|
|
176
|
+
try:
|
|
177
|
+
events.append(json.loads(raw))
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
continue
|
|
180
|
+
return events
|
|
181
|
+
|
|
182
|
+
def _evict_lru(self, conn: sqlite3.Connection) -> None:
|
|
183
|
+
count = conn.execute("SELECT COUNT(*) FROM live_session_meta").fetchone()[0]
|
|
184
|
+
if count <= _MAX_SESSIONS:
|
|
185
|
+
return
|
|
186
|
+
overflow = count - _MAX_SESSIONS
|
|
187
|
+
rows = conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
SELECT correlation_id FROM live_session_meta
|
|
190
|
+
ORDER BY last_touch ASC
|
|
191
|
+
LIMIT ?
|
|
192
|
+
""",
|
|
193
|
+
(overflow,),
|
|
194
|
+
).fetchall()
|
|
195
|
+
for (corr,) in rows:
|
|
196
|
+
conn.execute("DELETE FROM live_events WHERE correlation_id = ?", (corr,))
|
|
197
|
+
conn.execute("DELETE FROM live_session_meta WHERE correlation_id = ?", (corr,))
|
|
198
|
+
|
|
199
|
+
def is_emitted(self, correlation_id: str, rule_id: str) -> bool:
|
|
200
|
+
with self._lock:
|
|
201
|
+
conn = self._get_conn()
|
|
202
|
+
row = conn.execute(
|
|
203
|
+
"""
|
|
204
|
+
SELECT 1 FROM live_emitted
|
|
205
|
+
WHERE correlation_id = ? AND rule_id = ?
|
|
206
|
+
""",
|
|
207
|
+
(correlation_id, rule_id),
|
|
208
|
+
).fetchone()
|
|
209
|
+
return row is not None
|
|
210
|
+
|
|
211
|
+
def mark_emitted(self, correlation_id: str, rule_id: str, emitted_at: str = "") -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
conn = self._get_conn()
|
|
214
|
+
conn.execute(
|
|
215
|
+
"""
|
|
216
|
+
INSERT OR IGNORE INTO live_emitted (correlation_id, rule_id, emitted_at)
|
|
217
|
+
VALUES (?, ?, ?)
|
|
218
|
+
""",
|
|
219
|
+
(correlation_id, rule_id, emitted_at),
|
|
220
|
+
)
|
|
221
|
+
conn.commit()
|
|
222
|
+
|
|
223
|
+
def append_host_event(self, host_id: str, event: dict[str, Any]) -> list[dict[str, Any]]:
|
|
224
|
+
"""Append an event to the host-level window and return bounded events."""
|
|
225
|
+
if not host_id:
|
|
226
|
+
return []
|
|
227
|
+
with self._lock:
|
|
228
|
+
conn = self._get_conn()
|
|
229
|
+
now = time.time()
|
|
230
|
+
conn.execute(
|
|
231
|
+
"""
|
|
232
|
+
INSERT INTO live_host_meta (host_id, last_touch)
|
|
233
|
+
VALUES (?, ?)
|
|
234
|
+
ON CONFLICT(host_id) DO UPDATE SET last_touch = excluded.last_touch
|
|
235
|
+
""",
|
|
236
|
+
(host_id, now),
|
|
237
|
+
)
|
|
238
|
+
row = conn.execute(
|
|
239
|
+
"SELECT COALESCE(MAX(seq), 0) FROM live_host_events WHERE host_id = ?",
|
|
240
|
+
(host_id,),
|
|
241
|
+
).fetchone()
|
|
242
|
+
next_seq = int(row[0]) + 1
|
|
243
|
+
conn.execute(
|
|
244
|
+
"INSERT INTO live_host_events (host_id, seq, event_json) VALUES (?, ?, ?)",
|
|
245
|
+
(host_id, next_seq, json.dumps(event, separators=(",", ":"), sort_keys=True)),
|
|
246
|
+
)
|
|
247
|
+
min_seq = max(1, next_seq - _MAX_EVENTS_PER_SESSION + 1)
|
|
248
|
+
conn.execute(
|
|
249
|
+
"DELETE FROM live_host_events WHERE host_id = ? AND seq < ?",
|
|
250
|
+
(host_id, min_seq),
|
|
251
|
+
)
|
|
252
|
+
conn.commit()
|
|
253
|
+
return self._load_host_events(conn, host_id)
|
|
254
|
+
|
|
255
|
+
def _load_host_events(self, conn: sqlite3.Connection, host_id: str) -> list[dict[str, Any]]:
|
|
256
|
+
rows = conn.execute(
|
|
257
|
+
"""
|
|
258
|
+
SELECT event_json FROM live_host_events
|
|
259
|
+
WHERE host_id = ?
|
|
260
|
+
ORDER BY seq ASC
|
|
261
|
+
""",
|
|
262
|
+
(host_id,),
|
|
263
|
+
).fetchall()
|
|
264
|
+
events: list[dict[str, Any]] = []
|
|
265
|
+
for (raw,) in rows:
|
|
266
|
+
try:
|
|
267
|
+
events.append(json.loads(raw))
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
continue
|
|
270
|
+
return events
|
|
271
|
+
|
|
272
|
+
def is_host_emitted(self, host_id: str, rule_id: str) -> bool:
|
|
273
|
+
"""Has this host-level rule fired recently enough to stay suppressed?
|
|
274
|
+
|
|
275
|
+
Host checkpoints expire; session ones do not. A session ends, so a
|
|
276
|
+
permanent mark is correct there. A host does not: without an expiry the
|
|
277
|
+
first firing — quite possibly a false positive — silenced the rule on
|
|
278
|
+
that machine forever, and only deleting a SQLite row brought it back.
|
|
279
|
+
A recorder that goes permanently blind after one alert is worse than one
|
|
280
|
+
that occasionally repeats itself.
|
|
281
|
+
"""
|
|
282
|
+
with self._lock:
|
|
283
|
+
conn = self._get_conn()
|
|
284
|
+
row = conn.execute(
|
|
285
|
+
"""
|
|
286
|
+
SELECT emitted_at FROM live_host_emitted
|
|
287
|
+
WHERE host_id = ? AND rule_id = ?
|
|
288
|
+
""",
|
|
289
|
+
(host_id, rule_id),
|
|
290
|
+
).fetchone()
|
|
291
|
+
if row is None:
|
|
292
|
+
return False
|
|
293
|
+
emitted_at = str(row[0] or "")
|
|
294
|
+
if not emitted_at:
|
|
295
|
+
# Pre-expiry rows carry no usable timestamp. Treat them as stale
|
|
296
|
+
# so an upgrade re-arms the rule instead of inheriting silence.
|
|
297
|
+
conn.execute(
|
|
298
|
+
"DELETE FROM live_host_emitted WHERE host_id = ? AND rule_id = ?",
|
|
299
|
+
(host_id, rule_id),
|
|
300
|
+
)
|
|
301
|
+
conn.commit()
|
|
302
|
+
return False
|
|
303
|
+
if _is_expired(emitted_at):
|
|
304
|
+
conn.execute(
|
|
305
|
+
"DELETE FROM live_host_emitted WHERE host_id = ? AND rule_id = ?",
|
|
306
|
+
(host_id, rule_id),
|
|
307
|
+
)
|
|
308
|
+
conn.commit()
|
|
309
|
+
return False
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def mark_host_emitted(self, host_id: str, rule_id: str, emitted_at: str = "") -> None:
|
|
313
|
+
"""Checkpoint a host-level detection, refreshing the suppression clock.
|
|
314
|
+
|
|
315
|
+
Upsert rather than INSERT OR IGNORE: the timestamp drives expiry, so a
|
|
316
|
+
re-fire must move it forward or the rule would re-arm on the original
|
|
317
|
+
schedule regardless of recent activity. An absent timestamp falls back to
|
|
318
|
+
now, so a row always carries a usable clock.
|
|
319
|
+
"""
|
|
320
|
+
stamp = emitted_at or datetime.now(timezone.utc).isoformat()
|
|
321
|
+
with self._lock:
|
|
322
|
+
conn = self._get_conn()
|
|
323
|
+
conn.execute(
|
|
324
|
+
"""
|
|
325
|
+
INSERT INTO live_host_emitted (host_id, rule_id, emitted_at)
|
|
326
|
+
VALUES (?, ?, ?)
|
|
327
|
+
ON CONFLICT(host_id, rule_id) DO UPDATE SET emitted_at = excluded.emitted_at
|
|
328
|
+
""",
|
|
329
|
+
(host_id, rule_id, stamp),
|
|
330
|
+
)
|
|
331
|
+
conn.commit()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_store: LiveDetectionStore | None = None
|
|
335
|
+
_store_lock = threading.Lock()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_live_store() -> LiveDetectionStore:
|
|
339
|
+
global _store
|
|
340
|
+
if _store is None:
|
|
341
|
+
with _store_lock:
|
|
342
|
+
if _store is None:
|
|
343
|
+
from agentmetry.core.config import settings
|
|
344
|
+
|
|
345
|
+
_store = LiveDetectionStore(settings.detection_live_db_path)
|
|
346
|
+
return _store
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def reset_live_store_singleton() -> None:
|
|
350
|
+
"""Drop the singleton without deleting data — simulates process restart."""
|
|
351
|
+
global _store
|
|
352
|
+
with _store_lock:
|
|
353
|
+
if _store is not None:
|
|
354
|
+
_store.close()
|
|
355
|
+
_store = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Data model for correlated detections (sequence rules over a session)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Severity ordering for ranking detections most-severe first.
|
|
9
|
+
SEVERITY_RANK: dict[str, int] = {
|
|
10
|
+
"critical": 0,
|
|
11
|
+
"high": 1,
|
|
12
|
+
"medium": 2,
|
|
13
|
+
"low": 3,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Detection:
|
|
19
|
+
"""A named, ordered pattern of events — an incident, not a single event.
|
|
20
|
+
|
|
21
|
+
`event_ids` are the canonical events that formed the pattern, in order, so
|
|
22
|
+
the dashboard can link a detection back to the exact rows in the trail.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
rule_id: str
|
|
26
|
+
title: str
|
|
27
|
+
severity: str # critical | high | medium | low
|
|
28
|
+
summary: str
|
|
29
|
+
correlation_id: str
|
|
30
|
+
tactic_ids: list[str] = field(default_factory=list)
|
|
31
|
+
technique_ids: list[str] = field(default_factory=list)
|
|
32
|
+
event_ids: list[str] = field(default_factory=list)
|
|
33
|
+
first_seen_utc: str = ""
|
|
34
|
+
last_seen_utc: str = ""
|
|
35
|
+
# Triage state, attached on read from the disposition store rather than
|
|
36
|
+
# produced by the rule. A detection nobody dispositioned is an alert, not a
|
|
37
|
+
# control, so every detection carries the field even when it is untouched.
|
|
38
|
+
disposition: dict[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
def as_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"rule_id": self.rule_id,
|
|
43
|
+
"title": self.title,
|
|
44
|
+
"severity": self.severity,
|
|
45
|
+
"summary": self.summary,
|
|
46
|
+
"correlation_id": self.correlation_id,
|
|
47
|
+
"tactic_ids": self.tactic_ids,
|
|
48
|
+
"technique_ids": self.technique_ids,
|
|
49
|
+
"event_ids": self.event_ids,
|
|
50
|
+
"first_seen_utc": self.first_seen_utc,
|
|
51
|
+
"last_seen_utc": self.last_seen_utc,
|
|
52
|
+
"disposition": self.disposition,
|
|
53
|
+
}
|