chore(openstack): support multi-region in the same provider (#10135)

This commit is contained in:
Daniel Barranquero
2026-02-24 12:50:52 +01:00
committed by GitHub
parent 61076c755f
commit 030d053c84
12 changed files with 1192 additions and 130 deletions

View File

@@ -216,10 +216,8 @@ def get_prowler_provider_kwargs(
"filter_accounts": [provider.uid], "filter_accounts": [provider.uid],
} }
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
# No extra kwargs needed: clouds_yaml_content and clouds_yaml_cloud from the # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
# secret are sufficient. Validating project_id (provider.uid) against the # in the provider itself, so it's not needed here.
# clouds.yaml is not feasible because not all auth methods include it and the
# Keystone API is unavailable on public clouds.
pass pass
if mutelist_processor: if mutelist_processor:
@@ -294,6 +292,7 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
openstack_kwargs = { openstack_kwargs = {
"clouds_yaml_content": prowler_provider_kwargs["clouds_yaml_content"], "clouds_yaml_content": prowler_provider_kwargs["clouds_yaml_content"],
"clouds_yaml_cloud": prowler_provider_kwargs["clouds_yaml_cloud"], "clouds_yaml_cloud": prowler_provider_kwargs["clouds_yaml_cloud"],
"provider_id": provider.uid,
"raise_on_exception": False, "raise_on_exception": False,
} }
return prowler_provider.test_connection(**openstack_kwargs) return prowler_provider.test_connection(**openstack_kwargs)

View File

