prismor 0.1.2__py3-none-any.whl → 1.1.1__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/cli.py CHANGED
@@ -3,8 +3,10 @@
3
3
  import sys
4
4
  import json
5
5
  import click
6
+ import threading
7
+ import time
6
8
  from typing import Optional
7
- from .api import PrismorClient, PrismorAPIError
9
+ from .api import PrismorClient, PrismorAPIError, parse_github_repo
8
10
 
9
11
 
10
12
  def print_success(message: str):
@@ -27,6 +29,43 @@ def print_warning(message: str):
27
29
  click.secho(f"⚠ {message}", fg="yellow")
28
30
 
29
31
 
32
+ class Spinner:
33
+ """Simple spinner for showing loading state."""
34
+
35
+ def __init__(self, message: str = "Processing"):
36
+ self.message = message
37
+ self.spinner_chars = "|/-\\"
38
+ self.spinner_index = 0
39
+ self.running = False
40
+ self.thread = None
41
+
42
+ def _spin(self):
43
+ """Internal spinner loop."""
44
+ while self.running:
45
+ char = self.spinner_chars[self.spinner_index % len(self.spinner_chars)]
46
+ sys.stdout.write(f"\r{char} {self.message}...")
47
+ sys.stdout.flush()
48
+ self.spinner_index += 1
49
+ time.sleep(0.1)
50
+
51
+ def start(self):
52
+ """Start the spinner."""
53
+ self.running = True
54
+ self.thread = threading.Thread(target=self._spin, daemon=True)
55
+ self.thread.start()
56
+
57
+ def stop(self, message: str = None):
58
+ """Stop the spinner and clear the line."""
59
+ self.running = False
60
+ if self.thread:
61
+ self.thread.join(timeout=0.2)
62
+ # Clear the spinner line
63
+ sys.stdout.write("\r" + " " * (len(self.message) + 15) + "\r")
64
+ sys.stdout.flush()
65
+ if message:
66
+ click.echo(message)
67
+
68
+
30
69
  def format_scan_results(results: dict, scan_type: str):
31
70
  """Format and display scan results."""
32
71
  click.echo("\n" + "=" * 60)
@@ -117,113 +156,145 @@ def format_scan_results(results: dict, scan_type: str):
117
156
 
118
157
  @click.group(invoke_without_command=True)
119
158
  @click.option(
120
- "--scan",
159
+ "--repo",
160
+ "scan_repo",
121
161
  type=str,
122
- help="Repository to scan (username/repo or full GitHub URL)"
162
+ help="Repository to scan (username/repo, GitHub URL, SSH URL, etc.)"
123
163
  )
124
- @click.option("--vex", is_flag=True, help="Perform vulnerability scanning")
164
+ @click.option("--scan", is_flag=True, help="Perform vulnerability scanning")
125
165
  @click.option("--sbom", is_flag=True, help="Generate Software Bill of Materials")
126
166
  @click.option("--detect-secret", is_flag=True, help="Detect secrets in repository")
127
167
  @click.option("--fullscan", is_flag=True, help="Perform all scan types")
128
168
  @click.option("--branch", type=str, help="Specific branch to scan (defaults to main/master)")
129
169
  @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
130
- @click.version_option(version="0.1.2", prog_name="prismor")
170
+ @click.option("--output", "-o", type=click.Path(), help="Save results to file (JSON format)")
171
+ @click.option("--quiet", "-q", is_flag=True, help="Minimal output (only errors and final results)")
172
+ @click.version_option(version="1.1.1", prog_name="prismor")
131
173
  @click.pass_context
