feat(aws): support excluding regions from scans via CLI, env var, and config (#10688)

This commit is contained in:
Daniel Barranquero authored and GitHub committed 2026-04-15 17:59:46 +02:00
1 parent 9e31160887
commit 43913b1592
9 files changed
+358 -44

No files matched your search

@@ -33,6 +33,41 @@ To scan a particular AWS region with Prowler, use:
prowler aws -f/--region eu-west-1 us-east-1
```
### Excluding Specific Regions
To scan all supported AWS regions except a specific subset, use the `--excluded-region` flag:
```console
prowler aws --excluded-region eu-west-1 me-south-1
```
You can also configure the exclusion list with the `PROWLER_AWS_DISALLOWED_REGIONS` environment variable as a comma-separated list:
```console
export PROWLER_AWS_DISALLOWED_REGIONS="eu-west-1,me-south-1"
prowler aws
```
Or with the AWS provider configuration in `config.yaml`:
```yaml
aws:
disallowed_regions:
- eu-west-1
- me-south-1
```
When more than one source is set, precedence is:
1. `--excluded-region`
2. `PROWLER_AWS_DISALLOWED_REGIONS`
3. `aws.disallowed_regions` in `config.yaml`
<Note>
For self-hosted App or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection.
</Note>
### AWS Credentials Configuration
For details on configuring AWS credentials, refer to the following [Botocore](https://github.com/boto/botocore) [file](https://github.com/boto/botocore/blob/22a19ea7c4c2c4dd7df4ab8c32733cba0c7597a4/botocore/data/partitions.json).
+1
View File
@@ -17,6 +17,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_conditional_access_policy_mfa_enforced_for_guest_users` check for M365 provider [(#10616)](https://github.com/prowler-cloud/prowler/pull/10616)
- `entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced` check for m365 provider [(#10618)](https://github.com/prowler-cloud/prowler/pull/10618)
- `entra_conditional_access_policy_block_unknown_device_platforms` check for m365 provider [(#10615)](https://github.com/prowler-cloud/prowler/pull/10615)
- `--excluded-region` CLI flag, `PROWLER_AWS_DISALLOWED_REGIONS` environment variable, and `aws.disallowed_regions` config entry to skip specific AWS regions during scans [(#10688)](https://github.com/prowler-cloud/prowler/pull/10688)
### 🔄 Changed
+7 -3
View File
@@ -69,11 +69,11 @@ from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
GoogleWorkspaceCISASCuBA,
)
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.compliance import display_compliance_table
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
@@ -1311,8 +1311,12 @@ def prowler():
global_provider.identity.audited_regions,
)
if not global_provider.identity.audited_regions
else global_provider.identity.audited_regions
else set(global_provider.identity.audited_regions)
)
if global_provider._enabled_regions is not None:
security_hub_regions = security_hub_regions.intersection(
global_provider._enabled_regions
)
security_hub = SecurityHub(
aws_account_id=global_provider.identity.account,
+4
View File
@@ -3,6 +3,10 @@ aws:
# AWS Global Configuration
# aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config
mute_non_default_regions: False
# aws.disallowed_regions --> List of AWS regions to exclude from the scan.
# Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or
# the --excluded-region CLI flag. Precedence: CLI > env var > config file.
# disallowed_regions: []
# If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
# Mutelist:
# Accounts:
+136 -41
View File
@@ -111,6 +111,7 @@ class AwsProvider(Provider):
mfa: bool = False,
profile: str = None,
regions: set = set(),
excluded_regions: set = None,
organizations_role_arn: str = None,
scan_unused_services: bool = False,
resource_tags: list[str] = [],
@@ -136,6 +137,10 @@ class AwsProvider(Provider):
- mfa: A boolean indicating whether MFA is enabled.
- profile: The name of the AWS CLI profile to use.
- regions: A set of regions to audit.
- excluded_regions: A set of regions to skip during the scan. Applied
on top of `regions` and of the account's enabled regions. Also
settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable
or the `disallowed_regions` key in the provider config file.
- organizations_role_arn: The ARN of the AWS Organizations IAM role to assume.
- scan_unused_services: A boolean indicating whether to scan unused services. False by default.
- resource_tags: A list of tags to filter the resources to audit.
@@ -190,6 +195,33 @@ class AwsProvider(Provider):
logger.info("Initializing AWS provider ...")
# Load provider config early because provider-level settings can affect
# bootstrap region selection before the scan starts.
if config_content is not None:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
excluded_regions = self.resolve_excluded_regions(
excluded_regions, self._audit_config
)
# Normalize excluded_regions and prune the include-list up front so
# every downstream consumer (identity, STS region, service/region
# enumeration) sees an already-filtered view.
if excluded_regions and regions:
regions = set(regions) - excluded_regions
if not regions:
raise AWSArgumentTypeValidationError(
message=(
"All requested AWS regions are excluded by the "
"disallowed regions configuration."
),
file=pathlib.Path(__file__).name,
)
######## AWS Session
logger.info("Generating original session ...")
@@ -215,7 +247,7 @@ class AwsProvider(Provider):
# After the session is created, validate it
logger.info("Validating credentials ...")
sts_region = get_aws_region_for_sts(
self.session.current_session.region_name, regions
self.session.current_session.region_name, regions, excluded_regions
)
# Validate the credentials
@@ -229,7 +261,9 @@ class AwsProvider(Provider):
######## AWS Provider Identity
# Get profile region
profile_region = self.get_profile_region(self._session.current_session)
profile_region = self.get_profile_region(
self._session.current_session, excluded_regions
)
# Set identity
self._identity = self.set_identity(
@@ -332,7 +366,26 @@ class AwsProvider(Provider):
)
########
# Parse Scan Tags
# Get Enabled Regions
self._enabled_regions = self.get_aws_enabled_regions(
self._session.current_session
)
# Apply the exclusion to the account's enabled regions. This is the
# gate used by generate_regional_clients, so skipped regions never get
# a boto3 client created for them and cannot stall the scan.
if excluded_regions:
if self._enabled_regions is not None:
self._enabled_regions = self._enabled_regions - excluded_regions
if self._identity.audited_regions:
self._identity.audited_regions = (
set(self._identity.audited_regions) - excluded_regions
)
logger.info(f"Excluding AWS regions from scan: {sorted(excluded_regions)}")
self._excluded_regions = excluded_regions
# Parse Scan Tags after region exclusions are applied so tag discovery
# also skips disallowed regions.
if resource_tags:
self._audit_resources = self.get_tagged_resources(resource_tags)
@@ -340,22 +393,9 @@ class AwsProvider(Provider):
if resource_arn:
self._audit_resources = resource_arn
# Get Enabled Regions
self._enabled_regions = self.get_aws_enabled_regions(
self._session.current_session
)
# Set ignore unused services
self._scan_unused_services = scan_unused_services
# Audit Config
if config_content:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
# Fixer Config
self._fixer_config = fixer_config
@@ -468,12 +508,53 @@ class AwsProvider(Provider):
)
@staticmethod
def get_profile_region(session: Session):
profile_region = AWS_REGION_US_EAST_1
if session.region_name:
profile_region = session.region_name
def resolve_excluded_regions(
excluded_regions: set | list | tuple | None,
audit_config: dict | None,
) -> set[str]:
"""Resolve AWS region exclusions with precedence arg > env > config."""
if excluded_regions is not None:
raw_regions = excluded_regions
else:
raw_regions = Provider.get_excluded_regions_from_env()
if not raw_regions and isinstance(audit_config, dict):
raw_regions = audit_config.get("disallowed_regions") or []
return profile_region
return {str(region).strip() for region in raw_regions if str(region).strip()}
@staticmethod
def get_bootstrap_region_candidates(session_region: str | None) -> tuple[str, ...]:
"""Return safe fallback regions for bootstrap AWS calls."""
if session_region:
if session_region.startswith("cn-"):
return ("cn-north-1", "cn-northwest-1")
if session_region.startswith("us-gov-"):
return ("us-gov-east-1", "us-gov-west-1")
if session_region.startswith("eusc-"):
return ("eusc-de-east-1",)
if session_region.startswith("us-iso"):
return (session_region,)
return (AWS_STS_GLOBAL_ENDPOINT_REGION, "us-east-2", "us-west-2", "eu-west-1")
@staticmethod
def get_profile_region(
session: Session, excluded_regions: set[str] | None = None
) -> str:
excluded_regions = set(excluded_regions or ())
session_region = session.region_name
if session_region and session_region not in excluded_regions:
return session_region
for region in AwsProvider.get_bootstrap_region_candidates(session_region):
if region not in excluded_regions:
if session_region and session_region != region:
logger.info(
f"Configured AWS profile region {session_region} is excluded; using {region} for bootstrap clients."
)
return region
return session_region or AWS_REGION_US_EAST_1
@staticmethod
def set_identity(
@@ -701,12 +782,15 @@ class AwsProvider(Provider):
Caller Identity ARN: arn:aws:iam::123456789012:user/prowler
```
"""
# Beautify audited regions, set "all" if there is no filter region
regions = (
", ".join(self._identity.audited_regions)
if self._identity.audited_regions is not None
else "all"
)
# Beautify audited regions. If the scan includes all regions but some
# are explicitly excluded, reflect that in the banner instead of
# showing the misleading "all" label.
if self._identity.audited_regions:
regions = ", ".join(sorted(self._identity.audited_regions))
elif getattr(self, "_excluded_regions", None):
regions = f"all except {', '.join(sorted(self._excluded_regions))}"
else:
regions = "all"
# Beautify audited profile, set "default" if there is no profile set
profile = (
self._identity.profile if self._identity.profile is not None else "default"
@@ -745,6 +829,8 @@ class AwsProvider(Provider):
service_regions = AwsProvider.get_available_aws_service_regions(
service, self._identity.partition, self._identity.audited_regions
)
if getattr(self, "_excluded_regions", None):
service_regions = service_regions - self._excluded_regions
# Get the regions enabled for the account and get the intersection with the service available regions
if self._enabled_regions is not None:
@@ -962,6 +1048,8 @@ class AwsProvider(Provider):
service_regions = AwsProvider.get_available_aws_service_regions(
service, self._identity.partition, self._identity.audited_regions
)
if getattr(self, "_excluded_regions", None):
service_regions = service_regions - self._excluded_regions
default_region = self.get_global_region()
# global region of the partition when all regions are audited and there is no profile region
if self._identity.profile_region in service_regions:
@@ -1565,13 +1653,19 @@ def read_aws_regions_file() -> dict:
# TODO: This can be moved to another class since it doesn't need self
def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str:
def get_aws_region_for_sts(
session_region: str,
regions: set[str],
excluded_regions: set[str] | None = None,
) -> str:
"""
Get the AWS region for the STS Assume Role operation.
Args:
- session_region (str): The region configured in the AWS session.
- regions (set[str]): The regions passed with the -f/--region/--filter-region option.
- excluded_regions (set[str] | None): Regions that should be avoided for
bootstrap calls when possible.
Returns:
str: The AWS region for the STS Assume Role operation
@@ -1579,20 +1673,21 @@ def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str:
Example:
aws_region = get_aws_region_for_sts(session_region, regions)
"""
# If there is no region passed with -f/--region/--filter-region
if regions is None or len(regions) == 0:
# If you have a region configured in your AWS config or credentials file
if session_region is not None:
aws_region = session_region
else:
# If there is no region set passed with -f/--region
# we use the Global STS Endpoint Region, us-east-1
aws_region = AWS_STS_GLOBAL_ENDPOINT_REGION
else:
# Get the first region passed to the -f/--region
aws_region = list(regions)[0]
excluded_regions = set(excluded_regions or ())
return aws_region
if regions:
for region in regions:
if region not in excluded_regions:
return region
if session_region and session_region not in excluded_regions:
return session_region
for region in AwsProvider.get_bootstrap_region_candidates(session_region):
if region not in excluded_regions:
return region
return session_region or AWS_STS_GLOBAL_ENDPOINT_REGION
# TODO: this duplicates the provider arguments validation library
@@ -66,6 +66,16 @@ def init_parser(self):
help="AWS region names to run Prowler against",
choices=AwsProvider.get_regions(partition=None),
)
aws_regions_subparser.add_argument(
"--excluded-region",
"--excluded-regions",
nargs="+",
help=(
"AWS region names to exclude from the scan. Overrides the "
"PROWLER_AWS_DISALLOWED_REGIONS environment variable when set."
),
choices=AwsProvider.get_regions(partition=None),
)
# AWS Organizations
aws_orgs_subparser = aws_parser.add_argument_group("AWS Organizations")
aws_orgs_subparser.add_argument(
@@ -30,10 +30,12 @@ def quick_inventory(provider: AwsProvider, args):
ec2_client = provider.session.current_session.client(
"ec2", region_name=provider.identity.profile_region
)
excluded_regions = getattr(provider, "_excluded_regions", set())
# Get all the available regions
provider.identity.audited_regions = [
region["RegionName"]
for region in ec2_client.describe_regions()["Regions"]
if region["RegionName"] not in excluded_regions
]
with alive_bar(
+19
View File
@@ -1,4 +1,5 @@
import importlib
import os
import pkgutil
import sys
from abc import ABC, abstractmethod
@@ -135,6 +136,18 @@ class Provider(ABC):
"""
return set()
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
The variable is a comma-separated list of region identifiers to skip
during scans (e.g. "me-south-1, ap-east-1"). Whitespace around entries
is tolerated and empty entries are dropped. Returns an empty set when
the variable is unset or contains no usable values.
"""
raw = os.environ.get("PROWLER_AWS_DISALLOWED_REGIONS", "")
return {region.strip() for region in raw.split(",") if region.strip()}
@staticmethod
def get_global_provider() -> "Provider":
return Provider._global
@@ -160,6 +173,11 @@ class Provider(ABC):
if not isinstance(Provider._global, provider_class):
if "aws" in provider_class_name.lower():
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
else None
)
provider_class(
retries_max_attempts=arguments.aws_retries_max_attempts,
role_arn=arguments.role,
@@ -169,6 +187,7 @@ class Provider(ABC):
mfa=arguments.mfa,
profile=arguments.profile,
regions=set(arguments.region) if arguments.region else None,
excluded_regions=excluded_regions,
organizations_role_arn=arguments.organizations_role,
scan_unused_services=arguments.scan_unused_services,
resource_tags=arguments.resource_tag,
+144
View File
@@ -839,6 +839,132 @@ aws:
assert isinstance(aws_provider, AwsProvider)
@mock_aws
def test_excluded_regions_removed_from_enabled_regions(self):
aws_provider = AwsProvider(excluded_regions={AWS_REGION_EU_WEST_1})
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
assert AWS_REGION_EU_WEST_1 not in aws_provider.generate_regional_clients("ec2")
@mock_aws
def test_excluded_regions_pruned_from_input_regions(self):
aws_provider = AwsProvider(
regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1},
excluded_regions={AWS_REGION_EU_WEST_1},
)
assert AWS_REGION_EU_WEST_1 not in aws_provider._identity.audited_regions
assert AWS_REGION_US_EAST_1 in aws_provider._identity.audited_regions
@mock_aws
def test_excluded_regions_from_config_file(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
aws_provider = AwsProvider(config_path=config_path)
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1}
finally:
os.remove(config_path)
@mock_aws
def test_excluded_regions_from_env_on_direct_provider_init(self):
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_EU_WEST_1},
clear=False,
):
aws_provider = AwsProvider()
assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1}
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
@mock_aws
def test_excluded_regions_precedence_explicit_over_env_and_config(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_US_EAST_1},
clear=False,
):
aws_provider = AwsProvider(
config_path=config_path,
excluded_regions={AWS_REGION_US_EAST_2},
)
assert aws_provider._excluded_regions == {AWS_REGION_US_EAST_2}
assert AWS_REGION_US_EAST_2 not in aws_provider._enabled_regions
assert AWS_REGION_EU_WEST_1 in aws_provider._enabled_regions
assert AWS_REGION_US_EAST_1 in aws_provider._enabled_regions
finally:
os.remove(config_path)
@mock_aws
def test_excluded_regions_from_config_avoid_excluded_profile_region(
self, monkeypatch
):
monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_EU_WEST_1)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
aws_provider = AwsProvider(config_path=config_path)
assert aws_provider.identity.profile_region == AWS_REGION_US_EAST_1
finally:
os.remove(config_path)
@mock_aws
def test_aws_provider_raises_when_all_input_regions_are_excluded(self):
with raises(AWSArgumentTypeValidationError):
AwsProvider(
regions={AWS_REGION_EU_WEST_1},
excluded_regions={AWS_REGION_EU_WEST_1},
)
def test_get_excluded_regions_from_env_parses_comma_list(self):
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": " me-south-1 , ap-east-1 ,, "},
):
assert Provider.get_excluded_regions_from_env() == {
"me-south-1",
"ap-east-1",
}
def test_get_excluded_regions_from_env_ignores_legacy_generic_name(self):
with mock.patch.dict(
os.environ,
{"PROWLER_DISALLOWED_REGIONS": "me-south-1"},
clear=True,
):
assert Provider.get_excluded_regions_from_env() == set()
def test_get_excluded_regions_from_env_unset(self):
with mock.patch.dict(os.environ, {}, clear=True):
assert Provider.get_excluded_regions_from_env() == set()
@mock_aws
def test_print_credentials_shows_all_except_excluded_regions(self):
aws_provider = AwsProvider(
excluded_regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1}
)
with patch(
"prowler.providers.aws.aws_provider.print_boxes"
) as mock_print_boxes:
aws_provider.print_credentials()
report_lines = mock_print_boxes.call_args.args[0]
assert any(
"AWS Regions:" in line and "all except eu-west-1, us-east-1" in line
for line in report_lines
)
@mock_aws
def test_generate_regional_clients_all_enabled_regions(self):
aws_provider = AwsProvider()
@@ -2033,6 +2159,24 @@ aws:
== AWS_REGION_EU_WEST_1
)
def test_get_aws_region_for_sts_avoids_excluded_session_region(self):
input_regions = None
session_region = AWS_REGION_EU_WEST_1
assert (
get_aws_region_for_sts(
session_region, input_regions, {AWS_REGION_EU_WEST_1}
)
== AWS_REGION_US_EAST_1
)
def test_get_profile_region_avoids_excluded_session_region(self):
mocked_session = mock.Mock(region_name=AWS_REGION_EU_WEST_1)
assert (
AwsProvider.get_profile_region(mocked_session, {AWS_REGION_EU_WEST_1})
== AWS_REGION_US_EAST_1
)
@mock_aws
def test_set_session_config_default(self):
aws_provider = AwsProvider()