diff --git a/prowler/changelog.d/iac-connection-test-ssrf.security.md b/prowler/changelog.d/iac-connection-test-ssrf.security.md new file mode 100644 index 0000000000..4a175c4d07 --- /dev/null +++ b/prowler/changelog.d/iac-connection-test-ssrf.security.md @@ -0,0 +1 @@ +IaC connection test rejects repository URLs resolving to loopback, private or link-local hosts and stops echoing raw errors to the caller diff --git a/prowler/lib/network/__init__.py b/prowler/lib/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/network/ssrf.py b/prowler/lib/network/ssrf.py new file mode 100644 index 0000000000..d039c58084 --- /dev/null +++ b/prowler/lib/network/ssrf.py @@ -0,0 +1,138 @@ +"""Outbound URL validation for provider connection tests.""" + +from __future__ import annotations + +import ipaddress +import os +import re +import socket +from urllib.parse import urlparse + +from prowler.lib.logger import logger + +ALLOWED_PRIVATE_NETWORKS_ENV = "PROWLER_ALLOWED_PRIVATE_NETWORKS" + +_NON_PUBLIC_IP_PROPERTIES = ( + "is_private", + "is_loopback", + "is_link_local", + "is_multicast", + "is_reserved", + "is_unspecified", +) + +# scp-like git remotes (user@host:path) carry no scheme, so urlparse cannot read them +_SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?(?P[^:/]+):(?!//)") + +_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") + + +class OutboundURLNotAllowedError(Exception): + """A supplied URL points at a destination the worker must not reach.""" + + +def _parse_allowed_networks(raw: str | None) -> tuple: + if not raw or not raw.strip(): + return () + networks = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + networks.append(ipaddress.ip_network(entry, strict=False)) + except ValueError as error: + raise OutboundURLNotAllowedError( + f"Malformed entry {entry!r} in {ALLOWED_PRIVATE_NETWORKS_ENV}: {error}" + ) + return tuple(networks) + + +def allowed_private_networks() -> tuple: + """Operator-configured private networks the SSRF guard must not block.""" + networks = _parse_allowed_networks(os.environ.get(ALLOWED_PRIVATE_NETWORKS_ENV)) + if networks: + logger.warning( + f"{ALLOWED_PRIVATE_NETWORKS_ENV} is set — SSRF protection relaxed for private networks: " + + ", ".join(str(network) for network in networks) + ) + return networks + + +def _unwrap_ipv6(address: ipaddress._BaseAddress) -> ipaddress._BaseAddress: + if not isinstance(address, ipaddress.IPv6Address): + return address + embedded = address.ipv4_mapped or address.sixtofour + if embedded is None and address in _NAT64_WELL_KNOWN_PREFIX: + embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF) + return embedded or address + + +def _ip_is_non_public(address: str) -> bool: + try: + parsed = _unwrap_ipv6(ipaddress.ip_address(address)) + except ValueError: + return False + return any(getattr(parsed, prop) for prop in _NON_PUBLIC_IP_PROPERTIES) + + +def _ip_is_allowlisted(address: str, networks: tuple) -> bool: + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return False + return any( + parsed.version == network.version and parsed in network for network in networks + ) + + +def _resolve(host: str) -> set: + try: + return {sockaddr[0] for *_, sockaddr in socket.getaddrinfo(host, None)} + except socket.gaierror as error: + raise OutboundURLNotAllowedError(f"Could not resolve host {host!r}: {error}") + + +def extract_host(url: str) -> str: + """Host of a URL, accepting scp-like git remotes that carry no scheme.""" + scp_like = _SCP_LIKE_REMOTE.match(url) + if scp_like and "://" not in url: + return scp_like.group("host") + host = urlparse(url).hostname + if not host: + raise OutboundURLNotAllowedError(f"Could not read a host from URL {url!r}") + return host + + +def validate_outbound_host(host: str) -> None: + """Reject a host that is, or resolves to, a non-public address. + + Resolution happens here and again inside the client that connects, so a + hostile DNS server can still answer differently the second time. + """ + networks = allowed_private_networks() + + try: + ipaddress.ip_address(host) + except ValueError: + addresses = _resolve(host) + else: + addresses = {host} + + for address in addresses: + if _ip_is_non_public(address) and not _ip_is_allowlisted(address, networks): + raise OutboundURLNotAllowedError( + f"Host {host!r} resolves to non-public address {address} and cannot be reached" + ) + + +def validate_outbound_url( + url: str, *, allowed_schemes: tuple = ("http", "https") +) -> None: + """Reject a URL whose scheme is not allowed or whose host is not public.""" + scheme = urlparse(url).scheme + if scheme and scheme not in allowed_schemes: + raise OutboundURLNotAllowedError( + f"Disallowed URL scheme {scheme!r}. Allowed: {', '.join(allowed_schemes)}" + ) + validate_outbound_host(extract_host(url)) diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index f9477bc5ce..3c6a05445d 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -17,6 +17,10 @@ from prowler.config.config import ( ) from prowler.lib.check.models import CheckReportIAC from prowler.lib.logger import logger +from prowler.lib.network.ssrf import ( + OutboundURLNotAllowedError, + validate_outbound_url, +) from prowler.lib.utils.utils import print_boxes from prowler.lib.utils.vulnerability_references import ( build_finding_reference_url, @@ -652,6 +656,18 @@ class IacProvider(Provider): is_connected=False, error="Repository URL is required" ) + try: + validate_outbound_url( + scan_repository_url, + allowed_schemes=("http", "https", "ssh", "git"), + ) + except OutboundURLNotAllowedError as error: + logger.warning(f"Rejected IaC repository URL: {error}") + return Connection( + is_connected=False, + error="Repository URL is not an allowed destination.", + ) + # Try to clone the repository to test the connection with tempfile.TemporaryDirectory(): try: @@ -676,7 +692,10 @@ class IacProvider(Provider): return Connection(is_connected=True) except Exception as e: + # The raw error may carry the token-bearing URL or the + # remote's response, so it is logged and never returned error_msg = str(e) + logger.error(f"IaC repository connection test failed: {error_msg}") if "authentication" in error_msg.lower() or "401" in error_msg: return Connection( is_connected=False, @@ -690,13 +709,14 @@ class IacProvider(Provider): else: return Connection( is_connected=False, - error=f"Failed to connect to repository: {error_msg}", + error="Failed to connect to repository.", ) except Exception as error: if raise_on_exception: raise + logger.error(f"Unexpected error testing IaC repository connection: {error}") return Connection( is_connected=False, - error=f"Unexpected error testing connection: {str(error)}", + error="Unexpected error testing connection.", ) diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index fe51aacb8c..b08b11a84f 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -878,3 +878,67 @@ class TestIacProvider: # Pass a non-existent directory branch_name = provider._detect_branch_name("/non/existent/path") assert branch_name == "main" + + def test_test_connection_rejects_loopback_url(self): + with patch( + "prowler.providers.iac.iac_provider.porcelain.ls_remote" + ) as mock_ls_remote: + connection = IacProvider.test_connection( + scan_repository_url="https://127.0.0.1/user/repo.git" + ) + + assert connection.is_connected is False + assert connection.error == "Repository URL is not an allowed destination." + mock_ls_remote.assert_not_called() + + def test_test_connection_rejects_private_range_url(self): + with patch( + "prowler.providers.iac.iac_provider.porcelain.ls_remote" + ) as mock_ls_remote: + connection = IacProvider.test_connection( + scan_repository_url="https://10.0.0.1/user/repo.git" + ) + + assert connection.is_connected is False + assert connection.error == "Repository URL is not an allowed destination." + mock_ls_remote.assert_not_called() + + def test_test_connection_allows_public_url(self): + with ( + patch( + "prowler.lib.network.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("140.82.121.4", 0))], + ) as mock_getaddrinfo, + patch( + "prowler.providers.iac.iac_provider.porcelain.ls_remote" + ) as mock_ls_remote, + ): + connection = IacProvider.test_connection( + scan_repository_url="https://github.com/user/repo.git" + ) + + assert connection.is_connected is True + mock_getaddrinfo.assert_called_once_with("github.com", None) + mock_ls_remote.assert_called_once_with("https://github.com/user/repo.git") + + def test_test_connection_does_not_echo_raw_error(self): + with ( + patch( + "prowler.lib.network.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("140.82.121.4", 0))], + ), + patch( + "prowler.providers.iac.iac_provider.porcelain.ls_remote", + side_effect=Exception( + "https://x-access-token:SENTINEL_TOKEN@github.com/user/repo.git refused" + ), + ), + ): + connection = IacProvider.test_connection( + scan_repository_url="https://github.com/user/repo.git", + oauth_app_token="SENTINEL_TOKEN", + ) + + assert connection.is_connected is False + assert connection.error == "Failed to connect to repository." + assert "SENTINEL_TOKEN" not in connection.error