mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
1af9cdd351
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
860 lines
31 KiB
Python
860 lines
31 KiB
Python
from unittest.mock import MagicMock, patch
|
|
from uuid import UUID
|
|
|
|
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
|
|
assert isinstance(iaas_service.servers, list)
|
|
assert isinstance(iaas_service._nic_device_index, dict)
|
|
assert isinstance(iaas_service._public_ip_server_ids, set)
|
|
|
|
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"),
|
|
("_list_public_ips", "list_public_ips"),
|
|
("_list_servers", "list_servers"),
|
|
],
|
|
)
|
|
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()
|
|
service.servers = []
|
|
service._nic_device_index = {}
|
|
service._public_ip_server_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": []}
|
|
client.list_public_ips.return_value = {"items": []}
|
|
client.list_servers.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
|
|
|
|
|
|
class Test_IaaS_Service_NIC_Device_Index:
|
|
"""NIC device index is built during _list_server_nics to enable
|
|
public IP → server cross-reference.
|
|
"""
|
|
|
|
def _service(self):
|
|
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.server_nics = []
|
|
service.in_use_sg_ids = set()
|
|
service._nic_device_index = {}
|
|
return service
|
|
|
|
def test_nic_with_id_and_device_is_indexed(self):
|
|
service = self._service()
|
|
nic_id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
device_id = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
|
nic = MagicMock()
|
|
nic.id = nic_id
|
|
nic.device = device_id
|
|
nic.security_groups = []
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {"items": [nic]}
|
|
|
|
service._list_server_nics(client, "eu01")
|
|
|
|
assert str(nic_id) in service._nic_device_index
|
|
assert service._nic_device_index[str(nic_id)] == str(device_id)
|
|
|
|
def test_nic_without_device_is_not_indexed(self):
|
|
service = self._service()
|
|
nic = MagicMock()
|
|
nic.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
nic.device = None
|
|
nic.security_groups = []
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {"items": [nic]}
|
|
|
|
service._list_server_nics(client, "eu01")
|
|
|
|
assert service._nic_device_index == {}
|
|
|
|
def test_nic_indexing_error_is_skipped(self):
|
|
"""A NIC that raises while reading its id is skipped, not fatal."""
|
|
|
|
class MalformedNIC:
|
|
@property
|
|
def id(self):
|
|
raise ValueError("malformed nic")
|
|
|
|
service = self._service()
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {"items": [MalformedNIC()]}
|
|
|
|
service._list_server_nics(client, "eu01")
|
|
|
|
assert service._nic_device_index == {}
|
|
|
|
def test_dict_shaped_nic_is_indexed(self):
|
|
"""Raw dict NICs are indexed the same as SDK model NICs."""
|
|
service = self._service()
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {
|
|
"items": [{"id": "nic-1", "device": "server-1", "security_groups": []}]
|
|
}
|
|
|
|
service._list_server_nics(client, "eu01")
|
|
|
|
assert service._nic_device_index == {"nic-1": "server-1"}
|
|
|
|
|
|
class Test_IaaS_Service_PublicIps:
|
|
"""Tests for _list_public_ips."""
|
|
|
|
def _service(self):
|
|
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._nic_device_index = {}
|
|
service._public_ip_server_ids = set()
|
|
return service
|
|
|
|
def test_unattached_ip_is_ignored(self):
|
|
service = self._service()
|
|
ip = MagicMock()
|
|
ip.network_interface = None
|
|
client = MagicMock()
|
|
client.list_public_ips.return_value = {"items": [ip]}
|
|
|
|
service._list_public_ips(client, "eu01")
|
|
|
|
assert service._public_ip_server_ids == set()
|
|
|
|
def test_attached_ip_with_known_nic_marks_server(self):
|
|
nic_id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
server_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
service = self._service()
|
|
service._nic_device_index = {str(nic_id): server_id}
|
|
ip = MagicMock()
|
|
ip.network_interface = nic_id
|
|
client = MagicMock()
|
|
client.list_public_ips.return_value = {"items": [ip]}
|
|
|
|
service._list_public_ips(client, "eu01")
|
|
|
|
assert server_id in service._public_ip_server_ids
|
|
|
|
def test_attached_ip_with_unknown_nic_is_ignored(self):
|
|
service = self._service()
|
|
service._nic_device_index = {}
|
|
ip = MagicMock()
|
|
ip.network_interface = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
client = MagicMock()
|
|
client.list_public_ips.return_value = {"items": [ip]}
|
|
|
|
service._list_public_ips(client, "eu01")
|
|
|
|
assert service._public_ip_server_ids == set()
|
|
|
|
def test_list_public_ips_without_client_is_noop(self):
|
|
"""A missing regional client is logged and skipped, not fatal."""
|
|
service = self._service()
|
|
|
|
service._list_public_ips(None, "eu01")
|
|
|
|
assert service._public_ip_server_ids == set()
|
|
|
|
def test_public_ip_processing_error_is_skipped(self):
|
|
"""A public IP that raises while being read is skipped, not fatal."""
|
|
|
|
class MalformedIP:
|
|
@property
|
|
def network_interface(self):
|
|
raise ValueError("malformed public ip")
|
|
|
|
service = self._service()
|
|
client = MagicMock()
|
|
client.list_public_ips.return_value = {"items": [MalformedIP()]}
|
|
|
|
service._list_public_ips(client, "eu01")
|
|
|
|
assert service._public_ip_server_ids == set()
|
|
|
|
def test_dict_shaped_public_ip_marks_server(self):
|
|
"""Raw dict public IPs (camelCase networkInterface) mark the server."""
|
|
service = self._service()
|
|
service._nic_device_index = {"nic-1": "server-1"}
|
|
client = MagicMock()
|
|
client.list_public_ips.return_value = {"items": [{"networkInterface": "nic-1"}]}
|
|
|
|
service._list_public_ips(client, "eu01")
|
|
|
|
assert "server-1" in service._public_ip_server_ids
|
|
|
|
|
|
class Test_IaaS_Service_Servers:
|
|
"""Tests for _list_servers."""
|
|
|
|
def _service(self):
|
|
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.servers = []
|
|
service._public_ip_server_ids = set()
|
|
return service
|
|
|
|
def test_server_without_public_ip(self):
|
|
server_id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
service = self._service()
|
|
server_data = MagicMock()
|
|
server_data.id = server_id
|
|
server_data.name = "my-server"
|
|
client = MagicMock()
|
|
client.list_servers.return_value = {"items": [server_data]}
|
|
|
|
service._list_servers(client, "eu01")
|
|
|
|
assert len(service.servers) == 1
|
|
assert service.servers[0].has_public_ip is False
|
|
|
|
def test_server_with_public_ip(self):
|
|
server_id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
service = self._service()
|
|
service._public_ip_server_ids = {str(server_id)}
|
|
server_data = MagicMock()
|
|
server_data.id = server_id
|
|
server_data.name = "my-server"
|
|
client = MagicMock()
|
|
client.list_servers.return_value = {"items": [server_data]}
|
|
|
|
service._list_servers(client, "eu01")
|
|
|
|
assert len(service.servers) == 1
|
|
assert service.servers[0].has_public_ip is True
|
|
|
|
def test_empty_response(self):
|
|
service = self._service()
|
|
client = MagicMock()
|
|
client.list_servers.return_value = {"items": []}
|
|
|
|
service._list_servers(client, "eu01")
|
|
|
|
assert service.servers == []
|
|
|
|
def test_server_public_ip_detected_end_to_end(self):
|
|
"""Full cross-reference: NIC → public IP → server flagged has_public_ip."""
|
|
from prowler.providers.stackit.stackit_provider import StackitProvider
|
|
|
|
nic_id = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc")
|
|
server_id = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
|
|
|
nic = MagicMock()
|
|
nic.id = nic_id
|
|
nic.device = server_id
|
|
nic.security_groups = []
|
|
|
|
ip = MagicMock()
|
|
ip.network_interface = nic_id
|
|
|
|
server_data = MagicMock()
|
|
server_data.id = server_id
|
|
server_data.name = "internet-server"
|
|
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {"items": [nic]}
|
|
client.list_security_groups.return_value = {"items": []}
|
|
client.list_public_ips.return_value = {"items": [ip]}
|
|
client.list_servers.return_value = {"items": [server_data]}
|
|
|
|
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.servers = []
|
|
service._nic_device_index = {}
|
|
service._public_ip_server_ids = set()
|
|
|
|
service._fetch_all_regions()
|
|
|
|
assert len(service.servers) == 1
|
|
assert service.servers[0].has_public_ip is True
|
|
|
|
def test_list_servers_without_client_is_noop(self):
|
|
"""A missing regional client is logged and skipped, not fatal."""
|
|
service = self._service()
|
|
|
|
service._list_servers(None, "eu01")
|
|
|
|
assert service.servers == []
|
|
|
|
def test_server_processing_error_is_skipped(self):
|
|
"""A server that raises while being read is skipped, not fatal."""
|
|
|
|
class MalformedServer:
|
|
@property
|
|
def id(self):
|
|
raise ValueError("malformed server")
|
|
|
|
service = self._service()
|
|
client = MagicMock()
|
|
client.list_servers.return_value = {"items": [MalformedServer()]}
|
|
|
|
service._list_servers(client, "eu01")
|
|
|
|
assert service.servers == []
|
|
|
|
def test_dict_shaped_server_is_created(self):
|
|
"""Raw dict servers are created and flagged from _public_ip_server_ids."""
|
|
service = self._service()
|
|
service._public_ip_server_ids = {"server-1"}
|
|
client = MagicMock()
|
|
client.list_servers.return_value = {
|
|
"items": [{"id": "server-1", "name": "dict-server"}]
|
|
}
|
|
|
|
service._list_servers(client, "eu01")
|
|
|
|
assert len(service.servers) == 1
|
|
assert service.servers[0].name == "dict-server"
|
|
assert service.servers[0].has_public_ip is True
|
|
|
|
def test_dict_shaped_public_ip_detected_end_to_end(self):
|
|
"""Dict-shaped NIC/IP/server still correlate to has_public_ip=True.
|
|
|
|
Regression for the getattr-only correlation path: a dict-shaped
|
|
response must not silently report a PASS for an exposed server.
|
|
"""
|
|
from prowler.providers.stackit.stackit_provider import StackitProvider
|
|
|
|
client = MagicMock()
|
|
client.list_project_nics.return_value = {
|
|
"items": [{"id": "nic-1", "device": "server-1", "security_groups": []}]
|
|
}
|
|
client.list_security_groups.return_value = {"items": []}
|
|
client.list_public_ips.return_value = {"items": [{"networkInterface": "nic-1"}]}
|
|
client.list_servers.return_value = {
|
|
"items": [{"id": "server-1", "name": "dict-server"}]
|
|
}
|
|
|
|
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.servers = []
|
|
service._nic_device_index = {}
|
|
service._public_ip_server_ids = set()
|
|
|
|
service._fetch_all_regions()
|
|
|
|
assert len(service.servers) == 1
|
|
assert service.servers[0].has_public_ip is True
|