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,411 @@
|
|
|
1
|
+
"""Istanbul/NYC coverage plugin.
|
|
2
|
+
|
|
3
|
+
Istanbul (via NYC) is a JavaScript code coverage tool.
|
|
4
|
+
https://istanbul.js.org/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from lucidscan.core.logging import get_logger
|
|
18
|
+
from lucidscan.core.models import (
|
|
19
|
+
ScanContext,
|
|
20
|
+
Severity,
|
|
21
|
+
ToolDomain,
|
|
22
|
+
UnifiedIssue,
|
|
23
|
+
)
|
|
24
|
+
from lucidscan.plugins.coverage.base import (
|
|
25
|
+
CoveragePlugin,
|
|
26
|
+
CoverageResult,
|
|
27
|
+
FileCoverage,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
LOGGER = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IstanbulPlugin(CoveragePlugin):
|
|
34
|
+
"""Istanbul/NYC coverage plugin for JavaScript/TypeScript coverage analysis."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
37
|
+
"""Initialize IstanbulPlugin.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
project_root: Optional project root for finding NYC installation.
|
|
41
|
+
"""
|
|
42
|
+
self._project_root = project_root
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
"""Plugin identifier."""
|
|
47
|
+
return "istanbul"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def languages(self) -> List[str]:
|
|
51
|
+
"""Supported languages."""
|
|
52
|
+
return ["javascript", "typescript"]
|
|
53
|
+
|
|
54
|
+
def get_version(self) -> str:
|
|
55
|
+
"""Get NYC version.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Version string or 'unknown' if unable to determine.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
binary = self.ensure_binary()
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
[str(binary), "--version"],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
errors="replace",
|
|
68
|
+
)
|
|
69
|
+
# Output is just the version number like "15.1.0"
|
|
70
|
+
if result.returncode == 0:
|
|
71
|
+
return result.stdout.strip()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return "unknown"
|
|
75
|
+
|
|
76
|
+
def ensure_binary(self) -> Path:
|
|
77
|
+
"""Ensure NYC is available.
|
|
78
|
+
|
|
79
|
+
Checks for NYC in:
|
|
80
|
+
1. Project's node_modules/.bin/nyc
|
|
81
|
+
2. System PATH (globally installed)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Path to NYC binary.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FileNotFoundError: If NYC is not installed.
|
|
88
|
+
"""
|
|
89
|
+
# Check project node_modules first
|
|
90
|
+
if self._project_root:
|
|
91
|
+
node_nyc = self._project_root / "node_modules" / ".bin" / "nyc"
|
|
92
|
+
if node_nyc.exists():
|
|
93
|
+
return node_nyc
|
|
94
|
+
|
|
95
|
+
# Check system PATH
|
|
96
|
+
nyc_path = shutil.which("nyc")
|
|
97
|
+
if nyc_path:
|
|
98
|
+
return Path(nyc_path)
|
|
99
|
+
|
|
100
|
+
raise FileNotFoundError(
|
|
101
|
+
"NYC (Istanbul) is not installed. Install it with:\n"
|
|
102
|
+
" npm install nyc --save-dev\n"
|
|
103
|
+
" OR\n"
|
|
104
|
+
" npm install -g nyc"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def measure_coverage(
|
|
108
|
+
self,
|
|
109
|
+
context: ScanContext,
|
|
110
|
+
threshold: float = 80.0,
|
|
111
|
+
run_tests: bool = True,
|
|
112
|
+
) -> CoverageResult:
|
|
113
|
+
"""Run coverage analysis on the specified paths.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
context: Scan context with paths and configuration.
|
|
117
|
+
threshold: Coverage percentage threshold (default 80%).
|
|
118
|
+
run_tests: Whether to run tests if no existing coverage data exists.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
CoverageResult with coverage statistics and issues if below threshold.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
binary = self.ensure_binary()
|
|
125
|
+
except FileNotFoundError as e:
|
|
126
|
+
LOGGER.warning(str(e))
|
|
127
|
+
return CoverageResult(threshold=threshold)
|
|
128
|
+
|
|
129
|
+
# Always run tests fresh when run_tests=True to ensure accurate coverage
|
|
130
|
+
if run_tests:
|
|
131
|
+
LOGGER.info("Running tests with coverage...")
|
|
132
|
+
if not self._run_tests_with_coverage(binary, context):
|
|
133
|
+
LOGGER.warning("Failed to run tests with coverage")
|
|
134
|
+
return CoverageResult(threshold=threshold)
|
|
135
|
+
|
|
136
|
+
# Generate JSON report from coverage data
|
|
137
|
+
result = self._generate_and_parse_report(binary, context, threshold)
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
def _run_tests_with_coverage(
|
|
142
|
+
self,
|
|
143
|
+
binary: Path,
|
|
144
|
+
context: ScanContext,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Run tests with NYC coverage.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
binary: Path to NYC binary.
|
|
150
|
+
context: Scan context.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if tests ran successfully.
|
|
154
|
+
"""
|
|
155
|
+
# Check for jest or npm test
|
|
156
|
+
jest_path = None
|
|
157
|
+
if self._project_root:
|
|
158
|
+
node_jest = self._project_root / "node_modules" / ".bin" / "jest"
|
|
159
|
+
if node_jest.exists():
|
|
160
|
+
jest_path = node_jest
|
|
161
|
+
|
|
162
|
+
if not jest_path:
|
|
163
|
+
jest_which = shutil.which("jest")
|
|
164
|
+
if jest_which:
|
|
165
|
+
jest_path = Path(jest_which)
|
|
166
|
+
|
|
167
|
+
if jest_path:
|
|
168
|
+
# Run nyc jest
|
|
169
|
+
cmd = [str(binary), str(jest_path), "--passWithNoTests"]
|
|
170
|
+
else:
|
|
171
|
+
# Fall back to npm test
|
|
172
|
+
cmd = [str(binary), "npm", "test"]
|
|
173
|
+
|
|
174
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
subprocess.run(
|
|
178
|
+
cmd,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
encoding="utf-8",
|
|
182
|
+
errors="replace",
|
|
183
|
+
cwd=str(context.project_root),
|
|
184
|
+
)
|
|
185
|
+
return True
|
|
186
|
+
except Exception as e:
|
|
187
|
+
LOGGER.error(f"Failed to run tests with coverage: {e}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def _generate_and_parse_report(
|
|
191
|
+
self,
|
|
192
|
+
binary: Path,
|
|
193
|
+
context: ScanContext,
|
|
194
|
+
threshold: float,
|
|
195
|
+
) -> CoverageResult:
|
|
196
|
+
"""Generate JSON report and parse it.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
binary: Path to NYC binary.
|
|
200
|
+
context: Scan context.
|
|
201
|
+
threshold: Coverage percentage threshold.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
CoverageResult with parsed data.
|
|
205
|
+
"""
|
|
206
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
207
|
+
report_dir = Path(tmpdir)
|
|
208
|
+
|
|
209
|
+
cmd = [
|
|
210
|
+
str(binary),
|
|
211
|
+
"report",
|
|
212
|
+
"--reporter=json-summary",
|
|
213
|
+
f"--report-dir={report_dir}",
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
result = subprocess.run(
|
|
220
|
+
cmd,
|
|
221
|
+
capture_output=True,
|
|
222
|
+
text=True,
|
|
223
|
+
encoding="utf-8",
|
|
224
|
+
errors="replace",
|
|
225
|
+
cwd=str(context.project_root),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if result.returncode != 0:
|
|
229
|
+
LOGGER.warning(f"NYC report failed: {result.stderr}")
|
|
230
|
+
return CoverageResult(threshold=threshold)
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
LOGGER.error(f"Failed to generate coverage report: {e}")
|
|
234
|
+
return CoverageResult(threshold=threshold)
|
|
235
|
+
|
|
236
|
+
# Parse JSON report
|
|
237
|
+
report_file = report_dir / "coverage-summary.json"
|
|
238
|
+
if report_file.exists():
|
|
239
|
+
return self._parse_json_report(report_file, context.project_root, threshold)
|
|
240
|
+
else:
|
|
241
|
+
LOGGER.warning("Coverage JSON report not generated")
|
|
242
|
+
return CoverageResult(threshold=threshold)
|
|
243
|
+
|
|
244
|
+
def _parse_json_report(
|
|
245
|
+
self,
|
|
246
|
+
report_file: Path,
|
|
247
|
+
project_root: Path,
|
|
248
|
+
threshold: float,
|
|
249
|
+
) -> CoverageResult:
|
|
250
|
+
"""Parse Istanbul JSON summary report.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
report_file: Path to JSON report file.
|
|
254
|
+
project_root: Project root directory.
|
|
255
|
+
threshold: Coverage percentage threshold.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
CoverageResult with parsed data.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
with open(report_file) as f:
|
|
262
|
+
report = json.load(f)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
LOGGER.error(f"Failed to parse Istanbul JSON report: {e}")
|
|
265
|
+
return CoverageResult(threshold=threshold)
|
|
266
|
+
|
|
267
|
+
# Get total statistics
|
|
268
|
+
total = report.get("total", {})
|
|
269
|
+
lines = total.get("lines", {})
|
|
270
|
+
statements = total.get("statements", {})
|
|
271
|
+
branches = total.get("branches", {})
|
|
272
|
+
functions = total.get("functions", {})
|
|
273
|
+
|
|
274
|
+
# Calculate overall coverage (use lines as primary metric)
|
|
275
|
+
total_lines = lines.get("total", 0)
|
|
276
|
+
covered_lines = lines.get("covered", 0)
|
|
277
|
+
percent_covered = lines.get("pct", 0.0)
|
|
278
|
+
|
|
279
|
+
result = CoverageResult(
|
|
280
|
+
total_lines=total_lines,
|
|
281
|
+
covered_lines=covered_lines,
|
|
282
|
+
missing_lines=total_lines - covered_lines,
|
|
283
|
+
excluded_lines=0,
|
|
284
|
+
threshold=threshold,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Parse per-file coverage (all keys except "total")
|
|
288
|
+
for file_path, file_data in report.items():
|
|
289
|
+
if file_path == "total":
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
file_lines = file_data.get("lines", {})
|
|
293
|
+
file_total = file_lines.get("total", 0)
|
|
294
|
+
file_covered = file_lines.get("covered", 0)
|
|
295
|
+
|
|
296
|
+
file_coverage = FileCoverage(
|
|
297
|
+
file_path=project_root / file_path,
|
|
298
|
+
total_lines=file_total,
|
|
299
|
+
covered_lines=file_covered,
|
|
300
|
+
missing_lines=[], # Istanbul doesn't provide specific line numbers in summary
|
|
301
|
+
excluded_lines=0,
|
|
302
|
+
)
|
|
303
|
+
result.files[file_path] = file_coverage
|
|
304
|
+
|
|
305
|
+
# Generate issue if below threshold
|
|
306
|
+
if percent_covered < threshold:
|
|
307
|
+
issue = self._create_coverage_issue(
|
|
308
|
+
percent_covered,
|
|
309
|
+
threshold,
|
|
310
|
+
total_lines,
|
|
311
|
+
covered_lines,
|
|
312
|
+
total_lines - covered_lines,
|
|
313
|
+
statements,
|
|
314
|
+
branches,
|
|
315
|
+
functions,
|
|
316
|
+
)
|
|
317
|
+
result.issues.append(issue)
|
|
318
|
+
|
|
319
|
+
LOGGER.info(
|
|
320
|
+
f"Coverage: {percent_covered:.1f}% ({covered_lines}/{total_lines} lines) "
|
|
321
|
+
f"- threshold: {threshold}%"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
def _create_coverage_issue(
|
|
327
|
+
self,
|
|
328
|
+
percentage: float,
|
|
329
|
+
threshold: float,
|
|
330
|
+
total_lines: int,
|
|
331
|
+
covered_lines: int,
|
|
332
|
+
missing_lines: int,
|
|
333
|
+
statements: Dict[str, Any],
|
|
334
|
+
branches: Dict[str, Any],
|
|
335
|
+
functions: Dict[str, Any],
|
|
336
|
+
) -> UnifiedIssue:
|
|
337
|
+
"""Create a UnifiedIssue for coverage below threshold.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
percentage: Actual coverage percentage.
|
|
341
|
+
threshold: Required coverage threshold.
|
|
342
|
+
total_lines: Total number of lines.
|
|
343
|
+
covered_lines: Number of covered lines.
|
|
344
|
+
missing_lines: Number of missing lines.
|
|
345
|
+
statements: Statement coverage data.
|
|
346
|
+
branches: Branch coverage data.
|
|
347
|
+
functions: Function coverage data.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
UnifiedIssue for coverage failure.
|
|
351
|
+
"""
|
|
352
|
+
# Determine severity based on how far below threshold
|
|
353
|
+
if percentage < 50:
|
|
354
|
+
severity = Severity.HIGH
|
|
355
|
+
elif percentage < threshold - 10:
|
|
356
|
+
severity = Severity.MEDIUM
|
|
357
|
+
else:
|
|
358
|
+
severity = Severity.LOW
|
|
359
|
+
|
|
360
|
+
# Generate deterministic ID
|
|
361
|
+
issue_id = self._generate_issue_id(percentage, threshold)
|
|
362
|
+
|
|
363
|
+
gap = threshold - percentage
|
|
364
|
+
|
|
365
|
+
return UnifiedIssue(
|
|
366
|
+
id=issue_id,
|
|
367
|
+
domain=ToolDomain.COVERAGE,
|
|
368
|
+
source_tool="istanbul",
|
|
369
|
+
severity=severity,
|
|
370
|
+
rule_id="coverage_below_threshold",
|
|
371
|
+
title=f"Coverage {percentage:.1f}% is below threshold {threshold}%",
|
|
372
|
+
description=(
|
|
373
|
+
f"Project coverage is {percentage:.1f}%, which is {gap:.1f}% below "
|
|
374
|
+
f"the required threshold of {threshold}%. "
|
|
375
|
+
f"Lines: {covered_lines}/{total_lines} ({percentage:.1f}%), "
|
|
376
|
+
f"Statements: {statements.get('covered', 0)}/{statements.get('total', 0)} ({statements.get('pct', 0):.1f}%), "
|
|
377
|
+
f"Branches: {branches.get('covered', 0)}/{branches.get('total', 0)} ({branches.get('pct', 0):.1f}%), "
|
|
378
|
+
f"Functions: {functions.get('covered', 0)}/{functions.get('total', 0)} ({functions.get('pct', 0):.1f}%)"
|
|
379
|
+
),
|
|
380
|
+
recommendation=f"Add tests to cover at least {gap:.1f}% more of the codebase.",
|
|
381
|
+
file_path=None, # Project-level issue
|
|
382
|
+
line_start=None,
|
|
383
|
+
line_end=None,
|
|
384
|
+
fixable=False,
|
|
385
|
+
metadata={
|
|
386
|
+
"coverage_percentage": round(percentage, 2),
|
|
387
|
+
"threshold": threshold,
|
|
388
|
+
"total_lines": total_lines,
|
|
389
|
+
"covered_lines": covered_lines,
|
|
390
|
+
"missing_lines": missing_lines,
|
|
391
|
+
"gap_percentage": round(gap, 2),
|
|
392
|
+
"statements": statements,
|
|
393
|
+
"branches": branches,
|
|
394
|
+
"functions": functions,
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _generate_issue_id(self, percentage: float, threshold: float) -> str:
|
|
399
|
+
"""Generate deterministic issue ID.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
percentage: Coverage percentage.
|
|
403
|
+
threshold: Coverage threshold.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Unique issue ID.
|
|
407
|
+
"""
|
|
408
|
+
# ID based on rounded percentage and threshold for stability
|
|
409
|
+
content = f"istanbul:{round(percentage)}:{threshold}"
|
|
410
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
411
|
+
return f"istanbul-{hash_val}"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Plugin discovery via Python entry points.
|
|
2
|
+
|
|
3
|
+
Supports discovering different plugin types:
|
|
4
|
+
- Scanner plugins: lucidscan.scanners
|
|
5
|
+
- Enricher plugins: lucidscan.enrichers (future)
|
|
6
|
+
- Reporter plugins: lucidscan.reporters (future)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import entry_points
|
|
12
|
+
from typing import Dict, List, Type, TypeVar
|
|
13
|
+
|
|
14
|
+
from lucidscan.core.logging import get_logger
|
|
15
|
+
|
|
16
|
+
LOGGER = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Entry point group names for different plugin types
|
|
19
|
+
SCANNER_ENTRY_POINT_GROUP = "lucidscan.scanners"
|
|
20
|
+
ENRICHER_ENTRY_POINT_GROUP = "lucidscan.enrichers"
|
|
21
|
+
REPORTER_ENTRY_POINT_GROUP = "lucidscan.reporters"
|
|
22
|
+
|
|
23
|
+
# New plugin groups for v0.2+ quality pipeline
|
|
24
|
+
LINTER_ENTRY_POINT_GROUP = "lucidscan.linters"
|
|
25
|
+
TYPE_CHECKER_ENTRY_POINT_GROUP = "lucidscan.type_checkers"
|
|
26
|
+
TEST_RUNNER_ENTRY_POINT_GROUP = "lucidscan.test_runners"
|
|
27
|
+
COVERAGE_ENTRY_POINT_GROUP = "lucidscan.coverage"
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def discover_plugins(group: str, base_class: Type[T] | None = None) -> Dict[str, Type[T]]:
|
|
33
|
+
"""Discover all installed plugins for a given entry point group.
|
|
34
|
+
|
|
35
|
+
Plugins register themselves in their pyproject.toml:
|
|
36
|
+
|
|
37
|
+
[project.entry-points."lucidscan.scanners"]
|
|
38
|
+
trivy = "lucidscan.scanners.trivy:TrivyScanner"
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
group: Entry point group name (e.g., 'lucidscan.scanners').
|
|
42
|
+
base_class: Optional base class to validate plugins against.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dictionary mapping plugin names to plugin classes.
|
|
46
|
+
"""
|
|
47
|
+
plugins: Dict[str, Type[T]] = {}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
eps = entry_points(group=group)
|
|
51
|
+
except TypeError:
|
|
52
|
+
# Python 3.9 compatibility
|
|
53
|
+
all_eps = entry_points()
|
|
54
|
+
eps = getattr(all_eps, group, []) # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
for ep in eps:
|
|
57
|
+
try:
|
|
58
|
+
plugin_class = ep.load()
|
|
59
|
+
if base_class is not None and not issubclass(plugin_class, base_class):
|
|
60
|
+
LOGGER.warning(
|
|
61
|
+
f"Plugin '{ep.name}' does not inherit from {base_class.__name__}, skipping"
|
|
62
|
+
)
|
|
63
|
+
continue
|
|
64
|
+
plugins[ep.name] = plugin_class
|
|
65
|
+
LOGGER.debug(f"Discovered plugin: {ep.name} (group: {group})")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
LOGGER.warning(f"Failed to load plugin '{ep.name}': {e}")
|
|
68
|
+
|
|
69
|
+
return plugins
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_plugin(
|
|
73
|
+
group: str,
|
|
74
|
+
name: str,
|
|
75
|
+
base_class: Type[T] | None = None,
|
|
76
|
+
**kwargs,
|
|
77
|
+
) -> T | None:
|
|
78
|
+
"""Get an instantiated plugin by name.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
group: Entry point group name.
|
|
82
|
+
name: Plugin name (e.g., 'trivy').
|
|
83
|
+
base_class: Optional base class to validate against.
|
|
84
|
+
**kwargs: Additional arguments to pass to the plugin constructor.
|
|
85
|
+
Common kwargs include:
|
|
86
|
+
- project_root: Path to project root for tool installation.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Instantiated plugin or None if not found.
|
|
90
|
+
"""
|
|
91
|
+
plugins = discover_plugins(group, base_class)
|
|
92
|
+
plugin_class = plugins.get(name)
|
|
93
|
+
if plugin_class:
|
|
94
|
+
return plugin_class(**kwargs)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_available_plugins(group: str) -> List[str]:
|
|
99
|
+
"""List names of all available plugins in a group.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
group: Entry point group name.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of plugin names.
|
|
106
|
+
"""
|
|
107
|
+
return list(discover_plugins(group).keys())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Enricher plugins for lucidscan post-processing.
|
|
2
|
+
|
|
3
|
+
Enricher plugins process scan results after scanner execution,
|
|
4
|
+
adding context, metadata, or performing transformations like
|
|
5
|
+
deduplication or AI-powered explanations.
|
|
6
|
+
|
|
7
|
+
Plugins are discovered via Python entry points (lucidscan.enrichers group).
|
|
8
|
+
|
|
9
|
+
Example registration in pyproject.toml:
|
|
10
|
+
[project.entry-points."lucidscan.enrichers"]
|
|
11
|
+
dedup = "lucidscan_dedup:DedupEnricher"
|
|
12
|
+
epss = "lucidscan_epss:EPSSEnricher"
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Dict, List, Optional, Type
|
|
16
|
+
|
|
17
|
+
from lucidscan.plugins.enrichers.base import EnricherPlugin
|
|
18
|
+
from lucidscan.plugins.discovery import (
|
|
19
|
+
ENRICHER_ENTRY_POINT_GROUP,
|
|
20
|
+
discover_plugins,
|
|
21
|
+
get_plugin,
|
|
22
|
+
list_available_plugins as _list_plugins,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover_enricher_plugins() -> Dict[str, Type[EnricherPlugin]]:
|
|
27
|
+
"""Discover all installed enricher plugins via entry points.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Dictionary mapping plugin names to plugin classes.
|
|
31
|
+
"""
|
|
32
|
+
return discover_plugins(ENRICHER_ENTRY_POINT_GROUP, EnricherPlugin)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_enricher_plugin(name: str) -> Optional[EnricherPlugin]:
|
|
36
|
+
"""Get an instantiated enricher plugin by name.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
name: Plugin name (e.g., 'dedup', 'epss').
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Instantiated EnricherPlugin or None if not found.
|
|
43
|
+
"""
|
|
44
|
+
return get_plugin(ENRICHER_ENTRY_POINT_GROUP, name, EnricherPlugin)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_available_enrichers() -> List[str]:
|
|
48
|
+
"""List names of all available enricher plugins.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of enricher plugin names.
|
|
52
|
+
"""
|
|
53
|
+
return _list_plugins(ENRICHER_ENTRY_POINT_GROUP)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"EnricherPlugin",
|
|
58
|
+
"discover_enricher_plugins",
|
|
59
|
+
"get_enricher_plugin",
|
|
60
|
+
"list_available_enrichers",
|
|
61
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base class for enricher plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, List
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from lucidscan.core.models import ScanContext, UnifiedIssue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnricherPlugin(ABC):
|
|
13
|
+
"""Base class for all enricher plugins.
|
|
14
|
+
|
|
15
|
+
Enricher plugins process issues after scanning, adding additional
|
|
16
|
+
context, metadata, or performing transformations. They run sequentially
|
|
17
|
+
in the configured order, with each enricher receiving the output of
|
|
18
|
+
the previous one.
|
|
19
|
+
|
|
20
|
+
Example enrichers:
|
|
21
|
+
- Deduplication: Remove duplicate issues across scanners
|
|
22
|
+
- EPSS scoring: Add exploit prediction scores
|
|
23
|
+
- KEV tagging: Mark known exploited vulnerabilities
|
|
24
|
+
- AI explanation: Add LLM-generated explanations
|
|
25
|
+
|
|
26
|
+
Enricher constraints:
|
|
27
|
+
- Enrichers MUST NOT modify severity levels set by scanners
|
|
28
|
+
- Enrichers MUST NOT affect exit codes (that's the CLI's responsibility)
|
|
29
|
+
- Enrichers MAY filter, augment, or reorder issues
|
|
30
|
+
- Enrichers SHOULD preserve scanner_metadata from original issues
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
"""Enricher identifier (e.g., 'dedup', 'epss', 'kev').
|
|
37
|
+
|
|
38
|
+
This name is used for:
|
|
39
|
+
- Plugin discovery via entry points
|
|
40
|
+
- Configuration in .lucidscan.yml
|
|
41
|
+
- Logging and error messages
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def enrich(
|
|
46
|
+
self,
|
|
47
|
+
issues: List["UnifiedIssue"],
|
|
48
|
+
context: "ScanContext",
|
|
49
|
+
) -> List["UnifiedIssue"]:
|
|
50
|
+
"""Process and enrich issues.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
issues: List of issues from scanner execution (or previous enricher).
|
|
54
|
+
context: Scan context with project info and configuration.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Enriched list of issues. May be modified, filtered, augmented,
|
|
58
|
+
or returned unchanged.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
Any exception raised will be logged and the enricher skipped,
|
|
62
|
+
with the pipeline continuing with unenriched issues.
|
|
63
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Linter plugins for lucidscan.
|
|
2
|
+
|
|
3
|
+
This module provides linter integrations for the quality pipeline.
|
|
4
|
+
Linters are discovered via the lucidscan.linters entry point group.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lucidscan.plugins.linters.base import LinterPlugin
|
|
8
|
+
from lucidscan.plugins.discovery import (
|
|
9
|
+
discover_plugins,
|
|
10
|
+
LINTER_ENTRY_POINT_GROUP,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def discover_linter_plugins():
|
|
15
|
+
"""Discover all installed linter plugins.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dictionary mapping plugin names to plugin classes.
|
|
19
|
+
"""
|
|
20
|
+
return discover_plugins(LINTER_ENTRY_POINT_GROUP, LinterPlugin)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LinterPlugin",
|
|
25
|
+
"discover_linter_plugins",
|
|
26
|
+
]
|