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,464 @@
|
|
|
1
|
+
"""OpenGrep scanner plugin for SAST (Static Application Security Testing)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
from lucidscan.plugins.scanners.base import ScannerPlugin
|
|
14
|
+
from lucidscan.core.models import ScanContext, ScanDomain, Severity, UnifiedIssue
|
|
15
|
+
from lucidscan.bootstrap.paths import LucidscanPaths
|
|
16
|
+
from lucidscan.bootstrap.platform import get_platform_info
|
|
17
|
+
from lucidscan.bootstrap.versions import get_tool_version
|
|
18
|
+
from lucidscan.core.logging import get_logger
|
|
19
|
+
from lucidscan.core.subprocess_runner import run_with_streaming
|
|
20
|
+
|
|
21
|
+
LOGGER = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Default version from pyproject.toml [tool.lucidscan.tools]
|
|
24
|
+
DEFAULT_VERSION = get_tool_version("opengrep")
|
|
25
|
+
|
|
26
|
+
# OpenGrep severity mapping to unified severity
|
|
27
|
+
# OpenGrep/Semgrep uses: ERROR, WARNING, INFO
|
|
28
|
+
OPENGREP_SEVERITY_MAP: Dict[str, Severity] = {
|
|
29
|
+
"ERROR": Severity.HIGH,
|
|
30
|
+
"WARNING": Severity.MEDIUM,
|
|
31
|
+
"INFO": Severity.LOW,
|
|
32
|
+
# Additional mappings for rule metadata
|
|
33
|
+
"CRITICAL": Severity.CRITICAL,
|
|
34
|
+
"HIGH": Severity.HIGH,
|
|
35
|
+
"MEDIUM": Severity.MEDIUM,
|
|
36
|
+
"LOW": Severity.LOW,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenGrepScanner(ScannerPlugin):
|
|
41
|
+
"""Scanner plugin for OpenGrep (SAST).
|
|
42
|
+
|
|
43
|
+
Handles:
|
|
44
|
+
- Static application security testing via `opengrep scan`
|
|
45
|
+
|
|
46
|
+
Binary management:
|
|
47
|
+
- Downloads from https://github.com/opengrep/opengrep/releases/
|
|
48
|
+
- Caches at {project}/.lucidscan/bin/opengrep/{version}/opengrep
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
version: str = DEFAULT_VERSION,
|
|
54
|
+
project_root: Optional[Path] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._version = version
|
|
57
|
+
if project_root:
|
|
58
|
+
self._paths = LucidscanPaths.for_project(project_root)
|
|
59
|
+
else:
|
|
60
|
+
self._paths = LucidscanPaths.default()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def name(self) -> str:
|
|
64
|
+
return "opengrep"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def domains(self) -> List[ScanDomain]:
|
|
68
|
+
return [ScanDomain.SAST]
|
|
69
|
+
|
|
70
|
+
def get_version(self) -> str:
|
|
71
|
+
return self._version
|
|
72
|
+
|
|
73
|
+
def ensure_binary(self) -> Path:
|
|
74
|
+
"""Ensure the OpenGrep binary is available, downloading if needed."""
|
|
75
|
+
binary_dir = self._paths.plugin_bin_dir(self.name, self._version)
|
|
76
|
+
binary_name = self._get_binary_name()
|
|
77
|
+
binary_path = binary_dir / binary_name
|
|
78
|
+
|
|
79
|
+
if binary_path.exists():
|
|
80
|
+
LOGGER.debug(f"OpenGrep binary found at {binary_path}")
|
|
81
|
+
return binary_path
|
|
82
|
+
|
|
83
|
+
LOGGER.info(f"Downloading OpenGrep v{self._version}...")
|
|
84
|
+
self._download_binary(binary_dir)
|
|
85
|
+
|
|
86
|
+
if not binary_path.exists():
|
|
87
|
+
raise RuntimeError(f"Failed to download OpenGrep binary to {binary_path}")
|
|
88
|
+
|
|
89
|
+
return binary_path
|
|
90
|
+
|
|
91
|
+
def _get_binary_name(self) -> str:
|
|
92
|
+
"""Get the binary name for the current platform."""
|
|
93
|
+
platform_info = get_platform_info()
|
|
94
|
+
if platform_info.os == "windows":
|
|
95
|
+
return "opengrep.exe"
|
|
96
|
+
return "opengrep"
|
|
97
|
+
|
|
98
|
+
def _download_binary(self, dest_dir: Path) -> None:
|
|
99
|
+
"""Download OpenGrep binary for current platform."""
|
|
100
|
+
platform_info = get_platform_info()
|
|
101
|
+
|
|
102
|
+
# Map platform to OpenGrep release naming
|
|
103
|
+
# OpenGrep uses: opengrep_manylinux_x86, opengrep_osx_arm64, etc.
|
|
104
|
+
if platform_info.os == "linux":
|
|
105
|
+
os_name = "manylinux"
|
|
106
|
+
arch_name = "x86" if platform_info.arch == "amd64" else "aarch64"
|
|
107
|
+
filename = f"opengrep_{os_name}_{arch_name}"
|
|
108
|
+
elif platform_info.os == "darwin":
|
|
109
|
+
os_name = "osx"
|
|
110
|
+
arch_name = "x86" if platform_info.arch == "amd64" else "arm64"
|
|
111
|
+
filename = f"opengrep_{os_name}_{arch_name}"
|
|
112
|
+
elif platform_info.os == "windows":
|
|
113
|
+
filename = "opengrep_windows_x86.exe"
|
|
114
|
+
else:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"Unsupported platform: {platform_info.os}-{platform_info.arch}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Construct download URL
|
|
120
|
+
url = f"https://github.com/opengrep/opengrep/releases/download/v{self._version}/{filename}"
|
|
121
|
+
|
|
122
|
+
LOGGER.debug(f"Downloading from {url}")
|
|
123
|
+
|
|
124
|
+
# Create destination directory
|
|
125
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
# Determine destination path
|
|
128
|
+
binary_name = self._get_binary_name()
|
|
129
|
+
binary_path = dest_dir / binary_name
|
|
130
|
+
|
|
131
|
+
# Validate URL scheme and domain for security
|
|
132
|
+
if not url.startswith("https://github.com/"):
|
|
133
|
+
raise ValueError(f"Invalid download URL: {url}")
|
|
134
|
+
|
|
135
|
+
# Download binary directly (not an archive)
|
|
136
|
+
try:
|
|
137
|
+
with urlopen(url) as response: # nosec B310 nosemgrep
|
|
138
|
+
binary_path.write_bytes(response.read())
|
|
139
|
+
|
|
140
|
+
# Make binary executable (not needed on Windows)
|
|
141
|
+
if platform_info.os != "windows":
|
|
142
|
+
binary_path.chmod(0o755)
|
|
143
|
+
|
|
144
|
+
LOGGER.info(f"OpenGrep v{self._version} installed to {binary_path}")
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Clean up partial download
|
|
148
|
+
if binary_path.exists():
|
|
149
|
+
binary_path.unlink()
|
|
150
|
+
raise RuntimeError(f"Failed to download OpenGrep: {e}") from e
|
|
151
|
+
|
|
152
|
+
def scan(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
153
|
+
"""Execute OpenGrep scan and return normalized issues.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
context: Scan context containing target paths and configuration.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of unified issues found during the scan.
|
|
160
|
+
"""
|
|
161
|
+
if ScanDomain.SAST not in context.enabled_domains:
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
binary = self.ensure_binary()
|
|
165
|
+
return self._run_sast_scan(binary, context)
|
|
166
|
+
|
|
167
|
+
def _run_sast_scan(
|
|
168
|
+
self, binary: Path, context: ScanContext
|
|
169
|
+
) -> List[UnifiedIssue]:
|
|
170
|
+
"""Run OpenGrep SAST scan.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
binary: Path to the OpenGrep binary.
|
|
174
|
+
context: Scan context with project root and configuration.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of unified issues from the SAST scan.
|
|
178
|
+
"""
|
|
179
|
+
# Get SAST-specific config options
|
|
180
|
+
sast_config = context.get_scanner_options("sast")
|
|
181
|
+
|
|
182
|
+
# Get ruleset configuration
|
|
183
|
+
ruleset_list = sast_config.get("ruleset", ["auto"])
|
|
184
|
+
if isinstance(ruleset_list, list) and ruleset_list:
|
|
185
|
+
ruleset = ruleset_list[0] # Use first ruleset
|
|
186
|
+
else:
|
|
187
|
+
ruleset = "auto"
|
|
188
|
+
|
|
189
|
+
cmd = [
|
|
190
|
+
str(binary),
|
|
191
|
+
"scan",
|
|
192
|
+
"--json",
|
|
193
|
+
"--quiet",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
# Add timeout if specified
|
|
197
|
+
timeout = sast_config.get("timeout")
|
|
198
|
+
if timeout:
|
|
199
|
+
cmd.extend(["--timeout", str(timeout)])
|
|
200
|
+
|
|
201
|
+
# Add ruleset if specified (auto uses default rules)
|
|
202
|
+
if ruleset and ruleset != "auto":
|
|
203
|
+
cmd.extend(["--config", ruleset])
|
|
204
|
+
else:
|
|
205
|
+
# Use default rules - OpenGrep auto-detects without explicit config
|
|
206
|
+
cmd.extend(["--config", "auto"])
|
|
207
|
+
|
|
208
|
+
# Apply ignore patterns from .lucidscanignore and config
|
|
209
|
+
exclude_patterns = context.get_exclude_patterns()
|
|
210
|
+
for pattern in exclude_patterns:
|
|
211
|
+
cmd.extend(["--exclude", pattern])
|
|
212
|
+
|
|
213
|
+
# Add target path
|
|
214
|
+
cmd.append(str(context.project_root))
|
|
215
|
+
|
|
216
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
217
|
+
|
|
218
|
+
# Set environment variables for the scan
|
|
219
|
+
env = self._get_scan_env()
|
|
220
|
+
old_env: Dict[str, Optional[str]] = {}
|
|
221
|
+
for key, value in env.items():
|
|
222
|
+
if key not in os.environ or os.environ[key] != value:
|
|
223
|
+
old_env[key] = os.environ.get(key)
|
|
224
|
+
os.environ[key] = value
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
result = run_with_streaming(
|
|
228
|
+
cmd=cmd,
|
|
229
|
+
cwd=context.project_root,
|
|
230
|
+
tool_name="opengrep",
|
|
231
|
+
stream_handler=context.stream_handler,
|
|
232
|
+
timeout=180,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# OpenGrep returns non-zero exit code when findings exist
|
|
236
|
+
# This is expected behavior, not an error
|
|
237
|
+
if result.returncode not in (0, 1) and result.stderr:
|
|
238
|
+
LOGGER.warning(f"OpenGrep stderr: {result.stderr}")
|
|
239
|
+
|
|
240
|
+
if not result.stdout.strip():
|
|
241
|
+
LOGGER.debug("OpenGrep returned empty output")
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
return self._parse_opengrep_json(result.stdout, context.project_root)
|
|
245
|
+
|
|
246
|
+
except subprocess.TimeoutExpired:
|
|
247
|
+
LOGGER.warning("OpenGrep scan timed out after 180 seconds")
|
|
248
|
+
return []
|
|
249
|
+
except Exception as e:
|
|
250
|
+
LOGGER.error(f"OpenGrep scan failed: {e}")
|
|
251
|
+
return []
|
|
252
|
+
finally:
|
|
253
|
+
# Restore original environment
|
|
254
|
+
for key, value in old_env.items(): # type: ignore[assignment]
|
|
255
|
+
if value is None:
|
|
256
|
+
os.environ.pop(key, None)
|
|
257
|
+
else:
|
|
258
|
+
os.environ[key] = value
|
|
259
|
+
|
|
260
|
+
def _get_scan_env(self) -> Dict[str, str]:
|
|
261
|
+
"""Get environment variables for the scan process."""
|
|
262
|
+
env = os.environ.copy()
|
|
263
|
+
# Disable telemetry/metrics
|
|
264
|
+
env["SEMGREP_SEND_METRICS"] = "off"
|
|
265
|
+
env["OPENGREP_SEND_METRICS"] = "off"
|
|
266
|
+
return env
|
|
267
|
+
|
|
268
|
+
def _parse_opengrep_json(
|
|
269
|
+
self,
|
|
270
|
+
json_output: str,
|
|
271
|
+
project_root: Path,
|
|
272
|
+
) -> List[UnifiedIssue]:
|
|
273
|
+
"""Parse OpenGrep JSON output and convert to UnifiedIssue list.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
json_output: Raw JSON string from OpenGrep.
|
|
277
|
+
project_root: Project root path for relative path resolution.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
List of unified issues parsed from the JSON.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
data = json.loads(json_output)
|
|
284
|
+
except json.JSONDecodeError as e:
|
|
285
|
+
LOGGER.error(f"Failed to parse OpenGrep JSON: {e}")
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
issues: List[UnifiedIssue] = []
|
|
289
|
+
|
|
290
|
+
# OpenGrep output structure: {"results": [...], "errors": [...]}
|
|
291
|
+
results = data.get("results", [])
|
|
292
|
+
|
|
293
|
+
for result in results:
|
|
294
|
+
issue = self._result_to_unified_issue(result, project_root)
|
|
295
|
+
if issue:
|
|
296
|
+
issues.append(issue)
|
|
297
|
+
|
|
298
|
+
# Log any errors from the scan
|
|
299
|
+
errors = data.get("errors", [])
|
|
300
|
+
for error in errors:
|
|
301
|
+
LOGGER.warning(f"OpenGrep error: {error}")
|
|
302
|
+
|
|
303
|
+
LOGGER.debug(f"Parsed {len(issues)} issues from OpenGrep output")
|
|
304
|
+
return issues
|
|
305
|
+
|
|
306
|
+
def _result_to_unified_issue(
|
|
307
|
+
self,
|
|
308
|
+
result: Dict[str, Any],
|
|
309
|
+
project_root: Path,
|
|
310
|
+
) -> Optional[UnifiedIssue]:
|
|
311
|
+
"""Convert a single OpenGrep result to a UnifiedIssue.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
result: Result dict from OpenGrep JSON.
|
|
315
|
+
project_root: Project root path for relative path resolution.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
UnifiedIssue or None if conversion fails.
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
# Extract basic fields
|
|
322
|
+
rule_id = result.get("check_id", "unknown")
|
|
323
|
+
path = result.get("path", "unknown")
|
|
324
|
+
message = result.get("extra", {}).get("message", "No message")
|
|
325
|
+
|
|
326
|
+
# Extract location information
|
|
327
|
+
start = result.get("start", {})
|
|
328
|
+
end = result.get("end", {})
|
|
329
|
+
line_start = start.get("line", 1)
|
|
330
|
+
line_end = end.get("line", line_start)
|
|
331
|
+
col_start = start.get("col", 1)
|
|
332
|
+
col_end = end.get("col", col_start)
|
|
333
|
+
|
|
334
|
+
# Extract severity from extra metadata
|
|
335
|
+
extra = result.get("extra", {})
|
|
336
|
+
severity_str = extra.get("severity", "WARNING").upper()
|
|
337
|
+
|
|
338
|
+
# Also check metadata for severity
|
|
339
|
+
metadata = extra.get("metadata", {})
|
|
340
|
+
if "severity" in metadata:
|
|
341
|
+
severity_str = metadata["severity"].upper()
|
|
342
|
+
|
|
343
|
+
severity = OPENGREP_SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
|
|
344
|
+
|
|
345
|
+
# Extract code snippet (the matched lines)
|
|
346
|
+
lines = extra.get("lines", "")
|
|
347
|
+
code_snippet = lines if isinstance(lines, str) else str(lines)
|
|
348
|
+
|
|
349
|
+
# Generate deterministic issue ID
|
|
350
|
+
issue_id = self._generate_issue_id(rule_id, path, line_start, col_start)
|
|
351
|
+
|
|
352
|
+
# Build file path (relative or absolute)
|
|
353
|
+
file_path = Path(path)
|
|
354
|
+
if not file_path.is_absolute():
|
|
355
|
+
file_path = project_root / path
|
|
356
|
+
|
|
357
|
+
# Build title from rule ID
|
|
358
|
+
title = self._format_title(rule_id, message)
|
|
359
|
+
|
|
360
|
+
# Build description
|
|
361
|
+
description = message
|
|
362
|
+
if "metavars" in extra:
|
|
363
|
+
# Add context about matched variables
|
|
364
|
+
metavars = extra["metavars"]
|
|
365
|
+
if metavars:
|
|
366
|
+
description += f"\n\nMatched values: {json.dumps(metavars, indent=2)}"
|
|
367
|
+
|
|
368
|
+
# Build recommendation
|
|
369
|
+
recommendation = metadata.get("fix", None)
|
|
370
|
+
if not recommendation and "fix" in extra:
|
|
371
|
+
recommendation = extra["fix"]
|
|
372
|
+
|
|
373
|
+
# Build scanner metadata with raw OpenGrep data
|
|
374
|
+
scanner_metadata: Dict[str, Any] = {
|
|
375
|
+
"rule_id": rule_id,
|
|
376
|
+
"line_start": line_start,
|
|
377
|
+
"line_end": line_end,
|
|
378
|
+
"col_start": col_start,
|
|
379
|
+
"col_end": col_end,
|
|
380
|
+
"severity_raw": severity_str,
|
|
381
|
+
"fingerprint": result.get("extra", {}).get("fingerprint"),
|
|
382
|
+
"engine_kind": extra.get("engine_kind"),
|
|
383
|
+
"validation_state": extra.get("validation_state"),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# Add metadata fields if present
|
|
387
|
+
if metadata:
|
|
388
|
+
scanner_metadata["metadata"] = {
|
|
389
|
+
"cwe": metadata.get("cwe", []),
|
|
390
|
+
"owasp": metadata.get("owasp", []),
|
|
391
|
+
"references": metadata.get("references", []),
|
|
392
|
+
"category": metadata.get("category"),
|
|
393
|
+
"technology": metadata.get("technology", []),
|
|
394
|
+
"confidence": metadata.get("confidence"),
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# Get documentation URL from metadata references
|
|
398
|
+
references = metadata.get("references", []) if metadata else []
|
|
399
|
+
documentation_url = references[0] if references else None
|
|
400
|
+
|
|
401
|
+
return UnifiedIssue(
|
|
402
|
+
id=issue_id,
|
|
403
|
+
domain=ScanDomain.SAST,
|
|
404
|
+
source_tool="opengrep",
|
|
405
|
+
severity=severity,
|
|
406
|
+
rule_id=rule_id,
|
|
407
|
+
title=title,
|
|
408
|
+
description=description,
|
|
409
|
+
documentation_url=documentation_url,
|
|
410
|
+
file_path=file_path,
|
|
411
|
+
line_start=line_start,
|
|
412
|
+
line_end=line_end,
|
|
413
|
+
column_start=start.get("col") if start else None,
|
|
414
|
+
column_end=end.get("col") if end else None,
|
|
415
|
+
code_snippet=code_snippet,
|
|
416
|
+
recommendation=recommendation,
|
|
417
|
+
fixable=bool(extra.get("fix")),
|
|
418
|
+
suggested_fix=extra.get("fix"),
|
|
419
|
+
metadata=scanner_metadata,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
except Exception as e:
|
|
423
|
+
LOGGER.warning(f"Failed to convert OpenGrep result to UnifiedIssue: {e}")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
def _format_title(self, rule_id: str, message: str) -> str:
|
|
427
|
+
"""Format a human-readable title from rule ID and message.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
rule_id: OpenGrep rule identifier.
|
|
431
|
+
message: Rule message.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Formatted title string.
|
|
435
|
+
"""
|
|
436
|
+
# Shorten message if too long
|
|
437
|
+
max_message_len = 80
|
|
438
|
+
if len(message) > max_message_len:
|
|
439
|
+
message = message[:max_message_len - 3] + "..."
|
|
440
|
+
|
|
441
|
+
# Use rule ID as prefix for clarity
|
|
442
|
+
return f"{rule_id}: {message}"
|
|
443
|
+
|
|
444
|
+
def _generate_issue_id(
|
|
445
|
+
self,
|
|
446
|
+
rule_id: str,
|
|
447
|
+
path: str,
|
|
448
|
+
line: int,
|
|
449
|
+
col: int,
|
|
450
|
+
) -> str:
|
|
451
|
+
"""Generate a deterministic issue ID for deduplication.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
rule_id: OpenGrep rule identifier.
|
|
455
|
+
path: File path.
|
|
456
|
+
line: Line number.
|
|
457
|
+
col: Column number.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
A stable hash-based ID string.
|
|
461
|
+
"""
|
|
462
|
+
components = f"opengrep:{rule_id}:{path}:{line}:{col}"
|
|
463
|
+
hash_digest = hashlib.sha256(components.encode()).hexdigest()[:16]
|
|
464
|
+
return f"opengrep-{hash_digest}"
|