regscale-cli 6.23.0.1__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 (43) 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 +18 -3
  20. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  21. regscale/models/regscale_models/form_field_value.py +1 -1
  22. regscale/models/regscale_models/milestone.py +1 -0
  23. regscale/models/regscale_models/regscale_model.py +225 -60
  24. regscale/models/regscale_models/security_plan.py +3 -2
  25. regscale/regscale.py +7 -0
  26. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/METADATA +9 -9
  27. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/RECORD +43 -26
  28. tests/fixtures/test_fixture.py +13 -8
  29. tests/regscale/integrations/public/__init__.py +0 -0
  30. tests/regscale/integrations/public/test_alienvault.py +220 -0
  31. tests/regscale/integrations/public/test_cci.py +458 -0
  32. tests/regscale/integrations/public/test_cisa.py +1021 -0
  33. tests/regscale/integrations/public/test_emass.py +518 -0
  34. tests/regscale/integrations/public/test_fedramp.py +851 -0
  35. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  36. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  37. tests/regscale/integrations/public/test_oscal.py +453 -0
  38. tests/regscale/models/test_form_field_value_integration.py +304 -0
  39. tests/regscale/models/test_module_integration.py +582 -0
  40. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/LICENSE +0 -0
  41. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/WHEEL +0 -0
  42. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/entry_points.txt +0 -0
  43. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.0.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,7 @@ import logging
6
6
  import os
7
7
  import re
8
8
  from collections.abc import Iterator
9
+ from functools import lru_cache
9
10
  from typing import Any, Dict, List, Optional, Tuple, Union
10
11
 
11
12
  from regscale.core.app.utils.app_utils import check_file_path, error_and_exit, get_current_datetime
@@ -42,15 +43,16 @@ from regscale.integrations.commercial.wizv2.utils import (
42
43
  map_category,
43
44
  )
44
45
  from regscale.integrations.commercial.wizv2.variables import WizVariables
46
+ from regscale.integrations.variables import ScannerVariables
45
47
  from regscale.integrations.commercial.wizv2.wiz_auth import wiz_authenticate
46
48
  from regscale.integrations.scanner_integration import (
47
49
  IntegrationAsset,
48
50
  IntegrationFinding,
49
51
  ScannerIntegration,
50
52
  )
51
- from regscale.integrations.variables import ScannerVariables
52
53
  from regscale.models import IssueStatus, regscale_models
53
54
  from regscale.models.regscale_models.compliance_settings import ComplianceSettings
55
+ from regscale.models.regscale_models.regscale_model import RegScaleModel
54
56
 
55
57
  logger = logging.getLogger("regscale")
56
58
 
@@ -66,11 +68,19 @@ class WizVulnerabilityIntegration(ScannerIntegration):
66
68
  "High": regscale_models.IssueSeverity.High,
67
69
  "Medium": regscale_models.IssueSeverity.Moderate,
68
70
  "Low": regscale_models.IssueSeverity.Low,
71
+ "INFORMATIONAL": regscale_models.IssueSeverity.NotAssigned,
69
72
  }
70
73
  asset_lookup = "vulnerableAsset"
71
74
  wiz_token = None
72
75
  _compliance_settings = None
73
76
 
77
+ def __init__(self, *args, **kwargs):
78
+ super().__init__(*args, **kwargs)
79
+ # Suppress generic asset not found errors but use enhanced diagnostics instead
80
+ self.suppress_asset_not_found_errors = True
81
+ # Track unique missing asset types for summary reporting
82
+ self._missing_asset_types = set()
83
+
74
84
  @staticmethod
75
85
  def get_variables() -> Dict[str, Any]:
76
86
  """
@@ -84,6 +94,31 @@ class WizVulnerabilityIntegration(ScannerIntegration):
84
94
  "filterBy": {},
85
95
  }
86
96
 
97
+ def get_finding_identifier(self, finding) -> str:
98
+ """
99
+ Gets the finding identifier for Wiz findings.
100
+ For Wiz integrations, prioritize external_id since plugin_id can be non-unique.
101
+
102
+ :param finding: The finding
103
+ :return: The finding identifier
104
+ :rtype: str
105
+ """
106
+ # We could have a string truncation error platform side on IntegrationFindingId nvarchar(450)
107
+ prefix = f"{self.plan_id}:"
108
+
109
+ # For Wiz, prioritize external_id since plugin_id can be non-unique
110
+ if finding.external_id:
111
+ prefix += self.hash_string(finding.external_id).__str__()
112
+ else:
113
+ prefix += (
114
+ finding.cve or finding.plugin_id or finding.rule_id or self.hash_string(finding.external_id).__str__()
115
+ )
116
+
117
+ if ScannerVariables.issueCreation.lower() == "perasset":
118
+ res = f"{prefix}:{finding.asset_identifier}"
119
+ return res[:450]
120
+ return prefix[:450]
121
+
87
122
  def authenticate(self, client_id: Optional[str] = None, client_secret: Optional[str] = None) -> None:
88
123
  """
89
124
  Authenticates to Wiz using the client ID and client secret
@@ -97,14 +132,16 @@ class WizVulnerabilityIntegration(ScannerIntegration):
97
132
  logger.info("Authenticating to Wiz...")
98
133
  self.wiz_token = wiz_authenticate(client_id, client_secret)
99
134
 