132
- def cli(ctx, scan: Optional[str], vex: bool, sbom: bool, detect_secret: bool,
133
- fullscan: bool, branch: Optional[str], output_json: bool):
174
+ def cli(ctx, scan_repo: Optional[str], scan: bool, sbom: bool, detect_secret: bool,
175
+ fullscan: bool, branch: Optional[str], output_json: bool, output: Optional[str], quiet: bool):
134
176
  """Prismor CLI - Security scanning tool for GitHub repositories.
135
177
 
136
178
  Examples:
137
- prismor --scan username/repo --vex
138
- prismor --scan username/repo --fullscan
139
- prismor --scan https://github.com/username/repo --detect-secret
140
- prismor --scan username/repo --sbom --branch develop
179
+ prismor --repo username/repo --scan
180
+ prismor --repo username/repo --fullscan
181
+ prismor --repo https://github.com/username/repo --detect-secret
182
+ prismor --repo git@github.com:username/repo.git --sbom
183
+ prismor --repo github.com/username/repo --fullscan --branch develop
141
184
  prismor status
142
185
  prismor repos
143
186
  """
144
187
  # If no command and no scan option, show help
145
- if ctx.invoked_subcommand is None and not scan:
188
+ if ctx.invoked_subcommand is None and not scan_repo:
146
189
  click.echo(ctx.get_help())
147
190
  return
148
191
 
149
192
  # If scan option is provided, perform the scan
150
- if scan:
193
+ if scan_repo:
151
194
  # Check if at least one scan type is selected
152
- if not any([vex, sbom, detect_secret, fullscan]):
153
- print_error("Please specify at least one scan type: --vex, --sbom, --detect-secret, or --fullscan")
195
+ if not any([scan, sbom, detect_secret, fullscan]):
196
+ print_error("Please specify at least one scan type: --scan, --sbom, --detect-secret, or --fullscan")
154
197
  sys.exit(1)
155
198
 
156
199
  try:
157
200
  # Initialize API client
158
- print_info(f"Initializing Prismor scan for: {scan}")
201
+ if not quiet:
202
+ print_info(f"Initializing Prismor scan for: {scan_repo}")
159
203
  client = PrismorClient()
160
204
 
161
205
  # Determine scan type for display
162
206
  scan_types = []
163
207
  if fullscan:
164
- scan_types.append("Full Scan (VEX + SBOM + Secret Detection)")
208
+ scan_types.append("Full Scan (scan + SBOM + Secret Detection)")
165
209
  else:
166
- if vex:
167
- scan_types.append("VEX")
210
+ if scan:
211
+ scan_types.append("scan")
168
212
  if sbom:
169
213
  scan_types.append("SBOM")
170
214
  if detect_secret:
171
215
  scan_types.append("Secret Detection")
172
216
 
173
- print_info(f"Scan type: {', '.join(scan_types)}")
174
- print_info("Starting scan... (this may take a few minutes)")
217
+ if not quiet:
218
+ print_info(f"Scan type: {', '.join(scan_types)}")
219
+ if scan or fullscan:
220
+ print_info("Starting scan... (vulnerability scans run asynchronously and may take up to 10 minutes)")
221
+ else:
222
+ print_info("Starting scan... (this may take a few minutes)")
175
223
 
176
- # Perform scan
177
- results = client.scan(
178
- repo=scan,
179
- vex=vex,
180
- sbom=sbom,
181
- detect_secret=detect_secret,
182
- fullscan=fullscan,
183
- branch=branch
184
- )
224
+ # Show loading spinner during scan (unless quiet mode)
225
+ spinner = None
226
+ if not quiet and not output_json and not output:
227
+ spinner = Spinner("Scanning repository")
228
+ spinner.start()
229
+
230
+ try:
231
+ # Perform scan
232
+ results = client.scan(
233
+ repo=scan_repo,
234
+ scan=scan,
235
+ sbom=sbom,
236
+ detect_secret=detect_secret,
237
+ fullscan=fullscan,
238
+ branch=branch
239
+ )
240
+ if spinner:
241
+ spinner.stop()
242
+ except Exception as e:
243
+ if spinner:
244
+ spinner.stop()
245
+ raise e
246
+
247
+ # Save to file if --output specified
248
+ if output:
249
+ try:
250
+ with open(output, 'w') as f:
251
+ json.dump(results, f, indent=2)
252
+ if not quiet:
253
+ print_success(f"Results saved to: {output}")
254
+ except Exception as e:
255
+ print_error(f"Failed to save results to file: {str(e)}")
256
+ sys.exit(1)
185
257
 
