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.
Files changed (45) hide show
  1. security_use/__init__.py +9 -1
  2. security_use/auth/__init__.py +16 -0
  3. security_use/auth/client.py +223 -0
  4. security_use/auth/config.py +177 -0
  5. security_use/auth/oauth.py +317 -0
  6. security_use/cli.py +699 -34
  7. security_use/compliance/__init__.py +10 -0
  8. security_use/compliance/mapper.py +275 -0
  9. security_use/compliance/models.py +50 -0
  10. security_use/dependency_scanner.py +76 -30
  11. security_use/fixers/iac_fixer.py +173 -95
  12. security_use/iac/rules/azure.py +246 -0
  13. security_use/iac/rules/gcp.py +255 -0
  14. security_use/iac/rules/kubernetes.py +429 -0
  15. security_use/iac/rules/registry.py +56 -0
  16. security_use/parsers/__init__.py +18 -0
  17. security_use/parsers/base.py +2 -0
  18. security_use/parsers/composer.py +101 -0
  19. security_use/parsers/conda.py +97 -0
  20. security_use/parsers/dotnet.py +89 -0
  21. security_use/parsers/gradle.py +90 -0
  22. security_use/parsers/maven.py +108 -0
  23. security_use/parsers/npm.py +196 -0
  24. security_use/parsers/yarn.py +108 -0
  25. security_use/reporter.py +29 -1
  26. security_use/sbom/__init__.py +10 -0
  27. security_use/sbom/generator.py +340 -0
  28. security_use/sbom/models.py +40 -0
  29. security_use/scanner.py +15 -2
  30. security_use/sensor/__init__.py +125 -0
  31. security_use/sensor/alert_queue.py +207 -0
  32. security_use/sensor/config.py +217 -0
  33. security_use/sensor/dashboard_alerter.py +246 -0
  34. security_use/sensor/detector.py +415 -0
  35. security_use/sensor/endpoint_analyzer.py +339 -0
  36. security_use/sensor/middleware.py +521 -0
  37. security_use/sensor/models.py +140 -0
  38. security_use/sensor/webhook.py +227 -0
  39. security_use-0.2.9.dist-info/METADATA +531 -0
  40. security_use-0.2.9.dist-info/RECORD +60 -0
  41. security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
  42. security_use-0.1.1.dist-info/METADATA +0 -92
  43. security_use-0.1.1.dist-info/RECORD +0 -30
  44. {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
  45. {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/entry_points.txt +0 -0
@@ -10,182 +10,260 @@ from typing import Optional
10
10
 
11
11
 
12
12
  @dataclass
13
- class FixResult:
13
+ class IaCFixResult:
14
14
  """Result of applying or suggesting a fix."""
15
15
  success: bool
16
- file_modified: str = ""
17
- old_version: str = ""
18
- new_version: str = ""
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
- "S3_PUBLIC_ACCESS": {
29
- "pattern": r'acl\s*=\s*"public-read"',
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-read to private to prevent unauthorized public access.",
31
+ "explanation": "Changed S3 bucket ACL from public to private to prevent unauthorized access.",
32
32
  },
33
- "S3_VERSIONING_DISABLED": {
34
- "pattern": r'versioning\s*=\s*false',
35
- "replacement": 'versioning = true',
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
- "SG_OPEN_INGRESS": {
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 this to your specific IP range.",
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
- "RDS_PUBLIC": {
44
- "pattern": r'publicly_accessible\s*=\s*true',
45
- "replacement": 'publicly_accessible = false',
46
- "explanation": "Disabled public accessibility for RDS instance. Access via VPC or bastion host instead.",
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
- "EBS_UNENCRYPTED": {
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
- "IAM_WILDCARD_ACTION": {
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
- "CLOUDTRAIL_DISABLED": {
59
- "pattern": r'enable_logging\s*=\s*false',
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 fix(
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 = False
83
- ) -> FixResult:
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
- FixResult with the fix details.
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 FixResult(
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
- lines = content.split("\n")
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
- # Find the problematic section
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
- if not match:
120
- return FixResult(
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
- error=f"Could not find the issue pattern for rule {rule_id}"
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 the before snippet
126
- match_line = content[:match.start()].count("\n")
127
- start_line = max(0, match_line - 2)
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
- # Apply the fix
132
- new_content = pattern.sub(fix_info["replacement"], content, count=1)
133
- new_lines = new_content.split("\n")
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
- # Get the after snippet
136
- after_snippet = "\n".join(new_lines[start_line:end_line])
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
- # Write the fixed content
143
- path_obj.write_text(new_content)
144
- return FixResult(
233
+ path_obj.write_text(content)
234
+ return IaCFixResult(
145
235
  success=True,
146
- file_modified=file_path,
147
- diff=diff,
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=fix_info["explanation"],
241
+ explanation=explanation,
151
242
  )
152
243
  else:
153
- # Return suggested fix without applying
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
- diff=diff,
159
- explanation=fix_info["explanation"],
251
+ explanation=explanation,
160
252
  )
161
253
 
162
254
  except Exception as e:
163
- return FixResult(
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
- return list(self.fixes.keys())
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)