100
- def get_query_types(self, project_id: str) -> List[Dict[str, Any]]:
135
+ def get_query_types(self, project_id: str, filter_by: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
101
136
  """Get the query types for vulnerability scanning.
102
137
 
103
138
  :param str project_id: The project ID to get queries for
139
+ :param Optional[Dict[str, Any]] filter_by: Optional filter criteria (used by subclasses)
104
140
  :return: List of query types
105
141
  :rtype: List[Dict[str, Any]]
106
142
  """
107
- return get_wiz_vulnerability_queries(project_id=project_id)
143
+ # Base class ignores filter_by, subclasses can override to use it
144
+ return get_wiz_vulnerability_queries(project_id=project_id, filter_by=filter_by)
108
145
 
109
146
  def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
110
147
  """
@@ -313,7 +350,9 @@ class WizVulnerabilityIntegration(ScannerIntegration):
313
350
  # Step 2: Initialize progress tracking
314
351
  logger.info("Fetching Wiz findings using async concurrent queries...")
315
352
  self.num_findings_to_process = 0
316
- query_configs = self.get_query_types(project_id=project_id)
353
+ # Pass filter_by_override if provided
354
+ filter_by = kwargs.get("filter_by_override")
355
+ query_configs = self.get_query_types(project_id=project_id, filter_by=filter_by)
317
356
 
318
357
  main_task = self.finding_progress.add_task(
319
358
  "[cyan]Running concurrent GraphQL queries...", total=len(query_configs)
@@ -337,8 +376,13 @@ class WizVulnerabilityIntegration(ScannerIntegration):
337
376
  # Step 6: Process results
338
377
  yield from self._process_query_results(results, query_configs, project_id, should_fetch_fresh)
339
378
 
340
- # Step 7: Complete main task
341
- self.finding_progress.advance(main_task, len(query_configs))
379
+ # Step 7: Complete main task - ensure it's marked as 100% complete
380
+ self.finding_progress.update(
381
+ main_task,
382
+ description="[green]✓ Completed processing all Wiz findings",
383
+ completed=len(query_configs),
384
+ total=len(query_configs),
385
+ )
342
386
 
343
387
  except Exception as e:
344
388
  logger.error(f"Error in async findings fetch: {e!s}", exc_info=True)
@@ -350,6 +394,14 @@ class WizVulnerabilityIntegration(ScannerIntegration):
350
394
  logger.info("Falling back to synchronous query method...")
351
395
  yield from self.fetch_findings_sync(**kwargs)
352
396
 
397
+ # Log summary of missing asset types if any were found
398
+ if hasattr(self, "_missing_asset_types") and self._missing_asset_types:
399
+ logger.warning(
400
+ "Summary: Found references to missing asset types: %s. "
401
+ "Consider adding these to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py",
402
+ ", ".join(sorted(self._missing_asset_types)),
403
+ )
404
+
353
405
  logger.info(
354
406
  "Finished async fetching Wiz findings. Total findings to process: %d", self.num_findings_to_process or 0
355
407
  )
@@ -459,7 +511,9 @@ class WizVulnerabilityIntegration(ScannerIntegration):
459
511
 
460
512
  logger.info("Fetching Wiz findings using synchronous queries...")
461
513
  self.num_findings_to_process = 0
462
- query_types = self.get_query_types(project_id=project_id)
514
+ # Pass filter_by_override if provided
515
+ filter_by = kwargs.get("filter_by_override")
516
+ query_types = self.get_query_types(project_id=project_id, filter_by=filter_by)
463
517
 
464
518
  # Create detailed progress tracking for each query type
465
519
  main_task = self.finding_progress.add_task(
@@ -530,6 +584,14 @@ class WizVulnerabilityIntegration(ScannerIntegration):
530
584
  description=f"[green]✓ Completed fetching all Wiz findings ({self.num_findings_to_process or 0} total)",
531
585
  )
532
586
 
587
+ # Log summary of missing asset types if any were found
588
+ if hasattr(self, "_missing_asset_types") and self._missing_asset_types:
589
+ logger.warning(
590
+ "Summary: Found references to missing asset types: %s. "
591
+ "Consider adding these to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py",
592
+ ", ".join(sorted(self._missing_asset_types)),
593
+ )
594
+
533
595
  logger.info(
534
596
  "Finished synchronous fetching Wiz findings. Total findings to process: %d",
535
597
  self.num_findings_to_process or 0,
@@ -664,16 +726,218 @@ class WizVulnerabilityIntegration(ScannerIntegration):
664
726
  ) -> Iterator[IntegrationFinding]:
665
727
  """
666
728
  Parses a list of Wiz finding nodes into IntegrationFinding objects.
667
-
668
- This is a compatibility wrapper that calls the progress-aware version.
729
+ Groups findings by rule and scope for consolidation when appropriate.
669
730
 
670
731
  :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
671
732
  :param WizVulnerabilityType vulnerability_type: The type of vulnerability
672
733
  :yield: IntegrationFinding objects
673
734
  :rtype: Iterator[IntegrationFinding]
674
735
  """
675
- # Delegate to the progress-aware version without progress tracking
676
- yield from self.parse_findings_with_progress(nodes, vulnerability_type, task_id=None)
736
+ logger.info(
737
+ f"🔍 VULNERABILITY PROCESSING ANALYSIS: Received {len(nodes)} raw Wiz vulnerabilities for processing"
738
+ )
739
+
740
+ # Count issues by severity for analysis
741
+ severity_counts = {}
742
+ status_counts = {}
743
+ for node in nodes:
744
+ severity = node.get("severity", "Low")
745
+ status = node.get("status", "OPEN")
746
+ severity_counts[severity] = severity_counts.get(severity, 0) + 1
747
+ status_counts[status] = status_counts.get(status, 0) + 1
748
+
749
+ logger.debug(f"Raw vulnerability breakdown by severity: {severity_counts}")
750
+ logger.debug(f"Raw vulnerability breakdown by status: {status_counts}")
751
+
752
+ # Filter nodes by minimum severity configuration
753
+ filtered_nodes = []
754
+ filtered_out_count = 0
755
+ for node in nodes:
756
+ wiz_severity = node.get("severity", "Low")
757
+ wiz_id = node.get("id", "unknown")
758
+ if self.should_process_finding_by_severity(wiz_severity):
759
+ filtered_nodes.append(node)
760
+ else:
761
+ filtered_out_count += 1
762
+ logger.info(
763
+ f"🚫 FILTERED BY SEVERITY: Vulnerability {wiz_id} with severity '{wiz_severity}' filtered due to minimumSeverity configuration"
764
+ )
765
+
766
+ logger.info(
767
+ f"✅ After severity filtering: {len(filtered_nodes)} vulnerabilities kept, {filtered_out_count} filtered out"
768
+ )
769
+
770
+ if not filtered_nodes:
771
+ logger.warning(
772
+ "⚠️ All vulnerabilities filtered out by severity configuration - check your minimumSeverity setting"
773
+ )
774
+ return
775
+
776
+ # Apply consolidation logic for findings that support it
777
+ if self._should_apply_consolidation(vulnerability_type):
778
+ yield from self._parse_findings_with_consolidation(filtered_nodes, vulnerability_type)
779
+ else:
780
+ # Use original parsing for vulnerability types that shouldn't be consolidated
781
+ yield from self.parse_findings_with_progress(filtered_nodes, vulnerability_type, task_id=None)
782
+
783
+ def _should_apply_consolidation(self, vulnerability_type: WizVulnerabilityType) -> bool:
784
+ """
785
+ Determine if consolidation should be applied for this vulnerability type.
786
+
787
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
788
+ :return: True if consolidation should be applied
789
+ :rtype: bool
790
+ """
791
+ # Apply consolidation to finding types that commonly affect multiple assets
792
+ consolidation_types = {
793
+ WizVulnerabilityType.HOST_FINDING,
794
+ WizVulnerabilityType.DATA_FINDING,
795
+ WizVulnerabilityType.VULNERABILITY,
796
+ }
797
+ return vulnerability_type in consolidation_types
798
+
799
+ def _parse_findings_with_consolidation(
800
+ self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
801
+ ) -> Iterator[IntegrationFinding]:
802
+ """
803
+ Parse findings with consolidation logic applied.
804
+
805
+ :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
806
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
807
+ :yield: Consolidated IntegrationFinding objects
808
+ :rtype: Iterator[IntegrationFinding]
809
+ """
810
+ # Group nodes for potential consolidation
811
+ grouped_nodes = self._group_findings_for_consolidation(nodes)
812
+
813
+ # Process each group
814
+ for group_key, group_nodes in grouped_nodes.items():
815
+ if len(group_nodes) > 1:
816
+ # Multiple nodes with same rule - attempt consolidation
817
+ if consolidated_finding := self._create_consolidated_scanner_finding(group_nodes, vulnerability_type):
818
+ yield consolidated_finding
819
+ else:
820
+ # Single node - process normally
821
+ if finding := self.parse_finding(group_nodes[0], vulnerability_type):
822
+ yield finding
823
+
824
+ def _group_findings_for_consolidation(self, nodes: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
825
+ """
826
+ Group findings by rule and appropriate scope for consolidation.
827
+ - Database findings: group by server
828
+ - App Configuration findings: group by resource group
829
+ - Other findings: group by full resource path
830
+
831
+ :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
832
+ :return: Dictionary mapping group keys to lists of nodes
833
+ :rtype: Dict[str, List[Dict[str, Any]]]
834
+ """
835
+ groups = {}
836
+
837
+ for node in nodes:
838
+ # Create a grouping key based on rule and appropriate scope
839
+ rule_name = self._get_rule_name_from_node(node)
840
+ provider_id = self._get_provider_id_from_node(node)
841
+
842
+ # Determine the appropriate grouping scope based on resource type
843
+ grouping_scope = self._determine_grouping_scope(provider_id, rule_name)
844
+
845
+ # Group key combines rule name and scope
846
+ group_key = f"{rule_name}|{grouping_scope}"
847
+
848
+ if group_key not in groups:
849
+ groups[group_key] = []
850
+ groups[group_key].append(node)
851
+
852
+ return groups
853
+
854
+ def _get_rule_name_from_node(self, node: Dict[str, Any]) -> str:
855
+ """Get rule name from various node structures."""
856
+ # Try different ways to get rule name
857
+ if source_rule := node.get("sourceRule"):
858
+ return source_rule.get("name", "")
859
+ return node.get("name", node.get("title", ""))
860
+
861
+ def _get_provider_id_from_node(self, node: Dict[str, Any]) -> str:
862
+ """Get provider ID from various node structures."""
863
+ # Try different ways to get provider ID
864
+ if entity_snapshot := node.get("entitySnapshot"):
865
+ return entity_snapshot.get("providerId", "")
866
+
867
+ # Try other asset lookup patterns
868
+ asset_fields = ["vulnerableAsset", "entity", "resource", "relatedEntity", "sourceEntity", "target"]
869
+ for field in asset_fields:
870
+ if asset_obj := node.get(field):
871
+ if provider_id := asset_obj.get("providerId"):
872
+ return provider_id
873
+
874
+ return ""
875
+
876
+ def _determine_grouping_scope(self, provider_id: str, rule_name: str) -> str:
877
+ """
878
+ Determine the appropriate grouping scope for consolidation.
879
+
880
+ :param str provider_id: The provider ID
881
+ :param str rule_name: The rule name
882
+ :return: The grouping scope (server, resource group, or full path)
883
+ :rtype: str
884
+ """
885
+ # For database issues, group by server
886
+ if "/databases/" in provider_id:
887
+ return provider_id.split("/databases/")[0]
888
+
889
+ # For App Configuration issues, group by resource group to consolidate multiple stores
890
+ if (
891
+ "app configuration" in rule_name.lower()
892
+ and "/microsoft.appconfiguration/configurationstores/" in provider_id
893
+ ):
894
+ # Extract resource group path: /subscriptions/.../resourcegroups/rg_name
895
+ parts = provider_id.split("/resourcegroups/")
896
+ if len(parts) >= 2:
897
+ rg_part = parts[1].split("/")[0] # Get just the resource group name
898
+ return f"{parts[0]}/resourcegroups/{rg_part}"
899
+
900
+ # For other resources, use the full provider path (no consolidation)
901
+ return provider_id
902
+
903
+ def _create_consolidated_scanner_finding(
904
+ self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
905
+ ) -> Optional[IntegrationFinding]:
906
+ """
907
+ Create a consolidated finding from multiple nodes with the same rule.
908
+
909
+ :param List[Dict[str, Any]] nodes: List of nodes to consolidate
910
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
911
+ :return: Consolidated IntegrationFinding or None
912
+ :rtype: Optional[IntegrationFinding]
913
+ """
914
+ # Use the first node as the base
915
+ base_node = nodes[0]
916
+
917
+ # Collect all asset identifiers and provider IDs
918
+ asset_ids = []
919
+ provider_ids = []
920
+
921
+ for node in nodes:
922
+ if asset_id := self.get_asset_id_from_node(node, vulnerability_type):
923
+ asset_ids.append(asset_id)
924
+ if provider_id := self.get_provider_unique_id_from_node(node, vulnerability_type):
925
+ provider_ids.append(provider_id)
926
+
927
+ # If we couldn't extract asset info, fall back to normal parsing
928
+ if not asset_ids:
929
+ return self.parse_finding(base_node, vulnerability_type)
930
+
931
+ # Create the finding using normal parsing, then override asset identifiers
932
+ base_finding = self.parse_finding(base_node, vulnerability_type)
933
+ if not base_finding:
934
+ return None
935
+
936
+ # Override with consolidated asset information
937
+ base_finding.asset_identifier = asset_ids[0] # Use first asset as primary
938
+ base_finding.issue_asset_identifier_value = "\n".join(provider_ids) if provider_ids else None
939
+
940
+ return base_finding
677
941
 
678
942
  @classmethod
679
943
  def get_issue_severity(cls, severity: str) -> regscale_models.IssueSeverity:
@@ -686,6 +950,37 @@ class WizVulnerabilityIntegration(ScannerIntegration):
686
950
  """
687
951
  return cls.finding_severity_map.get(severity.capitalize(), regscale_models.IssueSeverity.Low)
688
952
 
953
+ def should_process_finding_by_severity(self, wiz_severity: str) -> bool:
954
+ """
955
+ Check if finding should be processed based on minimum severity configuration.
956
+
957
+ :param str wiz_severity: The Wiz severity level (e.g., "INFORMATIONAL", "Low", "Medium", etc.)
958
+ :return: True if finding should be processed, False if it should be filtered out
959
+ :rtype: bool
960
+ """
961
+ # Get minimum severity from configuration, default to "low"
962
+ min_severity = self.app.config.get("scanners", {}).get("wiz", {}).get("minimumSeverity", "low").lower()
963
+
964
+ # Log the configuration being used (only once to avoid spam)
965
+ if not hasattr(self, "_severity_config_logged"):
966
+ logger.debug(f"SEVERITY FILTER CONFIG: minimumSeverity = '{min_severity}'")
967
+ self._severity_config_logged = True
968
+
969
+ # Define severity hierarchy (lower index = higher severity)
970
+ severity_hierarchy = ["critical", "high", "medium", "low", "informational"]
971
+
972
+ try:
973
+ wiz_severity_lower = wiz_severity.lower()
974
+ min_severity_index = severity_hierarchy.index(min_severity)
975
+ finding_severity_index = severity_hierarchy.index(wiz_severity_lower)
976
+
977
+ # Process if finding severity is equal or higher (lower index) than minimum
978
+ return finding_severity_index <= min_severity_index
979
+ except ValueError:
980
+ # If severity not found in hierarchy, default to processing it
981
+ logger.warning(f"Unknown severity level: {wiz_severity}, processing anyway")
982
+ return True
983
+
689
984
  def process_comments(self, comments_dict: Dict) -> Optional[str]:
690
985
  """
691
986
  Processes comments from Wiz findings to match RegScale's comment format.
@@ -716,10 +1011,10 @@ class WizVulnerabilityIntegration(ScannerIntegration):
716
1011
  """
717
1012
  # Define asset lookup patterns for different vulnerability types
718
1013
  asset_lookup_patterns = {
719
- # WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
720
- # WizVulnerabilityType.CONFIGURATION: "resource",
721
- # WizVulnerabilityType.HOST_FINDING: "resource",
722
- # WizVulnerabilityType.DATA_FINDING: "resource",
1014
+ WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
1015
+ WizVulnerabilityType.CONFIGURATION: "resource",
1016
+ WizVulnerabilityType.HOST_FINDING: "resource",
1017
+ WizVulnerabilityType.DATA_FINDING: "resource",
723
1018
  WizVulnerabilityType.SECRET_FINDING: "resource",
724
1019
  WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "exposedEntity",
725
1020
  WizVulnerabilityType.END_OF_LIFE_FINDING: "vulnerableAsset",
@@ -738,7 +1033,76 @@ class WizVulnerabilityIntegration(ScannerIntegration):
738
1033
 
739
1034
  # Standard case - direct id access
740
1035
  asset_container = node.get(asset_lookup_key, {})
741
- return asset_container.get("id")
1036
+ asset_id = asset_container.get("id")
1037
+
1038
+ # Add debug logging to help diagnose missing assets
1039
+ if not asset_id:
1040
+ logger.debug(
1041
+ f"No asset ID found for {vulnerability_type.value} using key '{asset_lookup_key}'. "
1042
+ f"Available keys in node: {list(node.keys())}"
1043
+ )
1044
+ # Try alternative lookup patterns as fallback
1045
+ fallback_keys = ["vulnerableAsset", "resource", "exposedEntity", "entitySnapshot"]
1046
+ for fallback_key in fallback_keys:
1047
+ if fallback_key != asset_lookup_key and fallback_key in node:
1048
+ fallback_asset = node.get(fallback_key, {})
1049
+ if fallback_id := fallback_asset.get("id"):
1050
+ logger.debug(
1051
+ f"Found asset ID using fallback key '{fallback_key}' for {vulnerability_type.value}"
1052
+ )
1053
+ return fallback_id
1054
+
1055
+ return asset_id
1056
+
1057
+ def get_provider_unique_id_from_node(
1058
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1059
+ ) -> Optional[str]:
1060
+ """
1061
+ Get the providerUniqueId from a node based on the vulnerability type.
1062
+ This provides more meaningful asset identification for eMASS exports.
1063
+
1064
+ :param Dict[str, Any] node: The Wiz finding node
1065
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1066
+ :return: The providerUniqueId or fallback to asset name/ID
1067
+ :rtype: Optional[str]
1068
+ """
1069
+ # Define asset lookup patterns for different vulnerability types - aligned with get_asset_id_from_node
1070
+ asset_lookup_patterns = {
1071
+ WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
1072
+ WizVulnerabilityType.CONFIGURATION: "resource",
1073
+ WizVulnerabilityType.HOST_FINDING: "resource",
1074
+ WizVulnerabilityType.DATA_FINDING: "resource",
1075
+ WizVulnerabilityType.SECRET_FINDING: "resource",
1076
+ WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "exposedEntity",
1077
+ WizVulnerabilityType.END_OF_LIFE_FINDING: "vulnerableAsset",
1078
+ WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE: "exposedEntity",
1079
+ WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING: "scope",
1080
+ WizVulnerabilityType.ISSUE: "entitySnapshot",
1081
+ }
1082
+
1083
+ asset_lookup_key = asset_lookup_patterns.get(vulnerability_type, "entitySnapshot")
1084
+
1085
+ if asset_lookup_key == "scope":
1086
+ # Handle special case for excessive access findings where ID is nested
1087
+ scope = node.get("scope", {})
1088
+ graph_entity = scope.get("graphEntity", {})
1089
+ # Try providerUniqueId first, fallback to name, then id
1090
+ return graph_entity.get("providerUniqueId") or graph_entity.get("name") or graph_entity.get("id")
1091
+
1092
+ # Standard case - get asset container and extract provider identifier
1093
+ asset_container = node.get(asset_lookup_key, {})
1094
+
1095
+ # For Issue queries, the field is called 'providerId' instead of 'providerUniqueId'
1096
+ if vulnerability_type == WizVulnerabilityType.ISSUE:
1097
+ return (
1098
+ asset_container.get("providerId")
1099
+ or asset_container.get("providerUniqueId")
1100
+ or asset_container.get("name")
1101
+ or asset_container.get("id")
1102
+ )
1103
+
1104
+ # For other queries, try providerUniqueId first
1105
+ return asset_container.get("providerUniqueId") or asset_container.get("name") or asset_container.get("id")
742
1106
 
743
1107
  def parse_finding(
744
1108
  self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
@@ -769,28 +1133,16 @@ class WizVulnerabilityIntegration(ScannerIntegration):
769
1133
  logger.error("Error parsing Wiz finding: %s", str(e), exc_info=True)
770
1134
  return None
771
1135
 
772
- def _parse_secret_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1136
+ def _get_secret_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
773
1137
  """
774
- Parse secret finding from Wiz.
1138
+ Extract data specific to secret findings.
775
1139
 
776
1140
  :param Dict[str, Any] node: The Wiz finding node to parse
777
- :return: The parsed IntegrationFinding or None if parsing fails
778
- :rtype: Optional[IntegrationFinding]
1141
+ :return: Dictionary containing secret-specific data
1142
+ :rtype: Dict[str, Any]
779
1143
  """
780
- asset_id = node.get("resource", {}).get("id")
781
- if not asset_id:
782
- return None
783
-
784
- first_seen = node.get("firstSeenAt") or get_current_datetime()
785
- first_seen = format_to_regscale_iso(first_seen)
786
- severity = self.get_issue_severity(node.get("severity", "Low"))
787
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
788
-
789
- # Create meaningful title for secret findings
790
1144
  secret_type = node.get("type", "Unknown Secret")
791
1145
  resource_name = node.get("resource", {}).get("name", "Unknown Resource")
792
- title = f"Secret Detected: {secret_type} in {resource_name}"
793
-
794
1146
  # Build description with secret details
795
1147
  description_parts = [
796
1148
  f"Secret type: {secret_type}",
@@ -802,52 +1154,152 @@ class WizVulnerabilityIntegration(ScannerIntegration):
802
1154
  if rule := node.get("rule", {}):
803
1155
  description_parts.append(f"Detection rule: {rule.get('name', 'Unknown')}")
804
1156
 
805
- description = "\n".join(description_parts)
806
-
807
- return IntegrationFinding(
808
- control_labels=[],
809
- category="Wiz Secret Detection",
810
- title=title,
811
- description=description,
812
- severity=severity,
813
- status=self.map_status_to_issue_status(node.get("status", "Open")),
814
- asset_identifier=asset_id,
815
- external_id=node.get("id"),
816
- first_seen=first_seen,
817
- date_created=first_seen,
818
- last_seen=format_to_regscale_iso(node.get("lastSeenAt") or get_current_datetime()),
819
- remediation=f"Remove or properly secure the {secret_type} secret found in {resource_name}",
820
- plugin_name=f"Wiz Secret Detection - {secret_type}",
821
- vulnerability_type=WizVulnerabilityType.SECRET_FINDING.value,
822
- due_date=due_date,
823
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
824
- identification="Secret Scanning",
825
- )
1157
+ return {
1158
+ "category": "Wiz Secret Detection",
1159
+ "title": f"Secret Detected: {secret_type} in {resource_name}",
1160
+ "description": "\n".join(description_parts),
1161
+ "remediation": f"Remove or properly secure the {secret_type} secret found in {resource_name}",
1162
+ "plugin_name": f"Wiz Secret Detection - {secret_type}",
1163
+ "identification": "Secret Scanning",
1164
+ }
826
1165
 
827
- def _parse_network_exposure_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
828
- """Parse network exposure finding from Wiz.
1166
+ def _parse_secret_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1167
+ """
1168
+ Parse secret finding from Wiz.
829
1169
 
830
1170
  :param Dict[str, Any] node: The Wiz finding node to parse
831
1171
  :return: The parsed IntegrationFinding or None if parsing fails
832
1172
  :rtype: Optional[IntegrationFinding]
833
1173
  """
834
- asset_id = node.get("exposedEntity", {}).get("id")
1174
+ finding_data = self._get_secret_finding_data(node)
1175
+ return self._create_integration_finding(node, WizVulnerabilityType.SECRET_FINDING, finding_data)
1176
+
1177
+ def _create_integration_finding(
1178
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType, finding_data: Dict[str, Any]
1179
+ ) -> Optional[IntegrationFinding]:
1180
+ """
1181
+ Unified method to create IntegrationFinding objects from Wiz data.
1182
+
1183
+ :param Dict[str, Any] node: The Wiz finding node to parse
1184
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1185
+ :param Dict[str, Any] finding_data: Finding-specific data (title, description, etc.)
1186
+ :return: The parsed IntegrationFinding or None if parsing fails
1187
+ :rtype: Optional[IntegrationFinding]
1188
+ """
1189
+ # Get asset identifier
1190
+ asset_id = self.get_asset_id_from_node(node, vulnerability_type)
835
1191
  if not asset_id:
1192
+ logger.warning(
1193
+ f"Skipping {vulnerability_type.value} finding '{node.get('name', 'Unknown')}' "
1194
+ f"(ID: {node.get('id', 'Unknown')}) - no asset identifier found"
1195
+ )
836
1196
  return None
837
1197
 
838
- first_seen = node.get("firstSeenAt") or get_current_datetime()
839
- first_seen = format_to_regscale_iso(first_seen)
1198
+ # Get meaningful asset identifier for eMASS exports
1199
+ provider_unique_id = self.get_provider_unique_id_from_node(node, vulnerability_type)
840
1200
 
841
- # Network exposures typically don't have explicit severity, assume Medium
842
- severity = regscale_models.IssueSeverity.Moderate
1201
+ # Parse dates
1202
+ first_seen = self._get_first_seen_date(node)
1203
+ last_seen = self._get_last_seen_date(node, first_seen)
1204
+ # Get severity and calculate due date
1205
+ severity = self.get_issue_severity(finding_data.get("severity") or node.get("severity", "Low"))
843
1206
  due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
844
1207
 
845
- # Create meaningful title for network exposure
1208
+ # Get status with diagnostic logging
1209
+ wiz_status = node.get("status", "Open")
1210
+ logger.debug(f"Processing Wiz finding {node.get('id', 'Unknown')}: raw status from node = '{wiz_status}'")
1211
+ status = self.map_status_to_issue_status(wiz_status)
1212
+
1213
+ # Add diagnostic logging for unexpected issue closure
1214
+ if status == regscale_models.IssueStatus.Closed and wiz_status.upper() not in ["RESOLVED", "REJECTED"]:
1215
+ logger.warning(
1216
+ f"Unexpected issue closure: Wiz status '{wiz_status}' mapped to Closed status "
1217
+ f"for finding {node.get('id', 'Unknown')} - '{finding_data.get('title', 'Unknown')}'. "
1218
+ f"This may indicate a mapping configuration issue."
1219
+ )
1220
+
1221
+ # Process comments if available
1222
+ comments_dict = node.get("commentThread", {})
1223
+ formatted_comments = self.process_comments(comments_dict) if comments_dict else None
1224
+
1225
+ # Build IntegrationFinding with unified data structure
1226
+ integration_finding_data = {
1227
+ "control_labels": [],
1228
+ "category": finding_data.get("category", "Wiz Vulnerability"),
1229
+ "title": finding_data.get("title", node.get("name", "Unknown vulnerability")),
1230
+ "description": finding_data.get("description", node.get("description", "")),
1231
+ "severity": severity,
1232
+ "status": status,
1233
+ "asset_identifier": asset_id,
1234
+ "issue_asset_identifier_value": provider_unique_id,
1235
+ "external_id": finding_data.get("external_id", node.get("id")),
1236
+ "first_seen": first_seen,
1237
+ "date_created": first_seen,
1238
+ "last_seen": last_seen,
1239
+ "remediation": finding_data.get("remediation", node.get("description", "")),
1240
+ "plugin_name": finding_data.get("plugin_name", node.get("name", "Unknown")),
1241
+ "vulnerability_type": vulnerability_type.value,
1242
+ "due_date": due_date,
1243
+ "date_last_updated": format_to_regscale_iso(get_current_datetime()),
1244
+ "identification": finding_data.get("identification", "Vulnerability Assessment"),
1245
+ }
1246
+
1247
+ # Add optional fields if present
1248
+ if formatted_comments:
1249
+ integration_finding_data["comments"] = formatted_comments
1250
+ integration_finding_data["poam_comments"] = formatted_comments
1251
+
1252
+ # Add CVE-specific fields for generic findings
1253
+ if finding_data.get("cve"):
1254
+ integration_finding_data["cve"] = finding_data["cve"]
1255
+ if finding_data.get("cvss_score"):
1256
+ integration_finding_data["cvss_score"] = finding_data["cvss_score"]
1257
+ integration_finding_data["cvss_v3_base_score"] = finding_data["cvss_score"]
1258
+ if finding_data.get("source_rule_id"):
1259
+ integration_finding_data["source_rule_id"] = finding_data["source_rule_id"]
1260
+
1261
+ return IntegrationFinding(**integration_finding_data)
1262
+
1263
+ def _get_first_seen_date(self, node: Dict[str, Any]) -> str:
1264
+ """
1265
+ Get the first seen date from a Wiz node, with fallbacks.
1266
+
1267
+ :param Dict[str, Any] node: The Wiz finding node
1268
+ :return: ISO formatted first seen date
1269
+ :rtype: str
1270
+ """
1271
+ first_seen = node.get("firstSeenAt") or node.get("firstDetectedAt") or get_current_datetime()
1272
+ return format_to_regscale_iso(first_seen)
1273
+
1274
+ def _get_last_seen_date(self, node: Dict[str, Any], first_seen_fallback: str) -> str:
1275
+ """
1276
+ Get the last seen date from a Wiz node, with fallbacks.
1277
+
1278
+ :param Dict[str, Any] node: The Wiz finding node
1279
+ :param str first_seen_fallback: Fallback date if no last seen available
1280
+ :return: ISO formatted last seen date
1281
+ :rtype: str
1282
+ """
1283
+ last_seen = (
1284
+ node.get("lastSeenAt")
1285
+ or node.get("lastDetectedAt")
1286
+ or node.get("analyzedAt")
1287
+ or first_seen_fallback
1288
+ or get_current_datetime()
1289
+ )
1290
+ return format_to_regscale_iso(last_seen)
1291
+
1292
+ def _get_network_exposure_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1293
+ """
1294
+ Extract data specific to network exposure findings.
1295
+
1296
+ :param Dict[str, Any] node: The Wiz finding node to parse
1297
+ :return: Dictionary containing network exposure-specific data
1298
+ :rtype: Dict[str, Any]
1299
+ """
846
1300
  exposed_entity = node.get("exposedEntity", {})
847
1301
  entity_name = exposed_entity.get("name", "Unknown Entity")
848
1302
  port_range = node.get("portRange", "Unknown Port")
849
- title = f"Network Exposure: {entity_name} on {port_range}"
850
-
851
1303
  # Build description with network details
852
1304
  description_parts = [
853
1305
  f"Exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
@@ -861,52 +1313,37 @@ class WizVulnerabilityIntegration(ScannerIntegration):
861
1313
  if net_protocols := node.get("networkProtocols"):
862
1314
  description_parts.append(f"Network protocols: {', '.join(net_protocols)}")
863
1315
 
864
- description = "\n".join(description_parts)
865
-
866
- return IntegrationFinding(
867
- control_labels=[],
868
- category="Wiz Network Exposure",
869
- title=title,
870
- description=description,
871
- severity=severity,
872
- status=IssueStatus.Open,
873
- asset_identifier=asset_id,
874
- external_id=node.get("id"),
875
- first_seen=first_seen,
876
- date_created=first_seen,
877
- last_seen=first_seen, # Network exposures may not have lastSeen
878
- remediation=f"Review and restrict network access to {entity_name} on {port_range}",
879
- plugin_name=f"Wiz Network Exposure - {port_range}",
880
- vulnerability_type=WizVulnerabilityType.NETWORK_EXPOSURE_FINDING.value,
881
- due_date=due_date,
882
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
883
- identification="Network Security Assessment",
884
- )
1316
+ return {
1317
+ "category": "Wiz Network Exposure",
1318
+ "title": f"Network Exposure: {entity_name} on {port_range}",
1319
+ "description": "\n".join(description_parts),
1320
+ "severity": "Medium", # Network exposures typically don't have explicit severity
1321
+ "remediation": f"Review and restrict network access to {entity_name} on {port_range}",
1322
+ "plugin_name": f"Wiz Network Exposure - {port_range}",
1323
+ "identification": "Network Security Assessment",
1324
+ }
885
1325
 
886
- def _parse_external_attack_surface_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
887
- """Parse external attack surface finding from Wiz.
1326
+ def _parse_network_exposure_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1327
+ """Parse network exposure finding from Wiz.
888
1328
 
889
1329
  :param Dict[str, Any] node: The Wiz finding node to parse
890
1330
  :return: The parsed IntegrationFinding or None if parsing fails
891
1331
  :rtype: Optional[IntegrationFinding]
892
1332
  """
893
- asset_id = node.get("exposedEntity", {}).get("id")
894
- if not asset_id:
895
- return None
1333
+ finding_data = self._get_network_exposure_finding_data(node)
1334
+ return self._create_integration_finding(node, WizVulnerabilityType.NETWORK_EXPOSURE_FINDING, finding_data)
896
1335
 
897
- first_seen = node.get("firstSeenAt") or get_current_datetime()
898
- first_seen = format_to_regscale_iso(first_seen)
899
-
900
- # External attack surface findings are typically high severity
901
- severity = regscale_models.IssueSeverity.High
902
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1336
+ def _get_external_attack_surface_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1337
+ """
1338
+ Extract data specific to external attack surface findings.
903
1339
 
904
- # Create meaningful title for external attack surface
1340
+ :param Dict[str, Any] node: The Wiz finding node to parse
1341
+ :return: Dictionary containing external attack surface-specific data
1342
+ :rtype: Dict[str, Any]
1343
+ """
905
1344
  exposed_entity = node.get("exposedEntity", {})
906
1345
  entity_name = exposed_entity.get("name", "Unknown Entity")
907
1346
  port_range = node.get("portRange", "Unknown Port")
908
- title = f"External Attack Surface: {entity_name} exposed on {port_range}"
909
-
910
1347
  # Build description with attack surface details
911
1348
  description_parts = [
912
1349
  f"Externally exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
@@ -920,49 +1357,34 @@ class WizVulnerabilityIntegration(ScannerIntegration):
920
1357
  endpoint_names = [ep.get("name", "Unknown") for ep in endpoints[:3]] # Limit to first 3
921
1358
  description_parts.append(f"Application endpoints: {', '.join(endpoint_names)}")
922
1359
 
923
- description = "\n".join(description_parts)
924
-
925
- return IntegrationFinding(
926
- control_labels=[],
927
- category="Wiz External Attack Surface",
928
- title=title,
929
- description=description,
930
- severity=severity,
931
- status=IssueStatus.Open,
932
- asset_identifier=asset_id,
933
- external_id=node.get("id"),
934
- first_seen=first_seen,
935
- date_created=first_seen,
936
- last_seen=first_seen,
937
- remediation=f"Review external exposure of {entity_name} and implement proper access controls",
938
- plugin_name=f"Wiz External Attack Surface - {port_range}",
939
- vulnerability_type=WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE.value,
940
- due_date=due_date,
941
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
942
- identification="External Attack Surface Assessment",
943
- )
1360
+ return {
1361
+ "category": "Wiz External Attack Surface",
1362
+ "title": f"External Attack Surface: {entity_name} exposed on {port_range}",
1363
+ "description": "\n".join(description_parts),
1364
+ "severity": "High", # External attack surface findings are typically high severity
1365
+ "remediation": f"Review external exposure of {entity_name} and implement proper access controls",
1366
+ "plugin_name": f"Wiz External Attack Surface - {port_range}",
1367
+ "identification": "External Attack Surface Assessment",
1368
+ }
944
1369
 
945
- def _parse_excessive_access_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
946
- """Parse excessive access finding from Wiz.
1370
+ def _parse_external_attack_surface_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1371
+ """Parse external attack surface finding from Wiz.
947
1372
 
948
1373
  :param Dict[str, Any] node: The Wiz finding node to parse
949
1374
  :return: The parsed IntegrationFinding or None if parsing fails
950
1375
  :rtype: Optional[IntegrationFinding]
951
1376
  """
952
- scope = node.get("scope", {})
953
- asset_id = scope.get("graphEntity", {}).get("id")
954
- if not asset_id:
955
- return None
1377
+ finding_data = self._get_external_attack_surface_finding_data(node)
1378
+ return self._create_integration_finding(node, WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE, finding_data)
956
1379
 
957
- first_seen = get_current_datetime() # Excessive access findings may not have firstSeen
958
- first_seen = format_to_regscale_iso(first_seen)
959
- severity = self.get_issue_severity(node.get("severity", "Medium"))
960
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
961
-
962
- # Use the finding name directly as it's descriptive
963
- title = node.get("name", "Excessive Access Detected")
964
- description = node.get("description", "")
1380
+ def _get_excessive_access_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1381
+ """
1382
+ Extract data specific to excessive access findings.
965
1383
 
1384
+ :param Dict[str, Any] node: The Wiz finding node to parse
1385
+ :return: Dictionary containing excessive access-specific data
1386
+ :rtype: Dict[str, Any]
1387
+ """
966
1388
  # Add remediation details
967
1389
  remediation_parts = [node.get("description", "")]
968
1390
  if remediation_instructions := node.get("remediationInstructions"):
@@ -970,42 +1392,34 @@ class WizVulnerabilityIntegration(ScannerIntegration):
970
1392
  if policy_name := node.get("builtInPolicyRemediationName"):
971
1393
  remediation_parts.append(f"Built-in policy: {policy_name}")
972
1394
 
973
- remediation = "\n".join(filter(None, remediation_parts))
974
-
975
- return IntegrationFinding(
976
- control_labels=[],
977
- category="Wiz Excessive Access",
978
- title=title,
979
- description=description,
980
- severity=severity,
981
- status=self.map_status_to_issue_status(node.get("status", "Open")),
982
- asset_identifier=asset_id,
983
- external_id=node.get("id"),
984
- first_seen=first_seen,
985
- date_created=first_seen,
986
- last_seen=first_seen,
987
- remediation=remediation,
988
- plugin_name=f"Wiz Excessive Access - {node.get('remediationType', 'Unknown')}",
989
- vulnerability_type=WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING.value,
990
- due_date=due_date,
991
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
992
- identification="Access Control Assessment",
993
- )
1395
+ return {
1396
+ "category": "Wiz Excessive Access",
1397
+ "title": node.get("name", "Excessive Access Detected"),
1398
+ "description": node.get("description", ""),
1399
+ "remediation": "\n".join(filter(None, remediation_parts)),
1400
+ "plugin_name": f"Wiz Excessive Access - {node.get('remediationType', 'Unknown')}",
1401
+ "identification": "Access Control Assessment",
1402
+ }
994
1403
 
995
- def _parse_end_of_life_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
996
- """Parse end of life finding from Wiz."""
997
- asset_id = node.get("vulnerableAsset", {}).get("id")
998
- if not asset_id:
999
- return None
1404
+ def _parse_excessive_access_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1405
+ """Parse excessive access finding from Wiz.
1000
1406
 
1001
- first_seen = node.get("firstDetectedAt") or get_current_datetime()
1002
- first_seen = format_to_regscale_iso(first_seen)
1003
- severity = self.get_issue_severity(node.get("severity", "High"))
1004
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1407
+ :param Dict[str, Any] node: The Wiz finding node to parse
1408
+ :return: The parsed IntegrationFinding or None if parsing fails
1409
+ :rtype: Optional[IntegrationFinding]
1410
+ """
1411
+ finding_data = self._get_excessive_access_finding_data(node)
1412
+ return self._create_integration_finding(node, WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING, finding_data)
1413
+
1414
+ def _get_end_of_life_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1415
+ """
1416
+ Extract data specific to end of life findings.
1005
1417
 
1006
- # Create meaningful title for end-of-life findings
1418
+ :param Dict[str, Any] node: The Wiz finding node to parse
1419
+ :return: Dictionary containing end of life-specific data
1420
+ :rtype: Dict[str, Any]
1421
+ """
1007
1422
  name = node.get("name", "Unknown Technology")
1008
- title = f"End of Life: {name}"
1009
1423
 
1010
1424
  # Build description with EOL details
1011
1425
  description_parts = [node.get("description", "")]
@@ -1014,42 +1428,30 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1014
1428
  if recommended_version := node.get("recommendedVersion"):
1015
1429
  description_parts.append(f"Recommended version: {recommended_version}")
1016
1430
 
1017
- description = "\n".join(filter(None, description_parts))
1018
-
1019
- return IntegrationFinding(
1020
- control_labels=[],
1021
- category="Wiz End of Life",
1022
- title=title,
1023
- description=description,
1024
- severity=severity,
1025
- status=self.map_status_to_issue_status(node.get("status", "Open")),
1026
- asset_identifier=asset_id,
1027
- external_id=node.get("id"),
1028
- first_seen=first_seen,
1029
- date_created=first_seen,
1030
- last_seen=format_to_regscale_iso(node.get("lastDetectedAt") or get_current_datetime()),
1031
- remediation=f"Upgrade {name} to a supported version",
1032
- plugin_name=f"Wiz End of Life - {name}",
1033
- vulnerability_type=WizVulnerabilityType.END_OF_LIFE_FINDING.value,
1034
- due_date=due_date,
1035
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
1036
- identification="Technology Lifecycle Assessment",
1037
- )
1431
+ return {
1432
+ "category": "Wiz End of Life",
1433
+ "title": f"End of Life: {name}",
1434
+ "description": "\n".join(filter(None, description_parts)),
1435
+ "severity": "High", # End of life findings are typically high severity
1436
+ "remediation": f"Upgrade {name} to a supported version",
1437
+ "plugin_name": f"Wiz End of Life - {name}",
1438
+ "identification": "Technology Lifecycle Assessment",
1439
+ }
1038
1440
 
1039
- def _parse_generic_finding(
1040
- self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1041
- ) -> Optional[IntegrationFinding]:
1042
- """Generic parsing method for fallback cases."""
1043
- asset_id = self.get_asset_id_from_node(node, vulnerability_type)
1044
- if not asset_id:
1045
- return None
1441
+ def _parse_end_of_life_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1442
+ """Parse end of life finding from Wiz."""
1443
+ finding_data = self._get_end_of_life_finding_data(node)
1444
+ return self._create_integration_finding(node, WizVulnerabilityType.END_OF_LIFE_FINDING, finding_data)
1046
1445
 
1047
- first_seen = node.get("firstDetectedAt") or node.get("firstSeenAt") or get_current_datetime()
1048
- first_seen = format_to_regscale_iso(first_seen)
1049
- severity = self.get_issue_severity(node.get("severity", "Low"))
1050
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1446
+ def _get_generic_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1447
+ """
1448
+ Extract data specific to generic findings.
1051
1449
 
1052
- status = self.map_status_to_issue_status(node.get("status", "Open"))
1450
+ :param Dict[str, Any] node: The Wiz finding node to parse
1451
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1452
+ :return: Dictionary containing generic finding-specific data
1453
+ :rtype: Dict[str, Any]
1454
+ """
1053
1455
  name: str = node.get("name", "")
1054
1456
  cve = (
1055
1457
  name
@@ -1057,36 +1459,25 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1057
1459
  else node.get("cve", name)
1058
1460
  )
1059
1461
 
1060
- comments_dict = node.get("commentThread", {})
1061
- formatted_comments = self.process_comments(comments_dict)
1062
-
1063
- return IntegrationFinding(
1064
- control_labels=[],
1065
- category="Wiz Vulnerability",
1066
- title=node.get("name", "Unknown vulnerability"),
1067
- description=node.get("description", ""),
1068
- severity=severity,
1069
- status=status,
1070
- asset_identifier=asset_id,
1071
- external_id=f"{node.get('sourceRule', {'id': cve}).get('id')}",
1072
- first_seen=first_seen,
1073
- date_created=first_seen,
1074
- last_seen=format_to_regscale_iso(
1075
- node.get("lastDetectedAt") or node.get("analyzedAt") or get_current_datetime()
1076
- ),
1077
- remediation=node.get("description", ""),
1078
- cvss_score=node.get("score"),
1079
- cve=cve,
1080
- plugin_name=cve,
1081
- cvss_v3_base_score=node.get("score"),
1082
- source_rule_id=node.get("sourceRule", {}).get("id"),
1083
- vulnerability_type=vulnerability_type.value,
1084
- due_date=due_date,
1085
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
1086
- identification="Vulnerability Assessment",
1087
- comments=formatted_comments,
1088
- poam_comments=formatted_comments,
1089
- )
1462
+ return {
1463
+ "category": "Wiz Vulnerability",
1464
+ "title": node.get("name", "Unknown vulnerability"),
1465
+ "description": node.get("description", ""),
1466
+ "external_id": f"{node.get('sourceRule', {'id': cve}).get('id')}",
1467
+ "remediation": node.get("description", ""),
1468
+ "plugin_name": cve,
1469
+ "identification": "Vulnerability Assessment",
1470
+ "cve": cve,
1471
+ "cvss_score": node.get("score"),
1472
+ "source_rule_id": node.get("sourceRule", {}).get("id"),
1473
+ }
1474
+
1475
+ def _parse_generic_finding(
1476
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1477
+ ) -> Optional[IntegrationFinding]:
1478
+ """Generic parsing method for fallback cases."""
1479
+ finding_data = self._get_generic_finding_data(node)
1480
+ return self._create_integration_finding(node, vulnerability_type, finding_data)
1090
1481
 
1091
1482
  def get_compliance_settings(self):
1092
1483
  """
@@ -1164,12 +1555,22 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1164
1555
  """
1165
1556
  label_lower = label.lower()
1166
1557
 
1167
- # Check for open status mappings
1168
- if status_lower == "open" and label_lower in ["open", "active", "new"]:
1558
+ logger.debug(f"Checking compliance label matching: status='{status_lower}', label='{label_lower}'")
1559
+
1560
+ # Check for open status mappings (including IN_PROGRESS)
1561
+ if status_lower in ["open", "in_progress"] and label_lower in [
1562
+ "open",
1563
+ "active",
1564
+ "new",
1565
+ "in progress",
1566
+ "in_progress",
1567
+ ]:
1568
+ logger.debug(f"Matched status '{status_lower}' with label '{label_lower}' -> IssueStatus.Open")
1169
1569
  return IssueStatus.Open
1170
1570
 
1171
1571
  # Check for closed status mappings
1172
1572
  if status_lower in ["resolved", "rejected"] and label_lower in ["closed", "resolved", "rejected", "completed"]:
1573
+ logger.debug(f"Matched status '{status_lower}' with label '{label_lower}' -> IssueStatus.Closed")
1173
1574
  return IssueStatus.Closed
1174
1575
 
1175
1576
  return None
@@ -1184,10 +1585,19 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1184
1585
  """
1185
1586
  status_lower = status.lower()
1186
1587
 
1187
- if status_lower == "open":
1588
+ # Add debug logging to trace status mapping
1589
+ logger.debug(f"Mapping Wiz status: original='{status}', lowercase='{status_lower}'")
1590
+
1591
+ # Map open and in-progress statuses to Open
1592
+ if status_lower in ["open", "in_progress"]:
1593
+ logger.debug(f"Wiz status '{status}' mapped to IssueStatus.Open")
1188
1594
  return IssueStatus.Open
1595
+ # Map resolved and rejected statuses to Closed
1189
1596
  if status_lower in ["resolved", "rejected"]:
1597
+ logger.debug(f"Wiz status '{status}' mapped to IssueStatus.Closed")
1190
1598
  return IssueStatus.Closed
1599
+ # Default to Open for any unknown status
1600
+ logger.debug(f"Unknown Wiz status '{status}' encountered, defaulting to Open")
1191
1601
  return IssueStatus.Open
1192
1602
 
1193
1603
  def fetch_assets(self, *args, **kwargs) -> Iterator[IntegrationAsset]:
@@ -1298,6 +1708,13 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1298
1708
 
1299
1709
  return software_version, software_vendor, software_name
1300
1710
 
1711
+ @lru_cache()
1712
+ def get_user_id(self) -> str:
1713
+ """Function to return the default user ID
1714
+ :return: The default user ID as a string
1715
+ """
1716
+ return RegScaleModel.get_user_id()
1717
+
1301
1718
  def parse_asset(self, node: Dict[str, Any]) -> Optional[IntegrationAsset]:
1302
1719
  """
1303
1720
  Parses Wiz assets
@@ -1306,8 +1723,9 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1306
1723
  :return: The parsed IntegrationAsset
1307
1724
  :rtype: Optional[IntegrationAsset]
1308
1725
  """
1309
- name = node.get("name", "")
1726
+
1310
1727
  wiz_entity = node.get("graphEntity", {})
1728
+ name = wiz_entity.get("providerUniqueId") or node.get("name", "")
1311
1729
  if not wiz_entity:
1312
1730
  logger.warning("No graph entity found for asset %s", name)
1313
1731
  return None
@@ -1336,7 +1754,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1336
1754
  other_tracking_number=node.get("id", ""),
1337
1755
  identifier=node.get("id", ""),
1338
1756
  asset_type=create_asset_type(node.get("type", "")),
1339
- asset_owner_id=ScannerVariables.userId,
1757
+ asset_owner_id=self.get_user_id(),
1340
1758
  parent_id=self.plan_id,
1341
1759
  parent_module=regscale_models.SecurityPlan.get_module_slug(),
1342
1760
  asset_category=map_category(node),
@@ -1572,7 +1990,11 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1572
1990
  :param str identifier: The missing asset identifier
1573
1991
  :rtype: None
1574
1992
  """
1575
- logger.info("🔍 Analyzing missing asset: %s", identifier)
1993
+ # Only log detailed diagnostics for the first occurrence of each asset
1994
+ if identifier in self.alerted_assets:
1995
+ return
1996
+
1997
+ logger.debug("Analyzing missing asset: %s", identifier)
1576
1998
 
1577
1999
  # Define inventory files to search (constant moved up for clarity)
1578
2000
  inventory_files = (
@@ -1647,10 +2069,13 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1647
2069
  asset_type = self._extract_asset_type_from_node(asset_info)
1648
2070
  asset_name = self._extract_asset_name_from_node(asset_info)
1649
2071
 
1650
- logger.warning(
1651
- "🚨 MISSING ASSET FOUND: ID=%s, Type=%s, Name='%s', Source=%s\n"
1652
- " 💡 SOLUTION: Add '%s' to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py\n"
1653
- " 📍 Then re-run: regscale wiz inventory -id <plan_id> -p <project_id>",
2072
+ # Track missing asset types for summary reporting
2073
+ self._missing_asset_types.add(asset_type)
2074
+
2075
+ logger.info(
2076
+ "Missing asset found in cached data - ID: %s, Type: %s, Name: '%s', Source: %s\n"
2077
+ " Action: Consider adding '%s' to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py\n"
2078
+ " Then re-run: regscale wiz inventory --wiz_project_id <project_id> -id <plan_id>",
1654
2079
  identifier,
1655
2080
  asset_type,
1656
2081
  asset_name,
@@ -1665,13 +2090,11 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1665
2090
  :param str identifier: Asset identifier
1666
2091
  :rtype: None
1667
2092
  """
1668
- logger.warning(
1669
- " MISSING ASSET ANALYSIS: ID=%s\n"
1670
- " Asset not found in any cached data files.\n"
1671
- " This may indicate:\n"
1672
- " - Asset from different Wiz project\n"
1673
- " - Asset type not included in current queries\n"
1674
- " - Asset deleted from Wiz but finding still exists",
2093
+ logger.debug(
2094
+ "Asset not found in cached data - ID: %s. Possible reasons: "
2095
+ "(1) Asset from different Wiz project, "
2096
+ "(2) Asset type not included in queries, "
2097
+ "(3) Asset deleted from Wiz",
1675
2098
  identifier,
1676
2099
  )
1677
2100