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.
@@ -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