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,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}"