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.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- 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
|
+
"""
|