aws-cis-controls-assessment 1.0.10__py3-none-any.whl → 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2638 @@
1
+ """Controls 4, 5, 6: Access & Configuration Controls - Phase 2 assessments.
2
+
3
+ This module implements 18 critical assessment classes for CIS Controls 4 (Secure
4
+ Configuration), 5 (Account Management), and 6 (Access Control Management). These
5
+ assessments evaluate AWS resources for comprehensive access control, identity
6
+ management, and secure configuration compliance:
7
+
8
+ Control 4 - Secure Configuration (5 rules):
9
+ 1. IAMMaxSessionDurationCheckAssessment - Validates IAM role session duration <= 12 hours
10
+ 2. SecurityGroupDefaultRulesCheckAssessment - Ensures default security groups have no rules
11
+ 3. VPCDnsResolutionEnabledAssessment - Validates VPC DNS configuration
12
+ 4. RDSDefaultAdminCheckAssessment - Ensures RDS instances don't use default admin usernames
13
+ 5. EC2InstanceProfileLeastPrivilegeAssessment - Validates EC2 instance profile least privilege
14
+
15
+ Control 5 - Account Management (4 rules):
16
+ 1. IAMServiceAccountInventoryCheckAssessment - Validates service account documentation tags
17
+ 2. IAMAdminPolicyAttachedToRoleCheckAssessment - Ensures admin policies attached to roles, not users
18
+ 3. SSOEnabledCheckAssessment - Validates AWS IAM Identity Center (SSO) is configured
19
+ 4. IAMUserNoInlinePoliciesAssessment - Ensures IAM users don't have inline policies
20
+
21
+ Control 6 - Access Control Management (9 rules):
22
+ 1. IAMAccessAnalyzerEnabledAssessment - Ensures IAM Access Analyzer enabled in all regions
23
+ 2. IAMPermissionBoundariesCheckAssessment - Validates permission boundaries for elevated privileges
24
+ 3. OrganizationsSCPEnabledCheckAssessment - Ensures Service Control Policies are enabled
25
+ 4. CognitoUserPoolMFAEnabledAssessment - Validates Cognito user pools have MFA enabled
26
+ 5. VPNConnectionMFAEnabledAssessment - Ensures Client VPN endpoints require MFA
27
+
28
+ These rules address critical gaps in access control and configuration management
29
+ identified in the CIS Controls Gap Analysis and increase the total rule count
30
+ from 149 to 167.
31
+ """
32
+
33
+ from typing import Dict, List, Any
34
+ import logging
35
+ from botocore.exceptions import ClientError
36
+
37
+ from aws_cis_assessment.controls.base_control import BaseConfigRuleAssessment
38
+ from aws_cis_assessment.core.models import ComplianceResult, ComplianceStatus
39
+ from aws_cis_assessment.core.aws_client_factory import AWSClientFactory
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ============================================================================
45
+ # Control 4: Secure Configuration Assessments
46
+ # ============================================================================
47
+
48
+ class IAMMaxSessionDurationCheckAssessment(BaseConfigRuleAssessment):
49
+ """Assessment for iam-max-session-duration-check AWS Config rule.
50
+
51
+ Validates that IAM role session duration does not exceed 12 hours (43200 seconds)
52
+ to limit the window of opportunity for credential compromise.
53
+
54
+ This is a global service assessment that only runs in us-east-1.
55
+ """
56
+
57
+ def __init__(self):
58
+ super().__init__(
59
+ rule_name="iam-max-session-duration-check",
60
+ control_id="4.1",
61
+ resource_types=["AWS::IAM::Role"]
62
+ )
63
+
64
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
65
+ """Get IAM roles.
66
+
67
+ IAM is a global service, so we only query in us-east-1.
68
+
69
+ Args:
70
+ aws_factory: AWS client factory for API access
71
+ resource_type: AWS resource type (should be AWS::IAM::Role)
72
+ region: AWS region (should be us-east-1 for IAM)
73
+
74
+ Returns:
75
+ List of IAM role dictionaries with RoleName, RoleId, Arn, MaxSessionDuration
76
+ """
77
+ if resource_type != "AWS::IAM::Role":
78
+ return []
79
+
80
+ # IAM is a global service - only evaluate in us-east-1
81
+ if region != 'us-east-1':
82
+ logger.debug(f"Skipping IAM evaluation in {region} - global service evaluated in us-east-1 only")
83
+ return []
84
+
85
+ try:
86
+ iam_client = aws_factory.get_client('iam', region)
87
+
88
+ # List all IAM roles with pagination support
89
+ roles = []
90
+ marker = None
91
+
92
+ while True:
93
+ if marker:
94
+ response = aws_factory.aws_api_call_with_retry(
95
+ lambda: iam_client.list_roles(Marker=marker)
96
+ )
97
+ else:
98
+ response = aws_factory.aws_api_call_with_retry(
99
+ lambda: iam_client.list_roles()
100
+ )
101
+
102
+ roles.extend(response.get('Roles', []))
103
+
104
+ # Check if there are more results
105
+ if response.get('IsTruncated', False):
106
+ marker = response.get('Marker')
107
+ else:
108
+ break
109
+
110
+ logger.debug(f"Found {len(roles)} IAM roles")
111
+ return roles
112
+
113
+ except ClientError as e:
114
+ logger.error(f"Error retrieving IAM roles: {e}")
115
+ raise
116
+
117
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
118
+ """Evaluate if IAM role session duration is within acceptable limits.
119
+
120
+ Args:
121
+ resource: IAM role resource dictionary
122
+ aws_factory: AWS client factory for additional API calls
123
+ region: AWS region
124
+
125
+ Returns:
126
+ ComplianceResult indicating whether session duration is compliant
127
+ """
128
+ role_name = resource.get('RoleName', 'unknown')
129
+ role_arn = resource.get('Arn', 'unknown')
130
+ max_session_duration = resource.get('MaxSessionDuration', 3600) # Default is 1 hour
131
+
132
+ # Maximum allowed session duration: 12 hours = 43200 seconds
133
+ max_allowed_duration = 43200
134
+
135
+ try:
136
+ if max_session_duration <= max_allowed_duration:
137
+ compliance_status = ComplianceStatus.COMPLIANT
138
+ evaluation_reason = (
139
+ f"IAM role {role_name} has session duration of {max_session_duration} seconds "
140
+ f"({max_session_duration // 3600} hours), which is within the 12-hour limit"
141
+ )
142
+ else:
143
+ compliance_status = ComplianceStatus.NON_COMPLIANT
144
+ hours = max_session_duration // 3600
145
+ evaluation_reason = (
146
+ f"IAM role {role_name} has session duration of {max_session_duration} seconds "
147
+ f"({hours} hours), which exceeds the 12-hour limit. "
148
+ f"Update IAM role to limit session duration to 12 hours or less:\n"
149
+ f"1. Go to IAM console > Roles\n"
150
+ f"2. Select the role '{role_name}'\n"
151
+ f"3. Edit Maximum session duration\n"
152
+ f"4. Set to 12 hours (43200 seconds) or less\n"
153
+ f"5. Save changes\n\n"
154
+ f"AWS CLI example:\n"
155
+ f"aws iam update-role --role-name {role_name} --max-session-duration 43200"
156
+ )
157
+
158
+ except ClientError as e:
159
+ error_code = e.response.get('Error', {}).get('Code', '')
160
+
161
+ if error_code == 'AccessDenied':
162
+ compliance_status = ComplianceStatus.ERROR
163
+ evaluation_reason = (
164
+ f"Insufficient permissions to evaluate IAM role {role_name}. "
165
+ f"Required permissions: iam:ListRoles, iam:GetRole"
166
+ )
167
+ elif error_code == 'NoSuchEntity':
168
+ compliance_status = ComplianceStatus.ERROR
169
+ evaluation_reason = f"IAM role {role_name} not found (may have been deleted)"
170
+ else:
171
+ compliance_status = ComplianceStatus.ERROR
172
+ evaluation_reason = f"Error evaluating IAM role {role_name}: {str(e)}"
173
+
174
+ except Exception as e:
175
+ compliance_status = ComplianceStatus.ERROR
176
+ evaluation_reason = f"Unexpected error evaluating IAM role {role_name}: {str(e)}"
177
+
178
+ return ComplianceResult(
179
+ resource_id=role_arn,
180
+ resource_type="AWS::IAM::Role",
181
+ compliance_status=compliance_status,
182
+ evaluation_reason=evaluation_reason,
183
+ config_rule_name=self.rule_name,
184
+ region=region
185
+ )
186
+ class SecurityGroupDefaultRulesCheckAssessment(BaseConfigRuleAssessment):
187
+ """Assessment for security-group-default-rules-check AWS Config rule.
188
+
189
+ Ensures default security groups have no inbound or outbound rules as a security
190
+ best practice. Default security groups should not be used for actual workloads.
191
+
192
+ This is a regional service assessment that runs in all active regions.
193
+ """
194
+
195
+ def __init__(self):
196
+ super().__init__(
197
+ rule_name="security-group-default-rules-check",
198
+ control_id="4.2",
199
+ resource_types=["AWS::EC2::SecurityGroup"]
200
+ )
201
+
202
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
203
+ """Get default security groups.
204
+
205
+ Security groups are regional resources, so we query in each active region.
206
+ We filter for security groups with GroupName='default'.
207
+
208
+ Args:
209
+ aws_factory: AWS client factory for API access
210
+ resource_type: AWS resource type (should be AWS::EC2::SecurityGroup)
211
+ region: AWS region
212
+
213
+ Returns:
214
+ List of default security group dictionaries with GroupId, GroupName, VpcId, IpPermissions, IpPermissionsEgress
215
+ """
216
+ if resource_type != "AWS::EC2::SecurityGroup":
217
+ return []
218
+
219
+ try:
220
+ ec2_client = aws_factory.get_client('ec2', region)
221
+
222
+ # List all default security groups with pagination support
223
+ security_groups = []
224
+ next_token = None
225
+
226
+ while True:
227
+ # Filter for default security groups only
228
+ filters = [{'Name': 'group-name', 'Values': ['default']}]
229
+
230
+ if next_token:
231
+ response = aws_factory.aws_api_call_with_retry(
232
+ lambda: ec2_client.describe_security_groups(
233
+ Filters=filters,
234
+ NextToken=next_token
235
+ )
236
+ )
237
+ else:
238
+ response = aws_factory.aws_api_call_with_retry(
239
+ lambda: ec2_client.describe_security_groups(Filters=filters)
240
+ )
241
+
242
+ security_groups.extend(response.get('SecurityGroups', []))
243
+
244
+ # Check if there are more results
245
+ next_token = response.get('NextToken')
246
+ if not next_token:
247
+ break
248
+
249
+ logger.debug(f"Found {len(security_groups)} default security groups in {region}")
250
+ return security_groups
251
+
252
+ except ClientError as e:
253
+ error_code = e.response.get('Error', {}).get('Code', '')
254
+
255
+ if error_code in ['UnauthorizedOperation', 'AccessDenied']:
256
+ logger.warning(f"Insufficient permissions to list security groups in {region}: {e}")
257
+ return []
258
+ else:
259
+ logger.error(f"Error retrieving security groups in {region}: {e}")
260
+ raise
261
+
262
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
263
+ """Evaluate if default security group has no rules.
264
+
265
+ Args:
266
+ resource: Security group resource dictionary
267
+ aws_factory: AWS client factory for additional API calls
268
+ region: AWS region
269
+
270
+ Returns:
271
+ ComplianceResult indicating whether the default security group is compliant
272
+ """
273
+ group_id = resource.get('GroupId', 'unknown')
274
+ group_name = resource.get('GroupName', 'unknown')
275
+ vpc_id = resource.get('VpcId', 'unknown')
276
+
277
+ try:
278
+ # Get inbound and outbound rules
279
+ inbound_rules = resource.get('IpPermissions', [])
280
+ outbound_rules = resource.get('IpPermissionsEgress', [])
281
+
282
+ # Check if both rule lists are empty
283
+ if not inbound_rules and not outbound_rules:
284
+ compliance_status = ComplianceStatus.COMPLIANT
285
+ evaluation_reason = (
286
+ f"Default security group {group_id} in VPC {vpc_id} has no inbound or outbound rules"
287
+ )
288
+ else:
289
+ compliance_status = ComplianceStatus.NON_COMPLIANT
290
+
291
+ # Build detailed message about which rules exist
292
+ rule_details = []
293
+ if inbound_rules:
294
+ rule_details.append(f"{len(inbound_rules)} inbound rule(s)")
295
+ if outbound_rules:
296
+ rule_details.append(f"{len(outbound_rules)} outbound rule(s)")
297
+
298
+ evaluation_reason = (
299
+ f"Default security group {group_id} in VPC {vpc_id} has {' and '.join(rule_details)}. "
300
+ f"Default security groups should have no rules as a security best practice.\n\n"
301
+ f"Remove all rules from default security group:\n"
302
+ f"1. Go to EC2 console > Security Groups\n"
303
+ f"2. Select the default security group (ID: {group_id})\n"
304
+ f"3. Remove all inbound rules\n"
305
+ f"4. Remove all outbound rules (except the default allow-all egress if needed)\n"
306
+ f"5. Create custom security groups for actual use\n\n"
307
+ f"AWS CLI example to revoke inbound rules:\n"
308
+ f"aws ec2 describe-security-groups --group-ids {group_id} --region {region} --query 'SecurityGroups[0].IpPermissions' > permissions.json\n"
309
+ f"aws ec2 revoke-security-group-ingress --group-id {group_id} --region {region} --ip-permissions file://permissions.json\n\n"
310
+ f"AWS CLI example to revoke outbound rules:\n"
311
+ f"aws ec2 describe-security-groups --group-ids {group_id} --region {region} --query 'SecurityGroups[0].IpPermissionsEgress' > egress.json\n"
312
+ f"aws ec2 revoke-security-group-egress --group-id {group_id} --region {region} --ip-permissions file://egress.json\n\n"
313
+ f"Note: Default security groups cannot be deleted, only restricted."
314
+ )
315
+
316
+ except Exception as e:
317
+ compliance_status = ComplianceStatus.ERROR
318
+ evaluation_reason = f"Unexpected error evaluating security group {group_id}: {str(e)}"
319
+
320
+ return ComplianceResult(
321
+ resource_id=group_id,
322
+ resource_type="AWS::EC2::SecurityGroup",
323
+ compliance_status=compliance_status,
324
+ evaluation_reason=evaluation_reason,
325
+ config_rule_name=self.rule_name,
326
+ region=region
327
+ )
328
+
329
+
330
+ class VPCDnsResolutionEnabledAssessment(BaseConfigRuleAssessment):
331
+ """Assessment for vpc-dns-resolution-enabled AWS Config rule.
332
+
333
+ Validates that VPCs have both enableDnsHostnames and enableDnsSupport enabled
334
+ to ensure proper DNS resolution for resources within the VPC.
335
+
336
+ This is a regional service assessment that runs in all active regions.
337
+ """
338
+
339
+ def __init__(self):
340
+ super().__init__(
341
+ rule_name="vpc-dns-resolution-enabled",
342
+ control_id="4.3",
343
+ resource_types=["AWS::EC2::VPC"]
344
+ )
345
+
346
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
347
+ """Get VPCs.
348
+
349
+ VPCs are regional resources, so we query in each active region.
350
+
351
+ Args:
352
+ aws_factory: AWS client factory for API access
353
+ resource_type: AWS resource type (should be AWS::EC2::VPC)
354
+ region: AWS region
355
+
356
+ Returns:
357
+ List of VPC dictionaries with VpcId, CidrBlock, State, IsDefault
358
+ """
359
+ if resource_type != "AWS::EC2::VPC":
360
+ return []
361
+
362
+ try:
363
+ ec2_client = aws_factory.get_client('ec2', region)
364
+
365
+ # List all VPCs with pagination support
366
+ vpcs = []
367
+ next_token = None
368
+
369
+ while True:
370
+ if next_token:
371
+ response = aws_factory.aws_api_call_with_retry(
372
+ lambda: ec2_client.describe_vpcs(NextToken=next_token)
373
+ )
374
+ else:
375
+ response = aws_factory.aws_api_call_with_retry(
376
+ lambda: ec2_client.describe_vpcs()
377
+ )
378
+
379
+ vpcs.extend(response.get('Vpcs', []))
380
+
381
+ # Check if there are more results
382
+ next_token = response.get('NextToken')
383
+ if not next_token:
384
+ break
385
+
386
+ logger.debug(f"Found {len(vpcs)} VPCs in {region}")
387
+ return vpcs
388
+
389
+ except ClientError as e:
390
+ error_code = e.response.get('Error', {}).get('Code', '')
391
+
392
+ if error_code in ['UnauthorizedOperation', 'AccessDenied']:
393
+ logger.warning(f"Insufficient permissions to list VPCs in {region}: {e}")
394
+ return []
395
+ else:
396
+ logger.error(f"Error retrieving VPCs in {region}: {e}")
397
+ raise
398
+
399
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
400
+ """Evaluate if VPC has DNS resolution and hostnames enabled.
401
+
402
+ Args:
403
+ resource: VPC resource dictionary
404
+ aws_factory: AWS client factory for additional API calls
405
+ region: AWS region
406
+
407
+ Returns:
408
+ ComplianceResult indicating whether the VPC DNS configuration is compliant
409
+ """
410
+ vpc_id = resource.get('VpcId', 'unknown')
411
+
412
+ try:
413
+ ec2_client = aws_factory.get_client('ec2', region)
414
+
415
+ # Check enableDnsSupport attribute
416
+ dns_support_response = aws_factory.aws_api_call_with_retry(
417
+ lambda: ec2_client.describe_vpc_attribute(
418
+ VpcId=vpc_id,
419
+ Attribute='enableDnsSupport'
420
+ )
421
+ )
422
+ enable_dns_support = dns_support_response.get('EnableDnsSupport', {}).get('Value', False)
423
+
424
+ # Check enableDnsHostnames attribute
425
+ dns_hostnames_response = aws_factory.aws_api_call_with_retry(
426
+ lambda: ec2_client.describe_vpc_attribute(
427
+ VpcId=vpc_id,
428
+ Attribute='enableDnsHostnames'
429
+ )
430
+ )
431
+ enable_dns_hostnames = dns_hostnames_response.get('EnableDnsHostnames', {}).get('Value', False)
432
+
433
+ # Both must be enabled for compliance
434
+ if enable_dns_support and enable_dns_hostnames:
435
+ compliance_status = ComplianceStatus.COMPLIANT
436
+ evaluation_reason = (
437
+ f"VPC {vpc_id} has both DNS support and DNS hostnames enabled"
438
+ )
439
+ else:
440
+ compliance_status = ComplianceStatus.NON_COMPLIANT
441
+
442
+ # Build detailed message about which settings are disabled
443
+ disabled_settings = []
444
+ if not enable_dns_support:
445
+ disabled_settings.append("DNS support (enableDnsSupport)")
446
+ if not enable_dns_hostnames:
447
+ disabled_settings.append("DNS hostnames (enableDnsHostnames)")
448
+
449
+ evaluation_reason = (
450
+ f"VPC {vpc_id} has the following DNS settings disabled: {', '.join(disabled_settings)}. "
451
+ f"Both settings must be enabled for proper DNS resolution.\n\n"
452
+ f"Enable DNS resolution for VPC:\n"
453
+ f"1. Go to VPC console\n"
454
+ f"2. Select the VPC (ID: {vpc_id})\n"
455
+ )
456
+
457
+ if not enable_dns_support:
458
+ evaluation_reason += (
459
+ f"3. Actions > Edit DNS resolution\n"
460
+ f"4. Enable DNS resolution (enableDnsSupport)\n"
461
+ )
462
+
463
+ if not enable_dns_hostnames:
464
+ evaluation_reason += (
465
+ f"5. Actions > Edit DNS hostnames\n"
466
+ f"6. Enable DNS hostnames (enableDnsHostnames)\n"
467
+ )
468
+
469
+ evaluation_reason += f"\nAWS CLI examples:\n"
470
+ if not enable_dns_support:
471
+ evaluation_reason += f"aws ec2 modify-vpc-attribute --vpc-id {vpc_id} --enable-dns-support --region {region}\n"
472
+ if not enable_dns_hostnames:
473
+ evaluation_reason += f"aws ec2 modify-vpc-attribute --vpc-id {vpc_id} --enable-dns-hostnames --region {region}\n"
474
+
475
+ except ClientError as e:
476
+ error_code = e.response.get('Error', {}).get('Code', '')
477
+
478
+ if error_code == 'InvalidVpcID.NotFound':
479
+ compliance_status = ComplianceStatus.ERROR
480
+ evaluation_reason = f"VPC {vpc_id} not found (may have been deleted)"
481
+ elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
482
+ compliance_status = ComplianceStatus.ERROR
483
+ evaluation_reason = (
484
+ f"Insufficient permissions to evaluate VPC {vpc_id}. "
485
+ f"Required permissions: ec2:DescribeVpcs, ec2:DescribeVpcAttribute"
486
+ )
487
+ else:
488
+ compliance_status = ComplianceStatus.ERROR
489
+ evaluation_reason = f"Error evaluating VPC {vpc_id}: {str(e)}"
490
+
491
+ except Exception as e:
492
+ compliance_status = ComplianceStatus.ERROR
493
+ evaluation_reason = f"Unexpected error evaluating VPC {vpc_id}: {str(e)}"
494
+
495
+ return ComplianceResult(
496
+ resource_id=vpc_id,
497
+ resource_type="AWS::EC2::VPC",
498
+ compliance_status=compliance_status,
499
+ evaluation_reason=evaluation_reason,
500
+ config_rule_name=self.rule_name,
501
+ region=region
502
+ )
503
+
504
+
505
+ class RDSDefaultAdminCheckAssessment(BaseConfigRuleAssessment):
506
+ """Assessment for rds-default-admin-check AWS Config rule.
507
+
508
+ Ensures RDS instances don't use default admin usernames which are commonly
509
+ targeted in brute force attacks. Default usernames include: postgres, admin,
510
+ root, mysql, administrator, sa.
511
+
512
+ This is a regional service assessment that runs in all active regions.
513
+ """
514
+
515
+ # Default usernames to check (case-insensitive)
516
+ DEFAULT_USERNAMES = {'postgres', 'admin', 'root', 'mysql', 'administrator', 'sa'}
517
+
518
+ def __init__(self):
519
+ super().__init__(
520
+ rule_name="rds-default-admin-check",
521
+ control_id="4.4",
522
+ resource_types=["AWS::RDS::DBInstance"]
523
+ )
524
+
525
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
526
+ """Get RDS instances.
527
+
528
+ RDS instances are regional resources, so we query in each active region.
529
+
530
+ Args:
531
+ aws_factory: AWS client factory for API access
532
+ resource_type: AWS resource type (should be AWS::RDS::DBInstance)
533
+ region: AWS region
534
+
535
+ Returns:
536
+ List of RDS instance dictionaries with DBInstanceIdentifier, DBInstanceArn, MasterUsername, Engine
537
+ """
538
+ if resource_type != "AWS::RDS::DBInstance":
539
+ return []
540
+
541
+ try:
542
+ rds_client = aws_factory.get_client('rds', region)
543
+
544
+ # List all RDS instances with pagination support
545
+ db_instances = []
546
+ marker = None
547
+
548
+ while True:
549
+ if marker:
550
+ response = aws_factory.aws_api_call_with_retry(
551
+ lambda: rds_client.describe_db_instances(Marker=marker)
552
+ )
553
+ else:
554
+ response = aws_factory.aws_api_call_with_retry(
555
+ lambda: rds_client.describe_db_instances()
556
+ )
557
+
558
+ db_instances.extend(response.get('DBInstances', []))
559
+
560
+ # Check if there are more results
561
+ marker = response.get('Marker')
562
+ if not marker:
563
+ break
564
+
565
+ logger.debug(f"Found {len(db_instances)} RDS instances in {region}")
566
+ return db_instances
567
+
568
+ except ClientError as e:
569
+ error_code = e.response.get('Error', {}).get('Code', '')
570
+
571
+ if error_code in ['AccessDenied']:
572
+ logger.warning(f"Insufficient permissions to list RDS instances in {region}: {e}")
573
+ return []
574
+ else:
575
+ logger.error(f"Error retrieving RDS instances in {region}: {e}")
576
+ raise
577
+
578
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
579
+ """Evaluate if RDS instance uses a default admin username.
580
+
581
+ Args:
582
+ resource: RDS instance resource dictionary
583
+ aws_factory: AWS client factory for additional API calls
584
+ region: AWS region
585
+
586
+ Returns:
587
+ ComplianceResult indicating whether the RDS instance username is compliant
588
+ """
589
+ db_instance_id = resource.get('DBInstanceIdentifier', 'unknown')
590
+ db_instance_arn = resource.get('DBInstanceArn', 'unknown')
591
+ master_username = resource.get('MasterUsername', '')
592
+ engine = resource.get('Engine', 'unknown')
593
+
594
+ try:
595
+ # Check if master username is in the default list (case-insensitive)
596
+ if master_username.lower() in self.DEFAULT_USERNAMES:
597
+ compliance_status = ComplianceStatus.NON_COMPLIANT
598
+ evaluation_reason = (
599
+ f"RDS instance {db_instance_id} (engine: {engine}) uses default master username '{master_username}'. "
600
+ f"Default usernames are commonly targeted in brute force attacks and should be avoided.\n\n"
601
+ f"RDS master username cannot be changed after creation. Remediation requires:\n"
602
+ f"1. Create a snapshot of the existing RDS instance:\n"
603
+ f" aws rds create-db-snapshot --db-instance-identifier {db_instance_id} --db-snapshot-identifier {db_instance_id}-snapshot --region {region}\n\n"
604
+ f"2. Restore snapshot to a new instance with a custom master username:\n"
605
+ f" aws rds restore-db-instance-from-db-snapshot \\\n"
606
+ f" --db-instance-identifier {db_instance_id}-new \\\n"
607
+ f" --db-snapshot-identifier {db_instance_id}-snapshot \\\n"
608
+ f" --region {region}\n"
609
+ f" Note: You cannot change the master username during restore. You must create a new user with admin privileges.\n\n"
610
+ f"3. After restore, connect to the database and create a new admin user with a custom username\n"
611
+ f"4. Update application connection strings to use the new instance endpoint and new admin user\n"
612
+ f"5. Test the new instance thoroughly\n"
613
+ f"6. Delete the old instance after verification:\n"
614
+ f" aws rds delete-db-instance --db-instance-identifier {db_instance_id} --skip-final-snapshot --region {region}\n\n"
615
+ f"Note: This is a disruptive change requiring downtime. Plan accordingly and test in a non-production environment first.\n\n"
616
+ f"Best practice: When creating new RDS instances, always use custom master usernames that are not easily guessable."
617
+ )
618
+ else:
619
+ compliance_status = ComplianceStatus.COMPLIANT
620
+ evaluation_reason = (
621
+ f"RDS instance {db_instance_id} (engine: {engine}) uses custom master username '{master_username}' "
622
+ f"which is not a default value"
623
+ )
624
+
625
+ except ClientError as e:
626
+ error_code = e.response.get('Error', {}).get('Code', '')
627
+
628
+ if error_code == 'DBInstanceNotFound':
629
+ compliance_status = ComplianceStatus.ERROR
630
+ evaluation_reason = f"RDS instance {db_instance_id} not found (may have been deleted)"
631
+ elif error_code in ['AccessDenied']:
632
+ compliance_status = ComplianceStatus.ERROR
633
+ evaluation_reason = (
634
+ f"Insufficient permissions to evaluate RDS instance {db_instance_id}. "
635
+ f"Required permissions: rds:DescribeDBInstances"
636
+ )
637
+ else:
638
+ compliance_status = ComplianceStatus.ERROR
639
+ evaluation_reason = f"Error evaluating RDS instance {db_instance_id}: {str(e)}"
640
+
641
+ except Exception as e:
642
+ compliance_status = ComplianceStatus.ERROR
643
+ evaluation_reason = f"Unexpected error evaluating RDS instance {db_instance_id}: {str(e)}"
644
+
645
+ return ComplianceResult(
646
+ resource_id=db_instance_arn,
647
+ resource_type="AWS::RDS::DBInstance",
648
+ compliance_status=compliance_status,
649
+ evaluation_reason=evaluation_reason,
650
+ config_rule_name=self.rule_name,
651
+ region=region
652
+ )
653
+
654
+
655
+ class EC2InstanceProfileLeastPrivilegeAssessment(BaseConfigRuleAssessment):
656
+ """Assessment for ec2-instance-profile-least-privilege AWS Config rule.
657
+
658
+ Validates that EC2 instance profiles follow the principle of least privilege
659
+ by checking for overly permissive policies such as AdministratorAccess,
660
+ PowerUserAccess, or policies with Action:"*" and Resource:"*".
661
+
662
+ This is a regional service assessment (EC2) that queries global IAM service.
663
+ """
664
+
665
+ # Overly permissive managed policy ARNs
666
+ OVERLY_PERMISSIVE_POLICIES = {
667
+ 'arn:aws:iam::aws:policy/AdministratorAccess',
668
+ 'arn:aws:iam::aws:policy/PowerUserAccess'
669
+ }
670
+
671
+ def __init__(self):
672
+ super().__init__(
673
+ rule_name="ec2-instance-profile-least-privilege",
674
+ control_id="4.5",
675
+ resource_types=["AWS::EC2::Instance"]
676
+ )
677
+
678
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
679
+ """Get EC2 instances with instance profiles.
680
+
681
+ EC2 instances are regional resources, so we query in each active region.
682
+ We only return instances that have an instance profile attached.
683
+
684
+ Args:
685
+ aws_factory: AWS client factory for API access
686
+ resource_type: AWS resource type (should be AWS::EC2::Instance)
687
+ region: AWS region
688
+
689
+ Returns:
690
+ List of EC2 instance dictionaries with InstanceId, IamInstanceProfile, State
691
+ """
692
+ if resource_type != "AWS::EC2::Instance":
693
+ return []
694
+
695
+ try:
696
+ ec2_client = aws_factory.get_client('ec2', region)
697
+
698
+ # List all EC2 instances with pagination support
699
+ instances = []
700
+ next_token = None
701
+
702
+ while True:
703
+ if next_token:
704
+ response = aws_factory.aws_api_call_with_retry(
705
+ lambda: ec2_client.describe_instances(NextToken=next_token)
706
+ )
707
+ else:
708
+ response = aws_factory.aws_api_call_with_retry(
709
+ lambda: ec2_client.describe_instances()
710
+ )
711
+
712
+ # Extract instances from reservations
713
+ for reservation in response.get('Reservations', []):
714
+ for instance in reservation.get('Instances', []):
715
+ # Only include instances with instance profiles
716
+ if 'IamInstanceProfile' in instance:
717
+ instances.append(instance)
718
+
719
+ # Check if there are more results
720
+ next_token = response.get('NextToken')
721
+ if not next_token:
722
+ break
723
+
724
+ logger.debug(f"Found {len(instances)} EC2 instances with instance profiles in {region}")
725
+ return instances
726
+
727
+ except ClientError as e:
728
+ error_code = e.response.get('Error', {}).get('Code', '')
729
+
730
+ if error_code in ['UnauthorizedOperation', 'AccessDenied']:
731
+ logger.warning(f"Insufficient permissions to list EC2 instances in {region}: {e}")
732
+ return []
733
+ else:
734
+ logger.error(f"Error retrieving EC2 instances in {region}: {e}")
735
+ raise
736
+
737
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
738
+ """Evaluate if EC2 instance profile follows least privilege.
739
+
740
+ Args:
741
+ resource: EC2 instance resource dictionary
742
+ aws_factory: AWS client factory for additional API calls
743
+ region: AWS region
744
+
745
+ Returns:
746
+ ComplianceResult indicating whether the instance profile is compliant
747
+ """
748
+ instance_id = resource.get('InstanceId', 'unknown')
749
+ instance_profile_info = resource.get('IamInstanceProfile', {})
750
+ instance_profile_arn = instance_profile_info.get('Arn', 'unknown')
751
+
752
+ try:
753
+ # Extract instance profile name from ARN
754
+ # ARN format: arn:aws:iam::123456789012:instance-profile/profile-name
755
+ instance_profile_name = instance_profile_arn.split('/')[-1] if '/' in instance_profile_arn else 'unknown'
756
+
757
+ # Get IAM client (global service, use us-east-1)
758
+ iam_client = aws_factory.get_client('iam', 'us-east-1')
759
+
760
+ # Get instance profile details to find the associated role
761
+ instance_profile_response = aws_factory.aws_api_call_with_retry(
762
+ lambda: iam_client.get_instance_profile(InstanceProfileName=instance_profile_name)
763
+ )
764
+
765
+ instance_profile = instance_profile_response.get('InstanceProfile', {})
766
+ roles = instance_profile.get('Roles', [])
767
+
768
+ if not roles:
769
+ compliance_status = ComplianceStatus.ERROR
770
+ evaluation_reason = f"EC2 instance {instance_id} has instance profile {instance_profile_name} with no associated roles"
771
+
772
+ return ComplianceResult(
773
+ resource_id=instance_id,
774
+ resource_type="AWS::EC2::Instance",
775
+ compliance_status=compliance_status,
776
+ evaluation_reason=evaluation_reason,
777
+ config_rule_name=self.rule_name,
778
+ region=region
779
+ )
780
+
781
+ # Check the first role (instance profiles typically have one role)
782
+ role = roles[0]
783
+ role_name = role.get('RoleName', 'unknown')
784
+
785
+ # Check for overly permissive policies
786
+ overly_permissive_policies = []
787
+
788
+ # Check attached managed policies
789
+ attached_policies_response = aws_factory.aws_api_call_with_retry(
790
+ lambda: iam_client.list_attached_role_policies(RoleName=role_name)
791
+ )
792
+
793
+ for policy in attached_policies_response.get('AttachedPolicies', []):
794
+ policy_arn = policy.get('PolicyArn', '')
795
+ policy_name = policy.get('PolicyName', '')
796
+
797
+ # Check if it's an overly permissive managed policy
798
+ if policy_arn in self.OVERLY_PERMISSIVE_POLICIES:
799
+ overly_permissive_policies.append(f"Managed policy: {policy_name} ({policy_arn})")
800
+
801
+ # Check inline policies
802
+ inline_policies_response = aws_factory.aws_api_call_with_retry(
803
+ lambda: iam_client.list_role_policies(RoleName=role_name)
804
+ )
805
+
806
+ for policy_name in inline_policies_response.get('PolicyNames', []):
807
+ # Get the policy document
808
+ policy_response = aws_factory.aws_api_call_with_retry(
809
+ lambda: iam_client.get_role_policy(RoleName=role_name, PolicyName=policy_name)
810
+ )
811
+
812
+ policy_document = policy_response.get('PolicyDocument', {})
813
+
814
+ # Check if policy has Action:"*" with Resource:"*"
815
+ if self._is_overly_permissive_policy(policy_document):
816
+ overly_permissive_policies.append(f"Inline policy: {policy_name} (contains Action:'*' with Resource:'*')")
817
+
818
+ # Determine compliance status
819
+ if overly_permissive_policies:
820
+ compliance_status = ComplianceStatus.NON_COMPLIANT
821
+ evaluation_reason = (
822
+ f"EC2 instance {instance_id} has instance profile {instance_profile_name} with role {role_name} "
823
+ f"that contains overly permissive policies:\n"
824
+ )
825
+ for policy in overly_permissive_policies:
826
+ evaluation_reason += f" - {policy}\n"
827
+
828
+ evaluation_reason += (
829
+ f"\nApply least privilege to instance profile:\n"
830
+ f"1. Review the instance profile's IAM role policies\n"
831
+ f"2. Identify overly broad permissions (wildcards, full access)\n"
832
+ f"3. Create new policies with specific actions and resources\n"
833
+ f"4. Replace broad policies with specific policies\n"
834
+ f"5. Test application functionality\n"
835
+ f"6. Remove overly permissive policies\n\n"
836
+ f"AWS CLI examples:\n"
837
+ f"# Create a specific policy\n"
838
+ f"aws iam create-policy --policy-name {role_name}-specific-policy --policy-document file://policy.json\n\n"
839
+ f"# Attach specific policy to role\n"
840
+ f"aws iam attach-role-policy --role-name {role_name} --policy-arn arn:aws:iam::<account>:policy/{role_name}-specific-policy\n\n"
841
+ f"# Detach overly permissive policy\n"
842
+ f"aws iam detach-role-policy --role-name {role_name} --policy-arn <overly-permissive-policy-arn>\n\n"
843
+ f"Best practices:\n"
844
+ f"- Grant only the permissions required for the instance's workload\n"
845
+ f"- Use specific actions instead of wildcards\n"
846
+ f"- Limit resources to specific ARNs when possible\n"
847
+ f"- Regularly review and refine permissions"
848
+ )
849
+ else:
850
+ compliance_status = ComplianceStatus.COMPLIANT
851
+ evaluation_reason = (
852
+ f"EC2 instance {instance_id} has instance profile {instance_profile_name} with role {role_name} "
853
+ f"that follows least privilege principles (no overly permissive policies detected)"
854
+ )
855
+
856
+ except ClientError as e:
857
+ error_code = e.response.get('Error', {}).get('Code', '')
858
+
859
+ if error_code == 'InvalidInstanceID.NotFound':
860
+ compliance_status = ComplianceStatus.ERROR
861
+ evaluation_reason = f"EC2 instance {instance_id} not found (may have been deleted)"
862
+ elif error_code == 'NoSuchEntity':
863
+ compliance_status = ComplianceStatus.ERROR
864
+ evaluation_reason = f"Instance profile or role for EC2 instance {instance_id} not found (may have been deleted)"
865
+ elif error_code in ['AccessDenied', 'UnauthorizedOperation']:
866
+ compliance_status = ComplianceStatus.ERROR
867
+ evaluation_reason = (
868
+ f"Insufficient permissions to evaluate EC2 instance {instance_id}. "
869
+ f"Required permissions: ec2:DescribeInstances, iam:GetInstanceProfile, "
870
+ f"iam:ListAttachedRolePolicies, iam:ListRolePolicies, iam:GetRolePolicy"
871
+ )
872
+ else:
873
+ compliance_status = ComplianceStatus.ERROR
874
+ evaluation_reason = f"Error evaluating EC2 instance {instance_id}: {str(e)}"
875
+
876
+ except Exception as e:
877
+ compliance_status = ComplianceStatus.ERROR
878
+ evaluation_reason = f"Unexpected error evaluating EC2 instance {instance_id}: {str(e)}"
879
+
880
+ return ComplianceResult(
881
+ resource_id=instance_id,
882
+ resource_type="AWS::EC2::Instance",
883
+ compliance_status=compliance_status,
884
+ evaluation_reason=evaluation_reason,
885
+ config_rule_name=self.rule_name,
886
+ region=region
887
+ )
888
+
889
+ def _is_overly_permissive_policy(self, policy_document: Dict[str, Any]) -> bool:
890
+ """Check if a policy document contains overly permissive permissions.
891
+
892
+ Args:
893
+ policy_document: IAM policy document
894
+
895
+ Returns:
896
+ True if policy contains Action:"*" with Resource:"*", False otherwise
897
+ """
898
+ statements = policy_document.get('Statement', [])
899
+
900
+ for statement in statements:
901
+ # Skip deny statements
902
+ if statement.get('Effect') != 'Allow':
903
+ continue
904
+
905
+ actions = statement.get('Action', [])
906
+ resources = statement.get('Resource', [])
907
+
908
+ # Normalize to lists
909
+ if isinstance(actions, str):
910
+ actions = [actions]
911
+ if isinstance(resources, str):
912
+ resources = [resources]
913
+
914
+ # Check if both Action and Resource contain wildcards
915
+ has_wildcard_action = '*' in actions
916
+ has_wildcard_resource = '*' in resources
917
+
918
+ if has_wildcard_action and has_wildcard_resource:
919
+ return True
920
+
921
+ return False
922
+
923
+
924
+ # ============================================================================
925
+ # Control 5: Account Management Assessments
926
+ # ============================================================================
927
+
928
+ class IAMServiceAccountInventoryCheckAssessment(BaseConfigRuleAssessment):
929
+ """Assessment for iam-service-account-inventory-check AWS Config rule.
930
+
931
+ Validates that service accounts (IAM users and roles) have required documentation
932
+ tags: Purpose, Owner, and LastReviewed. Service accounts are identified by naming
933
+ convention (contains "service", "app", "application") or ServiceAccount=true tag.
934
+
935
+ This is a global service assessment that only runs in us-east-1.
936
+ """
937
+
938
+ # Required tags for service accounts
939
+ REQUIRED_TAGS = {'Purpose', 'Owner', 'LastReviewed'}
940
+
941
+ # Keywords in names that indicate service accounts
942
+ SERVICE_ACCOUNT_KEYWORDS = {'service', 'app', 'application'}
943
+
944
+ def __init__(self):
945
+ super().__init__(
946
+ rule_name="iam-service-account-inventory-check",
947
+ control_id="5.1",
948
+ resource_types=["AWS::IAM::User", "AWS::IAM::Role"]
949
+ )
950
+
951
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
952
+ """Get IAM users and roles that are service accounts.
953
+
954
+ IAM is a global service, so we only query in us-east-1.
955
+
956
+ Args:
957
+ aws_factory: AWS client factory for API access
958
+ resource_type: AWS resource type (AWS::IAM::User or AWS::IAM::Role)
959
+ region: AWS region (should be us-east-1 for IAM)
960
+
961
+ Returns:
962
+ List of IAM user/role dictionaries that are identified as service accounts
963
+ """
964
+ if resource_type not in ["AWS::IAM::User", "AWS::IAM::Role"]:
965
+ return []
966
+
967
+ # IAM is a global service - only evaluate in us-east-1
968
+ if region != 'us-east-1':
969
+ logger.debug(f"Skipping IAM evaluation in {region} - global service evaluated in us-east-1 only")
970
+ return []
971
+
972
+ try:
973
+ iam_client = aws_factory.get_client('iam', region)
974
+ service_accounts = []
975
+
976
+ if resource_type == "AWS::IAM::User":
977
+ # List all IAM users with pagination
978
+ marker = None
979
+ while True:
980
+ if marker:
981
+ response = aws_factory.aws_api_call_with_retry(
982
+ lambda: iam_client.list_users(Marker=marker)
983
+ )
984
+ else:
985
+ response = aws_factory.aws_api_call_with_retry(
986
+ lambda: iam_client.list_users()
987
+ )
988
+
989
+ users = response.get('Users', [])
990
+
991
+ # Filter for service accounts
992
+ for user in users:
993
+ if self._is_service_account(user.get('UserName', ''), user.get('Tags', [])):
994
+ service_accounts.append(user)
995
+
996
+ if response.get('IsTruncated', False):
997
+ marker = response.get('Marker')
998
+ else:
999
+ break
1000
+
1001
+ elif resource_type == "AWS::IAM::Role":
1002
+ # List all IAM roles with pagination
1003
+ marker = None
1004
+ while True:
1005
+ if marker:
1006
+ response = aws_factory.aws_api_call_with_retry(
1007
+ lambda: iam_client.list_roles(Marker=marker)
1008
+ )
1009
+ else:
1010
+ response = aws_factory.aws_api_call_with_retry(
1011
+ lambda: iam_client.list_roles()
1012
+ )
1013
+
1014
+ roles = response.get('Roles', [])
1015
+
1016
+ # Filter for service accounts
1017
+ for role in roles:
1018
+ if self._is_service_account(role.get('RoleName', ''), role.get('Tags', [])):
1019
+ service_accounts.append(role)
1020
+
1021
+ if response.get('IsTruncated', False):
1022
+ marker = response.get('Marker')
1023
+ else:
1024
+ break
1025
+
1026
+ logger.debug(f"Found {len(service_accounts)} service accounts of type {resource_type}")
1027
+ return service_accounts
1028
+
1029
+ except ClientError as e:
1030
+ error_code = e.response.get('Error', {}).get('Code', '')
1031
+
1032
+ if error_code in ['AccessDenied']:
1033
+ logger.warning(f"Insufficient permissions to list {resource_type}: {e}")
1034
+ return []
1035
+ else:
1036
+ logger.error(f"Error retrieving {resource_type}: {e}")
1037
+ raise
1038
+
1039
+ def _is_service_account(self, name: str, tags: List[Dict[str, str]]) -> bool:
1040
+ """Determine if an IAM user or role is a service account.
1041
+
1042
+ Service accounts are identified by:
1043
+ 1. Name contains "service", "app", or "application" (case-insensitive)
1044
+ 2. Has ServiceAccount=true tag
1045
+
1046
+ Args:
1047
+ name: IAM user or role name
1048
+ tags: List of tags
1049
+
1050
+ Returns:
1051
+ True if identified as service account, False otherwise
1052
+ """
1053
+ # Check naming convention
1054
+ name_lower = name.lower()
1055
+ for keyword in self.SERVICE_ACCOUNT_KEYWORDS:
1056
+ if keyword in name_lower:
1057
+ return True
1058
+
1059
+ # Check for ServiceAccount tag
1060
+ for tag in tags:
1061
+ if tag.get('Key') == 'ServiceAccount' and tag.get('Value', '').lower() == 'true':
1062
+ return True
1063
+
1064
+ return False
1065
+
1066
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
1067
+ """Evaluate if service account has required documentation tags.
1068
+
1069
+ Args:
1070
+ resource: IAM user or role resource dictionary
1071
+ aws_factory: AWS client factory for additional API calls
1072
+ region: AWS region
1073
+
1074
+ Returns:
1075
+ ComplianceResult indicating whether the service account has required tags
1076
+ """
1077
+ # Determine resource type and extract identifiers
1078
+ if 'UserName' in resource:
1079
+ resource_type = "AWS::IAM::User"
1080
+ resource_name = resource.get('UserName', 'unknown')
1081
+ resource_id = resource.get('Arn', 'unknown')
1082
+ else:
1083
+ resource_type = "AWS::IAM::Role"
1084
+ resource_name = resource.get('RoleName', 'unknown')
1085
+ resource_id = resource.get('Arn', 'unknown')
1086
+
1087
+ try:
1088
+ # Get tags from resource
1089
+ tags = resource.get('Tags', [])
1090
+
1091
+ # Extract tag keys and check for required tags
1092
+ tag_dict = {tag.get('Key'): tag.get('Value', '') for tag in tags}
1093
+ present_required_tags = set(tag_dict.keys()) & self.REQUIRED_TAGS
1094
+ missing_tags = self.REQUIRED_TAGS - present_required_tags
1095
+
1096
+ # Check if all required tags are present with non-empty values
1097
+ empty_tags = []
1098
+ for tag_key in present_required_tags:
1099
+ if not tag_dict.get(tag_key, '').strip():
1100
+ empty_tags.append(tag_key)
1101
+
1102
+ if not missing_tags and not empty_tags:
1103
+ compliance_status = ComplianceStatus.COMPLIANT
1104
+ evaluation_reason = (
1105
+ f"Service account {resource_name} has all required documentation tags: "
1106
+ f"Purpose='{tag_dict.get('Purpose')}', Owner='{tag_dict.get('Owner')}', "
1107
+ f"LastReviewed='{tag_dict.get('LastReviewed')}'"
1108
+ )
1109
+ else:
1110
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1111
+
1112
+ issues = []
1113
+ if missing_tags:
1114
+ issues.append(f"Missing tags: {', '.join(sorted(missing_tags))}")
1115
+ if empty_tags:
1116
+ issues.append(f"Empty tags: {', '.join(sorted(empty_tags))}")
1117
+
1118
+ evaluation_reason = (
1119
+ f"Service account {resource_name} is missing required documentation tags. "
1120
+ f"{' and '.join(issues)}.\n\n"
1121
+ f"Add required documentation tags to service accounts:\n"
1122
+ f"1. Go to IAM console > {'Users' if resource_type == 'AWS::IAM::User' else 'Roles'}\n"
1123
+ f"2. Select the service account '{resource_name}'\n"
1124
+ f"3. Tags tab > Manage tags\n"
1125
+ f"4. Add required tags:\n"
1126
+ f" - Purpose: Description of what the account is used for\n"
1127
+ f" - Owner: Team or individual responsible\n"
1128
+ f" - LastReviewed: Date of last access review (YYYY-MM-DD)\n"
1129
+ f"5. Save changes\n\n"
1130
+ f"AWS CLI example:\n"
1131
+ )
1132
+
1133
+ if resource_type == "AWS::IAM::User":
1134
+ evaluation_reason += (
1135
+ f"aws iam tag-user --user-name {resource_name} --tags "
1136
+ f"Key=Purpose,Value=\"API access for app\" "
1137
+ f"Key=Owner,Value=\"platform-team\" "
1138
+ f"Key=LastReviewed,Value=\"2024-01-15\"\n\n"
1139
+ )
1140
+ else:
1141
+ evaluation_reason += (
1142
+ f"aws iam tag-role --role-name {resource_name} --tags "
1143
+ f"Key=Purpose,Value=\"Lambda execution\" "
1144
+ f"Key=Owner,Value=\"dev-team\" "
1145
+ f"Key=LastReviewed,Value=\"2024-01-15\"\n\n"
1146
+ )
1147
+
1148
+ evaluation_reason += (
1149
+ f"Best practices:\n"
1150
+ f"- Review service accounts quarterly\n"
1151
+ f"- Update LastReviewed tag after each review\n"
1152
+ f"- Remove unused service accounts\n"
1153
+ f"- Document service account inventory"
1154
+ )
1155
+
1156
+ except ClientError as e:
1157
+ error_code = e.response.get('Error', {}).get('Code', '')
1158
+
1159
+ if error_code == 'NoSuchEntity':
1160
+ compliance_status = ComplianceStatus.ERROR
1161
+ evaluation_reason = f"Service account {resource_name} not found (may have been deleted)"
1162
+ elif error_code in ['AccessDenied']:
1163
+ compliance_status = ComplianceStatus.ERROR
1164
+ evaluation_reason = (
1165
+ f"Insufficient permissions to evaluate service account {resource_name}. "
1166
+ f"Required permissions: iam:ListUsers, iam:ListRoles, iam:ListUserTags, iam:ListRoleTags"
1167
+ )
1168
+ else:
1169
+ compliance_status = ComplianceStatus.ERROR
1170
+ evaluation_reason = f"Error evaluating service account {resource_name}: {str(e)}"
1171
+
1172
+ except Exception as e:
1173
+ compliance_status = ComplianceStatus.ERROR
1174
+ evaluation_reason = f"Unexpected error evaluating service account {resource_name}: {str(e)}"
1175
+
1176
+ return ComplianceResult(
1177
+ resource_id=resource_id,
1178
+ resource_type=resource_type,
1179
+ compliance_status=compliance_status,
1180
+ evaluation_reason=evaluation_reason,
1181
+ config_rule_name=self.rule_name,
1182
+ region=region
1183
+ )
1184
+
1185
+
1186
+ class IAMAdminPolicyAttachedToRoleCheckAssessment(BaseConfigRuleAssessment):
1187
+ """Assessment for iam-admin-policy-attached-to-role-check AWS Config rule.
1188
+
1189
+ Ensures administrative policies are attached to roles, not directly to users.
1190
+ This promotes best practices of using temporary credentials via role assumption
1191
+ rather than long-lived user credentials with administrative access.
1192
+
1193
+ This is a global service assessment that only runs in us-east-1.
1194
+ """
1195
+
1196
+ # Administrative managed policy ARNs
1197
+ ADMIN_MANAGED_POLICIES = {
1198
+ 'arn:aws:iam::aws:policy/AdministratorAccess',
1199
+ 'arn:aws:iam::aws:policy/PowerUserAccess'
1200
+ }
1201
+
1202
+ def __init__(self):
1203
+ super().__init__(
1204
+ rule_name="iam-admin-policy-attached-to-role-check",
1205
+ control_id="5.2",
1206
+ resource_types=["AWS::IAM::User"]
1207
+ )
1208
+
1209
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
1210
+ """Get IAM users.
1211
+
1212
+ IAM is a global service, so we only query in us-east-1.
1213
+
1214
+ Args:
1215
+ aws_factory: AWS client factory for API access
1216
+ resource_type: AWS resource type (should be AWS::IAM::User)
1217
+ region: AWS region (should be us-east-1 for IAM)
1218
+
1219
+ Returns:
1220
+ List of IAM user dictionaries
1221
+ """
1222
+ if resource_type != "AWS::IAM::User":
1223
+ return []
1224
+
1225
+ # IAM is a global service - only evaluate in us-east-1
1226
+ if region != 'us-east-1':
1227
+ logger.debug(f"Skipping IAM evaluation in {region} - global service evaluated in us-east-1 only")
1228
+ return []
1229
+
1230
+ try:
1231
+ iam_client = aws_factory.get_client('iam', region)
1232
+
1233
+ # List all IAM users with pagination
1234
+ users = []
1235
+ marker = None
1236
+
1237
+ while True:
1238
+ if marker:
1239
+ response = aws_factory.aws_api_call_with_retry(
1240
+ lambda: iam_client.list_users(Marker=marker)
1241
+ )
1242
+ else:
1243
+ response = aws_factory.aws_api_call_with_retry(
1244
+ lambda: iam_client.list_users()
1245
+ )
1246
+
1247
+ users.extend(response.get('Users', []))
1248
+
1249
+ if response.get('IsTruncated', False):
1250
+ marker = response.get('Marker')
1251
+ else:
1252
+ break
1253
+
1254
+ logger.debug(f"Found {len(users)} IAM users")
1255
+ return users
1256
+
1257
+ except ClientError as e:
1258
+ error_code = e.response.get('Error', {}).get('Code', '')
1259
+
1260
+ if error_code in ['AccessDenied']:
1261
+ logger.warning(f"Insufficient permissions to list IAM users: {e}")
1262
+ return []
1263
+ else:
1264
+ logger.error(f"Error retrieving IAM users: {e}")
1265
+ raise
1266
+
1267
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
1268
+ """Evaluate if IAM user has administrative policies attached.
1269
+
1270
+ Args:
1271
+ resource: IAM user resource dictionary
1272
+ aws_factory: AWS client factory for additional API calls
1273
+ region: AWS region
1274
+
1275
+ Returns:
1276
+ ComplianceResult indicating whether the user has admin policies
1277
+ """
1278
+ user_name = resource.get('UserName', 'unknown')
1279
+ user_arn = resource.get('Arn', 'unknown')
1280
+
1281
+ try:
1282
+ iam_client = aws_factory.get_client('iam', 'us-east-1')
1283
+ admin_policies = []
1284
+
1285
+ # Check attached managed policies
1286
+ attached_policies_response = aws_factory.aws_api_call_with_retry(
1287
+ lambda: iam_client.list_attached_user_policies(UserName=user_name)
1288
+ )
1289
+
1290
+ for policy in attached_policies_response.get('AttachedPolicies', []):
1291
+ policy_arn = policy.get('PolicyArn', '')
1292
+ policy_name = policy.get('PolicyName', '')
1293
+
1294
+ if policy_arn in self.ADMIN_MANAGED_POLICIES:
1295
+ admin_policies.append(f"Managed policy: {policy_name} ({policy_arn})")
1296
+
1297
+ # Check inline policies
1298
+ inline_policies_response = aws_factory.aws_api_call_with_retry(
1299
+ lambda: iam_client.list_user_policies(UserName=user_name)
1300
+ )
1301
+
1302
+ for policy_name in inline_policies_response.get('PolicyNames', []):
1303
+ # Get the policy document
1304
+ policy_response = aws_factory.aws_api_call_with_retry(
1305
+ lambda: iam_client.get_user_policy(UserName=user_name, PolicyName=policy_name)
1306
+ )
1307
+
1308
+ policy_document = policy_response.get('PolicyDocument', {})
1309
+
1310
+ # Check if policy has Action:"*" with Resource:"*"
1311
+ if self._is_admin_policy(policy_document):
1312
+ admin_policies.append(f"Inline policy: {policy_name} (contains Action:'*' with Resource:'*')")
1313
+
1314
+ # Determine compliance status
1315
+ if admin_policies:
1316
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1317
+ evaluation_reason = (
1318
+ f"IAM user {user_name} has administrative policies attached directly:\n"
1319
+ )
1320
+ for policy in admin_policies:
1321
+ evaluation_reason += f" - {policy}\n"
1322
+
1323
+ evaluation_reason += (
1324
+ f"\nMove administrative access from users to roles:\n"
1325
+ f"1. Create an IAM role for administrative access\n"
1326
+ f"2. Attach administrative policies to the role\n"
1327
+ f"3. Configure trust policy for the role (allow users to assume)\n"
1328
+ f"4. Remove administrative policies from IAM users\n"
1329
+ f"5. Users should assume the role when admin access is needed\n\n"
1330
+ f"AWS CLI example:\n"
1331
+ f"# Create admin role\n"
1332
+ f"aws iam create-role --role-name AdminRole --assume-role-policy-document file://trust-policy.json\n"
1333
+ f"aws iam attach-role-policy --role-name AdminRole --policy-arn arn:aws:iam::aws:policy/AdministratorAccess\n\n"
1334
+ f"# Remove admin policy from user\n"
1335
+ f"aws iam detach-user-policy --user-name {user_name} --policy-arn arn:aws:iam::aws:policy/AdministratorAccess\n\n"
1336
+ f"# User assumes role\n"
1337
+ f"aws sts assume-role --role-arn arn:aws:iam::<account>:role/AdminRole --role-session-name admin-session\n\n"
1338
+ f"Benefits:\n"
1339
+ f"- Temporary credentials with session limits\n"
1340
+ f"- Audit trail of role assumptions\n"
1341
+ f"- Centralized permission management\n"
1342
+ f"- Easier to revoke access"
1343
+ )
1344
+ else:
1345
+ compliance_status = ComplianceStatus.COMPLIANT
1346
+ evaluation_reason = (
1347
+ f"IAM user {user_name} does not have administrative policies attached directly"
1348
+ )
1349
+
1350
+ except ClientError as e:
1351
+ error_code = e.response.get('Error', {}).get('Code', '')
1352
+
1353
+ if error_code == 'NoSuchEntity':
1354
+ compliance_status = ComplianceStatus.ERROR
1355
+ evaluation_reason = f"IAM user {user_name} not found (may have been deleted)"
1356
+ elif error_code in ['AccessDenied']:
1357
+ compliance_status = ComplianceStatus.ERROR
1358
+ evaluation_reason = (
1359
+ f"Insufficient permissions to evaluate IAM user {user_name}. "
1360
+ f"Required permissions: iam:ListAttachedUserPolicies, iam:ListUserPolicies, iam:GetUserPolicy"
1361
+ )
1362
+ else:
1363
+ compliance_status = ComplianceStatus.ERROR
1364
+ evaluation_reason = f"Error evaluating IAM user {user_name}: {str(e)}"
1365
+
1366
+ except Exception as e:
1367
+ compliance_status = ComplianceStatus.ERROR
1368
+ evaluation_reason = f"Unexpected error evaluating IAM user {user_name}: {str(e)}"
1369
+
1370
+ return ComplianceResult(
1371
+ resource_id=user_arn,
1372
+ resource_type="AWS::IAM::User",
1373
+ compliance_status=compliance_status,
1374
+ evaluation_reason=evaluation_reason,
1375
+ config_rule_name=self.rule_name,
1376
+ region=region
1377
+ )
1378
+
1379
+ def _is_admin_policy(self, policy_document: Dict[str, Any]) -> bool:
1380
+ """Check if a policy document grants administrative permissions.
1381
+
1382
+ Args:
1383
+ policy_document: IAM policy document
1384
+
1385
+ Returns:
1386
+ True if policy contains Action:"*" with Resource:"*", False otherwise
1387
+ """
1388
+ statements = policy_document.get('Statement', [])
1389
+
1390
+ for statement in statements:
1391
+ # Skip deny statements
1392
+ if statement.get('Effect') != 'Allow':
1393
+ continue
1394
+
1395
+ actions = statement.get('Action', [])
1396
+ resources = statement.get('Resource', [])
1397
+
1398
+ # Normalize to lists
1399
+ if isinstance(actions, str):
1400
+ actions = [actions]
1401
+ if isinstance(resources, str):
1402
+ resources = [resources]
1403
+
1404
+ # Check if both Action and Resource contain wildcards
1405
+ has_wildcard_action = '*' in actions
1406
+ has_wildcard_resource = '*' in resources
1407
+
1408
+ if has_wildcard_action and has_wildcard_resource:
1409
+ return True
1410
+
1411
+ return False
1412
+
1413
+
1414
+ class SSOEnabledCheckAssessment(BaseConfigRuleAssessment):
1415
+ """Assessment for sso-enabled-check AWS Config rule.
1416
+
1417
+ Validates that AWS IAM Identity Center (SSO) is configured and enabled.
1418
+ SSO provides centralized user management and single sign-on experience,
1419
+ reducing IAM user sprawl and improving security.
1420
+
1421
+ This is a global service assessment that only runs in us-east-1.
1422
+ """
1423
+
1424
+ def __init__(self):
1425
+ super().__init__(
1426
+ rule_name="sso-enabled-check",
1427
+ control_id="5.3",
1428
+ resource_types=["AWS::::Account"]
1429
+ )
1430
+
1431
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
1432
+ """Get account-level resource for SSO check.
1433
+
1434
+ SSO is a global service, so we only query in us-east-1.
1435
+ Returns a single account-level resource.
1436
+
1437
+ Args:
1438
+ aws_factory: AWS client factory for API access
1439
+ resource_type: AWS resource type (should be AWS::::Account)
1440
+ region: AWS region (should be us-east-1 for SSO)
1441
+
1442
+ Returns:
1443
+ List containing single account-level resource dictionary
1444
+ """
1445
+ if resource_type != "AWS::::Account":
1446
+ return []
1447
+
1448
+ # SSO is a global service - only evaluate in us-east-1
1449
+ if region != 'us-east-1':
1450
+ logger.debug(f"Skipping SSO evaluation in {region} - global service evaluated in us-east-1 only")
1451
+ return []
1452
+
1453
+ try:
1454
+ # Get account ID for resource identification
1455
+ sts_client = aws_factory.get_client('sts', region)
1456
+ identity_response = aws_factory.aws_api_call_with_retry(
1457
+ lambda: sts_client.get_caller_identity()
1458
+ )
1459
+ account_id = identity_response.get('Account', 'unknown')
1460
+
1461
+ # Return single account-level resource
1462
+ return [{
1463
+ 'AccountId': account_id,
1464
+ 'ResourceType': 'AWS::::Account'
1465
+ }]
1466
+
1467
+ except ClientError as e:
1468
+ logger.error(f"Error getting account identity: {e}")
1469
+ raise
1470
+
1471
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
1472
+ """Evaluate if AWS IAM Identity Center (SSO) is enabled.
1473
+
1474
+ Args:
1475
+ resource: Account resource dictionary
1476
+ aws_factory: AWS client factory for additional API calls
1477
+ region: AWS region
1478
+
1479
+ Returns:
1480
+ ComplianceResult indicating whether SSO is enabled
1481
+ """
1482
+ account_id = resource.get('AccountId', 'unknown')
1483
+ resource_id = f"arn:aws::::account/{account_id}"
1484
+
1485
+ try:
1486
+ sso_admin_client = aws_factory.get_client('sso-admin', 'us-east-1')
1487
+
1488
+ # List SSO instances
1489
+ instances_response = aws_factory.aws_api_call_with_retry(
1490
+ lambda: sso_admin_client.list_instances()
1491
+ )
1492
+
1493
+ instances = instances_response.get('Instances', [])
1494
+
1495
+ if instances:
1496
+ # SSO is enabled - at least one instance exists
1497
+ instance_arns = [inst.get('InstanceArn', 'unknown') for inst in instances]
1498
+ compliance_status = ComplianceStatus.COMPLIANT
1499
+ evaluation_reason = (
1500
+ f"AWS IAM Identity Center (SSO) is enabled for account {account_id}. "
1501
+ f"Found {len(instances)} SSO instance(s): {', '.join(instance_arns)}"
1502
+ )
1503
+ else:
1504
+ # No SSO instances found
1505
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1506
+ evaluation_reason = (
1507
+ f"AWS IAM Identity Center (SSO) is not enabled for account {account_id}.\n\n"
1508
+ f"Enable AWS IAM Identity Center (SSO):\n"
1509
+ f"1. Go to IAM Identity Center console\n"
1510
+ f"2. Enable IAM Identity Center\n"
1511
+ f"3. Choose identity source:\n"
1512
+ f" - Identity Center directory (default)\n"
1513
+ f" - Active Directory\n"
1514
+ f" - External identity provider (SAML 2.0)\n"
1515
+ f"4. Configure users and groups\n"
1516
+ f"5. Assign users to AWS accounts and permission sets\n"
1517
+ f"6. Users access AWS via SSO portal\n\n"
1518
+ f"AWS CLI example:\n"
1519
+ f"# SSO must be enabled through console or Organizations API\n"
1520
+ f"# After enabling, configure permission sets:\n"
1521
+ f"aws sso-admin create-permission-set --instance-arn <instance-arn> --name ReadOnlyAccess\n"
1522
+ f"aws sso-admin attach-managed-policy-to-permission-set \\\n"
1523
+ f" --instance-arn <instance-arn> \\\n"
1524
+ f" --permission-set-arn <ps-arn> \\\n"
1525
+ f" --managed-policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess\n\n"
1526
+ f"Benefits:\n"
1527
+ f"- Centralized user management\n"
1528
+ f"- Single sign-on experience\n"
1529
+ f"- Temporary credentials\n"
1530
+ f"- Integration with corporate identity providers\n"
1531
+ f"- Reduced IAM user sprawl"
1532
+ )
1533
+
1534
+ except ClientError as e:
1535
+ error_code = e.response.get('Error', {}).get('Code', '')
1536
+
1537
+ if error_code in ['ResourceNotFoundException']:
1538
+ # ResourceNotFoundException indicates SSO is not configured
1539
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1540
+ evaluation_reason = (
1541
+ f"AWS IAM Identity Center (SSO) is not enabled for account {account_id}. "
1542
+ f"Enable SSO through the IAM Identity Center console to provide centralized "
1543
+ f"user management and single sign-on capabilities."
1544
+ )
1545
+ elif error_code in ['AccessDenied']:
1546
+ compliance_status = ComplianceStatus.ERROR
1547
+ evaluation_reason = (
1548
+ f"Insufficient permissions to check SSO status for account {account_id}. "
1549
+ f"Required permissions: sso:ListInstances"
1550
+ )
1551
+ else:
1552
+ compliance_status = ComplianceStatus.ERROR
1553
+ evaluation_reason = f"Error checking SSO status for account {account_id}: {str(e)}"
1554
+
1555
+ except Exception as e:
1556
+ compliance_status = ComplianceStatus.ERROR
1557
+ evaluation_reason = f"Unexpected error checking SSO status for account {account_id}: {str(e)}"
1558
+
1559
+ return ComplianceResult(
1560
+ resource_id=resource_id,
1561
+ resource_type="AWS::::Account",
1562
+ compliance_status=compliance_status,
1563
+ evaluation_reason=evaluation_reason,
1564
+ config_rule_name=self.rule_name,
1565
+ region=region
1566
+ )
1567
+
1568
+
1569
+ class IAMUserNoInlinePoliciesAssessment(BaseConfigRuleAssessment):
1570
+ """Assessment for iam-user-no-inline-policies AWS Config rule.
1571
+
1572
+ Ensures IAM users don't have inline policies attached. Inline policies are
1573
+ harder to manage, audit, and reuse compared to managed policies. Best practice
1574
+ is to use managed policies or group memberships for permission management.
1575
+
1576
+ This is a global service assessment that only runs in us-east-1.
1577
+ """
1578
+
1579
+ def __init__(self):
1580
+ super().__init__(
1581
+ rule_name="iam-user-no-inline-policies",
1582
+ control_id="5.4",
1583
+ resource_types=["AWS::IAM::User"]
1584
+ )
1585
+
1586
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
1587
+ """Get IAM users.
1588
+
1589
+ IAM is a global service, so we only query in us-east-1.
1590
+
1591
+ Args:
1592
+ aws_factory: AWS client factory for API access
1593
+ resource_type: AWS resource type (should be AWS::IAM::User)
1594
+ region: AWS region (should be us-east-1 for IAM)
1595
+
1596
+ Returns:
1597
+ List of IAM user dictionaries
1598
+ """
1599
+ if resource_type != "AWS::IAM::User":
1600
+ return []
1601
+
1602
+ # IAM is a global service - only evaluate in us-east-1
1603
+ if region != 'us-east-1':
1604
+ logger.debug(f"Skipping IAM evaluation in {region} - global service evaluated in us-east-1 only")
1605
+ return []
1606
+
1607
+ try:
1608
+ iam_client = aws_factory.get_client('iam', region)
1609
+
1610
+ # List all IAM users with pagination
1611
+ users = []
1612
+ marker = None
1613
+
1614
+ while True:
1615
+ if marker:
1616
+ response = aws_factory.aws_api_call_with_retry(
1617
+ lambda: iam_client.list_users(Marker=marker)
1618
+ )
1619
+ else:
1620
+ response = aws_factory.aws_api_call_with_retry(
1621
+ lambda: iam_client.list_users()
1622
+ )
1623
+
1624
+ users.extend(response.get('Users', []))
1625
+
1626
+ if response.get('IsTruncated', False):
1627
+ marker = response.get('Marker')
1628
+ else:
1629
+ break
1630
+
1631
+ logger.debug(f"Found {len(users)} IAM users")
1632
+ return users
1633
+
1634
+ except ClientError as e:
1635
+ error_code = e.response.get('Error', {}).get('Code', '')
1636
+
1637
+ if error_code in ['AccessDenied']:
1638
+ logger.warning(f"Insufficient permissions to list IAM users: {e}")
1639
+ return []
1640
+ else:
1641
+ logger.error(f"Error retrieving IAM users: {e}")
1642
+ raise
1643
+
1644
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
1645
+ """Evaluate if IAM user has inline policies.
1646
+
1647
+ Args:
1648
+ resource: IAM user resource dictionary
1649
+ aws_factory: AWS client factory for additional API calls
1650
+ region: AWS region
1651
+
1652
+ Returns:
1653
+ ComplianceResult indicating whether the user has inline policies
1654
+ """
1655
+ user_name = resource.get('UserName', 'unknown')
1656
+ user_arn = resource.get('Arn', 'unknown')
1657
+
1658
+ try:
1659
+ iam_client = aws_factory.get_client('iam', 'us-east-1')
1660
+
1661
+ # List inline policies for the user
1662
+ inline_policies_response = aws_factory.aws_api_call_with_retry(
1663
+ lambda: iam_client.list_user_policies(UserName=user_name)
1664
+ )
1665
+
1666
+ inline_policy_names = inline_policies_response.get('PolicyNames', [])
1667
+
1668
+ if not inline_policy_names:
1669
+ compliance_status = ComplianceStatus.COMPLIANT
1670
+ evaluation_reason = (
1671
+ f"IAM user {user_name} has no inline policies attached"
1672
+ )
1673
+ else:
1674
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1675
+ evaluation_reason = (
1676
+ f"IAM user {user_name} has {len(inline_policy_names)} inline policy/policies attached: "
1677
+ f"{', '.join(inline_policy_names)}.\n\n"
1678
+ f"Replace inline policies with managed policies or group memberships:\n"
1679
+ f"1. Review inline policy document\n"
1680
+ f"2. Create equivalent managed policy or identify existing managed policy\n"
1681
+ f"3. Attach managed policy to user or add user to appropriate group\n"
1682
+ f"4. Test that user still has required permissions\n"
1683
+ f"5. Delete inline policy\n\n"
1684
+ f"AWS CLI example:\n"
1685
+ f"# Get inline policy document\n"
1686
+ f"aws iam get-user-policy --user-name {user_name} --policy-name <inline-policy> > policy.json\n\n"
1687
+ f"# Create managed policy from document\n"
1688
+ f"aws iam create-policy --policy-name {user_name}-policy --policy-document file://policy.json\n\n"
1689
+ f"# Attach managed policy to user\n"
1690
+ f"aws iam attach-user-policy --user-name {user_name} --policy-arn <policy-arn>\n\n"
1691
+ f"# Delete inline policy\n"
1692
+ f"aws iam delete-user-policy --user-name {user_name} --policy-name <inline-policy>\n\n"
1693
+ f"Best practices:\n"
1694
+ f"- Use managed policies for reusability\n"
1695
+ f"- Use groups for common permission sets\n"
1696
+ f"- Avoid user-specific permissions when possible\n"
1697
+ f"- Managed policies are easier to audit and update"
1698
+ )
1699
+
1700
+ except ClientError as e:
1701
+ error_code = e.response.get('Error', {}).get('Code', '')
1702
+
1703
+ if error_code == 'NoSuchEntity':
1704
+ compliance_status = ComplianceStatus.ERROR
1705
+ evaluation_reason = f"IAM user {user_name} not found (may have been deleted)"
1706
+ elif error_code in ['AccessDenied']:
1707
+ compliance_status = ComplianceStatus.ERROR
1708
+ evaluation_reason = (
1709
+ f"Insufficient permissions to evaluate IAM user {user_name}. "
1710
+ f"Required permissions: iam:ListUserPolicies"
1711
+ )
1712
+ else:
1713
+ compliance_status = ComplianceStatus.ERROR
1714
+ evaluation_reason = f"Error evaluating IAM user {user_name}: {str(e)}"
1715
+
1716
+ except Exception as e:
1717
+ compliance_status = ComplianceStatus.ERROR
1718
+ evaluation_reason = f"Unexpected error evaluating IAM user {user_name}: {str(e)}"
1719
+
1720
+ return ComplianceResult(
1721
+ resource_id=user_arn,
1722
+ resource_type="AWS::IAM::User",
1723
+ compliance_status=compliance_status,
1724
+ evaluation_reason=evaluation_reason,
1725
+ config_rule_name=self.rule_name,
1726
+ region=region
1727
+ )
1728
+
1729
+
1730
+ # ============================================================================
1731
+ # Control 6: Access Control Management Assessments
1732
+ # ============================================================================
1733
+
1734
+ class IAMAccessAnalyzerEnabledAssessment(BaseConfigRuleAssessment):
1735
+ """Assessment for iam-access-analyzer-enabled AWS Config rule.
1736
+
1737
+ Ensures IAM Access Analyzer is enabled in all active regions to identify
1738
+ resources shared with external entities and detect unintended access.
1739
+
1740
+ This is a regional service assessment that runs in all active regions.
1741
+ """
1742
+
1743
+ def __init__(self):
1744
+ super().__init__(
1745
+ rule_name="iam-access-analyzer-enabled",
1746
+ control_id="6.1",
1747
+ resource_types=["AWS::AccessAnalyzer::Analyzer"]
1748
+ )
1749
+
1750
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
1751
+ """Get Access Analyzer analyzers.
1752
+
1753
+ Access Analyzer is a regional service, so we query in each active region.
1754
+
1755
+ Args:
1756
+ aws_factory: AWS client factory for API access
1757
+ resource_type: AWS resource type (should be AWS::AccessAnalyzer::Analyzer)
1758
+ region: AWS region
1759
+
1760
+ Returns:
1761
+ List of analyzer dictionaries or a single region-level resource
1762
+ """
1763
+ if resource_type != "AWS::AccessAnalyzer::Analyzer":
1764
+ return []
1765
+
1766
+ try:
1767
+ analyzer_client = aws_factory.get_client('accessanalyzer', region)
1768
+
1769
+ # List all analyzers with pagination support
1770
+ analyzers = []
1771
+ next_token = None
1772
+
1773
+ while True:
1774
+ if next_token:
1775
+ response = aws_factory.aws_api_call_with_retry(
1776
+ lambda: analyzer_client.list_analyzers(nextToken=next_token)
1777
+ )
1778
+ else:
1779
+ response = aws_factory.aws_api_call_with_retry(
1780
+ lambda: analyzer_client.list_analyzers()
1781
+ )
1782
+
1783
+ analyzers.extend(response.get('analyzers', []))
1784
+
1785
+ # Check if there are more results
1786
+ next_token = response.get('nextToken')
1787
+ if not next_token:
1788
+ break
1789
+
1790
+ # Return a single region-level resource to check if any active analyzer exists
1791
+ # This allows us to evaluate the region as a whole
1792
+ return [{'region': region, 'analyzers': analyzers}]
1793
+
1794
+ except ClientError as e:
1795
+ error_code = e.response.get('Error', {}).get('Code', '')
1796
+
1797
+ if error_code in ['AccessDenied']:
1798
+ logger.warning(f"Insufficient permissions to list Access Analyzers in {region}: {e}")
1799
+ return []
1800
+ else:
1801
+ logger.error(f"Error retrieving Access Analyzers in {region}: {e}")
1802
+ raise
1803
+
1804
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
1805
+ """Evaluate if Access Analyzer is enabled in the region.
1806
+
1807
+ Args:
1808
+ resource: Region-level resource with analyzers list
1809
+ aws_factory: AWS client factory for additional API calls
1810
+ region: AWS region
1811
+
1812
+ Returns:
1813
+ ComplianceResult indicating whether Access Analyzer is enabled
1814
+ """
1815
+ analyzers = resource.get('analyzers', [])
1816
+ resource_id = f"access-analyzer-{region}"
1817
+
1818
+ try:
1819
+ # Check if at least one analyzer with status ACTIVE exists
1820
+ active_analyzers = [a for a in analyzers if a.get('status') == 'ACTIVE']
1821
+
1822
+ if active_analyzers:
1823
+ compliance_status = ComplianceStatus.COMPLIANT
1824
+ analyzer_names = [a.get('name', 'unknown') for a in active_analyzers]
1825
+ evaluation_reason = (
1826
+ f"IAM Access Analyzer is enabled in region {region} with {len(active_analyzers)} active analyzer(s): "
1827
+ f"{', '.join(analyzer_names)}"
1828
+ )
1829
+ else:
1830
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1831
+ evaluation_reason = (
1832
+ f"IAM Access Analyzer is not enabled in region {region}. "
1833
+ f"Enable Access Analyzer to identify resources shared with external entities.\n\n"
1834
+ f"Enable IAM Access Analyzer:\n"
1835
+ f"1. Go to IAM console > Access Analyzer\n"
1836
+ f"2. Create analyzer for region {region}\n"
1837
+ f"3. Choose analyzer type:\n"
1838
+ f" - Account analyzer: Analyzes resources in the account\n"
1839
+ f" - Organization analyzer: Analyzes resources across organization\n"
1840
+ f"4. Review findings regularly\n\n"
1841
+ f"AWS CLI example:\n"
1842
+ f"aws accessanalyzer create-analyzer --analyzer-name account-analyzer --type ACCOUNT --region {region}\n\n"
1843
+ f"Benefits:\n"
1844
+ f"- Identifies resources shared with external entities\n"
1845
+ f"- Detects unintended access\n"
1846
+ f"- Continuous monitoring\n"
1847
+ f"- Compliance validation"
1848
+ )
1849
+
1850
+ except ClientError as e:
1851
+ error_code = e.response.get('Error', {}).get('Code', '')
1852
+
1853
+ if error_code in ['AccessDenied']:
1854
+ compliance_status = ComplianceStatus.ERROR
1855
+ evaluation_reason = (
1856
+ f"Insufficient permissions to evaluate Access Analyzer in {region}. "
1857
+ f"Required permissions: access-analyzer:ListAnalyzers"
1858
+ )
1859
+ elif error_code == 'ResourceNotFoundException':
1860
+ compliance_status = ComplianceStatus.NON_COMPLIANT
1861
+ evaluation_reason = f"No Access Analyzer found in region {region}"
1862
+ else:
1863
+ compliance_status = ComplianceStatus.ERROR
1864
+ evaluation_reason = f"Error evaluating Access Analyzer in {region}: {str(e)}"
1865
+
1866
+ except Exception as e:
1867
+ compliance_status = ComplianceStatus.ERROR
1868
+ evaluation_reason = f"Unexpected error evaluating Access Analyzer in {region}: {str(e)}"
1869
+
1870
+ return ComplianceResult(
1871
+ resource_id=resource_id,
1872
+ resource_type="AWS::AccessAnalyzer::Analyzer",
1873
+ compliance_status=compliance_status,
1874
+ evaluation_reason=evaluation_reason,
1875
+ config_rule_name=self.rule_name,
1876
+ region=region
1877
+ )
1878
+
1879
+
1880
+
1881
+ class IAMPermissionBoundariesCheckAssessment(BaseConfigRuleAssessment):
1882
+ """Assessment for iam-permission-boundaries-check AWS Config rule.
1883
+
1884
+ Validates that IAM roles with elevated privileges have permission boundaries
1885
+ configured to limit the maximum permissions they can grant.
1886
+
1887
+ This is a global service assessment that only runs in us-east-1.
1888
+ """
1889
+
1890
+ # Elevated privilege managed policy ARNs
1891
+ ELEVATED_PRIVILEGE_POLICIES = {
1892
+ 'arn:aws:iam::aws:policy/AdministratorAccess',
1893
+ 'arn:aws:iam::aws:policy/PowerUserAccess'
1894
+ }
1895
+
1896
+ def __init__(self):
1897
+ super().__init__(
1898
+ rule_name="iam-permission-boundaries-check",
1899
+ control_id="6.2",
1900
+ resource_types=["AWS::IAM::Role"]
1901
+ )
1902
+
1903
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
1904
+ """Get IAM roles with elevated privileges.
1905
+
1906
+ IAM is a global service, so we only query in us-east-1.
1907
+ We filter for roles with elevated privileges.
1908
+
1909
+ Args:
1910
+ aws_factory: AWS client factory for API access
1911
+ resource_type: AWS resource type (should be AWS::IAM::Role)
1912
+ region: AWS region (should be us-east-1 for IAM)
1913
+
1914
+ Returns:
1915
+ List of IAM role dictionaries with elevated privileges
1916
+ """
1917
+ if resource_type != "AWS::IAM::Role":
1918
+ return []
1919
+
1920
+ # IAM is a global service - only evaluate in us-east-1
1921
+ if region != 'us-east-1':
1922
+ logger.debug(f"Skipping IAM evaluation in {region} - global service evaluated in us-east-1 only")
1923
+ return []
1924
+
1925
+ try:
1926
+ iam_client = aws_factory.get_client('iam', region)
1927
+ elevated_privilege_roles = []
1928
+
1929
+ # List all IAM roles with pagination
1930
+ marker = None
1931
+ while True:
1932
+ if marker:
1933
+ response = aws_factory.aws_api_call_with_retry(
1934
+ lambda: iam_client.list_roles(Marker=marker)
1935
+ )
1936
+ else:
1937
+ response = aws_factory.aws_api_call_with_retry(
1938
+ lambda: iam_client.list_roles()
1939
+ )
1940
+
1941
+ roles = response.get('Roles', [])
1942
+
1943
+ # Check each role for elevated privileges
1944
+ for role in roles:
1945
+ role_name = role.get('RoleName', '')
1946
+ if self._has_elevated_privileges(iam_client, role_name, aws_factory):
1947
+ elevated_privilege_roles.append(role)
1948
+
1949
+ if response.get('IsTruncated', False):
1950
+ marker = response.get('Marker')
1951
+ else:
1952
+ break
1953
+
1954
+ logger.debug(f"Found {len(elevated_privilege_roles)} roles with elevated privileges")
1955
+ return elevated_privilege_roles
1956
+
1957
+ except ClientError as e:
1958
+ error_code = e.response.get('Error', {}).get('Code', '')
1959
+
1960
+ if error_code in ['AccessDenied']:
1961
+ logger.warning(f"Insufficient permissions to list IAM roles: {e}")
1962
+ return []
1963
+ else:
1964
+ logger.error(f"Error retrieving IAM roles: {e}")
1965
+ raise
1966
+
1967
+ def _has_elevated_privileges(self, iam_client, role_name: str, aws_factory: AWSClientFactory) -> bool:
1968
+ """Check if a role has elevated privileges.
1969
+
1970
+ Args:
1971
+ iam_client: IAM boto3 client
1972
+ role_name: IAM role name
1973
+ aws_factory: AWS client factory for retry logic
1974
+
1975
+ Returns:
1976
+ True if role has elevated privileges, False otherwise
1977
+ """
1978
+ try:
1979
+ # Check attached managed policies
1980
+ attached_policies_response = aws_factory.aws_api_call_with_retry(
1981
+ lambda: iam_client.list_attached_role_policies(RoleName=role_name)
1982
+ )
1983
+
1984
+ for policy in attached_policies_response.get('AttachedPolicies', []):
1985
+ policy_arn = policy.get('PolicyArn', '')
1986
+ if policy_arn in self.ELEVATED_PRIVILEGE_POLICIES:
1987
+ return True
1988
+
1989
+ # Check inline policies for Action:"*"
1990
+ inline_policies_response = aws_factory.aws_api_call_with_retry(
1991
+ lambda: iam_client.list_role_policies(RoleName=role_name)
1992
+ )
1993
+
1994
+ for policy_name in inline_policies_response.get('PolicyNames', []):
1995
+ policy_response = aws_factory.aws_api_call_with_retry(
1996
+ lambda: iam_client.get_role_policy(RoleName=role_name, PolicyName=policy_name)
1997
+ )
1998
+
1999
+ policy_document = policy_response.get('PolicyDocument', {})
2000
+ if self._has_wildcard_action(policy_document):
2001
+ return True
2002
+
2003
+ return False
2004
+
2005
+ except ClientError as e:
2006
+ logger.warning(f"Error checking privileges for role {role_name}: {e}")
2007
+ return False
2008
+
2009
+ def _has_wildcard_action(self, policy_document: Dict[str, Any]) -> bool:
2010
+ """Check if policy document contains Action:"*".
2011
+
2012
+ Args:
2013
+ policy_document: IAM policy document
2014
+
2015
+ Returns:
2016
+ True if policy contains Action:"*", False otherwise
2017
+ """
2018
+ statements = policy_document.get('Statement', [])
2019
+
2020
+ for statement in statements:
2021
+ if statement.get('Effect') != 'Allow':
2022
+ continue
2023
+
2024
+ actions = statement.get('Action', [])
2025
+ if isinstance(actions, str):
2026
+ actions = [actions]
2027
+
2028
+ if '*' in actions:
2029
+ return True
2030
+
2031
+ return False
2032
+
2033
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
2034
+ """Evaluate if role with elevated privileges has permission boundary.
2035
+
2036
+ Args:
2037
+ resource: IAM role resource dictionary
2038
+ aws_factory: AWS client factory for additional API calls
2039
+ region: AWS region
2040
+
2041
+ Returns:
2042
+ ComplianceResult indicating whether the role has permission boundary
2043
+ """
2044
+ role_name = resource.get('RoleName', 'unknown')
2045
+ role_arn = resource.get('Arn', 'unknown')
2046
+ permissions_boundary = resource.get('PermissionsBoundary')
2047
+
2048
+ try:
2049
+ if permissions_boundary and permissions_boundary.get('PermissionsBoundaryArn'):
2050
+ compliance_status = ComplianceStatus.COMPLIANT
2051
+ boundary_arn = permissions_boundary.get('PermissionsBoundaryArn', '')
2052
+ evaluation_reason = (
2053
+ f"IAM role {role_name} with elevated privileges has permission boundary configured: {boundary_arn}"
2054
+ )
2055
+ else:
2056
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2057
+ evaluation_reason = (
2058
+ f"IAM role {role_name} has elevated privileges but no permission boundary configured. "
2059
+ f"Permission boundaries limit the maximum permissions a role can grant.\n\n"
2060
+ f"Configure permission boundaries for delegated administration:\n"
2061
+ f"1. Create a permission boundary policy that defines maximum permissions\n"
2062
+ f"2. Attach permission boundary to roles with elevated privileges\n"
2063
+ f"3. Permission boundary limits what the role can do, even with full access policies\n\n"
2064
+ f"AWS CLI example:\n"
2065
+ f"# Create permission boundary policy\n"
2066
+ f"aws iam create-policy --policy-name DelegatedAdminBoundary --policy-document file://boundary.json\n\n"
2067
+ f"# Attach boundary to role\n"
2068
+ f"aws iam put-role-permissions-boundary --role-name {role_name} --permissions-boundary arn:aws:iam::<account>:policy/DelegatedAdminBoundary\n\n"
2069
+ f"Use cases:\n"
2070
+ f"- Delegated administration\n"
2071
+ f"- Developer self-service\n"
2072
+ f"- Prevent privilege escalation\n"
2073
+ f"- Enforce organizational policies"
2074
+ )
2075
+
2076
+ except ClientError as e:
2077
+ error_code = e.response.get('Error', {}).get('Code', '')
2078
+
2079
+ if error_code == 'NoSuchEntity':
2080
+ compliance_status = ComplianceStatus.ERROR
2081
+ evaluation_reason = f"IAM role {role_name} not found (may have been deleted)"
2082
+ elif error_code in ['AccessDenied']:
2083
+ compliance_status = ComplianceStatus.ERROR
2084
+ evaluation_reason = (
2085
+ f"Insufficient permissions to evaluate IAM role {role_name}. "
2086
+ f"Required permissions: iam:ListRoles, iam:ListAttachedRolePolicies, iam:ListRolePolicies"
2087
+ )
2088
+ else:
2089
+ compliance_status = ComplianceStatus.ERROR
2090
+ evaluation_reason = f"Error evaluating IAM role {role_name}: {str(e)}"
2091
+
2092
+ except Exception as e:
2093
+ compliance_status = ComplianceStatus.ERROR
2094
+ evaluation_reason = f"Unexpected error evaluating IAM role {role_name}: {str(e)}"
2095
+
2096
+ return ComplianceResult(
2097
+ resource_id=role_arn,
2098
+ resource_type="AWS::IAM::Role",
2099
+ compliance_status=compliance_status,
2100
+ evaluation_reason=evaluation_reason,
2101
+ config_rule_name=self.rule_name,
2102
+ region=region
2103
+ )
2104
+
2105
+
2106
+
2107
+ class OrganizationsSCPEnabledCheckAssessment(BaseConfigRuleAssessment):
2108
+ """Assessment for organizations-scp-enabled-check AWS Config rule.
2109
+
2110
+ Ensures Service Control Policies (SCPs) are enabled and in use within
2111
+ AWS Organizations to enforce organizational policies and guardrails.
2112
+
2113
+ This is a global service assessment that only runs in us-east-1.
2114
+ """
2115
+
2116
+ def __init__(self):
2117
+ super().__init__(
2118
+ rule_name="organizations-scp-enabled-check",
2119
+ control_id="6.3",
2120
+ resource_types=["AWS::::Account"]
2121
+ )
2122
+
2123
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
2124
+ """Get account-level resource for SCP check.
2125
+
2126
+ Organizations is a global service, so we only query in us-east-1.
2127
+ Returns a single account-level resource.
2128
+
2129
+ Args:
2130
+ aws_factory: AWS client factory for API access
2131
+ resource_type: AWS resource type (should be AWS::::Account)
2132
+ region: AWS region (should be us-east-1 for Organizations)
2133
+
2134
+ Returns:
2135
+ List with single account-level resource dictionary
2136
+ """
2137
+ if resource_type != "AWS::::Account":
2138
+ return []
2139
+
2140
+ # Organizations is a global service - only evaluate in us-east-1
2141
+ if region != 'us-east-1':
2142
+ logger.debug(f"Skipping Organizations evaluation in {region} - global service evaluated in us-east-1 only")
2143
+ return []
2144
+
2145
+ # Return a single account-level resource
2146
+ return [{'account': 'current', 'region': region}]
2147
+
2148
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
2149
+ """Evaluate if Service Control Policies are enabled and in use.
2150
+
2151
+ Args:
2152
+ resource: Account-level resource dictionary
2153
+ aws_factory: AWS client factory for additional API calls
2154
+ region: AWS region
2155
+
2156
+ Returns:
2157
+ ComplianceResult indicating whether SCPs are enabled and in use
2158
+ """
2159
+ resource_id = "aws-account-scp-check"
2160
+
2161
+ try:
2162
+ org_client = aws_factory.get_client('organizations', region)
2163
+
2164
+ # Check if account is part of an organization
2165
+ try:
2166
+ org_response = aws_factory.aws_api_call_with_retry(
2167
+ lambda: org_client.describe_organization()
2168
+ )
2169
+ organization = org_response.get('Organization', {})
2170
+ except ClientError as e:
2171
+ if e.response.get('Error', {}).get('Code') == 'AWSOrganizationsNotInUseException':
2172
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2173
+ evaluation_reason = (
2174
+ f"Account is not part of an AWS Organization. "
2175
+ f"Service Control Policies require AWS Organizations.\n\n"
2176
+ f"Enable AWS Organizations:\n"
2177
+ f"1. Go to AWS Organizations console\n"
2178
+ f"2. Create an organization\n"
2179
+ f"3. Enable all features (includes SCPs)\n"
2180
+ f"4. Create custom SCPs to enforce organizational policies\n\n"
2181
+ f"AWS CLI example:\n"
2182
+ f"aws organizations create-organization --feature-set ALL\n\n"
2183
+ f"Benefits:\n"
2184
+ f"- Centralized account management\n"
2185
+ f"- Policy-based access controls\n"
2186
+ f"- Consolidated billing\n"
2187
+ f"- Service control policies"
2188
+ )
2189
+
2190
+ return ComplianceResult(
2191
+ resource_id=resource_id,
2192
+ resource_type="AWS::::Account",
2193
+ compliance_status=compliance_status,
2194
+ evaluation_reason=evaluation_reason,
2195
+ config_rule_name=self.rule_name,
2196
+ region=region
2197
+ )
2198
+ else:
2199
+ raise
2200
+
2201
+ # Check if SCPs are enabled (FeatureSet includes ALL or SERVICE_CONTROL_POLICY)
2202
+ feature_set = organization.get('FeatureSet', '')
2203
+
2204
+ if feature_set not in ['ALL']:
2205
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2206
+ evaluation_reason = (
2207
+ f"AWS Organization exists but Service Control Policies are not enabled. "
2208
+ f"Current feature set: {feature_set}. Enable all features to use SCPs.\n\n"
2209
+ f"Enable all features in Organizations:\n"
2210
+ f"aws organizations enable-all-features\n\n"
2211
+ f"Note: This requires approval from all member accounts if using consolidated billing only."
2212
+ )
2213
+
2214
+ return ComplianceResult(
2215
+ resource_id=resource_id,
2216
+ resource_type="AWS::::Account",
2217
+ compliance_status=compliance_status,
2218
+ evaluation_reason=evaluation_reason,
2219
+ config_rule_name=self.rule_name,
2220
+ region=region
2221
+ )
2222
+
2223
+ # List SCPs to verify custom policies exist (beyond default FullAWSAccess)
2224
+ policies_response = aws_factory.aws_api_call_with_retry(
2225
+ lambda: org_client.list_policies(Filter='SERVICE_CONTROL_POLICY')
2226
+ )
2227
+
2228
+ policies = policies_response.get('Policies', [])
2229
+
2230
+ # Filter out the default FullAWSAccess policy
2231
+ custom_policies = [p for p in policies if p.get('Name') != 'FullAWSAccess']
2232
+
2233
+ if custom_policies:
2234
+ compliance_status = ComplianceStatus.COMPLIANT
2235
+ policy_names = [p.get('Name', 'unknown') for p in custom_policies]
2236
+ evaluation_reason = (
2237
+ f"Service Control Policies are enabled with {len(custom_policies)} custom policy/policies: "
2238
+ f"{', '.join(policy_names)}"
2239
+ )
2240
+ else:
2241
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2242
+ evaluation_reason = (
2243
+ f"Service Control Policies are enabled but no custom SCPs are in use. "
2244
+ f"Only the default FullAWSAccess policy exists.\n\n"
2245
+ f"Create custom SCPs to enforce organizational policies:\n"
2246
+ f"1. Go to AWS Organizations console > Policies > Service control policies\n"
2247
+ f"2. Create custom SCP\n"
2248
+ f"3. Attach SCP to OUs or accounts\n\n"
2249
+ f"AWS CLI example:\n"
2250
+ f"# Create custom SCP\n"
2251
+ f"aws organizations create-policy --name DenyRootUser --type SERVICE_CONTROL_POLICY --content file://scp.json\n\n"
2252
+ f"# Attach SCP to OU\n"
2253
+ f"aws organizations attach-policy --policy-id <policy-id> --target-id <ou-id>\n\n"
2254
+ f"Common SCP use cases:\n"
2255
+ f"- Deny access to specific regions\n"
2256
+ f"- Deny root user actions\n"
2257
+ f"- Require MFA for sensitive operations\n"
2258
+ f"- Prevent disabling security services\n"
2259
+ f"- Enforce tagging requirements"
2260
+ )
2261
+
2262
+ except ClientError as e:
2263
+ error_code = e.response.get('Error', {}).get('Code', '')
2264
+
2265
+ if error_code == 'AWSOrganizationsNotInUseException':
2266
+ compliance_status = ComplianceStatus.NOT_APPLICABLE
2267
+ evaluation_reason = "AWS Organizations is not enabled for this account"
2268
+ elif error_code in ['AccessDenied']:
2269
+ compliance_status = ComplianceStatus.ERROR
2270
+ evaluation_reason = (
2271
+ f"Insufficient permissions to evaluate Organizations. "
2272
+ f"Required permissions: organizations:DescribeOrganization, organizations:ListPolicies"
2273
+ )
2274
+ else:
2275
+ compliance_status = ComplianceStatus.ERROR
2276
+ evaluation_reason = f"Error evaluating Organizations: {str(e)}"
2277
+
2278
+ except Exception as e:
2279
+ compliance_status = ComplianceStatus.ERROR
2280
+ evaluation_reason = f"Unexpected error evaluating Organizations: {str(e)}"
2281
+
2282
+ return ComplianceResult(
2283
+ resource_id=resource_id,
2284
+ resource_type="AWS::::Account",
2285
+ compliance_status=compliance_status,
2286
+ evaluation_reason=evaluation_reason,
2287
+ config_rule_name=self.rule_name,
2288
+ region=region
2289
+ )
2290
+
2291
+
2292
+
2293
+ class CognitoUserPoolMFAEnabledAssessment(BaseConfigRuleAssessment):
2294
+ """Assessment for cognito-user-pool-mfa-enabled AWS Config rule.
2295
+
2296
+ Validates that Cognito user pools have MFA enabled to provide an additional
2297
+ layer of security for user authentication.
2298
+
2299
+ This is a regional service assessment that runs in all active regions.
2300
+ """
2301
+
2302
+ def __init__(self):
2303
+ super().__init__(
2304
+ rule_name="cognito-user-pool-mfa-enabled",
2305
+ control_id="6.4",
2306
+ resource_types=["AWS::Cognito::UserPool"]
2307
+ )
2308
+
2309
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
2310
+ """Get Cognito user pools.
2311
+
2312
+ Cognito user pools are regional resources, so we query in each active region.
2313
+
2314
+ Args:
2315
+ aws_factory: AWS client factory for API access
2316
+ resource_type: AWS resource type (should be AWS::Cognito::UserPool)
2317
+ region: AWS region
2318
+
2319
+ Returns:
2320
+ List of Cognito user pool dictionaries
2321
+ """
2322
+ if resource_type != "AWS::Cognito::UserPool":
2323
+ return []
2324
+
2325
+ try:
2326
+ cognito_client = aws_factory.get_client('cognito-idp', region)
2327
+
2328
+ # List all user pools with pagination support
2329
+ user_pools = []
2330
+ next_token = None
2331
+
2332
+ while True:
2333
+ if next_token:
2334
+ response = aws_factory.aws_api_call_with_retry(
2335
+ lambda: cognito_client.list_user_pools(MaxResults=60, NextToken=next_token)
2336
+ )
2337
+ else:
2338
+ response = aws_factory.aws_api_call_with_retry(
2339
+ lambda: cognito_client.list_user_pools(MaxResults=60)
2340
+ )
2341
+
2342
+ user_pools.extend(response.get('UserPools', []))
2343
+
2344
+ # Check if there are more results
2345
+ next_token = response.get('NextToken')
2346
+ if not next_token:
2347
+ break
2348
+
2349
+ logger.debug(f"Found {len(user_pools)} Cognito user pools in {region}")
2350
+ return user_pools
2351
+
2352
+ except ClientError as e:
2353
+ error_code = e.response.get('Error', {}).get('Code', '')
2354
+
2355
+ if error_code in ['AccessDenied']:
2356
+ logger.warning(f"Insufficient permissions to list Cognito user pools in {region}: {e}")
2357
+ return []
2358
+ else:
2359
+ logger.error(f"Error retrieving Cognito user pools in {region}: {e}")
2360
+ raise
2361
+
2362
+
2363
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
2364
+ """Evaluate if Cognito user pool has MFA enabled.
2365
+
2366
+ Args:
2367
+ resource: Cognito user pool resource dictionary
2368
+ aws_factory: AWS client factory for additional API calls
2369
+ region: AWS region
2370
+
2371
+ Returns:
2372
+ ComplianceResult indicating whether the user pool has MFA enabled
2373
+ """
2374
+ user_pool_id = resource.get('Id', 'unknown')
2375
+ user_pool_name = resource.get('Name', 'unknown')
2376
+
2377
+ try:
2378
+ cognito_client = aws_factory.get_client('cognito-idp', region)
2379
+
2380
+ # Get detailed user pool configuration including MFA settings
2381
+ pool_response = aws_factory.aws_api_call_with_retry(
2382
+ lambda: cognito_client.describe_user_pool(UserPoolId=user_pool_id)
2383
+ )
2384
+
2385
+ user_pool = pool_response.get('UserPool', {})
2386
+ mfa_configuration = user_pool.get('MfaConfiguration', 'OFF')
2387
+
2388
+ # MFA is compliant if set to 'ON' or 'OPTIONAL'
2389
+ if mfa_configuration in ['ON', 'OPTIONAL']:
2390
+ compliance_status = ComplianceStatus.COMPLIANT
2391
+ evaluation_reason = (
2392
+ f"Cognito user pool {user_pool_name} (ID: {user_pool_id}) has MFA configured as '{mfa_configuration}'"
2393
+ )
2394
+ else:
2395
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2396
+ evaluation_reason = (
2397
+ f"Cognito user pool {user_pool_name} (ID: {user_pool_id}) has MFA disabled (configuration: '{mfa_configuration}'). "
2398
+ f"Enable MFA to provide additional security for user authentication.\n\n"
2399
+ f"Enable MFA for Cognito user pools:\n"
2400
+ f"1. Go to Cognito console > User pools\n"
2401
+ f"2. Select the user pool '{user_pool_name}'\n"
2402
+ f"3. Sign-in experience tab > Multi-factor authentication\n"
2403
+ f"4. Configure MFA:\n"
2404
+ f" - Required: All users must use MFA\n"
2405
+ f" - Optional: Users can choose to enable MFA\n"
2406
+ f"5. Choose MFA methods:\n"
2407
+ f" - SMS text message\n"
2408
+ f" - Time-based one-time password (TOTP)\n"
2409
+ f" - Both\n"
2410
+ f"6. Save changes\n\n"
2411
+ f"AWS CLI example:\n"
2412
+ f"aws cognito-idp set-user-pool-mfa-config \\\n"
2413
+ f" --user-pool-id {user_pool_id} \\\n"
2414
+ f" --mfa-configuration ON \\\n"
2415
+ f" --software-token-mfa-configuration Enabled=true \\\n"
2416
+ f" --region {region}\n\n"
2417
+ f"Best practices:\n"
2418
+ f"- Use 'Required' for sensitive applications\n"
2419
+ f"- Support both SMS and TOTP for flexibility\n"
2420
+ f"- Test MFA flow before enforcing"
2421
+ )
2422
+
2423
+ except ClientError as e:
2424
+ error_code = e.response.get('Error', {}).get('Code', '')
2425
+
2426
+ if error_code == 'ResourceNotFoundException':
2427
+ compliance_status = ComplianceStatus.ERROR
2428
+ evaluation_reason = f"Cognito user pool {user_pool_id} not found (may have been deleted)"
2429
+ elif error_code in ['AccessDenied']:
2430
+ compliance_status = ComplianceStatus.ERROR
2431
+ evaluation_reason = (
2432
+ f"Insufficient permissions to evaluate Cognito user pool {user_pool_id}. "
2433
+ f"Required permissions: cognito-idp:DescribeUserPool"
2434
+ )
2435
+ else:
2436
+ compliance_status = ComplianceStatus.ERROR
2437
+ evaluation_reason = f"Error evaluating Cognito user pool {user_pool_id}: {str(e)}"
2438
+
2439
+ except Exception as e:
2440
+ compliance_status = ComplianceStatus.ERROR
2441
+ evaluation_reason = f"Unexpected error evaluating Cognito user pool {user_pool_id}: {str(e)}"
2442
+
2443
+ return ComplianceResult(
2444
+ resource_id=user_pool_id,
2445
+ resource_type="AWS::Cognito::UserPool",
2446
+ compliance_status=compliance_status,
2447
+ evaluation_reason=evaluation_reason,
2448
+ config_rule_name=self.rule_name,
2449
+ region=region
2450
+ )
2451
+
2452
+
2453
+
2454
+ class VPNConnectionMFAEnabledAssessment(BaseConfigRuleAssessment):
2455
+ """Assessment for vpn-connection-mfa-enabled AWS Config rule.
2456
+
2457
+ Ensures Client VPN endpoints require MFA authentication to provide an additional
2458
+ layer of security for VPN access.
2459
+
2460
+ This is a regional service assessment that runs in all active regions.
2461
+ """
2462
+
2463
+ def __init__(self):
2464
+ super().__init__(
2465
+ rule_name="vpn-connection-mfa-enabled",
2466
+ control_id="6.5",
2467
+ resource_types=["AWS::EC2::ClientVpnEndpoint"]
2468
+ )
2469
+
2470
+ def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
2471
+ """Get Client VPN endpoints.
2472
+
2473
+ Client VPN endpoints are regional resources, so we query in each active region.
2474
+
2475
+ Args:
2476
+ aws_factory: AWS client factory for API access
2477
+ resource_type: AWS resource type (should be AWS::EC2::ClientVpnEndpoint)
2478
+ region: AWS region
2479
+
2480
+ Returns:
2481
+ List of Client VPN endpoint dictionaries
2482
+ """
2483
+ if resource_type != "AWS::EC2::ClientVpnEndpoint":
2484
+ return []
2485
+
2486
+ try:
2487
+ ec2_client = aws_factory.get_client('ec2', region)
2488
+
2489
+ # List all Client VPN endpoints with pagination support
2490
+ vpn_endpoints = []
2491
+ next_token = None
2492
+
2493
+ while True:
2494
+ if next_token:
2495
+ response = aws_factory.aws_api_call_with_retry(
2496
+ lambda: ec2_client.describe_client_vpn_endpoints(NextToken=next_token)
2497
+ )
2498
+ else:
2499
+ response = aws_factory.aws_api_call_with_retry(
2500
+ lambda: ec2_client.describe_client_vpn_endpoints()
2501
+ )
2502
+
2503
+ vpn_endpoints.extend(response.get('ClientVpnEndpoints', []))
2504
+
2505
+ # Check if there are more results
2506
+ next_token = response.get('NextToken')
2507
+ if not next_token:
2508
+ break
2509
+
2510
+ logger.debug(f"Found {len(vpn_endpoints)} Client VPN endpoints in {region}")
2511
+ return vpn_endpoints
2512
+
2513
+ except ClientError as e:
2514
+ error_code = e.response.get('Error', {}).get('Code', '')
2515
+
2516
+ if error_code in ['UnauthorizedOperation', 'AccessDenied']:
2517
+ logger.warning(f"Insufficient permissions to list Client VPN endpoints in {region}: {e}")
2518
+ return []
2519
+ else:
2520
+ logger.error(f"Error retrieving Client VPN endpoints in {region}: {e}")
2521
+ raise
2522
+
2523
+
2524
+ def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
2525
+ """Evaluate if Client VPN endpoint requires MFA authentication.
2526
+
2527
+ Args:
2528
+ resource: Client VPN endpoint resource dictionary
2529
+ aws_factory: AWS client factory for additional API calls
2530
+ region: AWS region
2531
+
2532
+ Returns:
2533
+ ComplianceResult indicating whether the VPN endpoint requires MFA
2534
+ """
2535
+ endpoint_id = resource.get('ClientVpnEndpointId', 'unknown')
2536
+ status = resource.get('Status', {}).get('Code', 'unknown')
2537
+
2538
+ try:
2539
+ # Get authentication options
2540
+ auth_options = resource.get('AuthenticationOptions', [])
2541
+
2542
+ # Check if any authentication option requires MFA
2543
+ has_mfa = False
2544
+ auth_details = []
2545
+
2546
+ for auth_option in auth_options:
2547
+ auth_type = auth_option.get('Type', '')
2548
+ auth_details.append(auth_type)
2549
+
2550
+ # Check for directory-service-authentication (can have MFA through AD)
2551
+ if auth_type == 'directory-service-authentication':
2552
+ # Directory service authentication can enforce MFA through Active Directory
2553
+ # We consider this compliant if configured
2554
+ directory_id = auth_option.get('ActiveDirectory', {}).get('DirectoryId')
2555
+ if directory_id:
2556
+ has_mfa = True
2557
+ break
2558
+
2559
+ # Check for federated-authentication (can have MFA through SAML IdP)
2560
+ elif auth_type == 'federated-authentication':
2561
+ # Federated authentication can enforce MFA through the identity provider
2562
+ # We consider this compliant if configured
2563
+ saml_provider_arn = auth_option.get('FederatedAuthentication', {}).get('SAMLProviderArn')
2564
+ if saml_provider_arn:
2565
+ has_mfa = True
2566
+ break
2567
+
2568
+ # certificate-authentication alone doesn't provide MFA
2569
+ # (certificate is "something you have", but MFA needs a second factor)
2570
+
2571
+ if has_mfa:
2572
+ compliance_status = ComplianceStatus.COMPLIANT
2573
+ evaluation_reason = (
2574
+ f"Client VPN endpoint {endpoint_id} has MFA-capable authentication configured: {', '.join(auth_details)}"
2575
+ )
2576
+ else:
2577
+ compliance_status = ComplianceStatus.NON_COMPLIANT
2578
+ evaluation_reason = (
2579
+ f"Client VPN endpoint {endpoint_id} does not have MFA-capable authentication configured. "
2580
+ f"Current authentication: {', '.join(auth_details) if auth_details else 'None'}. "
2581
+ f"Enable MFA to provide additional security for VPN access.\n\n"
2582
+ f"Enable MFA for Client VPN endpoints:\n\n"
2583
+ f"For Active Directory authentication:\n"
2584
+ f"1. Go to VPC console > Client VPN Endpoints\n"
2585
+ f"2. Select the endpoint (ID: {endpoint_id})\n"
2586
+ f"3. Modify authentication\n"
2587
+ f"4. Enable MFA in Active Directory configuration\n"
2588
+ f"5. Apply changes\n\n"
2589
+ f"For SAML-based authentication:\n"
2590
+ f"1. Configure MFA in your identity provider (IdP)\n"
2591
+ f"2. Update SAML assertion to include MFA claim\n"
2592
+ f"3. Client VPN will enforce MFA through IdP\n\n"
2593
+ f"AWS CLI example (create with AD authentication):\n"
2594
+ f"aws ec2 create-client-vpn-endpoint \\\n"
2595
+ f" --client-cidr-block 10.0.0.0/16 \\\n"
2596
+ f" --server-certificate-arn <cert-arn> \\\n"
2597
+ f" --authentication-options Type=directory-service-authentication,ActiveDirectory={{DirectoryId=<dir-id>}} \\\n"
2598
+ f" --connection-log-options Enabled=true,CloudwatchLogGroup=<log-group> \\\n"
2599
+ f" --region {region}\n\n"
2600
+ f"Note: MFA enforcement depends on the authentication method:\n"
2601
+ f"- Active Directory: Configure MFA in AD\n"
2602
+ f"- SAML: Configure MFA in IdP\n"
2603
+ f"- Mutual authentication: Use certificate + additional factor\n\n"
2604
+ f"Best practices:\n"
2605
+ f"- Always require MFA for VPN access\n"
2606
+ f"- Use strong MFA methods (TOTP, hardware tokens)\n"
2607
+ f"- Monitor VPN connection logs\n"
2608
+ f"- Regularly review VPN access"
2609
+ )
2610
+
2611
+ except ClientError as e:
2612
+ error_code = e.response.get('Error', {}).get('Code', '')
2613
+
2614
+ if error_code == 'InvalidClientVpnEndpointId.NotFound':
2615
+ compliance_status = ComplianceStatus.ERROR
2616
+ evaluation_reason = f"Client VPN endpoint {endpoint_id} not found (may have been deleted)"
2617
+ elif error_code in ['UnauthorizedOperation', 'AccessDenied']:
2618
+ compliance_status = ComplianceStatus.ERROR
2619
+ evaluation_reason = (
2620
+ f"Insufficient permissions to evaluate Client VPN endpoint {endpoint_id}. "
2621
+ f"Required permissions: ec2:DescribeClientVpnEndpoints"
2622
+ )
2623
+ else:
2624
+ compliance_status = ComplianceStatus.ERROR
2625
+ evaluation_reason = f"Error evaluating Client VPN endpoint {endpoint_id}: {str(e)}"
2626
+
2627
+ except Exception as e:
2628
+ compliance_status = ComplianceStatus.ERROR
2629
+ evaluation_reason = f"Unexpected error evaluating Client VPN endpoint {endpoint_id}: {str(e)}"
2630
+
2631
+ return ComplianceResult(
2632
+ resource_id=endpoint_id,
2633
+ resource_type="AWS::EC2::ClientVpnEndpoint",
2634
+ compliance_status=compliance_status,
2635
+ evaluation_reason=evaluation_reason,
2636
+ config_rule_name=self.rule_name,
2637
+ region=region
2638
+ )