@@ -337,6 +337,99 @@ prowler openstack --clouds-yaml-cloud ovh-staging --output-directory ./reports/o
prowler openstack --clouds-yaml-cloud infomaniak-production --output-directory ./reports/infomaniak/ prowler openstack --clouds-yaml-cloud infomaniak-production --output-directory ./reports/infomaniak/
``` ```
## Multi-Region Scanning
Many OpenStack providers (OVH, Infomaniak, etc.) offer resources across multiple regions within the same project. By default, the `clouds.yaml` file downloaded from Horizon uses `region_name` which targets a **single region**. Prowler supports scanning **all regions** in a single run by using the `regions` key instead.
### Configuring Multi-Region
Replace the `region_name` key with a `regions` list in your `clouds.yaml`:
```yaml
clouds:
ovh-multiregion:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
regions:
- UK1
- DE1
identity_api_version: "3"
```
Then run Prowler as usual:
```bash
prowler openstack --clouds-yaml-cloud ovh-multiregion
```
Prowler will create a separate connection to each region and scan all resources across them. Findings in the output will include the region where each resource was found.
<Warning>
You must use **either** `region_name` (single region) **or** `regions` (multi-region), not both. Prowler will raise an error if both keys are present in the same cloud configuration.
</Warning>
### How It Works
The `region_name` and `regions` keys are part of the [OpenStack SDK configuration format](https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html#site-specific-file-locations). When `regions` is set, the SDK can produce a separate cloud config object for each region — but it does not iterate over them automatically. Prowler uses this to create one authenticated connection per region and iterates over all of them when listing resources. This means:
- **Authentication** is tested against every configured region during connection setup
- **Resources** from all regions are collected in a single scan
- **Findings** include the specific region for each resource
- If a single region fails to connect, the entire scan fails (fail-fast)
### Finding Your Available Regions
To discover which regions are available for your project, use the OpenStack CLI:
```bash
openstack --os-cloud your-cloud region list
```
Or check your provider's control panel for a list of available regions.
### Single-Region vs Multi-Region
| Configuration | Key | Behavior |
|--------------|-----|----------|
| Single region | `region_name: UK1` | Scans resources in UK1 only |
| Multi-region | `regions: [UK1, DE1]` | Scans resources in both UK1 and DE1 |
You can keep both configurations as separate cloud entries in the same `clouds.yaml` file:
```yaml
clouds:
# Single region entry — targets UK1 only
ovh:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
region_name: UK1
identity_api_version: "3"
# Multi-region entry — targets UK1 and DE1
ovh-multiregion:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
regions:
- UK1
- DE1
identity_api_version: "3"
```
## Creating a User With Reader Role ## Creating a User With Reader Role
For security auditing, Prowler only needs **read-only access** to your OpenStack resources. For security auditing, Prowler only needs **read-only access** to your OpenStack resources.
@@ -534,3 +627,4 @@ Using Public Cloud credentials can limit Keystone API access, so the command abo
- [OpenStack Documentation](https://docs.openstack.org/) - [OpenStack Documentation](https://docs.openstack.org/)
- [OpenStack Security Guide](https://docs.openstack.org/security-guide/) - [OpenStack Security Guide](https://docs.openstack.org/security-guide/)
- [clouds.yaml Format](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html) - [clouds.yaml Format](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html)
- [OpenStack SDK Configuration (`region_name` / `regions`)](https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html#site-specific-file-locations)

View File

@@ -180,6 +180,36 @@ prowler openstack --clouds-yaml-cloud production --output-directory ./reports/pr
prowler openstack --clouds-yaml-cloud staging --output-directory ./reports/staging/ prowler openstack --clouds-yaml-cloud staging --output-directory ./reports/staging/
``` ```
**Scan all regions in a single run:**
If your OpenStack project spans multiple regions, replace `region_name` with a `regions` list in your `clouds.yaml`:
```yaml
clouds:
ovh-multiregion:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
regions:
- UK1
- DE1
identity_api_version: "3"
```
```bash
prowler openstack --clouds-yaml-cloud ovh-multiregion
```
Prowler will connect to each region and scan resources across all of them. See the [Authentication guide](/user-guide/providers/openstack/authentication#multi-region-scanning) for more details.
<Note>
You must use either `region_name` (single region) or `regions` (multi-region list), not both.
</Note>
**Use mutelist to suppress findings:** **Use mutelist to suppress findings:**
Create a mutelist file to suppress known findings: Create a mutelist file to suppress known findings:

View File

@@ -28,6 +28,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Registry scan mode for `image` provider: enumerate and scan all images from OCI standard, Docker Hub, and ECR [(#9985)](https://github.com/prowler-cloud/prowler/pull/9985) - Registry scan mode for `image` provider: enumerate and scan all images from OCI standard, Docker Hub, and ECR [(#9985)](https://github.com/prowler-cloud/prowler/pull/9985)
- Add file descriptor limits (`ulimits`) to Docker Compose worker services to prevent `Too many open files` errors [(#10107)](https://github.com/prowler-cloud/prowler/pull/10107) - Add file descriptor limits (`ulimits`) to Docker Compose worker services to prevent `Too many open files` errors [(#10107)](https://github.com/prowler-cloud/prowler/pull/10107)
- CIS 6.0 for the AWS provider [(#10127)](https://github.com/prowler-cloud/prowler/pull/10127) - CIS 6.0 for the AWS provider [(#10127)](https://github.com/prowler-cloud/prowler/pull/10127)
- OpenStack provider multiple regions support [(#10135)](https://github.com/prowler-cloud/prowler/pull/10135)
### 🐞 Fixed ### 🐞 Fixed

View File

@@ -42,6 +42,18 @@ class OpenStackBaseException(ProwlerException):
"message": "Invalid or malformed clouds.yaml configuration file", "message": "Invalid or malformed clouds.yaml configuration file",
"remediation": "Check that the clouds.yaml file is valid YAML and follows the OpenStack configuration format.", "remediation": "Check that the clouds.yaml file is valid YAML and follows the OpenStack configuration format.",
}, },
(10009, "OpenStackInvalidProviderIdError"): {
"message": "Provider ID does not match the project_id in clouds.yaml",
"remediation": "Ensure the provider_id matches the project_id configured in your clouds.yaml file.",
},
(10010, "OpenStackNoRegionError"): {
"message": "No region configuration found in clouds.yaml",
"remediation": "Add either 'region_name' (single region) or 'regions' (list of regions) to your cloud configuration in clouds.yaml.",
},
(10011, "OpenStackAmbiguousRegionError"): {
"message": "Ambiguous region configuration in clouds.yaml",
"remediation": "Use either 'region_name' or 'regions' in your cloud configuration, not both.",
},
} }
def __init__(self, code, file=None, original_exception=None, message=None): def __init__(self, code, file=None, original_exception=None, message=None):
@@ -164,3 +176,39 @@ class OpenStackInvalidConfigError(OpenStackBaseException):
original_exception=original_exception, original_exception=original_exception,
message=message, message=message,
) )
class OpenStackInvalidProviderIdError(OpenStackBaseException):
"""Exception for provider_id not matching project_id in clouds.yaml"""
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
code=10009,
file=file,
original_exception=original_exception,
message=message,
)
class OpenStackNoRegionError(OpenStackBaseException):
"""Exception for missing region configuration in clouds.yaml"""
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
code=10010,
file=file,
original_exception=original_exception,
message=message,
)
class OpenStackAmbiguousRegionError(OpenStackBaseException):
"""Exception for ambiguous region configuration in clouds.yaml"""
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
code=10011,
file=file,
original_exception=original_exception,
message=message,
)

View File

@@ -9,8 +9,14 @@ class OpenStackService:
self.service_name = service_name self.service_name = service_name
self.provider = provider self.provider = provider
self.connection = provider.connection self.connection = provider.connection
self.regional_connections = provider.regional_connections
self.audited_regions = list(provider.regional_connections.keys())
self.session = provider.session self.session = provider.session
self.region = provider.session.region_name self.region = (
provider.session.region_name
or ", ".join(provider.session.regions or [])
or "global"
)
self.project_id = provider.session.project_id self.project_id = provider.session.project_id
self.identity = provider.identity self.identity = provider.identity
self.audit_config = provider.audit_config self.audit_config = provider.audit_config

View File

@@ -1,5 +1,5 @@
import re import re
from typing import Optional from typing import List, Optional
from pydantic.v1 import BaseModel, Field from pydantic.v1 import BaseModel, Field
@@ -35,27 +35,42 @@ class OpenStackSession(BaseModel):
username: str username: str
password: str password: str
project_id: str project_id: str
region_name: str region_name: Optional[str] = None
regions: Optional[List[str]] = None
user_domain_name: str = Field(default="Default") user_domain_name: str = Field(default="Default")
project_domain_name: str = Field(default="Default") project_domain_name: str = Field(default="Default")
def as_sdk_config(self) -> dict: def as_sdk_config(self, region_override: Optional[str] = None) -> dict:
"""Return a dict compatible with openstacksdk.connect(). """Return a dict compatible with openstacksdk.connect().
Note: The OpenStack SDK distinguishes between project_id (must be UUID) Note: The OpenStack SDK distinguishes between project_id (must be UUID)
and project_name (any string identifier). We accept project_id from users and project_name (any string identifier). We accept project_id from users
but internally pass it as project_name to the SDK if it's not a UUID. but internally pass it as project_name to the SDK if it's not a UUID.
This allows compatibility with providers like OVH that use numeric IDs. This allows compatibility with providers like OVH that use numeric IDs.
When ``regions`` is set (multi-region), we pass the first region as
``region_name`` for the default connection. The SDK does **not**
iterate over a ``regions`` list automatically — callers must create
one connection per region via ``regional_connections``.
Args:
region_override: If provided, use this region instead of the
session's ``region_name`` / first entry in ``regions``.
""" """
config = { config = {
"auth_url": self.auth_url, "auth_url": self.auth_url,
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"region_name": self.region_name,
"project_domain_name": self.project_domain_name, "project_domain_name": self.project_domain_name,
"user_domain_name": self.user_domain_name, "user_domain_name": self.user_domain_name,
"identity_api_version": self.identity_api_version, "identity_api_version": self.identity_api_version,
} }
# Determine region: explicit override > session region_name > first in regions list
region = region_override or self.region_name
if region:
config["region_name"] = region
elif self.regions:
config["region_name"] = self.regions[0]
# If project_id is a UUID, pass it as project_id to SDK # If project_id is a UUID, pass it as project_id to SDK
# Otherwise, pass it as project_name (e.g., OVH numeric IDs) # Otherwise, pass it as project_name (e.g., OVH numeric IDs)
if _is_uuid(self.project_id): if _is_uuid(self.project_id):

View File

@@ -18,11 +18,14 @@ from prowler.lib.utils.utils import print_boxes
from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider from prowler.providers.common.provider import Provider
from prowler.providers.openstack.exceptions.exceptions import ( from prowler.providers.openstack.exceptions.exceptions import (
OpenStackAmbiguousRegionError,
OpenStackAuthenticationError, OpenStackAuthenticationError,
OpenStackCloudNotFoundError, OpenStackCloudNotFoundError,
OpenStackConfigFileNotFoundError, OpenStackConfigFileNotFoundError,
OpenStackCredentialsError, OpenStackCredentialsError,
OpenStackInvalidConfigError, OpenStackInvalidConfigError,
OpenStackInvalidProviderIdError,
OpenStackNoRegionError,
OpenStackSessionError, OpenStackSessionError,
) )
from prowler.providers.openstack.lib.mutelist.mutelist import OpenStackMutelist from prowler.providers.openstack.lib.mutelist.mutelist import OpenStackMutelist
@@ -81,7 +84,22 @@ class OpenstackProvider(Provider):
user_domain_name=user_domain_name, user_domain_name=user_domain_name,
project_domain_name=project_domain_name, project_domain_name=project_domain_name,
) )
self._connection = OpenstackProvider._create_connection(self._session)
# Build per-region connections. When ``regions`` is configured
# (multi-region clouds.yaml) we create one connection per region;
# otherwise a single connection is created.
if self._session.regions:
self._regional_connections: dict[str, OpenStackConnection] = {}
for region in self._session.regions:
self._regional_connections[region] = (
OpenstackProvider._create_connection(self._session, region=region)
)
# Default connection = first region (used for identity setup, etc.)
self._connection = next(iter(self._regional_connections.values()))
else:
self._connection = OpenstackProvider._create_connection(self._session)
self._regional_connections = {self._session.region_name: self._connection}
self._identity = OpenstackProvider.setup_identity( self._identity = OpenstackProvider.setup_identity(
self._connection, self._session self._connection, self._session
) )
@@ -132,6 +150,10 @@ class OpenstackProvider(Provider):
def connection(self) -> OpenStackConnection: def connection(self) -> OpenStackConnection:
return self._connection return self._connection
@property
def regional_connections(self) -> dict[str, OpenStackConnection]:
return self._regional_connections
@staticmethod @staticmethod
def setup_session( def setup_session(
clouds_yaml_file: Optional[str] = None, clouds_yaml_file: Optional[str] = None,
@@ -269,13 +291,27 @@ class OpenstackProvider(Provider):
message=f"Missing required fields in clouds.yaml for cloud '{clouds_yaml_cloud}': {', '.join(missing_fields)}", message=f"Missing required fields in clouds.yaml for cloud '{clouds_yaml_cloud}': {', '.join(missing_fields)}",
) )
# Validate region configuration: must have region_name XOR regions
region_name = cloud_config.get("region_name")
regions = cloud_config.get("regions")
if region_name and regions:
raise OpenStackAmbiguousRegionError(
message=f"Cloud '{clouds_yaml_cloud}' has both 'region_name' and 'regions' configured. Use one or the other.",
)
if not region_name and not regions:
raise OpenStackNoRegionError(
message=f"Cloud '{clouds_yaml_cloud}' has neither 'region_name' nor 'regions' configured. Add one to your clouds.yaml.",
)
return OpenStackSession( return OpenStackSession(
auth_url=auth_dict.get("auth_url"), auth_url=auth_dict.get("auth_url"),
identity_api_version=str(cloud_config.get("identity_api_version", "3")), identity_api_version=str(cloud_config.get("identity_api_version", "3")),
username=auth_dict.get("username"), username=auth_dict.get("username"),
password=auth_dict.get("password"), password=auth_dict.get("password"),
project_id=auth_dict.get("project_id") or auth_dict.get("project_name"), project_id=auth_dict.get("project_id") or auth_dict.get("project_name"),
region_name=cloud_config.get("region_name"), region_name=region_name,
regions=regions,
user_domain_name=auth_dict.get("user_domain_name", "Default"), user_domain_name=auth_dict.get("user_domain_name", "Default"),
project_domain_name=auth_dict.get("project_domain_name", "Default"), project_domain_name=auth_dict.get("project_domain_name", "Default"),
) )
@@ -360,6 +396,28 @@ class OpenstackProvider(Provider):
message=f"Missing required fields in clouds.yaml for cloud '{clouds_yaml_cloud}': {', '.join(missing_fields)}", message=f"Missing required fields in clouds.yaml for cloud '{clouds_yaml_cloud}': {', '.join(missing_fields)}",
) )
# Get raw cloud config to validate region configuration.
# cloud_config.config is the SDK-processed config (CloudRegion),
# which may not preserve the 'regions' key. os_config.cloud_config
# holds the original parsed YAML before SDK processing.
raw_cloud_config = os_config.cloud_config.get("clouds", {}).get(
clouds_yaml_cloud, {}
)
region_name = raw_cloud_config.get("region_name")
regions = raw_cloud_config.get("regions")
if region_name and regions:
raise OpenStackAmbiguousRegionError(
file=clouds_yaml_file,
message=f"Cloud '{clouds_yaml_cloud}' has both 'region_name' and 'regions' configured. Use one or the other.",
)
if not region_name and not regions:
raise OpenStackNoRegionError(
file=clouds_yaml_file,
message=f"Cloud '{clouds_yaml_cloud}' has neither 'region_name' nor 'regions' configured. Add one to your clouds.yaml.",
)
# Build OpenStackSession from cloud config # Build OpenStackSession from cloud config
return OpenStackSession( return OpenStackSession(
auth_url=auth_dict.get("auth_url"), auth_url=auth_dict.get("auth_url"),
@@ -369,7 +427,8 @@ class OpenstackProvider(Provider):
username=auth_dict.get("username"), username=auth_dict.get("username"),
password=auth_dict.get("password"), password=auth_dict.get("password"),
project_id=auth_dict.get("project_id") or auth_dict.get("project_name"), project_id=auth_dict.get("project_id") or auth_dict.get("project_name"),
region_name=cloud_config.config.get("region_name"), region_name=region_name,
regions=regions,
user_domain_name=auth_dict.get("user_domain_name", "Default"), user_domain_name=auth_dict.get("user_domain_name", "Default"),
project_domain_name=auth_dict.get("project_domain_name", "Default"), project_domain_name=auth_dict.get("project_domain_name", "Default"),
) )
@@ -378,6 +437,8 @@ class OpenstackProvider(Provider):
OpenStackConfigFileNotFoundError, OpenStackConfigFileNotFoundError,
OpenStackCloudNotFoundError, OpenStackCloudNotFoundError,
OpenStackInvalidConfigError, OpenStackInvalidConfigError,
OpenStackNoRegionError,
OpenStackAmbiguousRegionError,
): ):
# Re-raise our custom exceptions # Re-raise our custom exceptions
raise raise
@@ -395,6 +456,7 @@ class OpenstackProvider(Provider):
@staticmethod @staticmethod
def _create_connection( def _create_connection(
session: OpenStackSession, session: OpenStackSession,
region: str | None = None,
) -> OpenStackConnection: ) -> OpenStackConnection:
"""Initialize the OpenStack SDK connection. """Initialize the OpenStack SDK connection.
@@ -402,13 +464,18 @@ class OpenstackProvider(Provider):
and environment variables to ensure Prowler uses only the credentials and environment variables to ensure Prowler uses only the credentials
provided through its own configuration mechanisms (CLI args, config file, provided through its own configuration mechanisms (CLI args, config file,
or environment variables read by Prowler itself in setup_session()). or environment variables read by Prowler itself in setup_session()).
Args:
session: The OpenStack session configuration.
region: Optional region override — when given, the connection is
scoped to this specific region instead of the session default.
""" """
try: try:
# Don't load from clouds.yaml or environment variables, we configure this in setup_session() # Don't load from clouds.yaml or environment variables, we configure this in setup_session()
conn = connect( conn = connect(
load_yaml_config=False, load_yaml_config=False,
load_envvars=False, load_envvars=False,
**session.as_sdk_config(), **session.as_sdk_config(region_override=region),
) )
conn.authorize() conn.authorize()
return conn return conn
@@ -463,7 +530,7 @@ class OpenstackProvider(Provider):
username=user_name, username=user_name,
project_id=project_id, project_id=project_id,
project_name=project_name, project_name=project_name,
region_name=session.region_name, region_name=session.region_name or ", ".join(session.regions or []),
user_domain_name=session.user_domain_name, user_domain_name=session.user_domain_name,
project_domain_name=session.project_domain_name, project_domain_name=session.project_domain_name,
) )
@@ -481,6 +548,7 @@ class OpenstackProvider(Provider):
region_name: Optional[str] = None, region_name: Optional[str] = None,
user_domain_name: Optional[str] = None, user_domain_name: Optional[str] = None,
project_domain_name: Optional[str] = None, project_domain_name: Optional[str] = None,
provider_id: Optional[str] = None,
raise_on_exception: bool = True, raise_on_exception: bool = True,
) -> Connection: ) -> Connection:
"""Test connection to OpenStack without creating a full provider instance. """Test connection to OpenStack without creating a full provider instance.
@@ -500,6 +568,7 @@ class OpenstackProvider(Provider):
region_name: OpenStack region name region_name: OpenStack region name
user_domain_name: User domain name (default: "Default") user_domain_name: User domain name (default: "Default")
project_domain_name: Project domain name (default: "Default") project_domain_name: Project domain name (default: "Default")
provider_id: OpenStack provider ID for validation (optional)
raise_on_exception: Whether to raise exception on failure (default: True) raise_on_exception: Whether to raise exception on failure (default: True)
Returns: Returns:
@@ -547,8 +616,18 @@ class OpenstackProvider(Provider):
project_domain_name=project_domain_name, project_domain_name=project_domain_name,
) )
# Create and test connection # Validate provider_id matches project_id from config
OpenstackProvider._create_connection(session) if provider_id and session.project_id != provider_id:
raise OpenStackInvalidProviderIdError(
message=f"Provider ID '{provider_id}' does not match project_id '{session.project_id}' from clouds.yaml",
)
# Create and test connection(s) — one per region when multi-region
if session.regions:
for region in session.regions:
OpenstackProvider._create_connection(session, region=region)
else:
OpenstackProvider._create_connection(session)
logger.info("OpenStack provider: Connection test successful") logger.info("OpenStack provider: Connection test successful")
return Connection(is_connected=True) return Connection(is_connected=True)
@@ -560,6 +639,9 @@ class OpenstackProvider(Provider):
OpenStackConfigFileNotFoundError, OpenStackConfigFileNotFoundError,
OpenStackCloudNotFoundError, OpenStackCloudNotFoundError,
OpenStackInvalidConfigError, OpenStackInvalidConfigError,
OpenStackInvalidProviderIdError,
OpenStackNoRegionError,
OpenStackAmbiguousRegionError,
) as error: ) as error:
logger.error(f"OpenStack connection test failed: {error}") logger.error(f"OpenStack connection test failed: {error}")
if raise_on_exception: if raise_on_exception:
@@ -578,18 +660,23 @@ class OpenstackProvider(Provider):
def print_credentials(self) -> None: def print_credentials(self) -> None:
"""Output sanitized credential summary.""" """Output sanitized credential summary."""
region = self._session.region_name
auth_url = self._session.auth_url auth_url = self._session.auth_url
project_id = self._session.project_id project_id = self._session.project_id
username = self._identity.username username = self._identity.username
if self._session.regions:
region_display = ", ".join(self._session.regions)
else:
region_display = self._session.region_name
messages = [ messages = [
f"Auth URL: {auth_url}", f"Auth URL: {auth_url}",
f"Project ID: {project_id}", f"Project ID: {project_id}",
f"Username: {username}", f"Username: {username}",
f"Region: {region}", f"Region: {region_display}",
] ]
print_boxes(messages, "OpenStack Credentials") print_boxes(messages, "OpenStack Credentials")
logger.info( logger.info(
f"Using OpenStack endpoint {Fore.YELLOW}{auth_url}{Style.RESET_ALL} " f"Using OpenStack endpoint {Fore.YELLOW}{auth_url}{Style.RESET_ALL} "
f"in region {Fore.YELLOW}{region}{Style.RESET_ALL}" f"in region {Fore.YELLOW}{region_display}{Style.RESET_ALL}"
) )

