fix(image): scope the outbound host check to Prowler App

This commit is contained in:
pedrooot committed 2026-10-07 17:09:15 +02:00
1 parent 7829404728
commit 23999ef7d9
4 files changed
+68 -3

No files matched your search

@@ -336,13 +336,19 @@ Skipping TLS verification disables certificate validation for registry connectio
<VersionBadge version="5.41.0" />
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
+7
View File
@@ -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()
@@ -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:
@@ -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."""