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