regscale-cli 6.21.2.0__py3-none-any.whl → 6.28.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/api.py +5 -2
  4. regscale/core/app/application.py +36 -6
  5. regscale/core/app/internal/control_editor.py +73 -21
  6. regscale/core/app/internal/evidence.py +727 -204
  7. regscale/core/app/internal/login.py +4 -2
  8. regscale/core/app/internal/model_editor.py +219 -64
  9. regscale/core/app/utils/app_utils.py +86 -12
  10. regscale/core/app/utils/catalog_utils/common.py +1 -1
  11. regscale/core/login.py +21 -4
  12. regscale/core/utils/async_graphql_client.py +363 -0
  13. regscale/core/utils/date.py +77 -1
  14. regscale/dev/cli.py +26 -0
  15. regscale/dev/code_gen.py +109 -24
  16. regscale/dev/version.py +72 -0
  17. regscale/integrations/commercial/__init__.py +30 -2
  18. regscale/integrations/commercial/aws/audit_manager_compliance.py +3908 -0
  19. regscale/integrations/commercial/aws/cli.py +3107 -54
  20. regscale/integrations/commercial/aws/cloudtrail_control_mappings.py +333 -0
  21. regscale/integrations/commercial/aws/cloudtrail_evidence.py +501 -0
  22. regscale/integrations/commercial/aws/cloudwatch_control_mappings.py +357 -0
  23. regscale/integrations/commercial/aws/cloudwatch_evidence.py +490 -0
  24. regscale/integrations/commercial/{amazon → aws}/common.py +71 -19
  25. regscale/integrations/commercial/aws/config_compliance.py +914 -0
  26. regscale/integrations/commercial/aws/conformance_pack_mappings.py +198 -0
  27. regscale/integrations/commercial/aws/control_compliance_analyzer.py +439 -0
  28. regscale/integrations/commercial/aws/evidence_generator.py +283 -0
  29. regscale/integrations/commercial/aws/guardduty_control_mappings.py +340 -0
  30. regscale/integrations/commercial/aws/guardduty_evidence.py +1053 -0
  31. regscale/integrations/commercial/aws/iam_control_mappings.py +368 -0
  32. regscale/integrations/commercial/aws/iam_evidence.py +574 -0
  33. regscale/integrations/commercial/aws/inventory/__init__.py +338 -22
  34. regscale/integrations/commercial/aws/inventory/base.py +107 -5
  35. regscale/integrations/commercial/aws/inventory/resources/analytics.py +390 -0
  36. regscale/integrations/commercial/aws/inventory/resources/applications.py +234 -0
  37. regscale/integrations/commercial/aws/inventory/resources/audit_manager.py +513 -0
  38. regscale/integrations/commercial/aws/inventory/resources/cloudtrail.py +315 -0
  39. regscale/integrations/commercial/aws/inventory/resources/cloudtrail_logs_metadata.py +476 -0
  40. regscale/integrations/commercial/aws/inventory/resources/cloudwatch.py +191 -0
  41. regscale/integrations/commercial/aws/inventory/resources/compute.py +328 -9
  42. regscale/integrations/commercial/aws/inventory/resources/config.py +464 -0
  43. regscale/integrations/commercial/aws/inventory/resources/containers.py +74 -9
  44. regscale/integrations/commercial/aws/inventory/resources/database.py +481 -31
  45. regscale/integrations/commercial/aws/inventory/resources/developer_tools.py +253 -0
  46. regscale/integrations/commercial/aws/inventory/resources/guardduty.py +286 -0
  47. regscale/integrations/commercial/aws/inventory/resources/iam.py +470 -0
  48. regscale/integrations/commercial/aws/inventory/resources/inspector.py +476 -0
  49. regscale/integrations/commercial/aws/inventory/resources/integration.py +175 -61
  50. regscale/integrations/commercial/aws/inventory/resources/kms.py +447 -0
  51. regscale/integrations/commercial/aws/inventory/resources/machine_learning.py +358 -0
  52. regscale/integrations/commercial/aws/inventory/resources/networking.py +390 -67
  53. regscale/integrations/commercial/aws/inventory/resources/s3.py +394 -0
  54. regscale/integrations/commercial/aws/inventory/resources/security.py +268 -72
  55. regscale/integrations/commercial/aws/inventory/resources/securityhub.py +473 -0
  56. regscale/integrations/commercial/aws/inventory/resources/storage.py +288 -29
  57. regscale/integrations/commercial/aws/inventory/resources/systems_manager.py +657 -0
  58. regscale/integrations/commercial/aws/inventory/resources/vpc.py +655 -0
  59. regscale/integrations/commercial/aws/kms_control_mappings.py +288 -0
  60. regscale/integrations/commercial/aws/kms_evidence.py +879 -0
  61. regscale/integrations/commercial/aws/ocsf/__init__.py +7 -0
  62. regscale/integrations/commercial/aws/ocsf/constants.py +115 -0
  63. regscale/integrations/commercial/aws/ocsf/mapper.py +435 -0
  64. regscale/integrations/commercial/aws/org_control_mappings.py +286 -0
  65. regscale/integrations/commercial/aws/org_evidence.py +666 -0
  66. regscale/integrations/commercial/aws/s3_control_mappings.py +356 -0
  67. regscale/integrations/commercial/aws/s3_evidence.py +632 -0
  68. regscale/integrations/commercial/aws/scanner.py +1072 -205
  69. regscale/integrations/commercial/aws/security_hub.py +319 -0
  70. regscale/integrations/commercial/aws/session_manager.py +282 -0
  71. regscale/integrations/commercial/aws/ssm_control_mappings.py +291 -0
  72. regscale/integrations/commercial/aws/ssm_evidence.py +492 -0
  73. regscale/integrations/commercial/jira.py +489 -153
  74. regscale/integrations/commercial/microsoft_defender/defender.py +326 -5
  75. regscale/integrations/commercial/microsoft_defender/defender_api.py +348 -14
  76. regscale/integrations/commercial/microsoft_defender/defender_constants.py +157 -0
  77. regscale/integrations/commercial/qualys/__init__.py +167 -68
  78. regscale/integrations/commercial/qualys/scanner.py +305 -39
  79. regscale/integrations/commercial/sarif/sairf_importer.py +432 -0
  80. regscale/integrations/commercial/sarif/sarif_converter.py +67 -0
  81. regscale/integrations/commercial/sicura/api.py +79 -42
  82. regscale/integrations/commercial/sicura/commands.py +8 -2
  83. regscale/integrations/commercial/sicura/scanner.py +83 -44
  84. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  85. regscale/integrations/commercial/synqly/assets.py +133 -16
  86. regscale/integrations/commercial/synqly/edr.py +2 -8
  87. regscale/integrations/commercial/synqly/query_builder.py +536 -0
  88. regscale/integrations/commercial/synqly/ticketing.py +27 -0
  89. regscale/integrations/commercial/synqly/vulnerabilities.py +165 -28
  90. regscale/integrations/commercial/tenablev2/cis_parsers.py +453 -0
  91. regscale/integrations/commercial/tenablev2/cis_scanner.py +447 -0
  92. regscale/integrations/commercial/tenablev2/commands.py +146 -5
  93. regscale/integrations/commercial/tenablev2/scanner.py +1 -3
  94. regscale/integrations/commercial/tenablev2/stig_parsers.py +113 -57
  95. regscale/integrations/commercial/wizv2/WizDataMixin.py +1 -1
  96. regscale/integrations/commercial/wizv2/click.py +191 -76
  97. regscale/integrations/commercial/wizv2/compliance/__init__.py +15 -0
  98. regscale/integrations/commercial/wizv2/{policy_compliance_helpers.py → compliance/helpers.py} +78 -60
  99. regscale/integrations/commercial/wizv2/compliance_report.py +1592 -0
  100. regscale/integrations/commercial/wizv2/core/__init__.py +133 -0
  101. regscale/integrations/commercial/wizv2/{async_client.py → core/client.py} +7 -3
  102. regscale/integrations/commercial/wizv2/{constants.py → core/constants.py} +92 -89
  103. regscale/integrations/commercial/wizv2/core/file_operations.py +237 -0
  104. regscale/integrations/commercial/wizv2/fetchers/__init__.py +11 -0
  105. regscale/integrations/commercial/wizv2/{data_fetcher.py → fetchers/policy_assessment.py} +66 -9
  106. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  107. regscale/integrations/commercial/wizv2/issue.py +776 -28
  108. regscale/integrations/commercial/wizv2/models/__init__.py +0 -0
  109. regscale/integrations/commercial/wizv2/parsers/__init__.py +34 -0
  110. regscale/integrations/commercial/wizv2/{parsers.py → parsers/main.py} +1 -1
  111. regscale/integrations/commercial/wizv2/processors/__init__.py +11 -0
  112. regscale/integrations/commercial/wizv2/{finding_processor.py → processors/finding.py} +1 -1
  113. regscale/integrations/commercial/wizv2/reports.py +243 -0
  114. regscale/integrations/commercial/wizv2/sbom.py +1 -1
  115. regscale/integrations/commercial/wizv2/scanner.py +1031 -441
  116. regscale/integrations/commercial/wizv2/utils/__init__.py +48 -0
  117. regscale/integrations/commercial/wizv2/{utils.py → utils/main.py} +116 -61
  118. regscale/integrations/commercial/wizv2/variables.py +89 -3
  119. regscale/integrations/compliance_integration.py +1036 -151
  120. regscale/integrations/control_matcher.py +432 -0
  121. regscale/integrations/due_date_handler.py +333 -0
  122. regscale/integrations/milestone_manager.py +291 -0
  123. regscale/integrations/public/__init__.py +14 -0
  124. regscale/integrations/public/cci_importer.py +834 -0
  125. regscale/integrations/public/csam/__init__.py +0 -0
  126. regscale/integrations/public/csam/csam.py +938 -0
  127. regscale/integrations/public/csam/csam_agency_defined.py +179 -0
  128. regscale/integrations/public/csam/csam_common.py +154 -0
  129. regscale/integrations/public/csam/csam_controls.py +432 -0
  130. regscale/integrations/public/csam/csam_poam.py +124 -0
  131. regscale/integrations/public/fedramp/click.py +77 -6
  132. regscale/integrations/public/fedramp/docx_parser.py +10 -1
  133. regscale/integrations/public/fedramp/fedramp_cis_crm.py +675 -289
  134. regscale/integrations/public/fedramp/fedramp_five.py +1 -1
  135. regscale/integrations/public/fedramp/poam/scanner.py +75 -7
  136. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  137. regscale/integrations/scanner_integration.py +1961 -430
  138. regscale/models/integration_models/CCI_List.xml +1 -0
  139. regscale/models/integration_models/aqua.py +2 -2
  140. regscale/models/integration_models/cisa_kev_data.json +805 -11
  141. regscale/models/integration_models/flat_file_importer/__init__.py +5 -8
  142. regscale/models/integration_models/nexpose.py +36 -10
  143. regscale/models/integration_models/qualys.py +3 -4
  144. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  145. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +87 -18
  146. regscale/models/integration_models/synqly_models/filter_parser.py +332 -0
  147. regscale/models/integration_models/synqly_models/ocsf_mapper.py +124 -25
  148. regscale/models/integration_models/synqly_models/synqly_model.py +89 -16
  149. regscale/models/locking.py +12 -8
  150. regscale/models/platform.py +4 -2
  151. regscale/models/regscale_models/__init__.py +7 -0
  152. regscale/models/regscale_models/assessment.py +2 -1
  153. regscale/models/regscale_models/catalog.py +1 -1
  154. regscale/models/regscale_models/compliance_settings.py +251 -1
  155. regscale/models/regscale_models/component.py +1 -0
  156. regscale/models/regscale_models/control_implementation.py +236 -41
  157. regscale/models/regscale_models/control_objective.py +74 -5
  158. regscale/models/regscale_models/file.py +2 -0
  159. regscale/models/regscale_models/form_field_value.py +5 -3
  160. regscale/models/regscale_models/inheritance.py +44 -0
  161. regscale/models/regscale_models/issue.py +301 -102
  162. regscale/models/regscale_models/milestone.py +33 -14
  163. regscale/models/regscale_models/organization.py +3 -0
  164. regscale/models/regscale_models/regscale_model.py +310 -73
  165. regscale/models/regscale_models/security_plan.py +4 -2
  166. regscale/models/regscale_models/vulnerability.py +3 -3
  167. regscale/regscale.py +25 -4
  168. regscale/templates/__init__.py +0 -0
  169. regscale/utils/threading/threadhandler.py +20 -15
  170. regscale/validation/record.py +23 -1
  171. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/METADATA +17 -33
  172. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/RECORD +310 -111
  173. tests/core/__init__.py +0 -0
  174. tests/core/utils/__init__.py +0 -0
  175. tests/core/utils/test_async_graphql_client.py +472 -0
  176. tests/fixtures/test_fixture.py +13 -8
  177. tests/regscale/core/test_login.py +171 -4
  178. tests/regscale/integrations/commercial/__init__.py +0 -0
  179. tests/regscale/integrations/commercial/aws/__init__.py +0 -0
  180. tests/regscale/integrations/commercial/aws/test_audit_manager_compliance.py +1304 -0
  181. tests/regscale/integrations/commercial/aws/test_audit_manager_evidence_aggregation.py +341 -0
  182. tests/regscale/integrations/commercial/aws/test_aws_analytics_collector.py +260 -0
  183. tests/regscale/integrations/commercial/aws/test_aws_applications_collector.py +242 -0
  184. tests/regscale/integrations/commercial/aws/test_aws_audit_manager_collector.py +1155 -0
  185. tests/regscale/integrations/commercial/aws/test_aws_cloudtrail_collector.py +534 -0
  186. tests/regscale/integrations/commercial/aws/test_aws_config_collector.py +400 -0
  187. tests/regscale/integrations/commercial/aws/test_aws_developer_tools_collector.py +203 -0
  188. tests/regscale/integrations/commercial/aws/test_aws_guardduty_collector.py +315 -0
  189. tests/regscale/integrations/commercial/aws/test_aws_iam_collector.py +458 -0
  190. tests/regscale/integrations/commercial/aws/test_aws_inspector_collector.py +353 -0
  191. tests/regscale/integrations/commercial/aws/test_aws_inventory_integration.py +530 -0
  192. tests/regscale/integrations/commercial/aws/test_aws_kms_collector.py +919 -0
  193. tests/regscale/integrations/commercial/aws/test_aws_machine_learning_collector.py +237 -0
  194. tests/regscale/integrations/commercial/aws/test_aws_s3_collector.py +722 -0
  195. tests/regscale/integrations/commercial/aws/test_aws_scanner_integration.py +722 -0
  196. tests/regscale/integrations/commercial/aws/test_aws_securityhub_collector.py +792 -0
  197. tests/regscale/integrations/commercial/aws/test_aws_systems_manager_collector.py +918 -0
  198. tests/regscale/integrations/commercial/aws/test_aws_vpc_collector.py +996 -0
  199. tests/regscale/integrations/commercial/aws/test_cli_evidence.py +431 -0
  200. tests/regscale/integrations/commercial/aws/test_cloudtrail_control_mappings.py +452 -0
  201. tests/regscale/integrations/commercial/aws/test_cloudtrail_evidence.py +788 -0
  202. tests/regscale/integrations/commercial/aws/test_config_compliance.py +298 -0
  203. tests/regscale/integrations/commercial/aws/test_conformance_pack_mappings.py +200 -0
  204. tests/regscale/integrations/commercial/aws/test_control_compliance_analyzer.py +375 -0
  205. tests/regscale/integrations/commercial/aws/test_datetime_parsing.py +223 -0
  206. tests/regscale/integrations/commercial/aws/test_evidence_generator.py +386 -0
  207. tests/regscale/integrations/commercial/aws/test_guardduty_control_mappings.py +564 -0
  208. tests/regscale/integrations/commercial/aws/test_guardduty_evidence.py +1041 -0
  209. tests/regscale/integrations/commercial/aws/test_iam_control_mappings.py +718 -0
  210. tests/regscale/integrations/commercial/aws/test_iam_evidence.py +1375 -0
  211. tests/regscale/integrations/commercial/aws/test_kms_control_mappings.py +656 -0
  212. tests/regscale/integrations/commercial/aws/test_kms_evidence.py +1163 -0
  213. tests/regscale/integrations/commercial/aws/test_ocsf_mapper.py +370 -0
  214. tests/regscale/integrations/commercial/aws/test_org_control_mappings.py +546 -0
  215. tests/regscale/integrations/commercial/aws/test_org_evidence.py +1240 -0
  216. tests/regscale/integrations/commercial/aws/test_s3_control_mappings.py +672 -0
  217. tests/regscale/integrations/commercial/aws/test_s3_evidence.py +987 -0
  218. tests/regscale/integrations/commercial/aws/test_scanner_evidence.py +373 -0
  219. tests/regscale/integrations/commercial/aws/test_security_hub_config_filtering.py +539 -0
  220. tests/regscale/integrations/commercial/aws/test_session_manager.py +516 -0
  221. tests/regscale/integrations/commercial/aws/test_ssm_control_mappings.py +588 -0
  222. tests/regscale/integrations/commercial/aws/test_ssm_evidence.py +735 -0
  223. tests/regscale/integrations/commercial/conftest.py +28 -0
  224. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  225. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  226. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  227. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  228. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  229. tests/regscale/integrations/commercial/test_aws.py +3742 -0
  230. tests/regscale/integrations/commercial/test_burp.py +48 -0
  231. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  232. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  233. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  234. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  235. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  236. tests/regscale/integrations/commercial/test_jira.py +2204 -0
  237. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  238. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  239. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  240. tests/regscale/integrations/commercial/test_sicura.py +349 -0
  241. tests/regscale/integrations/commercial/test_snow.py +423 -0
  242. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  243. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  244. tests/regscale/integrations/commercial/test_stig.py +33 -0
  245. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  246. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  247. tests/regscale/integrations/commercial/test_wiz.py +1365 -0
  248. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  249. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  250. tests/regscale/integrations/commercial/wizv2/compliance/__init__.py +1 -0
  251. tests/regscale/integrations/commercial/wizv2/compliance/test_helpers.py +903 -0
  252. tests/regscale/integrations/commercial/wizv2/core/__init__.py +1 -0
  253. tests/regscale/integrations/commercial/wizv2/core/test_auth.py +701 -0
  254. tests/regscale/integrations/commercial/wizv2/core/test_client.py +1037 -0
  255. tests/regscale/integrations/commercial/wizv2/core/test_file_operations.py +989 -0
  256. tests/regscale/integrations/commercial/wizv2/fetchers/__init__.py +1 -0
  257. tests/regscale/integrations/commercial/wizv2/fetchers/test_policy_assessment.py +805 -0
  258. tests/regscale/integrations/commercial/wizv2/parsers/__init__.py +1 -0
  259. tests/regscale/integrations/commercial/wizv2/parsers/test_main.py +1153 -0
  260. tests/regscale/integrations/commercial/wizv2/processors/__init__.py +1 -0
  261. tests/regscale/integrations/commercial/wizv2/processors/test_finding.py +671 -0
  262. tests/regscale/integrations/commercial/wizv2/test_WizDataMixin.py +537 -0
  263. tests/regscale/integrations/commercial/wizv2/test_click_comprehensive.py +851 -0
  264. tests/regscale/integrations/commercial/wizv2/test_compliance_report_comprehensive.py +910 -0
  265. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  266. tests/regscale/integrations/commercial/wizv2/test_file_cleanup.py +283 -0
  267. tests/regscale/integrations/commercial/wizv2/test_file_operations.py +260 -0
  268. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  269. tests/regscale/integrations/commercial/wizv2/test_issue_comprehensive.py +1203 -0
  270. tests/regscale/integrations/commercial/wizv2/test_reports.py +497 -0
  271. tests/regscale/integrations/commercial/wizv2/test_sbom.py +643 -0
  272. tests/regscale/integrations/commercial/wizv2/test_scanner_comprehensive.py +805 -0
  273. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  274. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1394 -0
  275. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  276. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  277. tests/regscale/integrations/commercial/wizv2/test_wiz_findings_comprehensive.py +364 -0
  278. tests/regscale/integrations/commercial/wizv2/test_wiz_inventory_comprehensive.py +644 -0
  279. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  280. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +1218 -0
  281. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +519 -0
  282. tests/regscale/integrations/commercial/wizv2/utils/__init__.py +1 -0
  283. tests/regscale/integrations/commercial/wizv2/utils/test_main.py +1523 -0
  284. tests/regscale/integrations/public/__init__.py +0 -0
  285. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  286. tests/regscale/integrations/public/fedramp/test_gen_asset_list.py +150 -0
  287. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  288. tests/regscale/integrations/public/test_alienvault.py +220 -0
  289. tests/regscale/integrations/public/test_cci.py +1053 -0
  290. tests/regscale/integrations/public/test_cisa.py +1021 -0
  291. tests/regscale/integrations/public/test_emass.py +518 -0
  292. tests/regscale/integrations/public/test_fedramp.py +1152 -0
  293. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  294. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  295. tests/regscale/integrations/public/test_oscal.py +453 -0
  296. tests/regscale/integrations/test_compliance_status_mapping.py +406 -0
  297. tests/regscale/integrations/test_control_matcher.py +1421 -0
  298. tests/regscale/integrations/test_control_matching.py +155 -0
  299. tests/regscale/integrations/test_milestone_manager.py +408 -0
  300. tests/regscale/models/test_control_implementation.py +118 -3
  301. tests/regscale/models/test_form_field_value_integration.py +304 -0
  302. tests/regscale/models/test_issue.py +378 -1
  303. tests/regscale/models/test_module_integration.py +582 -0
  304. tests/regscale/models/test_tenable_integrations.py +811 -105
  305. regscale/integrations/commercial/wizv2/policy_compliance.py +0 -3057
  306. regscale/integrations/public/fedramp/mappings/fedramp_r4_parts.json +0 -7388
  307. regscale/integrations/public/fedramp/mappings/fedramp_r5_parts.json +0 -9605
  308. regscale/integrations/public/fedramp/parts_mapper.py +0 -107
  309. /regscale/integrations/commercial/{amazon → sarif}/__init__.py +0 -0
  310. /regscale/integrations/commercial/wizv2/{wiz_auth.py → core/auth.py} +0 -0
  311. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/LICENSE +0 -0
  312. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/WHEEL +0 -0
  313. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/entry_points.txt +0 -0
  314. {regscale_cli-6.21.2.0.dist-info → regscale_cli-6.28.2.1.dist-info}/top_level.txt +0 -0
