regscale-cli 6.16.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (481) hide show
  1. regscale/__init__.py +1 -0
  2. regscale/airflow/__init__.py +9 -0
  3. regscale/airflow/azure/__init__.py +9 -0
  4. regscale/airflow/azure/cli.py +89 -0
  5. regscale/airflow/azure/upload_dags.py +116 -0
  6. regscale/airflow/click_dags.py +127 -0
  7. regscale/airflow/click_mixins.py +82 -0
  8. regscale/airflow/config.py +25 -0
  9. regscale/airflow/factories/__init__.py +0 -0
  10. regscale/airflow/factories/connections.py +58 -0
  11. regscale/airflow/factories/workflows.py +78 -0
  12. regscale/airflow/hierarchy.py +88 -0
  13. regscale/airflow/operators/__init__.py +0 -0
  14. regscale/airflow/operators/click.py +36 -0
  15. regscale/airflow/sensors/__init__.py +0 -0
  16. regscale/airflow/sensors/sql.py +107 -0
  17. regscale/airflow/sessions/__init__.py +0 -0
  18. regscale/airflow/sessions/sql/__init__.py +3 -0
  19. regscale/airflow/sessions/sql/queries.py +64 -0
  20. regscale/airflow/sessions/sql/sql_server_queries.py +248 -0
  21. regscale/airflow/tasks/__init__.py +0 -0
  22. regscale/airflow/tasks/branches.py +22 -0
  23. regscale/airflow/tasks/cli.py +116 -0
  24. regscale/airflow/tasks/click.py +73 -0
  25. regscale/airflow/tasks/debugging.py +9 -0
  26. regscale/airflow/tasks/groups.py +116 -0
  27. regscale/airflow/tasks/init.py +60 -0
  28. regscale/airflow/tasks/states.py +47 -0
  29. regscale/airflow/tasks/workflows.py +36 -0
  30. regscale/ansible/__init__.py +9 -0
  31. regscale/core/__init__.py +0 -0
  32. regscale/core/app/__init__.py +3 -0
  33. regscale/core/app/api.py +571 -0
  34. regscale/core/app/application.py +665 -0
  35. regscale/core/app/internal/__init__.py +136 -0
  36. regscale/core/app/internal/admin_actions.py +230 -0
  37. regscale/core/app/internal/assessments_editor.py +873 -0
  38. regscale/core/app/internal/catalog.py +316 -0
  39. regscale/core/app/internal/comparison.py +459 -0
  40. regscale/core/app/internal/control_editor.py +571 -0
  41. regscale/core/app/internal/encrypt.py +79 -0
  42. regscale/core/app/internal/evidence.py +1240 -0
  43. regscale/core/app/internal/file_uploads.py +151 -0
  44. regscale/core/app/internal/healthcheck.py +66 -0
  45. regscale/core/app/internal/login.py +305 -0
  46. regscale/core/app/internal/migrations.py +240 -0
  47. regscale/core/app/internal/model_editor.py +1701 -0
  48. regscale/core/app/internal/poam_editor.py +632 -0
  49. regscale/core/app/internal/workflow.py +105 -0
  50. regscale/core/app/logz.py +74 -0
  51. regscale/core/app/utils/XMLIR.py +258 -0
  52. regscale/core/app/utils/__init__.py +0 -0
  53. regscale/core/app/utils/api_handler.py +358 -0
  54. regscale/core/app/utils/app_utils.py +1110 -0
  55. regscale/core/app/utils/catalog_utils/__init__.py +0 -0
  56. regscale/core/app/utils/catalog_utils/common.py +91 -0
  57. regscale/core/app/utils/catalog_utils/compare_catalog.py +193 -0
  58. regscale/core/app/utils/catalog_utils/diagnostic_catalog.py +97 -0
  59. regscale/core/app/utils/catalog_utils/download_catalog.py +103 -0
  60. regscale/core/app/utils/catalog_utils/update_catalog.py +718 -0
  61. regscale/core/app/utils/catalog_utils/update_catalog_v2.py +1378 -0
  62. regscale/core/app/utils/catalog_utils/update_catalog_v3.py +1272 -0
  63. regscale/core/app/utils/catalog_utils/update_plans.py +334 -0
  64. regscale/core/app/utils/file_utils.py +238 -0
  65. regscale/core/app/utils/parser_utils.py +81 -0
  66. regscale/core/app/utils/pickle_file_handler.py +57 -0
  67. regscale/core/app/utils/regscale_utils.py +319 -0
  68. regscale/core/app/utils/report_utils.py +119 -0
  69. regscale/core/app/utils/variables.py +226 -0
  70. regscale/core/decorators.py +31 -0
  71. regscale/core/lazy_group.py +65 -0
  72. regscale/core/login.py +63 -0
  73. regscale/core/server/__init__.py +0 -0
  74. regscale/core/server/flask_api.py +473 -0
  75. regscale/core/server/helpers.py +373 -0
  76. regscale/core/server/rest.py +64 -0
  77. regscale/core/server/static/css/bootstrap.css +6030 -0
  78. regscale/core/server/static/css/bootstrap.min.css +6 -0
  79. regscale/core/server/static/css/main.css +176 -0
  80. regscale/core/server/static/images/regscale-cli.svg +49 -0
  81. regscale/core/server/static/images/regscale.svg +38 -0
  82. regscale/core/server/templates/base.html +74 -0
  83. regscale/core/server/templates/index.html +43 -0
  84. regscale/core/server/templates/login.html +28 -0
  85. regscale/core/server/templates/make_base64.html +22 -0
  86. regscale/core/server/templates/upload_STIG.html +109 -0
  87. regscale/core/server/templates/upload_STIG_result.html +26 -0
  88. regscale/core/server/templates/upload_ssp.html +144 -0
  89. regscale/core/server/templates/upload_ssp_result.html +128 -0
  90. regscale/core/static/__init__.py +0 -0
  91. regscale/core/static/regex.py +14 -0
  92. regscale/core/utils/__init__.py +117 -0
  93. regscale/core/utils/click_utils.py +13 -0
  94. regscale/core/utils/date.py +238 -0
  95. regscale/core/utils/graphql.py +254 -0
  96. regscale/core/utils/urls.py +23 -0
  97. regscale/dev/__init__.py +6 -0
  98. regscale/dev/analysis.py +454 -0
  99. regscale/dev/cli.py +235 -0
  100. regscale/dev/code_gen.py +492 -0
  101. regscale/dev/dirs.py +69 -0
  102. regscale/dev/docs.py +384 -0
  103. regscale/dev/monitoring.py +26 -0
  104. regscale/dev/profiling.py +216 -0
  105. regscale/exceptions/__init__.py +4 -0
  106. regscale/exceptions/license_exception.py +7 -0
  107. regscale/exceptions/validation_exception.py +9 -0
  108. regscale/integrations/__init__.py +1 -0
  109. regscale/integrations/commercial/__init__.py +486 -0
  110. regscale/integrations/commercial/ad.py +433 -0
  111. regscale/integrations/commercial/amazon/__init__.py +0 -0
  112. regscale/integrations/commercial/amazon/common.py +106 -0
  113. regscale/integrations/commercial/aqua/__init__.py +0 -0
  114. regscale/integrations/commercial/aqua/aqua.py +91 -0
  115. regscale/integrations/commercial/aws/__init__.py +6 -0
  116. regscale/integrations/commercial/aws/cli.py +322 -0
  117. regscale/integrations/commercial/aws/inventory/__init__.py +110 -0
  118. regscale/integrations/commercial/aws/inventory/base.py +64 -0
  119. regscale/integrations/commercial/aws/inventory/resources/__init__.py +19 -0
  120. regscale/integrations/commercial/aws/inventory/resources/compute.py +234 -0
  121. regscale/integrations/commercial/aws/inventory/resources/containers.py +113 -0
  122. regscale/integrations/commercial/aws/inventory/resources/database.py +101 -0
  123. regscale/integrations/commercial/aws/inventory/resources/integration.py +237 -0
  124. regscale/integrations/commercial/aws/inventory/resources/networking.py +253 -0
  125. regscale/integrations/commercial/aws/inventory/resources/security.py +240 -0
  126. regscale/integrations/commercial/aws/inventory/resources/storage.py +91 -0
  127. regscale/integrations/commercial/aws/scanner.py +823 -0
  128. regscale/integrations/commercial/azure/__init__.py +0 -0
  129. regscale/integrations/commercial/azure/common.py +32 -0
  130. regscale/integrations/commercial/azure/intune.py +488 -0
  131. regscale/integrations/commercial/azure/scanner.py +49 -0
  132. regscale/integrations/commercial/burp.py +78 -0
  133. regscale/integrations/commercial/cpe.py +144 -0
  134. regscale/integrations/commercial/crowdstrike.py +1117 -0
  135. regscale/integrations/commercial/defender.py +1511 -0
  136. regscale/integrations/commercial/dependabot.py +210 -0
  137. regscale/integrations/commercial/durosuite/__init__.py +0 -0
  138. regscale/integrations/commercial/durosuite/api.py +1546 -0
  139. regscale/integrations/commercial/durosuite/process_devices.py +101 -0
  140. regscale/integrations/commercial/durosuite/scanner.py +637 -0
  141. regscale/integrations/commercial/durosuite/variables.py +21 -0
  142. regscale/integrations/commercial/ecr.py +90 -0
  143. regscale/integrations/commercial/gcp/__init__.py +237 -0
  144. regscale/integrations/commercial/gcp/auth.py +96 -0
  145. regscale/integrations/commercial/gcp/control_tests.py +238 -0
  146. regscale/integrations/commercial/gcp/variables.py +18 -0
  147. regscale/integrations/commercial/gitlab.py +332 -0
  148. regscale/integrations/commercial/grype.py +165 -0
  149. regscale/integrations/commercial/ibm.py +90 -0
  150. regscale/integrations/commercial/import_all/__init__.py +0 -0
  151. regscale/integrations/commercial/import_all/import_all_cmd.py +467 -0
  152. regscale/integrations/commercial/import_all/scan_file_fingerprints.json +27 -0
  153. regscale/integrations/commercial/jira.py +1046 -0
  154. regscale/integrations/commercial/mappings/__init__.py +0 -0
  155. regscale/integrations/commercial/mappings/csf_controls.json +713 -0
  156. regscale/integrations/commercial/mappings/nist_800_53_r5_controls.json +1516 -0
  157. regscale/integrations/commercial/nessus/__init__.py +0 -0
  158. regscale/integrations/commercial/nessus/nessus_utils.py +429 -0
  159. regscale/integrations/commercial/nessus/scanner.py +416 -0
  160. regscale/integrations/commercial/nexpose.py +90 -0
  161. regscale/integrations/commercial/okta.py +798 -0
  162. regscale/integrations/commercial/opentext/__init__.py +0 -0
  163. regscale/integrations/commercial/opentext/click.py +99 -0
  164. regscale/integrations/commercial/opentext/scanner.py +143 -0
  165. regscale/integrations/commercial/prisma.py +91 -0
  166. regscale/integrations/commercial/qualys.py +1462 -0
  167. regscale/integrations/commercial/salesforce.py +980 -0
  168. regscale/integrations/commercial/sap/__init__.py +0 -0
  169. regscale/integrations/commercial/sap/click.py +31 -0
  170. regscale/integrations/commercial/sap/sysdig/__init__.py +0 -0
  171. regscale/integrations/commercial/sap/sysdig/click.py +57 -0
  172. regscale/integrations/commercial/sap/sysdig/sysdig_scanner.py +190 -0
  173. regscale/integrations/commercial/sap/tenable/__init__.py +0 -0
  174. regscale/integrations/commercial/sap/tenable/click.py +49 -0
  175. regscale/integrations/commercial/sap/tenable/scanner.py +196 -0
  176. regscale/integrations/commercial/servicenow.py +1756 -0
  177. regscale/integrations/commercial/sicura/__init__.py +0 -0
  178. regscale/integrations/commercial/sicura/api.py +855 -0
  179. regscale/integrations/commercial/sicura/commands.py +73 -0
  180. regscale/integrations/commercial/sicura/scanner.py +481 -0
  181. regscale/integrations/commercial/sicura/variables.py +16 -0
  182. regscale/integrations/commercial/snyk.py +90 -0
  183. regscale/integrations/commercial/sonarcloud.py +260 -0
  184. regscale/integrations/commercial/sqlserver.py +369 -0
  185. regscale/integrations/commercial/stig_mapper_integration/__init__.py +0 -0
  186. regscale/integrations/commercial/stig_mapper_integration/click_commands.py +38 -0
  187. regscale/integrations/commercial/stig_mapper_integration/mapping_engine.py +353 -0
  188. regscale/integrations/commercial/stigv2/__init__.py +0 -0
  189. regscale/integrations/commercial/stigv2/ckl_parser.py +349 -0
  190. regscale/integrations/commercial/stigv2/click_commands.py +95 -0
  191. regscale/integrations/commercial/stigv2/stig_integration.py +202 -0
  192. regscale/integrations/commercial/synqly/__init__.py +0 -0
  193. regscale/integrations/commercial/synqly/assets.py +46 -0
  194. regscale/integrations/commercial/synqly/ticketing.py +132 -0
  195. regscale/integrations/commercial/synqly/vulnerabilities.py +223 -0
  196. regscale/integrations/commercial/synqly_jira.py +840 -0
  197. regscale/integrations/commercial/tenablev2/__init__.py +0 -0
  198. regscale/integrations/commercial/tenablev2/authenticate.py +31 -0
  199. regscale/integrations/commercial/tenablev2/click.py +1584 -0
  200. regscale/integrations/commercial/tenablev2/scanner.py +504 -0
  201. regscale/integrations/commercial/tenablev2/stig_parsers.py +140 -0
  202. regscale/integrations/commercial/tenablev2/utils.py +78 -0
  203. regscale/integrations/commercial/tenablev2/variables.py +17 -0
  204. regscale/integrations/commercial/trivy.py +162 -0
  205. regscale/integrations/commercial/veracode.py +96 -0
  206. regscale/integrations/commercial/wizv2/WizDataMixin.py +97 -0
  207. regscale/integrations/commercial/wizv2/__init__.py +0 -0
  208. regscale/integrations/commercial/wizv2/click.py +429 -0
  209. regscale/integrations/commercial/wizv2/constants.py +1001 -0
  210. regscale/integrations/commercial/wizv2/issue.py +361 -0
  211. regscale/integrations/commercial/wizv2/models.py +112 -0
  212. regscale/integrations/commercial/wizv2/parsers.py +339 -0
  213. regscale/integrations/commercial/wizv2/sbom.py +115 -0
  214. regscale/integrations/commercial/wizv2/scanner.py +416 -0
  215. regscale/integrations/commercial/wizv2/utils.py +796 -0
  216. regscale/integrations/commercial/wizv2/variables.py +39 -0
  217. regscale/integrations/commercial/wizv2/wiz_auth.py +159 -0
  218. regscale/integrations/commercial/xray.py +91 -0
  219. regscale/integrations/integration/__init__.py +2 -0
  220. regscale/integrations/integration/integration.py +26 -0
  221. regscale/integrations/integration/inventory.py +17 -0
  222. regscale/integrations/integration/issue.py +100 -0
  223. regscale/integrations/integration_override.py +149 -0
  224. regscale/integrations/public/__init__.py +103 -0
  225. regscale/integrations/public/cisa.py +641 -0
  226. regscale/integrations/public/criticality_updater.py +70 -0
  227. regscale/integrations/public/emass.py +411 -0
  228. regscale/integrations/public/emass_slcm_import.py +697 -0
  229. regscale/integrations/public/fedramp/__init__.py +0 -0
  230. regscale/integrations/public/fedramp/appendix_parser.py +548 -0
  231. regscale/integrations/public/fedramp/click.py +479 -0
  232. regscale/integrations/public/fedramp/components.py +714 -0
  233. regscale/integrations/public/fedramp/docx_parser.py +259 -0
  234. regscale/integrations/public/fedramp/fedramp_cis_crm.py +1124 -0
  235. regscale/integrations/public/fedramp/fedramp_common.py +3181 -0
  236. regscale/integrations/public/fedramp/fedramp_docx.py +388 -0
  237. regscale/integrations/public/fedramp/fedramp_five.py +2343 -0
  238. regscale/integrations/public/fedramp/fedramp_traversal.py +138 -0
  239. regscale/integrations/public/fedramp/import_fedramp_r4_ssp.py +279 -0
  240. regscale/integrations/public/fedramp/import_workbook.py +495 -0
  241. regscale/integrations/public/fedramp/inventory_items.py +244 -0
  242. regscale/integrations/public/fedramp/mappings/__init__.py +0 -0
  243. regscale/integrations/public/fedramp/mappings/fedramp_r4_parts.json +7388 -0
  244. regscale/integrations/public/fedramp/mappings/fedramp_r5_params.json +8636 -0
  245. regscale/integrations/public/fedramp/mappings/fedramp_r5_parts.json +9605 -0
  246. regscale/integrations/public/fedramp/mappings/system_roles.py +34 -0
  247. regscale/integrations/public/fedramp/mappings/user.py +175 -0
  248. regscale/integrations/public/fedramp/mappings/values.py +141 -0
  249. regscale/integrations/public/fedramp/markdown_parser.py +150 -0
  250. regscale/integrations/public/fedramp/metadata.py +689 -0
  251. regscale/integrations/public/fedramp/models/__init__.py +59 -0
  252. regscale/integrations/public/fedramp/models/leveraged_auth_new.py +168 -0
  253. regscale/integrations/public/fedramp/models/poam_importer.py +522 -0
  254. regscale/integrations/public/fedramp/parts_mapper.py +107 -0
  255. regscale/integrations/public/fedramp/poam/__init__.py +0 -0
  256. regscale/integrations/public/fedramp/poam/scanner.py +851 -0
  257. regscale/integrations/public/fedramp/properties.py +201 -0
  258. regscale/integrations/public/fedramp/reporting.py +84 -0
  259. regscale/integrations/public/fedramp/resources.py +496 -0
  260. regscale/integrations/public/fedramp/rosetta.py +110 -0
  261. regscale/integrations/public/fedramp/ssp_logger.py +87 -0
  262. regscale/integrations/public/fedramp/system_characteristics.py +922 -0
  263. regscale/integrations/public/fedramp/system_control_implementations.py +582 -0
  264. regscale/integrations/public/fedramp/system_implementation.py +190 -0
  265. regscale/integrations/public/fedramp/xml_utils.py +87 -0
  266. regscale/integrations/public/nist_catalog.py +275 -0
  267. regscale/integrations/public/oscal.py +1946 -0
  268. regscale/integrations/public/otx.py +169 -0
  269. regscale/integrations/scanner_integration.py +2692 -0
  270. regscale/integrations/variables.py +25 -0
  271. regscale/models/__init__.py +7 -0
  272. regscale/models/app_models/__init__.py +5 -0
  273. regscale/models/app_models/catalog_compare.py +213 -0
  274. regscale/models/app_models/click.py +252 -0
  275. regscale/models/app_models/datetime_encoder.py +21 -0
  276. regscale/models/app_models/import_validater.py +321 -0
  277. regscale/models/app_models/mapping.py +260 -0
  278. regscale/models/app_models/pipeline.py +37 -0
  279. regscale/models/click_models.py +413 -0
  280. regscale/models/config.py +154 -0
  281. regscale/models/email_style.css +67 -0
  282. regscale/models/hierarchy.py +8 -0
  283. regscale/models/inspect_models.py +79 -0
  284. regscale/models/integration_models/__init__.py +0 -0
  285. regscale/models/integration_models/amazon_models/__init__.py +0 -0
  286. regscale/models/integration_models/amazon_models/inspector.py +262 -0
  287. regscale/models/integration_models/amazon_models/inspector_scan.py +206 -0
  288. regscale/models/integration_models/aqua.py +247 -0
  289. regscale/models/integration_models/azure_alerts.py +255 -0
  290. regscale/models/integration_models/base64.py +23 -0
  291. regscale/models/integration_models/burp.py +433 -0
  292. regscale/models/integration_models/burp_models.py +128 -0
  293. regscale/models/integration_models/cisa_kev_data.json +19333 -0
  294. regscale/models/integration_models/defender_data.py +93 -0
  295. regscale/models/integration_models/defenderimport.py +143 -0
  296. regscale/models/integration_models/drf.py +443 -0
  297. regscale/models/integration_models/ecr_models/__init__.py +0 -0
  298. regscale/models/integration_models/ecr_models/data.py +69 -0
  299. regscale/models/integration_models/ecr_models/ecr.py +239 -0
  300. regscale/models/integration_models/flat_file_importer.py +1079 -0
  301. regscale/models/integration_models/grype_import.py +247 -0
  302. regscale/models/integration_models/ibm.py +126 -0
  303. regscale/models/integration_models/implementation_results.py +85 -0
  304. regscale/models/integration_models/nexpose.py +140 -0
  305. regscale/models/integration_models/prisma.py +202 -0
  306. regscale/models/integration_models/qualys.py +720 -0
  307. regscale/models/integration_models/qualys_scanner.py +160 -0
  308. regscale/models/integration_models/sbom/__init__.py +0 -0
  309. regscale/models/integration_models/sbom/cyclone_dx.py +139 -0
  310. regscale/models/integration_models/send_reminders.py +620 -0
  311. regscale/models/integration_models/snyk.py +155 -0
  312. regscale/models/integration_models/synqly_models/__init__.py +0 -0
  313. regscale/models/integration_models/synqly_models/capabilities.json +1 -0
  314. regscale/models/integration_models/synqly_models/connector_types.py +22 -0
  315. regscale/models/integration_models/synqly_models/connectors/__init__.py +7 -0
  316. regscale/models/integration_models/synqly_models/connectors/assets.py +97 -0
  317. regscale/models/integration_models/synqly_models/connectors/ticketing.py +583 -0
  318. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +169 -0
  319. regscale/models/integration_models/synqly_models/ocsf_mapper.py +331 -0
  320. regscale/models/integration_models/synqly_models/param.py +72 -0
  321. regscale/models/integration_models/synqly_models/synqly_model.py +733 -0
  322. regscale/models/integration_models/synqly_models/tenants.py +39 -0
  323. regscale/models/integration_models/tenable_models/__init__.py +0 -0
  324. regscale/models/integration_models/tenable_models/integration.py +187 -0
  325. regscale/models/integration_models/tenable_models/models.py +513 -0
  326. regscale/models/integration_models/trivy_import.py +231 -0
  327. regscale/models/integration_models/veracode.py +217 -0
  328. regscale/models/integration_models/xray.py +135 -0
  329. regscale/models/locking.py +100 -0
  330. regscale/models/platform.py +110 -0
  331. regscale/models/regscale_models/__init__.py +67 -0
  332. regscale/models/regscale_models/assessment.py +570 -0
  333. regscale/models/regscale_models/assessment_plan.py +52 -0
  334. regscale/models/regscale_models/asset.py +567 -0
  335. regscale/models/regscale_models/asset_mapping.py +190 -0
  336. regscale/models/regscale_models/case.py +42 -0
  337. regscale/models/regscale_models/catalog.py +261 -0
  338. regscale/models/regscale_models/cci.py +46 -0
  339. regscale/models/regscale_models/change.py +167 -0
  340. regscale/models/regscale_models/checklist.py +372 -0
  341. regscale/models/regscale_models/comment.py +49 -0
  342. regscale/models/regscale_models/compliance_settings.py +112 -0
  343. regscale/models/regscale_models/component.py +412 -0
  344. regscale/models/regscale_models/component_mapping.py +65 -0
  345. regscale/models/regscale_models/control.py +38 -0
  346. regscale/models/regscale_models/control_implementation.py +1128 -0
  347. regscale/models/regscale_models/control_objective.py +261 -0
  348. regscale/models/regscale_models/control_parameter.py +100 -0
  349. regscale/models/regscale_models/control_test.py +34 -0
  350. regscale/models/regscale_models/control_test_plan.py +75 -0
  351. regscale/models/regscale_models/control_test_result.py +52 -0
  352. regscale/models/regscale_models/custom_field.py +245 -0
  353. regscale/models/regscale_models/data.py +109 -0
  354. regscale/models/regscale_models/data_center.py +40 -0
  355. regscale/models/regscale_models/deviation.py +203 -0
  356. regscale/models/regscale_models/email.py +97 -0
  357. regscale/models/regscale_models/evidence.py +47 -0
  358. regscale/models/regscale_models/evidence_mapping.py +40 -0
  359. regscale/models/regscale_models/facility.py +59 -0
  360. regscale/models/regscale_models/file.py +382 -0
  361. regscale/models/regscale_models/filetag.py +37 -0
  362. regscale/models/regscale_models/form_field_value.py +94 -0
  363. regscale/models/regscale_models/group.py +169 -0
  364. regscale/models/regscale_models/implementation_objective.py +335 -0
  365. regscale/models/regscale_models/implementation_option.py +275 -0
  366. regscale/models/regscale_models/implementation_role.py +33 -0
  367. regscale/models/regscale_models/incident.py +177 -0
  368. regscale/models/regscale_models/interconnection.py +43 -0
  369. regscale/models/regscale_models/issue.py +1176 -0
  370. regscale/models/regscale_models/leveraged_authorization.py +125 -0
  371. regscale/models/regscale_models/line_of_inquiry.py +52 -0
  372. regscale/models/regscale_models/link.py +205 -0
  373. regscale/models/regscale_models/meta_data.py +64 -0
  374. regscale/models/regscale_models/mixins/__init__.py +0 -0
  375. regscale/models/regscale_models/mixins/parent_cache.py +124 -0
  376. regscale/models/regscale_models/module.py +224 -0
  377. regscale/models/regscale_models/modules.py +191 -0
  378. regscale/models/regscale_models/objective.py +14 -0
  379. regscale/models/regscale_models/parameter.py +87 -0
  380. regscale/models/regscale_models/ports_protocol.py +81 -0
  381. regscale/models/regscale_models/privacy.py +89 -0
  382. regscale/models/regscale_models/profile.py +50 -0
  383. regscale/models/regscale_models/profile_link.py +68 -0
  384. regscale/models/regscale_models/profile_mapping.py +124 -0
  385. regscale/models/regscale_models/project.py +63 -0
  386. regscale/models/regscale_models/property.py +278 -0
  387. regscale/models/regscale_models/question.py +85 -0
  388. regscale/models/regscale_models/questionnaire.py +87 -0
  389. regscale/models/regscale_models/questionnaire_instance.py +177 -0
  390. regscale/models/regscale_models/rbac.py +132 -0
  391. regscale/models/regscale_models/reference.py +86 -0
  392. regscale/models/regscale_models/regscale_model.py +1643 -0
  393. regscale/models/regscale_models/requirement.py +29 -0
  394. regscale/models/regscale_models/risk.py +274 -0
  395. regscale/models/regscale_models/sbom.py +54 -0
  396. regscale/models/regscale_models/scan_history.py +436 -0
  397. regscale/models/regscale_models/search.py +53 -0
  398. regscale/models/regscale_models/security_control.py +132 -0
  399. regscale/models/regscale_models/security_plan.py +204 -0
  400. regscale/models/regscale_models/software_inventory.py +159 -0
  401. regscale/models/regscale_models/stake_holder.py +64 -0
  402. regscale/models/regscale_models/stig.py +647 -0
  403. regscale/models/regscale_models/supply_chain.py +152 -0
  404. regscale/models/regscale_models/system_role.py +188 -0
  405. regscale/models/regscale_models/system_role_external_assignment.py +40 -0
  406. regscale/models/regscale_models/tag.py +37 -0
  407. regscale/models/regscale_models/tag_mapping.py +19 -0
  408. regscale/models/regscale_models/task.py +133 -0
  409. regscale/models/regscale_models/threat.py +196 -0
  410. regscale/models/regscale_models/user.py +175 -0
  411. regscale/models/regscale_models/user_group.py +55 -0
  412. regscale/models/regscale_models/vulnerability.py +242 -0
  413. regscale/models/regscale_models/vulnerability_mapping.py +162 -0
  414. regscale/models/regscale_models/workflow.py +55 -0
  415. regscale/models/regscale_models/workflow_action.py +34 -0
  416. regscale/models/regscale_models/workflow_instance.py +269 -0
  417. regscale/models/regscale_models/workflow_instance_step.py +114 -0
  418. regscale/models/regscale_models/workflow_template.py +58 -0
  419. regscale/models/regscale_models/workflow_template_step.py +45 -0
  420. regscale/regscale.py +815 -0
  421. regscale/utils/__init__.py +7 -0
  422. regscale/utils/b64conversion.py +14 -0
  423. regscale/utils/click_utils.py +118 -0
  424. regscale/utils/decorators.py +48 -0
  425. regscale/utils/dict_utils.py +59 -0
  426. regscale/utils/files.py +79 -0
  427. regscale/utils/fxns.py +30 -0
  428. regscale/utils/graphql_client.py +113 -0
  429. regscale/utils/lists.py +16 -0
  430. regscale/utils/numbers.py +12 -0
  431. regscale/utils/shell.py +148 -0
  432. regscale/utils/string.py +121 -0
  433. regscale/utils/synqly_utils.py +165 -0
  434. regscale/utils/threading/__init__.py +8 -0
  435. regscale/utils/threading/threadhandler.py +131 -0
  436. regscale/utils/threading/threadsafe_counter.py +47 -0
  437. regscale/utils/threading/threadsafe_dict.py +242 -0
  438. regscale/utils/threading/threadsafe_list.py +83 -0
  439. regscale/utils/version.py +104 -0
  440. regscale/validation/__init__.py +0 -0
  441. regscale/validation/address.py +37 -0
  442. regscale/validation/record.py +48 -0
  443. regscale/visualization/__init__.py +5 -0
  444. regscale/visualization/click.py +34 -0
  445. regscale_cli-6.16.0.0.dist-info/LICENSE +21 -0
  446. regscale_cli-6.16.0.0.dist-info/METADATA +659 -0
  447. regscale_cli-6.16.0.0.dist-info/RECORD +481 -0
  448. regscale_cli-6.16.0.0.dist-info/WHEEL +5 -0
  449. regscale_cli-6.16.0.0.dist-info/entry_points.txt +6 -0
  450. regscale_cli-6.16.0.0.dist-info/top_level.txt +2 -0
  451. tests/fixtures/__init__.py +2 -0
  452. tests/fixtures/api.py +87 -0
  453. tests/fixtures/models.py +91 -0
  454. tests/fixtures/test_fixture.py +144 -0
  455. tests/mocks/__init__.py +0 -0
  456. tests/mocks/objects.py +3 -0
  457. tests/mocks/response.py +32 -0
  458. tests/mocks/xml.py +13 -0
  459. tests/regscale/__init__.py +0 -0
  460. tests/regscale/core/__init__.py +0 -0
  461. tests/regscale/core/test_api.py +232 -0
  462. tests/regscale/core/test_app.py +406 -0
  463. tests/regscale/core/test_login.py +37 -0
  464. tests/regscale/core/test_logz.py +66 -0
  465. tests/regscale/core/test_sbom_generator.py +87 -0
  466. tests/regscale/core/test_validation_utils.py +163 -0
  467. tests/regscale/core/test_version.py +78 -0
  468. tests/regscale/models/__init__.py +0 -0
  469. tests/regscale/models/test_asset.py +71 -0
  470. tests/regscale/models/test_config.py +26 -0
  471. tests/regscale/models/test_control_implementation.py +27 -0
  472. tests/regscale/models/test_import.py +97 -0
  473. tests/regscale/models/test_issue.py +36 -0
  474. tests/regscale/models/test_mapping.py +52 -0
  475. tests/regscale/models/test_platform.py +31 -0
  476. tests/regscale/models/test_regscale_model.py +346 -0
  477. tests/regscale/models/test_report.py +32 -0
  478. tests/regscale/models/test_tenable_integrations.py +118 -0
  479. tests/regscale/models/test_user_model.py +121 -0
  480. tests/regscale/test_about.py +19 -0
  481. tests/regscale/test_authorization.py +65 -0
