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.
Files changed (77) hide show
  1. aws_cis_assessment/__init__.py +11 -0
  2. aws_cis_assessment/cli/__init__.py +3 -0
  3. aws_cis_assessment/cli/examples.py +274 -0
  4. aws_cis_assessment/cli/main.py +1259 -0
  5. aws_cis_assessment/cli/utils.py +356 -0
  6. aws_cis_assessment/config/__init__.py +1 -0
  7. aws_cis_assessment/config/config_loader.py +328 -0
  8. aws_cis_assessment/config/rules/cis_controls_ig1.yaml +590 -0
  9. aws_cis_assessment/config/rules/cis_controls_ig2.yaml +412 -0
  10. aws_cis_assessment/config/rules/cis_controls_ig3.yaml +100 -0
  11. aws_cis_assessment/controls/__init__.py +1 -0
  12. aws_cis_assessment/controls/base_control.py +400 -0
  13. aws_cis_assessment/controls/ig1/__init__.py +239 -0
  14. aws_cis_assessment/controls/ig1/control_1_1.py +586 -0
  15. aws_cis_assessment/controls/ig1/control_2_2.py +231 -0
  16. aws_cis_assessment/controls/ig1/control_3_3.py +718 -0
  17. aws_cis_assessment/controls/ig1/control_3_4.py +235 -0
  18. aws_cis_assessment/controls/ig1/control_4_1.py +461 -0
  19. aws_cis_assessment/controls/ig1/control_access_keys.py +310 -0
  20. aws_cis_assessment/controls/ig1/control_advanced_security.py +512 -0
  21. aws_cis_assessment/controls/ig1/control_backup_recovery.py +510 -0
  22. aws_cis_assessment/controls/ig1/control_cloudtrail_logging.py +197 -0
  23. aws_cis_assessment/controls/ig1/control_critical_security.py +422 -0
  24. aws_cis_assessment/controls/ig1/control_data_protection.py +898 -0
  25. aws_cis_assessment/controls/ig1/control_iam_advanced.py +573 -0
  26. aws_cis_assessment/controls/ig1/control_iam_governance.py +493 -0
  27. aws_cis_assessment/controls/ig1/control_iam_policies.py +383 -0
  28. aws_cis_assessment/controls/ig1/control_instance_optimization.py +100 -0
  29. aws_cis_assessment/controls/ig1/control_network_enhancements.py +203 -0
  30. aws_cis_assessment/controls/ig1/control_network_security.py +672 -0
  31. aws_cis_assessment/controls/ig1/control_s3_enhancements.py +173 -0
  32. aws_cis_assessment/controls/ig1/control_s3_security.py +422 -0
  33. aws_cis_assessment/controls/ig1/control_vpc_security.py +235 -0
  34. aws_cis_assessment/controls/ig2/__init__.py +172 -0
  35. aws_cis_assessment/controls/ig2/control_3_10.py +698 -0
  36. aws_cis_assessment/controls/ig2/control_3_11.py +1330 -0
  37. aws_cis_assessment/controls/ig2/control_5_2.py +393 -0
  38. aws_cis_assessment/controls/ig2/control_advanced_encryption.py +355 -0
  39. aws_cis_assessment/controls/ig2/control_codebuild_security.py +263 -0
  40. aws_cis_assessment/controls/ig2/control_encryption_rest.py +382 -0
  41. aws_cis_assessment/controls/ig2/control_encryption_transit.py +382 -0
  42. aws_cis_assessment/controls/ig2/control_network_ha.py +467 -0
  43. aws_cis_assessment/controls/ig2/control_remaining_encryption.py +426 -0
  44. aws_cis_assessment/controls/ig2/control_remaining_rules.py +363 -0
  45. aws_cis_assessment/controls/ig2/control_service_logging.py +402 -0
  46. aws_cis_assessment/controls/ig3/__init__.py +49 -0
  47. aws_cis_assessment/controls/ig3/control_12_8.py +395 -0
  48. aws_cis_assessment/controls/ig3/control_13_1.py +467 -0
  49. aws_cis_assessment/controls/ig3/control_3_14.py +523 -0
  50. aws_cis_assessment/controls/ig3/control_7_1.py +359 -0
  51. aws_cis_assessment/core/__init__.py +1 -0
  52. aws_cis_assessment/core/accuracy_validator.py +425 -0
  53. aws_cis_assessment/core/assessment_engine.py +1266 -0
  54. aws_cis_assessment/core/audit_trail.py +491 -0
  55. aws_cis_assessment/core/aws_client_factory.py +313 -0
  56. aws_cis_assessment/core/error_handler.py +607 -0
  57. aws_cis_assessment/core/models.py +166 -0
  58. aws_cis_assessment/core/scoring_engine.py +459 -0
  59. aws_cis_assessment/reporters/__init__.py +8 -0
  60. aws_cis_assessment/reporters/base_reporter.py +454 -0
  61. aws_cis_assessment/reporters/csv_reporter.py +835 -0
  62. aws_cis_assessment/reporters/html_reporter.py +2162 -0
  63. aws_cis_assessment/reporters/json_reporter.py +561 -0
  64. aws_cis_controls_assessment-1.0.3.dist-info/METADATA +248 -0
  65. aws_cis_controls_assessment-1.0.3.dist-info/RECORD +77 -0
  66. aws_cis_controls_assessment-1.0.3.dist-info/WHEEL +5 -0
  67. aws_cis_controls_assessment-1.0.3.dist-info/entry_points.txt +2 -0
  68. aws_cis_controls_assessment-1.0.3.dist-info/licenses/LICENSE +21 -0
  69. aws_cis_controls_assessment-1.0.3.dist-info/top_level.txt +2 -0
  70. docs/README.md +94 -0
  71. docs/assessment-logic.md +766 -0
  72. docs/cli-reference.md +698 -0
  73. docs/config-rule-mappings.md +393 -0
  74. docs/developer-guide.md +858 -0
  75. docs/installation.md +299 -0
  76. docs/troubleshooting.md +634 -0
  77. 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
+ ]