@@ -5,13 +5,16 @@ import json
5
5
  import logging
6
6
  import os
7
7
  import re
8
- from typing import Any, Dict, Iterator, List, Optional, Union, Tuple
8
+ from collections.abc import Iterator
9
+ from functools import lru_cache
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
9
11
 
10
- from regscale.core.app.utils.app_utils import check_file_path, get_current_datetime, error_and_exit
12
+ from regscale.core.app.utils.app_utils import check_file_path, error_and_exit, get_current_datetime
11
13
  from regscale.core.utils import get_base_protocol_from_port
12
14
  from regscale.core.utils.date import format_to_regscale_iso
13
- from regscale.integrations.commercial.wizv2.async_client import run_async_queries
14
- from regscale.integrations.commercial.wizv2.constants import (
15
+ from regscale.integrations.commercial.wizv2.core.client import run_async_queries
16
+ from regscale.integrations.commercial.wizv2.core.file_operations import FileOperations
17
+ from regscale.integrations.commercial.wizv2.core.constants import (
15
18
  END_OF_LIFE_FILE_PATH,
16
19
  EXTERNAL_ATTACK_SURFACE_FILE_PATH,
17
20
  INVENTORY_FILE_PATH,
@@ -41,11 +44,16 @@ from regscale.integrations.commercial.wizv2.utils import (
41
44
  map_category,
42
45
  )
43
46
  from regscale.integrations.commercial.wizv2.variables import WizVariables
44
- from regscale.integrations.commercial.wizv2.wiz_auth import wiz_authenticate
45
- from regscale.integrations.scanner_integration import IntegrationAsset, IntegrationFinding, ScannerIntegration
46
47
  from regscale.integrations.variables import ScannerVariables
48
+ from regscale.integrations.commercial.wizv2.core.auth import wiz_authenticate
49
+ from regscale.integrations.scanner_integration import (
50
+ IntegrationAsset,
51
+ IntegrationFinding,
52
+ ScannerIntegration,
53
+ )
47
54
  from regscale.models import IssueStatus, regscale_models
48
55
  from regscale.models.regscale_models.compliance_settings import ComplianceSettings
56
+ from regscale.models.regscale_models.regscale_model import RegScaleModel
49
57
 
50
58
  logger = logging.getLogger("regscale")
51
59
 
@@ -61,11 +69,21 @@ class WizVulnerabilityIntegration(ScannerIntegration):
61
69
  "High": regscale_models.IssueSeverity.High,
62
70
  "Medium": regscale_models.IssueSeverity.Moderate,
63
71
  "Low": regscale_models.IssueSeverity.Low,
72
+ "INFORMATIONAL": regscale_models.IssueSeverity.NotAssigned,
73
+ "INFO": regscale_models.IssueSeverity.NotAssigned, # Wiz uses "INFO" for informational data findings
74
+ "None": regscale_models.IssueSeverity.NotAssigned, # Wiz uses "NONE" for findings without severity
64
75
  }
65
76
  asset_lookup = "vulnerableAsset"
66
77
  wiz_token = None
67
78
  _compliance_settings = None
68
79
 
80
+ def __init__(self, *args, **kwargs):
81
+ super().__init__(*args, **kwargs)
82
+ # Suppress generic asset not found errors but use enhanced diagnostics instead
83
+ self.suppress_asset_not_found_errors = True
84
+ # Track unique missing asset types for summary reporting
85
+ self._missing_asset_types = set()
86
+
69
87
  @staticmethod
70
88
  def get_variables() -> Dict[str, Any]:
71
89
  """
@@ -79,6 +97,31 @@ class WizVulnerabilityIntegration(ScannerIntegration):
79
97
  "filterBy": {},
80
98
  }
81
99
 
100
+ def get_finding_identifier(self, finding) -> str:
101
+ """
102
+ Gets the finding identifier for Wiz findings.
103
+ For Wiz integrations, prioritize external_id since plugin_id can be non-unique.
104
+
105
+ :param finding: The finding
106
+ :return: The finding identifier
107
+ :rtype: str
108
+ """
109
+ # We could have a string truncation error platform side on IntegrationFindingId nvarchar(450)
110
+ prefix = f"{self.plan_id}:"
111
+
112
+ # For Wiz, prioritize external_id since plugin_id can be non-unique
113
+ if finding.external_id:
114
+ prefix += self.hash_string(finding.external_id).__str__()
115
+ else:
116
+ prefix += (
117
+ finding.cve or finding.plugin_id or finding.rule_id or self.hash_string(finding.external_id).__str__()
118
+ )
119
+
120
+ if ScannerVariables.issueCreation.lower() == "perasset":
121
+ res = f"{prefix}:{finding.asset_identifier}"
122
+ return res[:450]
123
+ return prefix[:450]
124
+
82
125
  def authenticate(self, client_id: Optional[str] = None, client_secret: Optional[str] = None) -> None:
83
126
  """
84
127
  Authenticates to Wiz using the client ID and client secret
@@ -92,14 +135,16 @@ class WizVulnerabilityIntegration(ScannerIntegration):
92
135
  logger.info("Authenticating to Wiz...")
93
136
  self.wiz_token = wiz_authenticate(client_id, client_secret)
94
137
 
95
- def get_query_types(self, project_id: str) -> List[Dict[str, Any]]:
138
+ def get_query_types(self, project_id: str, filter_by: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
96
139
  """Get the query types for vulnerability scanning.
97
140
 
98
141
  :param str project_id: The project ID to get queries for
142
+ :param Optional[Dict[str, Any]] filter_by: Optional filter criteria (used by subclasses)
99
143
  :return: List of query types
100
144
  :rtype: List[Dict[str, Any]]
101
145
  """
102
- return get_wiz_vulnerability_queries(project_id=project_id)
146
+ # Base class ignores filter_by, subclasses can override to use it
147
+ return get_wiz_vulnerability_queries(project_id=project_id, filter_by=filter_by)
103
148
 
104
149
  def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
105
150
  """
@@ -119,7 +164,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
119
164
  # Use async concurrent queries for better performance
120
165
  yield from self.fetch_findings_async(*args, **kwargs)
121
166
  except Exception as e:
122
- logger.warning(f"Async query failed, falling back to sync: {str(e)}")
167
+ logger.warning(f"Async query failed, falling back to sync: {e!s}")
123
168
  # Fallback to synchronous method
124
169
  yield from self.fetch_findings_sync(**kwargs)
125
170
  else:
@@ -195,8 +240,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
195
240
  progress_tracker=self.finding_progress,
196
241
  max_concurrent=5,
197
242
  )
198
- else:
199
- return self._load_cached_data_with_progress(query_configs)
243
+ return self._load_cached_data_with_progress(query_configs)
200
244
 
201
245
  def _process_query_results(
202
246
  self,
@@ -215,35 +259,63 @@ class WizVulnerabilityIntegration(ScannerIntegration):
215
259
  :yield: IntegrationFinding objects
216
260
  :rtype: Iterator[IntegrationFinding]
217
261
  """
218
- parse_task = self.finding_progress.add_task("[magenta]Processing fetched findings...", total=len(results))
262
+ parse_task = self._init_progress_task(len(results))
219
263
 
220
264
  for query_type_str, nodes, error in results:
221
265
  if error:
222
266
  logger.error(f"Error fetching {query_type_str}: {error}")
223
- self.finding_progress.advance(parse_task, 1)
267
+ self._advance_progress(parse_task)
224
268
  continue
225
269
 
226
- # Find corresponding vulnerability type and config
227
270
  vulnerability_type, config = self._find_vulnerability_config(query_type_str, query_configs)
228
271
  if not vulnerability_type or not config:
229
272
  logger.warning(f"Could not find vulnerability type for {query_type_str}")
230
- self.finding_progress.advance(parse_task, 1)
273
+ self._advance_progress(parse_task)
231
274
  continue
232
275
 
233
- # Save fetched data to cache if fresh data was fetched
234
276
  if should_fetch_fresh and nodes:
235
277
  self._save_data_to_cache(nodes, config.get("file_path"))
236
278
 
237
- # Apply project filtering for certain vulnerability types
238
279
  nodes = self._apply_project_filtering(nodes, vulnerability_type, project_id, query_type_str)
239
280
 
240
281
  logger.info(f"Processing {len(nodes)} {query_type_str} findings...")
241
282
  yield from self.parse_findings(nodes, vulnerability_type)
283
+ self._advance_progress(parse_task)
284
+
285
+ self._finalize_progress(parse_task)
286
+
287
+ def _init_progress_task(self, total_results: int):
288
+ """
289
+ Initialize progress tracking task.
290
+
291
+ :param int total_results: Total number of results to process
292
+ :return: Task ID or None
293
+ """
294
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
295
+ return self.finding_progress.add_task("[magenta]Processing fetched findings...", total=total_results)
296
+ return None
297
+
298
+ def _advance_progress(self, parse_task) -> None:
299
+ """
300
+ Advance progress bar by one step.
301
+
302
+ :param parse_task: Progress task ID
303
+ :rtype: None
304
+ """
305
+ if parse_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "advance"):
242
306
  self.finding_progress.advance(parse_task, 1)
243
307
 
244
- self.finding_progress.update(
245
- parse_task, description=f"[green]✓ Processed all findings ({self.num_findings_to_process} total)"
246
- )
308
+ def _finalize_progress(self, parse_task) -> None:
309
+ """
310
+ Finalize progress tracking with completion message.
311
+
312
+ :param parse_task: Progress task ID
313
+ :rtype: None
314
+ """
315
+ if parse_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
316
+ self.finding_progress.update(
317
+ parse_task, description=f"[green]✓ Processed all findings ({self.num_findings_to_process} total)"
318
+ )
247
319
 
248
320
  def _find_vulnerability_config(
249
321
  self, query_type_str: str, query_configs: List[Dict[str, Any]]
@@ -309,11 +381,16 @@ class WizVulnerabilityIntegration(ScannerIntegration):
309
381
  # Step 2: Initialize progress tracking
310
382
  logger.info("Fetching Wiz findings using async concurrent queries...")
311
383
  self.num_findings_to_process = 0
312
- query_configs = self.get_query_types(project_id=project_id)
313
-
314
- main_task = self.finding_progress.add_task(
315
- "[cyan]Running concurrent GraphQL queries...", total=len(query_configs)
316
- )
384
+ # Pass filter_by_override if provided
385
+ filter_by = kwargs.get("filter_by_override")
386
+ query_configs = self.get_query_types(project_id=project_id, filter_by=filter_by)
387
+
388
+ # Backwards compatibility: check if finding_progress exists and has add_task method
389
+ main_task = None
390
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
391
+ main_task = self.finding_progress.add_task(
392
+ "[cyan]Running concurrent GraphQL queries...", total=len(query_configs)
393
+ )
317
394
 
318
395
  # Step 3: Setup authentication
319
396
  headers = self._setup_authentication_headers()
@@ -323,22 +400,37 @@ class WizVulnerabilityIntegration(ScannerIntegration):
323
400
  should_fetch_fresh = self._should_fetch_fresh_data(query_configs)
324
401
 
325
402
  # Step 5: Update main progress
326
- self.finding_progress.update(
327
- main_task,
328
- description="[green]✓ Completed all concurrent queries",
329
- completed=len(query_configs),
330
- total=len(query_configs),
331
- )
403
+ # Backwards compatibility: check if finding_progress exists and has update method
404
+ if main_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
405
+ self.finding_progress.update(
406
+ main_task,
407
+ description="[green]✓ Completed all concurrent queries",
408
+ completed=len(query_configs),
409
+ total=len(query_configs),
410
+ )
332
411
 
333
412
  # Step 6: Process results
334
413
  yield from self._process_query_results(results, query_configs, project_id, should_fetch_fresh)
335
414
 
336
- # Step 7: Complete main task
337
- self.finding_progress.advance(main_task, len(query_configs))
415
+ # Step 7: Complete main task - ensure it's marked as 100% complete
416
+ # Backwards compatibility: check if finding_progress exists and has update method
417
+ if main_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
418
+ self.finding_progress.update(
419
+ main_task,
420
+ description="[green]✓ Completed processing all Wiz findings",
421
+ completed=len(query_configs),
422
+ total=len(query_configs),
423
+ )
338
424
 
339
425
  except Exception as e:
340
- logger.error(f"Error in async findings fetch: {str(e)}", exc_info=True)
341
- if "main_task" in locals():
426
+ logger.error(f"Error in async findings fetch: {e!s}", exc_info=True)
427
+ # Backwards compatibility: check if finding_progress exists and has update method
428
+ if (
429
+ "main_task" in locals()
430
+ and main_task is not None
431
+ and self.finding_progress is not None
432
+ and hasattr(self.finding_progress, "update")
433
+ ):
342
434
  self.finding_progress.update(
343
435
  main_task, description=f"[red]✗ Error in concurrent queries: {str(e)[:50]}..."
344
436
  )
@@ -346,6 +438,14 @@ class WizVulnerabilityIntegration(ScannerIntegration):
346
438
  logger.info("Falling back to synchronous query method...")
347
439
  yield from self.fetch_findings_sync(**kwargs)
348
440
 
441
+ # Log summary of missing asset types if any were found
442
+ if hasattr(self, "_missing_asset_types") and self._missing_asset_types:
443
+ logger.warning(
444
+ "Summary: Found references to missing asset types: %s. "
445
+ "Consider adding these to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py",
446
+ ", ".join(sorted(self._missing_asset_types)),
447
+ )
448
+
349
449
  logger.info(
350
450
  "Finished async fetching Wiz findings. Total findings to process: %d", self.num_findings_to_process or 0
351
451
  )
@@ -386,34 +486,28 @@ class WizVulnerabilityIntegration(ScannerIntegration):
386
486
  :return: Results in the same format as async queries
387
487
  :rtype: List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]]
