From 23999ef7d99dd146e630618d740717294a06792c Mon Sep 17 00:00:00 2001 From: pedrooot Date: Wed, 7 Oct 2026 17:09:15 +0200 Subject: [PATCH] fix(image): scope the outbound host check to Prowler App --- .../providers/image/getting-started-image.mdx | 12 ++++-- prowler/__main__.py | 7 ++++ prowler/providers/image/lib/registry/base.py | 14 +++++++ .../image/lib/registry/test_oci_adapter.py | 38 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index 6b5d30b72e..5957b78cc6 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -336,13 +336,19 @@ Skipping TLS verification disables certificate validation for registry connectio -By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly: +On-premises registries live on private networks by definition. + +**Prowler CLI scans them with no extra configuration.** On the CLI the operator supplies the registry themselves, so there is nothing to defend against and the check below does not apply. + +**Prowler App rejects a registry URL that resolves to a non-public address**, as an SSRF defense. The check covers the registry URL supplied for the scan as well as the URLs the registry itself returns — token endpoints and pagination links. There the registry arrives from a tenant and the worker must not be used to reach the internal network it runs in. To scan an on-premises registry from Prowler App, declare the trusted ranges explicitly: ```bash -export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16" +PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16" ``` -The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed. +The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. The URL scheme is always restricted to `http` and `https`, and the same-origin rule on registry-provided URLs always applies, neither of which the allowlist relaxes. + +The scan runs in the worker, not the API, so the variable has to reach the worker. With Docker Compose set it in the root `.env`; with Helm add it under `api.djangoConfig`, which is rendered into the API ConfigMap that the worker inherits through `envFrom`. The other providers use `PROWLER_ALLOWED_PRIVATE_NETWORKS`. #### Supported Registries diff --git a/prowler/__main__.py b/prowler/__main__.py index b9de47c07b..d6bb367319 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import os import sys import tempfile from os import environ @@ -236,6 +237,12 @@ def _send_ocsf_to_cloud(file_path: str) -> dict | None: def prowler(): + # On the CLI the operator supplies the target themselves, so the outbound host + # check defends nothing and would refuse their own private network. setdefault, + # so an operator who set the variable explicitly keeps their choice. The name is + # the one the registry guard reads, in `image/lib/registry/base.py`. + os.environ.setdefault("PROWLER_SKIP_OUTBOUND_HOST_CHECK", "true") + # Parse Arguments # Refactor(CLI) parser = ProwlerArgumentParser() diff --git a/prowler/providers/image/lib/registry/base.py b/prowler/providers/image/lib/registry/base.py index 571b0b7b1e..ee10944275 100644 --- a/prowler/providers/image/lib/registry/base.py +++ b/prowler/providers/image/lib/registry/base.py @@ -30,6 +30,13 @@ _ALLOWLIST_HINT = ( "PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS." ) +# Same variable the shared guard reads; `prowler/__main__.py` sets it so the CLI, +# where the operator supplies the registry themselves, is not refused its own +# private network. Unset means the check runs, so a missing setting fails safe. +SKIP_OUTBOUND_CHECK_ENV = "PROWLER_SKIP_OUTBOUND_HOST_CHECK" + +_TRUTHY = {"1", "true", "yes", "on"} + _NON_PUBLIC_IP_PROPERTIES = ( "is_private", "is_loopback", @@ -40,6 +47,10 @@ _NON_PUBLIC_IP_PROPERTIES = ( ) +def _outbound_check_skipped() -> bool: + return os.environ.get(SKIP_OUTBOUND_CHECK_ENV, "").strip().lower() in _TRUTHY + + def _ip_is_non_public(ip_str: str) -> bool: try: addr = ipaddress.ip_address(ip_str) @@ -235,6 +246,9 @@ class RegistryAdapter(ABC): message=f"URL has no host: {canonical_url}", ) + if _outbound_check_skipped(): + return canonical_url + try: ipaddress.ip_address(host) except ValueError: diff --git a/tests/providers/image/lib/registry/test_oci_adapter.py b/tests/providers/image/lib/registry/test_oci_adapter.py index 9504fd467f..14416186a9 100644 --- a/tests/providers/image/lib/registry/test_oci_adapter.py +++ b/tests/providers/image/lib/registry/test_oci_adapter.py @@ -585,6 +585,44 @@ class TestTenantSuppliedRegistryUrlValidator: assert mock_request.called +class TestOutboundCheckOptOut: + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_private_registry_is_reachable_when_the_check_is_skipped( + self, mock_request, monkeypatch + ): + monkeypatch.setenv("PROWLER_SKIP_OUTBOUND_HOST_CHECK", "true") + resp = MagicMock(status_code=200, headers={}, ok=True) + resp.json.return_value = {"repositories": ["internal-app"]} + mock_request.return_value = resp + adapter = OciRegistryAdapter(registry_url="http://10.0.0.5:5000") + + assert adapter.list_repositories() == ["internal-app"] + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_private_registry_is_refused_when_the_variable_is_unset( + self, mock_request, monkeypatch + ): + monkeypatch.delenv("PROWLER_SKIP_OUTBOUND_HOST_CHECK", raising=False) + adapter = OciRegistryAdapter(registry_url="http://10.0.0.5:5000") + + with pytest.raises(ImageRegistryAuthError): + adapter.list_repositories() + + mock_request.assert_not_called() + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_the_scheme_check_still_applies_when_skipped( + self, mock_request, monkeypatch + ): + monkeypatch.setenv("PROWLER_SKIP_OUTBOUND_HOST_CHECK", "true") + adapter = OciRegistryAdapter(registry_url="https://registry.example.com") + + with pytest.raises(ImageRegistryAuthError, match="Disallowed URL scheme"): + adapter._validate_outbound_url("file:///etc/passwd", enforce_origin=False) + + mock_request.assert_not_called() + + class TestObtainBearerTokenAppliesValidator: """Integration: malicious Www-Authenticate realm must be rejected before the second call."""