aws-cis-controls-assessment 1.0.10__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_cis_assessment/__init__.py +2 -2
- aws_cis_assessment/config/rules/cis_controls_ig1.yaml +1 -1
- aws_cis_assessment/config/rules/cis_controls_ig2.yaml +599 -2
- aws_cis_assessment/controls/ig2/__init__.py +62 -1
- aws_cis_assessment/controls/ig2/control_4_5_6_access_configuration.py +2638 -0
- aws_cis_assessment/controls/ig2/control_8_audit_logging.py +984 -0
- aws_cis_assessment/core/assessment_engine.py +54 -0
- aws_cis_assessment/reporters/html_reporter.py +197 -35
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/METADATA +160 -52
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/RECORD +16 -14
- docs/cli-reference.md +1 -1
- docs/config-rule-mappings.md +423 -6
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/WHEEL +0 -0
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/entry_points.txt +0 -0
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {aws_cis_controls_assessment-1.0.10.dist-info → aws_cis_controls_assessment-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
)
|