feat(scaleway): add new provider (#11166)

This commit is contained in:
Pedro Martín
2026-05-18 16:42:10 +02:00
committed by GitHub
parent 37aa290d1c
commit bfcbe0a9c4
42 changed files with 1753 additions and 4 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ..."
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ..."
def mock_get_available_providers():
@@ -0,0 +1,96 @@
from unittest.mock import MagicMock
from prowler.providers.scaleway.models import (
ScalewayIdentityInfo,
ScalewaySession,
)
from prowler.providers.scaleway.services.iam.iam_service import (
ScalewayAPIKey,
ScalewayUser,
)
# Scaleway Identity
ORGANIZATION_ID = "b4ce0bfc-38fc-4c53-8757-548be64add26"
ROOT_USER_ID = "00000000-0000-0000-0000-000000000001"
MEMBER_USER_ID = "00000000-0000-0000-0000-000000000002"
APPLICATION_ID = "00000000-0000-0000-0000-000000000003"
BEARER_EMAIL = "pedro@prowler.com"
# Scaleway Credentials
ACCESS_KEY = "SCWAE000000000000000"
SECRET_KEY = "00000000-0000-0000-0000-000000000000"
# API Key Constants
ROOT_API_KEY = "SCWROOT00000000000000"
USER_API_KEY = "SCWUSER00000000000000"
APP_API_KEY = "SCWAPP000000000000000"
def set_mocked_scaleway_provider(
access_key: str = ACCESS_KEY,
secret_key: str = SECRET_KEY,
identity: ScalewayIdentityInfo = None,
audit_config: dict = None,
):
"""Create a mocked ScalewayProvider for testing."""
provider = MagicMock()
provider.type = "scaleway"
provider.session = ScalewaySession(
access_key=access_key,
secret_key=secret_key,
organization_id=ORGANIZATION_ID,
default_project_id=None,
default_region="fr-par",
client=MagicMock(),
)
provider.identity = identity or ScalewayIdentityInfo(
organization_id=ORGANIZATION_ID,
bearer_id=ROOT_USER_ID,
bearer_type="user",
bearer_email=BEARER_EMAIL,
account_root_user_id=ROOT_USER_ID,
)
provider.audit_config = audit_config or {}
provider.fixer_config = {}
return provider
def make_user(
user_id: str = ROOT_USER_ID,
email: str = BEARER_EMAIL,
account_root_user_id: str = ROOT_USER_ID,
mfa: bool = True,
) -> ScalewayUser:
return ScalewayUser(
id=user_id,
email=email,
username=email.split("@")[0] if email else None,
organization_id=ORGANIZATION_ID,
account_root_user_id=account_root_user_id,
mfa=mfa,
type_="owner" if user_id == account_root_user_id else "member",
status="activated",
)
def make_api_key(
access_key: str = USER_API_KEY,
user_id: str = MEMBER_USER_ID,
application_id: str = None,
description: str = "test key",
expires_at: str = None,
) -> ScalewayAPIKey:
return ScalewayAPIKey(
access_key=access_key,
description=description,
user_id=user_id,
application_id=application_id,
default_project_id=None,
editable=True,
managed=False,
creation_ip=None,
created_at="2026-01-01T00:00:00Z",
updated_at="2026-01-01T00:00:00Z",
expires_at=expires_at,
)
@@ -0,0 +1,106 @@
import os
from unittest import mock
import pytest
from prowler.providers.scaleway.exceptions.exceptions import (
ScalewayAuthenticationError,
ScalewayCredentialsError,
ScalewayIdentityError,
)
from prowler.providers.scaleway.models import ScalewaySession
from prowler.providers.scaleway.scaleway_provider import ScalewayProvider
from tests.providers.scaleway.scaleway_fixtures import (
ACCESS_KEY,
BEARER_EMAIL,
ORGANIZATION_ID,
ROOT_USER_ID,
SECRET_KEY,
)
class Test_ScalewayProvider_setup_session:
def test_missing_access_key_raises_credentials_error(self):
with mock.patch.dict(
os.environ, {"SCW_ACCESS_KEY": "", "SCW_SECRET_KEY": ""}, clear=False
):
os.environ.pop("SCW_ACCESS_KEY", None)
os.environ.pop("SCW_SECRET_KEY", None)
with pytest.raises(ScalewayCredentialsError):
ScalewayProvider.setup_session()
def test_returns_session_with_credentials(self):
session = ScalewayProvider.setup_session(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
)
assert isinstance(session, ScalewaySession)
assert session.access_key == ACCESS_KEY
assert session.organization_id == ORGANIZATION_ID
assert session.default_region == "fr-par"
class Test_ScalewayProvider_setup_identity:
def _build_session(self):
return ScalewaySession(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
default_region="fr-par",
client=mock.MagicMock(),
)
def test_resolves_user_bearer_identity(self):
session = self._build_session()
api_key = mock.MagicMock(user_id=ROOT_USER_ID, application_id=None)
user = mock.MagicMock(
email=BEARER_EMAIL,
organization_id=ORGANIZATION_ID,
account_root_user_id=ROOT_USER_ID,
)
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam = iam_cls.return_value
iam.get_api_key.return_value = api_key
iam.get_user.return_value = user
identity = ScalewayProvider.setup_identity(session)
assert identity.organization_id == ORGANIZATION_ID
assert identity.bearer_type == "user"
assert identity.bearer_id == ROOT_USER_ID
assert identity.bearer_email == BEARER_EMAIL
assert identity.account_root_user_id == ROOT_USER_ID
def test_missing_organization_raises_identity_error(self):
session = self._build_session()
session.organization_id = None
api_key = mock.MagicMock(user_id=None, application_id="app-id")
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam = iam_cls.return_value
iam.get_api_key.return_value = api_key
with pytest.raises(ScalewayIdentityError):
ScalewayProvider.setup_identity(session)
class Test_ScalewayProvider_validate_credentials:
def test_invalid_credentials_raise_authentication_error(self):
session = ScalewaySession(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
client=mock.MagicMock(),
)
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam_cls.return_value.get_api_key.side_effect = Exception("expired")
with pytest.raises(ScalewayAuthenticationError):
ScalewayProvider.validate_credentials(session)
@@ -0,0 +1,204 @@
from unittest import mock
from tests.providers.scaleway.scaleway_fixtures import (
APP_API_KEY,
APPLICATION_ID,
MEMBER_USER_ID,
ORGANIZATION_ID,
ROOT_API_KEY,
ROOT_USER_ID,
USER_API_KEY,
make_api_key,
set_mocked_scaleway_provider,
)
def _patch_clients(iam_client_mock):
"""Patch both the provider and the iam_client singleton."""
return [
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client_mock,
),
]
class Test_iam_api_keys_no_root_owned:
def test_no_api_keys_returns_empty_findings(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = []
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert result == []
def test_root_api_key_fails(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == ROOT_API_KEY
assert ROOT_USER_ID in result[0].status_extended
def test_user_api_key_passes(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(access_key=USER_API_KEY, user_id=MEMBER_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == USER_API_KEY
def test_application_api_key_passes(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(
access_key=APP_API_KEY, user_id=None, application_id=APPLICATION_ID
)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_users_load_failure_returns_manual(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = False
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = None
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not retrieve" in result[0].status_extended
def test_root_user_unresolved_returns_manual(self):
# Data loaded fine but account_root_user_id could not be resolved
# (e.g. application-scoped key). A root-owned key must NOT slip
# through as PASS — the check degrades to MANUAL instead.
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = None
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "account root user" in result[0].status_extended
@@ -0,0 +1,138 @@
from unittest import mock
from prowler.providers.scaleway.models import ScalewayIdentityInfo
from prowler.providers.scaleway.services.iam.iam_service import IAM
from tests.providers.scaleway.scaleway_fixtures import (
APPLICATION_ID,
MEMBER_USER_ID,
ORGANIZATION_ID,
ROOT_USER_ID,
USER_API_KEY,
set_mocked_scaleway_provider,
)
def _application_identity() -> ScalewayIdentityInfo:
"""Identity produced by an application-scoped API key: the IAM API
never exposes account_root_user_id for an application bearer."""
return ScalewayIdentityInfo(
organization_id=ORGANIZATION_ID,
bearer_id=APPLICATION_ID,
bearer_type="application",
bearer_email=None,
account_root_user_id=None,
)
def _mock_user(
user_id: str, account_root_user_id: str = ROOT_USER_ID, email: str = "u@example.com"
):
user = mock.MagicMock()
user.id = user_id
user.email = email
user.username = email.split("@")[0]
user.organization_id = ORGANIZATION_ID
user.account_root_user_id = account_root_user_id
user.mfa = True
user.type_ = "owner" if user_id == account_root_user_id else "member"
user.status = "activated"
return user
def _mock_api_key(access_key: str, user_id: str = None, application_id: str = None):
key = mock.MagicMock()
key.access_key = access_key
key.description = "test"
key.user_id = user_id
key.application_id = application_id
key.default_project_id = None
key.editable = True
key.managed = False
key.creation_ip = None
key.created_at = None
key.updated_at = None
key.expires_at = None
return key
class Test_IAM_service:
def test_loads_users_and_api_keys(self):
provider = set_mocked_scaleway_provider()
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(ROOT_USER_ID),
_mock_user(MEMBER_USER_ID, email="m@example.com"),
]
api.list_api_keys_all.return_value = [
_mock_api_key(USER_API_KEY, user_id=MEMBER_USER_ID),
_mock_api_key("SCWAPP", application_id=APPLICATION_ID),
]
iam = IAM(provider)
assert iam.users_loaded is True
assert iam.api_keys_loaded is True
assert iam.account_root_user_id == ROOT_USER_ID
assert len(iam.users) == 2
assert len(iam.api_keys) == 2
def test_marks_users_unloaded_on_error(self):
provider = set_mocked_scaleway_provider()
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.side_effect = Exception("denied")
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.users_loaded is False
assert iam.api_keys_loaded is True
# account_root_user_id comes from the audit identity, not the user
# list, so a failed user listing must not blind the root-key check.
assert iam.account_root_user_id == ROOT_USER_ID
def test_application_key_resolves_root_user_from_user_list(self):
# Application-scoped API key: identity.account_root_user_id is None,
# so it must be recovered from the loaded user list. Otherwise the
# root-key check would silently PASS root-owned keys.
provider = set_mocked_scaleway_provider(identity=_application_identity())
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(ROOT_USER_ID),
_mock_user(MEMBER_USER_ID, email="m@example.com"),
]
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.account_root_user_id == ROOT_USER_ID
def test_account_root_user_id_none_when_unresolvable(self):
# Application key + no user record exposes account_root_user_id:
# nothing to fall back to, so it stays None and the root-key check
# will degrade to MANUAL downstream.
provider = set_mocked_scaleway_provider(identity=_application_identity())
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(MEMBER_USER_ID, account_root_user_id=None)
]
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.account_root_user_id is None