lucidscan 0.5.12__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 (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. lucidscan-0.5.12.dist-info/top_level.txt +1 -0
@@ -0,0 +1,492 @@
1
+ """Trivy scanner plugin for SCA and container scanning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ import zipfile
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+ from urllib.request import urlopen
15
+
16
+ from lucidscan.plugins.scanners.base import ScannerPlugin
17
+ from lucidscan.core.models import ScanContext, ScanDomain, Severity, UnifiedIssue
18
+ from lucidscan.bootstrap.paths import LucidscanPaths
19
+ from lucidscan.bootstrap.platform import get_platform_info
20
+ from lucidscan.bootstrap.versions import get_tool_version
21
+ from lucidscan.core.logging import get_logger
22
+ from lucidscan.core.subprocess_runner import run_with_streaming
23
+
24
+ LOGGER = get_logger(__name__)
25
+
26
+ # Default version from pyproject.toml [tool.lucidscan.tools]
27
+ DEFAULT_VERSION = get_tool_version("trivy")
28
+
29
+ # Trivy severity mapping to unified severity
30
+ TRIVY_SEVERITY_MAP: Dict[str, Severity] = {
31
+ "CRITICAL": Severity.CRITICAL,
32
+ "HIGH": Severity.HIGH,
33
+ "MEDIUM": Severity.MEDIUM,
34
+ "LOW": Severity.LOW,
35
+ "UNKNOWN": Severity.INFO,
36
+ }
37
+
38
+
39
+ class TrivyScanner(ScannerPlugin):
40
+ """Scanner plugin for Trivy (SCA and container scanning).
41
+
42
+ Handles:
43
+ - SCA scans via `trivy fs`
44
+ - Container scans via `trivy image`
45
+
46
+ Binary management:
47
+ - Downloads from https://github.com/aquasecurity/trivy/releases/
48
+ - Caches at {project}/.lucidscan/bin/trivy/{version}/trivy
49
+ - Uses cache directory at {project}/.lucidscan/cache/trivy/
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ version: str = DEFAULT_VERSION,
55
+ project_root: Optional[Path] = None,
56
+ ) -> None:
57
+ self._version = version
58
+ if project_root:
59
+ self._paths = LucidscanPaths.for_project(project_root)
60
+ else:
61
+ self._paths = LucidscanPaths.default()
62
+
63
+ @property
64
+ def name(self) -> str:
65
+ return "trivy"
66
+
67
+ @property
68
+ def domains(self) -> List[ScanDomain]:
69
+ return [ScanDomain.SCA, ScanDomain.CONTAINER]
70
+
71
+ def get_version(self) -> str:
72
+ return self._version
73
+
74
+ def ensure_binary(self) -> Path:
75
+ """Ensure the Trivy binary is available, downloading if needed."""
76
+ binary_dir = self._paths.plugin_bin_dir(self.name, self._version)
77
+ binary_name = "trivy.exe" if sys.platform == "win32" else "trivy"
78
+ binary_path = binary_dir / binary_name
79
+
80
+ if binary_path.exists():
81
+ LOGGER.debug(f"Trivy binary found at {binary_path}")
82
+ return binary_path
83
+
84
+ LOGGER.info(f"Downloading Trivy v{self._version}...")
85
+ self._download_binary(binary_dir)
86
+
87
+ if not binary_path.exists():
88
+ raise RuntimeError(f"Failed to download Trivy binary to {binary_path}")
89
+
90
+ return binary_path
91
+
92
+ def _download_binary(self, dest_dir: Path) -> None:
93
+ """Download and extract Trivy binary for current platform."""
94
+ platform_info = get_platform_info()
95
+ is_windows = platform_info.os == "windows"
96
+
97
+ # Map platform to Trivy release naming
98
+ os_name = {
99
+ "darwin": "macOS",
100
+ "linux": "Linux",
101
+ "windows": "Windows",
102
+ }.get(platform_info.os)
103
+
104
+ arch_name = {
105
+ "amd64": "64bit",
106
+ "arm64": "ARM64",
107
+ }.get(platform_info.arch)
108
+
109
+ if not os_name or not arch_name:
110
+ raise RuntimeError(
111
+ f"Unsupported platform: {platform_info.os}-{platform_info.arch}"
112
+ )
113
+
114
+ # Construct download URL
115
+ # Windows uses .zip, others use .tar.gz
116
+ # Example: trivy_0.68.1_Linux-64bit.tar.gz or trivy_0.68.1_Windows-64bit.zip
117
+ extension = ".zip" if is_windows else ".tar.gz"
118
+ filename = f"trivy_{self._version}_{os_name}-{arch_name}{extension}"
119
+ url = f"https://github.com/aquasecurity/trivy/releases/download/v{self._version}/{filename}"
120
+
121
+ LOGGER.debug(f"Downloading from {url}")
122
+
123
+ # Create destination directory
124
+ dest_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ # Validate URL scheme and domain for security
127
+ if not url.startswith("https://github.com/"):
128
+ raise ValueError(f"Invalid download URL: {url}")
129
+
130
+ # Download and extract
131
+ # Use delete=False and manually clean up to avoid Windows file locking issues
132
+ tmp_file = tempfile.NamedTemporaryFile(suffix=extension, delete=False)
133
+ tmp_path = Path(tmp_file.name)
134
+ try:
135
+ with urlopen(url) as response: # nosec B310 nosemgrep
136
+ tmp_file.write(response.read())
137
+ # Close the file before extracting (required on Windows)
138
+ tmp_file.close()
139
+
140
+ if is_windows:
141
+ # Extract zip file safely (prevent path traversal)
142
+ with zipfile.ZipFile(tmp_path, "r") as zf:
143
+ for zip_member in zf.namelist():
144
+ # Validate each member path to prevent traversal attacks
145
+ member_path = (dest_dir / zip_member).resolve()
146
+ if not member_path.is_relative_to(dest_dir.resolve()):
147
+ raise ValueError(f"Path traversal detected: {zip_member}")
148
+ zf.extractall(dest_dir)
149
+ else:
150
+ # Extract tarball safely (prevent path traversal)
151
+ with tarfile.open(tmp_path, "r:gz") as tar:
152
+ for tar_member in tar.getmembers():
153
+ # Validate each member path to prevent traversal attacks
154
+ member_path = (dest_dir / tar_member.name).resolve()
155
+ if not member_path.is_relative_to(dest_dir.resolve()):
156
+ raise ValueError(f"Path traversal detected: {tar_member.name}")
157
+ # Extract individual member safely
158
+ tar.extract(tar_member, path=dest_dir)
159
+
160
+ # Make binary executable (on Unix)
161
+ binary_name = "trivy.exe" if is_windows else "trivy"
162
+ binary_path = dest_dir / binary_name
163
+ if binary_path.exists() and not is_windows:
164
+ binary_path.chmod(0o755)
165
+ LOGGER.info(f"Trivy v{self._version} installed to {binary_path}")
166
+
167
+ finally:
168
+ # Ensure file is closed before attempting to delete
169
+ if not tmp_file.closed:
170
+ tmp_file.close()
171
+ tmp_path.unlink(missing_ok=True)
172
+
173
+ def scan(self, context: ScanContext) -> List[UnifiedIssue]:
174
+ """Execute Trivy scan and return normalized issues.
175
+
176
+ Args:
177
+ context: Scan context containing target paths and configuration.
178
+
179
+ Returns:
180
+ List of unified issues found during the scan.
181
+ """
182
+ binary = self.ensure_binary()
183
+ cache_dir = self._paths.plugin_cache_dir(self.name)
184
+ cache_dir.mkdir(parents=True, exist_ok=True)
185
+
186
+ issues: List[UnifiedIssue] = []
187
+
188
+ # Determine which scan types to run based on enabled domains
189
+ if ScanDomain.SCA in context.enabled_domains:
190
+ issues.extend(self._run_fs_scan(binary, context, cache_dir))
191
+
192
+ if ScanDomain.CONTAINER in context.enabled_domains:
193
+ # Container scanning uses image targets from config
194
+ container_config = context.get_scanner_options("container")
195
+ image_targets = container_config.get("images", [])
196
+ for image in image_targets:
197
+ issues.extend(
198
+ self._run_image_scan(binary, image, cache_dir, context.stream_handler)
199
+ )
200
+
201
+ return issues
202
+
203
+ def _run_fs_scan(
204
+ self, binary: Path, context: ScanContext, cache_dir: Path
205
+ ) -> List[UnifiedIssue]:
206
+ """Run trivy fs scan for SCA.
207
+
208
+ Args:
209
+ binary: Path to the Trivy binary.
210
+ context: Scan context with project root and configuration.
211
+ cache_dir: Path to the Trivy cache directory.
212
+
213
+ Returns:
214
+ List of unified issues from the filesystem scan.
215
+ """
216
+ # Get SCA-specific config options
217
+ sca_config = context.get_scanner_options("sca")
218
+
219
+ cmd = [
220
+ str(binary),
221
+ "fs",
222
+ "--cache-dir", str(cache_dir),
223
+ "--format", "json",
224
+ "--quiet",
225
+ "--scanners", "vuln",
226
+ ]
227
+
228
+ # Apply config options
229
+ if sca_config.get("ignore_unfixed", False):
230
+ cmd.append("--ignore-unfixed")
231
+
232
+ if sca_config.get("skip_db_update", False):
233
+ cmd.append("--skip-db-update")
234
+
235
+ severity = sca_config.get("severity")
236
+ if severity and isinstance(severity, list):
237
+ cmd.extend(["--severity", ",".join(severity)])
238
+
239
+ # Apply ignore patterns from .lucidscanignore and config
240
+ exclude_patterns = context.get_exclude_patterns()
241
+ for pattern in exclude_patterns:
242
+ # Trivy uses --skip-dirs for directory patterns
243
+ if pattern.endswith("/") or pattern.endswith("/**"):
244
+ dir_pattern = pattern.rstrip("/*")
245
+ cmd.extend(["--skip-dirs", dir_pattern])
246
+ else:
247
+ # For file patterns, use --skip-files
248
+ cmd.extend(["--skip-files", pattern])
249
+
250
+ cmd.append(str(context.project_root))
251
+
252
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
253
+
254
+ try:
255
+ result = run_with_streaming(
256
+ cmd=cmd,
257
+ cwd=context.project_root,
258
+ tool_name="trivy-fs",
259
+ stream_handler=context.stream_handler,
260
+ timeout=180,
261
+ )
262
+
263
+ if result.returncode != 0 and result.stderr:
264
+ LOGGER.warning(f"Trivy stderr: {result.stderr}")
265
+
266
+ if not result.stdout.strip():
267
+ LOGGER.debug("Trivy returned empty output")
268
+ return []
269
+
270
+ return self._parse_trivy_json(result.stdout, ScanDomain.SCA)
271
+
272
+ except subprocess.TimeoutExpired:
273
+ LOGGER.warning("Trivy fs scan timed out after 180 seconds")
274
+ return []
275
+ except Exception as e:
276
+ LOGGER.error(f"Trivy fs scan failed: {e}")
277
+ return []
278
+
279
+ def _run_image_scan(
280
+ self,
281
+ binary: Path,
282
+ image: str,
283
+ cache_dir: Path,
284
+ stream_handler: Optional[Any] = None,
285
+ ) -> List[UnifiedIssue]:
286
+ """Run trivy image scan for container scanning.
287
+
288
+ Args:
289
+ binary: Path to the Trivy binary.
290
+ image: Container image reference (e.g., 'nginx:latest').
291
+ cache_dir: Path to the Trivy cache directory.
292
+ stream_handler: Optional handler for streaming output.
293
+
294
+ Returns:
295
+ List of unified issues from the container scan.
296
+ """
297
+
298
+ cmd = [
299
+ str(binary),
300
+ "image",
301
+ "--cache-dir", str(cache_dir),
302
+ "--format", "json",
303
+ "--quiet",
304
+ "--scanners", "vuln",
305
+ image,
306
+ ]
307
+
308
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
309
+
310
+ try:
311
+ result = run_with_streaming(
312
+ cmd=cmd,
313
+ cwd=Path.cwd(),
314
+ tool_name=f"trivy-image:{image}",
315
+ stream_handler=stream_handler,
316
+ timeout=300,
317
+ )
318
+
319
+ if result.returncode != 0 and result.stderr:
320
+ LOGGER.warning(f"Trivy stderr: {result.stderr}")
321
+
322
+ if not result.stdout.strip():
323
+ LOGGER.debug(f"Trivy returned empty output for image {image}")
324
+ return []
325
+
326
+ return self._parse_trivy_json(
327
+ result.stdout, ScanDomain.CONTAINER, image_ref=image
328
+ )
329
+
330
+ except subprocess.TimeoutExpired:
331
+ LOGGER.warning(f"Trivy image scan timed out after 300 seconds for {image}")
332
+ return []
333
+ except Exception as e:
334
+ LOGGER.error(f"Trivy image scan failed for {image}: {e}")
335
+ return []
336
+
337
+ def _parse_trivy_json(
338
+ self,
339
+ json_output: str,
340
+ domain: ScanDomain,
341
+ image_ref: Optional[str] = None,
342
+ ) -> List[UnifiedIssue]:
343
+ """Parse Trivy JSON output and convert to UnifiedIssue list.
344
+
345
+ Args:
346
+ json_output: Raw JSON string from Trivy.
347
+ domain: The scan domain (SCA or CONTAINER).
348
+ image_ref: Container image reference (for container scans).
349
+
350
+ Returns:
351
+ List of unified issues parsed from the JSON.
352
+ """
353
+ try:
354
+ data = json.loads(json_output)
355
+ except json.JSONDecodeError as e:
356
+ LOGGER.error(f"Failed to parse Trivy JSON: {e}")
357
+ return []
358
+
359
+ issues: List[UnifiedIssue] = []
360
+
361
+ # Trivy output structure: {"Results": [...]}
362
+ results = data.get("Results", [])
363
+
364
+ for result in results:
365
+ target = result.get("Target", "unknown")
366
+ target_type = result.get("Type", "unknown")
367
+ vulnerabilities = result.get("Vulnerabilities") or []
368
+
369
+ for vuln in vulnerabilities:
370
+ issue = self._vuln_to_unified_issue(
371
+ vuln, domain, target, target_type, image_ref
372
+ )
373
+ if issue:
374
+ issues.append(issue)
375
+
376
+ LOGGER.debug(f"Parsed {len(issues)} issues from Trivy output")
377
+ return issues
378
+
379
+ def _vuln_to_unified_issue(
380
+ self,
381
+ vuln: Dict[str, Any],
382
+ domain: ScanDomain,
383
+ target: str,
384
+ target_type: str,
385
+ image_ref: Optional[str] = None,
386
+ ) -> Optional[UnifiedIssue]:
387
+ """Convert a single Trivy vulnerability to a UnifiedIssue.
388
+
389
+ Args:
390
+ vuln: Vulnerability dict from Trivy JSON.
391
+ domain: The scan domain.
392
+ target: Target file or layer.
393
+ target_type: Type of target (e.g., 'npm', 'pip', 'alpine').
394
+ image_ref: Container image reference (for container scans).
395
+
396
+ Returns:
397
+ UnifiedIssue or None if conversion fails.
398
+ """
399
+ try:
400
+ vuln_id = vuln.get("VulnerabilityID", "UNKNOWN")
401
+ pkg_name = vuln.get("PkgName", "unknown")
402
+ installed_version = vuln.get("InstalledVersion", "unknown")
403
+ fixed_version = vuln.get("FixedVersion", "")
404
+ severity_str = vuln.get("Severity", "UNKNOWN").upper()
405
+ title = vuln.get("Title", f"Vulnerability in {pkg_name}")
406
+ description = vuln.get("Description", "No description available.")
407
+
408
+ # Map severity
409
+ severity = TRIVY_SEVERITY_MAP.get(severity_str, Severity.INFO)
410
+
411
+ # Generate deterministic issue ID
412
+ issue_id = self._generate_issue_id(
413
+ vuln_id, pkg_name, installed_version, target
414
+ )
415
+
416
+ # Build dependency string
417
+ dependency = f"{pkg_name}@{installed_version}"
418
+ if target_type:
419
+ dependency = f"{dependency} ({target_type})"
420
+
421
+ # Build recommendation
422
+ recommendation = None
423
+ if fixed_version:
424
+ recommendation = f"Upgrade {pkg_name} to version {fixed_version}"
425
+
426
+ # Determine file path
427
+ file_path = Path(target) if target and domain == ScanDomain.SCA else None
428
+
429
+ # Build scanner metadata with raw Trivy data
430
+ scanner_metadata: Dict[str, Any] = {
431
+ "vulnerability_id": vuln_id,
432
+ "pkg_name": pkg_name,
433
+ "installed_version": installed_version,
434
+ "fixed_version": fixed_version,
435
+ "target": target,
436
+ "target_type": target_type,
437
+ "references": vuln.get("References", []),
438
+ "cvss": vuln.get("CVSS", {}),
439
+ "cwe_ids": vuln.get("CweIDs", []),
440
+ "published_date": vuln.get("PublishedDate"),
441
+ "last_modified_date": vuln.get("LastModifiedDate"),
442
+ }
443
+
444
+ if image_ref:
445
+ scanner_metadata["image_ref"] = image_ref
446
+
447
+ # Get primary reference URL if available
448
+ references = vuln.get("References", [])
449
+ documentation_url = references[0] if references else None
450
+
451
+ return UnifiedIssue(
452
+ id=issue_id,
453
+ domain=domain,
454
+ source_tool="trivy",
455
+ severity=severity,
456
+ rule_id=vuln_id,
457
+ title=f"{vuln_id}: {title}",
458
+ description=description,
459
+ documentation_url=documentation_url,
460
+ file_path=file_path,
461
+ dependency=dependency,
462
+ recommendation=recommendation,
463
+ fixable=bool(fixed_version),
464
+ suggested_fix=f"Upgrade to version {fixed_version}" if fixed_version else None,
465
+ metadata=scanner_metadata,
466
+ )
467
+
468
+ except Exception as e:
469
+ LOGGER.warning(f"Failed to convert vulnerability to UnifiedIssue: {e}")
470
+ return None
471
+
472
+ def _generate_issue_id(
473
+ self,
474
+ vuln_id: str,
475
+ pkg_name: str,
476
+ version: str,
477
+ target: str,
478
+ ) -> str:
479
+ """Generate a deterministic issue ID for deduplication.
480
+
481
+ Args:
482
+ vuln_id: Vulnerability ID (e.g., CVE-2021-1234).
483
+ pkg_name: Package name.
484
+ version: Installed version.
485
+ target: Target file or layer.
486
+
487
+ Returns:
488
+ A stable hash-based ID string.
489
+ """
490
+ components = f"trivy:{vuln_id}:{pkg_name}:{version}:{target}"
491
+ hash_digest = hashlib.sha256(components.encode()).hexdigest()[:16]
492
+ return f"trivy-{hash_digest}"
@@ -0,0 +1,27 @@
1
+ """Test runner plugins for lucidscan.
2
+
3
+ This module provides test runner integrations for the quality pipeline.
4
+ Test runners are discovered via the lucidscan.test_runners entry point group.
5
+ """
6
+
7
+ from lucidscan.plugins.test_runners.base import TestRunnerPlugin, TestResult
8
+ from lucidscan.plugins.discovery import (
9
+ discover_plugins,
10
+ TEST_RUNNER_ENTRY_POINT_GROUP,
11
+ )
12
+
13
+
14
+ def discover_test_runner_plugins():
15
+ """Discover all installed test runner plugins.
16
+
17
+ Returns:
18
+ Dictionary mapping plugin names to plugin classes.
19
+ """
20
+ return discover_plugins(TEST_RUNNER_ENTRY_POINT_GROUP, TestRunnerPlugin)
21
+
22
+
23
+ __all__ = [
24
+ "TestRunnerPlugin",
25
+ "TestResult",
26
+ "discover_test_runner_plugins",
27
+ ]
@@ -0,0 +1,111 @@
1
+ """Base class for test runner plugins.
2
+
3
+ All test runner plugins inherit from TestRunnerPlugin and implement the run_tests() method.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ from lucidscan.core.models import ScanContext, UnifiedIssue, ToolDomain
14
+
15
+
16
+ @dataclass
17
+ class TestResult:
18
+ """Result statistics from test execution."""
19
+
20
+ passed: int = 0
21
+ failed: int = 0
22
+ skipped: int = 0
23
+ errors: int = 0
24
+ duration_ms: int = 0
25
+ issues: List[UnifiedIssue] = field(default_factory=list)
26
+
27
+ @property
28
+ def total(self) -> int:
29
+ """Total number of tests run."""
30
+ return self.passed + self.failed + self.skipped + self.errors
31
+
32
+ @property
33
+ def success(self) -> bool:
34
+ """Whether all tests passed (no failures or errors)."""
35
+ return self.failed == 0 and self.errors == 0
36
+
37
+
38
+ class TestRunnerPlugin(ABC):
39
+ """Abstract base class for test runner plugins.
40
+
41
+ Test runner plugins provide test execution functionality for the quality pipeline.
42
+ Each plugin wraps a specific test framework (pytest, Jest, etc.).
43
+ """
44
+
45
+ def __init__(self, project_root: Optional[Path] = None, **kwargs) -> None:
46
+ """Initialize the test runner plugin.
47
+
48
+ Args:
49
+ project_root: Optional project root for tool installation.
50
+ **kwargs: Additional arguments for subclasses.
51
+ """
52
+ self._project_root = project_root
53
+
54
+ @property
55
+ @abstractmethod
56
+ def name(self) -> str:
57
+ """Unique plugin identifier (e.g., 'pytest', 'jest').
58
+
59
+ Returns:
60
+ Plugin name string.
61
+ """
62
+
63
+ @property
64
+ @abstractmethod
65
+ def languages(self) -> List[str]:
66
+ """Languages this test runner supports.
67
+
68
+ Returns:
69
+ List of language names (e.g., ['python'], ['javascript', 'typescript']).
70
+ """
71
+
72
+ @property
73
+ def domain(self) -> ToolDomain:
74
+ """Tool domain (always TESTING for test runners).
75
+
76
+ Returns:
77
+ ToolDomain.TESTING
78
+ """
79
+ return ToolDomain.TESTING
80
+
81
+ @abstractmethod
82
+ def get_version(self) -> str:
83
+ """Get the version of the underlying test framework.
84
+
85
+ Returns:
86
+ Version string.
87
+ """
88
+
89
+ @abstractmethod
90
+ def ensure_binary(self) -> Path:
91
+ """Ensure the test framework is installed.
92
+
93
+ Finds or installs the tool if not present.
94
+
95
+ Returns:
96
+ Path to the tool binary.
97
+
98
+ Raises:
99
+ FileNotFoundError: If the tool cannot be found or installed.
100
+ """
101
+
102
+ @abstractmethod
103
+ def run_tests(self, context: ScanContext) -> TestResult:
104
+ """Run tests on the specified paths.
105
+
106
+ Args:
107
+ context: Scan context with paths and configuration.
108
+
109
+ Returns:
110
+ TestResult with test statistics and issues for failures.
111
+ """