regscale-cli 6.23.0.1__py3-none-any.whl → 6.24.0.1__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 (45) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +2 -0
  3. regscale/integrations/commercial/__init__.py +1 -0
  4. regscale/integrations/commercial/jira.py +95 -22
  5. regscale/integrations/commercial/sarif/sarif_converter.py +1 -1
  6. regscale/integrations/commercial/wizv2/click.py +132 -2
  7. regscale/integrations/commercial/wizv2/compliance_report.py +1574 -0
  8. regscale/integrations/commercial/wizv2/constants.py +72 -2
  9. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  10. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  11. regscale/integrations/commercial/wizv2/issue.py +775 -27
  12. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  13. regscale/integrations/commercial/wizv2/reports.py +243 -0
  14. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  15. regscale/integrations/compliance_integration.py +534 -56
  16. regscale/integrations/due_date_handler.py +210 -0
  17. regscale/integrations/public/cci_importer.py +444 -0
  18. regscale/integrations/scanner_integration.py +718 -153
  19. regscale/models/integration_models/CCI_List.xml +1 -0
  20. regscale/models/integration_models/cisa_kev_data.json +18 -3
  21. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  22. regscale/models/regscale_models/control_implementation.py +13 -3
  23. regscale/models/regscale_models/form_field_value.py +1 -1
  24. regscale/models/regscale_models/milestone.py +1 -0
  25. regscale/models/regscale_models/regscale_model.py +225 -60
  26. regscale/models/regscale_models/security_plan.py +3 -2
  27. regscale/regscale.py +7 -0
  28. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/METADATA +17 -17
  29. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/RECORD +45 -28
  30. tests/fixtures/test_fixture.py +13 -8
  31. tests/regscale/integrations/public/__init__.py +0 -0
  32. tests/regscale/integrations/public/test_alienvault.py +220 -0
  33. tests/regscale/integrations/public/test_cci.py +458 -0
  34. tests/regscale/integrations/public/test_cisa.py +1021 -0
  35. tests/regscale/integrations/public/test_emass.py +518 -0
  36. tests/regscale/integrations/public/test_fedramp.py +851 -0
  37. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  38. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  39. tests/regscale/integrations/public/test_oscal.py +453 -0
  40. tests/regscale/models/test_form_field_value_integration.py +304 -0
  41. tests/regscale/models/test_module_integration.py +582 -0
  42. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/LICENSE +0 -0
  43. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/WHEEL +0 -0
  44. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/entry_points.txt +0 -0
  45. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1574 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Wiz Compliance Report Integration for RegScale CLI."""
4
+
5
+ import csv
6
+ import gzip
7
+ import logging
8
+ import os
9
+ import re
10
+ from datetime import datetime, timedelta
11
+ from typing import Dict, Any, Optional, List
12
+
13
+ from regscale.core.app.utils.app_utils import error_and_exit
14
+ from regscale.core.app.utils.app_utils import get_current_datetime
15
+ from regscale.integrations.commercial.wizv2.file_cleanup import ReportFileCleanup
16
+ from regscale.integrations.commercial.wizv2.reports import WizReportManager
17
+ from regscale.integrations.commercial.wizv2.variables import WizVariables
18
+ from regscale.integrations.commercial.wizv2.wiz_auth import wiz_authenticate
19
+ from regscale.integrations.compliance_integration import ComplianceIntegration, ComplianceItem
20
+ from regscale.models import regscale_models
21
+ from regscale.models.regscale_models.control_implementation import ControlImplementation, ControlImplementationStatus
22
+
23
+ logger = logging.getLogger("regscale")
24
+
25
+
26
+ class WizComplianceReportItem(ComplianceItem):
27
+ """Compliance item parsed from Wiz CSV report."""
28
+
29
+ def __init__(self, csv_row: Dict[str, str]):
30
+ """
31
+ Initialize from CSV row data.
32
+
33
+ :param Dict[str, str] csv_row: Row data from CSV report
34
+ """
35
+ self.csv_data = csv_row
36
+ self._resource_name = csv_row.get("Resource Name", "") # Use _resource_name to avoid conflict with property
37
+ self.cloud_provider = csv_row.get("Cloud Provider", "")
38
+ self.cloud_provider_id = csv_row.get("Cloud Provider ID", "")
39
+ self._resource_id = csv_row.get("Resource ID", "") # Use _resource_id to avoid conflict with property
40
+ self.resource_region = csv_row.get("Resource Region", "")
41
+ self.subscription = csv_row.get("Subscription", "")
42
+ self.subscription_name = csv_row.get("Subscription Name", "")
43
+ self.policy_name = csv_row.get("Policy Name", "")
44
+ self.policy_id = csv_row.get("Policy ID", "")
45
+ self.result = csv_row.get("Result", "")
46
+ self._severity = csv_row.get("Severity", "") # Use _severity to avoid conflict with property
47
+ self.compliance_check_name = csv_row.get("Compliance Check Name (Wiz Subcategory)", "")
48
+ self._framework = csv_row.get("Framework", "") # Use _framework to avoid conflict with property
49
+ self.remediation_steps = csv_row.get("Remediation Steps", "")
50
+
51
+ # ComplianceItem abstract property implementations
52
+ @property
53
+ def resource_id(self) -> str:
54
+ """Unique identifier for the resource being assessed."""
55
+ return self.cloud_provider_id or self._resource_id or self._resource_name or "Unknown"
56
+
57
+ @property
58
+ def resource_name(self) -> str:
59
+ """Human-readable name of the resource."""
60
+ return self.get_unique_resource_name()
61
+
62
+ @property
63
+ def control_id(self) -> str:
64
+ """Control identifier (e.g., AC-3, SI-2)."""
65
+ return self.get_control_id()
66
+
67
+ @property
68
+ def compliance_result(self) -> str:
69
+ """Result of compliance check (PASS, FAIL, etc)."""
70
+ return self.result
71
+
72
+ @property
73
+ def severity(self) -> Optional[str]:
74
+ """Severity level of the compliance violation (if failed)."""
75
+ return self._severity if self._severity else None
76
+
77
+ @property
78
+ def description(self) -> str:
79
+ """Description of the compliance check."""
80
+ return self.get_finding_details()
81
+
82
+ @property
83
+ def framework(self) -> str:
84
+ """Compliance framework (e.g., NIST800-53R5, CSF)."""
85
+ if not self._framework:
86
+ return "NIST800-53R5"
87
+
88
+ # Normalize Wiz framework names to RegScale format
89
+ framework_mappings = {
90
+ "NIST SP 800-53 Revision 5": "NIST800-53R5",
91
+ "NIST SP 800-53 Rev 5": "NIST800-53R5",
92
+ "NIST SP 800-53 R5": "NIST800-53R5",
93
+ "NIST 800-53 Revision 5": "NIST800-53R5",
94
+ "NIST 800-53 Rev 5": "NIST800-53R5",
95
+ "NIST 800-53 R5": "NIST800-53R5",
96
+ }
97
+
98
+ return framework_mappings.get(self._framework, self._framework)
99
+
100
+ def get_control_id(self) -> str:
101
+ """Extract first control ID from compliance check name for compatibility."""
102
+ control_ids = self.get_all_control_ids()
103
+ return control_ids[0] if control_ids else ""
104
+
105
+ def get_all_control_ids(self) -> list:
106
+ """Extract all control IDs from compliance check name and normalize leading zeros."""
107
+ if not self.compliance_check_name:
108
+ return []
109
+
110
+ control_id_pattern = r"([A-Za-z]{2}-\d+)(?:\s*\(\s*(\d+)\s*\))?"
111
+ control_ids = []
112
+
113
+ for part in self.compliance_check_name.split(", "):
114
+ matches = re.findall(control_id_pattern, part.strip())
115
+ for match in matches:
116
+ base_control, enhancement = match
117
+ normalized_control = self._normalize_base_control(base_control)
118
+ formatted_control = self._format_control_id(normalized_control, enhancement)
119
+ control_ids.append(formatted_control)
120
+
121
+ return control_ids
122
+
123
+ def _normalize_base_control(self, base_control: str) -> str:
124
+ """Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)."""
125
+ if "-" in base_control:
126
+ prefix, number = base_control.split("-", 1)
127
+ try:
128
+ normalized_number = str(int(number))
129
+ return f"{prefix.upper()}-{normalized_number}"
130
+ except ValueError:
131
+ return base_control.upper()
132
+ else:
133
+ return base_control.upper()
134
+
135
+ def _format_control_id(self, base_control: str, enhancement: str) -> str:
136
+ """Format control ID with optional enhancement."""
137
+ if enhancement:
138
+ return f"{base_control}({enhancement})"
139
+ else:
140
+ return base_control
141
+
142
+ @property
143
+ def affected_controls(self) -> str:
144
+ """Get affected controls as comma-separated string for issues."""
145
+ control_ids = self.get_all_control_ids()
146
+ return ",".join(control_ids) if control_ids else self.control_id
147
+
148
+ def get_status(self) -> str:
149
+ """Get compliance status based on result."""
150
+ return "Satisfied" if self.result.lower() == "pass" else "Other Than Satisfied"
151
+
152
+ def get_implementation_status(self) -> str:
153
+ """Get implementation status based on result."""
154
+ return "Implemented" if self.result.lower() == "pass" else "In Remediation"
155
+
156
+ def get_severity(self) -> str:
157
+ """Map Wiz severity to RegScale severity."""
158
+ severity_map = {"CRITICAL": "High", "HIGH": "High", "MEDIUM": "Moderate", "LOW": "Low", "INFORMATIONAL": "Low"}
159
+ return severity_map.get(self._severity.upper(), "Low")
160
+
161
+ def get_unique_resource_name(self) -> str:
162
+ """Get a unique resource name by appending provider ID or resource ID."""
163
+ base_name = self._resource_name
164
+ if not base_name:
165
+ base_name = "Unknown Resource"
166
+
167
+ # Add region if available
168
+ if self.resource_region:
169
+ base_name = f"{base_name} ({self.resource_region})"
170
+
171
+ # Add unique identifier (prefer resource_id over cloud_provider_id)
172
+ unique_id = self._resource_id or self.cloud_provider_id
173
+ if unique_id:
174
+ # Extract just the last part of Azure resource IDs for brevity
175
+ if "/" in unique_id:
176
+ unique_suffix = unique_id.split("/")[-1]
177
+ else:
178
+ unique_suffix = unique_id
179
+
180
+ # Only append if not already in the name
181
+ if unique_suffix.lower() not in base_name.lower():
182
+ base_name = f"{base_name} [{unique_suffix[:12]}]" # Limit to 12 chars
183
+
184
+ return base_name
185
+
186
+ def get_unique_issue_identifier(self) -> str:
187
+ """Get a unique identifier for deduplication of issues."""
188
+ # Use resource_id + policy_id + control_id for uniqueness
189
+ resource_key = self._resource_id or self.cloud_provider_id or self._resource_name
190
+ policy_key = self.policy_id or self.policy_name
191
+ control_key = self.get_control_id()
192
+ return f"{resource_key}|{policy_key}|{control_key}"
193
+
194
+ def get_title(self) -> str:
195
+ """Get assessment title."""
196
+ return f"{self.get_control_id()} - {self.policy_name}"
197
+
198
+ def get_description(self) -> str:
199
+ """Get assessment description."""
200
+ return f"Wiz compliance assessment for {self.get_unique_resource_name()} - {self.policy_name}"
201
+
202
+ def get_finding_details(self) -> str:
203
+ """Get finding details for issues."""
204
+ details = f"Resource: {self.get_unique_resource_name()}\n"
205
+ details += f"Cloud Provider: {self.cloud_provider}\n"
206
+ if self.subscription_name:
207
+ details += f"Subscription: {self.subscription_name}\n"
208
+ details += f"Result: {self.result}\n"
209
+ details += f"Remediation: {self.remediation_steps}"
210
+ return details
211
+
212
+ def get_asset_identifier(self) -> str:
213
+ """Get asset identifier using cloud provider ID for issues."""
214
+ return self.cloud_provider_id or self._resource_id or self._resource_name or "Unknown"
215
+
216
+
217
+ class WizComplianceReportProcessor(ComplianceIntegration):
218
+ """Process compliance reports from Wiz and create assessments in RegScale."""
219
+
220
+ # Set the asset identifier field to match Wiz integration standard
221
+ asset_identifier_field: str = "wizId"
222
+
223
+ def __init__(
224
+ self,
225
+ plan_id: int,
226
+ wiz_project_id: str,
227
+ client_id: str,
228
+ client_secret: str,
229
+ regscale_module: str = "securityplans",
230
+ create_poams: bool = False,
231
+ report_file_path: Optional[str] = None,
232
+ bypass_control_filtering: bool = False,
233
+ max_report_age_days: int = 7,
234
+ force_fresh_report: bool = False,
235
+ reuse_existing_reports: bool = True,
236
+ **kwargs,
237
+ ):
238
+ """
239
+ Initialize the compliance report processor.
240
+
241
+ :param int plan_id: RegScale plan/SSP ID
242
+ :param str wiz_project_id: Wiz project ID
243
+ :param str client_id: Wiz client ID
244
+ :param str client_secret: Wiz client secret
245
+ :param str regscale_module: RegScale module to use
246
+ :param bool create_poams: Whether to create POAMs for failed assessments
247
+ :param Optional[str] report_file_path: Path to existing report file to use instead of creating new one
248
+ :param bool bypass_control_filtering: Skip control filtering for performance with large control sets
249
+ :param int max_report_age_days: Maximum age in days for reusing existing reports (default: 7 days)
250
+ :param bool force_fresh_report: Force creation of fresh report, ignoring existing reports
251
+ :param bool reuse_existing_reports: Whether to reuse existing Wiz reports instead of creating new ones (default: True)
252
+ """
253
+ # Call parent constructor with ComplianceIntegration parameters
254
+ super().__init__(
255
+ plan_id=plan_id,
256
+ framework="NIST800-53R5",
257
+ create_poams=create_poams,
258
+ parent_module=regscale_module,
259
+ **kwargs,
260
+ )
261
+
262
+ # Wiz-specific attributes
263
+ self.wiz_project_id = wiz_project_id
264
+ self.client_id = client_id
265
+ self.client_secret = client_secret
266
+ self.report_file_path = report_file_path
267
+ self.bypass_control_filtering = bypass_control_filtering
268
+ self.max_report_age_days = max_report_age_days
269
+ self.force_fresh_report = force_fresh_report
270
+ self.reuse_existing_reports = reuse_existing_reports
271
+ self.title = "Wiz Compliance" # Required by ScannerIntegration
272
+
273
+ # Initialize Wiz authentication
274
+ access_token = wiz_authenticate(client_id, client_secret)
275
+ if not access_token:
276
+ error_and_exit("Failed to authenticate with Wiz")
277
+
278
+ self.report_manager = WizReportManager(WizVariables.wizUrl, access_token)
279
+
280
+ def parse_csv_report(self, file_path: str) -> List[WizComplianceReportItem]:
281
+ """
282
+ Parse CSV compliance report.
283
+
284
+ :param str file_path: Path to CSV report file
285
+ :return: List of compliance items
286
+ :rtype: List[WizComplianceReportItem]
287
+ """
288
+ items = []
289
+
290
+ try:
291
+ # Handle gzipped files
292
+ if file_path.endswith(".gz"):
293
+ with gzip.open(file_path, "rt", encoding="utf-8") as f:
294
+ reader = csv.DictReader(f)
295
+ for row in reader:
296
+ items.append(WizComplianceReportItem(row))
297
+ else:
298
+ with open(file_path, "r", encoding="utf-8") as f:
299
+ reader = csv.DictReader(f)
300
+ for row in reader:
301
+ items.append(WizComplianceReportItem(row))
302
+
303
+ logger.info(f"Parsed {len(items)} compliance items from report")
304
+ return items
305
+
306
+ except Exception as e:
307
+ logger.error(f"Error parsing CSV report: {e}")
308
+ return []
309
+
310
+ # ComplianceIntegration abstract method implementations
311
+ def fetch_compliance_data(self) -> List[Dict[str, str]]:
312
+ """
313
+ Fetch raw compliance data from CSV report.
314
+
315
+ :return: List of raw compliance data (CSV rows as dictionaries)
316
+ :rtype: List[Dict[str, str]]
317
+ """
318
+ # Use provided report file or get/create one
319
+ if self.report_file_path and os.path.exists(self.report_file_path):
320
+ report_file_path = self.report_file_path
321
+ else:
322
+ report_file_path = self._get_or_create_report()
323
+ if not report_file_path or not os.path.exists(report_file_path):
324
+ logger.error("Failed to get compliance report")
325
+ return []
326
+
327
+ # Read CSV file and return raw data
328
+ raw_data = []
329
+ try:
330
+ with open(report_file_path, "r", encoding="utf-8") as file:
331
+ csv_reader = csv.DictReader(file)
332
+ raw_data = list(csv_reader)
333
+
334
+ logger.info(f"Fetched {len(raw_data)} raw compliance records from CSV")
335
+ return raw_data
336
+
337
+ except Exception as e:
338
+ logger.error(f"Error reading CSV report: {e}")
339
+ return []
340
+
341
+ def create_compliance_item(self, raw_data: Dict[str, str]) -> ComplianceItem:
342
+ """
343
+ Create a ComplianceItem from raw compliance data.
344
+
345
+ :param Dict[str, str] raw_data: Raw compliance data from CSV row
346
+ :return: ComplianceItem instance
347
+ :rtype: ComplianceItem
348
+ """
349
+ return WizComplianceReportItem(raw_data)
350
+
351
+ def _map_string_severity_to_enum(self, severity_str: str) -> regscale_models.IssueSeverity:
352
+ """
353
+ Convert string severity to regscale_models.IssueSeverity enum.
354
+
355
+ :param str severity_str: String severity like "HIGH", "MEDIUM", etc.
356
+ :return: IssueSeverity enum value
357
+ :rtype: regscale_models.IssueSeverity
358
+ """
359
+ severity_mapping = {
360
+ "CRITICAL": regscale_models.IssueSeverity.Critical,
361
+ "HIGH": regscale_models.IssueSeverity.High,
362
+ "MEDIUM": regscale_models.IssueSeverity.Moderate,
363
+ "MODERATE": regscale_models.IssueSeverity.Moderate,
364
+ "LOW": regscale_models.IssueSeverity.Low,
365
+ "INFORMATIONAL": regscale_models.IssueSeverity.Low,
366
+ }
367
+ return severity_mapping.get(severity_str.upper(), regscale_models.IssueSeverity.Low)
368
+
369
+ def process_compliance_data(self) -> None:
370
+ """
371
+ Override the parent method to implement bypass logic for large control sets.
372
+ """
373
+ if self.bypass_control_filtering:
374
+ logger.info("Bypassing control filtering due to bypass_control_filtering=True")
375
+ # Call parent method but bypass the allowed_controls_normalized logic
376
+ self._process_compliance_data_without_filtering()
377
+ else:
378
+ # Use standard parent implementation
379
+ super().process_compliance_data()
380
+
381
+ def _process_compliance_data_without_filtering(self) -> None:
382
+ """
383
+ Process compliance data without control filtering for performance.
384
+ """
385
+ logger.info("Processing compliance data without control filtering...")
386
+
387
+ self._reset_compliance_state()
388
+ raw_compliance_data = self.fetch_compliance_data()
389
+ self._process_raw_compliance_items(raw_compliance_data)
390
+ self._log_processing_debug_info()
391
+ self._categorize_controls_fail_first()
392
+ self._log_processing_summary()
393
+ self._log_categorization_debug_info()
394
+
395
+ def _reset_compliance_state(self) -> None:
396
+ """Reset state to avoid double counting on repeated calls."""
397
+ self.all_compliance_items = []
398
+ self.failed_compliance_items = []
399
+ self.passing_controls = {}
400
+ self.failing_controls = {}
401
+ self.asset_compliance_map.clear()
402
+
403
+ def _process_raw_compliance_items(self, raw_compliance_data: List[Any], allowed_controls: set = None) -> dict:
404
+ """Convert raw compliance data to ComplianceItem objects.
405
+
406
+ :param List[Any] raw_compliance_data: Raw compliance data from CSV row
407
+ :param set allowed_controls: Allowed control IDs (unused in this override, provided for interface compatibility)
408
+ :return: Processing statistics dictionary (empty dict for this implementation)
409
+ :rtype: dict
410
+ """
411
+ for raw_item in raw_compliance_data:
412
+ try:
413
+ compliance_item = self.create_compliance_item(raw_item)
414
+
415
+ if not self._is_valid_compliance_item_for_processing(compliance_item):
416
+ continue
417
+
418
+ self._add_compliance_item_to_collections(compliance_item)
419
+
420
+ except Exception as e:
421
+ logger.error(f"Error processing compliance item: {e}")
422
+ continue
423
+
424
+ # Return empty stats dict for interface compatibility
425
+ return {}
426
+
427
+ def _is_valid_compliance_item_for_processing(self, compliance_item: Any) -> bool:
428
+ """Check if compliance item has required control and resource IDs.
429
+
430
+ :param Any compliance_item: Compliance item to check
431
+ :return: True if compliance item has required control and resource IDs
432
+ :rtype: bool
433
+ """
434
+ control_id = getattr(compliance_item, "control_id", "")
435
+ resource_id = getattr(compliance_item, "resource_id", "")
436
+ return bool(control_id and resource_id)
437
+
438
+ def _add_compliance_item_to_collections(self, compliance_item: Any) -> None:
439
+ """Add compliance item to appropriate collections and categorize.
440
+
441
+ :param Any compliance_item: Compliance item to add to collections
442
+ :return: None
443
+ :rtype: None
444
+ """
445
+ self.all_compliance_items.append(compliance_item)
446
+ self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
447
+
448
+ # Categorize by result - normalize to handle case variations
449
+ result_lower = compliance_item.compliance_result.lower()
450
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
451
+
452
+ if result_lower in fail_statuses_lower:
453
+ self.failed_compliance_items.append(compliance_item)
454
+
455
+ def _log_processing_debug_info(self) -> None:
456
+ """
457
+ Log debug information before categorization.
458
+
459
+ Logs sample compliance item data and status configurations
460
+ to help with debugging categorization issues.
461
+
462
+ :return: None
463
+ :rtype: None
464
+ """
465
+ logger.debug(f"About to categorize {len(self.all_compliance_items)} compliance items")
466
+ if self.all_compliance_items:
467
+ sample_item = self.all_compliance_items[0]
468
+ logger.debug(
469
+ f"DEBUG: Sample item control_id='{sample_item.control_id}', result='{sample_item.compliance_result}'"
470
+ )
471
+ logger.debug(f"FAIL_STATUSES = {self.FAIL_STATUSES}")
472
+ logger.debug(f"PASS_STATUSES = {self.PASS_STATUSES}")
473
+
474
+ def _log_processing_summary(self, raw_compliance_data: list = None, stats: dict = None) -> None:
475
+ """
476
+ Log summary of processed compliance items.
477
+
478
+ Provides a summary count of total items, passing items, failing items,
479
+ and control categorization results for monitoring processing progress.
480
+
481
+ :param list raw_compliance_data: Raw compliance data (unused in this implementation, for interface compatibility)
482
+ :param dict stats: Processing statistics (unused in this implementation, for interface compatibility)
483
+ :return: None
484
+ :rtype: None
485
+ """
486
+ passing_count = len(self.all_compliance_items) - len(self.failed_compliance_items)
487
+ failing_count = len(self.failed_compliance_items)
488
+
489
+ logger.info(
490
+ f"Processed {len(self.all_compliance_items)} compliance items: "
491
+ f"{passing_count} passing, {failing_count} failing"
492
+ )
493
+ logger.info(
494
+ f"Control categorization: {len(self.passing_controls)} passing controls, "
495
+ f"{len(self.failing_controls)} failing controls"
496
+ )
497
+
498
+ def _log_categorization_debug_info(self) -> None:
499
+ """
500
+ Log debug information about categorized controls.
501
+
502
+ Outputs lists of passing and failing control IDs for debugging
503
+ categorization logic and identifying potential issues.
504
+
505
+ :return: None
506
+ :rtype: None
507
+ """
508
+ if self.passing_controls:
509
+ logger.debug(f"Passing control IDs: {list(self.passing_controls.keys())}")
510
+ if self.failing_controls:
511
+ logger.debug(f"Failing control IDs: {list(self.failing_controls.keys())}")
512
+ if not self.passing_controls and not self.failing_controls:
513
+ logger.error(
514
+ "DEBUG: No controls were categorized! This indicates an issue in _categorize_controls_fail_first"
515
+ )
516
+
517
+ def _categorize_controls_fail_first(self) -> None:
518
+ """
519
+ Categorize controls using fail-first logic.
520
+
521
+ If ANY compliance item for a control is failing, the entire control is marked as failing.
522
+ A control is only marked as passing if ALL instances of that control are passing.
523
+ """
524
+ logger.info("Starting fail-first control categorization...")
525
+
526
+ control_results = self._determine_control_results()
527
+ self._populate_control_collections(control_results)
528
+ self._populate_failed_compliance_items()
529
+ self._log_categorization_completion()
530
+
531
+ def _determine_control_results(self) -> Dict[str, str]:
532
+ """
533
+ Determine pass/fail status for each control based on compliance items.
534
+
535
+ Analyzes all compliance items and applies fail-first logic to determine
536
+ the overall status for each control. A control is marked as "fail" if
537
+ ANY compliance item for that control is failing.
538
+
539
+ :return: Dictionary mapping control IDs (lowercase) to "pass" or "fail"
540
+ :rtype: Dict[str, str]
541
+ """
542
+ control_results = {} # {control_id: "pass" or "fail"}
543
+
544
+ for item in self.all_compliance_items:
545
+ control_ids = self._get_control_ids_for_item(item)
546
+
547
+ for control_id in control_ids:
548
+ if not control_id:
549
+ continue
550
+
551
+ control_id_lower = control_id.lower()
552
+
553
+ if self._is_compliance_item_failing(item):
554
+ control_results[control_id_lower] = "fail"
555
+ logger.debug(f"Control {control_id} marked as FAILING due to failed item")
556
+ elif control_id_lower not in control_results:
557
+ control_results[control_id_lower] = "pass"
558
+
559
+ return control_results
560
+
561
+ def _get_control_ids_for_item(self, item: Any) -> List[str]:
562
+ """
563
+ Get all control IDs for a compliance item.
564
+
565
+ Extracts control IDs from compliance items that may reference
566
+ multiple controls (e.g., multi-control compliance checks).
567
+
568
+ :param Any item: Compliance item to extract control IDs from
569
+ :return: List of control ID strings
570
+ :rtype: List[str]
571
+ """
572
+ if hasattr(item, "get_all_control_ids"):
573
+ return item.get_all_control_ids()
574
+ else:
575
+ return [item.control_id] if item.control_id else []
576
+
577
+ def _is_compliance_item_failing(self, item: Any) -> bool:
578
+ """
579
+ Check if a compliance item is failing.
580
+
581
+ Compares the compliance result against the list of failure statuses
582
+ using case-insensitive matching.
583
+
584
+ :param Any item: Compliance item to check
585
+ :return: True if the item is failing, False otherwise
586
+ :rtype: bool
587
+ """
588
+ result_lower = item.compliance_result.lower()
589
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
590
+ return result_lower in fail_statuses_lower
591
+
592
+ def _populate_control_collections(self, control_results: Dict[str, str]) -> None:
593
+ """
594
+ Populate passing and failing control collections.
595
+
596
+ Based on the control results dictionary, populates the passing_controls
597
+ and failing_controls collections with the appropriate compliance items.
598
+
599
+ :param Dict[str, str] control_results: Dictionary mapping control IDs to "pass" or "fail"
600
+ :return: None
601
+ :rtype: None
602
+ """
603
+ for control_id_lower, result in control_results.items():
604
+ if result == "fail":
605
+ self.failing_controls[control_id_lower] = self._get_items_for_control(control_id_lower)
606
+ else:
607
+ self.passing_controls[control_id_lower] = self._get_items_for_control(control_id_lower)
608
+
609
+ def _get_items_for_control(self, control_id_lower: str) -> List[Any]:
610
+ """
611
+ Get all compliance items that belong to a specific control.
612
+
613
+ Searches through all compliance items to find those that reference
614
+ the specified control ID (case-insensitive matching).
615
+
616
+ :param str control_id_lower: Control ID in lowercase format
617
+ :return: List of compliance items for the control
618
+ :rtype: List[Any]
619
+ """
620
+ items = []
621
+ for item in self.all_compliance_items:
622
+ item_control_ids = self._get_normalized_control_ids_for_item(item)
623
+ if control_id_lower in item_control_ids:
624
+ items.append(item)
625
+ return items
626
+
627
+ def _get_normalized_control_ids_for_item(self, item: Any) -> List[str]:
628
+ """
629
+ Get normalized (lowercase) control IDs for an item.
630
+
631
+ Extracts all control IDs from a compliance item and normalizes
632
+ them to lowercase for consistent comparison and matching.
633
+
634
+ :param Any item: Compliance item to extract control IDs from
635
+ :return: List of normalized control ID strings
636
+ :rtype: List[str]
637
+ """
638
+ if hasattr(item, "get_all_control_ids"):
639
+ return [cid.lower() for cid in item.get_all_control_ids()]
640
+ else:
641
+ return [item.control_id.lower()] if item.control_id else []
642
+
643
+ def _populate_failed_compliance_items(self) -> None:
644
+ """
645
+ Populate and deduplicate the failed compliance items list.
646
+
647
+ Collects all failing compliance items from the failing_controls
648
+ collection and removes duplicates to create a clean list of
649
+ failed items for issue processing.
650
+
651
+ :return: None
652
+ :rtype: None
653
+ """
654
+ self.failed_compliance_items.clear()
655
+
656
+ for control_id, failing_items in self.failing_controls.items():
657
+ self.failed_compliance_items.extend(failing_items)
658
+
659
+ self.failed_compliance_items = self._remove_duplicate_items(self.failed_compliance_items)
660
+
661
+ def _remove_duplicate_items(self, items: List[Any]) -> List[Any]:
662
+ """
663
+ Remove duplicate compliance items while preserving order.
664
+
665
+ Uses resource_id and control_id to create unique keys for
666
+ deduplication while maintaining the original order of items.
667
+
668
+ :param List[Any] items: List of compliance items to deduplicate
669
+ :return: List of unique compliance items
670
+ :rtype: List[Any]
671
+ """
672
+ seen = set()
673
+ unique_items = []
674
+
675
+ for item in items:
676
+ item_key = f"{getattr(item, 'resource_id', '')}-{getattr(item, 'control_id', '')}"
677
+ if item_key not in seen:
678
+ seen.add(item_key)
679
+ unique_items.append(item)
680
+
681
+ return unique_items
682
+
683
+ def _log_categorization_completion(self) -> None:
684
+ """
685
+ Log completion of control categorization.
686
+
687
+ Provides final summary statistics about the categorization process,
688
+ including counts of passing/failing controls and failed items.
689
+
690
+ :return: None
691
+ :rtype: None
692
+ """
693
+ logger.info(
694
+ f"Fail-first categorization complete: {len(self.passing_controls)} passing, "
695
+ f"{len(self.failing_controls)} failing controls"
696
+ )
697
+ logger.info(f"Populated failed_compliance_items list with {len(self.failed_compliance_items)} items")
698
+
699
+ def process_compliance_sync(self) -> None:
700
+ """
701
+ New main method using ComplianceIntegration pattern.
702
+
703
+ This replaces the old process_compliance_report method.
704
+ """
705
+ logger.info("Starting Wiz compliance sync using ComplianceIntegration pattern...")
706
+ self.sync_compliance()
707
+
708
+ def _get_or_create_report(self, max_age_hours: int = None) -> Optional[str]:
709
+ """
710
+ Get existing recent report or create a new one if needed.
711
+
712
+ :param int max_age_hours: Maximum age in hours for reusing existing reports (deprecated, use max_report_age_days)
713
+ :return: Path to report file
714
+ :rtype: Optional[str]
715
+ """
716
+ # Handle force fresh report request
717
+ if self.force_fresh_report:
718
+ logger.info("Force fresh report requested, creating new compliance report...")
719
+ return self._create_and_download_report(force_new=True)
720
+
721
+ # Use instance variable max_report_age_days or legacy max_age_hours
722
+ if max_age_hours is not None:
723
+ # Legacy behavior for backward compatibility
724
+ max_age_hours_to_use = max_age_hours
725
+ logger.warning("Using deprecated max_age_hours parameter. Consider using max_report_age_days instead.")
726
+ else:
727
+ # Convert days to hours for the internal method
728
+ max_age_hours_to_use = self.max_report_age_days * 24
729
+
730
+ # Check for existing recent reports
731
+ existing_report = self._find_recent_report(max_age_hours_to_use)
732
+ if existing_report:
733
+ logger.info(f"Using existing report: {existing_report}")
734
+ return existing_report
735
+
736
+ # No recent report found, create a new one
737
+ logger.info(f"No recent report found within {self.max_report_age_days} days, creating new compliance report...")
738
+ return self._create_and_download_report()
739
+
740
+ def _find_recent_report(self, max_age_hours: int = 24) -> Optional[str]:
741
+ """
742
+ Find the most recent compliance report within the specified age limit.
743
+
744
+ :param int max_age_hours: Maximum age in hours
745
+ :return: Path to recent report file or None
746
+ :rtype: Optional[str]
747
+ """
748
+ artifacts_dir = "artifacts/wiz"
749
+ if not os.path.exists(artifacts_dir):
750
+ return None
751
+
752
+ report_prefix = f"compliance_report_{self.wiz_project_id}_"
753
+ cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
754
+
755
+ # Find matching files
756
+ matching_files = []
757
+ for filename in os.listdir(artifacts_dir):
758
+ if filename.startswith(report_prefix) and filename.endswith(".csv"):
759
+ file_path = os.path.join(artifacts_dir, filename)
760
+ try:
761
+ # Get file modification time
762
+ mod_time = datetime.fromtimestamp(os.path.getmtime(file_path))
763
+ if mod_time > cutoff_time:
764
+ matching_files.append((file_path, mod_time))
765
+ except (OSError, ValueError):
766
+ continue
767
+
768
+ if not matching_files:
769
+ return None
770
+
771
+ # Return the most recent file
772
+ most_recent = max(matching_files, key=lambda x: x[1])
773
+ age_hours = (datetime.now() - most_recent[1]).total_seconds() / 3600
774
+ logger.info(f"Found recent report (age: {age_hours:.1f}h): {most_recent[0]}")
775
+ return most_recent[0]
776
+
777
+ def _find_existing_compliance_report(self) -> Optional[str]:
778
+ """
779
+ Find existing compliance report for the current project.
780
+
781
+ :return: Report ID if found, None otherwise
782
+ :rtype: Optional[str]
783
+ """
784
+ try:
785
+ # Filter for compliance reports for this specific project
786
+ filter_by = {"project": [self.wiz_project_id], "type": ["COMPLIANCE_ASSESSMENTS"]}
787
+
788
+ logger.debug(f"Searching for existing compliance reports with filter: {filter_by}")
789
+ reports = self.report_manager.list_reports(filter_by=filter_by)
790
+
791
+ if not reports:
792
+ logger.info("No existing compliance reports found for this project")
793
+ return None
794
+
795
+ # Look for reports named "Compliance Report" (the default name)
796
+ compliance_reports = [report for report in reports if report.get("name", "").strip() == "Compliance Report"]
797
+
798
+ if not compliance_reports:
799
+ logger.info("No compliance reports with standard name found")
800
+ return None
801
+
802
+ # Return the first matching report (most recent will be used)
803
+ selected_report = compliance_reports[0]
804
+ report_id = selected_report.get("id")
805
+ report_name = selected_report.get("name", "Unknown")
806
+
807
+ logger.info(f"Found existing compliance report: '{report_name}' (ID: {report_id})")
808
+ return report_id
809
+
810
+ except Exception as e:
811
+ logger.error(f"Error searching for existing compliance reports: {e}")
812
+ return None
813
+
814
+ def _create_and_download_report(self, force_new: bool = False) -> Optional[str]:
815
+ """
816
+ Find existing compliance report and rerun it, or create a new one if none exists.
817
+
818
+ :param bool force_new: Force creation of new report, skip reuse logic
819
+ :return: Path to downloaded report file
820
+ :rtype: Optional[str]
821
+ """
822
+ if force_new or not self.reuse_existing_reports:
823
+ logger.info("Creating new compliance report (reuse disabled or forced)")
824
+ # Create new report
825
+ report_id = self.report_manager.create_compliance_report(self.wiz_project_id)
826
+ if not report_id:
827
+ logger.error("Failed to create compliance report")
828
+ return None
829
+
830
+ # Wait for completion and get download URL
831
+ download_url = self.report_manager.wait_for_report_completion(report_id)
832
+ else:
833
+ logger.info(f"Looking for existing compliance report for project: {self.wiz_project_id}")
834
+
835
+ # Try to find existing compliance report for this project
836
+ if existing_report_id := self._find_existing_compliance_report():
837
+ logger.info(
838
+ f"Found existing compliance report {existing_report_id}, rerunning instead of creating new one"
839
+ )
840
+ # Rerun existing report
841
+ download_url = self.report_manager.rerun_report(existing_report_id)
842
+ else:
843
+ logger.info("No existing compliance report found, creating new one")
844
+ # Create new report
845
+ report_id = self.report_manager.create_compliance_report(self.wiz_project_id)
846
+ if not report_id:
847
+ logger.error("Failed to create compliance report")
848
+ return None
849
+
850
+ # Wait for completion and get download URL
851
+ download_url = self.report_manager.wait_for_report_completion(report_id)
852
+
853
+ if not download_url:
854
+ logger.error("Failed to get download URL for report")
855
+ return None
856
+
857
+ # Download report
858
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
859
+ output_path = f"artifacts/wiz/compliance_report_{self.wiz_project_id}_{timestamp}.csv"
860
+
861
+ # Ensure directory exists
862
+ artifacts_dir = os.path.dirname(output_path)
863
+ os.makedirs(artifacts_dir, exist_ok=True)
864
+
865
+ if self.report_manager.download_report(download_url, output_path):
866
+ # Clean up old report files
867
+ ReportFileCleanup.cleanup_old_files(
868
+ directory=artifacts_dir, file_prefix="compliance_report_", extensions=[".csv"], keep_count=5
869
+ )
870
+ return output_path
871
+ else:
872
+ logger.error("Failed to download report")
873
+ return None
874
+
875
+ def _update_passing_controls_to_implemented(self, passing_control_ids: list[str]) -> None:
876
+ """
877
+ Update passing controls to 'Implemented' status in RegScale.
878
+
879
+ :param list[str] passing_control_ids: List of control IDs that passed
880
+ """
881
+ if not passing_control_ids:
882
+ return
883
+
884
+ # Initialize Application for control implementation updates
885
+ # app = Application() # Will be used through self.app
886
+
887
+ try:
888
+ # Use the existing method that works for getting control name to implementation ID mapping
889
+ control_impl_map = ControlImplementation.get_control_label_map_by_parent(
890
+ parent_id=self.plan_id, parent_module=self.parent_module
891
+ )
892
+
893
+ logger.debug(
894
+ f"Built control implementation map with {len(control_impl_map)} entries using get_control_label_map_by_parent"
895
+ )
896
+ if control_impl_map:
897
+ sample_keys = list(control_impl_map.keys())[:10]
898
+ logger.debug(f"Sample control names in map: {sample_keys}")
899
+
900
+ logger.debug(f"Looking for passing control IDs: {passing_control_ids}")
901
+
902
+ # Prepare batch updates for passing controls
903
+ implementations_to_update = []
904
+
905
+ # Debug: Show what keys are actually in the control_impl_map
906
+ if control_impl_map:
907
+ logger.debug(f"Control implementation map keys: {list(control_impl_map.keys())[:20]}")
908
+
909
+ for control_id in passing_control_ids:
910
+ control_id_lower = control_id.lower()
911
+ logger.debug(f"Looking for control '{control_id_lower}' in implementation map")
912
+
913
+ if control_id_lower in control_impl_map:
914
+ impl_id = control_impl_map[control_id_lower]
915
+ logger.debug(f"Found matching implementation for '{control_id_lower}': {impl_id}")
916
+
917
+ # Get the ControlImplementation object
918
+ impl = ControlImplementation.get_object(object_id=impl_id)
919
+ if impl:
920
+ # Update status using compliance settings
921
+ new_status = self._get_implementation_status_from_result("Pass")
922
+ logger.debug(f"Setting control {control_id} status from 'Pass' result to: {new_status}")
923
+ impl.status = new_status
924
+ impl.dateLastAssessed = get_current_datetime()
925
+ impl.lastAssessmentResult = "Pass"
926
+ impl.bStatusImplemented = True
927
+
928
+ # Set audit fields if available
929
+ user_id = self.app.config.get("userId")
930
+ if user_id:
931
+ impl.lastUpdatedById = user_id
932
+ impl.dateLastUpdated = get_current_datetime()
933
+
934
+ implementations_to_update.append(impl.dict())
935
+ logger.info(f"Marking control {control_id} as {new_status}")
936
+
937
+ # Batch update all implementations
938
+ if implementations_to_update:
939
+ ControlImplementation.put_batch_implementation(self.app, implementations_to_update)
940
+ logger.info(f"Successfully updated {len(implementations_to_update)} controls to Implemented status")
941
+ else:
942
+ logger.warning("No matching control implementations found to update")
943
+
944
+ except Exception as e:
945
+ logger.error(f"Error updating control implementation status: {e}")
946
+
947
+ def _update_failing_controls_to_in_remediation(self, control_ids: List[str]) -> None:
948
+ """
949
+ Update control implementation status to In Remediation for failing controls.
950
+
951
+ :param List[str] control_ids: List of control IDs that are failing
952
+ :return: None
953
+ :rtype: None
954
+ """
955
+ if not control_ids:
956
+ return
957
+
958
+ try:
959
+ control_impl_map = self._get_control_implementation_map()
960
+ if not control_impl_map:
961
+ return
962
+
963
+ implementations_to_update, controls_not_found = self._process_failing_control_ids(
964
+ control_ids, control_impl_map
965
+ )
966
+
967
+ self._log_update_summary(implementations_to_update, controls_not_found)
968
+ self._batch_update_implementations(implementations_to_update)
969
+
970
+ except Exception as e:
971
+ logger.error(f"Error updating failing control implementation status: {e}")
972
+
973
+ def _get_control_implementation_map(self) -> dict:
974
+ """Get control implementation map and validate it exists."""
975
+
976
+ control_impl_map = ControlImplementation.get_control_label_map_by_parent(
977
+ parent_id=self.plan_id, parent_module=self.parent_module
978
+ )
979
+
980
+ if not control_impl_map:
981
+ logger.warning("No control implementation mapping found for security plan")
982
+ return {}
983
+
984
+ logger.debug(f"Control implementation map contains {len(control_impl_map)} entries")
985
+ return control_impl_map
986
+
987
+ def _process_failing_control_ids(self, control_ids: List[str], control_impl_map: dict) -> tuple[list, list]:
988
+ """Process failing control IDs and return implementations to update and controls not found."""
989
+
990
+ logger.debug(f"Looking for failing control IDs: {control_ids}")
991
+ implementations_to_update = []
992
+ controls_not_found = []
993
+
994
+ # Debug: Show what keys are actually in the control_impl_map for comparison
995
+ if control_impl_map:
996
+ logger.debug(f"Control implementation map keys (first 20): {list(control_impl_map.keys())[:20]}")
997
+
998
+ for control_id in control_ids:
999
+ control_id_normalized = control_id.lower()
1000
+ logger.debug(f"Looking for control '{control_id_normalized}' in implementation map")
1001
+
1002
+ if control_id_normalized in control_impl_map:
1003
+ impl = self._update_single_control_implementation(
1004
+ control_id, control_id_normalized, control_impl_map[control_id_normalized]
1005
+ )
1006
+ if impl:
1007
+ implementations_to_update.append(impl)
1008
+ else:
1009
+ controls_not_found.append(control_id)
1010
+ else:
1011
+ logger.debug(f"Control '{control_id_normalized}' not found in implementation map")
1012
+ controls_not_found.append(control_id)
1013
+
1014
+ return implementations_to_update, controls_not_found
1015
+
1016
+ def _update_single_control_implementation(
1017
+ self, control_id: str, control_id_normalized: str, impl_id: int
1018
+ ) -> Optional[dict]:
1019
+ """Update a single control implementation to In Remediation status.
1020
+ :param str control_id: ID of the control to update
1021
+ :param str control_id_normalized: ID of the control to update
1022
+ :param int impl_id: ID of the implementation to update
1023
+ :return: Updated implementation status if implementation exists
1024
+ :rtype: Optional[dict]
1025
+ """
1026
+ from regscale.core.app.utils.app_utils import get_current_datetime
1027
+ from regscale.models.regscale_models import ControlImplementationStatus
1028
+
1029
+ logger.debug(f"Found matching implementation for '{control_id_normalized}': {impl_id}")
1030
+
1031
+ impl = ControlImplementation.get_object(object_id=impl_id)
1032
+ if not impl:
1033
+ logger.warning(f"Could not retrieve implementation object for ID {impl_id}")
1034
+ return None
1035
+
1036
+ # Update status using compliance settings
1037
+ new_status = self._get_implementation_status_from_result("Fail")
1038
+ logger.debug(f"Setting control {control_id} status from 'Fail' result to: {new_status}")
1039
+ impl.status = new_status
1040
+ impl.dateLastAssessed = get_current_datetime()
1041
+ impl.lastAssessmentResult = "Fail"
1042
+ impl.bStatusImplemented = False
1043
+
1044
+ # Set audit fields if available
1045
+ user_id = self.app.config.get("userId")
1046
+ if user_id:
1047
+ impl.lastUpdatedById = user_id
1048
+ impl.dateLastUpdated = get_current_datetime()
1049
+
1050
+ logger.info(f"Marking control {control_id} as {new_status}")
1051
+ return impl.dict()
1052
+
1053
+ def _log_update_summary(self, implementations_to_update: list, controls_not_found: list) -> None:
1054
+ """Log summary of control implementation updates."""
1055
+ if controls_not_found:
1056
+ skipped_list = ", ".join(controls_not_found[:5])
1057
+ more_indicator = "..." if len(controls_not_found) > 5 else ""
1058
+ logger.info(
1059
+ f"Control implementation status update summary: {len(implementations_to_update)} found, "
1060
+ f"{len(controls_not_found)} not in plan (skipped: {skipped_list}{more_indicator})"
1061
+ )
1062
+
1063
+ def _batch_update_implementations(self, implementations_to_update: list) -> None:
1064
+ """Perform batch update of control implementations."""
1065
+ if implementations_to_update:
1066
+ ControlImplementation.put_batch_implementation(self.app, implementations_to_update)
1067
+ logger.debug(f"Updated {len(implementations_to_update)} Control Implementations, Successfully!")
1068
+ logger.info(f"Successfully updated {len(implementations_to_update)} controls to In Remediation status")
1069
+ else:
1070
+ logger.warning("No matching control implementations found to update for failing controls")
1071
+
1072
+ def _process_control_assessments(self) -> None:
1073
+ """
1074
+ Override parent method to add control implementation status updates.
1075
+ """
1076
+ # Call parent method to create assessments
1077
+ super()._process_control_assessments()
1078
+
1079
+ # Update control implementation status for both passing and failing controls if enabled
1080
+ if self.update_control_status:
1081
+ if self.passing_controls:
1082
+ passing_control_ids = list(self.passing_controls.keys())
1083
+ logger.info(f"Updating control implementation status for {len(passing_control_ids)} passing controls")
1084
+ self._update_passing_controls_to_implemented(passing_control_ids)
1085
+
1086
+ if self.failing_controls:
1087
+ failing_control_ids = list(self.failing_controls.keys())
1088
+ logger.info(
1089
+ f"Attempting to update control implementation status for {len(failing_control_ids)} failing controls"
1090
+ )
1091
+ self._update_failing_controls_to_in_remediation(failing_control_ids)
1092
+
1093
+ def _categorize_controls_by_aggregation(self) -> None:
1094
+ """
1095
+ Override parent method to implement "fail-first" logic for Wiz compliance.
1096
+
1097
+ In the Wiz compliance integration, we implement strict "fail-first" logic:
1098
+ - If ANY compliance item for a control is failing, the entire control is marked as failing
1099
+ - A control is only marked as passing if ALL instances of that control are passing
1100
+ - This applies to both single-control and multi-control compliance items
1101
+ """
1102
+ control_items = self._group_compliance_items_by_control()
1103
+ self._apply_fail_first_logic_to_controls(control_items)
1104
+ self._populate_failed_compliance_items_from_control_items(control_items)
1105
+ self._log_categorization_results()
1106
+
1107
+ def _group_compliance_items_by_control(self) -> dict:
1108
+ """
1109
+ Group compliance items by control ID.
1110
+
1111
+ Creates a dictionary mapping control IDs (lowercase) to lists of
1112
+ compliance items that reference those controls. Handles multi-control
1113
+ items that may reference multiple control IDs.
1114
+
1115
+ :return: Dictionary mapping control IDs to lists of compliance items
1116
+ :rtype: dict
1117
+ """
1118
+ from collections import defaultdict
1119
+
1120
+ control_items = defaultdict(list)
1121
+
1122
+ for item in self.all_compliance_items:
1123
+ control_ids = self._extract_control_ids_from_item(item)
1124
+ self._add_item_to_control_groups(item, control_ids, control_items)
1125
+
1126
+ logger.debug(
1127
+ f"Grouped {len(self.all_compliance_items)} compliance items into {len(control_items)} control groups"
1128
+ )
1129
+ return control_items
1130
+
1131
+ def _extract_control_ids_from_item(self, item) -> list:
1132
+ """
1133
+ Extract all control IDs that an item affects.
1134
+
1135
+ Checks if the item has a get_all_control_ids method for multi-control
1136
+ items, otherwise falls back to the single control_id attribute.
1137
+
1138
+ :param item: Compliance item to extract control IDs from
1139
+ :type item: Any
1140
+ :return: List of control ID strings
1141
+ :rtype: list
1142
+ """
1143
+ if hasattr(item, "get_all_control_ids") and callable(item.get_all_control_ids):
1144
+ return item.get_all_control_ids()
1145
+ return [item.control_id] if item.control_id else []
1146
+
1147
+ def _add_item_to_control_groups(self, item, control_ids: list, control_items: dict) -> None:
1148
+ """
1149
+ Add item to all control groups it affects.
1150
+
1151
+ Adds the compliance item to the appropriate control groups based on
1152
+ all the control IDs it references. Uses lowercase control IDs as keys.
1153
+
1154
+ :param item: Compliance item to add to groups
1155
+ :type item: Any
1156
+ :param list control_ids: List of control IDs the item affects
1157
+ :param dict control_items: Dictionary of control groups to update
1158
+ :return: None
1159
+ :rtype: None
1160
+ """
1161
+ for control_id in control_ids:
1162
+ if control_id:
1163
+ control_key = control_id.lower()
1164
+ control_items[control_key].append(item)
1165
+
1166
+ def _apply_fail_first_logic_to_controls(self, control_items: dict) -> None:
1167
+ """
1168
+ Apply fail-first logic to categorize each control as passing or failing.
1169
+
1170
+ For each control, determines its overall status based on all associated
1171
+ compliance items. Any failure in the items makes the control fail.
1172
+
1173
+ :param dict control_items: Dictionary mapping control IDs to compliance items
1174
+ :return: None
1175
+ :rtype: None
1176
+ """
1177
+ for control_key, items in control_items.items():
1178
+ control_status = self._determine_control_status(items)
1179
+ self._categorize_control(control_key, control_status, len(items))
1180
+
1181
+ def _determine_control_status(self, items: list) -> dict:
1182
+ """
1183
+ Determine the overall status of a control based on its items.
1184
+
1185
+ Analyzes all compliance items for a control to determine if any are
1186
+ failing or passing. Returns status indicators and representative items.
1187
+
1188
+ :param list items: List of compliance items for the control
1189
+ :return: Dictionary with status flags and representative items
1190
+ :rtype: dict
1191
+ """
1192
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
1193
+ pass_statuses_lower = [status.lower() for status in self.PASS_STATUSES]
1194
+
1195
+ status = {"has_failure": False, "has_pass": False, "failing_item": None, "passing_item": None}
1196
+
1197
+ for item in items:
1198
+ result_lower = item.compliance_result.lower()
1199
+
1200
+ if result_lower in fail_statuses_lower:
1201
+ status["has_failure"] = True
1202
+ if not status["failing_item"]:
1203
+ status["failing_item"] = item
1204
+ elif result_lower in pass_statuses_lower:
1205
+ status["has_pass"] = True
1206
+ if not status["passing_item"]:
1207
+ status["passing_item"] = item
1208
+
1209
+ return status
1210
+
1211
+ def _categorize_control(self, control_key: str, status: dict, item_count: int) -> None:
1212
+ """
1213
+ Categorize a control as passing or failing based on its status.
1214
+
1215
+ Uses the status information to place the control in the appropriate
1216
+ passing or failing collection and logs the categorization decision.
1217
+
1218
+ :param str control_key: Control ID (lowercase)
1219
+ :param dict status: Status information from _determine_control_status
1220
+ :param int item_count: Number of items analyzed for the control
1221
+ :return: None
1222
+ :rtype: None
1223
+ """
1224
+ if status["has_failure"]:
1225
+ self.failing_controls[control_key] = status["failing_item"]
1226
+ logger.debug(f"Control {control_key} marked as FAILING: fail-first logic triggered")
1227
+ elif status["has_pass"]:
1228
+ self.passing_controls[control_key] = status["passing_item"]
1229
+ logger.debug(f"Control {control_key} marked as PASSING: all {item_count} items passed")
1230
+ else:
1231
+ logger.debug(f"Control {control_key} has unclear results - no pass or fail statuses found")
1232
+
1233
+ def _populate_failed_compliance_items_from_control_items(self, control_items: dict) -> None:
1234
+ """
1235
+ Populate the list of failed compliance items from failing controls.
1236
+
1237
+ Collects all failing compliance items from controls marked as failing,
1238
+ removes duplicates, and updates the failed_compliance_items list.
1239
+
1240
+ :param dict control_items: Dictionary mapping control IDs to compliance items
1241
+ :return: None
1242
+ :rtype: None
1243
+ """
1244
+ self.failed_compliance_items.clear()
1245
+ failing_items = self._collect_failing_items_from_controls(control_items)
1246
+ self.failed_compliance_items = self._remove_duplicate_items(failing_items)
1247
+ logger.info(f"Populated failed_compliance_items list with {len(self.failed_compliance_items)} items")
1248
+
1249
+ def _collect_failing_items_from_controls(self, control_items: dict) -> list:
1250
+ """
1251
+ Collect all failing items from controls marked as failing.
1252
+
1253
+ Iterates through controls marked as failing and collects all their
1254
+ compliance items that have failing status results.
1255
+
1256
+ :param dict control_items: Dictionary mapping control IDs to compliance items
1257
+ :return: List of failing compliance items
1258
+ :rtype: list
1259
+ """
1260
+ failing_items = []
1261
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
1262
+
1263
+ for control_key, items in control_items.items():
1264
+ if control_key in self.failing_controls:
1265
+ for item in items:
1266
+ if item.compliance_result.lower() in fail_statuses_lower:
1267
+ failing_items.append(item)
1268
+
1269
+ return failing_items
1270
+
1271
+ def _remove_duplicate_items(self, items: list) -> list:
1272
+ """
1273
+ Remove duplicate items while preserving order.
1274
+
1275
+ Uses resource_id and control_id combinations to create unique keys
1276
+ for deduplication while maintaining the original item order.
1277
+
1278
+ :param list items: List of compliance items to deduplicate
1279
+ :return: List of unique compliance items
1280
+ :rtype: list
1281
+ """
1282
+ seen = set()
1283
+ unique_items = []
1284
+
1285
+ for item in items:
1286
+ item_key = f"{getattr(item, 'resource_id', '')}-{getattr(item, 'control_id', '')}"
1287
+ if item_key not in seen:
1288
+ seen.add(item_key)
1289
+ unique_items.append(item)
1290
+
1291
+ return unique_items
1292
+
1293
+ def _log_categorization_results(self) -> None:
1294
+ """
1295
+ Log the final results of control categorization.
1296
+
1297
+ Provides summary statistics about the fail-first categorization
1298
+ process, including counts of passing and failing controls.
1299
+
1300
+ :return: None
1301
+ :rtype: None
1302
+ """
1303
+ logger.info(
1304
+ f"Control categorization with fail-first logic: "
1305
+ f"{len(self.passing_controls)} passing controls, "
1306
+ f"{len(self.failing_controls)} failing controls"
1307
+ )
1308
+
1309
+ def fetch_findings(self, *args, **kwargs):
1310
+ """
1311
+ Override to create one finding per control rather than per compliance item.
1312
+
1313
+ This ensures that each failing control gets exactly one issue in RegScale,
1314
+ consolidating all failed compliance items for that control.
1315
+ """
1316
+ logger.info("Fetching findings from failed controls (one per control)...")
1317
+
1318
+ processed_controls = set()
1319
+ findings_created = 0
1320
+
1321
+ for compliance_item in self.failed_compliance_items:
1322
+ control_ids = self._get_control_ids_for_item(compliance_item)
1323
+
1324
+ for control_id in control_ids:
1325
+ if not control_id or self._is_control_already_processed(control_id, processed_controls):
1326
+ continue
1327
+
1328
+ control_id_normalized = control_id.upper()
1329
+ processed_controls.add(control_id.lower())
1330
+
1331
+ control_failed_items = self._get_failed_items_for_control(control_id_normalized)
1332
+ finding = self._create_consolidated_finding_for_control(
1333
+ control_id=control_id_normalized, failed_items=control_failed_items
1334
+ )
1335
+
1336
+ if finding:
1337
+ findings_created += 1
1338
+ yield finding
1339
+
1340
+ self._log_findings_generation_summary(findings_created, len(processed_controls))
1341
+
1342
+ def _is_control_already_processed(self, control_id: str, processed_controls: set) -> bool:
1343
+ """
1344
+ Check if control has already been processed to avoid duplicates.
1345
+
1346
+ Uses case-insensitive comparison to determine if a control has
1347
+ already been processed for finding generation.
1348
+
1349
+ :param str control_id: Control ID to check
1350
+ :param set processed_controls: Set of already processed control IDs
1351
+ :return: True if control has been processed, False otherwise
1352
+ :rtype: bool
1353
+ """
1354
+ return control_id.lower() in processed_controls
1355
+
1356
+ def _get_failed_items_for_control(self, control_id_normalized: str) -> List[Any]:
1357
+ """
1358
+ Get all failed compliance items for a specific control.
1359
+
1360
+ Searches through the failed compliance items to find all items
1361
+ that reference the specified control ID (case-insensitive).
1362
+
1363
+ :param str control_id_normalized: Control ID in normalized format
1364
+ :return: List of failed compliance items for the control
1365
+ :rtype: List[Any]
1366
+ """
1367
+ control_failed_items = []
1368
+
1369
+ for item in self.failed_compliance_items:
1370
+ item_control_ids = self._get_control_ids_for_item(item)
1371
+
1372
+ if any(cid.upper() == control_id_normalized for cid in item_control_ids):
1373
+ control_failed_items.append(item)
1374
+
1375
+ return control_failed_items
1376
+
1377
+ def _log_findings_generation_summary(self, findings_created: int, controls_processed: int) -> None:
1378
+ """
1379
+ Log summary of findings generation.
1380
+
1381
+ Provides statistics about the finding generation process,
1382
+ including number of findings created and controls processed.
1383
+
1384
+ :param int findings_created: Number of findings successfully created
1385
+ :param int controls_processed: Number of controls processed
1386
+ :return: None
1387
+ :rtype: None
1388
+ """
1389
+ logger.info(
1390
+ f"Generated {findings_created} findings from {controls_processed} failing controls for issue processing"
1391
+ )
1392
+
1393
+ def _create_consolidated_finding_for_control(self, control_id: str, failed_items: list) -> Optional[Any]:
1394
+ """
1395
+ Create a single consolidated finding for a control with all its failed compliance items.
1396
+
1397
+ :param str control_id: The control identifier
1398
+ :param list failed_items: List of failed compliance items for this control
1399
+ :return: IntegrationFinding or None
1400
+ """
1401
+ try:
1402
+ from regscale.integrations.scanner_integration import IntegrationFinding
1403
+
1404
+ if not failed_items:
1405
+ return None
1406
+
1407
+ representative_item = failed_items[0]
1408
+ resource_info = self._collect_resource_information(failed_items)
1409
+ severity = self._determine_highest_severity(resource_info["severities"])
1410
+ description = self._build_consolidated_description(control_id, resource_info)
1411
+
1412
+ severity_enum = self._map_string_severity_to_enum(severity)
1413
+
1414
+ return self._create_integration_finding(
1415
+ control_id=control_id,
1416
+ severity_enum=severity_enum,
1417
+ description=description,
1418
+ representative_item=representative_item,
1419
+ )
1420
+
1421
+ except Exception as e:
1422
+ logger.error(f"Error creating consolidated finding for control {control_id}: {e}")
1423
+ return None
1424
+
1425
+ def _map_severity_to_priority(self, severity: Any) -> str:
1426
+ """
1427
+ Map severity enum to priority string.
1428
+
1429
+ Converts RegScale severity enumeration values to corresponding
1430
+ priority strings used in issue creation.
1431
+
1432
+ :param Any severity: Severity enum value
1433
+ :return: Priority string (High, Moderate, Low)
1434
+ :rtype: str
1435
+ """
1436
+ # Map severity to priority
1437
+ if hasattr(severity, "value"):
1438
+ severity_value = severity.value
1439
+ else:
1440
+ severity_value = str(severity)
1441
+
1442
+ priority_map = {"Critical": "High", "High": "High", "Moderate": "Moderate", "Low": "Low"}
1443
+
1444
+ return priority_map.get(severity_value, "Low")
1445
+
1446
+ def _collect_resource_information(self, failed_items: list) -> Dict[str, Any]:
1447
+ """Collect resource information from failed compliance items.
1448
+
1449
+ :param list failed_items: List of failed compliance items to process
1450
+ :return: Dictionary with resource information including affected_resources, severities, and descriptions
1451
+ :rtype: Dict[str, Any]
1452
+ """
1453
+ affected_resources = set()
1454
+ severities = []
1455
+ descriptions = []
1456
+
1457
+ for item in failed_items:
1458
+ affected_resources.add(item.resource_name)
1459
+ if item.severity:
1460
+ severities.append(item.severity)
1461
+ descriptions.append(f"- {item.resource_name}: {item.description}")
1462
+
1463
+ return {"affected_resources": affected_resources, "severities": severities, "descriptions": descriptions}
1464
+
1465
+ def _determine_highest_severity(self, severities: List[str]) -> str:
1466
+ """Determine the highest severity from a list of severities.
1467
+
1468
+ :param List[str] severities: List of severity strings to analyze
1469
+ :return: The highest severity found in the list
1470
+ :rtype: str
1471
+ """
1472
+ severity = "HIGH" # Default
1473
+ if severities:
1474
+ severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]
1475
+ for sev in severity_order:
1476
+ if sev in [s.upper() for s in severities]:
1477
+ severity = sev
1478
+ break
1479
+ return severity
1480
+
1481
+ def _build_consolidated_description(self, control_id: str, resource_info: Dict[str, Any]) -> str:
1482
+ """Build consolidated description for the finding.
1483
+
1484
+ :param str control_id: The control identifier
1485
+ :param Dict[str, Any] resource_info: Dictionary with resource information
1486
+ :return: Consolidated description string for the finding
1487
+ :rtype: str
1488
+ """
1489
+ affected_resources = resource_info["affected_resources"]
1490
+ descriptions = resource_info["descriptions"]
1491
+
1492
+ description = f"Control {control_id} failed for {len(affected_resources)} resource(s):\n\n"
1493
+ description += "\n".join(descriptions[:10]) # Limit to first 10 for readability
1494
+
1495
+ if len(descriptions) > 10:
1496
+ description += f"\n... and {len(descriptions) - 10} more resources"
1497
+
1498
+ return description
1499
+
1500
+ def _create_integration_finding(
1501
+ self, control_id: str, severity_enum: Any, description: str, representative_item: Any
1502
+ ) -> Any:
1503
+ """Create the IntegrationFinding object.
1504
+
1505
+ :param str control_id: The control identifier
1506
+ :param Any severity_enum: Severity enumeration value
1507
+ :param str description: Description for the finding
1508
+ :param Any representative_item: Representative compliance item
1509
+ :return: IntegrationFinding object
1510
+ :rtype: Any
1511
+ """
1512
+ from regscale.integrations.scanner_integration import IntegrationFinding
1513
+
1514
+ return IntegrationFinding(
1515
+ control_labels=[control_id],
1516
+ title=f"Compliance Violation: {control_id}",
1517
+ category="Compliance",
1518
+ plugin_name=f"{self.title} Compliance Scanner - {control_id}",
1519
+ severity=severity_enum,
1520
+ description=description,
1521
+ status="Open",
1522
+ priority=self._map_severity_to_priority(severity_enum),
1523
+ external_id=f"{self.title.lower().replace(' ', '-')}-control-{control_id}",
1524
+ first_seen=self.scan_date,
1525
+ last_seen=self.scan_date,
1526
+ scan_date=self.scan_date,
1527
+ asset_identifier=representative_item.resource_id,
1528
+ vulnerability_type="Compliance Violation",
1529
+ rule_id=control_id,
1530
+ baseline=representative_item.framework,
1531
+ affected_controls=control_id,
1532
+ )
1533
+
1534
+ def _create_finding_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[Any]:
1535
+ """
1536
+ Override parent method to properly set affected_controls for multi-control items.
1537
+
1538
+ :param ComplianceItem compliance_item: The compliance item
1539
+ :return: Finding object or None if creation fails
1540
+ :rtype: Optional[Any]
1541
+ """
1542
+ try:
1543
+ # Get severity mapping
1544
+ severity = compliance_item.severity or "Low"
1545
+ severity_enum = self._map_string_severity_to_enum(severity)
1546
+
1547
+ # Create the finding using the parent class structure
1548
+ from regscale.integrations.scanner_integration import IntegrationFinding
1549
+
1550
+ finding = IntegrationFinding(
1551
+ control_labels=[compliance_item.control_id],
1552
+ title=f"Compliance Violation: {compliance_item.control_id}",
1553
+ category="Compliance",
1554
+ plugin_name=f"{self.title} Compliance Scanner",
1555
+ severity=severity_enum,
1556
+ description=compliance_item.description,
1557
+ status="Open", # Use string instead of enum to avoid import issues
1558
+ priority=self._map_severity_to_priority(severity_enum),
1559
+ external_id=f"{self.title.lower()}-{compliance_item.control_id}-{compliance_item.resource_id}",
1560
+ first_seen=self.scan_date,
1561
+ last_seen=self.scan_date,
1562
+ scan_date=self.scan_date,
1563
+ asset_identifier=compliance_item.resource_id,
1564
+ vulnerability_type="Compliance Violation",
1565
+ rule_id=compliance_item.control_id,
1566
+ baseline=compliance_item.framework,
1567
+ affected_controls=compliance_item.affected_controls, # Use our property with all control IDs
1568
+ )
1569
+
1570
+ return finding
1571
+
1572
+ except Exception as e:
1573
+ logger.error(f"Error creating finding from compliance item: {e}")
1574
+ return None