388
488
  """
389
-
390
- results = []
391
- cache_task = self.finding_progress.add_task("[green]Loading cached Wiz data...", total=len(query_configs))
392
-
393
- for config in query_configs:
394
- query_type = config["type"].value
395
- file_path = config.get("file_path")
396
-
397
- try:
398
- if file_path and os.path.exists(file_path):
399
- with open(file_path, "r", encoding="utf-8") as file:
400
- nodes = json.load(file)
401
-
402
- logger.info(f"Loaded {len(nodes)} cached {query_type} findings from {file_path}")
403
- results.append((query_type, nodes, None))
404
- else:
405
- logger.warning(f"No cached data found for {query_type}")
406
- results.append((query_type, [], None))
407
-
408
- except Exception as e:
409
- logger.error(f"Error loading cached data for {query_type}: {e}")
410
- results.append((query_type, [], e))
411
-
412
- self.finding_progress.advance(cache_task, 1)
413
-
414
- self.finding_progress.update(
415
- cache_task, description=f"[green]✓ Loaded cached data for {len(query_configs)} query types"
416
- )
489
+ # Backwards compatibility: check if finding_progress exists and has add_task method
490
+ cache_task = None
491
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
492
+ cache_task = self.finding_progress.add_task("[green]Loading cached Wiz data...", total=len(query_configs))
493
+
494
+ def progress_callback(query_type: str, status: str):
495
+ if status == "loaded":
496
+ # Backwards compatibility: check if finding_progress exists and has advance method
497
+ if (
498
+ cache_task is not None
499
+ and self.finding_progress is not None
500
+ and hasattr(self.finding_progress, "advance")
501
+ ):
502
+ self.finding_progress.advance(cache_task, 1)
503
+
504
+ results = FileOperations.load_cached_findings(query_configs, progress_callback)
505
+
506
+ # Backwards compatibility: check if finding_progress exists and has update method
507
+ if cache_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
508
+ self.finding_progress.update(
509
+ cache_task, description=f"[green]✓ Loaded cached data for {len(query_configs)} query types"
510
+ )
417
511
 
418
512
  return results
419
513
 
@@ -428,21 +522,10 @@ class WizVulnerabilityIntegration(ScannerIntegration):
428
522
  if not file_path:
429
523
  return
430
524
 
431
- try:
432
- from regscale.core.app.utils.app_utils import check_file_path
433
-
434
- # Ensure directory exists
435
- check_file_path(os.path.dirname(file_path))
436
-
437
- # Save data to file
438
- with open(file_path, "w", encoding="utf-8") as file:
439
- json.dump(nodes, file)
440
-
525
+ success = FileOperations.save_json_file(nodes, file_path, create_dir=True)
526
+ if success:
441
527
  logger.debug(f"Saved {len(nodes)} nodes to cache file: {file_path}")
442
528
 
443
- except Exception as e:
444
- logger.warning(f"Failed to save data to cache file {file_path}: {e}")
445
-
446
529
  def fetch_findings_sync(self, **kwargs) -> Iterator[IntegrationFinding]:
447
530
  """
448
531
  Original synchronous method for fetching findings (renamed for fallback)
@@ -450,81 +533,214 @@ class WizVulnerabilityIntegration(ScannerIntegration):
450
533
  :return: Results in the same format as async queries
451
534
  :rtype: Iterator[IntegrationFinding]
