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,984 @@
|
|
|
1
|
+
"""Control 8.2: Collect Audit Logs - Audit logging assessments for Phase 1.
|
|
2
|
+
|
|
3
|
+
This module implements 7 critical audit logging assessment classes for CIS Control 8
|
|
4
|
+
(Audit Log Management). These assessments evaluate AWS resources for comprehensive
|
|
5
|
+
audit logging compliance across multiple services:
|
|
6
|
+
|
|
7
|
+
1. Route53QueryLoggingAssessment - Validates DNS query logging for Route 53 hosted zones
|
|
8
|
+
2. ALBAccessLogsEnabledAssessment - Ensures Application Load Balancers have access logging
|
|
9
|
+
3. CloudFrontAccessLogsEnabledAssessment - Validates CloudFront distribution access logging
|
|
10
|
+
4. CloudWatchLogRetentionCheckAssessment - Ensures CloudWatch log groups have appropriate retention
|
|
11
|
+
5. CloudTrailInsightsEnabledAssessment - Validates CloudTrail Insights for anomaly detection
|
|
12
|
+
6. ConfigRecordingAllResourcesAssessment - Ensures AWS Config records all resource types
|
|
13
|
+
7. WAFLoggingEnabledAssessment - Validates WAF web ACL logging configuration
|
|
14
|
+
|
|
15
|
+
These rules address the highest priority compliance gap identified in the CIS Controls
|
|
16
|
+
Gap Analysis and increase the total rule count from 142 to 149.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Dict, List, Any
|
|
20
|
+
import logging
|
|
21
|
+
from botocore.exceptions import ClientError
|
|
22
|
+
|
|
23
|
+
from aws_cis_assessment.controls.base_control import BaseConfigRuleAssessment
|
|
24
|
+
from aws_cis_assessment.core.models import ComplianceResult, ComplianceStatus
|
|
25
|
+
from aws_cis_assessment.core.aws_client_factory import AWSClientFactory
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# 1. Route 53 Query Logging Assessment
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
34
|
+
class Route53QueryLoggingAssessment(BaseConfigRuleAssessment):
|
|
35
|
+
"""Assessment for route53-query-logging-enabled AWS Config rule.
|
|
36
|
+
|
|
37
|
+
Validates that Route 53 hosted zones have query logging enabled to track
|
|
38
|
+
DNS queries for security investigations and compliance.
|
|
39
|
+
|
|
40
|
+
This is a global service assessment that only runs in us-east-1.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
super().__init__(
|
|
45
|
+
rule_name="route53-query-logging-enabled",
|
|
46
|
+
control_id="8.2",
|
|
47
|
+
resource_types=["AWS::Route53::HostedZone"]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
51
|
+
"""Get Route 53 hosted zones.
|
|
52
|
+
|
|
53
|
+
Route 53 is a global service, so we only query in us-east-1.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
aws_factory: AWS client factory for API access
|
|
57
|
+
resource_type: AWS resource type (should be AWS::Route53::HostedZone)
|
|
58
|
+
region: AWS region (should be us-east-1 for Route 53)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of hosted zone dictionaries with Id, Name, Config
|
|
62
|
+
"""
|
|
63
|
+
if resource_type != "AWS::Route53::HostedZone":
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
# Route 53 is a global service - only evaluate in us-east-1
|
|
67
|
+
if region != 'us-east-1':
|
|
68
|
+
logger.debug(f"Skipping Route 53 evaluation in {region} - global service evaluated in us-east-1 only")
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
route53_client = aws_factory.get_client('route53', region)
|
|
73
|
+
|
|
74
|
+
# List all hosted zones with pagination support
|
|
75
|
+
hosted_zones = []
|
|
76
|
+
marker = None
|
|
77
|
+
|
|
78
|
+
while True:
|
|
79
|
+
if marker:
|
|
80
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
81
|
+
lambda: route53_client.list_hosted_zones(Marker=marker)
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
85
|
+
lambda: route53_client.list_hosted_zones()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
hosted_zones.extend(response.get('HostedZones', []))
|
|
89
|
+
|
|
90
|
+
# Check if there are more results
|
|
91
|
+
if response.get('IsTruncated', False):
|
|
92
|
+
marker = response.get('NextMarker')
|
|
93
|
+
else:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Found {len(hosted_zones)} Route 53 hosted zones")
|
|
97
|
+
return hosted_zones
|
|
98
|
+
|
|
99
|
+
except ClientError as e:
|
|
100
|
+
logger.error(f"Error retrieving Route 53 hosted zones: {e}")
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
104
|
+
"""Evaluate if Route 53 hosted zone has query logging enabled.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
resource: Hosted zone resource dictionary
|
|
108
|
+
aws_factory: AWS client factory for additional API calls
|
|
109
|
+
region: AWS region
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
ComplianceResult indicating whether query logging is enabled
|
|
113
|
+
"""
|
|
114
|
+
hosted_zone_id = resource.get('Id', 'unknown')
|
|
115
|
+
hosted_zone_name = resource.get('Name', 'unknown')
|
|
116
|
+
|
|
117
|
+
# Extract just the zone ID (remove /hostedzone/ prefix if present)
|
|
118
|
+
if hosted_zone_id.startswith('/hostedzone/'):
|
|
119
|
+
zone_id = hosted_zone_id.split('/')[-1]
|
|
120
|
+
else:
|
|
121
|
+
zone_id = hosted_zone_id
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
route53_client = aws_factory.get_client('route53', region)
|
|
125
|
+
|
|
126
|
+
# Check if query logging is configured for this hosted zone
|
|
127
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
128
|
+
lambda: route53_client.list_query_logging_configs(HostedZoneId=zone_id)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
query_logging_configs = response.get('QueryLoggingConfigs', [])
|
|
132
|
+
|
|
133
|
+
if query_logging_configs:
|
|
134
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
135
|
+
evaluation_reason = f"Route 53 hosted zone {hosted_zone_name} ({zone_id}) has query logging enabled"
|
|
136
|
+
else:
|
|
137
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
138
|
+
evaluation_reason = (
|
|
139
|
+
f"Route 53 hosted zone {hosted_zone_name} ({zone_id}) does not have query logging enabled. "
|
|
140
|
+
f"Enable Route 53 query logging for this hosted zone. Create a CloudWatch Logs log group "
|
|
141
|
+
f"and configure query logging using the AWS Console, CLI, or API. Query logging helps track "
|
|
142
|
+
f"DNS queries for security investigations and compliance."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
except ClientError as e:
|
|
146
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
147
|
+
|
|
148
|
+
if error_code == 'AccessDenied':
|
|
149
|
+
compliance_status = ComplianceStatus.ERROR
|
|
150
|
+
evaluation_reason = f"Insufficient permissions to check query logging for hosted zone {zone_id}: {str(e)}"
|
|
151
|
+
elif error_code == 'NoSuchHostedZone':
|
|
152
|
+
compliance_status = ComplianceStatus.ERROR
|
|
153
|
+
evaluation_reason = f"Hosted zone {zone_id} not found (may have been deleted)"
|
|
154
|
+
else:
|
|
155
|
+
compliance_status = ComplianceStatus.ERROR
|
|
156
|
+
evaluation_reason = f"Error checking query logging for hosted zone {zone_id}: {str(e)}"
|
|
157
|
+
|
|
158
|
+
return ComplianceResult(
|
|
159
|
+
resource_id=zone_id,
|
|
160
|
+
resource_type="AWS::Route53::HostedZone",
|
|
161
|
+
compliance_status=compliance_status,
|
|
162
|
+
evaluation_reason=evaluation_reason,
|
|
163
|
+
config_rule_name=self.rule_name,
|
|
164
|
+
region=region
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ============================================================================
|
|
169
|
+
# 2. Application Load Balancer Access Logs Assessment
|
|
170
|
+
# ============================================================================
|
|
171
|
+
|
|
172
|
+
class ALBAccessLogsEnabledAssessment(BaseConfigRuleAssessment):
|
|
173
|
+
"""Assessment for alb-access-logs-enabled AWS Config rule.
|
|
174
|
+
|
|
175
|
+
Ensures Application Load Balancers have access logging enabled to analyze
|
|
176
|
+
traffic patterns and investigate security incidents.
|
|
177
|
+
|
|
178
|
+
This is a regional service assessment that runs in all active regions.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self):
|
|
182
|
+
super().__init__(
|
|
183
|
+
rule_name="alb-access-logs-enabled",
|
|
184
|
+
control_id="8.2",
|
|
185
|
+
resource_types=["AWS::ElasticLoadBalancingV2::LoadBalancer"]
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
189
|
+
"""Get Application Load Balancers in the specified region.
|
|
190
|
+
|
|
191
|
+
Filters for Type='application' to exclude Network Load Balancers and Gateway Load Balancers.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
aws_factory: AWS client factory for API access
|
|
195
|
+
resource_type: AWS resource type (should be AWS::ElasticLoadBalancingV2::LoadBalancer)
|
|
196
|
+
region: AWS region to query
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of ALB dictionaries with LoadBalancerArn, LoadBalancerName, Type
|
|
200
|
+
"""
|
|
201
|
+
if resource_type != "AWS::ElasticLoadBalancingV2::LoadBalancer":
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
elbv2_client = aws_factory.get_client('elbv2', region)
|
|
206
|
+
|
|
207
|
+
# List all load balancers with pagination support
|
|
208
|
+
load_balancers = []
|
|
209
|
+
marker = None
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
if marker:
|
|
213
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
214
|
+
lambda: elbv2_client.describe_load_balancers(Marker=marker)
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
218
|
+
lambda: elbv2_client.describe_load_balancers()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Filter for Application Load Balancers only (exclude NLB and Gateway LB)
|
|
222
|
+
albs = [lb for lb in response.get('LoadBalancers', []) if lb.get('Type') == 'application']
|
|
223
|
+
load_balancers.extend(albs)
|
|
224
|
+
|
|
225
|
+
# Check if there are more results
|
|
226
|
+
if 'NextMarker' in response:
|
|
227
|
+
marker = response['NextMarker']
|
|
228
|
+
else:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
logger.debug(f"Found {len(load_balancers)} Application Load Balancers in {region}")
|
|
232
|
+
return load_balancers
|
|
233
|
+
|
|
234
|
+
except ClientError as e:
|
|
235
|
+
logger.error(f"Error retrieving Application Load Balancers in {region}: {e}")
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
239
|
+
"""Evaluate if Application Load Balancer has access logging enabled.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
resource: Load balancer resource dictionary
|
|
243
|
+
aws_factory: AWS client factory for additional API calls
|
|
244
|
+
region: AWS region
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
ComplianceResult indicating whether access logging is enabled
|
|
248
|
+
"""
|
|
249
|
+
lb_arn = resource.get('LoadBalancerArn', 'unknown')
|
|
250
|
+
lb_name = resource.get('LoadBalancerName', 'unknown')
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
elbv2_client = aws_factory.get_client('elbv2', region)
|
|
254
|
+
|
|
255
|
+
# Get load balancer attributes
|
|
256
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
257
|
+
lambda: elbv2_client.describe_load_balancer_attributes(LoadBalancerArn=lb_arn)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
attributes = response.get('Attributes', [])
|
|
261
|
+
|
|
262
|
+
# Find the access_logs.s3.enabled attribute
|
|
263
|
+
access_logs_enabled = False
|
|
264
|
+
for attr in attributes:
|
|
265
|
+
if attr.get('Key') == 'access_logs.s3.enabled':
|
|
266
|
+
access_logs_enabled = attr.get('Value', 'false').lower() == 'true'
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
if access_logs_enabled:
|
|
270
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
271
|
+
evaluation_reason = f"Application Load Balancer {lb_name} has access logging enabled"
|
|
272
|
+
else:
|
|
273
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
274
|
+
evaluation_reason = (
|
|
275
|
+
f"Application Load Balancer {lb_name} does not have access logging enabled. "
|
|
276
|
+
f"Enable access logging for this Application Load Balancer. Configure an S3 bucket "
|
|
277
|
+
f"to store access logs using the AWS Console, CLI, or API. Access logs help analyze "
|
|
278
|
+
f"traffic patterns and investigate security incidents."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
except ClientError as e:
|
|
282
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
283
|
+
|
|
284
|
+
if error_code == 'AccessDenied':
|
|
285
|
+
compliance_status = ComplianceStatus.ERROR
|
|
286
|
+
evaluation_reason = f"Insufficient permissions to check access logging for ALB {lb_name}: {str(e)}"
|
|
287
|
+
elif error_code == 'LoadBalancerNotFound':
|
|
288
|
+
compliance_status = ComplianceStatus.ERROR
|
|
289
|
+
evaluation_reason = f"Load balancer {lb_name} not found (may have been deleted)"
|
|
290
|
+
else:
|
|
291
|
+
compliance_status = ComplianceStatus.ERROR
|
|
292
|
+
evaluation_reason = f"Error checking access logging for ALB {lb_name}: {str(e)}"
|
|
293
|
+
|
|
294
|
+
return ComplianceResult(
|
|
295
|
+
resource_id=lb_arn,
|
|
296
|
+
resource_type="AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
297
|
+
compliance_status=compliance_status,
|
|
298
|
+
evaluation_reason=evaluation_reason,
|
|
299
|
+
config_rule_name=self.rule_name,
|
|
300
|
+
region=region
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ============================================================================
|
|
305
|
+
# 3. CloudFront Access Logs Assessment
|
|
306
|
+
# ============================================================================
|
|
307
|
+
|
|
308
|
+
class CloudFrontAccessLogsEnabledAssessment(BaseConfigRuleAssessment):
|
|
309
|
+
"""Assessment for cloudfront-access-logs-enabled AWS Config rule.
|
|
310
|
+
|
|
311
|
+
Validates that CloudFront distributions have access logging enabled to track
|
|
312
|
+
content delivery requests and detect anomalous access patterns.
|
|
313
|
+
|
|
314
|
+
This is a global service assessment that only runs in us-east-1.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def __init__(self):
|
|
318
|
+
super().__init__(
|
|
319
|
+
rule_name="cloudfront-access-logs-enabled",
|
|
320
|
+
control_id="8.2",
|
|
321
|
+
resource_types=["AWS::CloudFront::Distribution"]
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
325
|
+
"""Get CloudFront distributions.
|
|
326
|
+
|
|
327
|
+
CloudFront is a global service, so we only query in us-east-1.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
aws_factory: AWS client factory for API access
|
|
331
|
+
resource_type: AWS resource type (should be AWS::CloudFront::Distribution)
|
|
332
|
+
region: AWS region (should be us-east-1 for CloudFront)
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of distribution dictionaries with Id, ARN, Status, DomainName
|
|
336
|
+
"""
|
|
337
|
+
if resource_type != "AWS::CloudFront::Distribution":
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
# CloudFront is a global service - only evaluate in us-east-1
|
|
341
|
+
if region != 'us-east-1':
|
|
342
|
+
logger.debug(f"Skipping CloudFront evaluation in {region} - global service evaluated in us-east-1 only")
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
cloudfront_client = aws_factory.get_client('cloudfront', region)
|
|
347
|
+
|
|
348
|
+
# List all distributions with pagination support
|
|
349
|
+
distributions = []
|
|
350
|
+
marker = None
|
|
351
|
+
|
|
352
|
+
while True:
|
|
353
|
+
if marker:
|
|
354
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
355
|
+
lambda: cloudfront_client.list_distributions(Marker=marker)
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
359
|
+
lambda: cloudfront_client.list_distributions()
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
distribution_list = response.get('DistributionList', {})
|
|
363
|
+
items = distribution_list.get('Items', [])
|
|
364
|
+
distributions.extend(items)
|
|
365
|
+
|
|
366
|
+
# Check if there are more results
|
|
367
|
+
if distribution_list.get('IsTruncated', False):
|
|
368
|
+
marker = distribution_list.get('NextMarker')
|
|
369
|
+
else:
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
logger.debug(f"Found {len(distributions)} CloudFront distributions")
|
|
373
|
+
return distributions
|
|
374
|
+
|
|
375
|
+
except ClientError as e:
|
|
376
|
+
logger.error(f"Error retrieving CloudFront distributions: {e}")
|
|
377
|
+
raise
|
|
378
|
+
|
|
379
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
380
|
+
"""Evaluate if CloudFront distribution has access logging enabled.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
resource: Distribution resource dictionary
|
|
384
|
+
aws_factory: AWS client factory for additional API calls
|
|
385
|
+
region: AWS region
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
ComplianceResult indicating whether access logging is enabled
|
|
389
|
+
"""
|
|
390
|
+
distribution_id = resource.get('Id', 'unknown')
|
|
391
|
+
distribution_domain = resource.get('DomainName', 'unknown')
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
cloudfront_client = aws_factory.get_client('cloudfront', region)
|
|
395
|
+
|
|
396
|
+
# Get distribution configuration
|
|
397
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
398
|
+
lambda: cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
distribution_config = response.get('DistributionConfig', {})
|
|
402
|
+
logging_config = distribution_config.get('Logging', {})
|
|
403
|
+
|
|
404
|
+
# Check if logging is enabled and bucket is configured
|
|
405
|
+
logging_enabled = logging_config.get('Enabled', False)
|
|
406
|
+
logging_bucket = logging_config.get('Bucket', '')
|
|
407
|
+
|
|
408
|
+
if logging_enabled and logging_bucket:
|
|
409
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
410
|
+
evaluation_reason = f"CloudFront distribution {distribution_id} ({distribution_domain}) has access logging enabled"
|
|
411
|
+
else:
|
|
412
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
413
|
+
if not logging_enabled:
|
|
414
|
+
evaluation_reason = (
|
|
415
|
+
f"CloudFront distribution {distribution_id} ({distribution_domain}) does not have access logging enabled. "
|
|
416
|
+
f"Enable access logging for this CloudFront distribution. Configure an S3 bucket to store access logs "
|
|
417
|
+
f"using the AWS Console, CLI, or API. Access logs help track content delivery requests and detect "
|
|
418
|
+
f"anomalous access patterns."
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
evaluation_reason = (
|
|
422
|
+
f"CloudFront distribution {distribution_id} ({distribution_domain}) has logging enabled but no S3 bucket configured. "
|
|
423
|
+
f"Configure an S3 bucket to store access logs using the AWS Console, CLI, or API."
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
except ClientError as e:
|
|
427
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
428
|
+
|
|
429
|
+
if error_code == 'AccessDenied':
|
|
430
|
+
compliance_status = ComplianceStatus.ERROR
|
|
431
|
+
evaluation_reason = f"Insufficient permissions to check access logging for CloudFront distribution {distribution_id}: {str(e)}"
|
|
432
|
+
elif error_code == 'NoSuchDistribution':
|
|
433
|
+
compliance_status = ComplianceStatus.ERROR
|
|
434
|
+
evaluation_reason = f"CloudFront distribution {distribution_id} not found (may have been deleted)"
|
|
435
|
+
else:
|
|
436
|
+
compliance_status = ComplianceStatus.ERROR
|
|
437
|
+
evaluation_reason = f"Error checking access logging for CloudFront distribution {distribution_id}: {str(e)}"
|
|
438
|
+
|
|
439
|
+
return ComplianceResult(
|
|
440
|
+
resource_id=distribution_id,
|
|
441
|
+
resource_type="AWS::CloudFront::Distribution",
|
|
442
|
+
compliance_status=compliance_status,
|
|
443
|
+
evaluation_reason=evaluation_reason,
|
|
444
|
+
config_rule_name=self.rule_name,
|
|
445
|
+
region=region
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ============================================================================
|
|
450
|
+
# 4. CloudWatch Log Retention Assessment
|
|
451
|
+
# ============================================================================
|
|
452
|
+
|
|
453
|
+
class CloudWatchLogRetentionCheckAssessment(BaseConfigRuleAssessment):
|
|
454
|
+
"""Assessment for cloudwatch-log-retention-check AWS Config rule.
|
|
455
|
+
|
|
456
|
+
Ensures CloudWatch log groups have appropriate retention periods so that logs
|
|
457
|
+
are retained long enough for compliance and investigation purposes.
|
|
458
|
+
|
|
459
|
+
This is a regional service assessment that runs in all active regions.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def __init__(self):
|
|
463
|
+
super().__init__(
|
|
464
|
+
rule_name="cloudwatch-log-retention-check",
|
|
465
|
+
control_id="8.2",
|
|
466
|
+
resource_types=["AWS::Logs::LogGroup"],
|
|
467
|
+
parameters={'minimumRetentionDays': 90}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
471
|
+
"""Get CloudWatch log groups in the specified region.
|
|
472
|
+
|
|
473
|
+
Uses pagination to handle large numbers of log groups.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
aws_factory: AWS client factory for API access
|
|
477
|
+
resource_type: AWS resource type (should be AWS::Logs::LogGroup)
|
|
478
|
+
region: AWS region to query
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
List of log group dictionaries with logGroupName, retentionInDays, creationTime, storedBytes
|
|
482
|
+
"""
|
|
483
|
+
if resource_type != "AWS::Logs::LogGroup":
|
|
484
|
+
return []
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
logs_client = aws_factory.get_client('logs', region)
|
|
488
|
+
|
|
489
|
+
# Use paginator for describe_log_groups to handle large result sets
|
|
490
|
+
paginator = logs_client.get_paginator('describe_log_groups')
|
|
491
|
+
|
|
492
|
+
log_groups = []
|
|
493
|
+
for page in paginator.paginate():
|
|
494
|
+
log_groups.extend(page.get('logGroups', []))
|
|
495
|
+
|
|
496
|
+
logger.debug(f"Found {len(log_groups)} CloudWatch log groups in {region}")
|
|
497
|
+
return log_groups
|
|
498
|
+
|
|
499
|
+
except ClientError as e:
|
|
500
|
+
logger.error(f"Error retrieving CloudWatch log groups in {region}: {e}")
|
|
501
|
+
raise
|
|
502
|
+
|
|
503
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
504
|
+
"""Evaluate if CloudWatch log group has appropriate retention period.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
resource: Log group resource dictionary
|
|
508
|
+
aws_factory: AWS client factory for additional API calls
|
|
509
|
+
region: AWS region
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
ComplianceResult indicating whether retention period is appropriate
|
|
513
|
+
"""
|
|
514
|
+
log_group_name = resource.get('logGroupName', 'unknown')
|
|
515
|
+
retention_in_days = resource.get('retentionInDays')
|
|
516
|
+
|
|
517
|
+
# Get minimum retention from parameters (default 90 days)
|
|
518
|
+
minimum_retention = self.parameters.get('minimumRetentionDays', 90)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
if retention_in_days is None:
|
|
522
|
+
# Indefinite retention (None) is considered non-compliant
|
|
523
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
524
|
+
evaluation_reason = (
|
|
525
|
+
f"CloudWatch log group {log_group_name} has indefinite retention (no retention period set). "
|
|
526
|
+
f"Set a retention period of at least {minimum_retention} days for this log group using the AWS Console, "
|
|
527
|
+
f"CLI, or API. Proper retention ensures logs are available for compliance and investigation while "
|
|
528
|
+
f"managing storage costs."
|
|
529
|
+
)
|
|
530
|
+
elif retention_in_days < minimum_retention:
|
|
531
|
+
# Retention less than minimum is non-compliant
|
|
532
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
533
|
+
evaluation_reason = (
|
|
534
|
+
f"CloudWatch log group {log_group_name} has retention period of {retention_in_days} days, "
|
|
535
|
+
f"which is less than the required {minimum_retention} days. "
|
|
536
|
+
f"Increase the retention period to at least {minimum_retention} days using the AWS Console, CLI, or API."
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
# Retention meets or exceeds minimum
|
|
540
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
541
|
+
evaluation_reason = f"CloudWatch log group {log_group_name} has retention period of {retention_in_days} days (>= {minimum_retention} days)"
|
|
542
|
+
|
|
543
|
+
except Exception as e:
|
|
544
|
+
compliance_status = ComplianceStatus.ERROR
|
|
545
|
+
evaluation_reason = f"Error evaluating retention for log group {log_group_name}: {str(e)}"
|
|
546
|
+
|
|
547
|
+
return ComplianceResult(
|
|
548
|
+
resource_id=log_group_name,
|
|
549
|
+
resource_type="AWS::Logs::LogGroup",
|
|
550
|
+
compliance_status=compliance_status,
|
|
551
|
+
evaluation_reason=evaluation_reason,
|
|
552
|
+
config_rule_name=self.rule_name,
|
|
553
|
+
region=region
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ============================================================================
|
|
558
|
+
# 5. CloudTrail Insights Assessment
|
|
559
|
+
# ============================================================================
|
|
560
|
+
|
|
561
|
+
class CloudTrailInsightsEnabledAssessment(BaseConfigRuleAssessment):
|
|
562
|
+
"""Assessment for cloudtrail-insights-enabled AWS Config rule.
|
|
563
|
+
|
|
564
|
+
Validates that CloudTrail Insights is enabled for anomaly detection so that
|
|
565
|
+
anomalous API activity can be automatically detected.
|
|
566
|
+
|
|
567
|
+
This is an account-level check that verifies at least one trail has Insights enabled.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(self):
|
|
571
|
+
super().__init__(
|
|
572
|
+
rule_name="cloudtrail-insights-enabled",
|
|
573
|
+
control_id="8.2",
|
|
574
|
+
resource_types=["AWS::CloudTrail::Trail"]
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
578
|
+
"""Get CloudTrail trails in the specified region.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
aws_factory: AWS client factory for API access
|
|
582
|
+
resource_type: AWS resource type (should be AWS::CloudTrail::Trail)
|
|
583
|
+
region: AWS region to query
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
List of trail dictionaries with Name, TrailARN, IsMultiRegionTrail, IsLogging
|
|
587
|
+
"""
|
|
588
|
+
if resource_type != "AWS::CloudTrail::Trail":
|
|
589
|
+
return []
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
cloudtrail_client = aws_factory.get_client('cloudtrail', region)
|
|
593
|
+
|
|
594
|
+
# Get all trails
|
|
595
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
596
|
+
lambda: cloudtrail_client.describe_trails()
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
trails = response.get('trailList', [])
|
|
600
|
+
|
|
601
|
+
# Get status for each trail
|
|
602
|
+
trails_with_status = []
|
|
603
|
+
for trail in trails:
|
|
604
|
+
trail_arn = trail.get('TrailARN', '')
|
|
605
|
+
try:
|
|
606
|
+
status_response = aws_factory.aws_api_call_with_retry(
|
|
607
|
+
lambda: cloudtrail_client.get_trail_status(Name=trail_arn)
|
|
608
|
+
)
|
|
609
|
+
trail['IsLogging'] = status_response.get('IsLogging', False)
|
|
610
|
+
except ClientError as e:
|
|
611
|
+
logger.warning(f"Error getting status for trail {trail_arn}: {e}")
|
|
612
|
+
trail['IsLogging'] = False
|
|
613
|
+
|
|
614
|
+
trails_with_status.append(trail)
|
|
615
|
+
|
|
616
|
+
logger.debug(f"Found {len(trails_with_status)} CloudTrail trails in {region}")
|
|
617
|
+
return trails_with_status
|
|
618
|
+
|
|
619
|
+
except ClientError as e:
|
|
620
|
+
logger.error(f"Error retrieving CloudTrail trails in {region}: {e}")
|
|
621
|
+
raise
|
|
622
|
+
|
|
623
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
624
|
+
"""Evaluate if CloudTrail trail has Insights enabled.
|
|
625
|
+
|
|
626
|
+
This is an account-level check - we check if at least one active trail has Insights enabled.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
resource: Trail resource dictionary
|
|
630
|
+
aws_factory: AWS client factory for additional API calls
|
|
631
|
+
region: AWS region
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
ComplianceResult indicating whether Insights is enabled
|
|
635
|
+
"""
|
|
636
|
+
trail_name = resource.get('Name', 'unknown')
|
|
637
|
+
trail_arn = resource.get('TrailARN', 'unknown')
|
|
638
|
+
is_logging = resource.get('IsLogging', False)
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
cloudtrail_client = aws_factory.get_client('cloudtrail', region)
|
|
642
|
+
|
|
643
|
+
# Only check Insights for active trails
|
|
644
|
+
if not is_logging:
|
|
645
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
646
|
+
evaluation_reason = (
|
|
647
|
+
f"CloudTrail trail {trail_name} is not actively logging. "
|
|
648
|
+
f"Enable logging for this trail and configure CloudTrail Insights for anomaly detection."
|
|
649
|
+
)
|
|
650
|
+
else:
|
|
651
|
+
# Get insight selectors for the trail
|
|
652
|
+
try:
|
|
653
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
654
|
+
lambda: cloudtrail_client.get_insight_selectors(TrailName=trail_arn)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
insight_selectors = response.get('InsightSelectors', [])
|
|
658
|
+
|
|
659
|
+
if insight_selectors:
|
|
660
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
661
|
+
evaluation_reason = f"CloudTrail trail {trail_name} has Insights enabled for anomaly detection"
|
|
662
|
+
else:
|
|
663
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
664
|
+
evaluation_reason = (
|
|
665
|
+
f"CloudTrail trail {trail_name} does not have Insights enabled. "
|
|
666
|
+
f"Enable CloudTrail Insights for this trail to detect anomalous API activity. "
|
|
667
|
+
f"Configure Insights using the AWS Console, CLI, or API."
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
except ClientError as e:
|
|
671
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
672
|
+
if error_code == 'InsightNotEnabledException':
|
|
673
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
674
|
+
evaluation_reason = (
|
|
675
|
+
f"CloudTrail trail {trail_name} does not have Insights enabled. "
|
|
676
|
+
f"Enable CloudTrail Insights for this trail to detect anomalous API activity."
|
|
677
|
+
)
|
|
678
|
+
else:
|
|
679
|
+
raise
|
|
680
|
+
|
|
681
|
+
except ClientError as e:
|
|
682
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
683
|
+
|
|
684
|
+
if error_code == 'AccessDenied':
|
|
685
|
+
compliance_status = ComplianceStatus.ERROR
|
|
686
|
+
evaluation_reason = f"Insufficient permissions to check Insights for CloudTrail trail {trail_name}: {str(e)}"
|
|
687
|
+
elif error_code == 'TrailNotFoundException':
|
|
688
|
+
compliance_status = ComplianceStatus.ERROR
|
|
689
|
+
evaluation_reason = f"CloudTrail trail {trail_name} not found (may have been deleted)"
|
|
690
|
+
else:
|
|
691
|
+
compliance_status = ComplianceStatus.ERROR
|
|
692
|
+
evaluation_reason = f"Error checking Insights for CloudTrail trail {trail_name}: {str(e)}"
|
|
693
|
+
|
|
694
|
+
return ComplianceResult(
|
|
695
|
+
resource_id=trail_arn,
|
|
696
|
+
resource_type="AWS::CloudTrail::Trail",
|
|
697
|
+
compliance_status=compliance_status,
|
|
698
|
+
evaluation_reason=evaluation_reason,
|
|
699
|
+
config_rule_name=self.rule_name,
|
|
700
|
+
region=region
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ============================================================================
|
|
705
|
+
# 6. AWS Config Recording Assessment
|
|
706
|
+
# ============================================================================
|
|
707
|
+
|
|
708
|
+
class ConfigRecordingAllResourcesAssessment(BaseConfigRuleAssessment):
|
|
709
|
+
"""Assessment for config-recording-all-resources AWS Config rule.
|
|
710
|
+
|
|
711
|
+
Ensures AWS Config is recording all resource types so that configuration
|
|
712
|
+
changes are tracked for compliance and security analysis.
|
|
713
|
+
|
|
714
|
+
This is a regional service assessment that runs in all active regions.
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
def __init__(self):
|
|
718
|
+
super().__init__(
|
|
719
|
+
rule_name="config-recording-all-resources",
|
|
720
|
+
control_id="8.2",
|
|
721
|
+
resource_types=["AWS::Config::ConfigurationRecorder"]
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
725
|
+
"""Get AWS Config configuration recorders in the specified region.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
aws_factory: AWS client factory for API access
|
|
729
|
+
resource_type: AWS resource type (should be AWS::Config::ConfigurationRecorder)
|
|
730
|
+
region: AWS region to query
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
List of configuration recorder dictionaries with name, roleARN, recordingGroup, recording status
|
|
734
|
+
"""
|
|
735
|
+
if resource_type != "AWS::Config::ConfigurationRecorder":
|
|
736
|
+
return []
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
config_client = aws_factory.get_client('config', region)
|
|
740
|
+
|
|
741
|
+
# Get all configuration recorders
|
|
742
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
743
|
+
lambda: config_client.describe_configuration_recorders()
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
recorders = response.get('ConfigurationRecorders', [])
|
|
747
|
+
|
|
748
|
+
# Get status for each recorder
|
|
749
|
+
recorders_with_status = []
|
|
750
|
+
for recorder in recorders:
|
|
751
|
+
recorder_name = recorder.get('name', '')
|
|
752
|
+
try:
|
|
753
|
+
status_response = aws_factory.aws_api_call_with_retry(
|
|
754
|
+
lambda: config_client.describe_configuration_recorder_status(
|
|
755
|
+
ConfigurationRecorderNames=[recorder_name]
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
statuses = status_response.get('ConfigurationRecordersStatus', [])
|
|
760
|
+
if statuses:
|
|
761
|
+
recorder['recording'] = statuses[0].get('recording', False)
|
|
762
|
+
else:
|
|
763
|
+
recorder['recording'] = False
|
|
764
|
+
|
|
765
|
+
except ClientError as e:
|
|
766
|
+
logger.warning(f"Error getting status for recorder {recorder_name}: {e}")
|
|
767
|
+
recorder['recording'] = False
|
|
768
|
+
|
|
769
|
+
recorders_with_status.append(recorder)
|
|
770
|
+
|
|
771
|
+
logger.debug(f"Found {len(recorders_with_status)} AWS Config recorders in {region}")
|
|
772
|
+
return recorders_with_status
|
|
773
|
+
|
|
774
|
+
except ClientError as e:
|
|
775
|
+
logger.error(f"Error retrieving AWS Config recorders in {region}: {e}")
|
|
776
|
+
raise
|
|
777
|
+
|
|
778
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
779
|
+
"""Evaluate if AWS Config recorder is recording all resource types.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
resource: Configuration recorder resource dictionary
|
|
783
|
+
aws_factory: AWS client factory for additional API calls
|
|
784
|
+
region: AWS region
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
ComplianceResult indicating whether recorder is recording all resources
|
|
788
|
+
"""
|
|
789
|
+
recorder_name = resource.get('name', 'unknown')
|
|
790
|
+
recording_group = resource.get('recordingGroup', {})
|
|
791
|
+
all_supported = recording_group.get('allSupported', False)
|
|
792
|
+
is_recording = resource.get('recording', False)
|
|
793
|
+
|
|
794
|
+
try:
|
|
795
|
+
if all_supported and is_recording:
|
|
796
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
797
|
+
evaluation_reason = f"AWS Config recorder {recorder_name} is recording all resource types and is active"
|
|
798
|
+
elif not all_supported and not is_recording:
|
|
799
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
800
|
+
evaluation_reason = (
|
|
801
|
+
f"AWS Config recorder {recorder_name} is not recording all resource types and is not active. "
|
|
802
|
+
f"Configure AWS Config to record all resource types (set allSupported=true) and start the recorder "
|
|
803
|
+
f"using the AWS Console, CLI, or API. Recording all resources ensures comprehensive configuration tracking."
|
|
804
|
+
)
|
|
805
|
+
elif not all_supported:
|
|
806
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
807
|
+
evaluation_reason = (
|
|
808
|
+
f"AWS Config recorder {recorder_name} is not recording all resource types (allSupported=false). "
|
|
809
|
+
f"Configure AWS Config to record all resource types using the AWS Console, CLI, or API."
|
|
810
|
+
)
|
|
811
|
+
else: # not is_recording
|
|
812
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
813
|
+
evaluation_reason = (
|
|
814
|
+
f"AWS Config recorder {recorder_name} is not actively recording (recording=false). "
|
|
815
|
+
f"Start the configuration recorder using the AWS Console, CLI, or API."
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
except Exception as e:
|
|
819
|
+
compliance_status = ComplianceStatus.ERROR
|
|
820
|
+
evaluation_reason = f"Error evaluating AWS Config recorder {recorder_name}: {str(e)}"
|
|
821
|
+
|
|
822
|
+
return ComplianceResult(
|
|
823
|
+
resource_id=recorder_name,
|
|
824
|
+
resource_type="AWS::Config::ConfigurationRecorder",
|
|
825
|
+
compliance_status=compliance_status,
|
|
826
|
+
evaluation_reason=evaluation_reason,
|
|
827
|
+
config_rule_name=self.rule_name,
|
|
828
|
+
region=region
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# ============================================================================
|
|
833
|
+
# 7. WAF Logging Assessment
|
|
834
|
+
# ============================================================================
|
|
835
|
+
|
|
836
|
+
class WAFLoggingEnabledAssessment(BaseConfigRuleAssessment):
|
|
837
|
+
"""Assessment for waf-logging-enabled AWS Config rule.
|
|
838
|
+
|
|
839
|
+
Validates that WAF web ACLs have logging enabled so that web application
|
|
840
|
+
firewall events are captured for security analysis.
|
|
841
|
+
|
|
842
|
+
This assessment handles both REGIONAL and CLOUDFRONT scopes:
|
|
843
|
+
- REGIONAL scope: Evaluated in all active regions
|
|
844
|
+
- CLOUDFRONT scope: Evaluated in us-east-1 only
|
|
845
|
+
"""
|
|
846
|
+
|
|
847
|
+
def __init__(self):
|
|
848
|
+
super().__init__(
|
|
849
|
+
rule_name="waf-logging-enabled",
|
|
850
|
+
control_id="8.2",
|
|
851
|
+
resource_types=["AWS::WAFv2::WebACL"]
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
855
|
+
"""Get WAF web ACLs in the specified region.
|
|
856
|
+
|
|
857
|
+
Handles both REGIONAL and CLOUDFRONT scopes. CLOUDFRONT scope is only
|
|
858
|
+
available in us-east-1.
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
aws_factory: AWS client factory for API access
|
|
862
|
+
resource_type: AWS resource type (should be AWS::WAFv2::WebACL)
|
|
863
|
+
region: AWS region to query
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
List of web ACL dictionaries with Name, Id, ARN, Scope
|
|
867
|
+
"""
|
|
868
|
+
if resource_type != "AWS::WAFv2::WebACL":
|
|
869
|
+
return []
|
|
870
|
+
|
|
871
|
+
try:
|
|
872
|
+
wafv2_client = aws_factory.get_client('wafv2', region)
|
|
873
|
+
|
|
874
|
+
web_acls = []
|
|
875
|
+
|
|
876
|
+
# Get REGIONAL web ACLs
|
|
877
|
+
try:
|
|
878
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
879
|
+
lambda: wafv2_client.list_web_acls(Scope='REGIONAL')
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
regional_acls = response.get('WebACLs', [])
|
|
883
|
+
for acl in regional_acls:
|
|
884
|
+
acl['Scope'] = 'REGIONAL'
|
|
885
|
+
web_acls.extend(regional_acls)
|
|
886
|
+
|
|
887
|
+
except ClientError as e:
|
|
888
|
+
logger.warning(f"Error retrieving REGIONAL WAF web ACLs in {region}: {e}")
|
|
889
|
+
|
|
890
|
+
# Get CLOUDFRONT web ACLs (only in us-east-1)
|
|
891
|
+
if region == 'us-east-1':
|
|
892
|
+
try:
|
|
893
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
894
|
+
lambda: wafv2_client.list_web_acls(Scope='CLOUDFRONT')
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
cloudfront_acls = response.get('WebACLs', [])
|
|
898
|
+
for acl in cloudfront_acls:
|
|
899
|
+
acl['Scope'] = 'CLOUDFRONT'
|
|
900
|
+
web_acls.extend(cloudfront_acls)
|
|
901
|
+
|
|
902
|
+
except ClientError as e:
|
|
903
|
+
logger.warning(f"Error retrieving CLOUDFRONT WAF web ACLs: {e}")
|
|
904
|
+
|
|
905
|
+
logger.debug(f"Found {len(web_acls)} WAF web ACLs in {region}")
|
|
906
|
+
return web_acls
|
|
907
|
+
|
|
908
|
+
except ClientError as e:
|
|
909
|
+
logger.error(f"Error retrieving WAF web ACLs in {region}: {e}")
|
|
910
|
+
raise
|
|
911
|
+
|
|
912
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
913
|
+
"""Evaluate if WAF web ACL has logging enabled.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
resource: Web ACL resource dictionary
|
|
917
|
+
aws_factory: AWS client factory for additional API calls
|
|
918
|
+
region: AWS region
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
ComplianceResult indicating whether logging is enabled
|
|
922
|
+
"""
|
|
923
|
+
web_acl_name = resource.get('Name', 'unknown')
|
|
924
|
+
web_acl_arn = resource.get('ARN', 'unknown')
|
|
925
|
+
web_acl_scope = resource.get('Scope', 'REGIONAL')
|
|
926
|
+
|
|
927
|
+
try:
|
|
928
|
+
wafv2_client = aws_factory.get_client('wafv2', region)
|
|
929
|
+
|
|
930
|
+
# Get logging configuration for the web ACL
|
|
931
|
+
try:
|
|
932
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
933
|
+
lambda: wafv2_client.get_logging_configuration(ResourceArn=web_acl_arn)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
logging_config = response.get('LoggingConfiguration', {})
|
|
937
|
+
log_destinations = logging_config.get('LogDestinationConfigs', [])
|
|
938
|
+
|
|
939
|
+
if log_destinations:
|
|
940
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
941
|
+
evaluation_reason = f"WAF web ACL {web_acl_name} ({web_acl_scope}) has logging enabled"
|
|
942
|
+
else:
|
|
943
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
944
|
+
evaluation_reason = (
|
|
945
|
+
f"WAF web ACL {web_acl_name} ({web_acl_scope}) has logging configuration but no log destinations. "
|
|
946
|
+
f"Configure a log destination (Kinesis Data Firehose, S3, or CloudWatch Logs) for this web ACL."
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
except ClientError as e:
|
|
950
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
951
|
+
|
|
952
|
+
if error_code == 'WAFNonexistentItemException':
|
|
953
|
+
# No logging configuration exists
|
|
954
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
955
|
+
evaluation_reason = (
|
|
956
|
+
f"WAF web ACL {web_acl_name} ({web_acl_scope}) does not have logging enabled. "
|
|
957
|
+
f"Enable logging for this WAF web ACL. Configure a log destination (Kinesis Data Firehose, "
|
|
958
|
+
f"S3, or CloudWatch Logs) using the AWS Console, CLI, or API. WAF logs help analyze web "
|
|
959
|
+
f"application firewall events for security analysis."
|
|
960
|
+
)
|
|
961
|
+
else:
|
|
962
|
+
raise
|
|
963
|
+
|
|
964
|
+
except ClientError as e:
|
|
965
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
966
|
+
|
|
967
|
+
if error_code == 'AccessDenied':
|
|
968
|
+
compliance_status = ComplianceStatus.ERROR
|
|
969
|
+
evaluation_reason = f"Insufficient permissions to check logging for WAF web ACL {web_acl_name}: {str(e)}"
|
|
970
|
+
elif error_code == 'WAFNonexistentItemException':
|
|
971
|
+
compliance_status = ComplianceStatus.ERROR
|
|
972
|
+
evaluation_reason = f"WAF web ACL {web_acl_name} not found (may have been deleted)"
|
|
973
|
+
else:
|
|
974
|
+
compliance_status = ComplianceStatus.ERROR
|
|
975
|
+
evaluation_reason = f"Error checking logging for WAF web ACL {web_acl_name}: {str(e)}"
|
|
976
|
+
|
|
977
|
+
return ComplianceResult(
|
|
978
|
+
resource_id=web_acl_arn,
|
|
979
|
+
resource_type="AWS::WAFv2::WebACL",
|
|
980
|
+
compliance_status=compliance_status,
|
|
981
|
+
evaluation_reason=evaluation_reason,
|
|
982
|
+
config_rule_name=self.rule_name,
|
|
983
|
+
region=region
|
|
984
|
+
)
|