186
258
  # Output results
187
- if output_json:
188
- click.echo(json.dumps(results, indent=2))
259
+ if output_json or output:
260
+ if not output: # Only print to stdout if not saving to file
261
+ click.echo(json.dumps(results, indent=2))
189
262
  else:
190
- print_success("Scan completed successfully!")
263
+ if not quiet:
264
+ print_success("Scan completed successfully!")
191
265
  format_scan_results(results, ', '.join(scan_types))
192
266
 
193
- # Try to get repository ID and display dashboard link
194
- try:
195
- # Extract repo name from scan input
196
- repo_name = scan
197
- if scan.startswith("http://") or scan.startswith("https://"):
198
- # Extract from GitHub URL
199
- if "github.com/" in scan:
200
- repo_name = scan.split("github.com/")[1].rstrip("/")
201
-
202
- # Get repository ID
203
- repo_info = client.get_repository_by_name(repo_name)
204
- if repo_info.get("success") and "repository" in repo_info:
205
- repo_id = repo_info["repository"]["id"]
206
- dashboard_url = f"https://prismor.dev/repositories/{repo_id}"
267
+ # Try to get repository ID and display dashboard link (unless quiet mode)
268
+ if not quiet:
269
+ try:
270
+ # Extract repo name from scan input using the comprehensive parser
271
+ repo_name = parse_github_repo(scan_repo)
207
272
 
208
- click.echo("\n" + "=" * 60)
209
- click.secho(" 📊 Dashboard Analysis", fg="cyan", bold=True)
210
- click.echo("=" * 60)
211
- click.secho(f"🔗 View detailed analysis and insights:", fg="blue")
212
- click.secho(f" {dashboard_url}", fg="green", bold=True)
213
- click.echo("\n💡 The dashboard provides:")
214
- click.echo(" Interactive visualizations and charts")
215
- click.echo(" Historical vulnerability trends")
216
- click.echo(" Detailed security reports")
217
- click.echo(" Team collaboration features")
218
- click.echo(" Export capabilities")
219
- click.echo("=" * 60 + "\n")
220
-
221
- except PrismorAPIError as e:
222
- # Repository might not be found, continue without dashboard link
223
- print_warning(f"Could not generate dashboard link: {str(e)}")
224
- except Exception as e:
225
- # Any other error, continue without dashboard link
226
- print_warning(f"Could not generate dashboard link: {str(e)}")
273
+ # Get repository ID
274
+ repo_info = client.get_repository_by_name(repo_name)
275
+ if repo_info.get("success") and "repository" in repo_info:
276
+ repo_id = repo_info["repository"]["id"]
277
+ dashboard_url = f"https://prismor.dev/repositories/{repo_id}"
278
+
279
+ click.echo("\n" + "=" * 60)
280
+ click.secho(" 📊 Dashboard Analysis", fg="cyan", bold=True)
281
+ click.echo("=" * 60)
282
+ click.secho(f"🔗 View detailed analysis and insights:", fg="blue")
283
+ click.secho(f" {dashboard_url}", fg="green", bold=True)
284
+ click.echo("\n💡 The dashboard provides:")
285
+ click.echo(" • Interactive visualizations and charts")
286
+ click.echo(" • Historical vulnerability trends")
287
+ click.echo(" • Detailed security reports")
288
+ click.echo(" Team collaboration features")
289
+ click.echo(" • Export capabilities")
290
+ click.echo("=" * 60 + "\n")
291
+
292
+ except PrismorAPIError as e:
293
+ # Repository might not be found, continue without dashboard link
294
+ print_warning(f"Could not generate dashboard link: {str(e)}")
295
+ except Exception as e:
296
+ # Any other error, continue without dashboard link
297
+ print_warning(f"Could not generate dashboard link: {str(e)}")
227
298
 
