diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 1973b5df0a..f8219358f6 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.25.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Oracle cloud identity scans now scan known or supplied regions to better support non ashburn tenancies [(#10528)](https://github.com/prowler-cloud/prowler/pull/10529) + +--- + ## [5.25.2] (Prowler v5.25.2) ### 🐞 Fixed diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index c5abaf3929..22fa10b276 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -66,6 +66,7 @@ class OraclecloudProvider(Provider): _compartments: list = [] _mutelist: OCIMutelist audit_metadata: Audit_Metadata + _home_region: str = "us-ashburn-1" def __init__( self, @@ -160,6 +161,13 @@ class OraclecloudProvider(Provider): # Get regions self._regions = self.get_regions_to_audit(region) + self._home_region = None + if self._regions: + self._home_region = next( + (region.key for region in self._regions if region.is_home_region), + self._regions[0].key, + ) + logger.info(f"Home region is: {self._home_region}") # Get compartments self._compartments = self.get_compartments_to_audit( @@ -217,6 +225,10 @@ class OraclecloudProvider(Provider): def regions(self): return self._regions + @property + def home_region(self): + return self._home_region + @property def compartments(self): return self._compartments diff --git a/prowler/providers/oraclecloud/services/identity/identity_service.py b/prowler/providers/oraclecloud/services/identity/identity_service.py index a0932bd54f..d36ed5c0ac 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_service.py +++ b/prowler/providers/oraclecloud/services/identity/identity_service.py @@ -1,6 +1,7 @@ """OCI Identity Service Module.""" from datetime import datetime +from threading import Lock from typing import Optional import oci @@ -26,6 +27,7 @@ class Identity(OCIService): self.policies = [] self.dynamic_groups = [] self.domains = [] + self._domains_lock = Lock() self.password_policy = None self.root_compartment_resources = [] self.active_non_root_compartments = [] @@ -61,8 +63,8 @@ class Identity(OCIService): regional_client: Regional OCI client """ try: - # Identity is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global users + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -312,7 +314,8 @@ class Identity(OCIService): def __list_groups__(self, regional_client): """List all IAM groups.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global groups + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -355,7 +358,8 @@ class Identity(OCIService): def __list_policies__(self, regional_client): """List all IAM policies.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global policies + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -399,8 +403,8 @@ class Identity(OCIService): def __list_dynamic_groups__(self, regional_client): """List all dynamic groups in the tenancy.""" try: - # Dynamic groups are only in the home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global dynamic groups + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -447,10 +451,6 @@ class Identity(OCIService): def __list_domains__(self, regional_client): """List all identity domains.""" try: - # Domains are only in the home region - if regional_client.region not in self.provider.identity.region: - return - identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Identity Domains...") @@ -458,6 +458,7 @@ class Identity(OCIService): try: # List all domains in the tenancy for compartment in self.audited_compartments: + domains = oci.pagination.list_call_get_all_results( identity_client.list_domains, compartment_id=compartment.id, @@ -465,20 +466,38 @@ class Identity(OCIService): ).data for domain in domains: - self.domains.append( - IdentityDomain( - id=domain.id, - display_name=domain.display_name, - description=domain.description or "", - url=domain.url, - home_region=domain.home_region, - compartment_id=compartment.id, - lifecycle_state=domain.lifecycle_state, - time_created=domain.time_created, - region=regional_client.region, - password_policies=[], + + # Threads run __list_domains__ concurrently per + # region; serialize the dedupe-then-append so two + # regions returning the same domain cannot race + # past each other and produce duplicates or lose + # the home-region preference. + with self._domains_lock: + existing = next( + (d for d in self.domains if d.id == domain.id), + None, + ) + if existing is not None: + # Prefer the entry from the domain's home region + if domain.home_region == regional_client.region: + self.domains.remove(existing) + else: + continue + + self.domains.append( + IdentityDomain( + id=domain.id, + display_name=domain.display_name, + description=domain.description or "", + url=domain.url, + home_region=domain.home_region, + compartment_id=compartment.id, + lifecycle_state=domain.lifecycle_state, + time_created=domain.time_created, + region=regional_client.region, + password_policies=[], + ) ) - ) except Exception as error: logger.error( @@ -493,8 +512,8 @@ class Identity(OCIService): def __list_domain_password_policies__(self, regional_client): """List password policies for all identity domains.""" try: - # Password policies are only in the home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for all domain scan + if regional_client.region != self.provider.home_region: return logger.info("Identity - Listing Domain Password Policies...") @@ -551,7 +570,8 @@ class Identity(OCIService): def __get_password_policy__(self, regional_client): """Get the password policy for the tenancy.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global password policies + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -578,8 +598,8 @@ class Identity(OCIService): def __search_root_compartment_resources__(self, regional_client): """Search for resources in the root compartment using OCI Resource Search.""" try: - # Search is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global search + if regional_client.region != self.provider.home_region: return logger.info("Identity - Searching for resources in root compartment...") @@ -626,10 +646,9 @@ class Identity(OCIService): def __search_active_non_root_compartments__(self, regional_client): """Search for active non-root compartments using OCI Resource Search.""" try: - # Search is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global search + if regional_client.region != self.provider.home_region: return - logger.info("Identity - Searching for active non-root compartments...") # Create search client using the helper method for proper authentication diff --git a/tests/providers/oraclecloud/oci_fixtures.py b/tests/providers/oraclecloud/oci_fixtures.py index b1ebef62d9..5d01bc5855 100644 --- a/tests/providers/oraclecloud/oci_fixtures.py +++ b/tests/providers/oraclecloud/oci_fixtures.py @@ -37,6 +37,7 @@ def set_mocked_oraclecloud_provider( signer=MagicMock(), profile="DEFAULT", ) + provider.home_region = region # Mock identity provider.identity = OCIIdentityInfo( diff --git a/tests/providers/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index dd3b7b7d27..8f162ba232 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -6,7 +6,7 @@ from prowler.providers.oraclecloud.exceptions.exceptions import ( OCIAuthenticationError, OCIInvalidConfigError, ) -from prowler.providers.oraclecloud.models import OCISession +from prowler.providers.oraclecloud.models import OCIIdentityInfo, OCIRegion, OCISession from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider @@ -199,3 +199,59 @@ MIIEpQIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8n0sMcD/QHWCJ7yGSEtLN2T ) assert connection.is_connected is True + + +class TestOraclecloudProviderInit: + """Tests for OraclecloudProvider initialization""" + + def test_init_with_region_set_populates_provider_state(self): + mock_session = OCISession( + config={"region": "us-ashburn-1"}, signer=None, profile="DEFAULT" + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="us-ashburn-1", + profile="DEFAULT", + audited_regions=set(), + audited_compartments=[], + ) + mock_regions = [ + OCIRegion(key="us-phoenix-1", name="us-phoenix-1", is_home_region=False), + OCIRegion(key="us-ashburn-1", name="us-ashburn-1", is_home_region=True), + ] + mock_compartments = ["ocid1.compartment.oc1..aaaaaaaexample"] + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ) as mock_setup_session, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + return_value=mock_regions, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=mock_compartments, + ), + patch( + "prowler.providers.common.provider.Provider.set_global_provider" + ) as mock_set_global, + ): + provider = OraclecloudProvider( + region={"us-ashburn-1"}, + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + assert mock_setup_session.call_args.kwargs["region"] == "us-ashburn-1" + assert provider.session == mock_session + assert provider.identity == mock_identity + assert provider.regions == mock_regions + assert provider.compartments == mock_compartments + assert provider.home_region == "us-ashburn-1" + mock_set_global.assert_called_once_with(provider) diff --git a/tests/providers/oraclecloud/services/identity/identity_service_test.py b/tests/providers/oraclecloud/services/identity/identity_service_test.py index bb695d7128..338a025d52 100644 --- a/tests/providers/oraclecloud/services/identity/identity_service_test.py +++ b/tests/providers/oraclecloud/services/identity/identity_service_test.py @@ -1,4 +1,7 @@ -from unittest.mock import patch +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from threading import Lock +from unittest.mock import MagicMock, patch from tests.providers.oraclecloud.oci_fixtures import set_mocked_oraclecloud_provider @@ -28,3 +31,184 @@ class TestIdentityService: # Verify service name assert identity_client.service == "identity" assert identity_client.provider == oraclecloud_provider + + def test_list_domains_passwords_skipped_outside_home(self): + """Domains should be skipped when not in home region.""" + with patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__init__", + return_value=None, + ): + from prowler.providers.oraclecloud.services.identity.identity_service import ( + Identity, + ) + + identity_client = Identity(None) + identity_client.service = "identity" + identity_client.provider = set_mocked_oraclecloud_provider() + identity_client.provider._home_region = "us-ashburn-1" + identity_client.audited_compartments = [ + MagicMock(id="ocid1.compartment.oc1..aaaaaaaexample") + ] + identity_client.domains = [] + identity_client._domains_lock = Lock() + identity_client.session_signer = None + identity_client.session_config = None + regional_client_ash = MagicMock() + regional_client_ash.region = "us-ashburn-1" + regional_client_chi = MagicMock() + regional_client_chi.region = "us-chicago-1" + + policy = MagicMock() + policy.id = "123" + policy.name = "Test Policy" + policy.description = "This is a test policy" + policy.min_length = 8 + policy.password_expires_after = 90 + policy.num_passwords_in_history = 5 + policy.password_expire_warning = 7 + policy.min_password_age = 1 + + domains = [] + for region in ["us-phoenix-1", "us-ashburn-1", "us-chicago-1"]: + domain = MagicMock() + domain.id = ( + "ocid1.domain.oc1.iad.aaaaaaaaexampleuniqueID" + if region == "us-chicago-1" + else "ocid1.domain.oc1.iad.aaaaaaaaexampleuniqueID2" + ) + domain.display_name = "exampledomain" + domain.description = "example" + domain.url = "https://idcs-example.identity.oraclecloud.com" + domain.home_region = region + domain.region = "us-ashburn-1" + domain.lifecycle_state = "ACTIVE" + domain.time_created = datetime.now() + domains.append(domain) + with ( + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__get_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.pagination.list_call_get_all_results", + return_value=MagicMock(data=domains), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.identity_domains.IdentityDomainsClient", + return_value=MagicMock( + list_password_policies=lambda: MagicMock( + data=MagicMock(resources=[policy]) + ) + ), + ), + ): + identity_client.__list_domains__(regional_client_ash) + identity_client.__list_domains__(regional_client_chi) + identity_client.__list_domain_password_policies__(regional_client_ash) + identity_client.__list_domain_password_policies__(regional_client_chi) + + assert ( + len(identity_client.domains) == 2 + and any( + domain.home_region == "us-ashburn-1" + and domain.region == "us-ashburn-1" + for domain in identity_client.domains + ) + and any( + domain.home_region == "us-chicago-1" + and domain.region == "us-chicago-1" + for domain in identity_client.domains + ) + and all(len(d.password_policies) == 1 for d in identity_client.domains) + ) + + def test_list_domains_concurrent_dedupes_and_prefers_home_region(self): + """__list_domains__ runs across regions in parallel; the dedupe + must stay correct under concurrent calls (no duplicates, home + region wins).""" + with patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__init__", + return_value=None, + ): + from prowler.providers.oraclecloud.services.identity.identity_service import ( + Identity, + ) + + identity_client = Identity(None) + identity_client.service = "identity" + identity_client.provider = set_mocked_oraclecloud_provider() + identity_client.audited_compartments = [ + MagicMock(id="ocid1.compartment.oc1..aaaaaaaexample") + ] + identity_client.domains = [] + identity_client._domains_lock = Lock() + identity_client.session_signer = None + identity_client.session_config = None + + regions = [ + "us-ashburn-1", + "us-chicago-1", + "us-phoenix-1", + "eu-frankfurt-1", + ] + home_region_by_domain = { + "ocid1.domain.oc1..domainA": "us-ashburn-1", + "ocid1.domain.oc1..domainB": "us-chicago-1", + "ocid1.domain.oc1..domainC": "eu-frankfurt-1", + } + + # Each region returns the same set of domains (every domain + # is visible from every region; only one of those regions is + # actually the domain's home region). + def make_domains_for_region(_region): + ds = [] + for domain_id, home_region in home_region_by_domain.items(): + d = MagicMock() + d.id = domain_id + d.display_name = f"name-{domain_id}" + d.description = "" + d.url = "https://example.identity.oraclecloud.com" + d.home_region = home_region + d.lifecycle_state = "ACTIVE" + d.time_created = datetime.now() + ds.append(d) + return MagicMock(data=ds) + + regional_clients = [] + for region in regions: + rc = MagicMock() + rc.region = region + regional_clients.append(rc) + + with ( + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__get_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.pagination.list_call_get_all_results", + side_effect=lambda _list_call, compartment_id, lifecycle_state: make_domains_for_region( + compartment_id + ), + ), + ): + # Run several iterations to make any race more likely + # to surface; with the lock removed this loop fails + # frequently with duplicates. + for _ in range(20): + identity_client.domains = [] + with ThreadPoolExecutor( + max_workers=len(regional_clients) + ) as executor: + futures = [ + executor.submit(identity_client.__list_domains__, rc) + for rc in regional_clients + ] + for f in futures: + f.result() + + assert len(identity_client.domains) == len(home_region_by_domain) + by_id = {d.id: d for d in identity_client.domains} + for domain_id, home_region in home_region_by_domain.items(): + assert by_id[domain_id].region == home_region + assert by_id[domain_id].home_region == home_region