feat(stackit): add ske_cluster_no_public_endpoint check (#11943)

Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
This commit is contained in:
Johannes Engler
2026-08-20 13:01:01 +01:00
committed by GitHub
co-authored by Hugo P.Brito
parent acb6ff0425
commit 3da4209ee7
15 changed files with 1163 additions and 7 deletions
@@ -0,0 +1,149 @@
from unittest import mock
from prowler.providers.stackit.services.ske.ske_service import Cluster
from tests.providers.stackit.stackit_fixtures import (
STACKIT_PROJECT_ID,
set_mocked_stackit_provider,
)
class Test_ske_cluster_no_public_endpoint:
def _run_check(self, ske_client):
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_stackit_provider(),
),
mock.patch(
"prowler.providers.stackit.services.ske.ske_service.SKEService",
new=ske_client,
) as service_client,
mock.patch(
"prowler.providers.stackit.services.ske.ske_client.ske_client",
new=service_client,
),
):
from prowler.providers.stackit.services.ske.ske_cluster_no_public_endpoint.ske_cluster_no_public_endpoint import (
ske_cluster_no_public_endpoint,
)
check = ske_cluster_no_public_endpoint()
return check.execute()
def _cluster(self, name="test-cluster", **kwargs):
defaults = {
"id": name,
"name": name,
"project_id": STACKIT_PROJECT_ID,
"region": "eu01",
}
defaults.update(kwargs)
return Cluster(**defaults)
def test_no_clusters(self):
ske_client = mock.MagicMock
ske_client.clusters = []
result = self._run_check(ske_client)
assert len(result) == 0
def test_cluster_without_acl_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [self._cluster(name="open-cluster", acl_enabled=False)]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SKE cluster open-cluster exposes its Kubernetes API endpoint to the "
"internet because the ACL extension is not enabled."
)
assert result[0].resource_id == "open-cluster"
assert result[0].resource_name == "open-cluster"
assert result[0].project_id == STACKIT_PROJECT_ID
assert result[0].location == "eu01"
def test_cluster_with_unrestricted_ipv4_cidr_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(
name="wide-cluster",
acl_enabled=True,
allowed_cidrs=["10.0.0.0/8", "0.0.0.0/0"],
)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SKE cluster wide-cluster exposes its Kubernetes API endpoint to the "
"internet because its ACL allows unrestricted access from 0.0.0.0/0."
)
def test_cluster_with_unrestricted_ipv6_cidr_is_public(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="v6-cluster", acl_enabled=True, allowed_cidrs=["::/0"])
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "unrestricted access from ::/0." in result[0].status_extended
def test_cluster_with_restricted_acl_passes(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(
name="locked-cluster",
access_scope="PUBLIC",
acl_enabled=True,
allowed_cidrs=["10.0.0.0/8", "192.0.2.0/24"],
)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SKE cluster locked-cluster restricts access to its Kubernetes API "
"endpoint to 2 allowed CIDR(s)."
)
def test_cluster_with_private_control_plane_passes_without_acl(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="sna-cluster", access_scope="SNA", acl_enabled=False)
]
result = self._run_check(ske_client)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SKE cluster sna-cluster has a private control plane and its Kubernetes "
"API endpoint is not reachable from the internet."
)
def test_mixed_clusters_report_independently(self):
ske_client = mock.MagicMock
ske_client.clusters = [
self._cluster(name="open-cluster", acl_enabled=False),
self._cluster(
name="locked-cluster", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
),
self._cluster(name="sna-cluster", access_scope="SNA"),
]
result = self._run_check(ske_client)
assert len(result) == 3
assert [report.status for report in result] == ["FAIL", "PASS", "PASS"]
assert [report.resource_name for report in result] == [
"open-cluster",
"locked-cluster",
"sna-cluster",
]
@@ -0,0 +1,526 @@
from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError
from prowler.providers.stackit.services.ske.ske_service import Cluster, SKEService
from tests.providers.stackit.stackit_fixtures import (
STACKIT_PROJECT_ID,
set_mocked_stackit_provider,
)
def mock_ske_fetch_all_regions(_):
"""Mock the _fetch_all_regions method to avoid real API calls."""
@patch(
"prowler.providers.stackit.services.ske.ske_service.SKEService._fetch_all_regions",
new=mock_ske_fetch_all_regions,
)
class Test_SKE_Service:
def test_service_initialization(self):
"""Test that the SKE service initializes correctly."""
ske_service = SKEService(set_mocked_stackit_provider())
assert ske_service.project_id == STACKIT_PROJECT_ID
assert ske_service.service_account_key_path is not None
assert isinstance(ske_service.clusters, list)
def test_service_service_account_key_path(self):
"""Test that the service correctly extracts the SA key path from provider."""
custom_path = "/tmp/custom-sa.json"
provider = set_mocked_stackit_provider(service_account_key_path=custom_path)
ske_service = SKEService(provider)
assert ske_service.service_account_key_path == custom_path
def test_service_requests_ske_regional_clients(self):
"""Test that the service asks the provider for SKE regional clients."""
provider = set_mocked_stackit_provider()
SKEService(provider)
provider.generate_regional_clients.assert_called_once_with("ske")
class Test_SKE_Service_ExtractItems:
"""Tests for the _extract_items response normalization helper."""
def test_dict_response(self):
assert SKEService._extract_items({"items": [1, 2]}, "list_clusters") == [1, 2]
def test_dict_response_without_items_key(self):
assert SKEService._extract_items({}, "list_clusters") == []
def test_list_response(self):
assert SKEService._extract_items(["cluster"], "list_clusters") == ["cluster"]
def test_model_with_items_attribute(self):
response = MagicMock(spec=["items"])
response.items = ["cluster"]
assert SKEService._extract_items(response, "list_clusters") == ["cluster"]
def test_model_with_callable_items_is_rejected(self):
# A bare MagicMock exposes ``items`` as a callable, which must not be
# mistaken for the items list.
assert SKEService._extract_items(MagicMock(), "list_clusters") == []
def test_unexpected_response_type(self):
assert SKEService._extract_items(object(), "list_clusters") == []
class Test_SKE_Service_GetField:
"""Tests for the _get_field dict/model accessor."""
def test_none_item_returns_default(self):
assert SKEService._get_field(None, "enabled", default="fallback") == "fallback"
def test_dict_alias_key_is_matched(self):
item = {"allowedCidrs": ["10.0.0.0/8"]}
assert SKEService._get_field(item, "allowed_cidrs", "allowedCidrs") == [
"10.0.0.0/8"
]
def test_dict_missing_keys_returns_default(self):
assert SKEService._get_field({}, "enabled", default=[]) == []
def test_dict_false_value_is_returned_not_treated_as_missing(self):
assert (
SKEService._get_field({"enabled": False}, "enabled", default=True) is False
)
def test_model_attribute_is_read(self):
item = MagicMock(spec=["enabled"])
item.enabled = True
assert SKEService._get_field(item, "enabled") is True
def test_model_false_attribute_is_returned(self):
item = MagicMock(spec=["enabled"])
item.enabled = False
assert SKEService._get_field(item, "enabled", default=True) is False
def test_model_missing_attribute_returns_default(self):
item = MagicMock(spec=["other"])
assert SKEService._get_field(item, "enabled", default="fallback") == "fallback"
class Test_SKE_Service_ParseAccessScope:
"""Tests for _parse_access_scope."""
def test_missing_network_returns_none(self):
assert SKEService._parse_access_scope({}) is None
def test_missing_control_plane_returns_none(self):
assert SKEService._parse_access_scope({"network": {"id": "net"}}) is None
def test_missing_access_scope_returns_none(self):
assert SKEService._parse_access_scope({"network": {"controlPlane": {}}}) is None
def test_camel_case_dict_scope(self):
cluster_data = {"network": {"controlPlane": {"accessScope": "SNA"}}}
assert SKEService._parse_access_scope(cluster_data) == "SNA"
def test_snake_case_dict_scope(self):
cluster_data = {"network": {"control_plane": {"access_scope": "PUBLIC"}}}
assert SKEService._parse_access_scope(cluster_data) == "PUBLIC"
def test_sdk_enum_is_normalized_to_wire_value(self):
# ``AccessScope`` is a ``str`` Enum whose ``str()`` renders as
# "AccessScope.SNA"; the parser must yield the "SNA" wire value.
from stackit.ske.models.access_scope import AccessScope
cluster_data = {"network": {"controlPlane": {"accessScope": AccessScope.SNA}}}
assert SKEService._parse_access_scope(cluster_data) == "SNA"
class Test_SKE_Service_ParseAcl:
"""Tests for _parse_acl."""
def test_missing_extensions_reports_no_acl(self):
assert SKEService._parse_acl({}) == (False, [])
def test_extensions_without_acl_reports_no_acl(self):
assert SKEService._parse_acl({"extensions": {"dns": {}}}) == (False, [])
def test_disabled_acl_keeps_its_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": False, "allowedCidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (False, ["10.0.0.0/8"])
def test_enabled_acl_camel_case_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"])
def test_enabled_acl_snake_case_cidrs(self):
cluster_data = {
"extensions": {"acl": {"enabled": True, "allowed_cidrs": ["10.0.0.0/8"]}}
}
assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"])
def test_enabled_acl_without_cidrs(self):
cluster_data = {"extensions": {"acl": {"enabled": True}}}
assert SKEService._parse_acl(cluster_data) == (True, [])
class Test_SKE_Service_HandleApiCall:
"""Tests for the centralized _handle_api_call wrapper."""
def _service(self):
service = object.__new__(SKEService)
service.provider = MagicMock()
return service
def test_returns_api_response(self):
service = self._service()
api_function = MagicMock(return_value={"items": []})
assert service._handle_api_call(api_function, project_id="p") == {"items": []}
api_function.assert_called_once_with(project_id="p")
def test_delegates_errors_to_provider_and_reraises(self):
service = self._service()
error = ValueError("boom")
api_function = MagicMock(side_effect=error)
with pytest.raises(ValueError):
service._handle_api_call(api_function)
service.provider.handle_api_error.assert_called_once_with(error)
class Test_SKE_Service_ListClusters:
"""Tests for _list_clusters."""
def _service(self):
service = object.__new__(SKEService)
service.provider = MagicMock()
service.project_id = STACKIT_PROJECT_ID
service.clusters = []
return service
def test_list_clusters_without_client_is_noop(self):
"""A missing regional client is logged and skipped, not fatal."""
service = self._service()
service._list_clusters(None, "eu01")
assert service.clusters == []
def test_list_clusters_populates_clusters(self):
service = self._service()
client = MagicMock()
client.list_clusters.return_value = {
"items": [
{"name": "open-cluster"},
{
"name": "locked-cluster",
"extensions": {
"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}
},
"network": {"controlPlane": {"accessScope": "PUBLIC"}},
},
]
}
service._list_clusters(client, "eu01")
assert [cluster.name for cluster in service.clusters] == [
"open-cluster",
"locked-cluster",
]
# SKE has no separate cluster id; the name doubles as the identifier.
assert service.clusters[0].id == "open-cluster"
assert service.clusters[0].acl_enabled is False
assert service.clusters[0].access_scope is None
assert service.clusters[1].acl_enabled is True
assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"]
assert service.clusters[1].access_scope == "PUBLIC"
assert all(
cluster.project_id == STACKIT_PROJECT_ID and cluster.region == "eu01"
for cluster in service.clusters
)
def test_cluster_processing_error_is_skipped(self):
"""A cluster that raises while being read is skipped, not fatal."""
class MalformedCluster:
@property
def name(self):
raise ValueError("malformed cluster")
service = self._service()
client = MagicMock()
client.list_clusters.return_value = {"items": [MalformedCluster()]}
service._list_clusters(client, "eu01")
assert service.clusters == []
class Test_SKE_Service_FetchAllRegions:
"""Tests for the region fetch loop and its error semantics."""
class _NotFound(Exception):
status = 404
class _Unauthorized(Exception):
status = 401
class _ServerError(Exception):
status = 500
class _ServiceNotEnabled(Exception):
# Body copied from a live SKE 403 for a region the project never enabled.
status = 403
body = (
'{"timestamp":"2026-08-20T09:51:33Z","status":403,'
'"error":"Forbidden","message":"Service not enabled"}'
)
class _Forbidden(Exception):
status = 403
body = '{"status":403,"error":"Forbidden","message":"Access denied"}'
def _service(self, regional_clients):
from prowler.providers.stackit.stackit_provider import StackitProvider
service = object.__new__(SKEService)
service.provider = MagicMock()
# Reuse the real centralized error handler so 401/403/404 semantics
# match production.
service.provider.handle_api_error = StackitProvider.handle_api_error
service.project_id = STACKIT_PROJECT_ID
service.regional_clients = regional_clients
service.clusters = []
return service
def _good_client(self, cluster_name="cluster-eu01"):
client = MagicMock()
client.list_clusters.return_value = {"items": [{"name": cluster_name}]}
return client
def _failing_client(self, error):
client = MagicMock()
client.list_clusters.side_effect = error
return client
def test_skips_region_where_project_is_absent(self):
service = self._service(
{
"eu01": self._good_client(),
"eu02": self._failing_client(self._NotFound()),
}
)
service._fetch_all_regions()
# eu01 cluster is collected; the eu02 404 is skipped silently.
assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"]
def test_skips_region_where_ske_is_not_enabled(self):
service = self._service(
{
"eu01": self._good_client(),
"eu02": self._failing_client(self._ServiceNotEnabled()),
}
)
service._fetch_all_regions()
# A project may enable SKE per region; the eu02 403 "Service not
# enabled" is a skip, not the credentials failure a bare 403 implies.
assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"]
def test_permission_denied_still_aborts_the_scan(self):
service = self._service({"eu01": self._failing_client(self._Forbidden())})
with pytest.raises(StackITInvalidTokenError):
service._fetch_all_regions()
def test_invalid_token_aborts_the_scan(self):
service = self._service({"eu01": self._failing_client(self._Unauthorized())})
with pytest.raises(StackITInvalidTokenError):
service._fetch_all_regions()
def test_unexpected_error_propagates(self):
service = self._service({"eu01": self._failing_client(self._ServerError())})
with pytest.raises(self._ServerError):
service._fetch_all_regions()
class Test_SKE_Service_SdkModelShapes:
"""Regression coverage for object-shaped (SDK model) API responses.
``_extract_items`` accepts both raw dicts and SDK models, so the cluster
parsing has to read either shape. Reading only dict keys would silently drop
the ACL of an SDK-model cluster and report a restricted cluster as exposed,
or worse, miss the ACL of an exposed one.
"""
@staticmethod
def _sdk_cluster(name, acl=None, access_scope=None):
"""Build a real ``stackit.ske`` Cluster model with optional ACL and scope."""
from stackit.ske.models.cluster import Cluster as SDKCluster
from stackit.ske.models.extension import Extension
from stackit.ske.models.image import Image
from stackit.ske.models.kubernetes import Kubernetes
from stackit.ske.models.machine import Machine
from stackit.ske.models.network import Network
from stackit.ske.models.nodepool import Nodepool
from stackit.ske.models.v2_control_plane_network import V2ControlPlaneNetwork
from stackit.ske.models.volume import Volume
nodepool = Nodepool(
name="np",
availabilityZones=["eu01-1"],
maximum=1,
minimum=1,
machine=Machine(image=Image(name="flatcar", version="1.0"), type="g1.2"),
volume=Volume(size=20),
)
return SDKCluster(
name=name,
kubernetes=Kubernetes(version="1.31.0"),
nodepools=[nodepool],
extensions=Extension(acl=acl) if acl is not None else None,
network=(
Network(controlPlane=V2ControlPlaneNetwork(accessScope=access_scope))
if access_scope is not None
else None
),
)
@staticmethod
def _sdk_acl(allowed_cidrs, enabled=True):
"""Build a real ``stackit.ske`` ACL extension model."""
from stackit.ske.models.acl import ACL
return ACL(allowedCidrs=allowed_cidrs, enabled=enabled)
def test_sdk_model_acl_is_parsed(self):
cluster = self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"]))
assert SKEService._parse_acl(cluster) == (True, ["10.0.0.0/8"])
def test_sdk_model_without_extensions_reports_no_acl(self):
cluster = self._sdk_cluster("open-clst")
assert SKEService._parse_acl(cluster) == (False, [])
assert SKEService._parse_access_scope(cluster) is None
def test_sdk_model_access_scope_enum_is_normalized(self):
cluster = self._sdk_cluster("sna-clst", access_scope="SNA")
assert SKEService._parse_access_scope(cluster) == "SNA"
def test_list_clusters_with_sdk_response_and_model_items(self):
from stackit.ske.models.list_clusters_response import ListClustersResponse
service = object.__new__(SKEService)
service.provider = MagicMock()
service.project_id = STACKIT_PROJECT_ID
service.clusters = []
client = MagicMock()
client.list_clusters.return_value = ListClustersResponse(
items=[
self._sdk_cluster("open-clst"),
self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"])),
]
)
service._list_clusters(client, "eu01")
assert [cluster.name for cluster in service.clusters] == [
"open-clst",
"locked-clst",
]
# An object-shaped ACL must not be dropped: the restricted cluster is
# not reported as internet-exposed, and the open one still is.
assert service.clusters[0].has_public_endpoint() is True
assert service.clusters[1].acl_enabled is True
assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"]
assert service.clusters[1].has_public_endpoint() is False
def test_dict_and_sdk_model_shapes_parse_identically(self):
"""The same cluster expressed as a dict or an SDK model must agree."""
sdk_cluster = self._sdk_cluster(
"locked-clst",
acl=self._sdk_acl(["10.0.0.0/8"]),
access_scope="PUBLIC",
)
dict_cluster = {
"name": "locked-clst",
"extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}},
"network": {"controlPlane": {"accessScope": "PUBLIC"}},
}
assert SKEService._parse_acl(sdk_cluster) == SKEService._parse_acl(dict_cluster)
assert SKEService._parse_access_scope(
sdk_cluster
) == SKEService._parse_access_scope(dict_cluster)
class Test_SKE_Cluster_Model:
"""Tests for the Cluster public-endpoint logic."""
def _cluster(self, **kwargs):
defaults = {
"id": "test-cluster",
"name": "test-cluster",
"project_id": STACKIT_PROJECT_ID,
"region": "eu01",
}
defaults.update(kwargs)
return Cluster(**defaults)
def test_private_control_plane_passes_regardless_of_acl(self):
cluster = self._cluster(access_scope="SNA", acl_enabled=False)
assert cluster.has_private_control_plane() is True
assert cluster.has_public_endpoint() is False
def test_disabled_acl_is_publicly_reachable(self):
cluster = self._cluster(acl_enabled=False)
assert cluster.has_private_control_plane() is False
assert cluster.has_public_endpoint() is True
def test_public_scope_with_restricted_acl_is_not_publicly_reachable(self):
cluster = self._cluster(
access_scope="PUBLIC", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
)
assert cluster.unrestricted_cidrs() == []
assert cluster.has_public_endpoint() is False
@pytest.mark.parametrize("unrestricted_cidr", ["0.0.0.0/0", "::/0"])
def test_unrestricted_cidr_in_allowlist_is_publicly_reachable(
self, unrestricted_cidr
):
cluster = self._cluster(
acl_enabled=True, allowed_cidrs=["10.0.0.0/8", unrestricted_cidr]
)
assert cluster.unrestricted_cidrs() == [unrestricted_cidr]
assert cluster.has_public_endpoint() is True
def test_enabled_acl_with_empty_allowlist_is_not_publicly_reachable(self):
cluster = self._cluster(acl_enabled=True, allowed_cidrs=[])
assert cluster.has_public_endpoint() is False
def test_unset_access_scope_falls_back_to_the_acl(self):
assert (
self._cluster(
access_scope=None, acl_enabled=True, allowed_cidrs=["10.0.0.0/8"]
).has_public_endpoint()
is False
)
assert (
self._cluster(access_scope=None, acl_enabled=False).has_public_endpoint()
is True
)
@@ -276,6 +276,40 @@ class TestStackITProviderTestConnection:
(None, self.KEY_CONTENT, self.PROJECT_ID)
]
def test_connection_resource_manager_403_does_not_fail_service_discovery(
self, fake_stackit_resourcemanager
):
class Http403Error(Exception):
status = 403
fake_stackit_resourcemanager.error = Http403Error()
with patch.object(stackit_provider_module.logger, "warning") as warning:
connection = StackitProvider.test_connection(
project_id=self.PROJECT_ID,
service_account_key_path=self.KEY_PATH,
)
assert connection == Connection(is_connected=True)
warning.assert_called_once_with(
"StackIT test_connection: Resource Manager access could not be verified "
"(403). Service permissions will be checked during discovery."
)
def test_connection_resource_manager_401_still_fails(
self, fake_stackit_resourcemanager
):
class Http401Error(Exception):
status = 401
fake_stackit_resourcemanager.error = Http401Error()
with pytest.raises(StackITInvalidTokenError):
StackitProvider.test_connection(
project_id=self.PROJECT_ID,
service_account_key_path=self.KEY_PATH,
)
def test_connection_returns_error_when_raise_on_exception_is_false(
self, fake_stackit_resourcemanager
):