regscale-cli 6.25.1.0__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.

Files changed (80) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/application.py +18 -3
  4. regscale/core/app/internal/login.py +0 -1
  5. regscale/core/app/utils/catalog_utils/common.py +1 -1
  6. regscale/integrations/commercial/sicura/api.py +14 -13
  7. regscale/integrations/commercial/sicura/commands.py +8 -2
  8. regscale/integrations/commercial/sicura/scanner.py +49 -39
  9. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  10. regscale/integrations/commercial/wizv2/click.py +26 -26
  11. regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
  12. regscale/integrations/commercial/wizv2/scanner.py +3 -3
  13. regscale/integrations/compliance_integration.py +67 -2
  14. regscale/integrations/control_matcher.py +358 -0
  15. regscale/integrations/milestone_manager.py +291 -0
  16. regscale/integrations/public/__init__.py +1 -0
  17. regscale/integrations/public/cci_importer.py +37 -38
  18. regscale/integrations/public/fedramp/click.py +60 -2
  19. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  20. regscale/integrations/scanner_integration.py +150 -96
  21. regscale/models/integration_models/cisa_kev_data.json +154 -4
  22. regscale/models/integration_models/nexpose.py +36 -10
  23. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  24. regscale/models/locking.py +12 -8
  25. regscale/models/platform.py +1 -2
  26. regscale/models/regscale_models/control_implementation.py +46 -21
  27. regscale/models/regscale_models/issue.py +256 -94
  28. regscale/models/regscale_models/milestone.py +1 -1
  29. regscale/models/regscale_models/regscale_model.py +6 -1
  30. regscale/templates/__init__.py +0 -0
  31. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
  32. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +80 -33
  33. tests/regscale/integrations/commercial/__init__.py +0 -0
  34. tests/regscale/integrations/commercial/conftest.py +28 -0
  35. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  36. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  37. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  38. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  39. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  40. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  41. tests/regscale/integrations/commercial/test_burp.py +48 -0
  42. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  43. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  44. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  45. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  46. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  47. tests/regscale/integrations/commercial/test_jira.py +1814 -0
  48. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  49. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  50. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  51. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  52. tests/regscale/integrations/commercial/test_snow.py +423 -0
  53. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  54. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  55. tests/regscale/integrations/commercial/test_stig.py +33 -0
  56. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  57. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  58. tests/regscale/integrations/commercial/test_wiz.py +1469 -0
  59. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  60. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  61. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  62. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  63. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  64. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
  65. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  66. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  67. tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
  68. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  69. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
  70. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
  71. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  72. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  73. tests/regscale/integrations/test_control_matcher.py +1314 -0
  74. tests/regscale/integrations/test_control_matching.py +155 -0
  75. tests/regscale/integrations/test_milestone_manager.py +408 -0
  76. tests/regscale/models/test_issue.py +378 -1
  77. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
  78. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
  79. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
  80. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1543 @@
