vardrrunner 0.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """VardrRunner — local automation runner for the VardrSec product family.
2
+
3
+ The version literal below is the single source of truth: `pyproject.toml` reads
4
+ it via setuptools' dynamic-version mechanism, and the heartbeat reports it to the
5
+ backend. Bump it here (and add a CHANGELOG entry) on every release.
6
+ """
7
+
8
+ __version__ = "0.28.0"
vardrrunner/api.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ Thin wrapper around requests for authenticated calls to the VardrMap API.
3
+ All methods raise requests.HTTPError on non-2xx responses.
4
+
5
+ The session retries transient failures (connection errors and 429/5xx) with
6
+ exponential backoff so a long-running daemon survives network blips and brief
7
+ backend restarts. Retries are limited to idempotent methods (urllib3's default:
8
+ GET/HEAD/PUT/DELETE/OPTIONS/TRACE) — POST and PATCH are never auto-retried, so a
9
+ dropped response can't cause a double-claim, double-import, or duplicate event.
10
+ """
11
+
12
+ import platform
13
+ from typing import Any
14
+
15
+ import requests
16
+ from requests.adapters import HTTPAdapter
17
+ from urllib3.util.retry import Retry
18
+
19
+ from vardrrunner import __version__
20
+
21
+ # Statuses worth retrying: rate-limit + transient server/proxy errors.
22
+ _RETRY_STATUSES = (429, 500, 502, 503, 504)
23
+
24
+
25
+ class VardrMapClient:
26
+ def __init__(
27
+ self,
28
+ api_url: str,
29
+ api_key: str,
30
+ *,
31
+ retries: int = 3,
32
+ backoff_factor: float = 0.5,
33
+ ):
34
+ self.base = api_url.rstrip("/")
35
+ self.session = requests.Session()
36
+ self.session.headers.update(
37
+ {
38
+ "Authorization": f"Bearer {api_key}",
39
+ # Identify the runner + version in backend logs.
40
+ "User-Agent": f"vardrrunner/{__version__} ({platform.system()})",
41
+ }
42
+ )
43
+
44
+ # Mount a retry-with-backoff adapter for transient failures. allowed_methods
45
+ # is left at urllib3's idempotent-only default so POST/PATCH are not retried.
46
+ retry = Retry(
47
+ total=retries,
48
+ connect=retries,
49
+ read=retries,
50
+ status=retries,
51
+ backoff_factor=backoff_factor,
52
+ status_forcelist=_RETRY_STATUSES,
53
+ raise_on_status=False,
54
+ )
55
+ adapter = HTTPAdapter(max_retries=retry)
56
+ self.session.mount("https://", adapter)
57
+ self.session.mount("http://", adapter)
58
+
59
+ def _url(self, path: str) -> str:
60
+ return f"{self.base}/{path.lstrip('/')}"
61
+
62
+ def get(self, path: str, params: dict | None = None) -> Any:
63
+ r = self.session.get(self._url(path), params=params, timeout=30)
64
+ r.raise_for_status()
65
+ return r.json()
66
+
67
+ def post(
68
+ self,
69
+ path: str,
70
+ json: dict | None = None,
71
+ files: dict | None = None,
72
+ data: dict | None = None,
73
+ ) -> Any:
74
+ r = self.session.post(self._url(path), json=json, files=files, data=data, timeout=60)
75
+ r.raise_for_status()
76
+ return r.json()
77
+
78
+ def whoami(self) -> dict:
79
+ return self.get("/me")
80
+
81
+ def engagements(self) -> list[dict]:
82
+ data = self.get("/engagements")
83
+ # VardrMap returns both keys during the engagements → engagements
84
+ # transition; prefer the new name and fall back to the old one.
85
+ return data.get("engagements") or data.get("engagements", [])
86
+
87
+ def engagement(self, engagement_id: str) -> dict:
88
+ return self.get(f"/engagements/{engagement_id}")
89
+
90
+ def scope(self, engagement_id: str) -> dict:
91
+ """Returns {"in": [...], "out": [...]} scope lists."""
92
+ return self.engagement(engagement_id).get("scope", {"in": [], "out": []})
93
+
94
+ # Backend rejects limit values above this with 422.
95
+ RECON_PAGE_SIZE = 500
96
+
97
+ def recon(
98
+ self, engagement_id: str, limit: int = 100, status_code: int | None = None
99
+ ) -> list[dict]:
100
+ """Fetch recon items, paginating in RECON_PAGE_SIZE chunks to avoid backend 422s."""
101
+ results: list[dict] = []
102
+ offset = 0
103
+ page_size = min(limit, self.RECON_PAGE_SIZE)
104
+
105
+ while len(results) < limit:
106
+ remaining = limit - len(results)
107
+ fetch = min(remaining, page_size)
108
+ params: dict = {"limit": fetch, "offset": offset}
109
+ if status_code is not None:
110
+ params["status_code"] = status_code
111
+ page = self.get(f"/engagements/{engagement_id}/recon", params=params).get("recon", [])
112
+ results.extend(page)
113
+ if len(page) < fetch:
114
+ break
115
+ offset += fetch
116
+
117
+ return results
118
+
119
+ def import_file(self, engagement_id: str, tool_type: str, file_path: str) -> dict:
120
+ with open(file_path, "rb") as fh:
121
+ return self.post(
122
+ f"/engagements/{engagement_id}/imports",
123
+ files={"file": (file_path, fh, "application/json")},
124
+ data={"tool_type": tool_type},
125
+ )
126
+
127
+ # ------------------------------------------------------------------
128
+ # Scan jobs (job queue for UI-initiated scans)
129
+ # ------------------------------------------------------------------
130
+
131
+ def pending_jobs(self) -> list[dict]:
132
+ """Return all pending jobs owned by the authenticated user."""
133
+ return self.get("/jobs/pending").get("jobs", [])
134
+
135
+ def claim_job(self, job_id: str) -> dict:
136
+ """Atomically claim a pending job. Raises HTTPError 409 if already claimed."""
137
+ return self.post(f"/jobs/{job_id}/claim")
138
+
139
+ def complete_job(self, job_id: str, status: str, error: str = "") -> dict:
140
+ """Mark a job done or failed."""
141
+ payload: dict = {"status": status}
142
+ if error:
143
+ payload["error_message"] = error
144
+ return self.patch(f"/jobs/{job_id}", json=payload)
145
+
146
+ def patch(self, path: str, json: dict | None = None) -> Any:
147
+ r = self.session.patch(self._url(path), json=json, timeout=30)
148
+ r.raise_for_status()
149
+ return r.json()
150
+
151
+ # ------------------------------------------------------------------
152
+ # Runner heartbeat
153
+ # ------------------------------------------------------------------
154
+
155
+ def send_heartbeat(self, payload: dict) -> dict:
156
+ """Post runner status (hostname, version, os, tools) to the backend."""
157
+ return self.post("/runner/heartbeat", json=payload)
158
+
159
+ # ------------------------------------------------------------------
160
+ # Job events
161
+ # ------------------------------------------------------------------
162
+
163
+ def post_event(self, job_id: str, kind: str, text: str = "") -> dict:
164
+ """Post a lifecycle event for a job (started, running, done, failed, …)."""
165
+ return self.post(f"/jobs/{job_id}/events", json={"kind": kind, "text": text})
166
+
167
+ # ------------------------------------------------------------------
168
+ # Services
169
+ # ------------------------------------------------------------------
170
+
171
+ def create_services(self, engagement_id: str, services: list[dict]) -> dict:
172
+ """Bulk-upsert nmap service results for an engagement."""
173
+ return self.post(f"/engagements/{engagement_id}/services", json={"services": services})
vardrrunner/cli.py ADDED
@@ -0,0 +1,440 @@
1
+ """
2
+ vardrrunner — local automation runner for the VardrSec product family.
3
+
4
+ Runs security tooling on the operator's machine and syncs results to a VardrSec
5
+ backend (today: VardrMap) over HTTP. See https://github.com/VardrSec/VardrRunner.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from vardrrunner.commands import auth, engagements, imports, jobs, run
14
+ from vardrrunner.commands import daemon as daemon_cmd
15
+ from vardrrunner.commands import doctor as doctor_cmd
16
+ from vardrrunner.commands import heartbeat as heartbeat_cmd
17
+ from vardrrunner.commands import pipeline as pipeline_cmd
18
+ from vardrrunner.commands import status as status_cmd
19
+
20
+ console = Console()
21
+
22
+ # Shared across every command that resolves targets, so the cap is described
23
+ # identically in `run --help` and `pipeline run --help`.
24
+ _MAX_TARGETS_HELP = "Abort if resolved targets exceed this count (0 disables the cap)"
25
+
26
+ app = typer.Typer(
27
+ name="vardrrunner",
28
+ help="Local runner for VardrMap. Runs tools locally, uploads results to your VardrMap instance.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Auth
34
+ # --------------------------------------------------------------------------- #
35
+
36
+ login_app = typer.Typer(help="Log in to a Vardr product.", no_args_is_help=True)
37
+ app.add_typer(login_app, name="login")
38
+ login_app.command("vardrmap")(auth.login_vardrmap)
39
+
40
+
41
+ @app.command()
42
+ def status():
43
+ """Show config, API connectivity, and local tool availability."""
44
+ status_cmd.run_status()
45
+
46
+
47
+ @app.command()
48
+ def doctor(
49
+ as_json: bool = typer.Option(False, "--json", help="Emit a machine-readable JSON report"),
50
+ ):
51
+ """Deep preflight before unattended use — exits non-zero on actionable failures."""
52
+ doctor_cmd.run_doctor(as_json=as_json)
53
+
54
+
55
+ @app.command()
56
+ def heartbeat():
57
+ """Send a heartbeat to VardrMap — reports hostname, version, and tool status."""
58
+ heartbeat_cmd.send_heartbeat(quiet=False)
59
+
60
+
61
+ @app.command()
62
+ def logout():
63
+ """Remove stored credentials (keychain + config file); keep the API URL."""
64
+ auth.logout()
65
+
66
+
67
+ @app.command()
68
+ def whoami():
69
+ """Show the identity tied to the configured API key."""
70
+ auth.whoami()
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # Programs
75
+ # --------------------------------------------------------------------------- #
76
+
77
+
78
+ @app.command()
79
+ def engagement_list():
80
+ """List all engagements in VardrMap."""
81
+ engagements.list_engagements()
82
+
83
+
84
+ # Alias `engagements` → `engagement-list` for a more natural UX
85
+ app.command(name="engagements")(engagement_list)
86
+ # Retired name, kept so existing habits and scripts do not break. Hidden from
87
+ # --help so only the current name is advertised.
88
+ app.command(name="programs", hidden=True)(engagement_list)
89
+
90
+
91
+ @app.command()
92
+ def scope(engagement_id: str = typer.Argument(..., help="Engagement UUID")):
93
+ """Show in-scope and out-of-scope items for an engagement."""
94
+ engagements.show_scope(engagement_id)
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Import
99
+ # --------------------------------------------------------------------------- #
100
+
101
+ import_app = typer.Typer(help="Import tool output files into VardrMap.", no_args_is_help=True)
102
+ app.add_typer(import_app, name="import")
103
+
104
+
105
+ @import_app.command("nuclei")
106
+ def import_nuclei(
107
+ engagement_id: str = typer.Option(
108
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
109
+ ),
110
+ file: Path = typer.Option(..., "--file", "-f", help="Path to nuclei JSONL output"),
111
+ ):
112
+ """Import a nuclei output file."""
113
+ imports.import_file("nuclei", engagement_id, file)
114
+
115
+
116
+ @import_app.command("httpx")
117
+ def import_httpx(
118
+ engagement_id: str = typer.Option(
119
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
120
+ ),
121
+ file: Path = typer.Option(..., "--file", "-f", help="Path to httpx JSON/JSONL output"),
122
+ ):
123
+ """Import an httpx output file."""
124
+ imports.import_file("httpx", engagement_id, file)
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Run
129
+ # --------------------------------------------------------------------------- #
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # Jobs
133
+ # --------------------------------------------------------------------------- #
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # Daemon
137
+ # --------------------------------------------------------------------------- #
138
+
139
+ daemon_app = typer.Typer(
140
+ help="Long-running background worker: polls jobs and sends heartbeats.", no_args_is_help=True
141
+ )
142
+ app.add_typer(daemon_app, name="daemon")
143
+
144
+
145
+ @daemon_app.command("start")
146
+ def daemon_start(
147
+ detach: bool = typer.Option(False, "--detach", "-d", help="Run in background"),
148
+ poll_interval: int = typer.Option(5, "--poll-interval", help="Seconds between job polls"),
149
+ heartbeat_interval: int = typer.Option(
150
+ 60, "--heartbeat-interval", help="Seconds between heartbeats"
151
+ ),
152
+ log_file: Path | None = typer.Option(None, "--log-file", help="Append output to file"),
153
+ ):
154
+ """Start the daemon (foreground by default, use --detach for background)."""
155
+ daemon_cmd.start(
156
+ detach=detach,
157
+ poll_interval=poll_interval,
158
+ heartbeat_interval=heartbeat_interval,
159
+ log_file=log_file,
160
+ )
161
+
162
+
163
+ @daemon_app.command("stop")
164
+ def daemon_stop():
165
+ """Stop a running daemon."""
166
+ daemon_cmd.stop()
167
+
168
+
169
+ @daemon_app.command("status")
170
+ def daemon_status():
171
+ """Show whether the daemon is running."""
172
+ daemon_cmd.status()
173
+
174
+
175
+ # --------------------------------------------------------------------------- #
176
+ # Jobs
177
+ # --------------------------------------------------------------------------- #
178
+
179
+ jobs_app = typer.Typer(help="Manage and execute scan job queue.", no_args_is_help=True)
180
+ app.add_typer(jobs_app, name="jobs")
181
+
182
+
183
+ @jobs_app.command("list")
184
+ def jobs_list():
185
+ """List pending scan jobs."""
186
+ jobs.list_jobs()
187
+
188
+
189
+ @jobs_app.command("run")
190
+ def jobs_run(
191
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
192
+ ):
193
+ """Claim and execute all pending scan jobs."""
194
+ jobs.run_jobs(yes=yes)
195
+
196
+
197
+ # --------------------------------------------------------------------------- #
198
+ # Run
199
+ # --------------------------------------------------------------------------- #
200
+
201
+ run_app = typer.Typer(
202
+ help="Run a tool locally and upload results to VardrMap.", no_args_is_help=True
203
+ )
204
+ app.add_typer(run_app, name="run")
205
+
206
+
207
+ @run_app.command("httpx")
208
+ def run_httpx(
209
+ engagement_id: str = typer.Option(
210
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
211
+ ),
212
+ scope: bool = typer.Option(False, "--scope", help="Use in-scope assets from VardrMap"),
213
+ from_recon: bool = typer.Option(
214
+ False, "--from-recon", help="Use live recon items from VardrMap"
215
+ ),
216
+ target: str | None = typer.Option(None, "--target", help="Single inline target"),
217
+ targets_file: Path | None = typer.Option(None, "--targets", help="Path to a targets .txt file"),
218
+ limit: int = typer.Option(100, "--limit", help="Max recon items to use (--from-recon only)"),
219
+ status_code: int | None = typer.Option(
220
+ None, "--status-code", help="Filter recon by HTTP status code"
221
+ ),
222
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
223
+ max_targets: int = typer.Option(
224
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
225
+ ),
226
+ ):
227
+ """Run httpx locally and upload results to VardrMap."""
228
+ run.run_httpx(
229
+ engagement_id=engagement_id,
230
+ scope=scope,
231
+ from_recon=from_recon,
232
+ target=target,
233
+ targets_file=targets_file,
234
+ limit=limit,
235
+ status_code=status_code,
236
+ yes=yes,
237
+ max_targets=max_targets,
238
+ )
239
+
240
+
241
+ @run_app.command("subfinder")
242
+ def run_subfinder(
243
+ engagement_id: str = typer.Option(
244
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
245
+ ),
246
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
247
+ max_targets: int = typer.Option(
248
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
249
+ ),
250
+ ):
251
+ """Run subfinder against wildcard scope entries and import discovered hosts."""
252
+ run.run_subfinder(engagement_id=engagement_id, yes=yes, max_targets=max_targets)
253
+
254
+
255
+ @run_app.command("nuclei")
256
+ def run_nuclei(
257
+ engagement_id: str = typer.Option(
258
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
259
+ ),
260
+ scope: bool = typer.Option(False, "--scope", help="Use in-scope assets from VardrMap"),
261
+ from_recon: bool = typer.Option(
262
+ False, "--from-recon", help="Use live recon items from VardrMap"
263
+ ),
264
+ target: str | None = typer.Option(None, "--target", help="Single inline target"),
265
+ targets_file: Path | None = typer.Option(None, "--targets", help="Path to a targets .txt file"),
266
+ limit: int = typer.Option(100, "--limit", help="Max recon items to use (--from-recon only)"),
267
+ status_code: int | None = typer.Option(
268
+ None, "--status-code", help="Filter recon by HTTP status code"
269
+ ),
270
+ severity: str | None = typer.Option(
271
+ None, "--severity", help="Comma-separated severities, e.g. high,critical"
272
+ ),
273
+ templates: str | None = typer.Option(
274
+ None, "--templates", "-t", help="Nuclei template path or tag"
275
+ ),
276
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
277
+ max_targets: int = typer.Option(
278
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
279
+ ),
280
+ ):
281
+ """Run nuclei locally and upload results to VardrMap."""
282
+ run.run_nuclei(
283
+ engagement_id=engagement_id,
284
+ scope=scope,
285
+ from_recon=from_recon,
286
+ target=target,
287
+ targets_file=targets_file,
288
+ limit=limit,
289
+ status_code=status_code,
290
+ severity=severity,
291
+ templates=templates,
292
+ yes=yes,
293
+ max_targets=max_targets,
294
+ )
295
+
296
+
297
+ @run_app.command("nmap")
298
+ def run_nmap(
299
+ engagement_id: str = typer.Option(
300
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
301
+ ),
302
+ scope: bool = typer.Option(False, "--scope", help="Use in-scope assets from VardrMap"),
303
+ from_recon: bool = typer.Option(
304
+ False, "--from-recon", help="Use live recon items from VardrMap"
305
+ ),
306
+ target: str | None = typer.Option(None, "--target", help="Single inline target"),
307
+ targets_file: Path | None = typer.Option(None, "--targets", help="Path to a targets .txt file"),
308
+ limit: int = typer.Option(500, "--limit", help="Max recon items to use (--from-recon only)"),
309
+ top_ports: int = typer.Option(100, "--top-ports", help="Number of most-common ports to scan"),
310
+ timing: int = typer.Option(
311
+ 3, "--timing", help="nmap timing template (0-4; 5 is never allowed)"
312
+ ),
313
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
314
+ max_targets: int = typer.Option(
315
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
316
+ ),
317
+ ):
318
+ """Run nmap service discovery locally and upload open ports to VardrMap."""
319
+ run.run_nmap(
320
+ engagement_id=engagement_id,
321
+ scope=scope,
322
+ from_recon=from_recon,
323
+ target=target,
324
+ targets_file=targets_file,
325
+ limit=limit,
326
+ top_ports=top_ports,
327
+ timing=timing,
328
+ yes=yes,
329
+ max_targets=max_targets,
330
+ )
331
+
332
+
333
+ @run_app.command("dnsx")
334
+ def run_dnsx(
335
+ engagement_id: str = typer.Option(
336
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
337
+ ),
338
+ scope: bool = typer.Option(False, "--scope", help="Use in-scope assets from VardrMap"),
339
+ from_recon: bool = typer.Option(
340
+ False, "--from-recon", help="Use live recon items from VardrMap"
341
+ ),
342
+ target: str | None = typer.Option(None, "--target", help="Single inline target"),
343
+ targets_file: Path | None = typer.Option(None, "--targets", help="Path to a targets .txt file"),
344
+ limit: int = typer.Option(500, "--limit", help="Max recon items to use (--from-recon only)"),
345
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
346
+ max_targets: int = typer.Option(
347
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
348
+ ),
349
+ ):
350
+ """Resolve hosts with dnsx and upload the resolvable ones as recon targets."""
351
+ run.run_dnsx(
352
+ engagement_id=engagement_id,
353
+ scope=scope,
354
+ from_recon=from_recon,
355
+ target=target,
356
+ targets_file=targets_file,
357
+ limit=limit,
358
+ yes=yes,
359
+ max_targets=max_targets,
360
+ )
361
+
362
+
363
+ @run_app.command("naabu")
364
+ def run_naabu(
365
+ engagement_id: str = typer.Option(
366
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
367
+ ),
368
+ scope: bool = typer.Option(False, "--scope", help="Use in-scope assets from VardrMap"),
369
+ from_recon: bool = typer.Option(
370
+ False, "--from-recon", help="Use live recon items from VardrMap"
371
+ ),
372
+ target: str | None = typer.Option(None, "--target", help="Single inline target"),
373
+ targets_file: Path | None = typer.Option(None, "--targets", help="Path to a targets .txt file"),
374
+ limit: int = typer.Option(500, "--limit", help="Max recon items to use (--from-recon only)"),
375
+ top_ports: int = typer.Option(100, "--top-ports", help="Number of most-common ports to scan"),
376
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
377
+ max_targets: int = typer.Option(
378
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
379
+ ),
380
+ ):
381
+ """Port-scan hosts with naabu locally and upload open ports to VardrMap."""
382
+ run.run_naabu(
383
+ engagement_id=engagement_id,
384
+ scope=scope,
385
+ from_recon=from_recon,
386
+ target=target,
387
+ targets_file=targets_file,
388
+ limit=limit,
389
+ top_ports=top_ports,
390
+ yes=yes,
391
+ max_targets=max_targets,
392
+ )
393
+
394
+
395
+ # --------------------------------------------------------------------------- #
396
+ # Pipeline
397
+ # --------------------------------------------------------------------------- #
398
+
399
+ pipeline_app = typer.Typer(help="Run a chain of tools as one recon pipeline.", no_args_is_help=True)
400
+ app.add_typer(pipeline_app, name="pipeline")
401
+
402
+
403
+ @pipeline_app.command("list")
404
+ def pipeline_list():
405
+ """List the available pipelines and their tool chains."""
406
+ pipeline_cmd.list_pipelines()
407
+
408
+
409
+ @pipeline_app.command("run")
410
+ def pipeline_run(
411
+ name: str = typer.Argument(..., help="Pipeline name (see `pipeline list`)"),
412
+ engagement_id: str = typer.Option(
413
+ ..., "--engagement", "--program", "-p", help="Engagement UUID"
414
+ ),
415
+ severity: str | None = typer.Option(
416
+ None, "--severity", help="nuclei severity filter for the scan stage"
417
+ ),
418
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
419
+ continue_on_error: bool = typer.Option(
420
+ False, "--continue-on-error", help="Keep going if a stage fails"
421
+ ),
422
+ dry_run: bool = typer.Option(
423
+ False, "--dry-run", help="Resolve first-stage targets and print the plan without executing"
424
+ ),
425
+ as_json: bool = typer.Option(False, "--json", help="Emit a machine-readable JSON result"),
426
+ max_targets: int = typer.Option(
427
+ run.MAX_TARGETS_DEFAULT, "--max-targets", help=_MAX_TARGETS_HELP
428
+ ),
429
+ ):
430
+ """Run every stage of a pipeline in order against an engagement."""
431
+ pipeline_cmd.run_pipeline(
432
+ name,
433
+ engagement_id,
434
+ severity=severity,
435
+ yes=yes,
436
+ continue_on_error=continue_on_error,
437
+ dry_run=dry_run,
438
+ as_json=as_json,
439
+ max_targets=max_targets,
440
+ )
File without changes