security-use 0.1.1__py3-none-any.whl → 0.2.9__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 +9 -1
- security_use/auth/__init__.py +16 -0
- security_use/auth/client.py +223 -0
- security_use/auth/config.py +177 -0
- security_use/auth/oauth.py +317 -0
- security_use/cli.py +699 -34
- security_use/compliance/__init__.py +10 -0
- security_use/compliance/mapper.py +275 -0
- security_use/compliance/models.py +50 -0
- security_use/dependency_scanner.py +76 -30
- security_use/fixers/iac_fixer.py +173 -95
- security_use/iac/rules/azure.py +246 -0
- security_use/iac/rules/gcp.py +255 -0
- security_use/iac/rules/kubernetes.py +429 -0
- security_use/iac/rules/registry.py +56 -0
- security_use/parsers/__init__.py +18 -0
- security_use/parsers/base.py +2 -0
- security_use/parsers/composer.py +101 -0
- security_use/parsers/conda.py +97 -0
- security_use/parsers/dotnet.py +89 -0
- security_use/parsers/gradle.py +90 -0
- security_use/parsers/maven.py +108 -0
- security_use/parsers/npm.py +196 -0
- security_use/parsers/yarn.py +108 -0
- security_use/reporter.py +29 -1
- security_use/sbom/__init__.py +10 -0
- security_use/sbom/generator.py +340 -0
- security_use/sbom/models.py +40 -0
- security_use/scanner.py +15 -2
- security_use/sensor/__init__.py +125 -0
- security_use/sensor/alert_queue.py +207 -0
- security_use/sensor/config.py +217 -0
- security_use/sensor/dashboard_alerter.py +246 -0
- security_use/sensor/detector.py +415 -0
- security_use/sensor/endpoint_analyzer.py +339 -0
- security_use/sensor/middleware.py +521 -0
- security_use/sensor/models.py +140 -0
- security_use/sensor/webhook.py +227 -0
- security_use-0.2.9.dist-info/METADATA +531 -0
- security_use-0.2.9.dist-info/RECORD +60 -0
- security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
- security_use-0.1.1.dist-info/METADATA +0 -92
- security_use-0.1.1.dist-info/RECORD +0 -30
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/entry_points.txt +0 -0
security_use/fixers/iac_fixer.py
CHANGED
|
@@ -10,182 +10,260 @@ from typing import Optional
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@dataclass
|
|
13
|
-
class
|
|
13
|
+
class IaCFixResult:
|
|
14
14
|
"""Result of applying or suggesting a fix."""
|
|
15
15
|
success: bool
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
diff: str = ""
|
|
16
|
+
file_path: str = ""
|
|
17
|
+
rule_id: str = ""
|
|
18
|
+
resource_name: str = ""
|
|
20
19
|
before: str = ""
|
|
21
20
|
after: str = ""
|
|
22
21
|
explanation: str = ""
|
|
23
22
|
error: Optional[str] = None
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
# Fix mappings for known rules
|
|
25
|
+
# Fix mappings for known rules - using CKV rule IDs from scanner
|
|
27
26
|
IAC_FIXES = {
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# S3 Bucket Rules
|
|
28
|
+
"CKV_AWS_20": { # S3 bucket with public access
|
|
29
|
+
"pattern": r'acl\s*=\s*"public-read(?:-write)?"',
|
|
30
30
|
"replacement": 'acl = "private"',
|
|
31
|
-
"explanation": "Changed S3 bucket ACL from public
|
|
31
|
+
"explanation": "Changed S3 bucket ACL from public to private to prevent unauthorized access.",
|
|
32
32
|
},
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"explanation": "Enabled S3 bucket versioning to allow recovery of accidentally deleted objects.",
|
|
33
|
+
"CKV_AWS_19": { # S3 bucket without encryption - handled via additions
|
|
34
|
+
"skip": True, # Use additions instead for cleaner handling
|
|
35
|
+
"explanation": "Added server-side encryption configuration to S3 bucket.",
|
|
37
36
|
},
|
|
38
|
-
|
|
37
|
+
# Security Group Rules
|
|
38
|
+
"CKV_AWS_23": { # Security group allows unrestricted ingress
|
|
39
39
|
"pattern": r'cidr_blocks\s*=\s*\[\s*"0\.0\.0\.0/0"\s*\]',
|
|
40
|
-
"replacement": 'cidr_blocks = ["10.0.0.0/8"]',
|
|
41
|
-
"explanation": "Restricted security group ingress to private IP range. Update
|
|
40
|
+
"replacement": 'cidr_blocks = ["10.0.0.0/8"] # TODO: Restrict to your IP range',
|
|
41
|
+
"explanation": "Restricted security group ingress to private IP range. Update to your specific IP range.",
|
|
42
42
|
},
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
43
|
+
# RDS Rules
|
|
44
|
+
"CKV_AWS_16": { # RDS instance without encryption
|
|
45
|
+
"pattern": r'(resource\s+"aws_db_instance"\s+"[^"]+"\s*\{)',
|
|
46
|
+
"replacement": r'\1\n storage_encrypted = true',
|
|
47
|
+
"explanation": "Enabled storage encryption for RDS instance.",
|
|
48
|
+
"multiline": True,
|
|
47
49
|
},
|
|
48
|
-
|
|
50
|
+
# EBS Rules
|
|
51
|
+
"CKV_AWS_3": { # EBS volume without encryption
|
|
49
52
|
"pattern": r'encrypted\s*=\s*false',
|
|
50
53
|
"replacement": 'encrypted = true',
|
|
51
54
|
"explanation": "Enabled EBS volume encryption to protect data at rest.",
|
|
52
55
|
},
|
|
53
|
-
|
|
56
|
+
# CloudTrail Rules
|
|
57
|
+
"CKV_AWS_35": { # CloudTrail not logging all events
|
|
58
|
+
"pattern": r'is_multi_region_trail\s*=\s*false',
|
|
59
|
+
"replacement": 'is_multi_region_trail = true',
|
|
60
|
+
"explanation": "Enabled multi-region CloudTrail logging.",
|
|
61
|
+
},
|
|
62
|
+
# IAM Rules
|
|
63
|
+
"CKV_AWS_40": { # IAM policy with wildcard action
|
|
54
64
|
"pattern": r'"Action"\s*:\s*"\*"',
|
|
55
|
-
"replacement": '"Action": ["s3:GetObject", "s3:PutObject"]',
|
|
65
|
+
"replacement": '"Action": ["s3:GetObject", "s3:PutObject"] # TODO: Specify required actions',
|
|
56
66
|
"explanation": "Replaced wildcard action with specific actions. Update to your required actions.",
|
|
57
67
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"replacement": 'enable_logging = true',
|
|
61
|
-
"explanation": "Enabled CloudTrail logging to maintain audit trail.",
|
|
62
|
-
},
|
|
63
|
-
"KMS_KEY_ROTATION": {
|
|
68
|
+
# KMS Rules
|
|
69
|
+
"CKV_AWS_7": { # KMS key rotation disabled
|
|
64
70
|
"pattern": r'enable_key_rotation\s*=\s*false',
|
|
65
71
|
"replacement": 'enable_key_rotation = true',
|
|
66
72
|
"explanation": "Enabled KMS key rotation for improved security compliance.",
|
|
67
73
|
},
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
# Additional patterns for adding missing configurations
|
|
77
|
+
IAC_ADDITIONS = {
|
|
78
|
+
"CKV_AWS_19": { # S3 bucket without encryption - add block if not present
|
|
79
|
+
"resource_type": "aws_s3_bucket",
|
|
80
|
+
"check_pattern": r'server_side_encryption_configuration',
|
|
81
|
+
"add_block": '''
|
|
82
|
+
server_side_encryption_configuration {
|
|
83
|
+
rule {
|
|
84
|
+
apply_server_side_encryption_by_default {
|
|
85
|
+
sse_algorithm = "AES256"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}''',
|
|
89
|
+
"explanation": "Added server-side encryption configuration to S3 bucket.",
|
|
90
|
+
},
|
|
91
|
+
"CKV_AWS_3": { # EBS volume - add encrypted = true if not present
|
|
92
|
+
"resource_type": "aws_ebs_volume",
|
|
93
|
+
"check_pattern": r'encrypted\s*=',
|
|
94
|
+
"add_block": '\n encrypted = true',
|
|
95
|
+
"explanation": "Added encryption setting to EBS volume.",
|
|
96
|
+
},
|
|
97
|
+
"CKV_AWS_16": { # RDS - add storage_encrypted if not present
|
|
98
|
+
"resource_type": "aws_db_instance",
|
|
99
|
+
"check_pattern": r'storage_encrypted\s*=',
|
|
100
|
+
"add_block": '\n storage_encrypted = true',
|
|
101
|
+
"explanation": "Added storage encryption to RDS instance.",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
70
105
|
|
|
71
106
|
class IaCFixer:
|
|
72
107
|
"""Fixer for Infrastructure as Code security issues."""
|
|
73
108
|
|
|
74
109
|
def __init__(self):
|
|
75
110
|
self.fixes = IAC_FIXES
|
|
111
|
+
self.additions = IAC_ADDITIONS
|
|
76
112
|
|
|
77
|
-
def
|
|
113
|
+
def fix_finding(
|
|
78
114
|
self,
|
|
79
115
|
file_path: str,
|
|
80
116
|
rule_id: str,
|
|
117
|
+
resource_name: str,
|
|
81
118
|
line_number: Optional[int] = None,
|
|
82
|
-
auto_apply: bool =
|
|
83
|
-
) ->
|
|
119
|
+
auto_apply: bool = True
|
|
120
|
+
) -> IaCFixResult:
|
|
84
121
|
"""Fix an IaC security issue.
|
|
85
122
|
|
|
86
123
|
Args:
|
|
87
124
|
file_path: Path to the IaC file.
|
|
88
125
|
rule_id: ID of the security rule that was violated.
|
|
126
|
+
resource_name: Name of the resource with the issue.
|
|
89
127
|
line_number: Optional line number where the issue is located.
|
|
90
128
|
auto_apply: If True, apply the fix. If False, only suggest.
|
|
91
129
|
|
|
92
130
|
Returns:
|
|
93
|
-
|
|
131
|
+
IaCFixResult with the fix details.
|
|
94
132
|
"""
|
|
95
133
|
path_obj = Path(file_path)
|
|
96
134
|
|
|
97
135
|
if not path_obj.exists():
|
|
98
|
-
return
|
|
136
|
+
return IaCFixResult(
|
|
99
137
|
success=False,
|
|
138
|
+
file_path=file_path,
|
|
139
|
+
rule_id=rule_id,
|
|
100
140
|
error=f"File does not exist: {file_path}"
|
|
101
141
|
)
|
|
102
142
|
|
|
103
|
-
if rule_id not in self.fixes:
|
|
104
|
-
return FixResult(
|
|
105
|
-
success=False,
|
|
106
|
-
error=f"No fix available for rule: {rule_id}"
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
fix_info = self.fixes[rule_id]
|
|
110
|
-
|
|
111
143
|
try:
|
|
112
144
|
content = path_obj.read_text()
|
|
113
|
-
|
|
145
|
+
original_content = content
|
|
146
|
+
fix_applied = False
|
|
147
|
+
explanation = ""
|
|
148
|
+
|
|
149
|
+
# Try pattern replacement first
|
|
150
|
+
if rule_id in self.fixes:
|
|
151
|
+
fix_info = self.fixes[rule_id]
|
|
152
|
+
|
|
153
|
+
# Skip if marked to use additions instead
|
|
154
|
+
if fix_info.get("skip"):
|
|
155
|
+
pass # Fall through to additions
|
|
156
|
+
else:
|
|
157
|
+
flags = re.IGNORECASE | re.MULTILINE
|
|
158
|
+
if fix_info.get("multiline"):
|
|
159
|
+
flags |= re.DOTALL
|
|
114
160
|
|
|
115
|
-
|
|
116
|
-
pattern = re.compile(fix_info["pattern"], re.IGNORECASE | re.MULTILINE)
|
|
117
|
-
match = pattern.search(content)
|
|
161
|
+
pattern = re.compile(fix_info["pattern"], flags)
|
|
118
162
|
|
|
119
|
-
|
|
120
|
-
|
|
163
|
+
if pattern.search(content):
|
|
164
|
+
content = pattern.sub(fix_info["replacement"], content, count=1)
|
|
165
|
+
fix_applied = True
|
|
166
|
+
explanation = fix_info["explanation"]
|
|
167
|
+
|
|
168
|
+
# Try adding missing configuration blocks
|
|
169
|
+
if not fix_applied and rule_id in self.additions:
|
|
170
|
+
add_info = self.additions[rule_id]
|
|
171
|
+
resource_type = add_info["resource_type"]
|
|
172
|
+
check_pattern = add_info["check_pattern"]
|
|
173
|
+
|
|
174
|
+
# Check if already has this configuration in the file
|
|
175
|
+
if re.search(check_pattern, content, re.IGNORECASE):
|
|
176
|
+
return IaCFixResult(
|
|
177
|
+
success=False,
|
|
178
|
+
file_path=file_path,
|
|
179
|
+
rule_id=rule_id,
|
|
180
|
+
resource_name=resource_name,
|
|
181
|
+
error="Configuration already exists in file"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Find the resource block - match balanced braces
|
|
185
|
+
resource_start_pattern = rf'resource\s+"{resource_type}"\s+"{re.escape(resource_name)}"\s*\{{'
|
|
186
|
+
match = re.search(resource_start_pattern, content)
|
|
187
|
+
|
|
188
|
+
if match:
|
|
189
|
+
# Find the matching closing brace
|
|
190
|
+
start_pos = match.end() - 1 # Position of opening brace
|
|
191
|
+
brace_count = 1
|
|
192
|
+
pos = start_pos + 1
|
|
193
|
+
|
|
194
|
+
while pos < len(content) and brace_count > 0:
|
|
195
|
+
if content[pos] == '{':
|
|
196
|
+
brace_count += 1
|
|
197
|
+
elif content[pos] == '}':
|
|
198
|
+
brace_count -= 1
|
|
199
|
+
pos += 1
|
|
200
|
+
|
|
201
|
+
if brace_count == 0:
|
|
202
|
+
# Insert the new block before the closing brace
|
|
203
|
+
insert_pos = pos - 1
|
|
204
|
+
new_content = content[:insert_pos] + add_info["add_block"] + "\n" + content[insert_pos:]
|
|
205
|
+
content = new_content
|
|
206
|
+
fix_applied = True
|
|
207
|
+
explanation = add_info["explanation"]
|
|
208
|
+
|
|
209
|
+
if not fix_applied:
|
|
210
|
+
return IaCFixResult(
|
|
121
211
|
success=False,
|
|
122
|
-
|
|
212
|
+
file_path=file_path,
|
|
213
|
+
rule_id=rule_id,
|
|
214
|
+
resource_name=resource_name,
|
|
215
|
+
error=f"No automatic fix available for rule {rule_id} or pattern not found"
|
|
123
216
|
)
|
|
124
217
|
|
|
125
|
-
# Get
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
end_line = min(len(lines), match_line + 5)
|
|
129
|
-
before_snippet = "\n".join(lines[start_line:end_line])
|
|
218
|
+
# Get before/after snippets
|
|
219
|
+
before_lines = original_content.split("\n")
|
|
220
|
+
after_lines = content.split("\n")
|
|
130
221
|
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
222
|
+
# Find changed region
|
|
223
|
+
start_line = 0
|
|
224
|
+
end_line = len(before_lines)
|
|
225
|
+
if line_number:
|
|
226
|
+
start_line = max(0, line_number - 3)
|
|
227
|
+
end_line = min(len(before_lines), line_number + 10)
|
|
134
228
|
|
|
135
|
-
|
|
136
|
-
after_snippet = "\n".join(
|
|
137
|
-
|
|
138
|
-
# Generate diff
|
|
139
|
-
diff = self._generate_diff(content, new_content)
|
|
229
|
+
before_snippet = "\n".join(before_lines[start_line:end_line])
|
|
230
|
+
after_snippet = "\n".join(after_lines[start_line:min(len(after_lines), end_line + 5)])
|
|
140
231
|
|
|
141
232
|
if auto_apply:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return FixResult(
|
|
233
|
+
path_obj.write_text(content)
|
|
234
|
+
return IaCFixResult(
|
|
145
235
|
success=True,
|
|
146
|
-
|
|
147
|
-
|
|
236
|
+
file_path=file_path,
|
|
237
|
+
rule_id=rule_id,
|
|
238
|
+
resource_name=resource_name,
|
|
148
239
|
before=before_snippet,
|
|
149
240
|
after=after_snippet,
|
|
150
|
-
explanation=
|
|
241
|
+
explanation=explanation,
|
|
151
242
|
)
|
|
152
243
|
else:
|
|
153
|
-
|
|
154
|
-
return FixResult(
|
|
244
|
+
return IaCFixResult(
|
|
155
245
|
success=True,
|
|
246
|
+
file_path=file_path,
|
|
247
|
+
rule_id=rule_id,
|
|
248
|
+
resource_name=resource_name,
|
|
156
249
|
before=before_snippet,
|
|
157
250
|
after=after_snippet,
|
|
158
|
-
|
|
159
|
-
explanation=fix_info["explanation"],
|
|
251
|
+
explanation=explanation,
|
|
160
252
|
)
|
|
161
253
|
|
|
162
254
|
except Exception as e:
|
|
163
|
-
return
|
|
255
|
+
return IaCFixResult(
|
|
164
256
|
success=False,
|
|
257
|
+
file_path=file_path,
|
|
258
|
+
rule_id=rule_id,
|
|
165
259
|
error=str(e)
|
|
166
260
|
)
|
|
167
261
|
|
|
168
|
-
def _generate_diff(self, old_content: str, new_content: str) -> str:
|
|
169
|
-
"""Generate a unified diff of the changes."""
|
|
170
|
-
old_lines = old_content.split("\n")
|
|
171
|
-
new_lines = new_content.split("\n")
|
|
172
|
-
|
|
173
|
-
diff_lines = []
|
|
174
|
-
for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
|
|
175
|
-
if old_line != new_line:
|
|
176
|
-
diff_lines.append(f"-{old_line}")
|
|
177
|
-
diff_lines.append(f"+{new_line}")
|
|
178
|
-
|
|
179
|
-
# Handle length differences
|
|
180
|
-
if len(old_lines) > len(new_lines):
|
|
181
|
-
for line in old_lines[len(new_lines):]:
|
|
182
|
-
diff_lines.append(f"-{line}")
|
|
183
|
-
elif len(new_lines) > len(old_lines):
|
|
184
|
-
for line in new_lines[len(old_lines):]:
|
|
185
|
-
diff_lines.append(f"+{line}")
|
|
186
|
-
|
|
187
|
-
return "\n".join(diff_lines) if diff_lines else "No changes"
|
|
188
|
-
|
|
189
262
|
def get_available_fixes(self) -> list[str]:
|
|
190
263
|
"""Get list of rule IDs that have available fixes."""
|
|
191
|
-
|
|
264
|
+
all_rules = set(self.fixes.keys()) | set(self.additions.keys())
|
|
265
|
+
return sorted(list(all_rules))
|
|
266
|
+
|
|
267
|
+
def has_fix(self, rule_id: str) -> bool:
|
|
268
|
+
"""Check if a fix is available for a rule."""
|
|
269
|
+
return rule_id in self.fixes or rule_id in self.additions
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Azure security rules for IaC scanning."""
|
|
2
|
+
|
|
3
|
+
from security_use.models import Severity
|
|
4
|
+
from security_use.iac.base import IaCResource
|
|
5
|
+
from security_use.iac.rules.base import Rule, RuleResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AzureStoragePublicAccessRule(Rule):
|
|
9
|
+
"""Check that Azure Storage accounts do not allow public access."""
|
|
10
|
+
|
|
11
|
+
RULE_ID = "CKV_AZURE_19"
|
|
12
|
+
TITLE = "Storage account with public access"
|
|
13
|
+
SEVERITY = Severity.CRITICAL
|
|
14
|
+
DESCRIPTION = (
|
|
15
|
+
"Azure Storage account allows public access. This can expose "
|
|
16
|
+
"sensitive data to unauthorized users."
|
|
17
|
+
)
|
|
18
|
+
REMEDIATION = (
|
|
19
|
+
"Set allow_blob_public_access to false and configure private endpoints."
|
|
20
|
+
)
|
|
21
|
+
RESOURCE_TYPES = ["azurerm_storage_account", "Microsoft.Storage/storageAccounts"]
|
|
22
|
+
|
|
23
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
24
|
+
"""Check if Storage account allows public access."""
|
|
25
|
+
# Terraform
|
|
26
|
+
allow_public = resource.get_config("allow_blob_public_access", default=True)
|
|
27
|
+
|
|
28
|
+
# ARM template
|
|
29
|
+
if allow_public:
|
|
30
|
+
properties = resource.get_config("properties", default={})
|
|
31
|
+
allow_public = properties.get("allowBlobPublicAccess", True)
|
|
32
|
+
|
|
33
|
+
fix_code = None
|
|
34
|
+
if allow_public:
|
|
35
|
+
fix_code = "allow_blob_public_access = false"
|
|
36
|
+
|
|
37
|
+
return self._create_result(not allow_public, resource, fix_code)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AzureStorageEncryptionRule(Rule):
|
|
41
|
+
"""Check that Azure Storage accounts have encryption enabled."""
|
|
42
|
+
|
|
43
|
+
RULE_ID = "CKV_AZURE_3"
|
|
44
|
+
TITLE = "Storage account without encryption"
|
|
45
|
+
SEVERITY = Severity.HIGH
|
|
46
|
+
DESCRIPTION = (
|
|
47
|
+
"Azure Storage account does not have encryption at rest enabled. "
|
|
48
|
+
"Data should be encrypted to protect sensitive information."
|
|
49
|
+
)
|
|
50
|
+
REMEDIATION = (
|
|
51
|
+
"Enable blob encryption services and configure customer-managed keys."
|
|
52
|
+
)
|
|
53
|
+
RESOURCE_TYPES = ["azurerm_storage_account", "Microsoft.Storage/storageAccounts"]
|
|
54
|
+
|
|
55
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
56
|
+
"""Check if Storage account has encryption enabled."""
|
|
57
|
+
has_encryption = False
|
|
58
|
+
|
|
59
|
+
# Terraform
|
|
60
|
+
blob_properties = resource.get_config("blob_properties", default={})
|
|
61
|
+
if blob_properties:
|
|
62
|
+
has_encryption = True
|
|
63
|
+
|
|
64
|
+
# ARM template - encryption is enabled by default since 2017
|
|
65
|
+
properties = resource.get_config("properties", default={})
|
|
66
|
+
encryption = properties.get("encryption", {})
|
|
67
|
+
if encryption.get("services", {}).get("blob", {}).get("enabled"):
|
|
68
|
+
has_encryption = True
|
|
69
|
+
|
|
70
|
+
# Check for minimum TLS version
|
|
71
|
+
min_tls = resource.get_config("min_tls_version", default="")
|
|
72
|
+
if min_tls == "TLS1_2":
|
|
73
|
+
has_encryption = True
|
|
74
|
+
|
|
75
|
+
return self._create_result(has_encryption, resource)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AzureNSGOpenIngressRule(Rule):
|
|
79
|
+
"""Check that Azure NSG doesn't allow unrestricted ingress."""
|
|
80
|
+
|
|
81
|
+
RULE_ID = "CKV_AZURE_9"
|
|
82
|
+
TITLE = "NSG allows unrestricted inbound traffic"
|
|
83
|
+
SEVERITY = Severity.HIGH
|
|
84
|
+
DESCRIPTION = (
|
|
85
|
+
"Network Security Group allows inbound traffic from 0.0.0.0/0 or * "
|
|
86
|
+
"on sensitive ports. This exposes services to the entire internet."
|
|
87
|
+
)
|
|
88
|
+
REMEDIATION = (
|
|
89
|
+
"Restrict source addresses to specific IP ranges or Azure service tags. "
|
|
90
|
+
"Avoid using * or 0.0.0.0/0 as the source."
|
|
91
|
+
)
|
|
92
|
+
RESOURCE_TYPES = [
|
|
93
|
+
"azurerm_network_security_rule",
|
|
94
|
+
"azurerm_network_security_group",
|
|
95
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
SENSITIVE_PORTS = ["22", "3389", "3306", "5432", "1433", "27017", "6379"]
|
|
99
|
+
|
|
100
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
101
|
+
"""Check if NSG has open ingress on sensitive ports."""
|
|
102
|
+
has_open_ingress = False
|
|
103
|
+
|
|
104
|
+
# Terraform: Check security_rule blocks
|
|
105
|
+
rules = resource.get_config("security_rule", default=[])
|
|
106
|
+
if isinstance(rules, list):
|
|
107
|
+
for rule in rules:
|
|
108
|
+
if self._is_open_rule(rule):
|
|
109
|
+
has_open_ingress = True
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
# Standalone security rule
|
|
113
|
+
if resource.resource_type == "azurerm_network_security_rule":
|
|
114
|
+
if self._is_open_rule(resource.config):
|
|
115
|
+
has_open_ingress = True
|
|
116
|
+
|
|
117
|
+
fix_code = None
|
|
118
|
+
if has_open_ingress:
|
|
119
|
+
fix_code = "# Restrict source_address_prefix to specific IP ranges"
|
|
120
|
+
|
|
121
|
+
return self._create_result(not has_open_ingress, resource, fix_code)
|
|
122
|
+
|
|
123
|
+
def _is_open_rule(self, rule: dict) -> bool:
|
|
124
|
+
"""Check if a rule allows open inbound access."""
|
|
125
|
+
direction = rule.get("direction", "").lower()
|
|
126
|
+
access = rule.get("access", "").lower()
|
|
127
|
+
source = rule.get("source_address_prefix", "")
|
|
128
|
+
|
|
129
|
+
if direction != "inbound" or access != "allow":
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
if source not in ["*", "0.0.0.0/0", "Internet"]:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# Check ports
|
|
136
|
+
dest_port = rule.get("destination_port_range", "")
|
|
137
|
+
dest_ports = rule.get("destination_port_ranges", [])
|
|
138
|
+
|
|
139
|
+
if dest_port == "*" or "*" in dest_ports:
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
for port in self.SENSITIVE_PORTS:
|
|
143
|
+
if dest_port == port or port in dest_ports:
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class AzureSQLEncryptionRule(Rule):
|
|
150
|
+
"""Check that Azure SQL databases have encryption enabled."""
|
|
151
|
+
|
|
152
|
+
RULE_ID = "CKV_AZURE_24"
|
|
153
|
+
TITLE = "SQL database without transparent data encryption"
|
|
154
|
+
SEVERITY = Severity.HIGH
|
|
155
|
+
DESCRIPTION = (
|
|
156
|
+
"Azure SQL database does not have transparent data encryption (TDE) enabled. "
|
|
157
|
+
"TDE protects data at rest by encrypting the database files."
|
|
158
|
+
)
|
|
159
|
+
REMEDIATION = (
|
|
160
|
+
"Enable transparent data encryption for the SQL database. "
|
|
161
|
+
"TDE is enabled by default for Azure SQL Database."
|
|
162
|
+
)
|
|
163
|
+
RESOURCE_TYPES = [
|
|
164
|
+
"azurerm_mssql_database",
|
|
165
|
+
"azurerm_sql_database",
|
|
166
|
+
"Microsoft.Sql/servers/databases",
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
170
|
+
"""Check if SQL database has TDE enabled."""
|
|
171
|
+
# Terraform - check if TDE is explicitly disabled
|
|
172
|
+
transparent_encryption = resource.get_config(
|
|
173
|
+
"transparent_data_encryption_enabled", default=True
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
fix_code = None
|
|
177
|
+
if not transparent_encryption:
|
|
178
|
+
fix_code = "transparent_data_encryption_enabled = true"
|
|
179
|
+
|
|
180
|
+
return self._create_result(bool(transparent_encryption), resource, fix_code)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AzureKeyVaultSoftDeleteRule(Rule):
|
|
184
|
+
"""Check that Azure Key Vault has soft delete enabled."""
|
|
185
|
+
|
|
186
|
+
RULE_ID = "CKV_AZURE_42"
|
|
187
|
+
TITLE = "Key Vault without soft delete"
|
|
188
|
+
SEVERITY = Severity.MEDIUM
|
|
189
|
+
DESCRIPTION = (
|
|
190
|
+
"Azure Key Vault does not have soft delete enabled. "
|
|
191
|
+
"Soft delete protects against accidental deletion of secrets and keys."
|
|
192
|
+
)
|
|
193
|
+
REMEDIATION = (
|
|
194
|
+
"Enable soft delete and purge protection for the Key Vault."
|
|
195
|
+
)
|
|
196
|
+
RESOURCE_TYPES = ["azurerm_key_vault", "Microsoft.KeyVault/vaults"]
|
|
197
|
+
|
|
198
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
199
|
+
"""Check if Key Vault has soft delete enabled."""
|
|
200
|
+
# Soft delete is enabled by default since Feb 2025
|
|
201
|
+
soft_delete = resource.get_config("soft_delete_retention_days", default=90)
|
|
202
|
+
purge_protection = resource.get_config("purge_protection_enabled", default=False)
|
|
203
|
+
|
|
204
|
+
passed = soft_delete > 0 and purge_protection
|
|
205
|
+
|
|
206
|
+
fix_code = None
|
|
207
|
+
if not passed:
|
|
208
|
+
fix_code = "soft_delete_retention_days = 90\npurge_protection_enabled = true"
|
|
209
|
+
|
|
210
|
+
return self._create_result(passed, resource, fix_code)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class AzureActivityLogRetentionRule(Rule):
|
|
214
|
+
"""Check that Azure activity logs have sufficient retention."""
|
|
215
|
+
|
|
216
|
+
RULE_ID = "CKV_AZURE_37"
|
|
217
|
+
TITLE = "Activity log with insufficient retention"
|
|
218
|
+
SEVERITY = Severity.MEDIUM
|
|
219
|
+
DESCRIPTION = (
|
|
220
|
+
"Azure activity logs do not have sufficient retention period. "
|
|
221
|
+
"Logs should be retained for at least 365 days for compliance."
|
|
222
|
+
)
|
|
223
|
+
REMEDIATION = (
|
|
224
|
+
"Configure activity log retention to at least 365 days or export to storage."
|
|
225
|
+
)
|
|
226
|
+
RESOURCE_TYPES = [
|
|
227
|
+
"azurerm_monitor_log_profile",
|
|
228
|
+
"Microsoft.Insights/logprofiles",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
def evaluate(self, resource: IaCResource) -> RuleResult:
|
|
232
|
+
"""Check if activity logs have sufficient retention."""
|
|
233
|
+
retention = resource.get_config("retention_policy", default={})
|
|
234
|
+
enabled = retention.get("enabled", False)
|
|
235
|
+
days = retention.get("days", 0)
|
|
236
|
+
|
|
237
|
+
passed = enabled and days >= 365
|
|
238
|
+
|
|
239
|
+
fix_code = None
|
|
240
|
+
if not passed:
|
|
241
|
+
fix_code = '''retention_policy {
|
|
242
|
+
enabled = true
|
|
243
|
+
days = 365
|
|
244
|
+
}'''
|
|
245
|
+
|
|
246
|
+
return self._create_result(passed, resource, fix_code)
|