mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
fix(image): scope the outbound host check to Prowler App
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user