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
lucidscan/mcp/tools.py
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
"""MCP tool executor for LucidScan operations.
|
|
2
|
+
|
|
3
|
+
Executes LucidScan scan operations and formats results for AI agents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import functools
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from lucidscan.config import LucidScanConfig
|
|
15
|
+
from lucidscan.core.domain_runner import (
|
|
16
|
+
DomainRunner,
|
|
17
|
+
detect_language,
|
|
18
|
+
get_domains_for_language,
|
|
19
|
+
)
|
|
20
|
+
from lucidscan.core.git import get_changed_files
|
|
21
|
+
from lucidscan.core.logging import get_logger
|
|
22
|
+
from lucidscan.core.models import DomainType, ScanContext, ScanDomain, ToolDomain, UnifiedIssue
|
|
23
|
+
from lucidscan.core.streaming import (
|
|
24
|
+
CLIStreamHandler,
|
|
25
|
+
MCPStreamHandler,
|
|
26
|
+
StreamEvent,
|
|
27
|
+
StreamHandler,
|
|
28
|
+
)
|
|
29
|
+
from lucidscan.mcp.formatter import InstructionFormatter
|
|
30
|
+
|
|
31
|
+
LOGGER = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MCPToolExecutor:
|
|
35
|
+
"""Executes LucidScan operations for MCP tools."""
|
|
36
|
+
|
|
37
|
+
# Map string domain names to the appropriate enum
|
|
38
|
+
# ScanDomain for scanner plugins, ToolDomain for other tools
|
|
39
|
+
# Use canonical names only - no synonyms
|
|
40
|
+
DOMAIN_MAP: Dict[str, DomainType] = {
|
|
41
|
+
"linting": ToolDomain.LINTING,
|
|
42
|
+
"type_checking": ToolDomain.TYPE_CHECKING,
|
|
43
|
+
"sast": ScanDomain.SAST,
|
|
44
|
+
"sca": ScanDomain.SCA,
|
|
45
|
+
"iac": ScanDomain.IAC,
|
|
46
|
+
"container": ScanDomain.CONTAINER,
|
|
47
|
+
"testing": ToolDomain.TESTING,
|
|
48
|
+
"coverage": ToolDomain.COVERAGE,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def __init__(self, project_root: Path, config: LucidScanConfig):
|
|
52
|
+
"""Initialize MCPToolExecutor.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
project_root: Project root directory.
|
|
56
|
+
config: LucidScan configuration.
|
|
57
|
+
"""
|
|
58
|
+
self.project_root = project_root
|
|
59
|
+
self.config = config
|
|
60
|
+
self.instruction_formatter = InstructionFormatter()
|
|
61
|
+
self._issue_cache: Dict[str, UnifiedIssue] = {}
|
|
62
|
+
self._tools_bootstrapped = False
|
|
63
|
+
# Use DomainRunner with debug logging for MCP (less verbose)
|
|
64
|
+
self._runner = DomainRunner(project_root, config, log_level="debug")
|
|
65
|
+
|
|
66
|
+
def _bootstrap_security_tools(self, security_domains: List[ScanDomain]) -> None:
|
|
67
|
+
"""Ensure security tool binaries are available.
|
|
68
|
+
|
|
69
|
+
Downloads tools if not already present. Called before first scan
|
|
70
|
+
to ensure tools are ready before async scan operations begin.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
security_domains: List of security domains that need to be bootstrapped.
|
|
74
|
+
"""
|
|
75
|
+
if self._tools_bootstrapped:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
from lucidscan.plugins.scanners import get_scanner_plugin
|
|
79
|
+
|
|
80
|
+
# Get unique scanners needed based on requested security domains only
|
|
81
|
+
scanners_to_bootstrap: set[str] = set()
|
|
82
|
+
for domain in security_domains:
|
|
83
|
+
plugin_name = self.config.get_plugin_for_domain(domain.value)
|
|
84
|
+
if plugin_name:
|
|
85
|
+
scanners_to_bootstrap.add(plugin_name)
|
|
86
|
+
|
|
87
|
+
for scanner_name in scanners_to_bootstrap:
|
|
88
|
+
try:
|
|
89
|
+
LOGGER.info(f"Bootstrapping {scanner_name}...")
|
|
90
|
+
scanner = get_scanner_plugin(scanner_name, project_root=self.project_root)
|
|
91
|
+
if scanner:
|
|
92
|
+
scanner.ensure_binary()
|
|
93
|
+
LOGGER.debug(f"{scanner_name} ready")
|
|
94
|
+
except Exception as e:
|
|
95
|
+
LOGGER.error(f"Failed to bootstrap {scanner_name}: {e}")
|
|
96
|
+
|
|
97
|
+
self._tools_bootstrapped = True
|
|
98
|
+
|
|
99
|
+
async def scan(
|
|
100
|
+
self,
|
|
101
|
+
domains: List[str],
|
|
102
|
+
files: Optional[List[str]] = None,
|
|
103
|
+
all_files: bool = False,
|
|
104
|
+
fix: bool = False,
|
|
105
|
+
on_progress: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Execute scan and return AI-formatted results.
|
|
108
|
+
|
|
109
|
+
Default behavior: Scans only changed files (uncommitted changes).
|
|
110
|
+
- If `files` is provided, scan only those specific files
|
|
111
|
+
- If `all_files` is True, scan entire project
|
|
112
|
+
- Otherwise, scan only changed files (git diff)
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
domains: List of domain names to scan (e.g., ["linting", "security"]).
|
|
116
|
+
files: Optional list of specific files to scan.
|
|
117
|
+
all_files: If True, scan entire project instead of just changed files.
|
|
118
|
+
fix: Whether to apply auto-fixes (linting only).
|
|
119
|
+
on_progress: Optional async callback for progress events (MCP notifications).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Structured scan result with AI instructions.
|
|
123
|
+
"""
|
|
124
|
+
# Convert domain strings to ToolDomain enums
|
|
125
|
+
enabled_domains = self._parse_domains(domains)
|
|
126
|
+
|
|
127
|
+
# Bootstrap security tools if needed (before async operations)
|
|
128
|
+
security_domains = [d for d in enabled_domains if isinstance(d, ScanDomain)]
|
|
129
|
+
if security_domains and not self._tools_bootstrapped:
|
|
130
|
+
if on_progress:
|
|
131
|
+
await on_progress({
|
|
132
|
+
"tool": "lucidscan",
|
|
133
|
+
"content": "Downloading security tools...",
|
|
134
|
+
"progress": 0,
|
|
135
|
+
"total": None,
|
|
136
|
+
})
|
|
137
|
+
loop = asyncio.get_event_loop()
|
|
138
|
+
await loop.run_in_executor(None, self._bootstrap_security_tools, security_domains)
|
|
139
|
+
|
|
140
|
+
# Create stream handler for progress output
|
|
141
|
+
stream_handler: Optional[StreamHandler] = None
|
|
142
|
+
|
|
143
|
+
if on_progress:
|
|
144
|
+
# Use MCP stream handler for async notifications
|
|
145
|
+
async def on_event(event: StreamEvent) -> None:
|
|
146
|
+
event_dict = {
|
|
147
|
+
"tool": event.tool_name,
|
|
148
|
+
"type": event.stream_type.value,
|
|
149
|
+
"content": event.content,
|
|
150
|
+
}
|
|
151
|
+
await on_progress(event_dict)
|
|
152
|
+
|
|
153
|
+
stream_handler = MCPStreamHandler(on_event=on_event)
|
|
154
|
+
else:
|
|
155
|
+
# Default: write progress to stderr
|
|
156
|
+
stream_handler = CLIStreamHandler(
|
|
157
|
+
output=sys.stderr,
|
|
158
|
+
show_output=True,
|
|
159
|
+
use_rich=False,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Build context with stream handler and partial scanning logic
|
|
163
|
+
context = self._build_context(enabled_domains, files, all_files, stream_handler)
|
|
164
|
+
|
|
165
|
+
# Run scans in parallel for different domains
|
|
166
|
+
all_issues: List[UnifiedIssue] = []
|
|
167
|
+
|
|
168
|
+
# Build list of tasks with their domain names for progress tracking
|
|
169
|
+
tasks_with_names: List[tuple[str, Coroutine]] = []
|
|
170
|
+
if ToolDomain.LINTING in enabled_domains:
|
|
171
|
+
tasks_with_names.append(("linting", self._run_linting(context, fix)))
|
|
172
|
+
if ToolDomain.TYPE_CHECKING in enabled_domains:
|
|
173
|
+
tasks_with_names.append(("type_checking", self._run_type_checking(context)))
|
|
174
|
+
if ScanDomain.SAST in enabled_domains:
|
|
175
|
+
tasks_with_names.append(("sast", self._run_security(context, ScanDomain.SAST)))
|
|
176
|
+
if ScanDomain.SCA in enabled_domains:
|
|
177
|
+
tasks_with_names.append(("sca", self._run_security(context, ScanDomain.SCA)))
|
|
178
|
+
if ScanDomain.IAC in enabled_domains:
|
|
179
|
+
tasks_with_names.append(("iac", self._run_security(context, ScanDomain.IAC)))
|
|
180
|
+
if ScanDomain.CONTAINER in enabled_domains:
|
|
181
|
+
tasks_with_names.append(("container", self._run_security(context, ScanDomain.CONTAINER)))
|
|
182
|
+
|
|
183
|
+
# Check if both testing and coverage are enabled
|
|
184
|
+
testing_enabled = ToolDomain.TESTING in enabled_domains
|
|
185
|
+
coverage_enabled = ToolDomain.COVERAGE in enabled_domains
|
|
186
|
+
|
|
187
|
+
if testing_enabled and not coverage_enabled:
|
|
188
|
+
# Only testing enabled - run tests standalone
|
|
189
|
+
tasks_with_names.append(("testing", self._run_testing(context)))
|
|
190
|
+
elif coverage_enabled:
|
|
191
|
+
# Coverage enabled - it will run tests with instrumentation
|
|
192
|
+
# Skip standalone testing to avoid running tests twice
|
|
193
|
+
# Coverage result includes test stats
|
|
194
|
+
tasks_with_names.append(("coverage", self._run_coverage(context, run_tests=True)))
|
|
195
|
+
|
|
196
|
+
total_domains = len(tasks_with_names)
|
|
197
|
+
|
|
198
|
+
if tasks_with_names:
|
|
199
|
+
# Send initial progress notification
|
|
200
|
+
if on_progress and total_domains > 0:
|
|
201
|
+
domain_names = [name for name, _ in tasks_with_names]
|
|
202
|
+
await on_progress({
|
|
203
|
+
"tool": "lucidscan",
|
|
204
|
+
"content": f"Scanning {total_domains} domain(s): {', '.join(domain_names)}",
|
|
205
|
+
"progress": 0,
|
|
206
|
+
"total": total_domains,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
# Wrap each task to report progress on completion
|
|
210
|
+
completed_count = 0
|
|
211
|
+
|
|
212
|
+
async def run_with_progress(
|
|
213
|
+
domain_name: str, coro: Coroutine
|
|
214
|
+
) -> List[UnifiedIssue]:
|
|
215
|
+
nonlocal completed_count
|
|
216
|
+
try:
|
|
217
|
+
if on_progress:
|
|
218
|
+
await on_progress({
|
|
219
|
+
"tool": domain_name,
|
|
220
|
+
"content": "started",
|
|
221
|
+
"progress": completed_count,
|
|
222
|
+
"total": total_domains,
|
|
223
|
+
})
|
|
224
|
+
result = await coro
|
|
225
|
+
completed_count += 1
|
|
226
|
+
if on_progress:
|
|
227
|
+
await on_progress({
|
|
228
|
+
"tool": domain_name,
|
|
229
|
+
"content": "completed",
|
|
230
|
+
"progress": completed_count,
|
|
231
|
+
"total": total_domains,
|
|
232
|
+
})
|
|
233
|
+
return result if result is not None else []
|
|
234
|
+
except Exception as e:
|
|
235
|
+
completed_count += 1
|
|
236
|
+
if on_progress:
|
|
237
|
+
await on_progress({
|
|
238
|
+
"tool": domain_name,
|
|
239
|
+
"content": f"failed: {e}",
|
|
240
|
+
"progress": completed_count,
|
|
241
|
+
"total": total_domains,
|
|
242
|
+
})
|
|
243
|
+
raise
|
|
244
|
+
|
|
245
|
+
# Run all tasks with progress tracking
|
|
246
|
+
tasks = [run_with_progress(name, coro) for name, coro in tasks_with_names]
|
|
247
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
248
|
+
|
|
249
|
+
for result in results:
|
|
250
|
+
if isinstance(result, BaseException):
|
|
251
|
+
LOGGER.warning(f"Scan task failed: {result}")
|
|
252
|
+
elif result is not None:
|
|
253
|
+
all_issues.extend(result)
|
|
254
|
+
|
|
255
|
+
# Cache issues for later reference
|
|
256
|
+
for issue in all_issues:
|
|
257
|
+
self._issue_cache[issue.id] = issue
|
|
258
|
+
|
|
259
|
+
# Build list of checked domain names for the formatter
|
|
260
|
+
checked_domain_names: List[str] = []
|
|
261
|
+
for domain in enabled_domains:
|
|
262
|
+
checked_domain_names.append(domain.value)
|
|
263
|
+
|
|
264
|
+
# Format as AI instructions with domain status
|
|
265
|
+
formatted_result = self.instruction_formatter.format_scan_result(
|
|
266
|
+
all_issues, checked_domains=checked_domain_names
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Add coverage summary if coverage was run
|
|
270
|
+
if context.coverage_result is not None:
|
|
271
|
+
cov = context.coverage_result
|
|
272
|
+
coverage_summary: Dict[str, Any] = {
|
|
273
|
+
"coverage_percentage": round(cov.percentage, 2),
|
|
274
|
+
"threshold": cov.threshold,
|
|
275
|
+
"total_lines": cov.total_lines,
|
|
276
|
+
"covered_lines": cov.covered_lines,
|
|
277
|
+
"missing_lines": cov.missing_lines,
|
|
278
|
+
"passed": cov.passed,
|
|
279
|
+
}
|
|
280
|
+
# Add test statistics if available
|
|
281
|
+
if cov.test_stats is not None:
|
|
282
|
+
ts = cov.test_stats
|
|
283
|
+
coverage_summary["tests"] = {
|
|
284
|
+
"total": ts.total,
|
|
285
|
+
"passed": ts.passed,
|
|
286
|
+
"failed": ts.failed,
|
|
287
|
+
"skipped": ts.skipped,
|
|
288
|
+
"errors": ts.errors,
|
|
289
|
+
"success": ts.success,
|
|
290
|
+
}
|
|
291
|
+
formatted_result["coverage_summary"] = coverage_summary
|
|
292
|
+
|
|
293
|
+
return formatted_result
|
|
294
|
+
|
|
295
|
+
async def check_file(self, file_path: str) -> Dict[str, Any]:
|
|
296
|
+
"""Check a single file.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
file_path: Path to the file (relative to project root).
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Structured scan result for the file.
|
|
303
|
+
"""
|
|
304
|
+
path = self.project_root / file_path
|
|
305
|
+
if not path.exists():
|
|
306
|
+
return {"error": f"File not found: {file_path}"}
|
|
307
|
+
|
|
308
|
+
# Detect language and run appropriate checks
|
|
309
|
+
language = detect_language(path)
|
|
310
|
+
domains = get_domains_for_language(language)
|
|
311
|
+
|
|
312
|
+
return await self.scan(domains, files=[file_path])
|
|
313
|
+
|
|
314
|
+
async def get_fix_instructions(self, issue_id: str) -> Dict[str, Any]:
|
|
315
|
+
"""Get detailed fix instructions for an issue.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
issue_id: The issue identifier.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Detailed fix instructions.
|
|
322
|
+
"""
|
|
323
|
+
issue = self._issue_cache.get(issue_id)
|
|
324
|
+
if not issue:
|
|
325
|
+
return {"error": f"Issue not found: {issue_id}"}
|
|
326
|
+
|
|
327
|
+
return self.instruction_formatter.format_single_issue(issue, detailed=True)
|
|
328
|
+
|
|
329
|
+
async def apply_fix(self, issue_id: str) -> Dict[str, Any]:
|
|
330
|
+
"""Apply auto-fix for an issue.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
issue_id: The issue identifier to fix.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Result of the fix operation.
|
|
337
|
+
"""
|
|
338
|
+
issue = self._issue_cache.get(issue_id)
|
|
339
|
+
if not issue:
|
|
340
|
+
return {"error": f"Issue not found: {issue_id}"}
|
|
341
|
+
|
|
342
|
+
# Only linting issues are auto-fixable
|
|
343
|
+
if issue.domain != ToolDomain.LINTING:
|
|
344
|
+
return {
|
|
345
|
+
"error": "Only linting issues support auto-fix",
|
|
346
|
+
"issue_type": issue.domain.value if issue.domain else "unknown",
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# Run linter in fix mode for the specific file
|
|
350
|
+
if not issue.file_path:
|
|
351
|
+
return {"error": "Issue has no file path for fixing"}
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
# Create stream handler for progress output (writes to stderr)
|
|
355
|
+
stream_handler = CLIStreamHandler(
|
|
356
|
+
output=sys.stderr,
|
|
357
|
+
show_output=True,
|
|
358
|
+
use_rich=False,
|
|
359
|
+
)
|
|
360
|
+
context = self._build_context(
|
|
361
|
+
[ToolDomain.LINTING],
|
|
362
|
+
files=[str(issue.file_path)],
|
|
363
|
+
stream_handler=stream_handler,
|
|
364
|
+
)
|
|
365
|
+
await self._run_linting(context, fix=True)
|
|
366
|
+
return {
|
|
367
|
+
"success": True,
|
|
368
|
+
"message": f"Applied fix for {issue_id}",
|
|
369
|
+
"file": str(issue.file_path),
|
|
370
|
+
}
|
|
371
|
+
except Exception as e:
|
|
372
|
+
return {"error": f"Failed to apply fix: {e}"}
|
|
373
|
+
|
|
374
|
+
async def get_status(self) -> Dict[str, Any]:
|
|
375
|
+
"""Get current LucidScan status and configuration.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Status information.
|
|
379
|
+
"""
|
|
380
|
+
from lucidscan.plugins.scanners import discover_scanner_plugins
|
|
381
|
+
from lucidscan.plugins.linters import discover_linter_plugins
|
|
382
|
+
from lucidscan.plugins.type_checkers import discover_type_checker_plugins
|
|
383
|
+
|
|
384
|
+
scanners = discover_scanner_plugins()
|
|
385
|
+
linters = discover_linter_plugins()
|
|
386
|
+
type_checkers = discover_type_checker_plugins()
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"project_root": str(self.project_root),
|
|
390
|
+
"available_tools": {
|
|
391
|
+
"scanners": list(scanners.keys()),
|
|
392
|
+
"linters": list(linters.keys()),
|
|
393
|
+
"type_checkers": list(type_checkers.keys()),
|
|
394
|
+
},
|
|
395
|
+
"enabled_domains": self.config.get_enabled_domains(),
|
|
396
|
+
"cached_issues": len(self._issue_cache),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async def get_help(self) -> Dict[str, Any]:
|
|
400
|
+
"""Get LucidScan documentation.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Documentation content in markdown format.
|
|
404
|
+
"""
|
|
405
|
+
from lucidscan.cli.commands.help import get_help_content
|
|
406
|
+
|
|
407
|
+
content = get_help_content()
|
|
408
|
+
return {
|
|
409
|
+
"documentation": content,
|
|
410
|
+
"format": "markdown",
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async def validate_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
414
|
+
"""Validate a configuration file.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
config_path: Optional path to config file (relative to project root).
|
|
418
|
+
If not provided, searches for lucidscan.yml in project root.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Structured validation result with valid flag, errors, and warnings.
|
|
422
|
+
"""
|
|
423
|
+
from lucidscan.config.loader import find_project_config
|
|
424
|
+
from lucidscan.config.validation import validate_config_file, ValidationSeverity
|
|
425
|
+
|
|
426
|
+
# Determine config path
|
|
427
|
+
path: Optional[Path]
|
|
428
|
+
if config_path:
|
|
429
|
+
path = self.project_root / config_path
|
|
430
|
+
else:
|
|
431
|
+
path = find_project_config(self.project_root)
|
|
432
|
+
|
|
433
|
+
if path is None:
|
|
434
|
+
return {
|
|
435
|
+
"valid": False,
|
|
436
|
+
"error": "No configuration file found in project root",
|
|
437
|
+
"searched_for": [
|
|
438
|
+
".lucidscan.yml",
|
|
439
|
+
".lucidscan.yaml",
|
|
440
|
+
"lucidscan.yml",
|
|
441
|
+
"lucidscan.yaml",
|
|
442
|
+
],
|
|
443
|
+
"errors": [],
|
|
444
|
+
"warnings": [],
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if not path.exists():
|
|
448
|
+
return {
|
|
449
|
+
"valid": False,
|
|
450
|
+
"error": f"Configuration file not found: {path}",
|
|
451
|
+
"errors": [],
|
|
452
|
+
"warnings": [],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
is_valid, issues = validate_config_file(path)
|
|
456
|
+
|
|
457
|
+
errors = []
|
|
458
|
+
warnings = []
|
|
459
|
+
|
|
460
|
+
for issue in issues:
|
|
461
|
+
issue_dict: Dict[str, Any] = {
|
|
462
|
+
"message": issue.message,
|
|
463
|
+
"key": issue.key,
|
|
464
|
+
}
|
|
465
|
+
if issue.suggestion:
|
|
466
|
+
issue_dict["suggestion"] = issue.suggestion
|
|
467
|
+
|
|
468
|
+
if issue.severity == ValidationSeverity.ERROR:
|
|
469
|
+
errors.append(issue_dict)
|
|
470
|
+
else:
|
|
471
|
+
warnings.append(issue_dict)
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
"valid": is_valid,
|
|
475
|
+
"config_path": str(path),
|
|
476
|
+
"errors": errors,
|
|
477
|
+
"warnings": warnings,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async def autoconfigure(self) -> Dict[str, Any]:
|
|
481
|
+
"""Get instructions for auto-configuring LucidScan.
|
|
482
|
+
|
|
483
|
+
Returns guidance for AI to analyze the codebase, ask the user
|
|
484
|
+
important configuration questions, and generate an appropriate
|
|
485
|
+
lucidscan.yml configuration file.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Instructions and guidance for configuration generation.
|
|
489
|
+
"""
|
|
490
|
+
return {
|
|
491
|
+
"instructions": (
|
|
492
|
+
"Analyze the codebase, ask 1-2 quick questions if needed, "
|
|
493
|
+
"then generate lucidscan.yml with smart defaults."
|
|
494
|
+
),
|
|
495
|
+
"analysis_steps": [
|
|
496
|
+
{
|
|
497
|
+
"step": 1,
|
|
498
|
+
"action": "Detect languages and package managers",
|
|
499
|
+
"files_to_check": [
|
|
500
|
+
"package.json",
|
|
501
|
+
"pyproject.toml",
|
|
502
|
+
"setup.py",
|
|
503
|
+
"requirements.txt",
|
|
504
|
+
"Cargo.toml",
|
|
505
|
+
"go.mod",
|
|
506
|
+
"pom.xml",
|
|
507
|
+
"build.gradle",
|
|
508
|
+
],
|
|
509
|
+
"what_to_look_for": (
|
|
510
|
+
"Presence of these files indicates the primary language(s). "
|
|
511
|
+
"package.json = JavaScript/TypeScript, "
|
|
512
|
+
"pyproject.toml/setup.py/requirements.txt = Python, "
|
|
513
|
+
"Cargo.toml = Rust, go.mod = Go, pom.xml/build.gradle = Java"
|
|
514
|
+
),
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
"step": 2,
|
|
518
|
+
"action": "Detect existing linting/type checking tools",
|
|
519
|
+
"files_to_check": [
|
|
520
|
+
".eslintrc",
|
|
521
|
+
".eslintrc.js",
|
|
522
|
+
".eslintrc.json",
|
|
523
|
+
"eslint.config.js",
|
|
524
|
+
"biome.json",
|
|
525
|
+
"ruff.toml",
|
|
526
|
+
"pyproject.toml (look for [tool.ruff] section)",
|
|
527
|
+
".flake8",
|
|
528
|
+
"tsconfig.json",
|
|
529
|
+
"mypy.ini",
|
|
530
|
+
"pyproject.toml (look for [tool.mypy] section)",
|
|
531
|
+
"pyrightconfig.json",
|
|
532
|
+
],
|
|
533
|
+
"what_to_look_for": (
|
|
534
|
+
"Existing tool configurations to preserve. "
|
|
535
|
+
"If a tool is already configured, use it rather than replacing. "
|
|
536
|
+
"For Python: ruff or flake8 for linting, mypy or pyright for types. "
|
|
537
|
+
"For JS/TS: eslint or biome for linting, tsconfig.json for TypeScript."
|
|
538
|
+
),
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
"step": 3,
|
|
542
|
+
"action": "Detect test frameworks and coverage",
|
|
543
|
+
"files_to_check": [
|
|
544
|
+
"pytest.ini",
|
|
545
|
+
"pyproject.toml (look for [tool.pytest] and [tool.coverage] sections)",
|
|
546
|
+
"conftest.py",
|
|
547
|
+
".coveragerc",
|
|
548
|
+
"jest.config.js",
|
|
549
|
+
"jest.config.ts",
|
|
550
|
+
"karma.conf.js",
|
|
551
|
+
"playwright.config.ts",
|
|
552
|
+
".nycrc",
|
|
553
|
+
".nycrc.json",
|
|
554
|
+
],
|
|
555
|
+
"what_to_look_for": (
|
|
556
|
+
"Test framework configurations and existing coverage settings. "
|
|
557
|
+
"Check if there's an existing coverage threshold defined. "
|
|
558
|
+
"pytest = Python tests, jest = JS/TS tests, "
|
|
559
|
+
"karma = Angular tests, playwright = E2E tests"
|
|
560
|
+
),
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"step": 4,
|
|
564
|
+
"action": "Ask user 1-2 quick questions based on detection",
|
|
565
|
+
"guidance": (
|
|
566
|
+
"If tests detected: ask coverage threshold (suggest 80%). "
|
|
567
|
+
"If large legacy codebase: ask strict vs gradual mode. "
|
|
568
|
+
"Otherwise, use smart defaults and skip questions."
|
|
569
|
+
),
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
"step": 5,
|
|
573
|
+
"action": "Read LucidScan documentation",
|
|
574
|
+
"tool_to_call": "get_help()",
|
|
575
|
+
"what_to_extract": (
|
|
576
|
+
"Read the 'Configuration Reference (lucidscan.yml)' section "
|
|
577
|
+
"to understand the full configuration format, available tools, "
|
|
578
|
+
"and valid options for each domain."
|
|
579
|
+
),
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"step": 6,
|
|
583
|
+
"action": "Generate lucidscan.yml",
|
|
584
|
+
"output_file": "lucidscan.yml",
|
|
585
|
+
"template_guidance": (
|
|
586
|
+
"Based on detected languages/tools AND user answers, create a configuration "
|
|
587
|
+
"that enables appropriate domains. Include: version, project metadata, "
|
|
588
|
+
"pipeline configuration with detected tools, fail_on thresholds, "
|
|
589
|
+
"coverage threshold, and ignore patterns."
|
|
590
|
+
),
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
"step": 7,
|
|
594
|
+
"action": "Validate the generated configuration",
|
|
595
|
+
"tool_to_call": "validate_config()",
|
|
596
|
+
"what_to_do": (
|
|
597
|
+
"After writing lucidscan.yml, call validate_config() to verify "
|
|
598
|
+
"the configuration is valid. If there are errors, fix them before "
|
|
599
|
+
"proceeding. Warnings can be addressed but are not blocking."
|
|
600
|
+
),
|
|
601
|
+
"on_error": (
|
|
602
|
+
"If validation returns errors, edit lucidscan.yml to fix the issues "
|
|
603
|
+
"and call validate_config() again until it passes."
|
|
604
|
+
),
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
"step": 8,
|
|
608
|
+
"action": "Inform user about tool installation and next steps",
|
|
609
|
+
"guidance": (
|
|
610
|
+
"After generating the config, tell the user: "
|
|
611
|
+
"1) Which tools need to be installed (security tools are auto-downloaded), "
|
|
612
|
+
"2) Run 'lucidscan init --claude-code' or '--cursor' for AI integration, "
|
|
613
|
+
"3) Run 'lucidscan scan --all' to verify the configuration works, "
|
|
614
|
+
"4) IMPORTANT: Restart Claude Code or Cursor for the configuration to take effect."
|
|
615
|
+
),
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
"questions_to_ask": {
|
|
619
|
+
"description": (
|
|
620
|
+
"Ask 1-3 quick questions based on codebase. Use smart defaults for the rest."
|
|
621
|
+
),
|
|
622
|
+
"conditional_questions": [
|
|
623
|
+
{
|
|
624
|
+
"id": "coverage_threshold",
|
|
625
|
+
"ask_when": "Tests detected (pytest.ini, jest.config.*, conftest.py, etc.)",
|
|
626
|
+
"question": "What coverage threshold? (80% recommended, or lower for legacy code)",
|
|
627
|
+
"default": 80,
|
|
628
|
+
"skip_if": "No tests detected - disable coverage, inform user they can enable later",
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
"id": "strictness",
|
|
632
|
+
"ask_when": "Large existing codebase with no lucidscan.yml",
|
|
633
|
+
"question": "Strict mode (fail on issues) or gradual adoption (report only)?",
|
|
634
|
+
"options": {
|
|
635
|
+
"strict": "fail_on errors - recommended for new/clean projects",
|
|
636
|
+
"gradual": "report only - recommended for legacy codebases to avoid blocking work",
|
|
637
|
+
},
|
|
638
|
+
"how_to_detect": (
|
|
639
|
+
"If you see many existing linting/type errors when analyzing, "
|
|
640
|
+
"suggest gradual mode. Otherwise, default to strict."
|
|
641
|
+
),
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
"id": "pre_commit_hook",
|
|
645
|
+
"ask_when": "Git repository detected (.git directory exists)",
|
|
646
|
+
"question": "Run LucidScan before every commit? (creates pre-commit hook)",
|
|
647
|
+
"default": True,
|
|
648
|
+
"if_yes": {
|
|
649
|
+
"action": "Create .git/hooks/pre-commit script",
|
|
650
|
+
"script_content": """#!/bin/sh
|
|
651
|
+
# LucidScan pre-commit hook
|
|
652
|
+
# Runs quality checks before allowing commit
|
|
653
|
+
|
|
654
|
+
echo "Running LucidScan checks..."
|
|
655
|
+
lucidscan scan --all
|
|
656
|
+
|
|
657
|
+
if [ $? -ne 0 ]; then
|
|
658
|
+
echo ""
|
|
659
|
+
echo "LucidScan found issues. Fix them before committing."
|
|
660
|
+
echo "To skip this check, use: git commit --no-verify"
|
|
661
|
+
exit 1
|
|
662
|
+
fi
|
|
663
|
+
""",
|
|
664
|
+
"make_executable": "chmod +x .git/hooks/pre-commit",
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
"always_use_defaults": {
|
|
669
|
+
"security": "Always enable security scanning (trivy + opengrep). Fail on 'high' severity.",
|
|
670
|
+
"testing": "Enable if tests detected. Always fail on test failures.",
|
|
671
|
+
"linting": "Enable with detected tool. Use strictness setting for fail_on.",
|
|
672
|
+
"type_checking": "Enable if tool detected. Use strictness setting for fail_on.",
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
"common_pitfalls": [
|
|
676
|
+
"Always add '**/.venv/**' and '**/node_modules/**' to ignore list",
|
|
677
|
+
"For legacy codebases: start with fail_on: none, fix issues gradually",
|
|
678
|
+
"Check current coverage with 'pytest --cov' before setting threshold",
|
|
679
|
+
],
|
|
680
|
+
"tool_recommendations": {
|
|
681
|
+
"python": {
|
|
682
|
+
"linter": "ruff (recommended, fast and comprehensive) or flake8",
|
|
683
|
+
"type_checker": "mypy (recommended, widely used) or pyright",
|
|
684
|
+
"test_runner": "pytest (standard choice)",
|
|
685
|
+
"coverage": "coverage.py (via pytest-cov)",
|
|
686
|
+
},
|
|
687
|
+
"javascript_typescript": {
|
|
688
|
+
"linter": "eslint (most popular) or biome (faster, newer)",
|
|
689
|
+
"type_checker": "typescript (tsc) - enabled via tsconfig.json",
|
|
690
|
+
"test_runner": "jest (most common), karma (Angular), or playwright (E2E)",
|
|
691
|
+
"coverage": "istanbul/nyc (usually included with jest)",
|
|
692
|
+
},
|
|
693
|
+
"java": {
|
|
694
|
+
"linter": "checkstyle",
|
|
695
|
+
"test_runner": "junit (via maven/gradle)",
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
"security_tools": {
|
|
699
|
+
"always_recommended": [
|
|
700
|
+
"trivy (for SCA - dependency vulnerability scanning)",
|
|
701
|
+
"opengrep (for SAST - code pattern security analysis)",
|
|
702
|
+
],
|
|
703
|
+
"optional": [
|
|
704
|
+
"checkov (for IaC scanning - Terraform, Kubernetes, CloudFormation)",
|
|
705
|
+
],
|
|
706
|
+
"note": "Security tools are downloaded automatically - no manual installation needed.",
|
|
707
|
+
},
|
|
708
|
+
"example_config": {
|
|
709
|
+
"description": "Example configurations with common settings",
|
|
710
|
+
"python_with_coverage": """version: 1
|
|
711
|
+
|
|
712
|
+
project:
|
|
713
|
+
name: my-python-project
|
|
714
|
+
languages: [python]
|
|
715
|
+
|
|
716
|
+
pipeline:
|
|
717
|
+
linting:
|
|
718
|
+
enabled: true
|
|
719
|
+
tools: [ruff]
|
|
720
|
+
type_checking:
|
|
721
|
+
enabled: true
|
|
722
|
+
tools: [mypy]
|
|
723
|
+
security:
|
|
724
|
+
enabled: true
|
|
725
|
+
tools:
|
|
726
|
+
- name: trivy
|
|
727
|
+
domains: [sca]
|
|
728
|
+
- name: opengrep
|
|
729
|
+
domains: [sast]
|
|
730
|
+
testing:
|
|
731
|
+
enabled: true
|
|
732
|
+
tools: [pytest]
|
|
733
|
+
coverage:
|
|
734
|
+
enabled: true
|
|
735
|
+
tools: [coverage_py]
|
|
736
|
+
threshold: 80
|
|
737
|
+
|
|
738
|
+
fail_on:
|
|
739
|
+
linting: error
|
|
740
|
+
type_checking: error
|
|
741
|
+
security: high
|
|
742
|
+
testing: any
|
|
743
|
+
coverage: any
|
|
744
|
+
|
|
745
|
+
ignore:
|
|
746
|
+
- "**/.venv/**"
|
|
747
|
+
- "**/__pycache__/**"
|
|
748
|
+
- "**/dist/**"
|
|
749
|
+
- "**/build/**"
|
|
750
|
+
- "**/.git/**"
|
|
751
|
+
""",
|
|
752
|
+
"typescript_with_coverage": """version: 1
|
|
753
|
+
|
|
754
|
+
project:
|
|
755
|
+
name: my-typescript-project
|
|
756
|
+
languages: [typescript]
|
|
757
|
+
|
|
758
|
+
pipeline:
|
|
759
|
+
linting:
|
|
760
|
+
enabled: true
|
|
761
|
+
tools: [eslint]
|
|
762
|
+
type_checking:
|
|
763
|
+
enabled: true
|
|
764
|
+
tools: [typescript]
|
|
765
|
+
security:
|
|
766
|
+
enabled: true
|
|
767
|
+
tools:
|
|
768
|
+
- name: trivy
|
|
769
|
+
domains: [sca]
|
|
770
|
+
- name: opengrep
|
|
771
|
+
domains: [sast]
|
|
772
|
+
testing:
|
|
773
|
+
enabled: true
|
|
774
|
+
tools: [jest]
|
|
775
|
+
coverage:
|
|
776
|
+
enabled: true
|
|
777
|
+
tools: [istanbul]
|
|
778
|
+
threshold: 80
|
|
779
|
+
|
|
780
|
+
fail_on:
|
|
781
|
+
linting: error
|
|
782
|
+
type_checking: error
|
|
783
|
+
security: high
|
|
784
|
+
testing: any
|
|
785
|
+
coverage: any
|
|
786
|
+
|
|
787
|
+
ignore:
|
|
788
|
+
- "**/node_modules/**"
|
|
789
|
+
- "**/dist/**"
|
|
790
|
+
- "**/build/**"
|
|
791
|
+
- "**/coverage/**"
|
|
792
|
+
- "**/.git/**"
|
|
793
|
+
""",
|
|
794
|
+
"gradual_adoption": """# Configuration for gradual adoption (legacy codebase)
|
|
795
|
+
version: 1
|
|
796
|
+
|
|
797
|
+
project:
|
|
798
|
+
name: legacy-project
|
|
799
|
+
languages: [python]
|
|
800
|
+
|
|
801
|
+
pipeline:
|
|
802
|
+
linting:
|
|
803
|
+
enabled: true
|
|
804
|
+
tools: [ruff]
|
|
805
|
+
type_checking:
|
|
806
|
+
enabled: true
|
|
807
|
+
tools: [mypy]
|
|
808
|
+
security:
|
|
809
|
+
enabled: true
|
|
810
|
+
tools:
|
|
811
|
+
- name: trivy
|
|
812
|
+
domains: [sca]
|
|
813
|
+
- name: opengrep
|
|
814
|
+
domains: [sast]
|
|
815
|
+
testing:
|
|
816
|
+
enabled: true
|
|
817
|
+
tools: [pytest]
|
|
818
|
+
coverage:
|
|
819
|
+
enabled: false # Enable later when tests are added
|
|
820
|
+
|
|
821
|
+
# Relaxed thresholds for gradual adoption
|
|
822
|
+
fail_on:
|
|
823
|
+
linting: none # Report only, don't fail
|
|
824
|
+
type_checking: none # Report only, don't fail
|
|
825
|
+
security: critical # Only fail on critical issues
|
|
826
|
+
testing: any
|
|
827
|
+
|
|
828
|
+
ignore:
|
|
829
|
+
- "**/.venv/**"
|
|
830
|
+
- "**/__pycache__/**"
|
|
831
|
+
""",
|
|
832
|
+
},
|
|
833
|
+
"post_config_steps": [
|
|
834
|
+
"Run 'lucidscan init --claude-code' or 'lucidscan init --cursor' to set up AI tool integration",
|
|
835
|
+
"Install required linting/testing tools via package manager (security tools auto-download)",
|
|
836
|
+
"Run 'lucidscan scan --all' to test the configuration and see initial results",
|
|
837
|
+
"If many issues appear, consider starting with relaxed thresholds (see gradual_adoption example)",
|
|
838
|
+
"IMPORTANT: Restart Claude Code or Cursor for the new configuration to take effect",
|
|
839
|
+
],
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
def _parse_domains(self, domains: List[str]) -> List[DomainType]:
|
|
843
|
+
"""Parse domain strings to domain enums.
|
|
844
|
+
|
|
845
|
+
When "all" is specified, returns domains based on what's configured
|
|
846
|
+
in lucidscan.yml. If no config exists, uses sensible defaults.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
domains: List of domain names.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
List of domain enums (ToolDomain or ScanDomain).
|
|
853
|
+
"""
|
|
854
|
+
if "all" in domains:
|
|
855
|
+
result: List[DomainType] = []
|
|
856
|
+
|
|
857
|
+
# Include tool domains based on pipeline config
|
|
858
|
+
# If explicitly configured, respect the enabled flag
|
|
859
|
+
# If not configured (None), enable by default for "all"
|
|
860
|
+
if self.config.pipeline.linting is None or self.config.pipeline.linting.enabled:
|
|
861
|
+
result.append(ToolDomain.LINTING)
|
|
862
|
+
if self.config.pipeline.type_checking is None or self.config.pipeline.type_checking.enabled:
|
|
863
|
+
result.append(ToolDomain.TYPE_CHECKING)
|
|
864
|
+
if self.config.pipeline.testing and self.config.pipeline.testing.enabled:
|
|
865
|
+
result.append(ToolDomain.TESTING)
|
|
866
|
+
if self.config.pipeline.coverage and self.config.pipeline.coverage.enabled:
|
|
867
|
+
result.append(ToolDomain.COVERAGE)
|
|
868
|
+
|
|
869
|
+
# Include security domains based on config (both legacy and pipeline)
|
|
870
|
+
security_domains = self.config.get_enabled_domains()
|
|
871
|
+
if security_domains:
|
|
872
|
+
for domain_str in security_domains:
|
|
873
|
+
try:
|
|
874
|
+
result.append(ScanDomain(domain_str))
|
|
875
|
+
except ValueError:
|
|
876
|
+
LOGGER.warning(f"Unknown security domain in config: {domain_str}")
|
|
877
|
+
else:
|
|
878
|
+
# No security config - use defaults (SCA and SAST)
|
|
879
|
+
result.append(ScanDomain.SCA)
|
|
880
|
+
result.append(ScanDomain.SAST)
|
|
881
|
+
|
|
882
|
+
return result
|
|
883
|
+
|
|
884
|
+
result = []
|
|
885
|
+
for domain in domains:
|
|
886
|
+
domain_lower = domain.lower()
|
|
887
|
+
if domain_lower in self.DOMAIN_MAP:
|
|
888
|
+
result.append(self.DOMAIN_MAP[domain_lower])
|
|
889
|
+
else:
|
|
890
|
+
LOGGER.warning(f"Unknown domain: {domain}")
|
|
891
|
+
|
|
892
|
+
return result
|
|
893
|
+
|
|
894
|
+
def _build_context(
|
|
895
|
+
self,
|
|
896
|
+
domains: List[DomainType],
|
|
897
|
+
files: Optional[List[str]] = None,
|
|
898
|
+
all_files: bool = False,
|
|
899
|
+
stream_handler: Optional[StreamHandler] = None,
|
|
900
|
+
) -> ScanContext:
|
|
901
|
+
"""Build scan context with partial scanning support.
|
|
902
|
+
|
|
903
|
+
Priority:
|
|
904
|
+
1. If `files` is provided, scan only those specific files
|
|
905
|
+
2. If `all_files` is True, scan entire project
|
|
906
|
+
3. Otherwise, scan only changed files (uncommitted changes)
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
domains: Enabled domains.
|
|
910
|
+
files: Optional specific files to scan.
|
|
911
|
+
all_files: If True, scan entire project.
|
|
912
|
+
stream_handler: Optional handler for streaming output.
|
|
913
|
+
|
|
914
|
+
Returns:
|
|
915
|
+
ScanContext instance.
|
|
916
|
+
"""
|
|
917
|
+
# Determine which paths to scan
|
|
918
|
+
paths: List[Path]
|
|
919
|
+
|
|
920
|
+
if files:
|
|
921
|
+
# Explicit files specified - use those
|
|
922
|
+
paths = []
|
|
923
|
+
for f in files:
|
|
924
|
+
file_path = self.project_root / f
|
|
925
|
+
if file_path.exists():
|
|
926
|
+
paths.append(file_path)
|
|
927
|
+
else:
|
|
928
|
+
LOGGER.warning(f"File not found: {f}")
|
|
929
|
+
if paths:
|
|
930
|
+
LOGGER.info(f"Scanning {len(paths)} specified file(s)")
|
|
931
|
+
else:
|
|
932
|
+
LOGGER.warning("No valid files specified, falling back to full scan")
|
|
933
|
+
paths = [self.project_root]
|
|
934
|
+
elif all_files:
|
|
935
|
+
# Explicit full scan requested
|
|
936
|
+
LOGGER.info("Scanning entire project (all_files=true)")
|
|
937
|
+
paths = [self.project_root]
|
|
938
|
+
else:
|
|
939
|
+
# Default: scan only changed files
|
|
940
|
+
changed_files = get_changed_files(self.project_root)
|
|
941
|
+
if changed_files is not None and len(changed_files) > 0:
|
|
942
|
+
LOGGER.info(f"Scanning {len(changed_files)} changed file(s)")
|
|
943
|
+
paths = changed_files
|
|
944
|
+
elif changed_files is not None and len(changed_files) == 0:
|
|
945
|
+
LOGGER.info("No changed files detected, nothing to scan")
|
|
946
|
+
paths = [] # Return empty list - no files to scan
|
|
947
|
+
else:
|
|
948
|
+
# Not a git repo or git command failed
|
|
949
|
+
LOGGER.info("Not a git repository, scanning entire project")
|
|
950
|
+
paths = [self.project_root]
|
|
951
|
+
|
|
952
|
+
return ScanContext(
|
|
953
|
+
project_root=self.project_root,
|
|
954
|
+
paths=paths,
|
|
955
|
+
enabled_domains=domains,
|
|
956
|
+
config=self.config,
|
|
957
|
+
stream_handler=stream_handler,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
async def _run_linting(
|
|
961
|
+
self,
|
|
962
|
+
context: ScanContext,
|
|
963
|
+
fix: bool = False,
|
|
964
|
+
) -> List[UnifiedIssue]:
|
|
965
|
+
"""Run linting checks asynchronously.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
context: Scan context.
|
|
969
|
+
fix: Whether to apply fixes.
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
List of linting issues.
|
|
973
|
+
"""
|
|
974
|
+
loop = asyncio.get_event_loop()
|
|
975
|
+
return await loop.run_in_executor(
|
|
976
|
+
None, self._runner.run_linting, context, fix
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
async def _run_type_checking(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
980
|
+
"""Run type checking asynchronously.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
context: Scan context.
|
|
984
|
+
|
|
985
|
+
Returns:
|
|
986
|
+
List of type checking issues.
|
|
987
|
+
"""
|
|
988
|
+
loop = asyncio.get_event_loop()
|
|
989
|
+
return await loop.run_in_executor(
|
|
990
|
+
None, self._runner.run_type_checking, context
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
async def _run_testing(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
994
|
+
"""Run test suite asynchronously.
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
context: Scan context.
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
List of test failure issues.
|
|
1001
|
+
"""
|
|
1002
|
+
loop = asyncio.get_event_loop()
|
|
1003
|
+
return await loop.run_in_executor(
|
|
1004
|
+
None, self._runner.run_tests, context
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
async def _run_coverage(
|
|
1008
|
+
self,
|
|
1009
|
+
context: ScanContext,
|
|
1010
|
+
run_tests: bool = True,
|
|
1011
|
+
) -> List[UnifiedIssue]:
|
|
1012
|
+
"""Run coverage analysis asynchronously.
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
context: Scan context.
|
|
1016
|
+
run_tests: Whether to run tests for coverage measurement.
|
|
1017
|
+
|
|
1018
|
+
Returns:
|
|
1019
|
+
List of coverage issues.
|
|
1020
|
+
"""
|
|
1021
|
+
loop = asyncio.get_event_loop()
|
|
1022
|
+
# Use functools.partial to pass run_tests parameter
|
|
1023
|
+
run_coverage_fn = functools.partial(
|
|
1024
|
+
self._runner.run_coverage,
|
|
1025
|
+
context,
|
|
1026
|
+
run_tests=run_tests,
|
|
1027
|
+
)
|
|
1028
|
+
issues = await loop.run_in_executor(None, run_coverage_fn)
|
|
1029
|
+
# Coverage result is stored in context.coverage_result by DomainRunner
|
|
1030
|
+
return issues
|
|
1031
|
+
|
|
1032
|
+
async def _run_security(
|
|
1033
|
+
self,
|
|
1034
|
+
context: ScanContext,
|
|
1035
|
+
domain: ScanDomain,
|
|
1036
|
+
) -> List[UnifiedIssue]:
|
|
1037
|
+
"""Run security scanner asynchronously.
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
context: Scan context.
|
|
1041
|
+
domain: Scanner domain (SAST, SCA, IAC, CONTAINER).
|
|
1042
|
+
|
|
1043
|
+
Returns:
|
|
1044
|
+
List of security issues.
|
|
1045
|
+
"""
|
|
1046
|
+
loop = asyncio.get_event_loop()
|
|
1047
|
+
return await loop.run_in_executor(
|
|
1048
|
+
None, self._runner.run_security, context, domain
|
|
1049
|
+
)
|