View File

@@ -15,125 +15,128 @@ class Compute(OpenStackService):
def __init__(self, provider) -> None: def __init__(self, provider) -> None:
super().__init__(__class__.__name__, provider) super().__init__(__class__.__name__, provider)
self.client = self.connection.compute
self.instances: List[ComputeInstance] = [] self.instances: List[ComputeInstance] = []
self._list_instances() self._list_instances()
def _list_instances(self) -> None: def _list_instances(self) -> None:
"""List all compute instances in the current project.""" """List all compute instances across all audited regions."""
logger.info("Compute - Listing instances...") logger.info("Compute - Listing instances...")
try: for region, conn in self.regional_connections.items():
for server in self.client.servers(): try:
# Extract security group names (handle None case) for server in conn.compute.servers():
sg_list = getattr(server, "security_groups", None) or [] # Extract security group names (handle None case)
security_groups = [sg.get("name", "") for sg in sg_list] sg_list = getattr(server, "security_groups", None) or []
security_groups = [sg.get("name", "") for sg in sg_list]
# Extract network information from addresses # Extract network information from addresses
networks_dict = {} networks_dict = {}
addresses_attr = getattr(server, "addresses", None) addresses_attr = getattr(server, "addresses", None)
if addresses_attr: if addresses_attr:
for net_name, addr_list in addresses_attr.items(): for net_name, addr_list in addresses_attr.items():
# addr_list is a list of dicts like: # addr_list is a list of dicts like:
# [{'version': 4, 'addr': '57.128.163.151', 'OS-EXT-IPS:type': 'fixed'}] # [{'version': 4, 'addr': '57.128.163.151', 'OS-EXT-IPS:type': 'fixed'}]
ip_list = [] ip_list = []
if isinstance(addr_list, list): if isinstance(addr_list, list):
for addr_dict in addr_list: for addr_dict in addr_list:
if isinstance(addr_dict, dict) and "addr" in addr_dict: if (
ip_list.append(addr_dict["addr"]) isinstance(addr_dict, dict)
elif isinstance(addr_dict, str): and "addr" in addr_dict
# Fallback: if it's just a string IP ):
ip_list.append(addr_dict) ip_list.append(addr_dict["addr"])
elif isinstance(addr_list, str): elif isinstance(addr_dict, str):
# Fallback: single string IP # Fallback: if it's just a string IP
ip_list = [addr_list] ip_list.append(addr_dict)
networks_dict[net_name] = ip_list elif isinstance(addr_list, str):
# Fallback: single string IP
ip_list = [addr_list]
networks_dict[net_name] = ip_list
# Extract trusted image certificates # Extract trusted image certificates
trusted_certs = ( trusted_certs = (
getattr(server, "trusted_image_certificates", None) or [] getattr(server, "trusted_image_certificates", None) or []
)
# Get SDK computed properties
public_v4 = getattr(server, "public_v4", "")
public_v6 = getattr(server, "public_v6", "")
private_v4 = getattr(server, "private_v4", "")
private_v6 = getattr(server, "private_v6", "")
# Fallback: If SDK attributes are not populated, classify IPs from networks
# This handles clouds where SDK computed properties are not available
if (
not (public_v4 or public_v6 or private_v4 or private_v6)
and networks_dict
):
for network_name, ip_list in networks_dict.items():
for ip_str in ip_list:
try:
ip_obj = ipaddress.ip_address(ip_str)
# Classify as private or public
if ip_obj.is_private:
# Assign first private IP found to appropriate field
if ip_obj.version == 4 and not private_v4:
private_v4 = ip_str
elif ip_obj.version == 6 and not private_v6:
private_v6 = ip_str
elif not (
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_reserved
or ip_obj.is_multicast
):
# Assign first public IP found to appropriate field
if ip_obj.version == 4 and not public_v4:
public_v4 = ip_str
elif ip_obj.version == 6 and not public_v6:
public_v6 = ip_str
except ValueError:
# Invalid IP address, skip
continue
self.instances.append(
ComputeInstance(
# Basic instance information
id=getattr(server, "id", ""),
name=getattr(server, "name", ""),
status=getattr(server, "status", ""),
flavor_id=getattr(server, "flavor", {}).get("id", ""),
security_groups=security_groups,
region=self.region,
project_id=self.project_id,
# Access Control & Authentication
is_locked=getattr(server, "is_locked", False),
locked_reason=getattr(server, "locked_reason", ""),
key_name=getattr(server, "key_name", ""),
user_id=getattr(server, "user_id", ""),
# Network Exposure
access_ipv4=getattr(server, "access_ipv4", ""),
access_ipv6=getattr(server, "access_ipv6", ""),
public_v4=public_v4,
public_v6=public_v6,
private_v4=private_v4,
private_v6=private_v6,
networks=networks_dict,
# Configuration Security
has_config_drive=getattr(server, "has_config_drive", False),
metadata=getattr(server, "metadata", {}),
user_data=getattr(server, "user_data", ""),
# Image Trust
trusted_image_certificates=(
trusted_certs if isinstance(trusted_certs, list) else []
),
) )
# Get SDK computed properties
public_v4 = getattr(server, "public_v4", "")
public_v6 = getattr(server, "public_v6", "")
private_v4 = getattr(server, "private_v4", "")
private_v6 = getattr(server, "private_v6", "")
# Fallback: If SDK attributes are not populated, classify IPs from networks
# This handles clouds where SDK computed properties are not available
if (
not (public_v4 or public_v6 or private_v4 or private_v6)
and networks_dict
):
for network_name, ip_list in networks_dict.items():
for ip_str in ip_list:
try:
ip_obj = ipaddress.ip_address(ip_str)
# Classify as private or public
if ip_obj.is_private:
# Assign first private IP found to appropriate field
if ip_obj.version == 4 and not private_v4:
private_v4 = ip_str
elif ip_obj.version == 6 and not private_v6:
private_v6 = ip_str
elif not (
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_reserved
or ip_obj.is_multicast
):
# Assign first public IP found to appropriate field
if ip_obj.version == 4 and not public_v4:
public_v4 = ip_str
elif ip_obj.version == 6 and not public_v6:
public_v6 = ip_str
except ValueError:
# Invalid IP address, skip
continue
self.instances.append(
ComputeInstance(
# Basic instance information
id=getattr(server, "id", ""),
name=getattr(server, "name", ""),
status=getattr(server, "status", ""),
flavor_id=getattr(server, "flavor", {}).get("id", ""),
security_groups=security_groups,
region=region,
project_id=self.project_id,
# Access Control & Authentication
is_locked=getattr(server, "is_locked", False),
locked_reason=getattr(server, "locked_reason", ""),
key_name=getattr(server, "key_name", ""),
user_id=getattr(server, "user_id", ""),
# Network Exposure
access_ipv4=getattr(server, "access_ipv4", ""),
access_ipv6=getattr(server, "access_ipv6", ""),
public_v4=public_v4,
public_v6=public_v6,
private_v4=private_v4,
private_v6=private_v6,
networks=networks_dict,
# Configuration Security
has_config_drive=getattr(server, "has_config_drive", False),
metadata=getattr(server, "metadata", {}),
user_data=getattr(server, "user_data", ""),
# Image Trust
trusted_image_certificates=(
trusted_certs if isinstance(trusted_certs, list) else []
),
)
)
except openstack_exceptions.SDKException as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- "
f"Failed to list compute instances in region {region}: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- "
f"Unexpected error listing compute instances in region {region}: {error}"
) )
except openstack_exceptions.SDKException as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- "
f"Failed to list compute instances: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- "
f"Unexpected error listing compute instances: {error}"
)
@dataclass @dataclass

