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,651 @@
|
|
|
1
|
+
"""Detection triage — what the human decided, and when.
|
|
2
|
+
|
|
3
|
+
Until now Agentmetry recorded findings and stopped. That is half a control: a
|
|
4
|
+
detection nobody dispositioned is an alert, not evidence. ISO/IEC 42001 cl. 10
|
|
5
|
+
and EN 18286 cl. 8 both ask for the corrective action, not just the observation,
|
|
6
|
+
and the compliance digest was already asking for a triage note the product had
|
|
7
|
+
nowhere to store.
|
|
8
|
+
|
|
9
|
+
Two rules shape this module.
|
|
10
|
+
|
|
11
|
+
**The decision is an event.** Every disposition change is appended to the
|
|
12
|
+
canonical trail as an `action.type = "detection_disposition"` event, so it lands
|
|
13
|
+
on the same hash chain as the finding it answers and forwards to the same SIEM.
|
|
14
|
+
The table below is a materialized view of those events, not the system of
|
|
15
|
+
record — `rebuild_from_trail` regenerates it, which is the same recomputable
|
|
16
|
+
property the rest of the product has.
|
|
17
|
+
|
|
18
|
+
**History is append-only.** A disposition is never edited or deleted, only
|
|
19
|
+
superseded. "It was a false positive" changing to "actually it was real" is
|
|
20
|
+
exactly the transition an auditor cares about, so both entries survive.
|
|
21
|
+
|
|
22
|
+
A detection has no database id: rules are recomputed from events on demand. Its
|
|
23
|
+
stable identity is the pair the live checkpoint already uses, the scope it fired
|
|
24
|
+
in and the rule that fired, which is what `detection_key` builds.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import sqlite3
|
|
32
|
+
import threading
|
|
33
|
+
import uuid
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from agentmetry.core.audit.identity import identity_fields
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
DISPOSITION_EVENT_TYPE = "detection_disposition"
|
|
43
|
+
|
|
44
|
+
#: Triage states, in the order they appear in the dashboard.
|
|
45
|
+
STATUSES: tuple[str, ...] = (
|
|
46
|
+
"new",
|
|
47
|
+
"acknowledged",
|
|
48
|
+
"in_progress",
|
|
49
|
+
"resolved",
|
|
50
|
+
"false_positive",
|
|
51
|
+
"risk_accepted",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
DEFAULT_STATUS = "new"
|
|
55
|
+
|
|
56
|
+
#: States that close a finding without confirming it. An auditor will ask why,
|
|
57
|
+
#: so the product asks first: a bare "false positive" is not a disposition, it
|
|
58
|
+
#: is a dismissal wearing one.
|
|
59
|
+
_NOTE_REQUIRED = frozenset({"false_positive", "risk_accepted"})
|
|
60
|
+
|
|
61
|
+
#: States that mean no further action is expected.
|
|
62
|
+
CLOSED_STATUSES = frozenset({"resolved", "false_positive", "risk_accepted"})
|
|
63
|
+
|
|
64
|
+
_MAX_NOTE_CHARS = 4000
|
|
65
|
+
_MAX_ASSIGNEE_CHARS = 128
|
|
66
|
+
|
|
67
|
+
_SCHEMA_SQL = """
|
|
68
|
+
CREATE TABLE IF NOT EXISTS detection_dispositions (
|
|
69
|
+
detection_key TEXT PRIMARY KEY,
|
|
70
|
+
correlation_id TEXT NOT NULL DEFAULT '',
|
|
71
|
+
rule_id TEXT NOT NULL DEFAULT '',
|
|
72
|
+
status TEXT NOT NULL DEFAULT 'new',
|
|
73
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
74
|
+
note TEXT NOT NULL DEFAULT '',
|
|
75
|
+
decided_by TEXT NOT NULL DEFAULT '',
|
|
76
|
+
decided_at_utc TEXT NOT NULL DEFAULT '',
|
|
77
|
+
first_seen_utc TEXT NOT NULL DEFAULT '',
|
|
78
|
+
event_id TEXT NOT NULL DEFAULT '',
|
|
79
|
+
history_json TEXT NOT NULL DEFAULT '[]'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_disp_status ON detection_dispositions(status);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_disp_corr ON detection_dispositions(correlation_id);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_disp_rule ON detection_dispositions(rule_id);
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DispositionError(ValueError):
|
|
89
|
+
"""Rejected disposition — surfaced to the caller as a 400, not a 500."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detection_key(correlation_id: str, rule_id: str) -> str:
|
|
93
|
+
"""Stable identity for a detection across recomputations.
|
|
94
|
+
|
|
95
|
+
Rules are re-run over the trail rather than stored, so the key is the scope
|
|
96
|
+
plus the rule, matching the pair `mark_detection_emitted` checkpoints on.
|
|
97
|
+
Host-level rules carry their host in `correlation_id` already.
|
|
98
|
+
|
|
99
|
+
The rule id is canonicalised through `RULE_ALIASES` first. A rule id stops
|
|
100
|
+
being an implementation detail the moment someone dispositions a finding
|
|
101
|
+
under it: rename it in place and every "we checked, it was our CI bot" ever
|
|
102
|
+
recorded is orphaned, and the evidence pack reports those periods as
|
|
103
|
+
untriaged. Renames are declared, and the key follows the rule.
|
|
104
|
+
"""
|
|
105
|
+
from .rules import canonical_rule_id
|
|
106
|
+
|
|
107
|
+
corr = (correlation_id or "").strip()
|
|
108
|
+
rule = (rule_id or "").strip()
|
|
109
|
+
if not rule:
|
|
110
|
+
raise DispositionError("rule_id is required")
|
|
111
|
+
return f"{corr}::{canonical_rule_id(rule)}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _candidate_keys(correlation_id: str, rule_id: str) -> list[str]:
|
|
115
|
+
"""The canonical key first, then any this detection used to be stored under."""
|
|
116
|
+
from .rules import canonical_rule_id, historical_rule_ids
|
|
117
|
+
|
|
118
|
+
corr = (correlation_id or "").strip()
|
|
119
|
+
canonical = canonical_rule_id(rule_id)
|
|
120
|
+
keys = [f"{corr}::{canonical}"]
|
|
121
|
+
keys.extend(f"{corr}::{old}" for old in historical_rule_ids(canonical))
|
|
122
|
+
return keys
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _now() -> str:
|
|
126
|
+
return datetime.now(timezone.utc).isoformat()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_rule_id(rule_id: str) -> str:
|
|
130
|
+
"""Reject a decision about a rule that does not exist.
|
|
131
|
+
|
|
132
|
+
The cheapest place to stop an orphan is before it is written. A typo in a
|
|
133
|
+
rule id produces a disposition nothing will ever match, which reads later as
|
|
134
|
+
a finding somebody reviewed and a separate finding nobody did.
|
|
135
|
+
|
|
136
|
+
Deliberately not applied on replay: the trail is the record, and an event
|
|
137
|
+
naming a since-retired rule still happened. `orphaned()` surfaces those.
|
|
138
|
+
"""
|
|
139
|
+
from .rules import canonical_rule_id, known_rule_ids
|
|
140
|
+
|
|
141
|
+
rule = (rule_id or "").strip()
|
|
142
|
+
if not rule:
|
|
143
|
+
raise DispositionError("rule_id is required")
|
|
144
|
+
known = known_rule_ids()
|
|
145
|
+
if rule not in known and canonical_rule_id(rule) not in known:
|
|
146
|
+
raise DispositionError(
|
|
147
|
+
f"unknown rule_id {rule!r}. Dispositioning a rule that does not "
|
|
148
|
+
"exist would create a decision nothing can ever match."
|
|
149
|
+
)
|
|
150
|
+
return canonical_rule_id(rule)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate(status: str, note: str) -> tuple[str, str]:
|
|
154
|
+
normalized = (status or "").strip().lower()
|
|
155
|
+
if normalized not in STATUSES:
|
|
156
|
+
raise DispositionError(
|
|
157
|
+
f"unknown status {status!r}; expected one of {', '.join(STATUSES)}"
|
|
158
|
+
)
|
|
159
|
+
clean_note = (note or "").strip()
|
|
160
|
+
if len(clean_note) > _MAX_NOTE_CHARS:
|
|
161
|
+
raise DispositionError(f"note exceeds {_MAX_NOTE_CHARS} characters")
|
|
162
|
+
if normalized in _NOTE_REQUIRED and not clean_note:
|
|
163
|
+
raise DispositionError(
|
|
164
|
+
f"status {normalized!r} requires a note explaining the decision"
|
|
165
|
+
)
|
|
166
|
+
return normalized, clean_note
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DispositionStore:
|
|
170
|
+
"""Thread-safe SQLite store for current triage state."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, db_path: Path | str) -> None:
|
|
173
|
+
self._db_path = str(db_path)
|
|
174
|
+
self._local = threading.local()
|
|
175
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
self._init_schema()
|
|
177
|
+
|
|
178
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
179
|
+
conn = getattr(self._local, "conn", None)
|
|
180
|
+
if conn is None:
|
|
181
|
+
conn = sqlite3.connect(self._db_path, timeout=30.0)
|
|
182
|
+
conn.row_factory = sqlite3.Row
|
|
183
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
184
|
+
self._local.conn = conn
|
|
185
|
+
return conn
|
|
186
|
+
|
|
187
|
+
def _init_schema(self) -> None:
|
|
188
|
+
conn = self._get_conn()
|
|
189
|
+
conn.executescript(_SCHEMA_SQL)
|
|
190
|
+
conn.commit()
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Write
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def record(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
correlation_id: str,
|
|
200
|
+
rule_id: str,
|
|
201
|
+
status: str,
|
|
202
|
+
assignee: str = "",
|
|
203
|
+
note: str = "",
|
|
204
|
+
decided_by: str = "",
|
|
205
|
+
decided_at_utc: str = "",
|
|
206
|
+
event_id: str = "",
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
"""Upsert current state and append to this detection's history."""
|
|
209
|
+
from .rules import canonical_rule_id
|
|
210
|
+
|
|
211
|
+
normalized, clean_note = _validate(status, note)
|
|
212
|
+
key = detection_key(correlation_id, rule_id)
|
|
213
|
+
decided_at = decided_at_utc or _now()
|
|
214
|
+
clean_assignee = (assignee or "").strip()[:_MAX_ASSIGNEE_CHARS]
|
|
215
|
+
|
|
216
|
+
conn = self._get_conn()
|
|
217
|
+
# Look under historical keys too. A rule renamed since the last decision
|
|
218
|
+
# must continue that decision's history rather than starting a second
|
|
219
|
+
# row beside it, which would read as an untriaged finding plus an
|
|
220
|
+
# orphan. Writing under the canonical key migrates the row in passing.
|
|
221
|
+
row = self._find_row(correlation_id, rule_id)
|
|
222
|
+
history: list[dict[str, Any]] = []
|
|
223
|
+
first_seen = decided_at
|
|
224
|
+
if row is not None:
|
|
225
|
+
try:
|
|
226
|
+
history = json.loads(row["history_json"]) or []
|
|
227
|
+
except (json.JSONDecodeError, TypeError):
|
|
228
|
+
history = []
|
|
229
|
+
first_seen = row["first_seen_utc"] or decided_at
|
|
230
|
+
stale_key = str(row["detection_key"])
|
|
231
|
+
if stale_key != key:
|
|
232
|
+
conn.execute(
|
|
233
|
+
"DELETE FROM detection_dispositions WHERE detection_key = ?",
|
|
234
|
+
(stale_key,),
|
|
235
|
+
)
|
|
236
|
+
logger.info("Migrated disposition %s -> %s after rule rename", stale_key, key)
|
|
237
|
+
|
|
238
|
+
history.append({
|
|
239
|
+
"status": normalized,
|
|
240
|
+
"assignee": clean_assignee,
|
|
241
|
+
"note": clean_note,
|
|
242
|
+
"decided_by": decided_by.strip(),
|
|
243
|
+
"decided_at_utc": decided_at,
|
|
244
|
+
"event_id": event_id,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
conn.execute(
|
|
248
|
+
"""INSERT INTO detection_dispositions
|
|
249
|
+
(detection_key, correlation_id, rule_id, status, assignee, note,
|
|
250
|
+
decided_by, decided_at_utc, first_seen_utc, event_id, history_json)
|
|
251
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
252
|
+
ON CONFLICT(detection_key) DO UPDATE SET
|
|
253
|
+
status = excluded.status,
|
|
254
|
+
assignee = excluded.assignee,
|
|
255
|
+
note = excluded.note,
|
|
256
|
+
decided_by = excluded.decided_by,
|
|
257
|
+
decided_at_utc = excluded.decided_at_utc,
|
|
258
|
+
event_id = excluded.event_id,
|
|
259
|
+
history_json = excluded.history_json""",
|
|
260
|
+
(
|
|
261
|
+
key,
|
|
262
|
+
(correlation_id or "").strip(),
|
|
263
|
+
# Store the canonical id so `orphaned()` and the dashboard agree
|
|
264
|
+
# with the key. The name used at decision time survives in the
|
|
265
|
+
# trail event, which is the record.
|
|
266
|
+
canonical_rule_id(rule_id),
|
|
267
|
+
normalized,
|
|
268
|
+
clean_assignee,
|
|
269
|
+
clean_note,
|
|
270
|
+
decided_by.strip(),
|
|
271
|
+
decided_at,
|
|
272
|
+
first_seen,
|
|
273
|
+
event_id,
|
|
274
|
+
json.dumps(history),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
conn.commit()
|
|
278
|
+
return self.get(correlation_id, rule_id) or {}
|
|
279
|
+
|
|
280
|
+
def clear(self) -> None:
|
|
281
|
+
"""Test helper — drop all triage state."""
|
|
282
|
+
conn = self._get_conn()
|
|
283
|
+
conn.execute("DELETE FROM detection_dispositions")
|
|
284
|
+
conn.commit()
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
# Read
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _find_row(self, correlation_id: str, rule_id: str) -> sqlite3.Row | None:
|
|
291
|
+
"""Look under the current key, then under any pre-rename key."""
|
|
292
|
+
conn = self._get_conn()
|
|
293
|
+
for key in _candidate_keys(correlation_id, rule_id):
|
|
294
|
+
row = conn.execute(
|
|
295
|
+
"SELECT * FROM detection_dispositions WHERE detection_key = ?",
|
|
296
|
+
(key,),
|
|
297
|
+
).fetchone()
|
|
298
|
+
if row is not None:
|
|
299
|
+
return row
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
def get(self, correlation_id: str, rule_id: str) -> dict[str, Any] | None:
|
|
303
|
+
row = self._find_row(correlation_id, rule_id)
|
|
304
|
+
return _row_to_dict(row) if row is not None else None
|
|
305
|
+
|
|
306
|
+
def for_correlation(self, correlation_id: str) -> dict[str, dict[str, Any]]:
|
|
307
|
+
"""Current state for one session, keyed by the rule id in force today.
|
|
308
|
+
|
|
309
|
+
Rows written before a rename are returned under the new name, so a
|
|
310
|
+
caller holding today's detections finds yesterday's decisions.
|
|
311
|
+
"""
|
|
312
|
+
from .rules import canonical_rule_id
|
|
313
|
+
|
|
314
|
+
rows = self._get_conn().execute(
|
|
315
|
+
"SELECT * FROM detection_dispositions WHERE correlation_id = ?",
|
|
316
|
+
((correlation_id or "").strip(),),
|
|
317
|
+
).fetchall()
|
|
318
|
+
return {
|
|
319
|
+
canonical_rule_id(str(row["rule_id"])): _row_to_dict(row) for row in rows
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
def orphaned(self) -> list[dict[str, Any]]:
|
|
323
|
+
"""Dispositions whose rule no longer exists.
|
|
324
|
+
|
|
325
|
+
Deleting a rule does not delete the decisions made about it, and it must
|
|
326
|
+
not: they are evidence of what a human concluded, and quietly dropping
|
|
327
|
+
them would make a reviewed period look unreviewed. They are surfaced
|
|
328
|
+
instead, so an auditor sees "decided, rule since retired" rather than a
|
|
329
|
+
silent gap.
|
|
330
|
+
"""
|
|
331
|
+
from .rules import known_rule_ids
|
|
332
|
+
|
|
333
|
+
known = known_rule_ids()
|
|
334
|
+
return [row for row in self.all() if row["rule_id"] not in known]
|
|
335
|
+
|
|
336
|
+
def all(self) -> list[dict[str, Any]]:
|
|
337
|
+
rows = self._get_conn().execute(
|
|
338
|
+
"SELECT * FROM detection_dispositions ORDER BY decided_at_utc DESC"
|
|
339
|
+
).fetchall()
|
|
340
|
+
return [_row_to_dict(row) for row in rows]
|
|
341
|
+
|
|
342
|
+
def counts(self) -> dict[str, int]:
|
|
343
|
+
rows = self._get_conn().execute(
|
|
344
|
+
"SELECT status, COUNT(*) AS n FROM detection_dispositions GROUP BY status"
|
|
345
|
+
).fetchall()
|
|
346
|
+
return {str(row["status"]): int(row["n"]) for row in rows}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
350
|
+
try:
|
|
351
|
+
history = json.loads(row["history_json"]) or []
|
|
352
|
+
except (json.JSONDecodeError, TypeError):
|
|
353
|
+
history = []
|
|
354
|
+
return {
|
|
355
|
+
"detection_key": row["detection_key"],
|
|
356
|
+
"correlation_id": row["correlation_id"],
|
|
357
|
+
"rule_id": row["rule_id"],
|
|
358
|
+
"status": row["status"],
|
|
359
|
+
"assignee": row["assignee"],
|
|
360
|
+
"note": row["note"],
|
|
361
|
+
"decided_by": row["decided_by"],
|
|
362
|
+
"decided_at_utc": row["decided_at_utc"],
|
|
363
|
+
"first_seen_utc": row["first_seen_utc"],
|
|
364
|
+
"event_id": row["event_id"],
|
|
365
|
+
"history": history,
|
|
366
|
+
"closed": row["status"] in CLOSED_STATUSES,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Canonical event
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def build_disposition_event(
|
|
375
|
+
*,
|
|
376
|
+
correlation_id: str,
|
|
377
|
+
rule_id: str,
|
|
378
|
+
status: str,
|
|
379
|
+
assignee: str = "",
|
|
380
|
+
note: str = "",
|
|
381
|
+
decided_by: str = "",
|
|
382
|
+
previous_status: str = DEFAULT_STATUS,
|
|
383
|
+
severity: str = "",
|
|
384
|
+
) -> dict[str, Any]:
|
|
385
|
+
"""Wrap a triage decision as a canonical event.
|
|
386
|
+
|
|
387
|
+
`action.outcome` carries the new status so a SIEM can alert on
|
|
388
|
+
`action.type:detection_disposition AND action.outcome:risk_accepted`
|
|
389
|
+
without understanding Agentmetry's vocabulary. The note is operator-written
|
|
390
|
+
text, never captured command content, so it is safe to forward.
|
|
391
|
+
"""
|
|
392
|
+
from agentmetry.core.config import settings
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
"schema_version": "1.1.0",
|
|
396
|
+
"event_id": str(uuid.uuid4()),
|
|
397
|
+
"correlation_id": correlation_id,
|
|
398
|
+
"session_id": "",
|
|
399
|
+
"timestamp_utc": _now(),
|
|
400
|
+
**identity_fields(),
|
|
401
|
+
"source_topic": f"disposition/{rule_id}",
|
|
402
|
+
"source": {"tier": "detection", "app": "agentmetry"},
|
|
403
|
+
"actor": {"id": decided_by or settings.operator_id, "type": "human"},
|
|
404
|
+
"initiator": {"type": "human", "id": decided_by or settings.operator_id},
|
|
405
|
+
"action": {
|
|
406
|
+
"type": DISPOSITION_EVENT_TYPE,
|
|
407
|
+
"outcome": status,
|
|
408
|
+
"reason": note or f"disposition set to {status}",
|
|
409
|
+
},
|
|
410
|
+
"agent": {"name": "agentmetry"},
|
|
411
|
+
"disposition": {
|
|
412
|
+
"detection_key": detection_key(correlation_id, rule_id),
|
|
413
|
+
"rule_id": rule_id,
|
|
414
|
+
"severity": severity,
|
|
415
|
+
"status": status,
|
|
416
|
+
"previous_status": previous_status,
|
|
417
|
+
"assignee": assignee,
|
|
418
|
+
"note": note,
|
|
419
|
+
"decided_by": decided_by or settings.operator_id,
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def extract_dispositions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
425
|
+
"""Pull disposition decisions out of a canonical event list, oldest first."""
|
|
426
|
+
found: list[dict[str, Any]] = []
|
|
427
|
+
for event in events:
|
|
428
|
+
action = event.get("action") or {}
|
|
429
|
+
if action.get("type") != DISPOSITION_EVENT_TYPE:
|
|
430
|
+
continue
|
|
431
|
+
disposition = event.get("disposition")
|
|
432
|
+
if not isinstance(disposition, dict):
|
|
433
|
+
continue
|
|
434
|
+
found.append({
|
|
435
|
+
"ts": event.get("timestamp_utc"),
|
|
436
|
+
"correlation_id": event.get("correlation_id"),
|
|
437
|
+
# The trail event's own id, so a replayed row still points back at
|
|
438
|
+
# the line that recorded the decision.
|
|
439
|
+
"event_id": event.get("event_id"),
|
|
440
|
+
**{
|
|
441
|
+
k: disposition.get(k)
|
|
442
|
+
for k in (
|
|
443
|
+
"detection_key",
|
|
444
|
+
"rule_id",
|
|
445
|
+
"severity",
|
|
446
|
+
"status",
|
|
447
|
+
"previous_status",
|
|
448
|
+
"assignee",
|
|
449
|
+
"note",
|
|
450
|
+
"decided_by",
|
|
451
|
+
)
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
return found
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# Module singleton
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
_store: DispositionStore | None = None
|
|
462
|
+
_store_lock = threading.Lock()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def get_disposition_store() -> DispositionStore:
|
|
466
|
+
global _store
|
|
467
|
+
if _store is None:
|
|
468
|
+
with _store_lock:
|
|
469
|
+
if _store is None:
|
|
470
|
+
from agentmetry.core.config import settings
|
|
471
|
+
|
|
472
|
+
_store = DispositionStore(settings.detection_disposition_db_path)
|
|
473
|
+
return _store
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def reset_disposition_store() -> None:
|
|
477
|
+
"""Test helper — drop the singleton so a new path takes effect."""
|
|
478
|
+
global _store
|
|
479
|
+
_store = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def apply_disposition(
|
|
483
|
+
*,
|
|
484
|
+
correlation_id: str,
|
|
485
|
+
rule_id: str,
|
|
486
|
+
status: str,
|
|
487
|
+
assignee: str = "",
|
|
488
|
+
note: str = "",
|
|
489
|
+
decided_by: str = "",
|
|
490
|
+
severity: str = "",
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Record a triage decision: trail first, then index, then forward.
|
|
493
|
+
|
|
494
|
+
Order matters. The trail insert is the durability guarantee, exactly as it
|
|
495
|
+
is for detections themselves — if it fails, nothing is written and the
|
|
496
|
+
caller gets an error rather than a UI that says "saved" over a decision
|
|
497
|
+
that was never recorded. Sink forwarding is best-effort: a down SIEM must
|
|
498
|
+
not lose the operator's decision.
|
|
499
|
+
"""
|
|
500
|
+
from agentmetry.core.audit.ingest import _get_sink
|
|
501
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
502
|
+
|
|
503
|
+
normalized, clean_note = _validate(status, note)
|
|
504
|
+
rule_id = validate_rule_id(rule_id)
|
|
505
|
+
store = get_disposition_store()
|
|
506
|
+
existing = store.get(correlation_id, rule_id)
|
|
507
|
+
previous = existing["status"] if existing else DEFAULT_STATUS
|
|
508
|
+
|
|
509
|
+
event = build_disposition_event(
|
|
510
|
+
correlation_id=correlation_id,
|
|
511
|
+
rule_id=rule_id,
|
|
512
|
+
status=normalized,
|
|
513
|
+
assignee=assignee,
|
|
514
|
+
note=clean_note,
|
|
515
|
+
decided_by=decided_by,
|
|
516
|
+
previous_status=previous,
|
|
517
|
+
severity=severity,
|
|
518
|
+
)
|
|
519
|
+
get_trail_db().insert(event)
|
|
520
|
+
|
|
521
|
+
current = store.record(
|
|
522
|
+
correlation_id=correlation_id,
|
|
523
|
+
rule_id=rule_id,
|
|
524
|
+
status=normalized,
|
|
525
|
+
assignee=assignee,
|
|
526
|
+
note=clean_note,
|
|
527
|
+
decided_by=decided_by,
|
|
528
|
+
decided_at_utc=event["timestamp_utc"],
|
|
529
|
+
event_id=event["event_id"],
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
sink = _get_sink()
|
|
533
|
+
if sink is not None:
|
|
534
|
+
try:
|
|
535
|
+
await sink.emit(event)
|
|
536
|
+
except Exception:
|
|
537
|
+
logger.exception("Failed to forward disposition for %s", rule_id)
|
|
538
|
+
|
|
539
|
+
logger.info(
|
|
540
|
+
"DISPOSITION %s %s -> %s by %s",
|
|
541
|
+
rule_id,
|
|
542
|
+
previous,
|
|
543
|
+
normalized,
|
|
544
|
+
decided_by or "operator",
|
|
545
|
+
)
|
|
546
|
+
return current
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class DispositionRebuildRefused(RuntimeError):
|
|
550
|
+
"""The trail cannot account for decisions the index already holds.
|
|
551
|
+
|
|
552
|
+
Raised instead of destroying them. Carries the orphaned keys so the
|
|
553
|
+
operator is told exactly what would have been lost.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
def __init__(self, missing: set[str]) -> None:
|
|
557
|
+
self.missing = missing
|
|
558
|
+
super().__init__(
|
|
559
|
+
f"{len(missing)} disposition(s) in the index have no matching event "
|
|
560
|
+
"in the trail; refusing to rebuild. Restore the trail, or pass "
|
|
561
|
+
"force=True to accept the loss."
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _replay_decisions(decisions: list[dict[str, Any]], store: DispositionStore) -> int:
|
|
566
|
+
replayed = 0
|
|
567
|
+
for decision in decisions:
|
|
568
|
+
try:
|
|
569
|
+
store.record(
|
|
570
|
+
correlation_id=str(decision.get("correlation_id") or ""),
|
|
571
|
+
rule_id=str(decision.get("rule_id") or ""),
|
|
572
|
+
status=str(decision.get("status") or DEFAULT_STATUS),
|
|
573
|
+
assignee=str(decision.get("assignee") or ""),
|
|
574
|
+
note=str(decision.get("note") or ""),
|
|
575
|
+
decided_by=str(decision.get("decided_by") or ""),
|
|
576
|
+
decided_at_utc=str(decision.get("ts") or ""),
|
|
577
|
+
event_id=str(decision.get("event_id") or ""),
|
|
578
|
+
)
|
|
579
|
+
replayed += 1
|
|
580
|
+
except DispositionError as exc:
|
|
581
|
+
logger.warning("skipping unreplayable disposition: %s", exc)
|
|
582
|
+
return replayed
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def rebuild_from_trail(
|
|
586
|
+
trail_db: Any | None = None, *, force: bool = False
|
|
587
|
+
) -> int:
|
|
588
|
+
"""Replay disposition events from the trail into the index.
|
|
589
|
+
|
|
590
|
+
The trail is the record and this table is an index over it, so replaying is
|
|
591
|
+
normally safe. It is not unconditionally safe, and that distinction is the
|
|
592
|
+
whole point of this function's shape.
|
|
593
|
+
|
|
594
|
+
Rebuilding starts by emptying the index. If the trail has been pruned,
|
|
595
|
+
rotated, restored from a partial backup, or simply repointed at a different
|
|
596
|
+
path, replay produces fewer decisions than the index held and the missing
|
|
597
|
+
ones are gone. Losing triage history is worse than losing detections: the
|
|
598
|
+
findings survive, so the period reads as *untriaged* rather than *unknown*,
|
|
599
|
+
which is exactly backwards for ISO/IEC 42001 cl. 10 evidence.
|
|
600
|
+
|
|
601
|
+
So a rebuild that cannot account for every key already in the index raises
|
|
602
|
+
`DispositionRebuildRefused` rather than proceeding. `force=True` accepts the
|
|
603
|
+
loss and is for an operator who knows why.
|
|
604
|
+
"""
|
|
605
|
+
if trail_db is None:
|
|
606
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
607
|
+
|
|
608
|
+
trail_db = get_trail_db()
|
|
609
|
+
|
|
610
|
+
decisions = extract_dispositions(
|
|
611
|
+
trail_db.events_by_action_type(DISPOSITION_EVENT_TYPE)
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
store = get_disposition_store()
|
|
615
|
+
in_trail = {
|
|
616
|
+
detection_key(
|
|
617
|
+
str(d.get("correlation_id") or ""), str(d.get("rule_id") or "")
|
|
618
|
+
)
|
|
619
|
+
for d in decisions
|
|
620
|
+
if d.get("rule_id")
|
|
621
|
+
}
|
|
622
|
+
in_index = {str(row["detection_key"]) for row in store.all()}
|
|
623
|
+
|
|
624
|
+
missing = in_index - in_trail
|
|
625
|
+
if missing and not force:
|
|
626
|
+
raise DispositionRebuildRefused(missing)
|
|
627
|
+
|
|
628
|
+
store.clear()
|
|
629
|
+
return _replay_decisions(decisions, store)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def reconcile_at_boot(trail_db: Any | None = None) -> int:
|
|
633
|
+
"""Bring the index up to date at startup without ever destroying it.
|
|
634
|
+
|
|
635
|
+
Returns the number of decisions replayed, or -1 when reconciliation was
|
|
636
|
+
declined. Declining is a warning, never a failure: an orchestrator that
|
|
637
|
+
refuses to start because its triage index disagrees with its trail is worse
|
|
638
|
+
than one that starts and says so.
|
|
639
|
+
"""
|
|
640
|
+
try:
|
|
641
|
+
replayed = rebuild_from_trail(trail_db)
|
|
642
|
+
except DispositionRebuildRefused as exc:
|
|
643
|
+
logger.warning(
|
|
644
|
+
"Triage index NOT rebuilt: %s Sample: %s",
|
|
645
|
+
exc,
|
|
646
|
+
", ".join(sorted(exc.missing)[:5]),
|
|
647
|
+
)
|
|
648
|
+
return -1
|
|
649
|
+
if replayed:
|
|
650
|
+
logger.info("Replayed %d disposition(s) from the trail", replayed)
|
|
651
|
+
return replayed
|