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,672 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CIS Control 3.3 - Network Security Controls
|
|
3
|
+
Critical network security rules to prevent public exposure and ensure proper network isolation.
|
|
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 DMSReplicationNotPublicAssessment(BaseConfigRuleAssessment):
|
|
20
|
+
"""
|
|
21
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
22
|
+
AWS Config Rule: dms-replication-not-public
|
|
23
|
+
|
|
24
|
+
Ensures DMS replication instances are not publicly accessible to prevent data exposure.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__(
|
|
29
|
+
rule_name="dms-replication-not-public",
|
|
30
|
+
control_id="3.3",
|
|
31
|
+
resource_types=["AWS::DMS::ReplicationInstance"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
35
|
+
"""Get all DMS replication instances in the region."""
|
|
36
|
+
if resource_type != "AWS::DMS::ReplicationInstance":
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
dms_client = aws_factory.get_client('dms', region)
|
|
41
|
+
|
|
42
|
+
# Get all DMS replication instances
|
|
43
|
+
paginator = dms_client.get_paginator('describe_replication_instances')
|
|
44
|
+
instances = []
|
|
45
|
+
|
|
46
|
+
for page in paginator.paginate():
|
|
47
|
+
for instance in page['ReplicationInstances']:
|
|
48
|
+
instances.append({
|
|
49
|
+
'ReplicationInstanceIdentifier': instance['ReplicationInstanceIdentifier'],
|
|
50
|
+
'ReplicationInstanceArn': instance['ReplicationInstanceArn'],
|
|
51
|
+
'ReplicationInstanceClass': instance.get('ReplicationInstanceClass', ''),
|
|
52
|
+
'PubliclyAccessible': instance.get('PubliclyAccessible', False),
|
|
53
|
+
'VpcSecurityGroups': [sg['VpcSecurityGroupId'] for sg in instance.get('VpcSecurityGroups', [])],
|
|
54
|
+
'ReplicationSubnetGroup': instance.get('ReplicationSubnetGroup', {}).get('ReplicationSubnetGroupIdentifier', ''),
|
|
55
|
+
'AvailabilityZone': instance.get('AvailabilityZone', ''),
|
|
56
|
+
'MultiAZ': instance.get('MultiAZ', False)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
logger.debug(f"Found {len(instances)} DMS replication instances in {region}")
|
|
60
|
+
return instances
|
|
61
|
+
|
|
62
|
+
except ClientError as e:
|
|
63
|
+
logger.error(f"Error retrieving DMS replication instances in {region}: {e}")
|
|
64
|
+
raise
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Unexpected error retrieving DMS replication instances 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 DMS replication instance is publicly accessible."""
|
|
71
|
+
instance_id = resource.get('ReplicationInstanceIdentifier', 'unknown')
|
|
72
|
+
is_public = resource.get('PubliclyAccessible', False)
|
|
73
|
+
|
|
74
|
+
if is_public:
|
|
75
|
+
return ComplianceResult(
|
|
76
|
+
resource_id=instance_id,
|
|
77
|
+
resource_type="AWS::DMS::ReplicationInstance",
|
|
78
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
79
|
+
evaluation_reason="DMS replication instance is publicly accessible",
|
|
80
|
+
config_rule_name=self.rule_name,
|
|
81
|
+
region=region
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
return ComplianceResult(
|
|
85
|
+
resource_id=instance_id,
|
|
86
|
+
resource_type="AWS::DMS::ReplicationInstance",
|
|
87
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
88
|
+
evaluation_reason="DMS replication instance is not publicly accessible",
|
|
89
|
+
config_rule_name=self.rule_name,
|
|
90
|
+
region=region
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ElasticsearchInVPCOnlyAssessment(BaseConfigRuleAssessment):
|
|
95
|
+
"""
|
|
96
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
97
|
+
AWS Config Rule: elasticsearch-in-vpc-only
|
|
98
|
+
|
|
99
|
+
Ensures Elasticsearch domains are deployed within VPC to prevent public access.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self):
|
|
103
|
+
super().__init__(
|
|
104
|
+
rule_name="elasticsearch-in-vpc-only",
|
|
105
|
+
control_id="3.3",
|
|
106
|
+
resource_types=["AWS::Elasticsearch::Domain"]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
110
|
+
"""Get all Elasticsearch domains in the region."""
|
|
111
|
+
if resource_type != "AWS::Elasticsearch::Domain":
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
es_client = aws_factory.get_client('es', region)
|
|
116
|
+
|
|
117
|
+
# Get all Elasticsearch domains
|
|
118
|
+
response = es_client.list_domain_names()
|
|
119
|
+
domains = []
|
|
120
|
+
|
|
121
|
+
for domain_info in response.get('DomainNames', []):
|
|
122
|
+
domain_name = domain_info['DomainName']
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Get detailed domain configuration
|
|
126
|
+
domain_response = es_client.describe_elasticsearch_domain(DomainName=domain_name)
|
|
127
|
+
domain = domain_response['DomainStatus']
|
|
128
|
+
|
|
129
|
+
vpc_options = domain.get('VPCOptions', {})
|
|
130
|
+
|
|
131
|
+
domains.append({
|
|
132
|
+
'DomainName': domain_name,
|
|
133
|
+
'DomainArn': domain.get('ARN', ''),
|
|
134
|
+
'ElasticsearchVersion': domain.get('ElasticsearchVersion', ''),
|
|
135
|
+
'VPCOptions': vpc_options,
|
|
136
|
+
'VPCId': vpc_options.get('VPCId', ''),
|
|
137
|
+
'SubnetIds': vpc_options.get('SubnetIds', []),
|
|
138
|
+
'SecurityGroupIds': vpc_options.get('SecurityGroupIds', []),
|
|
139
|
+
'Endpoint': domain.get('Endpoint', ''),
|
|
140
|
+
'Endpoints': domain.get('Endpoints', {})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
except ClientError as e:
|
|
144
|
+
logger.warning(f"Error getting details for Elasticsearch domain {domain_name}: {e}")
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
logger.debug(f"Found {len(domains)} Elasticsearch domains in {region}")
|
|
148
|
+
return domains
|
|
149
|
+
|
|
150
|
+
except ClientError as e:
|
|
151
|
+
logger.error(f"Error retrieving Elasticsearch domains in {region}: {e}")
|
|
152
|
+
raise
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"Unexpected error retrieving Elasticsearch domains in {region}: {e}")
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
158
|
+
"""Evaluate if Elasticsearch domain is deployed within VPC."""
|
|
159
|
+
domain_name = resource.get('DomainName', 'unknown')
|
|
160
|
+
vpc_id = resource.get('VPCId', '')
|
|
161
|
+
|
|
162
|
+
if vpc_id:
|
|
163
|
+
return ComplianceResult(
|
|
164
|
+
resource_id=domain_name,
|
|
165
|
+
resource_type="AWS::Elasticsearch::Domain",
|
|
166
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
167
|
+
evaluation_reason=f"Elasticsearch domain is deployed within VPC {vpc_id}",
|
|
168
|
+
config_rule_name=self.rule_name,
|
|
169
|
+
region=region
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
return ComplianceResult(
|
|
173
|
+
resource_id=domain_name,
|
|
174
|
+
resource_type="AWS::Elasticsearch::Domain",
|
|
175
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
176
|
+
evaluation_reason="Elasticsearch domain is not deployed within VPC",
|
|
177
|
+
config_rule_name=self.rule_name,
|
|
178
|
+
region=region
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class EC2InstancesInVPCAssessment(BaseConfigRuleAssessment):
|
|
183
|
+
"""
|
|
184
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
185
|
+
AWS Config Rule: ec2-instances-in-vpc
|
|
186
|
+
|
|
187
|
+
Ensures EC2 instances are deployed within VPC for network security.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self):
|
|
191
|
+
super().__init__(
|
|
192
|
+
rule_name="ec2-instances-in-vpc",
|
|
193
|
+
control_id="3.3",
|
|
194
|
+
resource_types=["AWS::EC2::Instance"]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
198
|
+
"""Get all EC2 instances in the region."""
|
|
199
|
+
if resource_type != "AWS::EC2::Instance":
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
204
|
+
|
|
205
|
+
# Get all EC2 instances
|
|
206
|
+
paginator = ec2_client.get_paginator('describe_instances')
|
|
207
|
+
instances = []
|
|
208
|
+
|
|
209
|
+
for page in paginator.paginate():
|
|
210
|
+
for reservation in page['Reservations']:
|
|
211
|
+
for instance in reservation['Instances']:
|
|
212
|
+
instances.append({
|
|
213
|
+
'InstanceId': instance['InstanceId'],
|
|
214
|
+
'VpcId': instance.get('VpcId', ''),
|
|
215
|
+
'SubnetId': instance.get('SubnetId', ''),
|
|
216
|
+
'State': instance.get('State', {}).get('Name', ''),
|
|
217
|
+
'InstanceType': instance.get('InstanceType', ''),
|
|
218
|
+
'PublicIpAddress': instance.get('PublicIpAddress', ''),
|
|
219
|
+
'PrivateIpAddress': instance.get('PrivateIpAddress', '')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
logger.debug(f"Found {len(instances)} EC2 instances in {region}")
|
|
223
|
+
return instances
|
|
224
|
+
|
|
225
|
+
except ClientError as e:
|
|
226
|
+
logger.error(f"Error retrieving EC2 instances in {region}: {e}")
|
|
227
|
+
raise
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.error(f"Unexpected error retrieving EC2 instances in {region}: {e}")
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
233
|
+
"""Evaluate if EC2 instance is deployed within VPC."""
|
|
234
|
+
instance_id = resource.get('InstanceId', 'unknown')
|
|
235
|
+
vpc_id = resource.get('VpcId', '')
|
|
236
|
+
state = resource.get('State', '')
|
|
237
|
+
|
|
238
|
+
# Skip terminated instances
|
|
239
|
+
if state == 'terminated':
|
|
240
|
+
return ComplianceResult(
|
|
241
|
+
resource_id=instance_id,
|
|
242
|
+
resource_type="AWS::EC2::Instance",
|
|
243
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
244
|
+
evaluation_reason=f"Instance {instance_id} is terminated",
|
|
245
|
+
config_rule_name=self.rule_name,
|
|
246
|
+
region=region
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if vpc_id:
|
|
250
|
+
return ComplianceResult(
|
|
251
|
+
resource_id=instance_id,
|
|
252
|
+
resource_type="AWS::EC2::Instance",
|
|
253
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
254
|
+
evaluation_reason=f"EC2 instance is deployed within VPC {vpc_id}",
|
|
255
|
+
config_rule_name=self.rule_name,
|
|
256
|
+
region=region
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
return ComplianceResult(
|
|
260
|
+
resource_id=instance_id,
|
|
261
|
+
resource_type="AWS::EC2::Instance",
|
|
262
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
263
|
+
evaluation_reason="EC2 instance is not deployed within VPC (EC2-Classic)",
|
|
264
|
+
config_rule_name=self.rule_name,
|
|
265
|
+
region=region
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class EMRMasterNoPublicIPAssessment(BaseConfigRuleAssessment):
|
|
270
|
+
"""
|
|
271
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
272
|
+
AWS Config Rule: emr-master-no-public-ip
|
|
273
|
+
|
|
274
|
+
Ensures EMR master nodes do not have public IP addresses.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self):
|
|
278
|
+
super().__init__(
|
|
279
|
+
rule_name="emr-master-no-public-ip",
|
|
280
|
+
control_id="3.3",
|
|
281
|
+
resource_types=["AWS::EMR::Cluster"]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
285
|
+
"""Get all EMR clusters in the region."""
|
|
286
|
+
if resource_type != "AWS::EMR::Cluster":
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
emr_client = aws_factory.get_client('emr', region)
|
|
291
|
+
|
|
292
|
+
# Get all EMR clusters
|
|
293
|
+
paginator = emr_client.get_paginator('list_clusters')
|
|
294
|
+
clusters = []
|
|
295
|
+
|
|
296
|
+
for page in paginator.paginate():
|
|
297
|
+
for cluster_summary in page['Clusters']:
|
|
298
|
+
cluster_id = cluster_summary['Id']
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Get detailed cluster information
|
|
302
|
+
cluster_response = emr_client.describe_cluster(ClusterId=cluster_id)
|
|
303
|
+
cluster = cluster_response['Cluster']
|
|
304
|
+
|
|
305
|
+
# Get instance groups to check master node configuration
|
|
306
|
+
instance_groups_response = emr_client.list_instance_groups(ClusterId=cluster_id)
|
|
307
|
+
|
|
308
|
+
master_public_ip = False
|
|
309
|
+
for instance_group in instance_groups_response['InstanceGroups']:
|
|
310
|
+
if instance_group['InstanceGroupType'] == 'MASTER':
|
|
311
|
+
# Check if master instances have public IPs
|
|
312
|
+
instances_response = emr_client.list_instances(
|
|
313
|
+
ClusterId=cluster_id,
|
|
314
|
+
InstanceGroupTypes=['MASTER']
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
for instance in instances_response['Instances']:
|
|
318
|
+
if instance.get('PublicIpAddress'):
|
|
319
|
+
master_public_ip = True
|
|
320
|
+
break
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
clusters.append({
|
|
324
|
+
'ClusterId': cluster_id,
|
|
325
|
+
'Name': cluster.get('Name', ''),
|
|
326
|
+
'State': cluster.get('Status', {}).get('State', ''),
|
|
327
|
+
'MasterPublicDnsName': cluster.get('MasterPublicDnsName', ''),
|
|
328
|
+
'Ec2InstanceAttributes': cluster.get('Ec2InstanceAttributes', {}),
|
|
329
|
+
'MasterHasPublicIP': master_public_ip
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
except ClientError as e:
|
|
333
|
+
logger.warning(f"Error getting details for EMR cluster {cluster_id}: {e}")
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
logger.debug(f"Found {len(clusters)} EMR clusters in {region}")
|
|
337
|
+
return clusters
|
|
338
|
+
|
|
339
|
+
except ClientError as e:
|
|
340
|
+
logger.error(f"Error retrieving EMR clusters in {region}: {e}")
|
|
341
|
+
raise
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.error(f"Unexpected error retrieving EMR clusters in {region}: {e}")
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
347
|
+
"""Evaluate if EMR master node has public IP address."""
|
|
348
|
+
cluster_id = resource.get('ClusterId', 'unknown')
|
|
349
|
+
state = resource.get('State', '')
|
|
350
|
+
master_has_public_ip = resource.get('MasterHasPublicIP', False)
|
|
351
|
+
|
|
352
|
+
# Skip terminated clusters
|
|
353
|
+
if state in ['TERMINATED', 'TERMINATED_WITH_ERRORS']:
|
|
354
|
+
return ComplianceResult(
|
|
355
|
+
resource_id=cluster_id,
|
|
356
|
+
resource_type="AWS::EMR::Cluster",
|
|
357
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
358
|
+
evaluation_reason=f"EMR cluster {cluster_id} is terminated",
|
|
359
|
+
config_rule_name=self.rule_name,
|
|
360
|
+
region=region
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if master_has_public_ip:
|
|
364
|
+
return ComplianceResult(
|
|
365
|
+
resource_id=cluster_id,
|
|
366
|
+
resource_type="AWS::EMR::Cluster",
|
|
367
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
368
|
+
evaluation_reason="EMR master node has public IP address",
|
|
369
|
+
config_rule_name=self.rule_name,
|
|
370
|
+
region=region
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
return ComplianceResult(
|
|
374
|
+
resource_id=cluster_id,
|
|
375
|
+
resource_type="AWS::EMR::Cluster",
|
|
376
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
377
|
+
evaluation_reason="EMR master node does not have public IP address",
|
|
378
|
+
config_rule_name=self.rule_name,
|
|
379
|
+
region=region
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class LambdaFunctionPublicAccessProhibitedAssessment(BaseConfigRuleAssessment):
|
|
384
|
+
"""
|
|
385
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
386
|
+
AWS Config Rule: lambda-function-public-access-prohibited
|
|
387
|
+
|
|
388
|
+
Ensures Lambda functions cannot be publicly accessed.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def __init__(self):
|
|
392
|
+
super().__init__(
|
|
393
|
+
rule_name="lambda-function-public-access-prohibited",
|
|
394
|
+
control_id="3.3",
|
|
395
|
+
resource_types=["AWS::Lambda::Function"]
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
399
|
+
"""Get all Lambda functions in the region."""
|
|
400
|
+
if resource_type != "AWS::Lambda::Function":
|
|
401
|
+
return []
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
lambda_client = aws_factory.get_client('lambda', region)
|
|
405
|
+
|
|
406
|
+
# Get all Lambda functions
|
|
407
|
+
paginator = lambda_client.get_paginator('list_functions')
|
|
408
|
+
functions = []
|
|
409
|
+
|
|
410
|
+
for page in paginator.paginate():
|
|
411
|
+
for function in page['Functions']:
|
|
412
|
+
function_name = function['FunctionName']
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
# Get function policy to check for public access
|
|
416
|
+
policy_response = lambda_client.get_policy(FunctionName=function_name)
|
|
417
|
+
policy_doc = json.loads(policy_response['Policy'])
|
|
418
|
+
|
|
419
|
+
has_public_access = False
|
|
420
|
+
public_statements = []
|
|
421
|
+
|
|
422
|
+
for statement in policy_doc.get('Statement', []):
|
|
423
|
+
if isinstance(statement, dict):
|
|
424
|
+
effect = statement.get('Effect', '')
|
|
425
|
+
principal = statement.get('Principal', {})
|
|
426
|
+
|
|
427
|
+
if effect == 'Allow':
|
|
428
|
+
if principal == '*' or (isinstance(principal, dict) and principal.get('AWS') == '*'):
|
|
429
|
+
has_public_access = True
|
|
430
|
+
public_statements.append(statement)
|
|
431
|
+
|
|
432
|
+
functions.append({
|
|
433
|
+
'FunctionName': function_name,
|
|
434
|
+
'FunctionArn': function['FunctionArn'],
|
|
435
|
+
'Runtime': function.get('Runtime', ''),
|
|
436
|
+
'VpcConfig': function.get('VpcConfig', {}),
|
|
437
|
+
'HasPublicAccess': has_public_access,
|
|
438
|
+
'PublicStatements': public_statements
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
except ClientError as e:
|
|
442
|
+
if e.response.get('Error', {}).get('Code') == 'ResourceNotFoundException':
|
|
443
|
+
# Function has no policy, so no public access
|
|
444
|
+
functions.append({
|
|
445
|
+
'FunctionName': function_name,
|
|
446
|
+
'FunctionArn': function['FunctionArn'],
|
|
447
|
+
'Runtime': function.get('Runtime', ''),
|
|
448
|
+
'VpcConfig': function.get('VpcConfig', {}),
|
|
449
|
+
'HasPublicAccess': False,
|
|
450
|
+
'PublicStatements': []
|
|
451
|
+
})
|
|
452
|
+
else:
|
|
453
|
+
logger.warning(f"Error getting policy for Lambda function {function_name}: {e}")
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
logger.debug(f"Found {len(functions)} Lambda functions in {region}")
|
|
457
|
+
return functions
|
|
458
|
+
|
|
459
|
+
except ClientError as e:
|
|
460
|
+
logger.error(f"Error retrieving Lambda functions in {region}: {e}")
|
|
461
|
+
raise
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"Unexpected error retrieving Lambda functions in {region}: {e}")
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
467
|
+
"""Evaluate if Lambda function has public access."""
|
|
468
|
+
function_name = resource.get('FunctionName', 'unknown')
|
|
469
|
+
has_public_access = resource.get('HasPublicAccess', False)
|
|
470
|
+
|
|
471
|
+
if has_public_access:
|
|
472
|
+
return ComplianceResult(
|
|
473
|
+
resource_id=function_name,
|
|
474
|
+
resource_type="AWS::Lambda::Function",
|
|
475
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
476
|
+
evaluation_reason="Lambda function allows public access",
|
|
477
|
+
config_rule_name=self.rule_name,
|
|
478
|
+
region=region
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
return ComplianceResult(
|
|
482
|
+
resource_id=function_name,
|
|
483
|
+
resource_type="AWS::Lambda::Function",
|
|
484
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
485
|
+
evaluation_reason="Lambda function does not allow public access",
|
|
486
|
+
config_rule_name=self.rule_name,
|
|
487
|
+
region=region
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class SageMakerNotebookNoDirectInternetAccessAssessment(BaseConfigRuleAssessment):
|
|
492
|
+
"""
|
|
493
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
494
|
+
AWS Config Rule: sagemaker-notebook-no-direct-internet-access
|
|
495
|
+
|
|
496
|
+
Ensures SageMaker notebooks do not have direct internet access.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
def __init__(self):
|
|
500
|
+
super().__init__(
|
|
501
|
+
rule_name="sagemaker-notebook-no-direct-internet-access",
|
|
502
|
+
control_id="3.3",
|
|
503
|
+
resource_types=["AWS::SageMaker::NotebookInstance"]
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
507
|
+
"""Get all SageMaker notebook instances in the region."""
|
|
508
|
+
if resource_type != "AWS::SageMaker::NotebookInstance":
|
|
509
|
+
return []
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
sagemaker_client = aws_factory.get_client('sagemaker', region)
|
|
513
|
+
|
|
514
|
+
# Get all SageMaker notebook instances
|
|
515
|
+
paginator = sagemaker_client.get_paginator('list_notebook_instances')
|
|
516
|
+
notebooks = []
|
|
517
|
+
|
|
518
|
+
for page in paginator.paginate():
|
|
519
|
+
for notebook_summary in page['NotebookInstances']:
|
|
520
|
+
notebook_name = notebook_summary['NotebookInstanceName']
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
# Get detailed notebook instance information
|
|
524
|
+
notebook_response = sagemaker_client.describe_notebook_instance(
|
|
525
|
+
NotebookInstanceName=notebook_name
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
notebooks.append({
|
|
529
|
+
'NotebookInstanceName': notebook_name,
|
|
530
|
+
'NotebookInstanceArn': notebook_response['NotebookInstanceArn'],
|
|
531
|
+
'NotebookInstanceStatus': notebook_response['NotebookInstanceStatus'],
|
|
532
|
+
'InstanceType': notebook_response['InstanceType'],
|
|
533
|
+
'SubnetId': notebook_response.get('SubnetId', ''),
|
|
534
|
+
'SecurityGroups': notebook_response.get('SecurityGroups', []),
|
|
535
|
+
'DirectInternetAccess': notebook_response.get('DirectInternetAccess', 'Enabled')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
except ClientError as e:
|
|
539
|
+
logger.warning(f"Error getting details for SageMaker notebook {notebook_name}: {e}")
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
logger.debug(f"Found {len(notebooks)} SageMaker notebook instances in {region}")
|
|
543
|
+
return notebooks
|
|
544
|
+
|
|
545
|
+
except ClientError as e:
|
|
546
|
+
logger.error(f"Error retrieving SageMaker notebook instances in {region}: {e}")
|
|
547
|
+
raise
|
|
548
|
+
except Exception as e:
|
|
549
|
+
logger.error(f"Unexpected error retrieving SageMaker notebook instances in {region}: {e}")
|
|
550
|
+
raise
|
|
551
|
+
|
|
552
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
553
|
+
"""Evaluate if SageMaker notebook has direct internet access."""
|
|
554
|
+
notebook_name = resource.get('NotebookInstanceName', 'unknown')
|
|
555
|
+
status = resource.get('NotebookInstanceStatus', '')
|
|
556
|
+
direct_internet_access = resource.get('DirectInternetAccess', 'Enabled')
|
|
557
|
+
|
|
558
|
+
# Skip deleted notebooks
|
|
559
|
+
if status == 'Deleting':
|
|
560
|
+
return ComplianceResult(
|
|
561
|
+
resource_id=notebook_name,
|
|
562
|
+
resource_type="AWS::SageMaker::NotebookInstance",
|
|
563
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
564
|
+
evaluation_reason=f"SageMaker notebook {notebook_name} is being deleted",
|
|
565
|
+
config_rule_name=self.rule_name,
|
|
566
|
+
region=region
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if direct_internet_access == 'Enabled':
|
|
570
|
+
return ComplianceResult(
|
|
571
|
+
resource_id=notebook_name,
|
|
572
|
+
resource_type="AWS::SageMaker::NotebookInstance",
|
|
573
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
574
|
+
evaluation_reason="SageMaker notebook has direct internet access enabled",
|
|
575
|
+
config_rule_name=self.rule_name,
|
|
576
|
+
region=region
|
|
577
|
+
)
|
|
578
|
+
else:
|
|
579
|
+
return ComplianceResult(
|
|
580
|
+
resource_id=notebook_name,
|
|
581
|
+
resource_type="AWS::SageMaker::NotebookInstance",
|
|
582
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
583
|
+
evaluation_reason="SageMaker notebook does not have direct internet access",
|
|
584
|
+
config_rule_name=self.rule_name,
|
|
585
|
+
region=region
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class SubnetAutoAssignPublicIPDisabledAssessment(BaseConfigRuleAssessment):
|
|
590
|
+
"""
|
|
591
|
+
CIS Control 3.3 - Configure Data Access Control Lists
|
|
592
|
+
AWS Config Rule: subnet-auto-assign-public-ip-disabled
|
|
593
|
+
|
|
594
|
+
Ensures subnets do not automatically assign public IPs to prevent accidental exposure.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def __init__(self):
|
|
598
|
+
super().__init__(
|
|
599
|
+
rule_name="subnet-auto-assign-public-ip-disabled",
|
|
600
|
+
control_id="3.3",
|
|
601
|
+
resource_types=["AWS::EC2::Subnet"]
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
605
|
+
"""Get all subnets in the region."""
|
|
606
|
+
if resource_type != "AWS::EC2::Subnet":
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
611
|
+
|
|
612
|
+
# Get all subnets
|
|
613
|
+
paginator = ec2_client.get_paginator('describe_subnets')
|
|
614
|
+
subnets = []
|
|
615
|
+
|
|
616
|
+
for page in paginator.paginate():
|
|
617
|
+
for subnet in page['Subnets']:
|
|
618
|
+
subnets.append({
|
|
619
|
+
'SubnetId': subnet['SubnetId'],
|
|
620
|
+
'VpcId': subnet['VpcId'],
|
|
621
|
+
'AvailabilityZone': subnet['AvailabilityZone'],
|
|
622
|
+
'CidrBlock': subnet['CidrBlock'],
|
|
623
|
+
'MapPublicIpOnLaunch': subnet.get('MapPublicIpOnLaunch', False),
|
|
624
|
+
'State': subnet.get('State', ''),
|
|
625
|
+
'Tags': subnet.get('Tags', [])
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
logger.debug(f"Found {len(subnets)} subnets in {region}")
|
|
629
|
+
return subnets
|
|
630
|
+
|
|
631
|
+
except ClientError as e:
|
|
632
|
+
logger.error(f"Error retrieving subnets in {region}: {e}")
|
|
633
|
+
raise
|
|
634
|
+
except Exception as e:
|
|
635
|
+
logger.error(f"Unexpected error retrieving subnets in {region}: {e}")
|
|
636
|
+
raise
|
|
637
|
+
|
|
638
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
639
|
+
"""Evaluate if subnet auto-assigns public IPs."""
|
|
640
|
+
subnet_id = resource.get('SubnetId', 'unknown')
|
|
641
|
+
map_public_ip = resource.get('MapPublicIpOnLaunch', False)
|
|
642
|
+
state = resource.get('State', '')
|
|
643
|
+
|
|
644
|
+
# Skip subnets that are not available
|
|
645
|
+
if state != 'available':
|
|
646
|
+
return ComplianceResult(
|
|
647
|
+
resource_id=subnet_id,
|
|
648
|
+
resource_type="AWS::EC2::Subnet",
|
|
649
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
650
|
+
evaluation_reason=f"Subnet {subnet_id} is in state '{state}'",
|
|
651
|
+
config_rule_name=self.rule_name,
|
|
652
|
+
region=region
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
if map_public_ip:
|
|
656
|
+
return ComplianceResult(
|
|
657
|
+
resource_id=subnet_id,
|
|
658
|
+
resource_type="AWS::EC2::Subnet",
|
|
659
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
660
|
+
evaluation_reason="Subnet automatically assigns public IP addresses",
|
|
661
|
+
config_rule_name=self.rule_name,
|
|
662
|
+
region=region
|
|
663
|
+
)
|
|
664
|
+
else:
|
|
665
|
+
return ComplianceResult(
|
|
666
|
+
resource_id=subnet_id,
|
|
667
|
+
resource_type="AWS::EC2::Subnet",
|
|
668
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
669
|
+
evaluation_reason="Subnet does not automatically assign public IP addresses",
|
|
670
|
+
config_rule_name=self.rule_name,
|
|
671
|
+
region=region
|
|
672
|
+
)
|