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
@@ -8,11 +8,13 @@ that follow common patterns across different compliance tools (Wiz, Tenable, Sic
8
8
  """
9
9
  import logging
10
10
  import re
11
+ import time
11
12
  from abc import ABC, abstractmethod
12
13
  from collections import defaultdict
13
14
  from typing import Dict, List, Optional, Any, Iterator
14
15
 
15
16
  from regscale.core.app.utils.app_utils import get_current_datetime, regscale_string_to_datetime
17
+ from regscale.integrations.control_matcher import ControlMatcher
16
18
  from regscale.integrations.scanner_integration import (
17
19
  ScannerIntegration,
18
20
  IntegrationAsset,
@@ -25,6 +27,8 @@ from regscale.models.regscale_models import (
25
27
  ControlImplementation,
26
28
  Assessment,
27
29
  ImplementationObjective,
30
+ SecurityPlan,
31
+ ComplianceSettings,
28
32
  )
29
33
 
30
34
  logger = logging.getLogger("regscale")
@@ -102,9 +106,37 @@ class ComplianceIntegration(ScannerIntegration, ABC):
102
106
  - Creating assessments and updating control status
103
107
  """
104
108
 
109
+ # String literal constants
110
+ NOT_APPLICABLE_LABEL = "Not Applicable"
111
+ NOT_APPLICABLE_LOWER = "not applicable"
112
+ NOT_APPLICABLE_UNDERSCORE = "not_applicable"
113
+
105
114
  # Status mapping constants
106
- PASS_STATUSES = ["PASS", "PASSED", "pass", "passed"]
107
- FAIL_STATUSES = ["FAIL", "FAILED", "fail", "failed"]
115
+ PASS_STATUSES = ["PASS", "PASSED", "Pass", "Passed", "pass", "passed", "COMPLIANT", "Compliant", "compliant"]
116
+ FAIL_STATUSES = [
117
+ "FAIL",
118
+ "FAILED",
119
+ "Fail",
120
+ "Failed",
121
+ "fail",
122
+ "failed",
123
+ "NONCOMPLIANT",
124
+ "NonCompliant",
125
+ "noncompliant",
126
+ ]
127
+
128
+ NOT_APPLICABLE_STATUSES = ["NOT_APPLICABLE", NOT_APPLICABLE_LABEL, "not_applicable", "NA", "N/A"]
129
+ INCONCLUSIVE_STATUSES = [
130
+ "INCONCLUSIVE",
131
+ "Inconclusive",
132
+ "inconclusive",
133
+ "UNKNOWN",
134
+ "Unknown",
135
+ "unknown",
136
+ "MANUAL",
137
+ "Manual",
138
+ "manual",
139
+ ]
108
140
 
109
141
  def __init__(
110
142
  self,
@@ -140,6 +172,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
140
172
  self.failed_compliance_items: List[ComplianceItem] = []
141
173
  self.passing_controls: Dict[str, ComplianceItem] = {}
142
174
  self.failing_controls: Dict[str, ComplianceItem] = {}
175
+ self.not_applicable_controls: Dict[str, ComplianceItem] = {}
143
176
 
144
177
  # Asset mapping for compliance to asset correlation
145
178
  self.asset_compliance_map: Dict[str, List[ComplianceItem]] = defaultdict(list)
@@ -160,6 +193,20 @@ class ComplianceIntegration(ScannerIntegration, ABC):
160
193
  # Set scan date
161
194
  self.scan_date = get_current_datetime()
162
195
 
196
+ # Cache for compliance settings
197
+ self._compliance_settings = None
198
+ self._security_plan = None
199
+ self._security_plan_loaded = False # Track if we've attempted to load
200
+ self._compliance_settings_loaded = False # Track if we've attempted to load
201
+ self._status_mapping_cache = {} # Cache for status mappings to avoid repeated calculations
202
+
203
+ # Initialize control matcher for robust control ID matching
204
+ self._control_matcher = ControlMatcher()
205
+
206
+ # Performance optimization: cache for control lookups
207
+ # Key: control ID variation (e.g., 'ac-2(1)') -> (ControlImplementation, SecurityControl)
208
+ self._control_lookup_cache: Dict[str, tuple[ControlImplementation, SecurityControl]] = {}
209
+
163
210
  def is_poam(self, finding: IntegrationFinding) -> bool: # type: ignore[override]
164
211
  """
165
212
  Determines if an issue should be considered a POAM for compliance integrations.
@@ -201,7 +248,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
201
248
  self._load_existing_assessments()
202
249
 
203
250
  self._cache_loaded = True
204
- logger.info("🗄️ Loaded existing records cache to prevent duplicates:")
251
+ logger.info("Loaded existing records cache to prevent duplicates:")
205
252
  logger.info(f" - Assets: {len(self._existing_assets_cache)}")
206
253
  logger.info(f" - Issues: {len(self._existing_issues_cache)}")
207
254
  logger.info(f" - Assessments: {len(self._existing_assessments_cache)}")
@@ -225,9 +272,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
225
272
  )
226
273
 
227
274
  for asset in existing_assets:
228
- # Cache by external_id, identifier, and other_tracking_number for flexible lookup
229
- if hasattr(asset, "externalId") and asset.externalId:
230
- self._existing_assets_cache[asset.externalId] = asset
275
+ # Cache by identifier and other_tracking_number for flexible lookup
231
276
  if hasattr(asset, "identifier") and asset.identifier:
232
277
  self._existing_assets_cache[asset.identifier] = asset
233
278
  if hasattr(asset, "otherTrackingNumber") and asset.otherTrackingNumber:
@@ -253,7 +298,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
253
298
  parent_id=self.plan_id, parent_module=self.parent_module
254
299
  )
255
300
  all_issues.update(plan_issues)
256
- logger.debug(f"🔍 Found {len(plan_issues)} issues directly under plan {self.plan_id}")
301
+ logger.debug(f"Found {len(plan_issues)} issues directly under plan {self.plan_id}")
257
302
 
258
303
  # Method 2: Get issues associated with control implementations (matches scanner integration logic)
259
304
  try:
@@ -274,24 +319,22 @@ class ComplianceIntegration(ScannerIntegration, ABC):
274
319
  except Exception as e:
275
320
  logger.debug(f"Could not load issue {issue_id}: {e}")
276
321
 
277
- logger.debug(f"🔍 Found {impl_issues_count} additional issues via control implementations")
322
+ logger.debug(f"Found {impl_issues_count} additional issues via control implementations")
278
323
  except Exception as e:
279
324
  logger.debug(f"Could not load issues by control implementation: {e}")
280
325
 
281
- logger.debug(f"🔍 Total unique issues found: {len(all_issues)} for plan {self.plan_id}")
326
+ logger.debug(f"Total unique issues found: {len(all_issues)} for plan {self.plan_id}")
282
327
 
283
328
  wiz_issues = 0
284
329
  for issue in all_issues:
285
330
  # Cache by external_id and other_identifier for flexible lookup
286
- if hasattr(issue, "externalId") and issue.externalId:
287
- self._existing_issues_cache[issue.externalId] = issue
288
- if "wiz-policy" in issue.externalId.lower():
289
- wiz_issues += 1
290
- logger.debug(f"📋 Cached Wiz issue: {issue.id} -> external_id: {issue.externalId}")
291
331
  if hasattr(issue, "otherIdentifier") and issue.otherIdentifier:
292
332
  self._existing_issues_cache[issue.otherIdentifier] = issue
333
+ if "wiz-policy" in issue.otherIdentifier.lower():
334
+ wiz_issues += 1
335
+ logger.debug(f"Cached Wiz issue: {issue.id} -> other_identifier: {issue.otherIdentifier}")
293
336
 
294
- logger.debug(f"🔍 Cached {wiz_issues} Wiz policy issues out of {len(all_issues)} total issues")
337
+ logger.debug(f"Cached {wiz_issues} Wiz policy issues out of {len(all_issues)} total issues")
295
338
 
296
339
  except Exception as e:
297
340
  logger.debug(f"Error loading existing issues: {e}")
@@ -378,6 +421,50 @@ class ComplianceIntegration(ScannerIntegration, ABC):
378
421
  cache_key = f"{implementation_id}_{day_key}"
379
422
  return self._existing_assessments_cache.get(cache_key)
380
423
 
424
+ def check_for_existing_evidence(self, file_name_pattern: str) -> bool:
425
+ """
426
+ Check if an evidence file matching the pattern already exists in RegScale.
427
+
428
+ This method fetches existing files for the plan and checks if any match
429
+ the provided pattern, helping prevent duplicate evidence uploads.
430
+
431
+ :param str file_name_pattern: Pattern to match against existing file names
432
+ :return: True if a matching file exists, False otherwise
433
+ :rtype: bool
434
+ """
435
+ try:
436
+ # Import here to avoid circular dependency
437
+ from regscale.models.regscale_models import File
438
+
439
+ # Get all existing files for the plan
440
+ existing_files = File.get_files_for_parent_from_regscale(
441
+ parent_id=self.plan_id, parent_module=self.parent_module
442
+ )
443
+
444
+ # Check if any file matches the pattern
445
+ for file_obj in existing_files:
446
+ if hasattr(file_obj, "trustedDisplayName") and file_obj.trustedDisplayName:
447
+ # Check if the pattern is in the file name
448
+ if file_name_pattern in file_obj.trustedDisplayName:
449
+ logger.debug(
450
+ "Found existing evidence file matching pattern '%s': %s",
451
+ file_name_pattern,
452
+ file_obj.trustedDisplayName,
453
+ )
454
+ return True
455
+
456
+ logger.debug("No existing evidence files found matching pattern '%s'", file_name_pattern)
457
+ return False
458
+
459
+ except Exception as e:
460
+ logger.warning(
461
+ "Unable to check for existing evidence files (pattern: '%s'): %s. Proceeding with upload.",
462
+ file_name_pattern,
463
+ e,
464
+ )
465
+ # Return False to allow upload to proceed if check fails
466
+ return False
467
+
381
468
  @abstractmethod
382
469
  def fetch_compliance_data(self) -> List[Any]:
383
470
  """
@@ -411,17 +498,33 @@ class ComplianceIntegration(ScannerIntegration, ABC):
411
498
  """
412
499
  logger.info("Processing compliance data...")
413
500
 
414
- # Reset state to avoid double counting on repeated calls
501
+ self._reset_compliance_state()
502
+ allowed_controls = self._build_allowed_controls_set()
503
+ raw_compliance_data = self.fetch_compliance_data()
504
+
505
+ processing_stats = self._process_raw_compliance_items(raw_compliance_data, allowed_controls)
506
+ self._log_processing_summary(raw_compliance_data, processing_stats)
507
+
508
+ # Perform control-level categorization based on aggregated results
509
+ self._categorize_controls_by_aggregation()
510
+ self._log_final_results()
511
+
512
+ def _reset_compliance_state(self) -> None:
513
+ """Reset state to avoid double counting on repeated calls."""
415
514
  self.all_compliance_items = []
416
515
  self.failed_compliance_items = []
417
516
  self.passing_controls = {}
418
517
  self.failing_controls = {}
518
+ self.not_applicable_controls = {}
419
519
  self.asset_compliance_map.clear()
420
520
 
421
- # Build allowed control IDs from plan/catalog controls to restrict scope
521
+ def _build_allowed_controls_set(self) -> set[str]:
522
+ """Build allowed control IDs from plan/catalog controls to restrict scope."""
422
523
  allowed_controls_normalized: set[str] = set()
423
524
  try:
424
525
  controls = self._get_controls()
526
+ logger.debug(f"Loaded {len(controls)} controls from plan/catalog")
527
+
425
528
  for ctl in controls:
426
529
  cid = (ctl.get("controlId") or "").strip()
427
530
  if not cid:
@@ -429,56 +532,242 @@ class ComplianceIntegration(ScannerIntegration, ABC):
429
532
  base, sub = self._normalize_control_id(cid)
430
533
  normalized = f"{base}({sub})" if sub else base
431
534
  allowed_controls_normalized.add(normalized)
432
- except Exception:
433
- # If controls cannot be loaded, proceed without additional filtering
535
+
536
+ logger.debug(f"Built allowed_controls_normalized set with {len(allowed_controls_normalized)} entries")
537
+ if allowed_controls_normalized:
538
+ sample = sorted(allowed_controls_normalized)[:5]
539
+ logger.debug(f"Sample allowed controls: {sample}")
540
+ except Exception as e:
541
+ logger.warning(f"Could not load controls from plan/catalog: {e}")
434
542
  allowed_controls_normalized = set()
435
543
 
436
- # Fetch raw compliance data
437
- raw_compliance_data = self.fetch_compliance_data()
544
+ return allowed_controls_normalized
545
+
546
+ def _process_raw_compliance_items(self, raw_compliance_data: list, allowed_controls: set) -> dict:
547
+ """Process raw compliance items and return processing statistics.
548
+ :param list raw_compliance_data: Raw compliance data from external system
549
+ :param set allowed_controls: Allowed control IDs
550
+ :return: Processed compliance items
551
+ :rtype: dict
552
+ """
553
+ stats = {"skipped_no_control": 0, "skipped_no_resource": 0, "skipped_not_in_plan": 0, "processed_count": 0}
438
554
 
439
- # Convert to ComplianceItem objects
440
555
  for raw_item in raw_compliance_data:
441
556
  try:
442
557
  compliance_item = self.create_compliance_item(raw_item)
443
- # Skip items that do not resolve to a control or resource
444
- if not getattr(compliance_item, "control_id", "") or not getattr(compliance_item, "resource_id", ""):
558
+ if not self._process_single_compliance_item(compliance_item, allowed_controls, stats):
445
559
  continue
446
-
447
- # If we have an allowed set, restrict to only controls in current plan/catalog
448
- if allowed_controls_normalized:
449
- base, sub = self._normalize_control_id(getattr(compliance_item, "control_id", ""))
450
- norm_item = f"{base}({sub})" if sub else base
451
- if norm_item not in allowed_controls_normalized:
452
- continue
453
- self.all_compliance_items.append(compliance_item)
454
-
455
- # Build asset mapping
456
- self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
457
-
458
- # Categorize by result
459
- if compliance_item.compliance_result in self.FAIL_STATUSES:
460
- self.failed_compliance_items.append(compliance_item)
461
- # Track failing controls (control can fail if ANY asset fails)
462
- control_key = compliance_item.control_id.lower()
463
- self.failing_controls[control_key] = compliance_item
464
- # Remove from passing if it was there
465
- self.passing_controls.pop(control_key, None)
466
-
467
- elif compliance_item.compliance_result in self.PASS_STATUSES:
468
- control_key = compliance_item.control_id.lower()
469
- # Only mark as passing if not already failing
470
- if control_key not in self.failing_controls:
471
- self.passing_controls[control_key] = compliance_item
472
-
473
560
  except Exception as e:
474
561
  logger.error(f"Error processing compliance item: {e}")
475
562
  continue
476
563
 
564
+ return stats
565
+
566
+ def _process_single_compliance_item(self, compliance_item: Any, allowed_controls: set, stats: dict) -> bool:
567
+ """Process a single compliance item and update statistics. Returns True if processed successfully."""
568
+ control_id = getattr(compliance_item, "control_id", "")
569
+ resource_id = getattr(compliance_item, "resource_id", "")
570
+
571
+ if not control_id:
572
+ stats["skipped_no_control"] += 1
573
+ return False
574
+ if not resource_id:
575
+ stats["skipped_no_resource"] += 1
576
+ return False
577
+
578
+ if not self._should_process_item(compliance_item, control_id, allowed_controls, stats):
579
+ return False
580
+
581
+ self._add_processed_item(compliance_item, stats)
582
+ return True
583
+
584
+ def _should_process_item(self, compliance_item: Any, control_id: str, allowed_controls: set, stats: dict) -> bool:
585
+ """Determine if an item should be processed based on control filtering."""
586
+ if not allowed_controls:
587
+ return True
588
+
589
+ base, sub = self._normalize_control_id(control_id)
590
+ norm_item = f"{base}({sub})" if sub else base
591
+
592
+ if norm_item in allowed_controls:
593
+ return True
594
+
595
+ # Allow PASS controls through even if they don't have existing implementations
596
+ if compliance_item.compliance_result in self.PASS_STATUSES:
597
+ return True
598
+
599
+ stats["skipped_not_in_plan"] += 1
600
+ if stats["skipped_not_in_plan"] <= 3:
601
+ logger.debug(f"Skipping control {norm_item} - not in plan (result: {compliance_item.compliance_result})")
602
+ return False
603
+
604
+ def _add_processed_item(self, compliance_item: Any, stats: dict) -> None:
605
+ """Add a processed item to collections and update statistics."""
606
+ self.all_compliance_items.append(compliance_item)
607
+ stats["processed_count"] += 1
608
+
609
+ # Build asset mapping
610
+ self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
611
+
612
+ # Categorize by result
613
+ if compliance_item.compliance_result in self.FAIL_STATUSES:
614
+ self.failed_compliance_items.append(compliance_item)
615
+ logger.debug(
616
+ f"Added failing compliance item: control={compliance_item.control_id}, "
617
+ f"result={compliance_item.compliance_result}, resource={compliance_item.resource_id}"
618
+ )
619
+
620
+ def _log_processing_summary(self, raw_compliance_data: list, stats: dict) -> None:
621
+ """Log summary of compliance data processing."""
622
+ logger.debug("Compliance item processing summary:")
623
+ logger.debug(f" - Total raw items: {len(raw_compliance_data)}")
624
+ logger.debug(f" - Skipped (no control_id): {stats['skipped_no_control']}")
625
+ logger.debug(f" - Skipped (no resource_id): {stats['skipped_no_resource']}")
626
+ logger.debug(f" - Skipped (not in plan): {stats['skipped_not_in_plan']}")
627
+ logger.debug(f" - Processed successfully: {stats['processed_count']}")
628
+
629
+ def _log_final_results(self) -> None:
630
+ """Log final processing results."""
477
631
  logger.debug(
478
632
  f"Processed {len(self.all_compliance_items)} compliance items: "
479
633
  f"{len(self.all_compliance_items) - len(self.failed_compliance_items)} passing, "
480
634
  f"{len(self.failed_compliance_items)} failing"
481
635
  )
636
+ logger.debug(
637
+ f"Control categorization: {len(self.passing_controls)} passing controls, "
638
+ f"{len(self.failing_controls)} failing controls"
639
+ )
640
+
641
+ def _categorize_controls_by_aggregation(self) -> None:
642
+ """
643
+ Categorize controls as passing or failing based on aggregated results across all compliance items.
644
+
645
+ This method uses project-scoped aggregation logic instead of the previous "any fail = control fails"
646
+ approach. For project-scoped integrations (like Wiz), this provides more accurate control status.
647
+ """
648
+
649
+ # Group all compliance items by control ID
650
+ control_items = self._group_items_by_control()
651
+
652
+ # Analyze each control's results
653
+ for control_key, items in control_items.items():
654
+ self._categorize_single_control(control_key, items)
655
+
656
+ def _group_items_by_control(self) -> dict:
657
+ """Group compliance items by control ID."""
658
+ from collections import defaultdict
659
+
660
+ control_items = defaultdict(list)
661
+ for item in self.all_compliance_items:
662
+ control_key = item.control_id.lower()
663
+ control_items[control_key].append(item)
664
+
665
+ return control_items
666
+
667
+ def _categorize_single_control(self, control_key: str, items: list) -> None:
668
+ """Categorize a single control based on its compliance items."""
669
+ from collections import Counter
670
+
671
+ results = [item.compliance_result for item in items]
672
+ result_counts = Counter(results)
673
+ total_items = len(results)
674
+
675
+ fail_count, pass_count, not_applicable_count = self._count_results(result_counts)
676
+
677
+ if fail_count == 0 and pass_count > 0:
678
+ self._mark_control_as_passing(control_key, items, pass_count, fail_count)
679
+ elif fail_count > 0:
680
+ self._handle_control_with_failures(control_key, items, fail_count, pass_count, total_items)
681
+ elif not_applicable_count > 0 and pass_count == 0 and fail_count == 0:
682
+ self._mark_control_as_not_applicable(control_key, items, not_applicable_count)
683
+ else:
684
+ logger.debug(f"Control {control_key} has unclear results: {dict(result_counts)}")
685
+
686
+ def _count_results(self, result_counts: dict) -> tuple[int, int, int]:
687
+ """Count pass, fail, and not applicable results from result counts."""
688
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
689
+ pass_statuses_lower = [status.lower() for status in self.PASS_STATUSES]
690
+ not_applicable_statuses_lower = [status.lower() for status in self.NOT_APPLICABLE_STATUSES]
691
+
692
+ fail_count = 0
693
+ pass_count = 0
694
+ not_applicable_count = 0
695
+
696
+ for result, count in result_counts.items():
697
+ if result is None: # Skip None results (controls without evidence)
698
+ continue
699
+ result_lower = result.lower()
700
+ if result_lower in fail_statuses_lower:
701
+ fail_count += count
702
+ elif result_lower in pass_statuses_lower:
703
+ pass_count += count
704
+ elif result_lower in not_applicable_statuses_lower:
705
+ not_applicable_count += count
706
+
707
+ return fail_count, pass_count, not_applicable_count
708
+
709
+ def _count_pass_fail_results(self, result_counts: dict) -> tuple[int, int]:
710
+ """Count pass and fail results from result counts (legacy method)."""
711
+ fail_count, pass_count, _ = self._count_results(result_counts)
712
+ return fail_count, pass_count
713
+
714
+ def _mark_control_as_passing(self, control_key: str, items: list, pass_count: int, fail_count: int) -> None:
715
+ """Mark a control as passing."""
716
+ self.passing_controls[control_key] = items[0] # Use first item as representative
717
+ logger.debug(f"Control {control_key} marked as PASSING: {pass_count}P/{fail_count}F")
718
+
719
+ def _mark_control_as_not_applicable(self, control_key: str, items: list, not_applicable_count: int) -> None:
720
+ """Mark a control as not applicable."""
721
+ self.not_applicable_controls[control_key] = items[0] # Use first item as representative
722
+ logger.debug(f"Control {control_key} marked as NOT_APPLICABLE: {not_applicable_count} items")
723
+
724
+ def _handle_control_with_failures(
725
+ self, control_key: str, items: list, fail_count: int, pass_count: int, total_items: int
726
+ ) -> None:
727
+ """Handle a control that has some failures."""
728
+ fail_ratio = fail_count / total_items
729
+ failure_threshold = getattr(self, "control_failure_threshold", 0.2)
730
+
731
+ if fail_ratio > failure_threshold:
732
+ self._mark_control_as_failing(control_key, items, pass_count, fail_count, fail_ratio, failure_threshold)
733
+ else:
734
+ self._mark_control_as_passing_with_warnings(
735
+ control_key, items, pass_count, fail_count, fail_ratio, failure_threshold
736
+ )
737
+
738
+ def _mark_control_as_failing(
739
+ self,
740
+ control_key: str,
741
+ items: list,
742
+ pass_count: int,
743
+ fail_count: int,
744
+ fail_ratio: float,
745
+ failure_threshold: float,
746
+ ) -> None:
747
+ """Mark a control as failing due to significant failures."""
748
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
749
+ failing_item = next(item for item in items if item.compliance_result.lower() in fail_statuses_lower)
750
+ self.failing_controls[control_key] = failing_item
751
+ logger.debug(
752
+ f"Control {control_key} marked as FAILING: {pass_count}P/{fail_count}F "
753
+ f"({fail_ratio:.1%} fail rate > {failure_threshold:.1%} threshold)"
754
+ )
755
+
756
+ def _mark_control_as_passing_with_warnings(
757
+ self,
758
+ control_key: str,
759
+ items: list,
760
+ pass_count: int,
761
+ fail_count: int,
762
+ fail_ratio: float,
763
+ failure_threshold: float,
764
+ ) -> None:
765
+ """Mark a control as passing despite low failure rate."""
766
+ self.passing_controls[control_key] = items[0]
767
+ logger.debug(
768
+ f"Control {control_key} marked as PASSING (low fail rate): {pass_count}P/{fail_count}F "
769
+ f"({fail_ratio:.1%} fail rate < {failure_threshold:.1%} threshold)"
770
+ )
482
771
 
483
772
  def create_asset_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[IntegrationAsset]:
484
773
  """
@@ -528,6 +817,14 @@ class ComplianceIntegration(ScannerIntegration, ABC):
528
817
  control_labels = [compliance_item.control_id] if compliance_item.control_id else []
529
818
  severity = self._map_severity(compliance_item.severity)
530
819
 
820
+ # Extract ARNs if available (for AWS Audit Manager and other integrations)
821
+ arns = None
822
+ if hasattr(compliance_item, "resource_arns"):
823
+ arns = compliance_item.resource_arns
824
+ # Only use ARNs if they're non-empty
825
+ if not arns:
826
+ arns = None
827
+
531
828
  finding = IntegrationFinding(
532
829
  control_labels=control_labels,
533
830
  title=f"Compliance Violation: {compliance_item.control_id}",
@@ -542,10 +839,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
542
839
  last_seen=self.scan_date,
543
840
  scan_date=self.scan_date,
544
841
  asset_identifier=compliance_item.resource_id,
842
+ issue_asset_identifier_value=arns,
545
843
  vulnerability_type="Compliance Violation",
546
844
  rule_id=compliance_item.control_id,
547
845
  baseline=compliance_item.framework,
548
- affected_controls=",".join(compliance_item.control_id),
846
+ affected_controls=compliance_item.control_id,
549
847
  )
550
848
 
551
849
  # Ensure affected controls are set to the normalized control label (e.g., RA-5, AC-2(1))
@@ -590,31 +888,60 @@ class ComplianceIntegration(ScannerIntegration, ABC):
590
888
 
591
889
  def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
592
890
  """
593
- Fetch findings from failed compliance items.
891
+ Fetch findings from failed compliance items to create RegScale issues.
594
892
 
595
893
  :param args: Variable positional arguments
596
894
  :param kwargs: Variable keyword arguments
597
- :return: Iterator of integration findings
895
+ :return: Iterator of integration findings (will be converted to RegScale Issue objects)
598
896
  :rtype: Iterator[IntegrationFinding]
599
897
  """
600
- logger.info("Fetching findings from failed compliance items...")
898
+ logger.info(f"Preparing to create issues from {len(self.failed_compliance_items)} failed compliance items...")
899
+
900
+ # Debug: Show sample of failed items
901
+ if self.failed_compliance_items:
902
+ sample_size = min(5, len(self.failed_compliance_items))
903
+ sample = self.failed_compliance_items[:sample_size]
904
+ logger.debug(f"Sample failed items (first {sample_size}):")
905
+ for item in sample:
906
+ logger.debug(
907
+ f" - Control: {item.control_id}, Result: {item.compliance_result}, Resource: {item.resource_id}"
908
+ )
601
909
 
602
910
  total = len(self.failed_compliance_items)
603
- task_id = self.finding_progress.add_task(
604
- f"[#f68d1f]Creating findings from {total} failed compliance item(s)...",
605
- total=total or None,
606
- )
911
+ # Backwards compatibility: check if finding_progress exists and has add_task method
912
+ task_id = None
913
+ if self.finding_progress is not None and hasattr(self.finding_progress, "add_task"):
914
+ task_id = self.finding_progress.add_task(
915
+ f"[#f68d1f]Creating issues from {total} failed compliance item(s)...",
916
+ total=total or None,
917
+ )
607
918
 
919
+ findings_created = 0
608
920
  for compliance_item in self.failed_compliance_items:
609
921
  finding = self.create_finding_from_compliance_item(compliance_item)
610
922
  if finding:
611
- self.finding_progress.advance(task_id, 1)
923
+ findings_created += 1
924
+ # Backwards compatibility: check if finding_progress exists and has advance method
925
+ if (
926
+ task_id is not None
927
+ and self.finding_progress is not None
928
+ and hasattr(self.finding_progress, "advance")
929
+ ):
930
+ self.finding_progress.advance(task_id, 1)
612
931
  yield finding
613
932
 
614
933
  # Ensure task completes if total is known
615
- if total:
934
+ # Backwards compatibility: check if finding_progress exists and has update method
935
+ if (
936
+ total
937
+ and task_id is not None
938
+ and self.finding_progress is not None
939
+ and hasattr(self.finding_progress, "update")
940
+ ):
616
941
  self.finding_progress.update(task_id, completed=total)
617
942
 
943
+ logger.info(f"Prepared {findings_created} issue records from {total} failed compliance items")
944
+
618
945
  def sync_compliance(self) -> None:
619
946
  """
620
947
  Main method to sync compliance data.
@@ -660,6 +987,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
660
987
  assets_processed = self.update_regscale_assets(iter(assets))
661
988
  self._log_asset_results(assets_processed)
662
989
 
990
+ # Refresh the asset map after creating/updating assets to ensure
991
+ # the map contains all assets for issue creation
992
+ logger.debug("Refreshing asset map after asset sync...")
993
+ self.asset_map_by_identifier.update(self.get_asset_map())
994
+
663
995
  def _log_asset_results(self, assets_processed: int) -> None:
664
996
  """
665
997
  Log asset processing results.
@@ -707,8 +1039,32 @@ class ComplianceIntegration(ScannerIntegration, ABC):
707
1039
  logger.debug("No findings to process into issues")
708
1040
  return
709
1041
 
710
- issues_created, issues_skipped = self._process_findings_to_issues(findings)
711
- self._log_issue_results(issues_created, issues_skipped)
1042
+ # Ensure asset map is populated before processing issues
1043
+ # This handles cases where assets were created in previous runs
1044
+ if not self.asset_map_by_identifier:
1045
+ logger.debug("Loading asset map before issue processing...")
1046
+ self.asset_map_by_identifier.update(self.get_asset_map())
1047
+
1048
+ findings_processed, findings_skipped = self._process_findings_to_issues(findings)
1049
+
1050
+ # CRITICAL FIX: Flush bulk issue operations to database
1051
+ # This ensures all issues created/updated in bulk mode are persisted
1052
+ logger.debug(f"Calling bulk_save for {findings_processed} processed findings ({findings_skipped} skipped)...")
1053
+ issue_results = regscale_models.Issue.bulk_save()
1054
+ logger.debug(
1055
+ f"Bulk save completed - created: {issue_results.get('created_count', 0)}, updated: {issue_results.get('updated_count', 0)}"
1056
+ )
1057
+
1058
+ # Update result counts with actual database operations
1059
+ if hasattr(self, "_results"):
1060
+ if "issues" not in self._results:
1061
+ self._results["issues"] = {}
1062
+ self._results["issues"].update(issue_results)
1063
+
1064
+ # Use actual database results for logging
1065
+ issues_created = issue_results.get("created_count", 0)
1066
+ issues_updated = issue_results.get("updated_count", 0)
1067
+ self._log_issue_results_accurate(issues_created, issues_updated, findings_processed, findings_skipped)
712
1068
 
713
1069
  def _process_findings_to_issues(self, findings: List[IntegrationFinding]) -> tuple[int, int]:
714
1070
  """
@@ -720,14 +1076,20 @@ class ComplianceIntegration(ScannerIntegration, ABC):
720
1076
  issues_created = 0
721
1077
  issues_skipped = 0
722
1078
 
723
- for finding in findings:
1079
+ logger.debug(f"Processing {len(findings)} findings into issues...")
1080
+ for i, finding in enumerate(findings):
724
1081
  try:
1082
+ logger.debug(
1083
+ f"Processing finding {i + 1}/{len(findings)}: external_id='{finding.external_id}', asset_identifier='{finding.asset_identifier}"
1084
+ )
725
1085
  if self._process_single_finding(finding):
726
1086
  issues_created += 1
1087
+ logger.debug(f" -> Finding {i + 1} processed successfully")
727
1088
  else:
728
1089
  issues_skipped += 1
1090
+ logger.debug(f" -> Finding {i + 1} skipped")
729
1091
  except Exception as e:
730
- logger.error(f"Error processing finding: {e}")
1092
+ logger.error(f"Error processing finding {i + 1}: {e}")
731
1093
  issues_skipped += 1
732
1094
 
733
1095
  return issues_created, issues_skipped
@@ -739,14 +1101,25 @@ class ComplianceIntegration(ScannerIntegration, ABC):
739
1101
  :param finding: Finding to process
740
1102
  :return: True if issue was created/updated, False if skipped
741
1103
  """
1104
+ logger.debug(
1105
+ f" -> Processing finding: external_id='{finding.external_id}', asset_identifier='{finding.asset_identifier}'"
1106
+ )
1107
+
742
1108
  asset = self._get_or_create_asset_for_finding(finding)
743
1109
  if not asset:
1110
+ logger.debug(f" -> Asset not found/created for identifier '{finding.asset_identifier}', skipping finding")
744
1111
  self._log_asset_not_found_error(finding)
745
1112
  return False
746
1113
 
1114
+ logger.debug(f" -> Found/created asset {asset.id} for identifier '{finding.asset_identifier}'")
747
1115
  issue_title = self.get_issue_title(finding)
748
1116
  issue = self.create_or_update_issue_from_finding(title=issue_title, finding=finding)
749
- return issue is not None
1117
+ success = issue is not None
1118
+ if success and issue:
1119
+ logger.debug(f" -> Successfully processed finding -> issue {issue.id}")
1120
+ else:
1121
+ logger.debug(" -> Failed to create/update issue for finding")
1122
+ return success
750
1123
 
751
1124
  def _get_or_create_asset_for_finding(self, finding: IntegrationFinding) -> Optional[regscale_models.Asset]:
752
1125
  """
@@ -778,6 +1151,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
778
1151
  def _log_issue_results(self, issues_created: int, issues_skipped: int) -> None:
779
1152
  """
780
1153
  Log issue processing results.
1154
+ DEPRECATED: Use _log_issue_results_accurate for accurate reporting.
781
1155
 
782
1156
  :param int issues_created: Number of issues created/updated
783
1157
  :param int issues_skipped: Number of issues skipped
@@ -791,6 +1165,36 @@ class ComplianceIntegration(ScannerIntegration, ABC):
791
1165
  else:
792
1166
  logger.debug("No issues processed")
793
1167
 
1168
+ def _log_issue_results_accurate(
1169
+ self, issues_created: int, issues_updated: int, findings_processed: int, findings_skipped: int
1170
+ ) -> None:
1171
+ """
1172
+ Log accurate issue processing results based on actual database operations.
1173
+
1174
+ :param int issues_created: Number of new issues created in database
1175
+ :param int issues_updated: Number of existing issues updated in database
1176
+ :param int findings_processed: Number of findings that were processed
1177
+ :param int findings_skipped: Number of findings that were skipped
1178
+ :return: None
1179
+ :rtype: None
1180
+ """
1181
+ total_db_operations = issues_created + issues_updated
1182
+
1183
+ if total_db_operations > 0:
1184
+ logger.info(
1185
+ f"Processed {findings_processed} findings into issues: {issues_created} new issues created, {issues_updated} existing issues updated"
1186
+ )
1187
+ if findings_skipped > 0:
1188
+ logger.info(f"Skipped {findings_skipped} findings (assets not found)")
1189
+ elif findings_skipped > 0:
1190
+ logger.warning(
1191
+ f"Issues processed: 0 created/updated, {findings_skipped} findings skipped (assets not found)"
1192
+ )
1193
+ else:
1194
+ logger.debug(
1195
+ f"Processed {findings_processed} findings but no database changes were needed (all issues up-to-date)"
1196
+ )
1197
+
794
1198
  def _finalize_scan_history(self, scan_history: regscale_models.ScanHistory) -> None:
795
1199
  """
796
1200
  Finalize scan history with error handling.
@@ -869,6 +1273,9 @@ class ComplianceIntegration(ScannerIntegration, ABC):
869
1273
  logger.warning("No control implementations found for assessment processing")
870
1274
  return
871
1275
 
1276
+ # Build control lookup cache for fast O(1) matching
1277
+ self._build_control_lookup_cache(implementations)
1278
+
872
1279
  all_control_ids = set(self.passing_controls.keys()) | set(self.failing_controls.keys())
873
1280
  logger.info(f"Processing assessments for {len(all_control_ids)} controls with compliance data")
874
1281
  logger.info(f"Control IDs with data: {sorted(list(all_control_ids))}")
@@ -909,6 +1316,56 @@ class ComplianceIntegration(ScannerIntegration, ABC):
909
1316
  logger.info(f"Found {len(implementations)} control implementations")
910
1317
  return implementations
911
1318
 
1319
+ def _build_control_lookup_cache(self, implementations: List[ControlImplementation]) -> None:
1320
+ """
1321
+ Build a lookup cache mapping control ID variations to implementations and security controls.
1322
+
1323
+ This dramatically improves performance by:
1324
+ 1. Fetching all SecurityControl objects once (instead of once per match attempt)
1325
+ 2. Pre-computing all control ID variations
1326
+ 3. Creating a dictionary for O(1) lookup instead of O(n) iteration
1327
+
1328
+ For 1011 implementations x 71 controls = 71,781 iterations in the old code.
1329
+ New code: 1011 fetches + 71 dictionary lookups = ~1082 operations (67x faster!)
1330
+
1331
+ :param List[ControlImplementation] implementations: List of control implementations to cache
1332
+ :return: None
1333
+ :rtype: None
1334
+ """
1335
+ if self._control_lookup_cache:
1336
+ # Cache already built
1337
+ return
1338
+
1339
+ logger.debug(f"Building control lookup cache for {len(implementations)} implementations...")
1340
+ start_time = time.time()
1341
+
1342
+ for implementation in implementations:
1343
+ try:
1344
+ security_control = SecurityControl.get_object(object_id=implementation.controlID)
1345
+ if not security_control or not security_control.controlId:
1346
+ continue
1347
+
1348
+ # Generate all variations of this control ID for flexible matching
1349
+ control_variations = self._control_matcher._get_control_id_variations(security_control.controlId)
1350
+
1351
+ # Map each variation to this implementation + security control pair
1352
+ for variation in control_variations:
1353
+ # Store the first implementation found for each variation
1354
+ # (if multiple implementations have the same control, use the first one)
1355
+ if variation not in self._control_lookup_cache:
1356
+ self._control_lookup_cache[variation] = (implementation, security_control)
1357
+
1358
+ except Exception as e: # noqa: BLE001
1359
+ logger.error(
1360
+ f"Error caching implementation {implementation.id} with controlID {implementation.controlID}: {e}"
1361
+ )
1362
+ continue
1363
+
1364
+ elapsed = time.time() - start_time
1365
+ logger.info(
1366
+ f"Built control lookup cache with {len(self._control_lookup_cache)} control ID variations in {elapsed:.2f}s"
1367
+ )
1368
+
912
1369
  def _log_sample_controls(self, implementations: List[ControlImplementation]) -> None:
913
1370
  """
914
1371
  Log sample control IDs for debugging purposes.
@@ -961,6 +1418,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
961
1418
 
962
1419
  if impl.id in processed_impl_today and self._find_existing_assessment_cached(impl.id, self.scan_date):
963
1420
  logger.debug(f"Skipping duplicate assessment for implementation {impl.id} (already processed today)")
1421
+ # IMPORTANT: Still update the control implementation status even when skipping assessment creation
1422
+ # This ensures status is updated on subsequent runs
1423
+ if self.update_control_status:
1424
+ logger.debug(f"Updating control implementation status for {control_id} (existing assessment)")
1425
+ self._update_implementation_status(impl, result)
964
1426
  else:
965
1427
  self._create_control_assessment(
966
1428
  implementation=impl,
@@ -972,7 +1434,6 @@ class ComplianceIntegration(ScannerIntegration, ABC):
972
1434
  processed_impl_today.add(impl.id)
973
1435
 
974
1436
  self._record_control_mapping(control_id, impl.id)
975
- self._map_assets_to_control_component(sec_control, items)
976
1437
  return 1
977
1438
  except Exception as e: # noqa: BLE001
978
1439
  logger.error(f"Error processing control assessment for '{control_id}': {e}")
@@ -987,41 +1448,31 @@ class ComplianceIntegration(ScannerIntegration, ABC):
987
1448
  """
988
1449
  Find matching implementation and security control for a control ID.
989
1450
 
1451
+ Uses ControlMatcher for robust control ID matching with leading zero normalization.
1452
+ Performance optimized with pre-built lookup cache for O(1) matching.
1453
+
990
1454
  :param str control_id: Control identifier to match
991
- :param List[ControlImplementation] implementations: Available implementations
1455
+ :param List[ControlImplementation] implementations: Available implementations (used for fallback only)
992
1456
  :return: Tuple of matching implementation and security control, or (None, None)
993
1457
  :rtype: tuple[Optional[ControlImplementation], Optional[SecurityControl]]
994
1458
  """
995
- matching_implementation = None
996
- matching_security_control = None
997
- for implementation in implementations:
998
- try:
999
- security_control = SecurityControl.get_object(object_id=implementation.controlID)
1000
- if not security_control:
1001
- logger.debug(
1002
- f"No security control found for implementation {implementation.id} with controlID: {implementation.controlID}"
1003
- )
1004
- continue
1005
- security_control_id = security_control.controlId
1006
- if not security_control_id:
1007
- logger.debug(f"Security control {security_control.id} has no controlId")
1008
- continue
1459
+ # Generate all variations of the search control ID for matching
1460
+ search_variations = self._control_matcher._get_control_id_variations(control_id)
1461
+ if not search_variations:
1462
+ logger.debug(f"Could not generate control ID variations for: {control_id}")
1463
+ return None, None
1464
+
1465
+ # Try to find a match using the pre-built lookup cache (O(1) lookup)
1466
+ for variation in search_variations:
1467
+ if variation in self._control_lookup_cache:
1468
+ implementation, security_control = self._control_lookup_cache[variation]
1009
1469
  logger.debug(
1010
- f"Comparing extracted '{control_id}' with RegScale control '{security_control_id}' (impl: {implementation.id})"
1011
- )
1012
- if self._control_ids_match(control_id, security_control_id):
1013
- matching_implementation = implementation
1014
- matching_security_control = security_control
1015
- logger.info(
1016
- f"✅ MATCH FOUND: '{security_control_id}' == '{control_id}' (implementation: {implementation.id})"
1017
- )
1018
- break
1019
- except Exception as e: # noqa: BLE001
1020
- logger.error(
1021
- f"Error processing implementation {implementation.id} with controlID {implementation.controlID}: {e}"
1470
+ f" MATCH FOUND: '{security_control.controlId}' == '{control_id}' (implementation: {implementation.id})"
1022
1471
  )
1023
- continue
1024
- return matching_implementation, matching_security_control
1472
+ return implementation, security_control
1473
+
1474
+ # No match found in cache
1475
+ return None, None
1025
1476
 
1026
1477
  def _log_no_match(self, control_id: str, implementations: List[ControlImplementation]) -> None:
1027
1478
  """
@@ -1048,7 +1499,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1048
1499
  Determine overall assessment result for a control.
1049
1500
 
1050
1501
  :param str control_id: Control identifier to check
1051
- :return: Assessment result ('Pass' or 'Fail')
1502
+ :return: Assessment result ('Pass', 'Fail', or 'Not Applicable')
1052
1503
  :rtype: str
1053
1504
  """
1054
1505
  is_failing = (
@@ -1056,7 +1507,18 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1056
1507
  or control_id.lower() in self.failing_controls
1057
1508
  or control_id.upper() in self.failing_controls
1058
1509
  )
1059
- return "Fail" if is_failing else "Pass"
1510
+ if is_failing:
1511
+ return "Fail"
1512
+
1513
+ is_not_applicable = (
1514
+ control_id in self.not_applicable_controls
1515
+ or control_id.lower() in self.not_applicable_controls
1516
+ or control_id.upper() in self.not_applicable_controls
1517
+ )
1518
+ if is_not_applicable:
1519
+ return self.NOT_APPLICABLE_LABEL
1520
+
1521
+ return "Pass"
1060
1522
 
1061
1523
  def _get_control_compliance_items(self, control_id: str) -> List[ComplianceItem]:
1062
1524
  """
@@ -1093,48 +1555,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1093
1555
  except Exception:
1094
1556
  pass
1095
1557
 
1096
- def _map_assets_to_control_component(self, sec_control: SecurityControl, items: List[ComplianceItem]) -> None:
1097
- """
1098
- Map assets to control-specific components for organization.
1099
-
1100
- :param SecurityControl sec_control: Security control to create component for
1101
- :param List[ComplianceItem] items: Compliance items with asset references
1102
- :return: None
1103
- :rtype: None
1104
- """
1105
- try:
1106
- component_title = f"Control {sec_control.controlId}"
1107
- component = self.components_by_title.get(component_title) if hasattr(self, "components_by_title") else None
1108
- if not component:
1109
- component = regscale_models.Component(
1110
- title=component_title,
1111
- componentType=regscale_models.ComponentType.Hardware,
1112
- securityPlansId=self.plan_id,
1113
- description=component_title,
1114
- componentOwnerId=self.get_assessor_id(),
1115
- ).get_or_create()
1116
- regscale_models.ComponentMapping(
1117
- componentId=component.id,
1118
- securityPlanId=self.plan_id,
1119
- ).get_or_create()
1120
- if hasattr(self, "components_by_title"):
1121
- self.components_by_title[component_title] = component
1122
-
1123
- for item in items:
1124
- asset = self.get_asset_by_identifier(getattr(item, "resource_id", ""))
1125
- if not asset:
1126
- continue
1127
- regscale_models.AssetMapping(
1128
- assetId=asset.id,
1129
- componentId=component.id,
1130
- ).get_or_create_with_status()
1131
- except Exception as map_exc: # noqa: BLE001
1132
- logger.debug(f"Control-to-asset mapping skipped due to: {map_exc}")
1133
-
1134
1558
  @staticmethod
1135
1559
  def _parse_control_id(control_id: str) -> tuple[str, Optional[str]]:
1136
1560
  """
1137
1561
  Parse a control id like 'AC-2(1)', 'AC-2 (1)', 'AC-2-1' into (base, sub).
1562
+ Normalizes leading zeros (e.g., AC-01 becomes AC-1).
1138
1563
 
1139
1564
  Returns (base, None) when no subcontrol.
1140
1565
 
@@ -1148,8 +1573,22 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1148
1573
  if not m:
1149
1574
  return cid.upper(), None
1150
1575
  base = m.group(1).upper()
1576
+ # Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)
1577
+ if "-" in base:
1578
+ prefix, number = base.split("-", 1)
1579
+ try:
1580
+ normalized_number = str(int(number))
1581
+ base = f"{prefix}-{normalized_number}"
1582
+ except ValueError:
1583
+ pass # Keep original if conversion fails
1151
1584
  # Subcontrol may be captured in group 2, 3, or 4 depending on the branch matched
1152
1585
  sub = m.group(2) or m.group(3) or m.group(4)
1586
+ # Normalize leading zeros in subcontrol (e.g., 01 -> 1)
1587
+ if sub:
1588
+ try:
1589
+ sub = str(int(sub))
1590
+ except ValueError:
1591
+ pass # Keep original if conversion fails
1153
1592
  return base, sub
1154
1593
 
1155
1594
  @classmethod
@@ -1181,6 +1620,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1181
1620
  def _normalize_control_id(control_id: str) -> tuple[str, Optional[str]]:
1182
1621
  """
1183
1622
  Normalize control id to a canonical tuple (BASE, SUB) for set membership.
1623
+ Normalizes leading zeros (e.g., AC-01 becomes AC-1).
1184
1624
 
1185
1625
  :param str control_id: Control identifier to normalize
1186
1626
  :return: Tuple of (base_control, subcontrol) in canonical form
@@ -1192,7 +1632,21 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1192
1632
  if not m:
1193
1633
  return cid.upper(), None
1194
1634
  base = m.group(1).upper()
1635
+ # Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)
1636
+ if "-" in base:
1637
+ prefix, number = base.split("-", 1)
1638
+ try:
1639
+ normalized_number = str(int(number))
1640
+ base = f"{prefix}-{normalized_number}"
1641
+ except ValueError:
1642
+ pass # Keep original if conversion fails
1195
1643
  sub = m.group(2) or m.group(3) or m.group(4)
1644
+ # Normalize leading zeros in subcontrol (e.g., 01 -> 1)
1645
+ if sub:
1646
+ try:
1647
+ sub = str(int(sub))
1648
+ except ValueError:
1649
+ pass # Keep original if conversion fails
1196
1650
  return base, sub
1197
1651
 
1198
1652
  def _create_control_assessment(
@@ -1259,8 +1713,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1259
1713
  pass
1260
1714
  else:
1261
1715
  # Create new assessment
1716
+ # leadAssessorId will be set automatically from the token via the Assessment model's default_factory
1262
1717
  assessment = Assessment(
1263
- leadAssessorId=implementation.createdById,
1264
1718
  title=f"{self.title} compliance assessment for {control_id.upper()}",
1265
1719
  assessmentType="Control Testing",
1266
1720
  plannedStart=get_current_datetime(),
@@ -1335,9 +1789,30 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1335
1789
  ] # NOSONAR
1336
1790
 
1337
1791
  if compliance_items:
1338
- # Group by result for summary
1339
- pass_count = len([item for item in compliance_items if item.compliance_result in self.PASS_STATUSES])
1340
- fail_count = len(compliance_items) - pass_count
1792
+ # Group by result for more detailed summary
1793
+ compliant_count = len([item for item in compliance_items if item.compliance_result in self.PASS_STATUSES])
1794
+ noncompliant_count = len(
1795
+ [item for item in compliance_items if item.compliance_result in self.FAIL_STATUSES]
1796
+ )
1797
+ inconclusive_count = len(
1798
+ [item for item in compliance_items if item.compliance_result in self.INCONCLUSIVE_STATUSES]
1799
+ )
1800
+ not_applicable_count = len(
1801
+ [item for item in compliance_items if item.compliance_result in self.NOT_APPLICABLE_STATUSES]
1802
+ )
1803
+
1804
+ # Calculate confidence score based on evidence quality
1805
+ total_evaluated = compliant_count + noncompliant_count + inconclusive_count
1806
+ confidence_score = 0
1807
+ if total_evaluated > 0:
1808
+ # Confidence is based on the ratio of conclusive evidence (compliant + noncompliant) to total
1809
+ conclusive_count = compliant_count + noncompliant_count
1810
+ confidence_score = round((conclusive_count / total_evaluated) * 100, 2)
1811
+
1812
+ # Calculate compliance score
1813
+ compliance_score = 0
1814
+ if (compliant_count + noncompliant_count) > 0:
1815
+ compliance_score = round((compliant_count / (compliant_count + noncompliant_count)) * 100, 2)
1341
1816
 
1342
1817
  # Count unique resources across all policy assessments for this control
1343
1818
  unique_resources = set()
@@ -1346,7 +1821,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1346
1821
  for item in compliance_items:
1347
1822
  unique_resources.add(item.resource_id)
1348
1823
  # Get policy name for aggregation
1349
- if hasattr(item, "description"):
1824
+ if hasattr(item, "description") and item.description: # Check for non-empty description
1350
1825
  unique_policies.add(
1351
1826
  item.description[:50] + "..." if len(item.description) > 50 else item.description
1352
1827
  )
@@ -1358,21 +1833,374 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1358
1833
  f"""
1359
1834
  <div style="margin-top: 20px;">
1360
1835
  <h4>Aggregated Assessment Summary</h4>
1836
+ <p><strong>Control {control_id} Analysis:</strong>
1837
+ status=<span style="color: {result_color}; font-weight: bold;">{result.upper()}</span>,
1838
+ score={compliance_score:.2f},
1839
+ confidence={confidence_score:.0f},
1840
+ compliant={compliant_count},
1841
+ noncompliant={noncompliant_count},
1842
+ inconclusive={inconclusive_count}
1843
+ {f', not_applicable={not_applicable_count}' if not_applicable_count > 0 else ''}
1844
+ </p>
1845
+ <hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 10px 0;">
1361
1846
  <p><strong>Policy Assessments:</strong> {len(compliance_items)} total</p>
1362
1847
  <p><strong>Unique Policies Tested:</strong> {len(unique_policies)}</p>
1363
1848
  <p><strong>Unique Resources Assessed:</strong> {len(unique_resources)}</p>
1364
- <p><strong>Passing Assessments:</strong> <span style="color: #2e7d32;">{pass_count}</span></p>
1365
- <p><strong>Failing Assessments:</strong> <span style="color: #d32f2f;">{fail_count}</span></p>
1366
- <p><strong>Overall Control Result:</strong> <span style="color: {result_color}; font-weight: bold;">{result}</span></p>
1849
+ <hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 10px 0;">
1850
+ <h5>Evidence Breakdown:</h5>
1851
+ <p style="margin-left: 20px;">
1852
+ <span style="color: #2e7d32;">✓ Compliant:</span> {compliant_count}<br>
1853
+ <span style="color: #d32f2f;">✗ Non-Compliant:</span> {noncompliant_count}<br>
1854
+ <span style="color: #ff9800;">⚠ Inconclusive:</span> {inconclusive_count}
1855
+ {f'<br><span style="color: #9e9e9e;">- Not Applicable:</span> {not_applicable_count}' if not_applicable_count > 0 else ''}
1856
+ </p>
1857
+ <hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 10px 0;">
1858
+ <p><strong>Compliance Score:</strong> {compliance_score:.2f}%
1859
+ <span style="font-size: 0.9em; color: #666;">(compliant / (compliant + noncompliant))</span></p>
1860
+ <p><strong>Confidence Level:</strong> {confidence_score:.0f}%
1861
+ <span style="font-size: 0.9em; color: #666;">(conclusive evidence ratio)</span></p>
1862
+ <p><strong>Overall Control Result:</strong>
1863
+ <span style="color: {result_color}; font-weight: bold;">{result}</span></p>
1367
1864
  </div>
1368
1865
  """
1369
1866
  )
1370
1867
 
1868
+ # Add detailed failure information
1869
+ if result == "Fail" and noncompliant_count > 0:
1870
+ failed_items = [item for item in compliance_items if item.compliance_result in self.FAIL_STATUSES]
1871
+ html_parts.append(self._create_failure_details_section(failed_items))
1872
+
1873
+ return "\n".join(html_parts)
1874
+
1875
+ def _create_failure_details_section(self, failed_items: List[ComplianceItem]) -> str:
1876
+ """
1877
+ Create detailed failure information section for failed compliance items.
1878
+
1879
+ :param List[ComplianceItem] failed_items: List of failed compliance items
1880
+ :return: HTML section with detailed failure information
1881
+ :rtype: str
1882
+ """
1883
+ html_parts = [self._get_failure_section_header()]
1884
+
1885
+ for idx, item in enumerate(failed_items, 1):
1886
+ html_parts.append(self._process_failed_item(idx, item))
1887
+
1888
+ html_parts.append("</div>")
1889
+ return "\n".join(html_parts)
1890
+
1891
+ def _get_failure_section_header(self) -> str:
1892
+ """Get the HTML header for failure details section."""
1893
+ return """
1894
+ <div style="margin-top: 20px; padding: 15px; background-color: #fff3e0;
1895
+ border-left: 4px solid #ff9800; border-radius: 5px;">
1896
+ <h4 style="color: #e65100; margin-top: 0;">Failed Evidence Details</h4>
1897
+ """
1898
+
1899
+ def _process_failed_item(self, idx: int, item: ComplianceItem) -> str:
1900
+ """
1901
+ Process a single failed item and return HTML.
1902
+
1903
+ :param int idx: The index of the failed item
1904
+ :param ComplianceItem item: The failed compliance item
1905
+ :return: HTML for the failed item
1906
+ :rtype: str
1907
+ """
1908
+ if self._has_aws_evidence(item):
1909
+ return self._process_aws_item_with_evidence(idx, item)
1910
+ return self._process_non_aws_item(idx, item)
1911
+
1912
+ def _has_aws_evidence(self, item: ComplianceItem) -> bool:
1913
+ """Check if item has AWS Audit Manager evidence."""
1914
+ return hasattr(item, "evidence_items") and item.evidence_items
1915
+
1916
+ def _process_aws_item_with_evidence(self, idx: int, item: ComplianceItem) -> str:
1917
+ """Process AWS item with evidence details."""
1918
+ evidence_categories = self._categorize_evidence(item)
1919
+
1920
+ if not evidence_categories["failed"]:
1921
+ return ""
1922
+
1923
+ html_parts = []
1924
+ html_parts.append(self._create_failed_check_header(idx, item, evidence_categories))
1925
+ html_parts.append(self._create_failed_evidence_details(evidence_categories["failed"]))
1926
+ html_parts.append(self._add_remediation_guidance(item))
1927
+ html_parts.append("</div>")
1928
+
1371
1929
  return "\n".join(html_parts)
1372
1930
 
1931
+ def _categorize_evidence(self, item: ComplianceItem) -> Dict[str, List[Any]]:
1932
+ """
1933
+ Categorize evidence items by compliance status.
1934
+
1935
+ :param ComplianceItem item: The compliance item with evidence
1936
+ :return: Dictionary with categorized evidence
1937
+ :rtype: Dict[str, List[Any]]
1938
+ """
1939
+ categories = {"failed": [], "compliant": [], "inconclusive": []}
1940
+
1941
+ for evidence in item.evidence_items:
1942
+ compliance_check = self._get_evidence_compliance_check(item, evidence)
1943
+
1944
+ if compliance_check == "FAILED":
1945
+ categories["failed"].append(evidence)
1946
+ elif compliance_check == "COMPLIANT":
1947
+ categories["compliant"].append(evidence)
1948
+ else:
1949
+ categories["inconclusive"].append(evidence)
1950
+
1951
+ return categories
1952
+
1953
+ def _get_evidence_compliance_check(self, item: ComplianceItem, evidence: Any) -> Optional[str]:
1954
+ """Get compliance check result for evidence."""
1955
+ if hasattr(item, "_get_evidence_compliance"):
1956
+ return item._get_evidence_compliance(evidence)
1957
+ return None
1958
+
1959
+ def _create_failed_check_header(
1960
+ self, idx: int, item: ComplianceItem, evidence_categories: Dict[str, List[Any]]
1961
+ ) -> str:
1962
+ """Create HTML header for failed check."""
1963
+ return f"""
1964
+ <div style="margin-top: 15px; padding: 10px; background-color: #ffebee; border-radius: 3px;">
1965
+ <h5 style="color: #c62828; margin-top: 0;">
1966
+ Failed Check #{idx}: {item.control_id}
1967
+ </h5>
1968
+ <p><strong>Resource:</strong> {getattr(item, 'resource_name', item.resource_id)}</p>
1969
+ <p><strong>Evidence Summary:</strong>
1970
+ {len(evidence_categories["failed"])} failed, {len(evidence_categories["compliant"])} compliant,
1971
+ {len(evidence_categories["inconclusive"])} inconclusive</p>
1972
+ """
1973
+
1974
+ def _create_failed_evidence_details(self, failed_evidence: List[Any]) -> str:
1975
+ """Create HTML for failed evidence details."""
1976
+ html_parts = ['<div style="margin-top: 10px;"><strong>Failed Evidence:</strong><ul>']
1977
+
1978
+ # Limit to 10 failed evidence items
1979
+ for evidence in failed_evidence[:10]:
1980
+ html_parts.append(self._format_single_evidence(evidence))
1981
+
1982
+ html_parts.append("</ul></div>")
1983
+ return "\n".join(html_parts)
1984
+
1985
+ def _format_single_evidence(self, evidence: Dict[str, Any]) -> str:
1986
+ """Format a single evidence item as HTML."""
1987
+ evidence_html = []
1988
+ evidence_source = evidence.get("dataSource", "Unknown source")
1989
+ evidence_id = evidence.get("id", "")[:50]
1990
+
1991
+ evidence_html.append(f"<li><strong>Source:</strong> {evidence_source}")
1992
+
1993
+ if evidence_id:
1994
+ evidence_html.append(f"<br><strong>Evidence ID:</strong> {evidence_id}")
1995
+
1996
+ resources_info = self._get_resources_info(evidence)
1997
+ if resources_info:
1998
+ evidence_html.append(f'<br><strong>Resources:</strong><ul><li>{"</li><li>".join(resources_info)}</li></ul>')
1999
+
2000
+ evidence_html.append("</li>")
2001
+ return "\n".join(evidence_html)
2002
+
2003
+ def _get_resources_info(self, evidence: Dict[str, Any]) -> List[str]:
2004
+ """Extract resource information from evidence."""
2005
+ resources_info = []
2006
+ resources_included = evidence.get("resourcesIncluded", [])
2007
+
2008
+ # Limit to 5 resources per evidence
2009
+ for resource in resources_included[:5]:
2010
+ resource_str = self._format_resource(resource)
2011
+ if resource_str:
2012
+ resources_info.append(resource_str)
2013
+
2014
+ return resources_info
2015
+
2016
+ def _format_resource(self, resource: Dict[str, Any]) -> Optional[str]:
2017
+ """Format a single resource as a string."""
2018
+ resource_type = resource.get("type", "Unknown")
2019
+ resource_value = resource.get("value", "")[:100]
2020
+ resource_check = resource.get("complianceCheck", "N/A")
2021
+
2022
+ if resource_value:
2023
+ return f"{resource_type}: {resource_value} (Status: {resource_check})"
2024
+ return None
2025
+
2026
+ def _add_remediation_guidance(self, item: ComplianceItem) -> str:
2027
+ """Add remediation guidance if available."""
2028
+ if not (hasattr(item, "action_plan_instructions") and item.action_plan_instructions):
2029
+ return ""
2030
+
2031
+ instructions = item.action_plan_instructions[:500]
2032
+ truncated = "..." if len(item.action_plan_instructions) > 500 else ""
2033
+
2034
+ return f"""
2035
+ <div style="margin-top: 10px; padding: 8px; background-color: #e3f2fd;
2036
+ border-left: 3px solid #1976d2; border-radius: 3px;">
2037
+ <strong>Remediation Guidance:</strong><br>
2038
+ {instructions}{truncated}
2039
+ </div>
2040
+ """
2041
+
2042
+ def _process_non_aws_item(self, idx: int, item: ComplianceItem) -> str:
2043
+ """Process non-AWS items or items without evidence."""
2044
+ description = self._get_item_description(item)
2045
+
2046
+ return f"""
2047
+ <div style="margin-top: 15px; padding: 10px; background-color: #ffebee; border-radius: 3px;">
2048
+ <h5 style="color: #c62828; margin-top: 0;">Failed Check #{idx}: {item.control_id}</h5>
2049
+ <p><strong>Resource:</strong> {getattr(item, 'resource_name', item.resource_id)}</p>
2050
+ <p><strong>Description:</strong> {description}</p>
2051
+ </div>
2052
+ """
2053
+
2054
+ def _get_item_description(self, item: ComplianceItem) -> str:
2055
+ """Get truncated description from item."""
2056
+ if hasattr(item, "description"):
2057
+ return item.description[:200]
2058
+ return "N/A"
2059
+
2060
+ def _get_security_plan(self) -> Optional[regscale_models.SecurityPlan]:
2061
+ """
2062
+ Get the security plan for this integration.
2063
+
2064
+ :return: SecurityPlan instance or None
2065
+ :rtype: Optional[regscale_models.SecurityPlan]
2066
+ """
2067
+ if not self._security_plan_loaded:
2068
+ self._security_plan_loaded = True # Mark as attempted to prevent repeated calls
2069
+ try:
2070
+ logger.info(f"[SECURITY PLAN] Retrieving security plan with ID: {self.plan_id}")
2071
+ self._security_plan = SecurityPlan.get_object(object_id=self.plan_id)
2072
+ if self._security_plan:
2073
+ logger.info(
2074
+ f"[SECURITY PLAN] Retrieved security plan: {getattr(self._security_plan, 'systemName', 'N/A')}"
2075
+ )
2076
+ logger.info(
2077
+ f"[SECURITY PLAN] complianceSettingsId: {getattr(self._security_plan, 'complianceSettingsId', None)}"
2078
+ )
2079
+ else:
2080
+ logger.warning(f"[SECURITY PLAN] No security plan found with ID: {self.plan_id}")
2081
+ except Exception as e:
2082
+ logger.error(f"[SECURITY PLAN] Error getting security plan {self.plan_id}: {e}")
2083
+ # Don't set to None - keep as None but mark as loaded to prevent retry
2084
+ return self._security_plan
2085
+
2086
+ def _get_compliance_settings(self) -> Optional[regscale_models.ComplianceSettings]:
2087
+ """
2088
+ Get compliance settings for the security plan.
2089
+
2090
+ :return: ComplianceSettings instance or None
2091
+ :rtype: Optional[regscale_models.ComplianceSettings]
2092
+ """
2093
+ if not self._compliance_settings_loaded:
2094
+ self._compliance_settings_loaded = True # Mark as attempted to prevent repeated calls
2095
+ try:
2096
+ security_plan = self._get_security_plan()
2097
+ logger.debug(f"[COMPLIANCE SETTINGS] Security plan retrieved: {security_plan is not None}")
2098
+ if security_plan:
2099
+ logger.debug(
2100
+ f"[COMPLIANCE SETTINGS] Security plan systemName: {getattr(security_plan, 'systemName', 'N/A')}"
2101
+ )
2102
+ logger.debug(f"[COMPLIANCE SETTINGS] Security plan ID: {getattr(security_plan, 'id', 'N/A')}")
2103
+ logger.debug(
2104
+ f"[COMPLIANCE SETTINGS] Has complianceSettingsId attribute: {hasattr(security_plan, 'complianceSettingsId')}"
2105
+ )
2106
+ logger.debug(
2107
+ f"[COMPLIANCE SETTINGS] complianceSettingsId value: {getattr(security_plan, 'complianceSettingsId', 'None')}"
2108
+ )
2109
+
2110
+ if self._has_valid_compliance_settings_id(security_plan):
2111
+ self._compliance_settings = self._fetch_compliance_settings(security_plan)
2112
+ else:
2113
+ self._log_missing_compliance_settings_reason(security_plan)
2114
+ except Exception as e:
2115
+ logger.debug(f"Error getting compliance settings: {e}")
2116
+ import traceback
2117
+
2118
+ logger.debug(f"Full traceback: {traceback.format_exc()}")
2119
+ # Don't set to None - keep as None but mark as loaded to prevent retry
2120
+ return self._compliance_settings
2121
+
2122
+ def _has_valid_compliance_settings_id(self, security_plan) -> bool:
2123
+ """Check if security plan has valid compliance settings ID."""
2124
+ return security_plan and hasattr(security_plan, "complianceSettingsId") and security_plan.complianceSettingsId
2125
+
2126
+ def _fetch_compliance_settings(self, security_plan) -> Optional[regscale_models.ComplianceSettings]:
2127
+ """Fetch and log compliance settings."""
2128
+ logger.debug(f"Retrieving compliance settings with ID: {security_plan.complianceSettingsId}")
2129
+ compliance_settings = ComplianceSettings.get_object(object_id=security_plan.complianceSettingsId)
2130
+
2131
+ if compliance_settings:
2132
+ logger.debug(f"Using compliance settings: {compliance_settings.title}")
2133
+ logger.debug(
2134
+ f"Compliance settings has field groups: {bool(getattr(compliance_settings, 'complianceSettingsFieldGroups', None))}"
2135
+ )
2136
+ else:
2137
+ logger.debug(f"No compliance settings found for ID: {security_plan.complianceSettingsId}")
2138
+
2139
+ return compliance_settings
2140
+
2141
+ def _log_missing_compliance_settings_reason(self, security_plan) -> None:
2142
+ """Log specific reason why compliance settings are not available."""
2143
+ if not security_plan:
2144
+ logger.debug("Security plan not found")
2145
+ elif not hasattr(security_plan, "complianceSettingsId"):
2146
+ logger.debug("Security plan does not have complianceSettingsId attribute")
2147
+ elif not security_plan.complianceSettingsId:
2148
+ logger.debug("Security plan has no complianceSettingsId set")
2149
+
2150
+ def _get_implementation_status_from_result(self, result: str, override: Optional[str] = None) -> str:
2151
+ """
2152
+ Get implementation status based on assessment result using enum-based mappings.
2153
+ Results are cached to avoid repeated calculations for the same input.
2154
+
2155
+ :param str result: Assessment result ('Pass', 'Fail', 'Not Applicable', etc.)
2156
+ :param Optional[str] override: Optional override value to use instead of the mapping
2157
+ :return: Implementation status string
2158
+ :rtype: str
2159
+ """
2160
+ # Use override directly if provided
2161
+ if override:
2162
+ return override
2163
+
2164
+ # Handle None or empty result
2165
+ if not result:
2166
+ logger.warning("Received None or empty result for status mapping, defaulting to 'Unknown'")
2167
+ return "Unknown"
2168
+
2169
+ # Check cache first to avoid repeated calculations
2170
+ cache_key = result.lower().strip()
2171
+ if cache_key in self._status_mapping_cache:
2172
+ cached_status = self._status_mapping_cache[cache_key]
2173
+ logger.debug(f"[STATUS MAPPING] Using cached mapping for '{result}': '{cached_status}'")
2174
+ return cached_status
2175
+
2176
+ logger.info(f"[STATUS MAPPING] Getting implementation status for result: {result}")
2177
+
2178
+ # Try to use the compliance settings mapping function
2179
+ compliance_settings = self._get_compliance_settings()
2180
+ if compliance_settings:
2181
+ logger.info(f"[STATUS MAPPING] Using compliance settings '{compliance_settings.title}' for mapping")
2182
+ try:
2183
+ mapped_status = compliance_settings.get_implementation_status_for_result(result, None)
2184
+ logger.info(f"[STATUS MAPPING] Mapped '{result}' to '{mapped_status}' using compliance settings")
2185
+ # Cache the result
2186
+ self._status_mapping_cache[cache_key] = mapped_status
2187
+ return mapped_status
2188
+ except Exception as e:
2189
+ logger.warning(f"[STATUS MAPPING] Error using compliance settings mapping: {e}")
2190
+
2191
+ # Fallback: Use the class method directly if no compliance settings instance
2192
+ framework = self._detect_compliance_framework()
2193
+ logger.info(f"[STATUS MAPPING] Using framework '{framework}' for fallback mapping")
2194
+ mapped_status = ComplianceSettings.get_status_mapping(framework, result, None)
2195
+ logger.info(f"[STATUS MAPPING] Mapped '{result}' to '{mapped_status}' using fallback")
2196
+ # Cache the result
2197
+ self._status_mapping_cache[cache_key] = mapped_status
2198
+ return mapped_status
2199
+
1373
2200
  def _update_implementation_status(self, implementation: ControlImplementation, result: str) -> None:
1374
2201
  """
1375
2202
  Update control implementation status based on assessment result.
2203
+ Uses compliance settings from the security plan if available, otherwise falls back to defaults.
1376
2204
 
1377
2205
  :param ControlImplementation implementation: Control implementation to update
1378
2206
  :param str result: Assessment result ('Pass' or 'Fail')
@@ -1380,15 +2208,30 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1380
2208
  :rtype: None
1381
2209
  """
1382
2210
  try:
1383
- if result == "Pass":
1384
- new_status = "Fully Implemented"
1385
- else:
1386
- new_status = "In Remediation"
2211
+ # Get status from compliance settings or fallback to default
2212
+ new_status = self._get_implementation_status_from_result(result)
1387
2213
 
1388
2214
  # Update implementation status
1389
2215
  implementation.status = new_status
1390
2216
  implementation.dateLastAssessed = get_current_datetime()
1391
2217
  implementation.lastAssessmentResult = result
2218
+
2219
+ # Ensure required fields are set if empty
2220
+ if not implementation.responsibility:
2221
+ implementation.responsibility = ControlImplementation.get_default_responsibility(
2222
+ parent_id=implementation.parentId
2223
+ )
2224
+ logger.debug(
2225
+ f"Setting default responsibility for implementation {implementation.id}: {implementation.responsibility}"
2226
+ )
2227
+
2228
+ if not implementation.implementation:
2229
+ control_id = (
2230
+ getattr(implementation.control, "controlId", "control") if implementation.control else "control"
2231
+ )
2232
+ implementation.implementation = f"Implementation details for {control_id} will be documented."
2233
+ logger.debug(f"Setting default implementation statement for implementation {implementation.id}")
2234
+
1392
2235
  implementation.save()
1393
2236
 
1394
2237
  # Update objectives if they exist
@@ -1401,7 +2244,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1401
2244
  objective.status = new_status
1402
2245
  objective.save()
1403
2246
 
1404
- logger.debug(f"Updated implementation status to {new_status}")
2247
+ logger.debug(f"Updated implementation status to {new_status} (from compliance settings)")
1405
2248
 
1406
2249
  except Exception as e:
1407
2250
  logger.error(f"Error updating implementation status: {e}")
@@ -1558,6 +2401,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1558
2401
  """
1559
2402
  Create or update an issue from a finding, using cache to prevent duplicates.
1560
2403
 
2404
+ Properly handles milestone creation for compliance integrations.
2405
+
1561
2406
  :param str title: Issue title
1562
2407
  :param IntegrationFinding finding: The finding to create issue from
1563
2408
  :return: Created or updated issue
@@ -1568,17 +2413,21 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1568
2413
 
1569
2414
  # Check for existing issue by external_id first
1570
2415
  external_id = finding.external_id
2416
+ logger.debug(f"Looking for existing issue with external_id: '{external_id}'")
1571
2417
  existing_issue = self._find_existing_issue_cached(external_id)
1572
2418
 
1573
2419
  if existing_issue:
1574
2420
  logger.debug(
1575
- f"Found existing issue {existing_issue.id} for external_id {external_id}, updating instead of creating"
2421
+ f"Found existing issue {existing_issue.id} (other_identifier: '{existing_issue.otherIdentifier}') for lookup external_id '{external_id}', updating instead of creating"
1576
2422
  )
1577
2423
 
2424
+ # Store original status for milestone comparison
2425
+ original_status = existing_issue.status
2426
+
1578
2427
  # Update existing issue with new finding data
1579
2428
  existing_issue.title = title
1580
2429
  existing_issue.description = finding.description
1581
- existing_issue.severity = finding.severity
2430
+ existing_issue.severityLevel = finding.severity
1582
2431
  existing_issue.status = finding.status
1583
2432
  # Ensure affectedControls is updated from the finding's control id
1584
2433
  try:
@@ -1598,10 +2447,46 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1598
2447
  except Exception:
1599
2448
  pass
1600
2449
  existing_issue.dateLastUpdated = self.scan_date
2450
+ # Set organization ID based on Issue Owner or SSP Owner hierarchy
2451
+ existing_issue.orgId = self.determine_issue_organization_id(existing_issue.issueOwnerId)
1601
2452
  existing_issue.save()
1602
2453
 
2454
+ # Create milestone if status changed
2455
+ # Reconstruct original issue state for comparison
2456
+ original_issue = regscale_models.Issue()
2457
+ original_issue.status = original_status
2458
+ self._create_milestones_for_updated_issue(existing_issue, finding, original_issue)
2459
+
1603
2460
  return existing_issue
1604
2461
  else:
1605
2462
  # No existing issue found, create new one using parent method
1606
2463
  logger.debug(f"No existing issue found for external_id {external_id}, creating new issue")
1607
2464
  return super().create_or_update_issue_from_finding(title, finding)
2465
+
2466
+ def _create_milestones_for_updated_issue(
2467
+ self,
2468
+ issue: regscale_models.Issue,
2469
+ finding: IntegrationFinding,
2470
+ original_issue: regscale_models.Issue,
2471
+ ) -> None:
2472
+ """
2473
+ Create milestones for an updated issue in compliance integration.
2474
+
2475
+ This method handles both status transition milestones and backfilling of missing
2476
+ creation milestones for existing issues.
2477
+
2478
+ :param regscale_models.Issue issue: The updated issue
2479
+ :param IntegrationFinding finding: The finding data
2480
+ :param regscale_models.Issue original_issue: Original state for comparison
2481
+ """
2482
+ milestone_manager = self.get_milestone_manager()
2483
+
2484
+ # First, ensure the issue has a creation milestone (backfill if missing)
2485
+ milestone_manager.ensure_creation_milestone_exists(issue=issue, finding=finding)
2486
+
2487
+ # Then, handle status transition milestones
2488
+ milestone_manager.create_milestones_for_issue(
2489
+ issue=issue,
2490
+ finding=finding,
2491
+ existing_issue=original_issue,
2492
+ )