aws-cis-controls-assessment 1.0.9__py3-none-any.whl → 1.0.10__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 +1 -1
- aws_cis_assessment/config/rules/cis_controls_ig1.yaml +94 -1
- aws_cis_assessment/config/rules/cis_controls_ig2.yaml +83 -1
- aws_cis_assessment/controls/ig1/__init__.py +17 -0
- aws_cis_assessment/controls/ig1/control_aws_backup_service.py +1276 -0
- aws_cis_assessment/controls/ig2/__init__.py +12 -0
- aws_cis_assessment/controls/ig2/control_aws_backup_ig2.py +23 -0
- aws_cis_assessment/core/assessment_engine.py +20 -0
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/METADATA +53 -10
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/RECORD +23 -20
- docs/README.md +14 -3
- docs/adding-aws-backup-controls.md +562 -0
- docs/assessment-logic.md +291 -3
- docs/cli-reference.md +1 -1
- docs/config-rule-mappings.md +46 -5
- docs/developer-guide.md +312 -3
- docs/installation.md +2 -2
- docs/troubleshooting.md +211 -2
- docs/user-guide.md +47 -2
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/WHEEL +0 -0
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/entry_points.txt +0 -0
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/licenses/LICENSE +0 -0
- {aws_cis_controls_assessment-1.0.9.dist-info → aws_cis_controls_assessment-1.0.10.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
"""AWS Backup Service Controls - Centralized backup infrastructure assessment.
|
|
2
|
+
|
|
3
|
+
This module implements AWS Backup service-level controls that assess the backup
|
|
4
|
+
infrastructure itself, complementing the existing resource-specific backup controls.
|
|
5
|
+
|
|
6
|
+
Controls:
|
|
7
|
+
- backup-plan-min-frequency-and-min-retention-check: Validates backup plan policies
|
|
8
|
+
- backup-vault-access-policy-check: Checks backup vault security
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Dict, List, Any
|
|
12
|
+
import logging
|
|
13
|
+
import json
|
|
14
|
+
from botocore.exceptions import ClientError
|
|
15
|
+
|
|
16
|
+
from aws_cis_assessment.controls.base_control import BaseConfigRuleAssessment
|
|
17
|
+
from aws_cis_assessment.core.models import ComplianceResult, ComplianceStatus
|
|
18
|
+
from aws_cis_assessment.core.aws_client_factory import AWSClientFactory
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BackupPlanMinFrequencyAndMinRetentionCheckAssessment(BaseConfigRuleAssessment):
|
|
24
|
+
"""Assessment for backup-plan-min-frequency-and-min-retention-check Config rule.
|
|
25
|
+
|
|
26
|
+
Validates that AWS Backup plans have appropriate backup frequency and retention
|
|
27
|
+
policies to ensure data protection and recovery capabilities.
|
|
28
|
+
|
|
29
|
+
Compliance Criteria:
|
|
30
|
+
- Backup plans must have at least one rule defined
|
|
31
|
+
- Each rule should have a valid schedule expression
|
|
32
|
+
- Retention period should be at least 7 days (configurable)
|
|
33
|
+
- Lifecycle policies should be properly configured
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, min_retention_days: int = 7):
|
|
37
|
+
"""Initialize backup plan assessment.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
min_retention_days: Minimum retention period in days (default: 7)
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(
|
|
43
|
+
rule_name="backup-plan-min-frequency-and-min-retention-check",
|
|
44
|
+
control_id="11.2",
|
|
45
|
+
resource_types=["AWS::Backup::BackupPlan"]
|
|
46
|
+
)
|
|
47
|
+
self.min_retention_days = min_retention_days
|
|
48
|
+
|
|
49
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
50
|
+
"""Get all AWS Backup plans in the region.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
aws_factory: AWS client factory for API calls
|
|
54
|
+
resource_type: Type of resource to retrieve
|
|
55
|
+
region: AWS region
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of backup plans with detailed configuration
|
|
59
|
+
"""
|
|
60
|
+
if resource_type != "AWS::Backup::BackupPlan":
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
65
|
+
|
|
66
|
+
# List all backup plans
|
|
67
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
68
|
+
lambda: backup_client.list_backup_plans()
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
plans = []
|
|
72
|
+
for plan in response.get('BackupPlansList', []):
|
|
73
|
+
plan_id = plan.get('BackupPlanId')
|
|
74
|
+
plan_name = plan.get('BackupPlanName')
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Get detailed plan information including rules
|
|
78
|
+
plan_details = aws_factory.aws_api_call_with_retry(
|
|
79
|
+
lambda: backup_client.get_backup_plan(BackupPlanId=plan_id)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
plans.append({
|
|
83
|
+
'BackupPlanId': plan_id,
|
|
84
|
+
'BackupPlanName': plan_name,
|
|
85
|
+
'BackupPlan': plan_details.get('BackupPlan'),
|
|
86
|
+
'BackupPlanArn': plan_details.get('BackupPlanArn'),
|
|
87
|
+
'VersionId': plan.get('VersionId'),
|
|
88
|
+
'CreationDate': plan.get('CreationDate')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
except ClientError as e:
|
|
92
|
+
logger.warning(f"Could not get details for backup plan {plan_name}: {e}")
|
|
93
|
+
# Include plan with minimal info
|
|
94
|
+
plans.append({
|
|
95
|
+
'BackupPlanId': plan_id,
|
|
96
|
+
'BackupPlanName': plan_name,
|
|
97
|
+
'BackupPlan': None,
|
|
98
|
+
'Error': str(e)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
logger.info(f"Retrieved {len(plans)} backup plan(s) in region {region}")
|
|
102
|
+
return plans
|
|
103
|
+
|
|
104
|
+
except ClientError as e:
|
|
105
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
106
|
+
logger.warning(f"Insufficient permissions to list backup plans in region {region}")
|
|
107
|
+
return []
|
|
108
|
+
logger.error(f"Error retrieving backup plans in region {region}: {e}")
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
112
|
+
"""Evaluate if backup plan has appropriate frequency and retention.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
resource: Backup plan resource to evaluate
|
|
116
|
+
aws_factory: AWS client factory
|
|
117
|
+
region: AWS region
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
ComplianceResult with evaluation details
|
|
121
|
+
"""
|
|
122
|
+
plan_id = resource.get('BackupPlanId', 'unknown')
|
|
123
|
+
plan_name = resource.get('BackupPlanName', 'unknown')
|
|
124
|
+
backup_plan = resource.get('BackupPlan')
|
|
125
|
+
|
|
126
|
+
# Check if plan details were retrieved
|
|
127
|
+
if backup_plan is None:
|
|
128
|
+
error_msg = resource.get('Error', 'Unknown error')
|
|
129
|
+
return ComplianceResult(
|
|
130
|
+
resource_id=plan_id,
|
|
131
|
+
resource_type="AWS::Backup::BackupPlan",
|
|
132
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
133
|
+
evaluation_reason=f"Could not retrieve backup plan details: {error_msg}",
|
|
134
|
+
config_rule_name=self.rule_name,
|
|
135
|
+
region=region
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Check backup rules
|
|
139
|
+
rules = backup_plan.get('Rules', [])
|
|
140
|
+
|
|
141
|
+
if not rules:
|
|
142
|
+
return ComplianceResult(
|
|
143
|
+
resource_id=plan_id,
|
|
144
|
+
resource_type="AWS::Backup::BackupPlan",
|
|
145
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
146
|
+
evaluation_reason=f"Backup plan '{plan_name}' has no backup rules defined",
|
|
147
|
+
config_rule_name=self.rule_name,
|
|
148
|
+
region=region
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Validate each rule
|
|
152
|
+
compliant_rules = 0
|
|
153
|
+
issues = []
|
|
154
|
+
|
|
155
|
+
for rule in rules:
|
|
156
|
+
rule_name = rule.get('RuleName', 'unnamed')
|
|
157
|
+
schedule = rule.get('ScheduleExpression', '')
|
|
158
|
+
lifecycle = rule.get('Lifecycle', {})
|
|
159
|
+
|
|
160
|
+
# Check schedule expression
|
|
161
|
+
if not schedule:
|
|
162
|
+
issues.append(f"Rule '{rule_name}' has no schedule expression")
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Validate schedule format (cron or rate expression)
|
|
166
|
+
has_valid_schedule = self._validate_schedule_expression(schedule)
|
|
167
|
+
if not has_valid_schedule:
|
|
168
|
+
issues.append(f"Rule '{rule_name}' has invalid schedule expression: {schedule}")
|
|
169
|
+
|
|
170
|
+
# Check retention period
|
|
171
|
+
delete_after_days = lifecycle.get('DeleteAfterDays')
|
|
172
|
+
move_to_cold_storage_after_days = lifecycle.get('MoveToColdStorageAfterDays')
|
|
173
|
+
|
|
174
|
+
if delete_after_days is None:
|
|
175
|
+
issues.append(f"Rule '{rule_name}' has no retention period defined")
|
|
176
|
+
elif delete_after_days < self.min_retention_days:
|
|
177
|
+
issues.append(
|
|
178
|
+
f"Rule '{rule_name}' has insufficient retention "
|
|
179
|
+
f"({delete_after_days} days, minimum: {self.min_retention_days} days)"
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
# Check cold storage configuration if present
|
|
183
|
+
if move_to_cold_storage_after_days is not None:
|
|
184
|
+
if move_to_cold_storage_after_days >= delete_after_days:
|
|
185
|
+
issues.append(
|
|
186
|
+
f"Rule '{rule_name}' has invalid lifecycle: "
|
|
187
|
+
f"cold storage transition ({move_to_cold_storage_after_days} days) "
|
|
188
|
+
f"must be before deletion ({delete_after_days} days)"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
# Rule is compliant
|
|
192
|
+
if has_valid_schedule:
|
|
193
|
+
compliant_rules += 1
|
|
194
|
+
else:
|
|
195
|
+
# No cold storage, just check schedule and retention
|
|
196
|
+
if has_valid_schedule:
|
|
197
|
+
compliant_rules += 1
|
|
198
|
+
|
|
199
|
+
# Determine overall compliance
|
|
200
|
+
if compliant_rules == len(rules) and not issues:
|
|
201
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
202
|
+
evaluation_reason = (
|
|
203
|
+
f"Backup plan '{plan_name}' has {len(rules)} compliant rule(s) "
|
|
204
|
+
f"with valid schedules and retention >= {self.min_retention_days} days"
|
|
205
|
+
)
|
|
206
|
+
elif compliant_rules > 0:
|
|
207
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
208
|
+
evaluation_reason = (
|
|
209
|
+
f"Backup plan '{plan_name}' has {compliant_rules}/{len(rules)} compliant rules. "
|
|
210
|
+
f"Issues: {'; '.join(issues)}"
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
214
|
+
evaluation_reason = (
|
|
215
|
+
f"Backup plan '{plan_name}' has no compliant rules. "
|
|
216
|
+
f"Issues: {'; '.join(issues)}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return ComplianceResult(
|
|
220
|
+
resource_id=plan_id,
|
|
221
|
+
resource_type="AWS::Backup::BackupPlan",
|
|
222
|
+
compliance_status=compliance_status,
|
|
223
|
+
evaluation_reason=evaluation_reason,
|
|
224
|
+
config_rule_name=self.rule_name,
|
|
225
|
+
region=region
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _validate_schedule_expression(self, schedule: str) -> bool:
|
|
229
|
+
"""Validate AWS Backup schedule expression format.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
schedule: Schedule expression (cron or rate)
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if valid, False otherwise
|
|
236
|
+
"""
|
|
237
|
+
if not schedule:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
schedule_lower = schedule.lower().strip()
|
|
241
|
+
|
|
242
|
+
# Check for cron expression
|
|
243
|
+
if schedule_lower.startswith('cron(') and schedule_lower.endswith(')'):
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
# Check for rate expression
|
|
247
|
+
if schedule_lower.startswith('rate(') and schedule_lower.endswith(')'):
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class BackupVaultAccessPolicyCheckAssessment(BaseConfigRuleAssessment):
|
|
254
|
+
"""Assessment for backup-vault-access-policy-check Config rule.
|
|
255
|
+
|
|
256
|
+
Validates that AWS Backup vaults have secure access policies that follow
|
|
257
|
+
the principle of least privilege and do not allow public access.
|
|
258
|
+
|
|
259
|
+
Compliance Criteria:
|
|
260
|
+
- Vaults should not allow public access (Principal: "*")
|
|
261
|
+
- Access policies should be restrictive
|
|
262
|
+
- Cross-account access should be explicitly authorized
|
|
263
|
+
- Vault lock should be considered for critical vaults
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(self):
|
|
267
|
+
"""Initialize backup vault access policy assessment."""
|
|
268
|
+
super().__init__(
|
|
269
|
+
rule_name="backup-vault-access-policy-check",
|
|
270
|
+
control_id="11.2",
|
|
271
|
+
resource_types=["AWS::Backup::BackupVault"]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
275
|
+
"""Get all AWS Backup vaults in the region.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
aws_factory: AWS client factory for API calls
|
|
279
|
+
resource_type: Type of resource to retrieve
|
|
280
|
+
region: AWS region
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
List of backup vaults with access policies
|
|
284
|
+
"""
|
|
285
|
+
if resource_type != "AWS::Backup::BackupVault":
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
290
|
+
|
|
291
|
+
# List all backup vaults
|
|
292
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
293
|
+
lambda: backup_client.list_backup_vaults()
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
vaults = []
|
|
297
|
+
for vault in response.get('BackupVaultList', []):
|
|
298
|
+
vault_name = vault.get('BackupVaultName')
|
|
299
|
+
|
|
300
|
+
# Get vault access policy
|
|
301
|
+
access_policy = None
|
|
302
|
+
policy_error = None
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
policy_response = aws_factory.aws_api_call_with_retry(
|
|
306
|
+
lambda: backup_client.get_backup_vault_access_policy(
|
|
307
|
+
BackupVaultName=vault_name
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
access_policy = policy_response.get('Policy')
|
|
311
|
+
except ClientError as e:
|
|
312
|
+
error_code = e.response.get('Error', {}).get('Code')
|
|
313
|
+
if error_code == 'ResourceNotFoundException':
|
|
314
|
+
# No policy is set - this is actually secure (default deny)
|
|
315
|
+
access_policy = None
|
|
316
|
+
elif error_code in ['AccessDenied', 'UnauthorizedOperation']:
|
|
317
|
+
policy_error = "Insufficient permissions to get vault policy"
|
|
318
|
+
else:
|
|
319
|
+
policy_error = str(e)
|
|
320
|
+
|
|
321
|
+
# Get vault lock status (if available)
|
|
322
|
+
vault_lock_status = None
|
|
323
|
+
try:
|
|
324
|
+
lock_response = aws_factory.aws_api_call_with_retry(
|
|
325
|
+
lambda: backup_client.describe_backup_vault(
|
|
326
|
+
BackupVaultName=vault_name
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
vault_lock_status = lock_response.get('Locked', False)
|
|
330
|
+
except ClientError:
|
|
331
|
+
# Lock status not available or not supported
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
vaults.append({
|
|
335
|
+
'BackupVaultName': vault_name,
|
|
336
|
+
'BackupVaultArn': vault.get('BackupVaultArn'),
|
|
337
|
+
'AccessPolicy': access_policy,
|
|
338
|
+
'PolicyError': policy_error,
|
|
339
|
+
'Locked': vault_lock_status,
|
|
340
|
+
'CreationDate': vault.get('CreationDate'),
|
|
341
|
+
'NumberOfRecoveryPoints': vault.get('NumberOfRecoveryPoints', 0)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
logger.info(f"Retrieved {len(vaults)} backup vault(s) in region {region}")
|
|
345
|
+
return vaults
|
|
346
|
+
|
|
347
|
+
except ClientError as e:
|
|
348
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
349
|
+
logger.warning(f"Insufficient permissions to list backup vaults in region {region}")
|
|
350
|
+
return []
|
|
351
|
+
logger.error(f"Error retrieving backup vaults in region {region}: {e}")
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
355
|
+
"""Evaluate if backup vault has secure access policy.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
resource: Backup vault resource to evaluate
|
|
359
|
+
aws_factory: AWS client factory
|
|
360
|
+
region: AWS region
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
ComplianceResult with evaluation details
|
|
364
|
+
"""
|
|
365
|
+
vault_name = resource.get('BackupVaultName', 'unknown')
|
|
366
|
+
access_policy = resource.get('AccessPolicy')
|
|
367
|
+
policy_error = resource.get('PolicyError')
|
|
368
|
+
is_locked = resource.get('Locked', False)
|
|
369
|
+
|
|
370
|
+
# Check if there was an error retrieving the policy
|
|
371
|
+
if policy_error:
|
|
372
|
+
return ComplianceResult(
|
|
373
|
+
resource_id=vault_name,
|
|
374
|
+
resource_type="AWS::Backup::BackupVault",
|
|
375
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
376
|
+
evaluation_reason=f"Could not retrieve access policy for vault '{vault_name}': {policy_error}",
|
|
377
|
+
config_rule_name=self.rule_name,
|
|
378
|
+
region=region
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# No policy means default deny - this is secure
|
|
382
|
+
if not access_policy:
|
|
383
|
+
evaluation_reason = f"Backup vault '{vault_name}' has no access policy (default deny - secure)"
|
|
384
|
+
if is_locked:
|
|
385
|
+
evaluation_reason += " and is locked for additional protection"
|
|
386
|
+
|
|
387
|
+
return ComplianceResult(
|
|
388
|
+
resource_id=vault_name,
|
|
389
|
+
resource_type="AWS::Backup::BackupVault",
|
|
390
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
391
|
+
evaluation_reason=evaluation_reason,
|
|
392
|
+
config_rule_name=self.rule_name,
|
|
393
|
+
region=region
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Parse and validate the access policy
|
|
397
|
+
try:
|
|
398
|
+
policy_doc = json.loads(access_policy)
|
|
399
|
+
statements = policy_doc.get('Statement', [])
|
|
400
|
+
|
|
401
|
+
if not statements:
|
|
402
|
+
return ComplianceResult(
|
|
403
|
+
resource_id=vault_name,
|
|
404
|
+
resource_type="AWS::Backup::BackupVault",
|
|
405
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
406
|
+
evaluation_reason=f"Backup vault '{vault_name}' has empty access policy (no permissions granted)",
|
|
407
|
+
config_rule_name=self.rule_name,
|
|
408
|
+
region=region
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Check for security issues
|
|
412
|
+
issues = []
|
|
413
|
+
warnings = []
|
|
414
|
+
|
|
415
|
+
for idx, statement in enumerate(statements):
|
|
416
|
+
statement_id = statement.get('Sid', f'Statement-{idx}')
|
|
417
|
+
effect = statement.get('Effect', 'Allow')
|
|
418
|
+
principal = statement.get('Principal', {})
|
|
419
|
+
actions = statement.get('Action', [])
|
|
420
|
+
|
|
421
|
+
# Convert single action to list
|
|
422
|
+
if isinstance(actions, str):
|
|
423
|
+
actions = [actions]
|
|
424
|
+
|
|
425
|
+
# Check for public access
|
|
426
|
+
if self._is_public_principal(principal):
|
|
427
|
+
issues.append(
|
|
428
|
+
f"Statement '{statement_id}' allows public access (Principal: *)"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Check for overly broad permissions
|
|
432
|
+
if effect == 'Allow':
|
|
433
|
+
if '*' in actions or 'backup:*' in actions:
|
|
434
|
+
warnings.append(
|
|
435
|
+
f"Statement '{statement_id}' grants broad permissions (Action: *)"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Check for dangerous actions
|
|
439
|
+
dangerous_actions = [
|
|
440
|
+
'backup:DeleteBackupVault',
|
|
441
|
+
'backup:DeleteRecoveryPoint',
|
|
442
|
+
'backup:PutBackupVaultAccessPolicy'
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
for action in actions:
|
|
446
|
+
if action in dangerous_actions:
|
|
447
|
+
warnings.append(
|
|
448
|
+
f"Statement '{statement_id}' allows potentially dangerous action: {action}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Determine compliance status
|
|
452
|
+
if issues:
|
|
453
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
454
|
+
evaluation_reason = (
|
|
455
|
+
f"Backup vault '{vault_name}' has insecure access policy. "
|
|
456
|
+
f"Issues: {'; '.join(issues)}"
|
|
457
|
+
)
|
|
458
|
+
if warnings:
|
|
459
|
+
evaluation_reason += f". Warnings: {'; '.join(warnings)}"
|
|
460
|
+
elif warnings:
|
|
461
|
+
# Warnings but no critical issues - still compliant but note the warnings
|
|
462
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
463
|
+
evaluation_reason = (
|
|
464
|
+
f"Backup vault '{vault_name}' has access policy with warnings: {'; '.join(warnings)}"
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
468
|
+
evaluation_reason = f"Backup vault '{vault_name}' has appropriate access policy"
|
|
469
|
+
if is_locked:
|
|
470
|
+
evaluation_reason += " and is locked for additional protection"
|
|
471
|
+
|
|
472
|
+
return ComplianceResult(
|
|
473
|
+
resource_id=vault_name,
|
|
474
|
+
resource_type="AWS::Backup::BackupVault",
|
|
475
|
+
compliance_status=compliance_status,
|
|
476
|
+
evaluation_reason=evaluation_reason,
|
|
477
|
+
config_rule_name=self.rule_name,
|
|
478
|
+
region=region
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
except json.JSONDecodeError as e:
|
|
482
|
+
return ComplianceResult(
|
|
483
|
+
resource_id=vault_name,
|
|
484
|
+
resource_type="AWS::Backup::BackupVault",
|
|
485
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
486
|
+
evaluation_reason=f"Backup vault '{vault_name}' has invalid access policy JSON: {str(e)}",
|
|
487
|
+
config_rule_name=self.rule_name,
|
|
488
|
+
region=region
|
|
489
|
+
)
|
|
490
|
+
except Exception as e:
|
|
491
|
+
return ComplianceResult(
|
|
492
|
+
resource_id=vault_name,
|
|
493
|
+
resource_type="AWS::Backup::BackupVault",
|
|
494
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
495
|
+
evaluation_reason=f"Error evaluating access policy for vault '{vault_name}': {str(e)}",
|
|
496
|
+
config_rule_name=self.rule_name,
|
|
497
|
+
region=region
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _is_public_principal(self, principal: Any) -> bool:
|
|
501
|
+
"""Check if principal allows public access.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
principal: Principal from IAM policy statement
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
True if principal allows public access, False otherwise
|
|
508
|
+
"""
|
|
509
|
+
# Check for wildcard principal
|
|
510
|
+
if principal == '*':
|
|
511
|
+
return True
|
|
512
|
+
|
|
513
|
+
# Check for AWS principal with wildcard
|
|
514
|
+
if isinstance(principal, dict):
|
|
515
|
+
aws_principal = principal.get('AWS')
|
|
516
|
+
if aws_principal == '*':
|
|
517
|
+
return True
|
|
518
|
+
if isinstance(aws_principal, list) and '*' in aws_principal:
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class BackupVaultLockCheckAssessment(BaseConfigRuleAssessment):
|
|
526
|
+
"""Assessment for backup-vault-lock-check Config rule (IG2).
|
|
527
|
+
|
|
528
|
+
Validates that AWS Backup vaults have Vault Lock enabled to prevent
|
|
529
|
+
deletion of recovery points, providing ransomware protection.
|
|
530
|
+
|
|
531
|
+
Compliance Criteria:
|
|
532
|
+
- Critical backup vaults should have Vault Lock enabled
|
|
533
|
+
- Vault Lock provides immutable backups (WORM - Write Once Read Many)
|
|
534
|
+
- Protects against accidental or malicious deletion
|
|
535
|
+
- Compliance mode prevents even root user from deleting backups
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__(self):
|
|
539
|
+
"""Initialize backup vault lock assessment."""
|
|
540
|
+
super().__init__(
|
|
541
|
+
rule_name="backup-vault-lock-check",
|
|
542
|
+
control_id="11.3",
|
|
543
|
+
resource_types=["AWS::Backup::BackupVault"]
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
547
|
+
"""Get all AWS Backup vaults with lock status.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
aws_factory: AWS client factory for API calls
|
|
551
|
+
resource_type: Type of resource to retrieve
|
|
552
|
+
region: AWS region
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List of backup vaults with lock configuration
|
|
556
|
+
"""
|
|
557
|
+
if resource_type != "AWS::Backup::BackupVault":
|
|
558
|
+
return []
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
562
|
+
|
|
563
|
+
# List all backup vaults
|
|
564
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
565
|
+
lambda: backup_client.list_backup_vaults()
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
vaults = []
|
|
569
|
+
for vault in response.get('BackupVaultList', []):
|
|
570
|
+
vault_name = vault.get('BackupVaultName')
|
|
571
|
+
|
|
572
|
+
# Get detailed vault information including lock status
|
|
573
|
+
try:
|
|
574
|
+
vault_details = aws_factory.aws_api_call_with_retry(
|
|
575
|
+
lambda: backup_client.describe_backup_vault(
|
|
576
|
+
BackupVaultName=vault_name
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
vaults.append({
|
|
581
|
+
'BackupVaultName': vault_name,
|
|
582
|
+
'BackupVaultArn': vault.get('BackupVaultArn'),
|
|
583
|
+
'Locked': vault_details.get('Locked', False),
|
|
584
|
+
'MinRetentionDays': vault_details.get('MinRetentionDays'),
|
|
585
|
+
'MaxRetentionDays': vault_details.get('MaxRetentionDays'),
|
|
586
|
+
'LockDate': vault_details.get('LockDate'),
|
|
587
|
+
'CreationDate': vault.get('CreationDate'),
|
|
588
|
+
'NumberOfRecoveryPoints': vault.get('NumberOfRecoveryPoints', 0)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
except ClientError as e:
|
|
592
|
+
error_code = e.response.get('Error', {}).get('Code')
|
|
593
|
+
if error_code in ['AccessDenied', 'UnauthorizedOperation']:
|
|
594
|
+
logger.warning(f"Insufficient permissions to describe vault {vault_name}")
|
|
595
|
+
vaults.append({
|
|
596
|
+
'BackupVaultName': vault_name,
|
|
597
|
+
'BackupVaultArn': vault.get('BackupVaultArn'),
|
|
598
|
+
'Error': 'Insufficient permissions'
|
|
599
|
+
})
|
|
600
|
+
else:
|
|
601
|
+
logger.warning(f"Could not get details for vault {vault_name}: {e}")
|
|
602
|
+
vaults.append({
|
|
603
|
+
'BackupVaultName': vault_name,
|
|
604
|
+
'BackupVaultArn': vault.get('BackupVaultArn'),
|
|
605
|
+
'Error': str(e)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
logger.info(f"Retrieved {len(vaults)} backup vault(s) in region {region}")
|
|
609
|
+
return vaults
|
|
610
|
+
|
|
611
|
+
except ClientError as e:
|
|
612
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
613
|
+
logger.warning(f"Insufficient permissions to list backup vaults in region {region}")
|
|
614
|
+
return []
|
|
615
|
+
logger.error(f"Error retrieving backup vaults in region {region}: {e}")
|
|
616
|
+
raise
|
|
617
|
+
|
|
618
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
619
|
+
"""Evaluate if backup vault has Vault Lock enabled.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
resource: Backup vault resource to evaluate
|
|
623
|
+
aws_factory: AWS client factory
|
|
624
|
+
region: AWS region
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
ComplianceResult with evaluation details
|
|
628
|
+
"""
|
|
629
|
+
vault_name = resource.get('BackupVaultName', 'unknown')
|
|
630
|
+
is_locked = resource.get('Locked', False)
|
|
631
|
+
min_retention = resource.get('MinRetentionDays')
|
|
632
|
+
max_retention = resource.get('MaxRetentionDays')
|
|
633
|
+
lock_date = resource.get('LockDate')
|
|
634
|
+
error = resource.get('Error')
|
|
635
|
+
|
|
636
|
+
# Check for errors
|
|
637
|
+
if error:
|
|
638
|
+
return ComplianceResult(
|
|
639
|
+
resource_id=vault_name,
|
|
640
|
+
resource_type="AWS::Backup::BackupVault",
|
|
641
|
+
compliance_status=ComplianceStatus.ERROR,
|
|
642
|
+
evaluation_reason=f"Could not evaluate vault lock for '{vault_name}': {error}",
|
|
643
|
+
config_rule_name=self.rule_name,
|
|
644
|
+
region=region
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Evaluate lock status
|
|
648
|
+
if is_locked:
|
|
649
|
+
lock_details = []
|
|
650
|
+
if min_retention:
|
|
651
|
+
lock_details.append(f"min retention: {min_retention} days")
|
|
652
|
+
if max_retention:
|
|
653
|
+
lock_details.append(f"max retention: {max_retention} days")
|
|
654
|
+
if lock_date:
|
|
655
|
+
lock_details.append(f"locked since: {lock_date}")
|
|
656
|
+
|
|
657
|
+
details_str = ", ".join(lock_details) if lock_details else "lock enabled"
|
|
658
|
+
|
|
659
|
+
return ComplianceResult(
|
|
660
|
+
resource_id=vault_name,
|
|
661
|
+
resource_type="AWS::Backup::BackupVault",
|
|
662
|
+
compliance_status=ComplianceStatus.COMPLIANT,
|
|
663
|
+
evaluation_reason=f"Backup vault '{vault_name}' has Vault Lock enabled ({details_str})",
|
|
664
|
+
config_rule_name=self.rule_name,
|
|
665
|
+
region=region
|
|
666
|
+
)
|
|
667
|
+
else:
|
|
668
|
+
return ComplianceResult(
|
|
669
|
+
resource_id=vault_name,
|
|
670
|
+
resource_type="AWS::Backup::BackupVault",
|
|
671
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
672
|
+
evaluation_reason=f"Backup vault '{vault_name}' does not have Vault Lock enabled (ransomware protection not configured)",
|
|
673
|
+
config_rule_name=self.rule_name,
|
|
674
|
+
region=region
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class BackupSelectionResourceCoverageCheckAssessment(BaseConfigRuleAssessment):
|
|
679
|
+
"""Assessment for backup-selection-resource-coverage-check Config rule (IG1).
|
|
680
|
+
|
|
681
|
+
Validates that AWS Backup plans have backup selections that cover critical
|
|
682
|
+
resources, ensuring comprehensive backup coverage.
|
|
683
|
+
|
|
684
|
+
Compliance Criteria:
|
|
685
|
+
- Backup plans should have at least one backup selection
|
|
686
|
+
- Backup selections should target specific resources or use tags
|
|
687
|
+
- Critical resource types should be included in backup coverage
|
|
688
|
+
- Selections should not be empty
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
def __init__(self):
|
|
692
|
+
"""Initialize backup selection coverage assessment."""
|
|
693
|
+
super().__init__(
|
|
694
|
+
rule_name="backup-selection-resource-coverage-check",
|
|
695
|
+
control_id="11.2",
|
|
696
|
+
resource_types=["AWS::Backup::BackupPlan"]
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
700
|
+
"""Get all AWS Backup plans with their selections.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
aws_factory: AWS client factory for API calls
|
|
704
|
+
resource_type: Type of resource to retrieve
|
|
705
|
+
region: AWS region
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
List of backup plans with selection details
|
|
709
|
+
"""
|
|
710
|
+
if resource_type != "AWS::Backup::BackupPlan":
|
|
711
|
+
return []
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
715
|
+
|
|
716
|
+
# List all backup plans
|
|
717
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
718
|
+
lambda: backup_client.list_backup_plans()
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
plans = []
|
|
722
|
+
for plan in response.get('BackupPlansList', []):
|
|
723
|
+
plan_id = plan.get('BackupPlanId')
|
|
724
|
+
plan_name = plan.get('BackupPlanName')
|
|
725
|
+
|
|
726
|
+
# Get backup selections for this plan
|
|
727
|
+
selections = []
|
|
728
|
+
try:
|
|
729
|
+
selections_response = aws_factory.aws_api_call_with_retry(
|
|
730
|
+
lambda: backup_client.list_backup_selections(
|
|
731
|
+
BackupPlanId=plan_id
|
|
732
|
+
)
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
for selection in selections_response.get('BackupSelectionsList', []):
|
|
736
|
+
selection_id = selection.get('SelectionId')
|
|
737
|
+
|
|
738
|
+
# Get detailed selection information
|
|
739
|
+
try:
|
|
740
|
+
selection_details = aws_factory.aws_api_call_with_retry(
|
|
741
|
+
lambda: backup_client.get_backup_selection(
|
|
742
|
+
BackupPlanId=plan_id,
|
|
743
|
+
SelectionId=selection_id
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
selections.append({
|
|
748
|
+
'SelectionId': selection_id,
|
|
749
|
+
'SelectionName': selection.get('SelectionName'),
|
|
750
|
+
'BackupSelection': selection_details.get('BackupSelection'),
|
|
751
|
+
'CreationDate': selection_details.get('CreationDate')
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
except ClientError as e:
|
|
755
|
+
logger.warning(f"Could not get selection details for {selection_id}: {e}")
|
|
756
|
+
selections.append({
|
|
757
|
+
'SelectionId': selection_id,
|
|
758
|
+
'SelectionName': selection.get('SelectionName'),
|
|
759
|
+
'Error': str(e)
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
except ClientError as e:
|
|
763
|
+
logger.warning(f"Could not list selections for plan {plan_name}: {e}")
|
|
764
|
+
|
|
765
|
+
plans.append({
|
|
766
|
+
'BackupPlanId': plan_id,
|
|
767
|
+
'BackupPlanName': plan_name,
|
|
768
|
+
'BackupSelections': selections,
|
|
769
|
+
'CreationDate': plan.get('CreationDate')
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
logger.info(f"Retrieved {len(plans)} backup plan(s) with selections in region {region}")
|
|
773
|
+
return plans
|
|
774
|
+
|
|
775
|
+
except ClientError as e:
|
|
776
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
777
|
+
logger.warning(f"Insufficient permissions to list backup plans in region {region}")
|
|
778
|
+
return []
|
|
779
|
+
logger.error(f"Error retrieving backup plans in region {region}: {e}")
|
|
780
|
+
raise
|
|
781
|
+
|
|
782
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
783
|
+
"""Evaluate if backup plan has adequate resource coverage.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
resource: Backup plan resource to evaluate
|
|
787
|
+
aws_factory: AWS client factory
|
|
788
|
+
region: AWS region
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
ComplianceResult with evaluation details
|
|
792
|
+
"""
|
|
793
|
+
plan_id = resource.get('BackupPlanId', 'unknown')
|
|
794
|
+
plan_name = resource.get('BackupPlanName', 'unknown')
|
|
795
|
+
selections = resource.get('BackupSelections', [])
|
|
796
|
+
|
|
797
|
+
# Check if plan has any selections
|
|
798
|
+
if not selections:
|
|
799
|
+
return ComplianceResult(
|
|
800
|
+
resource_id=plan_id,
|
|
801
|
+
resource_type="AWS::Backup::BackupPlan",
|
|
802
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
803
|
+
evaluation_reason=f"Backup plan '{plan_name}' has no backup selections (no resources will be backed up)",
|
|
804
|
+
config_rule_name=self.rule_name,
|
|
805
|
+
region=region
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# Analyze selections
|
|
809
|
+
valid_selections = 0
|
|
810
|
+
issues = []
|
|
811
|
+
resource_coverage = []
|
|
812
|
+
|
|
813
|
+
for selection in selections:
|
|
814
|
+
selection_name = selection.get('SelectionName', 'unnamed')
|
|
815
|
+
backup_selection = selection.get('BackupSelection')
|
|
816
|
+
error = selection.get('Error')
|
|
817
|
+
|
|
818
|
+
if error:
|
|
819
|
+
issues.append(f"Selection '{selection_name}' could not be evaluated: {error}")
|
|
820
|
+
continue
|
|
821
|
+
|
|
822
|
+
if not backup_selection:
|
|
823
|
+
issues.append(f"Selection '{selection_name}' has no configuration")
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
# Check selection criteria
|
|
827
|
+
resources = backup_selection.get('Resources', [])
|
|
828
|
+
list_of_tags = backup_selection.get('ListOfTags', [])
|
|
829
|
+
conditions = backup_selection.get('Conditions')
|
|
830
|
+
|
|
831
|
+
# Validate selection has targeting criteria
|
|
832
|
+
has_resources = len(resources) > 0
|
|
833
|
+
has_tags = len(list_of_tags) > 0
|
|
834
|
+
has_conditions = conditions is not None
|
|
835
|
+
|
|
836
|
+
if not (has_resources or has_tags or has_conditions):
|
|
837
|
+
issues.append(f"Selection '{selection_name}' has no targeting criteria (resources, tags, or conditions)")
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
# Selection is valid
|
|
841
|
+
valid_selections += 1
|
|
842
|
+
|
|
843
|
+
# Track what's being backed up
|
|
844
|
+
if has_resources:
|
|
845
|
+
resource_coverage.append(f"{len(resources)} specific resource(s)")
|
|
846
|
+
if has_tags:
|
|
847
|
+
resource_coverage.append(f"{len(list_of_tags)} tag-based rule(s)")
|
|
848
|
+
if has_conditions:
|
|
849
|
+
resource_coverage.append("conditional selection")
|
|
850
|
+
|
|
851
|
+
# Determine compliance
|
|
852
|
+
if valid_selections == 0:
|
|
853
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
854
|
+
evaluation_reason = (
|
|
855
|
+
f"Backup plan '{plan_name}' has {len(selections)} selection(s) but none are valid. "
|
|
856
|
+
f"Issues: {'; '.join(issues)}"
|
|
857
|
+
)
|
|
858
|
+
elif valid_selections < len(selections):
|
|
859
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
860
|
+
evaluation_reason = (
|
|
861
|
+
f"Backup plan '{plan_name}' has {valid_selections}/{len(selections)} valid selections. "
|
|
862
|
+
f"Coverage: {', '.join(resource_coverage)}. Issues: {'; '.join(issues)}"
|
|
863
|
+
)
|
|
864
|
+
else:
|
|
865
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
866
|
+
evaluation_reason = (
|
|
867
|
+
f"Backup plan '{plan_name}' has {valid_selections} valid selection(s) "
|
|
868
|
+
f"covering: {', '.join(resource_coverage)}"
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return ComplianceResult(
|
|
872
|
+
resource_id=plan_id,
|
|
873
|
+
resource_type="AWS::Backup::BackupPlan",
|
|
874
|
+
compliance_status=compliance_status,
|
|
875
|
+
evaluation_reason=evaluation_reason,
|
|
876
|
+
config_rule_name=self.rule_name,
|
|
877
|
+
region=region
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
class BackupReportPlanExistsCheckAssessment(BaseConfigRuleAssessment):
|
|
882
|
+
"""Assessment for backup-report-plan-exists-check Config rule (IG2).
|
|
883
|
+
|
|
884
|
+
Validates that AWS Backup has report plans configured to monitor backup
|
|
885
|
+
compliance and provide audit trails.
|
|
886
|
+
|
|
887
|
+
Compliance Criteria:
|
|
888
|
+
- At least one backup report plan should exist
|
|
889
|
+
- Report plans should be actively generating reports
|
|
890
|
+
- Reports should cover backup job status and compliance
|
|
891
|
+
- Report delivery should be configured
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
def __init__(self):
|
|
895
|
+
"""Initialize backup report plan assessment."""
|
|
896
|
+
super().__init__(
|
|
897
|
+
rule_name="backup-report-plan-exists-check",
|
|
898
|
+
control_id="11.3",
|
|
899
|
+
resource_types=["AWS::Backup::ReportPlan"]
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
903
|
+
"""Get all AWS Backup report plans.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
aws_factory: AWS client factory for API calls
|
|
907
|
+
resource_type: Type of resource to retrieve
|
|
908
|
+
region: AWS region
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
List of backup report plans
|
|
912
|
+
"""
|
|
913
|
+
if resource_type != "AWS::Backup::ReportPlan":
|
|
914
|
+
return []
|
|
915
|
+
|
|
916
|
+
try:
|
|
917
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
918
|
+
|
|
919
|
+
# List all report plans
|
|
920
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
921
|
+
lambda: backup_client.list_report_plans()
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
report_plans = []
|
|
925
|
+
for plan in response.get('ReportPlans', []):
|
|
926
|
+
report_plan_name = plan.get('ReportPlanName')
|
|
927
|
+
|
|
928
|
+
# Get detailed report plan information
|
|
929
|
+
try:
|
|
930
|
+
plan_details = aws_factory.aws_api_call_with_retry(
|
|
931
|
+
lambda: backup_client.describe_report_plan(
|
|
932
|
+
ReportPlanName=report_plan_name
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
report_plan = plan_details.get('ReportPlan', {})
|
|
937
|
+
report_plans.append({
|
|
938
|
+
'ReportPlanName': report_plan_name,
|
|
939
|
+
'ReportPlanArn': report_plan.get('ReportPlanArn'),
|
|
940
|
+
'ReportPlanDescription': report_plan.get('ReportPlanDescription'),
|
|
941
|
+
'ReportSetting': report_plan.get('ReportSetting'),
|
|
942
|
+
'ReportDeliveryChannel': report_plan.get('ReportDeliveryChannel'),
|
|
943
|
+
'CreationTime': report_plan.get('CreationTime'),
|
|
944
|
+
'LastAttemptedExecutionTime': report_plan.get('LastAttemptedExecutionTime'),
|
|
945
|
+
'LastSuccessfulExecutionTime': report_plan.get('LastSuccessfulExecutionTime')
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
except ClientError as e:
|
|
949
|
+
logger.warning(f"Could not get details for report plan {report_plan_name}: {e}")
|
|
950
|
+
report_plans.append({
|
|
951
|
+
'ReportPlanName': report_plan_name,
|
|
952
|
+
'Error': str(e)
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
logger.info(f"Retrieved {len(report_plans)} backup report plan(s) in region {region}")
|
|
956
|
+
|
|
957
|
+
# Return a single "account-level" resource if report plans exist
|
|
958
|
+
# This allows us to check if ANY report plans exist
|
|
959
|
+
if report_plans:
|
|
960
|
+
return [{
|
|
961
|
+
'AccountId': aws_factory.account_id,
|
|
962
|
+
'Region': region,
|
|
963
|
+
'ReportPlans': report_plans,
|
|
964
|
+
'TotalReportPlans': len(report_plans)
|
|
965
|
+
}]
|
|
966
|
+
else:
|
|
967
|
+
# Return empty resource to indicate no report plans
|
|
968
|
+
return [{
|
|
969
|
+
'AccountId': aws_factory.account_id,
|
|
970
|
+
'Region': region,
|
|
971
|
+
'ReportPlans': [],
|
|
972
|
+
'TotalReportPlans': 0
|
|
973
|
+
}]
|
|
974
|
+
|
|
975
|
+
except ClientError as e:
|
|
976
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
977
|
+
logger.warning(f"Insufficient permissions to list report plans in region {region}")
|
|
978
|
+
return []
|
|
979
|
+
logger.error(f"Error retrieving report plans in region {region}: {e}")
|
|
980
|
+
raise
|
|
981
|
+
|
|
982
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
983
|
+
"""Evaluate if backup report plans exist and are configured.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
resource: Account-level resource with report plans
|
|
987
|
+
aws_factory: AWS client factory
|
|
988
|
+
region: AWS region
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
ComplianceResult with evaluation details
|
|
992
|
+
"""
|
|
993
|
+
account_id = resource.get('AccountId', 'unknown')
|
|
994
|
+
report_plans = resource.get('ReportPlans', [])
|
|
995
|
+
total_plans = resource.get('TotalReportPlans', 0)
|
|
996
|
+
|
|
997
|
+
resource_id = f"backup-reporting-{region}"
|
|
998
|
+
|
|
999
|
+
# Check if any report plans exist
|
|
1000
|
+
if total_plans == 0:
|
|
1001
|
+
return ComplianceResult(
|
|
1002
|
+
resource_id=resource_id,
|
|
1003
|
+
resource_type="AWS::Backup::ReportPlan",
|
|
1004
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
1005
|
+
evaluation_reason=f"No backup report plans configured in region {region} (backup compliance monitoring not enabled)",
|
|
1006
|
+
config_rule_name=self.rule_name,
|
|
1007
|
+
region=region
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Analyze report plans
|
|
1011
|
+
active_plans = 0
|
|
1012
|
+
configured_plans = 0
|
|
1013
|
+
issues = []
|
|
1014
|
+
|
|
1015
|
+
for plan in report_plans:
|
|
1016
|
+
plan_name = plan.get('ReportPlanName', 'unnamed')
|
|
1017
|
+
error = plan.get('Error')
|
|
1018
|
+
|
|
1019
|
+
if error:
|
|
1020
|
+
issues.append(f"Report plan '{plan_name}' could not be evaluated: {error}")
|
|
1021
|
+
continue
|
|
1022
|
+
|
|
1023
|
+
# Check if report delivery is configured
|
|
1024
|
+
delivery_channel = plan.get('ReportDeliveryChannel')
|
|
1025
|
+
if not delivery_channel:
|
|
1026
|
+
issues.append(f"Report plan '{plan_name}' has no delivery channel configured")
|
|
1027
|
+
continue
|
|
1028
|
+
|
|
1029
|
+
# Check if S3 bucket is configured
|
|
1030
|
+
s3_bucket = delivery_channel.get('S3BucketName')
|
|
1031
|
+
if not s3_bucket:
|
|
1032
|
+
issues.append(f"Report plan '{plan_name}' has no S3 bucket configured for delivery")
|
|
1033
|
+
continue
|
|
1034
|
+
|
|
1035
|
+
configured_plans += 1
|
|
1036
|
+
|
|
1037
|
+
# Check if plan has been executed
|
|
1038
|
+
last_successful = plan.get('LastSuccessfulExecutionTime')
|
|
1039
|
+
if last_successful:
|
|
1040
|
+
active_plans += 1
|
|
1041
|
+
|
|
1042
|
+
# Determine compliance
|
|
1043
|
+
if configured_plans == 0:
|
|
1044
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
1045
|
+
evaluation_reason = (
|
|
1046
|
+
f"Region {region} has {total_plans} report plan(s) but none are properly configured. "
|
|
1047
|
+
f"Issues: {'; '.join(issues)}"
|
|
1048
|
+
)
|
|
1049
|
+
elif configured_plans < total_plans:
|
|
1050
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
1051
|
+
evaluation_reason = (
|
|
1052
|
+
f"Region {region} has {configured_plans}/{total_plans} properly configured report plans "
|
|
1053
|
+
f"({active_plans} active). Issues: {'; '.join(issues)}"
|
|
1054
|
+
)
|
|
1055
|
+
else:
|
|
1056
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
1057
|
+
evaluation_reason = (
|
|
1058
|
+
f"Region {region} has {configured_plans} properly configured report plan(s) "
|
|
1059
|
+
f"({active_plans} actively generating reports)"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
return ComplianceResult(
|
|
1063
|
+
resource_id=resource_id,
|
|
1064
|
+
resource_type="AWS::Backup::ReportPlan",
|
|
1065
|
+
compliance_status=compliance_status,
|
|
1066
|
+
evaluation_reason=evaluation_reason,
|
|
1067
|
+
config_rule_name=self.rule_name,
|
|
1068
|
+
region=region
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
class BackupRestoreTestingPlanExistsCheckAssessment(BaseConfigRuleAssessment):
|
|
1073
|
+
"""Assessment for backup-restore-testing-plan-exists-check Config rule (IG2).
|
|
1074
|
+
|
|
1075
|
+
Validates that AWS Backup has restore testing plans configured to ensure
|
|
1076
|
+
backups are actually recoverable and meet RTO/RPO requirements.
|
|
1077
|
+
|
|
1078
|
+
Compliance Criteria:
|
|
1079
|
+
- At least one restore testing plan should exist
|
|
1080
|
+
- Testing plans should be actively running
|
|
1081
|
+
- Critical backup vaults should be included in testing
|
|
1082
|
+
- Testing frequency should be appropriate
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
def __init__(self):
|
|
1086
|
+
"""Initialize backup restore testing plan assessment."""
|
|
1087
|
+
super().__init__(
|
|
1088
|
+
rule_name="backup-restore-testing-plan-exists-check",
|
|
1089
|
+
control_id="11.3",
|
|
1090
|
+
resource_types=["AWS::Backup::RestoreTestingPlan"]
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
def _get_resources(self, aws_factory: AWSClientFactory, resource_type: str, region: str) -> List[Dict[str, Any]]:
|
|
1094
|
+
"""Get all AWS Backup restore testing plans.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
aws_factory: AWS client factory for API calls
|
|
1098
|
+
resource_type: Type of resource to retrieve
|
|
1099
|
+
region: AWS region
|
|
1100
|
+
|
|
1101
|
+
Returns:
|
|
1102
|
+
List of restore testing plans
|
|
1103
|
+
"""
|
|
1104
|
+
if resource_type != "AWS::Backup::RestoreTestingPlan":
|
|
1105
|
+
return []
|
|
1106
|
+
|
|
1107
|
+
try:
|
|
1108
|
+
backup_client = aws_factory.get_client('backup', region)
|
|
1109
|
+
|
|
1110
|
+
# List all restore testing plans
|
|
1111
|
+
try:
|
|
1112
|
+
response = aws_factory.aws_api_call_with_retry(
|
|
1113
|
+
lambda: backup_client.list_restore_testing_plans()
|
|
1114
|
+
)
|
|
1115
|
+
except AttributeError:
|
|
1116
|
+
# API might not be available in all regions or SDK versions
|
|
1117
|
+
logger.warning(f"Restore testing API not available in region {region}")
|
|
1118
|
+
return [{
|
|
1119
|
+
'AccountId': aws_factory.account_id,
|
|
1120
|
+
'Region': region,
|
|
1121
|
+
'RestoreTestingPlans': [],
|
|
1122
|
+
'TotalPlans': 0,
|
|
1123
|
+
'ApiNotAvailable': True
|
|
1124
|
+
}]
|
|
1125
|
+
|
|
1126
|
+
testing_plans = []
|
|
1127
|
+
for plan in response.get('RestoreTestingPlans', []):
|
|
1128
|
+
plan_name = plan.get('RestoreTestingPlanName')
|
|
1129
|
+
|
|
1130
|
+
# Get detailed testing plan information
|
|
1131
|
+
try:
|
|
1132
|
+
plan_details = aws_factory.aws_api_call_with_retry(
|
|
1133
|
+
lambda: backup_client.get_restore_testing_plan(
|
|
1134
|
+
RestoreTestingPlanName=plan_name
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
testing_plan = plan_details.get('RestoreTestingPlan', {})
|
|
1139
|
+
testing_plans.append({
|
|
1140
|
+
'RestoreTestingPlanName': plan_name,
|
|
1141
|
+
'RestoreTestingPlanArn': testing_plan.get('RestoreTestingPlanArn'),
|
|
1142
|
+
'ScheduleExpression': testing_plan.get('ScheduleExpression'),
|
|
1143
|
+
'StartWindowHours': testing_plan.get('StartWindowHours'),
|
|
1144
|
+
'CreationTime': testing_plan.get('CreationTime'),
|
|
1145
|
+
'LastExecutionTime': testing_plan.get('LastExecutionTime'),
|
|
1146
|
+
'LastUpdateTime': testing_plan.get('LastUpdateTime')
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
except ClientError as e:
|
|
1150
|
+
logger.warning(f"Could not get details for restore testing plan {plan_name}: {e}")
|
|
1151
|
+
testing_plans.append({
|
|
1152
|
+
'RestoreTestingPlanName': plan_name,
|
|
1153
|
+
'Error': str(e)
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
logger.info(f"Retrieved {len(testing_plans)} restore testing plan(s) in region {region}")
|
|
1157
|
+
|
|
1158
|
+
# Return account-level resource
|
|
1159
|
+
if testing_plans:
|
|
1160
|
+
return [{
|
|
1161
|
+
'AccountId': aws_factory.account_id,
|
|
1162
|
+
'Region': region,
|
|
1163
|
+
'RestoreTestingPlans': testing_plans,
|
|
1164
|
+
'TotalPlans': len(testing_plans),
|
|
1165
|
+
'ApiNotAvailable': False
|
|
1166
|
+
}]
|
|
1167
|
+
else:
|
|
1168
|
+
return [{
|
|
1169
|
+
'AccountId': aws_factory.account_id,
|
|
1170
|
+
'Region': region,
|
|
1171
|
+
'RestoreTestingPlans': [],
|
|
1172
|
+
'TotalPlans': 0,
|
|
1173
|
+
'ApiNotAvailable': False
|
|
1174
|
+
}]
|
|
1175
|
+
|
|
1176
|
+
except ClientError as e:
|
|
1177
|
+
if e.response.get('Error', {}).get('Code') in ['AccessDenied', 'UnauthorizedOperation']:
|
|
1178
|
+
logger.warning(f"Insufficient permissions to list restore testing plans in region {region}")
|
|
1179
|
+
return []
|
|
1180
|
+
logger.error(f"Error retrieving restore testing plans in region {region}: {e}")
|
|
1181
|
+
raise
|
|
1182
|
+
|
|
1183
|
+
def _evaluate_resource_compliance(self, resource: Dict[str, Any], aws_factory: AWSClientFactory, region: str) -> ComplianceResult:
|
|
1184
|
+
"""Evaluate if restore testing plans exist and are active.
|
|
1185
|
+
|
|
1186
|
+
Args:
|
|
1187
|
+
resource: Account-level resource with testing plans
|
|
1188
|
+
aws_factory: AWS client factory
|
|
1189
|
+
region: AWS region
|
|
1190
|
+
|
|
1191
|
+
Returns:
|
|
1192
|
+
ComplianceResult with evaluation details
|
|
1193
|
+
"""
|
|
1194
|
+
account_id = resource.get('AccountId', 'unknown')
|
|
1195
|
+
testing_plans = resource.get('RestoreTestingPlans', [])
|
|
1196
|
+
total_plans = resource.get('TotalPlans', 0)
|
|
1197
|
+
api_not_available = resource.get('ApiNotAvailable', False)
|
|
1198
|
+
|
|
1199
|
+
resource_id = f"backup-restore-testing-{region}"
|
|
1200
|
+
|
|
1201
|
+
# Check if API is available
|
|
1202
|
+
if api_not_available:
|
|
1203
|
+
return ComplianceResult(
|
|
1204
|
+
resource_id=resource_id,
|
|
1205
|
+
resource_type="AWS::Backup::RestoreTestingPlan",
|
|
1206
|
+
compliance_status=ComplianceStatus.NOT_APPLICABLE,
|
|
1207
|
+
evaluation_reason=f"Restore testing API not available in region {region}",
|
|
1208
|
+
config_rule_name=self.rule_name,
|
|
1209
|
+
region=region
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
# Check if any testing plans exist
|
|
1213
|
+
if total_plans == 0:
|
|
1214
|
+
return ComplianceResult(
|
|
1215
|
+
resource_id=resource_id,
|
|
1216
|
+
resource_type="AWS::Backup::RestoreTestingPlan",
|
|
1217
|
+
compliance_status=ComplianceStatus.NON_COMPLIANT,
|
|
1218
|
+
evaluation_reason=f"No restore testing plans configured in region {region} (backup recoverability not validated)",
|
|
1219
|
+
config_rule_name=self.rule_name,
|
|
1220
|
+
region=region
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
# Analyze testing plans
|
|
1224
|
+
active_plans = 0
|
|
1225
|
+
scheduled_plans = 0
|
|
1226
|
+
issues = []
|
|
1227
|
+
|
|
1228
|
+
for plan in testing_plans:
|
|
1229
|
+
plan_name = plan.get('RestoreTestingPlanName', 'unnamed')
|
|
1230
|
+
error = plan.get('Error')
|
|
1231
|
+
|
|
1232
|
+
if error:
|
|
1233
|
+
issues.append(f"Testing plan '{plan_name}' could not be evaluated: {error}")
|
|
1234
|
+
continue
|
|
1235
|
+
|
|
1236
|
+
# Check if plan has a schedule
|
|
1237
|
+
schedule = plan.get('ScheduleExpression')
|
|
1238
|
+
if not schedule:
|
|
1239
|
+
issues.append(f"Testing plan '{plan_name}' has no schedule configured")
|
|
1240
|
+
continue
|
|
1241
|
+
|
|
1242
|
+
scheduled_plans += 1
|
|
1243
|
+
|
|
1244
|
+
# Check if plan has been executed
|
|
1245
|
+
last_execution = plan.get('LastExecutionTime')
|
|
1246
|
+
if last_execution:
|
|
1247
|
+
active_plans += 1
|
|
1248
|
+
|
|
1249
|
+
# Determine compliance
|
|
1250
|
+
if scheduled_plans == 0:
|
|
1251
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
1252
|
+
evaluation_reason = (
|
|
1253
|
+
f"Region {region} has {total_plans} restore testing plan(s) but none are properly scheduled. "
|
|
1254
|
+
f"Issues: {'; '.join(issues)}"
|
|
1255
|
+
)
|
|
1256
|
+
elif scheduled_plans < total_plans:
|
|
1257
|
+
compliance_status = ComplianceStatus.NON_COMPLIANT
|
|
1258
|
+
evaluation_reason = (
|
|
1259
|
+
f"Region {region} has {scheduled_plans}/{total_plans} properly scheduled testing plans "
|
|
1260
|
+
f"({active_plans} have executed). Issues: {'; '.join(issues)}"
|
|
1261
|
+
)
|
|
1262
|
+
else:
|
|
1263
|
+
compliance_status = ComplianceStatus.COMPLIANT
|
|
1264
|
+
evaluation_reason = (
|
|
1265
|
+
f"Region {region} has {scheduled_plans} properly configured restore testing plan(s) "
|
|
1266
|
+
f"({active_plans} have executed tests)"
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
return ComplianceResult(
|
|
1270
|
+
resource_id=resource_id,
|
|
1271
|
+
resource_type="AWS::Backup::RestoreTestingPlan",
|
|
1272
|
+
compliance_status=compliance_status,
|
|
1273
|
+
evaluation_reason=evaluation_reason,
|
|
1274
|
+
config_rule_name=self.rule_name,
|
|
1275
|
+
region=region
|
|
1276
|
+
)
|