complio 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.
- CHANGELOG.md +208 -0
- README.md +343 -0
- complio/__init__.py +48 -0
- complio/cli/__init__.py +0 -0
- complio/cli/banner.py +87 -0
- complio/cli/commands/__init__.py +0 -0
- complio/cli/commands/history.py +439 -0
- complio/cli/commands/scan.py +700 -0
- complio/cli/main.py +115 -0
- complio/cli/output.py +338 -0
- complio/config/__init__.py +17 -0
- complio/config/settings.py +333 -0
- complio/connectors/__init__.py +9 -0
- complio/connectors/aws/__init__.py +0 -0
- complio/connectors/aws/client.py +342 -0
- complio/connectors/base.py +135 -0
- complio/core/__init__.py +10 -0
- complio/core/registry.py +228 -0
- complio/core/runner.py +351 -0
- complio/py.typed +0 -0
- complio/reporters/__init__.py +7 -0
- complio/reporters/generator.py +417 -0
- complio/tests_library/__init__.py +0 -0
- complio/tests_library/base.py +492 -0
- complio/tests_library/identity/__init__.py +0 -0
- complio/tests_library/identity/access_key_rotation.py +302 -0
- complio/tests_library/identity/mfa_enforcement.py +327 -0
- complio/tests_library/identity/root_account_protection.py +470 -0
- complio/tests_library/infrastructure/__init__.py +0 -0
- complio/tests_library/infrastructure/cloudtrail_encryption.py +286 -0
- complio/tests_library/infrastructure/cloudtrail_log_validation.py +274 -0
- complio/tests_library/infrastructure/cloudtrail_logging.py +400 -0
- complio/tests_library/infrastructure/ebs_encryption.py +244 -0
- complio/tests_library/infrastructure/ec2_security_groups.py +321 -0
- complio/tests_library/infrastructure/iam_password_policy.py +460 -0
- complio/tests_library/infrastructure/nacl_security.py +356 -0
- complio/tests_library/infrastructure/rds_encryption.py +252 -0
- complio/tests_library/infrastructure/s3_encryption.py +301 -0
- complio/tests_library/infrastructure/s3_public_access.py +369 -0
- complio/tests_library/infrastructure/secrets_manager_encryption.py +248 -0
- complio/tests_library/infrastructure/vpc_flow_logs.py +287 -0
- complio/tests_library/logging/__init__.py +0 -0
- complio/tests_library/logging/cloudwatch_alarms.py +354 -0
- complio/tests_library/logging/cloudwatch_logs_encryption.py +281 -0
- complio/tests_library/logging/cloudwatch_retention.py +252 -0
- complio/tests_library/logging/config_enabled.py +393 -0
- complio/tests_library/logging/eventbridge_rules.py +460 -0
- complio/tests_library/logging/guardduty_enabled.py +436 -0
- complio/tests_library/logging/security_hub_enabled.py +416 -0
- complio/tests_library/logging/sns_encryption.py +273 -0
- complio/tests_library/network/__init__.py +0 -0
- complio/tests_library/network/alb_nlb_security.py +421 -0
- complio/tests_library/network/api_gateway_security.py +452 -0
- complio/tests_library/network/cloudfront_https.py +332 -0
- complio/tests_library/network/direct_connect_security.py +343 -0
- complio/tests_library/network/nacl_configuration.py +367 -0
- complio/tests_library/network/network_firewall.py +355 -0
- complio/tests_library/network/transit_gateway_security.py +318 -0
- complio/tests_library/network/vpc_endpoints_security.py +339 -0
- complio/tests_library/network/vpn_security.py +333 -0
- complio/tests_library/network/waf_configuration.py +428 -0
- complio/tests_library/security/__init__.py +0 -0
- complio/tests_library/security/kms_key_rotation.py +314 -0
- complio/tests_library/storage/__init__.py +0 -0
- complio/tests_library/storage/backup_encryption.py +288 -0
- complio/tests_library/storage/dynamodb_encryption.py +280 -0
- complio/tests_library/storage/efs_encryption.py +257 -0
- complio/tests_library/storage/elasticache_encryption.py +370 -0
- complio/tests_library/storage/redshift_encryption.py +252 -0
- complio/tests_library/storage/s3_versioning.py +264 -0
- complio/utils/__init__.py +26 -0
- complio/utils/errors.py +179 -0
- complio/utils/exceptions.py +151 -0
- complio/utils/history.py +243 -0
- complio/utils/logger.py +391 -0
- complio-0.1.1.dist-info/METADATA +385 -0
- complio-0.1.1.dist-info/RECORD +79 -0
- complio-0.1.1.dist-info/WHEEL +4 -0
- complio-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Network ACL security compliance test.
|
|
3
|
+
|
|
4
|
+
Checks that Network ACLs don't allow unrestricted access to sensitive ports.
|
|
5
|
+
|
|
6
|
+
ISO 27001 Control: A.8.20 - Network security
|
|
7
|
+
Requirement: Network access controls must restrict access to sensitive services
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from complio.connectors.aws.client import AWSConnector
|
|
11
|
+
>>> from complio.tests_library.infrastructure.nacl_security import NACLSecurityTest
|
|
12
|
+
>>>
|
|
13
|
+
>>> connector = AWSConnector("production", "us-east-1")
|
|
14
|
+
>>> connector.connect()
|
|
15
|
+
>>>
|
|
16
|
+
>>> test = NACLSecurityTest(connector)
|
|
17
|
+
>>> result = test.run()
|
|
18
|
+
>>> print(f"Passed: {result.passed}, Score: {result.score}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, List, Set
|
|
22
|
+
|
|
23
|
+
from botocore.exceptions import ClientError
|
|
24
|
+
|
|
25
|
+
from complio.connectors.aws.client import AWSConnector
|
|
26
|
+
from complio.tests_library.base import (
|
|
27
|
+
ComplianceTest,
|
|
28
|
+
Severity,
|
|
29
|
+
TestResult,
|
|
30
|
+
TestStatus,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Sensitive ports that should not be open to 0.0.0.0/0
|
|
35
|
+
SENSITIVE_PORTS = {
|
|
36
|
+
22: "SSH",
|
|
37
|
+
3389: "RDP",
|
|
38
|
+
1433: "MS SQL Server",
|
|
39
|
+
3306: "MySQL",
|
|
40
|
+
5432: "PostgreSQL",
|
|
41
|
+
27017: "MongoDB",
|
|
42
|
+
6379: "Redis",
|
|
43
|
+
9200: "Elasticsearch",
|
|
44
|
+
5984: "CouchDB",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NACLSecurityTest(ComplianceTest):
|
|
49
|
+
"""Test for Network ACL security compliance.
|
|
50
|
+
|
|
51
|
+
Verifies that Network ACLs don't allow unrestricted access (0.0.0.0/0)
|
|
52
|
+
to sensitive ports commonly used by database and management services.
|
|
53
|
+
|
|
54
|
+
Compliance Requirements:
|
|
55
|
+
- NACLs should not allow 0.0.0.0/0 access to sensitive ports
|
|
56
|
+
- Sensitive ports include: SSH (22), RDP (3389), databases (1433, 3306, 5432, 27017)
|
|
57
|
+
- Unrestricted access to these ports is HIGH severity
|
|
58
|
+
|
|
59
|
+
Scoring:
|
|
60
|
+
- 100% if all NACLs are secure
|
|
61
|
+
- Proportional score based on secure/total ratio
|
|
62
|
+
- Each insecure rule reduces the score
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> test = NACLSecurityTest(connector)
|
|
66
|
+
>>> result = test.execute()
|
|
67
|
+
>>> for finding in result.findings:
|
|
68
|
+
... print(f"{finding.resource_id}: {finding.title}")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, connector: AWSConnector) -> None:
|
|
72
|
+
"""Initialize Network ACL security test.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
connector: AWS connector instance
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(
|
|
78
|
+
test_id="nacl_security",
|
|
79
|
+
test_name="Network ACL Security Check",
|
|
80
|
+
description="Verify Network ACLs don't allow unrestricted access to sensitive ports",
|
|
81
|
+
control_id="A.8.20",
|
|
82
|
+
connector=connector,
|
|
83
|
+
scope="regional",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def execute(self) -> TestResult:
|
|
87
|
+
"""Execute Network ACL security compliance test.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
TestResult with findings for insecure NACL rules
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> test = NACLSecurityTest(connector)
|
|
94
|
+
>>> result = test.execute()
|
|
95
|
+
>>> print(result.score)
|
|
96
|
+
90.0
|
|
97
|
+
"""
|
|
98
|
+
result = TestResult(
|
|
99
|
+
test_id=self.test_id,
|
|
100
|
+
test_name=self.test_name,
|
|
101
|
+
status=TestStatus.PASSED,
|
|
102
|
+
passed=True,
|
|
103
|
+
score=100.0,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# Get EC2 client
|
|
108
|
+
ec2_client = self.connector.get_client("ec2")
|
|
109
|
+
|
|
110
|
+
# List all Network ACLs
|
|
111
|
+
self.logger.info("listing_network_acls")
|
|
112
|
+
response = ec2_client.describe_network_acls()
|
|
113
|
+
nacls = response.get("NetworkAcls", [])
|
|
114
|
+
|
|
115
|
+
if not nacls:
|
|
116
|
+
self.logger.info("no_nacls_found")
|
|
117
|
+
result.metadata["message"] = "No Network ACLs found in region"
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
self.logger.info("nacls_found", count=len(nacls))
|
|
121
|
+
|
|
122
|
+
# Check each NACL for insecure rules
|
|
123
|
+
secure_nacls = 0
|
|
124
|
+
total_nacls = len(nacls)
|
|
125
|
+
total_insecure_rules = 0
|
|
126
|
+
|
|
127
|
+
for nacl in nacls:
|
|
128
|
+
nacl_id = nacl["NetworkAclId"]
|
|
129
|
+
vpc_id = nacl.get("VpcId", "unknown")
|
|
130
|
+
is_default = nacl.get("IsDefault", False)
|
|
131
|
+
result.resources_scanned += 1
|
|
132
|
+
|
|
133
|
+
# Get NACL name from tags
|
|
134
|
+
nacl_name = "unnamed"
|
|
135
|
+
for tag in nacl.get("Tags", []):
|
|
136
|
+
if tag.get("Key") == "Name":
|
|
137
|
+
nacl_name = tag.get("Value", "unnamed")
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
# Check inbound rules for insecure configurations
|
|
141
|
+
insecure_rules = self._check_nacl_rules(nacl)
|
|
142
|
+
|
|
143
|
+
# Create evidence
|
|
144
|
+
evidence = self.create_evidence(
|
|
145
|
+
resource_id=nacl_id,
|
|
146
|
+
resource_type="network_acl",
|
|
147
|
+
data={
|
|
148
|
+
"nacl_id": nacl_id,
|
|
149
|
+
"nacl_name": nacl_name,
|
|
150
|
+
"vpc_id": vpc_id,
|
|
151
|
+
"is_default": is_default,
|
|
152
|
+
"insecure_rules_count": len(insecure_rules),
|
|
153
|
+
"insecure_rules": insecure_rules,
|
|
154
|
+
"total_entries": len(nacl.get("Entries", [])),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
result.add_evidence(evidence)
|
|
158
|
+
|
|
159
|
+
if len(insecure_rules) == 0:
|
|
160
|
+
secure_nacls += 1
|
|
161
|
+
self.logger.debug(
|
|
162
|
+
"nacl_secure",
|
|
163
|
+
nacl_id=nacl_id,
|
|
164
|
+
nacl_name=nacl_name
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
total_insecure_rules += len(insecure_rules)
|
|
168
|
+
|
|
169
|
+
# Create finding for each insecure rule
|
|
170
|
+
for insecure_rule in insecure_rules:
|
|
171
|
+
rule_number = insecure_rule["rule_number"]
|
|
172
|
+
port_range = insecure_rule["port_range"]
|
|
173
|
+
protocol = insecure_rule["protocol"]
|
|
174
|
+
service_name = insecure_rule["service_name"]
|
|
175
|
+
|
|
176
|
+
finding = self.create_finding(
|
|
177
|
+
resource_id=nacl_id,
|
|
178
|
+
resource_type="network_acl",
|
|
179
|
+
severity=Severity.HIGH,
|
|
180
|
+
title=f"Network ACL allows unrestricted access to {service_name}",
|
|
181
|
+
description=f"Network ACL '{nacl_name}' ({nacl_id}) has rule #{rule_number} that allows "
|
|
182
|
+
f"unrestricted access (0.0.0.0/0) to {service_name} on port {port_range}. "
|
|
183
|
+
f"This exposes sensitive services to the entire internet. "
|
|
184
|
+
"ISO 27001 A.8.20 requires proper network access controls.",
|
|
185
|
+
remediation=(
|
|
186
|
+
f"Restrict access to {service_name} in NACL '{nacl_id}':\n"
|
|
187
|
+
"1. Go to VPC → Network ACLs\n"
|
|
188
|
+
f"2. Select NACL '{nacl_id}'\n"
|
|
189
|
+
"3. Go to Inbound Rules tab\n"
|
|
190
|
+
f"4. Edit rule #{rule_number}\n"
|
|
191
|
+
"5. Change source from 0.0.0.0/0 to specific IP ranges\n"
|
|
192
|
+
"6. Save changes\n\n"
|
|
193
|
+
"Or use AWS CLI:\n"
|
|
194
|
+
f"# First, delete the insecure rule:\n"
|
|
195
|
+
f"aws ec2 delete-network-acl-entry --network-acl-id {nacl_id} \\\n"
|
|
196
|
+
f" --rule-number {rule_number} --ingress\n\n"
|
|
197
|
+
f"# Then, add a secure rule with specific source:\n"
|
|
198
|
+
f"aws ec2 create-network-acl-entry --network-acl-id {nacl_id} \\\n"
|
|
199
|
+
f" --rule-number {rule_number} --protocol {protocol} \\\n"
|
|
200
|
+
f" --port-range From={port_range},To={port_range} \\\n"
|
|
201
|
+
" --cidr-block <your-ip-range>/32 --rule-action allow --ingress"
|
|
202
|
+
),
|
|
203
|
+
evidence=evidence
|
|
204
|
+
)
|
|
205
|
+
result.add_finding(finding)
|
|
206
|
+
|
|
207
|
+
self.logger.warning(
|
|
208
|
+
"nacl_insecure",
|
|
209
|
+
nacl_id=nacl_id,
|
|
210
|
+
nacl_name=nacl_name,
|
|
211
|
+
insecure_rules_count=len(insecure_rules)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Calculate compliance score
|
|
215
|
+
# Score is based on the ratio of secure NACLs to total NACLs
|
|
216
|
+
if total_nacls > 0:
|
|
217
|
+
result.score = (secure_nacls / total_nacls) * 100
|
|
218
|
+
|
|
219
|
+
# Determine pass/fail
|
|
220
|
+
result.passed = secure_nacls == total_nacls
|
|
221
|
+
result.status = TestStatus.PASSED if result.passed else TestStatus.FAILED
|
|
222
|
+
|
|
223
|
+
# Add metadata
|
|
224
|
+
result.metadata = {
|
|
225
|
+
"total_nacls": total_nacls,
|
|
226
|
+
"secure_nacls": secure_nacls,
|
|
227
|
+
"insecure_nacls": total_nacls - secure_nacls,
|
|
228
|
+
"total_insecure_rules": total_insecure_rules,
|
|
229
|
+
"compliance_percentage": result.score,
|
|
230
|
+
"region": self.connector.region,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
self.logger.info(
|
|
234
|
+
"nacl_security_test_completed",
|
|
235
|
+
total=total_nacls,
|
|
236
|
+
secure=secure_nacls,
|
|
237
|
+
total_insecure_rules=total_insecure_rules,
|
|
238
|
+
score=result.score,
|
|
239
|
+
passed=result.passed
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
except ClientError as e:
|
|
243
|
+
error_code = e.response.get("Error", {}).get("Code")
|
|
244
|
+
self.logger.error("nacl_security_test_error", error_code=error_code, error=str(e))
|
|
245
|
+
result.status = TestStatus.ERROR
|
|
246
|
+
result.passed = False
|
|
247
|
+
result.score = 0.0
|
|
248
|
+
result.error_message = f"AWS API Error: {error_code} - {str(e)}"
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
self.logger.error("nacl_security_test_error", error=str(e))
|
|
252
|
+
result.status = TestStatus.ERROR
|
|
253
|
+
result.passed = False
|
|
254
|
+
result.score = 0.0
|
|
255
|
+
result.error_message = str(e)
|
|
256
|
+
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
def _check_nacl_rules(self, nacl: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
260
|
+
"""Check NACL rules for insecure configurations.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
nacl: Network ACL dictionary from AWS API
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of insecure rules with details
|
|
267
|
+
"""
|
|
268
|
+
insecure_rules = []
|
|
269
|
+
|
|
270
|
+
for entry in nacl.get("Entries", []):
|
|
271
|
+
# Only check inbound ALLOW rules
|
|
272
|
+
if entry.get("Egress", True):
|
|
273
|
+
continue # Skip egress rules
|
|
274
|
+
|
|
275
|
+
if entry.get("RuleAction") != "allow":
|
|
276
|
+
continue # Skip deny rules
|
|
277
|
+
|
|
278
|
+
# Check if rule allows 0.0.0.0/0 or ::/0
|
|
279
|
+
cidr_block = entry.get("CidrBlock", "")
|
|
280
|
+
ipv6_cidr_block = entry.get("Ipv6CidrBlock", "")
|
|
281
|
+
|
|
282
|
+
if cidr_block != "0.0.0.0/0" and ipv6_cidr_block != "::/0":
|
|
283
|
+
continue # Not unrestricted, skip
|
|
284
|
+
|
|
285
|
+
# Get port range
|
|
286
|
+
port_range = entry.get("PortRange", {})
|
|
287
|
+
from_port = port_range.get("From") if port_range else None
|
|
288
|
+
to_port = port_range.get("To") if port_range else None
|
|
289
|
+
|
|
290
|
+
# Get protocol
|
|
291
|
+
protocol = entry.get("Protocol", "-1")
|
|
292
|
+
|
|
293
|
+
# Check if rule allows sensitive ports
|
|
294
|
+
if protocol == "-1":
|
|
295
|
+
# All protocols - check if this is a problem
|
|
296
|
+
# This is very permissive but not always wrong for NACLs
|
|
297
|
+
# We'll flag it if it's rule 100 or lower (high priority)
|
|
298
|
+
rule_number = entry.get("RuleNumber", 32767)
|
|
299
|
+
if rule_number <= 100:
|
|
300
|
+
for port, service_name in SENSITIVE_PORTS.items():
|
|
301
|
+
insecure_rules.append({
|
|
302
|
+
"rule_number": rule_number,
|
|
303
|
+
"port_range": f"All (includes {port})",
|
|
304
|
+
"protocol": "All",
|
|
305
|
+
"service_name": f"All Services (includes {service_name})",
|
|
306
|
+
"cidr_block": cidr_block or ipv6_cidr_block,
|
|
307
|
+
})
|
|
308
|
+
break # Only add once for "all protocols" rules
|
|
309
|
+
elif from_port is not None and to_port is not None:
|
|
310
|
+
# Check if any sensitive port is in the range
|
|
311
|
+
for port, service_name in SENSITIVE_PORTS.items():
|
|
312
|
+
if from_port <= port <= to_port:
|
|
313
|
+
protocol_name = {
|
|
314
|
+
"6": "TCP",
|
|
315
|
+
"17": "UDP",
|
|
316
|
+
"-1": "All",
|
|
317
|
+
}.get(protocol, f"Protocol {protocol}")
|
|
318
|
+
|
|
319
|
+
port_display = f"{from_port}-{to_port}" if from_port != to_port else str(from_port)
|
|
320
|
+
|
|
321
|
+
insecure_rules.append({
|
|
322
|
+
"rule_number": entry.get("RuleNumber", 32767),
|
|
323
|
+
"port_range": port_display,
|
|
324
|
+
"protocol": protocol_name,
|
|
325
|
+
"service_name": service_name,
|
|
326
|
+
"cidr_block": cidr_block or ipv6_cidr_block,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return insecure_rules
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ============================================================================
|
|
333
|
+
# CONVENIENCE FUNCTION
|
|
334
|
+
# ============================================================================
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run_nacl_security_test(connector: AWSConnector) -> TestResult:
|
|
338
|
+
"""Run Network ACL security compliance test.
|
|
339
|
+
|
|
340
|
+
Convenience function for running the test.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
connector: AWS connector
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
TestResult
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
>>> from complio.connectors.aws.client import AWSConnector
|
|
350
|
+
>>> connector = AWSConnector("production", "us-east-1")
|
|
351
|
+
>>> connector.connect()
|
|
352
|
+
>>> result = run_nacl_security_test(connector)
|
|
353
|
+
>>> print(f"Score: {result.score}%")
|
|
354
|
+
"""
|
|
355
|
+
test = NACLSecurityTest(connector)
|
|
356
|
+
return test.execute()
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RDS instance encryption compliance test.
|
|
3
|
+
|
|
4
|
+
Checks that all RDS database instances have storage encryption enabled.
|
|
5
|
+
|
|
6
|
+
ISO 27001 Control: A.8.24 - Use of cryptography
|
|
7
|
+
Requirement: All data at rest must be encrypted
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from complio.connectors.aws.client import AWSConnector
|
|
11
|
+
>>> from complio.tests_library.infrastructure.rds_encryption import RDSEncryptionTest
|
|
12
|
+
>>>
|
|
13
|
+
>>> connector = AWSConnector("production", "us-east-1")
|
|
14
|
+
>>> connector.connect()
|
|
15
|
+
>>>
|
|
16
|
+
>>> test = RDSEncryptionTest(connector)
|
|
17
|
+
>>> result = test.run()
|
|
18
|
+
>>> print(f"Passed: {result.passed}, Score: {result.score}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict
|
|
22
|
+
|
|
23
|
+
from botocore.exceptions import ClientError
|
|
24
|
+
|
|
25
|
+
from complio.connectors.aws.client import AWSConnector
|
|
26
|
+
from complio.tests_library.base import (
|
|
27
|
+
ComplianceTest,
|
|
28
|
+
Severity,
|
|
29
|
+
TestResult,
|
|
30
|
+
TestStatus,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RDSEncryptionTest(ComplianceTest):
|
|
35
|
+
"""Test for RDS instance encryption compliance.
|
|
36
|
+
|
|
37
|
+
Verifies that all RDS database instances have storage encryption enabled.
|
|
38
|
+
|
|
39
|
+
Compliance Requirements:
|
|
40
|
+
- All RDS instances must have StorageEncrypted=True
|
|
41
|
+
- Both primary and read replicas must be encrypted
|
|
42
|
+
- Instances without encryption are non-compliant
|
|
43
|
+
|
|
44
|
+
Scoring:
|
|
45
|
+
- 100% if all instances are encrypted
|
|
46
|
+
- Proportional score based on encrypted/total ratio
|
|
47
|
+
- 0% if no instances are encrypted
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> test = RDSEncryptionTest(connector)
|
|
51
|
+
>>> result = test.execute()
|
|
52
|
+
>>> for finding in result.findings:
|
|
53
|
+
... print(f"{finding.resource_id}: {finding.title}")
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, connector: AWSConnector) -> None:
|
|
57
|
+
"""Initialize RDS encryption test.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
connector: AWS connector instance
|
|
61
|
+
"""
|
|
62
|
+
super().__init__(
|
|
63
|
+
test_id="rds_encryption",
|
|
64
|
+
test_name="RDS Instance Encryption Check",
|
|
65
|
+
description="Verify all RDS database instances have storage encryption enabled",
|
|
66
|
+
control_id="A.8.24",
|
|
67
|
+
connector=connector,
|
|
68
|
+
scope="regional",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def execute(self) -> TestResult:
|
|
72
|
+
"""Execute RDS encryption compliance test.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
TestResult with findings for non-encrypted instances
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> test = RDSEncryptionTest(connector)
|
|
79
|
+
>>> result = test.execute()
|
|
80
|
+
>>> print(result.score)
|
|
81
|
+
100.0
|
|
82
|
+
"""
|
|
83
|
+
result = TestResult(
|
|
84
|
+
test_id=self.test_id,
|
|
85
|
+
test_name=self.test_name,
|
|
86
|
+
status=TestStatus.PASSED,
|
|
87
|
+
passed=True,
|
|
88
|
+
score=100.0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# Get RDS client
|
|
93
|
+
rds_client = self.connector.get_client("rds")
|
|
94
|
+
|
|
95
|
+
# List all DB instances
|
|
96
|
+
self.logger.info("listing_rds_instances")
|
|
97
|
+
response = rds_client.describe_db_instances()
|
|
98
|
+
instances = response.get("DBInstances", [])
|
|
99
|
+
|
|
100
|
+
if not instances:
|
|
101
|
+
self.logger.info("no_rds_instances_found")
|
|
102
|
+
result.metadata["message"] = "No RDS instances found in region"
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
self.logger.info("rds_instances_found", count=len(instances))
|
|
106
|
+
|
|
107
|
+
# Check each instance for encryption
|
|
108
|
+
encrypted_count = 0
|
|
109
|
+
total_count = len(instances)
|
|
110
|
+
|
|
111
|
+
for instance in instances:
|
|
112
|
+
instance_id = instance["DBInstanceIdentifier"]
|
|
113
|
+
encrypted = instance.get("StorageEncrypted", False)
|
|
114
|
+
result.resources_scanned += 1
|
|
115
|
+
|
|
116
|
+
# Get instance details
|
|
117
|
+
engine = instance.get("Engine", "unknown")
|
|
118
|
+
engine_version = instance.get("EngineVersion", "unknown")
|
|
119
|
+
instance_class = instance.get("DBInstanceClass", "unknown")
|
|
120
|
+
storage_size = instance.get("AllocatedStorage", 0)
|
|
121
|
+
instance_status = instance.get("DBInstanceStatus", "unknown")
|
|
122
|
+
|
|
123
|
+
# Create evidence
|
|
124
|
+
evidence = self.create_evidence(
|
|
125
|
+
resource_id=instance_id,
|
|
126
|
+
resource_type="rds_instance",
|
|
127
|
+
data={
|
|
128
|
+
"instance_id": instance_id,
|
|
129
|
+
"encrypted": encrypted,
|
|
130
|
+
"engine": engine,
|
|
131
|
+
"engine_version": engine_version,
|
|
132
|
+
"instance_class": instance_class,
|
|
133
|
+
"storage_size_gb": storage_size,
|
|
134
|
+
"status": instance_status,
|
|
135
|
+
"kms_key_id": instance.get("KmsKeyId"),
|
|
136
|
+
"availability_zone": instance.get("AvailabilityZone"),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
result.add_evidence(evidence)
|
|
140
|
+
|
|
141
|
+
if encrypted:
|
|
142
|
+
encrypted_count += 1
|
|
143
|
+
self.logger.debug(
|
|
144
|
+
"rds_instance_encrypted",
|
|
145
|
+
instance_id=instance_id,
|
|
146
|
+
engine=engine,
|
|
147
|
+
kms_key_id=instance.get("KmsKeyId")
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Create finding for non-encrypted instance
|
|
151
|
+
finding = self.create_finding(
|
|
152
|
+
resource_id=instance_id,
|
|
153
|
+
resource_type="rds_instance",
|
|
154
|
+
severity=Severity.HIGH,
|
|
155
|
+
title="RDS instance storage encryption not enabled",
|
|
156
|
+
description=f"RDS instance '{instance_id}' ({engine} {engine_version}, {storage_size}GB) "
|
|
157
|
+
"does not have storage encryption enabled. "
|
|
158
|
+
"This violates ISO 27001 A.8.24 requirement for data-at-rest encryption.",
|
|
159
|
+
remediation=(
|
|
160
|
+
"RDS instances cannot be encrypted after creation. To remediate:\n"
|
|
161
|
+
"1. Create a snapshot of the unencrypted instance:\n"
|
|
162
|
+
f" aws rds create-db-snapshot --db-instance-identifier {instance_id} "
|
|
163
|
+
f"--db-snapshot-identifier {instance_id}-snapshot\n"
|
|
164
|
+
"2. Copy the snapshot with encryption enabled:\n"
|
|
165
|
+
f" aws rds copy-db-snapshot --source-db-snapshot-identifier {instance_id}-snapshot "
|
|
166
|
+
f"--target-db-snapshot-identifier {instance_id}-encrypted-snapshot --kms-key-id <key-id>\n"
|
|
167
|
+
"3. Restore a new instance from the encrypted snapshot:\n"
|
|
168
|
+
f" aws rds restore-db-instance-from-db-snapshot "
|
|
169
|
+
f"--db-instance-identifier {instance_id}-encrypted "
|
|
170
|
+
f"--db-snapshot-identifier {instance_id}-encrypted-snapshot\n"
|
|
171
|
+
"4. Update application connection strings\n"
|
|
172
|
+
"5. Delete the old unencrypted instance\n\n"
|
|
173
|
+
"Note: This will cause downtime during the migration."
|
|
174
|
+
),
|
|
175
|
+
evidence=evidence
|
|
176
|
+
)
|
|
177
|
+
result.add_finding(finding)
|
|
178
|
+
|
|
179
|
+
self.logger.warning(
|
|
180
|
+
"rds_instance_not_encrypted",
|
|
181
|
+
instance_id=instance_id,
|
|
182
|
+
engine=engine
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Calculate compliance score
|
|
186
|
+
if total_count > 0:
|
|
187
|
+
result.score = (encrypted_count / total_count) * 100
|
|
188
|
+
|
|
189
|
+
# Determine pass/fail
|
|
190
|
+
result.passed = encrypted_count == total_count
|
|
191
|
+
result.status = TestStatus.PASSED if result.passed else TestStatus.FAILED
|
|
192
|
+
|
|
193
|
+
# Add metadata
|
|
194
|
+
result.metadata = {
|
|
195
|
+
"total_instances": total_count,
|
|
196
|
+
"encrypted_instances": encrypted_count,
|
|
197
|
+
"non_encrypted_instances": total_count - encrypted_count,
|
|
198
|
+
"compliance_percentage": result.score,
|
|
199
|
+
"region": self.connector.region,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
self.logger.info(
|
|
203
|
+
"rds_encryption_test_completed",
|
|
204
|
+
total=total_count,
|
|
205
|
+
encrypted=encrypted_count,
|
|
206
|
+
score=result.score,
|
|
207
|
+
passed=result.passed
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
except ClientError as e:
|
|
211
|
+
error_code = e.response.get("Error", {}).get("Code")
|
|
212
|
+
self.logger.error("rds_encryption_test_error", error_code=error_code, error=str(e))
|
|
213
|
+
result.status = TestStatus.ERROR
|
|
214
|
+
result.passed = False
|
|
215
|
+
result.score = 0.0
|
|
216
|
+
result.error_message = f"AWS API Error: {error_code} - {str(e)}"
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self.logger.error("rds_encryption_test_error", error=str(e))
|
|
220
|
+
result.status = TestStatus.ERROR
|
|
221
|
+
result.passed = False
|
|
222
|
+
result.score = 0.0
|
|
223
|
+
result.error_message = str(e)
|
|
224
|
+
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ============================================================================
|
|
229
|
+
# CONVENIENCE FUNCTION
|
|
230
|
+
# ============================================================================
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_rds_encryption_test(connector: AWSConnector) -> TestResult:
|
|
234
|
+
"""Run RDS encryption compliance test.
|
|
235
|
+
|
|
236
|
+
Convenience function for running the test.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
connector: AWS connector
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
TestResult
|
|
243
|
+
|
|
244
|
+
Example:
|
|
245
|
+
>>> from complio.connectors.aws.client import AWSConnector
|
|
246
|
+
>>> connector = AWSConnector("production", "us-east-1")
|
|
247
|
+
>>> connector.connect()
|
|
248
|
+
>>> result = run_rds_encryption_test(connector)
|
|
249
|
+
>>> print(f"Score: {result.score}%")
|
|
250
|
+
"""
|
|
251
|
+
test = RDSEncryptionTest(connector)
|
|
252
|
+
return test.execute()
|