veltro-cli 0.15.7__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.
- veltro_cli/__init__.py +6 -0
- veltro_cli/__main__.py +7 -0
- veltro_cli/cli.py +478 -0
- veltro_cli/lib/__init__.py +3 -0
- veltro_cli/lib/agent.py +37 -0
- veltro_cli/lib/backup.py +139 -0
- veltro_cli/lib/compose.py +276 -0
- veltro_cli/lib/datasets.py +611 -0
- veltro_cli/lib/demo.py +421 -0
- veltro_cli/lib/demo_replay.py +794 -0
- veltro_cli/lib/doctor.py +99 -0
- veltro_cli/lib/env_init.py +93 -0
- veltro_cli/lib/errors.py +40 -0
- veltro_cli/lib/host.py +346 -0
- veltro_cli/lib/install.py +400 -0
- veltro_cli/lib/k8s.py +57 -0
- veltro_cli/lib/paths.py +42 -0
- veltro_cli/lib/products.py +54 -0
- veltro_cli/lib/release.py +88 -0
- veltro_cli/lib/status.py +170 -0
- veltro_cli/lib/suite_http.py +227 -0
- veltro_cli/lib/support.py +35 -0
- veltro_cli/lib/tls.py +113 -0
- veltro_cli/lib/toml_config.py +130 -0
- veltro_cli/lib/upgrade.py +379 -0
- veltro_cli/py.typed +0 -0
- veltro_cli-0.15.7.dist-info/METADATA +40 -0
- veltro_cli-0.15.7.dist-info/RECORD +31 -0
- veltro_cli-0.15.7.dist-info/WHEEL +5 -0
- veltro_cli-0.15.7.dist-info/entry_points.txt +2 -0
- veltro_cli-0.15.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""Replay demo datasets through VectorFlow, CHAD, and Warden.
|
|
2
|
+
|
|
3
|
+
`veltro demo seed --dataset <id>` drives this module. For a `corpus`
|
|
4
|
+
dataset it:
|
|
5
|
+
|
|
6
|
+
1. resolves the VectorFlow-owned CHAD data source used by the deployed demo
|
|
7
|
+
pipeline;
|
|
8
|
+
2. maps the Sigma fields the dataset's rule keys on;
|
|
9
|
+
3. creates or reconciles the rule, records the positive-match sample evidence
|
|
10
|
+
CHAD's deploy floor requires, and deploys it through the normal approval
|
|
11
|
+
path;
|
|
12
|
+
4. rebases the corpus onto wall-clock time and atomically drops JSONL into the
|
|
13
|
+
VectorFlow agent's file source;
|
|
14
|
+
5. follows this run's `event.id` prefix through CHAD to the Warden case.
|
|
15
|
+
|
|
16
|
+
`veltro demo reset` reverses what is reversible and names what is not. It
|
|
17
|
+
removes replay files, undeploys corpus rules, deletes their alerts, and resolves
|
|
18
|
+
then closes their Warden cases. Rule versions and audit rows remain as evidence.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
31
|
+
|
|
32
|
+
from veltro_cli.lib.datasets import (
|
|
33
|
+
Dataset,
|
|
34
|
+
DEMO_LABEL_FIELD,
|
|
35
|
+
field_value,
|
|
36
|
+
rebase_events,
|
|
37
|
+
replay_event_prefix,
|
|
38
|
+
)
|
|
39
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
40
|
+
from veltro_cli.lib.suite_http import Response, SuiteSession, trpc_data
|
|
41
|
+
|
|
42
|
+
INGEST_SUFFIX_DEFAULT = "vectorflow"
|
|
43
|
+
DEMO_MODE_PROCEDURE_GET = "demoMode.get"
|
|
44
|
+
DEMO_MODE_PROCEDURE_SET = "demoMode.set"
|
|
45
|
+
DEMO_MODE_PROCEDURE_DISABLE = "demoMode.disable"
|
|
46
|
+
RESET_REASON = "veltro demo reset"
|
|
47
|
+
SEED_REASON = "veltro demo seed"
|
|
48
|
+
SAFE_REPLAY_NAME = re.compile(r"^[a-z0-9-]+$")
|
|
49
|
+
# Every event `scripts/seed-demo.sh` writes carries an `event.id` under this
|
|
50
|
+
# prefix (`golden-<batch>-<n>`, `golden-ioc-…`, `golden-novel-…`). It is the
|
|
51
|
+
# golden path's equivalent of the corpus datasets' `labels.veltro_demo`.
|
|
52
|
+
GOLDEN_EVENT_PREFIX = "golden-"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Clock:
|
|
57
|
+
"""Injected time, so seeding is testable without sleeping."""
|
|
58
|
+
|
|
59
|
+
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
|
|
60
|
+
sleep: Callable[[float], None] = time.sleep
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class IngestTarget:
|
|
65
|
+
data_source_id: str
|
|
66
|
+
suffix: str
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def index_pattern(self) -> str:
|
|
70
|
+
return f"chad-alerts-{self.suffix}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SeedResult:
|
|
75
|
+
dataset_id: str
|
|
76
|
+
engine: str
|
|
77
|
+
run_token: str = ""
|
|
78
|
+
rule_id: str = ""
|
|
79
|
+
events_replayed: int = 0
|
|
80
|
+
alert_ids: list[str] = field(default_factory=list)
|
|
81
|
+
case_keys: list[str] = field(default_factory=list)
|
|
82
|
+
event_prefix: str = ""
|
|
83
|
+
notes: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"dataset": self.dataset_id,
|
|
88
|
+
"engine": self.engine,
|
|
89
|
+
"run_token": self.run_token,
|
|
90
|
+
"rule_id": self.rule_id,
|
|
91
|
+
"events_replayed": self.events_replayed,
|
|
92
|
+
"alert_ids": list(self.alert_ids),
|
|
93
|
+
"case_keys": list(self.case_keys),
|
|
94
|
+
"event_prefix": self.event_prefix,
|
|
95
|
+
"notes": list(self.notes),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ResetResult:
|
|
101
|
+
dataset_id: str
|
|
102
|
+
replay_files_deleted: int = 0
|
|
103
|
+
rules_undeployed: list[str] = field(default_factory=list)
|
|
104
|
+
alerts_deleted: int = 0
|
|
105
|
+
cases_closed: list[str] = field(default_factory=list)
|
|
106
|
+
retained: list[str] = field(default_factory=list)
|
|
107
|
+
notes: list[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
def as_dict(self) -> dict[str, Any]:
|
|
110
|
+
return {
|
|
111
|
+
"dataset": self.dataset_id,
|
|
112
|
+
"replay_files_deleted": self.replay_files_deleted,
|
|
113
|
+
"rules_undeployed": list(self.rules_undeployed),
|
|
114
|
+
"alerts_deleted": self.alerts_deleted,
|
|
115
|
+
"cases_closed": list(self.cases_closed),
|
|
116
|
+
"retained": list(self.retained),
|
|
117
|
+
"notes": list(self.notes),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rows(payload: Any, *keys: str) -> list[dict[str, Any]]:
|
|
122
|
+
"""CHAD and Warden list endpoints answer either a list or a keyed object."""
|
|
123
|
+
if isinstance(payload, list):
|
|
124
|
+
return [row for row in payload if isinstance(row, dict)]
|
|
125
|
+
if isinstance(payload, Mapping):
|
|
126
|
+
for key in keys:
|
|
127
|
+
value = payload.get(key)
|
|
128
|
+
if isinstance(value, list):
|
|
129
|
+
return [row for row in value if isinstance(row, dict)]
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _fail(response: Response, what: str, remedy: str) -> None:
|
|
134
|
+
raise VeltroCliError(
|
|
135
|
+
f"{what} (HTTP {response.status}): {response.detail()}",
|
|
136
|
+
remedy=remedy,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_ingest(session: SuiteSession, *, suffix: str = INGEST_SUFFIX_DEFAULT) -> IngestTarget:
|
|
141
|
+
"""The VectorFlow-owned CHAD push data source the demo replays through."""
|
|
142
|
+
response = session.get("/chad/api/data-sources")
|
|
143
|
+
if not response.ok:
|
|
144
|
+
_fail(
|
|
145
|
+
response,
|
|
146
|
+
"could not list CHAD data sources",
|
|
147
|
+
"log in as a suite admin and check CHAD is ready (`veltro status`)",
|
|
148
|
+
)
|
|
149
|
+
for row in _rows(response.json(), "data_sources", "patterns"):
|
|
150
|
+
pattern = str(row.get("pattern") or "")
|
|
151
|
+
if row.get("suffix") == suffix or pattern in (suffix, f"{suffix}-*") or row.get("name") == "VectorFlow (suite)":
|
|
152
|
+
return IngestTarget(data_source_id=str(row.get("id")), suffix=suffix)
|
|
153
|
+
raise VeltroCliError(
|
|
154
|
+
f"no CHAD push data source for the {suffix!r} pattern",
|
|
155
|
+
remedy=(
|
|
156
|
+
"seed the golden path first (`veltro demo seed --dataset brute-force`): it provisions the "
|
|
157
|
+
"VectorFlow pipeline and the CHAD push data source the corpus datasets replay through"
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def map_fields(session: SuiteSession, dataset: Dataset, target: IngestTarget) -> list[str]:
|
|
163
|
+
"""Map every Sigma field the dataset's rule keys on. 409 means already mapped."""
|
|
164
|
+
assert dataset.detection is not None
|
|
165
|
+
mapped: list[str] = []
|
|
166
|
+
for sigma_field, target_field in dataset.detection.field_mappings:
|
|
167
|
+
response = session.post(
|
|
168
|
+
"/chad/api/field-mappings",
|
|
169
|
+
json_body={
|
|
170
|
+
"sigma_field": sigma_field,
|
|
171
|
+
"target_field": target_field,
|
|
172
|
+
"data_source_id": target.data_source_id,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
if response.ok or response.status == 409:
|
|
176
|
+
mapped.append(sigma_field)
|
|
177
|
+
continue
|
|
178
|
+
_fail(
|
|
179
|
+
response,
|
|
180
|
+
f"could not map {sigma_field} -> {target_field}",
|
|
181
|
+
"grant the seeding user manage_data_sources in CHAD, or map the field by hand",
|
|
182
|
+
)
|
|
183
|
+
return mapped
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _rule_body(dataset: Dataset, target: IngestTarget, *, change_reason: str | None) -> dict[str, Any]:
|
|
187
|
+
detection = dataset.detection
|
|
188
|
+
assert detection is not None
|
|
189
|
+
body: dict[str, Any] = {
|
|
190
|
+
"title": detection.rule_title,
|
|
191
|
+
"description": detection.description,
|
|
192
|
+
"yaml_content": detection.yaml_content(),
|
|
193
|
+
"severity": detection.severity,
|
|
194
|
+
"data_source_id": target.data_source_id,
|
|
195
|
+
}
|
|
196
|
+
if detection.threshold is not None:
|
|
197
|
+
body.update(
|
|
198
|
+
{
|
|
199
|
+
"threshold_enabled": True,
|
|
200
|
+
"threshold_count": detection.threshold.count,
|
|
201
|
+
"threshold_window_minutes": detection.threshold.window_minutes,
|
|
202
|
+
"threshold_group_by": detection.threshold.group_by,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
if change_reason:
|
|
206
|
+
body["change_reason"] = change_reason
|
|
207
|
+
return body
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def find_rule(session: SuiteSession, title: str) -> dict[str, Any] | None:
|
|
211
|
+
response = session.get("/chad/api/rules")
|
|
212
|
+
if not response.ok:
|
|
213
|
+
_fail(response, "could not list CHAD rules", "check the seeding session has view_rules in CHAD")
|
|
214
|
+
for row in _rows(response.json(), "rules"):
|
|
215
|
+
if row.get("title") == title:
|
|
216
|
+
return row
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ensure_rule(session: SuiteSession, dataset: Dataset, target: IngestTarget) -> str:
|
|
221
|
+
"""Create or reconcile the dataset's rule; return its id."""
|
|
222
|
+
detection = dataset.detection
|
|
223
|
+
assert detection is not None
|
|
224
|
+
existing = find_rule(session, detection.rule_title)
|
|
225
|
+
if existing is None:
|
|
226
|
+
response = session.post("/chad/api/rules", json_body=_rule_body(dataset, target, change_reason=None))
|
|
227
|
+
if not response.ok:
|
|
228
|
+
_fail(response, f"could not create rule {detection.rule_title!r}", "check the rule YAML and field mappings")
|
|
229
|
+
rule_id = str((response.json({}) or {}).get("id") or "")
|
|
230
|
+
if not rule_id:
|
|
231
|
+
_fail(response, "CHAD accepted the rule but returned no id", "inspect the response body")
|
|
232
|
+
return rule_id
|
|
233
|
+
rule_id = str(existing.get("id"))
|
|
234
|
+
response = session.request(
|
|
235
|
+
"PATCH",
|
|
236
|
+
f"/chad/api/rules/{rule_id}",
|
|
237
|
+
json_body=_rule_body(dataset, target, change_reason=f"{SEED_REASON}: {dataset.id} reconciliation"),
|
|
238
|
+
)
|
|
239
|
+
if not response.ok:
|
|
240
|
+
_fail(
|
|
241
|
+
response,
|
|
242
|
+
f"rule {detection.rule_title!r} exists but could not be reconciled",
|
|
243
|
+
"resolve the rule by hand in CHAD, or delete it if it was never deployed",
|
|
244
|
+
)
|
|
245
|
+
return rule_id
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def record_sample_evidence(session: SuiteSession, dataset: Dataset, target: IngestTarget, rule_id: str) -> str:
|
|
249
|
+
"""Run the positive-match sample test CHAD's deploy floor requires."""
|
|
250
|
+
detection = dataset.detection
|
|
251
|
+
assert detection is not None
|
|
252
|
+
sample = dataset.events()[detection.sample_event_index]
|
|
253
|
+
response = session.post(
|
|
254
|
+
"/chad/api/rules/test",
|
|
255
|
+
json_body={
|
|
256
|
+
"rule_id": rule_id,
|
|
257
|
+
"data_source_id": target.data_source_id,
|
|
258
|
+
"yaml_content": detection.yaml_content(),
|
|
259
|
+
"sample_logs": [sample],
|
|
260
|
+
"expected_matches": [0],
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
if not response.ok:
|
|
264
|
+
_fail(
|
|
265
|
+
response,
|
|
266
|
+
f"sample test for {detection.rule_title!r} was refused",
|
|
267
|
+
"check the rule's fields are mapped for this data source",
|
|
268
|
+
)
|
|
269
|
+
body = response.json({}) or {}
|
|
270
|
+
matched = next(
|
|
271
|
+
(bool(match.get("matched")) for match in body.get("matches") or [] if match.get("log_index") == 0),
|
|
272
|
+
False,
|
|
273
|
+
)
|
|
274
|
+
if not matched:
|
|
275
|
+
raise VeltroCliError(
|
|
276
|
+
f"{dataset.id}: the rule did not match its own sample event "
|
|
277
|
+
f"(corpus index {detection.sample_event_index})",
|
|
278
|
+
remedy="fix the rule or the manifest's detection.sample.event_index; a rule that cannot fire on its own corpus must not deploy",
|
|
279
|
+
)
|
|
280
|
+
evidence_id = str(body.get("evidence_id") or "")
|
|
281
|
+
if not evidence_id:
|
|
282
|
+
raise VeltroCliError(
|
|
283
|
+
f"{dataset.id}: CHAD recorded no deploy-floor evidence for the sample test",
|
|
284
|
+
remedy="both rule_id and a non-empty expected_matches are required to persist evidence",
|
|
285
|
+
)
|
|
286
|
+
return evidence_id
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def deploy_rule(session: SuiteSession, dataset: Dataset, rule_id: str, *, clock: Clock, timeout: int = 60) -> None:
|
|
290
|
+
"""Deploy the rule, approving its own deployment request when gated."""
|
|
291
|
+
response = session.post(
|
|
292
|
+
f"/chad/api/rules/{rule_id}/deploy",
|
|
293
|
+
json_body={"change_reason": f"{SEED_REASON}: {dataset.id}"},
|
|
294
|
+
)
|
|
295
|
+
if response.status == 202:
|
|
296
|
+
request_id = str((response.json({}) or {}).get("deployment_request_id") or "")
|
|
297
|
+
if not request_id:
|
|
298
|
+
_fail(response, "CHAD gated the deploy but named no deployment request", "inspect the response body")
|
|
299
|
+
approval = session.post(
|
|
300
|
+
f"/chad/api/deployment-requests/{request_id}/approve",
|
|
301
|
+
json_body={"reason": f"{SEED_REASON}: suite-admin approval for the {dataset.id} demo dataset"},
|
|
302
|
+
)
|
|
303
|
+
if not approval.ok:
|
|
304
|
+
_fail(
|
|
305
|
+
approval,
|
|
306
|
+
f"deployment request {request_id} could not be approved",
|
|
307
|
+
"approve it in CHAD as a second user with approve_deployments, then re-run",
|
|
308
|
+
)
|
|
309
|
+
elif not response.ok:
|
|
310
|
+
_fail(response, f"deploy of {dataset.id} rule was refused", "read the named validation floor and fix the rule")
|
|
311
|
+
|
|
312
|
+
deadline = clock.now().timestamp() + timeout
|
|
313
|
+
while clock.now().timestamp() < deadline:
|
|
314
|
+
state = session.get(f"/chad/api/rules/{rule_id}")
|
|
315
|
+
body = state.json({}) or {}
|
|
316
|
+
if str(body.get("status") or "").lower() == "deployed" or body.get("deployed_at"):
|
|
317
|
+
return
|
|
318
|
+
clock.sleep(3)
|
|
319
|
+
raise VeltroCliError(
|
|
320
|
+
f"{dataset.id}: rule {rule_id} never reached deployed status within {timeout}s",
|
|
321
|
+
remedy="check CHAD's deployment requests and worker logs",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def replay_corpus(
|
|
326
|
+
dataset: Dataset,
|
|
327
|
+
*,
|
|
328
|
+
replay_dir: Path,
|
|
329
|
+
run_token: str,
|
|
330
|
+
clock: Clock,
|
|
331
|
+
) -> int:
|
|
332
|
+
"""Queue rebased JSONL for the VectorFlow agent's file source."""
|
|
333
|
+
if not SAFE_REPLAY_NAME.fullmatch(dataset.id) or not SAFE_REPLAY_NAME.fullmatch(run_token):
|
|
334
|
+
raise VeltroCliError(
|
|
335
|
+
"dataset id and run token must contain only lowercase letters, digits, and hyphens",
|
|
336
|
+
remedy="use a catalog dataset and let `veltro demo seed` generate the run token",
|
|
337
|
+
)
|
|
338
|
+
events = rebase_events(dataset, now=clock.now(), run_token=run_token)
|
|
339
|
+
replay_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
340
|
+
replay_dir.chmod(0o755)
|
|
341
|
+
final_path = replay_dir / f"{dataset.id}-{run_token}.jsonl"
|
|
342
|
+
temporary_path = replay_dir / f".{dataset.id}-{run_token}.tmp"
|
|
343
|
+
with temporary_path.open("w", encoding="utf-8") as handle:
|
|
344
|
+
for event in events:
|
|
345
|
+
handle.write(json.dumps(event, separators=(",", ":"), sort_keys=True))
|
|
346
|
+
handle.write("\n")
|
|
347
|
+
handle.flush()
|
|
348
|
+
os.fsync(handle.fileno())
|
|
349
|
+
temporary_path.chmod(0o644)
|
|
350
|
+
os.replace(temporary_path, final_path)
|
|
351
|
+
return len(events)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def collect_alerts(
|
|
355
|
+
session: SuiteSession,
|
|
356
|
+
dataset: Dataset,
|
|
357
|
+
target: IngestTarget,
|
|
358
|
+
rule_id: str,
|
|
359
|
+
*,
|
|
360
|
+
run_token: str,
|
|
361
|
+
) -> tuple[list[str], list[str]]:
|
|
362
|
+
"""Alert ids and Warden case keys for THIS run's events only."""
|
|
363
|
+
prefix = replay_event_prefix(dataset, run_token)
|
|
364
|
+
response = session.get(
|
|
365
|
+
f"/chad/api/alerts?index_pattern={target.index_pattern}&cluster=false&limit=100&rule_id={rule_id}"
|
|
366
|
+
)
|
|
367
|
+
if not response.ok:
|
|
368
|
+
return [], []
|
|
369
|
+
alert_ids: list[str] = []
|
|
370
|
+
case_keys: list[str] = []
|
|
371
|
+
for row in _rows(response.json(), "alerts"):
|
|
372
|
+
document = row.get("log_document") or {}
|
|
373
|
+
# This run only: Warden correlates repeated alerts into one open case,
|
|
374
|
+
# so an earlier run's alert would otherwise be accepted as proof.
|
|
375
|
+
event_id = str(field_value(document, "event.id") or "")
|
|
376
|
+
if not event_id.startswith(prefix):
|
|
377
|
+
continue
|
|
378
|
+
identifier = str(row.get("id") or row.get("alert_id") or "")
|
|
379
|
+
if identifier and identifier not in alert_ids:
|
|
380
|
+
alert_ids.append(identifier)
|
|
381
|
+
reference = row.get("warden_case_ref") or row.get("warden_case") or {}
|
|
382
|
+
if isinstance(reference, Mapping):
|
|
383
|
+
key = str(reference.get("case_key") or "")
|
|
384
|
+
if key and key not in case_keys:
|
|
385
|
+
case_keys.append(key)
|
|
386
|
+
return alert_ids, case_keys
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def case_exists(session: SuiteSession, case_key: str) -> bool:
|
|
390
|
+
response = session.get("/warden/api/cases?limit=100")
|
|
391
|
+
if not response.ok:
|
|
392
|
+
return False
|
|
393
|
+
return any(row.get("key") == case_key for row in _rows(response.json(), "cases"))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def seed_dataset(
|
|
397
|
+
session: SuiteSession,
|
|
398
|
+
dataset: Dataset,
|
|
399
|
+
*,
|
|
400
|
+
run_token: str,
|
|
401
|
+
replay_dir: Path,
|
|
402
|
+
clock: Clock | None = None,
|
|
403
|
+
ingest: IngestTarget | None = None,
|
|
404
|
+
settle_seconds: int = 90,
|
|
405
|
+
) -> SeedResult:
|
|
406
|
+
"""Provision, replay and follow one corpus dataset end to end."""
|
|
407
|
+
clock = clock or Clock()
|
|
408
|
+
if not dataset.is_corpus:
|
|
409
|
+
raise VeltroCliError(
|
|
410
|
+
f"{dataset.id} is provisioned by the golden-path seeder, not the corpus engine",
|
|
411
|
+
remedy="run `veltro demo seed --dataset brute-force`, which shells out to scripts/seed-demo.sh",
|
|
412
|
+
)
|
|
413
|
+
target = ingest or resolve_ingest(session)
|
|
414
|
+
result = SeedResult(dataset_id=dataset.id, engine=dataset.engine, run_token=run_token)
|
|
415
|
+
result.event_prefix = replay_event_prefix(dataset, run_token)
|
|
416
|
+
map_fields(session, dataset, target)
|
|
417
|
+
result.rule_id = ensure_rule(session, dataset, target)
|
|
418
|
+
record_sample_evidence(session, dataset, target, result.rule_id)
|
|
419
|
+
deploy_rule(session, dataset, result.rule_id, clock=clock)
|
|
420
|
+
result.events_replayed = replay_corpus(dataset, replay_dir=replay_dir, run_token=run_token, clock=clock)
|
|
421
|
+
|
|
422
|
+
deadline = clock.now().timestamp() + settle_seconds
|
|
423
|
+
while True:
|
|
424
|
+
alert_ids, case_keys = collect_alerts(
|
|
425
|
+
session, dataset, target, result.rule_id, run_token=run_token
|
|
426
|
+
)
|
|
427
|
+
result.alert_ids = alert_ids
|
|
428
|
+
result.case_keys = case_keys
|
|
429
|
+
enough_alerts = len(alert_ids) >= dataset.min_alerts
|
|
430
|
+
case_ready = (not dataset.expect_warden_case) or (
|
|
431
|
+
bool(case_keys) and case_exists(session, case_keys[0])
|
|
432
|
+
)
|
|
433
|
+
if enough_alerts and case_ready:
|
|
434
|
+
return result
|
|
435
|
+
if clock.now().timestamp() >= deadline:
|
|
436
|
+
break
|
|
437
|
+
clock.sleep(5)
|
|
438
|
+
|
|
439
|
+
if len(result.alert_ids) < dataset.min_alerts:
|
|
440
|
+
raise VeltroCliError(
|
|
441
|
+
f"{dataset.id}: replayed {result.events_replayed} events but CHAD raised "
|
|
442
|
+
f"{len(result.alert_ids)} alert(s), expected at least {dataset.min_alerts}",
|
|
443
|
+
remedy="check the rule is deployed and its fields are mapped, then re-run the seed",
|
|
444
|
+
)
|
|
445
|
+
raise VeltroCliError(
|
|
446
|
+
f"{dataset.id}: alert {result.alert_ids[0]} did not open a Warden case within {settle_seconds}s",
|
|
447
|
+
remedy="check CHAD -> Warden delivery (Settings > Warden) and the Warden automation bindings",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# --- reset ------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _demo_alerts(session: SuiteSession, dataset: Dataset, target: IngestTarget, rule_id: str) -> list[str]:
|
|
455
|
+
response = session.get(
|
|
456
|
+
f"/chad/api/alerts?index_pattern={target.index_pattern}&cluster=false&limit=500&rule_id={rule_id}"
|
|
457
|
+
)
|
|
458
|
+
if not response.ok:
|
|
459
|
+
return []
|
|
460
|
+
identifiers: list[str] = []
|
|
461
|
+
for row in _rows(response.json(), "alerts"):
|
|
462
|
+
document = row.get("log_document") or {}
|
|
463
|
+
if str(field_value(document, f"labels.{DEMO_LABEL_FIELD}") or "") != dataset.id:
|
|
464
|
+
continue
|
|
465
|
+
identifier = str(row.get("id") or row.get("alert_id") or "")
|
|
466
|
+
if identifier:
|
|
467
|
+
identifiers.append(identifier)
|
|
468
|
+
return identifiers
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _close_case(session: SuiteSession, case: Mapping[str, Any], dataset_id: str) -> bool:
|
|
472
|
+
"""Resolve then close a demo case. Warden has no case delete, by design."""
|
|
473
|
+
case_id = str(case.get("id") or "")
|
|
474
|
+
if not case_id:
|
|
475
|
+
return False
|
|
476
|
+
note = f"{RESET_REASON}: synthetic data from the {dataset_id} demo dataset"
|
|
477
|
+
status = str(case.get("status") or "")
|
|
478
|
+
if status not in {"resolved", "closed"}:
|
|
479
|
+
resolved = session.post(
|
|
480
|
+
f"/warden/api/cases/{case_id}/status",
|
|
481
|
+
json_body={"status": "resolved", "note": note},
|
|
482
|
+
)
|
|
483
|
+
if not resolved.ok:
|
|
484
|
+
return False
|
|
485
|
+
if status != "closed":
|
|
486
|
+
closed = session.post(
|
|
487
|
+
f"/warden/api/cases/{case_id}/status",
|
|
488
|
+
json_body={"status": "closed", "note": note, "disposition": "true_positive"},
|
|
489
|
+
)
|
|
490
|
+
return closed.ok
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _data_source_suffix(session: SuiteSession, data_source_id: str) -> str:
|
|
495
|
+
"""The alert-index suffix CHAD writes for one data source, or "" if unknown.
|
|
496
|
+
|
|
497
|
+
`resolve_ingest` reads the data-source table suffix-first; this reads the
|
|
498
|
+
same table id-first. A row names its suffix directly or through its index
|
|
499
|
+
pattern (`<suffix>-*`), which is the shape `scripts/seed-demo.sh` creates
|
|
500
|
+
when it takes its documented `ssh-bruteforce-demo` fallback because the
|
|
501
|
+
suite-seeded push pattern is not there yet.
|
|
502
|
+
"""
|
|
503
|
+
if not data_source_id:
|
|
504
|
+
return ""
|
|
505
|
+
response = session.get("/chad/api/data-sources")
|
|
506
|
+
if not response.ok:
|
|
507
|
+
return ""
|
|
508
|
+
for row in _rows(response.json(), "data_sources", "patterns"):
|
|
509
|
+
if str(row.get("id") or "") != data_source_id:
|
|
510
|
+
continue
|
|
511
|
+
suffix = str(row.get("suffix") or "")
|
|
512
|
+
if suffix:
|
|
513
|
+
return suffix
|
|
514
|
+
pattern = str(row.get("pattern") or "")
|
|
515
|
+
return pattern[:-2] if pattern.endswith("-*") else pattern
|
|
516
|
+
return ""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _golden_path_alerts(session: SuiteSession, rule_id: str, suffix: str) -> tuple[list[str], str]:
|
|
520
|
+
"""The seeder's own alerts on one rule, and a note if CHAD could not answer.
|
|
521
|
+
|
|
522
|
+
`_demo_alerts` recognises a corpus dataset by the `labels.veltro_demo`
|
|
523
|
+
marker its replay stamps on every event. The golden-path corpus is
|
|
524
|
+
generated by `scripts/seed-demo.sh`, which stamps no label — but every
|
|
525
|
+
event it writes carries a `golden-…` `event.id` (`golden-<batch>-<n>`,
|
|
526
|
+
`golden-ioc-…`, `golden-novel-…`). That prefix is checked on the returned
|
|
527
|
+
row rather than trusted to the `rule_id` query parameter, so an alert an
|
|
528
|
+
operator's own telemetry raised can never be deleted here even if CHAD
|
|
529
|
+
ignored the filter.
|
|
530
|
+
"""
|
|
531
|
+
pattern = IngestTarget(data_source_id="", suffix=suffix).index_pattern
|
|
532
|
+
response = session.get(
|
|
533
|
+
f"/chad/api/alerts?index_pattern={pattern}&cluster=false&limit=500&rule_id={rule_id}"
|
|
534
|
+
)
|
|
535
|
+
if not response.ok:
|
|
536
|
+
return [], (
|
|
537
|
+
f"could not list alerts for rule {rule_id} (HTTP {response.status}); "
|
|
538
|
+
"only the alerts the handoff named were deleted"
|
|
539
|
+
)
|
|
540
|
+
identifiers: list[str] = []
|
|
541
|
+
for row in _rows(response.json(), "alerts"):
|
|
542
|
+
document = row.get("log_document") or {}
|
|
543
|
+
if not str(field_value(document, "event.id") or "").startswith(GOLDEN_EVENT_PREFIX):
|
|
544
|
+
continue
|
|
545
|
+
identifier = str(row.get("id") or row.get("alert_id") or "")
|
|
546
|
+
if identifier:
|
|
547
|
+
identifiers.append(identifier)
|
|
548
|
+
return identifiers, ""
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def reset_golden_path(
|
|
552
|
+
session: SuiteSession,
|
|
553
|
+
*,
|
|
554
|
+
replay_dir: Path,
|
|
555
|
+
rule_title: str,
|
|
556
|
+
rule_id: str,
|
|
557
|
+
alert_ids: Sequence[str],
|
|
558
|
+
case_keys: Sequence[str],
|
|
559
|
+
ingest_suffix: str = INGEST_SUFFIX_DEFAULT,
|
|
560
|
+
) -> ResetResult:
|
|
561
|
+
"""Reset the golden-path records, by handoff id or by rule title.
|
|
562
|
+
|
|
563
|
+
`scripts/seed-demo.sh` writes its private handoff (`.seed-demo.env`) at the
|
|
564
|
+
very end, so a run that died anywhere before that names no rule, no alert
|
|
565
|
+
and no case — and the golden-path dataset has no `detection` block to fall
|
|
566
|
+
back on. That left a deployed synthetic rule, its alert and its open Warden
|
|
567
|
+
case behind while `reset` turned the banner off over them (veltro#984). So
|
|
568
|
+
the rule is resolved by its manifest title when the handoff names none, and
|
|
569
|
+
its alerts and cases are then reached through the rule itself — including
|
|
570
|
+
the CHAD index its alerts landed in, which the rule's own data source
|
|
571
|
+
names. The seeder falls back to a demo-owned `ssh-bruteforce-demo` pattern
|
|
572
|
+
when the suite-seeded push pattern is not there yet, so assuming
|
|
573
|
+
`ingest_suffix` in this path would look for the alert in an index the run
|
|
574
|
+
never wrote to.
|
|
575
|
+
"""
|
|
576
|
+
result = ResetResult(dataset_id="brute-force")
|
|
577
|
+
if replay_dir.is_dir():
|
|
578
|
+
for path in replay_dir.glob("brute-force-*.jsonl"):
|
|
579
|
+
path.unlink()
|
|
580
|
+
result.replay_files_deleted += 1
|
|
581
|
+
|
|
582
|
+
resolved_id = rule_id
|
|
583
|
+
resolved_suffix = ingest_suffix
|
|
584
|
+
if not resolved_id:
|
|
585
|
+
existing = find_rule(session, rule_title)
|
|
586
|
+
resolved_id = str(existing.get("id") or "") if existing else ""
|
|
587
|
+
if resolved_id:
|
|
588
|
+
result.notes.append(
|
|
589
|
+
f"the golden-path handoff named no rule; found {rule_title!r} by title "
|
|
590
|
+
f"({resolved_id}) and undeployed it"
|
|
591
|
+
)
|
|
592
|
+
from_rule = _data_source_suffix(
|
|
593
|
+
session, str((existing or {}).get("data_source_id") or "")
|
|
594
|
+
)
|
|
595
|
+
if from_rule and from_rule != resolved_suffix:
|
|
596
|
+
result.notes.append(
|
|
597
|
+
f"the rule ingests through the {from_rule!r} pattern, not the "
|
|
598
|
+
f"{resolved_suffix!r} default; its alerts were looked for there"
|
|
599
|
+
)
|
|
600
|
+
resolved_suffix = from_rule
|
|
601
|
+
else:
|
|
602
|
+
result.notes.append(
|
|
603
|
+
f"no CHAD rule titled {rule_title!r} exists; the golden-path seeder "
|
|
604
|
+
"created none, so none was undeployed"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if resolved_id:
|
|
608
|
+
undeploy = session.post(
|
|
609
|
+
f"/chad/api/rules/{resolved_id}/undeploy",
|
|
610
|
+
json_body={"change_reason": RESET_REASON},
|
|
611
|
+
)
|
|
612
|
+
if undeploy.ok or undeploy.status in (404, 409):
|
|
613
|
+
result.rules_undeployed.append(resolved_id)
|
|
614
|
+
else:
|
|
615
|
+
_fail(
|
|
616
|
+
undeploy,
|
|
617
|
+
"could not undeploy the brute-force rule",
|
|
618
|
+
"undeploy it in CHAD, then re-run the reset",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
found_ids: list[str] = []
|
|
622
|
+
if resolved_id:
|
|
623
|
+
found_ids, listing_note = _golden_path_alerts(session, resolved_id, resolved_suffix)
|
|
624
|
+
if listing_note:
|
|
625
|
+
result.notes.append(listing_note)
|
|
626
|
+
unique_alert_ids = list(
|
|
627
|
+
dict.fromkeys(identifier for identifier in (*alert_ids, *found_ids) if identifier)
|
|
628
|
+
)
|
|
629
|
+
if unique_alert_ids:
|
|
630
|
+
deletion = session.post(
|
|
631
|
+
"/chad/api/alerts/bulk/delete",
|
|
632
|
+
json_body={"alert_ids": unique_alert_ids, "change_reason": RESET_REASON},
|
|
633
|
+
)
|
|
634
|
+
if deletion.ok:
|
|
635
|
+
body = deletion.json({}) or {}
|
|
636
|
+
deleted = body.get("success")
|
|
637
|
+
result.alerts_deleted = (
|
|
638
|
+
len(deleted) if isinstance(deleted, list) else len(unique_alert_ids)
|
|
639
|
+
)
|
|
640
|
+
else:
|
|
641
|
+
result.notes.append(
|
|
642
|
+
f"CHAD refused to delete {len(unique_alert_ids)} golden-path alert(s) "
|
|
643
|
+
f"(HTTP {deletion.status}): {deletion.detail(120)}"
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
result.notes.append("no golden-path alert was recorded or found; none was deleted")
|
|
647
|
+
|
|
648
|
+
# Warden correlates a case on the rule title (scripts/seed-demo.sh), so a
|
|
649
|
+
# case carrying the golden-path title came from this synthetic rule even
|
|
650
|
+
# when the run died before recording its key.
|
|
651
|
+
wanted = set(case_keys)
|
|
652
|
+
cases = session.get("/warden/api/cases?limit=200")
|
|
653
|
+
if cases.ok:
|
|
654
|
+
for row in _rows(cases.json(), "cases"):
|
|
655
|
+
titled = rule_title in str(row.get("title") or "")
|
|
656
|
+
if (row.get("key") in wanted or titled) and _close_case(session, row, "brute-force"):
|
|
657
|
+
result.cases_closed.append(str(row.get("key") or row.get("id")))
|
|
658
|
+
else:
|
|
659
|
+
result.notes.append(f"could not list Warden cases (HTTP {cases.status}); no case was closed")
|
|
660
|
+
|
|
661
|
+
if resolved_id:
|
|
662
|
+
result.retained.append(f"CHAD rule {resolved_id} and its immutable version history")
|
|
663
|
+
result.retained.append(
|
|
664
|
+
"CHAD field mappings event.action, SourceIp, and User used by the golden-path "
|
|
665
|
+
"data source (the shell seeder may create missing mappings, but does not record "
|
|
666
|
+
"which shared mappings predated the run)"
|
|
667
|
+
)
|
|
668
|
+
result.retained.append("Warden cases are closed with a disposition, not deleted")
|
|
669
|
+
result.retained.append("audit rows for every reset action")
|
|
670
|
+
return result
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def reset_dataset(
|
|
674
|
+
session: SuiteSession,
|
|
675
|
+
dataset: Dataset,
|
|
676
|
+
*,
|
|
677
|
+
replay_dir: Path,
|
|
678
|
+
ingest: IngestTarget | None = None,
|
|
679
|
+
case_keys: Sequence[str] = (),
|
|
680
|
+
) -> ResetResult:
|
|
681
|
+
"""Stop the dataset's detection, remove replay files, delete alerts, and close cases."""
|
|
682
|
+
result = ResetResult(dataset_id=dataset.id)
|
|
683
|
+
if replay_dir.is_dir():
|
|
684
|
+
for path in replay_dir.glob(f"{dataset.id}-*.jsonl"):
|
|
685
|
+
path.unlink()
|
|
686
|
+
result.replay_files_deleted += 1
|
|
687
|
+
if not dataset.is_corpus:
|
|
688
|
+
raise VeltroCliError(
|
|
689
|
+
"golden-path cleanup requires its recorded rule, alert, and case identifiers",
|
|
690
|
+
remedy="use `veltro demo reset`, which reads the private golden-path handoff",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
assert dataset.detection is not None
|
|
694
|
+
rule = find_rule(session, dataset.detection.rule_title)
|
|
695
|
+
if rule is None:
|
|
696
|
+
result.notes.append("rule not present; nothing to undeploy")
|
|
697
|
+
for sigma_field, target_field in dataset.detection.field_mappings:
|
|
698
|
+
result.retained.append(
|
|
699
|
+
f"CHAD field mapping {sigma_field} -> {target_field} "
|
|
700
|
+
"(shared configuration; the rule is absent, so its data source is unknown)"
|
|
701
|
+
)
|
|
702
|
+
return result
|
|
703
|
+
rule_id = str(rule.get("id"))
|
|
704
|
+
undeploy = session.post(
|
|
705
|
+
f"/chad/api/rules/{rule_id}/undeploy",
|
|
706
|
+
json_body={"change_reason": RESET_REASON},
|
|
707
|
+
)
|
|
708
|
+
if undeploy.ok or undeploy.status in (404, 409):
|
|
709
|
+
result.rules_undeployed.append(rule_id)
|
|
710
|
+
if undeploy.status == 409:
|
|
711
|
+
result.notes.append(f"rule {rule_id} was already undeployed")
|
|
712
|
+
else:
|
|
713
|
+
_fail(undeploy, f"could not undeploy the {dataset.id} rule", "undeploy it in CHAD, then re-run the reset")
|
|
714
|
+
|
|
715
|
+
target = ingest or resolve_ingest(session)
|
|
716
|
+
alert_ids = _demo_alerts(session, dataset, target, rule_id)
|
|
717
|
+
if alert_ids:
|
|
718
|
+
deletion = session.post("/chad/api/alerts/bulk/delete", json_body={"alert_ids": alert_ids})
|
|
719
|
+
if deletion.ok:
|
|
720
|
+
body = deletion.json({}) or {}
|
|
721
|
+
deleted = body.get("success")
|
|
722
|
+
result.alerts_deleted = len(deleted) if isinstance(deleted, list) else len(alert_ids)
|
|
723
|
+
else:
|
|
724
|
+
result.notes.append(
|
|
725
|
+
f"CHAD refused to delete {len(alert_ids)} demo alert(s) (HTTP {deletion.status}): {deletion.detail(120)}"
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
keys = list(case_keys)
|
|
729
|
+
cases = session.get("/warden/api/cases?limit=200")
|
|
730
|
+
if cases.ok:
|
|
731
|
+
for row in _rows(cases.json(), "cases"):
|
|
732
|
+
if keys and row.get("key") not in keys:
|
|
733
|
+
continue
|
|
734
|
+
if not keys and dataset.detection.rule_title not in str(row.get("title") or ""):
|
|
735
|
+
continue
|
|
736
|
+
if _close_case(session, row, dataset.id):
|
|
737
|
+
result.cases_closed.append(str(row.get("key") or row.get("id")))
|
|
738
|
+
else:
|
|
739
|
+
result.notes.append(f"could not list Warden cases (HTTP {cases.status}); no case was closed")
|
|
740
|
+
|
|
741
|
+
result.retained.append(
|
|
742
|
+
f"CHAD rule {rule_id} and its version history (CHAD refuses to delete an ever-deployed rule: "
|
|
743
|
+
"its versions are deploy evidence)"
|
|
744
|
+
)
|
|
745
|
+
result.retained.append("Warden cases are closed with a disposition, not deleted (Warden has no case delete)")
|
|
746
|
+
for sigma_field, target_field in dataset.detection.field_mappings:
|
|
747
|
+
result.retained.append(
|
|
748
|
+
f"CHAD field mapping {sigma_field} -> {target_field} on data source "
|
|
749
|
+
f"{target.data_source_id} (shared configuration; may predate this seed)"
|
|
750
|
+
)
|
|
751
|
+
result.retained.append("audit rows for every action above, in both products")
|
|
752
|
+
return result
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# --- identity demo mode -----------------------------------------------------
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def read_demo_mode(session: SuiteSession) -> dict[str, Any] | None:
|
|
759
|
+
response = session.trpc_query(DEMO_MODE_PROCEDURE_GET)
|
|
760
|
+
if not response.ok:
|
|
761
|
+
return None
|
|
762
|
+
data = trpc_data(response)
|
|
763
|
+
return data if isinstance(data, dict) else None
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def set_demo_mode(
|
|
767
|
+
session: SuiteSession,
|
|
768
|
+
dataset_ids: Iterable[str],
|
|
769
|
+
*,
|
|
770
|
+
seeded_at: datetime,
|
|
771
|
+
) -> bool:
|
|
772
|
+
"""Turn the suite-wide demo banner on, naming the seeded datasets."""
|
|
773
|
+
payload = {
|
|
774
|
+
"enabled": True,
|
|
775
|
+
"datasets": sorted({str(identifier) for identifier in dataset_ids}),
|
|
776
|
+
"seededAt": seeded_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
777
|
+
}
|
|
778
|
+
response = session.trpc_mutation(DEMO_MODE_PROCEDURE_SET, payload)
|
|
779
|
+
if not response.ok:
|
|
780
|
+
raise VeltroCliError(
|
|
781
|
+
f"could not record demo mode in identity (HTTP {response.status}): {response.detail()}",
|
|
782
|
+
remedy="the seeding user must be a suite admin; the banner is driven by this setting",
|
|
783
|
+
)
|
|
784
|
+
return True
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def clear_demo_mode(session: SuiteSession) -> bool:
|
|
788
|
+
response = session.trpc_mutation(DEMO_MODE_PROCEDURE_DISABLE, {})
|
|
789
|
+
if not response.ok:
|
|
790
|
+
raise VeltroCliError(
|
|
791
|
+
f"could not clear demo mode in identity (HTTP {response.status}): {response.detail()}",
|
|
792
|
+
remedy="clear it from the identity console Demo mode panel, or re-run as a suite admin",
|
|
793
|
+
)
|
|
794
|
+
return True
|