1
+ """Test module for GCP scanner integration in RegScale CLI.
2
+
3
+ This module contains tests for the GCP scanner integration, focusing on asset fetching,
4
+ parsing, and the generation of integration findings based on GCP assets.
5
+ """
6
+
7
+ import copy
8
+ import datetime
9
+ import logging
10
+ from collections import Counter
11
+ from unittest.mock import ANY, patch, MagicMock, Mock
12
+
13
+ import pytest
14
+ from freezegun import freeze_time
15
+ from google.cloud import asset_v1
16
+ from proto.datetime_helpers import DatetimeWithNanoseconds
17
+
18
+ from regscale.core.utils.date import default_date_format
19
+ from regscale.integrations.commercial.gcp import gcp_control_tests
20
+ from regscale.integrations.commercial.gcp.__init__ import GCPScannerIntegration
21
+ from regscale.integrations.scanner_integration import IntegrationFinding, IntegrationAsset
22
+ from regscale.models import regscale_models
23
+
24
+
25
+ @pytest.fixture
26
+ def test_identifiers():
27
+ """Provide consistent test identifiers."""
28
+ return {
29
+ "test_string": "test_gcp_integration_12345",
30
+ "plan_id": 999999,
31
+ "assessor_id": 888888,
32
+ "component_id_1": 777777,
33
+ "component_id_2": 777778,
34
+ "asset_id_1": 666666,
35
+ "asset_id_2": 666667,
36
+ }
37
+
38
+
39
+ @pytest.fixture
40
+ def mock_control_mappings():
41
+ """Provide mock control implementation mappings with fake IDs."""
42
+ return {
43
+ "ac-2": 100001,
44
+ "au-2": 100002,
45
+ "ac-3": 100003,
46
+ "ac-5": 100004,
47
+ "ac-6": 100005,
48
+ "au-9": 100006,
49
+ "au-11": 100007,
50
+ "ca-3": 100008,
51
+ "cp-9": 100009,
52
+ "ia-2": 100010,
53
+ "sc-7": 100011,
54
+ "sc-12": 100012,
55
+ "si-4": 100013,
56
+ }
57
+
58
+
59
+ @pytest.fixture
60
+ def mock_regscale_models(mocker):
61
+ """Mock all RegScale model operations to avoid database dependencies."""
62
+ return {
63
+ "SecurityPlan": mocker.patch("regscale.models.regscale_models.SecurityPlan"),
64
+ "Component": mocker.patch("regscale.models.regscale_models.Component"),
65
+ "Asset": mocker.patch("regscale.models.regscale_models.Asset"),
66
+ "ComponentMapping": mocker.patch("regscale.models.regscale_models.ComponentMapping"),
67
+ "ControlImplementation": mocker.patch("regscale.models.regscale_models.ControlImplementation"),
68
+ }
69
+
70
+
71
+ def create_mock_asset(
72
+ update_seconds: int, update_nanos: int, name: str, asset_type: str, ancestors: list
73
+ ) -> asset_v1.Asset:
74
+ """Create a mock GCP Asset with specified properties for testing."""
75
+ mock_asset = asset_v1.Asset()
76
+ dt = datetime.datetime.fromtimestamp(update_seconds, tz=datetime.timezone.utc)
77
+ mock_asset.update_time = DatetimeWithNanoseconds(
78
+ dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, nanosecond=update_nanos, tzinfo=datetime.timezone.utc
79
+ )
80
+ mock_asset.name = name
81
+ mock_asset.asset_type = asset_type
82
+ mock_asset.ancestors.extend(ancestors)
83
+ return mock_asset
84
+
85
+
86
+ @pytest.fixture
87
+ def sample_gcp_assets(test_identifiers):
88
+ """Create consistent test data for GCP assets."""
89
+ test_string = test_identifiers["test_string"]
90
+ return [
91
+ create_mock_asset(
92
+ update_seconds=1703126248,
93
+ update_nanos=185897000,
94
+ name=f"//cloudbilling.googleapis.com/projects/test-project-1/billingInfo/{test_string}",
95
+ asset_type=f"cloudbilling.googleapis.com/ProjectBillingInfo/{test_string}",
96
+ ancestors=["projects/100001", "folders/200001", "organizations/300001"],
97
+ ),
98
+ create_mock_asset(
99
+ update_seconds=1703126249,
100
+ update_nanos=185897001,
101
+ name=f"//cloudbilling.googleapis.com/projects/test-project-2/billingInfo/{test_string}",
102
+ asset_type=f"cloudbilling.googleapis.com/ProjectBillingInfo/{test_string}",
103
+ ancestors=["projects/100002", "folders/200002", "organizations/300002"],
104
+ ),
105
+ ]
106
+
107
+
108
+ @pytest.fixture
109
+ def mock_asset_v1_client(sample_gcp_assets):
110
+ """Mock GCP AssetServiceClient for testing."""
111
+ with patch("google.cloud.asset_v1.AssetServiceClient") as mock_client, patch(
112
+ "regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled"
113
+ ) as mock_api_enabled:
114
+ mock_client.return_value.list_assets.return_value = sample_gcp_assets
115
+ mock_api_enabled.return_value = None
116
+ yield mock_client
117
+
118
+
119
+ def assert_integration_finding_structure(finding: IntegrationFinding, expected_status=None, expected_severity=None):
120
+ """Helper to validate IntegrationFinding structure."""
121
+ assert isinstance(finding, IntegrationFinding)
122
+ assert finding.title == "GCP Scanner Integration Control Assessment"
123
+ assert finding.date_created == "2024-01-24 16:16:25"
124
+ assert finding.date_last_updated == "2024-01-24 16:16:25"
125
+ if expected_status:
126
+ assert finding.status == expected_status
127
+ if expected_severity:
128
+ assert finding.severity == expected_severity
129
+
130
+
131
+ def assert_integration_asset_structure(asset: IntegrationAsset, test_identifiers: dict):
132
+ """Helper to validate IntegrationAsset structure."""
133
+ assert isinstance(asset, IntegrationAsset)
134
+ assert asset.asset_owner_id == str(test_identifiers["assessor_id"])
135
+ assert asset.parent_id == test_identifiers["plan_id"]
136
+ assert asset.parent_module == "security_plans"
137
+ assert asset.asset_category == "GCP"
138
+ assert asset.status == "Active (On Network)"
139
+
140
+
141
+ @freeze_time("2024-01-24 16:16:25")
142
+ class TestGCPScannerIntegration:
143
+ """Test suite for GCP Scanner Integration."""
144
+
145
+ @pytest.fixture(autouse=True)
146
+ def setup_test_isolation(self, mocker, test_identifiers, mock_control_mappings):
147
+ """Ensure each test runs in isolation with mocked dependencies."""
148
+ # Mock GCP variables to avoid external dependencies
149
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project-123")
150
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpOrganizationId", "test-org-12345")
151
+ mocker.patch(
152
+ "regscale.integrations.commercial.gcp.variables.GcpVariables.gcpCredentials", "test/path/credentials.json"
153
+ )
154
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpScanType", "project")
155
+
156
+ # Mock all API calls made during ScannerIntegration.__init__
157
+ mocker.patch(
158
+ "regscale.models.regscale_models.ControlImplementation.get_control_label_map_by_parent",
159
+ return_value=mock_control_mappings,
160
+ )
161
+ mocker.patch(
162
+ "regscale.models.regscale_models.ControlImplementation.get_control_id_map_by_parent",
163
+ return_value={v: k for k, v in mock_control_mappings.items()},
164
+ )
165
+ mocker.patch(
166
+ "regscale.models.regscale_models.Issue.get_open_issues_ids_by_implementation_id",
167
+ return_value={},
168
+ )
169
+ mocker.patch(
170
+ "regscale.models.regscale_models.Issue.get_user_id",
171
+ return_value=str(test_identifiers["assessor_id"]),
172
+ )
173
+
174
+ # Mock get_assessor_id method to avoid authentication dependencies
175
+ mocker.patch.object(GCPScannerIntegration, "get_assessor_id", return_value=str(test_identifiers["assessor_id"]))
176
+
177
+ # Mock GCP control tests to avoid import dependencies
178
+ mock_control_tests = {
179
+ "ac-2": {
180
+ "PUBLIC_BUCKET_ACL": {
181
+ "severity": "HIGH",
182
+ "description": "Cloud Storage buckets should not be anonymously or publicly accessible",
183
+ },
184
+ },
185
+ "ac-3": {
186
+ "PUBLIC_BUCKET_ACL": {
187
+ "severity": "HIGH",
188
+ "description": "Test PUBLIC_BUCKET_ACL description",
189
+ },
190
+ },
191
+ "au-9": {
192
+ "PUBLIC_LOG_BUCKET": {
193
+ "severity": "HIGH",
194
+ "description": "Storage buckets used as log sinks should not be publicly accessible",
195
+ },
196
+ },
197
+ "si-4": {
198
+ "FLOW_LOGS_DISABLED": {
199
+ "severity": "LOW",
200
+ "description": "VPC Flow logs should be Enabled for every subnet in VPC Network",
201
+ },
202
+ },
203
+ }
204
+ mocker.patch("regscale.integrations.commercial.gcp.control_tests.gcp_control_tests", mock_control_tests)
205
+
206
+ # Mock module slugs and strings to avoid RegScale internal dependencies
207
+ mocker.patch.object(regscale_models.SecurityPlan, "get_module_slug", return_value="security_plans")
208
+ mocker.patch.object(regscale_models.Component, "get_module_slug", return_value="components")
209
+ mocker.patch.object(regscale_models.SecurityPlan, "get_module_string", return_value="security_plans")
210
+ mocker.patch.object(regscale_models.Component, "get_module_string", return_value="components")
211
+
212
+ # Mock Application and APIHandler to avoid initialization API calls
213
+ mock_app = mocker.patch("regscale.core.app.application.Application")
214
+ mock_app.return_value.config = {"domain": "https://test.regscale.com"}
215
+
216
+ mock_api_handler = mocker.patch("regscale.core.app.utils.api_handler.APIHandler")
217
+ mock_api_handler.return_value.regscale_version = "test-version"
218
+
219
+ mocker.patch("regscale.integrations.public.cisa.pull_cisa_kev", return_value={})
220
+
221
+ # Mock AssetMapping and other model operations that make API calls
222
+ mocker.patch("regscale.models.regscale_models.AssetMapping.populate_cache_by_plan", return_value=None)
223
+ mocker.patch("regscale.models.regscale_models.AssetMapping.get_plan_objects", return_value=[])
224
+
225
+ # Mock STIG mapper loading
226
+ mocker.patch.object(GCPScannerIntegration, "load_stig_mapper", return_value=None)
227
+
228
+ # Mock GCP authentication functions to prevent real API calls
229
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials", return_value=None)
230
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled", return_value=None)
231
+
232
+ def test_get_passed_findings(self, mocker, test_identifiers, mock_control_mappings):
233
+ """Test get_passed_findings returns properly structured passed findings."""
234
+ mocker.patch(
235
+ "regscale.models.regscale_models.ControlImplementation.get_control_label_map_by_plan",
236
+ return_value=mock_control_mappings,
237
+ )
238
+
239
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
240
+
241
+ passed_findings = gcp_integration.get_passed_findings()
242
+
243
+ assert isinstance(passed_findings, list)
244
+ assert len(passed_findings) > 0
245
+
246
+ first_finding = passed_findings[0]
247
+ assert_integration_finding_structure(
248
+ first_finding,
249
+ expected_status=regscale_models.ControlTestResultStatus.PASS,
250
+ expected_severity=regscale_models.IssueSeverity.Low,
251
+ )
252
+ assert first_finding.plugin_name
253
+ assert isinstance(first_finding.control_labels, list)
254
+ assert len(first_finding.control_labels) > 0
255
+
256
+ expected_finding_count = sum(len(categories) for categories in gcp_control_tests.values())
257
+ assert len(passed_findings) == expected_finding_count
258
+
259
+ for finding in passed_findings:
260
+ assert_integration_finding_structure(finding, expected_status=regscale_models.ControlTestResultStatus.PASS)
261
+
262
+ def test_fetch_assets_calls_list_assets(self, mock_asset_v1_client: MagicMock, test_identifiers, sample_gcp_assets):
263
+ """Test fetch_assets calls AssetServiceClient.list_assets with correct parameters."""
264
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
265
+ assets = list(gcp_integration.fetch_assets())
266
+
267
+ assert len(assets) == len(sample_gcp_assets)
268
+ mock_asset_v1_client.return_value.list_assets.assert_called_once_with(request=ANY)
269
+
270
+ @pytest.mark.parametrize("asset_index", [0, 1])
271
+ def test_parse_asset_transforms_gcp_asset(
272
+ self, mock_asset_v1_client: MagicMock, test_identifiers, sample_gcp_assets, asset_index
273
+ ):
274
+ """Test parse_asset transforms GCP asset into properly structured IntegrationAsset."""
275
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
276
+ asset = sample_gcp_assets[asset_index]
277
+
278
+ integration_asset = gcp_integration.parse_asset(asset)
279
+
280
+ assert_integration_asset_structure(integration_asset, test_identifiers)
281
+ assert integration_asset.name == asset.name
282
+ assert integration_asset.identifier == asset.name
283
+ assert integration_asset.asset_type == asset.asset_type
284
+ assert integration_asset.date_last_updated == asset.update_time.strftime(default_date_format)
285
+
286
+ @pytest.fixture
287
+ def mock_regscale_data(self, test_identifiers, sample_gcp_assets):
288
+ """Create mock RegScale component and asset data for testing."""
289
+ test_string = test_identifiers["test_string"]
290
+ return {
291
+ "components": [
292
+ Mock(id=test_identifiers["component_id_1"], title=f"Test Component 1/{test_string}", delete=Mock()),
293
+ Mock(id=test_identifiers["component_id_2"], title=f"Test Component 2/{test_string}", delete=Mock()),
294
+ ],
295
+ "assets": [
296
+ Mock(id=test_identifiers["asset_id_1"], name=sample_gcp_assets[0].name, delete=Mock()),
297
+ Mock(id=test_identifiers["asset_id_2"], name=sample_gcp_assets[1].name, delete=Mock()),
298
+ ],
299
+ }
300
+
301
+ def create_gcp_integration_with_control_tests(self, test_identifiers):
302
+ """Helper to create GCP integration with control tests setup."""
303
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
304
+ gcp_integration.gcp_control_tests = copy.copy(gcp_control_tests)
305
+ return gcp_integration
306
+
307
+ def test_sync_assets(
308
+ self,
309
+ mocker,
310
+ mock_asset_v1_client: MagicMock,
311
+ mock_regscale_models,
312
+ test_identifiers,
313
+ sample_gcp_assets,
314
+ mock_regscale_data,
315
+ ):
316
+ """Test sync_assets calls parent ScannerIntegration.sync_assets method."""
317
+ plan_id = test_identifiers["plan_id"]
318
+
319
+ # Setup mocks for database operations
320
+ mock_regscale_models["Component"].get_all_by_parent.return_value = []
321
+ mock_regscale_models["Asset"].get_all_by_parent.return_value = []
322
+
323
+ # Mock ComponentMapping to return None (not found)
324
+ mock_component_mapping = Mock()
325
+ mock_component_mapping.find_by_unique.return_value = None
326
+ mock_regscale_models["ComponentMapping"].return_value = mock_component_mapping
327
+
328
+ # Mock asset creation to prevent actual database calls
329
+ mock_asset_instance = Mock()
330
+ mock_asset_instance.create_or_update_with_status.return_value = None
331
+ mocker.patch("regscale.models.regscale_models.Asset", return_value=mock_asset_instance)
332
+
333
+ # Mock the parent class sync_assets method to avoid real execution
334
+ mock_parent_sync = mocker.patch(
335
+ "regscale.integrations.scanner_integration.ScannerIntegration.sync_assets",
336
+ return_value=len(sample_gcp_assets),
337
+ )
338
+
339
+ # Call the classmethod to test inheritance
340
+ result = GCPScannerIntegration.sync_assets(plan_id=plan_id)
341
+
342
+ # Verify that parent sync_assets was called with correct parameters
343
+ mock_parent_sync.assert_called_once_with(plan_id=plan_id)
344
+ assert result == len(sample_gcp_assets)
345
+
346
+ @pytest.fixture
347
+ def mock_gcp_finding(self):
348
+ """Create a comprehensive mock GCP Security Center finding."""
349
+ mock_finding = Mock()
350
+ mock_finding.name = "organizations/123456789012/sources/12345/findings/test-finding-001"
351
+ mock_finding.category = "PUBLIC_BUCKET_ACL"
352
+ mock_finding.severity = 2 # HIGH
353
+ mock_finding.description = "Test finding description"
354
+ mock_finding.external_uri = "https://console.cloud.google.com/test"
355
+ mock_finding.resource_name = "//storage.googleapis.com/projects/test-project/global/buckets/test-bucket"
356
+
357
+ # Mock source_properties with proper get() method behavior
358
+ mock_source_properties = Mock()
359
+ mock_source_properties.get.side_effect = lambda key, default="": {
360
+ "Recommendation": "Test recommendation",
361
+ "Explanation": "Test explanation",
362
+ }.get(key, default)
363
+ mock_finding.source_properties = mock_source_properties
364
+
365
+ # Mock compliances
366
+ mock_compliance = Mock()
367
+ mock_compliance.standard = "nist"
368
+ mock_compliance.ids = ["AC-2", "AC-3"]
369
+ mock_finding.compliances = [mock_compliance]
370
+
371
+ return mock_finding
372
+
373
+ @pytest.fixture
374
+ def mock_gcp_finding_no_nist(self):
375
+ """Create mock GCP finding without NIST controls."""
376
+ mock_finding = Mock()
377
+ mock_finding.name = "organizations/123456789012/sources/12345/findings/test-finding-002"
378
+ mock_finding.category = "OPEN_FIREWALL"
379
+ mock_finding.description = "Firewall rule allows unrestricted access"
380
+ mock_finding.severity = 1 # CRITICAL
381
+ mock_finding.compliances = [] # No NIST controls
382
+ mock_source_properties = Mock()
383
+ mock_source_properties.get.return_value = ""
384
+ mock_finding.source_properties = mock_source_properties
385
+ return mock_finding
386
+
387
+ @pytest.fixture
388
+ def mock_failed_findings_response(self, mock_gcp_finding):
389
+ """Create mock ListFindingsPager response."""
390
+ mock_finding_wrapper = Mock()
391
+ mock_finding_wrapper.finding = mock_gcp_finding
392
+ return [mock_finding_wrapper]
393
+
394
+ @pytest.fixture
395
+ def mock_security_center_client(self, mocker, mock_failed_findings_response):
396
+ """Mock GCP Security Center client."""
397
+ mock_client = Mock()
398
+ mock_client.list_findings.return_value = mock_failed_findings_response
399
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
400
+ return mock_client
401
+
402
+ @pytest.mark.parametrize(
403
+ "scan_type,expected_sources",
404
+ [
405
+ ("project", "projects/test-project-123/sources/-"),
406
+ ("organization", "organizations/test-org-12345/sources/-"),
407
+ ],
408
+ )
409
+ def test_get_failed_findings_scan_type_handling(self, mocker, scan_type, expected_sources):
410
+ """Test get_failed_findings handles different scan types correctly."""
411
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpScanType", scan_type)
412
+ mock_client = Mock()
413
+ mock_findings = Mock()
414
+ mock_client.list_findings.return_value = mock_findings
415
+
416
+ # Mock the entire client creation flow
417
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
418
+
419
+ result = GCPScannerIntegration.get_failed_findings()
420
+
421
+ assert result == mock_findings
422
+ mock_client.list_findings.assert_called_once_with(request={"parent": expected_sources})
423
+
424
+ def test_get_failed_findings_invalid_sources(self, mocker):
425
+ """Test get_failed_findings raises NameError for invalid GCP sources."""
426
+ from google.api_core.exceptions import InvalidArgument
427
+
428
+ mock_client = Mock()
429
+ mock_client.list_findings.side_effect = InvalidArgument("Invalid parent")
430
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
431
+
432
+ with pytest.raises(NameError, match="gcpFindingSources is set incorrectly"):
433
+ GCPScannerIntegration.get_failed_findings()
434
+
435
+ def test_get_failed_findings_success(self, mock_security_center_client, mock_failed_findings_response):
436
+ """Test get_failed_findings successful authentication and client creation."""
437
+ result = GCPScannerIntegration.get_failed_findings()
438
+
439
+ assert result == mock_failed_findings_response
440
+ mock_security_center_client.list_findings.assert_called_once_with(
441
+ request={"parent": "projects/test-project-123/sources/-"}
442
+ )
443
+
444
+ def test_get_failed_findings_empty_response(self, mocker):
445
+ """Test get_failed_findings with empty findings response."""
446
+ mock_client = Mock()
447
+ mock_client.list_findings.return_value = []
448
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
449
+
450
+ result = GCPScannerIntegration.get_failed_findings()
451
+
452
+ assert result == []
453
+ mock_client.list_findings.assert_called_once()
454
+
455
+ def test_get_failed_findings_logging_behavior(self, mocker, caplog):
456
+ """Test get_failed_findings logging behavior."""
457
+ mock_client = Mock()
458
+ mock_client.list_findings.return_value = []
459
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
460
+
461
+ with caplog.at_level(logging.INFO):
462
+ GCPScannerIntegration.get_failed_findings()
463
+
464
+ assert "Fetching GCP findings..." in caplog.text
465
+ assert "Fetched GCP findings." in caplog.text
466
+
467
+ def test_get_failed_findings_error_logging(self, mocker, caplog):
468
+ """Test get_failed_findings logs errors properly."""
469
+ from google.api_core.exceptions import InvalidArgument
470
+
471
+ mock_client = Mock()
472
+ mock_client.list_findings.side_effect = InvalidArgument("Invalid parent")
473
+ mocker.patch("google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client)
474
+
475
+ with caplog.at_level(logging.ERROR):
476
+ with pytest.raises(NameError):
477
+ GCPScannerIntegration.get_failed_findings()
478
+
479
+ assert "gcpFindingSources is set incorrectly" in caplog.text
480
+
481
+ def test_parse_finding_with_nist_controls(self, test_identifiers, mock_gcp_finding):
482
+ """Test parse_finding successfully processes finding with NIST controls."""
483
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
484
+
485
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json_data"}'):
486
+ result = gcp_integration.parse_finding(mock_gcp_finding)
487
+
488
+ assert result is not None
489
+ assert isinstance(result, IntegrationFinding)
490
+ assert result.control_labels == ["ac-2", "ac-3"]
491
+ assert result.category == "PUBLIC_BUCKET_ACL"
492
+ assert result.severity == regscale_models.IssueSeverity.High
493
+ assert result.status == regscale_models.ControlTestResultStatus.FAIL
494
+
495
+ def test_parse_finding_no_nist_controls(self, test_identifiers, mock_gcp_finding_no_nist):
496
+ """Test parse_finding returns None when no NIST controls found."""
497
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
498
+
499
+ result = gcp_integration.parse_finding(mock_gcp_finding_no_nist)
500
+
501
+ assert result is None
502
+
503
+ def test_parse_finding_multiple_nist_controls(self, test_identifiers):
504
+ """Test parse_finding handles multiple NIST controls correctly."""
505
+ mock_finding = Mock()
506
+ mock_finding.category = "PUBLIC_BUCKET_ACL"
507
+ mock_finding.description = "Test finding"
508
+ mock_finding.severity = 2
509
+ mock_finding.external_uri = ""
510
+ mock_finding.resource_name = ""
511
+ mock_source_properties = Mock()
512
+ mock_source_properties.get.return_value = ""
513
+ mock_finding.source_properties = mock_source_properties
514
+
515
+ mock_compliance = Mock()
516
+ mock_compliance.standard = "nist"
517
+ mock_compliance.ids = ["AC-2", "au-2", "Si-4"] # Mixed case
518
+ mock_finding.compliances = [mock_compliance]
519
+
520
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
521
+
522
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
523
+ result = gcp_integration.parse_finding(mock_finding)
524
+
525
+ assert result.control_labels == ["ac-2", "au-2", "si-4"]
526
+
527
+ def test_parse_finding_control_test_status_update(self, test_identifiers, mock_gcp_finding):
528
+ """Test parse_finding updates control test status to Failed."""
529
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
530
+
531
+ # Use minimal control tests for this test
532
+ test_control_tests = {
533
+ "ac-2": {"PUBLIC_BUCKET_ACL": {"severity": "HIGH", "description": "Test description"}},
534
+ "ac-3": {"PUBLIC_BUCKET_ACL": {"severity": "HIGH", "description": "Test description"}},
535
+ }
536
+ gcp_integration.gcp_control_tests = copy.deepcopy(test_control_tests)
537
+
538
+ # Verify initial state
539
+ assert "status" not in gcp_integration.gcp_control_tests["ac-2"]["PUBLIC_BUCKET_ACL"]
540
+
541
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
542
+ gcp_integration.parse_finding(mock_gcp_finding)
543
+
544
+ # Verify status was set to Failed
545
+ assert gcp_integration.gcp_control_tests["ac-2"]["PUBLIC_BUCKET_ACL"]["status"] == "Failed"
546
+ assert gcp_integration.gcp_control_tests["ac-3"]["PUBLIC_BUCKET_ACL"]["status"] == "Failed"
547
+
548
+ @pytest.mark.parametrize(
549
+ "severity,expected_regscale_severity",
550
+ [
551
+ (0, regscale_models.IssueSeverity.Low),
552
+ (1, regscale_models.IssueSeverity.Critical),
553
+ (2, regscale_models.IssueSeverity.High),
554
+ (3, regscale_models.IssueSeverity.Moderate),
555
+ (4, regscale_models.IssueSeverity.Low),
556
+ (999, regscale_models.IssueSeverity.Low), # Unknown severity defaults to Low
557
+ ],
558
+ )
559
+ def test_parse_finding_severity_mapping(self, test_identifiers, severity, expected_regscale_severity):
560
+ """Test parse_finding correctly maps GCP severities to RegScale severities."""
561
+ mock_finding = Mock()
562
+ mock_finding.category = "TEST_SEVERITY"
563
+ mock_finding.description = "Test severity mapping"
564
+ mock_finding.severity = severity
565
+ mock_finding.external_uri = ""
566
+ mock_finding.resource_name = ""
567
+ mock_source_properties = Mock()
568
+ mock_source_properties.get.return_value = ""
569
+ mock_finding.source_properties = mock_source_properties
570
+
571
+ mock_compliance = Mock()
572
+ mock_compliance.standard = "nist"
573
+ mock_compliance.ids = ["ac-2"]
574
+ mock_finding.compliances = [mock_compliance]
575
+
576
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
577
+
578
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
579
+ result = gcp_integration.parse_finding(mock_finding)
580
+
581
+ assert result.severity == expected_regscale_severity
582
+ assert result.impact == expected_regscale_severity
583
+
584
+ def test_parse_finding_missing_source_properties(self, test_identifiers, mock_gcp_finding):
585
+ """Test parse_finding handles missing source properties gracefully."""
586
+ # Remove source_properties entirely
587
+ mock_gcp_finding.source_properties = {}
588
+
589
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
590
+
591
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
592
+ result = gcp_integration.parse_finding(mock_gcp_finding)
593
+
594
+ assert result is not None
595
+ # Should handle missing keys gracefully
596
+ assert result.gaps is not None
597
+ assert result.observations is not None
598
+
599
+ def test_parse_finding_control_labels_case_insensitive(self, test_identifiers):
600
+ """Test parse_finding handles case-insensitive control labels properly."""
601
+ mock_finding = Mock()
602
+ mock_finding.category = "PUBLIC_BUCKET_ACL"
603
+ mock_finding.description = "Test case handling"
604
+ mock_finding.severity = 2
605
+ mock_finding.external_uri = ""
606
+ mock_finding.resource_name = ""
607
+ mock_source_properties = Mock()
608
+ mock_source_properties.get.return_value = ""
609
+ mock_finding.source_properties = mock_source_properties
610
+
611
+ mock_compliance = Mock()
612
+ mock_compliance.standard = "nist"
613
+ mock_compliance.ids = ["AC-2", "au-2", "Si-4"] # Mixed case
614
+ mock_finding.compliances = [mock_compliance]
615
+
616
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
617
+
618
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
619
+ result = gcp_integration.parse_finding(mock_finding)
620
+
621
+ # Verify control test was marked as failed for ac-2, PUBLIC_BUCKET_ACL
622
+ assert gcp_integration.gcp_control_tests["ac-2"]["PUBLIC_BUCKET_ACL"]["status"] == "Failed"
623
+ # Verify other control tests are still available for passed findings
624
+ assert result.control_labels == ["ac-2", "au-2", "si-4"]
625
+
626
+ def test_parse_finding_json_serialization(self, test_identifiers, mock_gcp_finding):
627
+ """Test parse_finding handles JSON serialization properly."""
628
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
629
+
630
+ test_json = '{"name": "test-finding", "category": "PUBLIC_BUCKET_ACL"}'
631
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value=test_json):
632
+ result = gcp_integration.parse_finding(mock_gcp_finding)
633
+
634
+ assert result.evidence == test_json
635
+
636
+ def test_parse_finding_non_nist_compliance_ignored(self, test_identifiers):
637
+ """Test parse_finding ignores non-NIST compliance standards."""
638
+ mock_finding = Mock()
639
+ mock_finding.category = "TEST_CATEGORY"
640
+ mock_finding.description = "Test non-NIST compliance"
641
+ mock_finding.severity = 2
642
+ mock_finding.external_uri = ""
643
+ mock_finding.resource_name = ""
644
+ mock_source_properties = Mock()
645
+ mock_source_properties.get.return_value = ""
646
+ mock_finding.source_properties = mock_source_properties
647
+
648
+ # Non-NIST compliance
649
+ mock_compliance_iso = Mock()
650
+ mock_compliance_iso.standard = "iso27001"
651
+ mock_compliance_iso.ids = ["A.5.1.1"]
652
+
653
+ # Mixed with NIST
654
+ mock_compliance_nist = Mock()
655
+ mock_compliance_nist.standard = "nist"
656
+ mock_compliance_nist.ids = ["AC-2"]
657
+
658
+ mock_finding.compliances = [mock_compliance_iso, mock_compliance_nist]
659
+
660
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
661
+
662
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
663
+ result = gcp_integration.parse_finding(mock_finding)
664
+
665
+ # Should only include NIST controls
666
+ assert result.control_labels == ["ac-2"]
667
+
668
+ def test_fetch_findings_combines_failed_and_passed(self, mocker, test_identifiers, mock_gcp_finding):
669
+ """Test fetch_findings combines failed and passed findings correctly."""
670
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
671
+
672
+ # Mock get_failed_findings to return our test finding
673
+ mock_finding_wrapper = Mock()
674
+ mock_finding_wrapper.finding = mock_gcp_finding
675
+ mock_failed_findings = [mock_finding_wrapper]
676
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=mock_failed_findings)
677
+
678
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
679
+ findings = gcp_integration.fetch_findings()
680
+
681
+ # Should have both failed and passed findings
682
+ assert len(findings) > 1
683
+ failed_findings = [f for f in findings if f.status == regscale_models.ControlTestResultStatus.FAIL]
684
+ passed_findings = [f for f in findings if f.status == regscale_models.ControlTestResultStatus.PASS]
685
+ assert len(failed_findings) >= 1
686
+ assert len(passed_findings) >= 1
687
+
688
+ def test_fetch_findings_no_failed_findings(self, mocker, test_identifiers):
689
+ """Test fetch_findings when no failed findings exist."""
690
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
691
+
692
+ # Mock get_failed_findings to return empty list
693
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=[])
694
+
695
+ findings = gcp_integration.fetch_findings()
696
+
697
+ # Should still have passed findings
698
+ assert len(findings) > 0
699
+ assert all(f.status == regscale_models.ControlTestResultStatus.PASS for f in findings)
700
+
701
+ def test_fetch_findings_no_passed_findings(self, mocker, test_identifiers, mock_gcp_finding):
702
+ """Test fetch_findings when all control tests are marked as failed."""
703
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
704
+
705
+ # Mark all control tests as failed
706
+ gcp_integration.gcp_control_tests = copy.copy(gcp_control_tests)
707
+ for control_label, categories in gcp_integration.gcp_control_tests.items():
708
+ for category in categories:
709
+ gcp_integration.gcp_control_tests[control_label][category]["status"] = "Failed"
710
+
711
+ mock_finding_wrapper = Mock()
712
+ mock_finding_wrapper.finding = mock_gcp_finding
713
+ mock_failed_findings = [mock_finding_wrapper]
714
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=mock_failed_findings)
715
+
716
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
717
+ findings = gcp_integration.fetch_findings()
718
+
719
+ # Should only have failed findings
720
+ failed_findings = [f for f in findings if f.status == regscale_models.ControlTestResultStatus.FAIL]
721
+ passed_findings = [f for f in findings if f.status == regscale_models.ControlTestResultStatus.PASS]
722
+ assert len(failed_findings) >= 1
723
+ assert len(passed_findings) == 0
724
+
725
+ def test_fetch_findings_control_tests_state_management(self, mocker, test_identifiers, mock_gcp_finding):
726
+ """Test fetch_findings properly manages control test state."""
727
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
728
+
729
+ mock_finding_wrapper = Mock()
730
+ mock_finding_wrapper.finding = mock_gcp_finding
731
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=[mock_finding_wrapper])
732
+
733
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
734
+ gcp_integration.fetch_findings()
735
+
736
+ # Verify control test was marked as failed for ac-2, PUBLIC_BUCKET_ACL
737
+ assert gcp_integration.gcp_control_tests["ac-2"]["PUBLIC_BUCKET_ACL"]["status"] == "Failed"
738
+ # Verify other control tests are still available for passed findings
739
+
740
+ def test_fetch_findings_failed_findings_filtering(
741
+ self, mocker, test_identifiers, mock_gcp_finding, mock_gcp_finding_no_nist
742
+ ):
743
+ """Test fetch_findings properly filters findings that return None."""
744
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
745
+
746
+ # Mix of findings - one with NIST controls, one without
747
+ mock_finding_wrapper1 = Mock()
748
+ mock_finding_wrapper1.finding = mock_gcp_finding
749
+ mock_finding_wrapper2 = Mock()
750
+ mock_finding_wrapper2.finding = mock_gcp_finding_no_nist
751
+
752
+ mock_failed_findings = [mock_finding_wrapper1, mock_finding_wrapper2]
753
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=mock_failed_findings)
754
+
755
+ with patch("google.cloud.securitycenter_v1.Finding.to_json", return_value='{"test": "json"}'):
756
+ findings = gcp_integration.fetch_findings()
757
+
758
+ # Should filter out the finding without NIST controls
759
+ failed_findings = [f for f in findings if f.status == regscale_models.ControlTestResultStatus.FAIL]
760
+ # Only the finding with NIST controls should be included
761
+ assert len(failed_findings) >= 1
762
+
763
+ def test_fetch_findings_kwargs_handling(self, mocker, test_identifiers):
764
+ """Test fetch_findings accepts and handles kwargs properly."""
765
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
766
+
767
+ mocker.patch.object(GCPScannerIntegration, "get_failed_findings", return_value=[])
768
+
769
+ # Should not raise error when called with kwargs
770
+ findings = gcp_integration.fetch_findings(some_param="test_value", another_param=123)
771
+
772
+ assert isinstance(findings, list)
773
+
774
+ def test_get_passed_findings_skips_failed_control_tests(self, test_identifiers):
775
+ """Test get_passed_findings skips control tests already marked as Failed."""
776
+ gcp_integration = self.create_gcp_integration_with_control_tests(test_identifiers)
777
+
778
+ # Mark specific control test as Failed
779
+ gcp_integration.gcp_control_tests["ac-2"]["PUBLIC_BUCKET_ACL"]["status"] = "Failed"
780
+
781
+ passed_findings = gcp_integration.get_passed_findings()
782
+
783
+ # Should skip the failed control test
784
+ failed_findings = [
785
+ f for f in passed_findings if f.category == "PUBLIC_BUCKET_ACL" and "ac-2" in f.control_labels
786
+ ]
787
+ assert len(failed_findings) == 0
788
+
789
+ def test_get_passed_findings_empty_control_tests(self, mocker, test_identifiers):
790
+ """Test get_passed_findings handles empty control tests gracefully."""
791
+ # Mock the gcp_control_tests import to return empty dict
792
+ mocker.patch("regscale.integrations.commercial.gcp.__init__.gcp_control_tests", {})
793
+
794
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
795
+ passed_findings = gcp_integration.get_passed_findings()
796
+
797
+ assert passed_findings == []
798
+
799
+ @pytest.mark.parametrize(
800
+ "control_label,expected_label",
801
+ [
802
+ ("AC-2", "ac-2"),
803
+ ("ac-3", "ac-3"),
804
+ ("Au-9", "au-9"),
805
+ ("SI-4", "si-4"),
806
+ ],
807
+ )
808
+ def test_get_passed_findings_control_label_case_handling(
809
+ self, mocker, test_identifiers, control_label, expected_label
810
+ ):
811
+ """Test get_passed_findings handles control label case consistently."""
812
+ # Mock the gcp_control_tests import to use our custom test data
813
+ test_control_tests = {control_label: {"TEST_CATEGORY": {"severity": "HIGH", "description": "Test description"}}}
814
+ mocker.patch("regscale.integrations.commercial.gcp.__init__.gcp_control_tests", test_control_tests)
815
+
816
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
817
+ passed_findings = gcp_integration.get_passed_findings()
818
+
819
+ assert len(passed_findings) == 1
820
+ assert passed_findings[0].control_labels == [expected_label]
821
+
822
+ def test_get_passed_findings_different_severity_types(self, mocker, test_identifiers):
823
+ """Test get_passed_findings handles different severity types correctly."""
824
+ # Create control tests with different severities
825
+ test_control_tests = {
826
+ "ac-2": {
827
+ "HIGH_SEVERITY": {"severity": "HIGH", "description": "High severity test"},
828
+ "LOW_SEVERITY": {"severity": "LOW", "description": "Low severity test"},
829
+ "MEDIUM_SEVERITY": {"severity": "MEDIUM", "description": "Medium severity test"},
830
+ }
831
+ }
832
+ mocker.patch("regscale.integrations.commercial.gcp.__init__.gcp_control_tests", test_control_tests)
833
+
834
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
835
+ passed_findings = gcp_integration.get_passed_findings()
836
+
837
+ assert len(passed_findings) == 3
838
+ categories = [f.category for f in passed_findings]
839
+ assert "HIGH_SEVERITY" in categories
840
+ assert "LOW_SEVERITY" in categories
841
+ assert "MEDIUM_SEVERITY" in categories
842
+
843
+ def test_get_passed_findings_missing_descriptions(self, mocker, test_identifiers):
844
+ """Test get_passed_findings handles missing descriptions gracefully."""
845
+ # Create control test without description
846
+ test_control_tests = {"ac-2": {"NO_DESCRIPTION": {"severity": "HIGH"}}} # Missing description
847
+ mocker.patch("regscale.integrations.commercial.gcp.__init__.gcp_control_tests", test_control_tests)
848
+
849
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
850
+ passed_findings = gcp_integration.get_passed_findings()
851
+
852
+ assert len(passed_findings) == 1
853
+ # The IntegrationFinding class must set a default value when description is empty
854
+ assert passed_findings[0].description in ["", "No description provided"]
855
+
856
+ def test_get_passed_findings_deep_copy_behavior(self, test_identifiers):
857
+ """Test get_passed_findings doesn't modify the original control tests."""
858
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
859
+ original_control_tests = copy.deepcopy(gcp_control_tests)
860
+ gcp_integration.gcp_control_tests = copy.copy(gcp_control_tests)
861
+
862
+ gcp_integration.get_passed_findings()
863
+
864
+ # Original should be unchanged (deep comparison)
865
+ assert gcp_integration.gcp_control_tests == original_control_tests
866
+
867
+ def test_get_passed_findings_integration_finding_structure(self, mocker, test_identifiers):
868
+ """Test get_passed_findings creates properly structured IntegrationFindings."""
869
+ test_control_tests = {"ac-2": {"TEST_CAT": {"description": "Test desc"}}}
870
+ mocker.patch("regscale.integrations.commercial.gcp.__init__.gcp_control_tests", test_control_tests)
871
+
872
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
873
+ passed_findings = gcp_integration.get_passed_findings()
874
+
875
+ finding = passed_findings[0]
876
+ assert finding.title == "GCP Scanner Integration Control Assessment"
877
+ assert finding.status == regscale_models.ControlTestResultStatus.PASS
878
+ assert finding.severity == regscale_models.IssueSeverity.Low
879
+ assert finding.impact == regscale_models.IssueSeverity.Low
880
+ assert finding.plugin_name == "TEST_CAT"
881
+
882
+ def test_parse_asset_missing_name_field(self, test_identifiers):
883
+ """Test parse_asset handles missing name field gracefully."""
884
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
885
+
886
+ mock_asset = Mock()
887
+ mock_asset.name = "" # Empty name
888
+ mock_asset.asset_type = "test.asset.type"
889
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
890
+
891
+ result = gcp_integration.parse_asset(mock_asset)
892
+
893
+ assert result.name == ""
894
+ assert result.identifier == ""
895
+
896
+ def test_parse_asset_missing_asset_type(self, test_identifiers):
897
+ """Test parse_asset handles missing asset_type field gracefully."""
898
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
899
+
900
+ mock_asset = Mock()
901
+ mock_asset.name = "test-asset-name"
902
+ mock_asset.asset_type = "" # Empty asset type
903
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
904
+
905
+ result = gcp_integration.parse_asset(mock_asset)
906
+
907
+ assert result.asset_type == ""
908
+ assert result.component_names == [""]
909
+
910
+ def test_parse_asset_missing_update_time(self, test_identifiers):
911
+ """Test parse_asset handles missing update_time field."""
912
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
913
+
914
+ mock_asset = Mock()
915
+ mock_asset.name = "test-asset-name"
916
+ mock_asset.asset_type = "test.asset.type"
917
+ mock_asset.update_time = None
918
+
919
+ # Should raise AttributeError when trying to format None
920
+ with pytest.raises(AttributeError):
921
+ gcp_integration.parse_asset(mock_asset)
922
+
923
+ def test_parse_asset_invalid_timestamp_format(self, test_identifiers):
924
+ """Test parse_asset handles invalid timestamp gracefully."""
925
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
926
+
927
+ mock_asset = Mock()
928
+ mock_asset.name = "test-asset-name"
929
+ mock_asset.asset_type = "test.asset.type"
930
+ mock_update_time = Mock()
931
+ mock_update_time.strftime.side_effect = ValueError("Invalid format")
932
+ mock_asset.update_time = mock_update_time
933
+
934
+ # Should raise ValueError for invalid timestamp
935
+ with pytest.raises(ValueError):
936
+ gcp_integration.parse_asset(mock_asset)
937
+
938
+ def test_parse_asset_very_long_names(self, test_identifiers):
939
+ """Test parse_asset handles very long asset names."""
940
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
941
+
942
+ long_name = "a" * 1000 # Very long name
943
+ mock_asset = Mock()
944
+ mock_asset.name = long_name
945
+ mock_asset.asset_type = "test.asset.type"
946
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
947
+
948
+ result = gcp_integration.parse_asset(mock_asset)
949
+
950
+ assert result.name == long_name
951
+ assert result.identifier == long_name
952
+
953
+ def test_parse_asset_special_characters_in_fields(self, test_identifiers):
954
+ """Test parse_asset handles special characters in asset fields."""
955
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
956
+
957
+ special_name = "//test-asset@#$%^&*()_+{}[]|:;<>?,./"
958
+ mock_asset = Mock()
959
+ mock_asset.name = special_name
960
+ mock_asset.asset_type = "test.asset.type!@#"
961
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
962
+
963
+ result = gcp_integration.parse_asset(mock_asset)
964
+
965
+ assert result.name == special_name
966
+ assert result.asset_type == "test.asset.type!@#"
967
+
968
+ def test_parse_asset_different_asset_types(self, test_identifiers):
969
+ """Test parse_asset handles different GCP asset types."""
970
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
971
+
972
+ asset_types = [
973
+ "compute.googleapis.com/Instance",
974
+ "storage.googleapis.com/Bucket",
975
+ "cloudsql.googleapis.com/Instance",
976
+ "container.googleapis.com/Cluster",
977
+ ]
978
+
979
+ for asset_type in asset_types:
980
+ mock_asset = Mock()
981
+ mock_asset.name = f"test-{asset_type.replace('/', '-')}"
982
+ mock_asset.asset_type = asset_type
983
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
984
+
985
+ result = gcp_integration.parse_asset(mock_asset)
986
+
987
+ assert result.asset_type == asset_type
988
+ assert result.component_names == [asset_type]
989
+
990
+ def test_parse_asset_component_names_variations(self, test_identifiers):
991
+ """Test parse_asset component names are based on asset_type."""
992
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
993
+
994
+ mock_asset = Mock()
995
+ mock_asset.name = "test-asset"
996
+ mock_asset.asset_type = "custom.service.type"
997
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
998
+
999
+ result = gcp_integration.parse_asset(mock_asset)
1000
+
1001
+ assert result.component_names == ["custom.service.type"]
1002
+
1003
+ def test_parse_asset_minimal_required_fields(self, test_identifiers):
1004
+ """Test parse_asset with minimal required fields."""
1005
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1006
+
1007
+ mock_asset = Mock()
1008
+ mock_asset.name = "minimal-asset"
1009
+ mock_asset.asset_type = "minimal.type"
1010
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
1011
+
1012
+ result = gcp_integration.parse_asset(mock_asset)
1013
+
1014
+ # Verify all required IntegrationAsset fields are present
1015
+ assert result.name == "minimal-asset"
1016
+ assert result.identifier == "minimal-asset"
1017
+ assert result.asset_type == "minimal.type"
1018
+ assert result.asset_owner_id == str(test_identifiers["assessor_id"])
1019
+ assert result.parent_id == test_identifiers["plan_id"]
1020
+ assert result.parent_module == "security_plans"
1021
+ assert result.asset_category == "GCP"
1022
+ assert result.date_last_updated == "2024-01-24 16:16:25"
1023
+ assert result.component_names == ["minimal.type"]
1024
+ assert result.status == "Active (On Network)"
1025
+
1026
+ def test_parse_asset_maximum_field_lengths(self, test_identifiers):
1027
+ """Test parse_asset with maximum realistic field lengths."""
1028
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1029
+
1030
+ # Google Cloud resource names can be quite long
1031
+ max_name = "//compute.googleapis.com/projects/" + "x" * 100 + "/zones/us-central1-a/instances/" + "y" * 63
1032
+ max_type = "compute.googleapis.com/" + "z" * 50
1033
+
1034
+ mock_asset = Mock()
1035
+ mock_asset.name = max_name
1036
+ mock_asset.asset_type = max_type
1037
+ mock_asset.update_time.strftime.return_value = "2024-01-24 16:16:25"
1038
+
1039
+ result = gcp_integration.parse_asset(mock_asset)
1040
+
1041
+ assert result.name == max_name
1042
+ assert result.asset_type == max_type
1043
+
1044
+ @pytest.mark.parametrize(
1045
+ "scan_type,expected_sources",
1046
+ [
1047
+ ("project", "projects/test-project-123"),
1048
+ ("organization", "organizations/test-org-12345"),
1049
+ ],
1050
+ )
1051
+ def test_fetch_assets_scan_type_handling(self, mocker, test_identifiers, scan_type, expected_sources):
1052
+ """Test fetch_assets handles different scan types correctly."""
1053
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpScanType", scan_type)
1054
+
1055
+ mock_client = Mock()
1056
+ mock_assets = [Mock(), Mock()] # Two mock assets
1057
+ mock_client.list_assets.return_value = mock_assets
1058
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1059
+
1060
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1061
+ list(gcp_integration.fetch_assets())
1062
+
1063
+ # Verify correct parent was used
1064
+ mock_client.list_assets.assert_called_once()
1065
+ call_args = mock_client.list_assets.call_args
1066
+ request = call_args[1]["request"]
1067
+ assert request.parent == expected_sources
1068
+
1069
+ def test_fetch_assets_empty_response(self, mocker, test_identifiers):
1070
+ """Test fetch_assets handles empty asset response."""
1071
+ mock_client = Mock()
1072
+ mock_client.list_assets.return_value = [] # Empty response
1073
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1074
+
1075
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1076
+ assets = list(gcp_integration.fetch_assets())
1077
+
1078
+ assert assets == []
1079
+ assert gcp_integration.num_assets_to_process == 0
1080
+
1081
+ def test_fetch_assets_large_asset_sets(self, mocker, test_identifiers):
1082
+ """Test fetch_assets handles large numbers of assets."""
1083
+ mock_client = Mock()
1084
+ # Create 1000 mock assets
1085
+ large_asset_set = [Mock() for _ in range(1000)]
1086
+ mock_client.list_assets.return_value = large_asset_set
1087
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1088
+
1089
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1090
+ assets = list(gcp_integration.fetch_assets())
1091
+
1092
+ assert len(assets) == 1000
1093
+ assert gcp_integration.num_assets_to_process == 1000
1094
+
1095
+ def test_fetch_assets_logging_behavior(self, mocker, test_identifiers, caplog):
1096
+ """Test fetch_assets logging behavior."""
1097
+ mock_client = Mock()
1098
+ mock_client.list_assets.return_value = [Mock()]
1099
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1100
+
1101
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1102
+
1103
+ with caplog.at_level(logging.INFO):
1104
+ list(gcp_integration.fetch_assets())
1105
+
1106
+ assert "Fetching GCP assets..." in caplog.text
1107
+ assert "Fetched GCP assets." in caplog.text
1108
+
1109
+ def test_fetch_assets_authentication_error_handling(self, mocker, test_identifiers):
1110
+ """Test fetch_assets handles authentication errors."""
1111
+ from google.auth.exceptions import DefaultCredentialsError
1112
+
1113
+ mock_client = Mock()
1114
+ mock_client.list_assets.side_effect = DefaultCredentialsError("No credentials")
1115
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1116
+
1117
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1118
+
1119
+ # Should propagate authentication errors
1120
+ with pytest.raises(DefaultCredentialsError):
1121
+ list(gcp_integration.fetch_assets())
1122
+
1123
+ def test_fetch_assets_api_client_error_handling(self, mocker, test_identifiers):
1124
+ """Test fetch_assets handles API client errors."""
1125
+ from google.api_core.exceptions import NotFound
1126
+
1127
+ mock_client = Mock()
1128
+ mock_client.list_assets.side_effect = NotFound("Project not found")
1129
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1130
+
1131
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1132
+
1133
+ # Should propagate API errors
1134
+ with pytest.raises(NotFound):
1135
+ list(gcp_integration.fetch_assets())
1136
+
1137
+ def test_fetch_assets_parse_asset_integration(self, mocker, test_identifiers, sample_gcp_assets):
1138
+ """Test fetch_assets integrates with parse_asset correctly."""
1139
+ mock_client = Mock()
1140
+ mock_client.list_assets.return_value = sample_gcp_assets
1141
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1142
+
1143
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1144
+ assets = list(gcp_integration.fetch_assets())
1145
+
1146
+ # Should return IntegrationAsset objects
1147
+ assert len(assets) == len(sample_gcp_assets)
1148
+ for asset in assets:
1149
+ assert isinstance(asset, IntegrationAsset)
1150
+
1151
+ def test_fetch_assets_num_assets_counting(self, mocker, test_identifiers):
1152
+ """Test fetch_assets correctly counts processed assets."""
1153
+ mock_client = Mock()
1154
+ test_assets = [Mock() for _ in range(5)]
1155
+ mock_client.list_assets.return_value = test_assets
1156
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1157
+
1158
+ gcp_integration = GCPScannerIntegration(plan_id=test_identifiers["plan_id"])
1159
+ assets = list(gcp_integration.fetch_assets())
1160
+
1161
+ assert gcp_integration.num_assets_to_process == 5
1162
+ assert len(assets) == 5
1163
+
1164
+ def test_sync_assets_error_handling(self, mocker, test_identifiers):
1165
+ """Test sync_assets handles errors during asset processing."""
1166
+ # Mock parent class sync_assets to raise an exception
1167
+ mock_error = Exception("Processing error")
1168
+ mocker.patch("regscale.integrations.scanner_integration.ScannerIntegration.sync_assets", side_effect=mock_error)
1169
+
1170
+ # Should propagate processing errors
1171
+ with pytest.raises(Exception, match="Processing error"):
1172
+ GCPScannerIntegration.sync_assets(plan_id=test_identifiers["plan_id"])
1173
+
1174
+ def test_sync_assets_empty_assets_response(self, mocker, test_identifiers):
1175
+ """Test sync_assets when no assets are returned."""
1176
+ # Mock parent class sync_assets to return 0 for empty response
1177
+ mock_parent_sync = mocker.patch(
1178
+ "regscale.integrations.scanner_integration.ScannerIntegration.sync_assets", return_value=0
1179
+ )
1180
+
1181
+ result = GCPScannerIntegration.sync_assets(plan_id=test_identifiers["plan_id"])
1182
+
1183
+ # Should call parent method and return 0 for no assets
1184
+ mock_parent_sync.assert_called_once_with(plan_id=test_identifiers["plan_id"])
1185
+ assert result == 0
1186
+
1187
+ def test_sync_assets_asset_processing_statistics(self, mocker, test_identifiers):
1188
+ """Test sync_assets processes all returned assets."""
1189
+ # Mock parent class sync_assets to return count of processed assets
1190
+ asset_count = 3
1191
+ mock_parent_sync = mocker.patch(
1192
+ "regscale.integrations.scanner_integration.ScannerIntegration.sync_assets", return_value=asset_count
1193
+ )
1194
+
1195
+ result = GCPScannerIntegration.sync_assets(plan_id=test_identifiers["plan_id"])
1196
+
1197
+ # Should call parent method and return asset count
1198
+ mock_parent_sync.assert_called_once_with(plan_id=test_identifiers["plan_id"])
1199
+ assert result == asset_count
1200
+
1201
+ def test_sync_assets_kwargs_parameter_handling(self, mocker, test_identifiers):
1202
+ """Test sync_assets accepts and handles kwargs properly."""
1203
+ # Mock parent class sync_assets to accept kwargs
1204
+ mock_parent_sync = mocker.patch(
1205
+ "regscale.integrations.scanner_integration.ScannerIntegration.sync_assets", return_value=0
1206
+ )
1207
+
1208
+ # Should not raise error when called with extra kwargs
1209
+ result = GCPScannerIntegration.sync_assets(
1210
+ plan_id=test_identifiers["plan_id"], extra_param="test", another_param=123
1211
+ )
1212
+
1213
+ # Should call parent method with kwargs
1214
+ mock_parent_sync.assert_called_once_with(
1215
+ plan_id=test_identifiers["plan_id"], extra_param="test", another_param=123
1216
+ )
1217
+ assert result == 0
1218
+
1219
+ def test_sync_assets_integration_with_parent_class(self, mocker, test_identifiers):
1220
+ """Test sync_assets properly inherits from parent ScannerIntegration."""
1221
+ # Mock the parent class method to return a simple value
1222
+ mock_parent_sync = mocker.patch(
1223
+ "regscale.integrations.scanner_integration.ScannerIntegration.sync_assets", return_value=0
1224
+ )
1225
+
1226
+ # Call the classmethod properly (not instance method)
1227
+ result = GCPScannerIntegration.sync_assets(plan_id=test_identifiers["plan_id"])
1228
+
1229
+ # Should call parent class sync_assets method
1230
+ mock_parent_sync.assert_called_once_with(plan_id=test_identifiers["plan_id"])
1231
+ assert result == 0
1232
+
1233
+
1234
+ class TestGCPConfiguration:
1235
+ """Test suite for GCP configuration and class-level attributes."""
1236
+
1237
+ def test_finding_severity_map_all_mappings(self):
1238
+ """Test finding_severity_map contains all expected mappings."""
1239
+ expected_mappings = {
1240
+ 0: regscale_models.IssueSeverity.Low,
1241
+ 1: regscale_models.IssueSeverity.Critical,
1242
+ 2: regscale_models.IssueSeverity.High,
1243
+ 3: regscale_models.IssueSeverity.Moderate,
1244
+ 4: regscale_models.IssueSeverity.Low,
1245
+ }
1246
+
1247
+ assert GCPScannerIntegration.finding_severity_map == expected_mappings
1248
+
1249
+ def test_finding_severity_map_unknown_values(self):
1250
+ """Test finding_severity_map handles unknown severity values."""
1251
+ # The .get() method should default to Low for unknown values
1252
+ default_severity = GCPScannerIntegration.finding_severity_map.get(999, regscale_models.IssueSeverity.Low)
1253
+ assert default_severity == regscale_models.IssueSeverity.Low
1254
+
1255
+ def test_asset_identifier_field_value(self):
1256
+ """Test asset_identifier_field is set correctly."""
1257
+ assert GCPScannerIntegration.asset_identifier_field == "googleIdentifier"
1258
+
1259
+ def test_title_property_value(self):
1260
+ """Test title property value is correct."""
1261
+ assert GCPScannerIntegration.title == "GCP Scanner Integration"
1262
+
1263
+ def test_gcp_control_tests_initialization(self):
1264
+ """Test gcp_control_tests is properly initialized."""
1265
+ # Should be empty dict by default (gets populated during runtime)
1266
+ assert isinstance(GCPScannerIntegration.gcp_control_tests, dict)
1267
+
1268
+ def test_class_inheritance(self):
1269
+ """Test GCPScannerIntegration properly inherits from ScannerIntegration."""
1270
+ from regscale.integrations.scanner_integration import ScannerIntegration
1271
+
1272
+ assert issubclass(GCPScannerIntegration, ScannerIntegration)
1273
+
1274
+ # Test that it inherits key methods
1275
+ assert hasattr(GCPScannerIntegration, "sync_findings")
1276
+ assert hasattr(GCPScannerIntegration, "sync_assets")
1277
+ assert hasattr(GCPScannerIntegration, "process_asset")
1278
+
1279
+
1280
+ class TestGCPAuthentication:
1281
+ """Test suite for GCP authentication functions."""
1282
+
1283
+ @pytest.fixture(autouse=True)
1284
+ def setup_auth_test_isolation(self, mocker):
1285
+ """Ensure each auth test runs in isolation with mocked dependencies."""
1286
+ # Mock GCP variables to avoid external dependencies
1287
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project-123")
1288
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpOrganizationId", "test-org-12345")
1289
+ mocker.patch(
1290
+ "regscale.integrations.commercial.gcp.variables.GcpVariables.gcpCredentials", "test/path/credentials.json"
1291
+ )
1292
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpScanType", "project")
1293
+
1294
+ def test_ensure_gcp_credentials_sets_environment(self, mocker):
1295
+ """Test ensure_gcp_credentials sets environment variable when not present."""
1296
+
1297
+ # Mock environment without GOOGLE_APPLICATION_CREDENTIALS
1298
+ mock_environ = {}
1299
+ mocker.patch.dict("os.environ", mock_environ, clear=True)
1300
+
1301
+ # Mock the entire ensure_gcp_credentials function to simulate its behavior
1302
+ def mock_ensure_credentials():
1303
+ if not mock_environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
1304
+ mock_environ["GOOGLE_APPLICATION_CREDENTIALS"] = "test/path/creds.json"
1305
+
1306
+ mocker.patch(
1307
+ "regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials", side_effect=mock_ensure_credentials
1308
+ )
1309
+
1310
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_credentials
1311
+
1312
+ # Verify initial state - env var should not exist
1313
+ assert "GOOGLE_APPLICATION_CREDENTIALS" not in mock_environ
1314
+
1315
+ ensure_gcp_credentials()
1316
+
1317
+ # Now it should be set in our mock environment
1318
+ assert "GOOGLE_APPLICATION_CREDENTIALS" in mock_environ
1319
+ assert mock_environ["GOOGLE_APPLICATION_CREDENTIALS"] == "test/path/creds.json"
1320
+
1321
+ def test_ensure_gcp_credentials_existing_environment(self, mocker):
1322
+ """Test ensure_gcp_credentials doesn't override existing credentials."""
1323
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_credentials
1324
+
1325
+ existing_path = "/existing/path/credentials.json"
1326
+ mock_environ = {"GOOGLE_APPLICATION_CREDENTIALS": existing_path}
1327
+ mocker.patch.dict("os.environ", mock_environ, clear=True)
1328
+
1329
+ ensure_gcp_credentials()
1330
+
1331
+ # Should not change existing value
1332
+ assert mock_environ["GOOGLE_APPLICATION_CREDENTIALS"] == existing_path
1333
+
1334
+ def test_ensure_gcp_api_enabled_success(self, mocker):
1335
+ """Test ensure_gcp_api_enabled with enabled API."""
1336
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_api_enabled
1337
+
1338
+ # Mock successful API check
1339
+ mock_service = Mock()
1340
+ mock_response = {"state": "ENABLED"}
1341
+ mock_request = Mock()
1342
+ mock_request.execute.return_value = mock_response
1343
+ mock_service.services.return_value.get.return_value = mock_request
1344
+
1345
+ mock_build = mocker.patch("googleapiclient.discovery.build", return_value=mock_service)
1346
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1347
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project")
1348
+
1349
+ # Should not raise exception
1350
+ ensure_gcp_api_enabled("test-service.googleapis.com")
1351
+
1352
+ mock_build.assert_called_once_with("serviceusage", "v1")
1353
+
1354
+ def test_ensure_gcp_api_enabled_api_disabled_exits(self, mocker):
1355
+ """Test ensure_gcp_api_enabled exits when API is disabled."""
1356
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_api_enabled
1357
+
1358
+ # Mock disabled API response
1359
+ mock_service = Mock()
1360
+ mock_response = {"state": "DISABLED"}
1361
+ mock_request = Mock()
1362
+ mock_request.execute.return_value = mock_response
1363
+ mock_service.services.return_value.get.return_value = mock_request
1364
+
1365
+ mocker.patch("googleapiclient.discovery.build", return_value=mock_service)
1366
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1367
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project")
1368
+ mock_exit = mocker.patch("regscale.integrations.commercial.gcp.auth.error_and_exit")
1369
+
1370
+ ensure_gcp_api_enabled("test-service.googleapis.com")
1371
+
1372
+ mock_exit.assert_called_once()
1373
+
1374
+ def test_ensure_gcp_api_enabled_authentication_error(self, mocker):
1375
+ """Test ensure_gcp_api_enabled handles authentication errors."""
1376
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_api_enabled
1377
+ from google.auth.exceptions import GoogleAuthError
1378
+
1379
+ # Mock authentication error
1380
+ mocker.patch("googleapiclient.discovery.build", side_effect=GoogleAuthError("Auth failed"))
1381
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1382
+
1383
+ with pytest.raises(RuntimeError, match="Authentication error"):
1384
+ ensure_gcp_api_enabled("test-service.googleapis.com")
1385
+
1386
+ def test_ensure_gcp_api_enabled_general_exception(self, mocker):
1387
+ """Test ensure_gcp_api_enabled handles general exceptions."""
1388
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_api_enabled
1389
+
1390
+ # Mock general exception
1391
+ mocker.patch("googleapiclient.discovery.build", side_effect=Exception("General error"))
1392
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1393
+
1394
+ with pytest.raises(RuntimeError, match="An error occurred"):
1395
+ ensure_gcp_api_enabled("test-service.googleapis.com")
1396
+
1397
+ def test_get_gcp_security_center_client_success(self, mocker):
1398
+ """Test get_gcp_security_center_client returns client successfully."""
1399
+ from regscale.integrations.commercial.gcp.auth import get_gcp_security_center_client
1400
+
1401
+ # Mock successful client creation
1402
+ mock_client = Mock()
1403
+ mock_security_center = mocker.patch(
1404
+ "google.cloud.securitycenter.SecurityCenterClient", return_value=mock_client
1405
+ )
1406
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled")
1407
+
1408
+ result = get_gcp_security_center_client()
1409
+
1410
+ assert result == mock_client
1411
+ mock_security_center.assert_called_once()
1412
+
1413
+ def test_get_gcp_security_center_client_auth_error(self, mocker):
1414
+ """Test get_gcp_security_center_client handles authentication errors."""
1415
+ from regscale.integrations.commercial.gcp.auth import get_gcp_security_center_client
1416
+ from google.auth.exceptions import DefaultCredentialsError
1417
+
1418
+ # Mock authentication error during client creation
1419
+ mocker.patch(
1420
+ "google.cloud.securitycenter.SecurityCenterClient", side_effect=DefaultCredentialsError("No creds")
1421
+ )
1422
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled")
1423
+
1424
+ with pytest.raises(DefaultCredentialsError):
1425
+ get_gcp_security_center_client()
1426
+
1427
+ def test_get_gcp_asset_service_client_success(self, mocker):
1428
+ """Test get_gcp_asset_service_client returns client successfully."""
1429
+ from regscale.integrations.commercial.gcp.auth import get_gcp_asset_service_client
1430
+
1431
+ # Mock successful client creation
1432
+ mock_client = Mock()
1433
+ mock_asset_client = mocker.patch("google.cloud.asset_v1.AssetServiceClient", return_value=mock_client)
1434
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled")
1435
+
1436
+ result = get_gcp_asset_service_client()
1437
+
1438
+ assert result == mock_client
1439
+ mock_asset_client.assert_called_once()
1440
+
1441
+ def test_get_gcp_asset_service_client_auth_error(self, mocker):
1442
+ """Test get_gcp_asset_service_client handles authentication errors."""
1443
+ from regscale.integrations.commercial.gcp.auth import get_gcp_asset_service_client
1444
+ from google.auth.exceptions import DefaultCredentialsError
1445
+
1446
+ # Mock authentication error during client creation
1447
+ mocker.patch("google.cloud.asset_v1.AssetServiceClient", side_effect=DefaultCredentialsError("No creds"))
1448
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_api_enabled")
1449
+
1450
+ with pytest.raises(DefaultCredentialsError):
1451
+ get_gcp_asset_service_client()
1452
+
1453
+ def test_ensure_security_center_api_enabled_system_call(self, mocker):
1454
+ """Test ensure_security_center_api_enabled makes correct system call."""
1455
+ from regscale.integrations.commercial.gcp.auth import ensure_security_center_api_enabled
1456
+
1457
+ mock_system = mocker.patch("os.system")
1458
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1459
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project-123")
1460
+
1461
+ ensure_security_center_api_enabled()
1462
+
1463
+ expected_command = "gcloud services enable securitycenter.googleapis.com --project test-project-123"
1464
+ mock_system.assert_called_once_with(expected_command)
1465
+
1466
+ def test_api_enablement_project_id_usage(self, mocker):
1467
+ """Test API enablement functions use correct project ID."""
1468
+ from regscale.integrations.commercial.gcp.auth import ensure_gcp_api_enabled
1469
+
1470
+ test_project_id = "custom-test-project-456"
1471
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", test_project_id)
1472
+ mocker.patch("regscale.integrations.commercial.gcp.auth.ensure_gcp_credentials")
1473
+
1474
+ # Mock service to capture project ID usage
1475
+ mock_service = Mock()
1476
+ mock_response = {"state": "ENABLED"}
1477
+ mock_request = Mock()
1478
+ mock_request.execute.return_value = mock_response
1479
+ mock_service.services.return_value.get.return_value = mock_request
1480
+ mocker.patch("googleapiclient.discovery.build", return_value=mock_service)
1481
+
1482
+ ensure_gcp_api_enabled("test-service.googleapis.com")
1483
+
1484
+ # Verify project ID was used in the API call
1485
+ mock_service.services.return_value.get.assert_called_once_with(
1486
+ name=f"projects/{test_project_id}/services/test-service.googleapis.com"
1487
+ )
1488
+
1489
+
1490
+ class TestGCPVariables:
1491
+ """Test suite for GCP variables configuration."""
1492
+
1493
+ @pytest.fixture(autouse=True)
1494
+ def setup_variables_test_isolation(self, mocker):
1495
+ """Ensure each variables test runs in isolation with mocked dependencies."""
1496
+ # Mock GCP variables to avoid external dependencies
1497
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpProjectId", "test-project-123")
1498
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpOrganizationId", "test-org-12345")
1499
+ mocker.patch(
1500
+ "regscale.integrations.commercial.gcp.variables.GcpVariables.gcpCredentials", "test/path/credentials.json"
1501
+ )
1502
+ mocker.patch("regscale.integrations.commercial.gcp.variables.GcpVariables.gcpScanType", "project")
1503
+
1504
+ def test_gcp_variables_class_structure(self):
1505
+ """Test GcpVariables class has correct structure."""
1506
+ from regscale.integrations.commercial.gcp.variables import GcpVariables
1507
+
1508
+ # Should have class-level attributes
1509
+ assert hasattr(GcpVariables, "gcpProjectId")
1510
+ assert hasattr(GcpVariables, "gcpOrganizationId")
1511
+ assert hasattr(GcpVariables, "gcpScanType")
1512
+ assert hasattr(GcpVariables, "gcpCredentials")
1513
+
1514
+ def test_gcp_variables_required_fields(self):
1515
+ """Test GcpVariables has all required configuration fields."""
1516
+ from regscale.integrations.commercial.gcp.variables import GcpVariables
1517
+
1518
+ # Verify key variables exist and are accessible
1519
+ required_vars = ["gcpProjectId", "gcpOrganizationId", "gcpScanType", "gcpCredentials"]
1520
+
1521
+ for var_name in required_vars:
1522
+ assert hasattr(GcpVariables, var_name), f"Missing required variable: {var_name}"
1523
+
1524
+ def test_gcp_variables_default_values(self):
1525
+ """Test GcpVariables provides sensible default/example values."""
1526
+ from regscale.integrations.commercial.gcp.variables import GcpVariables
1527
+
1528
+ # These should be example values, not None
1529
+ # Note: We can't directly access the values due to the metaclass,
1530
+ # but we can verify the attributes exist
1531
+ assert hasattr(GcpVariables, "gcpProjectId")
1532
+ assert hasattr(GcpVariables, "gcpOrganizationId")
1533
+ assert hasattr(GcpVariables, "gcpScanType")
1534
+ assert hasattr(GcpVariables, "gcpCredentials")
1535
+
1536
+ def test_gcp_variables_type_annotations(self):
1537
+ """Test GcpVariables uses RsVariableType for type annotations."""
1538
+ from regscale.integrations.commercial.gcp.variables import GcpVariables
1539
+ from regscale.core.app.utils.variables import RsVariablesMeta
1540
+
1541
+ # Should use RsVariablesMeta metaclass
1542
+ assert isinstance(GcpVariables, type)
1543
+ assert isinstance(GcpVariables, RsVariablesMeta)