regscale-cli 6.25.0.1__py3-none-any.whl → 6.26.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.
- regscale/_version.py +1 -1
- regscale/airflow/hierarchy.py +2 -2
- regscale/core/app/application.py +18 -3
- regscale/core/app/internal/login.py +0 -1
- regscale/core/app/utils/catalog_utils/common.py +1 -1
- regscale/integrations/commercial/sicura/api.py +14 -13
- regscale/integrations/commercial/sicura/commands.py +8 -2
- regscale/integrations/commercial/sicura/scanner.py +49 -39
- regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
- regscale/integrations/commercial/synqly/assets.py +17 -0
- regscale/integrations/commercial/wizv2/click.py +26 -26
- regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
- regscale/integrations/commercial/wizv2/constants.py +20 -71
- regscale/integrations/commercial/wizv2/scanner.py +3 -3
- regscale/integrations/compliance_integration.py +67 -2
- regscale/integrations/control_matcher.py +358 -0
- regscale/integrations/due_date_handler.py +118 -6
- regscale/integrations/milestone_manager.py +291 -0
- regscale/integrations/public/__init__.py +1 -0
- regscale/integrations/public/cci_importer.py +37 -38
- regscale/integrations/public/fedramp/click.py +60 -2
- regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
- regscale/integrations/scanner_integration.py +199 -130
- regscale/models/integration_models/cisa_kev_data.json +199 -4
- regscale/models/integration_models/nexpose.py +36 -10
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/locking.py +12 -8
- regscale/models/platform.py +1 -2
- regscale/models/regscale_models/control_implementation.py +46 -21
- regscale/models/regscale_models/issue.py +256 -94
- regscale/models/regscale_models/milestone.py +1 -1
- regscale/models/regscale_models/regscale_model.py +6 -1
- regscale/templates/__init__.py +0 -0
- regscale/utils/threading/threadhandler.py +20 -15
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +84 -37
- tests/regscale/integrations/commercial/__init__.py +0 -0
- tests/regscale/integrations/commercial/conftest.py +28 -0
- tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
- tests/regscale/integrations/commercial/test_aws.py +3731 -0
- tests/regscale/integrations/commercial/test_burp.py +48 -0
- tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
- tests/regscale/integrations/commercial/test_dependabot.py +341 -0
- tests/regscale/integrations/commercial/test_gcp.py +1543 -0
- tests/regscale/integrations/commercial/test_gitlab.py +549 -0
- tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
- tests/regscale/integrations/commercial/test_jira.py +1814 -0
- tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
- tests/regscale/integrations/commercial/test_okta.py +1228 -0
- tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
- tests/regscale/integrations/commercial/test_sicura.py +350 -0
- tests/regscale/integrations/commercial/test_snow.py +423 -0
- tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
- tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
- tests/regscale/integrations/commercial/test_stig.py +33 -0
- tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
- tests/regscale/integrations/commercial/test_stigv2.py +406 -0
- tests/regscale/integrations/commercial/test_wiz.py +1469 -0
- tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
- tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
- tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
- tests/regscale/integrations/public/fedramp/__init__.py +1 -0
- tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
- tests/regscale/integrations/test_control_matcher.py +1314 -0
- tests/regscale/integrations/test_control_matching.py +155 -0
- tests/regscale/integrations/test_milestone_manager.py +408 -0
- tests/regscale/models/test_issue.py +378 -1
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.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
|