prismor-cli 1.3.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.
- prismor/__init__.py +6 -0
- prismor/api.py +770 -0
- prismor/cli.py +1305 -0
- prismor/cli_config.py +55 -0
- prismor/local_fix.py +338 -0
- prismor/sanitize.py +179 -0
- prismor_cli-1.3.0.dist-info/METADATA +919 -0
- prismor_cli-1.3.0.dist-info/RECORD +12 -0
- prismor_cli-1.3.0.dist-info/WHEEL +5 -0
- prismor_cli-1.3.0.dist-info/entry_points.txt +2 -0
- prismor_cli-1.3.0.dist-info/licenses/LICENSE +22 -0
- prismor_cli-1.3.0.dist-info/top_level.txt +1 -0
prismor/cli.py
ADDED
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
"""Command-line interface for Prismor security scanning tool."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import click
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .api import PrismorClient, PrismorAPIError, parse_github_repo, DEFAULT_SCAN_POLL_INTERVAL_SECONDS, DEFAULT_SCAN_MAX_WAIT_SECONDS
|
|
13
|
+
from .sanitize import strip_sensitive, build_fix_prompt
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.spinner import Spinner as RichSpinner
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
from rich import box
|
|
23
|
+
RICH_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
RICH_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
console = Console() if RICH_AVAILABLE else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_git_branch() -> Optional[str]:
|
|
31
|
+
"""Auto-detect the current git branch if running inside a git repository."""
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
35
|
+
capture_output=True, text=True, timeout=5
|
|
36
|
+
)
|
|
37
|
+
if result.returncode == 0:
|
|
38
|
+
branch = result.stdout.strip()
|
|
39
|
+
if branch and branch != 'HEAD':
|
|
40
|
+
return branch
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_success(message: str):
|
|
47
|
+
click.secho(f"✓ {message}", fg="green")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def print_error(message: str):
|
|
51
|
+
click.secho(f"✗ {message}", fg="red", err=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def print_info(message: str):
|
|
55
|
+
click.secho(f"ℹ {message}", fg="blue")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def print_warning(message: str):
|
|
59
|
+
click.secho(f"⚠ {message}", fg="yellow")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Spinner:
|
|
63
|
+
"""Simple spinner for showing loading state."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str = "Processing"):
|
|
66
|
+
self.message = message
|
|
67
|
+
self.spinner_chars = "|/-\\"
|
|
68
|
+
self.spinner_index = 0
|
|
69
|
+
self.running = False
|
|
70
|
+
self.thread = None
|
|
71
|
+
|
|
72
|
+
def _spin(self):
|
|
73
|
+
while self.running:
|
|
74
|
+
char = self.spinner_chars[self.spinner_index % len(self.spinner_chars)]
|
|
75
|
+
sys.stdout.write(f"\r{char} {self.message}...")
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
self.spinner_index += 1
|
|
78
|
+
time.sleep(0.1)
|
|
79
|
+
|
|
80
|
+
def start(self):
|
|
81
|
+
self.running = True
|
|
82
|
+
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
83
|
+
self.thread.start()
|
|
84
|
+
|
|
85
|
+
def stop(self, message: str = None):
|
|
86
|
+
self.running = False
|
|
87
|
+
if self.thread:
|
|
88
|
+
self.thread.join(timeout=0.2)
|
|
89
|
+
sys.stdout.write("\r" + " " * (len(self.message) + 15) + "\r")
|
|
90
|
+
sys.stdout.flush()
|
|
91
|
+
if message:
|
|
92
|
+
click.echo(message)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def should_show_spinner() -> bool:
|
|
96
|
+
"""Show spinners only in interactive terminals, never in CI logs."""
|
|
97
|
+
if os.environ.get("PRISMOR_NO_SPINNER", "").strip().lower() in {"1", "true", "yes", "on"}:
|
|
98
|
+
return False
|
|
99
|
+
if os.environ.get("GITHUB_ACTIONS", "").strip().lower() == "true":
|
|
100
|
+
return False
|
|
101
|
+
if os.environ.get("CI", "").strip().lower() == "true":
|
|
102
|
+
return False
|
|
103
|
+
return sys.stdout.isatty()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Severity display config
|
|
107
|
+
_SEVERITY_COLORS = {
|
|
108
|
+
"CRITICAL": "bold red",
|
|
109
|
+
"HIGH": "red",
|
|
110
|
+
"MEDIUM": "yellow",
|
|
111
|
+
"LOW": "blue",
|
|
112
|
+
"UNKNOWN": "dim",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_SEVERITY_CLICK_COLORS = {
|
|
116
|
+
"CRITICAL": ("red", True),
|
|
117
|
+
"HIGH": ("red", False),
|
|
118
|
+
"MEDIUM": ("yellow", False),
|
|
119
|
+
"LOW": ("blue", False),
|
|
120
|
+
"UNKNOWN": (None, False),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
MAX_VULN_ROWS = 5
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _render_vuln_table_rich(vulns: list, dashboard_url: Optional[str] = None):
|
|
127
|
+
"""Render a Rich vulnerability table capped at MAX_VULN_ROWS."""
|
|
128
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan", expand=False)
|
|
129
|
+
table.add_column("Severity", min_width=10)
|
|
130
|
+
table.add_column("CVE / ID", min_width=20)
|
|
131
|
+
table.add_column("Package", min_width=18)
|
|
132
|
+
table.add_column("Installed", min_width=12)
|
|
133
|
+
table.add_column("Fixed In", min_width=12)
|
|
134
|
+
|
|
135
|
+
shown = vulns[:MAX_VULN_ROWS]
|
|
136
|
+
for v in shown:
|
|
137
|
+
sev = (v.get("Severity") or v.get("severity") or "UNKNOWN").upper()
|
|
138
|
+
style = _SEVERITY_COLORS.get(sev, "dim")
|
|
139
|
+
cve = v.get("VulnerabilityID") or v.get("id") or "—"
|
|
140
|
+
pkg = v.get("PkgName") or v.get("package") or v.get("pkg") or "—"
|
|
141
|
+
installed = v.get("InstalledVersion") or v.get("installed_version") or "—"
|
|
142
|
+
fixed = v.get("FixedVersion") or v.get("fixed_version") or "—"
|
|
143
|
+
table.add_row(
|
|
144
|
+
Text(sev, style=style),
|
|
145
|
+
cve,
|
|
146
|
+
pkg,
|
|
147
|
+
installed,
|
|
148
|
+
fixed,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
console.print(table)
|
|
152
|
+
|
|
153
|
+
remaining = len(vulns) - MAX_VULN_ROWS
|
|
154
|
+
if remaining > 0:
|
|
155
|
+
msg = f" ... and {remaining} more"
|
|
156
|
+
if dashboard_url:
|
|
157
|
+
msg += f" — view full report: {dashboard_url}"
|
|
158
|
+
click.secho(msg, fg="cyan")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _render_vuln_table_plain(vulns: list, dashboard_url: Optional[str] = None):
|
|
162
|
+
"""Fallback plain-text vulnerability table."""
|
|
163
|
+
header = f" {'SEVERITY':<12} {'CVE / ID':<26} {'PACKAGE':<20} {'INSTALLED':<14} {'FIXED IN'}"
|
|
164
|
+
click.echo(header)
|
|
165
|
+
click.echo(" " + "-" * 90)
|
|
166
|
+
for v in vulns[:MAX_VULN_ROWS]:
|
|
167
|
+
sev = (v.get("Severity") or v.get("severity") or "UNKNOWN").upper()
|
|
168
|
+
fg, bold = _SEVERITY_CLICK_COLORS.get(sev, (None, False))
|
|
169
|
+
cve = (v.get("VulnerabilityID") or v.get("id") or "—")[:25]
|
|
170
|
+
pkg = (v.get("PkgName") or v.get("package") or "—")[:19]
|
|
171
|
+
installed = (v.get("InstalledVersion") or v.get("installed_version") or "—")[:13]
|
|
172
|
+
fixed = (v.get("FixedVersion") or v.get("fixed_version") or "—")[:13]
|
|
173
|
+
row = f" {sev:<12} {cve:<26} {pkg:<20} {installed:<14} {fixed}"
|
|
174
|
+
click.secho(row, fg=fg, bold=bold)
|
|
175
|
+
|
|
176
|
+
remaining = len(vulns) - MAX_VULN_ROWS
|
|
177
|
+
if remaining > 0:
|
|
178
|
+
msg = f" ... and {remaining} more"
|
|
179
|
+
if dashboard_url:
|
|
180
|
+
msg += f" — view full report: {dashboard_url}"
|
|
181
|
+
click.secho(msg, fg="cyan")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def render_vuln_table(vulns: list, dashboard_url: Optional[str] = None):
|
|
185
|
+
"""Render vulnerability table using Rich if available, else plain text."""
|
|
186
|
+
if not vulns:
|
|
187
|
+
click.secho(" No vulnerabilities found ✓", fg="green")
|
|
188
|
+
return
|
|
189
|
+
if RICH_AVAILABLE and should_show_spinner():
|
|
190
|
+
_render_vuln_table_rich(vulns, dashboard_url)
|
|
191
|
+
else:
|
|
192
|
+
_render_vuln_table_plain(vulns, dashboard_url)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Sanitization + prompt-building helpers live in prismor.sanitize so the
|
|
196
|
+
# local-fix path can share them without a circular import. Keep the private
|
|
197
|
+
# alias for the existing call sites in this module.
|
|
198
|
+
_strip_sensitive = strip_sensitive
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_prompt_results(results: dict, repo: str):
|
|
202
|
+
"""Format results with an LLM prompt for fixing vulnerabilities."""
|
|
203
|
+
click.echo(build_fix_prompt(results, repo, mode="advise"))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_scan_results(results: dict, scan_type: str, dashboard_url: Optional[str] = None):
|
|
207
|
+
"""Format and display scan results."""
|
|
208
|
+
click.echo("\n" + "=" * 60)
|
|
209
|
+
click.secho(f" Scan Results - {scan_type}", fg="cyan", bold=True)
|
|
210
|
+
click.echo("=" * 60 + "\n")
|
|
211
|
+
|
|
212
|
+
if "repository" in results:
|
|
213
|
+
click.secho("Repository:", fg="yellow", bold=True)
|
|
214
|
+
click.echo(f" {results['repository']}\n")
|
|
215
|
+
|
|
216
|
+
if "branch" in results:
|
|
217
|
+
click.secho("Branch:", fg="yellow", bold=True)
|
|
218
|
+
click.echo(f" {results['branch']}\n")
|
|
219
|
+
|
|
220
|
+
if "commit_sha" in results:
|
|
221
|
+
click.secho("Commit SHA:", fg="yellow", bold=True)
|
|
222
|
+
click.echo(f" {results['commit_sha']}\n")
|
|
223
|
+
|
|
224
|
+
if "scans" in results:
|
|
225
|
+
scans = results["scans"]
|
|
226
|
+
|
|
227
|
+
# Vulnerability scan results
|
|
228
|
+
if "vulnerability" in scans:
|
|
229
|
+
vuln_scan = scans["vulnerability"]
|
|
230
|
+
click.secho("Vulnerability Scan:", fg="yellow", bold=True)
|
|
231
|
+
status_color = "green" if vuln_scan.get("status") in ["success", "completed"] else "red"
|
|
232
|
+
click.secho(f" Status: {vuln_scan.get('status', 'unknown')}", fg=status_color)
|
|
233
|
+
|
|
234
|
+
vulns = []
|
|
235
|
+
if "scan_results" in vuln_scan:
|
|
236
|
+
scan_data = vuln_scan["scan_results"]
|
|
237
|
+
# Flat list format (legacy)
|
|
238
|
+
flat = scan_data.get("vulnerabilities")
|
|
239
|
+
if isinstance(flat, list) and flat and isinstance(flat[0], dict) and "Severity" in flat[0]:
|
|
240
|
+
vulns = flat
|
|
241
|
+
else:
|
|
242
|
+
# Trivy format: Results[].Vulnerabilities[]
|
|
243
|
+
for target in scan_data.get("Results", []):
|
|
244
|
+
vulns.extend(target.get("Vulnerabilities") or [])
|
|
245
|
+
# Final fallback: top-level Results as flat list
|
|
246
|
+
if not vulns:
|
|
247
|
+
top = scan_data.get("Results", [])
|
|
248
|
+
if isinstance(top, list) and top and "Severity" in top[0]:
|
|
249
|
+
vulns = top
|
|
250
|
+
|
|
251
|
+
if vulns:
|
|
252
|
+
click.secho(f" Vulnerabilities Found: {len(vulns)}", fg="red" if len(vulns) > 0 else "green")
|
|
253
|
+
click.echo()
|
|
254
|
+
render_vuln_table(vulns, dashboard_url)
|
|
255
|
+
else:
|
|
256
|
+
click.secho(" Vulnerabilities Found: 0", fg="green")
|
|
257
|
+
|
|
258
|
+
if "public_url" in vuln_scan:
|
|
259
|
+
click.echo(f"\n Results URL: {vuln_scan['public_url']}")
|
|
260
|
+
click.echo()
|
|
261
|
+
|
|
262
|
+
# SBOM results
|
|
263
|
+
if "sbom" in scans:
|
|
264
|
+
sbom_scan = scans["sbom"]
|
|
265
|
+
click.secho("SBOM Generation:", fg="yellow", bold=True)
|
|
266
|
+
status_color = "green" if sbom_scan.get("status") in ["success", "completed"] else "red"
|
|
267
|
+
click.secho(f" Status: {sbom_scan.get('status', 'unknown')}", fg=status_color)
|
|
268
|
+
if "sbom" in sbom_scan:
|
|
269
|
+
sbom_data = sbom_scan["sbom"]
|
|
270
|
+
if isinstance(sbom_data, list):
|
|
271
|
+
click.echo(f" Artifacts Found: {len(sbom_data)}")
|
|
272
|
+
if "public_url" in sbom_scan:
|
|
273
|
+
click.echo(f" Results URL: {sbom_scan['public_url']}")
|
|
274
|
+
click.echo()
|
|
275
|
+
|
|
276
|
+
# Secret scan results
|
|
277
|
+
if "secret" in scans:
|
|
278
|
+
secret_scan = scans["secret"]
|
|
279
|
+
click.secho("Secret Detection:", fg="yellow", bold=True)
|
|
280
|
+
status_color = "green" if secret_scan.get("status") in ["success", "completed"] else "red"
|
|
281
|
+
click.secho(f" Status: {secret_scan.get('status', 'unknown')}", fg=status_color)
|
|
282
|
+
|
|
283
|
+
if "summary" in secret_scan:
|
|
284
|
+
summary = secret_scan["summary"]
|
|
285
|
+
if isinstance(summary, dict):
|
|
286
|
+
total = summary.get("total", 0)
|
|
287
|
+
click.echo(f" Total Detected: {total}")
|
|
288
|
+
ai_triage = summary.get("ai_triage")
|
|
289
|
+
if isinstance(ai_triage, dict) and ai_triage.get("total", 0) > 0:
|
|
290
|
+
real = ai_triage.get("real_secrets", 0)
|
|
291
|
+
false_pos = ai_triage.get("false_positives", 0)
|
|
292
|
+
click.secho(" AI Triage:", fg="cyan", bold=True)
|
|
293
|
+
if real > 0:
|
|
294
|
+
click.secho(f" Real Secrets: {real}", fg="red", bold=True)
|
|
295
|
+
else:
|
|
296
|
+
click.secho(f" Real Secrets: {real}", fg="green")
|
|
297
|
+
click.secho(f" False Positives: {false_pos}", fg="bright_black")
|
|
298
|
+
by_severity = ai_triage.get("by_severity", {})
|
|
299
|
+
if by_severity:
|
|
300
|
+
click.secho(" Severity Breakdown:", fg="cyan")
|
|
301
|
+
if by_severity.get("CRITICAL", 0) > 0:
|
|
302
|
+
click.secho(f" CRITICAL: {by_severity['CRITICAL']}", fg="red", bold=True)
|
|
303
|
+
if by_severity.get("HIGH", 0) > 0:
|
|
304
|
+
click.secho(f" HIGH: {by_severity['HIGH']}", fg="red")
|
|
305
|
+
if by_severity.get("MEDIUM", 0) > 0:
|
|
306
|
+
click.secho(f" MEDIUM: {by_severity['MEDIUM']}", fg="yellow")
|
|
307
|
+
if by_severity.get("LOW", 0) > 0:
|
|
308
|
+
click.secho(f" LOW: {by_severity['LOW']}", fg="blue")
|
|
309
|
+
if by_severity.get("FALSE_POSITIVE", 0) > 0:
|
|
310
|
+
click.secho(f" FALSE POSITIVE: {by_severity['FALSE_POSITIVE']}", fg="bright_black")
|
|
311
|
+
else:
|
|
312
|
+
for key, value in summary.items():
|
|
313
|
+
if key not in ("total", "ai_triage"):
|
|
314
|
+
click.echo(f" {key}: {value}")
|
|
315
|
+
else:
|
|
316
|
+
click.echo(f" Summary: {summary}")
|
|
317
|
+
|
|
318
|
+
if "public_url" in secret_scan:
|
|
319
|
+
click.echo(f" Results URL: {secret_scan['public_url']}")
|
|
320
|
+
click.echo()
|
|
321
|
+
|
|
322
|
+
if "scanned_at" in results:
|
|
323
|
+
click.secho("Scanned At:", fg="yellow", bold=True)
|
|
324
|
+
click.echo(f" {results['scanned_at']}\n")
|
|
325
|
+
|
|
326
|
+
click.echo("=" * 60 + "\n")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _watch_scan(client: PrismorClient, job_id: str):
|
|
330
|
+
"""Poll scan status with a live spinner until done, then return status_data."""
|
|
331
|
+
poll_interval = DEFAULT_SCAN_POLL_INTERVAL_SECONDS
|
|
332
|
+
max_wait = DEFAULT_SCAN_MAX_WAIT_SECONDS
|
|
333
|
+
started_at = time.time()
|
|
334
|
+
|
|
335
|
+
use_rich = RICH_AVAILABLE and should_show_spinner()
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
if use_rich:
|
|
339
|
+
with Live(refresh_per_second=4, transient=True) as live:
|
|
340
|
+
while True:
|
|
341
|
+
elapsed = int(time.time() - started_at)
|
|
342
|
+
live.update(Text(f" ⠸ Polling scan {job_id}... ({elapsed}s elapsed)", style="cyan"))
|
|
343
|
+
time.sleep(poll_interval)
|
|
344
|
+
if time.time() - started_at > max_wait:
|
|
345
|
+
raise PrismorAPIError(f"Timed out after {max_wait}s. Check with: prismor scan-status {job_id}")
|
|
346
|
+
status_data = client.check_scan_status(job_id)
|
|
347
|
+
status = status_data.get("status")
|
|
348
|
+
if status in {"completed", "success", "failed", "error"}:
|
|
349
|
+
return status_data
|
|
350
|
+
else:
|
|
351
|
+
while True:
|
|
352
|
+
elapsed = int(time.time() - started_at)
|
|
353
|
+
print_info(f"Polling scan {job_id}... ({elapsed}s elapsed)")
|
|
354
|
+
time.sleep(poll_interval)
|
|
355
|
+
if time.time() - started_at > max_wait:
|
|
356
|
+
raise PrismorAPIError(f"Timed out after {max_wait}s. Check with: prismor scan-status {job_id}")
|
|
357
|
+
status_data = client.check_scan_status(job_id)
|
|
358
|
+
status = status_data.get("status")
|
|
359
|
+
if status in {"completed", "success", "failed", "error"}:
|
|
360
|
+
return status_data
|
|
361
|
+
except KeyboardInterrupt:
|
|
362
|
+
click.echo()
|
|
363
|
+
print_warning(f"Stopped watching. Scan still running in background.")
|
|
364
|
+
click.echo(f" Check with: prismor scan-status {job_id}")
|
|
365
|
+
sys.exit(0)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _show_fix_success_panel(status_data: dict):
|
|
369
|
+
"""Display a highlighted panel for a successful fix job."""
|
|
370
|
+
pr_url = status_data.get("pr_url", "")
|
|
371
|
+
branch = status_data.get("branch", "")
|
|
372
|
+
files = status_data.get("files_changed") or []
|
|
373
|
+
summary = status_data.get("summary", "")
|
|
374
|
+
|
|
375
|
+
if RICH_AVAILABLE and should_show_spinner():
|
|
376
|
+
lines = ["[bold green]Fix PR Ready![/bold green]"]
|
|
377
|
+
if pr_url:
|
|
378
|
+
lines.append(f"[green]{pr_url}[/green]")
|
|
379
|
+
if branch:
|
|
380
|
+
lines.append(f"Branch: {branch}")
|
|
381
|
+
if files:
|
|
382
|
+
lines.append(f"Files changed: {len(files)}")
|
|
383
|
+
if summary:
|
|
384
|
+
lines.append(f"\n{summary}")
|
|
385
|
+
console.print(Panel("\n".join(lines), border_style="green", expand=False))
|
|
386
|
+
else:
|
|
387
|
+
click.echo("\n" + "=" * 60)
|
|
388
|
+
print_success("Fix PR Ready!")
|
|
389
|
+
if pr_url:
|
|
390
|
+
click.secho(f" PR: {pr_url}", fg="green", bold=True)
|
|
391
|
+
if branch:
|
|
392
|
+
click.echo(f" Branch: {branch}")
|
|
393
|
+
if files:
|
|
394
|
+
click.echo(f" Files changed: {len(files)}")
|
|
395
|
+
for f in files[:10]:
|
|
396
|
+
click.echo(f" • {f}")
|
|
397
|
+
if len(files) > 10:
|
|
398
|
+
click.echo(f" ... and {len(files) - 10} more")
|
|
399
|
+
if summary:
|
|
400
|
+
click.echo(f" Summary: {summary}")
|
|
401
|
+
click.echo("=" * 60 + "\n")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@click.group(invoke_without_command=True)
|
|
405
|
+
@click.option("--repo", "scan_repo", type=str, help="Repository to scan (username/repo, GitHub URL, SSH URL, etc.)")
|
|
406
|
+
@click.option("--scan", is_flag=True, help="Perform vulnerability scanning")
|
|
407
|
+
@click.option("--sbom", is_flag=True, help="Generate Software Bill of Materials")
|
|
408
|
+
@click.option("--detect-secret", is_flag=True, help="Detect secrets in repository")
|
|
409
|
+
@click.option("--fullscan", is_flag=True, help="Perform all scan types")
|
|
410
|
+
@click.option("--fix", is_flag=True, help="AI auto-fix: scan then open a PR with fixes (implies --scan if no scan flag given)")
|
|
411
|
+
@click.option("--branch", type=str, help="Specific branch to scan (defaults to main/master)")
|
|
412
|
+
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
|
|
413
|
+
@click.option("--output", "-o", type=click.Path(), help="Save results to file (JSON format)")
|
|
414
|
+
@click.option("--quiet", "-q", is_flag=True, help="Minimal output (only errors and final results)")
|
|
415
|
+
@click.option("--action_id", type=str, help="GitHub Action Run ID associated with this scan")
|
|
416
|
+
@click.option("--prompt", is_flag=True, help="Output detailed scan results with an LLM prompt for fixing vulnerabilities")
|
|
417
|
+
@click.version_option(version=__version__, prog_name="prismor")
|
|
418
|
+
@click.pass_context
|
|
419
|
+
def cli(ctx, scan_repo: Optional[str], scan: bool, sbom: bool, detect_secret: bool,
|
|
420
|
+
fullscan: bool, fix: bool, branch: Optional[str], output_json: bool, output: Optional[str], quiet: bool, action_id: Optional[str], prompt: bool):
|
|
421
|
+
"""Prismor CLI - Security scanning tool for GitHub repositories.
|
|
422
|
+
|
|
423
|
+
Examples:
|
|
424
|
+
prismor --repo username/repo --scan
|
|
425
|
+
prismor --repo username/repo --fullscan
|
|
426
|
+
prismor --repo username/repo --scan --fix
|
|
427
|
+
prismor --repo https://github.com/username/repo --detect-secret
|
|
428
|
+
prismor --repo git@github.com:username/repo.git --sbom
|
|
429
|
+
prismor --repo github.com/username/repo --fullscan --branch develop
|
|
430
|
+
prismor trigger-fix username/repo
|
|
431
|
+
prismor fix-status <job_id>
|
|
432
|
+
prismor status
|
|
433
|
+
prismor repos
|
|
434
|
+
"""
|
|
435
|
+
if ctx.invoked_subcommand is None and not scan_repo:
|
|
436
|
+
click.echo(ctx.get_help())
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
if scan_repo:
|
|
440
|
+
if not any([scan, sbom, detect_secret, fullscan, fix]):
|
|
441
|
+
print_error("Please specify at least one scan type: --scan, --sbom, --detect-secret, --fullscan, or --fix")
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
if fix and not any([scan, sbom, detect_secret, fullscan]):
|
|
445
|
+
scan = True
|
|
446
|
+
|
|
447
|
+
if not branch:
|
|
448
|
+
detected = detect_git_branch()
|
|
449
|
+
if detected:
|
|
450
|
+
branch = detected
|
|
451
|
+
if not quiet:
|
|
452
|
+
print_info(f"Auto-detected branch: {branch}")
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
if not quiet:
|
|
456
|
+
print_info(f"Initializing Prismor scan for: {scan_repo}")
|
|
457
|
+
client = PrismorClient()
|
|
458
|
+
|
|
459
|
+
scan_types = []
|
|
460
|
+
if fullscan:
|
|
461
|
+
scan_types.append("Full Scan (scan + SBOM + Secret Detection)")
|
|
462
|
+
else:
|
|
463
|
+
if scan:
|
|
464
|
+
scan_types.append("scan")
|
|
465
|
+
if sbom:
|
|
466
|
+
scan_types.append("SBOM")
|
|
467
|
+
if detect_secret:
|
|
468
|
+
scan_types.append("Secret Detection")
|
|
469
|
+
|
|
470
|
+
if not quiet:
|
|
471
|
+
print_info(f"Scan type: {', '.join(scan_types)}")
|
|
472
|
+
if scan or fullscan:
|
|
473
|
+
print_info("Starting scan... (typical scans finish in 30-90s; large repos may take several minutes)")
|
|
474
|
+
else:
|
|
475
|
+
print_info("Starting scan...")
|
|
476
|
+
|
|
477
|
+
spinner = None
|
|
478
|
+
if not quiet and not output_json and not output and should_show_spinner():
|
|
479
|
+
spinner = Spinner("Scanning repository")
|
|
480
|
+
spinner.start()
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
results = client.scan(
|
|
484
|
+
repo=scan_repo,
|
|
485
|
+
scan=scan,
|
|
486
|
+
sbom=sbom,
|
|
487
|
+
detect_secret=detect_secret,
|
|
488
|
+
fullscan=fullscan,
|
|
489
|
+
branch=branch,
|
|
490
|
+
action_id=action_id
|
|
491
|
+
)
|
|
492
|
+
if spinner:
|
|
493
|
+
spinner.stop()
|
|
494
|
+
except Exception as e:
|
|
495
|
+
if spinner:
|
|
496
|
+
spinner.stop()
|
|
497
|
+
raise e
|
|
498
|
+
|
|
499
|
+
# Detect silent-success responses. The server returns 200 even when the
|
|
500
|
+
# scanner couldn't fetch the repo (bad branch, no access, repo not connected).
|
|
501
|
+
# Heuristic: if commit_sha is missing AND every scan type came back with
|
|
502
|
+
# zero findings AND no Trivy "Results" array, the scanner never examined
|
|
503
|
+
# real code. Fail loudly instead of letting users mistake it for a clean scan.
|
|
504
|
+
if isinstance(results, dict):
|
|
505
|
+
no_commit = not (results.get("commit_sha") or "").strip()
|
|
506
|
+
scans_dict = results.get("scans") or {}
|
|
507
|
+
vuln_sr = (scans_dict.get("vulnerability") or {}).get("scan_results") or {}
|
|
508
|
+
no_vuln_targets = not (isinstance(vuln_sr, dict) and vuln_sr.get("Results"))
|
|
509
|
+
secret_sr = (scans_dict.get("secret") or {}).get("scan_results")
|
|
510
|
+
no_secrets = not secret_sr
|
|
511
|
+
sbom_sr = (scans_dict.get("sbom") or {}).get("scan_results") or {}
|
|
512
|
+
no_artifacts = not (isinstance(sbom_sr, dict) and sbom_sr.get("artifacts"))
|
|
513
|
+
if no_commit and no_vuln_targets and no_secrets and no_artifacts:
|
|
514
|
+
branch_label = results.get("branch") or branch or "default"
|
|
515
|
+
print_error(
|
|
516
|
+
f"Scan returned no results for {scan_repo} (branch: {branch_label}). "
|
|
517
|
+
"The branch may not exist, the repository may not be connected to your "
|
|
518
|
+
"account, or Prismor may not have access. Verify with: prismor repos"
|
|
519
|
+
)
|
|
520
|
+
sys.exit(2)
|
|
521
|
+
|
|
522
|
+
results = _strip_sensitive(results)
|
|
523
|
+
|
|
524
|
+
if output:
|
|
525
|
+
try:
|
|
526
|
+
with open(output, 'w') as f:
|
|
527
|
+
json.dump(results, f, indent=2)
|
|
528
|
+
if not quiet:
|
|
529
|
+
print_success(f"Results saved to: {output}")
|
|
530
|
+
except Exception as e:
|
|
531
|
+
print_error(f"Failed to save results to file: {str(e)}")
|
|
532
|
+
sys.exit(1)
|
|
533
|
+
|
|
534
|
+
# AI auto-fix: trigger after scan completes
|
|
535
|
+
if fix and not output_json and not output:
|
|
536
|
+
if not quiet:
|
|
537
|
+
click.echo()
|
|
538
|
+
print_info("Triggering AI auto-fix...")
|
|
539
|
+
fix_spinner = None
|
|
540
|
+
if not quiet and should_show_spinner():
|
|
541
|
+
fix_spinner = Spinner("Starting auto-fix")
|
|
542
|
+
fix_spinner.start()
|
|
543
|
+
try:
|
|
544
|
+
fix_result = client.trigger_autofix(scan_repo, branch=branch)
|
|
545
|
+
if fix_spinner:
|
|
546
|
+
fix_spinner.stop()
|
|
547
|
+
except Exception as e:
|
|
548
|
+
if fix_spinner:
|
|
549
|
+
fix_spinner.stop()
|
|
550
|
+
print_error(f"Auto-fix failed to start: {str(e)}")
|
|
551
|
+
fix_result = None
|
|
552
|
+
|
|
553
|
+
if fix_result and fix_result.get("ok"):
|
|
554
|
+
fix_job_id = fix_result.get("job_id", "")
|
|
555
|
+
if not quiet:
|
|
556
|
+
print_success("Auto-fix job started!")
|
|
557
|
+
click.secho(f" Job ID: {fix_job_id}", fg="yellow", bold=True)
|
|
558
|
+
click.echo(f" Track progress: prismor fix-status {fix_job_id} --watch")
|
|
559
|
+
|
|
560
|
+
# Output results
|
|
561
|
+
if output_json or output:
|
|
562
|
+
if not output:
|
|
563
|
+
click.echo(json.dumps(results, indent=2))
|
|
564
|
+
elif prompt:
|
|
565
|
+
format_prompt_results(results, scan_repo)
|
|
566
|
+
else:
|
|
567
|
+
if not quiet:
|
|
568
|
+
print_success("Scan completed successfully!")
|
|
569
|
+
|
|
570
|
+
# Fetch dashboard URL for the "view more" link
|
|
571
|
+
dashboard_url = None
|
|
572
|
+
if not quiet:
|
|
573
|
+
try:
|
|
574
|
+
repo_name = parse_github_repo(scan_repo)
|
|
575
|
+
repo_info = client.get_repository_by_name(repo_name)
|
|
576
|
+
if repo_info.get("success") and "repository" in repo_info:
|
|
577
|
+
repo_id = repo_info["repository"]["id"]
|
|
578
|
+
dashboard_url = f"{client.base_url}/repositories/{repo_id}"
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
format_scan_results(results, ', '.join(scan_types), dashboard_url=dashboard_url)
|
|
583
|
+
|
|
584
|
+
if dashboard_url and not quiet:
|
|
585
|
+
click.secho(f" View full analysis: {dashboard_url}", fg="cyan")
|
|
586
|
+
click.echo()
|
|
587
|
+
|
|
588
|
+
except PrismorAPIError as e:
|
|
589
|
+
print_error(str(e))
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@cli.command()
|
|
597
|
+
def version():
|
|
598
|
+
"""Display the version of Prismor CLI."""
|
|
599
|
+
click.echo(f"Prismor CLI v{__version__}")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@cli.command()
|
|
603
|
+
def config():
|
|
604
|
+
"""Display current configuration."""
|
|
605
|
+
click.echo("\n" + "=" * 60)
|
|
606
|
+
click.secho(" Prismor CLI Configuration", fg="cyan", bold=True)
|
|
607
|
+
click.echo("=" * 60 + "\n")
|
|
608
|
+
|
|
609
|
+
api_key = os.environ.get("PRISMOR_API_KEY")
|
|
610
|
+
if api_key:
|
|
611
|
+
masked_key = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "***"
|
|
612
|
+
print_success(f"PRISMOR_API_KEY: {masked_key}")
|
|
613
|
+
else:
|
|
614
|
+
print_error("PRISMOR_API_KEY: Not set")
|
|
615
|
+
click.echo("\nPlease specify your API key. You can generate one for free at:")
|
|
616
|
+
click.secho(" https://www.prismor.dev/cli", fg="cyan", underline=True)
|
|
617
|
+
click.echo("\nTo set your API key, run:")
|
|
618
|
+
click.echo(" export PRISMOR_API_KEY=your_api_key")
|
|
619
|
+
|
|
620
|
+
click.echo("=" * 60 + "\n")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@cli.group()
|
|
624
|
+
def org():
|
|
625
|
+
"""Manage which organization your scans and fixes belong to."""
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@org.command("list")
|
|
630
|
+
def org_list():
|
|
631
|
+
"""List the organizations you belong to (★ = active)."""
|
|
632
|
+
from prismor import cli_config
|
|
633
|
+
try:
|
|
634
|
+
client = PrismorClient()
|
|
635
|
+
data = client.list_orgs()
|
|
636
|
+
orgs = data.get("orgs", [])
|
|
637
|
+
active = cli_config.active_org_id() or data.get("activeOrgId")
|
|
638
|
+
if not orgs:
|
|
639
|
+
print_error("You're not a member of any organization.")
|
|
640
|
+
return
|
|
641
|
+
click.echo("")
|
|
642
|
+
for o in orgs:
|
|
643
|
+
mark = "★" if o.get("id") == active else " "
|
|
644
|
+
click.secho(f" {mark} {o.get('name')}", fg="cyan" if mark == "★" else None, bold=mark == "★", nl=False)
|
|
645
|
+
click.echo(f" ({o.get('slug')}) · {str(o.get('role','')).lower()}")
|
|
646
|
+
click.echo("\n Switch with: prismor org switch <slug>\n")
|
|
647
|
+
except Exception as e:
|
|
648
|
+
print_error(f"Could not list organizations: {e}")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@org.command("switch")
|
|
652
|
+
@click.argument("slug_or_id")
|
|
653
|
+
def org_switch(slug_or_id: str):
|
|
654
|
+
"""Set the active organization (scans/fixes will be attributed to it)."""
|
|
655
|
+
from prismor import cli_config
|
|
656
|
+
try:
|
|
657
|
+
client = PrismorClient()
|
|
658
|
+
orgs = client.list_orgs().get("orgs", [])
|
|
659
|
+
match = next((o for o in orgs if o.get("slug") == slug_or_id or o.get("id") == slug_or_id), None)
|
|
660
|
+
if not match:
|
|
661
|
+
print_error(f"You're not a member of '{slug_or_id}'. Run `prismor org list` to see your orgs.")
|
|
662
|
+
return
|
|
663
|
+
cli_config.set_active_org(match)
|
|
664
|
+
print_success(f"Active organization: {match.get('name')} ({match.get('slug')})")
|
|
665
|
+
except Exception as e:
|
|
666
|
+
print_error(f"Could not switch organization: {e}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@org.command("current")
|
|
670
|
+
def org_current():
|
|
671
|
+
"""Show the active organization."""
|
|
672
|
+
from prismor import cli_config
|
|
673
|
+
active = cli_config.active_org()
|
|
674
|
+
if active:
|
|
675
|
+
click.echo(f"\n Active organization: {active.get('name')} ({active.get('slug')})\n")
|
|
676
|
+
else:
|
|
677
|
+
click.echo("\n No active organization set — using your API key's default org.")
|
|
678
|
+
click.echo(" Set one with: prismor org switch <slug>\n")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@cli.group()
|
|
682
|
+
def policy():
|
|
683
|
+
"""Manage your organization's security policy as code (pull / apply / show)."""
|
|
684
|
+
pass
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@policy.command("show")
|
|
688
|
+
def policy_show():
|
|
689
|
+
"""Print the active org policy (version + YAML)."""
|
|
690
|
+
try:
|
|
691
|
+
data = PrismorClient().get_org_policy()
|
|
692
|
+
p = data.get("policy", {})
|
|
693
|
+
click.echo("")
|
|
694
|
+
click.secho(f" Org policy · v{p.get('version', 0)}" + (f" · {p.get('name')}" if p.get('name') else " · (default — everything observes)"), fg="cyan", bold=True)
|
|
695
|
+
if not data.get("signingConfigured", True):
|
|
696
|
+
click.secho(" ⚠ Policy signing not configured — devices will reject remote policy.", fg="yellow")
|
|
697
|
+
click.echo("")
|
|
698
|
+
click.echo(p.get("yaml", ""))
|
|
699
|
+
except Exception as e:
|
|
700
|
+
print_error(f"Could not fetch policy: {e}")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@policy.command("pull")
|
|
704
|
+
@click.option("-o", "--output", "output", default=None, help="Write the policy YAML to this file (default: stdout).")
|
|
705
|
+
def policy_pull(output):
|
|
706
|
+
"""Fetch the active org policy YAML (for version control / editing)."""
|
|
707
|
+
try:
|
|
708
|
+
data = PrismorClient().get_org_policy()
|
|
709
|
+
yaml_text = data.get("policy", {}).get("yaml", "")
|
|
710
|
+
if output:
|
|
711
|
+
with open(output, "w") as f:
|
|
712
|
+
f.write(yaml_text)
|
|
713
|
+
print_success(f"Wrote org policy (v{data.get('policy', {}).get('version', 0)}) to {output}")
|
|
714
|
+
else:
|
|
715
|
+
click.echo(yaml_text, nl=False)
|
|
716
|
+
except Exception as e:
|
|
717
|
+
print_error(f"Could not pull policy: {e}")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@policy.command("lint")
|
|
721
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
722
|
+
def policy_lint(file):
|
|
723
|
+
"""Validate a policy YAML file against the floor (no changes made)."""
|
|
724
|
+
try:
|
|
725
|
+
with open(file) as f:
|
|
726
|
+
yaml_text = f.read()
|
|
727
|
+
PrismorClient().apply_org_policy(yaml_text, dry_run=True)
|
|
728
|
+
print_success("Policy is valid (parses + honors the non-weakening floor).")
|
|
729
|
+
except PrismorAPIError as e:
|
|
730
|
+
print_error(f"Policy is invalid:\n{e}")
|
|
731
|
+
except Exception as e:
|
|
732
|
+
print_error(f"Could not lint policy: {e}")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@policy.command("apply")
|
|
736
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
737
|
+
@click.option("--dry-run", is_flag=True, help="Validate only; don't publish.")
|
|
738
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
739
|
+
def policy_apply(file, dry_run, yes):
|
|
740
|
+
"""Publish a policy YAML file to the org (signed; devices apply within ~30s)."""
|
|
741
|
+
try:
|
|
742
|
+
with open(file) as f:
|
|
743
|
+
yaml_text = f.read()
|
|
744
|
+
if dry_run:
|
|
745
|
+
PrismorClient().apply_org_policy(yaml_text, dry_run=True)
|
|
746
|
+
print_success("Valid — would publish cleanly. Re-run without --dry-run to apply.")
|
|
747
|
+
return
|
|
748
|
+
if not yes:
|
|
749
|
+
click.secho(" This publishes the org policy to EVERY enrolled device (~30s).", fg="yellow")
|
|
750
|
+
if not click.confirm(" Continue?", default=False):
|
|
751
|
+
click.echo(" Aborted.")
|
|
752
|
+
return
|
|
753
|
+
res = PrismorClient().apply_org_policy(yaml_text, dry_run=False)
|
|
754
|
+
print_success(f"Published org policy v{res.get('version')}. Enrolled devices apply it within ~30s.")
|
|
755
|
+
if not res.get("signingConfigured", True):
|
|
756
|
+
click.secho(" ⚠ Policy signing not configured — devices will reject it until set.", fg="yellow")
|
|
757
|
+
except PrismorAPIError as e:
|
|
758
|
+
print_error(f"Could not apply policy:\n{e}")
|
|
759
|
+
except Exception as e:
|
|
760
|
+
print_error(f"Could not apply policy: {e}")
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@cli.command()
|
|
764
|
+
def devices():
|
|
765
|
+
"""List enrolled devices in your active organization."""
|
|
766
|
+
try:
|
|
767
|
+
data = PrismorClient().list_devices()
|
|
768
|
+
rows = data.get("devices", [])
|
|
769
|
+
click.echo("")
|
|
770
|
+
click.secho(f" Devices ({len(rows)})", fg="cyan", bold=True)
|
|
771
|
+
click.echo("")
|
|
772
|
+
if not rows:
|
|
773
|
+
click.echo(" No devices enrolled. Enroll one with: immunity enroll <token>\n")
|
|
774
|
+
return
|
|
775
|
+
for d in rows:
|
|
776
|
+
status = d.get("status", "active")
|
|
777
|
+
color = "green" if status == "active" else "red"
|
|
778
|
+
click.secho(f" {d.get('label')}", bold=True, nl=False)
|
|
779
|
+
click.echo(f" · {d.get('platform') or 'unknown'} · {d.get('owner')} · ", nl=False)
|
|
780
|
+
click.secho(status, fg=color, nl=False)
|
|
781
|
+
click.echo(f" · policy v{d.get('appliedPolicyVersion') if d.get('appliedPolicyVersion') is not None else '-'}")
|
|
782
|
+
click.echo("")
|
|
783
|
+
except Exception as e:
|
|
784
|
+
print_error(f"Could not list devices: {e}")
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
@cli.command()
|
|
788
|
+
def members():
|
|
789
|
+
"""List members of your active organization and their roles."""
|
|
790
|
+
try:
|
|
791
|
+
data = PrismorClient().list_members()
|
|
792
|
+
rows = data.get("members", [])
|
|
793
|
+
click.echo("")
|
|
794
|
+
click.secho(f" Members ({len(rows)})", fg="cyan", bold=True)
|
|
795
|
+
click.echo("")
|
|
796
|
+
for mbr in rows:
|
|
797
|
+
click.secho(f" {mbr.get('name') or mbr.get('email')}", bold=True, nl=False)
|
|
798
|
+
click.echo(f" ({mbr.get('email')}) · {str(mbr.get('role', '')).lower()}")
|
|
799
|
+
click.echo("")
|
|
800
|
+
except Exception as e:
|
|
801
|
+
print_error(f"Could not list members: {e}")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@cli.command()
|
|
805
|
+
def repos():
|
|
806
|
+
"""List your connected repositories."""
|
|
807
|
+
try:
|
|
808
|
+
client = PrismorClient()
|
|
809
|
+
spinner = Spinner("Loading repositories") if should_show_spinner() else None
|
|
810
|
+
if spinner:
|
|
811
|
+
spinner.start()
|
|
812
|
+
try:
|
|
813
|
+
repos_data = client.get_repositories()
|
|
814
|
+
if spinner:
|
|
815
|
+
spinner.stop()
|
|
816
|
+
except Exception as e:
|
|
817
|
+
if spinner:
|
|
818
|
+
spinner.stop()
|
|
819
|
+
raise e
|
|
820
|
+
|
|
821
|
+
click.echo("\n" + "=" * 60)
|
|
822
|
+
click.secho(" Your Repositories", fg="cyan", bold=True)
|
|
823
|
+
click.echo("=" * 60 + "\n")
|
|
824
|
+
|
|
825
|
+
user_info = repos_data.get("user", {})
|
|
826
|
+
if user_info:
|
|
827
|
+
click.secho(f"User: {user_info.get('name', 'Unknown')} ({user_info.get('email', 'No email')})", fg="yellow")
|
|
828
|
+
click.echo()
|
|
829
|
+
|
|
830
|
+
repositories = repos_data.get("repositories", [])
|
|
831
|
+
if repositories:
|
|
832
|
+
for repo in repositories:
|
|
833
|
+
click.secho(f"• {repo.get('name', 'Unknown')}", fg="green")
|
|
834
|
+
click.echo(f" URL: {repo.get('htmlUrl', 'No URL')}")
|
|
835
|
+
click.echo(f" Owner: {repo.get('githubOwner', 'Unknown')}")
|
|
836
|
+
click.echo()
|
|
837
|
+
else:
|
|
838
|
+
print_warning("No repositories found. Connect repositories through the web interface.")
|
|
839
|
+
|
|
840
|
+
click.echo("=" * 60 + "\n")
|
|
841
|
+
|
|
842
|
+
except PrismorAPIError as e:
|
|
843
|
+
print_error(str(e))
|
|
844
|
+
sys.exit(1)
|
|
845
|
+
except Exception as e:
|
|
846
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
847
|
+
sys.exit(1)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@cli.command()
|
|
851
|
+
@click.argument("repo", type=str)
|
|
852
|
+
@click.option("--branch", type=str, help="Specific branch to scan (defaults to main)")
|
|
853
|
+
@click.option("--token", type=str, help="GitHub token (or set GITHUB_TOKEN env var)")
|
|
854
|
+
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
|
|
855
|
+
def start_scan(repo: str, branch: Optional[str], token: Optional[str], output_json: bool):
|
|
856
|
+
"""Start a vulnerability scan and return a job_id for status checking.
|
|
857
|
+
|
|
858
|
+
REPO is the repository to scan (username/repo, GitHub URL, SSH URL, etc.)
|
|
859
|
+
|
|
860
|
+
Examples:
|
|
861
|
+
prismor start-scan username/repo
|
|
862
|
+
prismor start-scan https://github.com/username/repo --branch develop
|
|
863
|
+
prismor start-scan username/repo --token ghp_xxxxx
|
|
864
|
+
prismor start-scan username/repo --json
|
|
865
|
+
"""
|
|
866
|
+
if not branch:
|
|
867
|
+
detected = detect_git_branch()
|
|
868
|
+
if detected:
|
|
869
|
+
branch = detected
|
|
870
|
+
print_info(f"Auto-detected branch: {branch}")
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
client = PrismorClient()
|
|
874
|
+
print_info(f"Starting vulnerability scan for: {repo}")
|
|
875
|
+
if branch:
|
|
876
|
+
print_info(f"Branch: {branch}")
|
|
877
|
+
|
|
878
|
+
spinner = Spinner("Starting scan") if should_show_spinner() else None
|
|
879
|
+
if spinner:
|
|
880
|
+
spinner.start()
|
|
881
|
+
try:
|
|
882
|
+
result = client.start_vulnerability_scan(repo, branch, token)
|
|
883
|
+
if spinner:
|
|
884
|
+
spinner.stop()
|
|
885
|
+
except Exception as e:
|
|
886
|
+
if spinner:
|
|
887
|
+
spinner.stop()
|
|
888
|
+
raise e
|
|
889
|
+
|
|
890
|
+
if output_json:
|
|
891
|
+
click.echo(json.dumps(result, indent=2))
|
|
892
|
+
else:
|
|
893
|
+
click.echo("\n" + "=" * 60)
|
|
894
|
+
click.secho(" Scan Started", fg="cyan", bold=True)
|
|
895
|
+
click.echo("=" * 60 + "\n")
|
|
896
|
+
|
|
897
|
+
job_id = result.get("job_id")
|
|
898
|
+
if job_id:
|
|
899
|
+
print_success("Scan started successfully!")
|
|
900
|
+
click.echo()
|
|
901
|
+
click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
|
|
902
|
+
click.echo()
|
|
903
|
+
click.secho("Repository:", fg="yellow", bold=True)
|
|
904
|
+
click.echo(f" {result.get('repository', repo)}")
|
|
905
|
+
click.echo()
|
|
906
|
+
if "branch" in result:
|
|
907
|
+
click.secho("Branch:", fg="yellow", bold=True)
|
|
908
|
+
click.echo(f" {result['branch']}")
|
|
909
|
+
click.echo()
|
|
910
|
+
click.secho("Status:", fg="yellow", bold=True)
|
|
911
|
+
click.echo(f" {result.get('status', 'accepted')}")
|
|
912
|
+
click.echo()
|
|
913
|
+
click.secho("Next Steps:", fg="cyan", bold=True)
|
|
914
|
+
click.echo(f" Check scan status with:")
|
|
915
|
+
click.secho(f" prismor scan-status {job_id} --watch", fg="green", bold=True)
|
|
916
|
+
click.echo()
|
|
917
|
+
else:
|
|
918
|
+
print_error("Failed to get job_id from response")
|
|
919
|
+
click.echo(json.dumps(result, indent=2))
|
|
920
|
+
|
|
921
|
+
click.echo("=" * 60 + "\n")
|
|
922
|
+
|
|
923
|
+
except PrismorAPIError as e:
|
|
924
|
+
print_error(str(e))
|
|
925
|
+
sys.exit(1)
|
|
926
|
+
except Exception as e:
|
|
927
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
928
|
+
sys.exit(1)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@cli.command()
|
|
932
|
+
@click.argument("job_id", type=str)
|
|
933
|
+
@click.option("--watch", is_flag=True, help="Poll until the scan completes (up to 30 min)")
|
|
934
|
+
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
|
|
935
|
+
@click.option("--prompt", is_flag=True, help="Output detailed scan results with an LLM prompt for fixing vulnerabilities")
|
|
936
|
+
def scan_status(job_id: str, watch: bool, output_json: bool, prompt: bool):
|
|
937
|
+
"""Check the status of a vulnerability scan job.
|
|
938
|
+
|
|
939
|
+
JOB_ID is the job ID returned when starting a scan.
|
|
940
|
+
|
|
941
|
+
Examples:
|
|
942
|
+
prismor scan-status a724a4663cda4bf087ad171683cb726d
|
|
943
|
+
prismor scan-status a724a4663cda4bf087ad171683cb726d --watch
|
|
944
|
+
prismor scan-status 50cbe253e5634227b81fe744c2a0b3e7 --json
|
|
945
|
+
"""
|
|
946
|
+
try:
|
|
947
|
+
client = PrismorClient()
|
|
948
|
+
|
|
949
|
+
if watch:
|
|
950
|
+
print_info(f"Watching scan {job_id} (Ctrl+C to stop)...")
|
|
951
|
+
status_data = _watch_scan(client, job_id)
|
|
952
|
+
else:
|
|
953
|
+
print_info(f"Checking scan status for job: {job_id}")
|
|
954
|
+
spinner = Spinner("Checking status") if should_show_spinner() else None
|
|
955
|
+
if spinner:
|
|
956
|
+
spinner.start()
|
|
957
|
+
try:
|
|
958
|
+
status_data = client.check_scan_status(job_id)
|
|
959
|
+
if spinner:
|
|
960
|
+
spinner.stop()
|
|
961
|
+
except Exception as e:
|
|
962
|
+
if spinner:
|
|
963
|
+
spinner.stop()
|
|
964
|
+
raise e
|
|
965
|
+
|
|
966
|
+
status_data = _strip_sensitive(status_data)
|
|
967
|
+
|
|
968
|
+
if output_json:
|
|
969
|
+
click.echo(json.dumps(status_data, indent=2))
|
|
970
|
+
return
|
|
971
|
+
|
|
972
|
+
if prompt and status_data.get("status") in {"completed", "success"}:
|
|
973
|
+
repo_name = status_data.get("repository", "Unknown Repository")
|
|
974
|
+
format_prompt_results(status_data.get("results", status_data), repo_name)
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
click.echo("\n" + "=" * 60)
|
|
978
|
+
click.secho(" Scan Status", fg="cyan", bold=True)
|
|
979
|
+
click.echo("=" * 60 + "\n")
|
|
980
|
+
|
|
981
|
+
click.secho(f"Job ID: {status_data.get('job_id', job_id)}", fg="yellow", bold=True)
|
|
982
|
+
click.echo()
|
|
983
|
+
|
|
984
|
+
status = status_data.get("status", "unknown")
|
|
985
|
+
if status in {"completed", "success"}:
|
|
986
|
+
print_success(f"Status: {status}")
|
|
987
|
+
click.echo()
|
|
988
|
+
|
|
989
|
+
if "repository" in status_data:
|
|
990
|
+
click.secho("Repository:", fg="yellow", bold=True)
|
|
991
|
+
click.echo(f" {status_data['repository']}")
|
|
992
|
+
click.echo()
|
|
993
|
+
|
|
994
|
+
if "branch" in status_data:
|
|
995
|
+
click.secho("Branch:", fg="yellow", bold=True)
|
|
996
|
+
click.echo(f" {status_data['branch']}")
|
|
997
|
+
click.echo()
|
|
998
|
+
|
|
999
|
+
if "duration" in status_data:
|
|
1000
|
+
click.secho("Duration:", fg="yellow", bold=True)
|
|
1001
|
+
click.echo(f" {status_data['duration']:.2f} seconds")
|
|
1002
|
+
click.echo()
|
|
1003
|
+
|
|
1004
|
+
if "public_url" in status_data:
|
|
1005
|
+
click.secho("Results URL:", fg="yellow", bold=True)
|
|
1006
|
+
click.secho(f" {status_data['public_url']}", fg="green")
|
|
1007
|
+
click.echo()
|
|
1008
|
+
|
|
1009
|
+
if "presigned_url" in status_data:
|
|
1010
|
+
click.secho("Presigned URL (expires in 1 hour):", fg="yellow", bold=True)
|
|
1011
|
+
click.secho(f" {status_data['presigned_url']}", fg="blue")
|
|
1012
|
+
click.echo()
|
|
1013
|
+
|
|
1014
|
+
if "summary" in status_data:
|
|
1015
|
+
summary = status_data["summary"]
|
|
1016
|
+
click.secho("Vulnerability Summary:", fg="yellow", bold=True)
|
|
1017
|
+
click.echo(f" Total Vulnerabilities: {summary.get('total_vulnerabilities', 0)}")
|
|
1018
|
+
click.echo(f" Total Targets Scanned: {summary.get('total_targets', 0)}")
|
|
1019
|
+
click.echo()
|
|
1020
|
+
severity_breakdown = summary.get('severity_breakdown', {})
|
|
1021
|
+
if severity_breakdown:
|
|
1022
|
+
click.secho(" Severity Breakdown:", fg="yellow")
|
|
1023
|
+
if severity_breakdown.get('CRITICAL', 0) > 0:
|
|
1024
|
+
click.secho(f" CRITICAL: {severity_breakdown['CRITICAL']}", fg="red", bold=True)
|
|
1025
|
+
if severity_breakdown.get('HIGH', 0) > 0:
|
|
1026
|
+
click.secho(f" HIGH: {severity_breakdown['HIGH']}", fg="red")
|
|
1027
|
+
if severity_breakdown.get('MEDIUM', 0) > 0:
|
|
1028
|
+
click.secho(f" MEDIUM: {severity_breakdown['MEDIUM']}", fg="yellow")
|
|
1029
|
+
if severity_breakdown.get('LOW', 0) > 0:
|
|
1030
|
+
click.secho(f" LOW: {severity_breakdown['LOW']}", fg="blue")
|
|
1031
|
+
if severity_breakdown.get('UNKNOWN', 0) > 0:
|
|
1032
|
+
click.secho(f" UNKNOWN: {severity_breakdown['UNKNOWN']}", fg="white")
|
|
1033
|
+
click.echo()
|
|
1034
|
+
|
|
1035
|
+
if "scan_date" in status_data:
|
|
1036
|
+
click.secho("Scan Date:", fg="yellow", bold=True)
|
|
1037
|
+
click.echo(f" {status_data['scan_date']}")
|
|
1038
|
+
click.echo()
|
|
1039
|
+
|
|
1040
|
+
elif status == "running":
|
|
1041
|
+
print_info(f"Status: {status}")
|
|
1042
|
+
if "message" in status_data:
|
|
1043
|
+
click.echo(f" {status_data['message']}")
|
|
1044
|
+
click.echo()
|
|
1045
|
+
click.echo(" The scan is still in progress.")
|
|
1046
|
+
click.echo(f" Watch it live with: prismor scan-status {job_id} --watch")
|
|
1047
|
+
click.echo()
|
|
1048
|
+
|
|
1049
|
+
elif status == "failed":
|
|
1050
|
+
print_error(f"Status: {status}")
|
|
1051
|
+
if "error" in status_data:
|
|
1052
|
+
click.echo(f" Error: {status_data['error']}")
|
|
1053
|
+
click.echo()
|
|
1054
|
+
else:
|
|
1055
|
+
click.secho(f"Status: {status}", fg="yellow")
|
|
1056
|
+
click.echo()
|
|
1057
|
+
|
|
1058
|
+
click.echo("=" * 60 + "\n")
|
|
1059
|
+
|
|
1060
|
+
except PrismorAPIError as e:
|
|
1061
|
+
print_error(str(e))
|
|
1062
|
+
sys.exit(1)
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
1065
|
+
sys.exit(1)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
@cli.command()
|
|
1069
|
+
def status():
|
|
1070
|
+
"""Check your account status and GitHub integration."""
|
|
1071
|
+
try:
|
|
1072
|
+
client = PrismorClient()
|
|
1073
|
+
spinner = Spinner("Checking account status") if should_show_spinner() else None
|
|
1074
|
+
if spinner:
|
|
1075
|
+
spinner.start()
|
|
1076
|
+
try:
|
|
1077
|
+
auth_data = client.authenticate()
|
|
1078
|
+
if spinner:
|
|
1079
|
+
spinner.stop()
|
|
1080
|
+
except Exception as e:
|
|
1081
|
+
if spinner:
|
|
1082
|
+
spinner.stop()
|
|
1083
|
+
raise e
|
|
1084
|
+
|
|
1085
|
+
click.echo("\n" + "=" * 60)
|
|
1086
|
+
click.secho(" Account Status", fg="cyan", bold=True)
|
|
1087
|
+
click.echo("=" * 60 + "\n")
|
|
1088
|
+
|
|
1089
|
+
user_info = auth_data.get("user", {})
|
|
1090
|
+
if user_info:
|
|
1091
|
+
repositories = user_info.get("repositories", [])
|
|
1092
|
+
click.secho(f"Connected Repositories: {len(repositories)}", fg="green")
|
|
1093
|
+
if repositories:
|
|
1094
|
+
click.echo("\nRepository List:")
|
|
1095
|
+
for repo in repositories:
|
|
1096
|
+
click.echo(f" • {repo.get('name', 'Unknown')} ({repo.get('htmlUrl', 'No URL')})")
|
|
1097
|
+
else:
|
|
1098
|
+
print_warning("No repositories connected.")
|
|
1099
|
+
click.echo("\nTo connect repositories:")
|
|
1100
|
+
click.echo(" 1. Visit https://prismor.dev/dashboard")
|
|
1101
|
+
click.echo(" 2. Connect your GitHub account")
|
|
1102
|
+
click.echo(" 3. Select repositories to scan")
|
|
1103
|
+
else:
|
|
1104
|
+
print_error("Failed to retrieve account information.")
|
|
1105
|
+
|
|
1106
|
+
click.echo("\n" + "=" * 60 + "\n")
|
|
1107
|
+
|
|
1108
|
+
except PrismorAPIError as e:
|
|
1109
|
+
print_error(str(e))
|
|
1110
|
+
sys.exit(1)
|
|
1111
|
+
except Exception as e:
|
|
1112
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
1113
|
+
sys.exit(1)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@cli.command("trigger-fix")
|
|
1117
|
+
@click.argument("repo", type=str)
|
|
1118
|
+
@click.option("--branch", type=str, help="Base branch to apply fixes on (defaults to main)")
|
|
1119
|
+
@click.option("--instruction", type=str, help="Custom fix instruction for the AI agent")
|
|
1120
|
+
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
|
|
1121
|
+
def trigger_fix(repo: str, branch: Optional[str], instruction: Optional[str], output_json: bool):
|
|
1122
|
+
"""Trigger AI auto-fix for a repository without running a scan first.
|
|
1123
|
+
|
|
1124
|
+
REPO is the repository to fix (username/repo, GitHub URL, etc.)
|
|
1125
|
+
|
|
1126
|
+
Examples:
|
|
1127
|
+
prismor trigger-fix username/repo
|
|
1128
|
+
prismor trigger-fix https://github.com/username/repo --branch develop
|
|
1129
|
+
prismor trigger-fix username/repo --instruction "Update lodash to 4.17.21"
|
|
1130
|
+
prismor trigger-fix username/repo --json
|
|
1131
|
+
"""
|
|
1132
|
+
if not branch:
|
|
1133
|
+
detected = detect_git_branch()
|
|
1134
|
+
if detected:
|
|
1135
|
+
branch = detected
|
|
1136
|
+
print_info(f"Auto-detected branch: {branch}")
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
client = PrismorClient()
|
|
1140
|
+
print_info(f"Triggering auto-fix for: {repo}")
|
|
1141
|
+
|
|
1142
|
+
spinner = Spinner("Starting auto-fix") if should_show_spinner() else None
|
|
1143
|
+
if spinner:
|
|
1144
|
+
spinner.start()
|
|
1145
|
+
try:
|
|
1146
|
+
result = client.trigger_autofix(repo, branch=branch, instruction=instruction)
|
|
1147
|
+
if spinner:
|
|
1148
|
+
spinner.stop()
|
|
1149
|
+
except Exception as e:
|
|
1150
|
+
if spinner:
|
|
1151
|
+
spinner.stop()
|
|
1152
|
+
raise e
|
|
1153
|
+
|
|
1154
|
+
if output_json:
|
|
1155
|
+
click.echo(json.dumps(result, indent=2))
|
|
1156
|
+
else:
|
|
1157
|
+
click.echo("\n" + "=" * 60)
|
|
1158
|
+
click.secho(" Auto-Fix Triggered", fg="cyan", bold=True)
|
|
1159
|
+
click.echo("=" * 60 + "\n")
|
|
1160
|
+
|
|
1161
|
+
if result.get("ok"):
|
|
1162
|
+
print_success("Auto-fix job started successfully!")
|
|
1163
|
+
click.echo()
|
|
1164
|
+
job_id = result.get("job_id", "")
|
|
1165
|
+
click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
|
|
1166
|
+
click.echo()
|
|
1167
|
+
click.secho("Next Steps:", fg="cyan", bold=True)
|
|
1168
|
+
click.echo(" Watch for the PR with:")
|
|
1169
|
+
click.secho(f" prismor fix-status {job_id} --watch", fg="green", bold=True)
|
|
1170
|
+
else:
|
|
1171
|
+
print_error(f"Failed: {result.get('error', 'Unknown error')}")
|
|
1172
|
+
|
|
1173
|
+
click.echo("\n" + "=" * 60 + "\n")
|
|
1174
|
+
|
|
1175
|
+
except PrismorAPIError as e:
|
|
1176
|
+
print_error(str(e))
|
|
1177
|
+
sys.exit(1)
|
|
1178
|
+
except Exception as e:
|
|
1179
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
1180
|
+
sys.exit(1)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
@cli.command("fix-status")
|
|
1184
|
+
@click.argument("job_id", type=str)
|
|
1185
|
+
@click.option("--watch", is_flag=True, help="Poll until the fix completes (up to 30 min)")
|
|
1186
|
+
@click.option("--wait", is_flag=True, hidden=True, help="Alias for --watch (deprecated)")
|
|
1187
|
+
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
|
|
1188
|
+
def fix_status(job_id: str, watch: bool, wait: bool, output_json: bool):
|
|
1189
|
+
"""Check the status of an AI auto-fix job.
|
|
1190
|
+
|
|
1191
|
+
JOB_ID is the job ID returned by 'trigger-fix' or '--fix'.
|
|
1192
|
+
|
|
1193
|
+
Examples:
|
|
1194
|
+
prismor fix-status agent_cli_1234567890_abc123
|
|
1195
|
+
prismor fix-status agent_cli_1234567890_abc123 --watch
|
|
1196
|
+
prismor fix-status agent_cli_1234567890_abc123 --json
|
|
1197
|
+
"""
|
|
1198
|
+
polling = watch or wait # support legacy --wait flag
|
|
1199
|
+
|
|
1200
|
+
try:
|
|
1201
|
+
client = PrismorClient()
|
|
1202
|
+
print_info(f"Checking fix status for job: {job_id}")
|
|
1203
|
+
|
|
1204
|
+
max_wait = 1800
|
|
1205
|
+
poll_interval = 10
|
|
1206
|
+
started_at = time.time()
|
|
1207
|
+
use_rich = RICH_AVAILABLE and should_show_spinner()
|
|
1208
|
+
|
|
1209
|
+
while True:
|
|
1210
|
+
spinner = Spinner("Checking status") if (should_show_spinner() and not use_rich) else None
|
|
1211
|
+
if spinner:
|
|
1212
|
+
spinner.start()
|
|
1213
|
+
try:
|
|
1214
|
+
status_data = client.check_fix_status(job_id)
|
|
1215
|
+
if spinner:
|
|
1216
|
+
spinner.stop()
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
if spinner:
|
|
1219
|
+
spinner.stop()
|
|
1220
|
+
raise e
|
|
1221
|
+
|
|
1222
|
+
status = status_data.get("status", "unknown")
|
|
1223
|
+
done = status in {"success", "failed", "validation_failed"}
|
|
1224
|
+
|
|
1225
|
+
if output_json:
|
|
1226
|
+
click.echo(json.dumps(status_data, indent=2))
|
|
1227
|
+
if not polling or done:
|
|
1228
|
+
break
|
|
1229
|
+
time.sleep(poll_interval)
|
|
1230
|
+
continue
|
|
1231
|
+
|
|
1232
|
+
if status == "processing" and polling and (time.time() - started_at) < max_wait:
|
|
1233
|
+
elapsed = int(time.time() - started_at)
|
|
1234
|
+
if use_rich:
|
|
1235
|
+
# Print a single live line then sleep — simple approach without Live context loop
|
|
1236
|
+
sys.stdout.write(f"\r ⠸ Waiting for AI fix... ({elapsed}s elapsed) ")
|
|
1237
|
+
sys.stdout.flush()
|
|
1238
|
+
else:
|
|
1239
|
+
print_info(f"Still processing... ({elapsed}s elapsed). Checking again in {poll_interval}s")
|
|
1240
|
+
try:
|
|
1241
|
+
time.sleep(poll_interval)
|
|
1242
|
+
except KeyboardInterrupt:
|
|
1243
|
+
click.echo()
|
|
1244
|
+
print_warning("Stopped watching. Fix still running in background.")
|
|
1245
|
+
click.echo(f" Check with: prismor fix-status {job_id}")
|
|
1246
|
+
sys.exit(0)
|
|
1247
|
+
continue
|
|
1248
|
+
|
|
1249
|
+
# Clear any inline progress line before printing the result
|
|
1250
|
+
if use_rich and polling:
|
|
1251
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
1252
|
+
sys.stdout.flush()
|
|
1253
|
+
|
|
1254
|
+
if status == "success":
|
|
1255
|
+
_show_fix_success_panel(status_data)
|
|
1256
|
+
else:
|
|
1257
|
+
click.echo("\n" + "=" * 60)
|
|
1258
|
+
click.secho(" Auto-Fix Status", fg="cyan", bold=True)
|
|
1259
|
+
click.echo("=" * 60 + "\n")
|
|
1260
|
+
click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
|
|
1261
|
+
click.echo()
|
|
1262
|
+
|
|
1263
|
+
if status in {"failed", "validation_failed"}:
|
|
1264
|
+
print_error(f"Status: {status}")
|
|
1265
|
+
click.echo()
|
|
1266
|
+
if status_data.get("error"):
|
|
1267
|
+
click.echo(f" Error: {status_data['error']}")
|
|
1268
|
+
click.echo()
|
|
1269
|
+
if status_data.get("retry_count"):
|
|
1270
|
+
click.echo(f" Retries: {status_data['retry_count']}")
|
|
1271
|
+
click.echo()
|
|
1272
|
+
else:
|
|
1273
|
+
click.secho(f"Status: {status}", fg="yellow")
|
|
1274
|
+
click.echo()
|
|
1275
|
+
if not polling:
|
|
1276
|
+
click.echo(f" The fix is still in progress.")
|
|
1277
|
+
click.echo(f" Watch it with: prismor fix-status {job_id} --watch")
|
|
1278
|
+
click.echo()
|
|
1279
|
+
|
|
1280
|
+
click.echo("=" * 60 + "\n")
|
|
1281
|
+
|
|
1282
|
+
break
|
|
1283
|
+
|
|
1284
|
+
except PrismorAPIError as e:
|
|
1285
|
+
print_error(str(e))
|
|
1286
|
+
sys.exit(1)
|
|
1287
|
+
except Exception as e:
|
|
1288
|
+
print_error(f"Unexpected error: {str(e)}")
|
|
1289
|
+
sys.exit(1)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
# Register the local AI auto-fix command. Defined in its own module so the
|
|
1293
|
+
# agent-runner logic stays isolated; imported here at the bottom so all of this
|
|
1294
|
+
# module's console helpers exist before fix_local's deferred import resolves them.
|
|
1295
|
+
from .local_fix import fix_local # noqa: E402
|
|
1296
|
+
cli.add_command(fix_local)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def main():
|
|
1300
|
+
"""Entry point for the CLI."""
|
|
1301
|
+
cli()
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
if __name__ == "__main__":
|
|
1305
|
+
main()
|