452
535
  """
453
- # Use shared validation logic
454
536
  project_id = self._validate_project_id(kwargs.get("wiz_project_id"))
455
-
456
537
  logger.info("Fetching Wiz findings using synchronous queries...")
457
538
  self.num_findings_to_process = 0
458
- query_types = self.get_query_types(project_id=project_id)
459
539
 
460
- # Create detailed progress tracking for each query type
461
- main_task = self.finding_progress.add_task(
462
- "[cyan]Fetching Wiz findings across all query types...", total=len(query_types)
463
- )
540
+ filter_by = kwargs.get("filter_by_override")
541
+ query_types = self.get_query_types(project_id=project_id, filter_by=filter_by)
542
+
543
+ main_task = self._create_main_progress_task(len(query_types))
464
544
 
465
545
  for i, wiz_vulnerability_type in enumerate(query_types, 1):
466
- vulnerability_name = self._get_friendly_vulnerability_name(wiz_vulnerability_type["type"])
546
+ yield from self._process_single_query_type(
547
+ wiz_vulnerability_type, project_id, i, len(query_types), main_task
548
+ )
549
+
550
+ self._complete_main_progress_task(main_task)
551
+ self._log_missing_asset_types_summary()
552
+
553
+ def _create_main_progress_task(self, total_query_types: int):
554
+ """
555
+ Create main progress tracking task.
556
+
557
+ :param int total_query_types: Total number of query types
558
+ :return: Task ID or None
559
+ """
560
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
561
+ return self.finding_progress.add_task(
562
+ "[cyan]Fetching Wiz findings across all query types...", total=total_query_types
563
+ )
564
+ return None
565
+
566
+ def _process_single_query_type(
567
+ self, wiz_vulnerability_type: dict, project_id: str, step: int, total_steps: int, main_task
568
+ ) -> Iterator[IntegrationFinding]:
569
+ """
570
+ Process a single query type and yield findings.
571
+
572
+ :param dict wiz_vulnerability_type: Query type configuration
573
+ :param str project_id: Project ID for filtering
574
+ :param int step: Current step number
575
+ :param int total_steps: Total number of steps
576
+ :param main_task: Main progress task ID
577
+ :yield: IntegrationFinding objects
578
+ :rtype: Iterator[IntegrationFinding]
579
+ """
580
+ vulnerability_name = self._get_friendly_vulnerability_name(wiz_vulnerability_type["type"])
581
+ self._update_main_progress_description(main_task, step, total_steps, vulnerability_name)
467
582
 
468
- # Update main progress with current query type
583
+ query_task = self._create_query_task(vulnerability_name)
584
+ nodes = self._fetch_query_data(wiz_vulnerability_type, vulnerability_name, query_task)
585
+ nodes = self._apply_query_filtering(wiz_vulnerability_type, nodes, project_id, vulnerability_name)
586
+
587
+ if nodes:
588
+ yield from self._parse_query_results(nodes, wiz_vulnerability_type["type"], vulnerability_name)
589
+
590
+ self._complete_query_tasks(query_task, main_task)
591
+
592
+ def _update_main_progress_description(
593
+ self, main_task, step: int, total_steps: int, vulnerability_name: str
594
+ ) -> None:
595
+ """
596
+ Update main progress task description.
597
+
598
+ :param main_task: Main task ID
599
+ :param int step: Current step
600
+ :param int total_steps: Total steps
601
+ :param str vulnerability_name: Vulnerability name
602
+ :rtype: None
603
+ """
604
+ if main_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
469
605
  self.finding_progress.update(
470
- main_task, description=f"[cyan]Step {i}/{len(query_types)}: Fetching {vulnerability_name}..."
606
+ main_task, description=f"[cyan]Step {step}/{total_steps}: Fetching {vulnerability_name}..."
471
607
  )
472
608
 
473
- # Create subtask for this specific query type
474
- query_task = self.finding_progress.add_task(
475
- f"[yellow]Querying Wiz API for {vulnerability_name}...", total=None # Indeterminate while fetching
609
+ def _create_query_task(self, vulnerability_name: str):
610
+ """
611
+ Create query-specific progress task.
612
+
613
+ :param str vulnerability_name: Vulnerability name
614
+ :return: Task ID or None
615
+ """
616
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
617
+ return self.finding_progress.add_task(f"[yellow]Querying Wiz API for {vulnerability_name}...", total=None)
618
+ return None
619
+
620
+ def _fetch_query_data(self, wiz_vulnerability_type: dict, vulnerability_name: str, query_task) -> list:
621
+ """
622
+ Fetch data for a single query type.
623
+
624
+ :param dict wiz_vulnerability_type: Query type configuration
625
+ :param str vulnerability_name: Vulnerability name
626
+ :param query_task: Query task ID
627
+ :return: List of nodes
628
+ :rtype: list
629
+ """
630
+ variables = wiz_vulnerability_type.get("variables", self.get_variables())
631
+ nodes = self.fetch_wiz_data_if_needed(
632
+ query=wiz_vulnerability_type["query"],
633
+ variables=variables,
634
+ topic_key=wiz_vulnerability_type["topic_key"],
635
+ file_path=wiz_vulnerability_type["file_path"],
636
+ )
637
+
638
+ if query_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
639
+ self.finding_progress.update(
640
+ query_task, description=f"[green]✓ Fetched {len(nodes)} {vulnerability_name} from Wiz API"
476
641
  )
477
642
 
478
- # Use the variables from the query type configuration
479
- variables = wiz_vulnerability_type.get("variables", self.get_variables())
643
+ return nodes
480
644
 
481
- nodes = self.fetch_wiz_data_if_needed(
482
- query=wiz_vulnerability_type["query"],
483
- variables=variables,
484
- topic_key=wiz_vulnerability_type["topic_key"],
485
- file_path=wiz_vulnerability_type["file_path"],
645
+ def _apply_query_filtering(
646
+ self, wiz_vulnerability_type: dict, nodes: list, project_id: str, vulnerability_name: str
647
+ ) -> list:
648
+ """
649
+ Apply project filtering if needed for the query type.
650
+
651
+ :param dict wiz_vulnerability_type: Query type configuration
652
+ :param list nodes: Nodes to filter
653
+ :param str project_id: Project ID
654
+ :param str vulnerability_name: Vulnerability name
655
+ :return: Filtered nodes
656
+ :rtype: list
657
+ """
658
+ if wiz_vulnerability_type["type"] not in [
659
+ WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING,
660
+ WizVulnerabilityType.NETWORK_EXPOSURE_FINDING,
661
+ WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE,
662
+ ]:
663
+ return nodes
664
+
665
+ filter_task = None
666
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
667
+ filter_task = self.finding_progress.add_task(
668
+ f"[blue]Filtering {vulnerability_name} by project...", total=len(nodes)
486
669
  )
487
670
 
488
- # Update query task to show data fetched
671
+ nodes = self._filter_findings_by_project_with_progress(nodes, project_id, filter_task)
672
+
673
+ if filter_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
489
674
  self.finding_progress.update(
490
- query_task, description=f"[green]✓ Fetched {len(nodes)} {vulnerability_name} from Wiz API"
675
+ filter_task, description=f"[green]✓ Filtered to {len(nodes)} {vulnerability_name} for project"
491
676
  )
492
677
 
493
- # Filter findings by project for queries that don't support API-level project filtering
494
- if wiz_vulnerability_type["type"] in [
495
- WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING,
496
- WizVulnerabilityType.NETWORK_EXPOSURE_FINDING,
497
- WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE,
498
- ]:
499
- filter_task = self.finding_progress.add_task(
500
- f"[blue]Filtering {vulnerability_name} by project...", total=len(nodes)
501
- )
502
- nodes = self._filter_findings_by_project_with_progress(nodes, project_id, filter_task)
503
- self.finding_progress.update(
504
- filter_task, description=f"[green]✓ Filtered to {len(nodes)} {vulnerability_name} for project"
505
- )
678
+ return nodes
506
679
 
507
- # Create parsing task
508
- if nodes:
509
- parse_task = self.finding_progress.add_task(
510
- f"[magenta]Parsing {len(nodes)} {vulnerability_name}...", total=len(nodes)
511
- )
680
+ def _parse_query_results(
681
+ self, nodes: list, vulnerability_type, vulnerability_name: str
682
+ ) -> Iterator[IntegrationFinding]:
683
+ """
684
+ Parse nodes and yield findings.
512
685
 
513
- yield from self.parse_findings_with_progress(nodes, wiz_vulnerability_type["type"], parse_task)
686
+ :param list nodes: Nodes to parse
687
+ :param vulnerability_type: Vulnerability type
688
+ :param str vulnerability_name: Vulnerability name
689
+ :yield: IntegrationFinding objects
690
+ :rtype: Iterator[IntegrationFinding]
691
+ """
692
+ parse_task = None
693
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
694
+ parse_task = self.finding_progress.add_task(
695
+ f"[magenta]Parsing {len(nodes)} {vulnerability_name}...", total=len(nodes)
696
+ )
514
697
 
515
- self.finding_progress.update(
516
- parse_task, description=f"[green]✓ Parsed {len(nodes)} {vulnerability_name} successfully"
517
- )
698
+ yield from self.parse_findings_with_progress(nodes, vulnerability_type, parse_task)
518
699
 
519
- # Mark query complete and advance main progress
700
+ if parse_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
701
+ self.finding_progress.update(
702
+ parse_task, description=f"[green]✓ Parsed {len(nodes)} {vulnerability_name} successfully"
703
+ )
704
+
705
+ def _complete_query_tasks(self, query_task, main_task) -> None:
706
+ """
707
+ Mark query and main tasks as progressing.
708
+
709
+ :param query_task: Query task ID
710
+ :param main_task: Main task ID
711
+ :rtype: None
712
+ """
713
+ if query_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
520
714
  self.finding_progress.update(query_task, completed=1, total=1)
715
+
716
+ if main_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "advance"):
521
717
  self.finding_progress.advance(main_task, 1)
522
718
 
523
- # Update main task completion
524
- self.finding_progress.update(
525
- main_task,
526
- description=f"[green]✓ Completed fetching all Wiz findings ({self.num_findings_to_process or 0} total)",
527
- )
719
+ def _complete_main_progress_task(self, main_task) -> None:
720
+ """
721
+ Complete main progress task with final message.
722
+
723
+ :param main_task: Main task ID
724
+ :rtype: None
725
+ """
726
+ if main_task is not None and self.finding_progress is not None and hasattr(self.finding_progress, "update"):
727
+ self.finding_progress.update(
728
+ main_task,
729
+ description=f"[green]✓ Completed fetching all Wiz findings ({self.num_findings_to_process or 0} total)",
730
+ )
731
+
732
+ def _log_missing_asset_types_summary(self) -> None:
733
+ """
734
+ Log summary of missing asset types if any were found.
735
+
736
+ :rtype: None
737
+ """
738
+ if hasattr(self, "_missing_asset_types") and self._missing_asset_types:
739
+ logger.warning(
740
+ "Summary: Found references to missing asset types: %s. "
741
+ "Consider adding these to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py",
742
+ ", ".join(sorted(self._missing_asset_types)),
743
+ )
528
744
 
529
745
  logger.info(
530
746
  "Finished synchronous fetching Wiz findings. Total findings to process: %d",
@@ -574,7 +790,8 @@ class WizVulnerabilityIntegration(ScannerIntegration):
574
790
  yield finding
575
791
 
576
792
  # Update progress if task_id provided
577
- if task_id is not None:
793
+ # Backwards compatibility: check if finding_progress exists and has advance method
794
+ if task_id is not None and self.finding_progress is not None and hasattr(self.finding_progress, "advance"):
578
795
  self.finding_progress.advance(task_id, 1)
579
796
 
580
797
  # Log parsing results for this type
@@ -612,7 +829,8 @@ class WizVulnerabilityIntegration(ScannerIntegration):
612
829
  filtered_nodes.append(node)
613
830
 
614
831
  # Update progress if task_id provided
615
- if task_id is not None:
832
+ # Backwards compatibility: check if finding_progress exists and has advance method
833
+ if task_id is not None and self.finding_progress is not None and hasattr(self.finding_progress, "advance"):
616
834
  self.finding_progress.advance(task_id, 1)
617
835
 
618
836
  filtered_count = len(filtered_nodes)
@@ -660,16 +878,233 @@ class WizVulnerabilityIntegration(ScannerIntegration):
660
878
  ) -> Iterator[IntegrationFinding]:
661
879
  """
662
880
  Parses a list of Wiz finding nodes into IntegrationFinding objects.
663
-
664
- This is a compatibility wrapper that calls the progress-aware version.
881
+ Groups findings by rule and scope for consolidation when appropriate.
665
882
 
666
883
  :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
667
884
  :param WizVulnerabilityType vulnerability_type: The type of vulnerability
668
885
  :yield: IntegrationFinding objects
669
886
  :rtype: Iterator[IntegrationFinding]
