regscale-cli 6.27.2.0__py3-none-any.whl → 6.28.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (140) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +1 -0
  3. regscale/core/app/internal/control_editor.py +73 -21
  4. regscale/core/app/internal/login.py +4 -1
  5. regscale/core/app/internal/model_editor.py +219 -64
  6. regscale/core/app/utils/app_utils.py +11 -2
  7. regscale/core/login.py +21 -4
  8. regscale/core/utils/date.py +77 -1
  9. regscale/dev/cli.py +26 -0
  10. regscale/dev/version.py +72 -0
  11. regscale/integrations/commercial/__init__.py +15 -1
  12. regscale/integrations/commercial/amazon/amazon/__init__.py +0 -0
  13. regscale/integrations/commercial/amazon/amazon/common.py +204 -0
  14. regscale/integrations/commercial/amazon/common.py +48 -58
  15. regscale/integrations/commercial/aws/audit_manager_compliance.py +2671 -0
  16. regscale/integrations/commercial/aws/cli.py +3093 -55
  17. regscale/integrations/commercial/aws/cloudtrail_control_mappings.py +333 -0
  18. regscale/integrations/commercial/aws/cloudtrail_evidence.py +501 -0
  19. regscale/integrations/commercial/aws/cloudwatch_control_mappings.py +357 -0
  20. regscale/integrations/commercial/aws/cloudwatch_evidence.py +490 -0
  21. regscale/integrations/commercial/aws/config_compliance.py +914 -0
  22. regscale/integrations/commercial/aws/conformance_pack_mappings.py +198 -0
  23. regscale/integrations/commercial/aws/evidence_generator.py +283 -0
  24. regscale/integrations/commercial/aws/guardduty_control_mappings.py +340 -0
  25. regscale/integrations/commercial/aws/guardduty_evidence.py +1053 -0
  26. regscale/integrations/commercial/aws/iam_control_mappings.py +368 -0
  27. regscale/integrations/commercial/aws/iam_evidence.py +574 -0
  28. regscale/integrations/commercial/aws/inventory/__init__.py +223 -22
  29. regscale/integrations/commercial/aws/inventory/base.py +107 -5
  30. regscale/integrations/commercial/aws/inventory/resources/audit_manager.py +513 -0
  31. regscale/integrations/commercial/aws/inventory/resources/cloudtrail.py +315 -0
  32. regscale/integrations/commercial/aws/inventory/resources/cloudtrail_logs_metadata.py +476 -0
  33. regscale/integrations/commercial/aws/inventory/resources/cloudwatch.py +191 -0
  34. regscale/integrations/commercial/aws/inventory/resources/compute.py +66 -9
  35. regscale/integrations/commercial/aws/inventory/resources/config.py +464 -0
  36. regscale/integrations/commercial/aws/inventory/resources/containers.py +74 -9
  37. regscale/integrations/commercial/aws/inventory/resources/database.py +106 -31
  38. regscale/integrations/commercial/aws/inventory/resources/guardduty.py +286 -0
  39. regscale/integrations/commercial/aws/inventory/resources/iam.py +470 -0
  40. regscale/integrations/commercial/aws/inventory/resources/inspector.py +476 -0
  41. regscale/integrations/commercial/aws/inventory/resources/integration.py +175 -61
  42. regscale/integrations/commercial/aws/inventory/resources/kms.py +447 -0
  43. regscale/integrations/commercial/aws/inventory/resources/networking.py +103 -67
  44. regscale/integrations/commercial/aws/inventory/resources/s3.py +394 -0
  45. regscale/integrations/commercial/aws/inventory/resources/security.py +268 -72
  46. regscale/integrations/commercial/aws/inventory/resources/securityhub.py +473 -0
  47. regscale/integrations/commercial/aws/inventory/resources/storage.py +53 -29
  48. regscale/integrations/commercial/aws/inventory/resources/systems_manager.py +657 -0
  49. regscale/integrations/commercial/aws/inventory/resources/vpc.py +655 -0
  50. regscale/integrations/commercial/aws/kms_control_mappings.py +288 -0
  51. regscale/integrations/commercial/aws/kms_evidence.py +879 -0
  52. regscale/integrations/commercial/aws/ocsf/__init__.py +7 -0
  53. regscale/integrations/commercial/aws/ocsf/constants.py +115 -0
  54. regscale/integrations/commercial/aws/ocsf/mapper.py +435 -0
  55. regscale/integrations/commercial/aws/org_control_mappings.py +286 -0
  56. regscale/integrations/commercial/aws/org_evidence.py +666 -0
  57. regscale/integrations/commercial/aws/s3_control_mappings.py +356 -0
  58. regscale/integrations/commercial/aws/s3_evidence.py +632 -0
  59. regscale/integrations/commercial/aws/scanner.py +853 -205
  60. regscale/integrations/commercial/aws/security_hub.py +319 -0
  61. regscale/integrations/commercial/aws/session_manager.py +282 -0
  62. regscale/integrations/commercial/aws/ssm_control_mappings.py +291 -0
  63. regscale/integrations/commercial/aws/ssm_evidence.py +492 -0
  64. regscale/integrations/commercial/synqly/query_builder.py +4 -1
  65. regscale/integrations/compliance_integration.py +308 -38
  66. regscale/integrations/control_matcher.py +78 -23
  67. regscale/integrations/due_date_handler.py +3 -0
  68. regscale/integrations/public/csam/csam.py +572 -763
  69. regscale/integrations/public/csam/csam_agency_defined.py +179 -0
  70. regscale/integrations/public/csam/csam_common.py +154 -0
  71. regscale/integrations/public/csam/csam_controls.py +432 -0
  72. regscale/integrations/public/csam/csam_poam.py +124 -0
  73. regscale/integrations/public/fedramp/click.py +17 -4
  74. regscale/integrations/public/fedramp/fedramp_cis_crm.py +271 -62
  75. regscale/integrations/public/fedramp/poam/scanner.py +74 -7
  76. regscale/integrations/scanner_integration.py +415 -85
  77. regscale/models/integration_models/cisa_kev_data.json +80 -20
  78. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  79. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +44 -3
  80. regscale/models/integration_models/synqly_models/ocsf_mapper.py +41 -12
  81. regscale/models/platform.py +3 -0
  82. regscale/models/regscale_models/__init__.py +5 -0
  83. regscale/models/regscale_models/assessment.py +2 -1
  84. regscale/models/regscale_models/component.py +1 -1
  85. regscale/models/regscale_models/control_implementation.py +55 -24
  86. regscale/models/regscale_models/control_objective.py +74 -5
  87. regscale/models/regscale_models/file.py +2 -0
  88. regscale/models/regscale_models/issue.py +2 -5
  89. regscale/models/regscale_models/organization.py +3 -0
  90. regscale/models/regscale_models/regscale_model.py +17 -5
  91. regscale/models/regscale_models/security_plan.py +1 -0
  92. regscale/regscale.py +11 -1
  93. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/METADATA +1 -1
  94. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/RECORD +140 -57
  95. tests/regscale/core/test_login.py +171 -4
  96. tests/regscale/integrations/commercial/aws/__init__.py +0 -0
  97. tests/regscale/integrations/commercial/aws/test_audit_manager_compliance.py +1304 -0
  98. tests/regscale/integrations/commercial/aws/test_audit_manager_evidence_aggregation.py +341 -0
  99. tests/regscale/integrations/commercial/aws/test_aws_audit_manager_collector.py +1155 -0
  100. tests/regscale/integrations/commercial/aws/test_aws_cloudtrail_collector.py +534 -0
  101. tests/regscale/integrations/commercial/aws/test_aws_config_collector.py +400 -0
  102. tests/regscale/integrations/commercial/aws/test_aws_guardduty_collector.py +315 -0
  103. tests/regscale/integrations/commercial/aws/test_aws_iam_collector.py +458 -0
  104. tests/regscale/integrations/commercial/aws/test_aws_inspector_collector.py +353 -0
  105. tests/regscale/integrations/commercial/aws/test_aws_inventory_integration.py +530 -0
  106. tests/regscale/integrations/commercial/aws/test_aws_kms_collector.py +919 -0
  107. tests/regscale/integrations/commercial/aws/test_aws_s3_collector.py +722 -0
  108. tests/regscale/integrations/commercial/aws/test_aws_scanner_integration.py +722 -0
  109. tests/regscale/integrations/commercial/aws/test_aws_securityhub_collector.py +792 -0
  110. tests/regscale/integrations/commercial/aws/test_aws_systems_manager_collector.py +918 -0
  111. tests/regscale/integrations/commercial/aws/test_aws_vpc_collector.py +996 -0
  112. tests/regscale/integrations/commercial/aws/test_cli_evidence.py +431 -0
  113. tests/regscale/integrations/commercial/aws/test_cloudtrail_control_mappings.py +452 -0
  114. tests/regscale/integrations/commercial/aws/test_cloudtrail_evidence.py +788 -0
  115. tests/regscale/integrations/commercial/aws/test_config_compliance.py +298 -0
  116. tests/regscale/integrations/commercial/aws/test_conformance_pack_mappings.py +200 -0
  117. tests/regscale/integrations/commercial/aws/test_evidence_generator.py +386 -0
  118. tests/regscale/integrations/commercial/aws/test_guardduty_control_mappings.py +564 -0
  119. tests/regscale/integrations/commercial/aws/test_guardduty_evidence.py +1041 -0
  120. tests/regscale/integrations/commercial/aws/test_iam_control_mappings.py +718 -0
  121. tests/regscale/integrations/commercial/aws/test_iam_evidence.py +1375 -0
  122. tests/regscale/integrations/commercial/aws/test_kms_control_mappings.py +656 -0
  123. tests/regscale/integrations/commercial/aws/test_kms_evidence.py +1163 -0
  124. tests/regscale/integrations/commercial/aws/test_ocsf_mapper.py +370 -0
  125. tests/regscale/integrations/commercial/aws/test_org_control_mappings.py +546 -0
  126. tests/regscale/integrations/commercial/aws/test_org_evidence.py +1240 -0
  127. tests/regscale/integrations/commercial/aws/test_s3_control_mappings.py +672 -0
  128. tests/regscale/integrations/commercial/aws/test_s3_evidence.py +987 -0
  129. tests/regscale/integrations/commercial/aws/test_scanner_evidence.py +373 -0
  130. tests/regscale/integrations/commercial/aws/test_security_hub_config_filtering.py +539 -0
  131. tests/regscale/integrations/commercial/aws/test_session_manager.py +516 -0
  132. tests/regscale/integrations/commercial/aws/test_ssm_control_mappings.py +588 -0
  133. tests/regscale/integrations/commercial/aws/test_ssm_evidence.py +735 -0
  134. tests/regscale/integrations/commercial/test_aws.py +55 -56
  135. tests/regscale/integrations/test_control_matcher.py +24 -0
  136. tests/regscale/models/test_control_implementation.py +118 -3
  137. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/LICENSE +0 -0
  138. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/WHEEL +0 -0
  139. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/entry_points.txt +0 -0
  140. {regscale_cli-6.27.2.0.dist-info → regscale_cli-6.28.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,914 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """AWS Config Compliance Integration for RegScale CLI."""
4
+
5
+ import gzip
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+ from io import BytesIO
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import boto3
16
+ from botocore.exceptions import ClientError
17
+
18
+ from regscale.core.app.utils.app_utils import get_current_datetime
19
+ from regscale.integrations.compliance_integration import ComplianceIntegration, ComplianceItem
20
+
21
+ logger = logging.getLogger("regscale")
22
+
23
+ # Constants for file paths and cache TTL
24
+ CONFIG_COMPLIANCE_CACHE_FILE = os.path.join("artifacts", "aws", "config_compliance_assessments.json")
25
+ CACHE_TTL_SECONDS = 4 * 60 * 60 # 4 hours in seconds
26
+
27
+ # HTML tag constants to avoid duplication
28
+ HTML_STRONG_OPEN = "<strong>"
29
+ HTML_STRONG_CLOSE = "</strong>"
30
+ HTML_P_OPEN = "<p>"
31
+ HTML_P_CLOSE = "</p>"
32
+ HTML_UL_OPEN = "<ul>"
33
+ HTML_UL_CLOSE = "</ul>"
34
+ HTML_LI_OPEN = "<li>"
35
+ HTML_LI_CLOSE = "</li>"
36
+ HTML_H2_OPEN = "<h2>"
37
+ HTML_H2_CLOSE = "</h2>"
38
+ HTML_H3_OPEN = "<h3>"
39
+ HTML_H3_CLOSE = "</h3>"
40
+ HTML_H4_OPEN = "<h4>"
41
+ HTML_H4_CLOSE = "</h4>"
42
+ HTML_BR = "<br>"
43
+
44
+
45
+ class AWSConfigComplianceItem(ComplianceItem):
46
+ """
47
+ Compliance item from AWS Config rule evaluation.
48
+
49
+ Represents a control assessment based on AWS Config rule evaluations.
50
+ Multiple Config rules can map to a single control, and the control passes
51
+ only if ALL associated rules are compliant.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ control_id: str,
57
+ control_name: str,
58
+ framework: str,
59
+ rule_evaluations: List[Dict[str, Any]],
60
+ resource_id: Optional[str] = None,
61
+ resource_name: Optional[str] = None,
62
+ ):
63
+ """
64
+ Initialize from AWS Config rule evaluations.
65
+
66
+ :param str control_id: Control identifier (e.g., AC-2, SI-3)
67
+ :param str control_name: Human-readable control name
68
+ :param str framework: Compliance framework
69
+ :param List[Dict[str, Any]] rule_evaluations: Config rule evaluation results
70
+ :param Optional[str] resource_id: Resource identifier (AWS account ID typically)
71
+ :param Optional[str] resource_name: Resource name
72
+ """
73
+ self._control_id = control_id
74
+ self._control_name = control_name
75
+ self._framework = framework
76
+ self.rule_evaluations = rule_evaluations
77
+ self._resource_id = resource_id or ""
78
+ self._resource_name = resource_name or ""
79
+
80
+ # Cache for aggregated compliance result
81
+ self._aggregated_compliance_result = None
82
+
83
+ @property
84
+ def resource_id(self) -> str:
85
+ """Unique identifier for the resource being assessed."""
86
+ return self._resource_id
87
+
88
+ @property
89
+ def resource_name(self) -> str:
90
+ """Human-readable name of the resource."""
91
+ return self._resource_name
92
+
93
+ @property
94
+ def control_id(self) -> str:
95
+ """Control identifier (e.g., AC-3, SI-2)."""
96
+ return self._control_id
97
+
98
+ def _aggregate_rule_compliance(self) -> Optional[str]:
99
+ """
100
+ Aggregate rule evaluation results to determine overall control compliance.
101
+
102
+ AWS Config rule compliance types:
103
+ - "COMPLIANT": Resource is compliant with the rule
104
+ - "NON_COMPLIANT": Resource violates the rule
105
+ - "NOT_APPLICABLE": Rule doesn't apply to this resource
106
+ - "INSUFFICIENT_DATA": Not enough data to determine compliance
107
+
108
+ Aggregation Logic:
109
+ 1. If ANY rule shows "NON_COMPLIANT" → Control FAILS
110
+ 2. If ALL applicable rules show "COMPLIANT" → Control PASSES
111
+ 3. If only NOT_APPLICABLE or INSUFFICIENT_DATA → INCONCLUSIVE
112
+
113
+ :return: "PASS", "FAIL", or None (if inconclusive/no data)
114
+ :rtype: Optional[str]
115
+ """
116
+ if not self.rule_evaluations:
117
+ logger.debug(f"Control {self.control_id}: No rule evaluations available")
118
+ return None
119
+
120
+ compliant_count = 0
121
+ non_compliant_count = 0
122
+ not_applicable_count = 0
123
+ insufficient_data_count = 0
124
+
125
+ for evaluation in self.rule_evaluations:
126
+ compliance_type = evaluation.get("compliance_type", "").upper()
127
+
128
+ if compliance_type == "NON_COMPLIANT":
129
+ non_compliant_count += 1
130
+ elif compliance_type == "COMPLIANT":
131
+ compliant_count += 1
132
+ elif compliance_type == "NOT_APPLICABLE":
133
+ not_applicable_count += 1
134
+ else: # INSUFFICIENT_DATA or other
135
+ insufficient_data_count += 1
136
+
137
+ total_evaluations = len(self.rule_evaluations)
138
+
139
+ logger.debug(
140
+ f"Control {self.control_id} rule summary: "
141
+ f"{non_compliant_count} NON_COMPLIANT, {compliant_count} COMPLIANT, "
142
+ f"{not_applicable_count} NOT_APPLICABLE, {insufficient_data_count} INSUFFICIENT_DATA "
143
+ f"out of {total_evaluations} total"
144
+ )
145
+
146
+ # If ANY rule is non-compliant, the control fails
147
+ if non_compliant_count > 0:
148
+ logger.info(
149
+ f"Control {self.control_id} FAILS: {non_compliant_count} non-compliant rule(s) "
150
+ f"out of {total_evaluations}"
151
+ )
152
+ return "FAIL"
153
+
154
+ # If we have compliant rules and no failures, control passes
155
+ if compliant_count > 0:
156
+ if not_applicable_count > 0 or insufficient_data_count > 0:
157
+ logger.info(
158
+ f"Control {self.control_id} PASSES: {compliant_count} compliant, "
159
+ f"{not_applicable_count} not applicable, "
160
+ f"{insufficient_data_count} insufficient data (no failures)"
161
+ )
162
+ else:
163
+ logger.info(f"Control {self.control_id} PASSES: All {compliant_count} rules compliant")
164
+ return "PASS"
165
+
166
+ # If no applicable compliance checks available, we cannot determine status
167
+ logger.warning(
168
+ f"Control {self.control_id}: No conclusive compliance checks in {total_evaluations} evaluation(s)"
169
+ )
170
+ return None
171
+
172
+ @property
173
+ def compliance_result(self) -> Optional[str]:
174
+ """
175
+ Result of compliance check (PASS, FAIL, etc).
176
+
177
+ Aggregates Config rule evaluations to determine control-level compliance.
178
+
179
+ :return: "PASS", "FAIL", or None (if no conclusive data available)
180
+ :rtype: Optional[str]
181
+ """
182
+ # Use cached result if available
183
+ if self._aggregated_compliance_result is not None or hasattr(self, "_result_was_cached"):
184
+ return self._aggregated_compliance_result
185
+
186
+ # Aggregate rule compliance checks
187
+ result = self._aggregate_rule_compliance()
188
+
189
+ if result is None:
190
+ logger.info(
191
+ f"Control {self.control_id}: No conclusive data for compliance determination. "
192
+ f"Control status will not be updated. Rule evaluations: {len(self.rule_evaluations)}"
193
+ )
194
+
195
+ # Cache the result (including None)
196
+ self._aggregated_compliance_result = result
197
+ self._result_was_cached = True
198
+ return result
199
+
200
+ @property
201
+ def severity(self) -> Optional[str]:
202
+ """Severity level of the compliance violation (if failed)."""
203
+ if self.compliance_result != "FAIL":
204
+ return None
205
+
206
+ # Determine severity based on number of non-compliant rules
207
+ non_compliant_count = sum(
208
+ 1 for eval in self.rule_evaluations if eval.get("compliance_type", "").upper() == "NON_COMPLIANT"
209
+ )
210
+
211
+ if non_compliant_count >= 5:
212
+ return "HIGH"
213
+ elif non_compliant_count >= 2:
214
+ return "MEDIUM"
215
+ return "LOW"
216
+
217
+ @property
218
+ def description(self) -> str:
219
+ """Description of the compliance check using HTML formatting."""
220
+ desc_parts = [
221
+ f"{HTML_H3_OPEN}AWS Config compliance assessment for control {self.control_id}{HTML_H3_CLOSE}",
222
+ HTML_P_OPEN,
223
+ f"{HTML_STRONG_OPEN}Control:{HTML_STRONG_CLOSE} {self._control_name}{HTML_BR}",
224
+ f"{HTML_STRONG_OPEN}Framework:{HTML_STRONG_CLOSE} {self._framework}{HTML_BR}",
225
+ f"{HTML_STRONG_OPEN}Total Rules:{HTML_STRONG_CLOSE} {len(self.rule_evaluations)}",
226
+ HTML_P_CLOSE,
227
+ ]
228
+
229
+ # Add compliance summary
230
+ compliant_rules = [e for e in self.rule_evaluations if e.get("compliance_type") == "COMPLIANT"]
231
+ non_compliant_rules = [e for e in self.rule_evaluations if e.get("compliance_type") == "NON_COMPLIANT"]
232
+
233
+ desc_parts.extend(
234
+ [
235
+ f"{HTML_H4_OPEN}Compliance Summary{HTML_H4_CLOSE}",
236
+ HTML_UL_OPEN,
237
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}Compliant Rules:{HTML_STRONG_CLOSE} {len(compliant_rules)}"
238
+ f"{HTML_LI_CLOSE}",
239
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}Non-Compliant Rules:{HTML_STRONG_CLOSE} {len(non_compliant_rules)}"
240
+ f"{HTML_LI_CLOSE}",
241
+ HTML_UL_CLOSE,
242
+ ]
243
+ )
244
+
245
+ # Add non-compliant rules details
246
+ if non_compliant_rules:
247
+ desc_parts.append(f"{HTML_H4_OPEN}Non-Compliant Rules{HTML_H4_CLOSE}")
248
+ desc_parts.append(HTML_UL_OPEN)
249
+ for rule_eval in non_compliant_rules[:10]: # Show up to 10 non-compliant rules
250
+ rule_name = rule_eval.get("rule_name", "Unknown")
251
+ resource_count = rule_eval.get("non_compliant_resource_count", 0)
252
+ desc_parts.append(
253
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}{rule_name}{HTML_STRONG_CLOSE}: "
254
+ f"{resource_count} non-compliant resource(s){HTML_LI_CLOSE}"
255
+ )
256
+ if len(non_compliant_rules) > 10:
257
+ desc_parts.append(
258
+ f"{HTML_LI_OPEN}... and {len(non_compliant_rules) - 10} more non-compliant rule(s){HTML_LI_CLOSE}"
259
+ )
260
+ desc_parts.append(HTML_UL_CLOSE)
261
+
262
+ return "\n".join(desc_parts)
263
+
264
+ @property
265
+ def framework(self) -> str:
266
+ """Compliance framework (e.g., NIST800-53R5, CSF)."""
267
+ return self._framework
268
+
269
+
270
+ @dataclass
271
+ class ConfigEvidenceConfig:
272
+ """Configuration for evidence collection from AWS Config."""
273
+
274
+ collect_evidence: bool = False
275
+ evidence_as_attachments: bool = True
276
+ evidence_as_records: bool = False
277
+ evidence_control_ids: Optional[List[str]] = None
278
+ evidence_frequency: int = 30
279
+
280
+
281
+ @dataclass
282
+ class ConfigFilterConfig:
283
+ """Configuration for filtering AWS Config resources."""
284
+
285
+ account_id: Optional[str] = None
286
+ tags: Optional[Dict[str, str]] = None
287
+ conformance_pack_name: Optional[str] = None
288
+
289
+
290
+ class AWSConfigCompliance(ComplianceIntegration):
291
+ """Process AWS Config compliance assessments and create compliance records in RegScale."""
292
+
293
+ def __init__(
294
+ self,
295
+ plan_id: int,
296
+ region: str = "us-east-1",
297
+ framework: str = "NIST800-53R5",
298
+ create_issues: bool = True,
299
+ update_control_status: bool = True,
300
+ create_poams: bool = False,
301
+ parent_module: str = "securityplans",
302
+ evidence_config: Optional[ConfigEvidenceConfig] = None,
303
+ filter_config: Optional[ConfigFilterConfig] = None,
304
+ use_security_hub: bool = False,
305
+ force_refresh: bool = False,
306
+ **kwargs,
307
+ ):
308
+ """
309
+ Initialize AWS Config compliance integration.
310
+
311
+ :param int plan_id: RegScale plan ID
312
+ :param str region: AWS region
313
+ :param str framework: Compliance framework
314
+ :param bool create_issues: Whether to create issues for failed compliance
315
+ :param bool update_control_status: Whether to update control implementation status
316
+ :param bool create_poams: Whether to mark issues as POAMs
317
+ :param str parent_module: RegScale parent module
318
+ :param Optional[ConfigEvidenceConfig] evidence_config: Evidence collection configuration
319
+ :param Optional[ConfigFilterConfig] filter_config: Resource filtering configuration
320
+ :param bool use_security_hub: Include Security Hub control findings
321
+ :param bool force_refresh: Force refresh of compliance data by bypassing cache
322
+ :param kwargs: Additional parameters including AWS credentials
323
+ """
324
+ super().__init__(
325
+ plan_id=plan_id,
326
+ framework=framework,
327
+ create_issues=create_issues,
328
+ update_control_status=update_control_status,
329
+ create_poams=create_poams,
330
+ parent_module=parent_module,
331
+ **kwargs,
332
+ )
333
+
334
+ self.region = region
335
+ self.title = "AWS Config"
336
+
337
+ # Evidence collection parameters
338
+ self.evidence_config = evidence_config or ConfigEvidenceConfig()
339
+ self.collect_evidence = self.evidence_config.collect_evidence
340
+ self.evidence_as_attachments = self.evidence_config.evidence_as_attachments
341
+ self.evidence_as_records = self.evidence_config.evidence_as_records
342
+ self.evidence_control_ids = self.evidence_config.evidence_control_ids
343
+ self.evidence_frequency = self.evidence_config.evidence_frequency
344
+
345
+ # Filtering parameters
346
+ self.filter_config = filter_config or ConfigFilterConfig()
347
+ self.conformance_pack_name = self.filter_config.conformance_pack_name
348
+ self.account_id = self.filter_config.account_id
349
+ self.tags = self.filter_config.tags or {}
350
+
351
+ # Security Hub integration
352
+ self.use_security_hub = use_security_hub
353
+
354
+ # Cache control
355
+ self.force_refresh = force_refresh
356
+
357
+ # Extract AWS credentials from kwargs
358
+ profile = kwargs.get("profile")
359
+ aws_access_key_id = kwargs.get("aws_access_key_id")
360
+ aws_secret_access_key = kwargs.get("aws_secret_access_key")
361
+ aws_session_token = kwargs.get("aws_session_token")
362
+
363
+ # Initialize AWS session
364
+ if aws_access_key_id and aws_secret_access_key:
365
+ logger.info("Initializing AWS Config client with explicit credentials")
366
+ self.session = boto3.Session(
367
+ region_name=region,
368
+ aws_access_key_id=aws_access_key_id,
369
+ aws_secret_access_key=aws_secret_access_key,
370
+ aws_session_token=aws_session_token,
371
+ )
372
+ else:
373
+ logger.info(f"Initializing AWS Config client with profile: {profile if profile else 'default'}")
374
+ self.session = boto3.Session(profile_name=profile, region_name=region)
375
+
376
+ try:
377
+ self.config_client = self.session.client("config")
378
+ logger.info("Successfully created AWS Config client")
379
+
380
+ if self.use_security_hub:
381
+ self.securityhub_client = self.session.client("securityhub")
382
+ logger.info("Successfully created AWS Security Hub client")
383
+ except Exception as e:
384
+ logger.error(f"Failed to create AWS client: {e}")
385
+ raise
386
+
387
+ def fetch_compliance_data(self) -> List[Dict[str, Any]]:
388
+ """
389
+ Fetch raw compliance data from AWS Config.
390
+
391
+ Returns control-level compliance data aggregated from Config rules.
392
+
393
+ :return: List of raw compliance data (control + rule evaluations)
394
+ :rtype: List[Dict[str, Any]]
395
+ """
396
+ # Check cache first unless force refresh
397
+ if not self.force_refresh and self._is_cache_valid():
398
+ cached_data = self._load_cached_data()
399
+ if cached_data:
400
+ return cached_data
401
+
402
+ if self.force_refresh:
403
+ logger.info("Force refresh requested, fetching fresh data from AWS Config...")
404
+
405
+ try:
406
+ compliance_data = self._fetch_fresh_compliance_data()
407
+ self._save_to_cache(compliance_data)
408
+ return compliance_data
409
+ except ClientError as e:
410
+ logger.error(f"Error fetching compliance data from AWS Config: {e}")
411
+ return []
412
+
413
+ def _fetch_fresh_compliance_data(self) -> List[Dict[str, Any]]:
414
+ """
415
+ Fetch fresh compliance data from AWS Config.
416
+
417
+ :return: List of raw compliance data
418
+ :rtype: List[Dict[str, Any]]
419
+ """
420
+ logger.info("Fetching compliance data from AWS Config...")
421
+
422
+ # Initialize and fetch Config rules
423
+ config_collector = self._initialize_config_collector()
424
+ config_rules = self._fetch_config_rules(config_collector)
425
+
426
+ # Build control mappings from rules
427
+ control_mappings, _ = self._build_control_mappings(config_collector, config_rules)
428
+
429
+ # Get and build compliance data
430
+ compliance_data = self._build_compliance_data(config_collector, control_mappings)
431
+
432
+ logger.info(f"Fetched {len(compliance_data)} control compliance items from AWS Config")
433
+ return compliance_data
434
+
435
+ def _initialize_config_collector(self):
436
+ """Initialize Config collector with filtering."""
437
+ from regscale.integrations.commercial.aws.inventory.resources.config import ConfigCollector
438
+
439
+ self._log_filtering_info()
440
+
441
+ return ConfigCollector(session=self.session, region=self.region, account_id=self.account_id, tags=self.tags)
442
+
443
+ def _log_filtering_info(self) -> None:
444
+ """Log filtering information if filters are applied."""
445
+ if self.account_id:
446
+ logger.info(f"Filtering Config rules by account ID: {self.account_id}")
447
+ if self.tags:
448
+ logger.info(f"Filtering Config rules by tags: {self.tags}")
449
+
450
+ def _fetch_config_rules(self, config_collector) -> List[Dict[str, Any]]:
451
+ """Fetch Config rules from AWS."""
452
+ config_data = config_collector.collect()
453
+ config_rules = config_data.get("ConfigRules", [])
454
+ logger.info(f"Found {len(config_rules)} Config rules after filtering")
455
+ return config_rules
456
+
457
+ def _build_control_mappings(self, config_collector, config_rules: List[Dict[str, Any]]) -> Tuple[Dict, Dict]:
458
+ """Build mappings between rules and controls."""
459
+ from regscale.integrations.commercial.aws.conformance_pack_mappings import map_rule_to_controls
460
+
461
+ control_mappings = {} # control_id -> list of rule names
462
+ rule_metadata = {} # rule_name -> {rule data, tags}
463
+
464
+ for rule in config_rules:
465
+ self._process_single_rule(rule, config_collector, control_mappings, rule_metadata, map_rule_to_controls)
466
+
467
+ logger.info(f"Mapped {len(config_rules)} rules to {len(control_mappings)} controls")
468
+ return control_mappings, rule_metadata
469
+
470
+ def _process_single_rule(
471
+ self, rule: Dict, config_collector, control_mappings: Dict, rule_metadata: Dict, map_rule_to_controls
472
+ ) -> None:
473
+ """Process a single Config rule and map to controls."""
474
+ rule_name = rule.get("ConfigRuleName", "")
475
+ rule_tags = self._get_rule_tags(rule, config_collector)
476
+
477
+ # Map rule to controls
478
+ control_ids = map_rule_to_controls(
479
+ rule_name=rule_name,
480
+ rule_description=rule.get("Description"),
481
+ rule_tags=rule_tags,
482
+ framework=self.framework,
483
+ )
484
+
485
+ # Store mappings
486
+ self._store_control_mappings(control_ids, rule_name, control_mappings)
487
+ rule_metadata[rule_name] = rule
488
+
489
+ def _get_rule_tags(self, rule: Dict, config_collector) -> Dict:
490
+ """Get or fetch tags for a rule."""
491
+ rule_tags = rule.get("Tags", {})
492
+ rule_arn = rule.get("ConfigRuleArn", "")
493
+
494
+ if not rule_tags and rule_arn:
495
+ try:
496
+ rule_tags = config_collector._get_rule_tags(self.config_client, rule_arn)
497
+ rule["Tags"] = rule_tags
498
+ except Exception as e:
499
+ logger.debug(f"Could not fetch tags for rule {rule.get('ConfigRuleName', '')}: {e}")
500
+
501
+ return rule_tags
502
+
503
+ def _store_control_mappings(self, control_ids: List[str], rule_name: str, control_mappings: Dict) -> None:
504
+ """Store control to rule mappings."""
505
+ for control_id in control_ids:
506
+ if control_id not in control_mappings:
507
+ control_mappings[control_id] = []
508
+ control_mappings[control_id].append(rule_name)
509
+
510
+ def _build_compliance_data(self, config_collector, control_mappings: Dict) -> List[Dict[str, Any]]:
511
+ """Build compliance data structure from control mappings."""
512
+ control_compliance = config_collector.get_aggregate_compliance_by_control(control_mappings)
513
+ compliance_data = []
514
+ account_id = self._get_aws_account_id()
515
+
516
+ for control_id, rule_evaluations in control_compliance.items():
517
+ if rule_evaluations:
518
+ compliance_data.append(self._create_compliance_item_dict(control_id, rule_evaluations, account_id))
519
+
520
+ return compliance_data
521
+
522
+ def _create_compliance_item_dict(self, control_id: str, rule_evaluations: List, account_id: str) -> Dict[str, Any]:
523
+ """Create a compliance item dictionary."""
524
+ return {
525
+ "control_id": control_id,
526
+ "control_name": f"Control {control_id}", # Will be enriched by RegScale lookup
527
+ "rule_evaluations": rule_evaluations,
528
+ "resource_id": account_id,
529
+ "resource_name": f"AWS Account {account_id}",
530
+ }
531
+
532
+ def _get_aws_account_id(self) -> str:
533
+ """
534
+ Get AWS account ID from STS.
535
+
536
+ :return: AWS account ID
537
+ :rtype: str
538
+ """
539
+ try:
540
+ sts_client = self.session.client("sts")
541
+ response = sts_client.get_caller_identity()
542
+ return response.get("Account", "")
543
+ except Exception as e:
544
+ logger.warning(f"Could not get AWS account ID: {e}")
545
+ return ""
546
+
547
+ def create_compliance_item(self, raw_data: Dict[str, Any]) -> ComplianceItem:
548
+ """
549
+ Create a ComplianceItem from raw compliance data.
550
+
551
+ :param Dict[str, Any] raw_data: Raw compliance data (control + rule evaluations)
552
+ :return: ComplianceItem instance
553
+ :rtype: ComplianceItem
554
+ """
555
+ control_id = raw_data.get("control_id", "")
556
+ control_name = raw_data.get("control_name", "")
557
+ rule_evaluations = raw_data.get("rule_evaluations", [])
558
+ resource_id = raw_data.get("resource_id")
559
+ resource_name = raw_data.get("resource_name")
560
+
561
+ return AWSConfigComplianceItem(
562
+ control_id=control_id,
563
+ control_name=control_name,
564
+ framework=self.framework,
565
+ rule_evaluations=rule_evaluations,
566
+ resource_id=resource_id,
567
+ resource_name=resource_name,
568
+ )
569
+
570
+ def _is_cache_valid(self) -> bool:
571
+ """Check if the cache file exists and is within the TTL."""
572
+ if not os.path.exists(CONFIG_COMPLIANCE_CACHE_FILE):
573
+ return False
574
+
575
+ file_age = time.time() - os.path.getmtime(CONFIG_COMPLIANCE_CACHE_FILE)
576
+ is_valid = file_age < CACHE_TTL_SECONDS
577
+
578
+ if is_valid:
579
+ hours_old = file_age / 3600
580
+ logger.info(f"Using cached Config compliance data (age: {hours_old:.1f} hours)")
581
+
582
+ return is_valid
583
+
584
+ def _load_cached_data(self) -> List[Dict[str, Any]]:
585
+ """Load compliance data from cache file."""
586
+ try:
587
+ with open(CONFIG_COMPLIANCE_CACHE_FILE, encoding="utf-8") as file:
588
+ cached_data = json.load(file)
589
+ logger.info(f"Loaded {len(cached_data)} compliance items from cache")
590
+ return cached_data
591
+ except (json.JSONDecodeError, IOError) as e:
592
+ logger.warning(f"Error reading cache file: {e}. Fetching fresh data.")
593
+ return []
594
+
595
+ def _save_to_cache(self, compliance_data: List[Dict[str, Any]]) -> None:
596
+ """Save compliance data to cache file."""
597
+ try:
598
+ os.makedirs(os.path.dirname(CONFIG_COMPLIANCE_CACHE_FILE), exist_ok=True)
599
+
600
+ with open(CONFIG_COMPLIANCE_CACHE_FILE, "w", encoding="utf-8") as file:
601
+ json.dump(compliance_data, file, indent=2, default=str)
602
+
603
+ logger.info(f"Cached {len(compliance_data)} compliance items")
604
+ except IOError as e:
605
+ logger.warning(f"Error writing to cache file: {e}")
606
+
607
+ def sync_compliance(self) -> None:
608
+ """
609
+ Sync compliance data from AWS Config to RegScale.
610
+
611
+ Extends the base sync_compliance method to add evidence collection.
612
+
613
+ :return: None
614
+ :rtype: None
615
+ """
616
+ # Call the base class sync_compliance to handle control assessments
617
+ super().sync_compliance()
618
+
619
+ # If evidence collection is enabled, collect evidence after compliance sync
620
+ if self.collect_evidence:
621
+ logger.info("Evidence collection enabled, starting evidence collection...")
622
+ try:
623
+ # Collect evidence based on mode
624
+ if self.evidence_as_records:
625
+ logger.info("Creating individual Evidence records per control...")
626
+ self._collect_evidence_as_records()
627
+ else:
628
+ logger.info("Creating consolidated evidence file for SSP...")
629
+ self._collect_evidence_as_ssp_attachment()
630
+ except Exception as e:
631
+ logger.error(f"Error during evidence collection: {e}", exc_info=True)
632
+
633
+ def _collect_evidence_as_ssp_attachment(self) -> None:
634
+ """
635
+ Collect evidence and attach as file to SecurityPlan (default mode).
636
+
637
+ :return: None
638
+ :rtype: None
639
+ """
640
+ from regscale.core.app.api import Api
641
+ from regscale.models.regscale_models.file import File
642
+
643
+ logger.info("Collecting evidence as SSP-level attachment...")
644
+
645
+ # Collect all evidence data
646
+ all_evidence = self._collect_all_evidence_data()
647
+
648
+ if not all_evidence:
649
+ logger.warning("No evidence data collected")
650
+ return
651
+
652
+ # Generate filename
653
+ scan_date = get_current_datetime(dt_format="%Y-%m-%d")
654
+ safe_framework = self.framework.replace(" ", "_").replace("/", "_")
655
+ file_name = f"config_compliance_{safe_framework}_{scan_date}.jsonl.gz"
656
+
657
+ # Compress evidence data
658
+ jsonl_content = "\n".join([json.dumps(item, default=str) for item in all_evidence])
659
+
660
+ compressed_buffer = BytesIO()
661
+ with gzip.open(compressed_buffer, "wt", encoding="utf-8", compresslevel=9) as gz_file:
662
+ gz_file.write(jsonl_content)
663
+
664
+ compressed_data = compressed_buffer.getvalue()
665
+ compressed_size_mb = len(compressed_data) / (1024 * 1024)
666
+ uncompressed_size_mb = len(jsonl_content.encode("utf-8")) / (1024 * 1024)
667
+ compression_ratio = (1 - (len(compressed_data) / len(jsonl_content.encode("utf-8")))) * 100
668
+
669
+ logger.info(
670
+ "Compressed evidence: %.2f MB -> %.2f MB (%.1f%% reduction)",
671
+ uncompressed_size_mb,
672
+ compressed_size_mb,
673
+ compression_ratio,
674
+ )
675
+
676
+ # Upload to SecurityPlan
677
+ api = Api()
678
+ success = File.upload_file_to_regscale(
679
+ file_name=file_name,
680
+ parent_id=self.plan_id,
681
+ parent_module="securityplans",
682
+ api=api,
683
+ file_data=compressed_data,
684
+ tags=f"aws,config,compliance,{safe_framework.lower()}",
685
+ )
686
+
687
+ if success:
688
+ logger.info(f"Successfully uploaded evidence file '{file_name}' to SecurityPlan {self.plan_id}")
689
+ else:
690
+ logger.warning(f"Failed to upload evidence file to SecurityPlan {self.plan_id}")
691
+
692
+ def _collect_evidence_as_records(self) -> None:
693
+ """
694
+ Collect evidence and create individual Evidence records per control.
695
+
696
+ :return: None
697
+ :rtype: None
698
+ """
699
+ from regscale.core.app.api import Api
700
+ from regscale.models.regscale_models.evidence import Evidence
701
+ from regscale.models.regscale_models.evidence_mapping import EvidenceMapping
702
+ from regscale.models.regscale_models.file import File
703
+
704
+ logger.info("Collecting evidence as individual records per control...")
705
+
706
+ # Collect evidence grouped by control
707
+ evidence_by_control = self._collect_evidence_by_control()
708
+
709
+ if not evidence_by_control:
710
+ logger.warning("No evidence data collected")
711
+ return
712
+
713
+ scan_date = get_current_datetime(dt_format="%Y-%m-%d")
714
+ safe_framework = self.framework.replace(" ", "_").replace("/", "_")
715
+ api = Api()
716
+ evidence_records_created = 0
717
+
718
+ for control_id, control_evidence in evidence_by_control.items():
719
+ # Filter by evidence_control_ids if specified
720
+ if self.evidence_control_ids and control_id not in self.evidence_control_ids:
721
+ continue
722
+
723
+ try:
724
+ # Create Evidence record
725
+ title = f"AWS Config Evidence - {control_id} - {scan_date}"
726
+ description = self._build_evidence_description(control_id, control_evidence)
727
+ due_date = (datetime.now() + timedelta(days=self.evidence_frequency)).isoformat()
728
+
729
+ evidence = Evidence(
730
+ title=title,
731
+ description=description,
732
+ status="Collected",
733
+ updateFrequency=self.evidence_frequency,
734
+ dueDate=due_date,
735
+ )
736
+
737
+ created_evidence = evidence.create()
738
+ if not created_evidence or not created_evidence.id:
739
+ logger.error(f"Failed to create evidence record for control {control_id}")
740
+ continue
741
+
742
+ logger.info(f"Created evidence record {created_evidence.id}: {title}")
743
+
744
+ # Compress and upload evidence file
745
+ file_name = f"config_evidence_{control_id}_{scan_date}.jsonl.gz"
746
+ jsonl_content = "\n".join([json.dumps(item, default=str) for item in control_evidence])
747
+
748
+ compressed_buffer = BytesIO()
749
+ with gzip.open(compressed_buffer, "wt", encoding="utf-8", compresslevel=9) as gz_file:
750
+ gz_file.write(jsonl_content)
751
+
752
+ compressed_data = compressed_buffer.getvalue()
753
+
754
+ success = File.upload_file_to_regscale(
755
+ file_name=file_name,
756
+ parent_id=created_evidence.id,
757
+ parent_module="evidence",
758
+ api=api,
759
+ file_data=compressed_data,
760
+ tags=f"aws,config,{control_id.lower()},{safe_framework.lower()}",
761
+ )
762
+
763
+ if success:
764
+ logger.info(f"Uploaded evidence file for control {control_id}")
765
+
766
+ # Map evidence to SSP
767
+ mapping = EvidenceMapping(
768
+ evidenceID=created_evidence.id, mappedID=self.plan_id, mappingType="securityplans"
769
+ )
770
+ mapping.create()
771
+ logger.info(f"Linked evidence {created_evidence.id} to SSP {self.plan_id}")
772
+
773
+ evidence_records_created += 1
774
+
775
+ except Exception as ex:
776
+ logger.error(f"Failed to create evidence record for control {control_id}: {ex}", exc_info=True)
777
+
778
+ logger.info(f"Created {evidence_records_created} evidence record(s)")
779
+
780
+ def _collect_all_evidence_data(self) -> List[Dict[str, Any]]:
781
+ """
782
+ Collect all evidence data for SSP-level attachment.
783
+
784
+ :return: List of evidence items
785
+ :rtype: List[Dict[str, Any]]
786
+ """
787
+ from regscale.integrations.commercial.aws.inventory.resources.config import ConfigCollector
788
+
789
+ all_evidence = []
790
+ config_collector = ConfigCollector(session=self.session, region=self.region)
791
+
792
+ # Get all Config rules
793
+ config_data = config_collector.collect()
794
+ config_rules = config_data.get("ConfigRules", [])
795
+
796
+ for rule in config_rules:
797
+ rule_name = rule.get("ConfigRuleName", "")
798
+
799
+ try:
800
+ # Get compliance details for this rule
801
+ compliance_details = config_collector.get_compliance_details(rule_name)
802
+
803
+ for detail in compliance_details:
804
+ evidence_item = {
805
+ "rule_name": rule_name,
806
+ "compliance_type": detail.get("ComplianceType", ""),
807
+ "resource_type": detail.get("EvaluationResultIdentifier", {})
808
+ .get("EvaluationResultQualifier", {})
809
+ .get("ResourceType", ""),
810
+ "resource_id": detail.get("EvaluationResultIdentifier", {})
811
+ .get("EvaluationResultQualifier", {})
812
+ .get("ResourceId", ""),
813
+ "config_rule_invoked_time": str(detail.get("ConfigRuleInvokedTime", "")),
814
+ "result_recorded_time": str(detail.get("ResultRecordedTime", "")),
815
+ "annotation": detail.get("Annotation", ""),
816
+ }
817
+ all_evidence.append(evidence_item)
818
+
819
+ except Exception as e:
820
+ logger.debug(f"Error collecting evidence for rule {rule_name}: {e}")
821
+
822
+ logger.info(f"Collected {len(all_evidence)} evidence items")
823
+ return all_evidence
824
+
825
+ def _collect_evidence_by_control(self) -> Dict[str, List[Dict[str, Any]]]:
826
+ """
827
+ Collect evidence data grouped by control ID.
828
+
829
+ :return: Dictionary mapping control_id to list of evidence items
830
+ :rtype: Dict[str, List[Dict[str, Any]]]
831
+ """
832
+ from regscale.integrations.commercial.aws.inventory.resources.config import ConfigCollector
833
+ from regscale.integrations.commercial.aws.conformance_pack_mappings import map_rule_to_controls
834
+
835
+ evidence_by_control = {}
836
+ config_collector = ConfigCollector(session=self.session, region=self.region)
837
+
838
+ # Get all Config rules
839
+ config_data = config_collector.collect()
840
+ config_rules = config_data.get("ConfigRules", [])
841
+
842
+ for rule in config_rules:
843
+ rule_name = rule.get("ConfigRuleName", "")
844
+ rule_description = rule.get("Description")
845
+ rule_tags = rule.get("Tags", {})
846
+
847
+ # Map rule to controls
848
+ control_ids = map_rule_to_controls(
849
+ rule_name=rule_name,
850
+ rule_description=rule_description,
851
+ rule_tags=rule_tags,
852
+ framework=self.framework,
853
+ )
854
+
855
+ if not control_ids:
856
+ continue
857
+
858
+ try:
859
+ # Get compliance details for this rule
860
+ compliance_details = config_collector.get_compliance_details(rule_name)
861
+
862
+ for detail in compliance_details:
863
+ evidence_item = {
864
+ "rule_name": rule_name,
865
+ "compliance_type": detail.get("ComplianceType", ""),
866
+ "resource_type": detail.get("EvaluationResultIdentifier", {})
867
+ .get("EvaluationResultQualifier", {})
868
+ .get("ResourceType", ""),
869
+ "resource_id": detail.get("EvaluationResultIdentifier", {})
870
+ .get("EvaluationResultQualifier", {})
871
+ .get("ResourceId", ""),
872
+ "config_rule_invoked_time": str(detail.get("ConfigRuleInvokedTime", "")),
873
+ "result_recorded_time": str(detail.get("ResultRecordedTime", "")),
874
+ "annotation": detail.get("Annotation", ""),
875
+ }
876
+
877
+ # Add to each control this rule maps to
878
+ for control_id in control_ids:
879
+ if control_id not in evidence_by_control:
880
+ evidence_by_control[control_id] = []
881
+ evidence_by_control[control_id].append(evidence_item)
882
+
883
+ except Exception as e:
884
+ logger.debug(f"Error collecting evidence for rule {rule_name}: {e}")
885
+
886
+ logger.info(f"Collected evidence for {len(evidence_by_control)} controls")
887
+ return evidence_by_control
888
+
889
+ def _build_evidence_description(self, control_id: str, control_evidence: List[Dict[str, Any]]) -> str:
890
+ """
891
+ Build HTML description for evidence record.
892
+
893
+ :param str control_id: Control ID
894
+ :param List[Dict[str, Any]] control_evidence: Evidence items for this control
895
+ :return: HTML description
896
+ :rtype: str
897
+ """
898
+ compliant_count = sum(1 for e in control_evidence if e.get("compliance_type") == "COMPLIANT")
899
+ non_compliant_count = sum(1 for e in control_evidence if e.get("compliance_type") == "NON_COMPLIANT")
900
+
901
+ desc_parts = [
902
+ f"{HTML_H3_OPEN}AWS Config Evidence for Control {control_id}{HTML_H3_CLOSE}",
903
+ f"{HTML_P_OPEN}{HTML_STRONG_OPEN}Framework:{HTML_STRONG_CLOSE} {self.framework}{HTML_P_CLOSE}",
904
+ f"{HTML_H4_OPEN}Summary{HTML_H4_CLOSE}",
905
+ HTML_UL_OPEN,
906
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}Total Evidence Items:{HTML_STRONG_CLOSE} {len(control_evidence)}"
907
+ f"{HTML_LI_CLOSE}",
908
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}Compliant:{HTML_STRONG_CLOSE} {compliant_count}{HTML_LI_CLOSE}",
909
+ f"{HTML_LI_OPEN}{HTML_STRONG_OPEN}Non-Compliant:{HTML_STRONG_CLOSE} {non_compliant_count}"
910
+ f"{HTML_LI_CLOSE}",
911
+ HTML_UL_CLOSE,
912
+ ]
913
+
914
+ return "\n".join(desc_parts)