orithos-cli 0.1.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.
Files changed (40) hide show
  1. orithos_cli/__init__.py +6 -0
  2. orithos_cli/agent.py +78 -0
  3. orithos_cli/agent_wizard.py +255 -0
  4. orithos_cli/auth.py +21 -0
  5. orithos_cli/cli.py +82 -0
  6. orithos_cli/compliance.py +133 -0
  7. orithos_cli/config.py +114 -0
  8. orithos_cli/configure.py +103 -0
  9. orithos_cli/connection.py +69 -0
  10. orithos_cli/connection_wizard.py +129 -0
  11. orithos_cli/discovery.py +78 -0
  12. orithos_cli/graph.py +102 -0
  13. orithos_cli/guardrail.py +131 -0
  14. orithos_cli/mcp.py +170 -0
  15. orithos_cli/output.py +178 -0
  16. orithos_cli/probes.py +50 -0
  17. orithos_cli/remediation.py +111 -0
  18. orithos_cli/runtime.py +100 -0
  19. orithos_cli/scan.py +758 -0
  20. orithos_cli/skill.py +64 -0
  21. orithos_cli/skillscan/__init__.py +37 -0
  22. orithos_cli/skillscan/checks/__init__.py +28 -0
  23. orithos_cli/skillscan/checks/credentials.py +112 -0
  24. orithos_cli/skillscan/checks/iocs.py +95 -0
  25. orithos_cli/skillscan/checks/manifest.py +156 -0
  26. orithos_cli/skillscan/checks/network.py +117 -0
  27. orithos_cli/skillscan/checks/obfuscation.py +130 -0
  28. orithos_cli/skillscan/checks/permissions.py +108 -0
  29. orithos_cli/skillscan/checks/shell.py +140 -0
  30. orithos_cli/skillscan/collect.py +205 -0
  31. orithos_cli/skillscan/model.py +99 -0
  32. orithos_cli/skillscan/report.py +130 -0
  33. orithos_cli/template.py +60 -0
  34. orithos_cli/verify.py +85 -0
  35. orithos_cli/wizard.py +314 -0
  36. orithos_cli-0.1.0.dist-info/METADATA +99 -0
  37. orithos_cli-0.1.0.dist-info/RECORD +40 -0
  38. orithos_cli-0.1.0.dist-info/WHEEL +5 -0
  39. orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
