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/lib/demo.py ADDED
@@ -0,0 +1,421 @@
1
+ """`veltro demo list|seed|reset` — the demo sandbox (§7.3).
2
+
3
+ `seed` provisions and replays one or more datasets from `content/datasets/`:
4
+
5
+ * the `brute-force` dataset is the golden path and is provisioned by
6
+ `scripts/seed-demo.sh`, which also builds the VectorFlow pipeline, the CHAD
7
+ push data source and the Warden bindings the other datasets replay through;
8
+ * every other dataset is provisioned and replayed by
9
+ `veltro_cli.lib.demo_replay` against the running suite.
10
+
11
+ `reset` undoes what is reversible per dataset and names what is retained (see
12
+ `demo_replay.reset_dataset`), then clears the suite demo-mode banner.
13
+
14
+ Both write `.demo-datasets.json` in the suite directory (mode 0600): the
15
+ per-dataset run token, rule, alert ids and case keys the parameterised
16
+ `e2e/demo-datasets.spec.ts` follows. `seed` claims every dataset it is about
17
+ to touch — in that handoff and in identity demo mode — BEFORE its first write,
18
+ and rewrites each entry with the real identifiers as the dataset completes, so
19
+ a run that dies half-way still shows the synthetic-data banner and is still
20
+ undone by `veltro demo reset`. That covers the golden path, whose shell seeder
21
+ records its identifiers only in its last step: `reset` resolves its rule by
22
+ the title `content/datasets/brute-force/dataset.json` carries, then reaches
23
+ that rule's alerts and Warden cases through it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import shlex
31
+ import subprocess
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Mapping, Sequence
35
+
36
+ from veltro_cli.lib.datasets import (
37
+ GOLDEN_PATH_ENGINE,
38
+ Dataset,
39
+ load_catalog,
40
+ resolve_datasets,
41
+ )
42
+ from veltro_cli.lib.demo_replay import (
43
+ INGEST_SUFFIX_DEFAULT,
44
+ Clock,
45
+ clear_demo_mode,
46
+ read_demo_mode,
47
+ reset_dataset,
48
+ resolve_ingest,
49
+ reset_golden_path,
50
+ seed_dataset,
51
+ set_demo_mode,
52
+ )
53
+ from veltro_cli.lib.errors import VeltroCliError
54
+ from veltro_cli.lib.paths import enrollment_token_dir, resolve_dir
55
+ from veltro_cli.lib.suite_http import SuiteSession
56
+ from veltro_cli.lib.toml_config import VELTRO_TOML, read_config
57
+
58
+ ACTIONS = ("list", "seed", "reset")
59
+ HANDOFF_NAME = ".demo-datasets.json"
60
+ HANDOFF_SCHEMA = "veltro-demo-handoff/v1"
61
+ DEFAULT_DATASET = "brute-force"
62
+ REPLAY_SUBDIR = "demo"
63
+ SEED_HANDOFF_NAME = ".seed-demo.env"
64
+ GOLDEN_HANDOFF_FIELDS = {
65
+ "CHAD_RULE_ID",
66
+ "CHAD_ALERT_ID",
67
+ "CHAD_IOC_ALERT_ID",
68
+ "CHAD_INGEST_SUFFIX",
69
+ "CHAD_NOVEL_ALERT_ID",
70
+ "WARDEN_CASE_KEY",
71
+ }
72
+
73
+
74
+ def _run_token(now: datetime) -> str:
75
+ return f"{int(now.timestamp())}-{os.getpid()}"
76
+
77
+
78
+ def public_url(root: Path, env: dict[str, str]) -> str:
79
+ """The suite origin: PUBLIC_BASE_URL, else veltro.toml, else localhost."""
80
+ from_env = env.get("PUBLIC_BASE_URL", "").strip()
81
+ if from_env:
82
+ return from_env
83
+ config = read_config(root / VELTRO_TOML)
84
+ if config and config.public_url:
85
+ return config.public_url
86
+ return "http://localhost"
87
+
88
+
89
+ def replay_dir(root: Path, env: Mapping[str, str]) -> Path:
90
+ """Where corpus JSONL is queued for the VectorFlow agent's file source.
91
+
92
+ The agent reads `/run/vf-agent-enrollment/demo/*.jsonl`, and compose binds
93
+ `VF_ENROLLMENT_TOKEN_DIR` (default `<suite>/.suite/vf-agent`) onto that
94
+ target. `.env.example` says that variable is the ONLY name for the
95
+ directory, so this resolves exactly the way `scripts/seed-demo.sh` does:
96
+ `VELTRO_DEMO_REPLAY_DIR` overrides the whole path, else
97
+ `VF_ENROLLMENT_TOKEN_DIR`, else the in-tree default — with the same `demo/`
98
+ subdirectory the deployed source globs. Hardcoding the default instead put
99
+ the corpus somewhere the relocated agent could not see, and the seed then
100
+ failed 90s later blaming the rule and the field mappings (veltro#984).
101
+ """
102
+ explicit = env.get("VELTRO_DEMO_REPLAY_DIR", "").strip()
103
+ if explicit:
104
+ return Path(explicit).expanduser().resolve()
105
+ base = env.get("VF_ENROLLMENT_TOKEN_DIR", "").strip()
106
+ root_dir = Path(base).expanduser().resolve() if base else enrollment_token_dir(root)
107
+ return root_dir / REPLAY_SUBDIR
108
+
109
+
110
+ def _admin_credentials(env: dict[str, str]) -> tuple[str, str]:
111
+ email = env.get("VF_ADMIN_EMAIL", "").strip()
112
+ password = env.get("VF_ADMIN_PASSWORD", "")
113
+ if not email or not password:
114
+ raise VeltroCliError(
115
+ "VF_ADMIN_EMAIL and VF_ADMIN_PASSWORD are required to seed a dataset",
116
+ remedy="export a suite admin login; the same pair scripts/seed-demo.sh uses",
117
+ )
118
+ return email, password
119
+
120
+
121
+ def write_handoff(root: Path, payload: dict[str, Any]) -> Path:
122
+ path = root / HANDOFF_NAME
123
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
124
+ path.chmod(0o600)
125
+ return path
126
+
127
+
128
+ def read_handoff(root: Path) -> dict[str, Any]:
129
+ path = root / HANDOFF_NAME
130
+ if not path.is_file():
131
+ return {}
132
+ try:
133
+ payload = json.loads(path.read_text(encoding="utf-8"))
134
+ except json.JSONDecodeError:
135
+ return {}
136
+ return payload if isinstance(payload, dict) else {}
137
+
138
+ def read_golden_path_handoff(root: Path) -> dict[str, str]:
139
+ """Read only non-secret identifiers from the shell handoff."""
140
+ path = root / SEED_HANDOFF_NAME
141
+ if not path.is_file():
142
+ return {}
143
+ values: dict[str, str] = {}
144
+ for line in path.read_text(encoding="utf-8").splitlines():
145
+ key, separator, encoded = line.partition("=")
146
+ if separator != "=" or key not in GOLDEN_HANDOFF_FIELDS:
147
+ continue
148
+ parsed = shlex.split(encoded)
149
+ if len(parsed) == 1 and parsed[0]:
150
+ values[key] = parsed[0]
151
+ return values
152
+
153
+
154
+ def _golden_path_argv(root: Path, extra_args: Sequence[str]) -> list[str]:
155
+ script = root / "scripts" / "seed-demo.sh"
156
+ if not script.is_file():
157
+ raise VeltroCliError(
158
+ f"{script} not found",
159
+ remedy="run from an installed suite directory or a repository checkout",
160
+ )
161
+ return ["bash", str(script), *extra_args]
162
+
163
+
164
+ def _seed_golden_path(
165
+ root: Path,
166
+ extra_args: Sequence[str],
167
+ runner: Callable[..., Any] | None,
168
+ env: dict[str, str],
169
+ ) -> int:
170
+ argv = _golden_path_argv(root, extra_args)
171
+ if runner is not None:
172
+ result = runner(argv, env, root)
173
+ return int(getattr(result, "returncode", result))
174
+ completed = subprocess.run(argv, cwd=str(root), check=False)
175
+ return int(completed.returncode)
176
+
177
+
178
+ def format_catalog(catalog: dict[str, Dataset]) -> str:
179
+ lines = ["datasets (content/datasets):"]
180
+ for dataset in sorted(catalog.values(), key=lambda d: (d.engine != GOLDEN_PATH_ENGINE, d.id)):
181
+ techniques = ", ".join(dataset.attack_techniques)
182
+ lines.append(f" {dataset.id:<22} {dataset.title}")
183
+ lines.append(f" {'':<22} engine={dataset.engine} attack={techniques}")
184
+ lines.append("")
185
+ lines.append("seed one: veltro demo seed --dataset <id>")
186
+ lines.append("seed all: veltro demo seed --dataset all")
187
+ lines.append("undo: veltro demo reset [--dataset <id>]")
188
+ return "\n".join(lines) + "\n"
189
+
190
+
191
+ def run_demo(
192
+ action: str,
193
+ *,
194
+ directory: str | Path | None = None,
195
+ datasets: Sequence[str] = (),
196
+ extra_args: Sequence[str] = (),
197
+ runner: Callable[..., Any] | None = None,
198
+ session: SuiteSession | None = None,
199
+ clock: Clock | None = None,
200
+ env: dict[str, str] | None = None,
201
+ out: Callable[[str], None] = print,
202
+ ) -> int:
203
+ if action not in ACTIONS:
204
+ raise VeltroCliError(
205
+ f"unknown demo action {action!r}",
206
+ remedy="use `veltro demo list`, `veltro demo seed` or `veltro demo reset`",
207
+ )
208
+ root = resolve_dir(directory)
209
+ environment = dict(os.environ if env is None else env)
210
+ clock = clock or Clock()
211
+
212
+ if action == "list":
213
+ out(format_catalog(load_catalog(root)).rstrip("\n"))
214
+ return 0
215
+ if action == "seed":
216
+ return _seed(root, datasets, extra_args, runner, session, clock, environment, out)
217
+ return _reset(root, datasets, session, environment, out)
218
+
219
+
220
+ def _open_session(root: Path, environment: dict[str, str], session: SuiteSession | None) -> SuiteSession:
221
+ if session is not None:
222
+ return session
223
+ live = SuiteSession(base_url=public_url(root, environment))
224
+ live.login(*_admin_credentials(environment))
225
+ return live
226
+
227
+
228
+ def _persist(
229
+ root: Path,
230
+ live: SuiteSession,
231
+ results: dict[str, Any],
232
+ seeded_at: datetime,
233
+ ) -> Path:
234
+ """Record what is (or is being) seeded, in both places `reset` reads.
235
+
236
+ Called before the first mutation and again after every dataset, because a
237
+ seed that dies half-way used to leave a deployed rule, an alert, a Warden
238
+ case and a queued JSONL file behind with demo mode OFF: no product banner
239
+ said the data was synthetic, and `veltro demo reset` answered "nothing to
240
+ reset" (veltro#984). The marker is what makes a partial seed visible and
241
+ undoable, so it is written first and kept current, not written last.
242
+ """
243
+ set_demo_mode(live, results.keys(), seeded_at=seeded_at)
244
+ return write_handoff(
245
+ root,
246
+ {
247
+ "schema": HANDOFF_SCHEMA,
248
+ "seeded_at": seeded_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
249
+ "datasets": results,
250
+ },
251
+ )
252
+
253
+
254
+ def _seed(
255
+ root: Path,
256
+ names: Sequence[str],
257
+ extra_args: Sequence[str],
258
+ runner: Callable[..., Any] | None,
259
+ session: SuiteSession | None,
260
+ clock: Clock,
261
+ environment: dict[str, str],
262
+ out: Callable[[str], None],
263
+ ) -> int:
264
+ catalog = load_catalog(root)
265
+ selected = resolve_datasets(root, names or (DEFAULT_DATASET,))
266
+ corpus = [dataset for dataset in selected if dataset.is_corpus]
267
+ golden = [dataset for dataset in selected if not dataset.is_corpus]
268
+ if corpus and not golden:
269
+ golden = [catalog[DEFAULT_DATASET]]
270
+ out("== brute-force: preparing the VectorFlow → CHAD → Warden replay path ==")
271
+ queue = replay_dir(root, environment)
272
+
273
+ # Open the session BEFORE anything is mutated: it validates the admin
274
+ # credentials and it is what records the demo-mode marker the whole run
275
+ # depends on. Failing here costs nothing; failing later leaves state.
276
+ live = _open_session(root, environment, session)
277
+ seeded_at = clock.now()
278
+ results: dict[str, Any] = dict(read_handoff(root).get("datasets") or {})
279
+ for dataset in (*golden, *corpus):
280
+ results.setdefault(
281
+ dataset.id,
282
+ {
283
+ "dataset": dataset.id,
284
+ "engine": dataset.engine,
285
+ "status": "seeding",
286
+ "notes": [
287
+ "claimed before the first write; `veltro demo reset --dataset "
288
+ f"{dataset.id}` undoes whatever this run created"
289
+ ],
290
+ },
291
+ )
292
+ _persist(root, live, results, seeded_at)
293
+ out(" demo mode on: every product shows the demo banner while this seed runs")
294
+
295
+ for dataset in golden:
296
+ out(f"== {dataset.id}: golden-path seeder (scripts/seed-demo.sh) ==")
297
+ code = _seed_golden_path(root, extra_args, runner, environment)
298
+ if code != 0:
299
+ raise VeltroCliError(
300
+ f"the golden-path seeder failed (exit {code})",
301
+ remedy="read its FAIL line above; every step names what to fix",
302
+ )
303
+ golden_handoff = read_golden_path_handoff(root)
304
+ results[dataset.id] = {
305
+ "dataset": dataset.id,
306
+ "engine": dataset.engine,
307
+ "rule_id": golden_handoff.get("CHAD_RULE_ID", ""),
308
+ "ingest_suffix": golden_handoff.get("CHAD_INGEST_SUFFIX", INGEST_SUFFIX_DEFAULT),
309
+ "alert_ids": [
310
+ identifier
311
+ for key in ("CHAD_ALERT_ID", "CHAD_IOC_ALERT_ID", "CHAD_NOVEL_ALERT_ID")
312
+ if (identifier := golden_handoff.get(key))
313
+ ],
314
+ "case_keys": (
315
+ [golden_handoff["WARDEN_CASE_KEY"]]
316
+ if golden_handoff.get("WARDEN_CASE_KEY")
317
+ else []
318
+ ),
319
+ "notes": ["provisioned by scripts/seed-demo.sh; see .seed-demo.env for its private handoff"],
320
+ }
321
+ _persist(root, live, results, seeded_at)
322
+
323
+ if corpus:
324
+ ingest = resolve_ingest(live)
325
+ for dataset in corpus:
326
+ token = _run_token(clock.now())
327
+ out(f"== {dataset.id}: replaying {dataset.title} ==")
328
+ result = seed_dataset(
329
+ live,
330
+ dataset,
331
+ run_token=token,
332
+ replay_dir=queue,
333
+ clock=clock,
334
+ ingest=ingest,
335
+ )
336
+ results[dataset.id] = result.as_dict()
337
+ _persist(root, live, results, seeded_at)
338
+ out(
339
+ f" {result.events_replayed} events queued through VectorFlow, "
340
+ f"{len(result.alert_ids)} CHAD alert(s), Warden case(s): "
341
+ f"{', '.join(result.case_keys) or 'none'}"
342
+ )
343
+
344
+ path = _persist(root, live, results, seeded_at)
345
+ out(f"handoff: {path}")
346
+ return 0
347
+
348
+
349
+ def _reset(
350
+ root: Path,
351
+ names: Sequence[str],
352
+ session: SuiteSession | None,
353
+ environment: dict[str, str],
354
+ out: Callable[[str], None],
355
+ ) -> int:
356
+ catalog = load_catalog(root)
357
+ handoff = read_handoff(root)
358
+ recorded = list((handoff.get("datasets") or {}).keys())
359
+ live = _open_session(root, environment, session)
360
+ if not names:
361
+ state = read_demo_mode(live) or {}
362
+ from_identity = [str(identifier) for identifier in state.get("datasets") or []]
363
+ names = [name for name in dict.fromkeys([*recorded, *from_identity]) if name in catalog]
364
+ if not names:
365
+ out("nothing to reset: no seeded dataset recorded in the handoff or in identity demo mode")
366
+ clear_demo_mode(live)
367
+ return 0
368
+
369
+ selected = resolve_datasets(root, names)
370
+ queue = replay_dir(root, environment)
371
+ remaining = dict(handoff.get("datasets") or {})
372
+ for dataset in selected:
373
+ recorded_result = remaining.get(dataset.id) or {}
374
+ case_keys = [str(key) for key in recorded_result.get("case_keys") or []]
375
+ if dataset.is_corpus:
376
+ result = reset_dataset(
377
+ live,
378
+ dataset,
379
+ replay_dir=queue,
380
+ case_keys=case_keys,
381
+ )
382
+ else:
383
+ result = reset_golden_path(
384
+ live,
385
+ replay_dir=queue,
386
+ rule_title=dataset.rule_title,
387
+ rule_id=str(recorded_result.get("rule_id") or ""),
388
+ alert_ids=[str(identifier) for identifier in recorded_result.get("alert_ids") or []],
389
+ case_keys=case_keys,
390
+ ingest_suffix=str(recorded_result.get("ingest_suffix") or INGEST_SUFFIX_DEFAULT),
391
+ )
392
+ remaining.pop(dataset.id, None)
393
+ out(f"== {dataset.id} ==")
394
+ if result.replay_files_deleted:
395
+ out(f" replay files deleted: {result.replay_files_deleted}")
396
+ if result.rules_undeployed:
397
+ out(f" undeployed rule(s): {', '.join(result.rules_undeployed)}")
398
+ out(f" alerts deleted: {result.alerts_deleted}")
399
+ if result.cases_closed:
400
+ out(f" cases closed: {', '.join(result.cases_closed)}")
401
+ for note in result.notes:
402
+ out(f" note: {note}")
403
+ for retained in result.retained:
404
+ out(f" retained: {retained}")
405
+
406
+ if remaining:
407
+ write_handoff(
408
+ root,
409
+ {
410
+ "schema": HANDOFF_SCHEMA,
411
+ "seeded_at": handoff.get("seeded_at", ""),
412
+ "datasets": remaining,
413
+ },
414
+ )
415
+ set_demo_mode(live, remaining.keys(), seeded_at=datetime.now(timezone.utc))
416
+ out(f"still seeded: {', '.join(sorted(remaining))} (demo mode stays on)")
417
+ else:
418
+ (root / HANDOFF_NAME).unlink(missing_ok=True)
419
+ clear_demo_mode(live)
420
+ out("demo mode off: the demo banner is gone from every product")
421
+ return 0