670
887
  """
671
- # Delegate to the progress-aware version without progress tracking
672
- yield from self.parse_findings_with_progress(nodes, vulnerability_type, task_id=None)
888
+ logger.debug(f"VULNERABILITY PROCESSING ANALYSIS: Received {len(nodes)} raw Wiz vulnerabilities for processing")
889
+
890
+ # Count issues by severity for analysis
891
+ severity_counts: dict[str, int] = {}
892
+ status_counts: dict[str, int] = {}
893
+ for node in nodes:
894
+ severity = node.get("severity", "Low")
895
+ status = node.get("status", "OPEN")
896
+ severity_counts[severity] = severity_counts.get(severity, 0) + 1
897
+ status_counts[status] = status_counts.get(status, 0) + 1
898
+
899
+ logger.debug(f"Raw vulnerability breakdown by severity: {severity_counts}")
900
+ logger.debug(f"Raw vulnerability breakdown by status: {status_counts}")
901
+
902
+ # Filter nodes by minimum severity configuration
903
+ filtered_nodes = []
904
+ filtered_out_count = 0
905
+ for node in nodes:
906
+ wiz_severity = node.get("severity", "Low")
907
+ wiz_id = node.get("id", "unknown")
908
+
909
+ # Log sample record for NONE severity (only first occurrence per session)
910
+ if wiz_severity and wiz_severity.upper() == "NONE":
911
+ if not hasattr(self, "_none_severity_sample_logged"):
912
+ logger.info(
913
+ f"SAMPLE RECORD - Vulnerability with NONE severity (treating as informational): "
914
+ f"ID={node.get('id', 'Unknown')}, "
915
+ f"Name={node.get('name', 'Unknown')}, "
916
+ f"Type={node.get('type', 'Unknown')}, "
917
+ f"Severity={wiz_severity}"
918
+ )
919
+ self._none_severity_sample_logged = True
920
+
921
+ if self.should_process_finding_by_severity(wiz_severity):
922
+ filtered_nodes.append(node)
923
+ else:
924
+ filtered_out_count += 1
925
+ logger.debug(
926
+ f"FILTERED BY SEVERITY: Vulnerability {wiz_id} with severity '{wiz_severity}' "
927
+ f"filtered due to minimumSeverity configuration"
928
+ )
929
+
930
+ logger.info(
931
+ f"After severity filtering: {len(filtered_nodes)} vulnerabilities kept, {filtered_out_count} filtered out"
932
+ )
933
+
934
+ if not filtered_nodes:
935
+ logger.warning(
936
+ "All vulnerabilities filtered out by severity configuration - check your minimumSeverity setting"
937
+ )
938
+ return
939
+
940
+ # Apply consolidation logic for findings that support it
941
+ if self._should_apply_consolidation(vulnerability_type):
942
+ yield from self._parse_findings_with_consolidation(filtered_nodes, vulnerability_type)
943
+ else:
944
+ # Use original parsing for vulnerability types that shouldn't be consolidated
945
+ yield from self.parse_findings_with_progress(filtered_nodes, vulnerability_type, task_id=None)
946
+
947
+ def _should_apply_consolidation(self, vulnerability_type: WizVulnerabilityType) -> bool:
948
+ """
949
+ Determine if consolidation should be applied for this vulnerability type.
950
+
951
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
952
+ :return: True if consolidation should be applied
953
+ :rtype: bool
954
+ """
955
+ # Apply consolidation to finding types that commonly affect multiple assets
956
+ consolidation_types = {
957
+ WizVulnerabilityType.HOST_FINDING,
958
+ WizVulnerabilityType.DATA_FINDING,
959
+ WizVulnerabilityType.VULNERABILITY,
960
+ }
961
+ return vulnerability_type in consolidation_types
962
+
963
+ def _parse_findings_with_consolidation(
964
+ self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
965
+ ) -> Iterator[IntegrationFinding]:
966
+ """
967
+ Parse findings with consolidation logic applied.
968
+
969
+ :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
970
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
971
+ :yield: Consolidated IntegrationFinding objects
972
+ :rtype: Iterator[IntegrationFinding]
973
+ """
974
+ # Group nodes for potential consolidation
975
+ grouped_nodes = self._group_findings_for_consolidation(nodes)
976
+
977
+ # Process each group
978
+ for group_key, group_nodes in grouped_nodes.items():
979
+ if len(group_nodes) > 1:
980
+ # Multiple nodes with same rule - attempt consolidation
981
+ if consolidated_finding := self._create_consolidated_scanner_finding(group_nodes, vulnerability_type):
982
+ yield consolidated_finding
983
+ else:
984
+ # Single node - process normally
985
+ if finding := self.parse_finding(group_nodes[0], vulnerability_type):
986
+ yield finding
987
+
988
+ def _group_findings_for_consolidation(self, nodes: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
989
+ """
990
+ Group findings by rule and appropriate scope for consolidation.
991
+ - Database findings: group by server
992
+ - App Configuration findings: group by resource group
993
+ - Other findings: group by full resource path
994
+
995
+ :param List[Dict[str, Any]] nodes: List of Wiz finding nodes
996
+ :return: Dictionary mapping group keys to lists of nodes
997
+ :rtype: Dict[str, List[Dict[str, Any]]]
998
+ """
999
+ groups = {}
1000
+
1001
+ for node in nodes:
1002
+ # Create a grouping key based on rule and appropriate scope
1003
+ rule_name = self._get_rule_name_from_node(node)
1004
+ provider_id = self._get_provider_id_from_node(node)
1005
+
1006
+ # Determine the appropriate grouping scope based on resource type
1007
+ grouping_scope = self._determine_grouping_scope(provider_id, rule_name)
1008
+
1009
+ # Group key combines rule name and scope
1010
+ group_key = f"{rule_name}|{grouping_scope}"
1011
+
1012
+ if group_key not in groups:
1013
+ groups[group_key] = []
1014
+ groups[group_key].append(node)
1015
+
1016
+ return groups
1017
+
1018
+ def _get_rule_name_from_node(self, node: Dict[str, Any]) -> str:
1019
+ """Get rule name from various node structures."""
1020
+ # Try different ways to get rule name
1021
+ if source_rule := node.get("sourceRule"):
1022
+ return source_rule.get("name", "")
1023
+ return node.get("name", node.get("title", ""))
1024
+
1025
+ def _get_provider_id_from_node(self, node: Dict[str, Any]) -> str:
1026
+ """Get provider ID from various node structures."""
1027
+ # Try different ways to get provider ID
1028
+ if entity_snapshot := node.get("entitySnapshot"):
1029
+ return entity_snapshot.get("providerId", "")
1030
+
1031
+ # Try other asset lookup patterns
1032
+ asset_fields = ["vulnerableAsset", "entity", "resource", "relatedEntity", "sourceEntity", "target"]
1033
+ for field in asset_fields:
1034
+ if asset_obj := node.get(field):
1035
+ if provider_id := asset_obj.get("providerId"):
1036
+ return provider_id
1037
+ # For vulnerability nodes, use asset ID if providerId is not available
1038
+ if field == "vulnerableAsset" and (asset_id := asset_obj.get("id")):
1039
+ return asset_id
1040
+
1041
+ return ""
1042
+
1043
+ def _determine_grouping_scope(self, provider_id: str, rule_name: str) -> str:
1044
+ """
1045
+ Determine the appropriate grouping scope for consolidation.
1046
+
1047
+ :param str provider_id: The provider ID
1048
+ :param str rule_name: The rule name
1049
+ :return: The grouping scope (server, resource group, or full path)
1050
+ :rtype: str
1051
+ """
1052
+ # For database issues, group by server
1053
+ if "/databases/" in provider_id:
1054
+ return provider_id.split("/databases/")[0]
1055
+
1056
+ # For App Configuration issues, group by resource group to consolidate multiple stores
1057
+ if (
1058
+ "app configuration" in rule_name.lower()
1059
+ and "/microsoft.appconfiguration/configurationstores/" in provider_id
1060
+ ):
1061
+ # Extract resource group path: /subscriptions/.../resourcegroups/rg_name
1062
+ parts = provider_id.split("/resourcegroups/")
1063
+ if len(parts) >= 2:
1064
+ rg_part = parts[1].split("/")[0] # Get just the resource group name
1065
+ return f"{parts[0]}/resourcegroups/{rg_part}"
1066
+
1067
+ # For other resources, use the full provider path (no consolidation)
1068
+ return provider_id
1069
+
1070
+ def _create_consolidated_scanner_finding(
1071
+ self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
1072
+ ) -> Optional[IntegrationFinding]:
1073
+ """
1074
+ Create a consolidated finding from multiple nodes with the same rule.
1075
+
1076
+ :param List[Dict[str, Any]] nodes: List of nodes to consolidate
1077
+ :param WizVulnerabilityType vulnerability_type: The vulnerability type
1078
+ :return: Consolidated IntegrationFinding or None
1079
+ :rtype: Optional[IntegrationFinding]
1080
+ """
1081
+ # Use the first node as the base
1082
+ base_node = nodes[0]
1083
+
1084
+ # Collect all asset identifiers and provider IDs
1085
+ asset_ids = []
1086
+ provider_ids = []
1087
+
1088
+ for node in nodes:
1089
+ if asset_id := self.get_asset_id_from_node(node, vulnerability_type):
1090
+ asset_ids.append(asset_id)
1091
+ if provider_id := self.get_provider_unique_id_from_node(node, vulnerability_type):
1092
+ provider_ids.append(provider_id)
1093
+
1094
+ # If we couldn't extract asset info, fall back to normal parsing
1095
+ if not asset_ids:
1096
+ return self.parse_finding(base_node, vulnerability_type)
1097
+
1098
+ # Create the finding using normal parsing, then override asset identifiers
1099
+ base_finding = self.parse_finding(base_node, vulnerability_type)
1100
+ if not base_finding:
1101
+ return None
1102
+
1103
+ # Override with consolidated asset information
1104
+ base_finding.asset_identifier = asset_ids[0] # Use first asset as primary
1105
+ base_finding.issue_asset_identifier_value = "\n".join(provider_ids) if provider_ids else None
1106
+
1107
+ return base_finding
673
1108
 
674
1109
  @classmethod
675
1110
  def get_issue_severity(cls, severity: str) -> regscale_models.IssueSeverity:
@@ -682,6 +1117,44 @@ class WizVulnerabilityIntegration(ScannerIntegration):
682
1117
  """
683
1118
  return cls.finding_severity_map.get(severity.capitalize(), regscale_models.IssueSeverity.Low)
684
1119
 
1120
+ def should_process_finding_by_severity(self, wiz_severity: str) -> bool:
1121
+ """
1122
+ Check if finding should be processed based on minimum severity configuration.
1123
+
1124
+ :param str wiz_severity: The Wiz severity level (e.g., "INFORMATIONAL", "Low", "Medium", etc.)
1125
+ :return: True if finding should be processed, False if it should be filtered out
1126
+ :rtype: bool
1127
+ """
1128
+ # Get minimum severity from configuration, default to "low"
1129
+ min_severity = self.app.config.get("scanners", {}).get("wiz", {}).get("minimumSeverity", "low").lower()
1130
+
1131
+ # Log the configuration being used (only once to avoid spam)
1132
+ if not hasattr(self, "_severity_config_logged"):
1133
+ logger.debug(f"SEVERITY FILTER CONFIG: minimumSeverity = '{min_severity}'")
1134
+ self._severity_config_logged = True
1135
+
1136
+ # Define severity hierarchy (lower index = higher severity)
1137
+ # Note: "info", "informational", and "none" are all treated as informational
1138
+ severity_hierarchy = ["critical", "high", "medium", "low", "informational", "info", "none"]
1139
+
1140
+ try:
1141
+ wiz_severity_lower = wiz_severity.lower()
1142
+
1143
+ # Handle empty or None severity values - treat as informational
1144
+ # Normalize "info" to "informational" for consistent processing
1145
+ if not wiz_severity_lower or wiz_severity_lower == "none" or wiz_severity_lower == "info":
1146
+ wiz_severity_lower = "informational"
1147
+
1148
+ min_severity_index = severity_hierarchy.index(min_severity)
1149
+ finding_severity_index = severity_hierarchy.index(wiz_severity_lower)
1150
+
1151
+ # Process if finding severity is equal or higher (lower index) than minimum
1152
+ return finding_severity_index <= min_severity_index
1153
+ except ValueError:
1154
+ # If severity not found in hierarchy, default to processing it
1155
+ logger.warning(f"Unknown severity level: {wiz_severity}, processing anyway")
1156
+ return True
1157
+
685
1158
  def process_comments(self, comments_dict: Dict) -> Optional[str]:
686
1159
  """
687
1160
  Processes comments from Wiz findings to match RegScale's comment format.
@@ -694,7 +1167,8 @@ class WizVulnerabilityIntegration(ScannerIntegration):
694
1167
 
695
1168
  if comments := comments_dict.get("comments", {}).get("edges", []):
696
1169
  formatted_comments = [
697
- f"{edge.get('node', {}).get('author', {}).get('name', 'Unknown')}: {edge.get('node', {}).get('body', 'No comment')}"
1170
+ f"{edge.get('node', {}).get('author', {}).get('name', 'Unknown')}: "
1171
+ f"{edge.get('node', {}).get('body', 'No comment')}"
698
1172
  for edge in comments
699
1173
  ]
700
1174
  # Join with newlines
@@ -712,10 +1186,10 @@ class WizVulnerabilityIntegration(ScannerIntegration):
712
1186
  """
713
1187
  # Define asset lookup patterns for different vulnerability types
