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,303 @@
|
|
|
1
|
+
"""SARIF reporter plugin for IDE integration.
|
|
2
|
+
|
|
3
|
+
Outputs scan results in SARIF 2.1.0 format, compatible with:
|
|
4
|
+
- GitHub Security tab (Code Scanning)
|
|
5
|
+
- VS Code SARIF Viewer extension
|
|
6
|
+
- Other SARIF-compatible tools
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Dict, IO, List, Optional
|
|
13
|
+
|
|
14
|
+
from lucidscan.plugins.reporters.base import ReporterPlugin
|
|
15
|
+
from lucidscan.core.models import ScanResult, Severity, UnifiedIssue
|
|
16
|
+
|
|
17
|
+
# SARIF schema URL
|
|
18
|
+
SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
19
|
+
SARIF_VERSION = "2.1.0"
|
|
20
|
+
|
|
21
|
+
# Project information
|
|
22
|
+
LUCIDSCAN_INFO_URI = "https://github.com/voldeq/lucidscan"
|
|
23
|
+
|
|
24
|
+
# Severity mapping to SARIF security-severity (CVSS-aligned 0.0-10.0)
|
|
25
|
+
# and level (error, warning, note)
|
|
26
|
+
SEVERITY_MAP: Dict[Severity, Dict[str, Any]] = {
|
|
27
|
+
Severity.CRITICAL: {"security-severity": "9.5", "level": "error"},
|
|
28
|
+
Severity.HIGH: {"security-severity": "7.5", "level": "error"},
|
|
29
|
+
Severity.MEDIUM: {"security-severity": "5.5", "level": "warning"},
|
|
30
|
+
Severity.LOW: {"security-severity": "2.5", "level": "warning"},
|
|
31
|
+
Severity.INFO: {"security-severity": "0.0", "level": "note"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SARIFReporter(ReporterPlugin):
|
|
36
|
+
"""Reporter plugin for SARIF 2.1.0 output format.
|
|
37
|
+
|
|
38
|
+
Produces SARIF JSON suitable for upload to GitHub Code Scanning
|
|
39
|
+
or viewing in VS Code with the SARIF Viewer extension.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def name(self) -> str:
|
|
44
|
+
return "sarif"
|
|
45
|
+
|
|
46
|
+
def report(self, result: ScanResult, output: IO[str]) -> None:
|
|
47
|
+
"""Format and write the scan result as SARIF JSON.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
result: The aggregated scan result to format.
|
|
51
|
+
output: Output stream to write the formatted result.
|
|
52
|
+
"""
|
|
53
|
+
sarif_doc = self._build_sarif(result)
|
|
54
|
+
json.dump(sarif_doc, output, indent=2)
|
|
55
|
+
output.write("\n")
|
|
56
|
+
|
|
57
|
+
def _build_sarif(self, result: ScanResult) -> Dict[str, Any]:
|
|
58
|
+
"""Build the complete SARIF document.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
result: Scan result containing issues and metadata.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
SARIF document as a dictionary.
|
|
65
|
+
"""
|
|
66
|
+
# Get lucidscan version from metadata or use default
|
|
67
|
+
version = "unknown"
|
|
68
|
+
if result.metadata:
|
|
69
|
+
version = result.metadata.lucidscan_version
|
|
70
|
+
|
|
71
|
+
# Collect unique rules from all issues
|
|
72
|
+
rules = self._collect_rules(result.issues)
|
|
73
|
+
|
|
74
|
+
# Convert issues to SARIF results
|
|
75
|
+
results = [
|
|
76
|
+
self._issue_to_result(issue)
|
|
77
|
+
for issue in result.issues
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"$schema": SARIF_SCHEMA,
|
|
82
|
+
"version": SARIF_VERSION,
|
|
83
|
+
"runs": [
|
|
84
|
+
{
|
|
85
|
+
"tool": {
|
|
86
|
+
"driver": {
|
|
87
|
+
"name": "lucidscan",
|
|
88
|
+
"version": version,
|
|
89
|
+
"informationUri": LUCIDSCAN_INFO_URI,
|
|
90
|
+
"rules": rules,
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"results": results,
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def _collect_rules(self, issues: List[UnifiedIssue]) -> List[Dict[str, Any]]:
|
|
99
|
+
"""Collect unique rules from issues.
|
|
100
|
+
|
|
101
|
+
Each unique rule ID becomes a SARIF rule definition.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
issues: List of unified issues to extract rules from.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of SARIF rule definitions.
|
|
108
|
+
"""
|
|
109
|
+
rules_dict: Dict[str, Dict[str, Any]] = {}
|
|
110
|
+
|
|
111
|
+
for issue in issues:
|
|
112
|
+
rule_id = self._get_rule_id(issue)
|
|
113
|
+
|
|
114
|
+
# Skip if we already have this rule
|
|
115
|
+
if rule_id in rules_dict:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Build rule definition
|
|
119
|
+
rule = self._build_rule(issue, rule_id)
|
|
120
|
+
rules_dict[rule_id] = rule
|
|
121
|
+
|
|
122
|
+
return list(rules_dict.values())
|
|
123
|
+
|
|
124
|
+
def _build_rule(self, issue: UnifiedIssue, rule_id: str) -> Dict[str, Any]:
|
|
125
|
+
"""Build a SARIF rule definition from an issue.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
issue: The issue to build a rule from.
|
|
129
|
+
rule_id: The rule identifier.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
SARIF rule definition.
|
|
133
|
+
"""
|
|
134
|
+
severity_info = SEVERITY_MAP.get(issue.severity, SEVERITY_MAP[Severity.MEDIUM])
|
|
135
|
+
|
|
136
|
+
rule: Dict[str, Any] = {
|
|
137
|
+
"id": rule_id,
|
|
138
|
+
"shortDescription": {
|
|
139
|
+
"text": self._truncate(issue.title, 1024),
|
|
140
|
+
},
|
|
141
|
+
"defaultConfiguration": {
|
|
142
|
+
"level": severity_info["level"],
|
|
143
|
+
},
|
|
144
|
+
"properties": {
|
|
145
|
+
"security-severity": severity_info["security-severity"],
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Add full description if we have more detail
|
|
150
|
+
if issue.description and issue.description != issue.title:
|
|
151
|
+
rule["fullDescription"] = {
|
|
152
|
+
"text": issue.description,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Add help URI from documentation_url or recommendation
|
|
156
|
+
if issue.documentation_url:
|
|
157
|
+
rule["helpUri"] = issue.documentation_url
|
|
158
|
+
elif issue.recommendation:
|
|
159
|
+
# Check if recommendation contains a URL
|
|
160
|
+
if issue.recommendation.startswith("http"):
|
|
161
|
+
rule["helpUri"] = issue.recommendation
|
|
162
|
+
elif "See: " in issue.recommendation:
|
|
163
|
+
# Extract URL from "See: <url>" format
|
|
164
|
+
url = issue.recommendation.replace("See: ", "").strip()
|
|
165
|
+
if url.startswith("http"):
|
|
166
|
+
rule["helpUri"] = url
|
|
167
|
+
|
|
168
|
+
# Add CWE/OWASP tags from metadata if available
|
|
169
|
+
tool_metadata = issue.metadata
|
|
170
|
+
if tool_metadata:
|
|
171
|
+
tags = []
|
|
172
|
+
if "cwe" in tool_metadata:
|
|
173
|
+
cwe_ids = tool_metadata["cwe"]
|
|
174
|
+
if isinstance(cwe_ids, list):
|
|
175
|
+
tags.extend(cwe_ids)
|
|
176
|
+
elif cwe_ids:
|
|
177
|
+
tags.append(cwe_ids)
|
|
178
|
+
if "owasp" in tool_metadata:
|
|
179
|
+
owasp_ids = tool_metadata["owasp"]
|
|
180
|
+
if isinstance(owasp_ids, list):
|
|
181
|
+
tags.extend(owasp_ids)
|
|
182
|
+
elif owasp_ids:
|
|
183
|
+
tags.append(owasp_ids)
|
|
184
|
+
if tags:
|
|
185
|
+
rule["properties"]["tags"] = tags
|
|
186
|
+
|
|
187
|
+
return rule
|
|
188
|
+
|
|
189
|
+
def _issue_to_result(self, issue: UnifiedIssue) -> Dict[str, Any]:
|
|
190
|
+
"""Convert a UnifiedIssue to a SARIF result.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
issue: The issue to convert.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
SARIF result object.
|
|
197
|
+
"""
|
|
198
|
+
rule_id = self._get_rule_id(issue)
|
|
199
|
+
severity_info = SEVERITY_MAP.get(issue.severity, SEVERITY_MAP[Severity.MEDIUM])
|
|
200
|
+
|
|
201
|
+
result: Dict[str, Any] = {
|
|
202
|
+
"ruleId": rule_id,
|
|
203
|
+
"message": {
|
|
204
|
+
"text": issue.description or issue.title,
|
|
205
|
+
},
|
|
206
|
+
"level": severity_info["level"],
|
|
207
|
+
"fingerprints": {
|
|
208
|
+
"v1": issue.id,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Add location if we have file information
|
|
213
|
+
location = self._build_location(issue)
|
|
214
|
+
if location:
|
|
215
|
+
result["locations"] = [location]
|
|
216
|
+
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
def _build_location(self, issue: UnifiedIssue) -> Optional[Dict[str, Any]]:
|
|
220
|
+
"""Build a SARIF physical location from issue file info.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
issue: The issue containing file location info.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
SARIF location object or None if no file info.
|
|
227
|
+
"""
|
|
228
|
+
if not issue.file_path:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# Use relative path for SARIF (strip leading slash if present)
|
|
232
|
+
file_uri = str(issue.file_path)
|
|
233
|
+
if file_uri.startswith("/"):
|
|
234
|
+
# Try to make it relative - just use the path as-is for now
|
|
235
|
+
# In practice, the path should already be relative or project-relative
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
location: Dict[str, Any] = {
|
|
239
|
+
"physicalLocation": {
|
|
240
|
+
"artifactLocation": {
|
|
241
|
+
"uri": file_uri,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Add region if we have line information
|
|
247
|
+
if issue.line_start is not None:
|
|
248
|
+
region: Dict[str, Any] = {
|
|
249
|
+
"startLine": issue.line_start,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if issue.line_end is not None:
|
|
253
|
+
region["endLine"] = issue.line_end
|
|
254
|
+
|
|
255
|
+
location["physicalLocation"]["region"] = region
|
|
256
|
+
|
|
257
|
+
return location
|
|
258
|
+
|
|
259
|
+
def _get_rule_id(self, issue: UnifiedIssue) -> str:
|
|
260
|
+
"""Extract or generate a rule ID for an issue.
|
|
261
|
+
|
|
262
|
+
Uses the issue's rule_id field, falling back to scanner-specific
|
|
263
|
+
identifiers in metadata if needed.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
issue: The issue to get a rule ID from.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Rule identifier string.
|
|
270
|
+
"""
|
|
271
|
+
# Use the new rule_id field if available
|
|
272
|
+
if issue.rule_id:
|
|
273
|
+
return issue.rule_id
|
|
274
|
+
|
|
275
|
+
# Fallback to metadata for older scanner output formats
|
|
276
|
+
metadata = issue.metadata
|
|
277
|
+
|
|
278
|
+
# Try scanner-specific IDs from metadata
|
|
279
|
+
if "vulnerability_id" in metadata:
|
|
280
|
+
return metadata["vulnerability_id"]
|
|
281
|
+
if "rule_id" in metadata:
|
|
282
|
+
return metadata["rule_id"]
|
|
283
|
+
if "check_id" in metadata:
|
|
284
|
+
return metadata["check_id"]
|
|
285
|
+
|
|
286
|
+
# Fallback: construct from source tool and issue title
|
|
287
|
+
# Use a simplified version of the title as rule ID
|
|
288
|
+
title_slug = issue.title.split(":")[0].strip()
|
|
289
|
+
return f"{issue.source_tool}/{title_slug}"
|
|
290
|
+
|
|
291
|
+
def _truncate(self, text: str, max_length: int) -> str:
|
|
292
|
+
"""Truncate text to max length with ellipsis.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
text: Text to truncate.
|
|
296
|
+
max_length: Maximum allowed length.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Truncated text.
|
|
300
|
+
"""
|
|
301
|
+
if len(text) <= max_length:
|
|
302
|
+
return text
|
|
303
|
+
return text[: max_length - 3] + "..."
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Summary reporter plugin for lucidscan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import IO, List
|
|
6
|
+
|
|
7
|
+
from lucidscan.core.models import ScanResult
|
|
8
|
+
from lucidscan.plugins.reporters.base import ReporterPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SummaryReporter(ReporterPlugin):
|
|
12
|
+
"""Reporter plugin that outputs a brief scan summary.
|
|
13
|
+
|
|
14
|
+
Produces a concise summary with:
|
|
15
|
+
- Total issue count
|
|
16
|
+
- Breakdown by severity
|
|
17
|
+
- Breakdown by scanner domain
|
|
18
|
+
- Scan duration and project info
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
return "summary"
|
|
24
|
+
|
|
25
|
+
def report(self, result: ScanResult, output: IO[str]) -> None:
|
|
26
|
+
"""Format scan result as a summary and write to output.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
result: The scan result to format.
|
|
30
|
+
output: Output stream to write to.
|
|
31
|
+
"""
|
|
32
|
+
lines = self._format_summary(result)
|
|
33
|
+
output.write("\n".join(lines))
|
|
34
|
+
output.write("\n")
|
|
35
|
+
|
|
36
|
+
def _format_summary(self, result: ScanResult) -> List[str]:
|
|
37
|
+
"""Format scan result as a brief summary."""
|
|
38
|
+
lines: List[str] = []
|
|
39
|
+
|
|
40
|
+
if result.summary:
|
|
41
|
+
lines.append(f"Total issues: {result.summary.total}")
|
|
42
|
+
|
|
43
|
+
if result.summary.by_severity:
|
|
44
|
+
lines.append("\nBy severity:")
|
|
45
|
+
for sev in ["critical", "high", "medium", "low", "info"]:
|
|
46
|
+
count = result.summary.by_severity.get(sev, 0)
|
|
47
|
+
if count > 0:
|
|
48
|
+
lines.append(f" {sev.upper()}: {count}")
|
|
49
|
+
|
|
50
|
+
if result.summary.by_scanner:
|
|
51
|
+
lines.append("\nBy scanner domain:")
|
|
52
|
+
for scanner, count in result.summary.by_scanner.items():
|
|
53
|
+
lines.append(f" {scanner.upper()}: {count}")
|
|
54
|
+
else:
|
|
55
|
+
lines.append("No summary available.")
|
|
56
|
+
|
|
57
|
+
if result.metadata:
|
|
58
|
+
lines.append(f"\nScan duration: {result.metadata.duration_ms}ms")
|
|
59
|
+
lines.append(f"Project: {result.metadata.project_root}")
|
|
60
|
+
|
|
61
|
+
return lines
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Table reporter plugin for lucidscan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import IO, List
|
|
6
|
+
|
|
7
|
+
from lucidscan.core.models import ScanResult, Severity
|
|
8
|
+
from lucidscan.plugins.reporters.base import ReporterPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TableReporter(ReporterPlugin):
|
|
12
|
+
"""Reporter plugin that outputs scan results as a human-readable table.
|
|
13
|
+
|
|
14
|
+
Produces a formatted table suitable for terminal display with:
|
|
15
|
+
- Severity-sorted issues
|
|
16
|
+
- Truncated fields for readability
|
|
17
|
+
- Summary statistics at the bottom
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "table"
|
|
23
|
+
|
|
24
|
+
def report(self, result: ScanResult, output: IO[str]) -> None:
|
|
25
|
+
"""Format scan result as a table and write to output.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
result: The scan result to format.
|
|
29
|
+
output: Output stream to write to.
|
|
30
|
+
"""
|
|
31
|
+
lines = self._format_table(result)
|
|
32
|
+
output.write("\n".join(lines))
|
|
33
|
+
output.write("\n")
|
|
34
|
+
|
|
35
|
+
def _format_table(self, result: ScanResult) -> List[str]:
|
|
36
|
+
"""Format scan result as a human-readable table."""
|
|
37
|
+
lines: List[str] = []
|
|
38
|
+
|
|
39
|
+
if not result.issues:
|
|
40
|
+
lines.append("No issues found.")
|
|
41
|
+
return lines
|
|
42
|
+
|
|
43
|
+
# Header
|
|
44
|
+
lines.append(f"{'SEVERITY':<10} {'ID':<20} {'DEPENDENCY':<40} {'TITLE'}")
|
|
45
|
+
lines.append("-" * 100)
|
|
46
|
+
|
|
47
|
+
# Sort by severity
|
|
48
|
+
severity_order = {
|
|
49
|
+
Severity.CRITICAL: 0,
|
|
50
|
+
Severity.HIGH: 1,
|
|
51
|
+
Severity.MEDIUM: 2,
|
|
52
|
+
Severity.LOW: 3,
|
|
53
|
+
Severity.INFO: 4,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
sorted_issues = sorted(
|
|
57
|
+
result.issues, key=lambda x: severity_order.get(x.severity, 5)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
for issue in sorted_issues:
|
|
61
|
+
sev = issue.severity.value.upper()
|
|
62
|
+
# Use rule_id first, then check metadata for vulnerability_id, fallback to issue id
|
|
63
|
+
rule_id = issue.rule_id or issue.metadata.get("vulnerability_id", issue.id)
|
|
64
|
+
rule_id = rule_id[:20]
|
|
65
|
+
dep = (issue.dependency or "")[:40]
|
|
66
|
+
title = issue.title[:60] if len(issue.title) > 60 else issue.title
|
|
67
|
+
lines.append(f"{sev:<10} {rule_id:<20} {dep:<40} {title}")
|
|
68
|
+
|
|
69
|
+
# Summary
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append("-" * 100)
|
|
72
|
+
if result.summary:
|
|
73
|
+
lines.append(f"Total: {result.summary.total} issues")
|
|
74
|
+
sev_parts = [
|
|
75
|
+
f"{sev}: {count}"
|
|
76
|
+
for sev, count in result.summary.by_severity.items()
|
|
77
|
+
]
|
|
78
|
+
if sev_parts:
|
|
79
|
+
lines.append(f"By severity: {', '.join(sev_parts)}")
|
|
80
|
+
|
|
81
|
+
return lines
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Scanner plugins for integrating external security tools.
|
|
2
|
+
|
|
3
|
+
Plugins are discovered via Python entry points (lucidscan.scanners group).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional, Type
|
|
8
|
+
|
|
9
|
+
from lucidscan.plugins.scanners.base import ScannerPlugin
|
|
10
|
+
from lucidscan.plugins.scanners.trivy import TrivyScanner
|
|
11
|
+
from lucidscan.plugins.scanners.opengrep import OpenGrepScanner
|
|
12
|
+
from lucidscan.plugins.scanners.checkov import CheckovScanner
|
|
13
|
+
from lucidscan.plugins import SCANNER_ENTRY_POINT_GROUP
|
|
14
|
+
from lucidscan.plugins.discovery import discover_plugins, get_plugin, list_available_plugins as _list_plugins
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discover_scanner_plugins() -> Dict[str, Type[ScannerPlugin]]:
|
|
18
|
+
"""Discover all installed scanner plugins via entry points."""
|
|
19
|
+
return discover_plugins(SCANNER_ENTRY_POINT_GROUP, ScannerPlugin)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_scanner_plugin(
|
|
23
|
+
name: str,
|
|
24
|
+
project_root: Optional[Path] = None,
|
|
25
|
+
) -> ScannerPlugin | None:
|
|
26
|
+
"""Get an instantiated scanner plugin by name.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
name: Scanner plugin name (e.g., 'trivy').
|
|
30
|
+
project_root: Optional project root for tool installation.
|
|
31
|
+
If provided, tools are installed to {project_root}/.lucidscan/
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Instantiated scanner plugin or None if not found.
|
|
35
|
+
"""
|
|
36
|
+
kwargs = {}
|
|
37
|
+
if project_root:
|
|
38
|
+
kwargs["project_root"] = project_root
|
|
39
|
+
return get_plugin(SCANNER_ENTRY_POINT_GROUP, name, ScannerPlugin, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_available_scanners() -> list[str]:
|
|
43
|
+
"""List names of all available scanner plugins."""
|
|
44
|
+
return _list_plugins(SCANNER_ENTRY_POINT_GROUP)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"ScannerPlugin",
|
|
49
|
+
"TrivyScanner",
|
|
50
|
+
"OpenGrepScanner",
|
|
51
|
+
"CheckovScanner",
|
|
52
|
+
"discover_scanner_plugins",
|
|
53
|
+
"get_scanner_plugin",
|
|
54
|
+
"list_available_scanners",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from lucidscan.core.models import ScanContext, ScanDomain, UnifiedIssue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScannerPlugin(ABC):
|
|
11
|
+
"""Base class for all scanner plugins.
|
|
12
|
+
|
|
13
|
+
Each scanner plugin wraps an underlying security tool and exposes it
|
|
14
|
+
through a common interface. Plugins are self-contained and manage
|
|
15
|
+
their own binary lifecycle.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, project_root: Optional[Path] = None, **kwargs) -> None:
|
|
19
|
+
"""Initialize the scanner plugin.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
project_root: Optional project root for tool installation.
|
|
23
|
+
**kwargs: Additional arguments for subclasses.
|
|
24
|
+
"""
|
|
25
|
+
self.project_root = project_root
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""Plugin identifier (e.g., 'trivy', 'opengrep')."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def domains(self) -> List[ScanDomain]:
|
|
35
|
+
"""Scan domains this plugin supports (SCA, SAST, IAC, CONTAINER)."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def ensure_binary(self) -> Path:
|
|
39
|
+
"""Ensure the scanner binary is available, downloading if needed.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to the scanner binary.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get_version(self) -> str:
|
|
47
|
+
"""Return the version of the underlying scanner."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def scan(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
51
|
+
"""Execute scan and return normalized issues.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
context: Scan context containing target paths and configuration.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of unified issues found during the scan.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|