Files
prowler/tests/providers/stackit/services/iaas/iaas_service_test.py
T
2026-05-28 13:16:38 +02:00

516 lines
19 KiB
Python

from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError
from prowler.providers.stackit.services.iaas.iaas_service import (
IaaSService,
SecurityGroupRule,
)
from tests.providers.stackit.stackit_fixtures import (
STACKIT_PROJECT_ID,
set_mocked_stackit_provider,
)
def mock_iaas_fetch_all_regions(_):
"""Mock the _fetch_all_regions method to avoid real API calls."""
@patch(
"prowler.providers.stackit.services.iaas.iaas_service.IaaSService._fetch_all_regions",
new=mock_iaas_fetch_all_regions,
)
class Test_IaaS_Service:
def test_service_initialization(self):
"""Test that the IaaS service initializes correctly."""
iaas_service = IaaSService(set_mocked_stackit_provider())
assert iaas_service.project_id == STACKIT_PROJECT_ID
assert iaas_service.service_account_key_path is not None
assert isinstance(iaas_service.security_groups, list)
assert isinstance(iaas_service.server_nics, list)
assert isinstance(iaas_service.in_use_sg_ids, set)
assert iaas_service.scan_unused_services is False
def test_service_project_id(self):
"""Test that the service correctly extracts project_id from provider."""
iaas_service = IaaSService(set_mocked_stackit_provider())
assert iaas_service.project_id == STACKIT_PROJECT_ID
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)
iaas_service = IaaSService(provider)
assert iaas_service.service_account_key_path == custom_path
def test_security_groups_list_structure(self):
"""Test that security_groups is properly initialized as a list."""
iaas_service = IaaSService(set_mocked_stackit_provider())
assert hasattr(iaas_service, "security_groups")
assert isinstance(iaas_service.security_groups, list)
def test_in_use_sg_ids_set_structure(self):
"""Test that in_use_sg_ids is properly initialized as a set."""
iaas_service = IaaSService(set_mocked_stackit_provider())
assert hasattr(iaas_service, "in_use_sg_ids")
assert isinstance(iaas_service.in_use_sg_ids, set)
@pytest.mark.parametrize(
"method_name,client_method_name",
[
("_list_server_nics", "list_project_nics"),
("_list_security_groups", "list_security_groups"),
("_list_security_group_rules", "list_security_group_rules"),
],
)
def test_list_methods_propagate_api_errors(self, method_name, client_method_name):
"""API/auth failures must fail the scan instead of returning empty data."""
iaas_service = IaaSService(set_mocked_stackit_provider())
client = MagicMock()
getattr(client, client_method_name).side_effect = StackITInvalidTokenError(
message="Invalid token"
)
with pytest.raises(StackITInvalidTokenError):
if method_name == "_list_security_group_rules":
getattr(iaas_service, method_name)(client, "eu01", "sg-1")
else:
getattr(iaas_service, method_name)(client, "eu01")
def test_security_group_parsing_errors_are_skipped_locally(self):
"""Malformed resources are skipped while valid resources are retained."""
class MalformedSecurityGroup:
@property
def id(self):
raise ValueError("malformed security group")
iaas_service = IaaSService(set_mocked_stackit_provider())
client = MagicMock()
client.list_security_groups.return_value = [
MalformedSecurityGroup(),
{"id": "sg-1", "name": "valid-sg"},
]
client.list_security_group_rules.return_value = []
iaas_service._list_security_groups(client, "eu01")
assert len(iaas_service.security_groups) == 1
assert iaas_service.security_groups[0].id == "sg-1"
def test_in_use_considers_all_nics_not_only_public(self):
"""A SG attached to any NIC (public or private) counts as in_use."""
iaas_service = IaaSService(set_mocked_stackit_provider())
# NIC without a public IP, but has a security group attached
private_nic = {"id": "nic-private", "security_groups": ["sg-private"]}
used = iaas_service._get_used_security_group_ids([private_nic])
assert "sg-private" in used
def test_in_use_sg_ids_populated_via_list_server_nics(self):
"""_list_server_nics marks SGs on any NIC as in_use."""
iaas_service = IaaSService(set_mocked_stackit_provider())
client = MagicMock()
client.list_project_nics.return_value = [
{"id": "nic-1", "security_groups": ["sg-1"]},
{"id": "nic-2", "security_groups": ["sg-2"]},
]
iaas_service._list_server_nics(client, "eu01")
assert "sg-1" in iaas_service.in_use_sg_ids
assert "sg-2" in iaas_service.in_use_sg_ids
# Test SecurityGroupRule helper methods
class Test_SecurityGroupRule:
def test_is_unrestricted_with_none(self):
"""Test that None ip_range is considered unrestricted."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range=None,
port_range_min=22,
port_range_max=22,
)
assert rule.is_unrestricted() is True
def test_is_unrestricted_with_cidr(self):
"""Test that 0.0.0.0/0 is considered unrestricted."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_unrestricted() is True
def test_is_unrestricted_with_ipv6(self):
"""Test that ::/0 is considered unrestricted."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="::/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_unrestricted() is True
def test_is_restricted_with_specific_ip(self):
"""Test that specific IP range is considered restricted."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="10.0.0.0/8",
port_range_min=22,
port_range_max=22,
)
assert rule.is_unrestricted() is False
def test_is_ingress_true(self):
"""Test that ingress direction is properly detected."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_ingress() is True
def test_is_ingress_false(self):
"""Test that egress direction returns false for is_ingress."""
rule = SecurityGroupRule(
id="rule-1",
direction="egress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_ingress() is False
def test_is_tcp_with_tcp_protocol(self):
"""Test that TCP protocol is detected correctly."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_tcp() is True
def test_is_tcp_with_none_protocol(self):
"""Test that None protocol is treated as TCP (all protocols)."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol=None,
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_tcp() is True
def test_is_tcp_with_all_protocol(self):
"""Test that 'all' protocol is treated as TCP."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="all",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_tcp() is True
def test_is_tcp_with_udp_protocol(self):
"""Test that UDP protocol returns false for is_tcp."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="udp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.is_tcp() is False
def test_includes_port_exact_match(self):
"""Test that exact port match is detected."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=22,
port_range_max=22,
)
assert rule.includes_port(22) is True
def test_includes_port_in_range(self):
"""Test that port within range is detected."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=20,
port_range_max=25,
)
assert rule.includes_port(22) is True
assert rule.includes_port(20) is True
assert rule.includes_port(25) is True
def test_includes_port_outside_range(self):
"""Test that port outside range is not detected."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=80,
port_range_max=443,
)
assert rule.includes_port(22) is False
def test_includes_port_with_none_range(self):
"""Test that None port range means all ports."""
rule = SecurityGroupRule(
id="rule-1",
direction="ingress",
protocol="tcp",
ip_range="0.0.0.0/0",
port_range_min=None,
port_range_max=None,
)
assert rule.includes_port(22) is True
assert rule.includes_port(443) is True
assert rule.includes_port(65535) is True
class Test_IaaS_Service_Extract_Items:
"""Cover ``IaaSService._extract_items`` against the three response shapes the
StackIT SDK can return. The previous implementation matched ``dict`` via
``hasattr(response, "items")`` and returned the bound method instead of the
items field.
"""
def test_extract_items_from_sdk_model(self):
"""SDK models expose ``items`` as a non-callable attribute."""
response = MagicMock(spec=["items"])
sentinel = [{"id": "sg-1"}, {"id": "sg-2"}]
response.items = sentinel
assert IaaSService._extract_items(response, "endpoint") is sentinel
def test_extract_items_from_dict_with_items_key(self):
"""Dict responses must use the ``items`` key, not ``dict.items()``."""
response = {"items": [{"id": "sg-1"}]}
assert IaaSService._extract_items(response, "endpoint") == [{"id": "sg-1"}]
def test_extract_items_from_empty_dict(self):
"""An empty dict yields an empty list, not the ``dict.items`` method."""
assert IaaSService._extract_items({}, "endpoint") == []
def test_extract_items_from_list(self):
"""A plain list response is returned as-is."""
response = [{"id": "sg-1"}]
assert IaaSService._extract_items(response, "endpoint") is response
def test_extract_items_unknown_shape_returns_empty(self):
"""Unknown shapes fall back to an empty list and log a warning."""
assert IaaSService._extract_items(42, "endpoint") == []
def test_extract_items_ignores_dict_items_method(self):
"""Regression: ``dict`` exposes ``items`` as a method; ensure the
``isinstance(dict)`` branch wins and we do not return the bound method.
"""
result = IaaSService._extract_items({"items": ["ok"]}, "endpoint")
assert result == ["ok"]
assert not callable(result)
class Test_IaaS_Service_Used_Security_Group_Ids:
"""Cover ``_get_used_security_group_ids``. The SDK returns NIC security
group references as ``uuid.UUID`` while a security group id is a ``str``;
they must be normalized to ``str`` so the in-use membership test matches.
"""
def _service(self):
return object.__new__(IaaSService)
def test_uuid_references_are_normalized_to_str(self):
from uuid import UUID
sg_uuid = UUID("2040c1fa-72a6-47bc-a53b-f62075ae6d35")
nic = MagicMock(spec=["security_groups"])
nic.security_groups = [sg_uuid]
used = self._service()._get_used_security_group_ids([nic])
# Stored as the string form so `str(sg.id) in used` matches.
assert used == {"2040c1fa-72a6-47bc-a53b-f62075ae6d35"}
assert all(isinstance(x, str) for x in used)
def test_dict_nic_camelcase_security_groups_key(self):
nic = {"securityGroups": ["sg-aaaa", "sg-bbbb"]}
used = self._service()._get_used_security_group_ids([nic])
assert used == {"sg-aaaa", "sg-bbbb"}
def test_empty_nic_security_groups(self):
nic = MagicMock(spec=["security_groups"])
nic.security_groups = []
assert self._service()._get_used_security_group_ids([nic]) == set()
def test_in_use_matches_uuid_reference_end_to_end(self):
"""A security group whose id (str) matches a NIC reference (UUID) must
be flagged in_use=True after a full region fetch.
"""
from uuid import UUID
sg_id = "2040c1fa-72a6-47bc-a53b-f62075ae6d35"
client = MagicMock()
nic = MagicMock(spec=["security_groups"])
nic.security_groups = [UUID(sg_id)]
client.list_project_nics.return_value = {"items": [nic]}
client.list_security_groups.return_value = {"items": [{"id": sg_id}]}
client.list_security_group_rules.return_value = {"items": []}
from prowler.providers.stackit.stackit_provider import StackitProvider
service = object.__new__(IaaSService)
service.provider = MagicMock()
service.provider.handle_api_error = StackitProvider.handle_api_error
service.project_id = STACKIT_PROJECT_ID
service.scan_unused_services = False
service.regional_clients = {"eu01": client}
service.security_groups = []
service.server_nics = []
service.in_use_sg_ids = set()
service._fetch_all_regions()
assert len(service.security_groups) == 1
assert service.security_groups[0].in_use is True
class Test_IaaS_Service_Fetch_All_Regions:
"""Cover ``_fetch_all_regions`` multi-region behaviour. A project is not
provisioned in every StackIT region; the region where it is absent answers
with HTTP 404. That must be skipped, not abort the whole scan (which
previously left every check failing to load with an empty report).
"""
class _NotFound(Exception):
status = 404
class _Forbidden(Exception):
status = 403
def _service(self, regional_clients):
from prowler.providers.stackit.stackit_provider import StackitProvider
service = object.__new__(IaaSService)
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.scan_unused_services = True
service.regional_clients = regional_clients
service.security_groups = []
service.server_nics = []
service.in_use_sg_ids = set()
return service
def _good_client(self, sg_id="sg-eu01"):
client = MagicMock()
client.list_project_nics.return_value = {"items": []}
client.list_security_groups.return_value = {"items": [{"id": sg_id}]}
client.list_security_group_rules.return_value = {"items": []}
return client
def _missing_region_client(self):
client = MagicMock()
client.list_project_nics.side_effect = self._NotFound()
client.list_security_groups.side_effect = self._NotFound()
return client
def test_skips_region_where_project_is_absent(self):
service = self._service(
{"eu01": self._good_client(), "eu02": self._missing_region_client()}
)
service._fetch_all_regions()
# eu01 security group is collected; the eu02 404 is skipped silently.
assert [sg.id for sg in service.security_groups] == ["sg-eu01"]
def test_403_still_aborts(self):
bad = MagicMock()
bad.list_project_nics.side_effect = self._Forbidden()
service = self._service({"eu01": bad})
with pytest.raises(StackITInvalidTokenError):
service._fetch_all_regions()
class Test_IaaS_Service_Log_Skipped_Security_Groups:
"""``_log_skipped_security_groups`` should emit a hint only when groups
exist, none are in use, and ``scan_unused_services`` is off.
"""
def _service(self, security_groups, scan_unused_services):
service = object.__new__(IaaSService)
service.scan_unused_services = scan_unused_services
service.security_groups = security_groups
return service
def _sg(self, in_use):
sg = MagicMock()
sg.in_use = in_use
return sg
def test_logs_when_all_skipped(self, caplog):
import logging
service = self._service([self._sg(False), self._sg(False)], False)
with caplog.at_level(logging.INFO):
service._log_skipped_security_groups()
assert "scan-unused-services" in caplog.text
def test_no_log_when_scan_unused_services_enabled(self, caplog):
import logging
service = self._service([self._sg(False)], True)
with caplog.at_level(logging.INFO):
service._log_skipped_security_groups()
assert "scan-unused-services" not in caplog.text
def test_no_log_when_a_group_is_in_use(self, caplog):
import logging
service = self._service([self._sg(False), self._sg(True)], False)
with caplog.at_level(logging.INFO):
service._log_skipped_security_groups()
assert "scan-unused-services" not in caplog.text
def test_no_log_when_no_security_groups(self, caplog):
import logging
service = self._service([], False)
with caplog.at_level(logging.INFO):
service._log_skipped_security_groups()
assert "scan-unused-services" not in caplog.text