orithos_cli/scan.py ADDED
@@ -0,0 +1,758 @@
1
+ """Scan commands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ import httpx
9
+ import click
10
+
11
+ from orithos_cli.config import get_config, fmt_http_error
12
+ from orithos_cli.output import (
13
+ FindingOutput,
14
+ ScanStatusOutput,
15
+ format_summary,
16
+ format_json,
17
+ format_sarif,
18
+ format_junit,
19
+ )
20
+
21
+
22
+ DEFAULT_PROBES = [
23
+ "prompt_injection",
24
+ "tool_misuse",
25
+ "scope_escalation",
26
+ "exfil_attempt",
27
+ ]
28
+
29
+ ORITHOS_VERSION = "0.1.0"
30
+
31
+
32
+ def _s(
33
+ text: str = "", fg: str | None = None, dim: bool = False, bold: bool = False
34
+ ) -> str:
35
+ return click.style(text, fg=fg, dim=dim, bold=bold)
36
+
37
+
38
+ def _sev_color(sev: str) -> str:
39
+ if sev in ("critical", "high"):
40
+ return "red"
41
+ if sev == "medium":
42
+ return "yellow"
43
+ if sev == "low":
44
+ return "green"
45
+ return None
46
+
47
+
48
+ def _fmt(s: str) -> str:
49
+ return s.replace("\n", "\\n")[:80]
50
+
51
+
52
+ def _resolve_agent(cfg, agent: str) -> dict | None:
53
+ """Resolve agent name or ID. Tries ID first, then name search."""
54
+ try:
55
+ r = httpx.get(
56
+ f"{cfg.api_url}/v1/agents/{agent}",
57
+ headers=cfg.auth_headers(),
58
+ timeout=cfg.timeout,
59
+ )
60
+ if r.status_code == 200:
61
+ return r.json()
62
+ except httpx.RequestError:
63
+ pass
64
+ try:
65
+ r = httpx.get(
66
+ f"{cfg.api_url}/v1/agents",
67
+ headers=cfg.auth_headers(),
68
+ timeout=cfg.timeout,
69
+ )
70
+ if r.status_code == 200:
71
+ agents = r.json()
72
+ if isinstance(agents, list):
73
+ for a in agents:
74
+ if a.get("name") == agent or a.get("id") == agent:
75
+ return a
76
+ except httpx.RequestError:
77
+ pass
78
+ return None
79
+
80
+
81
+ def _parse_tools(tools_str: str) -> list[dict]:
82
+ try:
83
+ parsed = json.loads(tools_str)
84
+ if isinstance(parsed, list):
85
+ return parsed
86
+ except (json.JSONDecodeError, TypeError):
87
+ pass
88
+ return []
89
+
90
+
91
+ def _tool_summary(tools: list[dict]) -> str:
92
+ parts = []
93
+ for t in tools[:4]:
94
+ name = t.get("name", "?")
95
+ params = t.get("parameters", [])
96
+ access = "read"
97
+ if any(
98
+ p.get("name", "").lower() in ("amount", "value", "price") for p in params
99
+ ):
100
+ access = "write·financial"
101
+ elif any(
102
+ p.get("name", "").lower() in ("password", "key", "secret") for p in params
103
+ ):
104
+ access = "write·sensitive"
105
+ elif params:
106
+ access = "write"
107
+ parts.append(f"{name} ({access})")
108
+ if len(tools) > 4:
109
+ parts.append(f"+{len(tools) - 4} more")
110
+ return " · ".join(parts) if parts else "none declared"
111
+
112
+
113
+ def _print_prescan(agent: dict) -> None:
114
+ prompt = agent.get("system_prompt") or agent.get("prompt_preview") or ""
115
+ tokens = max(1, len(prompt) // 4)
116
+ constraints = agent.get("constraints_count", 0) or max(
117
+ 1, prompt.count("must") + prompt.count("never") + prompt.count("always")
118
+ )
119
+ identifiers = agent.get("identifiers_count", 0) or sum(
120
+ 1 for w in prompt.split() if w.startswith("CSB-") or w.startswith("ID:")
121
+ )
122
+
123
+ tools = _parse_tools(agent.get("tools_config", "[]"))
124
+ memory = agent.get("memory_sources", "[]")
125
+ guardrails = agent.get("guardrails", "[]")
126
+
127
+ try:
128
+ mem_list = json.loads(memory) if isinstance(memory, str) else memory
129
+ except (json.JSONDecodeError, TypeError):
130
+ mem_list = []
131
+ try:
132
+ gd_list = json.loads(guardrails) if isinstance(guardrails, str) else guardrails
133
+ except (json.JSONDecodeError, TypeError):
134
+ gd_list = []
135
+
136
+ click.echo(_s(f"\u2726 Orithos v{ORITHOS_VERSION}", fg="green"))
137
+ click.echo(_s(" Loading agent configuration...", dim=True))
138
+ click.echo(
139
+ _s(
140
+ f" \u21b3 System prompt: {tokens} tokens \u00b7 {constraints} constraints extracted \u00b7 {identifiers} identifiers flagged",
141
+ dim=True,
142
+ )
143
+ )
144
+
145
+ tool_line = _tool_summary(tools)
146
+ if tool_line:
147
+ click.echo(_s(f" \u21b3 Tools: {tool_line}", dim=True))
148
+
149
+ if mem_list:
150
+ for m in mem_list[:2]:
151
+ provider = m.get("provider", "?")
152
+ doc_count = m.get("doc_count", "?")
153
+ risk = m.get("risk", "")
154
+ click.echo(
155
+ _s(
156
+ f" \u21b3 RAG: {m.get('name', 'knowledge-base')} \u00b7 {provider} \u00b7 {doc_count} docs"
157
+ + (f" ({risk} risk flagged)" if risk else ""),
158
+ dim=True,
159
+ )
160
+ )
161
+
162
+ for g in gd_list[:2]:
163
+ gtype = g.get("type", "?")
164
+ stage = g.get("stage", "both")
165
+ click.echo(_s(f" \u21b3 Guardrails: {gtype} ({stage})", dim=True))
166
+
167
+ total_probes = agent.get("probe_count", 0) or 245
168
+ click.echo(_s(f" Generating {total_probes} agent-targeted probes...", dim=True))
169
+ click.echo(_s(f" \u21b3 {total_probes} probes \u00b7 mixed categories", dim=True))
170
+ click.echo(_s("\u2726 Scan executing \u00b7 session-isolated", fg="green"))
171
+
172
+
173
+ def _poll_with_progress(
174
+ status_url: str,
175
+ cfg,
176
+ poll_interval: int,
177
+ max_wait: int,
178
+ total_probes: int,
179
+ ) -> dict:
180
+ start_time = time.monotonic()
181
+ last_probe = ""
182
+ while True:
183
+ elapsed = time.monotonic() - start_time
184
+ if elapsed > max_wait:
185
+ click.echo(
186
+ _s(f"Timed out after {max_wait}s.", fg="red", bold=True),
187
+ err=True,
188
+ )
189
+ raise SystemExit(1)
190
+ try:
191
+ r = httpx.get(status_url, headers=cfg.auth_headers(), timeout=cfg.timeout)
192
+ r.raise_for_status()
193
+ except httpx.RequestError as exc:
194
+ click.echo(_s(f"Warning: status poll failed: {exc}", fg="yellow"), err=True)
195
+ time.sleep(poll_interval)
196
+ continue
197
+ scan = r.json()
198
+ status = scan["status"]
199
+ done = scan.get("completed_tests", 0)
200
+ total = scan.get("total_tests", total_probes) or total_probes
201
+ pct = math.floor(done / total * 100) if total > 0 else 0
202
+
203
+ probe_name = scan.get("current_probe_name", "")
204
+ stage = scan.get("current_probe_stage", "")
205
+
206
+ if status in ("completed", "failed"):
207
+ mins = int(elapsed // 60)
208
+ secs = int(elapsed % 60)
209
+ if status == "completed":
210
+ click.echo(
211
+ _s(
212
+ f"\n\u2726 Scan complete \u00b7 {done}/{total} probes \u00b7 {mins}m {secs}s",
213
+ fg="green",
214
+ bold=True,
215
+ )
216
+ )
217
+ else:
218
+ click.echo(
219
+ _s(
220
+ f"\n\u2716 Scan failed \u00b7 {done}/{total} probes \u00b7 {mins}m {secs}s",
221
+ fg="red",
222
+ bold=True,
223
+ )
224
+ )
225
+ return scan
226
+
227
+ if probe_name and probe_name != last_probe:
228
+ last_probe = probe_name
229
+ click.echo(
230
+ _s(
231
+ f" \u26a0 {_fmt(probe_name)} \u2014 {stage or 'running'}",
232
+ fg="yellow",
233
+ )
234
+ )
235
+
236
+ bar_len = 20
237
+ filled = round(bar_len * pct / 100)
238
+ bar = _s("\u2588" * filled, fg="green") + _s(
239
+ "\u2591" * (bar_len - filled), dim=True
240
+ )
241
+ pct_str = (
242
+ _s(f"{pct}%", dim=True) if pct < 100 else _s("100%", fg="green", bold=True)
243
+ )
244
+ click.echo(
245
+ f"\r [{bar}] {pct_str} \u00b7 {_s(f'{done}/{total}', dim=True)} \u00b7 [{_s(f'{elapsed:.0f}s', dim=True)}]",
246
+ nl=False,
247
+ )
248
+ time.sleep(poll_interval)
249
+
250
+
251
+ def _print_completion(scan: dict, findings: list[FindingOutput]) -> None:
252
+ risk = scan.get("severity", "unknown").upper()
253
+ total = len(findings)
254
+ by_sev: dict[str, int] = {}
255
+ for f in findings:
256
+ by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
257
+
258
+ crit = by_sev.get("critical", 0)
259
+ high = by_sev.get("high", 0)
260
+ med = by_sev.get("medium", 0)
261
+ low = by_sev.get("low", 0)
262
+
263
+ parts = []
264
+ if crit:
265
+ parts.append(f"{crit} critical")
266
+ if high:
267
+ parts.append(f"{high} high")
268
+ if med:
269
+ parts.append(f"{med} medium")
270
+ if low:
271
+ parts.append(f"{low} low")
272
+ findings_str = " \u00b7 ".join(parts) if parts else "0 findings"
273
+
274
+ risk_c = _sev_color(risk.lower())
275
+ click.echo(
276
+ _s(" Risk score: ", dim=True)
277
+ + _s(risk, fg=risk_c, bold=True)
278
+ + _s(f" \u00b7 {findings_str}", dim=True)
279
+ )
280
+ click.echo(_s(" Scan ID: ", dim=True) + _s(scan["id"], dim=True))
281
+
282
+ if findings:
283
+ click.echo("")
284
+ for f in findings[:3]:
285
+ fc = _sev_color(f.severity)
286
+ click.echo(
287
+ f" [{_s(f.severity.upper(), fg=fc, bold=True)}] {_fmt(f.attack_path)}"
288
+ )
289
+ if f.recommendation:
290
+ click.echo(_s(f" {_fmt(f.recommendation)}", dim=True))
291
+ if total > 3:
292
+ click.echo(_s(f" ... and {total - 3} more findings", dim=True))
293
+ else:
294
+ click.echo(_s(" No findings \u2014 agent is secure.", fg="green"))
295
+
296
+
297
+ def _run_scan(
298
+ agent: str,
299
+ probes: str,
300
+ output_format: str,
301
+ poll_interval: int,
302
+ max_wait: int,
303
+ output_file: str | None,
304
+ connection_id: str | None = None,
305
+ config: dict | None = None,
306
+ ) -> None:
307
+ cfg = get_config()
308
+
309
+ agent_obj = _resolve_agent(cfg, agent)
310
+ if not agent_obj:
311
+ click.echo(f"Error: agent '{agent}' not found", err=True)
312
+ raise SystemExit(1)
313
+
314
+ agent_id = agent_obj["id"]
315
+ total_probes = agent_obj.get("probe_count", 0) or 245
316
+
317
+ _print_prescan(agent_obj)
318
+
319
+ # Auto-use preferred connection if no explicit connection given
320
+ if not connection_id and agent_obj.get("preferred_connection_id"):
321
+ connection_id = agent_obj["preferred_connection_id"]
322
+ click.echo(
323
+ click.style(f" Using preferred connection: {connection_id[:8]}…", dim=True)
324
+ )
325
+
326
+ probe_list = [p.strip() for p in probes.split(",") if p.strip()]
327
+ body: dict = {"agent_id": agent_id, "probe_set": probe_list}
328
+ if connection_id:
329
+ body["connection_id"] = connection_id
330
+ if config:
331
+ body["config"] = config
332
+ try:
333
+ r = httpx.post(
334
+ f"{cfg.api_url}/v1/scans",
335
+ json=body,
336
+ headers=cfg.auth_headers(),
337
+ timeout=cfg.timeout,
338
+ )
339
+ r.raise_for_status()
340
+ except httpx.HTTPStatusError as exc:
341
+ click.echo(f"Error creating scan: {fmt_http_error(exc)}", err=True)
342
+ raise SystemExit(1)
343
+ except httpx.RequestError as exc:
344
+ click.echo(f"Connection error: {exc}", err=True)
345
+ raise SystemExit(1)
346
+
347
+ scan_data = r.json()
348
+ scan_id = scan_data["scan_id"]
349
+
350
+ # Poll with progress
351
+ status_url = f"{cfg.api_url}/v1/scans/{scan_id}"
352
+ scan_status = _poll_with_progress(
353
+ status_url, cfg, poll_interval, max_wait, total_probes
354
+ )
355
+
356
+ if scan_status["status"] == "failed":
357
+ click.echo("", err=True)
358
+ raise SystemExit(1)
359
+
360
+ # Fetch findings
361
+ try:
362
+ fr = httpx.get(
363
+ f"{status_url}/findings",
364
+ headers=cfg.auth_headers(),
365
+ timeout=cfg.timeout,
366
+ )
367
+ fr.raise_for_status()
368
+ except httpx.RequestError as exc:
369
+ click.echo(f"Error fetching findings: {exc}", err=True)
370
+ raise SystemExit(1)
371
+
372
+ findings_data = fr.json()
373
+ # GET /v1/scans/{id}/findings returns a PaginatedFindingsResponse
374
+ # envelope ({"items": [...], "total": ...}), not a bare list.
375
+ if isinstance(findings_data, dict):
376
+ findings_data = findings_data.get("items", [])
377
+ findings = [FindingOutput(**f) for f in findings_data]
378
+
379
+ if output_format == "summary":
380
+ _print_completion(scan_status, findings)
381
+ return
382
+
383
+ # Structured output
384
+ scan_out = ScanStatusOutput(
385
+ id=scan_id,
386
+ status=scan_status["status"],
387
+ severity=scan_status.get("severity"),
388
+ total_tests=scan_status.get("total_tests", 0),
389
+ completed_tests=scan_status.get("completed_tests", 0),
390
+ )
391
+
392
+ if output_format == "json":
393
+ output = format_json(findings, scan_out)
394
+ elif output_format == "sarif":
395
+ output = format_sarif(findings, scan_out, cfg.api_url)
396
+ elif output_format == "junit":
397
+ output = format_junit(findings, scan_out)
398
+ else:
399
+ output = format_summary(findings, scan_out)
400
+
401
+ if output_file:
402
+ try:
403
+ with open(output_file, "w") as f:
404
+ f.write(output)
405
+ click.echo(f"Output written to {output_file}")
406
+ except OSError as exc:
407
+ click.echo(f"Failed to write output file: {exc}", err=True)
408
+ raise SystemExit(1)
409
+ else:
410
+ click.echo(output)
411
+
412
+
413
+ @click.group(name="scan", invoke_without_command=True)
414
+ @click.pass_context
415
+ @click.option("--agent", "-a", default="", help="Agent name or ID to scan")
416
+ @click.option(
417
+ "--connection",
418
+ "-c",
419
+ default="",
420
+ help="Connection name or ID (default: agent's preferred connection)",
421
+ )
422
+ @click.option(
423
+ "--probes",
424
+ default=",".join(DEFAULT_PROBES),
425
+ help="Comma-separated probe names (default: all probes)",
426
+ show_default=False,
427
+ )
428
+ @click.option(
429
+ "--format",
430
+ "output_format",
431
+ type=click.Choice(["summary", "json", "sarif", "junit"], case_sensitive=False),
432
+ default="summary",
433
+ help="Output format",
434
+ )
435
+ @click.option(
436
+ "--poll-interval",
437
+ default=2,
438
+ type=int,
439
+ help="Seconds between status polls (default: 2)",
440
+ hidden=True,
441
+ )
442
+ @click.option(
443
+ "--max-wait",
444
+ default=600,
445
+ type=int,
446
+ help="Max seconds to wait for scan completion (default: 600)",
447
+ )
448
+ @click.option(
449
+ "--output-file",
450
+ "output_file",
451
+ type=click.Path(dir_okay=False, writable=True),
452
+ default=None,
453
+ help="Write output to file instead of stdout",
454
+ )
455
+ def scan_group(
456
+ ctx: click.Context,
457
+ agent: str,
458
+ connection: str,
459
+ probes: str,
460
+ output_format: str,
461
+ poll_interval: int,
462
+ max_wait: int,
463
+ output_file: str | None,
464
+ ) -> None:
465
+ """Scan agents for security vulnerabilities.
466
+
467
+ Runs a full security scan against an agent and streams live results.
468
+
469
+ Examples:
470
+
471
+ orithos scan --agent "Customer Support Agent"
472
+
473
+ orithos scan --agent "Customer Support Agent" --connection staging
474
+
475
+ orithos scan --agent 12c2733b --format json
476
+ """
477
+ if ctx.invoked_subcommand is not None:
478
+ return
479
+
480
+ if not agent:
481
+ click.echo(ctx.get_help())
482
+ return
483
+
484
+ connection_id = connection or None
485
+ _run_scan(
486
+ agent,
487
+ probes,
488
+ output_format,
489
+ poll_interval,
490
+ max_wait,
491
+ output_file,
492
+ connection_id,
493
+ )
494
+
495
+
496
+ @scan_group.command("new")
497
+ def scan_new() -> None:
498
+ """Interactive scan wizard — configure and launch."""
499
+ from orithos_cli.wizard import interactive_wizard
500
+
501
+ interactive_wizard()
502
+
503
+
504
+ @scan_group.command("run")
505
+ def scan_run() -> None:
506
+ """Interactive scan wizard — alias for 'scan new'."""
507
+ from orithos_cli.wizard import interactive_wizard
508
+
509
+ interactive_wizard()
510
+
511
+
512
+ @scan_group.command("status")
513
+ @click.argument("scan_id")
514
+ def scan_status(scan_id: str) -> None:
515
+ """Check the status of a scan."""
516
+ cfg = get_config()
517
+ try:
518
+ r = httpx.get(
519
+ f"{cfg.api_url}/v1/scans/{scan_id}",
520
+ headers=cfg.auth_headers(),
521
+ timeout=cfg.timeout,
522
+ )
523
+ r.raise_for_status()
524
+ except httpx.HTTPStatusError as exc:
525
+ click.echo(f"Scan not found: {exc.response.status_code}", err=True)
526
+ raise SystemExit(1)
527
+ except httpx.RequestError as exc:
528
+ click.echo(f"Connection error: {exc}", err=True)
529
+ raise SystemExit(1)
530
+
531
+ data = r.json()
532
+ click.echo(f"Scan: {data['id']}")
533
+ click.echo(f"Status: {data['status']}")
534
+ click.echo(f"Severity: {data.get('severity') or 'N/A'}")
535
+ click.echo(
536
+ f"Tests: {data.get('completed_tests', 0)}/{data.get('total_tests', 0)}"
537
+ )
538
+ if data.get("started_at"):
539
+ click.echo(f"Started: {data['started_at']}")
540
+ if data.get("completed_at"):
541
+ click.echo(f"Completed: {data['completed_at']}")
542
+
543
+
544
+ @scan_group.command("findings")
545
+ @click.argument("scan_id")
546
+ def scan_findings(scan_id: str) -> None:
547
+ """Print findings for a completed scan."""
548
+ cfg = get_config()
549
+ try:
550
+ r = httpx.get(
551
+ f"{cfg.api_url}/v1/scans/{scan_id}/findings",
552
+ headers=cfg.auth_headers(),
553
+ timeout=cfg.timeout,
554
+ )
555
+ r.raise_for_status()
556
+ except httpx.HTTPStatusError as exc:
557
+ click.echo(f"Error: {exc.response.status_code}", err=True)
558
+ raise SystemExit(1)
559
+ except httpx.RequestError as exc:
560
+ click.echo(f"Connection error: {exc}", err=True)
561
+ raise SystemExit(1)
562
+
563
+ findings = r.json()
564
+ if not findings:
565
+ click.echo("No findings.")
566
+ return
567
+ for f in findings:
568
+ click.echo(
569
+ f"[{f['severity'].upper()}] {f.get('attack_path', f.get('name', '?'))}"
570
+ )
571
+ if f.get("recommendation"):
572
+ click.echo(f" {f['recommendation']}")
573
+ click.echo("")
574
+
575
+
576
+ @scan_group.command("rerun")
577
+ @click.argument("scan_id")
578
+ @click.option(
579
+ "--scope",
580
+ "rerun_scope",
581
+ type=click.Choice(["same", "failed_only", "passed_only"], case_sensitive=False),
582
+ default="same",
583
+ help="Probe scope for rerun",
584
+ )
585
+ @click.option(
586
+ "--format",
587
+ "output_format",
588
+ type=click.Choice(["summary", "json", "sarif", "junit"], case_sensitive=False),
589
+ default="summary",
590
+ help="Output format",
591
+ )
592
+ @click.option(
593
+ "--output-file",
594
+ type=click.Path(dir_okay=False, writable=True),
595
+ default=None,
596
+ help="Write output to file instead of stdout",
597
+ )
598
+ @click.option(
599
+ "--poll-interval",
600
+ default=2,
601
+ type=int,
602
+ help="Seconds between status polls (default: 2)",
603
+ hidden=True,
604
+ )
605
+ @click.option(
606
+ "--max-wait",
607
+ default=600,
608
+ type=int,
609
+ help="Max seconds to wait for scan completion (default: 600)",
610
+ )
611
+ def scan_rerun(
612
+ scan_id: str,
613
+ rerun_scope: str,
614
+ output_format: str,
615
+ output_file: str | None,
616
+ poll_interval: int,
617
+ max_wait: int,
618
+ ) -> None:
619
+ """Rerun an existing scan with scope options."""
620
+ cfg = get_config()
621
+
622
+ try:
623
+ r = httpx.post(
624
+ f"{cfg.api_url}/v1/scans/{scan_id}/rerun",
625
+ params={"scope": rerun_scope},
626
+ headers=cfg.auth_headers(),
627
+ timeout=cfg.timeout,
628
+ )
629
+ r.raise_for_status()
630
+ except httpx.HTTPStatusError as exc:
631
+ click.echo(f"Error rerunning scan: {fmt_http_error(exc)}", err=True)
632
+ raise SystemExit(1)
633
+ except httpx.RequestError as exc:
634
+ click.echo(f"Connection error: {exc}", err=True)
635
+ raise SystemExit(1)
636
+
637
+ rerun_data = r.json()
638
+ new_scan_id = rerun_data["scan_id"]
639
+ click.echo(f"Rerun created: {new_scan_id} (originated from {scan_id})")
640
+
641
+ status_url = f"{cfg.api_url}/v1/scans/{new_scan_id}"
642
+ scan_status = _poll_with_progress(status_url, cfg, poll_interval, max_wait, 0)
643
+
644
+ if scan_status["status"] == "failed":
645
+ raise SystemExit(1)
646
+
647
+ try:
648
+ fr = httpx.get(
649
+ f"{status_url}/findings",
650
+ headers=cfg.auth_headers(),
651
+ timeout=cfg.timeout,
652
+ )
653
+ fr.raise_for_status()
654
+ except httpx.RequestError as exc:
655
+ click.echo(f"Error fetching findings: {exc}", err=True)
656
+ raise SystemExit(1)
657
+
658
+ findings_data = fr.json()
659
+ # GET /v1/scans/{id}/findings returns a PaginatedFindingsResponse
660
+ # envelope ({"items": [...], "total": ...}), not a bare list.
661
+ if isinstance(findings_data, dict):
662
+ findings_data = findings_data.get("items", [])
663
+ findings = [FindingOutput(**f) for f in findings_data]
664
+ scan_out = ScanStatusOutput(
665
+ id=new_scan_id,
666
+ status=scan_status["status"],
667
+ severity=scan_status.get("severity"),
668
+ total_tests=scan_status.get("total_tests", 0),
669
+ completed_tests=scan_status.get("completed_tests", 0),
670
+ )
671
+
672
+ if output_format == "json":
673
+ output = format_json(findings, scan_out)
674
+ elif output_format == "sarif":
675
+ output = format_sarif(findings, scan_out, cfg.api_url)
676
+ elif output_format == "junit":
677
+ output = format_junit(findings, scan_out)
678
+ else:
679
+ output = format_summary(findings, scan_out)
680
+
681
+ if output_file:
682
+ try:
683
+ with open(output_file, "w") as f:
684
+ f.write(output)
685
+ click.echo(f"Output written to {output_file}")
686
+ except OSError as exc:
687
+ click.echo(f"Failed to write output file: {exc}", err=True)
688
+ raise SystemExit(1)
689
+ else:
690
+ click.echo(output)
691
+
692
+
693
+ @scan_group.command("report")
694
+ @click.argument("scan_id")
695
+ @click.option(
696
+ "--format",
697
+ "output_format",
698
+ type=click.Choice(["summary", "json"], case_sensitive=False),
699
+ default="summary",
700
+ help="Output format",
701
+ )
702
+ def scan_report(scan_id: str, output_format: str) -> None:
703
+ """Generate a full security report for a completed scan."""
704
+ cfg = get_config()
705
+ try:
706
+ r = httpx.get(
707
+ f"{cfg.api_url}/v1/scans/{scan_id}/report",
708
+ headers=cfg.auth_headers(),
709
+ timeout=cfg.timeout,
710
+ )
711
+ r.raise_for_status()
712
+ except httpx.HTTPStatusError as exc:
713
+ click.echo(f"Scan not found: {exc.response.status_code}", err=True)
714
+ raise SystemExit(1)
715
+ except httpx.RequestError as exc:
716
+ click.echo(f"Connection error: {exc}", err=True)
717
+ raise SystemExit(1)
718
+
719
+ report = r.json()
720
+ if output_format == "json":
721
+ click.echo(json.dumps(report, indent=2))
722
+ return
723
+
724
+ click.echo("Orithos Security Report")
725
+ click.echo(f"{'=' * 50}")
726
+ click.echo(f"Scan ID: {report['scan_id']}")
727
+ click.echo(f"Status: {report['status']}")
728
+ click.echo(f"Severity: {report.get('severity') or 'N/A'}")
729
+ click.echo(f"Agent ID: {report['agent_id']}")
730
+
731
+ summary = report.get("summary", {})
732
+ click.echo("")
733
+ click.echo("Summary:")
734
+ click.echo(f" Total tests: {summary.get('total', 0)}")
735
+ click.echo(f" Completed: {summary.get('completed', 0)}")
736
+ click.echo(f" High: {summary.get('high', 0)}")
737
+ click.echo(f" Medium: {summary.get('medium', 0)}")
738
+ click.echo(f" Low: {summary.get('low', 0)}")
739
+
740
+ findings = report.get("findings", [])
741
+ click.echo("")
742
+ click.echo(f"Findings ({len(findings)}):")
743
+ if findings:
744
+ for f in findings:
745
+ click.echo(
746
+ f" [{f['severity'].upper()}] {f.get('attack_path', f.get('name', '?'))}"
747
+ )
748
+ if f.get("recommendation"):
749
+ click.echo(f" {f['recommendation']}")
750
+ else:
751
+ click.echo(" No findings \u2014 agent is secure.")
752
+
753
+ recommendations = report.get("recommendations", [])
754
+ if recommendations:
755
+ click.echo("")
756
+ click.echo("Top Recommendations:")
757
+ for i, rec in enumerate(recommendations[:5], 1):
758
+ click.echo(f" {i}. {rec}")