View File

@@ -62,6 +62,9 @@ def set_mocked_openstack_provider(
# Mock connection # Mock connection
provider.connection = MagicMock() provider.connection = MagicMock()
# Mock regional connections (single-region default)
provider.regional_connections = {region_name: provider.connection}
# Mock audit config # Mock audit config
provider.audit_config = audit_config or {} provider.audit_config = audit_config or {}
provider.fixer_config = {} provider.fixer_config = {}

View File

@@ -14,11 +14,14 @@ from prowler.config.config import (
) )
from prowler.providers.common.models import Connection from prowler.providers.common.models import Connection
from prowler.providers.openstack.exceptions.exceptions import ( from prowler.providers.openstack.exceptions.exceptions import (
OpenStackAmbiguousRegionError,
OpenStackAuthenticationError, OpenStackAuthenticationError,
OpenStackCloudNotFoundError, OpenStackCloudNotFoundError,
OpenStackConfigFileNotFoundError, OpenStackConfigFileNotFoundError,
OpenStackCredentialsError, OpenStackCredentialsError,
OpenStackInvalidConfigError, OpenStackInvalidConfigError,
OpenStackInvalidProviderIdError,
OpenStackNoRegionError,
) )
from prowler.providers.openstack.models import OpenStackIdentityInfo, OpenStackSession from prowler.providers.openstack.models import OpenStackIdentityInfo, OpenStackSession
from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider
@@ -1023,3 +1026,623 @@ clouds:
assert provider.session.auth_url == "https://openstack.example.com:5000/v3" assert provider.session.auth_url == "https://openstack.example.com:5000/v3"
assert provider.session.username == "test-user" assert provider.session.username == "test-user"
assert provider.session.project_id == "test-project" assert provider.session.project_id == "test-project"
class TestOpenstackProviderRegionValidation:
"""Test suite for OpenStack Provider region validation (region_name XOR regions)."""
@pytest.fixture(autouse=True)
def clean_openstack_env(self, monkeypatch):
"""Ensure clean OpenStack environment for all tests."""
openstack_env_vars = [
"OS_AUTH_URL",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"OS_CLOUD",
"OS_IDENTITY_API_VERSION",
"OS_USER_DOMAIN_NAME",
"OS_PROJECT_DOMAIN_NAME",
]
for env_var in openstack_env_vars:
monkeypatch.delenv(env_var, raising=False)
def test_clouds_yaml_content_with_region_name_only(self):
"""Test that clouds.yaml content with only region_name produces a valid session."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
region_name: RegionOne
identity_api_version: 3
"""
session = OpenstackProvider._setup_session_from_clouds_yaml_content(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
)
assert session.region_name == "RegionOne"
assert session.regions is None
def test_clouds_yaml_content_with_regions_list_only(self):
"""Test that clouds.yaml content with only regions list produces a valid session."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- RegionOne
- RegionTwo
identity_api_version: 3
"""
session = OpenstackProvider._setup_session_from_clouds_yaml_content(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
)
assert session.region_name is None
assert session.regions == ["RegionOne", "RegionTwo"]
def test_clouds_yaml_content_with_both_region_name_and_regions(self):
"""Test that clouds.yaml content with both region_name and regions raises error."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
region_name: RegionOne
regions:
- RegionOne
- RegionTwo
identity_api_version: 3
"""
with pytest.raises(OpenStackAmbiguousRegionError) as excinfo:
OpenstackProvider._setup_session_from_clouds_yaml_content(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
)
assert "both 'region_name' and 'regions'" in str(excinfo.value)
def test_clouds_yaml_content_with_neither_region_name_nor_regions(self):
"""Test that clouds.yaml content with neither region_name nor regions raises error."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
identity_api_version: 3
"""
with pytest.raises(OpenStackNoRegionError) as excinfo:
OpenstackProvider._setup_session_from_clouds_yaml_content(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
)
assert "neither 'region_name' nor 'regions'" in str(excinfo.value)
def test_clouds_yaml_file_with_regions_list(self, tmp_path):
"""Test loading clouds.yaml file with regions list."""
clouds_yaml = tmp_path / "clouds.yaml"
clouds_yaml.write_text(
"""
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- RegionOne
- RegionTwo
identity_api_version: 3
"""
)
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
mock_connection.current_user_id = None
mock_connection.current_project_id = "test-project-id"
mock_connection.identity.get_project.return_value = None
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
provider = OpenstackProvider(
clouds_yaml_file=str(clouds_yaml),
clouds_yaml_cloud="test-cloud",
)
assert provider.session.region_name is None
assert provider.session.regions == ["RegionOne", "RegionTwo"]
def test_clouds_yaml_file_with_both_regions_raises_error(self, tmp_path):
"""Test that clouds.yaml file with both region_name and regions raises error."""
clouds_yaml = tmp_path / "clouds.yaml"
clouds_yaml.write_text(
"""
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
region_name: RegionOne
regions:
- RegionOne
- RegionTwo
identity_api_version: 3
"""
)
with pytest.raises(OpenStackAmbiguousRegionError):
OpenstackProvider(
clouds_yaml_file=str(clouds_yaml),
clouds_yaml_cloud="test-cloud",
)
def test_clouds_yaml_file_with_no_region_raises_error(self, tmp_path):
"""Test that clouds.yaml file with neither region_name nor regions raises error."""
clouds_yaml = tmp_path / "clouds.yaml"
clouds_yaml.write_text(
"""
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
identity_api_version: 3
"""
)
with pytest.raises(OpenStackNoRegionError):
OpenstackProvider(
clouds_yaml_file=str(clouds_yaml),
clouds_yaml_cloud="test-cloud",
)
def test_session_as_sdk_config_with_regions_list(self):
"""Test OpenStackSession.as_sdk_config() with regions list uses first region."""
session = OpenStackSession(
auth_url="https://openstack.example.com:5000/v3",
identity_api_version="3",
username="test-user",
password="test-password",
project_id="test-project",
regions=["RegionOne", "RegionTwo"],
user_domain_name="Default",
project_domain_name="Default",
)
sdk_config = session.as_sdk_config()
# SDK does not iterate over regions automatically, so we pass the
# first region as region_name for the default connection
assert sdk_config["region_name"] == "RegionOne"
assert "regions" not in sdk_config
def test_session_as_sdk_config_with_region_name(self):
"""Test OpenStackSession.as_sdk_config() with single region_name."""
session = OpenStackSession(
auth_url="https://openstack.example.com:5000/v3",
identity_api_version="3",
username="test-user",
password="test-password",
project_id="test-project",
region_name="RegionOne",
user_domain_name="Default",
project_domain_name="Default",
)
sdk_config = session.as_sdk_config()
assert sdk_config["region_name"] == "RegionOne"
assert "regions" not in sdk_config
class TestOpenstackProviderIdValidation:
"""Test suite for OpenStack Provider ID validation."""
@pytest.fixture(autouse=True)
def clean_openstack_env(self, monkeypatch):
"""Ensure clean OpenStack environment for all tests."""
openstack_env_vars = [
"OS_AUTH_URL",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"OS_CLOUD",
"OS_IDENTITY_API_VERSION",
"OS_USER_DOMAIN_NAME",
"OS_PROJECT_DOMAIN_NAME",
]
for env_var in openstack_env_vars:
monkeypatch.delenv(env_var, raising=False)
def test_test_connection_provider_id_matches(self):
"""Test test_connection succeeds when provider_id matches project_id."""
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
connection_result = OpenstackProvider.test_connection(
auth_url="https://openstack.example.com:5000/v3",
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
provider_id="test-project-id",
raise_on_exception=False,
)
assert connection_result.is_connected is True
assert connection_result.error is None
def test_test_connection_provider_id_does_not_match(self):
"""Test test_connection fails when provider_id doesn't match project_id."""
connection_result = OpenstackProvider.test_connection(
auth_url="https://openstack.example.com:5000/v3",
username="test-user",
password="test-password",
project_id="actual-project-id",
region_name="RegionOne",
provider_id="different-project-id",
raise_on_exception=False,
)
assert connection_result.is_connected is False
assert isinstance(connection_result.error, OpenStackInvalidProviderIdError)
def test_test_connection_provider_id_mismatch_raises(self):
"""Test test_connection raises when provider_id doesn't match and raise_on_exception=True."""
with pytest.raises(OpenStackInvalidProviderIdError) as excinfo:
OpenstackProvider.test_connection(
auth_url="https://openstack.example.com:5000/v3",
username="test-user",
password="test-password",
project_id="actual-project-id",
region_name="RegionOne",
provider_id="different-project-id",
raise_on_exception=True,
)
assert "different-project-id" in str(excinfo.value)
assert "actual-project-id" in str(excinfo.value)
def test_test_connection_no_provider_id_skips_validation(self):
"""Test test_connection skips provider_id validation when not provided."""
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
connection_result = OpenstackProvider.test_connection(
auth_url="https://openstack.example.com:5000/v3",
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
raise_on_exception=False,
)
assert connection_result.is_connected is True
def test_test_connection_provider_id_with_clouds_yaml_content(self):
"""Test test_connection validates provider_id against clouds.yaml content project_id."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: yaml-project-id
region_name: RegionOne
"""
connection_result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
provider_id="wrong-project-id",
raise_on_exception=False,
)
assert connection_result.is_connected is False
assert isinstance(connection_result.error, OpenStackInvalidProviderIdError)
def test_test_connection_region_error_surfaced(self):
"""Test test_connection surfaces region validation errors."""
clouds_yaml_content = """
clouds:
test-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
identity_api_version: 3
"""
connection_result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="test-cloud",
raise_on_exception=False,
)
assert connection_result.is_connected is False
assert isinstance(connection_result.error, OpenStackNoRegionError)
class TestOpenstackProviderRegionalConnections:
"""Test suite for OpenStack Provider regional_connections."""
@pytest.fixture(autouse=True)
def clean_openstack_env(self, monkeypatch):
"""Ensure clean OpenStack environment for all tests."""
openstack_env_vars = [
"OS_AUTH_URL",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"OS_CLOUD",
"OS_IDENTITY_API_VERSION",
"OS_USER_DOMAIN_NAME",
"OS_PROJECT_DOMAIN_NAME",
]
for env_var in openstack_env_vars:
monkeypatch.delenv(env_var, raising=False)
def test_single_region_regional_connections(self):
"""Test regional_connections has one entry for single-region provider."""
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
mock_connection.current_user_id = None
mock_connection.current_project_id = "test-project"
mock_connection.identity.get_project.return_value = None
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
provider = OpenstackProvider(
auth_url="https://openstack.example.com:5000/v3",
username="test-user",
password="test-password",
project_id="test-project",
region_name="RegionOne",
)
assert len(provider.regional_connections) == 1
assert "RegionOne" in provider.regional_connections
assert provider.regional_connections["RegionOne"] is provider.connection
mock_connect.assert_called_once()
def test_multi_region_regional_connections(self):
"""Test regional_connections has entries for each region in multi-region setup."""
mock_conn_region1 = MagicMock()
mock_conn_region1.authorize.return_value = None
mock_conn_region1.current_user_id = None
mock_conn_region1.current_project_id = "test-project-id"
mock_conn_region1.identity.get_project.return_value = None
mock_conn_region2 = MagicMock()
mock_conn_region2.authorize.return_value = None
clouds_yaml_content = """
clouds:
multi-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- UK1
- DE1
identity_api_version: 3
"""
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.side_effect = [mock_conn_region1, mock_conn_region2]
provider = OpenstackProvider(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="multi-cloud",
)
assert len(provider.regional_connections) == 2
assert "UK1" in provider.regional_connections
assert "DE1" in provider.regional_connections
assert provider.regional_connections["UK1"] is mock_conn_region1
assert provider.regional_connections["DE1"] is mock_conn_region2
# Default connection should be the first region
assert provider.connection is mock_conn_region1
assert mock_connect.call_count == 2
def test_multi_region_test_connection_tests_all_regions(self):
"""Test test_connection tests connectivity to every region."""
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
clouds_yaml_content = """
clouds:
multi-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- UK1
- DE1
identity_api_version: 3
"""
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="multi-cloud",
raise_on_exception=False,
)
assert result.is_connected is True
# Should have called connect once per region
assert mock_connect.call_count == 2
def test_multi_region_test_connection_fails_if_one_region_fails(self):
"""Test test_connection fails if any region fails."""
mock_conn_ok = MagicMock()
mock_conn_ok.authorize.return_value = None
mock_conn_fail = MagicMock()
mock_conn_fail.authorize.side_effect = openstack_exceptions.SDKException(
"Connection failed in DE1"
)
clouds_yaml_content = """
clouds:
multi-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- UK1
- DE1
identity_api_version: 3
"""
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.side_effect = [mock_conn_ok, mock_conn_fail]
result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="multi-cloud",
raise_on_exception=False,
)
assert result.is_connected is False
def test_session_as_sdk_config_region_override(self):
"""Test as_sdk_config with region_override overrides region_name."""
session = OpenStackSession(
auth_url="https://openstack.example.com:5000/v3",
identity_api_version="3",
username="test-user",
password="test-password",
project_id="test-project",
region_name="RegionOne",
user_domain_name="Default",
project_domain_name="Default",
)
sdk_config = session.as_sdk_config(region_override="RegionTwo")
assert sdk_config["region_name"] == "RegionTwo"
def test_session_as_sdk_config_region_override_with_regions_list(self):
"""Test as_sdk_config with region_override overrides regions list."""
session = OpenStackSession(
auth_url="https://openstack.example.com:5000/v3",
identity_api_version="3",
username="test-user",
password="test-password",
project_id="test-project",
regions=["UK1", "DE1"],
user_domain_name="Default",
project_domain_name="Default",
)
sdk_config = session.as_sdk_config(region_override="DE1")
assert sdk_config["region_name"] == "DE1"
def test_multi_region_test_connection_provider_id_matches(self):
"""Test test_connection validates provider_id in multi-region setup."""
mock_connection = MagicMock()
mock_connection.authorize.return_value = None
clouds_yaml_content = """
clouds:
multi-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: test-project-id
regions:
- UK1
- DE1
identity_api_version: 3
"""
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = mock_connection
result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="multi-cloud",
provider_id="test-project-id",
raise_on_exception=False,
)
assert result.is_connected is True
def test_multi_region_test_connection_provider_id_mismatch(self):
"""Test test_connection fails when provider_id doesn't match in multi-region."""
clouds_yaml_content = """
clouds:
multi-cloud:
auth:
auth_url: https://openstack.example.com:5000/v3
username: test-user
password: test-password
project_id: actual-project-id
regions:
- UK1
- DE1
identity_api_version: 3
"""
result = OpenstackProvider.test_connection(
clouds_yaml_content=clouds_yaml_content,
clouds_yaml_cloud="multi-cloud",
provider_id="wrong-project-id",
raise_on_exception=False,
)
assert result.is_connected is False
assert isinstance(result.error, OpenStackInvalidProviderIdError)

