regscale-cli 6.23.0.0__py3-none-any.whl → 6.24.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 (44) 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/sarif/sarif_converter.py +1 -1
  5. regscale/integrations/commercial/wizv2/click.py +109 -2
  6. regscale/integrations/commercial/wizv2/compliance_report.py +1485 -0
  7. regscale/integrations/commercial/wizv2/constants.py +72 -2
  8. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  9. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  10. regscale/integrations/commercial/wizv2/issue.py +775 -27
  11. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  12. regscale/integrations/commercial/wizv2/reports.py +243 -0
  13. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  14. regscale/integrations/compliance_integration.py +304 -51
  15. regscale/integrations/due_date_handler.py +210 -0
  16. regscale/integrations/public/cci_importer.py +444 -0
  17. regscale/integrations/scanner_integration.py +718 -153
  18. regscale/models/integration_models/CCI_List.xml +1 -0
  19. regscale/models/integration_models/cisa_kev_data.json +61 -3
  20. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  21. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +3 -3
  22. regscale/models/regscale_models/form_field_value.py +1 -1
  23. regscale/models/regscale_models/milestone.py +1 -0
  24. regscale/models/regscale_models/regscale_model.py +225 -60
  25. regscale/models/regscale_models/security_plan.py +3 -2
  26. regscale/regscale.py +7 -0
  27. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/METADATA +9 -9
  28. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/RECORD +44 -27
  29. tests/fixtures/test_fixture.py +13 -8
  30. tests/regscale/integrations/public/__init__.py +0 -0
  31. tests/regscale/integrations/public/test_alienvault.py +220 -0
  32. tests/regscale/integrations/public/test_cci.py +458 -0
  33. tests/regscale/integrations/public/test_cisa.py +1021 -0
  34. tests/regscale/integrations/public/test_emass.py +518 -0
  35. tests/regscale/integrations/public/test_fedramp.py +851 -0
  36. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  37. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  38. tests/regscale/integrations/public/test_oscal.py +453 -0
  39. tests/regscale/models/test_form_field_value_integration.py +304 -0
  40. tests/regscale/models/test_module_integration.py +582 -0
  41. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/LICENSE +0 -0
  42. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/WHEEL +0 -0
  43. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/entry_points.txt +0 -0
  44. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/top_level.txt +0 -0
@@ -4,10 +4,11 @@ import logging
4
4
  import re
5
5
  from typing import List, Dict, Any, Iterator, Optional
6
6
 
7
+ from regscale import models as regscale_models
7
8
  from regscale.core.app.utils.parser_utils import safe_datetime_str
8
9
  from regscale.integrations.scanner_integration import IntegrationFinding
9
- from regscale.utils.dict_utils import get_value
10
10
  from regscale.models import Issue
