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.
- security_use/__init__.py +15 -0
- security_use/cli.py +348 -0
- security_use/dependency_scanner.py +199 -0
- security_use/fixers/__init__.py +6 -0
- security_use/fixers/dependency_fixer.py +196 -0
- security_use/fixers/iac_fixer.py +191 -0
- security_use/iac/__init__.py +9 -0
- security_use/iac/base.py +69 -0
- security_use/iac/cloudformation.py +207 -0
- security_use/iac/rules/__init__.py +29 -0
- security_use/iac/rules/aws.py +338 -0
- security_use/iac/rules/base.py +96 -0
- security_use/iac/rules/registry.py +115 -0
- security_use/iac/terraform.py +177 -0
- security_use/iac_scanner.py +215 -0
- security_use/models.py +139 -0
- security_use/osv_client.py +386 -0
- security_use/parsers/__init__.py +16 -0
- security_use/parsers/base.py +43 -0
- security_use/parsers/pipfile.py +133 -0
- security_use/parsers/poetry_lock.py +42 -0
- security_use/parsers/pyproject.py +178 -0
- security_use/parsers/requirements.py +86 -0
- security_use/py.typed +0 -0
- security_use/reporter.py +368 -0
- security_use/scanner.py +74 -0
- security_use-0.1.1.dist-info/METADATA +92 -0
- security_use-0.1.1.dist-info/RECORD +30 -0
- security_use-0.1.1.dist-info/WHEEL +4 -0
- security_use-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -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
|
security_use/reporter.py
ADDED
|
@@ -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'.")
|