aws-cis-controls-assessment 1.0.3__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.
- aws_cis_assessment/__init__.py +11 -0
- aws_cis_assessment/cli/__init__.py +3 -0
- aws_cis_assessment/cli/examples.py +274 -0
- aws_cis_assessment/cli/main.py +1259 -0
- aws_cis_assessment/cli/utils.py +356 -0
- aws_cis_assessment/config/__init__.py +1 -0
- aws_cis_assessment/config/config_loader.py +328 -0
- aws_cis_assessment/config/rules/cis_controls_ig1.yaml +590 -0
- aws_cis_assessment/config/rules/cis_controls_ig2.yaml +412 -0
- aws_cis_assessment/config/rules/cis_controls_ig3.yaml +100 -0
- aws_cis_assessment/controls/__init__.py +1 -0
- aws_cis_assessment/controls/base_control.py +400 -0
- aws_cis_assessment/controls/ig1/__init__.py +239 -0
- aws_cis_assessment/controls/ig1/control_1_1.py +586 -0
- aws_cis_assessment/controls/ig1/control_2_2.py +231 -0
- aws_cis_assessment/controls/ig1/control_3_3.py +718 -0
- aws_cis_assessment/controls/ig1/control_3_4.py +235 -0
- aws_cis_assessment/controls/ig1/control_4_1.py +461 -0
- aws_cis_assessment/controls/ig1/control_access_keys.py +310 -0
- aws_cis_assessment/controls/ig1/control_advanced_security.py +512 -0
- aws_cis_assessment/controls/ig1/control_backup_recovery.py +510 -0
- aws_cis_assessment/controls/ig1/control_cloudtrail_logging.py +197 -0
- aws_cis_assessment/controls/ig1/control_critical_security.py +422 -0
- aws_cis_assessment/controls/ig1/control_data_protection.py +898 -0
- aws_cis_assessment/controls/ig1/control_iam_advanced.py +573 -0
- aws_cis_assessment/controls/ig1/control_iam_governance.py +493 -0
- aws_cis_assessment/controls/ig1/control_iam_policies.py +383 -0
- aws_cis_assessment/controls/ig1/control_instance_optimization.py +100 -0
- aws_cis_assessment/controls/ig1/control_network_enhancements.py +203 -0
- aws_cis_assessment/controls/ig1/control_network_security.py +672 -0
- aws_cis_assessment/controls/ig1/control_s3_enhancements.py +173 -0
- aws_cis_assessment/controls/ig1/control_s3_security.py +422 -0
- aws_cis_assessment/controls/ig1/control_vpc_security.py +235 -0
- aws_cis_assessment/controls/ig2/__init__.py +172 -0
- aws_cis_assessment/controls/ig2/control_3_10.py +698 -0
- aws_cis_assessment/controls/ig2/control_3_11.py +1330 -0
- aws_cis_assessment/controls/ig2/control_5_2.py +393 -0
- aws_cis_assessment/controls/ig2/control_advanced_encryption.py +355 -0
- aws_cis_assessment/controls/ig2/control_codebuild_security.py +263 -0
- aws_cis_assessment/controls/ig2/control_encryption_rest.py +382 -0
- aws_cis_assessment/controls/ig2/control_encryption_transit.py +382 -0
- aws_cis_assessment/controls/ig2/control_network_ha.py +467 -0
- aws_cis_assessment/controls/ig2/control_remaining_encryption.py +426 -0
- aws_cis_assessment/controls/ig2/control_remaining_rules.py +363 -0
- aws_cis_assessment/controls/ig2/control_service_logging.py +402 -0
- aws_cis_assessment/controls/ig3/__init__.py +49 -0
- aws_cis_assessment/controls/ig3/control_12_8.py +395 -0
- aws_cis_assessment/controls/ig3/control_13_1.py +467 -0
- aws_cis_assessment/controls/ig3/control_3_14.py +523 -0
- aws_cis_assessment/controls/ig3/control_7_1.py +359 -0
- aws_cis_assessment/core/__init__.py +1 -0
- aws_cis_assessment/core/accuracy_validator.py +425 -0
- aws_cis_assessment/core/assessment_engine.py +1266 -0
- aws_cis_assessment/core/audit_trail.py +491 -0
- aws_cis_assessment/core/aws_client_factory.py +313 -0
- aws_cis_assessment/core/error_handler.py +607 -0
- aws_cis_assessment/core/models.py +166 -0
- aws_cis_assessment/core/scoring_engine.py +459 -0
- aws_cis_assessment/reporters/__init__.py +8 -0
- aws_cis_assessment/reporters/base_reporter.py +454 -0
- aws_cis_assessment/reporters/csv_reporter.py +835 -0
- aws_cis_assessment/reporters/html_reporter.py +2162 -0
- aws_cis_assessment/reporters/json_reporter.py +561 -0
- aws_cis_controls_assessment-1.0.3.dist-info/METADATA +248 -0
- aws_cis_controls_assessment-1.0.3.dist-info/RECORD +77 -0
- aws_cis_controls_assessment-1.0.3.dist-info/WHEEL +5 -0
- aws_cis_controls_assessment-1.0.3.dist-info/entry_points.txt +2 -0
- aws_cis_controls_assessment-1.0.3.dist-info/licenses/LICENSE +21 -0
- aws_cis_controls_assessment-1.0.3.dist-info/top_level.txt +2 -0
- docs/README.md +94 -0
- docs/assessment-logic.md +766 -0
- docs/cli-reference.md +698 -0
- docs/config-rule-mappings.md +393 -0
- docs/developer-guide.md +858 -0
- docs/installation.md +299 -0
- docs/troubleshooting.md +634 -0
- docs/user-guide.md +487 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CIS Control 3.3 - Data Protection Controls
|
|
3
|
+
Critical data protection rules to prevent public exposure of sensitive data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
import boto3
|
|
9
|
+
import json
|
|
10
|
+
from botocore.exceptions import ClientError, NoCredentialsError
|
|
11
|
+
|
|
12
|
+
from aws_cis_assessment.controls.base_control import BaseConfigRuleAssessment
|
|
13
|
+
from aws_cis_assessment.core.models import ComplianceResult, ComplianceStatus
|
|
14
|
+
from aws_cis_assessment.core.aws_client_factory import AWSClientFactory
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EBSSnapshotPublicRestorableCheckAssessment(BaseConfigRuleAssessment):
|
|
20
|
+
"""
|
|
21
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
22
|
+
AWS Config Rule: ebs-snapshot-public-restorable-check
|
|
23
|
+
|
|
24
|
+
Ensures EBS snapshots are not publicly restorable to prevent data exposure.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__(
|
|
29
|
+
rule_name="ebs-snapshot-public-restorable-check",
|
|
30
|
+
control_id="3.3",
|
|
31
|
+
resource_types=["AWS::EC2::Snapshot"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
35
|
+
"""Get all EBS snapshots owned by the account."""
|
|
36
|
+
if resource_type != "AWS::EC2::Snapshot":
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
41
|
+
|
|
42
|
+
# Get all EBS snapshots owned by the account
|
|
43
|
+
paginator = ec2_client.get_paginator('describe_snapshots')
|
|
44
|
+
page_iterator = paginator.paginate(OwnerIds=['self'])
|
|
45
|
+
|
|
46
|
+
snapshots = []
|
|
47
|
+
for page in page_iterator:
|
|
48
|
+
for snapshot in page['Snapshots']:
|
|
49
|
+
snapshots.append({
|
|
50
|
+
'SnapshotId': snapshot['SnapshotId'],
|
|
51
|
+
'Description': snapshot.get('Description', ''),
|
|
52
|
+
'VolumeId': snapshot.get('VolumeId', ''),
|
|
53
|
+
'VolumeSize': snapshot.get('VolumeSize', 0),
|
|
54
|
+
'Encrypted': snapshot.get('Encrypted', False),
|
|
55
|
+
'State': snapshot.get('State', ''),
|
|
56
|
+
'StartTime': snapshot.get('StartTime')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
logger.debug(f"Found {len(snapshots)} EBS snapshots in {region}")
|
|
60
|
+
return snapshots
|
|
61
|
+
|
|
62
|
+
except ClientError as e:
|
|
63
|
+
logger.error(f"Error retrieving EBS snapshots in {region}: {e}")
|
|
64
|
+
raise
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Unexpected error retrieving EBS snapshots in {region}: {e}")
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
70
|
+
"""Evaluate if EBS snapshot is publicly restorable."""
|
|
71
|
+
snapshot_id = resource.get('SnapshotId', 'unknown')
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
75
|
+
|
|
76
|
+
# Check if snapshot has public restore permissions
|
|
77
|
+
response = ec2_client.describe_snapshot_attribute(
|
|
78
|
+
SnapshotId=snapshot_id,
|
|
79
|
+
Attribute='createVolumePermission'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
create_volume_permissions = response.get('CreateVolumePermissions', [])
|
|
83
|
+
is_public = any(
|
|
84
|
+
perm.get('Group') == 'all'
|
|
85
|
+
for perm in create_volume_permissions
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if is_public:
|
|
89
|
+
return ComplianceResult(
|
|
90
|
+
resource_id=snapshot_id,
|
|
91
|
+
resource_type="AWS::EC2::Snapshot",
|
|
92
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
93
|
+
evaluation_reason="EBS snapshot allows public restore access",
|
|
94
|
+
config_rule_name=self.rule_name,
|
|
95
|
+
region=region
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
return ComplianceResult(
|
|
99
|
+
resource_id=snapshot_id,
|
|
100
|
+
resource_type="AWS::EC2::Snapshot",
|
|
101
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
102
|
+
evaluation_reason="EBS snapshot does not allow public restore access",
|
|
103
|
+
config_rule_name=self.rule_name,
|
|
104
|
+
region=region
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except ClientError as e:
|
|
108
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
109
|
+
if error_code == 'InvalidSnapshot.NotFound':
|
|
110
|
+
return ComplianceResult(
|
|
111
|
+
resource_id=snapshot_id,
|
|
112
|
+
resource_type="AWS::EC2::Snapshot",
|
|
113
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
114
|
+
evaluation_reason="Snapshot no longer exists",
|
|
115
|
+
config_rule_name=self.rule_name,
|
|
116
|
+
region=region
|
|
117
|
+
)
|
|
118
|
+
elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
|
|
119
|
+
return ComplianceResult(
|
|
120
|
+
resource_id=snapshot_id,
|
|
121
|
+
resource_type="AWS::EC2::Snapshot",
|
|
122
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
123
|
+
evaluation_reason=f"Insufficient permissions to check snapshot attributes: {error_code}",
|
|
124
|
+
config_rule_name=self.rule_name,
|
|
125
|
+
region=region
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
logger.warning(f"Error checking snapshot {snapshot_id} in {region}: {e}")
|
|
129
|
+
return ComplianceResult(
|
|
130
|
+
resource_id=snapshot_id,
|
|
131
|
+
resource_type="AWS::EC2::Snapshot",
|
|
132
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
133
|
+
evaluation_reason=f"Error checking snapshot attributes: {str(e)}",
|
|
134
|
+
config_rule_name=self.rule_name,
|
|
135
|
+
region=region
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(f"Unexpected error checking snapshot {snapshot_id} in {region}: {e}")
|
|
140
|
+
return ComplianceResult(
|
|
141
|
+
resource_id=snapshot_id,
|
|
142
|
+
resource_type="AWS::EC2::Snapshot",
|
|
143
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
144
|
+
evaluation_reason=f"Unexpected error: {str(e)}",
|
|
145
|
+
config_rule_name=self.rule_name,
|
|
146
|
+
region=region
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RDSSnapshotsPublicProhibitedAssessment(BaseConfigRuleAssessment):
|
|
151
|
+
"""
|
|
152
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
153
|
+
AWS Config Rule: rds-snapshots-public-prohibited
|
|
154
|
+
|
|
155
|
+
Ensures RDS snapshots are not publicly accessible to prevent database data exposure.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self):
|
|
159
|
+
super().__init__(
|
|
160
|
+
rule_name="rds-snapshots-public-prohibited",
|
|
161
|
+
control_id="3.3",
|
|
162
|
+
resource_types=["AWS::RDS::DBSnapshot", "AWS::RDS::DBClusterSnapshot"]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
166
|
+
"""Get all RDS snapshots (DB and cluster snapshots)."""
|
|
167
|
+
resources = []
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
rds_client = aws_factory.get_client('rds', region)
|
|
171
|
+
|
|
172
|
+
if resource_type == "AWS::RDS::DBSnapshot":
|
|
173
|
+
# Get DB snapshots
|
|
174
|
+
paginator = rds_client.get_paginator('describe_db_snapshots')
|
|
175
|
+
for page in paginator.paginate(SnapshotType='manual'):
|
|
176
|
+
for snapshot in page['DBSnapshots']:
|
|
177
|
+
resources.append({
|
|
178
|
+
'Type': 'DBSnapshot',
|
|
179
|
+
'SnapshotId': snapshot['DBSnapshotIdentifier'],
|
|
180
|
+
'DBInstanceIdentifier': snapshot.get('DBInstanceIdentifier', ''),
|
|
181
|
+
'Engine': snapshot.get('Engine', ''),
|
|
182
|
+
'Encrypted': snapshot.get('Encrypted', False),
|
|
183
|
+
'SnapshotType': snapshot.get('SnapshotType', '')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
elif resource_type == "AWS::RDS::DBClusterSnapshot":
|
|
187
|
+
# Get DB cluster snapshots
|
|
188
|
+
paginator = rds_client.get_paginator('describe_db_cluster_snapshots')
|
|
189
|
+
for page in paginator.paginate(SnapshotType='manual'):
|
|
190
|
+
for snapshot in page['DBClusterSnapshots']:
|
|
191
|
+
resources.append({
|
|
192
|
+
'Type': 'DBClusterSnapshot',
|
|
193
|
+
'SnapshotId': snapshot['DBClusterSnapshotIdentifier'],
|
|
194
|
+
'DBClusterIdentifier': snapshot.get('DBClusterIdentifier', ''),
|
|
195
|
+
'Engine': snapshot.get('Engine', ''),
|
|
196
|
+
'Encrypted': snapshot.get('StorageEncrypted', False)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
logger.debug(f"Found {len(resources)} RDS snapshots of type {resource_type} in {region}")
|
|
200
|
+
return resources
|
|
201
|
+
|
|
202
|
+
except ClientError as e:
|
|
203
|
+
logger.error(f"Error retrieving RDS snapshots in {region}: {e}")
|
|
204
|
+
raise
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.error(f"Unexpected error retrieving RDS snapshots in {region}: {e}")
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
210
|
+
"""Evaluate if RDS snapshot is publicly accessible."""
|
|
211
|
+
snapshot_id = resource.get('SnapshotId', 'unknown')
|
|
212
|
+
snapshot_type = resource.get('Type', 'Unknown')
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
rds_client = aws_factory.get_client('rds', region)
|
|
216
|
+
|
|
217
|
+
is_public = False
|
|
218
|
+
|
|
219
|
+
if snapshot_type == 'DBSnapshot':
|
|
220
|
+
# Check DB snapshot attributes
|
|
221
|
+
response = rds_client.describe_db_snapshot_attributes(
|
|
222
|
+
DBSnapshotIdentifier=snapshot_id
|
|
223
|
+
)
|
|
224
|
+
attributes = response.get('DBSnapshotAttributesResult', {}).get('DBSnapshotAttributes', [])
|
|
225
|
+
|
|
226
|
+
for attr in attributes:
|
|
227
|
+
if attr['AttributeName'] == 'restore' and 'all' in attr.get('AttributeValues', []):
|
|
228
|
+
is_public = True
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
resource_type = "AWS::RDS::DBSnapshot"
|
|
232
|
+
|
|
233
|
+
elif snapshot_type == 'DBClusterSnapshot':
|
|
234
|
+
# Check cluster snapshot attributes
|
|
235
|
+
response = rds_client.describe_db_cluster_snapshot_attributes(
|
|
236
|
+
DBClusterSnapshotIdentifier=snapshot_id
|
|
237
|
+
)
|
|
238
|
+
attributes = response.get('DBClusterSnapshotAttributesResult', {}).get('DBClusterSnapshotAttributes', [])
|
|
239
|
+
|
|
240
|
+
for attr in attributes:
|
|
241
|
+
if attr['AttributeName'] == 'restore' and 'all' in attr.get('AttributeValues', []):
|
|
242
|
+
is_public = True
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
resource_type = "AWS::RDS::DBClusterSnapshot"
|
|
246
|
+
|
|
247
|
+
else:
|
|
248
|
+
return ComplianceResult(
|
|
249
|
+
resource_id=snapshot_id,
|
|
250
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
251
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
252
|
+
evaluation_reason=f"Unknown snapshot type: {snapshot_type}",
|
|
253
|
+
config_rule_name=self.rule_name,
|
|
254
|
+
region=region
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if is_public:
|
|
258
|
+
return ComplianceResult(
|
|
259
|
+
resource_id=snapshot_id,
|
|
260
|
+
resource_type=resource_type,
|
|
261
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
262
|
+
evaluation_reason=f"RDS {snapshot_type.lower()} allows public restore access",
|
|
263
|
+
config_rule_name=self.rule_name,
|
|
264
|
+
region=region
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
return ComplianceResult(
|
|
268
|
+
resource_id=snapshot_id,
|
|
269
|
+
resource_type=resource_type,
|
|
270
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
271
|
+
evaluation_reason=f"RDS {snapshot_type.lower()} does not allow public restore access",
|
|
272
|
+
config_rule_name=self.rule_name,
|
|
273
|
+
region=region
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
except ClientError as e:
|
|
277
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
278
|
+
if error_code in ['DBSnapshotNotFoundFault', 'DBClusterSnapshotNotFoundFault']:
|
|
279
|
+
return ComplianceResult(
|
|
280
|
+
resource_id=snapshot_id,
|
|
281
|
+
resource_type=f"AWS::RDS::{snapshot_type}",
|
|
282
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
283
|
+
evaluation_reason="Snapshot no longer exists",
|
|
284
|
+
config_rule_name=self.rule_name,
|
|
285
|
+
region=region
|
|
286
|
+
)
|
|
287
|
+
elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
|
|
288
|
+
return ComplianceResult(
|
|
289
|
+
resource_id=snapshot_id,
|
|
290
|
+
resource_type=f"AWS::RDS::{snapshot_type}",
|
|
291
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
292
|
+
evaluation_reason=f"Insufficient permissions to check snapshot attributes: {error_code}",
|
|
293
|
+
config_rule_name=self.rule_name,
|
|
294
|
+
region=region
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
logger.warning(f"Error checking RDS snapshot {snapshot_id} in {region}: {e}")
|
|
298
|
+
return ComplianceResult(
|
|
299
|
+
resource_id=snapshot_id,
|
|
300
|
+
resource_type=f"AWS::RDS::{snapshot_type}",
|
|
301
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
302
|
+
evaluation_reason=f"Error checking snapshot attributes: {str(e)}",
|
|
303
|
+
config_rule_name=self.rule_name,
|
|
304
|
+
region=region
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error(f"Unexpected error checking RDS snapshot {snapshot_id} in {region}: {e}")
|
|
309
|
+
return ComplianceResult(
|
|
310
|
+
resource_id=snapshot_id,
|
|
311
|
+
resource_type=f"AWS::RDS::{snapshot_type}",
|
|
312
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
313
|
+
evaluation_reason=f"Unexpected error: {str(e)}",
|
|
314
|
+
config_rule_name=self.rule_name,
|
|
315
|
+
region=region
|
|
316
|
+
)
|
|
317
|
+
"""
|
|
318
|
+
Evaluate RDS snapshot public access compliance.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
aws_factory: AWS client factory
|
|
322
|
+
region: AWS region to evaluate
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
List of ComplianceResult objects
|
|
326
|
+
"""
|
|
327
|
+
results = []
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
rds_client = aws_factory.get_client('rds', region)
|
|
331
|
+
|
|
332
|
+
# Check DB snapshots
|
|
333
|
+
db_snapshots_paginator = rds_client.get_paginator('describe_db_snapshots')
|
|
334
|
+
db_snapshot_count = 0
|
|
335
|
+
|
|
336
|
+
for page in db_snapshots_paginator.paginate(SnapshotType='manual'):
|
|
337
|
+
for snapshot in page['DBSnapshots']:
|
|
338
|
+
db_snapshot_count += 1
|
|
339
|
+
snapshot_id = snapshot['DBSnapshotIdentifier']
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
# Check snapshot attributes for public access
|
|
343
|
+
response = rds_client.describe_db_snapshot_attributes(
|
|
344
|
+
DBSnapshotIdentifier=snapshot_id
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
attributes = response.get('DBSnapshotAttributesResult', {}).get('DBSnapshotAttributes', [])
|
|
348
|
+
is_public = False
|
|
349
|
+
|
|
350
|
+
for attr in attributes:
|
|
351
|
+
if attr['AttributeName'] == 'restore' and 'all' in attr.get('AttributeValues', []):
|
|
352
|
+
is_public = True
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
if is_public:
|
|
356
|
+
results.append(ComplianceResult(
|
|
357
|
+
resource_id=snapshot_id,
|
|
358
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
359
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
360
|
+
evaluation_reason="RDS DB snapshot allows public restore access",
|
|
361
|
+
config_rule_name=self.rule_name,
|
|
362
|
+
region=region,
|
|
363
|
+
resource_details={
|
|
364
|
+
'snapshot_id': snapshot_id,
|
|
365
|
+
'db_instance_identifier': snapshot.get('DBInstanceIdentifier', ''),
|
|
366
|
+
'engine': snapshot.get('Engine', ''),
|
|
367
|
+
'encrypted': snapshot.get('Encrypted', False),
|
|
368
|
+
'snapshot_type': snapshot.get('SnapshotType', ''),
|
|
369
|
+
'public_attributes': attributes
|
|
370
|
+
},
|
|
371
|
+
remediation_guidance={
|
|
372
|
+
'description': 'Remove public restore permissions from RDS snapshot',
|
|
373
|
+
'cli_command': f'aws rds modify-db-snapshot-attribute --db-snapshot-identifier {snapshot_id} --attribute-name restore --values-to-remove all --region {region}',
|
|
374
|
+
'console_url': f'https://{region}.console.aws.amazon.com/rds/home?region={region}#snapshot:id={snapshot_id}',
|
|
375
|
+
'additional_info': 'Ensure only specific AWS accounts have restore permissions if sharing is required'
|
|
376
|
+
}
|
|
377
|
+
))
|
|
378
|
+
else:
|
|
379
|
+
results.append(ComplianceResult(
|
|
380
|
+
resource_id=snapshot_id,
|
|
381
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
382
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
383
|
+
evaluation_reason="RDS DB snapshot does not allow public restore access",
|
|
384
|
+
config_rule_name=self.rule_name,
|
|
385
|
+
region=region,
|
|
386
|
+
resource_details={
|
|
387
|
+
'snapshot_id': snapshot_id,
|
|
388
|
+
'db_instance_identifier': snapshot.get('DBInstanceIdentifier', ''),
|
|
389
|
+
'encrypted': snapshot.get('Encrypted', False)
|
|
390
|
+
}
|
|
391
|
+
))
|
|
392
|
+
|
|
393
|
+
except ClientError as e:
|
|
394
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
395
|
+
if error_code == 'DBSnapshotNotFoundFault':
|
|
396
|
+
continue
|
|
397
|
+
elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
|
|
398
|
+
results.append(ComplianceResult(
|
|
399
|
+
resource_id=snapshot_id,
|
|
400
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
401
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
402
|
+
evaluation_reason=f"Insufficient permissions to check snapshot attributes: {error_code}",
|
|
403
|
+
config_rule_name=self.rule_name,
|
|
404
|
+
region=region
|
|
405
|
+
))
|
|
406
|
+
else:
|
|
407
|
+
logger.warning(f"Error checking RDS snapshot {snapshot_id} in {region}: {e}")
|
|
408
|
+
|
|
409
|
+
# Check DB cluster snapshots
|
|
410
|
+
cluster_snapshots_paginator = rds_client.get_paginator('describe_db_cluster_snapshots')
|
|
411
|
+
cluster_snapshot_count = 0
|
|
412
|
+
|
|
413
|
+
for page in cluster_snapshots_paginator.paginate(SnapshotType='manual'):
|
|
414
|
+
for snapshot in page['DBClusterSnapshots']:
|
|
415
|
+
cluster_snapshot_count += 1
|
|
416
|
+
snapshot_id = snapshot['DBClusterSnapshotIdentifier']
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
# Check cluster snapshot attributes for public access
|
|
420
|
+
response = rds_client.describe_db_cluster_snapshot_attributes(
|
|
421
|
+
DBClusterSnapshotIdentifier=snapshot_id
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
attributes = response.get('DBClusterSnapshotAttributesResult', {}).get('DBClusterSnapshotAttributes', [])
|
|
425
|
+
is_public = False
|
|
426
|
+
|
|
427
|
+
for attr in attributes:
|
|
428
|
+
if attr['AttributeName'] == 'restore' and 'all' in attr.get('AttributeValues', []):
|
|
429
|
+
is_public = True
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
if is_public:
|
|
433
|
+
results.append(ComplianceResult(
|
|
434
|
+
resource_id=snapshot_id,
|
|
435
|
+
resource_type="AWS::RDS::DBClusterSnapshot",
|
|
436
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
437
|
+
evaluation_reason="RDS cluster snapshot allows public restore access",
|
|
438
|
+
config_rule_name=self.rule_name,
|
|
439
|
+
region=region,
|
|
440
|
+
resource_details={
|
|
441
|
+
'snapshot_id': snapshot_id,
|
|
442
|
+
'db_cluster_identifier': snapshot.get('DBClusterIdentifier', ''),
|
|
443
|
+
'engine': snapshot.get('Engine', ''),
|
|
444
|
+
'encrypted': snapshot.get('StorageEncrypted', False)
|
|
445
|
+
},
|
|
446
|
+
remediation_guidance={
|
|
447
|
+
'description': 'Remove public restore permissions from RDS cluster snapshot',
|
|
448
|
+
'cli_command': f'aws rds modify-db-cluster-snapshot-attribute --db-cluster-snapshot-identifier {snapshot_id} --attribute-name restore --values-to-remove all --region {region}',
|
|
449
|
+
'console_url': f'https://{region}.console.aws.amazon.com/rds/home?region={region}#cluster-snapshot:id={snapshot_id}',
|
|
450
|
+
'additional_info': 'Ensure only specific AWS accounts have restore permissions if sharing is required'
|
|
451
|
+
}
|
|
452
|
+
))
|
|
453
|
+
else:
|
|
454
|
+
results.append(ComplianceResult(
|
|
455
|
+
resource_id=snapshot_id,
|
|
456
|
+
resource_type="AWS::RDS::DBClusterSnapshot",
|
|
457
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
458
|
+
evaluation_reason="RDS cluster snapshot does not allow public restore access",
|
|
459
|
+
config_rule_name=self.rule_name,
|
|
460
|
+
region=region,
|
|
461
|
+
resource_details={
|
|
462
|
+
'snapshot_id': snapshot_id,
|
|
463
|
+
'db_cluster_identifier': snapshot.get('DBClusterIdentifier', ''),
|
|
464
|
+
'encrypted': snapshot.get('StorageEncrypted', False)
|
|
465
|
+
}
|
|
466
|
+
))
|
|
467
|
+
|
|
468
|
+
except ClientError as e:
|
|
469
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
470
|
+
if error_code == 'DBClusterSnapshotNotFoundFault':
|
|
471
|
+
continue
|
|
472
|
+
elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
|
|
473
|
+
results.append(ComplianceResult(
|
|
474
|
+
resource_id=snapshot_id,
|
|
475
|
+
resource_type="AWS::RDS::DBClusterSnapshot",
|
|
476
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
477
|
+
evaluation_reason=f"Insufficient permissions to check cluster snapshot attributes: {error_code}",
|
|
478
|
+
config_rule_name=self.rule_name,
|
|
479
|
+
region=region
|
|
480
|
+
))
|
|
481
|
+
else:
|
|
482
|
+
logger.warning(f"Error checking RDS cluster snapshot {snapshot_id} in {region}: {e}")
|
|
483
|
+
|
|
484
|
+
# If no snapshots found, return informational result
|
|
485
|
+
total_snapshots = db_snapshot_count + cluster_snapshot_count
|
|
486
|
+
if total_snapshots == 0:
|
|
487
|
+
results.append(ComplianceResult(
|
|
488
|
+
resource_id=f"no-rds-snapshots-{region}",
|
|
489
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
490
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
491
|
+
evaluation_reason="No RDS snapshots found in this region",
|
|
492
|
+
config_rule_name=self.rule_name,
|
|
493
|
+
region=region
|
|
494
|
+
))
|
|
495
|
+
|
|
496
|
+
logger.info(f"Evaluated {total_snapshots} RDS snapshots in {region} ({db_snapshot_count} DB + {cluster_snapshot_count} cluster)")
|
|
497
|
+
|
|
498
|
+
except ClientError as e:
|
|
499
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
500
|
+
if error_code in ['UnauthorizedOperation', 'AccessDenied']:
|
|
501
|
+
results.append(ComplianceResult(
|
|
502
|
+
resource_id=f"access-denied-{region}",
|
|
503
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
504
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
505
|
+
evaluation_reason=f"Insufficient permissions to describe RDS snapshots: {error_code}",
|
|
506
|
+
config_rule_name=self.rule_name,
|
|
507
|
+
region=region
|
|
508
|
+
))
|
|
509
|
+
else:
|
|
510
|
+
logger.error(f"Error evaluating RDS snapshots in {region}: {e}")
|
|
511
|
+
results.append(ComplianceResult(
|
|
512
|
+
resource_id=f"error-{region}",
|
|
513
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
514
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
515
|
+
evaluation_reason=f"Error evaluating RDS snapshots: {str(e)}",
|
|
516
|
+
config_rule_name=self.rule_name,
|
|
517
|
+
region=region
|
|
518
|
+
))
|
|
519
|
+
|
|
520
|
+
except Exception as e:
|
|
521
|
+
logger.error(f"Unexpected error evaluating RDS snapshots in {region}: {e}")
|
|
522
|
+
results.append(ComplianceResult(
|
|
523
|
+
resource_id=f"error-{region}",
|
|
524
|
+
resource_type="AWS::RDS::DBSnapshot",
|
|
525
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
526
|
+
evaluation_reason=f"Unexpected error: {str(e)}",
|
|
527
|
+
config_rule_name=self.rule_name,
|
|
528
|
+
region=region
|
|
529
|
+
))
|
|
530
|
+
|
|
531
|
+
return results
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class RDSInstancePublicAccessCheckAssessment(BaseConfigRuleAssessment):
|
|
535
|
+
"""
|
|
536
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
537
|
+
AWS Config Rule: rds-instance-public-access-check
|
|
538
|
+
|
|
539
|
+
Ensures RDS instances are not publicly accessible to prevent database exposure.
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
def __init__(self):
|
|
543
|
+
super().__init__(
|
|
544
|
+
rule_name="rds-instance-public-access-check",
|
|
545
|
+
control_id="3.3",
|
|
546
|
+
resource_types=["AWS::RDS::DBInstance", "AWS::RDS::DBCluster"]
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
550
|
+
"""Get all RDS instances and clusters in the region."""
|
|
551
|
+
resources = []
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
rds_client = aws_factory.get_client('rds', region)
|
|
555
|
+
|
|
556
|
+
if resource_type == "AWS::RDS::DBInstance":
|
|
557
|
+
# Get DB instances
|
|
558
|
+
paginator = rds_client.get_paginator('describe_db_instances')
|
|
559
|
+
for page in paginator.paginate():
|
|
560
|
+
for instance in page['DBInstances']:
|
|
561
|
+
resources.append({
|
|
562
|
+
'Type': 'DBInstance',
|
|
563
|
+
'InstanceId': instance['DBInstanceIdentifier'],
|
|
564
|
+
'Engine': instance.get('Engine', ''),
|
|
565
|
+
'InstanceClass': instance.get('DBInstanceClass', ''),
|
|
566
|
+
'PubliclyAccessible': instance.get('PubliclyAccessible', False),
|
|
567
|
+
'VpcSecurityGroups': [sg['VpcSecurityGroupId'] for sg in instance.get('VpcSecurityGroups', [])],
|
|
568
|
+
'DBSubnetGroup': instance.get('DBSubnetGroup', {}).get('DBSubnetGroupName', ''),
|
|
569
|
+
'Endpoint': instance.get('Endpoint', {}).get('Address', '')
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
elif resource_type == "AWS::RDS::DBCluster":
|
|
573
|
+
# Get DB clusters
|
|
574
|
+
paginator = rds_client.get_paginator('describe_db_clusters')
|
|
575
|
+
for page in paginator.paginate():
|
|
576
|
+
for cluster in page['DBClusters']:
|
|
577
|
+
# Check if any member instances are publicly accessible
|
|
578
|
+
public_members = []
|
|
579
|
+
for member in cluster.get('DBClusterMembers', []):
|
|
580
|
+
member_id = member['DBInstanceIdentifier']
|
|
581
|
+
try:
|
|
582
|
+
member_response = rds_client.describe_db_instances(DBInstanceIdentifier=member_id)
|
|
583
|
+
member_instance = member_response['DBInstances'][0]
|
|
584
|
+
if member_instance.get('PubliclyAccessible', False):
|
|
585
|
+
public_members.append(member_id)
|
|
586
|
+
except ClientError:
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
resources.append({
|
|
590
|
+
'Type': 'DBCluster',
|
|
591
|
+
'ClusterId': cluster['DBClusterIdentifier'],
|
|
592
|
+
'Engine': cluster.get('Engine', ''),
|
|
593
|
+
'PublicMembers': public_members,
|
|
594
|
+
'VpcSecurityGroups': [sg['VpcSecurityGroupId'] for sg in cluster.get('VpcSecurityGroups', [])],
|
|
595
|
+
'DBSubnetGroup': cluster.get('DBSubnetGroup', ''),
|
|
596
|
+
'Endpoint': cluster.get('Endpoint', '')
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
logger.debug(f"Found {len(resources)} RDS resources of type {resource_type} in {region}")
|
|
600
|
+
return resources
|
|
601
|
+
|
|
602
|
+
except ClientError as e:
|
|
603
|
+
logger.error(f"Error retrieving RDS resources in {region}: {e}")
|
|
604
|
+
raise
|
|
605
|
+
except Exception as e:
|
|
606
|
+
logger.error(f"Unexpected error retrieving RDS resources in {region}: {e}")
|
|
607
|
+
raise
|
|
608
|
+
|
|
609
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
610
|
+
"""Evaluate if RDS instance or cluster is publicly accessible."""
|
|
611
|
+
resource_type = resource.get('Type', 'Unknown')
|
|
612
|
+
|
|
613
|
+
if resource_type == 'DBInstance':
|
|
614
|
+
instance_id = resource.get('InstanceId', 'unknown')
|
|
615
|
+
is_public = resource.get('PubliclyAccessible', False)
|
|
616
|
+
|
|
617
|
+
if is_public:
|
|
618
|
+
return ComplianceResult(
|
|
619
|
+
resource_id=instance_id,
|
|
620
|
+
resource_type="AWS::RDS::DBInstance",
|
|
621
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
622
|
+
evaluation_reason="RDS instance is publicly accessible",
|
|
623
|
+
config_rule_name=self.rule_name,
|
|
624
|
+
region=region
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
return ComplianceResult(
|
|
628
|
+
resource_id=instance_id,
|
|
629
|
+
resource_type="AWS::RDS::DBInstance",
|
|
630
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
631
|
+
evaluation_reason="RDS instance is not publicly accessible",
|
|
632
|
+
config_rule_name=self.rule_name,
|
|
633
|
+
region=region
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
elif resource_type == 'DBCluster':
|
|
637
|
+
cluster_id = resource.get('ClusterId', 'unknown')
|
|
638
|
+
public_members = resource.get('PublicMembers', [])
|
|
639
|
+
|
|
640
|
+
if public_members:
|
|
641
|
+
return ComplianceResult(
|
|
642
|
+
resource_id=cluster_id,
|
|
643
|
+
resource_type="AWS::RDS::DBCluster",
|
|
644
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
645
|
+
evaluation_reason=f"RDS cluster has publicly accessible member instances: {', '.join(public_members)}",
|
|
646
|
+
config_rule_name=self.rule_name,
|
|
647
|
+
region=region
|
|
648
|
+
)
|
|
649
|
+
else:
|
|
650
|
+
return ComplianceResult(
|
|
651
|
+
resource_id=cluster_id,
|
|
652
|
+
resource_type="AWS::RDS::DBCluster",
|
|
653
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
654
|
+
evaluation_reason="RDS cluster has no publicly accessible member instances",
|
|
655
|
+
config_rule_name=self.rule_name,
|
|
656
|
+
region=region
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
else:
|
|
660
|
+
return ComplianceResult(
|
|
661
|
+
resource_id="unknown",
|
|
662
|
+
resource_type="AWS::RDS::DBInstance",
|
|
663
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
664
|
+
evaluation_reason=f"Unknown RDS resource type: {resource_type}",
|
|
665
|
+
config_rule_name=self.rule_name,
|
|
666
|
+
region=region
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class RedshiftClusterPublicAccessCheckAssessment(BaseConfigRuleAssessment):
|
|
671
|
+
"""
|
|
672
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
673
|
+
AWS Config Rule: redshift-cluster-public-access-check
|
|
674
|
+
|
|
675
|
+
Ensures Redshift clusters are not publicly accessible to prevent data warehouse exposure.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
def __init__(self):
|
|
679
|
+
super().__init__(
|
|
680
|
+
rule_name="redshift-cluster-public-access-check",
|
|
681
|
+
control_id="3.3",
|
|
682
|
+
resource_types=["AWS::Redshift::Cluster"]
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
686
|
+
"""Get all Redshift clusters in the region."""
|
|
687
|
+
if resource_type != "AWS::Redshift::Cluster":
|
|
688
|
+
return []
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
redshift_client = aws_factory.get_client('redshift', region)
|
|
692
|
+
|
|
693
|
+
# Get all Redshift clusters
|
|
694
|
+
paginator = redshift_client.get_paginator('describe_clusters')
|
|
695
|
+
clusters = []
|
|
696
|
+
|
|
697
|
+
for page in paginator.paginate():
|
|
698
|
+
for cluster in page['Clusters']:
|
|
699
|
+
clusters.append({
|
|
700
|
+
'ClusterId': cluster['ClusterIdentifier'],
|
|
701
|
+
'NodeType': cluster.get('NodeType', ''),
|
|
702
|
+
'NumberOfNodes': cluster.get('NumberOfNodes', 0),
|
|
703
|
+
'PubliclyAccessible': cluster.get('PubliclyAccessible', False),
|
|
704
|
+
'VpcId': cluster.get('VpcId', ''),
|
|
705
|
+
'VpcSecurityGroups': [sg['VpcSecurityGroupId'] for sg in cluster.get('VpcSecurityGroups', [])],
|
|
706
|
+
'ClusterSubnetGroup': cluster.get('ClusterSubnetGroupName', ''),
|
|
707
|
+
'Endpoint': cluster.get('Endpoint', {}).get('Address', '') if cluster.get('Endpoint') else '',
|
|
708
|
+
'Encrypted': cluster.get('Encrypted', False)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
logger.debug(f"Found {len(clusters)} Redshift clusters in {region}")
|
|
712
|
+
return clusters
|
|
713
|
+
|
|
714
|
+
except ClientError as e:
|
|
715
|
+
logger.error(f"Error retrieving Redshift clusters in {region}: {e}")
|
|
716
|
+
raise
|
|
717
|
+
except Exception as e:
|
|
718
|
+
logger.error(f"Unexpected error retrieving Redshift clusters in {region}: {e}")
|
|
719
|
+
raise
|
|
720
|
+
|
|
721
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
722
|
+
"""Evaluate if Redshift cluster is publicly accessible."""
|
|
723
|
+
cluster_id = resource.get('ClusterId', 'unknown')
|
|
724
|
+
is_public = resource.get('PubliclyAccessible', False)
|
|
725
|
+
|
|
726
|
+
if is_public:
|
|
727
|
+
return ComplianceResult(
|
|
728
|
+
resource_id=cluster_id,
|
|
729
|
+
resource_type="AWS::Redshift::Cluster",
|
|
730
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
731
|
+
evaluation_reason="Redshift cluster is publicly accessible",
|
|
732
|
+
config_rule_name=self.rule_name,
|
|
733
|
+
region=region
|
|
734
|
+
)
|
|
735
|
+
else:
|
|
736
|
+
return ComplianceResult(
|
|
737
|
+
resource_id=cluster_id,
|
|
738
|
+
resource_type="AWS::Redshift::Cluster",
|
|
739
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
740
|
+
evaluation_reason="Redshift cluster is not publicly accessible",
|
|
741
|
+
config_rule_name=self.rule_name,
|
|
742
|
+
region=region
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
class S3BucketLevelPublicAccessProhibitedAssessment(BaseConfigRuleAssessment):
|
|
747
|
+
"""
|
|
748
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
749
|
+
AWS Config Rule: s3-bucket-level-public-access-prohibited
|
|
750
|
+
|
|
751
|
+
Ensures S3 buckets do not allow public access at the bucket level to prevent data exposure.
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
def __init__(self):
|
|
755
|
+
super().__init__(
|
|
756
|
+
rule_name="s3-bucket-level-public-access-prohibited",
|
|
757
|
+
control_id="3.3",
|
|
758
|
+
resource_types=["AWS::S3::Bucket"]
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
762
|
+
"""Get all S3 buckets (only from us-east-1 to avoid duplicates)."""
|
|
763
|
+
if resource_type != "AWS::S3::Bucket":
|
|
764
|
+
return []
|
|
765
|
+
|
|
766
|
+
# S3 is global, only check from us-east-1 to avoid duplicate checks
|
|
767
|
+
if region != 'us-east-1':
|
|
768
|
+
return []
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
s3_client = aws_factory.get_client('s3', region)
|
|
772
|
+
|
|
773
|
+
response = s3_client.list_buckets()
|
|
774
|
+
buckets = []
|
|
775
|
+
|
|
776
|
+
for bucket in response.get('Buckets', []):
|
|
777
|
+
bucket_name = bucket['Name']
|
|
778
|
+
|
|
779
|
+
try:
|
|
780
|
+
# Get bucket public access block configuration
|
|
781
|
+
pab_config = {}
|
|
782
|
+
try:
|
|
783
|
+
pab_response = s3_client.get_public_access_block(Bucket=bucket_name)
|
|
784
|
+
pab_config = pab_response.get('PublicAccessBlockConfiguration', {})
|
|
785
|
+
except ClientError as e:
|
|
786
|
+
if e.response.get('Error', {}).get('Code') != 'NoSuchPublicAccessBlockConfiguration':
|
|
787
|
+
raise e
|
|
788
|
+
|
|
789
|
+
# Check bucket ACL for public permissions
|
|
790
|
+
acl_response = s3_client.get_bucket_acl(Bucket=bucket_name)
|
|
791
|
+
grants = acl_response.get('Grants', [])
|
|
792
|
+
|
|
793
|
+
public_acl = False
|
|
794
|
+
for grant in grants:
|
|
795
|
+
grantee = grant.get('Grantee', {})
|
|
796
|
+
if grantee.get('Type') == 'Group':
|
|
797
|
+
uri = grantee.get('URI', '')
|
|
798
|
+
if 'AllUsers' in uri or 'AuthenticatedUsers' in uri:
|
|
799
|
+
public_acl = True
|
|
800
|
+
break
|
|
801
|
+
|
|
802
|
+
# Check bucket policy for public permissions
|
|
803
|
+
public_policy = False
|
|
804
|
+
policy_statements = []
|
|
805
|
+
try:
|
|
806
|
+
policy_response = s3_client.get_bucket_policy(Bucket=bucket_name)
|
|
807
|
+
policy_doc = json.loads(policy_response['Policy'])
|
|
808
|
+
statements = policy_doc.get('Statement', [])
|
|
809
|
+
|
|
810
|
+
for statement in statements:
|
|
811
|
+
if isinstance(statement, dict):
|
|
812
|
+
effect = statement.get('Effect', '')
|
|
813
|
+
principal = statement.get('Principal', {})
|
|
814
|
+
|
|
815
|
+
if effect == 'Allow':
|
|
816
|
+
if principal == '*' or (isinstance(principal, dict) and principal.get('AWS') == '*'):
|
|
817
|
+
public_policy = True
|
|
818
|
+
policy_statements.append(statement)
|
|
819
|
+
|
|
820
|
+
except ClientError as e:
|
|
821
|
+
if e.response.get('Error', {}).get('Code') != 'NoSuchBucketPolicy':
|
|
822
|
+
raise e
|
|
823
|
+
|
|
824
|
+
buckets.append({
|
|
825
|
+
'BucketName': bucket_name,
|
|
826
|
+
'PublicAccessBlock': pab_config,
|
|
827
|
+
'PublicACL': public_acl,
|
|
828
|
+
'PublicPolicy': public_policy,
|
|
829
|
+
'PolicyStatements': policy_statements
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
except ClientError as e:
|
|
833
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
834
|
+
if error_code in ['NoSuchBucket', 'BucketNotEmpty', 'AccessDenied']:
|
|
835
|
+
continue
|
|
836
|
+
else:
|
|
837
|
+
logger.warning(f"Error checking S3 bucket {bucket_name}: {e}")
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
logger.debug(f"Found {len(buckets)} S3 buckets from {region}")
|
|
841
|
+
return buckets
|
|
842
|
+
|
|
843
|
+
except ClientError as e:
|
|
844
|
+
logger.error(f"Error retrieving S3 buckets from {region}: {e}")
|
|
845
|
+
raise
|
|
846
|
+
except Exception as e:
|
|
847
|
+
logger.error(f"Unexpected error retrieving S3 buckets from {region}: {e}")
|
|
848
|
+
raise
|
|
849
|
+
|
|
850
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
851
|
+
"""Evaluate if S3 bucket allows public access."""
|
|
852
|
+
bucket_name = resource.get('BucketName', 'unknown')
|
|
853
|
+
pab_config = resource.get('PublicAccessBlock', {})
|
|
854
|
+
public_acl = resource.get('PublicACL', False)
|
|
855
|
+
public_policy = resource.get('PublicPolicy', False)
|
|
856
|
+
|
|
857
|
+
# Check if all public access is blocked
|
|
858
|
+
block_public_acls = pab_config.get('BlockPublicAcls', False)
|
|
859
|
+
ignore_public_acls = pab_config.get('IgnorePublicAcls', False)
|
|
860
|
+
block_public_policy = pab_config.get('BlockPublicPolicy', False)
|
|
861
|
+
restrict_public_buckets = pab_config.get('RestrictPublicBuckets', False)
|
|
862
|
+
|
|
863
|
+
all_blocked = all([
|
|
864
|
+
block_public_acls,
|
|
865
|
+
ignore_public_acls,
|
|
866
|
+
block_public_policy,
|
|
867
|
+
restrict_public_buckets
|
|
868
|
+
])
|
|
869
|
+
|
|
870
|
+
# Determine compliance
|
|
871
|
+
has_public_access = public_acl or public_policy or not all_blocked
|
|
872
|
+
|
|
873
|
+
if has_public_access:
|
|
874
|
+
issues = []
|
|
875
|
+
if not all_blocked:
|
|
876
|
+
issues.append("Public Access Block not fully configured")
|
|
877
|
+
if public_acl:
|
|
878
|
+
issues.append("Bucket ACL allows public access")
|
|
879
|
+
if public_policy:
|
|
880
|
+
issues.append("Bucket policy allows public access")
|
|
881
|
+
|
|
882
|
+
return ComplianceResult(
|
|
883
|
+
resource_id=bucket_name,
|
|
884
|
+
resource_type="AWS::S3::Bucket",
|
|
885
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
886
|
+
evaluation_reason=f"S3 bucket allows public access: {', '.join(issues)}",
|
|
887
|
+
config_rule_name=self.rule_name,
|
|
888
|
+
region=region
|
|
889
|
+
)
|
|
890
|
+
else:
|
|
891
|
+
return ComplianceResult(
|
|
892
|
+
resource_id=bucket_name,
|
|
893
|
+
resource_type="AWS::S3::Bucket",
|
|
894
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
895
|
+
evaluation_reason="S3 bucket blocks all public access",
|
|
896
|
+
config_rule_name=self.rule_name,
|
|
897
|
+
region=region
|
|
898
|
+
)
|