security-use 0.1.1__py3-none-any.whl → 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. security_use/__init__.py +9 -1
  2. security_use/auth/__init__.py +16 -0
  3. security_use/auth/client.py +223 -0
  4. security_use/auth/config.py +177 -0
  5. security_use/auth/oauth.py +317 -0
  6. security_use/cli.py +699 -34
  7. security_use/compliance/__init__.py +10 -0
  8. security_use/compliance/mapper.py +275 -0
  9. security_use/compliance/models.py +50 -0
  10. security_use/dependency_scanner.py +76 -30
  11. security_use/fixers/iac_fixer.py +173 -95
  12. security_use/iac/rules/azure.py +246 -0
  13. security_use/iac/rules/gcp.py +255 -0
  14. security_use/iac/rules/kubernetes.py +429 -0
  15. security_use/iac/rules/registry.py +56 -0
  16. security_use/parsers/__init__.py +18 -0
  17. security_use/parsers/base.py +2 -0
  18. security_use/parsers/composer.py +101 -0
  19. security_use/parsers/conda.py +97 -0
  20. security_use/parsers/dotnet.py +89 -0
  21. security_use/parsers/gradle.py +90 -0
  22. security_use/parsers/maven.py +108 -0
  23. security_use/parsers/npm.py +196 -0
  24. security_use/parsers/yarn.py +108 -0
  25. security_use/reporter.py +29 -1
  26. security_use/sbom/__init__.py +10 -0
  27. security_use/sbom/generator.py +340 -0
  28. security_use/sbom/models.py +40 -0
  29. security_use/scanner.py +15 -2
  30. security_use/sensor/__init__.py +125 -0
  31. security_use/sensor/alert_queue.py +207 -0
  32. security_use/sensor/config.py +217 -0
  33. security_use/sensor/dashboard_alerter.py +246 -0
  34. security_use/sensor/detector.py +415 -0
  35. security_use/sensor/endpoint_analyzer.py +339 -0
  36. security_use/sensor/middleware.py +521 -0
  37. security_use/sensor/models.py +140 -0
  38. security_use/sensor/webhook.py +227 -0
  39. security_use-0.2.9.dist-info/METADATA +531 -0
  40. security_use-0.2.9.dist-info/RECORD +60 -0
  41. security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
  42. security_use-0.1.1.dist-info/METADATA +0 -92
  43. security_use-0.1.1.dist-info/RECORD +0 -30
  44. {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
  45. {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/entry_points.txt +0 -0
security_use/cli.py CHANGED
@@ -75,6 +75,87 @@ def _output_result(
75
75
  console.print(report)
76
76
 
77
77
 
78
+ def _get_git_info(path: str) -> tuple[Optional[str], Optional[str], Optional[str]]:
79
+ """Get git repository info (repo name, branch, commit) for the given path."""
80
+ import subprocess
81
+
82
+ # Resolve to absolute path and find the directory
83
+ scan_path = Path(path).resolve()
84
+ if scan_path.is_file():
85
+ scan_path = scan_path.parent
86
+
87
+ try:
88
+ # Get repo name from remote URL
89
+ result = subprocess.run(
90
+ ["git", "remote", "get-url", "origin"],
91
+ capture_output=True, text=True, timeout=5,
92
+ cwd=str(scan_path)
93
+ )
94
+ repo_name = None
95
+ if result.returncode == 0:
96
+ url = result.stdout.strip()
97
+ # Extract repo name from URL
98
+ if url.endswith(".git"):
99
+ url = url[:-4]
100
+ repo_name = url.split("/")[-1]
101
+
102
+ # Get current branch
103
+ result = subprocess.run(
104
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
105
+ capture_output=True, text=True, timeout=5,
106
+ cwd=str(scan_path)
107
+ )
108
+ branch = result.stdout.strip() if result.returncode == 0 else None
109
+
110
+ # Get current commit SHA
111
+ result = subprocess.run(
112
+ ["git", "rev-parse", "HEAD"],
113
+ capture_output=True, text=True, timeout=5,
114
+ cwd=str(scan_path)
115
+ )
116
+ commit = result.stdout.strip() if result.returncode == 0 else None
117
+
118
+ return repo_name, branch, commit
119
+ except Exception:
120
+ return None, None, None
121
+
122
+
123
+ def _auto_upload_results(result: ScanResult, scan_type: str, path: str) -> None:
124
+ """Automatically upload scan results to dashboard if authenticated."""
125
+ from security_use.auth import AuthConfig, DashboardClient, OAuthError
126
+
127
+ config = AuthConfig()
128
+ if not config.is_authenticated:
129
+ return # Silently skip if not authenticated
130
+
131
+ try:
132
+ client = DashboardClient(config)
133
+ repo_name, branch, commit = _get_git_info(path)
134
+
135
+ # Use path as repo name if git info not available
136
+ if not repo_name:
137
+ repo_name = Path(path).resolve().name
138
+
139
+ response = client.upload_scan(
140
+ result=result,
141
+ scan_type=scan_type,
142
+ repo_name=repo_name,
143
+ branch=branch,
144
+ commit_sha=commit,
145
+ )
146
+
147
+ summary = response.get("summary", {})
148
+ total = summary.get("total", result.total_issues)
149
+ console.print(f"\n[dim]Results synced to dashboard ({total} finding(s))[/dim]")
150
+
151
+ except OAuthError:
152
+ # Silently ignore auth errors - user might have expired token
153
+ pass
154
+ except Exception:
155
+ # Don't fail the scan if upload fails
156
+ pass
157
+
158
+
78
159
  @click.group()
79
160
  @click.version_option(version=__version__, prog_name="security-use")
80
161
  def main() -> None:
@@ -129,6 +210,10 @@ def scan_deps(path: str, format: str, severity: str, output: Optional[str]) -> N
129
210
  # Output results
130
211
  _output_result(result, format, output)
131
212
 
213
+ # Auto-upload to dashboard if authenticated
214
+ if not is_machine_format:
215
+ _auto_upload_results(result, "deps", path)
216
+
132
217
  # Exit with error code if vulnerabilities found
133
218
  if result.vulnerabilities:
134
219
  if not is_machine_format:
@@ -160,12 +245,18 @@ def scan_deps(path: str, format: str, severity: str, output: Optional[str]) -> N
160
245
  type=click.Path(),
161
246
  help="Write output to file",
162
247
  )
163
- def scan_iac(path: str, format: str, severity: str, output: Optional[str]) -> None:
248
+ @click.option(
249
+ "--compliance", "-c",
250
+ type=click.Choice(["soc2", "hipaa", "pci-dss", "nist-800-53", "cis-aws", "cis-azure", "cis-gcp", "cis-kubernetes", "iso-27001"]),
251
+ help="Filter by compliance framework",
252
+ )
253
+ def scan_iac(path: str, format: str, severity: str, output: Optional[str], compliance: Optional[str]) -> None:
164
254
  """Scan Infrastructure as Code for security misconfigurations.
165
255
 
166
256
  PATH is the file or directory to scan (default: current directory).
167
257
  """
168
258
  from security_use.iac_scanner import IaCScanner
259
+ from security_use.compliance import ComplianceMapper, ComplianceFramework
169
260
 
170
261
  is_machine_format = format in ("json", "sarif")
171
262
 
@@ -179,9 +270,37 @@ def scan_iac(path: str, format: str, severity: str, output: Optional[str]) -> No
179
270
  threshold = _get_severity_threshold(severity)
180
271
  result = _filter_by_severity(result, threshold)
181
272
 
273
+ # Filter by compliance framework if specified
274
+ if compliance:
275
+ framework_map = {
276
+ "soc2": ComplianceFramework.SOC2,
277
+ "hipaa": ComplianceFramework.HIPAA,
278
+ "pci-dss": ComplianceFramework.PCI_DSS,
279
+ "nist-800-53": ComplianceFramework.NIST_800_53,
280
+ "cis-aws": ComplianceFramework.CIS_AWS,
281
+ "cis-azure": ComplianceFramework.CIS_AZURE,
282
+ "cis-gcp": ComplianceFramework.CIS_GCP,
283
+ "cis-kubernetes": ComplianceFramework.CIS_K8S,
284
+ "iso-27001": ComplianceFramework.ISO_27001,
285
+ }
286
+ mapper = ComplianceMapper()
287
+ framework = framework_map[compliance]
288
+
289
+ # Filter findings to those with compliance mappings
290
+ compliance_findings = mapper.get_findings_by_framework(result.iac_findings, framework)
291
+ compliance_rule_ids = {f.rule_id for f in compliance_findings}
292
+ result.iac_findings = [f for f in result.iac_findings if f.rule_id in compliance_rule_ids]
293
+
294
+ if not is_machine_format:
295
+ console.print(f"[dim]Filtered by {compliance.upper()} compliance[/dim]")
296
+
182
297
  # Output results
183
298
  _output_result(result, format, output)
184
299
 
300
+ # Auto-upload to dashboard if authenticated
301
+ if not is_machine_format:
302
+ _auto_upload_results(result, "iac", path)
303
+
185
304
  # Exit with error code if findings found
186
305
  if result.iac_findings:
187
306
  if not is_machine_format:
@@ -250,6 +369,10 @@ def scan_all(path: str, format: str, severity: str, output: Optional[str]) -> No
250
369
  # Output results
251
370
  _output_result(result, format, output)
252
371
 
372
+ # Auto-upload to dashboard if authenticated (use "deps" as primary type for combined scan)
373
+ if not is_machine_format:
374
+ _auto_upload_results(result, "deps", path)
375
+
253
376
  # Exit with error code if issues found
254
377
  if result.total_issues > 0:
255
378
  if not is_machine_format:
@@ -267,50 +390,131 @@ def scan_all(path: str, format: str, severity: str, output: Optional[str]) -> No
267
390
  is_flag=True,
268
391
  help="Show what would be fixed without making changes",
269
392
  )
270
- def fix(path: str, dry_run: bool) -> None:
271
- """Auto-fix dependency vulnerabilities by updating versions.
393
+ @click.option(
394
+ "--deps-only",
395
+ is_flag=True,
396
+ help="Only fix dependency vulnerabilities",
397
+ )
398
+ @click.option(
399
+ "--iac-only",
400
+ is_flag=True,
401
+ help="Only fix IaC misconfigurations",
402
+ )
403
+ def fix(path: str, dry_run: bool, deps_only: bool, iac_only: bool) -> None:
404
+ """Auto-fix security vulnerabilities and IaC misconfigurations.
272
405
 
273
406
  PATH is the file or directory to scan and fix (default: current directory).
274
407
  """
275
408
  from security_use.dependency_scanner import DependencyScanner
409
+ from security_use.iac_scanner import IaCScanner
410
+ from security_use.fixers.iac_fixer import IaCFixer
276
411
 
277
- console.print(f"[blue]Scanning dependencies in {path}...[/blue]")
278
-
279
- scanner = DependencyScanner()
280
- result = scanner.scan_path(Path(path))
281
-
282
- if not result.vulnerabilities:
283
- console.print("[green]No vulnerabilities found - nothing to fix[/green]")
284
- return
285
-
286
- # Group vulnerabilities by package
287
- package_fixes: dict[str, tuple[str, str]] = {}
288
- for vuln in result.vulnerabilities:
289
- if vuln.fixed_version and vuln.package not in package_fixes:
290
- package_fixes[vuln.package] = (vuln.installed_version, vuln.fixed_version)
412
+ fix_deps = not iac_only
413
+ fix_iac = not deps_only
291
414
 
292
- if not package_fixes:
293
- console.print("[yellow]No automatic fixes available for found vulnerabilities[/yellow]")
294
- return
415
+ total_fixes = 0
295
416
 
296
- console.print(f"\n[bold]Found {len(package_fixes)} package(s) to update:[/bold]\n")
417
+ # Fix dependency vulnerabilities
418
+ if fix_deps:
419
+ console.print(f"[blue]Scanning dependencies in {path}...[/blue]")
297
420
 
298
- for package, (current, fixed) in package_fixes.items():
299
- console.print(f" • {package}: {current} → {fixed}")
421
+ dep_scanner = DependencyScanner()
422
+ dep_result = dep_scanner.scan_path(Path(path))
423
+
424
+ if dep_result.vulnerabilities:
425
+ # Group vulnerabilities by package
426
+ package_fixes: dict[str, tuple[str, str]] = {}
427
+ for vuln in dep_result.vulnerabilities:
428
+ if vuln.fixed_version and vuln.package not in package_fixes:
429
+ package_fixes[vuln.package] = (vuln.installed_version, vuln.fixed_version)
430
+
431
+ if package_fixes:
432
+ console.print(f"\n[bold]Found {len(package_fixes)} package(s) to update:[/bold]\n")
433
+
434
+ for package, (current, fixed) in package_fixes.items():
435
+ console.print(f" • {package}: {current} → {fixed}")
436
+
437
+ if not dry_run:
438
+ console.print("\n[blue]Applying dependency fixes...[/blue]")
439
+ for file_path in dep_result.scanned_files:
440
+ path_obj = Path(file_path)
441
+ if path_obj.suffix == ".txt" or path_obj.name == "requirements.txt":
442
+ _fix_requirements_file(path_obj, package_fixes)
443
+ total_fixes += len(package_fixes)
444
+ else:
445
+ console.print("[yellow]No automatic fixes available for dependency vulnerabilities[/yellow]")
446
+ else:
447
+ console.print("[green]No dependency vulnerabilities found[/green]")
448
+
449
+ # Fix IaC misconfigurations
450
+ if fix_iac:
451
+ console.print(f"\n[blue]Scanning IaC files in {path}...[/blue]")
452
+
453
+ iac_scanner = IaCScanner()
454
+ iac_result = iac_scanner.scan_path(Path(path))
455
+
456
+ if iac_result.iac_findings:
457
+ iac_fixer = IaCFixer()
458
+
459
+ # Filter findings that have available fixes
460
+ fixable_findings = [
461
+ f for f in iac_result.iac_findings
462
+ if iac_fixer.has_fix(f.rule_id)
463
+ ]
464
+
465
+ if fixable_findings:
466
+ # Deduplicate findings by (file_path, rule_id, resource_name)
467
+ seen_fixes: set[tuple[str, str, str]] = set()
468
+ unique_findings = []
469
+ for finding in fixable_findings:
470
+ key = (finding.file_path, finding.rule_id, finding.resource_name)
471
+ if key not in seen_fixes:
472
+ seen_fixes.add(key)
473
+ unique_findings.append(finding)
474
+
475
+ console.print(f"\n[bold]Found {len(unique_findings)} IaC issue(s) to fix:[/bold]\n")
476
+
477
+ for finding in unique_findings:
478
+ console.print(f" • [{finding.rule_id}] {finding.title}")
479
+ console.print(f" {finding.file_path}:{finding.line_number} ({finding.resource_name})")
480
+
481
+ if not dry_run:
482
+ console.print("\n[blue]Applying IaC fixes...[/blue]")
483
+
484
+ for finding in unique_findings:
485
+ result = iac_fixer.fix_finding(
486
+ file_path=finding.file_path,
487
+ rule_id=finding.rule_id,
488
+ resource_name=finding.resource_name,
489
+ line_number=finding.line_number,
490
+ auto_apply=True,
491
+ )
492
+
493
+ if result.success:
494
+ console.print(f" [green]Fixed {finding.rule_id} in {finding.file_path}[/green]")
495
+ console.print(f" {result.explanation}")
496
+ total_fixes += 1
497
+ else:
498
+ console.print(f" [yellow]Could not fix {finding.rule_id}: {result.error}[/yellow]")
499
+ else:
500
+ console.print("[yellow]No automatic fixes available for IaC findings[/yellow]")
501
+
502
+ # Report unfixable findings
503
+ unfixable = [f for f in iac_result.iac_findings if not iac_fixer.has_fix(f.rule_id)]
504
+ if unfixable:
505
+ console.print(f"\n[yellow]{len(unfixable)} IaC finding(s) require manual remediation:[/yellow]")
506
+ for finding in unfixable:
507
+ console.print(f" • [{finding.rule_id}] {finding.title}")
508
+ else:
509
+ console.print("[green]No IaC misconfigurations found[/green]")
300
510
 
511
+ # Summary
301
512
  if dry_run:
302
513
  console.print("\n[yellow]Dry run - no changes made[/yellow]")
303
- return
304
-
305
- # Apply fixes
306
- console.print("\n[blue]Applying fixes...[/blue]")
307
-
308
- for file_path in result.scanned_files:
309
- path_obj = Path(file_path)
310
- if path_obj.suffix == ".txt" or path_obj.name == "requirements.txt":
311
- _fix_requirements_file(path_obj, package_fixes)
312
-
313
- console.print("[green]Fixes applied successfully[/green]")
514
+ elif total_fixes > 0:
515
+ console.print(f"\n[green]Successfully applied {total_fixes} fix(es)[/green]")
516
+ else:
517
+ console.print("\n[yellow]No fixes were applied[/yellow]")
314
518
 
315
519
 
316
520
  def _fix_requirements_file(
@@ -344,5 +548,466 @@ def version() -> None:
344
548
  click.echo(f"security-use version {__version__}")
345
549
 
346
550
 
551
+ # =============================================================================
552
+ # CI/CD Command
553
+ # =============================================================================
554
+
555
+
556
+ @main.command()
557
+ @click.argument("path", type=click.Path(exists=True), default=".")
558
+ @click.option(
559
+ "--fail-on", "-f",
560
+ type=click.Choice(["critical", "high", "medium", "low"]),
561
+ default="high",
562
+ help="Minimum severity to fail on",
563
+ )
564
+ @click.option(
565
+ "--output", "-o",
566
+ type=click.Choice(["sarif", "json", "table", "minimal"]),
567
+ default="minimal",
568
+ help="Output format",
569
+ )
570
+ @click.option(
571
+ "--sarif-file",
572
+ type=click.Path(),
573
+ help="Write SARIF output to file",
574
+ )
575
+ @click.option(
576
+ "--deps-only",
577
+ is_flag=True,
578
+ help="Only scan dependencies",
579
+ )
580
+ @click.option(
581
+ "--iac-only",
582
+ is_flag=True,
583
+ help="Only scan IaC files",
584
+ )
585
+ def ci(path: str, fail_on: str, output: str, sarif_file: Optional[str], deps_only: bool, iac_only: bool) -> None:
586
+ """Run security scan optimized for CI/CD pipelines.
587
+
588
+ Designed for non-interactive CI environments with minimal output
589
+ and clear exit codes:
590
+ - Exit 0: No issues found at or above severity threshold
591
+ - Exit 1: Issues found at or above severity threshold
592
+ - Exit 2: Scan error
593
+
594
+ PATH is the directory to scan (default: current directory).
595
+ """
596
+ from security_use.dependency_scanner import DependencyScanner
597
+ from security_use.iac_scanner import IaCScanner
598
+
599
+ try:
600
+ result = ScanResult()
601
+
602
+ # Scan dependencies
603
+ if not iac_only:
604
+ dep_scanner = DependencyScanner()
605
+ dep_result = dep_scanner.scan_path(Path(path))
606
+ result.vulnerabilities = dep_result.vulnerabilities
607
+ result.scanned_files.extend(dep_result.scanned_files)
608
+ result.errors.extend(dep_result.errors)
609
+
610
+ # Scan IaC
611
+ if not deps_only:
612
+ iac_scanner = IaCScanner()
613
+ iac_result = iac_scanner.scan_path(Path(path))
614
+ result.iac_findings = iac_result.iac_findings
615
+ result.scanned_files.extend(iac_result.scanned_files)
616
+ result.errors.extend(iac_result.errors)
617
+
618
+ # Filter by severity
619
+ threshold = _get_severity_threshold(fail_on)
620
+ filtered_result = _filter_by_severity(result, threshold)
621
+
622
+ # Write SARIF if requested
623
+ if sarif_file:
624
+ reporter = create_reporter("sarif")
625
+ sarif_content = reporter.generate(result) # Full results for SARIF
626
+ Path(sarif_file).write_text(sarif_content, encoding="utf-8")
627
+
628
+ # Output based on format
629
+ if output == "minimal":
630
+ # Minimal output for CI logs
631
+ total = filtered_result.total_issues
632
+ if total > 0:
633
+ click.echo(f"FAILED: {total} issue(s) at {fail_on.upper()} or above")
634
+ click.echo(f" Vulnerabilities: {len(filtered_result.vulnerabilities)}")
635
+ click.echo(f" IaC Findings: {len(filtered_result.iac_findings)}")
636
+ else:
637
+ click.echo(f"PASSED: No issues at {fail_on.upper()} or above")
638
+ elif output == "table":
639
+ _output_result(result, "table", None)
640
+ else:
641
+ reporter = create_reporter(output)
642
+ click.echo(reporter.generate(result))
643
+
644
+ # Exit with appropriate code
645
+ if filtered_result.total_issues > 0:
646
+ sys.exit(1)
647
+ sys.exit(0)
648
+
649
+ except Exception as e:
650
+ click.echo(f"ERROR: {e}", err=True)
651
+ sys.exit(2)
652
+
653
+
654
+ # =============================================================================
655
+ # Authentication Commands
656
+ # =============================================================================
657
+
658
+
659
+ @main.group()
660
+ def auth() -> None:
661
+ """Authenticate with SecurityUse dashboard."""
662
+ pass
663
+
664
+
665
+ @auth.command("login")
666
+ @click.option(
667
+ "--no-browser",
668
+ is_flag=True,
669
+ help="Don't automatically open the browser",
670
+ )
671
+ def auth_login(no_browser: bool) -> None:
672
+ """Log in to SecurityUse dashboard.
673
+
674
+ This will open your browser to authenticate with security-use.dev.
675
+ After authentication, scan results can be synced to your dashboard.
676
+ """
677
+ from security_use.auth import OAuthFlow, OAuthError, AuthConfig
678
+
679
+ config = AuthConfig()
680
+
681
+ if config.is_authenticated:
682
+ console.print(f"[yellow]Already logged in as {config.user.email if config.user else 'unknown'}[/yellow]")
683
+ console.print("Run 'security-use auth logout' first to log in as a different user.")
684
+ return
685
+
686
+ oauth = OAuthFlow(config)
687
+
688
+ try:
689
+ # Request device code
690
+ console.print("[blue]Requesting authorization...[/blue]")
691
+ device_code = oauth.request_device_code()
692
+
693
+ # Show user code
694
+ console.print(f"\n[bold]Your authorization code:[/bold] [cyan]{device_code.user_code}[/cyan]")
695
+ console.print(f"\nOpen this URL to authenticate:")
696
+ console.print(f"[link={device_code.verification_uri}]{device_code.verification_uri}[/link]")
697
+
698
+ if not no_browser:
699
+ import webbrowser
700
+ verification_url = (
701
+ device_code.verification_uri_complete
702
+ or f"{device_code.verification_uri}?user_code={device_code.user_code}"
703
+ )
704
+ console.print("\n[dim]Opening browser...[/dim]")
705
+ webbrowser.open(verification_url)
706
+
707
+ console.print("\n[dim]Waiting for authorization (press Ctrl+C to cancel)...[/dim]")
708
+
709
+ def on_status(msg: str) -> None:
710
+ console.print(f"[dim]{msg}[/dim]", end="\r")
711
+
712
+ # Poll for token
713
+ token = oauth.poll_for_token(device_code, on_status)
714
+
715
+ # Get user info
716
+ try:
717
+ user = oauth.get_user_info(token)
718
+ config.save_token(token, user)
719
+ console.print(f"\n[green]Successfully logged in as {user.email}[/green]")
720
+ if user.org_name:
721
+ console.print(f"[dim]Organization: {user.org_name}[/dim]")
722
+ except OAuthError:
723
+ config.save_token(token)
724
+ console.print("\n[green]Successfully logged in[/green]")
725
+
726
+ console.print("\nYou can now sync scan results to your dashboard:")
727
+ console.print(" security-use scan all ./project --sync")
728
+
729
+ except OAuthError as e:
730
+ console.print(f"\n[red]Authentication failed: {e}[/red]")
731
+ sys.exit(1)
732
+ except KeyboardInterrupt:
733
+ console.print("\n[yellow]Authentication cancelled[/yellow]")
734
+ sys.exit(1)
735
+
736
+
737
+ @auth.command("logout")
738
+ def auth_logout() -> None:
739
+ """Log out from SecurityUse dashboard.
740
+
741
+ This will clear your stored credentials.
742
+ """
743
+ from security_use.auth import AuthConfig
744
+
745
+ config = AuthConfig()
746
+
747
+ if not config.is_authenticated:
748
+ console.print("[yellow]Not currently logged in[/yellow]")
749
+ return
750
+
751
+ user_email = config.user.email if config.user else "unknown"
752
+ config.clear()
753
+ console.print(f"[green]Successfully logged out from {user_email}[/green]")
754
+
755
+
756
+ @auth.command("status")
757
+ def auth_status() -> None:
758
+ """Check authentication status.
759
+
760
+ Shows whether you're logged in and account details.
761
+ """
762
+ from security_use.auth import AuthConfig, get_config_dir
763
+
764
+ config = AuthConfig()
765
+
766
+ if config.is_authenticated:
767
+ console.print("[green]Logged in[/green]")
768
+ if config.user:
769
+ console.print(f" Email: {config.user.email}")
770
+ if config.user.name:
771
+ console.print(f" Name: {config.user.name}")
772
+ if config.user.org_name:
773
+ console.print(f" Organization: {config.user.org_name}")
774
+ if config.token and config.token.expires_at:
775
+ console.print(f" Token expires: {config.token.expires_at}")
776
+ else:
777
+ console.print("[yellow]Not logged in[/yellow]")
778
+ console.print("\nRun 'security-use auth login' to authenticate.")
779
+
780
+ console.print(f"\n[dim]Config directory: {get_config_dir()}[/dim]")
781
+
782
+
783
+ @auth.command("token")
784
+ def auth_token() -> None:
785
+ """Print the current access token.
786
+
787
+ Useful for integrations that need the token directly.
788
+ """
789
+ from security_use.auth import AuthConfig
790
+
791
+ config = AuthConfig()
792
+
793
+ if not config.is_authenticated:
794
+ console.print("[red]Not logged in[/red]", err=True)
795
+ sys.exit(1)
796
+
797
+ token = config.get_access_token()
798
+ if token:
799
+ click.echo(token)
800
+ else:
801
+ console.print("[red]No valid token available[/red]", err=True)
802
+ sys.exit(1)
803
+
804
+
805
+ # =============================================================================
806
+ # SBOM Commands
807
+ # =============================================================================
808
+
809
+
810
+ @main.group()
811
+ def sbom() -> None:
812
+ """Generate Software Bill of Materials (SBOM)."""
813
+ pass
814
+
815
+
816
+ @sbom.command("generate")
817
+ @click.argument("path", type=click.Path(exists=True), default=".")
818
+ @click.option(
819
+ "--format", "-f",
820
+ type=click.Choice(["cyclonedx-json", "cyclonedx-xml", "spdx-json", "spdx-tv"]),
821
+ default="cyclonedx-json",
822
+ help="Output format",
823
+ )
824
+ @click.option(
825
+ "--output", "-o",
826
+ type=click.Path(),
827
+ help="Write output to file",
828
+ )
829
+ @click.option(
830
+ "--include-vulns",
831
+ is_flag=True,
832
+ help="Include vulnerability information (VEX)",
833
+ )
834
+ def sbom_generate(path: str, format: str, output: Optional[str], include_vulns: bool) -> None:
835
+ """Generate an SBOM for the project.
836
+
837
+ Scans dependency files and generates a Software Bill of Materials
838
+ in CycloneDX or SPDX format.
839
+
840
+ PATH is the directory to scan (default: current directory).
841
+ """
842
+ from security_use.sbom import SBOMGenerator, SBOMFormat
843
+
844
+ format_map = {
845
+ "cyclonedx-json": SBOMFormat.CYCLONEDX_JSON,
846
+ "cyclonedx-xml": SBOMFormat.CYCLONEDX_XML,
847
+ "spdx-json": SBOMFormat.SPDX_JSON,
848
+ "spdx-tv": SBOMFormat.SPDX_TV,
849
+ }
850
+
851
+ console.print(f"[blue]Generating SBOM for {path}...[/blue]")
852
+
853
+ generator = SBOMGenerator()
854
+ result = generator.generate(
855
+ Path(path),
856
+ format=format_map[format],
857
+ include_vulnerabilities=include_vulns,
858
+ )
859
+
860
+ if output:
861
+ Path(output).write_text(result.content, encoding="utf-8")
862
+ console.print(f"[green]SBOM written to {output}[/green]")
863
+ console.print(f" Format: {format}")
864
+ console.print(f" Components: {result.component_count}")
865
+ else:
866
+ click.echo(result.content)
867
+
868
+
869
+ @sbom.command("enrich")
870
+ @click.argument("sbom_file", type=click.Path(exists=True))
871
+ @click.option(
872
+ "--output", "-o",
873
+ type=click.Path(),
874
+ help="Write enriched SBOM to file",
875
+ )
876
+ def sbom_enrich(sbom_file: str, output: Optional[str]) -> None:
877
+ """Enrich an existing SBOM with vulnerability data.
878
+
879
+ Adds VEX (Vulnerability Exploitability eXchange) information
880
+ to an existing SBOM file.
881
+
882
+ SBOM_FILE is the path to the existing SBOM.
883
+ """
884
+ import json
885
+
886
+ console.print(f"[blue]Enriching SBOM: {sbom_file}...[/blue]")
887
+
888
+ # Read existing SBOM
889
+ content = Path(sbom_file).read_text(encoding="utf-8")
890
+
891
+ try:
892
+ sbom_data = json.loads(content)
893
+ except json.JSONDecodeError:
894
+ console.print("[red]Error: Only JSON SBOM files can be enriched[/red]")
895
+ sys.exit(1)
896
+
897
+ # TODO: Add vulnerability enrichment logic
898
+ # This would query OSV for each component and add VEX data
899
+
900
+ console.print("[yellow]SBOM enrichment not yet implemented[/yellow]")
901
+
902
+
903
+ # =============================================================================
904
+ # Sync Command
905
+ # =============================================================================
906
+
907
+
908
+ @main.command()
909
+ @click.argument("path", type=click.Path(exists=True), default=".")
910
+ @click.option(
911
+ "--project", "-p",
912
+ help="Project name for the dashboard",
913
+ )
914
+ @click.option(
915
+ "--severity", "-s",
916
+ type=click.Choice(["critical", "high", "medium", "low"]),
917
+ default="low",
918
+ help="Minimum severity to report",
919
+ )
920
+ def sync(path: str, project: Optional[str], severity: str) -> None:
921
+ """Scan and sync results to SecurityUse dashboard.
922
+
923
+ This command scans the project and uploads the results to your
924
+ dashboard at security-use.dev.
925
+
926
+ Requires authentication. Run 'security-use auth login' first.
927
+ """
928
+ from security_use.dependency_scanner import DependencyScanner
929
+ from security_use.iac_scanner import IaCScanner
930
+ from security_use.auth import AuthConfig, DashboardClient, OAuthError
931
+
932
+ config = AuthConfig()
933
+
934
+ if not config.is_authenticated:
935
+ console.print("[red]Not logged in. Run 'security-use auth login' first.[/red]")
936
+ sys.exit(1)
937
+
938
+ console.print(f"[blue]Scanning {path}...[/blue]")
939
+
940
+ # Combined result
941
+ result = ScanResult()
942
+
943
+ # Scan dependencies
944
+ dep_scanner = DependencyScanner()
945
+ dep_result = dep_scanner.scan_path(Path(path))
946
+ result.vulnerabilities = dep_result.vulnerabilities
947
+ result.scanned_files.extend(dep_result.scanned_files)
948
+ result.errors.extend(dep_result.errors)
949
+
950
+ # Scan IaC
951
+ iac_scanner = IaCScanner()
952
+ iac_result = iac_scanner.scan_path(Path(path))
953
+ result.iac_findings = iac_result.iac_findings
954
+ result.scanned_files.extend(iac_result.scanned_files)
955
+ result.errors.extend(iac_result.errors)
956
+
957
+ # Filter by severity
958
+ threshold = _get_severity_threshold(severity)
959
+ result = _filter_by_severity(result, threshold)
960
+
961
+ # Try to get git info
962
+ branch = None
963
+ commit = None
964
+ try:
965
+ import subprocess
966
+ branch = subprocess.run(
967
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
968
+ capture_output=True, text=True, cwd=path
969
+ ).stdout.strip() or None
970
+ commit = subprocess.run(
971
+ ["git", "rev-parse", "HEAD"],
972
+ capture_output=True, text=True, cwd=path
973
+ ).stdout.strip() or None
974
+ except Exception:
975
+ pass
976
+
977
+ # Determine project name
978
+ project_name = project or Path(path).resolve().name
979
+
980
+ console.print(f"\n[bold]Scan Summary:[/bold]")
981
+ console.print(f" Vulnerabilities: {len(result.vulnerabilities)}")
982
+ console.print(f" IaC Findings: {len(result.iac_findings)}")
983
+ console.print(f" Files Scanned: {len(result.scanned_files)}")
984
+
985
+ # Upload to dashboard
986
+ console.print(f"\n[blue]Uploading to dashboard...[/blue]")
987
+
988
+ try:
989
+ client = DashboardClient(config)
990
+ response = client.upload_scan(
991
+ result=result,
992
+ project_name=project_name,
993
+ project_path=str(Path(path).resolve()),
994
+ branch=branch,
995
+ commit=commit,
996
+ )
997
+
998
+ console.print(f"[green]Scan uploaded successfully![/green]")
999
+
1000
+ if "scan_id" in response:
1001
+ console.print(f" Scan ID: {response['scan_id']}")
1002
+ if "url" in response:
1003
+ console.print(f"\n View results: [link={response['url']}]{response['url']}[/link]")
1004
+ elif "dashboard_url" in response:
1005
+ console.print(f"\n View results: [link={response['dashboard_url']}]{response['dashboard_url']}[/link]")
1006
+
1007
+ except OAuthError as e:
1008
+ console.print(f"[red]Failed to upload: {e}[/red]")
1009
+ sys.exit(1)
1010
+
1011
+
347
1012
  if __name__ == "__main__":
348
1013
  main()