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,484 @@
1
+ """Checkov scanner plugin for IaC (Infrastructure as Code) scanning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import venv
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
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.versions import get_tool_version
17
+ from lucidscan.core.logging import get_logger
18
+ from lucidscan.core.subprocess_runner import run_with_streaming
19
+
20
+ LOGGER = get_logger(__name__)
21
+
22
+
23
+ def _glob_to_regex(pattern: str) -> str:
24
+ """Convert a gitignore-style glob pattern to a regex pattern.
25
+
26
+ Checkov's Bicep runner (and possibly others) treats --skip-path
27
+ values as regex patterns, so we need to convert glob patterns.
28
+
29
+ Args:
30
+ pattern: Gitignore-style glob pattern (e.g., ".venv/**", "*.tf").
31
+
32
+ Returns:
33
+ Equivalent regex pattern.
34
+ """
35
+ # Escape regex special characters except * and ?
36
+ # These are the special chars in regex that need escaping
37
+ result = ""
38
+ i = 0
39
+ while i < len(pattern):
40
+ c = pattern[i]
41
+
42
+ if c == "*":
43
+ # Check for ** (match anything including path separators)
44
+ if i + 1 < len(pattern) and pattern[i + 1] == "*":
45
+ result += ".*"
46
+ i += 2
47
+ continue
48
+ else:
49
+ # Single * matches anything except path separator
50
+ result += "[^/]*"
51
+ elif c == "?":
52
+ # ? matches any single character except path separator
53
+ result += "[^/]"
54
+ elif c in r"\.^$+{}[]|()":
55
+ # Escape regex special characters
56
+ result += "\\" + c
57
+ else:
58
+ result += c
59
+
60
+ i += 1
61
+
62
+ return result
63
+
64
+ # Default version from pyproject.toml [tool.lucidscan.tools]
65
+ DEFAULT_VERSION = get_tool_version("checkov")
66
+
67
+ # Checkov severity mapping to unified severity
68
+ CHECKOV_SEVERITY_MAP: Dict[str, Severity] = {
69
+ "CRITICAL": Severity.CRITICAL,
70
+ "HIGH": Severity.HIGH,
71
+ "MEDIUM": Severity.MEDIUM,
72
+ "LOW": Severity.LOW,
73
+ "INFO": Severity.INFO,
74
+ "UNKNOWN": Severity.INFO,
75
+ }
76
+
77
+
78
+ class CheckovScanner(ScannerPlugin):
79
+ """Scanner plugin for Checkov (IaC scanning).
80
+
81
+ Handles:
82
+ - Infrastructure-as-Code scanning for Terraform, Kubernetes,
83
+ CloudFormation, ARM templates, and more via `checkov`
84
+
85
+ Binary management:
86
+ - Installs via pip into a virtual environment
87
+ - Caches at {project}/.lucidscan/bin/checkov/{version}/venv/
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ version: str = DEFAULT_VERSION,
93
+ project_root: Optional[Path] = None,
94
+ ) -> None:
95
+ self._version = version
96
+ if project_root:
97
+ self._paths = LucidscanPaths.for_project(project_root)
98
+ else:
99
+ self._paths = LucidscanPaths.default()
100
+
101
+ @property
102
+ def name(self) -> str:
103
+ return "checkov"
104
+
105
+ @property
106
+ def domains(self) -> List[ScanDomain]:
107
+ return [ScanDomain.IAC]
108
+
109
+ def get_version(self) -> str:
110
+ return self._version
111
+
112
+ def ensure_binary(self) -> Path:
113
+ """Ensure the Checkov binary is available, installing if needed.
114
+
115
+ Checkov is a Python package, so we install it into a dedicated
116
+ virtual environment to avoid conflicts with system packages.
117
+
118
+ Returns:
119
+ Path to the checkov binary in the virtual environment.
120
+ """
121
+ venv_dir = self._paths.plugin_bin_dir(self.name, self._version) / "venv"
122
+ binary_path = self._get_binary_path(venv_dir)
123
+
124
+ if binary_path.exists():
125
+ LOGGER.debug(f"Checkov binary found at {binary_path}")
126
+ return binary_path
127
+
128
+ LOGGER.info(f"Installing Checkov v{self._version}...")
129
+ self._install_checkov(venv_dir)
130
+
131
+ if not binary_path.exists():
132
+ raise RuntimeError(f"Failed to install Checkov to {binary_path}")
133
+
134
+ return binary_path
135
+
136
+ def _get_binary_path(self, venv_dir: Path) -> Path:
137
+ """Get the path to the checkov binary in the virtual environment."""
138
+ # On Windows, binaries are in Scripts/, on Unix in bin/
139
+ if sys.platform == "win32":
140
+ return venv_dir / "Scripts" / "checkov.exe"
141
+ return venv_dir / "bin" / "checkov"
142
+
143
+ def _get_pip_path(self, venv_dir: Path) -> Path:
144
+ """Get the path to pip in the virtual environment."""
145
+ if sys.platform == "win32":
146
+ return venv_dir / "Scripts" / "pip.exe"
147
+ return venv_dir / "bin" / "pip"
148
+
149
+ def _install_checkov(self, venv_dir: Path) -> None:
150
+ """Install Checkov into a virtual environment.
151
+
152
+ Args:
153
+ venv_dir: Path to the virtual environment directory.
154
+ """
155
+ # Create parent directories
156
+ venv_dir.parent.mkdir(parents=True, exist_ok=True)
157
+
158
+ # Create virtual environment
159
+ LOGGER.debug(f"Creating virtual environment at {venv_dir}")
160
+ venv.create(venv_dir, with_pip=True)
161
+
162
+ # Install checkov
163
+ pip_path = self._get_pip_path(venv_dir)
164
+
165
+ try:
166
+ # Upgrade pip first to avoid issues (best effort, don't fail if it doesn't work)
167
+ # On Windows, pip upgrade can fail with exit code 1 due to file locking
168
+ # when trying to upgrade itself while running
169
+ subprocess.run(
170
+ [str(pip_path), "install", "--upgrade", "pip"],
171
+ capture_output=True,
172
+ check=False, # Don't fail if pip upgrade fails
173
+ timeout=120, # 2 minute timeout for pip upgrade
174
+ )
175
+
176
+ # Install specific version of checkov
177
+ package_spec = f"checkov=={self._version}"
178
+ LOGGER.debug(f"Installing {package_spec}")
179
+
180
+ # Use UTF-8 encoding with error replacement to handle Windows cp1252 issues
181
+ result = subprocess.run(
182
+ [str(pip_path), "install", package_spec],
183
+ capture_output=True,
184
+ text=True,
185
+ encoding="utf-8",
186
+ errors="replace",
187
+ check=False,
188
+ timeout=300, # 5 minute timeout for checkov install
189
+ )
190
+
191
+ if result.returncode != 0:
192
+ LOGGER.error(f"pip install failed: {result.stderr}")
193
+ raise RuntimeError(f"Failed to install checkov: {result.stderr}")
194
+
195
+ LOGGER.info(f"Checkov v{self._version} installed to {venv_dir}")
196
+
197
+ except subprocess.CalledProcessError as e:
198
+ # Clean up failed installation
199
+ if venv_dir.exists():
200
+ import shutil
201
+ shutil.rmtree(venv_dir, ignore_errors=True)
202
+ raise RuntimeError(f"Failed to install Checkov: {e}") from e
203
+
204
+ def scan(self, context: ScanContext) -> List[UnifiedIssue]:
205
+ """Execute Checkov scan and return normalized issues.
206
+
207
+ Args:
208
+ context: Scan context containing target paths and configuration.
209
+
210
+ Returns:
211
+ List of unified issues found during the scan.
212
+ """
213
+ if ScanDomain.IAC not in context.enabled_domains:
214
+ return []
215
+
216
+ binary = self.ensure_binary()
217
+ return self._run_iac_scan(binary, context)
218
+
219
+ def _run_iac_scan(
220
+ self, binary: Path, context: ScanContext
221
+ ) -> List[UnifiedIssue]:
222
+ """Run Checkov IaC scan.
223
+
224
+ Args:
225
+ binary: Path to the Checkov binary.
226
+ context: Scan context with project root and configuration.
227
+
228
+ Returns:
229
+ List of unified issues from the IaC scan.
230
+ """
231
+ # Get IaC-specific config options
232
+ iac_config = context.get_scanner_options("iac")
233
+
234
+ # Build command
235
+ cmd = [
236
+ str(binary),
237
+ "--directory", str(context.project_root),
238
+ "--output", "json",
239
+ "--quiet",
240
+ "--compact",
241
+ ]
242
+
243
+ # Add framework filter if specified in config
244
+ frameworks = iac_config.get("framework", [])
245
+ if frameworks:
246
+ for framework in frameworks:
247
+ cmd.extend(["--framework", framework])
248
+
249
+ # Add skip checks if specified
250
+ skip_checks = iac_config.get("skip_checks", [])
251
+ if skip_checks:
252
+ cmd.extend(["--skip-check", ",".join(skip_checks)])
253
+
254
+ # Apply ignore patterns from .lucidscanignore and config
255
+ # Convert glob patterns to regex since Checkov's Bicep runner
256
+ # (and possibly others) treats --skip-path as regex
257
+ exclude_patterns = context.get_exclude_patterns()
258
+ for pattern in exclude_patterns:
259
+ regex_pattern = _glob_to_regex(pattern)
260
+ cmd.extend(["--skip-path", regex_pattern])
261
+
262
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
263
+
264
+ # Checkov doesn't support custom env in run_with_streaming, so set env vars first
265
+ import os
266
+ env = self._get_scan_env()
267
+ old_env: Dict[str, Optional[str]] = {}
268
+ for key, value in env.items():
269
+ if key not in os.environ or os.environ[key] != value:
270
+ old_env[key] = os.environ.get(key)
271
+ os.environ[key] = value
272
+
273
+ try:
274
+ result = run_with_streaming(
275
+ cmd=cmd,
276
+ cwd=context.project_root,
277
+ tool_name="checkov",
278
+ stream_handler=context.stream_handler,
279
+ timeout=180,
280
+ )
281
+
282
+ # Checkov returns non-zero exit code when findings exist
283
+ # Exit code 1 means findings found (expected)
284
+ # Exit code 2 means error
285
+ if result.returncode == 2 and result.stderr:
286
+ LOGGER.warning(f"Checkov stderr: {result.stderr}")
287
+
288
+ if not result.stdout.strip():
289
+ LOGGER.debug("Checkov returned empty output")
290
+ return []
291
+
292
+ return self._parse_checkov_json(result.stdout, context.project_root)
293
+
294
+ except subprocess.TimeoutExpired:
295
+ LOGGER.warning("Checkov scan timed out after 180 seconds")
296
+ return []
297
+ except Exception as e:
298
+ LOGGER.error(f"Checkov scan failed: {e}")
299
+ return []
300
+ finally:
301
+ # Restore original environment
302
+ for key, value in old_env.items(): # type: ignore[assignment]
303
+ if value is None:
304
+ os.environ.pop(key, None)
305
+ else:
306
+ os.environ[key] = value
307
+
308
+ def _get_scan_env(self) -> Dict[str, str]:
309
+ """Get environment variables for the scan process."""
310
+ import os
311
+ env = os.environ.copy()
312
+ # Disable telemetry/analytics
313
+ env["BC_SKIP_MAPPING"] = "TRUE"
314
+ env["CHECKOV_RUN_SCA_PACKAGE_SCAN"] = "false"
315
+ return env
316
+
317
+ def _parse_checkov_json(
318
+ self,
319
+ json_output: str,
320
+ project_root: Path,
321
+ ) -> List[UnifiedIssue]:
322
+ """Parse Checkov JSON output and convert to UnifiedIssue list.
323
+
324
+ Args:
325
+ json_output: Raw JSON string from Checkov.
326
+ project_root: Project root path for relative path resolution.
327
+
328
+ Returns:
329
+ List of unified issues parsed from the JSON.
330
+ """
331
+ try:
332
+ data = json.loads(json_output)
333
+ except json.JSONDecodeError as e:
334
+ LOGGER.error(f"Failed to parse Checkov JSON: {e}")
335
+ return []
336
+
337
+ issues: List[UnifiedIssue] = []
338
+
339
+ # Checkov can output a list of results (one per framework) or a single result
340
+ if isinstance(data, list):
341
+ results_list = data
342
+ else:
343
+ results_list = [data]
344
+
345
+ for framework_result in results_list:
346
+ # Skip if not a dict (could be error message)
347
+ if not isinstance(framework_result, dict):
348
+ continue
349
+
350
+ # Get the check type (framework)
351
+ check_type = framework_result.get("check_type", "unknown")
352
+
353
+ # Process failed checks
354
+ results = framework_result.get("results", {})
355
+ failed_checks = results.get("failed_checks", [])
356
+
357
+ for check in failed_checks:
358
+ issue = self._check_to_unified_issue(check, check_type, project_root)
359
+ if issue:
360
+ issues.append(issue)
361
+
362
+ LOGGER.debug(f"Parsed {len(issues)} issues from Checkov output")
363
+ return issues
364
+
365
+ def _check_to_unified_issue(
366
+ self,
367
+ check: Dict[str, Any],
368
+ check_type: str,
369
+ project_root: Path,
370
+ ) -> Optional[UnifiedIssue]:
371
+ """Convert a single Checkov failed check to a UnifiedIssue.
372
+
373
+ Args:
374
+ check: Failed check dict from Checkov JSON.
375
+ check_type: The framework/check type (e.g., 'terraform', 'kubernetes').
376
+ project_root: Project root path for relative path resolution.
377
+
378
+ Returns:
379
+ UnifiedIssue or None if conversion fails.
380
+ """
381
+ try:
382
+ # Extract basic fields
383
+ check_id = check.get("check_id", "UNKNOWN")
384
+ check_name = check.get("check", "Unknown check")
385
+ file_path_str = check.get("file_path", "")
386
+ resource = check.get("resource", "")
387
+ guideline = check.get("guideline", "")
388
+
389
+ # Extract line numbers
390
+ file_line_range = check.get("file_line_range", [])
391
+ line_start = file_line_range[0] if len(file_line_range) > 0 else None
392
+ line_end = file_line_range[1] if len(file_line_range) > 1 else line_start
393
+
394
+ # Extract severity (Checkov includes severity in some checks)
395
+ severity_str = check.get("severity", "MEDIUM")
396
+ if severity_str is None:
397
+ severity_str = "MEDIUM"
398
+ severity = CHECKOV_SEVERITY_MAP.get(severity_str.upper(), Severity.MEDIUM)
399
+
400
+ # Generate deterministic issue ID
401
+ issue_id = self._generate_issue_id(
402
+ check_id, file_path_str, resource, line_start
403
+ )
404
+
405
+ # Build file path
406
+ file_path = None
407
+ if file_path_str:
408
+ # Remove leading slash if present (Checkov sometimes includes it)
409
+ clean_path = file_path_str.lstrip("/")
410
+ file_path = project_root / clean_path
411
+
412
+ # Build title
413
+ title = f"{check_id}: {check_name}"
414
+
415
+ # Build description
416
+ description = check_name
417
+ if resource:
418
+ description += f"\n\nResource: {resource}"
419
+
420
+ # Build recommendation
421
+ recommendation = None
422
+ if guideline:
423
+ recommendation = f"See: {guideline}"
424
+
425
+ # Build IaC resource string
426
+ iac_resource = resource if resource else None
427
+
428
+ # Build scanner metadata
429
+ scanner_metadata: Dict[str, Any] = {
430
+ "check_id": check_id,
431
+ "check_type": check_type,
432
+ "resource": resource,
433
+ "resource_address": check.get("resource_address"),
434
+ "guideline": guideline,
435
+ "severity_raw": severity_str,
436
+ "bc_check_id": check.get("bc_check_id"),
437
+ "evaluations": check.get("evaluations"),
438
+ "check_class": check.get("check_class"),
439
+ }
440
+
441
+ return UnifiedIssue(
442
+ id=issue_id,
443
+ domain=ScanDomain.IAC,
444
+ source_tool="checkov",
445
+ severity=severity,
446
+ rule_id=check_id,
447
+ title=title,
448
+ description=description,
449
+ documentation_url=guideline,
450
+ file_path=file_path,
451
+ line_start=line_start,
452
+ line_end=line_end,
453
+ iac_resource=iac_resource,
454
+ recommendation=recommendation,
455
+ fixable=False, # Checkov doesn't auto-fix
456
+ metadata=scanner_metadata,
457
+ )
458
+
459
+ except Exception as e:
460
+ LOGGER.warning(f"Failed to convert Checkov check to UnifiedIssue: {e}")
461
+ return None
462
+
463
+ def _generate_issue_id(
464
+ self,
465
+ check_id: str,
466
+ file_path: str,
467
+ resource: str,
468
+ line: Optional[int],
469
+ ) -> str:
470
+ """Generate a deterministic issue ID for deduplication.
471
+
472
+ Args:
473
+ check_id: Checkov check identifier (e.g., CKV_AWS_123).
474
+ file_path: File path.
475
+ resource: Resource identifier.
476
+ line: Line number (optional).
477
+
478
+ Returns:
479
+ A stable hash-based ID string.
480
+ """
481
+ line_str = str(line) if line is not None else ""
482
+ components = f"checkov:{check_id}:{file_path}:{resource}:{line_str}"
483
+ hash_digest = hashlib.sha256(components.encode()).hexdigest()[:16]
484
+ return f"checkov-{hash_digest}"