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,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
+ }