regscale-cli 6.25.1.0__py3-none-any.whl → 6.27.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 (146) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/application.py +19 -4
  4. regscale/core/app/internal/evidence.py +419 -2
  5. regscale/core/app/internal/login.py +0 -1
  6. regscale/core/app/utils/catalog_utils/common.py +1 -1
  7. regscale/dev/code_gen.py +24 -20
  8. regscale/integrations/commercial/jira.py +367 -126
  9. regscale/integrations/commercial/qualys/__init__.py +7 -8
  10. regscale/integrations/commercial/qualys/scanner.py +8 -3
  11. regscale/integrations/commercial/sicura/api.py +14 -13
  12. regscale/integrations/commercial/sicura/commands.py +8 -2
  13. regscale/integrations/commercial/sicura/scanner.py +49 -39
  14. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  15. regscale/integrations/commercial/synqly/assets.py +17 -0
  16. regscale/integrations/commercial/synqly/vulnerabilities.py +45 -28
  17. regscale/integrations/commercial/tenablev2/cis_parsers.py +453 -0
  18. regscale/integrations/commercial/tenablev2/cis_scanner.py +447 -0
  19. regscale/integrations/commercial/tenablev2/commands.py +142 -1
  20. regscale/integrations/commercial/tenablev2/scanner.py +0 -1
  21. regscale/integrations/commercial/tenablev2/stig_parsers.py +113 -57
  22. regscale/integrations/commercial/wizv2/WizDataMixin.py +1 -1
  23. regscale/integrations/commercial/wizv2/click.py +64 -79
  24. regscale/integrations/commercial/wizv2/compliance/__init__.py +15 -0
  25. regscale/integrations/commercial/wizv2/{policy_compliance_helpers.py → compliance/helpers.py} +78 -60
  26. regscale/integrations/commercial/wizv2/compliance_report.py +161 -165
  27. regscale/integrations/commercial/wizv2/core/__init__.py +133 -0
  28. regscale/integrations/commercial/wizv2/{async_client.py → core/client.py} +3 -3
  29. regscale/integrations/commercial/wizv2/{constants.py → core/constants.py} +1 -17
  30. regscale/integrations/commercial/wizv2/core/file_operations.py +237 -0
  31. regscale/integrations/commercial/wizv2/fetchers/__init__.py +11 -0
  32. regscale/integrations/commercial/wizv2/{data_fetcher.py → fetchers/policy_assessment.py} +5 -9
  33. regscale/integrations/commercial/wizv2/issue.py +1 -1
  34. regscale/integrations/commercial/wizv2/models/__init__.py +0 -0
  35. regscale/integrations/commercial/wizv2/parsers/__init__.py +34 -0
  36. regscale/integrations/commercial/wizv2/{parsers.py → parsers/main.py} +1 -1
  37. regscale/integrations/commercial/wizv2/processors/__init__.py +11 -0
  38. regscale/integrations/commercial/wizv2/{finding_processor.py → processors/finding.py} +1 -1
  39. regscale/integrations/commercial/wizv2/reports.py +1 -1
  40. regscale/integrations/commercial/wizv2/sbom.py +1 -1
  41. regscale/integrations/commercial/wizv2/scanner.py +39 -99
  42. regscale/integrations/commercial/wizv2/utils/__init__.py +48 -0
  43. regscale/integrations/commercial/wizv2/{utils.py → utils/main.py} +116 -61
  44. regscale/integrations/commercial/wizv2/variables.py +89 -3
  45. regscale/integrations/compliance_integration.py +60 -41
  46. regscale/integrations/control_matcher.py +377 -0
  47. regscale/integrations/due_date_handler.py +14 -8
  48. regscale/integrations/milestone_manager.py +291 -0
  49. regscale/integrations/public/__init__.py +1 -0
  50. regscale/integrations/public/cci_importer.py +37 -38
  51. regscale/integrations/public/fedramp/click.py +60 -2
  52. regscale/integrations/public/fedramp/docx_parser.py +10 -1
  53. regscale/integrations/public/fedramp/fedramp_cis_crm.py +393 -340
  54. regscale/integrations/public/fedramp/fedramp_five.py +1 -1
  55. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  56. regscale/integrations/scanner_integration.py +277 -153
  57. regscale/models/integration_models/cisa_kev_data.json +282 -9
  58. regscale/models/integration_models/nexpose.py +36 -10
  59. regscale/models/integration_models/qualys.py +3 -4
  60. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  61. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +24 -7
  62. regscale/models/integration_models/synqly_models/synqly_model.py +8 -1
  63. regscale/models/locking.py +12 -8
  64. regscale/models/platform.py +1 -2
  65. regscale/models/regscale_models/control_implementation.py +47 -22
  66. regscale/models/regscale_models/issue.py +256 -95
  67. regscale/models/regscale_models/milestone.py +1 -1
  68. regscale/models/regscale_models/regscale_model.py +6 -1
  69. regscale/templates/__init__.py +0 -0
  70. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/METADATA +1 -17
  71. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/RECORD +145 -65
  72. tests/regscale/integrations/commercial/__init__.py +0 -0
  73. tests/regscale/integrations/commercial/conftest.py +28 -0
  74. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  75. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  76. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  77. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  78. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  79. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  80. tests/regscale/integrations/commercial/test_burp.py +48 -0
  81. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  82. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  83. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  84. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  85. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  86. tests/regscale/integrations/commercial/test_jira.py +2204 -0
  87. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  88. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  89. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  90. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  91. tests/regscale/integrations/commercial/test_snow.py +423 -0
  92. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  93. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  94. tests/regscale/integrations/commercial/test_stig.py +33 -0
  95. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  96. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  97. tests/regscale/integrations/commercial/test_wiz.py +1365 -0
  98. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  99. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  100. tests/regscale/integrations/commercial/wizv2/compliance/__init__.py +1 -0
  101. tests/regscale/integrations/commercial/wizv2/compliance/test_helpers.py +903 -0
  102. tests/regscale/integrations/commercial/wizv2/core/__init__.py +1 -0
  103. tests/regscale/integrations/commercial/wizv2/core/test_auth.py +701 -0
  104. tests/regscale/integrations/commercial/wizv2/core/test_client.py +1037 -0
  105. tests/regscale/integrations/commercial/wizv2/core/test_file_operations.py +989 -0
  106. tests/regscale/integrations/commercial/wizv2/fetchers/__init__.py +1 -0
  107. tests/regscale/integrations/commercial/wizv2/fetchers/test_policy_assessment.py +805 -0
  108. tests/regscale/integrations/commercial/wizv2/parsers/__init__.py +1 -0
  109. tests/regscale/integrations/commercial/wizv2/parsers/test_main.py +1153 -0
  110. tests/regscale/integrations/commercial/wizv2/processors/__init__.py +1 -0
  111. tests/regscale/integrations/commercial/wizv2/processors/test_finding.py +671 -0
  112. tests/regscale/integrations/commercial/wizv2/test_WizDataMixin.py +537 -0
  113. tests/regscale/integrations/commercial/wizv2/test_click_comprehensive.py +851 -0
  114. tests/regscale/integrations/commercial/wizv2/test_compliance_report_comprehensive.py +910 -0
  115. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  116. tests/regscale/integrations/commercial/wizv2/test_file_cleanup.py +283 -0
  117. tests/regscale/integrations/commercial/wizv2/test_file_operations.py +260 -0
  118. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  119. tests/regscale/integrations/commercial/wizv2/test_issue_comprehensive.py +1203 -0
  120. tests/regscale/integrations/commercial/wizv2/test_reports.py +497 -0
  121. tests/regscale/integrations/commercial/wizv2/test_sbom.py +643 -0
  122. tests/regscale/integrations/commercial/wizv2/test_scanner_comprehensive.py +805 -0
  123. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  124. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1394 -0
  125. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  126. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  127. tests/regscale/integrations/commercial/wizv2/test_wiz_findings_comprehensive.py +364 -0
  128. tests/regscale/integrations/commercial/wizv2/test_wiz_inventory_comprehensive.py +644 -0
  129. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  130. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +1132 -0
  131. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +519 -0
  132. tests/regscale/integrations/commercial/wizv2/utils/__init__.py +1 -0
  133. tests/regscale/integrations/commercial/wizv2/utils/test_main.py +1523 -0
  134. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  135. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  136. tests/regscale/integrations/public/test_fedramp.py +301 -0
  137. tests/regscale/integrations/test_control_matcher.py +1397 -0
  138. tests/regscale/integrations/test_control_matching.py +155 -0
  139. tests/regscale/integrations/test_milestone_manager.py +408 -0
  140. tests/regscale/models/test_issue.py +378 -1
  141. regscale/integrations/commercial/wizv2/policy_compliance.py +0 -3543
  142. /regscale/integrations/commercial/wizv2/{wiz_auth.py → core/auth.py} +0 -0
  143. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/LICENSE +0 -0
  144. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/WHEEL +0 -0
  145. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/entry_points.txt +0 -0
  146. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1748 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Tests for Microsoft Defender API integration"""
4
+ import json
5
+ from datetime import datetime
6
+ from json import JSONDecodeError
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ from pathlib import Path
11
+ from requests import Response
12
+
13
+ from regscale.integrations.commercial.microsoft_defender.defender_api import DefenderApi
14
+ from tests import CLITestFixture
15
+
16
+
17
+ class TestDefenderApi(CLITestFixture):
18
+ PATH = "regscale.integrations.commercial.microsoft_defender.defender_api"
19
+ cloud_token = "azureCloudAccessToken"
20
+ cloud_tenant_id = "azureCloudTenantId"
21
+ cloud_client_id = "azureCloudClientId"
22
+ cloud_secret = "azureCloudSecret"
23
+ cloud_subscription_id = "azureCloudSubscriptionId"
24
+ azure_365_tenant_id = "azure365TenantId"
25
+ azure_365_client_id = "azure365ClientId"
26
+ azure_365_secret = "azure365Secret"
27
+ azure_365_access_token = "azure365AccessToken"
28
+ azure_entra_tenant_id = "azureEntraTenantId"
29
+ azure_entra_client_id = "azureEntraClientId"
30
+ azure_entra_secret = "azureEntraSecret"
31
+ azure_entra_access_token = "azureEntraAccessToken"
32
+
33
+ def setup_mock_api(self, mock_api):
34
+ """Helper method to setup mock API with required Azure configuration"""
35
+ mock_api.config = {
36
+ "azure365AccessToken": "Bearer 365-token",
37
+ "azure365TenantId": "365-tenant",
38
+ "azure365ClientId": "365-client",
39
+ "azure365Secret": "365-secret",
40
+ "azureCloudAccessToken": "Bearer cloud-token",
41
+ "azureCloudTenantId": "cloud-tenant",
42
+ "azureCloudClientId": "cloud-client",
43
+ "azureCloudSecret": "cloud-secret",
44
+ "azureCloudSubscriptionId": "test-sub",
45
+ "azureEntraAccessToken": "Bearer entra-token",
46
+ "azureEntraTenantId": "entra-tenant",
47
+ "azureEntraClientId": "entra-client",
48
+ "azureEntraSecret": "entra-secret",
49
+ }
50
+
51
+ # Mock the initial get call for check_token to return 200 (valid token)
52
+ mock_get_response = MagicMock()
53
+ mock_get_response.status_code = 200
54
+ mock_api.get.return_value = mock_get_response
55
+
56
+ # Mock the post response for token retrieval during initialization
57
+ mock_token_response = MagicMock()
58
+ mock_token_response.json.return_value = {"access_token": "test-token"}
59
+ mock_api.post.return_value = mock_token_response
60
+
61
+ # Mock app.save_config to avoid config changes
62
+ mock_api.app.save_config = MagicMock()
63
+
64
+ return mock_api
65
+
66
+ def test_init(self):
67
+ """Test init file and config"""
68
+ self.verify_config(
69
+ [
70
+ self.azure_365_tenant_id,
71
+ self.azure_365_client_id,
72
+ self.azure_365_secret,
73
+ self.cloud_tenant_id,
74
+ self.cloud_client_id,
75
+ self.cloud_secret,
76
+ self.cloud_subscription_id,
77
+ ]
78
+ )
79
+
80
+ @patch(f"{PATH}.Api")
81
+ def test_defender_api_init_365(self, mock_api_class):
82
+ """Test DefenderApi initialization for 365"""
83
+ mock_api = MagicMock()
84
+ mock_api.config = self.config
85
+ mock_api_class.return_value = mock_api
86
+
87
+ defender_api = DefenderApi(system="365")
88
+
89
+ assert defender_api.system == "365"
90
+ assert defender_api.api == mock_api
91
+ assert defender_api.decode_error == "JSON Decode error"
92
+ assert defender_api.skip_token_key == "$skipToken"
93
+
94
+ @patch(f"{PATH}.Api")
95
+ def test_defender_api_init_cloud(self, mock_api_class):
96
+ """Test DefenderApi initialization for cloud"""
97
+ mock_api = MagicMock()
98
+ mock_api.config = {
99
+ "azureCloudAccessToken": "Bearer test-token",
100
+ "azureCloudTenantId": "test-tenant",
101
+ "azureCloudClientId": "test-client",
102
+ "azureCloudSecret": "test-secret",
103
+ "azureCloudSubscriptionId": "test-sub",
104
+ }
105
+ mock_api_class.return_value = mock_api
106
+
107
+ # Mock the post method for token operations
108
+ mock_post_response = MagicMock()
109
+ mock_post_response.json.return_value = {"access_token": "test-token"}
110
+ mock_api.post.return_value = mock_post_response
111
+
112
+ defender_api = DefenderApi(system="cloud")
113
+
114
+ assert defender_api.system == "cloud"
115
+ assert defender_api.api == mock_api
116
+ assert defender_api.config == mock_api.config
117
+
118
+ @patch(f"{PATH}.Api")
119
+ def test_set_headers(self, mock_api_class):
120
+ """Test setting headers"""
121
+ mock_api = MagicMock()
122
+ mock_api_class.return_value = mock_api
123
+
124
+ defender_api = DefenderApi(system="365")
125
+
126
+ with patch.object(defender_api, "check_token", return_value="Bearer test-token"):
127
+ headers = defender_api.set_headers()
128
+
129
+ assert headers["Content-Type"] == "application/json"
130
+ assert headers["Authorization"] == "Bearer test-token"
131
+
132
+ @patch(f"{PATH}.Api")
133
+ def test_get_token_365(self, mock_api_class):
134
+ """Test getting token for Defender 365"""
135
+ mock_api = MagicMock()
136
+ mock_api.config = {
137
+ "azure365AccessToken": "Bearer existing-token",
138
+ "azure365TenantId": "test-tenant",
139
+ "azure365ClientId": "test-client",
140
+ "azure365Secret": "test-secret",
141
+ "azureCloudAccessToken": "Bearer cloud-token",
142
+ "azureCloudTenantId": "cloud-tenant",
143
+ "azureCloudClientId": "cloud-client",
144
+ "azureCloudSecret": "cloud-secret",
145
+ "azureCloudSubscriptionId": "cloud-sub",
146
+ }
147
+ mock_api_class.return_value = mock_api
148
+
149
+ # Mock the initial get call for check_token to return 200 (valid token)
150
+ mock_get_response = MagicMock()
151
+ mock_get_response.status_code = 200
152
+ mock_api.get.return_value = mock_get_response
153
+
154
+ mock_response = MagicMock()
155
+ mock_response.json.return_value = {"access_token": "test-access-token"}
156
+ mock_api.post.return_value = mock_response
157
+
158
+ defender_api = DefenderApi(system="365")
159
+
160
+ # Reset the post mock after initialization
161
+ mock_api.post.reset_mock()
162
+
163
+ with patch.object(defender_api, "_parse_and_save_token", return_value="Bearer test-access-token") as mock_parse:
164
+ token = defender_api.get_token()
165
+
166
+ expected_url = "https://login.windows.net/test-tenant/oauth2/token"
167
+ expected_data = {
168
+ "resource": "https://api.securitycenter.windows.com",
169
+ "client_id": "test-client",
170
+ "client_secret": "test-secret",
171
+ "grant_type": "client_credentials",
172
+ }
173
+
174
+ mock_api.post.assert_called_once_with(
175
+ url=expected_url, headers={"Content-Type": "application/x-www-form-urlencoded"}, data=expected_data
176
+ )
177
+ mock_parse.assert_called_once_with(mock_response, "azure365AccessToken")
178
+ assert token == "Bearer test-access-token"
179
+
180
+ @patch(f"{PATH}.Api")
181
+ def test_get_token_cloud(self, mock_api_class):
182
+ """Test getting token for Defender for Cloud"""
183
+ mock_api = MagicMock()
184
+ mock_api.config = {
185
+ "azure365AccessToken": "Bearer 365-token",
186
+ "azure365TenantId": "365-tenant",
187
+ "azure365ClientId": "365-client",
188
+ "azure365Secret": "365-secret",
189
+ "azureCloudAccessToken": "Bearer existing-token",
190
+ "azureCloudTenantId": "test-tenant",
191
+ "azureCloudClientId": "test-client",
192
+ "azureCloudSecret": "test-secret",
193
+ "azureCloudSubscriptionId": "test-sub",
194
+ }
195
+ mock_api_class.return_value = mock_api
196
+
197
+ # Mock the initial get call for check_token to return 200 (valid token)
198
+ mock_get_response = MagicMock()
199
+ mock_get_response.status_code = 200
200
+ mock_api.get.return_value = mock_get_response
201
+
202
+ mock_response = MagicMock()
203
+ mock_response.json.return_value = {"access_token": "test-access-token"}
204
+ mock_api.post.return_value = mock_response
205
+
206
+ defender_api = DefenderApi(system="cloud")
207
+
208
+ # Reset the post mock after initialization
209
+ mock_api.post.reset_mock()
210
+
211
+ with patch.object(defender_api, "_parse_and_save_token", return_value="Bearer test-access-token") as mock_parse:
212
+ token = defender_api.get_token()
213
+
214
+ expected_url = "https://login.microsoftonline.com/test-tenant/oauth2/token"
215
+ expected_data = {
216
+ "resource": "https://management.azure.com",
217
+ "client_id": "test-client",
218
+ "client_secret": "test-secret",
219
+ "grant_type": "client_credentials",
220
+ }
221
+
222
+ mock_api.post.assert_called_once_with(
223
+ url=expected_url, headers={"Content-Type": "application/x-www-form-urlencoded"}, data=expected_data
224
+ )
225
+ mock_parse.assert_called_once_with(mock_response, "azureCloudAccessToken")
226
+ assert token == "Bearer test-access-token"
227
+
228
+ @patch(f"{PATH}.DefenderApi.set_headers")
229
+ @patch(f"{PATH}.error_and_exit")
230
+ @patch(f"{PATH}.Api")
231
+ def test_get_token_key_error(self, mock_api_class, mock_error_exit, mock_set_headers):
232
+ """Test get_token with KeyError"""
233
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
234
+
235
+ mock_api = MagicMock()
236
+ mock_api.config = {
237
+ "azure365AccessToken": "Bearer existing-token",
238
+ "azure365TenantId": "test-tenant",
239
+ "azure365ClientId": "test-client",
240
+ "azure365Secret": "test-secret",
241
+ "azureCloudAccessToken": "Bearer cloud-token",
242
+ "azureCloudTenantId": "cloud-tenant",
243
+ "azureCloudClientId": "cloud-client",
244
+ "azureCloudSecret": "cloud-secret",
245
+ "azureCloudSubscriptionId": "cloud-sub",
246
+ }
247
+ mock_api_class.return_value = mock_api
248
+
249
+ mock_response = MagicMock()
250
+ mock_response.json.return_value = {"error": "invalid_client"}
251
+ mock_response.text = "Invalid client"
252
+ mock_api.post.return_value = mock_response
253
+
254
+ defender_api = DefenderApi(system="365")
255
+
256
+ with patch.object(defender_api, "_parse_and_save_token", side_effect=KeyError("access_token")):
257
+ defender_api.get_token()
258
+
259
+ mock_error_exit.assert_called_once()
260
+ args = mock_error_exit.call_args[0][0]
261
+ assert "Didn't receive token from Azure" in args
262
+ assert "Invalid client" in args
263
+
264
+ @patch(f"{PATH}.DefenderApi.set_headers")
265
+ @patch(f"{PATH}.error_and_exit")
266
+ @patch(f"{PATH}.Api")
267
+ def test_get_token_json_decode_error(self, mock_api_class, mock_error_exit, mock_set_headers):
268
+ """Test get_token with JSONDecodeError"""
269
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
270
+
271
+ mock_api = MagicMock()
272
+ mock_api.config = {
273
+ "azure365AccessToken": "Bearer existing-token",
274
+ "azure365TenantId": "test-tenant",
275
+ "azure365ClientId": "test-client",
276
+ "azure365Secret": "test-secret",
277
+ "azureCloudAccessToken": "Bearer cloud-token",
278
+ "azureCloudTenantId": "cloud-tenant",
279
+ "azureCloudClientId": "cloud-client",
280
+ "azureCloudSecret": "cloud-secret",
281
+ "azureCloudSubscriptionId": "cloud-sub",
282
+ }
283
+ mock_api_class.return_value = mock_api
284
+
285
+ mock_response = MagicMock()
286
+ mock_response.text = "Invalid JSON"
287
+ mock_api.post.return_value = mock_response
288
+
289
+ defender_api = DefenderApi(system="365")
290
+
291
+ with patch.object(defender_api, "_parse_and_save_token", side_effect=JSONDecodeError("msg", "doc", 0)):
292
+ defender_api.get_token()
293
+
294
+ mock_error_exit.assert_called_once()
295
+ args = mock_error_exit.call_args[0][0]
296
+ assert "Unable to authenticate with Azure" in args
297
+
298
+ @patch(f"{PATH}.Api")
299
+ def test_check_token_valid_cloud(self, mock_api_class):
300
+ """Test checking valid token for cloud"""
301
+ mock_api = MagicMock()
302
+ mock_api.config = {
303
+ "azure365AccessToken": "Bearer 365-token",
304
+ "azure365TenantId": "365-tenant",
305
+ "azure365ClientId": "365-client",
306
+ "azure365Secret": "365-secret",
307
+ "azureCloudAccessToken": "Bearer valid-token",
308
+ "azureCloudTenantId": "cloud-tenant",
309
+ "azureCloudClientId": "cloud-client",
310
+ "azureCloudSecret": "cloud-secret",
311
+ "azureCloudSubscriptionId": "cloud-sub",
312
+ }
313
+ mock_api_class.return_value = mock_api
314
+
315
+ mock_response = MagicMock()
316
+ mock_response.status_code = 200
317
+ mock_api.get.return_value = mock_response
318
+
319
+ # Mock app.save_config to avoid changes to the config
320
+ mock_api.app.save_config = MagicMock()
321
+
322
+ defender_api = DefenderApi(system="cloud")
323
+
324
+ # Ensure config retains the expected value for the token test
325
+ defender_api.config["azureCloudAccessToken"] = "Bearer valid-token"
326
+
327
+ token = defender_api.check_token(url="https://test.com")
328
+
329
+ assert token == "Bearer valid-token"
330
+
331
+ @patch(f"{PATH}.Api")
332
+ def test_check_token_valid_365(self, mock_api_class):
333
+ """Test checking valid token for 365"""
334
+ mock_api = MagicMock()
335
+ mock_api.config = {
336
+ "azure365AccessToken": "Bearer valid-token",
337
+ "azure365TenantId": "test-tenant",
338
+ "azure365ClientId": "test-client",
339
+ "azure365Secret": "test-secret",
340
+ }
341
+ mock_api_class.return_value = mock_api
342
+
343
+ # Mock the response for the token validation check
344
+ mock_response = MagicMock()
345
+ mock_response.status_code = 200
346
+ mock_api.get.return_value = mock_response
347
+
348
+ # Mock the post method for potential token refresh (shouldn't be called)
349
+ mock_post_response = MagicMock()
350
+ mock_post_response.json.return_value = {"access_token": "new-token"}
351
+ mock_api.post.return_value = mock_post_response
352
+
353
+ defender_api = DefenderApi(system="365")
354
+
355
+ # Reset the config after initialization to ensure we use the expected token
356
+ defender_api.config["azure365AccessToken"] = "Bearer valid-token"
357
+
358
+ token = defender_api.check_token(url="https://test.com")
359
+
360
+ assert token == "Bearer valid-token"
361
+
362
+ @patch(f"{PATH}.error_and_exit")
363
+ @patch(f"{PATH}.Api")
364
+ def test_check_token_permission_error(self, mock_api_class, mock_error_exit):
365
+ """Test checking token with permission error"""
366
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
367
+ mock_error_exit.side_effect = SystemExit(1)
368
+
369
+ mock_api = MagicMock()
370
+ mock_api.config = {
371
+ "azureCloudAccessToken": "Bearer invalid-token",
372
+ "azureCloudTenantId": "test-tenant",
373
+ "azureCloudClientId": "test-client",
374
+ "azureCloudSecret": "test-secret",
375
+ "azureCloudSubscriptionId": "test-sub",
376
+ }
377
+ mock_api_class.return_value = mock_api
378
+
379
+ mock_response = MagicMock()
380
+ mock_response.status_code = 403
381
+ mock_response.reason = "Forbidden"
382
+ mock_response.text = "Access denied"
383
+ mock_api.get.return_value = mock_response
384
+
385
+ defender_api = DefenderApi(system="cloud")
386
+
387
+ # This should raise SystemExit due to error_and_exit being called
388
+ with pytest.raises(SystemExit):
389
+ defender_api.check_token(url="https://test.com")
390
+
391
+ mock_error_exit.assert_called_once()
392
+ args = mock_error_exit.call_args[0][0]
393
+ assert "Incorrect permissions" in args
394
+
395
+ @patch(f"{PATH}.Api")
396
+ def test_check_token_invalid_need_refresh(self, mock_api_class):
397
+ """Test checking invalid token that needs refresh"""
398
+ mock_api = MagicMock()
399
+ mock_api.config = {
400
+ "azureCloudAccessToken": "Bearer expired-token",
401
+ "azureCloudTenantId": "test-tenant",
402
+ "azureCloudClientId": "test-client",
403
+ "azureCloudSecret": "test-secret",
404
+ "azureCloudSubscriptionId": "test-sub",
405
+ }
406
+ mock_api_class.return_value = mock_api
407
+
408
+ mock_response = MagicMock()
409
+ mock_response.status_code = 401
410
+ mock_api.get.return_value = mock_response
411
+
412
+ defender_api = DefenderApi(system="cloud")
413
+
414
+ with patch.object(defender_api, "get_token", return_value="Bearer new-token") as mock_get_token:
415
+ token = defender_api.check_token(url="https://test.com")
416
+
417
+ mock_get_token.assert_called_once()
418
+ assert token == "Bearer new-token"
419
+
420
+ @patch(f"{PATH}.Api")
421
+ def test_check_token_empty_get_new(self, mock_api_class):
422
+ """Test checking empty token - should get new one"""
423
+ mock_api = MagicMock()
424
+ mock_api.config = {
425
+ "azureCloudAccessToken": "",
426
+ "azureCloudTenantId": "test-tenant",
427
+ "azureCloudClientId": "test-client",
428
+ "azureCloudSecret": "test-secret",
429
+ "azureCloudSubscriptionId": "test-sub",
430
+ }
431
+ mock_api_class.return_value = mock_api
432
+
433
+ defender_api = DefenderApi(system="cloud")
434
+
435
+ with patch.object(defender_api, "get_token", return_value="Bearer new-token") as mock_get_token:
436
+ token = defender_api.check_token()
437
+
438
+ mock_get_token.assert_called_once()
439
+ assert token == "Bearer new-token"
440
+
441
+ @patch(f"{PATH}.error_and_exit")
442
+ def test_check_token_unsupported_system(self, mock_error_exit):
443
+ """Test checking token with unsupported system"""
444
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
445
+ mock_error_exit.side_effect = SystemExit(1)
446
+
447
+ with patch(f"{self.PATH}.Api") as mock_api_class:
448
+ mock_api = MagicMock()
449
+ mock_api.config = {
450
+ "azure365AccessToken": "Bearer 365-token",
451
+ "azure365TenantId": "365-tenant",
452
+ "azure365ClientId": "365-client",
453
+ "azure365Secret": "365-secret",
454
+ "azureCloudAccessToken": "Bearer cloud-token",
455
+ "azureCloudTenantId": "cloud-tenant",
456
+ "azureCloudClientId": "cloud-client",
457
+ "azureCloudSecret": "cloud-secret",
458
+ "azureCloudSubscriptionId": "cloud-sub",
459
+ }
460
+ mock_api_class.return_value = mock_api
461
+
462
+ # Use a proper type annotation bypass for the test
463
+ defender_api = DefenderApi.__new__(DefenderApi)
464
+ defender_api.system = "unsupported" # type: ignore
465
+ defender_api.api = mock_api
466
+ defender_api.config = mock_api.config
467
+
468
+ # This should raise SystemExit due to error_and_exit being called
469
+ with pytest.raises(SystemExit):
470
+ defender_api.check_token(url="https://test.com")
471
+
472
+ mock_error_exit.assert_called_once()
473
+ args = mock_error_exit.call_args[0][0]
474
+ assert "Unsupported is not supported" in args
475
+
476
+ @patch(f"{PATH}.Api")
477
+ def test_parse_and_save_token(self, mock_api_class):
478
+ """Test parsing and saving token"""
479
+ mock_api = MagicMock()
480
+ mock_api.config = {
481
+ "azure365AccessToken": "Bearer 365-token",
482
+ "azure365TenantId": "365-tenant",
483
+ "azure365ClientId": "365-client",
484
+ "azure365Secret": "365-secret",
485
+ "azureCloudAccessToken": "Bearer cloud-token",
486
+ "azureCloudTenantId": "cloud-tenant",
487
+ "azureCloudClientId": "cloud-client",
488
+ "azureCloudSecret": "cloud-secret",
489
+ "azureCloudSubscriptionId": "cloud-sub",
490
+ }
491
+ mock_api.app.save_config = MagicMock()
492
+ mock_api_class.return_value = mock_api
493
+
494
+ # Mock the initial get call for check_token to return 200 (valid token)
495
+ mock_get_response = MagicMock()
496
+ mock_get_response.status_code = 200
497
+ mock_api.get.return_value = mock_get_response
498
+
499
+ mock_response = MagicMock()
500
+ mock_response.json.return_value = {"access_token": "test-token"}
501
+
502
+ defender_api = DefenderApi(system="365")
503
+
504
+ token = defender_api._parse_and_save_token(response=mock_response, key="testKey")
505
+
506
+ assert token == "Bearer test-token"
507
+ assert mock_api.config["testKey"] == "Bearer test-token"
508
+ # save_config should be called at least once (during initialization and potentially during test)
509
+ assert mock_api.app.save_config.call_count >= 1
510
+
511
+ @patch(f"{PATH}.error_and_exit")
512
+ @patch(f"{PATH}.Api")
513
+ def test_execute_resource_graph_query_error_response(self, mock_api_class, mock_error_exit):
514
+ """Test execute_resource_graph_query with error response"""
515
+ mock_api = MagicMock()
516
+ mock_api.config = {
517
+ "azure365AccessToken": "Bearer 365-token",
518
+ "azure365TenantId": "365-tenant",
519
+ "azure365ClientId": "365-client",
520
+ "azure365Secret": "365-secret",
521
+ "azureCloudAccessToken": "Bearer cloud-token",
522
+ "azureCloudTenantId": "cloud-tenant",
523
+ "azureCloudClientId": "cloud-client",
524
+ "azureCloudSecret": "cloud-secret",
525
+ "azureCloudSubscriptionId": "test-sub",
526
+ }
527
+ mock_api_class.return_value = mock_api
528
+
529
+ mock_response = MagicMock()
530
+ mock_response.status_code = 400
531
+ mock_response.reason = "Bad Request"
532
+ mock_response.text = "Invalid query"
533
+ mock_api.post.return_value = mock_response
534
+
535
+ # Mock the initial get call for check_token to return 200 (valid token)
536
+ mock_get_response = MagicMock()
537
+ mock_get_response.status_code = 200
538
+ mock_api.get.return_value = mock_get_response
539
+
540
+ defender_api = DefenderApi(system="cloud")
541
+ defender_api.headers = {"Authorization": "Bearer test"}
542
+
543
+ defender_api.execute_resource_graph_query(query="invalid query")
544
+
545
+ mock_error_exit.assert_called_once()
546
+ args = mock_error_exit.call_args[0][0]
547
+ assert "Received unexpected response" in args
548
+
549
+ @patch(f"{PATH}.DefenderApi.set_headers")
550
+ @patch(f"{PATH}.Api")
551
+ def test_execute_resource_graph_query_success(self, mock_api_class, mock_set_headers):
552
+ """Test execute_resource_graph_query with successful response"""
553
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
554
+
555
+ mock_api = MagicMock()
556
+ mock_api.config = {"azureCloudSubscriptionId": "test-sub"}
557
+ mock_api_class.return_value = mock_api
558
+
559
+ mock_response = MagicMock()
560
+ mock_response.status_code = 200
561
+ mock_response.json.return_value = {
562
+ "data": [{"id": "resource1"}, {"id": "resource2"}],
563
+ "totalRecords": 2,
564
+ "count": 2,
565
+ }
566
+ mock_api.post.return_value = mock_response
567
+
568
+ defender_api = DefenderApi(system="cloud")
569
+ defender_api.headers = {"Authorization": "Bearer test"}
570
+
571
+ result = defender_api.execute_resource_graph_query(query="resources | limit 10")
572
+
573
+ assert len(result) == 2
574
+ assert result[0]["id"] == "resource1"
575
+ assert result[1]["id"] == "resource2"
576
+
577
+ @patch(f"{PATH}.DefenderApi.set_headers")
578
+ @patch(f"{PATH}.Api")
579
+ def test_execute_resource_graph_query_with_pagination(self, mock_api_class, mock_set_headers):
580
+ """Test execute_resource_graph_query with pagination"""
581
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
582
+
583
+ mock_api = MagicMock()
584
+ mock_api.config = {"azureCloudSubscriptionId": "test-sub"}
585
+ mock_api_class.return_value = mock_api
586
+
587
+ # First response with skip token
588
+ mock_response1 = MagicMock()
589
+ mock_response1.status_code = 200
590
+ mock_response1.json.return_value = {
591
+ "data": [{"id": "resource1"}],
592
+ "totalRecords": 2,
593
+ "count": 1,
594
+ "$skipToken": "skip-token-123",
595
+ }
596
+
597
+ # Second response without skip token
598
+ mock_response2 = MagicMock()
599
+ mock_response2.status_code = 200
600
+ mock_response2.json.return_value = {"data": [{"id": "resource2"}], "totalRecords": 2, "count": 1}
601
+
602
+ mock_api.post.side_effect = [mock_response1, mock_response2]
603
+
604
+ defender_api = DefenderApi(system="cloud")
605
+ defender_api.headers = {"Authorization": "Bearer test"}
606
+
607
+ result = defender_api.execute_resource_graph_query(query="resources")
608
+
609
+ assert len(result) == 2
610
+ assert result[0]["id"] == "resource1"
611
+ assert result[1]["id"] == "resource2"
612
+ assert mock_api.post.call_count == 2
613
+
614
+ @patch(f"{PATH}.DefenderApi.set_headers")
615
+ @patch(f"{PATH}.error_and_exit")
616
+ @patch(f"{PATH}.Api")
617
+ def test_execute_resource_graph_query_json_decode_error(self, mock_api_class, mock_error_exit, mock_set_headers):
618
+ """Test execute_resource_graph_query with JSON decode error"""
619
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
620
+ mock_error_exit.side_effect = SystemExit(1)
621
+
622
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
623
+
624
+ mock_api = MagicMock()
625
+ mock_api.config = {"azureCloudSubscriptionId": "test-sub"}
626
+ mock_api_class.return_value = mock_api
627
+
628
+ mock_response = MagicMock()
629
+ mock_response.status_code = 200
630
+ mock_response.json.side_effect = JSONDecodeError("msg", "doc", 0)
631
+ mock_api.post.return_value = mock_response
632
+
633
+ defender_api = DefenderApi(system="cloud")
634
+ defender_api.headers = {"Authorization": "Bearer test"}
635
+
636
+ # This should raise SystemExit due to error_and_exit being called
637
+ with pytest.raises(SystemExit):
638
+ defender_api.execute_resource_graph_query(query="resources")
639
+
640
+ mock_error_exit.assert_called_once_with("JSON Decode error")
641
+
642
+ @patch(f"{PATH}.DefenderApi.set_headers")
643
+ @patch(f"{PATH}.error_and_exit")
644
+ @patch(f"{PATH}.Api")
645
+ def test_execute_resource_graph_query_key_error(self, mock_api_class, mock_error_exit, mock_set_headers):
646
+ """Test execute_resource_graph_query with KeyError"""
647
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
648
+ mock_error_exit.side_effect = SystemExit(1)
649
+
650
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
651
+
652
+ mock_api = MagicMock()
653
+ mock_api.config = {"azureCloudSubscriptionId": "test-sub"}
654
+ mock_api_class.return_value = mock_api
655
+
656
+ mock_response = MagicMock()
657
+ mock_response.status_code = 200
658
+ mock_response.reason = "OK"
659
+ mock_response.text = "Missing data field"
660
+ mock_response.json.return_value = {"error": "missing data"}
661
+ mock_api.post.return_value = mock_response
662
+
663
+ defender_api = DefenderApi(system="cloud")
664
+ defender_api.headers = {"Authorization": "Bearer test"}
665
+
666
+ # This should raise SystemExit due to error_and_exit being called
667
+ with pytest.raises(SystemExit):
668
+ defender_api.execute_resource_graph_query(query="resources")
669
+
670
+ mock_error_exit.assert_called_once()
671
+ args = mock_error_exit.call_args[0][0]
672
+ assert "Received unexpected response" in args
673
+
674
+ @patch(f"{PATH}.DefenderApi.set_headers")
675
+ @patch(f"{PATH}.error_and_exit")
676
+ @patch(f"{PATH}.Api")
677
+ def test_get_items_from_azure_error_response(self, mock_api_class, mock_error_exit, mock_set_headers):
678
+ """Test get_items_from_azure with error response"""
679
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
680
+ mock_error_exit.side_effect = SystemExit(1)
681
+
682
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
683
+
684
+ mock_api = MagicMock()
685
+ mock_api.config = {}
686
+ mock_api_class.return_value = mock_api
687
+
688
+ mock_response = MagicMock()
689
+ mock_response.status_code = 500
690
+ mock_response.reason = "Internal Server Error"
691
+ mock_response.text = "Server error"
692
+ mock_api.get.return_value = mock_response
693
+
694
+ defender_api = DefenderApi(system="365")
695
+ defender_api.headers = {"Authorization": "Bearer test"}
696
+
697
+ # This should raise SystemExit due to error_and_exit being called
698
+ with pytest.raises(SystemExit):
699
+ defender_api.get_items_from_azure(url="https://test.com")
700
+
701
+ mock_error_exit.assert_called_once()
702
+
703
+ @patch(f"{PATH}.DefenderApi.set_headers")
704
+ @patch(f"{PATH}.Api")
705
+ def test_get_items_from_azure_success(self, mock_api_class, mock_set_headers):
706
+ """Test get_items_from_azure with successful response"""
707
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
708
+
709
+ mock_api = MagicMock()
710
+ mock_api.config = {
711
+ "azure365AccessToken": "Bearer existing-token",
712
+ "azure365TenantId": "test-tenant",
713
+ "azure365ClientId": "test-client",
714
+ "azure365Secret": "test-secret",
715
+ "azureCloudAccessToken": "Bearer cloud-token",
716
+ "azureCloudTenantId": "cloud-tenant",
717
+ "azureCloudClientId": "cloud-client",
718
+ "azureCloudSecret": "cloud-secret",
719
+ "azureCloudSubscriptionId": "cloud-sub",
720
+ }
721
+ mock_api_class.return_value = mock_api
722
+
723
+ # Mock response without nextLink to avoid infinite recursion
724
+ mock_response = MagicMock()
725
+ mock_response.status_code = 200
726
+ mock_response.json.return_value = {
727
+ "value": [{"id": "item1"}, {"id": "item2"}]
728
+ # No nextLink to avoid pagination recursion
729
+ }
730
+ mock_api.get.return_value = mock_response
731
+
732
+ defender_api = DefenderApi(system="365")
733
+
734
+ result = defender_api.get_items_from_azure(url="https://test.com")
735
+
736
+ assert len(result) == 2
737
+ assert result[0]["id"] == "item1"
738
+ assert result[1]["id"] == "item2"
739
+
740
+ @patch(f"{PATH}.Api")
741
+ def test_get_items_from_azure_with_pagination(self, mock_api_class):
742
+ """Test get_items_from_azure with pagination"""
743
+ mock_api = MagicMock()
744
+ mock_api_class.return_value = mock_api
745
+
746
+ # First response with next link
747
+ mock_response1 = MagicMock()
748
+ mock_response1.status_code = 200
749
+ mock_response1.json.return_value = {"value": [{"id": "item1"}], "nextLink": "https://test.com?skip=1"}
750
+
751
+ # Second response without next link
752
+ mock_response2 = MagicMock()
753
+ mock_response2.status_code = 200
754
+ mock_response2.json.return_value = {"value": [{"id": "item2"}]}
755
+
756
+ mock_api.get.side_effect = [mock_response1, mock_response2]
757
+
758
+ # Mock the initial get call for check_token to return 200 (valid token)
759
+ mock_get_response = MagicMock()
760
+ mock_get_response.status_code = 200
761
+ mock_api.get.return_value = mock_get_response
762
+
763
+ defender_api = DefenderApi(system="365")
764
+ defender_api.headers = {"Authorization": "Bearer test"}
765
+
766
+ result = defender_api.get_items_from_azure(url="https://test.com")
767
+
768
+ assert len(result) == 2
769
+ assert result[0]["id"] == "item1"
770
+ assert result[1]["id"] == "item2"
771
+
772
+ @patch(f"{PATH}.DefenderApi.set_headers")
773
+ @patch(f"{PATH}.error_and_exit")
774
+ @patch(f"{PATH}.Api")
775
+ def test_get_items_from_azure_json_decode_error(self, mock_api_class, mock_error_exit, mock_set_headers):
776
+ """Test get_items_from_azure with JSON decode error"""
777
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
778
+ mock_error_exit.side_effect = SystemExit(1)
779
+
780
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
781
+
782
+ mock_api = MagicMock()
783
+ mock_api.config = {}
784
+ mock_api_class.return_value = mock_api
785
+
786
+ mock_response = MagicMock()
787
+ mock_response.status_code = 200
788
+ mock_response.json.side_effect = JSONDecodeError("msg", "doc", 0)
789
+ mock_api.get.return_value = mock_response
790
+
791
+ defender_api = DefenderApi(system="365")
792
+ defender_api.headers = {"Authorization": "Bearer test"}
793
+
794
+ # This should raise SystemExit due to error_and_exit being called
795
+ with pytest.raises(SystemExit):
796
+ defender_api.get_items_from_azure(url="https://test.com")
797
+
798
+ mock_error_exit.assert_called_once_with("JSON Decode error")
799
+
800
+ @patch(f"{PATH}.DefenderApi.set_headers")
801
+ @patch(f"{PATH}.error_and_exit")
802
+ @patch(f"{PATH}.Api")
803
+ def test_get_items_from_azure_key_error(self, mock_api_class, mock_error_exit, mock_set_headers):
804
+ """Test get_items_from_azure with KeyError"""
805
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
806
+ mock_error_exit.side_effect = SystemExit(1)
807
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
808
+
809
+ mock_api = MagicMock()
810
+ mock_api.config = {
811
+ "azure365AccessToken": "Bearer existing-token",
812
+ "azure365TenantId": "test-tenant",
813
+ "azure365ClientId": "test-client",
814
+ "azure365Secret": "test-secret",
815
+ "azureCloudAccessToken": "Bearer cloud-token",
816
+ "azureCloudTenantId": "cloud-tenant",
817
+ "azureCloudClientId": "cloud-client",
818
+ "azureCloudSecret": "cloud-secret",
819
+ "azureCloudSubscriptionId": "cloud-sub",
820
+ }
821
+ mock_api_class.return_value = mock_api
822
+
823
+ # Mock response that will cause KeyError - missing "value" key
824
+ mock_response = MagicMock()
825
+ mock_response.status_code = 200
826
+ mock_response.text = "Missing value field"
827
+ mock_response.json.return_value = {"error": "missing value"} # No "value" key
828
+ mock_api.get.return_value = mock_response
829
+
830
+ defender_api = DefenderApi(system="365")
831
+ defender_api.headers = {"Authorization": "Bearer test"}
832
+
833
+ # This should raise SystemExit due to error_and_exit being called
834
+ with pytest.raises(SystemExit):
835
+ defender_api.get_items_from_azure(url="https://test.com")
836
+
837
+ mock_error_exit.assert_called_once()
838
+ args = mock_error_exit.call_args[0][0]
839
+ assert "Received unexpected response" in args
840
+
841
+ @patch(f"{PATH}.DefenderApi.set_headers")
842
+ @patch(f"{PATH}.Api")
843
+ def test_fetch_queries_from_azure_success(self, mock_api_class, mock_set_headers):
844
+ """Test fetching queries from Azure successfully"""
845
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
846
+
847
+ mock_api = MagicMock()
848
+ mock_api.config = {
849
+ "azureCloudSubscriptionId": "test-sub",
850
+ "azureCloudAccessToken": "Bearer cloud-token",
851
+ "azureCloudTenantId": "cloud-tenant",
852
+ "azureCloudClientId": "cloud-client",
853
+ "azureCloudSecret": "cloud-secret",
854
+ }
855
+ mock_api_class.return_value = mock_api
856
+
857
+ mock_response = MagicMock()
858
+ mock_response.status_code = 200
859
+ mock_response.raise_for_status.return_value = None # Success case
860
+ mock_response.json.return_value = {"value": [{"name": "Query1", "id": "1"}, {"name": "Query2", "id": "2"}]}
861
+ mock_api.get.return_value = mock_response
862
+
863
+ defender_api = DefenderApi(system="cloud")
864
+
865
+ result = defender_api.fetch_queries_from_azure()
866
+
867
+ assert len(result) == 2
868
+ assert result[0]["name"] == "Query1"
869
+ assert result[1]["name"] == "Query2"
870
+
871
+ expected_url = (
872
+ "https://management.azure.com/subscriptions/test-sub/"
873
+ "providers/Microsoft.ResourceGraph/queries?api-version=2024-04-01"
874
+ )
875
+ mock_api.get.assert_called_with(
876
+ url=expected_url, headers={"Content-Type": "application/json", "Authorization": "Bearer test"}
877
+ )
878
+
879
+ @patch(f"{PATH}.error_and_exit")
880
+ @patch(f"{PATH}.Api")
881
+ def test_fetch_queries_from_azure_error(self, mock_api_class, mock_error_exit):
882
+ """Test fetching queries from Azure with error"""
883
+ mock_api = MagicMock()
884
+ mock_api.config = {
885
+ "azure365AccessToken": "Bearer 365-token",
886
+ "azure365TenantId": "365-tenant",
887
+ "azure365ClientId": "365-client",
888
+ "azure365Secret": "365-secret",
889
+ "azureCloudAccessToken": "Bearer cloud-token",
890
+ "azureCloudTenantId": "cloud-tenant",
891
+ "azureCloudClientId": "cloud-client",
892
+ "azureCloudSecret": "cloud-secret",
893
+ "azureCloudSubscriptionId": "test-sub",
894
+ }
895
+ mock_api_class.return_value = mock_api
896
+
897
+ mock_response = MagicMock()
898
+ mock_response.status_code = 400
899
+ mock_response.reason = "Bad Request"
900
+ mock_response.text = "Invalid request"
901
+ mock_response.raise_for_status.return_value = True # Indicates error
902
+ mock_api.get.return_value = mock_response
903
+
904
+ # Mock the initial get call for check_token to return 200 (valid token)
905
+ mock_get_response = MagicMock()
906
+ mock_get_response.status_code = 200
907
+ mock_api.get.return_value = mock_get_response
908
+
909
+ defender_api = DefenderApi(system="cloud")
910
+ defender_api.headers = {"Authorization": "Bearer test"}
911
+
912
+ defender_api.fetch_queries_from_azure()
913
+
914
+ mock_error_exit.assert_called_once()
915
+
916
+ @patch(f"{PATH}.DefenderApi.set_headers")
917
+ @patch(f"{PATH}.Api")
918
+ def test_fetch_and_run_query_success(self, mock_api_class, mock_set_headers):
919
+ """Test fetching and running query successfully"""
920
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
921
+
922
+ mock_api = MagicMock()
923
+ mock_api.config = {
924
+ "azureCloudAccessToken": "Bearer cloud-token",
925
+ "azureCloudTenantId": "cloud-tenant",
926
+ "azureCloudClientId": "cloud-client",
927
+ "azureCloudSecret": "cloud-secret",
928
+ "azureCloudSubscriptionId": "cloud-sub",
929
+ }
930
+ mock_api_class.return_value = mock_api
931
+
932
+ mock_response = MagicMock()
933
+ mock_response.status_code = 200
934
+ mock_response.raise_for_status.return_value = None # Success case
935
+ mock_response.json.return_value = {"properties": {"query": "resources | limit 10"}}
936
+ mock_api.get.return_value = mock_response
937
+
938
+ defender_api = DefenderApi(system="cloud")
939
+
940
+ # Mock the execute_resource_graph_query method
941
+ with patch.object(
942
+ defender_api, "execute_resource_graph_query", return_value=[{"id": "resource1"}]
943
+ ) as mock_execute:
944
+ query = {"subscriptionId": "test-sub", "resourceGroup": "test-rg", "name": "test-query"}
945
+
946
+ result = defender_api.fetch_and_run_query(query=query)
947
+
948
+ expected_url = (
949
+ "https://management.azure.com/subscriptions/test-sub/resourceGroups/"
950
+ "test-rg/providers/Microsoft.ResourceGraph/queries/test-query"
951
+ "?api-version=2024-04-01"
952
+ )
953
+ mock_api.get.assert_called_once_with(
954
+ url=expected_url, headers={"Content-Type": "application/json", "Authorization": "Bearer test"}
955
+ )
956
+ mock_execute.assert_called_once_with(query="resources | limit 10")
957
+ assert len(result) == 1
958
+ assert result[0]["id"] == "resource1"
959
+
960
+ @patch(f"{PATH}.DefenderApi.set_headers")
961
+ @patch(f"{PATH}.error_and_exit")
962
+ @patch(f"{PATH}.Api")
963
+ def test_fetch_and_run_query_error(self, mock_api_class, mock_error_exit, mock_set_headers):
964
+ """Test fetching and running query with error"""
965
+ # Make error_and_exit raise SystemExit to properly simulate exit behavior
966
+ mock_error_exit.side_effect = SystemExit(1)
967
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
968
+
969
+ mock_api = MagicMock()
970
+ mock_api.config = {
971
+ "azure365AccessToken": "Bearer 365-token",
972
+ "azure365TenantId": "365-tenant",
973
+ "azure365ClientId": "365-client",
974
+ "azure365Secret": "365-secret",
975
+ "azureCloudAccessToken": "Bearer cloud-token",
976
+ "azureCloudTenantId": "cloud-tenant",
977
+ "azureCloudClientId": "cloud-client",
978
+ "azureCloudSecret": "cloud-secret",
979
+ "azureCloudSubscriptionId": "test-sub",
980
+ }
981
+ mock_api_class.return_value = mock_api
982
+
983
+ mock_response = MagicMock()
984
+ mock_response.status_code = 404
985
+ mock_response.reason = "Not Found"
986
+ mock_response.text = "Query not found"
987
+ mock_response.raise_for_status.return_value = True # Error case
988
+ mock_api.get.return_value = mock_response
989
+
990
+ defender_api = DefenderApi(system="cloud")
991
+
992
+ query = {"subscriptionId": "test-sub", "resourceGroup": "test-rg", "name": "nonexistent-query"}
993
+
994
+ # This should raise SystemExit due to error_and_exit being called
995
+ with pytest.raises(SystemExit):
996
+ defender_api.fetch_and_run_query(query=query)
997
+
998
+ # Should be called exactly once for the query fetch error
999
+ assert mock_error_exit.call_count == 1
1000
+ args = mock_error_exit.call_args[0][0]
1001
+ assert "Received unexpected response" in args
1002
+
1003
+ # ==============================
1004
+ # NEW TESTS FOR ENTRA FUNCTIONALITY
1005
+ # ==============================
1006
+
1007
+ @patch(f"{PATH}.Api")
1008
+ def test_defender_api_init_entra(self, mock_api_class):
1009
+ """Test DefenderApi initialization for entra"""
1010
+ mock_api = MagicMock()
1011
+ mock_api.config = {
1012
+ "azureEntraAccessToken": "Bearer entra-token",
1013
+ "azureEntraTenantId": "entra-tenant",
1014
+ "azureEntraClientId": "entra-client",
1015
+ "azureEntraSecret": "entra-secret",
1016
+ }
1017
+ mock_api_class.return_value = mock_api
1018
+
1019
+ # Mock the post method for token operations
1020
+ mock_post_response = MagicMock()
1021
+ mock_post_response.json.return_value = {"access_token": "test-token"}
1022
+ mock_api.post.return_value = mock_post_response
1023
+
1024
+ defender_api = DefenderApi(system="entra")
1025
+
1026
+ assert defender_api.system == "entra"
1027
+ assert defender_api.api == mock_api
1028
+ assert defender_api.config == mock_api.config
1029
+
1030
+ @patch(f"{PATH}.Api")
1031
+ def test_get_token_entra(self, mock_api_class):
1032
+ """Test getting token for Azure Entra"""
1033
+ mock_api = MagicMock()
1034
+ mock_api.config = {
1035
+ "azureEntraAccessToken": "Bearer existing-token",
1036
+ "azureEntraTenantId": "test-tenant",
1037
+ "azureEntraClientId": "test-client",
1038
+ "azureEntraSecret": "test-secret",
1039
+ }
1040
+ mock_api_class.return_value = mock_api
1041
+
1042
+ # Mock the initial get call for check_token to return 200 (valid token)
1043
+ mock_get_response = MagicMock()
1044
+ mock_get_response.status_code = 200
1045
+ mock_api.get.return_value = mock_get_response
1046
+
1047
+ mock_response = MagicMock()
1048
+ mock_response.json.return_value = {"access_token": "test-access-token"}
1049
+ mock_api.post.return_value = mock_response
1050
+
1051
+ defender_api = DefenderApi(system="entra")
1052
+
1053
+ # Reset the post mock after initialization
1054
+ mock_api.post.reset_mock()
1055
+
1056
+ with patch.object(defender_api, "_parse_and_save_token", return_value="Bearer test-access-token") as mock_parse:
1057
+ token = defender_api.get_token()
1058
+
1059
+ expected_url = "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token"
1060
+ expected_data = {
1061
+ "scope": "https://graph.microsoft.com/.default",
1062
+ "client_id": "test-client",
1063
+ "client_secret": "test-secret",
1064
+ "grant_type": "client_credentials",
1065
+ }
1066
+
1067
+ mock_api.post.assert_called_once_with(
1068
+ url=expected_url, headers={"Content-Type": "application/x-www-form-urlencoded"}, data=expected_data
1069
+ )
1070
+ mock_parse.assert_called_once_with(mock_response, "azureEntraAccessToken")
1071
+ assert token == "Bearer test-access-token"
1072
+
1073
+ @patch(f"{PATH}.Api")
1074
+ def test_check_token_valid_entra(self, mock_api_class):
1075
+ """Test checking valid token for entra"""
1076
+ mock_api = MagicMock()
1077
+ mock_api.config = {
1078
+ "azureEntraAccessToken": "Bearer valid-token",
1079
+ "azureEntraTenantId": "entra-tenant",
1080
+ "azureEntraClientId": "entra-client",
1081
+ "azureEntraSecret": "entra-secret",
1082
+ }
1083
+ mock_api_class.return_value = mock_api
1084
+
1085
+ # Mock the response for the token validation check
1086
+ mock_response = MagicMock()
1087
+ mock_response.status_code = 200
1088
+ mock_api.get.return_value = mock_response
1089
+
1090
+ # Mock the post method for potential token refresh (shouldn't be called)
1091
+ mock_post_response = MagicMock()
1092
+ mock_post_response.json.return_value = {"access_token": "new-token"}
1093
+ mock_api.post.return_value = mock_post_response
1094
+
1095
+ defender_api = DefenderApi(system="entra")
1096
+
1097
+ # Reset the config after initialization to ensure we use the expected token
1098
+ defender_api.config["azureEntraAccessToken"] = "Bearer valid-token"
1099
+
1100
+ token = defender_api.check_token(url="https://test.com")
1101
+
1102
+ assert token == "Bearer valid-token"
1103
+
1104
+ @patch(f"{PATH}.DefenderApi.set_headers")
1105
+ @patch(f"{PATH}.get_current_datetime")
1106
+ @patch(f"{PATH}.save_data_to")
1107
+ @patch(f"{PATH}.check_file_path")
1108
+ @patch(f"{PATH}.Api")
1109
+ def test_get_and_save_entra_evidence_success(
1110
+ self, mock_api_class, mock_check_file_path, mock_save_data, mock_get_datetime, mock_set_headers
1111
+ ):
1112
+ """Test get_and_save_entra_evidence with successful response"""
1113
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
1114
+ mock_get_datetime.return_value = "20230101"
1115
+
1116
+ mock_api = MagicMock()
1117
+ mock_api.config = {
1118
+ "azureEntraAccessToken": "Bearer entra-token",
1119
+ "azureEntraTenantId": "entra-tenant",
1120
+ "azureEntraClientId": "entra-client",
1121
+ "azureEntraSecret": "entra-secret",
1122
+ }
1123
+ mock_api_class.return_value = mock_api
1124
+
1125
+ defender_api = DefenderApi(system="entra")
1126
+
1127
+ # Mock get_items_from_azure
1128
+ with patch.object(
1129
+ defender_api, "get_items_from_azure", return_value=[{"id": "user1", "displayName": "Test User"}]
1130
+ ) as mock_get_items:
1131
+ result = defender_api.get_and_save_entra_evidence("users")
1132
+
1133
+ # Verify the correct endpoint was called
1134
+ expected_url = "https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,accountEnabled,userType,createdDateTime&$top=999"
1135
+ mock_get_items.assert_called_once_with(url=expected_url, parse_value=True)
1136
+
1137
+ # Verify save_data_to was called
1138
+ mock_save_data.assert_called_once()
1139
+
1140
+ # Verify result is a list of paths
1141
+ assert isinstance(result, list)
1142
+ assert len(result) == 1
1143
+ assert isinstance(result[0], Path)
1144
+
1145
+ @patch(f"{PATH}.error_and_exit")
1146
+ @patch(f"{PATH}.Api")
1147
+ def test_get_and_save_entra_evidence_wrong_system(self, mock_api_class, mock_error_exit):
1148
+ """Test get_and_save_entra_evidence with wrong system"""
1149
+ mock_error_exit.side_effect = SystemExit(1)
1150
+
1151
+ mock_api = MagicMock()
1152
+ mock_api_class.return_value = mock_api
1153
+
1154
+ defender_api = DefenderApi(system="365")
1155
+
1156
+ with pytest.raises(SystemExit):
1157
+ defender_api.get_and_save_entra_evidence("users")
1158
+
1159
+ mock_error_exit.assert_called_once_with("This method can only be used with system='entra'")
1160
+
1161
+ @patch(f"{PATH}.error_and_exit")
1162
+ @patch(f"{PATH}.Api")
1163
+ def test_get_and_save_entra_evidence_unknown_endpoint(self, mock_api_class, mock_error_exit):
1164
+ """Test get_and_save_entra_evidence with unknown endpoint"""
1165
+ mock_error_exit.side_effect = SystemExit(1)
1166
+
1167
+ mock_api = MagicMock()
1168
+ mock_api_class.return_value = mock_api
1169
+
1170
+ defender_api = DefenderApi(system="entra")
1171
+
1172
+ with pytest.raises(SystemExit):
1173
+ defender_api.get_and_save_entra_evidence("invalid_endpoint")
1174
+
1175
+ mock_error_exit.assert_called_once_with("Unknown endpoint key: invalid_endpoint")
1176
+
1177
+ @patch(f"{PATH}.DefenderApi.set_headers")
1178
+ @patch(f"{PATH}.get_current_datetime")
1179
+ @patch(f"{PATH}.save_data_to")
1180
+ @patch(f"{PATH}.check_file_path")
1181
+ @patch(f"{PATH}.Api")
1182
+ def test_get_and_save_entra_evidence_with_parameters(
1183
+ self, mock_api_class, mock_check_file_path, mock_save_data, mock_get_datetime, mock_set_headers
1184
+ ):
1185
+ """Test get_and_save_entra_evidence with URL parameters"""
1186
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
1187
+ mock_get_datetime.return_value = "20230101"
1188
+
1189
+ mock_api = MagicMock()
1190
+ mock_api.config = {
1191
+ "azureEntraAccessToken": "Bearer entra-token",
1192
+ "azureEntraTenantId": "entra-tenant",
1193
+ "azureEntraClientId": "entra-client",
1194
+ "azureEntraSecret": "entra-secret",
1195
+ }
1196
+ mock_api_class.return_value = mock_api
1197
+
1198
+ defender_api = DefenderApi(system="entra")
1199
+
1200
+ # Mock get_items_from_azure
1201
+ with patch.object(
1202
+ defender_api, "get_items_from_azure", return_value=[{"id": "log1", "activityDateTime": "2023-01-01"}]
1203
+ ) as mock_get_items:
1204
+ result = defender_api.get_and_save_entra_evidence("sign_in_logs", start_date="2023-01-01T00:00:00Z")
1205
+
1206
+ # Verify the correct endpoint was called with parameters
1207
+ expected_url = "https://graph.microsoft.com/v1.0/auditLogs/signIns?$filter=createdDateTime ge 2023-01-01T00:00:00Z&$top=1000"
1208
+ mock_get_items.assert_called_once_with(url=expected_url, parse_value=True)
1209
+
1210
+ assert len(result) == 1
1211
+
1212
+ @patch(f"{PATH}.error_and_exit")
1213
+ @patch(f"{PATH}.Api")
1214
+ def test_get_and_save_entra_evidence_missing_required_param(self, mock_api_class, mock_error_exit):
1215
+ """Test get_and_save_entra_evidence with missing required parameter"""
1216
+ mock_error_exit.side_effect = SystemExit(1)
1217
+
1218
+ mock_api = MagicMock()
1219
+ mock_api_class.return_value = mock_api
1220
+
1221
+ defender_api = DefenderApi(system="entra")
1222
+
1223
+ with pytest.raises(SystemExit):
1224
+ defender_api.get_and_save_entra_evidence("access_review_instances")
1225
+
1226
+ mock_error_exit.assert_called_once_with("def_id parameter is required for this endpoint")
1227
+
1228
+ @patch(f"{PATH}.DefenderApi.get_and_save_entra_evidence")
1229
+ @patch(f"{PATH}.DefenderApi.collect_entra_access_reviews")
1230
+ @patch(f"{PATH}.check_file_path")
1231
+ @patch(f"{PATH}.Api")
1232
+ def test_collect_all_entra_evidence_success(
1233
+ self, mock_api_class, mock_check_file_path, mock_collect_access_reviews, mock_get_and_save_evidence
1234
+ ):
1235
+ """Test collect_all_entra_evidence with successful response"""
1236
+ mock_api = MagicMock()
1237
+ mock_api.config = {
1238
+ "azureEntraAccessToken": "Bearer entra-token",
1239
+ "azureEntraTenantId": "entra-tenant",
1240
+ "azureEntraClientId": "entra-client",
1241
+ "azureEntraSecret": "entra-secret",
1242
+ }
1243
+ mock_api_class.return_value = mock_api
1244
+
1245
+ defender_api = DefenderApi(system="entra")
1246
+
1247
+ # Mock return values
1248
+ mock_get_and_save_evidence.return_value = [Path("/test/path.csv")]
1249
+ mock_collect_access_reviews.return_value = [Path("/test/access_reviews.csv")]
1250
+
1251
+ result = defender_api.collect_all_entra_evidence(days_back=30)
1252
+
1253
+ # Verify all expected evidence types are in the result
1254
+ expected_keys = [
1255
+ "users",
1256
+ "users_delta",
1257
+ "guest_users",
1258
+ "groups_and_members",
1259
+ "security_groups",
1260
+ "role_assignments",
1261
+ "role_definitions",
1262
+ "pim_assignments",
1263
+ "pim_eligibility",
1264
+ "conditional_access",
1265
+ "auth_methods_policy",
1266
+ "user_mfa_registration",
1267
+ "mfa_registered_users",
1268
+ "sign_in_logs",
1269
+ "directory_audits",
1270
+ "provisioning_logs",
1271
+ "access_review_definitions",
1272
+ ]
1273
+ for key in expected_keys:
1274
+ assert key in result
1275
+
1276
+ # Verify check_file_path was called
1277
+ mock_check_file_path.assert_called_once()
1278
+
1279
+ @patch(f"{PATH}.DefenderApi.get_and_save_entra_evidence")
1280
+ @patch(f"{PATH}.DefenderApi.collect_entra_access_reviews")
1281
+ @patch(f"{PATH}.check_file_path")
1282
+ @patch(f"{PATH}.Api")
1283
+ def test_collect_all_entra_evidence_with_exceptions(
1284
+ self, mock_api_class, mock_check_file_path, mock_collect_access_reviews, mock_get_and_save_evidence
1285
+ ):
1286
+ """Test collect_all_entra_evidence handles exceptions gracefully"""
1287
+ mock_api = MagicMock()
1288
+ mock_api.config = {
1289
+ "azureEntraAccessToken": "Bearer entra-token",
1290
+ "azureEntraTenantId": "entra-tenant",
1291
+ "azureEntraClientId": "entra-client",
1292
+ "azureEntraSecret": "entra-secret",
1293
+ }
1294
+ mock_api_class.return_value = mock_api
1295
+
1296
+ defender_api = DefenderApi(system="entra")
1297
+
1298
+ # Make get_and_save_entra_evidence raise an exception for some calls
1299
+ def side_effect(endpoint_key, **kwargs):
1300
+ if endpoint_key in ["users", "sign_in_logs"]:
1301
+ raise Exception("API Error")
1302
+ return [Path("/test/path.csv")]
1303
+
1304
+ mock_get_and_save_evidence.side_effect = side_effect
1305
+ mock_collect_access_reviews.return_value = [Path("/test/access_reviews.csv")]
1306
+
1307
+ result = defender_api.collect_all_entra_evidence(days_back=30)
1308
+
1309
+ # Verify that failed evidence types return empty lists
1310
+ assert result["users"] == []
1311
+ assert result["sign_in_logs"] == []
1312
+ # Verify that the method was called and completed (even with exceptions)
1313
+ assert "guest_users" in result
1314
+ assert "access_review_definitions" in result
1315
+ mock_collect_access_reviews.assert_called_once()
1316
+
1317
+ @patch(f"{PATH}.DefenderApi.get_items_from_azure")
1318
+ @patch(f"{PATH}.DefenderApi._flatten_access_review_definition")
1319
+ @patch(f"{PATH}.DefenderApi._flatten_access_review_instance")
1320
+ @patch(f"{PATH}.DefenderApi._flatten_access_review_decision")
1321
+ @patch(f"{PATH}.save_data_to")
1322
+ @patch(f"{PATH}.get_current_datetime")
1323
+ @patch(f"{PATH}.Api")
1324
+ def test_collect_entra_access_reviews_success(
1325
+ self,
1326
+ mock_api_class,
1327
+ mock_get_datetime,
1328
+ mock_save_data,
1329
+ mock_flatten_decision,
1330
+ mock_flatten_instance,
1331
+ mock_flatten_definition,
1332
+ mock_get_items,
1333
+ ):
1334
+ """Test collect_entra_access_reviews with successful response"""
1335
+ mock_get_datetime.return_value = "2023-01-01"
1336
+ mock_api = MagicMock()
1337
+ mock_api_class.return_value = mock_api
1338
+
1339
+ defender_api = DefenderApi(system="entra")
1340
+
1341
+ # Mock data
1342
+ mock_definition = {"id": "def1", "displayName": "Test Review"}
1343
+ mock_instance = {"id": "inst1", "status": "InProgress"}
1344
+ mock_decision = {"id": "dec1", "decision": "Approve"}
1345
+
1346
+ # Setup return values
1347
+ mock_get_items.side_effect = [
1348
+ [mock_definition], # definitions
1349
+ [mock_instance], # instances
1350
+ [mock_decision], # decisions
1351
+ ]
1352
+ mock_flatten_definition.return_value = {"flattened": "definition"}
1353
+ mock_flatten_instance.return_value = {"flattened": "instance"}
1354
+ mock_flatten_decision.return_value = {"flattened": "decision"}
1355
+
1356
+ result = defender_api.collect_entra_access_reviews()
1357
+
1358
+ # Verify calls were made
1359
+ assert mock_get_items.call_count == 3
1360
+ mock_flatten_definition.assert_called_once_with(mock_definition)
1361
+ mock_flatten_instance.assert_called_once_with("def1", mock_instance)
1362
+ mock_flatten_decision.assert_called_once_with("def1", "inst1", mock_decision)
1363
+
1364
+ # Verify save_data_to was called for all three types
1365
+ assert mock_save_data.call_count == 3
1366
+
1367
+ # Verify result is a list of paths
1368
+ assert isinstance(result, list)
1369
+ assert len(result) == 3
1370
+
1371
+ def test_flatten_access_review_definition(self):
1372
+ """Test _flatten_access_review_definition static method"""
1373
+ definition = {
1374
+ "id": "def-123",
1375
+ "displayName": "Test Review",
1376
+ "status": "Active",
1377
+ "createdDateTime": "2023-01-01T00:00:00Z",
1378
+ "createdBy": {"displayName": "Admin", "id": "user1", "userPrincipalName": "admin@test.com"},
1379
+ "scope": {"@odata.type": "test-type", "query": "test-query"},
1380
+ "settings": {
1381
+ "defaultDecision": "Approve",
1382
+ "autoApplyDecisionsEnabled": True,
1383
+ "instanceDurationInDays": 30,
1384
+ "recurrence": {"pattern": {"type": "weekly", "interval": 1}},
1385
+ },
1386
+ }
1387
+
1388
+ result = DefenderApi._flatten_access_review_definition(definition)
1389
+
1390
+ assert result["id"] == "def-123"
1391
+ assert result["displayName"] == "Test Review"
1392
+ assert result["status"] == "Active"
1393
+ assert result["createdBy_displayName"] == "Admin"
1394
+ assert result["scope_type"] == "test-type"
1395
+ assert result["settings_defaultDecision"] == "Approve"
1396
+ assert result["settings_recurrence_type"] == "weekly"
1397
+ assert result["settings_recurrence_interval"] == 1
1398
+
1399
+ def test_flatten_access_review_instance(self):
1400
+ """Test _flatten_access_review_instance static method"""
1401
+ instance = {
1402
+ "id": "inst-123",
1403
+ "status": "InProgress",
1404
+ "startDateTime": "2023-01-01T00:00:00Z",
1405
+ "endDateTime": "2023-01-31T23:59:59Z",
1406
+ "scope": {"@odata.type": "instance-type"},
1407
+ "reviewers": [{"id": "rev1"}, {"id": "rev2"}],
1408
+ "fallbackReviewers": [{"id": "fallback1"}],
1409
+ }
1410
+
1411
+ result = DefenderApi._flatten_access_review_instance("def-123", instance)
1412
+
1413
+ assert result["definition_id"] == "def-123"
1414
+ assert result["id"] == "inst-123"
1415
+ assert result["status"] == "InProgress"
1416
+ assert result["startDateTime"] == "2023-01-01T00:00:00Z"
1417
+ assert result["reviewers_count"] == 2
1418
+ assert result["fallbackReviewers_count"] == 1
1419
+
1420
+ def test_flatten_access_review_decision(self):
1421
+ """Test _flatten_access_review_decision static method"""
1422
+ decision = {
1423
+ "id": "dec-123",
1424
+ "decision": "Approve",
1425
+ "recommendation": "Deny",
1426
+ "justification": "Test justification",
1427
+ "reviewedBy": {"id": "reviewer1", "displayName": "Reviewer", "userPrincipalName": "reviewer@test.com"},
1428
+ "target": {"@odata.type": "target-type", "userId": "target-user", "userDisplayName": "Target User"},
1429
+ "principal": {"@odata.type": "principal-type", "id": "principal1", "displayName": "Principal"},
1430
+ }
1431
+
1432
+ result = DefenderApi._flatten_access_review_decision("def-123", "inst-123", decision)
1433
+
1434
+ assert result["definition_id"] == "def-123"
1435
+ assert result["instance_id"] == "inst-123"
1436
+ assert result["decision_id"] == "dec-123"
1437
+ assert result["decision"] == "Approve"
1438
+ assert result["reviewedBy_displayName"] == "Reviewer"
1439
+ assert result["target_type"] == "target-type"
1440
+ assert result["principal_id"] == "principal1"
1441
+
1442
+ @patch(f"{PATH}.error_and_exit")
1443
+ @patch(f"{PATH}.Api")
1444
+ def test_get_and_save_entra_evidence_required_group_id_missing(self, mock_api_class, mock_error_exit):
1445
+ """Test get_and_save_entra_evidence with missing group_id parameter"""
1446
+ mock_error_exit.side_effect = SystemExit(1)
1447
+
1448
+ mock_api = MagicMock()
1449
+ mock_api_class.return_value = mock_api
1450
+
1451
+ defender_api = DefenderApi(system="entra")
1452
+
1453
+ with pytest.raises(SystemExit):
1454
+ defender_api.get_and_save_entra_evidence("access_review_decisions")
1455
+
1456
+ mock_error_exit.assert_called_once_with("def_id parameter is required for this endpoint")
1457
+
1458
+ @patch(f"{PATH}.DefenderApi.set_headers")
1459
+ @patch(f"{PATH}.get_current_datetime")
1460
+ @patch(f"{PATH}.save_data_to")
1461
+ @patch(f"{PATH}.check_file_path")
1462
+ @patch(f"{PATH}.Api")
1463
+ def test_get_and_save_entra_evidence_parse_value_false(
1464
+ self, mock_api_class, mock_check_file_path, mock_save_data, mock_get_datetime, mock_set_headers
1465
+ ):
1466
+ """Test get_and_save_entra_evidence with parse_value=False"""
1467
+ mock_set_headers.return_value = {"Content-Type": "application/json", "Authorization": "Bearer test"}
1468
+ mock_get_datetime.return_value = "20230101"
1469
+
1470
+ mock_api = MagicMock()
1471
+ mock_api.config = {
1472
+ "azureEntraAccessToken": "Bearer entra-token",
1473
+ "azureEntraTenantId": "entra-tenant",
1474
+ "azureEntraClientId": "entra-client",
1475
+ "azureEntraSecret": "entra-secret",
1476
+ }
1477
+ mock_api_class.return_value = mock_api
1478
+
1479
+ defender_api = DefenderApi(system="entra")
1480
+
1481
+ # Mock get_items_from_azure
1482
+ with patch.object(defender_api, "get_items_from_azure", return_value={"policy": "data"}) as mock_get_items:
1483
+ result = defender_api.get_and_save_entra_evidence("auth_methods_policy", parse_value=False)
1484
+
1485
+ # Verify parse_value=False was passed
1486
+ mock_get_items.assert_called_once_with(
1487
+ url="https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy", parse_value=False
1488
+ )
1489
+
1490
+ assert len(result) == 1
1491
+
1492
+ @patch(f"{PATH}.DefenderApi.collect_entra_access_reviews")
1493
+ @patch(f"{PATH}.DefenderApi.get_and_save_entra_evidence")
1494
+ @patch(f"{PATH}.check_file_path")
1495
+ @patch(f"{PATH}.Api")
1496
+ def test_collect_all_entra_evidence_custom_days_back(
1497
+ self, mock_api_class, mock_check_file_path, mock_get_and_save_evidence, mock_collect_access_reviews
1498
+ ):
1499
+ """Test collect_all_entra_evidence with custom days_back parameter"""
1500
+ mock_api = MagicMock()
1501
+ mock_api.config = {
1502
+ "azureEntraAccessToken": "Bearer entra-token",
1503
+ "azureEntraTenantId": "entra-tenant",
1504
+ "azureEntraClientId": "entra-client",
1505
+ "azureEntraSecret": "entra-secret",
1506
+ }
1507
+ mock_api_class.return_value = mock_api
1508
+
1509
+ defender_api = DefenderApi(system="entra")
1510
+
1511
+ # Mock return values
1512
+ mock_get_and_save_evidence.return_value = [Path("/test/path.csv")]
1513
+ mock_collect_access_reviews.return_value = [Path("/test/access_reviews.csv")]
1514
+
1515
+ result = defender_api.collect_all_entra_evidence(days_back=60)
1516
+ assert result
1517
+
1518
+ # Verify that start_date was calculated correctly for 60 days back
1519
+ calls_with_start_date = [
1520
+ call
1521
+ for call in mock_get_and_save_evidence.call_args_list
1522
+ if len(call.kwargs) > 0 and "start_date" in call.kwargs
1523
+ ]
1524
+
1525
+ # Should have 3 calls with start_date (sign_in_logs, directory_audits, provisioning_logs)
1526
+ assert len(calls_with_start_date) == 3
1527
+
1528
+ # Verify the date format is correct (should be 60 days back)
1529
+ for call in calls_with_start_date:
1530
+ start_date = call.kwargs["start_date"]
1531
+ assert start_date.endswith("T00:00:00Z")
1532
+ # Should be a valid date format
1533
+ datetime.strptime(start_date.replace("T00:00:00Z", ""), "%Y-%m-%d")
1534
+
1535
+ @patch(f"{PATH}.DefenderApi.collect_entra_access_reviews")
1536
+ @patch(f"{PATH}.DefenderApi.get_and_save_entra_evidence")
1537
+ @patch(f"{PATH}.check_file_path")
1538
+ @patch(f"{PATH}.Api")
1539
+ def test_collect_all_entra_evidence_partial_failure_recovery(
1540
+ self, mock_api_class, mock_check_file_path, mock_get_and_save_evidence, mock_collect_access_reviews
1541
+ ):
1542
+ """Test collect_all_entra_evidence handles partial failures and continues processing"""
1543
+ mock_api = MagicMock()
1544
+ mock_api.config = {
1545
+ "azureEntraAccessToken": "Bearer entra-token",
1546
+ "azureEntraTenantId": "entra-tenant",
1547
+ "azureEntraClientId": "entra-client",
1548
+ "azureEntraSecret": "entra-secret",
1549
+ }
1550
+ mock_api_class.return_value = mock_api
1551
+
1552
+ defender_api = DefenderApi(system="entra")
1553
+
1554
+ # Mock different failure scenarios
1555
+ def side_effect(endpoint_key, **kwargs):
1556
+ if endpoint_key in ["users", "role_assignments", "auth_methods_policy"]:
1557
+ raise Exception(f"API Error for {endpoint_key}")
1558
+ return [Path(f"/test/{endpoint_key}.csv")]
1559
+
1560
+ mock_get_and_save_evidence.side_effect = side_effect
1561
+ mock_collect_access_reviews.side_effect = Exception("Access reviews API error")
1562
+
1563
+ result = defender_api.collect_all_entra_evidence(days_back=30)
1564
+
1565
+ # Verify that failed evidence types return empty lists (grouped by category)
1566
+ # Users group failure affects all user-related evidence
1567
+ assert result["users"] == []
1568
+ assert result["users_delta"] == []
1569
+ assert result["guest_users"] == []
1570
+ assert result["groups_and_members"] == []
1571
+ assert result["security_groups"] == []
1572
+
1573
+ # RBAC/PIM group failure affects all RBAC-related evidence
1574
+ assert result["role_assignments"] == []
1575
+ assert result["role_definitions"] == []
1576
+ assert result["pim_assignments"] == []
1577
+ assert result["pim_eligibility"] == []
1578
+
1579
+ # Auth methods group failure affects all auth-related evidence
1580
+ assert result["auth_methods_policy"] == []
1581
+ assert result["user_mfa_registration"] == []
1582
+ assert result["mfa_registered_users"] == []
1583
+
1584
+ # Access reviews failure
1585
+ assert result["access_review_definitions"] == []
1586
+
1587
+ # Verify that successful evidence types have data
1588
+ assert len(result["conditional_access"]) == 1
1589
+ assert len(result["sign_in_logs"]) == 1
1590
+ assert len(result["directory_audits"]) == 1
1591
+
1592
+ # Verify that all expected keys are present
1593
+ expected_keys = [
1594
+ "users",
1595
+ "users_delta",
1596
+ "guest_users",
1597
+ "groups_and_members",
1598
+ "security_groups",
1599
+ "role_assignments",
1600
+ "role_definitions",
1601
+ "pim_assignments",
1602
+ "pim_eligibility",
1603
+ "conditional_access",
1604
+ "auth_methods_policy",
1605
+ "user_mfa_registration",
1606
+ "mfa_registered_users",
1607
+ "sign_in_logs",
1608
+ "directory_audits",
1609
+ "provisioning_logs",
1610
+ "access_review_definitions",
1611
+ ]
1612
+ for key in expected_keys:
1613
+ assert key in result
1614
+
1615
+ @patch(f"{PATH}.DefenderApi.get_items_from_azure")
1616
+ @patch(f"{PATH}.save_data_to")
1617
+ @patch(f"{PATH}.get_current_datetime")
1618
+ @patch(f"{PATH}.Api")
1619
+ def test_collect_entra_access_reviews_multiple_definitions(
1620
+ self, mock_api_class, mock_get_datetime, mock_save_data, mock_get_items
1621
+ ):
1622
+ """Test collect_entra_access_reviews with multiple access review definitions"""
1623
+ mock_get_datetime.return_value = "2023-01-01"
1624
+ mock_api = MagicMock()
1625
+ mock_api_class.return_value = mock_api
1626
+
1627
+ defender_api = DefenderApi(system="entra")
1628
+
1629
+ # Mock multiple definitions with different scenarios
1630
+ mock_definitions = [
1631
+ {"id": "def1", "displayName": "Review 1"},
1632
+ {"id": "def2", "displayName": "Review/With/Slashes"},
1633
+ {"id": "def3", "displayName": "Review With Spaces"},
1634
+ ]
1635
+ mock_instances = [{"id": "inst1", "status": "InProgress"}, {"id": "inst2", "status": "Completed"}]
1636
+ mock_decisions = [{"id": "dec1", "decision": "Approve"}, {"id": "dec2", "decision": "Deny"}]
1637
+
1638
+ # Setup return values for each definition
1639
+ mock_get_items.side_effect = [
1640
+ mock_definitions, # definitions call
1641
+ # For def1
1642
+ mock_instances, # instances call
1643
+ mock_decisions, # decisions call for inst1
1644
+ mock_decisions, # decisions call for inst2
1645
+ # For def2
1646
+ mock_instances, # instances call
1647
+ mock_decisions, # decisions call for inst1
1648
+ mock_decisions, # decisions call for inst2
1649
+ # For def3
1650
+ mock_instances, # instances call
1651
+ mock_decisions, # decisions call for inst1
1652
+ mock_decisions, # decisions call for inst2
1653
+ ]
1654
+
1655
+ result = defender_api.collect_entra_access_reviews()
1656
+
1657
+ # Verify the correct number of API calls were made
1658
+ # 1 for definitions + 3 instances + 6 decisions (2 per definition) = 10 calls
1659
+ assert mock_get_items.call_count == 10
1660
+
1661
+ # Verify save_data_to was called for each definition (3 definitions * 3 file types each)
1662
+ assert mock_save_data.call_count == 9
1663
+
1664
+ # Verify result contains all file paths (3 definitions * 3 files each)
1665
+ assert len(result) == 9
1666
+
1667
+ # Verify all results are Path objects
1668
+ for file_path in result:
1669
+ assert isinstance(file_path, Path)
1670
+
1671
+ @patch(f"{PATH}.Api")
1672
+ def test_collect_entra_access_reviews_empty_definitions(self, mock_api_class):
1673
+ """Test collect_entra_access_reviews with no access review definitions"""
1674
+ mock_api = MagicMock()
1675
+ mock_api_class.return_value = mock_api
1676
+
1677
+ defender_api = DefenderApi(system="entra")
1678
+
1679
+ # Mock get_items_from_azure to return empty list
1680
+ with patch.object(defender_api, "get_items_from_azure", return_value=[]) as mock_get_items:
1681
+ result = defender_api.collect_entra_access_reviews()
1682
+
1683
+ # Should only call once for definitions
1684
+ mock_get_items.assert_called_once()
1685
+
1686
+ # Should return empty list
1687
+ assert result == []
1688
+
1689
+ def test_flatten_access_review_definition_with_missing_fields(self):
1690
+ """Test _flatten_access_review_definition with missing optional fields"""
1691
+ definition = {
1692
+ "id": "def-123",
1693
+ "displayName": "Test Review",
1694
+ "status": "Active",
1695
+ # Missing most optional fields
1696
+ }
1697
+
1698
+ result = DefenderApi._flatten_access_review_definition(definition)
1699
+
1700
+ # Verify required fields are present
1701
+ assert result["id"] == "def-123"
1702
+ assert result["displayName"] == "Test Review"
1703
+ assert result["status"] == "Active"
1704
+
1705
+ # Verify missing fields are handled gracefully (should be None)
1706
+ assert result["createdDateTime"] is None
1707
+ assert result["createdBy_displayName"] is None
1708
+ assert result["scope_type"] is None
1709
+ assert result["settings_defaultDecision"] is None
1710
+
1711
+ def test_flatten_access_review_instance_with_minimal_data(self):
1712
+ """Test _flatten_access_review_instance with minimal required data"""
1713
+ instance = {
1714
+ "id": "inst-123",
1715
+ "status": "InProgress",
1716
+ # Missing most optional fields
1717
+ }
1718
+
1719
+ result = DefenderApi._flatten_access_review_instance("def-123", instance)
1720
+
1721
+ assert result["definition_id"] == "def-123"
1722
+ assert result["id"] == "inst-123"
1723
+ assert result["status"] == "InProgress"
1724
+
1725
+ # Missing fields should be handled gracefully
1726
+ assert result["startDateTime"] is None
1727
+ assert result["reviewers_count"] == 0 # Empty list length
1728
+ assert result["fallbackReviewers_count"] == 0
1729
+
1730
+ def test_flatten_access_review_decision_with_minimal_data(self):
1731
+ """Test _flatten_access_review_decision with minimal required data"""
1732
+ decision = {
1733
+ "id": "dec-123",
1734
+ "decision": "Approve",
1735
+ # Missing most optional fields
1736
+ }
1737
+
1738
+ result = DefenderApi._flatten_access_review_decision("def-123", "inst-123", decision)
1739
+
1740
+ assert result["definition_id"] == "def-123"
1741
+ assert result["instance_id"] == "inst-123"
1742
+ assert result["decision_id"] == "dec-123"
1743
+ assert result["decision"] == "Approve"
1744
+
1745
+ # Missing fields should be handled gracefully
1746
+ assert result["reviewedBy_displayName"] is None
1747
+ assert result["target_type"] is None
1748
+ assert result["principal_id"] is None