From 0d55db5ee5a854a6ecb938f2c26b4afbc87c6079 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Wed, 7 Oct 2026 17:08:45 +0200 Subject: [PATCH] fix(network): scope the outbound host check to Prowler App --- docs/user-guide/providers/kubernetes/misc.mdx | 16 +++++++------ prowler/__main__.py | 7 ++++++ prowler/lib/network/ssrf.py | 16 +++++++++++++ tests/lib/network/ssrf_test.py | 24 +++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/user-guide/providers/kubernetes/misc.mdx b/docs/user-guide/providers/kubernetes/misc.mdx index 0990ef1515..b20b74fcf3 100644 --- a/docs/user-guide/providers/kubernetes/misc.mdx +++ b/docs/user-guide/providers/kubernetes/misc.mdx @@ -46,18 +46,20 @@ These environment variables are supported both when using an external `kubeconfi ## Private Cluster Endpoints -By default, Prowler rejects a `kubeconfig` whose `clusters[*].cluster.server` resolves to a non-public address, as an SSRF defense. Clusters reached over a private network are a legitimate setup, so to scan them declare the trusted ranges explicitly: +Clusters reached over a private network are a normal setup. + +**Prowler CLI scans them with no extra configuration.** On the CLI the operator supplies the `kubeconfig` themselves, so there is nothing to defend against and the check below does not apply. + +**Prowler App rejects a `kubeconfig` whose `clusters[*].cluster.server` resolves to a non-public address**, as an SSRF defense. There the kube-config arrives from a tenant and the worker must not be used to reach the internal network it runs in. To scan a private cluster from Prowler App, declare the trusted ranges explicitly: ```console -export PROWLER_ALLOWED_PRIVATE_NETWORKS="10.20.0.0/16,192.168.65.254/32" -prowler kubernetes ... +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. A kubeconfig declaring several clusters is rejected when any one of them resolves to a non-public address outside the allowlist. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control. When unset, only public addresses are reachable. +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. A kubeconfig declaring several clusters is rejected when any one of them resolves to a non-public address outside the allowlist. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control. -The variable is read by the process that runs the scan: +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`. @@ -67,6 +69,6 @@ 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 IaC and OpenStack 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 IaC and OpenStack providers. The Image provider has its own, `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS`. The check resolves the cluster hostname locally, before the Kubernetes client connects. If egress is only possible through `HTTPS_PROXY` and the hostname cannot be resolved locally, declare the cluster's address range in `PROWLER_ALLOWED_PRIVATE_NETWORKS` or make the name resolvable to the scanning process. diff --git a/prowler/__main__.py b/prowler/__main__.py index b9de47c07b..669c1c81b9 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 @@ -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() diff --git a/prowler/lib/network/ssrf.py b/prowler/lib/network/ssrf.py index 514c062995..2c5575fcb6 100644 --- a/prowler/lib/network/ssrf.py +++ b/prowler/lib/network/ssrf.py @@ -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: diff --git a/tests/lib/network/ssrf_test.py b/tests/lib/network/ssrf_test.py index 4b8005d0e3..1c8fd8a119 100644 --- a/tests/lib/network/ssrf_test.py +++ b/tests/lib/network/ssrf_test.py @@ -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)