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.
- orithos_cli/__init__.py +6 -0
- orithos_cli/agent.py +78 -0
- orithos_cli/agent_wizard.py +255 -0
- orithos_cli/auth.py +21 -0
- orithos_cli/cli.py +82 -0
- orithos_cli/compliance.py +133 -0
- orithos_cli/config.py +114 -0
- orithos_cli/configure.py +103 -0
- orithos_cli/connection.py +69 -0
- orithos_cli/connection_wizard.py +129 -0
- orithos_cli/discovery.py +78 -0
- orithos_cli/graph.py +102 -0
- orithos_cli/guardrail.py +131 -0
- orithos_cli/mcp.py +170 -0
- orithos_cli/output.py +178 -0
- orithos_cli/probes.py +50 -0
- orithos_cli/remediation.py +111 -0
- orithos_cli/runtime.py +100 -0
- orithos_cli/scan.py +758 -0
- orithos_cli/skill.py +64 -0
- orithos_cli/skillscan/__init__.py +37 -0
- orithos_cli/skillscan/checks/__init__.py +28 -0
- orithos_cli/skillscan/checks/credentials.py +112 -0
- orithos_cli/skillscan/checks/iocs.py +95 -0
- orithos_cli/skillscan/checks/manifest.py +156 -0
- orithos_cli/skillscan/checks/network.py +117 -0
- orithos_cli/skillscan/checks/obfuscation.py +130 -0
- orithos_cli/skillscan/checks/permissions.py +108 -0
- orithos_cli/skillscan/checks/shell.py +140 -0
- orithos_cli/skillscan/collect.py +205 -0
- orithos_cli/skillscan/model.py +99 -0
- orithos_cli/skillscan/report.py +130 -0
- orithos_cli/template.py +60 -0
- orithos_cli/verify.py +85 -0
- orithos_cli/wizard.py +314 -0
- orithos_cli-0.1.0.dist-info/METADATA +99 -0
- orithos_cli-0.1.0.dist-info/RECORD +40 -0
- orithos_cli-0.1.0.dist-info/WHEEL +5 -0
- orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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}")
|