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.
@@ -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
+ )