security-use 0.1.1__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.
@@ -0,0 +1,178 @@
1
+ """Parser for pyproject.toml files (PEP 621 and Poetry formats)."""
2
+
3
+ import re
4
+ from typing import Any, Optional
5
+
6
+ from security_use.parsers.base import Dependency, DependencyParser
7
+
8
+ try:
9
+ import tomllib
10
+ except ImportError:
11
+ import tomli as tomllib # type: ignore[import-not-found]
12
+
13
+
14
+ class PyProjectParser(DependencyParser):
15
+ """Parser for pyproject.toml files supporting PEP 621 and Poetry formats."""
16
+
17
+ VERSION_RE = re.compile(
18
+ r"^(?P<name>[a-zA-Z0-9][-a-zA-Z0-9._]*)"
19
+ r"(?:\[(?P<extras>[^\]]+)\])?"
20
+ r"(?P<spec>(?:[<>=!~^]+[^;#\s,]+,?)+)?"
21
+ r"(?:;.*)?$"
22
+ )
23
+
24
+ def parse(self, content: str) -> list[Dependency]:
25
+ """Parse pyproject.toml content."""
26
+ try:
27
+ data = tomllib.loads(content)
28
+ except Exception:
29
+ return []
30
+
31
+ dependencies: list[Dependency] = []
32
+
33
+ # Parse PEP 621 format ([project] section)
34
+ dependencies.extend(self._parse_pep621(data))
35
+
36
+ # Parse Poetry format ([tool.poetry] section)
37
+ dependencies.extend(self._parse_poetry(data))
38
+
39
+ return dependencies
40
+
41
+ def _parse_pep621(self, data: dict[str, Any]) -> list[Dependency]:
42
+ """Parse PEP 621 format dependencies."""
43
+ dependencies = []
44
+ project = data.get("project", {})
45
+
46
+ # Main dependencies
47
+ for dep_str in project.get("dependencies", []):
48
+ dep = self._parse_requirement_string(dep_str)
49
+ if dep:
50
+ dependencies.append(dep)
51
+
52
+ # Optional dependencies
53
+ for group_deps in project.get("optional-dependencies", {}).values():
54
+ for dep_str in group_deps:
55
+ dep = self._parse_requirement_string(dep_str)
56
+ if dep:
57
+ dependencies.append(dep)
58
+
59
+ return dependencies
60
+
61
+ def _parse_poetry(self, data: dict[str, Any]) -> list[Dependency]:
62
+ """Parse Poetry format dependencies."""
63
+ dependencies = []
64
+ poetry = data.get("tool", {}).get("poetry", {})
65
+
66
+ # Main dependencies
67
+ dependencies.extend(
68
+ self._parse_poetry_deps(poetry.get("dependencies", {}))
69
+ )
70
+
71
+ # Dev dependencies (old format)
72
+ dependencies.extend(
73
+ self._parse_poetry_deps(poetry.get("dev-dependencies", {}))
74
+ )
75
+
76
+ # Group dependencies (new format)
77
+ for group in poetry.get("group", {}).values():
78
+ dependencies.extend(
79
+ self._parse_poetry_deps(group.get("dependencies", {}))
80
+ )
81
+
82
+ return dependencies
83
+
84
+ def _parse_poetry_deps(
85
+ self, deps: dict[str, Any]
86
+ ) -> list[Dependency]:
87
+ """Parse Poetry dependencies dict."""
88
+ dependencies = []
89
+
90
+ for name, spec in deps.items():
91
+ # Skip python version requirement
92
+ if name.lower() == "python":
93
+ continue
94
+
95
+ version = None
96
+ version_spec = None
97
+ extras = None
98
+
99
+ if isinstance(spec, str):
100
+ version, version_spec = self._parse_poetry_version(spec)
101
+ elif isinstance(spec, dict):
102
+ ver = spec.get("version")
103
+ if ver:
104
+ version, version_spec = self._parse_poetry_version(ver)
105
+ extras = spec.get("extras")
106
+
107
+ dependencies.append(
108
+ Dependency(
109
+ name=name,
110
+ version=version,
111
+ version_spec=version_spec,
112
+ extras=extras,
113
+ )
114
+ )
115
+
116
+ return dependencies
117
+
118
+ def _parse_poetry_version(self, spec: str) -> tuple[Optional[str], str]:
119
+ """Parse Poetry version specifier."""
120
+ spec = spec.strip()
121
+
122
+ # Exact version
123
+ if spec.startswith("=="):
124
+ return spec[2:].strip(), spec
125
+
126
+ # Caret (^) - compatible release
127
+ if spec.startswith("^"):
128
+ return spec[1:].strip(), spec
129
+
130
+ # Tilde (~) - compatible release
131
+ if spec.startswith("~"):
132
+ return spec[1:].strip(), spec
133
+
134
+ # Wildcard
135
+ if spec == "*":
136
+ return None, spec
137
+
138
+ # Plain version (treated as exact)
139
+ if re.match(r"^[\d.]+", spec):
140
+ return spec, f"=={spec}"
141
+
142
+ return None, spec
143
+
144
+ def _parse_requirement_string(self, req: str) -> Optional[Dependency]:
145
+ """Parse a PEP 508 requirement string."""
146
+ req = req.strip()
147
+ match = self.VERSION_RE.match(req)
148
+ if not match:
149
+ # Handle bare package names
150
+ if re.match(r"^[a-zA-Z0-9][-a-zA-Z0-9._]*$", req):
151
+ return Dependency(name=req, version=None)
152
+ return None
153
+
154
+ name = match.group("name")
155
+ extras_str = match.group("extras")
156
+ spec = match.group("spec")
157
+
158
+ extras = [e.strip() for e in extras_str.split(",")] if extras_str else None
159
+
160
+ version = None
161
+ if spec:
162
+ # Extract exact version if pinned
163
+ if "==" in spec:
164
+ ver_match = re.search(r"==([^,<>=!~;#\s]+)", spec)
165
+ if ver_match:
166
+ version = ver_match.group(1)
167
+
168
+ return Dependency(
169
+ name=name,
170
+ version=version,
171
+ version_spec=spec,
172
+ extras=extras,
173
+ )
174
+
175
+ @classmethod
176
+ def supported_filenames(cls) -> list[str]:
177
+ """Return supported filenames."""
178
+ return ["pyproject.toml"]
@@ -0,0 +1,86 @@
1
+ """Parser for requirements.txt files."""
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from security_use.parsers.base import Dependency, DependencyParser
7
+
8
+
9
+ class RequirementsParser(DependencyParser):
10
+ """Parser for requirements.txt format files."""
11
+
12
+ # Regex patterns for parsing requirements
13
+ COMMENT_RE = re.compile(r"#.*$")
14
+ REQUIREMENT_RE = re.compile(
15
+ r"^(?P<name>[a-zA-Z0-9][-a-zA-Z0-9._]*)"
16
+ r"(?:\[(?P<extras>[^\]]+)\])?"
17
+ r"(?P<spec>(?:[<>=!~]+[^;#\s,]+,?)+)?"
18
+ r"(?:;.*)?$"
19
+ )
20
+ VERSION_RE = re.compile(r"[<>=!~]+(?P<version>[^,<>=!~;#\s]+)")
21
+
22
+ def parse(self, content: str) -> list[Dependency]:
23
+ """Parse requirements.txt content."""
24
+ dependencies = []
25
+
26
+ for line_num, line in enumerate(content.splitlines(), start=1):
27
+ dep = self._parse_line(line, line_num)
28
+ if dep:
29
+ dependencies.append(dep)
30
+
31
+ return dependencies
32
+
33
+ def _parse_line(self, line: str, line_number: int) -> Optional[Dependency]:
34
+ """Parse a single line from requirements.txt."""
35
+ # Remove comments and whitespace
36
+ line = self.COMMENT_RE.sub("", line).strip()
37
+
38
+ # Skip empty lines and special directives
39
+ if not line or line.startswith("-") or line.startswith("http"):
40
+ return None
41
+
42
+ # Skip editable installs
43
+ if line.startswith("-e") or line.startswith("--"):
44
+ return None
45
+
46
+ match = self.REQUIREMENT_RE.match(line)
47
+ if not match:
48
+ return None
49
+
50
+ name = match.group("name")
51
+ extras_str = match.group("extras")
52
+ spec = match.group("spec")
53
+
54
+ extras = [e.strip() for e in extras_str.split(",")] if extras_str else None
55
+
56
+ version = None
57
+ if spec:
58
+ # Extract exact version if pinned (==)
59
+ if "==" in spec:
60
+ ver_match = re.search(r"==([^,<>=!~;#\s]+)", spec)
61
+ if ver_match:
62
+ version = ver_match.group(1)
63
+ else:
64
+ # For ranges, extract the lower bound
65
+ ver_matches = self.VERSION_RE.findall(spec)
66
+ if ver_matches:
67
+ version = ver_matches[0]
68
+
69
+ return Dependency(
70
+ name=name,
71
+ version=version,
72
+ version_spec=spec,
73
+ line_number=line_number,
74
+ extras=extras,
75
+ )
76
+
77
+ @classmethod
78
+ def supported_filenames(cls) -> list[str]:
79
+ """Return supported filenames."""
80
+ return [
81
+ "requirements.txt",
82
+ "requirements-dev.txt",
83
+ "requirements-test.txt",
84
+ "requirements.in",
85
+ "constraints.txt",
86
+ ]
security_use/py.typed ADDED
File without changes
@@ -0,0 +1,368 @@
1
+ """Vulnerability report generators."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Optional
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from security_use.models import ScanResult, Severity, Vulnerability, IaCFinding
13
+
14
+
15
+ class ReportGenerator(ABC):
16
+ """Abstract base class for report generators."""
17
+
18
+ @abstractmethod
19
+ def generate(self, result: ScanResult) -> str:
20
+ """Generate a report from scan results.
21
+
22
+ Args:
23
+ result: The scan results to report.
24
+
25
+ Returns:
26
+ Formatted report as a string.
27
+ """
28
+ pass
29
+
30
+
31
+ class JSONReporter(ReportGenerator):
32
+ """Generate JSON format reports."""
33
+
34
+ def __init__(self, indent: int = 2) -> None:
35
+ """Initialize JSON reporter.
36
+
37
+ Args:
38
+ indent: JSON indentation level.
39
+ """
40
+ self.indent = indent
41
+
42
+ def generate(self, result: ScanResult) -> str:
43
+ """Generate JSON report."""
44
+ return json.dumps(result.to_dict(), indent=self.indent)
45
+
46
+
47
+ class TableReporter(ReportGenerator):
48
+ """Generate rich table format reports for CLI output."""
49
+
50
+ SEVERITY_COLORS = {
51
+ Severity.CRITICAL: "red bold",
52
+ Severity.HIGH: "red",
53
+ Severity.MEDIUM: "yellow",
54
+ Severity.LOW: "blue",
55
+ Severity.UNKNOWN: "dim",
56
+ }
57
+
58
+ def __init__(self, show_details: bool = True) -> None:
59
+ """Initialize table reporter.
60
+
61
+ Args:
62
+ show_details: Whether to show full details or summary only.
63
+ """
64
+ self.show_details = show_details
65
+ self.console = Console(record=True, force_terminal=True)
66
+
67
+ def generate(self, result: ScanResult) -> str:
68
+ """Generate table report."""
69
+ # Render to console buffer
70
+ self._render_summary(result)
71
+
72
+ if result.vulnerabilities:
73
+ self._render_vulnerabilities(result.vulnerabilities)
74
+
75
+ if result.iac_findings:
76
+ self._render_iac_findings(result.iac_findings)
77
+
78
+ if result.errors:
79
+ self._render_errors(result.errors)
80
+
81
+ return self.console.export_text()
82
+
83
+ def _render_summary(self, result: ScanResult) -> None:
84
+ """Render summary panel."""
85
+ severity_counts = self._count_by_severity(result)
86
+
87
+ summary_text = Text()
88
+ summary_text.append(f"Total Issues: {result.total_issues}\n")
89
+ summary_text.append(f"Files Scanned: {len(result.scanned_files)}\n\n")
90
+
91
+ summary_text.append("By Severity:\n", style="bold")
92
+ for severity in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW]:
93
+ count = severity_counts.get(severity, 0)
94
+ color = self.SEVERITY_COLORS[severity]
95
+ summary_text.append(f" {severity.value}: ", style=color)
96
+ summary_text.append(f"{count}\n")
97
+
98
+ panel = Panel(
99
+ summary_text,
100
+ title="Security Scan Summary",
101
+ border_style="blue",
102
+ )
103
+ self.console.print(panel)
104
+
105
+ def _render_vulnerabilities(self, vulns: list[Vulnerability]) -> None:
106
+ """Render vulnerabilities table."""
107
+ table = Table(
108
+ title="Dependency Vulnerabilities",
109
+ show_header=True,
110
+ header_style="bold cyan",
111
+ )
112
+
113
+ table.add_column("Severity", width=10)
114
+ table.add_column("Package", width=20)
115
+ table.add_column("Version", width=12)
116
+ table.add_column("CVE ID", width=20)
117
+ table.add_column("Fix Version", width=12)
118
+
119
+ if self.show_details:
120
+ table.add_column("Title", width=40)
121
+
122
+ for vuln in sorted(vulns, key=lambda v: self._severity_order(v.severity)):
123
+ color = self.SEVERITY_COLORS[vuln.severity]
124
+ row = [
125
+ Text(vuln.severity.value, style=color),
126
+ vuln.package,
127
+ vuln.installed_version,
128
+ vuln.id,
129
+ vuln.fixed_version or "N/A",
130
+ ]
131
+ if self.show_details:
132
+ row.append(vuln.title[:40] if len(vuln.title) > 40 else vuln.title)
133
+
134
+ table.add_row(*row)
135
+
136
+ self.console.print(table)
137
+
138
+ def _render_iac_findings(self, findings: list[IaCFinding]) -> None:
139
+ """Render IaC findings table."""
140
+ table = Table(
141
+ title="Infrastructure as Code Findings",
142
+ show_header=True,
143
+ header_style="bold cyan",
144
+ )
145
+
146
+ table.add_column("Severity", width=10)
147
+ table.add_column("Rule ID", width=15)
148
+ table.add_column("Resource", width=25)
149
+ table.add_column("File", width=30)
150
+ table.add_column("Line", width=6)
151
+
152
+ if self.show_details:
153
+ table.add_column("Title", width=35)
154
+
155
+ for finding in sorted(findings, key=lambda f: self._severity_order(f.severity)):
156
+ color = self.SEVERITY_COLORS[finding.severity]
157
+ row = [
158
+ Text(finding.severity.value, style=color),
159
+ finding.rule_id,
160
+ finding.resource_name[:25] if len(finding.resource_name) > 25 else finding.resource_name,
161
+ finding.file_path[-30:] if len(finding.file_path) > 30 else finding.file_path,
162
+ str(finding.line_number),
163
+ ]
164
+ if self.show_details:
165
+ row.append(finding.title[:35] if len(finding.title) > 35 else finding.title)
166
+
167
+ table.add_row(*row)
168
+
169
+ self.console.print(table)
170
+
171
+ def _render_errors(self, errors: list[str]) -> None:
172
+ """Render errors panel."""
173
+ error_text = Text()
174
+ for error in errors:
175
+ error_text.append(f"• {error}\n", style="red")
176
+
177
+ panel = Panel(
178
+ error_text,
179
+ title="Errors",
180
+ border_style="red",
181
+ )
182
+ self.console.print(panel)
183
+
184
+ def _count_by_severity(self, result: ScanResult) -> dict[Severity, int]:
185
+ """Count issues by severity."""
186
+ counts: dict[Severity, int] = {}
187
+
188
+ for vuln in result.vulnerabilities:
189
+ counts[vuln.severity] = counts.get(vuln.severity, 0) + 1
190
+
191
+ for finding in result.iac_findings:
192
+ counts[finding.severity] = counts.get(finding.severity, 0) + 1
193
+
194
+ return counts
195
+
196
+ def _severity_order(self, severity: Severity) -> int:
197
+ """Get severity order for sorting (lower = more severe)."""
198
+ order = {
199
+ Severity.CRITICAL: 0,
200
+ Severity.HIGH: 1,
201
+ Severity.MEDIUM: 2,
202
+ Severity.LOW: 3,
203
+ Severity.UNKNOWN: 4,
204
+ }
205
+ return order.get(severity, 5)
206
+
207
+
208
+ class SARIFReporter(ReportGenerator):
209
+ """Generate SARIF (Static Analysis Results Interchange Format) reports.
210
+
211
+ SARIF is a standard format for static analysis tools that integrates
212
+ with VS Code, GitHub, and other tools.
213
+ """
214
+
215
+ SARIF_VERSION = "2.1.0"
216
+ SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
217
+
218
+ def __init__(self, tool_name: str = "security-use", tool_version: str = "0.1.0") -> None:
219
+ """Initialize SARIF reporter.
220
+
221
+ Args:
222
+ tool_name: Name of the scanning tool.
223
+ tool_version: Version of the scanning tool.
224
+ """
225
+ self.tool_name = tool_name
226
+ self.tool_version = tool_version
227
+
228
+ def generate(self, result: ScanResult) -> str:
229
+ """Generate SARIF report."""
230
+ sarif = {
231
+ "$schema": self.SCHEMA,
232
+ "version": self.SARIF_VERSION,
233
+ "runs": [self._create_run(result)],
234
+ }
235
+ return json.dumps(sarif, indent=2)
236
+
237
+ def _create_run(self, result: ScanResult) -> dict[str, Any]:
238
+ """Create a SARIF run object."""
239
+ rules: list[dict[str, Any]] = []
240
+ results: list[dict[str, Any]] = []
241
+
242
+ # Add vulnerability rules and results
243
+ for vuln in result.vulnerabilities:
244
+ rule = self._create_vulnerability_rule(vuln)
245
+ if not any(r["id"] == rule["id"] for r in rules):
246
+ rules.append(rule)
247
+ results.append(self._create_vulnerability_result(vuln))
248
+
249
+ # Add IaC finding rules and results
250
+ for finding in result.iac_findings:
251
+ rule = self._create_iac_rule(finding)
252
+ if not any(r["id"] == rule["id"] for r in rules):
253
+ rules.append(rule)
254
+ results.append(self._create_iac_result(finding))
255
+
256
+ return {
257
+ "tool": {
258
+ "driver": {
259
+ "name": self.tool_name,
260
+ "version": self.tool_version,
261
+ "informationUri": "https://github.com/security-use/security-use",
262
+ "rules": rules,
263
+ }
264
+ },
265
+ "results": results,
266
+ }
267
+
268
+ def _create_vulnerability_rule(self, vuln: Vulnerability) -> dict[str, Any]:
269
+ """Create a SARIF rule for a vulnerability."""
270
+ return {
271
+ "id": vuln.id,
272
+ "name": f"VulnerablePackage/{vuln.package}",
273
+ "shortDescription": {"text": vuln.title},
274
+ "fullDescription": {"text": vuln.description or vuln.title},
275
+ "helpUri": vuln.references[0] if vuln.references else None,
276
+ "defaultConfiguration": {
277
+ "level": self._severity_to_sarif_level(vuln.severity)
278
+ },
279
+ "properties": {
280
+ "security-severity": str(vuln.cvss_score) if vuln.cvss_score else "0.0"
281
+ },
282
+ }
283
+
284
+ def _create_vulnerability_result(self, vuln: Vulnerability) -> dict[str, Any]:
285
+ """Create a SARIF result for a vulnerability."""
286
+ message = f"{vuln.package}@{vuln.installed_version} has a known vulnerability ({vuln.id})"
287
+ if vuln.fixed_version:
288
+ message += f". Update to version {vuln.fixed_version} to fix."
289
+
290
+ return {
291
+ "ruleId": vuln.id,
292
+ "level": self._severity_to_sarif_level(vuln.severity),
293
+ "message": {"text": message},
294
+ "locations": [], # Dependency vulnerabilities don't have specific locations
295
+ }
296
+
297
+ def _create_iac_rule(self, finding: IaCFinding) -> dict[str, Any]:
298
+ """Create a SARIF rule for an IaC finding."""
299
+ return {
300
+ "id": finding.rule_id,
301
+ "name": finding.title.replace(" ", ""),
302
+ "shortDescription": {"text": finding.title},
303
+ "fullDescription": {"text": finding.description},
304
+ "help": {"text": finding.remediation},
305
+ "defaultConfiguration": {
306
+ "level": self._severity_to_sarif_level(finding.severity)
307
+ },
308
+ }
309
+
310
+ def _create_iac_result(self, finding: IaCFinding) -> dict[str, Any]:
311
+ """Create a SARIF result for an IaC finding."""
312
+ return {
313
+ "ruleId": finding.rule_id,
314
+ "level": self._severity_to_sarif_level(finding.severity),
315
+ "message": {"text": finding.title},
316
+ "locations": [
317
+ {
318
+ "physicalLocation": {
319
+ "artifactLocation": {"uri": finding.file_path},
320
+ "region": {
321
+ "startLine": finding.line_number,
322
+ "startColumn": 1,
323
+ },
324
+ }
325
+ }
326
+ ],
327
+ }
328
+
329
+ def _severity_to_sarif_level(self, severity: Severity) -> str:
330
+ """Convert severity to SARIF level."""
331
+ mapping = {
332
+ Severity.CRITICAL: "error",
333
+ Severity.HIGH: "error",
334
+ Severity.MEDIUM: "warning",
335
+ Severity.LOW: "note",
336
+ Severity.UNKNOWN: "none",
337
+ }
338
+ return mapping.get(severity, "none")
339
+
340
+
341
+ def create_reporter(
342
+ format: str,
343
+ show_details: bool = True,
344
+ tool_name: str = "security-use",
345
+ tool_version: str = "0.1.0",
346
+ ) -> ReportGenerator:
347
+ """Create a reporter for the specified format.
348
+
349
+ Args:
350
+ format: Output format ('json', 'table', 'sarif').
351
+ show_details: Whether to show full details (for table format).
352
+ tool_name: Tool name (for SARIF format).
353
+ tool_version: Tool version (for SARIF format).
354
+
355
+ Returns:
356
+ Appropriate ReportGenerator instance.
357
+
358
+ Raises:
359
+ ValueError: If format is not supported.
360
+ """
361
+ if format == "json":
362
+ return JSONReporter()
363
+ elif format == "table":
364
+ return TableReporter(show_details=show_details)
365
+ elif format == "sarif":
366
+ return SARIFReporter(tool_name=tool_name, tool_version=tool_version)
367
+ else:
368
+ raise ValueError(f"Unsupported format: {format}. Use 'json', 'table', or 'sarif'.")