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.
- security_use/__init__.py +9 -1
- security_use/auth/__init__.py +16 -0
- security_use/auth/client.py +223 -0
- security_use/auth/config.py +177 -0
- security_use/auth/oauth.py +317 -0
- security_use/cli.py +699 -34
- security_use/compliance/__init__.py +10 -0
- security_use/compliance/mapper.py +275 -0
- security_use/compliance/models.py +50 -0
- security_use/dependency_scanner.py +76 -30
- security_use/fixers/iac_fixer.py +173 -95
- security_use/iac/rules/azure.py +246 -0
- security_use/iac/rules/gcp.py +255 -0
- security_use/iac/rules/kubernetes.py +429 -0
- security_use/iac/rules/registry.py +56 -0
- security_use/parsers/__init__.py +18 -0
- security_use/parsers/base.py +2 -0
- security_use/parsers/composer.py +101 -0
- security_use/parsers/conda.py +97 -0
- security_use/parsers/dotnet.py +89 -0
- security_use/parsers/gradle.py +90 -0
- security_use/parsers/maven.py +108 -0
- security_use/parsers/npm.py +196 -0
- security_use/parsers/yarn.py +108 -0
- security_use/reporter.py +29 -1
- security_use/sbom/__init__.py +10 -0
- security_use/sbom/generator.py +340 -0
- security_use/sbom/models.py +40 -0
- security_use/scanner.py +15 -2
- security_use/sensor/__init__.py +125 -0
- security_use/sensor/alert_queue.py +207 -0
- security_use/sensor/config.py +217 -0
- security_use/sensor/dashboard_alerter.py +246 -0
- security_use/sensor/detector.py +415 -0
- security_use/sensor/endpoint_analyzer.py +339 -0
- security_use/sensor/middleware.py +521 -0
- security_use/sensor/models.py +140 -0
- security_use/sensor/webhook.py +227 -0
- security_use-0.2.9.dist-info/METADATA +531 -0
- security_use-0.2.9.dist-info/RECORD +60 -0
- security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
- security_use-0.1.1.dist-info/METADATA +0 -92
- security_use-0.1.1.dist-info/RECORD +0 -30
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
-
|
|
271
|
-
""
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
console.print("[yellow]No automatic fixes available for found vulnerabilities[/yellow]")
|
|
294
|
-
return
|
|
415
|
+
total_fixes = 0
|
|
295
416
|
|
|
296
|
-
|
|
417
|
+
# Fix dependency vulnerabilities
|
|
418
|
+
if fix_deps:
|
|
419
|
+
console.print(f"[blue]Scanning dependencies in {path}...[/blue]")
|
|
297
420
|
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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()
|