228
299
  except PrismorAPIError as e:
229
300
  print_error(str(e))
@@ -236,7 +307,7 @@ def cli(ctx, scan: Optional[str], vex: bool, sbom: bool, detect_secret: bool,
236
307
  @cli.command()
237
308
  def version():
238
309
  """Display the version of Prismor CLI."""
239
- click.echo("Prismor CLI v0.1.0")
310
+ click.echo("Prismor CLI v1.1.0")
240
311
 
241
312
 
242
313
  @cli.command()
@@ -256,6 +327,8 @@ def config():
256
327
  print_success(f"PRISMOR_API_KEY: {masked_key}")
257
328
  else:
258
329
  print_error("PRISMOR_API_KEY: Not set")
330
+ click.echo("\nPlease specify your API key. You can generate one for free at:")
331
+ click.secho(" https://www.prismor.dev/cli", fg="cyan", underline=True)
259
332
  click.echo("\nTo set your API key, run:")
260
333
  click.echo(" export PRISMOR_API_KEY=your_api_key")
261
334
 
@@ -268,7 +341,14 @@ def repos():
268
341
  """List your connected repositories."""
269
342
  try:
270
343
  client = PrismorClient()
271
- repos_data = client.get_repositories()
344
+ spinner = Spinner("Loading repositories")
345
+ spinner.start()
346
+ try:
347
+ repos_data = client.get_repositories()
348
+ spinner.stop()
349
+ except Exception as e:
350
+ spinner.stop()
351
+ raise e
272
352
 
273
353
  click.echo("\n" + "=" * 60)
274
354
  click.secho(" Your Repositories", fg="cyan", bold=True)
@@ -299,12 +379,216 @@ def repos():
299
379
  sys.exit(1)
300
380
 
301
381
 
382
+ @cli.command()
383
+ @click.argument("repo", type=str)
384
+ @click.option("--branch", type=str, help="Specific branch to scan (defaults to main)")
385
+ @click.option("--token", type=str, help="GitHub token (or set GITHUB_TOKEN env var)")
386
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
387
+ def start_scan(repo: str, branch: Optional[str], token: Optional[str], output_json: bool):
388
+ """Start a vulnerability scan and return a job_id for status checking.
389
+
390
+ REPO is the repository to scan (username/repo, GitHub URL, SSH URL, etc.)
391
+
392
+ This command starts a scan asynchronously and returns immediately with a job_id.
393
+ Use 'prismor scan-status <job_id>' to check when the scan completes.
394
+
395
+ Note: Requires GitHub token. Set GITHUB_TOKEN environment variable or use --token option.
396
+
397
+ Examples:
398
+ prismor start-scan username/repo
399
+ prismor start-scan https://github.com/username/repo --branch develop
400
+ prismor start-scan username/repo --token ghp_xxxxx
401
+ prismor start-scan username/repo --json
402
+ """
403
+ try:
404
+ client = PrismorClient()
405
+ print_info(f"Starting vulnerability scan for: {repo}")
406
+ if branch:
407
+ print_info(f"Branch: {branch}")
408
+
409
+ spinner = Spinner("Starting scan")
410
+ spinner.start()
411
+ try:
412
+ result = client.start_vulnerability_scan(repo, branch, token)
413
+ spinner.stop()
414
+ except Exception as e:
415
+ spinner.stop()
416
+ raise e
417
+
418
+ if output_json:
419
+ click.echo(json.dumps(result, indent=2))
420
+ else:
421
+ click.echo("\n" + "=" * 60)
422
+ click.secho(" Scan Started", fg="cyan", bold=True)
423
+ click.echo("=" * 60 + "\n")
424
+
425
+ job_id = result.get("job_id")
426
+ if job_id:
427
+ print_success(f"Scan started successfully!")
428
+ click.echo()
429
+ click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
430
+ click.echo()
431
+ click.secho("Repository:", fg="yellow", bold=True)
432
+ click.echo(f" {result.get('repository', repo)}")
433
+ click.echo()
434
+ if "branch" in result:
435
+ click.secho("Branch:", fg="yellow", bold=True)
436
+ click.echo(f" {result['branch']}")
437
+ click.echo()
438
+ click.secho("Status:", fg="yellow", bold=True)
439
+ click.echo(f" {result.get('status', 'accepted')}")
440
+ click.echo()
441
+ click.secho("Next Steps:", fg="cyan", bold=True)
442
+ click.echo(f" Check scan status with:")
443
+ click.secho(f" prismor scan-status {job_id}", fg="green", bold=True)
444
+ click.echo()
445
+ else:
446
+ print_error("Failed to get job_id from response")
447
+ click.echo(json.dumps(result, indent=2))
448
+
449
+ click.echo("=" * 60 + "\n")
450
+
451
+ except PrismorAPIError as e:
452
+ print_error(str(e))
453
+ sys.exit(1)
454
+ except Exception as e:
455
+ print_error(f"Unexpected error: {str(e)}")
456
+ sys.exit(1)
457
+
458
+
459
+ @cli.command()
460
+ @click.argument("job_id", type=str)
461
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
462
+ def scan_status(job_id: str, output_json: bool):
463
+ """Check the status of a vulnerability scan job.
464
+
465
+ JOB_ID is the job ID returned when starting a scan.
466
+
467
+ Examples:
468
+ prismor scan-status a724a4663cda4bf087ad171683cb726d
469
+ prismor scan-status 50cbe253e5634227b81fe744c2a0b3e7 --json
470
+ """
471
+ try:
472
+ client = PrismorClient()
473
+ print_info(f"Checking scan status for job: {job_id}")
474
+
475
+ spinner = Spinner("Checking status")
476
+ spinner.start()
477
+ try:
478
+ status_data = client.check_scan_status(job_id)
479
+ spinner.stop()
480
+ except Exception as e:
481
+ spinner.stop()
482
+ raise e
483
+
484
+ if output_json:
485
+ click.echo(json.dumps(status_data, indent=2))
486
+ else:
487
+ click.echo("\n" + "=" * 60)
488
+ click.secho(" Scan Status", fg="cyan", bold=True)
489
+ click.echo("=" * 60 + "\n")
490
+
491
+ click.secho(f"Job ID: {status_data.get('job_id', job_id)}", fg="yellow", bold=True)
492
+ click.echo()
493
+
494
+ status = status_data.get("status", "unknown")
495
+ if status == "completed":
496
+ print_success(f"Status: {status}")
497
+ click.echo()
498
+
499
+ if "repository" in status_data:
500
+ click.secho("Repository:", fg="yellow", bold=True)
501
+ click.echo(f" {status_data['repository']}")
502
+ click.echo()
503
+
504
+ if "branch" in status_data:
505
+ click.secho("Branch:", fg="yellow", bold=True)
506
+ click.echo(f" {status_data['branch']}")
507
+ click.echo()
508
+
509
+ if "duration" in status_data:
510
+ click.secho("Duration:", fg="yellow", bold=True)
511
+ click.echo(f" {status_data['duration']:.2f} seconds")
512
+ click.echo()
513
+
514
+ if "public_url" in status_data:
515
+ click.secho("Results URL:", fg="yellow", bold=True)
516
+ click.secho(f" {status_data['public_url']}", fg="green")
517
+ click.echo()
518
+
519
+ if "presigned_url" in status_data:
520
+ click.secho("Presigned URL (expires in 1 hour):", fg="yellow", bold=True)
521
+ click.secho(f" {status_data['presigned_url']}", fg="blue")
522
+ click.echo()
523
+
524
+ # Display vulnerability summary if available
525
+ if "summary" in status_data:
526
+ summary = status_data["summary"]
527
+ click.secho("Vulnerability Summary:", fg="yellow", bold=True)
528
+ click.echo(f" Total Vulnerabilities: {summary.get('total_vulnerabilities', 0)}")
529
+ click.echo(f" Total Targets Scanned: {summary.get('total_targets', 0)}")
530
+ click.echo()
531
+
532
+ severity_breakdown = summary.get('severity_breakdown', {})
533
+ if severity_breakdown:
534
+ click.secho(" Severity Breakdown:", fg="yellow")
535
+ if severity_breakdown.get('CRITICAL', 0) > 0:
536
+ click.secho(f" CRITICAL: {severity_breakdown['CRITICAL']}", fg="red", bold=True)
537
+ if severity_breakdown.get('HIGH', 0) > 0:
538
+ click.secho(f" HIGH: {severity_breakdown['HIGH']}", fg="red")
539
+ if severity_breakdown.get('MEDIUM', 0) > 0:
540
+ click.secho(f" MEDIUM: {severity_breakdown['MEDIUM']}", fg="yellow")
541
+ if severity_breakdown.get('LOW', 0) > 0:
542
+ click.secho(f" LOW: {severity_breakdown['LOW']}", fg="blue")
543
+ if severity_breakdown.get('UNKNOWN', 0) > 0:
544
+ click.secho(f" UNKNOWN: {severity_breakdown['UNKNOWN']}", fg="white")
545
+ click.echo()
546
+
547
+ if "scan_date" in status_data:
548
+ click.secho("Scan Date:", fg="yellow", bold=True)
549
+ click.echo(f" {status_data['scan_date']}")
550
+ click.echo()
551
+
552
+ elif status == "running":
553
+ print_info(f"Status: {status}")
554
+ if "message" in status_data:
555
+ click.echo(f" {status_data['message']}")
556
+ click.echo()
557
+ click.echo("The scan is still in progress. Check back in a few moments.")
558
+ click.echo()
559
+
560
+ elif status == "failed":
561
+ print_error(f"Status: {status}")
562
+ if "error" in status_data:
563
+ click.echo(f" Error: {status_data['error']}")
564
+ click.echo()
565
+ else:
566
+ click.secho(f"Status: {status}", fg="yellow")
567
+ click.echo()
568
+
569
+ click.echo("=" * 60 + "\n")
570
+
571
+ except PrismorAPIError as e:
572
+ print_error(str(e))
573
+ sys.exit(1)
574
+ except Exception as e:
575
+ print_error(f"Unexpected error: {str(e)}")
576
+ sys.exit(1)
577
+
578
+
302
579
  @cli.command()
303
580
  def status():
304
581
  """Check your account status and GitHub integration."""
305
582
  try:
306
583
  client = PrismorClient()
307
- auth_data = client.authenticate()
584
+ spinner = Spinner("Checking account status")
585
+ spinner.start()
586
+ try:
587
+ auth_data = client.authenticate()
588
+ spinner.stop()
589
+ except Exception as e:
590
+ spinner.stop()
591
+ raise e
308
592
 
309
593
  click.echo("\n" + "=" * 60)
310
594
  click.secho(" Account Status", fg="cyan", bold=True)
@@ -312,9 +596,7 @@ def status():
312
596
 
313
597
  user_info = auth_data.get("user", {})
314
598
  if user_info:
315
- click.secho(f"User: {user_info.get('name', 'Unknown')} ({user_info.get('email', 'No email')})", fg="yellow")
316
- click.echo()
317
-
599
+
318
600
  repositories = user_info.get("repositories", [])
319
601
  click.secho(f"Connected Repositories: {len(repositories)}", fg="green")
320
602