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,177 @@
|
|
|
1
|
+
"""Terraform HCL2 parser."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import hcl2
|
|
7
|
+
|
|
8
|
+
from security_use.iac.base import IaCParser, IaCResource, ParseResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TerraformParser(IaCParser):
|
|
12
|
+
"""Parser for Terraform .tf files (HCL2 format)."""
|
|
13
|
+
|
|
14
|
+
# Resource type to provider mapping
|
|
15
|
+
PROVIDER_PREFIXES = {
|
|
16
|
+
"aws_": "aws",
|
|
17
|
+
"azurerm_": "azure",
|
|
18
|
+
"google_": "gcp",
|
|
19
|
+
"kubernetes_": "kubernetes",
|
|
20
|
+
"helm_": "helm",
|
|
21
|
+
"docker_": "docker",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def parse(self, content: str, file_path: str = "<string>") -> ParseResult:
|
|
25
|
+
"""Parse Terraform HCL2 content.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
content: HCL2 file content.
|
|
29
|
+
file_path: Path to the file.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ParseResult with resources and any errors.
|
|
33
|
+
"""
|
|
34
|
+
result = ParseResult()
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
# Parse HCL2
|
|
38
|
+
parsed = hcl2.loads(content)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
result.errors.append(f"Failed to parse {file_path}: {e}")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
# Extract resources
|
|
44
|
+
for resource_block in parsed.get("resource", []):
|
|
45
|
+
for resource_type, instances in resource_block.items():
|
|
46
|
+
# instances is a dict: {resource_name: config, ...}
|
|
47
|
+
for resource_name, config in instances.items():
|
|
48
|
+
line_number = self._find_resource_line(
|
|
49
|
+
content, resource_type, resource_name
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
resource = IaCResource(
|
|
53
|
+
resource_type=resource_type,
|
|
54
|
+
name=resource_name,
|
|
55
|
+
config=config if isinstance(config, dict) else {},
|
|
56
|
+
file_path=file_path,
|
|
57
|
+
line_number=line_number,
|
|
58
|
+
provider=self._get_provider(resource_type),
|
|
59
|
+
)
|
|
60
|
+
result.resources.append(resource)
|
|
61
|
+
|
|
62
|
+
# Extract data sources
|
|
63
|
+
for data_block in parsed.get("data", []):
|
|
64
|
+
for data_type, instances in data_block.items():
|
|
65
|
+
# instances is a dict: {data_name: config, ...}
|
|
66
|
+
for data_name, config in instances.items():
|
|
67
|
+
line_number = self._find_data_line(
|
|
68
|
+
content, data_type, data_name
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
resource = IaCResource(
|
|
72
|
+
resource_type=f"data.{data_type}",
|
|
73
|
+
name=data_name,
|
|
74
|
+
config=config if isinstance(config, dict) else {},
|
|
75
|
+
file_path=file_path,
|
|
76
|
+
line_number=line_number,
|
|
77
|
+
provider=self._get_provider(data_type),
|
|
78
|
+
)
|
|
79
|
+
result.resources.append(resource)
|
|
80
|
+
|
|
81
|
+
# Extract variables
|
|
82
|
+
for var_block in parsed.get("variable", []):
|
|
83
|
+
for var_name, var_config in var_block.items():
|
|
84
|
+
result.variables[var_name] = var_config
|
|
85
|
+
|
|
86
|
+
# Extract outputs
|
|
87
|
+
for output_block in parsed.get("output", []):
|
|
88
|
+
for output_name, output_config in output_block.items():
|
|
89
|
+
result.outputs[output_name] = output_config
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def _find_resource_line(
|
|
94
|
+
self, content: str, resource_type: str, resource_name: str
|
|
95
|
+
) -> int:
|
|
96
|
+
"""Find the line number where a resource is defined."""
|
|
97
|
+
pattern = rf'resource\s+"{re.escape(resource_type)}"\s+"{re.escape(resource_name)}"'
|
|
98
|
+
return self._find_pattern_line(content, pattern)
|
|
99
|
+
|
|
100
|
+
def _find_data_line(
|
|
101
|
+
self, content: str, data_type: str, data_name: str
|
|
102
|
+
) -> int:
|
|
103
|
+
"""Find the line number where a data source is defined."""
|
|
104
|
+
pattern = rf'data\s+"{re.escape(data_type)}"\s+"{re.escape(data_name)}"'
|
|
105
|
+
return self._find_pattern_line(content, pattern)
|
|
106
|
+
|
|
107
|
+
def _find_pattern_line(self, content: str, pattern: str) -> int:
|
|
108
|
+
"""Find line number matching a pattern."""
|
|
109
|
+
lines = content.split("\n")
|
|
110
|
+
for i, line in enumerate(lines, start=1):
|
|
111
|
+
if re.search(pattern, line):
|
|
112
|
+
return i
|
|
113
|
+
return 1 # Default to line 1 if not found
|
|
114
|
+
|
|
115
|
+
def _get_provider(self, resource_type: str) -> str:
|
|
116
|
+
"""Determine provider from resource type."""
|
|
117
|
+
for prefix, provider in self.PROVIDER_PREFIXES.items():
|
|
118
|
+
if resource_type.startswith(prefix):
|
|
119
|
+
return provider
|
|
120
|
+
return "unknown"
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def supported_extensions(cls) -> list[str]:
|
|
124
|
+
"""Return supported file extensions."""
|
|
125
|
+
return [".tf"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TerraformPlanParser(IaCParser):
|
|
129
|
+
"""Parser for Terraform plan JSON output."""
|
|
130
|
+
|
|
131
|
+
def parse(self, content: str, file_path: str = "<string>") -> ParseResult:
|
|
132
|
+
"""Parse Terraform plan JSON.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
content: JSON plan output.
|
|
136
|
+
file_path: Path to the file.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
ParseResult with planned resources.
|
|
140
|
+
"""
|
|
141
|
+
import json
|
|
142
|
+
|
|
143
|
+
result = ParseResult()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
plan = json.loads(content)
|
|
147
|
+
except json.JSONDecodeError as e:
|
|
148
|
+
result.errors.append(f"Failed to parse {file_path}: {e}")
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
# Extract planned resources from resource_changes
|
|
152
|
+
for change in plan.get("resource_changes", []):
|
|
153
|
+
if change.get("change", {}).get("actions", []) == ["no-op"]:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
resource_type = change.get("type", "unknown")
|
|
157
|
+
resource_name = change.get("name", "unknown")
|
|
158
|
+
|
|
159
|
+
# Get the planned values
|
|
160
|
+
after = change.get("change", {}).get("after", {})
|
|
161
|
+
|
|
162
|
+
resource = IaCResource(
|
|
163
|
+
resource_type=resource_type,
|
|
164
|
+
name=resource_name,
|
|
165
|
+
config=after if isinstance(after, dict) else {},
|
|
166
|
+
file_path=file_path,
|
|
167
|
+
line_number=1,
|
|
168
|
+
provider=change.get("provider_name", "unknown").split("/")[-1],
|
|
169
|
+
)
|
|
170
|
+
result.resources.append(resource)
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def supported_extensions(cls) -> list[str]:
|
|
176
|
+
"""Return supported file extensions."""
|
|
177
|
+
return [".tfplan.json", ".tfplan"]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""IaC scanner for detecting security misconfigurations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from security_use.models import IaCFinding, ScanResult
|
|
7
|
+
from security_use.iac.base import IaCParser, IaCResource
|
|
8
|
+
from security_use.iac.terraform import TerraformParser
|
|
9
|
+
from security_use.iac.cloudformation import CloudFormationParser
|
|
10
|
+
from security_use.iac.rules.registry import get_registry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IaCScanner:
|
|
14
|
+
"""Scanner for Infrastructure as Code files."""
|
|
15
|
+
|
|
16
|
+
# File extensions to parser mapping
|
|
17
|
+
PARSERS: dict[str, type[IaCParser]] = {
|
|
18
|
+
".tf": TerraformParser,
|
|
19
|
+
".yaml": CloudFormationParser,
|
|
20
|
+
".yml": CloudFormationParser,
|
|
21
|
+
".json": CloudFormationParser,
|
|
22
|
+
".template": CloudFormationParser,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Patterns for identifying IaC files
|
|
26
|
+
IAC_FILE_PATTERNS = [
|
|
27
|
+
"*.tf",
|
|
28
|
+
"*.yaml",
|
|
29
|
+
"*.yml",
|
|
30
|
+
"*.json",
|
|
31
|
+
"**/terraform/**/*.tf",
|
|
32
|
+
"**/cloudformation/**/*.yaml",
|
|
33
|
+
"**/cloudformation/**/*.yml",
|
|
34
|
+
"**/cdk.out/**/*.json",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Initialize the IaC scanner."""
|
|
39
|
+
self._registry = get_registry()
|
|
40
|
+
|
|
41
|
+
def scan_path(self, path: Path) -> ScanResult:
|
|
42
|
+
"""Scan a path for IaC security issues.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: File or directory path to scan.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
ScanResult containing IaC findings.
|
|
49
|
+
"""
|
|
50
|
+
result = ScanResult()
|
|
51
|
+
|
|
52
|
+
if path.is_file():
|
|
53
|
+
files = [path]
|
|
54
|
+
else:
|
|
55
|
+
files = self._find_iac_files(path)
|
|
56
|
+
|
|
57
|
+
for file_path in files:
|
|
58
|
+
try:
|
|
59
|
+
content = file_path.read_text(encoding="utf-8")
|
|
60
|
+
file_result = self.scan_content(content, str(file_path))
|
|
61
|
+
result.iac_findings.extend(file_result.iac_findings)
|
|
62
|
+
result.scanned_files.append(str(file_path))
|
|
63
|
+
result.errors.extend(file_result.errors)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
result.errors.append(f"Error scanning {file_path}: {e}")
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def scan_content(self, content: str, file_path: str) -> ScanResult:
|
|
70
|
+
"""Scan IaC file content for security issues.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
content: The file content to scan.
|
|
74
|
+
file_path: Path to the file (for parser selection and reporting).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
ScanResult containing IaC findings.
|
|
78
|
+
"""
|
|
79
|
+
result = ScanResult()
|
|
80
|
+
|
|
81
|
+
# Get appropriate parser
|
|
82
|
+
parser = self._get_parser(file_path)
|
|
83
|
+
if parser is None:
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
# Parse the file
|
|
87
|
+
parse_result = parser.parse(content, file_path)
|
|
88
|
+
result.errors.extend(parse_result.errors)
|
|
89
|
+
|
|
90
|
+
if not parse_result.resources:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# Evaluate rules against resources
|
|
94
|
+
findings = self._evaluate_resources(parse_result.resources, file_path)
|
|
95
|
+
result.iac_findings = findings
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
def _evaluate_resources(
|
|
100
|
+
self, resources: list[IaCResource], file_path: str
|
|
101
|
+
) -> list[IaCFinding]:
|
|
102
|
+
"""Evaluate security rules against resources.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
resources: List of parsed IaC resources.
|
|
106
|
+
file_path: Path to the source file.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of IaC findings.
|
|
110
|
+
"""
|
|
111
|
+
findings = []
|
|
112
|
+
rules = self._registry.get_all()
|
|
113
|
+
|
|
114
|
+
for resource in resources:
|
|
115
|
+
for rule in rules:
|
|
116
|
+
if rule.applies_to(resource):
|
|
117
|
+
rule_result = rule.evaluate(resource)
|
|
118
|
+
if not rule_result.passed:
|
|
119
|
+
finding = IaCFinding(
|
|
120
|
+
rule_id=rule_result.rule_id,
|
|
121
|
+
title=rule_result.title,
|
|
122
|
+
severity=rule_result.severity,
|
|
123
|
+
resource_type=resource.resource_type,
|
|
124
|
+
resource_name=resource.name,
|
|
125
|
+
file_path=file_path,
|
|
126
|
+
line_number=resource.line_number,
|
|
127
|
+
description=rule_result.description,
|
|
128
|
+
remediation=rule_result.remediation,
|
|
129
|
+
fix_code=rule_result.fix_code,
|
|
130
|
+
)
|
|
131
|
+
findings.append(finding)
|
|
132
|
+
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
def _find_iac_files(self, directory: Path) -> list[Path]:
|
|
136
|
+
"""Find all IaC files in a directory.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
directory: Directory to search.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of IaC file paths.
|
|
143
|
+
"""
|
|
144
|
+
files = []
|
|
145
|
+
|
|
146
|
+
# Find Terraform files
|
|
147
|
+
files.extend(directory.rglob("*.tf"))
|
|
148
|
+
|
|
149
|
+
# Find CloudFormation files (exclude node_modules, etc.)
|
|
150
|
+
for ext in [".yaml", ".yml", ".json"]:
|
|
151
|
+
for file_path in directory.rglob(f"*{ext}"):
|
|
152
|
+
# Skip common non-IaC directories
|
|
153
|
+
if self._should_skip_path(file_path):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Check if it looks like a CloudFormation template
|
|
157
|
+
if self._is_likely_cloudformation(file_path):
|
|
158
|
+
files.append(file_path)
|
|
159
|
+
|
|
160
|
+
return files
|
|
161
|
+
|
|
162
|
+
def _should_skip_path(self, path: Path) -> bool:
|
|
163
|
+
"""Check if a path should be skipped."""
|
|
164
|
+
skip_dirs = {
|
|
165
|
+
"node_modules",
|
|
166
|
+
".git",
|
|
167
|
+
".terraform",
|
|
168
|
+
"__pycache__",
|
|
169
|
+
"venv",
|
|
170
|
+
".venv",
|
|
171
|
+
"dist",
|
|
172
|
+
"build",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for part in path.parts:
|
|
176
|
+
if part in skip_dirs:
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def _is_likely_cloudformation(self, path: Path) -> bool:
|
|
181
|
+
"""Check if a file is likely a CloudFormation template."""
|
|
182
|
+
# Check common CloudFormation directory names
|
|
183
|
+
cf_dirs = {"cloudformation", "cfn", "templates", "cdk.out"}
|
|
184
|
+
if any(d in str(path).lower() for d in cf_dirs):
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
# Check filename patterns
|
|
188
|
+
name = path.name.lower()
|
|
189
|
+
if any(
|
|
190
|
+
pattern in name
|
|
191
|
+
for pattern in ["template", "stack", "cloudformation", "cfn"]
|
|
192
|
+
):
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
# For JSON/YAML files in root, we'd need to peek at content
|
|
196
|
+
# to determine if it's CloudFormation
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def _get_parser(self, file_path: str) -> Optional[IaCParser]:
|
|
200
|
+
"""Get the appropriate parser for a file.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_path: Path to the file.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Parser instance or None if unsupported.
|
|
207
|
+
"""
|
|
208
|
+
path = Path(file_path)
|
|
209
|
+
suffix = path.suffix.lower()
|
|
210
|
+
|
|
211
|
+
parser_class = self.PARSERS.get(suffix)
|
|
212
|
+
if parser_class:
|
|
213
|
+
return parser_class()
|
|
214
|
+
|
|
215
|
+
return None
|
security_use/models.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Data models for security scan results."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Severity(Enum):
|
|
9
|
+
"""Vulnerability severity levels."""
|
|
10
|
+
|
|
11
|
+
CRITICAL = "CRITICAL"
|
|
12
|
+
HIGH = "HIGH"
|
|
13
|
+
MEDIUM = "MEDIUM"
|
|
14
|
+
LOW = "LOW"
|
|
15
|
+
UNKNOWN = "UNKNOWN"
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_cvss(cls, score: Optional[float]) -> "Severity":
|
|
19
|
+
"""Convert CVSS score to severity level."""
|
|
20
|
+
if score is None:
|
|
21
|
+
return cls.UNKNOWN
|
|
22
|
+
if score >= 9.0:
|
|
23
|
+
return cls.CRITICAL
|
|
24
|
+
if score >= 7.0:
|
|
25
|
+
return cls.HIGH
|
|
26
|
+
if score >= 4.0:
|
|
27
|
+
return cls.MEDIUM
|
|
28
|
+
return cls.LOW
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Vulnerability:
|
|
33
|
+
"""Represents a vulnerability in a dependency."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
package: str
|
|
37
|
+
installed_version: str
|
|
38
|
+
severity: Severity
|
|
39
|
+
title: str
|
|
40
|
+
description: str
|
|
41
|
+
affected_versions: str
|
|
42
|
+
fixed_version: Optional[str] = None
|
|
43
|
+
cvss_score: Optional[float] = None
|
|
44
|
+
references: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
"""Convert to dictionary representation."""
|
|
48
|
+
return {
|
|
49
|
+
"id": self.id,
|
|
50
|
+
"package": self.package,
|
|
51
|
+
"installed_version": self.installed_version,
|
|
52
|
+
"severity": self.severity.value,
|
|
53
|
+
"title": self.title,
|
|
54
|
+
"description": self.description,
|
|
55
|
+
"affected_versions": self.affected_versions,
|
|
56
|
+
"fixed_version": self.fixed_version,
|
|
57
|
+
"cvss_score": self.cvss_score,
|
|
58
|
+
"references": self.references,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class IaCFinding:
|
|
64
|
+
"""Represents a security finding in Infrastructure as Code."""
|
|
65
|
+
|
|
66
|
+
rule_id: str
|
|
67
|
+
title: str
|
|
68
|
+
severity: Severity
|
|
69
|
+
resource_type: str
|
|
70
|
+
resource_name: str
|
|
71
|
+
file_path: str
|
|
72
|
+
line_number: int
|
|
73
|
+
description: str
|
|
74
|
+
remediation: str
|
|
75
|
+
fix_code: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
"""Convert to dictionary representation."""
|
|
79
|
+
return {
|
|
80
|
+
"rule_id": self.rule_id,
|
|
81
|
+
"title": self.title,
|
|
82
|
+
"severity": self.severity.value,
|
|
83
|
+
"resource_type": self.resource_type,
|
|
84
|
+
"resource_name": self.resource_name,
|
|
85
|
+
"file_path": self.file_path,
|
|
86
|
+
"line_number": self.line_number,
|
|
87
|
+
"description": self.description,
|
|
88
|
+
"remediation": self.remediation,
|
|
89
|
+
"fix_code": self.fix_code,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ScanResult:
|
|
95
|
+
"""Combined result from all security scans."""
|
|
96
|
+
|
|
97
|
+
vulnerabilities: list[Vulnerability] = field(default_factory=list)
|
|
98
|
+
iac_findings: list[IaCFinding] = field(default_factory=list)
|
|
99
|
+
scanned_files: list[str] = field(default_factory=list)
|
|
100
|
+
errors: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def total_issues(self) -> int:
|
|
104
|
+
"""Total number of security issues found."""
|
|
105
|
+
return len(self.vulnerabilities) + len(self.iac_findings)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def critical_count(self) -> int:
|
|
109
|
+
"""Count of critical severity issues."""
|
|
110
|
+
return sum(
|
|
111
|
+
1
|
|
112
|
+
for v in self.vulnerabilities
|
|
113
|
+
if v.severity == Severity.CRITICAL
|
|
114
|
+
) + sum(
|
|
115
|
+
1
|
|
116
|
+
for f in self.iac_findings
|
|
117
|
+
if f.severity == Severity.CRITICAL
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def high_count(self) -> int:
|
|
122
|
+
"""Count of high severity issues."""
|
|
123
|
+
return sum(
|
|
124
|
+
1 for v in self.vulnerabilities if v.severity == Severity.HIGH
|
|
125
|
+
) + sum(1 for f in self.iac_findings if f.severity == Severity.HIGH)
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict:
|
|
128
|
+
"""Convert to dictionary representation."""
|
|
129
|
+
return {
|
|
130
|
+
"vulnerabilities": [v.to_dict() for v in self.vulnerabilities],
|
|
131
|
+
"iac_findings": [f.to_dict() for f in self.iac_findings],
|
|
132
|
+
"scanned_files": self.scanned_files,
|
|
133
|
+
"errors": self.errors,
|
|
134
|
+
"summary": {
|
|
135
|
+
"total_issues": self.total_issues,
|
|
136
|
+
"critical": self.critical_count,
|
|
137
|
+
"high": self.high_count,
|
|
138
|
+
},
|
|
139
|
+
}
|