714
1188
  asset_lookup_patterns = {
715
- # WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
716
- # WizVulnerabilityType.CONFIGURATION: "resource",
717
- # WizVulnerabilityType.HOST_FINDING: "resource",
718
- # WizVulnerabilityType.DATA_FINDING: "resource",
1189
+ WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
1190
+ WizVulnerabilityType.CONFIGURATION: "resource",
1191
+ WizVulnerabilityType.HOST_FINDING: "resource",
1192
+ WizVulnerabilityType.DATA_FINDING: "resource",
719
1193
  WizVulnerabilityType.SECRET_FINDING: "resource",
720
1194
  WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "exposedEntity",
721
1195
  WizVulnerabilityType.END_OF_LIFE_FINDING: "vulnerableAsset",
@@ -733,8 +1207,81 @@ class WizVulnerabilityIntegration(ScannerIntegration):
733
1207
  return graph_entity.get("id")
734
1208
 
735
1209
  # Standard case - direct id access
736
- asset_container = node.get(asset_lookup_key, {})
737
- return asset_container.get("id")
1210
+ asset_container = node.get(asset_lookup_key) or {}
1211
+ asset_id = asset_container.get("id") if isinstance(asset_container, dict) else None
1212
+
1213
+ # Add debug logging to help diagnose missing assets
1214
+ if not asset_id:
1215
+ logger.debug(
1216
+ f"No asset ID found for {vulnerability_type.value} using key '{asset_lookup_key}'. "
1217
+ f"Available keys in node: {list(node.keys())}"
1218
+ )
1219
+ # Try alternative lookup patterns as fallback
1220
+ fallback_keys = ["vulnerableAsset", "resource", "exposedEntity", "entitySnapshot"]
1221
+ for fallback_key in fallback_keys:
1222
+ if fallback_key != asset_lookup_key and fallback_key in node:
1223
+ fallback_asset = node.get(fallback_key) or {}
1224
+ if isinstance(fallback_asset, dict) and (fallback_id := fallback_asset.get("id")):
1225
+ logger.debug(
1226
+ f"Found asset ID using fallback key '{fallback_key}' for {vulnerability_type.value}"
1227
+ )
1228
+ return fallback_id
1229
+
1230
+ return asset_id
1231
+
1232
+ def get_provider_unique_id_from_node(
1233
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1234
+ ) -> Optional[str]:
1235
+ """
1236
+ Get the providerUniqueId from a node based on the vulnerability type.
1237
+ This provides more meaningful asset identification for eMASS exports.
1238
+
1239
+ :param Dict[str, Any] node: The Wiz finding node
1240
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1241
+ :return: The providerUniqueId or fallback to asset name/ID
1242
+ :rtype: Optional[str]
1243
+ """
1244
+ # Define asset lookup patterns for different vulnerability types - aligned with get_asset_id_from_node
1245
+ asset_lookup_patterns = {
1246
+ WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
1247
+ WizVulnerabilityType.CONFIGURATION: "resource",
1248
+ WizVulnerabilityType.HOST_FINDING: "resource",
1249
+ WizVulnerabilityType.DATA_FINDING: "resource",
1250
+ WizVulnerabilityType.SECRET_FINDING: "resource",
1251
+ WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "exposedEntity",
1252
+ WizVulnerabilityType.END_OF_LIFE_FINDING: "vulnerableAsset",
1253
+ WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE: "exposedEntity",
1254
+ WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING: "scope",
1255
+ WizVulnerabilityType.ISSUE: "entitySnapshot",
1256
+ }
1257
+
1258
+ asset_lookup_key = asset_lookup_patterns.get(vulnerability_type, "entitySnapshot")
1259
+
1260
+ if asset_lookup_key == "scope":
1261
+ # Handle special case for excessive access findings where ID is nested
1262
+ scope = node.get("scope", {})
1263
+ graph_entity = scope.get("graphEntity", {})
1264
+ # Try providerUniqueId first, fallback to name, then id
1265
+ return graph_entity.get("providerUniqueId") or graph_entity.get("name") or graph_entity.get("id")
1266
+
1267
+ # Standard case - get asset container and extract provider identifier
1268
+ asset_container = node.get(asset_lookup_key) or {}
1269
+
1270
+ # Ensure asset_container is a dict before accessing
1271
+ if not isinstance(asset_container, dict):
1272
+ return None
1273
+
1274
+ # For Issue queries, the field is called 'providerId' instead of 'providerUniqueId'
1275
+ if vulnerability_type == WizVulnerabilityType.ISSUE:
1276
+ return (
1277
+ asset_container.get("providerId")
1278
+ or asset_container.get("providerUniqueId")
1279
+ or asset_container.get("name")
1280
+ or asset_container.get("id")
1281
+ )
1282
+
1283
+ # For other queries, try providerUniqueId first
1284
+ return asset_container.get("providerUniqueId") or asset_container.get("name") or asset_container.get("id")
738
1285
 
739
1286
  def parse_finding(
740
1287
  self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
@@ -751,43 +1298,30 @@ class WizVulnerabilityIntegration(ScannerIntegration):
751
1298
  # Route to specific parsing method based on vulnerability type
752
1299
  if vulnerability_type == WizVulnerabilityType.SECRET_FINDING:
753
1300
  return self._parse_secret_finding(node)
754
- elif vulnerability_type == WizVulnerabilityType.NETWORK_EXPOSURE_FINDING:
1301
+ if vulnerability_type == WizVulnerabilityType.NETWORK_EXPOSURE_FINDING:
755
1302
  return self._parse_network_exposure_finding(node)
756
- elif vulnerability_type == WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE:
1303
+ if vulnerability_type == WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE:
757
1304
  return self._parse_external_attack_surface_finding(node)
758
- elif vulnerability_type == WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING:
1305
+ if vulnerability_type == WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING:
759
1306
  return self._parse_excessive_access_finding(node)
760
- elif vulnerability_type == WizVulnerabilityType.END_OF_LIFE_FINDING:
1307
+ if vulnerability_type == WizVulnerabilityType.END_OF_LIFE_FINDING:
761
1308
  return self._parse_end_of_life_finding(node)
762
- else:
763
- # Fallback to generic parsing for any other types
764
- return self._parse_generic_finding(node, vulnerability_type)
1309
+ # Fallback to generic parsing for any other types
1310
+ return self._parse_generic_finding(node, vulnerability_type)
765
1311
  except (KeyError, TypeError, ValueError) as e:
766
1312
  logger.error("Error parsing Wiz finding: %s", str(e), exc_info=True)
767
1313
  return None
768
1314
 
769
- def _parse_secret_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1315
+ def _get_secret_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
770
1316
  """
771
- Parse secret finding from Wiz.
1317
+ Extract data specific to secret findings.
772
1318
 
773
1319
  :param Dict[str, Any] node: The Wiz finding node to parse
774
- :return: The parsed IntegrationFinding or None if parsing fails
775
- :rtype: Optional[IntegrationFinding]
1320
+ :return: Dictionary containing secret-specific data
1321
+ :rtype: Dict[str, Any]
776
1322
  """
777
- asset_id = node.get("resource", {}).get("id")
778
- if not asset_id:
779
- return None
780
-
781
- first_seen = node.get("firstSeenAt") or get_current_datetime()
782
- first_seen = format_to_regscale_iso(first_seen)
783
- severity = self.get_issue_severity(node.get("severity", "Low"))
784
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
785
-
786
- # Create meaningful title for secret findings
787
1323
  secret_type = node.get("type", "Unknown Secret")
788
1324
  resource_name = node.get("resource", {}).get("name", "Unknown Resource")
789
- title = f"Secret Detected: {secret_type} in {resource_name}"
790
-
791
1325
  # Build description with secret details
792
1326
  description_parts = [
793
1327
  f"Secret type: {secret_type}",
@@ -799,52 +1333,152 @@ class WizVulnerabilityIntegration(ScannerIntegration):
799
1333
  if rule := node.get("rule", {}):
800
1334
  description_parts.append(f"Detection rule: {rule.get('name', 'Unknown')}")
801
1335
 
802
- description = "\n".join(description_parts)
803
-
804
- return IntegrationFinding(
805
- control_labels=[],
806
- category="Wiz Secret Detection",
807
- title=title,
808
- description=description,
809
- severity=severity,
810
- status=self.map_status_to_issue_status(node.get("status", "Open")),
811
- asset_identifier=asset_id,
812
- external_id=node.get("id"),
813
- first_seen=first_seen,
814
- date_created=first_seen,
815
- last_seen=format_to_regscale_iso(node.get("lastSeenAt") or get_current_datetime()),
816
- remediation=f"Remove or properly secure the {secret_type} secret found in {resource_name}",
817
- plugin_name=f"Wiz Secret Detection - {secret_type}",
818
- vulnerability_type=WizVulnerabilityType.SECRET_FINDING.value,
819
- due_date=due_date,
820
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
821
- identification="Secret Scanning",
822
- )
1336
+ return {
1337
+ "category": "Wiz Secret Detection",
1338
+ "title": f"Secret Detected: {secret_type} in {resource_name}",
1339
+ "description": "\n".join(description_parts),
1340
+ "remediation": f"Remove or properly secure the {secret_type} secret found in {resource_name}",
1341
+ "plugin_name": f"Wiz Secret Detection - {secret_type}",
1342
+ "identification": "Secret Scanning",
1343
+ }
823
1344
 
824
- def _parse_network_exposure_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
825
- """Parse network exposure finding from Wiz.
1345
+ def _parse_secret_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1346
+ """
1347
+ Parse secret finding from Wiz.
1348
+
1349
+ :param Dict[str, Any] node: The Wiz finding node to parse
1350
+ :return: The parsed IntegrationFinding or None if parsing fails
1351
+ :rtype: Optional[IntegrationFinding]
1352
+ """
1353
+ finding_data = self._get_secret_finding_data(node)
1354
+ return self._create_integration_finding(node, WizVulnerabilityType.SECRET_FINDING, finding_data)
1355
+
1356
+ def _create_integration_finding(
1357
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType, finding_data: Dict[str, Any]
1358
+ ) -> Optional[IntegrationFinding]:
1359
+ """
1360
+ Unified method to create IntegrationFinding objects from Wiz data.
826
1361
 
827
1362
  :param Dict[str, Any] node: The Wiz finding node to parse
1363
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1364
+ :param Dict[str, Any] finding_data: Finding-specific data (title, description, etc.)
828
1365
  :return: The parsed IntegrationFinding or None if parsing fails
829
1366
  :rtype: Optional[IntegrationFinding]
830
1367
  """
831
- asset_id = node.get("exposedEntity", {}).get("id")
1368
+ # Get asset identifier
1369
+ asset_id = self.get_asset_id_from_node(node, vulnerability_type)
832
1370
  if not asset_id:
1371
+ logger.debug(
1372
+ f"Skipping {vulnerability_type.value} finding '{node.get('name', 'Unknown')}' "
1373
+ f"(ID: {node.get('id', 'Unknown')}) - no asset identifier found"
1374
+ )
833
1375
  return None
834
1376
 
835
- first_seen = node.get("firstSeenAt") or get_current_datetime()
836
- first_seen = format_to_regscale_iso(first_seen)
1377
+ # Get meaningful asset identifier for eMASS exports
1378
+ provider_unique_id = self.get_provider_unique_id_from_node(node, vulnerability_type)
837
1379
 
838
- # Network exposures typically don't have explicit severity, assume Medium
839
- severity = regscale_models.IssueSeverity.Moderate
1380
+ # Parse dates
1381
+ first_seen = self._get_first_seen_date(node)
1382
+ last_seen = self._get_last_seen_date(node, first_seen)
1383
+ # Get severity and calculate due date
1384
+ severity = self.get_issue_severity(finding_data.get("severity") or node.get("severity", "Low"))
840
1385
  due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
841
1386
 
842
- # Create meaningful title for network exposure
1387
+ # Get status with diagnostic logging
1388
+ wiz_status = node.get("status", "Open")
1389
+ logger.debug(f"Processing Wiz finding {node.get('id', 'Unknown')}: raw status from node = '{wiz_status}'")
1390
+ status = self.map_status_to_issue_status(wiz_status)
1391
+
1392
+ # Add diagnostic logging for unexpected issue closure
1393
+ if status == regscale_models.IssueStatus.Closed and wiz_status.upper() not in ["RESOLVED", "REJECTED"]:
1394
+ logger.warning(
1395
+ f"Unexpected issue closure: Wiz status '{wiz_status}' mapped to Closed status "
1396
+ f"for finding {node.get('id', 'Unknown')} - '{finding_data.get('title', 'Unknown')}'. "
1397
+ f"This may indicate a mapping configuration issue."
1398
+ )
1399
+
1400
+ # Process comments if available
1401
+ comments_dict = node.get("commentThread", {})
1402
+ formatted_comments = self.process_comments(comments_dict) if comments_dict else None
1403
+
1404
+ # Build IntegrationFinding with unified data structure
1405
+ integration_finding_data = {
1406
+ "control_labels": [],
1407
+ "category": finding_data.get("category", "Wiz Vulnerability"),
1408
+ "title": finding_data.get("title", node.get("name", "Unknown vulnerability")),
1409
+ "description": finding_data.get("description", node.get("description", "")),
1410
+ "severity": severity,
1411
+ "status": status,
1412
+ "asset_identifier": asset_id,
1413
+ "issue_asset_identifier_value": provider_unique_id,
1414
+ "external_id": finding_data.get("external_id", node.get("id")),
1415
+ "first_seen": first_seen,
1416
+ "date_created": first_seen,
1417
+ "last_seen": last_seen,
1418
+ "remediation": finding_data.get("remediation", node.get("description", "")),
1419
+ "plugin_name": finding_data.get("plugin_name", node.get("name", "Unknown")),
1420
+ "vulnerability_type": vulnerability_type.value,
1421
+ "due_date": due_date,
1422
+ "date_last_updated": format_to_regscale_iso(get_current_datetime()),
1423
+ "identification": finding_data.get("identification", "Vulnerability Assessment"),
1424
+ }
1425
+
1426
+ # Add optional fields if present
1427
+ if formatted_comments:
1428
+ integration_finding_data["comments"] = formatted_comments
1429
+ integration_finding_data["poam_comments"] = formatted_comments
1430
+
1431
+ # Add CVE-specific fields for generic findings
1432
+ if finding_data.get("cve"):
1433
+ integration_finding_data["cve"] = finding_data["cve"]
1434
+ if finding_data.get("cvss_score"):
1435
+ integration_finding_data["cvss_score"] = finding_data["cvss_score"]
1436
+ integration_finding_data["cvss_v3_base_score"] = finding_data["cvss_score"]
1437
+ if finding_data.get("source_rule_id"):
1438
+ integration_finding_data["source_rule_id"] = finding_data["source_rule_id"]
1439
+
1440
+ return IntegrationFinding(**integration_finding_data)
1441
+
1442
+ def _get_first_seen_date(self, node: Dict[str, Any]) -> str:
1443
+ """
1444
+ Get the first seen date from a Wiz node, with fallbacks.
1445
+
1446
+ :param Dict[str, Any] node: The Wiz finding node
1447
+ :return: ISO formatted first seen date
1448
+ :rtype: str
1449
+ """
1450
+ first_seen = node.get("firstSeenAt") or node.get("firstDetectedAt") or get_current_datetime()
1451
+ return format_to_regscale_iso(first_seen)
1452
+
1453
+ def _get_last_seen_date(self, node: Dict[str, Any], first_seen_fallback: str) -> str:
1454
+ """
1455
+ Get the last seen date from a Wiz node, with fallbacks.
1456
+
1457
+ :param Dict[str, Any] node: The Wiz finding node
1458
+ :param str first_seen_fallback: Fallback date if no last seen available
1459
+ :return: ISO formatted last seen date
1460
+ :rtype: str
1461
+ """
1462
+ last_seen = (
1463
+ node.get("lastSeenAt")
1464
+ or node.get("lastDetectedAt")
1465
+ or node.get("analyzedAt")
1466
+ or first_seen_fallback
1467
+ or get_current_datetime()
1468
+ )
1469
+ return format_to_regscale_iso(last_seen)
1470
+
1471
+ def _get_network_exposure_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1472
+ """
1473
+ Extract data specific to network exposure findings.
1474
+
1475
+ :param Dict[str, Any] node: The Wiz finding node to parse
1476
+ :return: Dictionary containing network exposure-specific data
1477
+ :rtype: Dict[str, Any]
1478
+ """
843
1479
  exposed_entity = node.get("exposedEntity", {})
844
1480
  entity_name = exposed_entity.get("name", "Unknown Entity")
845
1481
  port_range = node.get("portRange", "Unknown Port")
846
- title = f"Network Exposure: {entity_name} on {port_range}"
847
-
848
1482
  # Build description with network details
849
1483
  description_parts = [
850
1484
  f"Exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
@@ -858,52 +1492,37 @@ class WizVulnerabilityIntegration(ScannerIntegration):
858
1492
  if net_protocols := node.get("networkProtocols"):
859
1493
  description_parts.append(f"Network protocols: {', '.join(net_protocols)}")
860
1494
 
861
- description = "\n".join(description_parts)
862
-
863
- return IntegrationFinding(
864
- control_labels=[],
865
- category="Wiz Network Exposure",
866
- title=title,
867
- description=description,
868
- severity=severity,
869
- status=IssueStatus.Open,
870
- asset_identifier=asset_id,
871
- external_id=node.get("id"),
872
- first_seen=first_seen,
873
- date_created=first_seen,
874
- last_seen=first_seen, # Network exposures may not have lastSeen
875
- remediation=f"Review and restrict network access to {entity_name} on {port_range}",
876
- plugin_name=f"Wiz Network Exposure - {port_range}",
877
- vulnerability_type=WizVulnerabilityType.NETWORK_EXPOSURE_FINDING.value,
878
- due_date=due_date,
879
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
880
- identification="Network Security Assessment",
881
- )
1495
+ return {
1496
+ "category": "Wiz Network Exposure",
1497
+ "title": f"Network Exposure: {entity_name} on {port_range}",
1498
+ "description": "\n".join(description_parts),
1499
+ "severity": "Medium", # Network exposures typically don't have explicit severity
1500
+ "remediation": f"Review and restrict network access to {entity_name} on {port_range}",
1501
+ "plugin_name": f"Wiz Network Exposure - {port_range}",
1502
+ "identification": "Network Security Assessment",
1503
+ }
882
1504
 
883
- def _parse_external_attack_surface_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
884
- """Parse external attack surface finding from Wiz.
1505
+ def _parse_network_exposure_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1506
+ """Parse network exposure finding from Wiz.
885
1507
 
886
1508
  :param Dict[str, Any] node: The Wiz finding node to parse
887
1509
  :return: The parsed IntegrationFinding or None if parsing fails
888
1510
  :rtype: Optional[IntegrationFinding]
889
1511
  """
890
- asset_id = node.get("exposedEntity", {}).get("id")
891
- if not asset_id:
892
- return None
1512
+ finding_data = self._get_network_exposure_finding_data(node)
1513
+ return self._create_integration_finding(node, WizVulnerabilityType.NETWORK_EXPOSURE_FINDING, finding_data)
893
1514
 
894
- first_seen = node.get("firstSeenAt") or get_current_datetime()
895
- first_seen = format_to_regscale_iso(first_seen)
896
-
897
- # External attack surface findings are typically high severity
898
- severity = regscale_models.IssueSeverity.High
899
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1515
+ def _get_external_attack_surface_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1516
+ """
1517
+ Extract data specific to external attack surface findings.
900
1518
 
901
- # Create meaningful title for external attack surface
1519
+ :param Dict[str, Any] node: The Wiz finding node to parse
1520
+ :return: Dictionary containing external attack surface-specific data
1521
+ :rtype: Dict[str, Any]
1522
+ """
902
1523
  exposed_entity = node.get("exposedEntity", {})
903
1524
  entity_name = exposed_entity.get("name", "Unknown Entity")
904
1525
  port_range = node.get("portRange", "Unknown Port")
905
- title = f"External Attack Surface: {entity_name} exposed on {port_range}"
906
-
907
1526
  # Build description with attack surface details
908
1527
  description_parts = [
909
1528
  f"Externally exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
@@ -917,49 +1536,34 @@ class WizVulnerabilityIntegration(ScannerIntegration):
917
1536
  endpoint_names = [ep.get("name", "Unknown") for ep in endpoints[:3]] # Limit to first 3
918
1537
  description_parts.append(f"Application endpoints: {', '.join(endpoint_names)}")
919
1538
 
920
- description = "\n".join(description_parts)
921
-
922
- return IntegrationFinding(
923
- control_labels=[],
924
- category="Wiz External Attack Surface",
925
- title=title,
926
- description=description,
927
- severity=severity,
928
- status=IssueStatus.Open,
929
- asset_identifier=asset_id,
930
- external_id=node.get("id"),
931
- first_seen=first_seen,
932
- date_created=first_seen,
933
- last_seen=first_seen,
934
- remediation=f"Review external exposure of {entity_name} and implement proper access controls",
935
- plugin_name=f"Wiz External Attack Surface - {port_range}",
936
- vulnerability_type=WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE.value,
937
- due_date=due_date,
938
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
939
- identification="External Attack Surface Assessment",
940
- )
1539
+ return {
1540
+ "category": "Wiz External Attack Surface",
1541
+ "title": f"External Attack Surface: {entity_name} exposed on {port_range}",
1542
+ "description": "\n".join(description_parts),
1543
+ "severity": "High", # External attack surface findings are typically high severity
1544
+ "remediation": f"Review external exposure of {entity_name} and implement proper access controls",
1545
+ "plugin_name": f"Wiz External Attack Surface - {port_range}",
1546
+ "identification": "External Attack Surface Assessment",
1547
+ }
941
1548
 
942
- def _parse_excessive_access_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
943
- """Parse excessive access finding from Wiz.
1549
+ def _parse_external_attack_surface_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1550
+ """Parse external attack surface finding from Wiz.
944
1551
 
945
1552
  :param Dict[str, Any] node: The Wiz finding node to parse
946
1553
  :return: The parsed IntegrationFinding or None if parsing fails
947
1554
  :rtype: Optional[IntegrationFinding]
948
1555
  """
949
- scope = node.get("scope", {})
950
- asset_id = scope.get("graphEntity", {}).get("id")
951
- if not asset_id:
952
- return None
1556
+ finding_data = self._get_external_attack_surface_finding_data(node)
1557
+ return self._create_integration_finding(node, WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE, finding_data)
953
1558
 
954
- first_seen = get_current_datetime() # Excessive access findings may not have firstSeen
955
- first_seen = format_to_regscale_iso(first_seen)
956
- severity = self.get_issue_severity(node.get("severity", "Medium"))
957
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
958
-
959
- # Use the finding name directly as it's descriptive
960
- title = node.get("name", "Excessive Access Detected")
961
- description = node.get("description", "")
1559
+ def _get_excessive_access_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1560
+ """
1561
+ Extract data specific to excessive access findings.
962
1562
 
1563
+ :param Dict[str, Any] node: The Wiz finding node to parse
1564
+ :return: Dictionary containing excessive access-specific data
1565
+ :rtype: Dict[str, Any]
1566
+ """
963
1567
  # Add remediation details
964
1568
  remediation_parts = [node.get("description", "")]
965
1569
  if remediation_instructions := node.get("remediationInstructions"):
@@ -967,42 +1571,34 @@ class WizVulnerabilityIntegration(ScannerIntegration):
967
1571
  if policy_name := node.get("builtInPolicyRemediationName"):
968
1572
  remediation_parts.append(f"Built-in policy: {policy_name}")
969
1573
 
970
- remediation = "\n".join(filter(None, remediation_parts))
971
-
972
- return IntegrationFinding(
973
- control_labels=[],
974
- category="Wiz Excessive Access",
975
- title=title,
976
- description=description,
977
- severity=severity,
978
- status=self.map_status_to_issue_status(node.get("status", "Open")),
979
- asset_identifier=asset_id,
980
- external_id=node.get("id"),
981
- first_seen=first_seen,
982
- date_created=first_seen,
983
- last_seen=first_seen,
984
- remediation=remediation,
985
- plugin_name=f"Wiz Excessive Access - {node.get('remediationType', 'Unknown')}",
986
- vulnerability_type=WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING.value,
987
- due_date=due_date,
988
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
989
- identification="Access Control Assessment",
990
- )
1574
+ return {
1575
+ "category": "Wiz Excessive Access",
1576
+ "title": node.get("name", "Excessive Access Detected"),
1577
+ "description": node.get("description", ""),
1578
+ "remediation": "\n".join(filter(None, remediation_parts)),
1579
+ "plugin_name": f"Wiz Excessive Access - {node.get('remediationType', 'Unknown')}",
1580
+ "identification": "Access Control Assessment",
1581
+ }
991
1582
 
992
- def _parse_end_of_life_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
993
- """Parse end of life finding from Wiz."""
994
- asset_id = node.get("vulnerableAsset", {}).get("id")
995
- if not asset_id:
996
- return None
1583
+ def _parse_excessive_access_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1584
+ """Parse excessive access finding from Wiz.
997
1585
 
998
- first_seen = node.get("firstDetectedAt") or get_current_datetime()
999
- first_seen = format_to_regscale_iso(first_seen)
1000
- severity = self.get_issue_severity(node.get("severity", "High"))
1001
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1586
+ :param Dict[str, Any] node: The Wiz finding node to parse
1587
+ :return: The parsed IntegrationFinding or None if parsing fails
1588
+ :rtype: Optional[IntegrationFinding]
1589
+ """
1590
+ finding_data = self._get_excessive_access_finding_data(node)
1591
+ return self._create_integration_finding(node, WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING, finding_data)
1002
1592
 
1003
- # Create meaningful title for end-of-life findings
1593
+ def _get_end_of_life_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1594
+ """
1595
+ Extract data specific to end of life findings.
1596
+
1597
+ :param Dict[str, Any] node: The Wiz finding node to parse
1598
+ :return: Dictionary containing end of life-specific data
1599
+ :rtype: Dict[str, Any]
1600
+ """
1004
1601
  name = node.get("name", "Unknown Technology")
1005
- title = f"End of Life: {name}"
1006
1602
 
1007
1603
  # Build description with EOL details
1008
1604
  description_parts = [node.get("description", "")]
@@ -1011,42 +1607,30 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1011
1607
  if recommended_version := node.get("recommendedVersion"):
1012
1608
  description_parts.append(f"Recommended version: {recommended_version}")
1013
1609
 
1014
- description = "\n".join(filter(None, description_parts))
1015
-
1016
- return IntegrationFinding(
1017
- control_labels=[],
1018
- category="Wiz End of Life",
1019
- title=title,
1020
- description=description,
1021
- severity=severity,
1022
- status=self.map_status_to_issue_status(node.get("status", "Open")),
1023
- asset_identifier=asset_id,
1024
- external_id=node.get("id"),
1025
- first_seen=first_seen,
1026
- date_created=first_seen,
1027
- last_seen=format_to_regscale_iso(node.get("lastDetectedAt") or get_current_datetime()),
1028
- remediation=f"Upgrade {name} to a supported version",
1029
- plugin_name=f"Wiz End of Life - {name}",
1030
- vulnerability_type=WizVulnerabilityType.END_OF_LIFE_FINDING.value,
1031
- due_date=due_date,
1032
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
1033
- identification="Technology Lifecycle Assessment",
1034
- )
1610
+ return {
1611
+ "category": "Wiz End of Life",
1612
+ "title": f"End of Life: {name}",
1613
+ "description": "\n".join(filter(None, description_parts)),
1614
+ "severity": "High", # End of life findings are typically high severity
1615
+ "remediation": f"Upgrade {name} to a supported version",
1616
+ "plugin_name": f"Wiz End of Life - {name}",
1617
+ "identification": "Technology Lifecycle Assessment",
1618
+ }
1035
1619
 
1036
- def _parse_generic_finding(
1037
- self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1038
- ) -> Optional[IntegrationFinding]:
1039
- """Generic parsing method for fallback cases."""
1040
- asset_id = self.get_asset_id_from_node(node, vulnerability_type)
1041
- if not asset_id:
1042
- return None
1620
+ def _parse_end_of_life_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
1621
+ """Parse end of life finding from Wiz."""
1622
+ finding_data = self._get_end_of_life_finding_data(node)
1623
+ return self._create_integration_finding(node, WizVulnerabilityType.END_OF_LIFE_FINDING, finding_data)
1043
1624
 
1044
- first_seen = node.get("firstDetectedAt") or node.get("firstSeenAt") or get_current_datetime()
1045
- first_seen = format_to_regscale_iso(first_seen)
1046
- severity = self.get_issue_severity(node.get("severity", "Low"))
1047
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
1625
+ def _get_generic_finding_data(self, node: Dict[str, Any]) -> Dict[str, Any]:
1626
+ """
1627
+ Extract data specific to generic findings.
1048
1628
 
1049
- status = self.map_status_to_issue_status(node.get("status", "Open"))
1629
+ :param Dict[str, Any] node: The Wiz finding node to parse
1630
+ :param WizVulnerabilityType vulnerability_type: The type of vulnerability
1631
+ :return: Dictionary containing generic finding-specific data
1632
+ :rtype: Dict[str, Any]
1633
+ """
1050
1634
  name: str = node.get("name", "")
1051
1635
  cve = (
1052
1636
  name
@@ -1054,36 +1638,25 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1054
1638
  else node.get("cve", name)
1055
1639
  )
1056
1640
 
1057
- comments_dict = node.get("commentThread", {})
1058
- formatted_comments = self.process_comments(comments_dict)
1059
-
1060
- return IntegrationFinding(
1061
- control_labels=[],
1062
- category="Wiz Vulnerability",
1063
- title=node.get("name", "Unknown vulnerability"),
1064
- description=node.get("description", ""),
1065
- severity=severity,
1066
- status=status,
1067
- asset_identifier=asset_id,
1068
- external_id=f"{node.get('sourceRule', {'id': cve}).get('id')}",
1069
- first_seen=first_seen,
1070
- date_created=first_seen,
1071
- last_seen=format_to_regscale_iso(
1072
- node.get("lastDetectedAt") or node.get("analyzedAt") or get_current_datetime()
1073
- ),
1074
- remediation=node.get("description", ""),
1075
- cvss_score=node.get("score"),
1076
- cve=cve,
1077
- plugin_name=cve,
1078
- cvss_v3_base_score=node.get("score"),
1079
- source_rule_id=node.get("sourceRule", {}).get("id"),
1080
- vulnerability_type=vulnerability_type.value,
1081
- due_date=due_date,
1082
- date_last_updated=format_to_regscale_iso(get_current_datetime()),
1083
- identification="Vulnerability Assessment",
1084
- comments=formatted_comments,
1085
- poam_comments=formatted_comments,
1086
- )
1641
+ return {
1642
+ "category": "Wiz Vulnerability",
1643
+ "title": node.get("name", "Unknown vulnerability"),
1644
+ "description": node.get("description", ""),
1645
+ "external_id": f"{node.get('sourceRule', {'id': cve}).get('id')}",
1646
+ "remediation": node.get("description", ""),
1647
+ "plugin_name": cve,
1648
+ "identification": "Vulnerability Assessment",
1649
+ "cve": cve,
1650
+ "cvss_score": node.get("score"),
1651
+ "source_rule_id": node.get("sourceRule", {}).get("id"),
1652
+ }
1653
+
1654
+ def _parse_generic_finding(
1655
+ self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
1656
+ ) -> Optional[IntegrationFinding]:
1657
+ """Generic parsing method for fallback cases."""
1658
+ finding_data = self._get_generic_finding_data(node)
1659
+ return self._create_integration_finding(node, vulnerability_type, finding_data)
1087
1660
 
1088
1661
  def get_compliance_settings(self):
1089
1662
  """
@@ -1161,12 +1734,22 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1161
1734
  """
1162
1735
  label_lower = label.lower()
1163
1736
 
1164
- # Check for open status mappings
1165
- if status_lower == "open" and label_lower in ["open", "active", "new"]:
1737
+ logger.debug(f"Checking compliance label matching: status='{status_lower}', label='{label_lower}'")
1738
+
1739
+ # Check for open status mappings (including IN_PROGRESS)
1740
+ if status_lower in ["open", "in_progress"] and label_lower in [
1741
+ "open",
1742
+ "active",
1743
+ "new",
1744
+ "in progress",
1745
+ "in_progress",
1746
+ ]:
1747
+ logger.debug(f"Matched status '{status_lower}' with label '{label_lower}' -> IssueStatus.Open")
1166
1748
  return IssueStatus.Open
1167
1749
 
1168
1750
  # Check for closed status mappings
1169
1751
  if status_lower in ["resolved", "rejected"] and label_lower in ["closed", "resolved", "rejected", "completed"]:
1752
+ logger.debug(f"Matched status '{status_lower}' with label '{label_lower}' -> IssueStatus.Closed")
1170
1753
  return IssueStatus.Closed
1171
1754
 
1172
1755
  return None
@@ -1181,12 +1764,20 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1181
1764
  """
1182
1765
  status_lower = status.lower()
1183
1766
 
1184
- if status_lower == "open":
1767
+ # Add debug logging to trace status mapping
1768
+ logger.debug(f"Mapping Wiz status: original='{status}', lowercase='{status_lower}'")
1769
+
1770
+ # Map open and in-progress statuses to Open
1771
+ if status_lower in ["open", "in_progress"]:
1772
+ logger.debug(f"Wiz status '{status}' mapped to IssueStatus.Open")
1185
1773
  return IssueStatus.Open
1186
- elif status_lower in ["resolved", "rejected"]:
1774
+ # Map resolved and rejected statuses to Closed
1775
+ if status_lower in ["resolved", "rejected"]:
1776
+ logger.debug(f"Wiz status '{status}' mapped to IssueStatus.Closed")
1187
1777
  return IssueStatus.Closed
1188
- else:
1189
- return IssueStatus.Open
1778
+ # Default to Open for any unknown status
1779
+ logger.debug(f"Unknown Wiz status '{status}' encountered, defaulting to Open")
1780
+ return IssueStatus.Open
1190
1781
 
1191
1782
  def fetch_assets(self, *args, **kwargs) -> Iterator[IntegrationAsset]:
1192
1783
  """
@@ -1296,6 +1887,13 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1296
1887
 
1297
1888
  return software_version, software_vendor, software_name
1298
1889
 
1890
+ @lru_cache()
1891
+ def get_user_id(self) -> str:
1892
+ """Function to return the default user ID
1893
+ :return: The default user ID as a string
1894
+ """
1895
+ return RegScaleModel.get_user_id()
1896
+
1299
1897
  def parse_asset(self, node: Dict[str, Any]) -> Optional[IntegrationAsset]:
1300
1898
  """
1301
1899
  Parses Wiz assets
@@ -1304,8 +1902,9 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1304
1902
  :return: The parsed IntegrationAsset
1305
1903
  :rtype: Optional[IntegrationAsset]
1306
1904
  """
1307
- name = node.get("name", "")
1905
+
1308
1906
  wiz_entity = node.get("graphEntity", {})
1907
+ name = wiz_entity.get("providerUniqueId") or node.get("name", "")
1309
1908
  if not wiz_entity:
1310
1909
  logger.warning("No graph entity found for asset %s", name)
1311
1910
  return None
@@ -1334,7 +1933,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1334
1933
  other_tracking_number=node.get("id", ""),
1335
1934
  identifier=node.get("id", ""),
1336
1935
  asset_type=create_asset_type(node.get("type", "")),
1337
- asset_owner_id=ScannerVariables.userId,
1936
+ asset_owner_id=self.get_user_id(),
1338
1937
  parent_id=self.plan_id,
1339
1938
  parent_module=regscale_models.SecurityPlan.get_module_slug(),
1340
1939
  asset_category=map_category(node),
@@ -1435,6 +2034,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1435
2034
  def get_software_name(software_name_dict: dict, wiz_entity_properties: dict, node: dict) -> Optional[str]:
1436
2035
  """
1437
2036
  Gets the software name from the software name dictionary or Wiz entity properties.
2037
+ If no software name is present, assigns a name based on the parent asset and assigned component type.
1438
2038
 
1439
2039
  :param dict software_name_dict: Dictionary containing software name and vendor
1440
2040
  :param dict wiz_entity_properties: Properties of the Wiz entity
@@ -1442,9 +2042,32 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1442
2042
  :return: Software name
1443
2043
  :rtype: Optional[str]
1444
2044
  """
1445
- if map_category(node) == regscale_models.AssetCategory.Software:
1446
- return software_name_dict.get("software_name") or wiz_entity_properties.get("nativeType")
1447
- return None
2045
+ if map_category(node) != regscale_models.AssetCategory.Software:
2046
+ return None
2047
+
2048
+ # First try CPE-derived software name
2049
+ if software_name := software_name_dict.get("software_name"):
2050
+ return software_name
2051
+
2052
+ # Then try nativeType if it exists and looks meaningful
2053
+ native_type = wiz_entity_properties.get("nativeType")
2054
+ if native_type and not native_type.startswith(("Microsoft.", "AWS::", "Google.")):
2055
+ return native_type
2056
+
2057
+ # Finally, generate a name based on parent asset and component type
2058
+ parent_name = node.get("name", "")
2059
+ component_type = node.get("type", "").replace("_", " ").title()
2060
+
2061
+ if not parent_name:
2062
+ return component_type
2063
+
2064
+ # Clean up parent name for better readability by removing
2065
+ # common prefixes/suffixes that aren't meaningful
2066
+ cleaned_parent = parent_name
2067
+ for prefix in ["1-", "temp-", "test-"]:
2068
+ if cleaned_parent.lower().startswith(prefix):
2069
+ cleaned_parent = cleaned_parent[len(prefix) :]
2070
+ return f"{cleaned_parent} - {component_type}" if cleaned_parent else component_type
1448
2071
 
1449
2072
  # Pre-compiled regex for better performance (ReDoS-safe pattern)
1450
2073
  _PACKAGE_PATTERN = re.compile(r"([^()]+) \(([^()]+)\)")
@@ -1481,37 +2104,27 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1481
2104
  :return: List of nodes as dictionaries
1482
2105
  :rtype: List[Dict]
1483
2106
  """
1484
- fetch_interval = datetime.timedelta(hours=WizVariables.wizFullPullLimitHours or 8) # Interval to fetch new data
1485
- current_time = datetime.datetime.now()
1486
- check_file_path(os.path.dirname(file_path))
1487
-
1488
- if os.path.exists(file_path):
1489
- file_mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
1490
- if current_time - file_mod_time < fetch_interval:
1491
- logger.info("File %s is newer than %s hours. Using cached data...", file_path, fetch_interval)
1492
- with open(file_path, "r", encoding="utf-8") as file:
1493
- return json.load(file)
1494
- else:
1495
- logger.info("File %s is older than %s hours. Fetching new data...", file_path, fetch_interval)
1496
- else:
1497
- logger.info("File %s does not exist. Fetching new data...", file_path)
2107
+ max_age_hours = WizVariables.wizFullPullLimitHours or 8
1498
2108
 
1499
- self.authenticate(WizVariables.wizClientId, WizVariables.wizClientSecret)
2109
+ def fetch_fresh_data():
2110
+ # Ensure we have a valid token (should already be set by caller)
2111
+ if not self.wiz_token:
2112
+ error_and_exit("Wiz token is not set. Please authenticate before calling fetch_wiz_data_if_needed.")
1500
2113
 
1501
- if not self.wiz_token:
1502
- error_and_exit("Wiz token is not set. Please authenticate first.")
2114
+ return fetch_wiz_data(
2115
+ query=query,
2116
+ variables=variables,
2117
+ api_endpoint_url=WizVariables.wizUrl,
2118
+ token=self.wiz_token,
2119
+ topic_key=topic_key,
2120
+ )
1503
2121
 
1504
- nodes = fetch_wiz_data(
1505
- query=query,
1506
- variables=variables,
1507
- api_endpoint_url=WizVariables.wizUrl,
1508
- token=self.wiz_token,
1509
- topic_key=topic_key,
2122
+ return FileOperations.load_cache_or_fetch(
2123
+ file_path=file_path,
2124
+ fetch_fn=fetch_fresh_data,
2125
+ max_age_hours=max_age_hours,
2126
+ save_cache=True,
1510
2127
  )
1511
- with open(file_path, "w", encoding="utf-8") as file:
1512
- json.dump(nodes, file)
1513
-
1514
- return nodes
1515
2128
 
1516
2129
  def get_asset_by_identifier(self, identifier: str) -> Optional[regscale_models.Asset]:
1517
2130
  """
@@ -1546,7 +2159,11 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1546
2159
  :param str identifier: The missing asset identifier
1547
2160
  :rtype: None
1548
2161
  """
1549
- logger.info("🔍 Analyzing missing asset: %s", identifier)
2162
+ # Only log detailed diagnostics for the first occurrence of each asset
2163
+ if identifier in self.alerted_assets:
2164
+ return
2165
+
2166
+ logger.debug("Analyzing missing asset: %s", identifier)
1550
2167
 
1551
2168
  # Define inventory files to search (constant moved up for clarity)
1552
2169
  inventory_files = (
@@ -1575,39 +2192,11 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1575
2192
  :return: Tuple of (asset_info, source_file) or (None, None)
1576
2193
  :rtype: tuple[Optional[Dict], Optional[str]]
1577
2194
  """
1578
- for file_path in file_paths:
1579
- if not os.path.exists(file_path):
1580
- continue
1581
-
1582
- try:
1583
- asset_info = self._search_single_file(identifier, file_path)
1584
- if asset_info:
1585
- return asset_info, file_path
1586
- except (json.JSONDecodeError, IOError) as e:
1587
- logger.debug("Error reading %s: %s", file_path, e)
1588
- continue
1589
-
1590
- return None, None
1591
-
1592
- def _search_single_file(self, identifier: str, file_path: str) -> Optional[Dict]:
1593
- """
1594
- Search for asset in a single JSON file
1595
-
1596
- :param str identifier: Asset identifier to search for
1597
- :param str file_path: Path to JSON file
1598
- :return: Asset data if found, None otherwise
1599
- :rtype: Optional[Dict]
1600
- """
1601
- logger.debug("Searching for asset %s in %s", identifier, file_path)
1602
-
1603
- with open(file_path, "r", encoding="utf-8") as f:
1604
- data = json.load(f)
1605
-
1606
- if not isinstance(data, list):
1607
- return None
1608
-
1609
- # Use generator expression for memory efficiency
1610
- return next((item for item in data if self._find_asset_in_node(item, identifier)), None)
2195
+ return FileOperations.search_json_files(
2196
+ identifier=identifier,
2197
+ file_paths=list(file_paths),
2198
+ match_fn=self._find_asset_in_node,
2199
+ )
1611
2200
 
1612
2201
  def _log_found_asset_details(self, identifier: str, asset_info: Dict, source_file: str) -> None:
1613
2202
  """
@@ -1621,10 +2210,13 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1621
2210
  asset_type = self._extract_asset_type_from_node(asset_info)
1622
2211
  asset_name = self._extract_asset_name_from_node(asset_info)
1623
2212
 
1624
- logger.warning(
1625
- "🚨 MISSING ASSET FOUND: ID=%s, Type=%s, Name='%s', Source=%s\n"
1626
- " 💡 SOLUTION: Add '%s' to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py\n"
1627
- " 📍 Then re-run: regscale wiz inventory -id <plan_id> -p <project_id>",
2213
+ # Track missing asset types for summary reporting
2214
+ self._missing_asset_types.add(asset_type)
2215
+
2216
+ logger.info(
2217
+ "Missing asset found in cached data - ID: %s, Type: %s, Name: '%s', Source: %s\n"
2218
+ " Action: Consider adding '%s' to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py\n"
2219
+ " Then re-run: regscale wiz inventory --wiz_project_id <project_id> -id <plan_id>",
1628
2220
  identifier,
1629
2221
  asset_type,
1630
2222
  asset_name,
@@ -1639,13 +2231,11 @@ class WizVulnerabilityIntegration(ScannerIntegration):
1639
2231
  :param str identifier: Asset identifier
1640
2232
  :rtype: None
1641
2233
  """
1642
- logger.warning(
1643
- " MISSING ASSET ANALYSIS: ID=%s\n"
1644
- " Asset not found in any cached data files.\n"
1645
- " This may indicate:\n"
1646
- " - Asset from different Wiz project\n"
1647
- " - Asset type not included in current queries\n"
1648
- " - Asset deleted from Wiz but finding still exists",
2234
+ logger.debug(
2235
+ "Asset not found in cached data - ID: %s. Possible reasons: "
2236
+ "(1) Asset from different Wiz project, "
2237
+ "(2) Asset type not included in queries, "
2238
+ "(3) Asset deleted from Wiz",
1649
2239
  identifier,
1650
2240
  )
1651
2241