View File

@@ -28,9 +28,10 @@ class TestComputeService:
assert compute.service_name == "Compute" assert compute.service_name == "Compute"
assert compute.provider == provider assert compute.provider == provider
assert compute.connection == provider.connection assert compute.connection == provider.connection
assert compute.regional_connections == provider.regional_connections
assert compute.audited_regions == [OPENSTACK_REGION]
assert compute.region == OPENSTACK_REGION assert compute.region == OPENSTACK_REGION
assert compute.project_id == OPENSTACK_PROJECT_ID assert compute.project_id == OPENSTACK_PROJECT_ID
assert compute.client == provider.connection.compute
assert compute.instances == [] assert compute.instances == []
mock_list.assert_called_once() mock_list.assert_called_once()
@@ -303,6 +304,8 @@ class TestComputeService:
assert hasattr(compute, "service_name") assert hasattr(compute, "service_name")
assert hasattr(compute, "provider") assert hasattr(compute, "provider")
assert hasattr(compute, "connection") assert hasattr(compute, "connection")
assert hasattr(compute, "regional_connections")
assert hasattr(compute, "audited_regions")
assert hasattr(compute, "session") assert hasattr(compute, "session")
assert hasattr(compute, "region") assert hasattr(compute, "region")
assert hasattr(compute, "project_id") assert hasattr(compute, "project_id")
@@ -343,3 +346,153 @@ class TestComputeService:
assert len(compute.instances) == 1 assert len(compute.instances) == 1
assert compute.instances[0].id == "instance-1" assert compute.instances[0].id == "instance-1"
assert compute.instances[0].networks == {} # Should default to empty dict assert compute.instances[0].networks == {} # Should default to empty dict
def test_compute_list_instances_multi_region(self):
"""Test listing instances across multiple regions."""
provider = set_mocked_openstack_provider()
# Create two mock connections for two regions
mock_conn_uk1 = MagicMock()
mock_conn_de1 = MagicMock()
# Set up regional connections
provider.regional_connections = {"UK1": mock_conn_uk1, "DE1": mock_conn_de1}
mock_server_uk = MagicMock()
mock_server_uk.id = "instance-uk"
mock_server_uk.name = "Instance UK"
mock_server_uk.status = "ACTIVE"
mock_server_uk.flavor = {"id": "flavor-1"}
mock_server_uk.security_groups = [{"name": "default"}]
mock_server_uk.is_locked = False
mock_server_uk.locked_reason = ""
mock_server_uk.key_name = ""
mock_server_uk.user_id = ""
mock_server_uk.access_ipv4 = ""
mock_server_uk.access_ipv6 = ""
mock_server_uk.public_v4 = ""
mock_server_uk.public_v6 = ""
mock_server_uk.private_v4 = "10.0.0.1"
mock_server_uk.private_v6 = ""
mock_server_uk.addresses = {"private": [{"version": 4, "addr": "10.0.0.1"}]}
mock_server_uk.has_config_drive = False
mock_server_uk.metadata = {}
mock_server_uk.user_data = ""
mock_server_uk.trusted_image_certificates = []
mock_server_de = MagicMock()
mock_server_de.id = "instance-de"
mock_server_de.name = "Instance DE"
mock_server_de.status = "ACTIVE"
mock_server_de.flavor = {"id": "flavor-2"}
mock_server_de.security_groups = [{"name": "default"}]
mock_server_de.is_locked = False
mock_server_de.locked_reason = ""
mock_server_de.key_name = ""
mock_server_de.user_id = ""
mock_server_de.access_ipv4 = ""
mock_server_de.access_ipv6 = ""
mock_server_de.public_v4 = ""
mock_server_de.public_v6 = ""
mock_server_de.private_v4 = "10.0.0.2"
mock_server_de.private_v6 = ""
mock_server_de.addresses = {"private": [{"version": 4, "addr": "10.0.0.2"}]}
mock_server_de.has_config_drive = False
mock_server_de.metadata = {}
mock_server_de.user_data = ""
mock_server_de.trusted_image_certificates = []
mock_conn_uk1.compute.servers.return_value = [mock_server_uk]
mock_conn_de1.compute.servers.return_value = [mock_server_de]
compute = Compute(provider)
assert len(compute.instances) == 2
# Verify instances have correct region tags
uk_instance = next(i for i in compute.instances if i.id == "instance-uk")
de_instance = next(i for i in compute.instances if i.id == "instance-de")
assert uk_instance.region == "UK1"
assert de_instance.region == "DE1"
def test_compute_list_instances_multi_region_partial_failure(self):
"""Test that a failing region doesn't prevent other regions from being listed."""
provider = set_mocked_openstack_provider()
mock_conn_ok = MagicMock()
mock_conn_fail = MagicMock()
provider.regional_connections = {"UK1": mock_conn_ok, "DE1": mock_conn_fail}
mock_server = MagicMock()
mock_server.id = "instance-uk"
mock_server.name = "Instance UK"
mock_server.status = "ACTIVE"
mock_server.flavor = {"id": "flavor-1"}
mock_server.security_groups = [{"name": "default"}]
mock_server.is_locked = False
mock_server.locked_reason = ""
mock_server.key_name = ""
mock_server.user_id = ""
mock_server.access_ipv4 = ""
mock_server.access_ipv6 = ""
mock_server.public_v4 = ""
mock_server.public_v6 = ""
mock_server.private_v4 = "10.0.0.1"
mock_server.private_v6 = ""
mock_server.addresses = {}
mock_server.has_config_drive = False
mock_server.metadata = {}
mock_server.user_data = ""
mock_server.trusted_image_certificates = []
mock_conn_ok.compute.servers.return_value = [mock_server]
mock_conn_fail.compute.servers.side_effect = openstack_exceptions.SDKException(
"API error in DE1"
)
compute = Compute(provider)
# Should have the instance from UK1, DE1 failure is logged but doesn't crash
assert len(compute.instances) == 1
assert compute.instances[0].id == "instance-uk"
assert compute.instances[0].region == "UK1"
def test_compute_list_instances_multi_region_one_empty(self):
"""Test multi-region where one region has instances and the other is empty."""
provider = set_mocked_openstack_provider()
mock_conn_uk1 = MagicMock()
mock_conn_de1 = MagicMock()
provider.regional_connections = {"UK1": mock_conn_uk1, "DE1": mock_conn_de1}
mock_server = MagicMock()
mock_server.id = "instance-uk"
mock_server.name = "Instance UK"
mock_server.status = "ACTIVE"
mock_server.flavor = {"id": "flavor-1"}
mock_server.security_groups = [{"name": "default"}]
mock_server.is_locked = False
mock_server.locked_reason = ""
mock_server.key_name = ""
mock_server.user_id = ""
mock_server.access_ipv4 = ""
mock_server.access_ipv6 = ""
mock_server.public_v4 = ""
mock_server.public_v6 = ""
mock_server.private_v4 = "10.0.0.1"
mock_server.private_v6 = ""
mock_server.addresses = {}
mock_server.has_config_drive = False
mock_server.metadata = {}
mock_server.user_data = ""
mock_server.trusted_image_certificates = []
mock_conn_uk1.compute.servers.return_value = [mock_server]
mock_conn_de1.compute.servers.return_value = [] # Empty region
compute = Compute(provider)
assert len(compute.instances) == 1
assert compute.instances[0].id == "instance-uk"
assert compute.instances[0].region == "UK1"