@@ -0,0 +1,1756 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Integration of ServiceNow into RegScale CLI tool"""
4
+
5
+ # standard python imports
6
+ import datetime
7
+ import json
8
+ import os
9
+ import sys
10
+ from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed
11
+ from json import JSONDecodeError
12
+ from threading import Lock
13
+ from typing import List, Optional, Tuple, Union, Literal
14
+ from urllib.parse import urljoin
15
+
16
+ import click
17
+ import requests
18
+ from pathlib import Path
19
+ from rich.progress import track
20
+
21
+ from regscale.core.app.api import Api
22
+ from regscale.core.app.application import Application
23
+ from regscale.core.app.logz import create_logger
24
+ from regscale.core.app.utils.api_handler import APIUpdateError
25
+ from regscale.core.app.utils.app_utils import (
26
+ check_file_path,
27
+ check_license,
28
+ create_progress_object,
29
+ compute_hashes_in_directory,
30
+ error_and_exit,
31
+ save_data_to,
32
+ get_current_datetime,
33
+ )
34
+ from regscale.core.app.utils.regscale_utils import verify_provided_module
35
+ from regscale.models import Change, Data, File, Issue, regscale_id, regscale_module
36
+ from regscale.utils.threading.threadhandler import create_threads, thread_assignment
37
+
38
+ job_progress = create_progress_object()
39
+ logger = create_logger()
40
+ APP_JSON = "application/json"
41
+ HEADERS = {"Content-Type": APP_JSON, "Accept": APP_JSON}
42
+ INCIDENT_TABLE = "api/now/table/incident"
43
+ update_counter = []
44
+ update_objects = []
45
+ new_regscale_objects = []
46
+ updated_regscale_objects = []
47
+
48
+
49
+ class ServiceNowConfig:
50
+ """
51
+ ServiceNow configuration class
52
+ """
53
+
54
+ reg_config: dict
55
+ url: str
56
+ user: str
57
+ pwd: str
58
+ reg_api: "Api" = Api()
59
+ api: "Api" = Api()
60
+ custom_fields: dict = {}
61
+ incident_type: str = "Low"
62
+ incident_group: str = "Service Desk"
63
+ urgency_map = {
64
+ "High": "1",
65
+ "Medium": "2",
66
+ "Low": "3",
67
+ }
68
+
69
+ def __init__(self, reg_config: dict, incident_type: str = "Low", incident_group: str = "Service Desk"):
70
+ self.reg_config = reg_config
71
+ self.url = reg_config.get("snowUrl")
72
+ self.user = reg_config.get("snowUserName")
73
+ self.pwd = reg_config.get("snowPassword")
74
+ self.api.auth = (self.user, self.pwd)
75
+ self.custom_fields = reg_config.get("serviceNow", {}).get("customFields", {})
76
+ self.incident_type = self.urgency_map.get(incident_type, "Low")
77
+ self.incident_group = incident_group
78
+ self.check_servicenow_config()
79
+
80
+ def check_servicenow_config(self) -> None:
81
+ """
82
+ Check if ServiceNow configuration is complete and not the defaults
83
+
84
+ :return: None
85
+ """
86
+ fields = {"snowUrl": "url", "snowUserName": "user", "snowPassword": "pwd"}
87
+ missing_keys = []
88
+ for key, field in fields.items():
89
+ if value := getattr(self, field):
90
+ if value == self.api.app.template.get(key):
91
+ missing_keys.append(key)
92
+ if missing_keys:
93
+ error_and_exit(
94
+ f"ServiceNow configuration is incomplete. Missing values for the following key(s): {', '.join(missing_keys)}",
95
+ )
96
+
97
+
98
+ # Create group to handle ServiceNow integration
99
+ @click.group()
100
+ def servicenow():
101
+ """Auto-assigns incidents in ServiceNow for remediation."""
102
+ check_license()
103
+
104
+
105
+ ####################################################################################################
106
+ #
107
+ # PROCESS ISSUES TO ServiceNow
108
+ # ServiceNow REST API Docs:
109
+ # https://docs.servicenow.com/bundle/xanadu-application-development/page/build/custom-application/concept/build-applications.html
110
+ # Use the REST API Explorer in ServiceNow to select table, get URL, and select which fields to
111
+ # populate
112
+ #
113
+ ####################################################################################################
114
+ @servicenow.command()
115
+ @regscale_id()
116
+ @regscale_module()
117
+ @click.option(
118
+ "--snow_assignment_group",
119
+ type=click.STRING,
120
+ help="RegScale will sync the issues for the record to this ServiceNow assignment group.",
121
+ prompt="Enter the name of the project in ServiceNow",
122
+ required=True,
123
+ )
124
+ @click.option(
125
+ "--snow_incident_type",
126
+ type=click.STRING,
127
+ help="Enter the ServiceNow incident type to use when creating new issues from RegScale.",
128
+ prompt="Enter the ServiceNow incident type",
129
+ required=True,
130
+ )
131
+ def issues(
132
+ regscale_id: int,
133
+ regscale_module: str,
134
+ snow_assignment_group: str,
135
+ snow_incident_type: str,
136
+ ):
137
+ """Process issues to ServiceNow."""
138
+ sync_snow_to_regscale(
139
+ regscale_id=regscale_id,
140
+ regscale_module=regscale_module,
141
+ snow_assignment_group=snow_assignment_group,
142
+ snow_incident_type=snow_incident_type,
143
+ )
144
+
145
+
146
+ @servicenow.command(name="issues_and_attachments")
147
+ @regscale_id()
148
+ @regscale_module()
149
+ @click.option(
150
+ "--snow_assignment_group",
151
+ "-g",
152
+ type=click.STRING,
153
+ help="RegScale will sync the issues for the record to this ServiceNow assignment group.",
154
+ prompt="ServiceNow assignment group",
155
+ required=True,
156
+ )
157
+ @click.option(
158
+ "--snow_incident_type",
159
+ "-t",
160
+ type=click.Choice(["High", "Medium", "Low"], case_sensitive=False),
161
+ help="Enter the ServiceNow incident type to use when creating new issues from RegScale.",
162
+ prompt="ServiceNow incident type",
163
+ required=True,
164
+ )
165
+ @click.option(
166
+ "--sync_attachments",
167
+ "-a",
168
+ type=click.BOOL,
169
+ help=(
170
+ "Whether RegScale will sync the attachments for the issue "
171
+ "in the provided ServiceNow assignment group and vice versa. Defaults to True."
172
+ ),
173
+ required=False,
174
+ default=True,
175
+ )
176
+ @click.option(
177
+ "--sync_all_incidents",
178
+ "-all",
179
+ type=click.BOOL,
180
+ help=(
181
+ "Whether to Sync all incidents from ServiceNow and RegScale issues for the "
182
+ "provided regscale_id and regscale_module."
183
+ ),
184
+ required=False,
185
+ default=True,
186
+ )
187
+ def issues_and_attachments(
188
+ regscale_id: int,
189
+ regscale_module: str,
190
+ snow_assignment_group: str,
191
+ snow_incident_type: str,
192
+ sync_attachments: bool = True,
193
+ sync_all_incidents: bool = True,
194
+ ):
195
+ """Process issues to ServiceNow."""
196
+ sync_snow_and_regscale(
197
+ parent_id=regscale_id,
198
+ parent_module=regscale_module,
199
+ snow_assignment_group=snow_assignment_group,
200
+ snow_incident_type=snow_incident_type.title(),
201
+ sync_attachments=sync_attachments,
202
+ sync_all_incidents=sync_all_incidents,
203
+ )
204
+
205
+
206
+ @servicenow.command(name="sync_work_notes")
207
+ @regscale_id(required=False)
208
+ @regscale_module(required=False)
209
+ def sync_work_notes(regscale_id: int, regscale_module: str):
210
+ """Sync work notes from ServiceNow to existing issues in RegScale. Use regscale_id and regscale_module to sync work notes to specific issues."""
211
+ if not regscale_id and not regscale_module:
212
+ sync_notes_to_regscale()
213
+ elif regscale_id and regscale_module:
214
+ sync_notes_to_regscale(regscale_id=regscale_id, regscale_module=regscale_module)
215
+ else:
216
+ error_and_exit("Please provide both --regscale_id and --regscale_module to sync work notes.")
217
+
218
+
219
+ def get_issues_data(reg_api: Api, url_issues: str) -> List[dict]:
220
+ """
221
+ Fetch the full issue list from RegScale
222
+
223
+ :param Api reg_api: RegScale API object
224
+ :param str url_issues: URL for RegScale issues
225
+ :return: List of issues
226
+ :rtype: List[dict]
227
+ """
228
+ logger.info("Fetching full issue list from RegScale.")
229
+ issue_response = reg_api.get(url_issues)
230
+ result = []
231
+ if issue_response.status_code == 204:
232
+ logger.warning("No existing issues for this RegScale record.")
233
+ else:
234
+ try:
235
+ result = issue_response.json()
236
+ except JSONDecodeError as rex:
237
+ error_and_exit(f"Unable to fetch issues from RegScale.\\n{rex}")
238
+ return result
239
+
240
+
241
+ def create_snow_incident(
242
+ snow_config: ServiceNowConfig,
243
+ incident_url: str,
244
+ snow_incident: dict,
245
+ tag: dict,
246
+ custom_fields: Optional[dict] = None,
247
+ ) -> dict:
248
+ """
249
+ Create a new incident in ServiceNow
250
+
251
+ :param ServiceNowConfig snow_config: ServiceNow configuration as a dictionary
252
+ :param str incident_url: URL for ServiceNow incidents
253
+ :param dict snow_incident: Incident data
254
+ :param dict tag: ServiceNow tag to add to new incident
255
+ :param Optional[dict] custom_fields: Custom fields to add to the incident, defaults to None
256
+ :return: Incident response
257
+ :rtype: dict
258
+ """
259
+ if custom_fields is None:
260
+ custom_fields = {}
261
+ result = {}
262
+ snow_api = snow_config.api
263
+ try:
264
+ response = snow_api.post(
265
+ url=incident_url,
266
+ headers=HEADERS,
267
+ json={**snow_incident, **custom_fields},
268
+ )
269
+ if not response.raise_for_status():
270
+ result = response.json()
271
+ if tag:
272
+ new_incident = result["result"]
273
+ payload = {
274
+ "label": tag["sys_id"],
275
+ "read": "yes",
276
+ "table": "incident",
277
+ "table_key": new_incident["sys_id"],
278
+ "title": f"Incident - {new_incident['number']}",
279
+ "id_type": "incident",
280
+ "id_display": new_incident["number"],
281
+ "viewable_by": "everyone",
282
+ }
283
+ tag_url = urljoin(snow_config.url, "/api/now/table/label_entry")
284
+ res = snow_api.post(tag_url, headers=HEADERS, json=payload)
285
+ if res.ok:
286
+ logger.debug("Tag %s added to incident %s", tag["name"], new_incident["sys_id"])
287
+ else:
288
+ logger.warning("Unable to add tag %s to incident %s", tag["name"], new_incident["sys_id"])
289
+ except requests.exceptions.RequestException as ex:
290
+ if custom_fields:
291
+ logger.error(
292
+ "Unable to create incident %s in ServiceNow. Retrying without custom fields %s...\n%s",
293
+ snow_incident,
294
+ custom_fields,
295
+ ex,
296
+ )
297
+ return create_snow_incident(snow_config, incident_url, snow_incident, tag)
298
+ logger.error("Unable to create incident %s in ServiceNow...\n%s", snow_incident, ex)
299
+ return result
300
+
301
+
302
+ def sync_snow_to_regscale(
303
+ regscale_id: int,
304
+ regscale_module: str,
305
+ snow_assignment_group: str,
306
+ snow_incident_type: str,
307
+ ) -> None:
308
+ """
309
+ Sync issues from ServiceNow to RegScale via API
310
+ :param int regscale_id: ID # of record in RegScale to associate issues with
311
+ :param str regscale_module: RegScale module to associate issues with
312
+ :param str snow_assignment_group: Snow assignment group to filter for
313
+ :param str snow_incident_type: Snow incident type to filter for
314
+ :rtype: None
315
+ """
316
+ # initialize variables
317
+ app = Application()
318
+ reg_api = Api()
319
+ verify_provided_module(regscale_module)
320
+ config = app.config
321
+
322
+ # Group related variables into a dictionary
323
+ snow_config = ServiceNowConfig(reg_config=config)
324
+
325
+ url_issues = urljoin(
326
+ config["domain"],
327
+ f"api/issues/getAllByParent/{str(regscale_id)}/{str(regscale_module).lower()}",
328
+ )
329
+
330
+ if issues_data := get_issues_data(reg_api, url_issues):
331
+ check_file_path("artifacts")
332
+ save_data_to(
333
+ file=Path("./artifacts/existingRecordIssues.json"),
334
+ data=issues_data,
335
+ )
336
+ logger.info(
337
+ "Writing out RegScale issue list for Record # %s to the artifacts folder "
338
+ + "(see existingRecordIssues.json).",
339
+ regscale_id,
340
+ )
341
+ logger.info(
342
+ "%s existing issues retrieved for processing from RegScale.",
343
+ len(issues_data),
344
+ )
345
+
346
+ int_new, int_skipped = process_issues(
347
+ issues_data,
348
+ snow_config,
349
+ snow_assignment_group,
350
+ snow_incident_type,
351
+ )
352
+
353
+ logger.info(
354
+ "%i new issue incidents opened in ServiceNow and %i issues already exist and were skipped.",
355
+ int_new,
356
+ int_skipped,
357
+ )
358
+ else:
359
+ logger.warning("No issues found for this record in RegScale. No issues were processed.")
360
+
361
+
362
+ def create_snow_assignment_group(snow_assignment_group: str, snow_config: ServiceNowConfig) -> None:
363
+ """
364
+ Create a new assignment group in ServiceNow or if one already exists,
365
+ a 403 is returned from SNOW.
366
+
367
+ :param str snow_assignment_group: ServiceNow assignment group
368
+ :param ServiceNowConfig snow_config: ServiceNow configuration
369
+ :rtype: None
370
+ """
371
+ # Create a service now assignment group. The api will not allow me create dups
372
+ snow_api = snow_config.api
373
+ payload = {
374
+ "name": snow_assignment_group,
375
+ "description": "An automatically generated Service Now assignment group from RegScale.",
376
+ "active": True,
377
+ }
378
+ url = urljoin(snow_config.url, "api/now/table/sys_user_group")
379
+ response = snow_api.post(
380
+ url=url,
381
+ headers=HEADERS,
382
+ json=payload,
383
+ )
384
+ if response.status_code == 201:
385
+ logger.info("ServiceNow Assignment Group %s created.", snow_assignment_group)
386
+ elif response.status_code == 403:
387
+ # I expect a 403 for a duplicate code already found
388
+ logger.debug("ServiceNow Assignment Group %s already exists.", snow_assignment_group)
389
+ elif response.status_code == 401:
390
+ error_and_exit("Unauthorized to create ServiceNow Assignment Group. Please check your ServiceNow credentials.")
391
+ else:
392
+ error_and_exit(
393
+ f"Unable to create ServiceNow Assignment Group {snow_assignment_group}. "
394
+ f"Status code: {response.status_code}"
395
+ )
396
+
397
+
398
+ def get_service_now_incidents(snow_config: ServiceNowConfig, query: str) -> List[dict]:
399
+ """
400
+ Get all incidents from ServiceNow
401
+
402
+ :param dict snow_config: ServiceNow configuration
403
+ :param str query: Query string
404
+ :return: List of incidents
405
+ :rtype: List[dict]
406
+ """
407
+ snow_api = snow_config.api
408
+ incident_url = urljoin(snow_config.url, INCIDENT_TABLE)
409
+ offset = 0
410
+ limit = 500
411
+ data = []
412
+
413
+ while True:
414
+ result, offset = query_service_now(
415
+ api=snow_api,
416
+ snow_url=incident_url,
417
+ offset=offset,
418
+ limit=limit,
419
+ query=query,
420
+ )
421
+ data += result
422
+ if not result:
423
+ break
424
+
425
+ return data
426
+
427
+
428
+ def process_issues(
429
+ issues_data: List[dict],
430
+ snow_config: ServiceNowConfig,
431
+ snow_assignment_group: str,
432
+ snow_incident_type: str,
433
+ ) -> Tuple[int, int]:
434
+ """
435
+ Process issues and create new incidents in ServiceNow
436
+
437
+ :param List[dict] issues_data: List of issues
438
+ :param ServiceNowConfig snow_config: ServiceNow configuration
439
+ :param str snow_assignment_group: ServiceNow assignment group
440
+ :param str snow_incident_type: ServiceNow incident type
441
+ :return: Number of new incidents created, plus number of skipped incidents
442
+ :rtype: Tuple[int, int]
443
+ """
444
+ config = snow_config.reg_config
445
+ int_new = 0
446
+ int_skipped = 0
447
+ # Need a lock for int_new
448
+ lock = Lock()
449
+ # Make sure the assignment group exists
450
+ create_snow_assignment_group(snow_assignment_group, snow_config)
451
+
452
+ with job_progress:
453
+ with ThreadPoolExecutor(max_workers=10) as executor:
454
+ if issues_data:
455
+ task = job_progress.add_task(
456
+ f"[#f8b737]Syncing {len(issues_data)} RegScale issues to ServiceNow",
457
+ total=len(issues_data),
458
+ )
459
+
460
+ futures = [
461
+ executor.submit(
462
+ create_incident,
463
+ iss,
464
+ snow_config,
465
+ snow_assignment_group,
466
+ snow_incident_type,
467
+ config,
468
+ {},
469
+ {},
470
+ False,
471
+ )
472
+ for iss in issues_data
473
+ ]
474
+ for future in as_completed(futures):
475
+ try:
476
+ snow_response = future.result()
477
+ with lock:
478
+ if snow_response:
479
+ iss = snow_response["originalIssue"]
480
+ int_new += 1
481
+ logger.debug(snow_response)
482
+ logger.info(
483
+ "SNOW Incident ID %s created.",
484
+ snow_response["result"]["sys_id"],
485
+ )
486
+ iss["serviceNowId"] = snow_response["result"]["sys_id"]
487
+ try:
488
+ Issue(**iss).save()
489
+ except APIUpdateError as ex:
490
+ logger.error(
491
+ "Unable to update issue in RegScale: %s\n%s",
492
+ iss,
493
+ ex,
494
+ )
495
+ else:
496
+ int_skipped += 1
497
+ job_progress.update(task, advance=1)
498
+ except CancelledError as e:
499
+ logger.error("Future was cancelled: %s", e)
500
+
501
+ return int_new, int_skipped
502
+
503
+
504
+ def create_incident(
505
+ iss: dict,
506
+ snow_config: ServiceNowConfig,
507
+ snow_assignment_group: str,
508
+ snow_incident_type: str,
509
+ config: dict,
510
+ tag: dict,
511
+ attachments: dict,
512
+ add_attachments: bool = False,
513
+ ) -> Optional[dict]:
514
+ """
515
+ Create a new incident in ServiceNow
516
+
517
+ :param dict iss: Issue data
518
+ :param ServiceNowConfig snow_config: ServiceNow configuration
519
+ :param str snow_assignment_group: ServiceNow assignment group
520
+ :param str snow_incident_type: ServiceNow incident type
521
+ :param dict config: Application config
522
+ :param dict tag: ServiceNow tag to add to new incidents
523
+ :param dict attachments: Dict of attachments from RegScale and ServiceNow
524
+ :param bool add_attachments: Sync attachments from RegScale to ServiceNow, defaults to False
525
+ :return: Response dataset from ServiceNow or None
526
+ :rtype: Optional[dict]
527
+ """
528
+ response = None
529
+ if iss.get("serviceNowId", "") != "" and iss.get("serviceNowId") is not None:
530
+ return response
531
+
532
+ snow_incident = map_regscale_to_snow_incident(
533
+ regscale_issue=iss,
534
+ snow_assignment_group=snow_assignment_group,
535
+ snow_incident_type=snow_incident_type,
536
+ config=config,
537
+ )
538
+ incident_url = urljoin(snow_config.url, INCIDENT_TABLE)
539
+ if response := create_snow_incident(
540
+ snow_config=snow_config,
541
+ incident_url=incident_url,
542
+ snow_incident=snow_incident,
543
+ tag=tag,
544
+ custom_fields=snow_config.custom_fields, # type: ignore
545
+ ):
546
+ response["originalIssue"] = iss
547
+ if add_attachments and attachments:
548
+ compare_files_for_dupes_and_upload(
549
+ snow_issue=response["result"],
550
+ regscale_issue=iss,
551
+ snow_config=snow_config,
552
+ attachments=attachments,
553
+ )
554
+ return response
555
+
556
+
557
+ def map_regscale_to_snow_incident(
558
+ regscale_issue: Union[dict, Issue],
559
+ snow_assignment_group: str,
560
+ snow_incident_type: str,
561
+ config: dict,
562
+ ) -> dict:
563
+ """
564
+ Map RegScale issue to ServiceNow incident
565
+
566
+ :param Union[dict, Issue] regscale_issue: RegScale issue to map to ServiceNow incident
567
+ :param str snow_assignment_group: ServiceNow assignment group
568
+ :param str snow_incident_type: ServiceNow incident type
569
+ :param dict config: RegScale CLI Application configuration
570
+ :return: ServiceNow incident data
571
+ :rtype: dict
572
+ """
573
+ if isinstance(regscale_issue, Issue):
574
+ regscale_issue = regscale_issue.model_dump()
575
+ snow_incident = {
576
+ "description": regscale_issue["description"],
577
+ "short_description": regscale_issue["title"],
578
+ "assignment_group": snow_assignment_group,
579
+ "due_date": regscale_issue["dueDate"],
580
+ "comments": f"RegScale Issue #{regscale_issue['id']} - {config['domain']}/form/issues/{regscale_issue['id']}",
581
+ "state": "New",
582
+ "urgency": snow_incident_type,
583
+ }
584
+ # update state and closed_at if the RegScale issue is closed
585
+ if regscale_issue["status"] == "Closed":
586
+ snow_incident["state"] = "Closed"
587
+ snow_incident["closed_at"] = regscale_issue["dateCompleted"]
588
+ return snow_incident
589
+
590
+
591
+ def sync_snow_and_regscale(
592
+ parent_id: int,
593
+ parent_module: str,
594
+ snow_assignment_group: str,
595
+ snow_incident_type: Literal["High", "Medium", "Low"],
596
+ sync_attachments: bool = True,
597
+ sync_all_incidents: bool = True,
598
+ ) -> None:
599
+ """
600
+ Sync issues, bidirectionally, from ServiceNow into RegScale as issues
601
+
602
+ :param int parent_id: ID # from RegScale to associate issues with
603
+ :param str parent_module: RegScale module to associate issues with
604
+ :param str snow_assignment_group: Assignment Group Name of the project in ServiceNow
605
+ :param str snow_incident_type: Type of issues to sync from ServiceNow
606
+ :param bool sync_attachments: Whether to sync attachments in RegScale & ServiceNow, defaults to True
607
+ :param bool sync_all_incidents: Whether to sync all incidents from ServiceNow and RegScale issues
608
+ :rtype: None
609
+ """
610
+ app = check_license()
611
+ api = Api()
612
+ config = app.config
613
+ snow_config = ServiceNowConfig(
614
+ reg_config=config, incident_type=snow_incident_type, incident_group=snow_assignment_group
615
+ )
616
+
617
+ # see if provided RegScale Module is an accepted option
618
+ verify_provided_module(parent_module)
619
+ # Make sure the assignment group exists
620
+ create_snow_assignment_group(snow_assignment_group, snow_config)
621
+ query = "&sysparm_display_value=true"
622
+ tag = get_or_create_snow_tag(snow_config=snow_config, tag_name=f"regscale-{parent_module}-{parent_id}")
623
+ if sync_all_incidents:
624
+ incidents = get_service_now_incidents(snow_config=snow_config, query=query)
625
+ else:
626
+ incidents = get_snow_incidents(snow_config=snow_config, query=query, tag=tag)
627
+
628
+ (
629
+ regscale_issues,
630
+ regscale_attachments,
631
+ ) = Issue.fetch_issues_and_attachments_by_parent(
632
+ parent_id=parent_id,
633
+ parent_module=parent_module,
634
+ fetch_attachments=sync_attachments,
635
+ )
636
+ snow_attachments = get_snow_attachment_metadata(snow_config)
637
+ attachments = {
638
+ "regscale": regscale_attachments or {},
639
+ "snow": snow_attachments or {},
640
+ }
641
+
642
+ if regscale_issues:
643
+ # sync RegScale issues to SNOW
644
+ if issues_to_update := sync_regscale_to_snow(
645
+ regscale_issues=regscale_issues,
646
+ snow_config=snow_config,
647
+ config=config,
648
+ attachments=attachments,
649
+ tag=tag,
650
+ sync_attachments=sync_attachments,
651
+ ):
652
+ with job_progress:
653
+ # create task to update RegScale issues
654
+ updating_issues = job_progress.add_task(
655
+ f"[#f8b737]Updating {len(issues_to_update)} RegScale issue(s) from ServiceNow...",
656
+ total=len(issues_to_update),
657
+ )
658
+ # create threads to analyze ServiceNow incidents and RegScale issues
659
+ create_threads(
660
+ process=update_regscale_issues,
661
+ args=(
662
+ issues_to_update,
663
+ api,
664
+ updating_issues,
665
+ ),
666
+ thread_count=len(issues_to_update),
667
+ )
668
+ # output the final result
669
+ logger.info(
670
+ "%i/%i issue(s) updated in RegScale.",
671
+ len(issues_to_update),
672
+ len(update_counter),
673
+ )
674
+ else:
675
+ logger.info("No issues need to be updated in RegScale.")
676
+
677
+ if incidents:
678
+ sync_snow_incidents_to_regscale_issues(
679
+ incidents=incidents,
680
+ regscale_issues=regscale_issues,
681
+ sync_attachments=sync_attachments,
682
+ attachments=attachments,
683
+ app=app,
684
+ snow_config=snow_config,
685
+ parent_id=parent_id,
686
+ parent_module=parent_module,
687
+ )
688
+ else:
689
+ logger.info("No incidents need to be analyzed from ServiceNow.")
690
+
691
+
692
+ def get_or_create_snow_tag(snow_config: ServiceNowConfig, tag_name: str) -> dict:
693
+ """
694
+ Check if a tag exists in ServiceNow, if not, create it
695
+
696
+ :param ServiceNowConfig snow_config: ServiceNow configuration
697
+ :param str tag_name: Desired name of the tag
698
+ :return: List of tags
699
+ :rtype: List[str]
700
+ """
701
+ snow_api = snow_config.api
702
+ tags_url = urljoin(snow_config.url, "api/now/table/label")
703
+
704
+ offset = 0
705
+ limit = 500
706
+ data = []
707
+
708
+ while True:
709
+ result, offset = query_service_now(
710
+ api=snow_api,
711
+ snow_url=tags_url,
712
+ offset=offset,
713
+ limit=limit,
714
+ query=f"&sysparm_query=name={tag_name}",
715
+ )
716
+ data += result
717
+ if not result:
718
+ break
719
+
720
+ if data:
721
+ return data[0]
722
+ return create_snow_tag(snow_config=snow_config, tag_name=tag_name)
723
+
724
+
725
+ def create_snow_tag(snow_config: ServiceNowConfig, tag_name: str) -> Optional[dict]:
726
+ """
727
+ Create a new assignment group in ServiceNow or if one already exists,
728
+ a 403 is returned from SNOW.
729
+
730
+ :param ServiceNowConfig snow_config: ServiceNow configuration dictionary
731
+ :param str tag_name: ServiceNow tag name
732
+ :return: Created tag or None
733
+ :rtype: Optional[dict]
734
+ """
735
+ # Create a service now tag. The api will not allow duplicates
736
+ snow_api = snow_config.api
737
+ payload = {
738
+ "name": tag_name,
739
+ "max_entries": 100000, # arbitrary number, just needs to be large to avoid limit issues
740
+ "global": False,
741
+ "active": True,
742
+ "sys_class_name": "tag",
743
+ "type": "Standard",
744
+ }
745
+ url = urljoin(snow_config.url, "api/now/table/label")
746
+ response = snow_api.post(
747
+ url=url,
748
+ headers=HEADERS,
749
+ json=payload,
750
+ )
751
+ if response.status_code == 201:
752
+ logger.info("ServiceNow Tag %s created.", tag_name)
753
+ return response.json()["result"]
754
+ elif response.status_code == 403:
755
+ # I expect a 403 for a duplicate code already found
756
+ logger.debug("ServiceNow Tag %s already exists.", tag_name)
757
+ elif response.status_code == 401:
758
+ error_and_exit("Unauthorized to create ServiceNow Tag. Please check your ServiceNow credentials.")
759
+ else:
760
+ error_and_exit(f"Unable to create ServiceNow Tag {tag_name}. Status code: {response.status_code}")
761
+
762
+
763
+ def update_regscale_issues(args: Tuple, thread: int) -> None:
764
+ """
765
+ Function to compare ServiceNow incidents and RegScale issues
766
+
767
+ :param Tuple args: Tuple of args to use during the process
768
+ :param int thread: Thread number of current thread
769
+ :rtype: None
770
+ """
771
+ # set up local variables from the passed args
772
+ (
773
+ regscale_issues,
774
+ app,
775
+ task,
776
+ ) = args
777
+ # find which records should be executed by the current thread
778
+ threads = thread_assignment(thread=thread, total_items=len(regscale_issues))
779
+ # iterate through the thread assignment items and process them
780
+ for i in range(len(threads)):
781
+ # set the issue for the thread for later use in the function
782
+ issue = regscale_issues[threads[i]]
783
+ # update the issue in RegScale
784
+ issue = issue.save()
785
+ logger.debug(
786
+ "RegScale Issue %i was updated with the ServiceNow incident #%s.",
787
+ issue.id,
788
+ issue.serviceNowId,
789
+ )
790
+ update_counter.append(issue)
791
+ # update progress bar
792
+ job_progress.update(task, advance=1)
793
+
794
+
795
+ def get_snow_incidents(snow_config: ServiceNowConfig, query: str = "", tag: Optional[dict] = None) -> List[dict]:
796
+ """
797
+ Get all incidents from ServiceNow
798
+
799
+ :param ServiceNowConfig snow_config: ServiceNow Configuration object
800
+ :param str query: Query string, defaults to ""
801
+ :param dict tag: Tag to filter incidents by, defaults to None
802
+ :return: List of incidents
803
+ :rtype: List[dict]
804
+ """
805
+ snow_api = snow_config.api
806
+ incident_url = urljoin(snow_config.url, INCIDENT_TABLE)
807
+ offset = 0
808
+ limit = 500
809
+ data = []
810
+ if tag:
811
+ query += f"&sysparm_query=sys_tags.{tag['sys_id']}={tag['sys_id']}"
812
+
813
+ while True:
814
+ result, offset = query_service_now(
815
+ api=snow_api,
816
+ snow_url=incident_url,
817
+ offset=offset,
818
+ limit=limit,
819
+ query=query,
820
+ )
821
+ data += result
822
+ if not result:
823
+ break
824
+
825
+ return data
826
+
827
+
828
+ def sync_regscale_to_snow(
829
+ regscale_issues: list[Issue],
830
+ snow_config: ServiceNowConfig,
831
+ config: dict,
832
+ attachments: dict,
833
+ tag: dict,
834
+ sync_attachments: bool = True,
835
+ ) -> list[Issue]:
836
+ """
837
+ Sync issues from RegScale to SNOW
838
+
839
+ :param list[Issue] regscale_issues: list of RegScale issues to sync to SNOW
840
+ :param ServiceNowConfig snow_config: SNOW configuration
841
+ :param dict config: RegScale CLI configuration
842
+ :param dict attachments: Dict of attachments from RegScale and SNOW
843
+ :param dict tag: SNOW tag to add to new incidents
844
+ :param bool sync_attachments: Sync attachments from RegScale to SNOW, defaults to True
845
+ :return: list of RegScale issues that need to be updated
846
+ :rtype: list[Issue]
847
+ """
848
+ new_issue_counter = 0
849
+ issuess_to_update = []
850
+ with job_progress:
851
+ # create task to create ServiceNow incidents
852
+ creating_issues = job_progress.add_task(
853
+ f"[#f8b737]Verifying {len(regscale_issues)} RegScale issue(s) exist in ServiceNow...",
854
+ total=len(regscale_issues),
855
+ )
856
+ for issue in regscale_issues:
857
+ # create_incident has logic to check if the issue already has serviceNowId populated
858
+ if new_issue := create_incident(
859
+ iss=issue.model_dump(),
860
+ snow_config=snow_config,
861
+ snow_assignment_group=snow_config.incident_group,
862
+ snow_incident_type=snow_config.incident_type,
863
+ config=config,
864
+ tag=tag,
865
+ add_attachments=sync_attachments,
866
+ attachments=attachments,
867
+ ):
868
+ # log progress
869
+ new_issue_counter += 1
870
+ # get the ServiceNow incident ID
871
+ snow_id = new_issue["result"]["number"]
872
+ # update the RegScale issue for the ServiceNow link
873
+ issue.serviceNowId = snow_id
874
+ # add the issue to the update_issues global list
875
+ issuess_to_update.append(issue)
876
+ job_progress.update(creating_issues, advance=1)
877
+ # output the final result
878
+ logger.info("%i new incident(s) opened in ServiceNow.", new_issue_counter)
879
+ return issuess_to_update
880
+
881
+
882
+ def compare_files_for_dupes_and_upload(
883
+ snow_issue: dict,
884
+ regscale_issue: dict,
885
+ snow_config: ServiceNowConfig,
886
+ attachments: dict,
887
+ ) -> None:
888
+ """
889
+ Compare files for duplicates and upload them to ServiceNow and RegScale
890
+
891
+ :param dict snow_issue: SNOW issue to upload the attachments to
892
+ :param dict regscale_issue: RegScale issue to upload the attachments from
893
+ :param ServiceNowConfig snow_config: SNOW configuration
894
+ :param dict attachments: Attachments from RegScale and ServiceNow
895
+ :rtype: None
896
+ """
897
+ import tempfile
898
+
899
+ api = Api()
900
+ snow_uploaded_attachments = []
901
+ regscale_uploaded_attachments = []
902
+ with tempfile.TemporaryDirectory() as temp_dir:
903
+ snow_dir, regscale_dir = download_issue_attachments_to_directory(
904
+ directory=temp_dir,
905
+ regscale_issue=regscale_issue,
906
+ snow_issue=snow_issue,
907
+ api=api,
908
+ snow_config=snow_config,
909
+ attachments=attachments,
910
+ )
911
+ snow_attachment_hashes = compute_hashes_in_directory(snow_dir)
912
+ regscale_attachment_hashes = compute_hashes_in_directory(regscale_dir)
913
+
914
+ upload_files_to_snow(
915
+ snow_attachment_hashes=snow_attachment_hashes,
916
+ regscale_attachment_hashes=regscale_attachment_hashes,
917
+ snow_issue=snow_issue,
918
+ snow_config=snow_config,
919
+ regscale_issue=regscale_issue,
920
+ snow_uploaded_attachments=snow_uploaded_attachments,
921
+ )
922
+ upload_files_to_regscale(
923
+ snow_attachment_hashes=snow_attachment_hashes,
924
+ regscale_attachment_hashes=regscale_attachment_hashes,
925
+ regscale_issue=regscale_issue,
926
+ api=api,
927
+ regscale_uploaded_attachments=regscale_uploaded_attachments,
928
+ )
929
+
930
+ log_upload_results(regscale_uploaded_attachments, snow_uploaded_attachments, regscale_issue, snow_issue)
931
+
932
+
933
+ def download_snow_attachment(attachment: dict, snow_config: ServiceNowConfig, save_dir: str) -> None:
934
+ """
935
+ Download an attachment from ServiceNow
936
+
937
+ :param dict attachment: Attachment to download
938
+ :param ServiceNowConfig snow_config: SNOW configuration
939
+ :param str save_dir: Directory to save the attachment in
940
+ :rtype: None
941
+ """
942
+ snow_api = snow_config.api
943
+ # check if the file_name has an extension
944
+ if not Path(attachment["file_name"]).suffix:
945
+ import mimetypes
946
+
947
+ suffix = mimetypes.guess_extension(attachment["content_type"])
948
+ attachment["file_name"] = attachment["file_name"] + suffix
949
+ with open(os.path.join(save_dir, attachment["file_name"]), "wb") as file:
950
+ res = snow_api.get(attachment["download_link"])
951
+ if res.ok:
952
+ file.write(res.content)
953
+ else:
954
+ logger.error("Unable to download %s from ServiceNow.", attachment["file_name"])
955
+
956
+
957
+ def upload_files_to_snow(
958
+ snow_attachment_hashes: dict,
959
+ regscale_attachment_hashes: dict,
960
+ snow_issue: dict,
961
+ snow_config: ServiceNowConfig,
962
+ regscale_issue: dict,
963
+ snow_uploaded_attachments: list,
964
+ ) -> None:
965
+ """
966
+ Upload files to ServiceNow
967
+
968
+ :param dict snow_attachment_hashes: Dictionary of SNOW attachment hashes
969
+ :param dict regscale_attachment_hashes: Dictionary of RegScale attachment hashes
970
+ :param dict snow_issue: SNOW issue to upload the attachments to
971
+ :param ServiceNowConfig snow_config: SNOW configuration
972
+ :param dict regscale_issue: RegScale issue to upload the attachments from
973
+ :param list snow_uploaded_attachments: List of SNOW attachments that were uploaded
974
+ :rtype: None
975
+ """
976
+ snow_api = snow_config.api
977
+ upload_url = urljoin(snow_config.url, "/api/now/attachment/file")
978
+
979
+ for file_hash, file in regscale_attachment_hashes.items():
980
+ if file_hash not in snow_attachment_hashes:
981
+ with open(file, "rb") as in_file:
982
+ path_file = Path(file)
983
+ data = in_file.read()
984
+ params = {
985
+ "table_name": "incident",
986
+ "table_sys_id": snow_issue["sys_id"],
987
+ "file_name": f"RegScale_Issue_{regscale_issue['id']}_{path_file.name}",
988
+ }
989
+ headers = {"Content-Type": File.determine_mime_type(path_file.suffix), "Accept": APP_JSON}
990
+ response = snow_api.post(url=upload_url, headers=headers, data=data, params=params) # type: ignore
991
+ if response.raise_for_status():
992
+ logger.error(
993
+ "Unable to upload %s to ServiceNow incident %s.",
994
+ path_file.name,
995
+ snow_issue["number"],
996
+ )
997
+ else:
998
+ logger.debug(
999
+ "Uploaded %s to ServiceNow incident %s.",
1000
+ path_file.name,
1001
+ snow_issue["number"],
1002
+ )
1003
+ snow_uploaded_attachments.append(file)
1004
+
1005
+
1006
+ def download_issue_attachments_to_directory(
1007
+ directory: str,
1008
+ regscale_issue: dict,
1009
+ snow_issue: dict,
1010
+ api: Api,
1011
+ snow_config: ServiceNowConfig,
1012
+ attachments: dict,
1013
+ ) -> tuple[str, str]:
1014
+ """
1015
+ Function to download attachments from ServiceNow and RegScale issues to a directory
1016
+
1017
+ :param str directory: Directory to store the files in
1018
+ :param dict regscale_issue: RegScale issue to download the attachments for
1019
+ :param dict snow_issue: SNOW issue to download the attachments for
1020
+ :param Api api: Api object to use for interacting with RegScale
1021
+ :param ServiceNowConfig snow_config: SNOW configuration
1022
+ :param dict attachments: Dictionary of attachments from RegScale and ServiceNow
1023
+ :return: Tuple of strings containing the SNOW and RegScale directories
1024
+ :rtype: tuple[str, str]
1025
+ """
1026
+ # determine which attachments need to be uploaded to prevent duplicates by checking hashes
1027
+ snow_dir = os.path.join(directory, "snow")
1028
+ check_file_path(snow_dir, False)
1029
+ # download all attachments from ServiceNow to the snow directory in temp_dir
1030
+ for attachment in attachments["snow"].get(snow_issue.get("sys_id"), []):
1031
+ download_snow_attachment(attachment, snow_config, snow_dir)
1032
+ # get the regscale issue attachments
1033
+ regscale_issue_attachments = attachments["regscale"].get(regscale_issue["id"], [])
1034
+ # create a directory for the regscale attachments
1035
+ regscale_dir = os.path.join(directory, "regscale")
1036
+ check_file_path(regscale_dir, False)
1037
+ # download regscale attachments to the directory
1038
+ for attachment in regscale_issue_attachments:
1039
+ with open(os.path.join(regscale_dir, attachment.trustedDisplayName), "wb") as file:
1040
+ file.write(
1041
+ File.download_file_from_regscale_to_memory(
1042
+ api=api,
1043
+ record_id=regscale_issue["id"],
1044
+ module="issues",
1045
+ stored_name=attachment.trustedStorageName,
1046
+ file_hash=(attachment.fileHash if attachment.fileHash else attachment.shaHash),
1047
+ )
1048
+ )
1049
+ return snow_dir, regscale_dir
1050
+
1051
+
1052
+ def upload_files_to_regscale(
1053
+ snow_attachment_hashes: dict,
1054
+ regscale_attachment_hashes: dict,
1055
+ regscale_issue: dict,
1056
+ api: Api,
1057
+ regscale_uploaded_attachments: list,
1058
+ ) -> None:
1059
+ """
1060
+ Upload files to RegScale
1061
+
1062
+ :param dict snow_attachment_hashes: Dictionary of SNOW attachment hashes
1063
+ :param dict regscale_attachment_hashes: Dictionary of RegScale attachment hashes
1064
+ :param dict regscale_issue: RegScale issue to upload the attachments to
1065
+ :param Api api: Api object to use for interacting with RegScale
1066
+ :param list regscale_uploaded_attachments: List of RegScale attachments that were uploaded
1067
+ :rtype: None
1068
+ :return: None
1069
+ """
1070
+ for file_hash, file in snow_attachment_hashes.items():
1071
+ if file_hash not in regscale_attachment_hashes:
1072
+ with open(file, "rb") as in_file:
1073
+ path_file = Path(file)
1074
+ if File.upload_file_to_regscale(
1075
+ file_name=f"ServiceNow_attachment_{path_file.name}",
1076
+ parent_id=regscale_issue["id"],
1077
+ parent_module="issues",
1078
+ api=api,
1079
+ file_data=in_file.read(),
1080
+ ):
1081
+ regscale_uploaded_attachments.append(file)
1082
+ logger.debug(
1083
+ "Uploaded %s to RegScale issue #%i.",
1084
+ path_file.name,
1085
+ regscale_issue["id"],
1086
+ )
1087
+ else:
1088
+ logger.warning(
1089
+ "Unable to upload %s to RegScale issue #%i.",
1090
+ path_file.name,
1091
+ regscale_issue["id"],
1092
+ )
1093
+
1094
+
1095
+ def log_upload_results(
1096
+ regscale_uploaded_attachments: list, snow_uploaded_attachments: list, regscale_issue: dict, snow_issue: dict
1097
+ ) -> None:
1098
+ """
1099
+ Log the results of the upload process
1100
+
1101
+ :param list regscale_uploaded_attachments: List of RegScale attachments that were uploaded
1102
+ :param list snow_uploaded_attachments: List of Snow attachments that were uploaded
1103
+ :param dict regscale_issue: RegScale issue that the attachments were uploaded to
1104
+ :param dict snow_issue: SNOW issue that the attachments were uploaded to
1105
+ :rtype: None
1106
+ """
1107
+ if regscale_uploaded_attachments and snow_uploaded_attachments:
1108
+ logger.info(
1109
+ "%i file(s) uploaded to RegScale issue #%i and %i file(s) uploaded to ServiceNow incident %s.",
1110
+ len(regscale_uploaded_attachments),
1111
+ regscale_issue["id"],
1112
+ len(snow_uploaded_attachments),
1113
+ snow_issue["number"],
1114
+ )
1115
+ elif snow_uploaded_attachments:
1116
+ logger.info(
1117
+ "%i file(s) uploaded to ServiceNow incident %s.",
1118
+ len(snow_uploaded_attachments),
1119
+ snow_issue["number"],
1120
+ )
1121
+ elif regscale_uploaded_attachments:
1122
+ logger.info(
1123
+ "%i file(s) uploaded to RegScale issue #%i.",
1124
+ len(regscale_uploaded_attachments),
1125
+ regscale_issue["id"],
1126
+ )
1127
+
1128
+
1129
+ def sync_snow_incidents_to_regscale_issues(
1130
+ incidents: list[dict],
1131
+ regscale_issues: list[Issue],
1132
+ sync_attachments: bool,
1133
+ attachments: dict,
1134
+ app: "Application",
1135
+ snow_config: ServiceNowConfig,
1136
+ parent_id: int,
1137
+ parent_module: str,
1138
+ ) -> None:
1139
+ """
1140
+ Sync incidents from ServiceNow to RegScale
1141
+
1142
+ :param list[dict] incidents: List of SNOW incidents to sync to RegScale
1143
+ :param list[Issue] regscale_issues: List of RegScale issues to compare to SNOW Incidents
1144
+ :param bool sync_attachments: Sync attachments from ServieNow to RegScale, defaults to True
1145
+ :param dict attachments: Attachments from RegScale and ServiceNow
1146
+ :param Application app: RegScale CLI application object
1147
+ :param dict snow_config: ServiceNow configuration
1148
+ :param int parent_id: Parent record ID in RegScale
1149
+ :param str parent_module: Parent record module in RegScale
1150
+ :rtype: None
1151
+ """
1152
+ issues_closed = []
1153
+ with job_progress:
1154
+ creating_issues = job_progress.add_task(
1155
+ f"[#f8b737]Comparing {len(incidents)} ServiceNow incident(s)"
1156
+ f" and {len(regscale_issues)} RegScale issue(s)...",
1157
+ total=len(incidents),
1158
+ )
1159
+ create_threads(
1160
+ process=create_and_update_regscale_issues,
1161
+ args=(
1162
+ incidents,
1163
+ regscale_issues,
1164
+ snow_config,
1165
+ sync_attachments,
1166
+ attachments,
1167
+ app,
1168
+ parent_id,
1169
+ parent_module,
1170
+ creating_issues,
1171
+ job_progress,
1172
+ ),
1173
+ thread_count=len(incidents),
1174
+ )
1175
+ logger.info(
1176
+ f"Analyzed {len(incidents)} ServiceNow incidents(s), created {len(new_regscale_objects)} issue(s), "
1177
+ f"updated {len(updated_regscale_objects)} issue(s), and closed {len(issues_closed)} issue(s) in RegScale.",
1178
+ )
1179
+
1180
+
1181
+ def create_and_update_regscale_issues(args: Tuple, thread: int) -> None:
1182
+ """
1183
+ Function to create or update issues in RegScale from ServiceNow
1184
+
1185
+ :param Tuple args: Tuple of args to use during the process
1186
+ :param int thread: Thread number of current thread
1187
+ :rtype: None
1188
+ """
1189
+ # set up local variables from the passed args
1190
+ (
1191
+ incidents,
1192
+ regscale_issues,
1193
+ snow_config,
1194
+ add_attachments,
1195
+ attachments,
1196
+ app,
1197
+ parent_id,
1198
+ parent_module,
1199
+ task,
1200
+ progress,
1201
+ ) = args
1202
+ # find which records should be executed by the current thread
1203
+ threads = thread_assignment(thread=thread, total_items=len(incidents))
1204
+ # iterate through the thread assignment items and process them
1205
+ for i in range(len(threads)):
1206
+ snow_incident: dict = incidents[threads[i]]
1207
+ regscale_issue: Optional[Issue] = next(
1208
+ (issue for issue in regscale_issues if issue.serviceNowId == snow_incident["number"]), None
1209
+ )
1210
+ data = Data(
1211
+ parentId=0,
1212
+ parentModule=Issue.get_module_string(),
1213
+ dataType="JSON",
1214
+ dataSource=f"ServiceNow Incident #{snow_incident['number']}",
1215
+ rawData=json.dumps(snow_incident),
1216
+ )
1217
+ # see if the incident needs to be created in RegScale
1218
+ if not regscale_issue:
1219
+ # map the SNOW incident to a RegScale issue object
1220
+ issue = map_incident_to_regscale_issue(
1221
+ incident=snow_incident,
1222
+ parent_id=parent_id,
1223
+ parent_module=parent_module,
1224
+ )
1225
+ # create the issue in RegScale
1226
+ if regscale_issue := issue.create():
1227
+ logger.debug(
1228
+ "Created issue #%i-%s in RegScale.",
1229
+ regscale_issue.id,
1230
+ regscale_issue.title,
1231
+ )
1232
+ data.parentId = regscale_issue.id
1233
+ data.create()
1234
+ new_regscale_objects.append(regscale_issue)
1235
+ else:
1236
+ logger.warning("Unable to create issue in RegScale.\nIssue: %s", issue.dict())
1237
+ elif snow_incident["state"].lower() == "closed" and regscale_issue.status not in ["Closed", "Cancelled"]:
1238
+ # update the status and date completed of the RegScale issue
1239
+ regscale_issue.status = "Closed"
1240
+ regscale_issue.dateCompleted = snow_incident["closed_at"]
1241
+ # update the issue in RegScale
1242
+ updated_regscale_objects.append(regscale_issue.save())
1243
+ data.parentId = regscale_issue.id
1244
+ data.create_or_update()
1245
+ elif regscale_issue:
1246
+ # update the issue in RegScale
1247
+ updated_regscale_objects.append(regscale_issue.save())
1248
+ data.parentId = regscale_issue.id
1249
+ data.create_or_update()
1250
+
1251
+ if add_attachments and regscale_issue and snow_incident["sys_id"] in attachments["snow"]:
1252
+ # determine which attachments need to be uploaded to prevent duplicates by
1253
+ # getting the hashes of all SNOW & RegScale attachments
1254
+ compare_files_for_dupes_and_upload(
1255
+ snow_issue=snow_incident,
1256
+ regscale_issue=regscale_issue.model_dump(),
1257
+ snow_config=snow_config,
1258
+ attachments=attachments,
1259
+ )
1260
+ # update progress bar
1261
+ progress.update(task, advance=1)
1262
+
1263
+
1264
+ def map_incident_to_regscale_issue(incident: dict, parent_id: int, parent_module: str) -> Issue:
1265
+ """
1266
+ Map a ServiceNow incident to a RegScale issue
1267
+
1268
+ :param dict incident: ServiceNow incident to map to RegScale issue
1269
+ :param int parent_id: Parent record ID in RegScale
1270
+ :param str parent_module: Parent record module in RegScale
1271
+ :return: RegScale issue object
1272
+ :rtype: Issue
1273
+ """
1274
+ default_due_date = datetime.datetime.now() + datetime.timedelta(days=30)
1275
+ new_issue = Issue(
1276
+ title=incident["short_description"],
1277
+ description=incident["description"],
1278
+ dueDate=incident["due_date"] or default_due_date.strftime("%Y-%m-%d %H:%M:%S"),
1279
+ parentId=parent_id,
1280
+ parentModule=parent_module,
1281
+ serviceNowId=incident["number"],
1282
+ status="Closed" if incident["state"].lower() == "closed" else "Open",
1283
+ severityLevel=Issue.assign_severity(incident["priority"].split(" ")[-1]),
1284
+ )
1285
+ # correct the status if it is canceled
1286
+ if incident["state"].lower() == "canceled":
1287
+ new_issue.status = "Cancelled"
1288
+ if new_issue.status in ["Closed", "Cancelled"]:
1289
+ new_issue.dateCompleted = incident.get("closed_at", get_current_datetime())
1290
+ return new_issue
1291
+
1292
+
1293
+ def get_snow_attachment_metadata(snow_config: ServiceNowConfig) -> dict[str, list[dict]]:
1294
+ """
1295
+ Get attachments for a ServiceNow incident
1296
+
1297
+ :param ServiceNowConfig snow_config: ServiceNow's configuration object
1298
+ :return: Dictionary of attachments with table_sys_id as the key and the attachments as the value
1299
+ :rtype: dict[str, list[dict]]
1300
+ """
1301
+ snow_api = snow_config.api
1302
+ attachment_url = urljoin(snow_config.url, "api/now/attachment")
1303
+ offset = 0
1304
+ limit = 500
1305
+ data = []
1306
+ sorted_data = {}
1307
+
1308
+ while True:
1309
+ result, offset = query_service_now(
1310
+ api=snow_api,
1311
+ snow_url=attachment_url,
1312
+ offset=offset,
1313
+ limit=limit,
1314
+ query="&table_name=incident",
1315
+ )
1316
+ data += result
1317
+ if not result:
1318
+ break
1319
+ for item in data:
1320
+ key = item["table_sys_id"]
1321
+ if key in sorted_data:
1322
+ sorted_data[key].append(item)
1323
+ else:
1324
+ sorted_data[key] = [item]
1325
+ return sorted_data
1326
+
1327
+
1328
+ def sync_notes_to_regscale(regscale_id: int = None, regscale_module: str = None) -> None:
1329
+ """
1330
+ Sync work notes from ServiceNow to existing issues
1331
+
1332
+ :param int regscale_id: RegScale record ID
1333
+ :param str regscale_module: RegScale record module
1334
+ :rtype: None
1335
+ """
1336
+ app = Application()
1337
+ # get secrets
1338
+ snow_config = ServiceNowConfig(reg_config=app.config)
1339
+ query = ""
1340
+ data = get_service_now_incidents(snow_config, query=query)
1341
+ work_notes = get_service_now_work_notes(snow_config, data)
1342
+ work_notes_mapping = {}
1343
+ # change work_notes to a dictionary using the incident id as the key and a list of work notes as the value
1344
+ for work_note in work_notes:
1345
+ key = work_note["element_id"]
1346
+ if key in work_notes_mapping:
1347
+ work_notes_mapping[key].append(work_note)
1348
+ else:
1349
+ work_notes_mapping[key] = [work_note]
1350
+ process_work_notes(
1351
+ data=data,
1352
+ work_notes_mapping=work_notes_mapping,
1353
+ regscale_id=regscale_id,
1354
+ regscale_module=regscale_module,
1355
+ )
1356
+
1357
+
1358
+ def get_service_now_work_notes(snow_config: ServiceNowConfig, incidents: list) -> list:
1359
+ """
1360
+ Get all work notes from ServiceNow
1361
+
1362
+ :param ServiceNowConfig snow_config: ServiceNow's configuration dictionary
1363
+ :param list incidents: List of incidents from ServiceNow
1364
+ :return: List of work notes
1365
+ :rtype: list
1366
+ """
1367
+ snow_api = snow_config.api
1368
+ work_notes_url = urljoin(snow_config.url, "api/now/table/sys_journal_field")
1369
+ offset = 0
1370
+ limit = 500
1371
+ data = []
1372
+ if sys_ids := [incident["sys_id"] for incident in incidents]:
1373
+ # filter work notes by using the sys_ids, and only get work notes for incidents
1374
+ query = f"&element_idIN{','.join(sys_ids)}&element=work_notes&name=incident"
1375
+ else:
1376
+ query = "element=work_notes"
1377
+
1378
+ while True:
1379
+ result, offset = query_service_now(
1380
+ api=snow_api,
1381
+ snow_url=work_notes_url,
1382
+ offset=offset,
1383
+ limit=limit,
1384
+ query=query,
1385
+ )
1386
+ data += result
1387
+ if not result:
1388
+ break
1389
+
1390
+ return data
1391
+
1392
+
1393
+ def process_work_notes(
1394
+ data: list,
1395
+ work_notes_mapping: dict[str, list[dict]],
1396
+ regscale_id: int = None,
1397
+ regscale_module: str = None,
1398
+ ) -> None:
1399
+ """
1400
+ Process and Sync the ServiceNow work notes to RegScale
1401
+
1402
+ :param list data: list of data from ServiceNow to sync with RegScale
1403
+ :param dict[str, list[dict]] work_notes_mapping: Mapping of work notes from SNOW with the incident sys_id as the key
1404
+ :param int regscale_id: RegScale record ID, defaults to None
1405
+ :param str regscale_module: RegScale record module, defaults to None
1406
+ :rtype: None
1407
+ """
1408
+ update_issues: list[Issue] = []
1409
+ for dat in track(
1410
+ data,
1411
+ description=f"Processing {len(data):,} ServiceNow incidents",
1412
+ ):
1413
+ incident_number = dat["number"]
1414
+ try:
1415
+ if regscale_id and regscale_module:
1416
+ regscale_issues = Issue.get_all_by_parent(regscale_id, regscale_module)
1417
+ else:
1418
+ regscale_issues = Issue.find_by_service_now_id(incident_number)
1419
+ logger.debug("Processing ServiceNow Issue # %s", incident_number)
1420
+ if updated_issue := determine_issue_description(
1421
+ incident=dat,
1422
+ regscale_issues=regscale_issues,
1423
+ work_notes_mapping=work_notes_mapping,
1424
+ ):
1425
+ update_issues.append(updated_issue)
1426
+ except requests.HTTPError:
1427
+ logger.warning(
1428
+ "HTTP Error: Unable to find RegScale issue with ServiceNow incident ID of %s.",
1429
+ incident_number,
1430
+ )
1431
+ if len(update_issues) > 0:
1432
+ logger.debug(update_issues)
1433
+ _ = Issue.batch_update(update_issues)
1434
+ else:
1435
+ logger.warning("All ServiceNow work notes are already in RegScale. No updates needed.")
1436
+ sys.exit(0)
1437
+
1438
+
1439
+ def determine_issue_description(
1440
+ incident: dict, regscale_issues: List[Issue], work_notes_mapping: dict[str, list[dict]]
1441
+ ) -> Optional[Issue]:
1442
+ """
1443
+ Determine if the issue description needs to be updated
1444
+
1445
+ :param dict incident: ServiceNow incident
1446
+ :param List[Issue] regscale_issues: List of RegScale issues to update the description for
1447
+ :param dict[str, list[dict]] work_notes_mapping: Mapping of work notes from SNOW with the incident sys_id as the key
1448
+ :return: Issue if description needs to be updated
1449
+ """
1450
+ # legacy SNOW work notes are stored as a string in the incident object, check if it is populated
1451
+ # if not, check the work_notes_mapping for the incident sys_id which will return a list of work notes
1452
+ work_notes = incident.get("work_notes") or work_notes_mapping.get(incident["sys_id"], [])
1453
+ if not work_notes:
1454
+ return None
1455
+
1456
+ for issue in regscale_issues:
1457
+ if issue.serviceNowId != incident["number"]:
1458
+ continue
1459
+ # if work_notes is a list, convert it to a string
1460
+ if isinstance(work_notes, list):
1461
+ work_notes = build_issue_description_from_list(work_notes, issue)
1462
+ if work_notes not in issue.description:
1463
+ logger.info(
1464
+ "Updating work item for RegScale issue # %s and ServiceNow incident " + "# %s.",
1465
+ issue.id,
1466
+ incident["number"],
1467
+ )
1468
+ issue.description = f"<strong>ServiceNow Work Notes: </strong>{work_notes}<br/>" + issue.description
1469
+ return issue
1470
+
1471
+
1472
+ def build_issue_description_from_list(work_notes: list[dict], issue: Issue) -> str:
1473
+ """
1474
+ Build a new description from a list of work notes from ServiceNow
1475
+
1476
+ :param list[dict] work_notes: List of work notes from ServiceNow
1477
+ :param Issue issue: RegScale issue
1478
+ :return: New description
1479
+ :rtype: str
1480
+ """
1481
+ new_description = ""
1482
+ # if work_notes is a list, convert it to a string
1483
+ for note in work_notes:
1484
+ if note["value"] not in issue.description:
1485
+ new_description += f"<br/>{note['value']}"
1486
+ return new_description
1487
+
1488
+
1489
+ def query_service_now(api: Api, snow_url: str, offset: int, limit: int, query: str) -> Tuple[list, int]:
1490
+ """
1491
+ Paginate through query results
1492
+
1493
+ :param Api api: API object
1494
+ :param str snow_url: URL for ServiceNow incidents
1495
+ :param int offset: Used in URL for ServiceNow API call
1496
+ :param int limit: Used in URL for ServiceNow API call
1497
+ :param str query: Query string for ServiceNow API call
1498
+ :return: Tuple[Result data from API call, offset integer provided]
1499
+ :rtype: Tuple[list, int]
1500
+ """
1501
+ result = []
1502
+ offset_param = f"&sysparm_offset={offset}"
1503
+ url = urljoin(snow_url, f"?sysparm_limit={limit}{offset_param}{query}")
1504
+ logger.debug(url)
1505
+ response = api.get(url=url, headers=HEADERS)
1506
+ if response.status_code == 200:
1507
+ try:
1508
+ result = response.json().get("result", [])
1509
+ except JSONDecodeError as e:
1510
+ logger.error("Unable to decode JSON: %s\nResponse: %i: %s", e, response.status_code, response.text)
1511
+ else:
1512
+ logger.error(
1513
+ "Unable to query ServiceNow. Status code: %s, Reason: %s",
1514
+ response.status_code,
1515
+ response.reason,
1516
+ )
1517
+ offset += limit
1518
+ logger.debug(len(result))
1519
+ return result, offset
1520
+
1521
+
1522
+ def get_service_now_changes(snow_config: ServiceNowConfig, query: str) -> List[dict]:
1523
+ """
1524
+ Get all change requests from ServiceNow
1525
+
1526
+ :param dict snow_config: ServiceNow configuration
1527
+ :param str query: Query string
1528
+ :return: List of change requests
1529
+ :rtype: List[dict]
1530
+ """
1531
+ snow_api = snow_config.api
1532
+ changes_url = urljoin(snow_config.url, "api/now/table/change_request")
1533
+ offset = 0
1534
+ limit = 500
1535
+ data = []
1536
+
1537
+ while True:
1538
+ result, offset = query_service_now(
1539
+ api=snow_api,
1540
+ snow_url=changes_url,
1541
+ offset=offset,
1542
+ limit=limit,
1543
+ query=query,
1544
+ )
1545
+ data += result
1546
+ if not result:
1547
+ break
1548
+
1549
+ return data
1550
+
1551
+
1552
+ @servicenow.command(name="sync_changes")
1553
+ @click.option(
1554
+ "--start_date",
1555
+ "-s",
1556
+ type=click.DateTime(formats=["%Y-%m-%d"]),
1557
+ help="The start date to query ServiceNow for changes in YYYY-MM-DD format. Defaults to 30 days ago.",
1558
+ required=False,
1559
+ default=datetime.datetime.now() - datetime.timedelta(days=30),
1560
+ )
1561
+ @click.option(
1562
+ "--sync_all_changes",
1563
+ "-all",
1564
+ is_flag=True,
1565
+ help="Whether to Sync all change requests from ServiceNow into RegScale as Changes. Defaults to False.",
1566
+ default=False,
1567
+ )
1568
+ def sync_changes(
1569
+ start_date: datetime.datetime,
1570
+ # sync_attachments: bool,
1571
+ sync_all_changes: bool,
1572
+ ):
1573
+ """Sync change requests from ServiceNow into RegScale as Changes."""
1574
+ sync_snow_changes(
1575
+ start_date=start_date,
1576
+ sync_all_changes=sync_all_changes,
1577
+ )
1578
+
1579
+
1580
+ def sync_snow_changes(
1581
+ start_date: datetime.datetime,
1582
+ sync_all_changes: bool = False,
1583
+ ) -> None:
1584
+ """
1585
+ Sync change requests from ServiceNow into RegScale as Changes
1586
+
1587
+ :param datetime.datetime start_date: The start date to query SNOW for changes, ignored if sync_all_changes is True
1588
+ :param bool sync_all_changes: Whether to sync all change requests from SNOW into RegScale changes
1589
+ :rtype: None
1590
+ """
1591
+ app = check_license()
1592
+ config = app.config
1593
+ snow_config = ServiceNowConfig(reg_config=config)
1594
+ query = "&sysparm_display_value=true"
1595
+
1596
+ if sync_all_changes:
1597
+ changes = get_service_now_changes(snow_config=snow_config, query=query)
1598
+ else:
1599
+ query += f"&sysparm_query=sys_created_on>={start_date.strftime('%Y-%m-%d %H:%M:%S')}"
1600
+ changes = get_service_now_changes(snow_config=snow_config, query=query)
1601
+
1602
+ logger.info(f"Retrieved {len(changes)} change(s) from ServiceNow.")
1603
+ regscale_changes = Change.fetch_all_changes()
1604
+
1605
+ if changes:
1606
+ sync_snow_changes_to_regscale_issues(
1607
+ changes=changes,
1608
+ regscale_changes=regscale_changes,
1609
+ app=app,
1610
+ snow_config=snow_config,
1611
+ )
1612
+ current_date = get_current_datetime(dt_format="%Y%m%d_%H-%M-%S")
1613
+ # save the snow_changes to a xlsx file
1614
+ save_data_to(
1615
+ file=Path(f"artifacts/snow_changes_{current_date}.xlsx"),
1616
+ data=changes,
1617
+ transpose_data=False,
1618
+ )
1619
+ else:
1620
+ logger.info("No changes need to be analyzed from ServiceNow.")
1621
+
1622
+
1623
+ def sync_snow_changes_to_regscale_issues(
1624
+ changes: list[dict],
1625
+ regscale_changes: list[Change],
1626
+ app: "Application",
1627
+ snow_config: ServiceNowConfig,
1628
+ ) -> None:
1629
+ """
1630
+ Sync incidents from ServiceNow to RegScale
1631
+
1632
+ :param list[dict] changes: List of SNOW incidents to sync to RegScale
1633
+ :param list[Change] regscale_changes: List of RegScale issues to compare to SNOW Incidents
1634
+ :param Application app: RegScale CLI application object
1635
+ :param dict snow_config: ServiceNow configuration
1636
+ :rtype: None
1637
+ """
1638
+ issues_closed = []
1639
+ with job_progress:
1640
+ creating_issues = job_progress.add_task(
1641
+ f"[#f8b737]Comparing {len(changes)} ServiceNow change(s)"
1642
+ f" and {len(regscale_changes)} RegScale change(s)...",
1643
+ total=len(changes),
1644
+ )
1645
+ app.thread_manager.submit_tasks_from_list(
1646
+ func=create_and_update_regscale_changes,
1647
+ items=changes,
1648
+ args=(
1649
+ changes,
1650
+ regscale_changes,
1651
+ snow_config,
1652
+ app,
1653
+ creating_issues,
1654
+ job_progress,
1655
+ ),
1656
+ )
1657
+ _ = app.thread_manager.execute_and_verify(terminate_after=True)
1658
+ logger.info(
1659
+ f"Analyzed {len(changes)} ServiceNow change(s), created {len(new_regscale_objects)} change(s), "
1660
+ f"updated {len(updated_regscale_objects)} change(s), and closed {len(issues_closed)} change(s) in RegScale.",
1661
+ )
1662
+
1663
+
1664
+ def create_and_update_regscale_changes(snow_change: dict, args: Tuple) -> None:
1665
+ """
1666
+ Function to create or update changes in RegScale from ServiceNow
1667
+
1668
+ :param dict snow_change: ServiceNow change request object
1669
+ :param Tuple args: Tuple of args to use during the process
1670
+ :rtype: None
1671
+ """
1672
+ # set up local variables from the passed args
1673
+ (
1674
+ snow_changes,
1675
+ regscale_changes,
1676
+ snow_config,
1677
+ app,
1678
+ task,
1679
+ progress,
1680
+ ) = args
1681
+ regscale_change: Optional[Change] = next(
1682
+ (change for change in regscale_changes if snow_change["number"] in change.title), None
1683
+ )
1684
+ change = map_snow_change_to_regscale_change(
1685
+ change=snow_change,
1686
+ )
1687
+ if regscale_change:
1688
+ change.id = regscale_change.id
1689
+ change.save()
1690
+ updated_regscale_objects.append(change)
1691
+ else:
1692
+ new_change = change.create()
1693
+ new_regscale_objects.append(new_change)
1694
+ change = new_change
1695
+ _ = Data(
1696
+ parentId=change.id,
1697
+ parentModule=Change.get_module_string(),
1698
+ dataType="JSON",
1699
+ dataSource=f"ServiceNow Change #{snow_change['number']}",
1700
+ rawData=json.dumps(snow_change),
1701
+ ).create_or_update()
1702
+ progress.update(task, advance=1)
1703
+
1704
+
1705
+ def map_snow_change_to_regscale_change(change: dict) -> Change:
1706
+ """
1707
+ Map a ServiceNow change request to a RegScale change record
1708
+
1709
+ :param dict change: ServiceNow change request to map to RegScale change object
1710
+ :return: RegScale change object
1711
+ :rtype: Change
1712
+ """
1713
+ from regscale.models.regscale_models.change import ChangePriority, ChangeStatus, ChangeType
1714
+
1715
+ status_map = {
1716
+ "Approved": ChangeStatus.approved.value,
1717
+ "Not Requested": ChangeStatus.draft.value,
1718
+ "Authorize": ChangeStatus.pending_approval.value,
1719
+ "Closed": ChangeStatus.complete.value,
1720
+ "Canceled": ChangeStatus.cancelled.value,
1721
+ }
1722
+ priority_map = {
1723
+ "1 - Critical": ChangePriority.critical.value,
1724
+ "2 - High": ChangePriority.high.value,
1725
+ "3 - Moderate": ChangePriority.moderate.value,
1726
+ "4 - Low": ChangePriority.low.value,
1727
+ }
1728
+ change_type_map = {
1729
+ "Standard": ChangeType.standard.value,
1730
+ "Emergency": ChangeType.emergency.value,
1731
+ "Normal": ChangeType.normal.value,
1732
+ }
1733
+
1734
+ regscale_change = Change(
1735
+ title=f'{change["short_description"]} #{change["number"]}',
1736
+ description=change["description"],
1737
+ changeReason=change.get("reason") or "No reason provided.",
1738
+ dateRequested=change["sys_created_on"],
1739
+ startChangeWindow=change.get("start_date") or change.get("opened_at"),
1740
+ endChangeWindow=change.get("end_date"),
1741
+ dateWorkCompleted=change.get("work_end") or change.get("closed_at"),
1742
+ outageRequired="No",
1743
+ priority=priority_map.get(change["priority"], ChangePriority.moderate.value),
1744
+ changeType=change_type_map.get(change.get("type", "Normal")),
1745
+ status=status_map.get(change["state"], ChangeStatus.draft.value),
1746
+ changePlan=change.get("implementation_plan"),
1747
+ riskAssessment=change.get("risk_impact_analysis"),
1748
+ rollbackPlan=change.get("backout_plan"),
1749
+ testPlan=change.get("test_plan"),
1750
+ notes=change.get("comments_and_work_notes"),
1751
+ securityImpactAssessment=change.get("impact"),
1752
+ )
1753
+ if regscale_change.dateWorkCompleted and regscale_change.status != ChangeStatus.complete.value:
1754
+ regscale_change.dateWorkCompleted = None
1755
+
1756
+ return regscale_change