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,586 @@
|
|
|
1
|
+
"""Control 1.1: Establish and Maintain Detailed Enterprise Asset Inventory assessments."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Any
|
|
4
|
+
import logging
|
|
5
|
+
from botocore.exceptions import ClientError
|
|
6
|
+
|
|
7
|
+
from aws_cis_assessment.controls.base_control import BaseConfigRuleAssessment
|
|
8
|
+
from aws_cis_assessment.core.models import ComplianceResult, ComplianceStatus
|
|
9
|
+
from aws_cis_assessment.core.aws_client_factory import AWSClientFactory
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EIPAttachedAssessment(BaseConfigRuleAssessment):
|
|
15
|
+
"""Assessment for eip-attached Config rule - ensures Elastic IPs are attached."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
"""Initialize EIP attached assessment."""
|
|
19
|
+
super().__init__(
|
|
20
|
+
rule_name="eip-attached",
|
|
21
|
+
control_id="1.1",
|
|
22
|
+
resource_types=["AWS::EC2::EIP"]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
26
|
+
"""Get all Elastic IP addresses in the region."""
|
|
27
|
+
if resource_type != "AWS::EC2::EIP":
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
32
|
+
|
|
33
|
+
# Use retry logic for API call
|
|
34
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
35
|
+
lambda: ec2_client.describe_addresses()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
eips = []
|
|
39
|
+
for address in response.get('Addresses', []):
|
|
40
|
+
eips.append({
|
|
41
|
+
'AllocationId': address.get('AllocationId'),
|
|
42
|
+
'PublicIp': address.get('PublicIp'),
|
|
43
|
+
'Domain': address.get('Domain', 'standard'),
|
|
44
|
+
'InstanceId': address.get('InstanceId'),
|
|
45
|
+
'NetworkInterfaceId': address.get('NetworkInterfaceId'),
|
|
46
|
+
'AssociationId': address.get('AssociationId'),
|
|
47
|
+
'Tags': address.get('Tags', [])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
logger.debug(f"Found {len(eips)} Elastic IPs in region {region}")
|
|
51
|
+
return eips
|
|
52
|
+
|
|
53
|
+
except ClientError as e:
|
|
54
|
+
logger.error(f"Error retrieving Elastic IPs in region {region}: {e}")
|
|
55
|
+
raise
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Unexpected error retrieving Elastic IPs in region {region}: {e}")
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
61
|
+
"""Evaluate if Elastic IP is attached to an instance or network interface."""
|
|
62
|
+
allocation_id = resource.get('AllocationId', 'unknown')
|
|
63
|
+
public_ip = resource.get('PublicIp', 'unknown')
|
|
64
|
+
|
|
65
|
+
# Check if EIP is attached to an instance or network interface
|
|
66
|
+
is_attached = bool(resource.get('InstanceId') or resource.get('NetworkInterfaceId'))
|
|
67
|
+
|
|
68
|
+
if is_attached:
|
|
69
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
70
|
+
evaluation_reason = f"EIP {public_ip} is attached to instance/ENI"
|
|
71
|
+
if resource.get('InstanceId'):
|
|
72
|
+
evaluation_reason += f" (Instance: {resource.get('InstanceId')})"
|
|
73
|
+
if resource.get('NetworkInterfaceId'):
|
|
74
|
+
evaluation_reason += f" (ENI: {resource.get('NetworkInterfaceId')})"
|
|
75
|
+
else:
|
|
76
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
77
|
+
evaluation_reason = f"EIP {public_ip} is not attached to any instance or network interface"
|
|
78
|
+
|
|
79
|
+
return ComplianceResult(
|
|
80
|
+
resource_id=allocation_id or public_ip,
|
|
81
|
+
resource_type="AWS::EC2::EIP",
|
|
82
|
+
compliance_status=compliance_status,
|
|
83
|
+
evaluation_reason=evaluation_reason,
|
|
84
|
+
config_rule_name=self.rule_name,
|
|
85
|
+
region=region
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _get_rule_remediation_steps(self) -> List[str]:
|
|
89
|
+
"""Get specific remediation steps for unattached EIPs."""
|
|
90
|
+
return [
|
|
91
|
+
"Identify unattached Elastic IP addresses in your AWS account",
|
|
92
|
+
"For each unattached EIP, determine if it's still needed:",
|
|
93
|
+
" - If needed: Attach the EIP to an EC2 instance or Elastic Network Interface",
|
|
94
|
+
" - If not needed: Release the EIP to avoid unnecessary charges",
|
|
95
|
+
"Use AWS CLI: aws ec2 associate-address --allocation-id <eip-id> --instance-id <instance-id>",
|
|
96
|
+
"Or use AWS CLI: aws ec2 release-address --allocation-id <eip-id>",
|
|
97
|
+
"Monitor EIP usage regularly to prevent future unattached EIPs"
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EC2StoppedInstanceAssessment(BaseConfigRuleAssessment):
|
|
102
|
+
"""Assessment for ec2-stopped-instance Config rule - checks for long-stopped instances."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, allowed_days: int = 30):
|
|
105
|
+
"""Initialize EC2 stopped instance assessment."""
|
|
106
|
+
super().__init__(
|
|
107
|
+
rule_name="ec2-stopped-instance",
|
|
108
|
+
control_id="1.1",
|
|
109
|
+
resource_types=["AWS::EC2::Instance"],
|
|
110
|
+
parameters={"allowedDays": allowed_days}
|
|
111
|
+
)
|
|
112
|
+
self.allowed_days = allowed_days
|
|
113
|
+
|
|
114
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
115
|
+
"""Get all EC2 instances in the region."""
|
|
116
|
+
if resource_type != "AWS::EC2::Instance":
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
121
|
+
|
|
122
|
+
# Get all instances (running and stopped)
|
|
123
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
124
|
+
lambda: ec2_client.describe_instances()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
instances = []
|
|
128
|
+
for reservation in response.get('Reservations', []):
|
|
129
|
+
for instance in reservation.get('Instances', []):
|
|
130
|
+
instances.append({
|
|
131
|
+
'InstanceId': instance.get('InstanceId'),
|
|
132
|
+
'State': instance.get('State', {}),
|
|
133
|
+
'LaunchTime': instance.get('LaunchTime'),
|
|
134
|
+
'StateTransitionReason': instance.get('StateTransitionReason'),
|
|
135
|
+
'InstanceType': instance.get('InstanceType'),
|
|
136
|
+
'Tags': instance.get('Tags', [])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
logger.debug(f"Found {len(instances)} EC2 instances in region {region}")
|
|
140
|
+
return instances
|
|
141
|
+
|
|
142
|
+
except ClientError as e:
|
|
143
|
+
logger.error(f"Error retrieving EC2 instances in region {region}: {e}")
|
|
144
|
+
raise
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(f"Unexpected error retrieving EC2 instances in region {region}: {e}")
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
150
|
+
"""Evaluate if EC2 instance has been stopped for too long."""
|
|
151
|
+
instance_id = resource.get('InstanceId', 'unknown')
|
|
152
|
+
state = resource.get('State', {})
|
|
153
|
+
state_name = state.get('Name', 'unknown')
|
|
154
|
+
|
|
155
|
+
# Only evaluate stopped instances
|
|
156
|
+
if state_name != 'stopped':
|
|
157
|
+
return ComplianceResult(
|
|
158
|
+
resource_id=instance_id,
|
|
159
|
+
resource_type="AWS::EC2::Instance",
|
|
160
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
161
|
+
evaluation_reason=f"Instance {instance_id} is in state '{state_name}', not stopped",
|
|
162
|
+
config_rule_name=self.rule_name,
|
|
163
|
+
region=region
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Parse state transition reason to get stop time
|
|
167
|
+
# Format: "User initiated (2023-01-01 12:00:00 GMT)"
|
|
168
|
+
state_reason = resource.get('StateTransitionReason', '')
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
from datetime import datetime, timezone
|
|
172
|
+
import re
|
|
173
|
+
|
|
174
|
+
# Extract timestamp from state transition reason
|
|
175
|
+
timestamp_match = re.search(r'\(([^)]+)\)', state_reason)
|
|
176
|
+
if timestamp_match:
|
|
177
|
+
timestamp_str = timestamp_match.group(1)
|
|
178
|
+
# Try to parse the timestamp
|
|
179
|
+
try:
|
|
180
|
+
# Handle different timestamp formats
|
|
181
|
+
for fmt in ['%Y-%m-%d %H:%M:%S %Z', '%Y-%m-%d %H:%M:%S GMT']:
|
|
182
|
+
try:
|
|
183
|
+
stop_time = datetime.strptime(timestamp_str, fmt)
|
|
184
|
+
if stop_time.tzinfo is None:
|
|
185
|
+
stop_time = stop_time.replace(tzinfo=timezone.utc)
|
|
186
|
+
break
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
else:
|
|
190
|
+
# If parsing fails, assume compliant
|
|
191
|
+
return ComplianceResult(
|
|
192
|
+
resource_id=instance_id,
|
|
193
|
+
resource_type="AWS::EC2::Instance",
|
|
194
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
195
|
+
evaluation_reason=f"Could not parse stop time from: {state_reason}",
|
|
196
|
+
config_rule_name=self.rule_name,
|
|
197
|
+
region=region
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Calculate days stopped
|
|
201
|
+
now = datetime.now(timezone.utc)
|
|
202
|
+
days_stopped = (now - stop_time).days
|
|
203
|
+
|
|
204
|
+
if days_stopped > self.allowed_days:
|
|
205
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
206
|
+
evaluation_reason = f"Instance {instance_id} has been stopped for {days_stopped} days (allowed: {self.allowed_days})"
|
|
207
|
+
else:
|
|
208
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
209
|
+
evaluation_reason = f"Instance {instance_id} has been stopped for {days_stopped} days (within allowed: {self.allowed_days})"
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(f"Error parsing stop time for instance {instance_id}: {e}")
|
|
213
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
214
|
+
evaluation_reason = f"Could not determine stop duration for instance {instance_id}"
|
|
215
|
+
else:
|
|
216
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
217
|
+
evaluation_reason = f"Could not extract stop time from state reason: {state_reason}"
|
|
218
|
+
|
|
219
|
+
except ImportError:
|
|
220
|
+
# Fallback if datetime parsing fails
|
|
221
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
222
|
+
evaluation_reason = f"Instance {instance_id} is stopped but duration could not be determined"
|
|
223
|
+
|
|
224
|
+
return ComplianceResult(
|
|
225
|
+
resource_id=instance_id,
|
|
226
|
+
resource_type="AWS::EC2::Instance",
|
|
227
|
+
compliance_status=compliance_status,
|
|
228
|
+
evaluation_reason=evaluation_reason,
|
|
229
|
+
config_rule_name=self.rule_name,
|
|
230
|
+
region=region
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _get_rule_remediation_steps(self) -> List[str]:
|
|
234
|
+
"""Get specific remediation steps for long-stopped instances."""
|
|
235
|
+
return [
|
|
236
|
+
f"Identify EC2 instances that have been stopped for more than {self.allowed_days} days",
|
|
237
|
+
"Review each long-stopped instance to determine if it's still needed:",
|
|
238
|
+
" - If needed: Start the instance or create an AMI and terminate the instance",
|
|
239
|
+
" - If not needed: Terminate the instance to avoid storage costs",
|
|
240
|
+
"Use AWS CLI: aws ec2 terminate-instances --instance-ids <instance-id>",
|
|
241
|
+
"Or create AMI first: aws ec2 create-image --instance-id <instance-id> --name <ami-name>",
|
|
242
|
+
"Set up automated monitoring to alert on long-stopped instances",
|
|
243
|
+
"Consider using AWS Instance Scheduler for automated start/stop"
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class VPCNetworkACLUnusedAssessment(BaseConfigRuleAssessment):
|
|
248
|
+
"""Assessment for vpc-network-acl-unused-check Config rule - ensures NACLs are in use."""
|
|
249
|
+
|
|
250
|
+
def __init__(self):
|
|
251
|
+
"""Initialize VPC Network ACL unused assessment."""
|
|
252
|
+
super().__init__(
|
|
253
|
+
rule_name="vpc-network-acl-unused-check",
|
|
254
|
+
control_id="1.1",
|
|
255
|
+
resource_types=["AWS::EC2::NetworkAcl"]
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
259
|
+
"""Get all Network ACLs in the region."""
|
|
260
|
+
if resource_type != "AWS::EC2::NetworkAcl":
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
265
|
+
|
|
266
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
267
|
+
lambda: ec2_client.describe_network_acls()
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
nacls = []
|
|
271
|
+
for nacl in response.get('NetworkAcls', []):
|
|
272
|
+
nacls.append({
|
|
273
|
+
'NetworkAclId': nacl.get('NetworkAclId'),
|
|
274
|
+
'VpcId': nacl.get('VpcId'),
|
|
275
|
+
'IsDefault': nacl.get('IsDefault', False),
|
|
276
|
+
'Associations': nacl.get('Associations', []),
|
|
277
|
+
'Tags': nacl.get('Tags', [])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
logger.debug(f"Found {len(nacls)} Network ACLs in region {region}")
|
|
281
|
+
return nacls
|
|
282
|
+
|
|
283
|
+
except ClientError as e:
|
|
284
|
+
logger.error(f"Error retrieving Network ACLs in region {region}: {e}")
|
|
285
|
+
raise
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.error(f"Unexpected error retrieving Network ACLs in region {region}: {e}")
|
|
288
|
+
raise
|
|
289
|
+
|
|
290
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
291
|
+
"""Evaluate if Network ACL is in use (associated with subnets)."""
|
|
292
|
+
nacl_id = resource.get('NetworkAclId', 'unknown')
|
|
293
|
+
is_default = resource.get('IsDefault', False)
|
|
294
|
+
associations = resource.get('Associations', [])
|
|
295
|
+
|
|
296
|
+
# Default NACLs are always considered compliant
|
|
297
|
+
if is_default:
|
|
298
|
+
return ComplianceResult(
|
|
299
|
+
resource_id=nacl_id,
|
|
300
|
+
resource_type="AWS::EC2::NetworkAcl",
|
|
301
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
302
|
+
evaluation_reason=f"Network ACL {nacl_id} is the default NACL",
|
|
303
|
+
config_rule_name=self.rule_name,
|
|
304
|
+
region=region
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Check if NACL has subnet associations
|
|
308
|
+
if associations:
|
|
309
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
310
|
+
subnet_count = len(associations)
|
|
311
|
+
evaluation_reason = f"Network ACL {nacl_id} is associated with {subnet_count} subnet(s)"
|
|
312
|
+
else:
|
|
313
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
314
|
+
evaluation_reason = f"Network ACL {nacl_id} is not associated with any subnets"
|
|
315
|
+
|
|
316
|
+
return ComplianceResult(
|
|
317
|
+
resource_id=nacl_id,
|
|
318
|
+
resource_type="AWS::EC2::NetworkAcl",
|
|
319
|
+
compliance_status=compliance_status,
|
|
320
|
+
evaluation_reason=evaluation_reason,
|
|
321
|
+
config_rule_name=self.rule_name,
|
|
322
|
+
region=region
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _get_rule_remediation_steps(self) -> List[str]:
|
|
326
|
+
"""Get specific remediation steps for unused Network ACLs."""
|
|
327
|
+
return [
|
|
328
|
+
"Identify Network ACLs that are not associated with any subnets",
|
|
329
|
+
"For each unused Network ACL, determine if it's still needed:",
|
|
330
|
+
" - If needed: Associate the NACL with appropriate subnets",
|
|
331
|
+
" - If not needed: Delete the unused Network ACL",
|
|
332
|
+
"Use AWS CLI: aws ec2 replace-network-acl-association --association-id <assoc-id> --network-acl-id <nacl-id>",
|
|
333
|
+
"Or delete unused: aws ec2 delete-network-acl --network-acl-id <nacl-id>",
|
|
334
|
+
"Review Network ACL rules to ensure they provide appropriate security",
|
|
335
|
+
"Document the purpose of each custom Network ACL"
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class EC2InstanceManagedBySSMAssessment(BaseConfigRuleAssessment):
|
|
340
|
+
"""Assessment for ec2-instance-managed-by-systems-manager Config rule."""
|
|
341
|
+
|
|
342
|
+
def __init__(self):
|
|
343
|
+
"""Initialize EC2 instance managed by SSM assessment."""
|
|
344
|
+
super().__init__(
|
|
345
|
+
rule_name="ec2-instance-managed-by-systems-manager",
|
|
346
|
+
control_id="1.1",
|
|
347
|
+
resource_types=["AWS::EC2::Instance"]
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
351
|
+
"""Get all EC2 instances in the region."""
|
|
352
|
+
if resource_type != "AWS::EC2::Instance":
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
357
|
+
|
|
358
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
359
|
+
lambda: ec2_client.describe_instances(
|
|
360
|
+
Filters=[
|
|
361
|
+
{'Name': 'instance-state-name', 'Values': ['running', 'stopped']}
|
|
362
|
+
]
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
instances = []
|
|
367
|
+
for reservation in response.get('Reservations', []):
|
|
368
|
+
for instance in reservation.get('Instances', []):
|
|
369
|
+
instances.append({
|
|
370
|
+
'InstanceId': instance.get('InstanceId'),
|
|
371
|
+
'State': instance.get('State', {}),
|
|
372
|
+
'Platform': instance.get('Platform'), # Windows instances have this set
|
|
373
|
+
'IamInstanceProfile': instance.get('IamInstanceProfile'),
|
|
374
|
+
'Tags': instance.get('Tags', [])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
logger.debug(f"Found {len(instances)} EC2 instances in region {region}")
|
|
378
|
+
return instances
|
|
379
|
+
|
|
380
|
+
except ClientError as e:
|
|
381
|
+
logger.error(f"Error retrieving EC2 instances in region {region}: {e}")
|
|
382
|
+
raise
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.error(f"Unexpected error retrieving EC2 instances in region {region}: {e}")
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
388
|
+
"""Evaluate if EC2 instance is managed by Systems Manager."""
|
|
389
|
+
instance_id = resource.get('InstanceId', 'unknown')
|
|
390
|
+
state = resource.get('State', {})
|
|
391
|
+
state_name = state.get('Name', 'unknown')
|
|
392
|
+
|
|
393
|
+
# Only evaluate running instances
|
|
394
|
+
if state_name not in ['running']:
|
|
395
|
+
return ComplianceResult(
|
|
396
|
+
resource_id=instance_id,
|
|
397
|
+
resource_type="AWS::EC2::Instance",
|
|
398
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
399
|
+
evaluation_reason=f"Instance {instance_id} is in state '{state_name}', not running",
|
|
400
|
+
config_rule_name=self.rule_name,
|
|
401
|
+
region=region
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
# Check if instance is managed by SSM
|
|
406
|
+
ssm_client = aws_factory.get_client('ssm', region)
|
|
407
|
+
|
|
408
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
409
|
+
lambda: ssm_client.describe_instance_information(
|
|
410
|
+
Filters=[
|
|
411
|
+
{'Key': 'InstanceIds', 'Values': [instance_id]}
|
|
412
|
+
]
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
managed_instances = response.get('InstanceInformationList', [])
|
|
417
|
+
|
|
418
|
+
if managed_instances:
|
|
419
|
+
# Instance is managed by SSM
|
|
420
|
+
instance_info = managed_instances[0]
|
|
421
|
+
ping_status = instance_info.get('PingStatus', 'Unknown')
|
|
422
|
+
agent_version = instance_info.get('AgentVersion', 'Unknown')
|
|
423
|
+
|
|
424
|
+
if ping_status == 'Online':
|
|
425
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
426
|
+
evaluation_reason = f"Instance {instance_id} is managed by SSM (Agent: {agent_version}, Status: {ping_status})"
|
|
427
|
+
else:
|
|
428
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
429
|
+
evaluation_reason = f"Instance {instance_id} is registered with SSM but status is {ping_status}"
|
|
430
|
+
else:
|
|
431
|
+
# Instance is not managed by SSM
|
|
432
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
433
|
+
evaluation_reason = f"Instance {instance_id} is not managed by Systems Manager"
|
|
434
|
+
|
|
435
|
+
except ClientError as e:
|
|
436
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
437
|
+
if error_code in ['AccessDenied', 'UnauthorizedOperation']:
|
|
438
|
+
compliance_status = ComplianceStatus.ERROR
|
|
439
|
+
evaluation_reason = f"Insufficient permissions to check SSM status for instance {instance_id}"
|
|
440
|
+
else:
|
|
441
|
+
compliance_status = ComplianceStatus.ERROR
|
|
442
|
+
evaluation_reason = f"Error checking SSM status for instance {instance_id}: {str(e)}"
|
|
443
|
+
except Exception as e:
|
|
444
|
+
compliance_status = ComplianceStatus.ERROR
|
|
445
|
+
evaluation_reason = f"Unexpected error checking SSM status for instance {instance_id}: {str(e)}"
|
|
446
|
+
|
|
447
|
+
return ComplianceResult(
|
|
448
|
+
resource_id=instance_id,
|
|
449
|
+
resource_type="AWS::EC2::Instance",
|
|
450
|
+
compliance_status=compliance_status,
|
|
451
|
+
evaluation_reason=evaluation_reason,
|
|
452
|
+
config_rule_name=self.rule_name,
|
|
453
|
+
region=region
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _get_rule_remediation_steps(self) -> List[str]:
|
|
457
|
+
"""Get specific remediation steps for instances not managed by SSM."""
|
|
458
|
+
return [
|
|
459
|
+
"Identify EC2 instances that are not managed by Systems Manager",
|
|
460
|
+
"For each unmanaged instance:",
|
|
461
|
+
" 1. Ensure the instance has an IAM role with SSM permissions",
|
|
462
|
+
" 2. Attach the AmazonSSMManagedInstanceCore policy to the role",
|
|
463
|
+
" 3. Install or update the SSM Agent (pre-installed on Amazon Linux 2, Ubuntu 16.04+, Windows)",
|
|
464
|
+
" 4. Verify the instance appears in Systems Manager console",
|
|
465
|
+
"Use AWS CLI to check SSM status: aws ssm describe-instance-information",
|
|
466
|
+
"Create IAM role: aws iam create-role --role-name SSMRole --assume-role-policy-document file://trust-policy.json",
|
|
467
|
+
"Attach policy: aws iam attach-role-policy --role-name SSMRole --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
|
|
468
|
+
"Associate role with instance: aws ec2 associate-iam-instance-profile --instance-id <instance-id> --iam-instance-profile Name=SSMRole"
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class EC2SecurityGroupAttachedAssessment(BaseConfigRuleAssessment):
|
|
473
|
+
"""Assessment for ec2-security-group-attached-to-eni Config rule."""
|
|
474
|
+
|
|
475
|
+
def __init__(self):
|
|
476
|
+
"""Initialize EC2 security group attached assessment."""
|
|
477
|
+
super().__init__(
|
|
478
|
+
rule_name="ec2-security-group-attached-to-eni",
|
|
479
|
+
control_id="1.1",
|
|
480
|
+
resource_types=["AWS::EC2::SecurityGroup"]
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
484
|
+
"""Get all Security Groups in the region."""
|
|
485
|
+
if resource_type != "AWS::EC2::SecurityGroup":
|
|
486
|
+
return []
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
490
|
+
|
|
491
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
492
|
+
lambda: ec2_client.describe_security_groups()
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
security_groups = []
|
|
496
|
+
for sg in response.get('SecurityGroups', []):
|
|
497
|
+
security_groups.append({
|
|
498
|
+
'GroupId': sg.get('GroupId'),
|
|
499
|
+
'GroupName': sg.get('GroupName'),
|
|
500
|
+
'VpcId': sg.get('VpcId'),
|
|
501
|
+
'Description': sg.get('Description'),
|
|
502
|
+
'Tags': sg.get('Tags', [])
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
logger.debug(f"Found {len(security_groups)} Security Groups in region {region}")
|
|
506
|
+
return security_groups
|
|
507
|
+
|
|
508
|
+
except ClientError as e:
|
|
509
|
+
logger.error(f"Error retrieving Security Groups in region {region}: {e}")
|
|
510
|
+
raise
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.error(f"Unexpected error retrieving Security Groups in region {region}: {e}")
|
|
513
|
+
raise
|
|
514
|
+
|
|
515
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
516
|
+
"""Evaluate if Security Group is attached to network interfaces."""
|
|
517
|
+
group_id = resource.get('GroupId', 'unknown')
|
|
518
|
+
group_name = resource.get('GroupName', 'unknown')
|
|
519
|
+
|
|
520
|
+
# Skip default security groups as they're always considered compliant
|
|
521
|
+
if group_name == 'default':
|
|
522
|
+
return ComplianceResult(
|
|
523
|
+
resource_id=group_id,
|
|
524
|
+
resource_type="AWS::EC2::SecurityGroup",
|
|
525
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
526
|
+
evaluation_reason=f"Security Group {group_id} is the default security group",
|
|
527
|
+
config_rule_name=self.rule_name,
|
|
528
|
+
region=region
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
ec2_client = aws_factory.get_client('ec2', region)
|
|
533
|
+
|
|
534
|
+
# Check if security group is attached to any network interfaces
|
|
535
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
536
|
+
lambda: ec2_client.describe_network_interfaces(
|
|
537
|
+
Filters=[
|
|
538
|
+
{'Name': 'group-id', 'Values': [group_id]}
|
|
539
|
+
]
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
network_interfaces = response.get('NetworkInterfaces', [])
|
|
544
|
+
|
|
545
|
+
if network_interfaces:
|
|
546
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
547
|
+
eni_count = len(network_interfaces)
|
|
548
|
+
evaluation_reason = f"Security Group {group_id} is attached to {eni_count} network interface(s)"
|
|
549
|
+
else:
|
|
550
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
551
|
+
evaluation_reason = f"Security Group {group_id} is not attached to any network interfaces"
|
|
552
|
+
|
|
553
|
+
except ClientError as e:
|
|
554
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
555
|
+
if error_code in ['AccessDenied', 'UnauthorizedOperation']:
|
|
556
|
+
compliance_status = ComplianceStatus.ERROR
|
|
557
|
+
evaluation_reason = f"Insufficient permissions to check network interfaces for security group {group_id}"
|
|
558
|
+
else:
|
|
559
|
+
compliance_status = ComplianceStatus.ERROR
|
|
560
|
+
evaluation_reason = f"Error checking network interfaces for security group {group_id}: {str(e)}"
|
|
561
|
+
except Exception as e:
|
|
562
|
+
compliance_status = ComplianceStatus.ERROR
|
|
563
|
+
evaluation_reason = f"Unexpected error checking network interfaces for security group {group_id}: {str(e)}"
|
|
564
|
+
|
|
565
|
+
return ComplianceResult(
|
|
566
|
+
resource_id=group_id,
|
|
567
|
+
resource_type="AWS::EC2::SecurityGroup",
|
|
568
|
+
compliance_status=compliance_status,
|
|
569
|
+
evaluation_reason=evaluation_reason,
|
|
570
|
+
config_rule_name=self.rule_name,
|
|
571
|
+
region=region
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def _get_rule_remediation_steps(self) -> List[str]:
|
|
575
|
+
"""Get specific remediation steps for unattached security groups."""
|
|
576
|
+
return [
|
|
577
|
+
"Identify Security Groups that are not attached to any network interfaces",
|
|
578
|
+
"For each unattached Security Group, determine if it's still needed:",
|
|
579
|
+
" - If needed: Attach the security group to appropriate EC2 instances or ENIs",
|
|
580
|
+
" - If not needed: Delete the unused security group",
|
|
581
|
+
"Use AWS CLI: aws ec2 modify-instance-attribute --instance-id <instance-id> --groups <sg-id>",
|
|
582
|
+
"Or delete unused: aws ec2 delete-security-group --group-id <sg-id>",
|
|
583
|
+
"Review security group rules to ensure they follow least privilege principle",
|
|
584
|
+
"Document the purpose of each custom security group",
|
|
585
|
+
"Set up monitoring to alert on unused security groups"
|
|
586
|
+
]
|