11
+ from regscale.utils.dict_utils import get_value
11
12
  from .constants import (
12
13
  get_wiz_issue_queries,
13
14
  WizVulnerabilityType,
@@ -26,29 +27,608 @@ class WizIssue(WizVulnerabilityIntegration):
26
27
  asset_identifier_field = "wizId"
27
28
  issue_identifier_field = "wizId"
28
29
 
29
- def get_query_types(self, project_id: str) -> List[Dict[str, Any]]:
30
+ def get_query_types(self, project_id: str, filter_by: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
30
31
  """Get the query types for issue scanning.
31
32
 
32
33
  :param str project_id: The project ID to get queries for
34
+ :param Optional[Dict[str, Any]] filter_by: Optional filter criteria to override defaults
33
35
  :return: List of query types
34
36
  :rtype: List[Dict[str, Any]]
35
37
  """
36
- return get_wiz_issue_queries(project_id=project_id)
38
+ return get_wiz_issue_queries(project_id=project_id, filter_by=filter_by)
37
39
 
38
40
  def parse_findings(
39
41
  self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
40
42
  ) -> Iterator[IntegrationFinding]:
41
43
  """
42
- Parse the Wiz issues into IntegrationFinding objects
44
+ Parse the Wiz issues into IntegrationFinding objects.
45
+ Groups issues by source rule and server to consolidate multiple database assets.
43
46
  :param nodes:
44
47
  :param vulnerability_type:
45
48
  :return:
46
49
  """
50
+ logger.debug(f"ISSUE PROCESSING ANALYSIS: Received {len(nodes)} raw Wiz issues for processing")
51
+
52
+ # Analyze and log raw issue statistics
53
+ self._log_raw_issue_statistics(nodes)
54
+
55
+ # Filter nodes by minimum severity configuration
56
+ filtered_nodes = self._filter_nodes_by_severity(nodes)
57
+ if not filtered_nodes:
58
+ return
59
+
60
+ # Group and process issues for consolidation
61
+ grouped_issues = self._group_issues_for_consolidation(filtered_nodes)
62
+ self._log_consolidation_analysis(grouped_issues)
63
+
64
+ # Generate findings from grouped issues
65
+ yield from self._generate_findings_from_groups(grouped_issues, vulnerability_type, len(nodes))
66
+
67
+ def _log_raw_issue_statistics(self, nodes: List[Dict[str, Any]]) -> None:
68
+ """
69
+ Count and log raw issue statistics by severity and status.
70
+
71
+ :param List[Dict[str, Any]] nodes: List of raw Wiz issues
72
+ """
73
+ severity_counts: Dict[str, int] = {}
74
+ status_counts: Dict[str, int] = {}
75
+
76
+ for node in nodes:
77
+ severity = node.get("severity", "Low")
78
+ status = node.get("status", "OPEN")
79
+ severity_counts[severity] = severity_counts.get(severity, 0) + 1
80
+ status_counts[status] = status_counts.get(status, 0) + 1
81
+
82
+ logger.debug(f"Raw issue breakdown by severity: {severity_counts}")
83
+ logger.debug(f"Raw issue breakdown by status: {status_counts}")
84
+
85
+ def _filter_nodes_by_severity(self, nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
86
+ """
87
+ Filter nodes based on minimum severity configuration.
88
+
89
+ :param List[Dict[str, Any]] nodes: List of raw Wiz issues
90
+ :return: Filtered list of issues that meet severity requirements
91
+ :rtype: List[Dict[str, Any]]
92
+ """
93
+ filtered_nodes = []
94
+ filtered_out_count = 0
95
+
47
96
  for node in nodes:
48
- finding = self.parse_finding(node, vulnerability_type)
97
+ wiz_severity = node.get("severity", "Low")
98
+ wiz_id = node.get("id", "unknown")
99
+
100
+ if self.should_process_finding_by_severity(wiz_severity):
101
+ filtered_nodes.append(node)
102
+ else:
103
+ filtered_out_count += 1
104
+ logger.debug(
105
+ f"FILTERED BY SEVERITY: Issue {wiz_id} with severity '{wiz_severity}' filtered due to minimumSeverity configuration"
106
+ )
107
+
108
+ logger.debug(f"After severity filtering: {len(filtered_nodes)} issues kept, {filtered_out_count} filtered out")
109
+
110
+ if not filtered_nodes:
111
+ logger.warning("All findings filtered out by severity configuration - check your minimumSeverity setting")
112
+
113
+ return filtered_nodes
114
+
115
+ def _log_consolidation_analysis(self, grouped_issues: Dict[str, List[Dict[str, Any]]]) -> None:
116
+ """
117
+ Log detailed consolidation analysis statistics.
118
+
119
+ :param Dict[str, List[Dict[str, Any]]] grouped_issues: Issues grouped for consolidation
120
+ """
121
+ total_groups = len(grouped_issues)
122
+ consolidated_groups = sum(1 for group in grouped_issues.values() if len(group) > 1)
123
+ total_consolidated_issues = sum(len(group) for group in grouped_issues.values() if len(group) > 1)
124
+ single_issue_groups = total_groups - consolidated_groups
125
+
126
+ logger.debug("CONSOLIDATION ANALYSIS:")
127
+ logger.debug(f" • Total groups: {total_groups}")
128
+ logger.debug(f" • Groups with multiple issues (consolidated): {consolidated_groups}")
129
+ logger.debug(f" • Total issues being consolidated: {total_consolidated_issues}")
130
+ logger.debug(f" • Single-issue groups: {single_issue_groups}")
131
+ logger.debug(f" • Expected RegScale issues to create: {total_groups}")
132
+
133
+ def _generate_findings_from_groups(
134
+ self,
135
+ grouped_issues: Dict[str, List[Dict[str, Any]]],
136
+ vulnerability_type: WizVulnerabilityType,
137
+ total_raw_issues: int,
138
+ ) -> Iterator[IntegrationFinding]:
139
+ """
140
+ Generate IntegrationFindings from grouped issues, handling both consolidation and single issues.
141
+
142
+ :param Dict[str, List[Dict[str, Any]]] grouped_issues: Issues grouped for processing
143
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
144
+ :param int total_raw_issues: Total number of raw issues for logging
145
+ :return: Generator of IntegrationFindings
146
+ :rtype: Iterator[IntegrationFinding]
147
+ """
148
+ findings_generated = 0
149
+
150
+ for group_key, group_issues in grouped_issues.items():
151
+ if len(group_issues) > 1:
152
+ finding = self._process_consolidated_group(group_key, group_issues, vulnerability_type)
153
+ else:
154
+ finding = self._process_single_issue_group(group_issues[0], vulnerability_type)
155
+
49
156
  if finding:
157
+ findings_generated += 1
50
158
  yield finding
51
159
 
160
+ logger.info(f"Generated {findings_generated} RegScale findings from {total_raw_issues} raw Wiz issues")
161
+
162
+ def _process_consolidated_group(
163
+ self, group_key: str, group_issues: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
164
+ ) -> Optional[IntegrationFinding]:
165
+ """
166
+ Process a group with multiple issues that need consolidation.
167
+
168
+ :param str group_key: The consolidation group key
169
+ :param List[Dict[str, Any]] group_issues: List of issues to consolidate
170
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
171
+ :return: Consolidated finding or None if failed
172
+ :rtype: Optional[IntegrationFinding]
173
+ """
174
+ issue_ids = [issue.get("id", "unknown") for issue in group_issues]
175
+ logger.debug(f"CONSOLIDATING: Group '{group_key}' - merging {len(group_issues)} issues: {issue_ids}")
176
+
177
+ finding = self._create_consolidated_finding(group_issues, vulnerability_type)
178
+ if not finding:
179
+ logger.warning(f"Failed to create consolidated finding for group '{group_key}'")
180
+
181
+ return finding
182
+
183
+ def _process_single_issue_group(
184
+ self, issue: Dict[str, Any], vulnerability_type: WizVulnerabilityType
185
+ ) -> Optional[IntegrationFinding]:
186
+ """
187
+ Process a single issue that doesn't require consolidation.
188
+
189
+ :param Dict[str, Any] issue: The single issue to process
190
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
191
+ :return: Single issue finding or None if failed
192
+ :rtype: Optional[IntegrationFinding]
193
+ """
194
+ wiz_id = issue.get("id", "unknown")
195
+ logger.debug(f"📝 SINGLE ISSUE: Processing issue {wiz_id} individually")
196
+
197
+ finding = self.parse_finding(issue, vulnerability_type)
198
+ if not finding:
199
+ logger.warning(f"Failed to create finding for single issue {wiz_id}")
200
+
201
+ return finding
202
+
203
+ def _group_issues_for_consolidation(self, nodes: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
204
+ """
205
+ Group issues by source rule name (title) for consolidation.
206
+ This consolidates all issues with the same rule/title regardless of the affected asset.
207
+
208
+ :param List[Dict[str, Any]] nodes: List of Wiz issues
209
+ :return: Dictionary mapping rule names to lists of issues
210
+ :rtype: Dict[str, List[Dict[str, Any]]]
211
+ """
212
+ groups: Dict[str, List[Dict[str, Any]]] = {}
213
+
214
+ for issue in nodes:
215
+ # Group by source rule name only (which becomes the title)
216
+ # This ensures all issues with identical titles are consolidated together
217
+ source_rule = issue.get("sourceRule", {})
218
+ rule_name = source_rule.get("name", "")
219
+
220
+ # Use rule name as the grouping key
221
+ # If no rule name, fall back to issue name to avoid empty keys
222
+ group_key = rule_name or issue.get("name", "") or f"unknown-{issue.get('id', 'no-id')}"
223
+
224
+ if group_key not in groups:
225
+ groups[group_key] = []
226
+ groups[group_key].append(issue)
227
+
228
+ # Log the grouping for analysis
229
+ logger.debug(f"Grouping issue {issue.get('id')} under key '{group_key}'")
230
+
231
+ return groups
232
+
233
+ def _determine_grouping_scope(self, provider_id: str, rule_name: str) -> str:
234
+ """
235
+ DEPRECATED: This method is no longer used as consolidation is now done by title only.
236
+ Kept for backward compatibility but will be removed in future versions.
237
+
238
+ :param str provider_id: The Azure provider ID
239
+ :param str rule_name: The source rule name
240
+ :return: The grouping scope (always returns provider_id)
241
+ :rtype: str
242
+ """
243
+ # This method is deprecated - consolidation now happens by title only
244
+ logger.debug("_determine_grouping_scope is deprecated and will be removed in a future version")
245
+ return provider_id
246
+
247
+ def _create_consolidated_finding(
248
+ self, issues: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
249
+ ) -> IntegrationFinding:
250
+ """
251
+ Create a consolidated finding from multiple issues with the same rule.
252
+ Implements priority rules for severity, status, due date, and asset consolidation.
253
+
254
+ :param List[Dict[str, Any]] issues: List of issues to consolidate
255
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
256
+ :return: Consolidated IntegrationFinding
257
+ :rtype: IntegrationFinding
258
+ """
259
+ # Determine consolidation priorities
260
+ highest_severity = self._determine_highest_severity(issues)
261
+ most_urgent_status = self._determine_most_urgent_status(issues)
262
+ earliest_created = self._find_earliest_creation_date(issues)
263
+ base_issue = self._select_base_issue(issues, highest_severity)
264
+
265
+ # Consolidate asset information
266
+ primary_asset_id, consolidated_provider_ids = self._consolidate_all_assets(issues)
267
+
268
+ # Log consolidation details
269
+ self._log_consolidation_details(
270
+ issues, base_issue, highest_severity, most_urgent_status, primary_asset_id, consolidated_provider_ids
271
+ )
272
+
273
+ # Create and return the consolidated finding
274
+ return self._build_integration_finding(
275
+ base_issue,
276
+ vulnerability_type,
277
+ highest_severity,
278
+ most_urgent_status,
279
+ earliest_created,
280
+ primary_asset_id,
281
+ consolidated_provider_ids,
282
+ )
283
+
284
+ def _determine_highest_severity(self, issues: List[Dict[str, Any]]) -> str:
285
+ """
286
+ Determine the highest priority severity from a list of issues.
287
+
288
+ :param List[Dict[str, Any]] issues: List of issues to analyze
289
+ :return: The highest severity level
290
+ :rtype: str
291
+ """
292
+ severity_priority = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFORMATIONAL": 0}
293
+ highest_severity = "LOW"
294
+ highest_priority = 0
295
+
296
+ for issue in issues:
297
+ issue_severity = issue.get("severity", "LOW").upper()
298
+ priority = severity_priority.get(issue_severity, 0)
299
+ if priority > highest_priority:
300
+ highest_priority = priority
301
+ highest_severity = issue_severity
302
+
303
+ return highest_severity
304
+
305
+ def _determine_most_urgent_status(self, issues: List[Dict[str, Any]]) -> str:
306
+ """
307
+ Determine the most urgent status from a list of issues.
308
+ Any open issue means the consolidated issue should be open.
309
+
310
+ :param List[Dict[str, Any]] issues: List of issues to analyze
311
+ :return: The most urgent status
312
+ :rtype: str
313
+ """
314
+ for issue in issues:
315
+ issue_status = issue.get("status", "OPEN").upper()
316
+ if issue_status in ["OPEN", "IN_PROGRESS", "ACKNOWLEDGE"]:
317
+ return "OPEN"
318
+ return "RESOLVED"
319
+
320
+ def _find_earliest_creation_date(self, issues: List[Dict[str, Any]]) -> Optional[str]:
321
+ """
322
+ Find the earliest creation date from a list of issues.
323
+
324
+ :param List[Dict[str, Any]] issues: List of issues to analyze
325
+ :return: The earliest creation date or None
326
+ :rtype: Optional[str]
327
+ """
328
+ earliest_created = None
329
+ for issue in issues:
330
+ created = safe_datetime_str(issue.get("createdAt"))
331
+ if created and (not earliest_created or created < earliest_created):
332
+ earliest_created = created
333
+ return earliest_created
334
+
335
+ def _select_base_issue(self, issues: List[Dict[str, Any]], highest_severity: str) -> Dict[str, Any]:
336
+ """
337
+ Select the base issue for consolidation based on highest severity.
338
+
339
+ :param List[Dict[str, Any]] issues: List of issues to choose from
340
+ :param str highest_severity: The highest severity level identified
341
+ :return: The selected base issue
342
+ :rtype: Dict[str, Any]
343
+ """
344
+ for issue in issues:
345
+ if issue.get("severity", "LOW").upper() == highest_severity:
346
+ return issue
347
+ return issues[0] # Fallback to first issue
348
+
349
+ def _consolidate_all_assets(self, issues: List[Dict[str, Any]]) -> tuple[Optional[str], Optional[str]]:
350
+ """
351
+ Consolidate all asset identifiers and provider IDs from multiple issues.
352
+
353
+ :param List[Dict[str, Any]] issues: List of issues to consolidate assets from
354
+ :return: Tuple of (primary_asset_id, consolidated_provider_ids)
355
+ :rtype: tuple[Optional[str], Optional[str]]
356
+ """
357
+ asset_ids: List[str] = []
358
+ provider_ids: List[str] = []
359
+ seen_asset_ids: set[str] = set()
360
+ seen_provider_ids: set[str] = set()
361
+
362
+ for issue in issues:
363
+ self._collect_assets_from_issue(issue, asset_ids, provider_ids, seen_asset_ids, seen_provider_ids)
364
+
365
+ primary_asset_id = asset_ids[0] if asset_ids else None
366
+ consolidated_provider_ids = "\n".join(provider_ids) if provider_ids else None
367
+
368
+ return primary_asset_id, consolidated_provider_ids
369
+
370
+ def _collect_assets_from_issue(
371
+ self,
372
+ issue: Dict[str, Any],
373
+ asset_ids: List[str],
374
+ provider_ids: List[str],
375
+ seen_asset_ids: set,
376
+ seen_provider_ids: set,
377
+ ) -> None:
378
+ """
379
+ Collect asset IDs and provider IDs from a single issue.
380
+
381
+ :param Dict[str, Any] issue: The issue to extract assets from
382
+ :param List[str] asset_ids: List to append asset IDs to
383
+ :param List[str] provider_ids: List to append provider IDs to
384
+ :param set seen_asset_ids: Set to track seen asset IDs
385
+ :param set seen_provider_ids: Set to track seen provider IDs
386
+ """
387
+ self._collect_from_entity_snapshot(issue, asset_ids, provider_ids, seen_asset_ids, seen_provider_ids)
388
+ self._collect_from_related_entities(issue, asset_ids, provider_ids, seen_asset_ids, seen_provider_ids)
389
+
390
+ def _collect_from_entity_snapshot(
391
+ self,
392
+ issue: Dict[str, Any],
393
+ asset_ids: List[str],
394
+ provider_ids: List[str],
395
+ seen_asset_ids: set,
396
+ seen_provider_ids: set,
397
+ ) -> None:
398
+ """
399
+ Collect asset IDs and provider IDs from entitySnapshot.
400
+
401
+ :param Dict[str, Any] issue: The issue to extract assets from
402
+ :param List[str] asset_ids: List to append asset IDs to
403
+ :param List[str] provider_ids: List to append provider IDs to
404
+ :param set seen_asset_ids: Set to track seen asset IDs
405
+ :param set seen_provider_ids: Set to track seen provider IDs
406
+ """
407
+ entity_snapshot = issue.get("entitySnapshot", {})
408
+ self._add_entity_ids(entity_snapshot, asset_ids, provider_ids, seen_asset_ids, seen_provider_ids)
409
+
410
+ def _collect_from_related_entities(
411
+ self,
412
+ issue: Dict[str, Any],
413
+ asset_ids: List[str],
414
+ provider_ids: List[str],
415
+ seen_asset_ids: set,
416
+ seen_provider_ids: set,
417
+ ) -> None:
418
+ """
419
+ Collect asset IDs and provider IDs from related entities.
420
+
421
+ :param Dict[str, Any] issue: The issue to extract assets from
422
+ :param List[str] asset_ids: List to append asset IDs to
423
+ :param List[str] provider_ids: List to append provider IDs to
424
+ :param set seen_asset_ids: Set to track seen asset IDs
425
+ :param set seen_provider_ids: Set to track seen provider IDs
426
+ """
427
+ for entity in issue.get("relatedEntities", []):
428
+ if entity and isinstance(entity, dict):
429
+ self._add_entity_ids(entity, asset_ids, provider_ids, seen_asset_ids, seen_provider_ids)
430
+
431
+ def _add_entity_ids(
432
+ self,
433
+ entity: Dict[str, Any],
434
+ asset_ids: List[str],
435
+ provider_ids: List[str],
436
+ seen_asset_ids: set,
437
+ seen_provider_ids: set,
438
+ ) -> None:
439
+ """
440
+ Add entity ID and provider ID from a single entity if not already seen.
441
+
442
+ :param Dict[str, Any] entity: The entity to extract IDs from
443
+ :param List[str] asset_ids: List to append asset IDs to
444
+ :param List[str] provider_ids: List to append provider IDs to
445
+ :param set seen_asset_ids: Set to track seen asset IDs
446
+ :param set seen_provider_ids: Set to track seen provider IDs
447
+ """
448
+ self._add_unique_id(entity.get("id"), asset_ids, seen_asset_ids)
449
+ self._add_unique_id(entity.get("providerId"), provider_ids, seen_provider_ids)
450
+
451
+ def _add_unique_id(self, id_value: Optional[str], id_list: List[str], seen_ids: set) -> None:
452
+ """
453
+ Add an ID to the list if it exists and hasn't been seen before.
454
+
455
+ :param Optional[str] id_value: The ID value to add
456
+ :param List[str] id_list: List to append the ID to
457
+ :param set seen_ids: Set to track seen IDs
458
+ """
459
+ if id_value and id_value not in seen_ids:
460
+ id_list.append(id_value)
461
+ seen_ids.add(id_value)
462
+
463
+ def _log_consolidation_details(
464
+ self,
465
+ issues: List[Dict[str, Any]],
466
+ base_issue: Dict[str, Any],
467
+ highest_severity: str,
468
+ most_urgent_status: str,
469
+ primary_asset_id: Optional[str],
470
+ consolidated_provider_ids: Optional[str],
471
+ ) -> None:
472
+ """
473
+ Log detailed information about the consolidation process.
474
+
475
+ :param List[Dict[str, Any]] issues: List of issues being consolidated
476
+ :param Dict[str, Any] base_issue: The base issue selected for consolidation
477
+ :param str highest_severity: The highest severity determined
478
+ :param str most_urgent_status: The most urgent status determined
479
+ :param Optional[str] primary_asset_id: The primary asset ID
480
+ :param Optional[str] consolidated_provider_ids: The consolidated provider IDs
481
+ """
482
+ rule_name = base_issue.get("sourceRule", {}).get("name", "Unknown")
483
+ severity_count = len([i for i in issues if i.get("severity", "").upper() == highest_severity])
484
+ asset_count = len(primary_asset_id.split("\n")) if primary_asset_id else 0
485
+ provider_count = len(consolidated_provider_ids.split("\n")) if consolidated_provider_ids else 0
486
+
487
+ logger.debug(f"CONSOLIDATION DETAILS for '{rule_name}':")
488
+ logger.debug(f" • Consolidating {len(issues)} issues into 1")
489
+ logger.debug(f" • Highest severity: {highest_severity} (from {severity_count} issues)")
490
+ logger.debug(f" • Most urgent status: {most_urgent_status}")
491
+ logger.debug(f" • Total unique assets: {asset_count}")
492
+ logger.debug(f" • Total unique provider IDs: {provider_count}")
493
+
494
+ def _build_integration_finding(
495
+ self,
496
+ base_issue: Dict[str, Any],
497
+ vulnerability_type: WizVulnerabilityType,
498
+ highest_severity: str,
499
+ most_urgent_status: str,
500
+ earliest_created: Optional[str],
501
+ primary_asset_id: Optional[str],
502
+ consolidated_provider_ids: Optional[str],
503
+ ) -> IntegrationFinding:
504
+ """
505
+ Build the final IntegrationFinding object from consolidated data.
506
+
507
+ :param Dict[str, Any] base_issue: The base issue to use for field values
508
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
509
+ :param str highest_severity: The highest severity determined
510
+ :param str most_urgent_status: The most urgent status determined
511
+ :param Optional[str] earliest_created: The earliest creation date
512
+ :param Optional[str] primary_asset_id: The primary asset identifier
513
+ :param Optional[str] consolidated_provider_ids: The consolidated provider IDs
514
+ :return: The built IntegrationFinding
515
+ :rtype: IntegrationFinding
516
+ """
517
+ wiz_id = base_issue.get("id", "N/A")
518
+ severity = self.get_issue_severity(highest_severity)
519
+ status = self.map_status_to_issue_status(most_urgent_status)
520
+ date_created = earliest_created or safe_datetime_str(base_issue.get("createdAt"))
521
+ name = base_issue.get("name", "")
522
+
523
+ # Handle source rule (Control) specific fields
524
+ source_rule = base_issue.get("sourceRule", {})
525
+ control_name = source_rule.get("name", "")
526
+ control_labels = self._parse_security_subcategories(source_rule)
527
+ description = (
528
+ self._format_control_description(source_rule) if source_rule else base_issue.get("description", "")
529
+ )
530
+
531
+ # Handle CVE if present
532
+ cve = (
533
+ name
534
+ if name and (name.startswith("CVE") or name.startswith("GHSA")) and not base_issue.get("cve")
535
+ else base_issue.get("cve")
536
+ )
537
+
538
+ # Get plugin name and source rule ID
539
+ plugin_name = self._get_plugin_name(base_issue)
540
+ source_rule_id = self._get_source_rule_id(source_rule)
541
+ security_check = f"Wiz {plugin_name}"
542
+
543
+ return IntegrationFinding(
544
+ control_labels=control_labels,
545
+ category="Wiz Control" if source_rule else "Wiz Vulnerability",
546
+ title=control_name or base_issue.get("name") or f"unknown - {wiz_id}",
547
+ security_check=security_check,
548
+ description=description,
549
+ severity=severity,
550
+ status=status,
551
+ asset_identifier=primary_asset_id or f"wiz-issue-{wiz_id}",
552
+ issue_asset_identifier_value=consolidated_provider_ids,
553
+ external_id=wiz_id,
554
+ first_seen=date_created,
555
+ last_seen=safe_datetime_str(base_issue.get("lastDetectedAt")),
556
+ remediation=source_rule.get("resolutionRecommendation")
557
+ or f"Update to version {base_issue.get('fixedVersion')} or higher",
558
+ cve=cve,
559
+ plugin_name=plugin_name,
560
+ source_rule_id=source_rule_id,
561
+ vulnerability_type=vulnerability_type.value,
562
+ date_created=date_created,
563
+ due_date=Issue.get_due_date(severity, self.app.config, "wiz", date_created),
564
+ recommendation_for_mitigation=source_rule.get("resolutionRecommendation")
565
+ or base_issue.get("description", ""),
566
+ poam_comments=None,
567
+ basis_for_adjustment=None,
568
+ )
569
+
570
+ def _get_consolidated_asset_identifiers(self, wiz_issue: Dict[str, Any]) -> tuple[str, Optional[str]]:
571
+ """
572
+ Get consolidated asset identifiers for an issue.
573
+ For multiple assets, returns the primary asset ID and all provider IDs as newline-separated string.
574
+
575
+ :param Dict[str, Any] wiz_issue: The Wiz issue
576
+ :return: Tuple of (primary_asset_id, consolidated_provider_ids)
577
+ :rtype: tuple[str, Optional[str]]
578
+ """
579
+ assets = self._collect_all_assets(wiz_issue)
580
+
581
+ if not assets:
582
+ return None, None
583
+
584
+ primary_asset_id = assets[0][0]
585
+ consolidated_provider_ids = self._consolidate_provider_ids(assets)
586
+
587
+ return primary_asset_id, consolidated_provider_ids
588
+
589
+ def _collect_all_assets(self, wiz_issue: Dict[str, Any]) -> List[tuple[str, Optional[str]]]:
590
+ """Collect all assets from entitySnapshot and relatedEntities."""
591
+ assets = []
592
+
593
+ # Check entitySnapshot first (primary asset)
594
+ assets.extend(self._extract_entity_snapshot_assets(wiz_issue))
595
+
596
+ # Check related entities (additional assets)
597
+ assets.extend(self._extract_related_entity_assets(wiz_issue))
598
+
599
+ # If no assets found yet, try the standard single-asset approach
600
+ if not assets:
601
+ asset_id, provider_id = self._get_asset_identifiers(wiz_issue)
602
+ if asset_id:
603
+ assets.append((asset_id, provider_id))
604
+
605
+ return assets
606
+
607
+ def _extract_entity_snapshot_assets(self, wiz_issue: Dict[str, Any]) -> List[tuple[str, Optional[str]]]:
608
+ """Extract assets from entitySnapshot."""
609
+ assets = []
610
+ if entity_snapshot := wiz_issue.get("entitySnapshot"):
611
+ if entity_id := entity_snapshot.get("id"):
612
+ provider_id = self._get_provider_id_from_entity(entity_snapshot)
613
+ assets.append((entity_id, provider_id))
614
+ return assets
615
+
616
+ def _extract_related_entity_assets(self, wiz_issue: Dict[str, Any]) -> List[tuple[str, Optional[str]]]:
617
+ """Extract assets from relatedEntities."""
618
+ assets = []
619
+ entities = wiz_issue.get("relatedEntities", [])
620
+ if entities and isinstance(entities, list):
621
+ for entity in entities:
622
+ if entity and isinstance(entity, dict) and (entity_id := entity.get("id")):
623
+ provider_id = self._get_provider_id_from_entity(entity)
624
+ assets.append((entity_id, provider_id))
625
+ return assets
626
+
627
+ def _consolidate_provider_ids(self, assets: List[tuple[str, Optional[str]]]) -> Optional[str]:
628
+ """Consolidate provider IDs from assets into newline-separated string."""
629
+ provider_ids = [provider_id for _, provider_id in assets if provider_id]
630
+ return "\n".join(provider_ids) if provider_ids else None
631
+
52
632
  def _parse_security_subcategories(self, source_rule: Dict[str, Any]) -> List[str]:
53
633
  """
54
634
  Parse security subcategories from a source rule.
@@ -111,20 +691,58 @@ class WizIssue(WizVulnerabilityIntegration):
111
691
  :return: The asset identifier
112
692
  :rtype: str
113
693
  """
114
- # Check entitySnapshot first
694
+ return (
695
+ WizIssue._get_id_from_entity_snapshot(wiz_issue)
696
+ or WizIssue._get_id_from_related_entities(wiz_issue)
697
+ or WizIssue._get_id_from_asset_paths(wiz_issue)
698
+ or WizIssue._get_id_from_source_rule(wiz_issue)
699
+ or WizIssue._get_fallback_issue_id(wiz_issue)
700
+ )
701
+
702
+ @staticmethod
703
+ def _get_id_from_entity_snapshot(wiz_issue: Dict[str, Any]) -> Optional[str]:
704
+ """
705
+ Get asset ID from entitySnapshot.
706
+
707
+ :param Dict[str, Any] wiz_issue: The Wiz issue
708
+ :return: Asset ID if found, None otherwise
709
+ :rtype: Optional[str]
710
+ """
115
711
  if entity_snapshot := wiz_issue.get("entitySnapshot"):
116
- if entity_id := entity_snapshot.get("id"):
117
- return entity_id
712
+ return entity_snapshot.get("id")
713
+ return None
118
714
 
119
- # Check related entities
120
- if "relatedEntities" in wiz_issue:
121
- entities = wiz_issue.get("relatedEntities", [])
122
- if entities and isinstance(entities, list):
123
- for entity in entities:
124
- if entity and isinstance(entity, dict) and (entity_id := entity.get("id")):
125
- return entity_id
715
+ @staticmethod
716
+ def _get_id_from_related_entities(wiz_issue: Dict[str, Any]) -> Optional[str]:
717
+ """
718
+ Get asset ID from related entities.
719
+
720
+ :param Dict[str, Any] wiz_issue: The Wiz issue
721
+ :return: Asset ID if found, None otherwise
722
+ :rtype: Optional[str]
723
+ """
724
+ if "relatedEntities" not in wiz_issue:
725
+ return None
726
+
727
+ entities = wiz_issue.get("relatedEntities", [])
728
+ if not (entities and isinstance(entities, list)):
729
+ return None
730
+
731
+ for entity in entities:
732
+ if entity and isinstance(entity, dict):
733
+ if entity_id := entity.get("id"):
734
+ return entity_id
735
+ return None
736
+
737
+ @staticmethod
738
+ def _get_id_from_asset_paths(wiz_issue: Dict[str, Any]) -> Optional[str]:
739
+ """
740
+ Get asset ID from common asset ID paths.
126
741
 
127
- # Check common asset ID paths
742
+ :param Dict[str, Any] wiz_issue: The Wiz issue
743
+ :return: Asset ID if found, None otherwise
744
+ :rtype: Optional[str]
745
+ """
128
746
  asset_paths = [
129
747
  "vulnerableAsset.id",
130
748
  "entity.id",
@@ -137,30 +755,136 @@ class WizIssue(WizVulnerabilityIntegration):
137
755
  for path in asset_paths:
138
756
  if asset_id := get_value(wiz_issue, path):
139
757
  return asset_id
758
+ return None
140
759
 
141
- # Try source rule as fallback
760
+ @staticmethod
761
+ def _get_id_from_source_rule(wiz_issue: Dict[str, Any]) -> Optional[str]:
762
+ """
763
+ Get asset ID from source rule as fallback.
764
+
765
+ :param Dict[str, Any] wiz_issue: The Wiz issue
766
+ :return: Asset ID if found, None otherwise
767
+ :rtype: Optional[str]
768
+ """
142
769
  if source_rule := wiz_issue.get("sourceRule"):
143
770
  if rule_id := source_rule.get("id"):
144
771
  return f"wiz-rule-{rule_id}"
772
+ return None
145
773
 
146
- # Final fallback - use the issue ID
774
+ @staticmethod
775
+ def _get_fallback_issue_id(wiz_issue: Dict[str, Any]) -> str:
776
+ """
777
+ Get fallback asset ID using the issue ID.
778
+
779
+ :param Dict[str, Any] wiz_issue: The Wiz issue
780
+ :return: Fallback asset ID
781
+ :rtype: str
782
+ """
147
783
  return f"wiz-issue-{wiz_issue.get('id', 'unknown')}"
148
784
 
785
+ @staticmethod
786
+ def _get_asset_identifiers(wiz_issue: Dict[str, Any]) -> tuple[str, Optional[str]]:
787
+ """
788
+ Get both asset_identifier and issue_asset_identifier_value consistently.
789
+ Ensures both values refer to the same asset entity.
790
+
791
+ :param Dict[str, Any] wiz_issue: The Wiz issue
792
+ :return: Tuple of (asset_identifier, issue_asset_identifier_value)
793
+ :rtype: tuple[str, Optional[str]]
794
+ """
795
+ # Try each potential source in order
796
+ asset_id, provider_id = WizIssue._try_entity_snapshot(wiz_issue)
797
+ if asset_id:
798
+ return asset_id, provider_id
799
+
800
+ asset_id, provider_id = WizIssue._try_related_entities(wiz_issue)
801
+ if asset_id:
802
+ return asset_id, provider_id
803
+
804
+ asset_id, provider_id = WizIssue._try_common_asset_paths(wiz_issue)
805
+ if asset_id:
806
+ return asset_id, provider_id
807
+
808
+ return WizIssue._get_fallback_identifiers(wiz_issue)
809
+
810
+ @staticmethod
811
+ def _try_entity_snapshot(wiz_issue: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
812
+ """Try to get identifiers from entitySnapshot."""
813
+ if entity_snapshot := wiz_issue.get("entitySnapshot"):
814
+ if entity_id := entity_snapshot.get("id"):
815
+ provider_id = WizIssue._get_provider_id_from_entity(entity_snapshot)
816
+ return entity_id, provider_id
817
+ return None, None
818
+
819
+ @staticmethod
820
+ def _try_related_entities(wiz_issue: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
821
+ """Try to get identifiers from relatedEntities."""
822
+ if "relatedEntities" in wiz_issue:
823
+ entities = wiz_issue.get("relatedEntities", [])
824
+ if entities and isinstance(entities, list):
825
+ for entity in entities:
826
+ if entity and isinstance(entity, dict) and (entity_id := entity.get("id")):
827
+ provider_id = WizIssue._get_provider_id_from_entity(entity)
828
+ return entity_id, provider_id
829
+ return None, None
830
+
831
+ @staticmethod
832
+ def _try_common_asset_paths(wiz_issue: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
833
+ """Try to get identifiers from common asset paths."""
834
+ asset_paths = ["vulnerableAsset", "entity", "resource", "relatedEntity", "sourceEntity", "target"]
835
+
836
+ for path in asset_paths:
837
+ if asset_obj := wiz_issue.get(path):
838
+ if asset_id := asset_obj.get("id"):
839
+ provider_id = WizIssue._get_provider_id_from_entity(asset_obj)
840
+ return asset_id, provider_id
841
+ return None, None
842
+
843
+ @staticmethod
844
+ def _get_fallback_identifiers(wiz_issue: Dict[str, Any]) -> tuple[str, None]:
845
+ """Get fallback identifiers when no asset entity is found."""
846
+ # Try source rule as fallback
847
+ if source_rule := wiz_issue.get("sourceRule"):
848
+ if rule_id := source_rule.get("id"):
849
+ return f"wiz-rule-{rule_id}", None
850
+
851
+ # Final fallback - use the issue ID
852
+ issue_id = wiz_issue.get("id", "unknown")
853
+ return f"wiz-issue-{issue_id}", None
854
+
855
+ @staticmethod
856
+ def _get_provider_id_from_entity(entity: Dict[str, Any]) -> Optional[str]:
857
+ """Extract provider ID from an entity object."""
858
+ return entity.get("providerId") or entity.get("providerUniqueId") or entity.get("name")
859
+
149
860
  @staticmethod
150
861
  def _format_control_description(control: Dict[str, Any]) -> str:
151
862
  """
152
863
  Format the control description with additional context.
864
+ Handles different description field names for different source rule types.
153
865
 
154
866
  :param Dict[str, Any] control: The control data
155
867
  :return: Formatted description
156
868
  :rtype: str
157
869
  """
158
870
  formatted_desc = []
159
- if description := control.get("controlDescription", ""):
871
+
872
+ # Try different description field names based on source rule type
873
+ description = (
874
+ control.get("controlDescription")
875
+ or control.get("cloudEventRuleDescription")
876
+ or control.get("cloudConfigurationRuleDescription")
877
+ or control.get("description", "")
878
+ )
879
+
880
+ if description:
160
881
  formatted_desc.append("Description:")
161
882
  formatted_desc.append(description)
162
883
 
163
- if recommendation := control.get("resolutionRecommendation", ""):
884
+ # Try different recommendation field names
885
+ recommendation = control.get("resolutionRecommendation") or control.get("remediationInstructions", "")
886
+
887
+ if recommendation:
164
888
  if formatted_desc:
165
889
  formatted_desc.append("\n")
166
890
  formatted_desc.append("Resolution Recommendation:")
@@ -206,8 +930,8 @@ class WizIssue(WizVulnerabilityIntegration):
206
930
  if not name:
207
931
  return f"Wiz-{service_type}-Config"
208
932
 
209
- # Simplified regex pattern that just looks for service name at start
210
- service_match = re.match(r"^([A-Za-z\s]+?)\s+(?:public|private|should|must|needs|to)", name)
933
+ # Safe regex pattern that looks for service name at start
934
+ service_match = re.match(r"^([A-Za-z\s]{1,50}?)\s+(?:public|private|should|must|needs|to)", name)
211
935
  if not service_match:
212
936
  return f"Wiz-{service_type}-Config"
213
937
 
@@ -241,7 +965,9 @@ class WizIssue(WizVulnerabilityIntegration):
241
965
 
242
966
  # Fallback to control name prefix
243
967
  if name:
244
- prefix_match = re.match(r"^([A-Za-z\s]+?)\s+(?:exposed|misconfigured|vulnerable|security|access)", name)
968
+ prefix_match = re.match(
969
+ r"^([A-Za-z\s]{1,50}?)\s+(?:exposed|misconfigured|vulnerable|security|access)", name
970
+ )
245
971
  if prefix_match:
246
972
  prefix = "".join(word.capitalize() for word in prefix_match.group(1).strip().split())
247
973
  return f"Wiz-Control-{prefix}"
@@ -262,7 +988,7 @@ class WizIssue(WizVulnerabilityIntegration):
262
988
  return "Wiz-Event"
263
989
  if not name:
264
990
  return f"Wiz-{service_type}-Event"
265
- event_match = re.match(r"^([A-Za-z\s]+?)\s+(detection|event|alert|activity)", name)
991
+ event_match = re.match(r"^([A-Za-z]+(?: [A-Za-z]+)*)\s+(detection|event|alert|activity)", name)
266
992
  if not event_match:
267
993
  return f"Wiz-{service_type}-Event"
268
994
 
@@ -304,7 +1030,28 @@ class WizIssue(WizVulnerabilityIntegration):
304
1030
  """
305
1031
  wiz_id = wiz_issue.get("id", "N/A")
306
1032
  severity = self.get_issue_severity(wiz_issue.get("severity", "Low"))
307
- status = self.map_status_to_issue_status(wiz_issue.get("status", "OPEN"))
1033
+
1034
+ # Get status with diagnostic logging
1035
+ wiz_status = wiz_issue.get("status", "OPEN")
1036
+ logger.debug(f"Processing Wiz issue {wiz_id}: raw status from node = '{wiz_status}'")
1037
+ status = self.map_status_to_issue_status(wiz_status)
1038
+
1039
+ # Enhanced status mapping logging
1040
+ logger.debug(f"STATUS MAPPING: Wiz issue {wiz_id} - '{wiz_status}' -> {status}")
1041
+
1042
+ # Check if we're creating a closed issue (which might not appear in your count)
1043
+ if status == regscale_models.IssueStatus.Closed:
1044
+ logger.debug(
1045
+ f"CLOSED ISSUE: Issue {wiz_id} will be created as CLOSED (status='{wiz_status}') - this won't appear in open issue counts"
1046
+ )
1047
+
1048
+ # Add diagnostic logging for unexpected issue closure
1049
+ if wiz_status.upper() not in ["RESOLVED", "REJECTED"]:
1050
+ logger.warning(
1051
+ f"Unexpected issue closure: Wiz issue status '{wiz_status}' mapped to Closed status "
1052
+ f"for issue {wiz_id} - '{wiz_issue.get('sourceRule', {}).get('name', 'Unknown rule')}'. "
1053
+ f"This may indicate a mapping configuration issue."
1054
+ )
308
1055
  date_created = safe_datetime_str(wiz_issue.get("createdAt"))
309
1056
  name: str = wiz_issue.get("name", "")
310
1057
 
@@ -315,8 +1062,8 @@ class WizIssue(WizVulnerabilityIntegration):
315
1062
  # Get control labels from security subcategories
316
1063
  control_labels = self._parse_security_subcategories(source_rule)
317
1064
 
318
- # Get asset identifier
319
- asset_id = self._get_asset_identifier(wiz_issue)
1065
+ # Get asset identifier and consolidated provider IDs
1066
+ asset_id, issue_asset_identifier_value = self._get_consolidated_asset_identifiers(wiz_issue)
320
1067
 
321
1068
  # Format description with control context
322
1069
  description = self._format_control_description(source_rule) if source_rule else wiz_issue.get("description", "")
@@ -344,6 +1091,7 @@ class WizIssue(WizVulnerabilityIntegration):
344
1091
  severity=severity,
345
1092
  status=status,
346
1093
  asset_identifier=asset_id,
1094
+ issue_asset_identifier_value=issue_asset_identifier_value,
347
1095
  external_id=wiz_id,
348
1096
  first_seen=date_created,
349
1097
  last_seen=safe_datetime_str(wiz_issue.get("lastDetectedAt")),