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,463 @@
|
|
|
1
|
+
"""Shared domain runner for executing scanner plugins.
|
|
2
|
+
|
|
3
|
+
This module provides shared functionality for running scanner plugins
|
|
4
|
+
across both CLI and MCP interfaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Type
|
|
11
|
+
|
|
12
|
+
from lucidscan.config import LucidScanConfig
|
|
13
|
+
from lucidscan.core.logging import get_logger
|
|
14
|
+
from lucidscan.core.models import ScanContext, ScanDomain, UnifiedIssue
|
|
15
|
+
|
|
16
|
+
LOGGER = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Plugin to supported languages mapping
|
|
19
|
+
PLUGIN_LANGUAGES: Dict[str, List[str]] = {
|
|
20
|
+
# Linters
|
|
21
|
+
"ruff": ["python"],
|
|
22
|
+
"eslint": ["javascript", "typescript"],
|
|
23
|
+
"biome": ["javascript", "typescript"],
|
|
24
|
+
"checkstyle": ["java"],
|
|
25
|
+
# Type checkers
|
|
26
|
+
"mypy": ["python"],
|
|
27
|
+
"pyright": ["python"],
|
|
28
|
+
"typescript": ["typescript"],
|
|
29
|
+
# Test runners
|
|
30
|
+
"pytest": ["python"],
|
|
31
|
+
"jest": ["javascript", "typescript"],
|
|
32
|
+
"karma": ["javascript", "typescript"],
|
|
33
|
+
"playwright": ["javascript", "typescript"],
|
|
34
|
+
# Coverage
|
|
35
|
+
"coverage_py": ["python"],
|
|
36
|
+
"istanbul": ["javascript", "typescript"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# File extension to language mapping
|
|
40
|
+
EXTENSION_LANGUAGE: Dict[str, str] = {
|
|
41
|
+
".py": "python",
|
|
42
|
+
".pyi": "python",
|
|
43
|
+
".js": "javascript",
|
|
44
|
+
".jsx": "javascript",
|
|
45
|
+
".ts": "typescript",
|
|
46
|
+
".tsx": "typescript",
|
|
47
|
+
".java": "java",
|
|
48
|
+
".go": "go",
|
|
49
|
+
".rs": "rust",
|
|
50
|
+
".rb": "ruby",
|
|
51
|
+
".tf": "terraform",
|
|
52
|
+
".yaml": "yaml",
|
|
53
|
+
".yml": "yaml",
|
|
54
|
+
".json": "json",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def filter_plugins_by_language(
|
|
59
|
+
plugins: Dict[str, Type[Any]],
|
|
60
|
+
project_languages: List[str],
|
|
61
|
+
) -> Dict[str, Type[Any]]:
|
|
62
|
+
"""Filter plugins to only those supporting the project's languages.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
plugins: Dict of plugin_name -> plugin_class.
|
|
66
|
+
project_languages: List of languages from project config.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Filtered dict of plugins that support at least one project language.
|
|
70
|
+
"""
|
|
71
|
+
if not project_languages:
|
|
72
|
+
return plugins
|
|
73
|
+
|
|
74
|
+
filtered = {}
|
|
75
|
+
for name, cls in plugins.items():
|
|
76
|
+
supported_langs = PLUGIN_LANGUAGES.get(name, [])
|
|
77
|
+
# Include plugin if it supports any of the project languages
|
|
78
|
+
# or if the plugin has no language restrictions
|
|
79
|
+
if not supported_langs or any(
|
|
80
|
+
lang.lower() in [sl.lower() for sl in supported_langs]
|
|
81
|
+
for lang in project_languages
|
|
82
|
+
):
|
|
83
|
+
filtered[name] = cls
|
|
84
|
+
|
|
85
|
+
return filtered
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def filter_plugins_by_config(
|
|
89
|
+
plugins: Dict[str, Type[Any]],
|
|
90
|
+
config: LucidScanConfig,
|
|
91
|
+
domain: str,
|
|
92
|
+
) -> Dict[str, Type[Any]]:
|
|
93
|
+
"""Filter plugins based on configuration.
|
|
94
|
+
|
|
95
|
+
First tries to filter by explicitly configured tools. If none are
|
|
96
|
+
configured, falls back to language-based filtering.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
plugins: Dict of plugin_name -> plugin_class.
|
|
100
|
+
config: LucidScan configuration.
|
|
101
|
+
domain: Domain name (linting, type_checking, testing, coverage).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Filtered dict of plugins.
|
|
105
|
+
"""
|
|
106
|
+
configured_tools = config.pipeline.get_enabled_tool_names(domain)
|
|
107
|
+
if configured_tools:
|
|
108
|
+
return {
|
|
109
|
+
name: cls for name, cls in plugins.items()
|
|
110
|
+
if name in configured_tools
|
|
111
|
+
}
|
|
112
|
+
return filter_plugins_by_language(plugins, config.project.languages)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def filter_scanners_by_config(
|
|
116
|
+
scanners: Dict[str, Type[Any]],
|
|
117
|
+
config: LucidScanConfig,
|
|
118
|
+
domain: str,
|
|
119
|
+
) -> Dict[str, Type[Any]]:
|
|
120
|
+
"""Filter scanner plugins based on configuration for a specific domain.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
scanners: Dict of scanner_name -> scanner_class.
|
|
124
|
+
config: LucidScan configuration.
|
|
125
|
+
domain: Scanner domain (sast, sca, iac, container).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Filtered dict of scanners.
|
|
129
|
+
"""
|
|
130
|
+
configured_plugin = config.get_plugin_for_domain(domain)
|
|
131
|
+
if configured_plugin:
|
|
132
|
+
return {
|
|
133
|
+
name: cls for name, cls in scanners.items()
|
|
134
|
+
if name == configured_plugin
|
|
135
|
+
}
|
|
136
|
+
return scanners
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def detect_language(path: Path) -> str:
|
|
140
|
+
"""Detect language from file extension.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path: File path.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Language name or "unknown".
|
|
147
|
+
"""
|
|
148
|
+
suffix = path.suffix.lower()
|
|
149
|
+
return EXTENSION_LANGUAGE.get(suffix, "unknown")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_domains_for_language(language: str) -> List[str]:
|
|
153
|
+
"""Get appropriate domains for a language.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
language: Language name.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of domain names.
|
|
160
|
+
"""
|
|
161
|
+
# Default domains for most languages - use specific security domains
|
|
162
|
+
# "sast" for static analysis, "sca" for dependency scanning
|
|
163
|
+
domains = ["linting", "sast", "sca"]
|
|
164
|
+
|
|
165
|
+
if language == "python":
|
|
166
|
+
domains.extend(["type_checking", "testing", "coverage"])
|
|
167
|
+
elif language in ("javascript", "typescript"):
|
|
168
|
+
domains.extend(["type_checking", "testing", "coverage"])
|
|
169
|
+
elif language == "terraform":
|
|
170
|
+
domains = ["iac"]
|
|
171
|
+
elif language in ("yaml", "json"):
|
|
172
|
+
domains = ["iac", "sast"]
|
|
173
|
+
|
|
174
|
+
return domains
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DomainRunner:
|
|
178
|
+
"""Executes plugin-based domain scans.
|
|
179
|
+
|
|
180
|
+
Provides a unified interface for running linting, type checking,
|
|
181
|
+
testing, coverage, and security scans across both CLI and MCP.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
project_root: Path,
|
|
187
|
+
config: LucidScanConfig,
|
|
188
|
+
log_level: str = "info",
|
|
189
|
+
):
|
|
190
|
+
"""Initialize DomainRunner.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
project_root: Project root directory.
|
|
194
|
+
config: LucidScan configuration.
|
|
195
|
+
log_level: Logging level for plugin execution ("info" or "debug").
|
|
196
|
+
"""
|
|
197
|
+
self.project_root = project_root
|
|
198
|
+
self.config = config
|
|
199
|
+
self._log_level = log_level
|
|
200
|
+
|
|
201
|
+
def _log(self, level: str, message: str) -> None:
|
|
202
|
+
"""Log a message at the configured level."""
|
|
203
|
+
if level == "info" and self._log_level == "info":
|
|
204
|
+
LOGGER.info(message)
|
|
205
|
+
else:
|
|
206
|
+
LOGGER.debug(message)
|
|
207
|
+
|
|
208
|
+
def run_linting(
|
|
209
|
+
self,
|
|
210
|
+
context: ScanContext,
|
|
211
|
+
fix: bool = False,
|
|
212
|
+
) -> List[UnifiedIssue]:
|
|
213
|
+
"""Run linting checks.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
context: Scan context.
|
|
217
|
+
fix: Whether to apply automatic fixes.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
List of linting issues.
|
|
221
|
+
"""
|
|
222
|
+
from lucidscan.plugins.linters import discover_linter_plugins
|
|
223
|
+
|
|
224
|
+
issues: List[UnifiedIssue] = []
|
|
225
|
+
linters = discover_linter_plugins()
|
|
226
|
+
|
|
227
|
+
if not linters:
|
|
228
|
+
LOGGER.warning("No linter plugins found")
|
|
229
|
+
return issues
|
|
230
|
+
|
|
231
|
+
linters = filter_plugins_by_config(linters, self.config, "linting")
|
|
232
|
+
|
|
233
|
+
for name, plugin_class in linters.items():
|
|
234
|
+
try:
|
|
235
|
+
self._log("info", f"Running linter: {name}")
|
|
236
|
+
plugin = plugin_class(project_root=self.project_root)
|
|
237
|
+
|
|
238
|
+
if fix and plugin.supports_fix:
|
|
239
|
+
fix_result = plugin.fix(context)
|
|
240
|
+
self._log(
|
|
241
|
+
"info",
|
|
242
|
+
f"{name}: Fixed {fix_result.issues_fixed} issues, "
|
|
243
|
+
f"{fix_result.issues_remaining} remaining"
|
|
244
|
+
)
|
|
245
|
+
# Run again to get remaining issues
|
|
246
|
+
issues.extend(plugin.lint(context))
|
|
247
|
+
else:
|
|
248
|
+
issues.extend(plugin.lint(context))
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
LOGGER.error(f"Linter {name} failed: {e}")
|
|
252
|
+
|
|
253
|
+
return issues
|
|
254
|
+
|
|
255
|
+
def run_type_checking(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
256
|
+
"""Run type checking.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
context: Scan context.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
List of type checking issues.
|
|
263
|
+
"""
|
|
264
|
+
from lucidscan.plugins.type_checkers import discover_type_checker_plugins
|
|
265
|
+
|
|
266
|
+
issues: List[UnifiedIssue] = []
|
|
267
|
+
checkers = discover_type_checker_plugins()
|
|
268
|
+
|
|
269
|
+
if not checkers:
|
|
270
|
+
LOGGER.warning("No type checker plugins found")
|
|
271
|
+
return issues
|
|
272
|
+
|
|
273
|
+
checkers = filter_plugins_by_config(checkers, self.config, "type_checking")
|
|
274
|
+
|
|
275
|
+
for name, plugin_class in checkers.items():
|
|
276
|
+
try:
|
|
277
|
+
self._log("info", f"Running type checker: {name}")
|
|
278
|
+
plugin = plugin_class(project_root=self.project_root)
|
|
279
|
+
issues.extend(plugin.check(context))
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
LOGGER.error(f"Type checker {name} failed: {e}")
|
|
283
|
+
|
|
284
|
+
return issues
|
|
285
|
+
|
|
286
|
+
def run_tests(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
287
|
+
"""Run test suite.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
context: Scan context.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of test failure issues.
|
|
294
|
+
"""
|
|
295
|
+
from lucidscan.plugins.test_runners import discover_test_runner_plugins
|
|
296
|
+
|
|
297
|
+
issues: List[UnifiedIssue] = []
|
|
298
|
+
runners = discover_test_runner_plugins()
|
|
299
|
+
|
|
300
|
+
if not runners:
|
|
301
|
+
LOGGER.warning("No test runner plugins found")
|
|
302
|
+
return issues
|
|
303
|
+
|
|
304
|
+
runners = filter_plugins_by_config(runners, self.config, "testing")
|
|
305
|
+
|
|
306
|
+
for name, plugin_class in runners.items():
|
|
307
|
+
try:
|
|
308
|
+
self._log("info", f"Running test runner: {name}")
|
|
309
|
+
plugin = plugin_class(project_root=self.project_root)
|
|
310
|
+
result = plugin.run_tests(context)
|
|
311
|
+
|
|
312
|
+
self._log(
|
|
313
|
+
"info",
|
|
314
|
+
f"{name}: {result.passed} passed, {result.failed} failed, "
|
|
315
|
+
f"{result.skipped} skipped, {result.errors} errors"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
issues.extend(result.issues)
|
|
319
|
+
|
|
320
|
+
except FileNotFoundError:
|
|
321
|
+
LOGGER.debug(f"Test runner {name} not available")
|
|
322
|
+
except Exception as e:
|
|
323
|
+
LOGGER.error(f"Test runner {name} failed: {e}")
|
|
324
|
+
|
|
325
|
+
return issues
|
|
326
|
+
|
|
327
|
+
def run_coverage(
|
|
328
|
+
self,
|
|
329
|
+
context: ScanContext,
|
|
330
|
+
threshold: float = 80.0,
|
|
331
|
+
run_tests: bool = True,
|
|
332
|
+
) -> List[UnifiedIssue]:
|
|
333
|
+
"""Run coverage analysis.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
context: Scan context.
|
|
337
|
+
threshold: Coverage percentage threshold.
|
|
338
|
+
run_tests: Whether to run tests for coverage.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
List of coverage issues.
|
|
342
|
+
"""
|
|
343
|
+
from lucidscan.plugins.coverage import discover_coverage_plugins
|
|
344
|
+
|
|
345
|
+
issues: List[UnifiedIssue] = []
|
|
346
|
+
plugins = discover_coverage_plugins()
|
|
347
|
+
|
|
348
|
+
if not plugins:
|
|
349
|
+
LOGGER.warning("No coverage plugins found")
|
|
350
|
+
return issues
|
|
351
|
+
|
|
352
|
+
plugins = filter_plugins_by_config(plugins, self.config, "coverage")
|
|
353
|
+
|
|
354
|
+
for name, plugin_class in plugins.items():
|
|
355
|
+
try:
|
|
356
|
+
self._log("info", f"Running coverage: {name}")
|
|
357
|
+
plugin = plugin_class(project_root=self.project_root)
|
|
358
|
+
result = plugin.measure_coverage(
|
|
359
|
+
context, threshold=threshold, run_tests=run_tests
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
status = "PASSED" if result.passed else "FAILED"
|
|
363
|
+
|
|
364
|
+
# Build log message with test stats if available
|
|
365
|
+
log_parts = [
|
|
366
|
+
f"{name}: {result.percentage:.1f}%",
|
|
367
|
+
f"({result.covered_lines}/{result.total_lines} lines)",
|
|
368
|
+
f"- threshold: {threshold}%",
|
|
369
|
+
f"- {status}",
|
|
370
|
+
]
|
|
371
|
+
if result.test_stats:
|
|
372
|
+
ts = result.test_stats
|
|
373
|
+
log_parts.append(
|
|
374
|
+
f"| Tests: {ts.total} total, {ts.passed} passed, "
|
|
375
|
+
f"{ts.failed} failed, {ts.skipped} skipped"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
self._log("info", " ".join(log_parts))
|
|
379
|
+
|
|
380
|
+
# Store the coverage result in context for MCP to access
|
|
381
|
+
context.coverage_result = result
|
|
382
|
+
|
|
383
|
+
issues.extend(result.issues)
|
|
384
|
+
|
|
385
|
+
except FileNotFoundError:
|
|
386
|
+
LOGGER.debug(f"Coverage plugin {name} not available")
|
|
387
|
+
except Exception as e:
|
|
388
|
+
LOGGER.error(f"Coverage plugin {name} failed: {e}")
|
|
389
|
+
|
|
390
|
+
return issues
|
|
391
|
+
|
|
392
|
+
def run_security(
|
|
393
|
+
self,
|
|
394
|
+
context: ScanContext,
|
|
395
|
+
domain: ScanDomain,
|
|
396
|
+
) -> List[UnifiedIssue]:
|
|
397
|
+
"""Run security scanner for a specific domain.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
context: Scan context.
|
|
401
|
+
domain: Scanner domain (SAST, SCA, IAC, CONTAINER).
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
List of security issues.
|
|
405
|
+
"""
|
|
406
|
+
from lucidscan.plugins.scanners import discover_scanner_plugins
|
|
407
|
+
|
|
408
|
+
issues: List[UnifiedIssue] = []
|
|
409
|
+
scanners = discover_scanner_plugins()
|
|
410
|
+
|
|
411
|
+
if not scanners:
|
|
412
|
+
LOGGER.warning("No scanner plugins found")
|
|
413
|
+
return issues
|
|
414
|
+
|
|
415
|
+
# Filter by configured plugin for this domain
|
|
416
|
+
domain_str = domain.value.lower()
|
|
417
|
+
scanners = filter_scanners_by_config(scanners, self.config, domain_str)
|
|
418
|
+
|
|
419
|
+
for name, scanner_class in scanners.items():
|
|
420
|
+
try:
|
|
421
|
+
scanner = scanner_class(project_root=self.project_root)
|
|
422
|
+
if domain in scanner.domains:
|
|
423
|
+
self._log("info", f"Running {domain_str} scanner: {name}")
|
|
424
|
+
result = scanner.scan(context)
|
|
425
|
+
issues.extend(result)
|
|
426
|
+
|
|
427
|
+
except Exception as e:
|
|
428
|
+
LOGGER.error(f"Scanner {name} failed: {e}")
|
|
429
|
+
|
|
430
|
+
return issues
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def check_severity_threshold(
|
|
434
|
+
issues: List[UnifiedIssue],
|
|
435
|
+
threshold: Optional[str],
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Check if any issues meet or exceed the severity threshold.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
issues: List of issues to check.
|
|
441
|
+
threshold: Severity threshold ('critical', 'high', 'medium', 'low').
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
True if issues at or above threshold exist, False otherwise.
|
|
445
|
+
"""
|
|
446
|
+
if not threshold or not issues:
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
threshold_order = {
|
|
450
|
+
"critical": 0,
|
|
451
|
+
"high": 1,
|
|
452
|
+
"medium": 2,
|
|
453
|
+
"low": 3,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
threshold_level = threshold_order.get(threshold.lower(), 99)
|
|
457
|
+
|
|
458
|
+
for issue in issues:
|
|
459
|
+
issue_level = threshold_order.get(issue.severity.value, 99)
|
|
460
|
+
if issue_level <= threshold_level:
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
return False
|
lucidscan/core/git.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Git utilities for detecting changed files.
|
|
2
|
+
|
|
3
|
+
Provides functionality to detect uncommitted changes in a git repository
|
|
4
|
+
for partial/incremental scanning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from lucidscan.core.logging import get_logger
|
|
14
|
+
|
|
15
|
+
LOGGER = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_git_repo(path: Path) -> bool:
|
|
19
|
+
"""Check if the given path is inside a git repository.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: Path to check.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True if inside a git repository, False otherwise.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "rev-parse", "--git-dir"],
|
|
30
|
+
cwd=path,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=5,
|
|
34
|
+
)
|
|
35
|
+
return result.returncode == 0
|
|
36
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_git_root(path: Path) -> Optional[Path]:
|
|
41
|
+
"""Get the root directory of the git repository.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
path: Path inside the repository.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Path to git root, or None if not a git repository.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
52
|
+
cwd=path,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=5,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode == 0:
|
|
58
|
+
return Path(result.stdout.strip())
|
|
59
|
+
return None
|
|
60
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_changed_files(
|
|
65
|
+
project_root: Path,
|
|
66
|
+
include_untracked: bool = True,
|
|
67
|
+
include_staged: bool = True,
|
|
68
|
+
include_unstaged: bool = True,
|
|
69
|
+
) -> Optional[List[Path]]:
|
|
70
|
+
"""Get list of changed files in the git repository.
|
|
71
|
+
|
|
72
|
+
Returns files that have uncommitted changes (staged, unstaged, or untracked).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
project_root: Root directory of the project.
|
|
76
|
+
include_untracked: Include untracked files.
|
|
77
|
+
include_staged: Include staged (added to index) files.
|
|
78
|
+
include_unstaged: Include unstaged modifications.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
List of changed file paths (absolute), or None if not a git repo
|
|
82
|
+
or git command fails.
|
|
83
|
+
"""
|
|
84
|
+
if not is_git_repo(project_root):
|
|
85
|
+
LOGGER.debug(f"Not a git repository: {project_root}")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
changed_files: set[Path] = set()
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Get staged files (files added to index)
|
|
92
|
+
if include_staged:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["git", "diff", "--cached", "--name-only"],
|
|
95
|
+
cwd=project_root,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=30,
|
|
99
|
+
)
|
|
100
|
+
if result.returncode == 0:
|
|
101
|
+
for line in result.stdout.strip().split("\n"):
|
|
102
|
+
if line:
|
|
103
|
+
file_path = project_root / line
|
|
104
|
+
if file_path.exists():
|
|
105
|
+
changed_files.add(file_path)
|
|
106
|
+
|
|
107
|
+
# Get unstaged modifications (modified but not staged)
|
|
108
|
+
if include_unstaged:
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["git", "diff", "--name-only"],
|
|
111
|
+
cwd=project_root,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=30,
|
|
115
|
+
)
|
|
116
|
+
if result.returncode == 0:
|
|
117
|
+
for line in result.stdout.strip().split("\n"):
|
|
118
|
+
if line:
|
|
119
|
+
file_path = project_root / line
|
|
120
|
+
if file_path.exists():
|
|
121
|
+
changed_files.add(file_path)
|
|
122
|
+
|
|
123
|
+
# Get untracked files
|
|
124
|
+
if include_untracked:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
127
|
+
cwd=project_root,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=30,
|
|
131
|
+
)
|
|
132
|
+
if result.returncode == 0:
|
|
133
|
+
for line in result.stdout.strip().split("\n"):
|
|
134
|
+
if line:
|
|
135
|
+
file_path = project_root / line
|
|
136
|
+
if file_path.exists():
|
|
137
|
+
changed_files.add(file_path)
|
|
138
|
+
|
|
139
|
+
LOGGER.debug(f"Found {len(changed_files)} changed files in {project_root}")
|
|
140
|
+
return sorted(changed_files)
|
|
141
|
+
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
LOGGER.warning("Git command timed out, falling back to full scan")
|
|
144
|
+
return None
|
|
145
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
|
|
146
|
+
LOGGER.warning(f"Git command failed: {e}, falling back to full scan")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def filter_files_by_extension(
|
|
151
|
+
files: List[Path],
|
|
152
|
+
extensions: Optional[List[str]] = None,
|
|
153
|
+
) -> List[Path]:
|
|
154
|
+
"""Filter files by extension.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
files: List of file paths.
|
|
158
|
+
extensions: List of extensions to include (e.g., [".py", ".js"]).
|
|
159
|
+
If None, returns all files.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Filtered list of files.
|
|
163
|
+
"""
|
|
164
|
+
if extensions is None:
|
|
165
|
+
return files
|
|
166
|
+
|
|
167
|
+
# Normalize extensions to include the dot
|
|
168
|
+
normalized_extensions = set()
|
|
169
|
+
for ext in extensions:
|
|
170
|
+
if not ext.startswith("."):
|
|
171
|
+
ext = f".{ext}"
|
|
172
|
+
normalized_extensions.add(ext.lower())
|
|
173
|
+
|
|
174
|
+
return [f for f in files if f.suffix.lower() in normalized_extensions]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def configure_logging(*, debug: bool = False, verbose: bool = False, quiet: bool = False) -> None:
|
|
8
|
+
"""Configure root logging level based on CLI flags.
|
|
9
|
+
|
|
10
|
+
Precedence:
|
|
11
|
+
- quiet → ERROR
|
|
12
|
+
- debug → DEBUG
|
|
13
|
+
- verbose → INFO
|
|
14
|
+
- default → WARNING
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
if quiet:
|
|
18
|
+
level = logging.ERROR
|
|
19
|
+
elif debug:
|
|
20
|
+
level = logging.DEBUG
|
|
21
|
+
elif verbose:
|
|
22
|
+
level = logging.INFO
|
|
23
|
+
else:
|
|
24
|
+
level = logging.WARNING
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
30
|
+
"""Return a module-level logger."""
|
|
31
|
+
|
|
32
|
+
return logging.getLogger(name if name is not None else __name__)
|
|
33
|
+
|
|
34
|
+
|