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,307 @@
|
|
|
1
|
+
"""Scan command implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from argparse import Namespace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from lucidscan.cli.commands import Command
|
|
11
|
+
from lucidscan.cli.config_bridge import ConfigBridge
|
|
12
|
+
from lucidscan.cli.exit_codes import (
|
|
13
|
+
EXIT_ISSUES_FOUND,
|
|
14
|
+
EXIT_SCANNER_ERROR,
|
|
15
|
+
EXIT_SUCCESS,
|
|
16
|
+
)
|
|
17
|
+
from lucidscan.config.ignore import load_ignore_patterns
|
|
18
|
+
from lucidscan.config.models import LucidScanConfig
|
|
19
|
+
from lucidscan.core.domain_runner import DomainRunner, check_severity_threshold
|
|
20
|
+
from lucidscan.core.git import get_changed_files
|
|
21
|
+
from lucidscan.core.logging import get_logger
|
|
22
|
+
from lucidscan.core.models import CoverageSummary, ScanContext, ScanResult, UnifiedIssue
|
|
23
|
+
from lucidscan.core.streaming import CLIStreamHandler, StreamHandler
|
|
24
|
+
from lucidscan.pipeline import PipelineConfig, PipelineExecutor
|
|
25
|
+
from lucidscan.plugins.reporters import get_reporter_plugin
|
|
26
|
+
|
|
27
|
+
LOGGER = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ScanCommand(Command):
|
|
31
|
+
"""Executes security scanning."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, version: str):
|
|
34
|
+
"""Initialize ScanCommand.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
version: Current lucidscan version string.
|
|
38
|
+
"""
|
|
39
|
+
self._version = version
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Command identifier."""
|
|
44
|
+
return "scan"
|
|
45
|
+
|
|
46
|
+
def execute(self, args: Namespace, config: LucidScanConfig | None = None) -> int:
|
|
47
|
+
"""Execute the scan command.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
args: Parsed command-line arguments.
|
|
51
|
+
config: Loaded configuration.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Exit code based on scan results.
|
|
55
|
+
"""
|
|
56
|
+
if config is None:
|
|
57
|
+
LOGGER.error("Configuration is required for scan command")
|
|
58
|
+
return EXIT_SCANNER_ERROR
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = self._run_scan(args, config)
|
|
62
|
+
|
|
63
|
+
# Determine output format: CLI > config > default (json)
|
|
64
|
+
if args.format:
|
|
65
|
+
output_format = args.format
|
|
66
|
+
elif config.output.format:
|
|
67
|
+
output_format = config.output.format
|
|
68
|
+
else:
|
|
69
|
+
output_format = "json"
|
|
70
|
+
|
|
71
|
+
reporter = get_reporter_plugin(output_format)
|
|
72
|
+
if not reporter:
|
|
73
|
+
LOGGER.error(f"Reporter plugin '{output_format}' not found")
|
|
74
|
+
return EXIT_SCANNER_ERROR
|
|
75
|
+
|
|
76
|
+
# Write output to stdout
|
|
77
|
+
reporter.report(result, sys.stdout)
|
|
78
|
+
|
|
79
|
+
# Check severity threshold - CLI overrides config
|
|
80
|
+
# Use get_fail_on_threshold to handle both string and dict formats
|
|
81
|
+
threshold = args.fail_on if args.fail_on else config.get_fail_on_threshold("security")
|
|
82
|
+
if check_severity_threshold(result.issues, threshold):
|
|
83
|
+
return EXIT_ISSUES_FOUND
|
|
84
|
+
|
|
85
|
+
return EXIT_SUCCESS
|
|
86
|
+
|
|
87
|
+
except FileNotFoundError as e:
|
|
88
|
+
LOGGER.error(str(e))
|
|
89
|
+
raise
|
|
90
|
+
except Exception as e:
|
|
91
|
+
LOGGER.error(f"Scan failed: {e}")
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
def _run_scan(
|
|
95
|
+
self, args: Namespace, config: LucidScanConfig
|
|
96
|
+
) -> ScanResult:
|
|
97
|
+
"""Execute the scan based on CLI arguments and config.
|
|
98
|
+
|
|
99
|
+
Uses PipelineExecutor to run the scan pipeline:
|
|
100
|
+
1. Linting (if --linting or --all)
|
|
101
|
+
2. Scanner execution (parallel by default)
|
|
102
|
+
3. Enricher execution (sequential, in configured order)
|
|
103
|
+
4. Result aggregation
|
|
104
|
+
|
|
105
|
+
Partial Scanning (default behavior):
|
|
106
|
+
- If --files is specified, scan only those files
|
|
107
|
+
- If --all-files is specified, scan entire project
|
|
108
|
+
- Otherwise, scan only changed files (uncommitted changes)
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
args: Parsed CLI arguments.
|
|
112
|
+
config: Loaded configuration.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
ScanResult containing all issues found.
|
|
116
|
+
"""
|
|
117
|
+
project_root = Path(args.path).resolve()
|
|
118
|
+
|
|
119
|
+
if not project_root.exists():
|
|
120
|
+
raise FileNotFoundError(f"Path does not exist: {project_root}")
|
|
121
|
+
|
|
122
|
+
enabled_domains = ConfigBridge.get_enabled_domains(config, args)
|
|
123
|
+
|
|
124
|
+
# Load ignore patterns from .lucidscanignore and config
|
|
125
|
+
ignore_patterns = load_ignore_patterns(project_root, config.ignore)
|
|
126
|
+
|
|
127
|
+
# Determine which files to scan (partial scanning logic)
|
|
128
|
+
scan_paths = self._determine_scan_paths(args, project_root)
|
|
129
|
+
|
|
130
|
+
# Create stream handler if streaming is enabled
|
|
131
|
+
stream_handler: Optional[StreamHandler] = None
|
|
132
|
+
stream_enabled = getattr(args, "stream", False) or getattr(args, "verbose", False)
|
|
133
|
+
if stream_enabled:
|
|
134
|
+
stream_handler = CLIStreamHandler(
|
|
135
|
+
output=sys.stderr,
|
|
136
|
+
show_output=True,
|
|
137
|
+
use_rich=False, # Use plain output for better compatibility
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Build scan context
|
|
141
|
+
context = ScanContext(
|
|
142
|
+
project_root=project_root,
|
|
143
|
+
paths=scan_paths,
|
|
144
|
+
enabled_domains=enabled_domains,
|
|
145
|
+
config=config,
|
|
146
|
+
ignore_patterns=ignore_patterns,
|
|
147
|
+
stream_handler=stream_handler,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Create domain runner for executing tool-based scans
|
|
151
|
+
runner = DomainRunner(project_root, config, log_level="info")
|
|
152
|
+
|
|
153
|
+
all_issues: List[UnifiedIssue] = []
|
|
154
|
+
pipeline_result: Optional[ScanResult] = None
|
|
155
|
+
|
|
156
|
+
# Run linting if requested
|
|
157
|
+
linting_enabled = getattr(args, "linting", False) or getattr(args, "all", False)
|
|
158
|
+
fix_enabled = getattr(args, "fix", False)
|
|
159
|
+
|
|
160
|
+
if linting_enabled:
|
|
161
|
+
all_issues.extend(runner.run_linting(context, fix_enabled))
|
|
162
|
+
|
|
163
|
+
# Run type checking if requested
|
|
164
|
+
type_checking_enabled = getattr(args, "type_checking", False) or getattr(
|
|
165
|
+
args, "all", False
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if type_checking_enabled:
|
|
169
|
+
all_issues.extend(runner.run_type_checking(context))
|
|
170
|
+
|
|
171
|
+
# Run tests if requested
|
|
172
|
+
testing_enabled = getattr(args, "testing", False) or getattr(args, "all", False)
|
|
173
|
+
|
|
174
|
+
if testing_enabled:
|
|
175
|
+
all_issues.extend(runner.run_tests(context))
|
|
176
|
+
|
|
177
|
+
# Run coverage if requested
|
|
178
|
+
coverage_enabled = getattr(args, "coverage", False) or getattr(
|
|
179
|
+
args, "all", False
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
coverage_summary: Optional[CoverageSummary] = None
|
|
183
|
+
if coverage_enabled:
|
|
184
|
+
coverage_threshold = getattr(args, "coverage_threshold", None) or 80.0
|
|
185
|
+
# Don't re-run tests if they were already run in the testing domain
|
|
186
|
+
run_tests_for_coverage = not testing_enabled
|
|
187
|
+
all_issues.extend(
|
|
188
|
+
runner.run_coverage(context, coverage_threshold, run_tests_for_coverage)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Build coverage summary from context.coverage_result
|
|
192
|
+
if context.coverage_result is not None:
|
|
193
|
+
cov = context.coverage_result
|
|
194
|
+
coverage_summary = CoverageSummary(
|
|
195
|
+
coverage_percentage=round(cov.percentage, 2),
|
|
196
|
+
threshold=cov.threshold,
|
|
197
|
+
total_lines=cov.total_lines,
|
|
198
|
+
covered_lines=cov.covered_lines,
|
|
199
|
+
missing_lines=cov.missing_lines,
|
|
200
|
+
passed=cov.passed,
|
|
201
|
+
)
|
|
202
|
+
# Add test stats if available
|
|
203
|
+
if cov.test_stats is not None:
|
|
204
|
+
ts = cov.test_stats
|
|
205
|
+
coverage_summary.tests_total = ts.total
|
|
206
|
+
coverage_summary.tests_passed = ts.passed
|
|
207
|
+
coverage_summary.tests_failed = ts.failed
|
|
208
|
+
coverage_summary.tests_skipped = ts.skipped
|
|
209
|
+
coverage_summary.tests_errors = ts.errors
|
|
210
|
+
|
|
211
|
+
# Run security scanning if any domains are enabled
|
|
212
|
+
if enabled_domains:
|
|
213
|
+
# Collect unique scanners needed based on config
|
|
214
|
+
needed_scanners: List[str] = []
|
|
215
|
+
for domain in enabled_domains:
|
|
216
|
+
scanner_name = config.get_plugin_for_domain(domain.value)
|
|
217
|
+
if scanner_name and scanner_name not in needed_scanners:
|
|
218
|
+
needed_scanners.append(scanner_name)
|
|
219
|
+
elif not scanner_name:
|
|
220
|
+
LOGGER.warning(
|
|
221
|
+
f"No scanner plugin configured for domain: {domain.value}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if needed_scanners:
|
|
225
|
+
# Build pipeline configuration
|
|
226
|
+
pipeline_config = PipelineConfig(
|
|
227
|
+
sequential_scanners=getattr(args, "sequential", False),
|
|
228
|
+
max_workers=config.pipeline.max_workers,
|
|
229
|
+
enricher_order=config.pipeline.enrichers,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Execute pipeline
|
|
233
|
+
executor = PipelineExecutor(
|
|
234
|
+
config=config,
|
|
235
|
+
pipeline_config=pipeline_config,
|
|
236
|
+
lucidscan_version=self._version,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
pipeline_result = executor.execute(needed_scanners, context)
|
|
240
|
+
all_issues.extend(pipeline_result.issues)
|
|
241
|
+
|
|
242
|
+
# Build final result
|
|
243
|
+
result = ScanResult(issues=all_issues)
|
|
244
|
+
result.summary = result.compute_summary()
|
|
245
|
+
result.coverage_summary = coverage_summary
|
|
246
|
+
|
|
247
|
+
# Preserve metadata from pipeline execution
|
|
248
|
+
if pipeline_result and pipeline_result.metadata:
|
|
249
|
+
result.metadata = pipeline_result.metadata
|
|
250
|
+
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
def _determine_scan_paths(
|
|
254
|
+
self, args: Namespace, project_root: Path
|
|
255
|
+
) -> List[Path]:
|
|
256
|
+
"""Determine which paths to scan based on CLI arguments.
|
|
257
|
+
|
|
258
|
+
Priority:
|
|
259
|
+
1. --files: Scan only specified files
|
|
260
|
+
2. --all-files: Scan entire project
|
|
261
|
+
3. Default: Scan only changed files (uncommitted changes)
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
args: Parsed CLI arguments.
|
|
265
|
+
project_root: Project root directory.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of paths to scan.
|
|
269
|
+
"""
|
|
270
|
+
# If --files is specified, use those files
|
|
271
|
+
files_arg = getattr(args, "files", None)
|
|
272
|
+
if files_arg:
|
|
273
|
+
paths = []
|
|
274
|
+
for file_path in files_arg:
|
|
275
|
+
path = Path(file_path)
|
|
276
|
+
if not path.is_absolute():
|
|
277
|
+
path = project_root / path
|
|
278
|
+
path = path.resolve()
|
|
279
|
+
if path.exists():
|
|
280
|
+
paths.append(path)
|
|
281
|
+
else:
|
|
282
|
+
LOGGER.warning(f"File not found: {file_path}")
|
|
283
|
+
if paths:
|
|
284
|
+
LOGGER.info(f"Scanning {len(paths)} specified file(s)")
|
|
285
|
+
return paths
|
|
286
|
+
# Fall through to full scan if no valid files
|
|
287
|
+
LOGGER.warning("No valid files specified, falling back to full scan")
|
|
288
|
+
|
|
289
|
+
# If --all-files is specified, scan entire project
|
|
290
|
+
all_files = getattr(args, "all_files", False)
|
|
291
|
+
if all_files:
|
|
292
|
+
LOGGER.info("Scanning entire project (--all-files)")
|
|
293
|
+
return [project_root]
|
|
294
|
+
|
|
295
|
+
# Default: scan only changed files
|
|
296
|
+
changed_files = get_changed_files(project_root)
|
|
297
|
+
if changed_files is not None and len(changed_files) > 0:
|
|
298
|
+
LOGGER.info(f"Scanning {len(changed_files)} changed file(s)")
|
|
299
|
+
return changed_files
|
|
300
|
+
|
|
301
|
+
# Fall back to full scan if no changed files or not a git repo
|
|
302
|
+
if changed_files is not None and len(changed_files) == 0:
|
|
303
|
+
LOGGER.info("No changed files detected, nothing to scan")
|
|
304
|
+
return [] # Return empty list - no files to scan
|
|
305
|
+
else:
|
|
306
|
+
LOGGER.info("Not a git repository, scanning entire project")
|
|
307
|
+
return [project_root]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Serve command implementation.
|
|
2
|
+
|
|
3
|
+
Run LucidScan as an MCP server for AI agents or as a file watcher.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from argparse import Namespace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lucidscan.cli.commands import Command
|
|
13
|
+
from lucidscan.cli.exit_codes import EXIT_SUCCESS, EXIT_SCANNER_ERROR
|
|
14
|
+
from lucidscan.config import LucidScanConfig
|
|
15
|
+
from lucidscan.core.logging import get_logger
|
|
16
|
+
|
|
17
|
+
LOGGER = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServeCommand(Command):
|
|
21
|
+
"""Run LucidScan as a server for AI integration."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, version: str):
|
|
24
|
+
"""Initialize ServeCommand.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
version: Current lucidscan version string.
|
|
28
|
+
"""
|
|
29
|
+
self._version = version
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
"""Command identifier."""
|
|
34
|
+
return "serve"
|
|
35
|
+
|
|
36
|
+
def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
|
|
37
|
+
"""Execute the serve command.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
args: Parsed command-line arguments.
|
|
41
|
+
config: LucidScan configuration.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Exit code.
|
|
45
|
+
"""
|
|
46
|
+
if config is None:
|
|
47
|
+
LOGGER.error("Configuration is required for serve command")
|
|
48
|
+
return EXIT_SCANNER_ERROR
|
|
49
|
+
|
|
50
|
+
project_root = Path(args.path).resolve()
|
|
51
|
+
|
|
52
|
+
if not project_root.is_dir():
|
|
53
|
+
LOGGER.error(f"Not a directory: {project_root}")
|
|
54
|
+
return EXIT_SCANNER_ERROR
|
|
55
|
+
|
|
56
|
+
# Determine mode
|
|
57
|
+
if args.mcp:
|
|
58
|
+
return self._run_mcp_server(args, config, project_root)
|
|
59
|
+
elif args.watch:
|
|
60
|
+
return self._run_file_watcher(args, config, project_root)
|
|
61
|
+
else:
|
|
62
|
+
# Default to MCP mode
|
|
63
|
+
return self._run_mcp_server(args, config, project_root)
|
|
64
|
+
|
|
65
|
+
def _run_mcp_server(
|
|
66
|
+
self,
|
|
67
|
+
args: Namespace,
|
|
68
|
+
config: LucidScanConfig,
|
|
69
|
+
project_root: Path,
|
|
70
|
+
) -> int:
|
|
71
|
+
"""Run LucidScan as an MCP server.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
args: Parsed command-line arguments.
|
|
75
|
+
config: LucidScan configuration.
|
|
76
|
+
project_root: Project root directory.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Exit code.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from lucidscan.mcp.server import LucidScanMCPServer
|
|
83
|
+
|
|
84
|
+
LOGGER.info(f"Starting MCP server for {project_root}")
|
|
85
|
+
server = LucidScanMCPServer(project_root, config)
|
|
86
|
+
asyncio.run(server.run())
|
|
87
|
+
return EXIT_SUCCESS
|
|
88
|
+
except ImportError as e:
|
|
89
|
+
LOGGER.error(f"MCP dependencies not installed: {e}")
|
|
90
|
+
LOGGER.error("Install with: pip install lucidscan[mcp]")
|
|
91
|
+
return EXIT_SCANNER_ERROR
|
|
92
|
+
except Exception as e:
|
|
93
|
+
LOGGER.error(f"MCP server error: {e}")
|
|
94
|
+
return EXIT_SCANNER_ERROR
|
|
95
|
+
|
|
96
|
+
def _run_file_watcher(
|
|
97
|
+
self,
|
|
98
|
+
args: Namespace,
|
|
99
|
+
config: LucidScanConfig,
|
|
100
|
+
project_root: Path,
|
|
101
|
+
) -> int:
|
|
102
|
+
"""Run LucidScan in file watcher mode.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
args: Parsed command-line arguments.
|
|
106
|
+
config: LucidScan configuration.
|
|
107
|
+
project_root: Project root directory.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Exit code.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
from lucidscan.mcp.watcher import LucidScanFileWatcher
|
|
114
|
+
|
|
115
|
+
debounce_ms = getattr(args, "debounce", 1000)
|
|
116
|
+
LOGGER.info(f"Starting file watcher for {project_root}")
|
|
117
|
+
LOGGER.info(f"Debounce: {debounce_ms}ms")
|
|
118
|
+
|
|
119
|
+
watcher = LucidScanFileWatcher(
|
|
120
|
+
project_root=project_root,
|
|
121
|
+
config=config,
|
|
122
|
+
debounce_ms=debounce_ms,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Set up result callback
|
|
126
|
+
def on_result(result):
|
|
127
|
+
"""Print scan results to stdout."""
|
|
128
|
+
import json
|
|
129
|
+
print(json.dumps(result, indent=2))
|
|
130
|
+
|
|
131
|
+
watcher.on_result(on_result)
|
|
132
|
+
asyncio.run(watcher.start())
|
|
133
|
+
return EXIT_SUCCESS
|
|
134
|
+
except ImportError as e:
|
|
135
|
+
LOGGER.error(f"Watcher dependencies not installed: {e}")
|
|
136
|
+
return EXIT_SCANNER_ERROR
|
|
137
|
+
except KeyboardInterrupt:
|
|
138
|
+
LOGGER.info("File watcher stopped")
|
|
139
|
+
return EXIT_SUCCESS
|
|
140
|
+
except Exception as e:
|
|
141
|
+
LOGGER.error(f"File watcher error: {e}")
|
|
142
|
+
return EXIT_SCANNER_ERROR
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Status command implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from lucidscan.config.models import LucidScanConfig
|
|
10
|
+
|
|
11
|
+
from lucidscan.bootstrap.paths import get_lucidscan_home, LucidscanPaths
|
|
12
|
+
from lucidscan.bootstrap.platform import get_platform_info
|
|
13
|
+
from lucidscan.bootstrap.validation import validate_binary, ToolStatus
|
|
14
|
+
from lucidscan.cli.commands import Command
|
|
15
|
+
from lucidscan.cli.exit_codes import EXIT_SUCCESS
|
|
16
|
+
from lucidscan.plugins.scanners import discover_scanner_plugins
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StatusCommand(Command):
|
|
20
|
+
"""Shows scanner plugin status and environment information."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, version: str):
|
|
23
|
+
"""Initialize StatusCommand.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
version: Current lucidscan version string.
|
|
27
|
+
"""
|
|
28
|
+
self._version = version
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
"""Command identifier."""
|
|
33
|
+
return "status"
|
|
34
|
+
|
|
35
|
+
def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
|
|
36
|
+
"""Execute the status command.
|
|
37
|
+
|
|
38
|
+
Displays lucidscan version, platform info, and scanner plugin status.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
args: Parsed command-line arguments.
|
|
42
|
+
config: Optional LucidScan configuration (unused).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Exit code (always 0 for status).
|
|
46
|
+
"""
|
|
47
|
+
# Use current directory as project root
|
|
48
|
+
home = get_lucidscan_home()
|
|
49
|
+
paths = LucidscanPaths(home)
|
|
50
|
+
platform_info = get_platform_info()
|
|
51
|
+
|
|
52
|
+
print(f"lucidscan version: {self._version}")
|
|
53
|
+
print(f"Platform: {platform_info.os}-{platform_info.arch}")
|
|
54
|
+
print(f"Tool cache: {home}/bin/")
|
|
55
|
+
print()
|
|
56
|
+
|
|
57
|
+
# Discover plugins via entry points
|
|
58
|
+
print("Scanner plugins:")
|
|
59
|
+
plugins = discover_scanner_plugins()
|
|
60
|
+
|
|
61
|
+
if plugins:
|
|
62
|
+
for name, plugin_class in sorted(plugins.items()):
|
|
63
|
+
try:
|
|
64
|
+
plugin = plugin_class()
|
|
65
|
+
domains = ", ".join(d.value.upper() for d in plugin.domains)
|
|
66
|
+
binary_dir = paths.plugin_bin_dir(name, plugin.get_version())
|
|
67
|
+
binary_path = binary_dir / name
|
|
68
|
+
|
|
69
|
+
status = validate_binary(binary_path)
|
|
70
|
+
if status == ToolStatus.PRESENT:
|
|
71
|
+
status_str = f"v{plugin.get_version()} installed"
|
|
72
|
+
else:
|
|
73
|
+
status_str = f"v{plugin.get_version()} (not downloaded)"
|
|
74
|
+
|
|
75
|
+
print(f" {name}: {status_str} [{domains}]")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f" {name}: error loading plugin ({e})")
|
|
78
|
+
else:
|
|
79
|
+
print(" No plugins discovered.")
|
|
80
|
+
|
|
81
|
+
print()
|
|
82
|
+
print("Tools are downloaded to .lucidscan/ on first scan.")
|
|
83
|
+
|
|
84
|
+
return EXIT_SUCCESS
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Validate command implementation.
|
|
2
|
+
|
|
3
|
+
Validates lucidscan.yml configuration files and reports issues.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from argparse import Namespace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from lucidscan.config.models import LucidScanConfig
|
|
14
|
+
|
|
15
|
+
from lucidscan.cli.commands import Command
|
|
16
|
+
from lucidscan.cli.exit_codes import EXIT_ISSUES_FOUND, EXIT_INVALID_USAGE, EXIT_SUCCESS
|
|
17
|
+
from lucidscan.config.loader import find_project_config
|
|
18
|
+
from lucidscan.config.validation import (
|
|
19
|
+
ConfigValidationIssue,
|
|
20
|
+
validate_config_file,
|
|
21
|
+
ValidationSeverity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidateCommand(Command):
|
|
26
|
+
"""Validates lucidscan.yml configuration files."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""Command identifier."""
|
|
31
|
+
return "validate"
|
|
32
|
+
|
|
33
|
+
def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
|
|
34
|
+
"""Execute the validate command.
|
|
35
|
+
|
|
36
|
+
Validates a configuration file and reports errors/warnings.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
args: Parsed command-line arguments.
|
|
40
|
+
config: Optional LucidScan configuration (unused).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Exit code: 0 = valid, 1 = has errors, 3 = file not found.
|
|
44
|
+
"""
|
|
45
|
+
# Determine config path
|
|
46
|
+
config_path = getattr(args, "config", None)
|
|
47
|
+
if config_path:
|
|
48
|
+
config_path = Path(config_path)
|
|
49
|
+
else:
|
|
50
|
+
# Find in current directory
|
|
51
|
+
config_path = find_project_config(Path.cwd())
|
|
52
|
+
|
|
53
|
+
if config_path is None:
|
|
54
|
+
print("No configuration file found.")
|
|
55
|
+
print("Looked for: .lucidscan.yml, .lucidscan.yaml, lucidscan.yml, lucidscan.yaml")
|
|
56
|
+
return EXIT_INVALID_USAGE
|
|
57
|
+
|
|
58
|
+
if not config_path.exists():
|
|
59
|
+
print(f"Configuration file not found: {config_path}")
|
|
60
|
+
return EXIT_INVALID_USAGE
|
|
61
|
+
|
|
62
|
+
print(f"Validating {config_path}...")
|
|
63
|
+
|
|
64
|
+
is_valid, issues = validate_config_file(config_path)
|
|
65
|
+
|
|
66
|
+
if not issues:
|
|
67
|
+
print("Configuration is valid.")
|
|
68
|
+
return EXIT_SUCCESS
|
|
69
|
+
|
|
70
|
+
# Group by severity
|
|
71
|
+
errors = [i for i in issues if i.severity == ValidationSeverity.ERROR]
|
|
72
|
+
warnings = [i for i in issues if i.severity == ValidationSeverity.WARNING]
|
|
73
|
+
|
|
74
|
+
# Print errors
|
|
75
|
+
if errors:
|
|
76
|
+
print(f"\nErrors ({len(errors)}):")
|
|
77
|
+
for issue in errors:
|
|
78
|
+
self._print_issue(issue)
|
|
79
|
+
|
|
80
|
+
# Print warnings
|
|
81
|
+
if warnings:
|
|
82
|
+
print(f"\nWarnings ({len(warnings)}):")
|
|
83
|
+
for issue in warnings:
|
|
84
|
+
self._print_issue(issue)
|
|
85
|
+
|
|
86
|
+
if errors:
|
|
87
|
+
print(f"\nConfiguration is invalid ({len(errors)} error(s)).")
|
|
88
|
+
return EXIT_ISSUES_FOUND
|
|
89
|
+
else:
|
|
90
|
+
print(f"\nConfiguration is valid with {len(warnings)} warning(s).")
|
|
91
|
+
return EXIT_SUCCESS
|
|
92
|
+
|
|
93
|
+
def _print_issue(self, issue: ConfigValidationIssue) -> None:
|
|
94
|
+
"""Print a formatted issue.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
issue: The validation issue to print.
|
|
98
|
+
"""
|
|
99
|
+
location = ""
|
|
100
|
+
if issue.key:
|
|
101
|
+
location = f" [{issue.key}]"
|
|
102
|
+
|
|
103
|
+
print(f" - {issue.message}{location}")
|
|
104
|
+
if issue.suggestion:
|
|
105
|
+
print(f" Did you mean '{issue.suggestion}'?")
|