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