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

This commit is contained in:
pedrooot committed 2026-10-07 17:09:06 +02:00
1 parent 2a23ec3016
commit 7fed4b21ab
4 files changed
+61 -6

No files matched your search

@@ -613,19 +613,27 @@ Using Public Cloud credentials can limit Keystone API access, so the command abo
### Private Keystone Endpoints
Most OpenStack deployments keep Keystone on an internal network. By default Prowler rejects an `auth_url` that resolves to a non-public address, as an SSRF defense, and the check runs before the SDK connects — on both the connection test and the scan. To scan a private cloud, declare the trusted ranges:
Most OpenStack deployments keep Keystone on an internal network, which is the normal case rather than the exception.
**Prowler CLI scans a private cloud with no extra configuration:**
```console
export PROWLER_ALLOWED_PRIVATE_NETWORKS="10.20.0.0/16,192.168.65.254/32"
export OS_AUTH_URL="https://keystone.internal:5000/v3"
prowler openstack
```
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. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control. When unset, only public addresses are reachable.
On the CLI the operator supplies the `auth_url` themselves, so there is nothing to defend against and the check below does not apply.
The variable is read by the process that runs the scan:
**Prowler App rejects an `auth_url` that resolves to a non-public address**, as an SSRF defense, before the SDK connects and on both the connection test and the scan. There the URL arrives from a tenant and the worker must not be used to reach the internal network it runs in. To scan a private cloud from Prowler App, declare the trusted ranges:
```console
PROWLER_ALLOWED_PRIVATE_NETWORKS="10.20.0.0/16,192.168.65.254/32"
```
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. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control.
The scan runs in the worker, not the API, so the variable has to reach the worker:
- **CLI**: export it in the shell running `prowler`.
- **Docker Compose**: set it in the root `.env`; it reaches the worker through the shared environment file.
- **Helm**: add it under `api.djangoConfig`, which is rendered into the API ConfigMap that the worker inherits through `envFrom`.
@@ -635,7 +643,7 @@ api:
PROWLER_ALLOWED_PRIVATE_NETWORKS: "10.20.0.0/16"
```
In Prowler App the scan runs in the worker, not the API, so setting the variable only on the API container has no effect. The same variable applies to the Kubernetes and IaC providers. The Image provider has its own, `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS`.
Setting it only on the API container has no effect. The same variable applies to the Kubernetes and IaC providers. The Image provider has its own, `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS`.
## Next Steps
+7
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import tempfile
from os import environ
@@ -55,6 +56,7 @@ from prowler.lib.check.custom_checks_metadata import (
from prowler.lib.check.models import CheckMetadata
from prowler.lib.cli.parser import ProwlerArgumentParser
from prowler.lib.logger import logger, set_logging_config
from prowler.lib.network.ssrf import SKIP_OUTBOUND_CHECK_ENV
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import (
ASDEssentialEightAWS,
@@ -236,6 +238,11 @@ 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.
os.environ.setdefault(SKIP_OUTBOUND_CHECK_ENV, "true")
# Parse Arguments
# Refactor(CLI)
parser = ProwlerArgumentParser()
+16
View File
@@ -12,6 +12,14 @@ from prowler.lib.logger import logger
ALLOWED_PRIVATE_NETWORKS_ENV = "PROWLER_ALLOWED_PRIVATE_NETWORKS"
# The check stays on unless a host process opts out. `prowler/__main__.py` opts the
# CLI out, because there the operator and the person supplying the URL are the same
# and there is no SSRF boundary to defend. Anything else -- Prowler App's API and
# worker above all -- keeps the check, 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",
@@ -59,6 +67,11 @@ def allowed_private_networks() -> tuple:
return networks
def outbound_check_skipped() -> bool:
"""Whether this process asked to skip the non-public destination check."""
return os.environ.get(SKIP_OUTBOUND_CHECK_ENV, "").strip().lower() in _TRUTHY
def _unwrap_ipv6(address: ipaddress._BaseAddress) -> ipaddress._BaseAddress:
if not isinstance(address, ipaddress.IPv6Address):
return address
@@ -114,6 +127,9 @@ def validate_outbound_host(host: str) -> None:
Resolution happens here and again inside the client that connects, so a
hostile DNS server can still answer differently the second time.
"""
if outbound_check_skipped():
return
networks = allowed_private_networks()
try:
+24
View File
@@ -1,4 +1,5 @@
import ipaddress
import pathlib
import socket
from unittest import mock
@@ -6,6 +7,7 @@ import pytest
from prowler.lib.network.ssrf import (
ALLOWED_PRIVATE_NETWORKS_ENV,
SKIP_OUTBOUND_CHECK_ENV,
OutboundURLNotAllowedError,
allowed_private_networks,
extract_host,
@@ -89,6 +91,28 @@ class TestValidateOutboundHost:
validate_outbound_host("nowhere.invalid")
class TestOutboundCheckOptOut:
def test_is_enforced_when_the_variable_is_unset(self, monkeypatch):
monkeypatch.delenv(SKIP_OUTBOUND_CHECK_ENV, raising=False)
with pytest.raises(OutboundURLNotAllowedError):
validate_outbound_host("169.254.169.254")
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"])
def test_is_skipped_when_the_variable_is_truthy(self, monkeypatch, raw):
monkeypatch.setenv(SKIP_OUTBOUND_CHECK_ENV, raw)
validate_outbound_host("169.254.169.254")
@pytest.mark.parametrize("raw", ["", "0", "false", "no", "off", "maybe"])
def test_is_enforced_for_anything_else(self, monkeypatch, raw):
monkeypatch.setenv(SKIP_OUTBOUND_CHECK_ENV, raw)
with pytest.raises(OutboundURLNotAllowedError):
validate_outbound_host("169.254.169.254")
def test_the_cli_entrypoint_opts_out(self):
source = pathlib.Path("prowler/__main__.py").read_text()
assert "os.environ.setdefault(SKIP_OUTBOUND_CHECK_ENV" in source
class TestAllowedPrivateNetworks:
def test_is_empty_when_unset(self, monkeypatch):
monkeypatch.delenv(ALLOWED_PRIVATE_NETWORKS_ENV, raising=False)