Skip to content

Commit 8ae8ffa

Browse files
prowler-botcesararrobapedrooot
authored
fix(aws): try the rest of the partition when the bootstrap region is unreachable (#12822)
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com> Co-authored-by: pedrooot <pedromarting3@gmail.com>
1 parent 3e825c6 commit 8ae8ffa

4 files changed

Lines changed: 548 additions & 13 deletions

File tree

docs/user-guide/providers/aws/regions-and-partitions.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ It matters most where nothing else says. Resolving an identity means calling STS
3939

4040
A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two.
4141

42+
When no configured region says which one to prefer, the first region of the partition is tried, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers.
43+
4244
<Note>
4345
Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request.
4446
</Note>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Bootstrap STS calls now try up to two more regions of the partition declared in `PROWLER_AWS_PARTITION` when the first one cannot be reached, so a deployment that routes to only one region of its partition no longer fails on an endpoint it has no path to. This covers validating credentials, assuming a role and getting an MFA session token

prowler/providers/aws/aws_provider.py

Lines changed: 137 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
from datetime import datetime
44
from functools import lru_cache
55
from re import fullmatch
6-
from typing import Optional
6+
from typing import Any, Callable, Optional
77

88
from boto3.session import Session
99
from botocore.config import Config
1010
from botocore.credentials import RefreshableCredentials
11-
from botocore.exceptions import ClientError, NoCredentialsError, ProfileNotFound
11+
from botocore.exceptions import (
12+
ClientError,
13+
ConnectTimeoutError,
14+
EndpointConnectionError,
15+
NoCredentialsError,
16+
ProfileNotFound,
17+
ReadTimeoutError,
18+
)
1219
from botocore.session import Session as BotocoreSession
1320
from colorama import Fore, Style
1421
from pytz import utc
@@ -263,7 +270,10 @@ def __init__(
263270
caller_identity = self.validate_credentials(
264271
session=self.session.current_session,
265272
aws_region=sts_region,
273+
excluded_regions=excluded_regions,
266274
)
275+
# Later STS calls go where validation got an answer, not where it timed out
276+
sts_region = caller_identity.region
267277

268278
logger.info("Credentials validated")
269279
########
@@ -690,17 +700,19 @@ def setup_session(
690700
or session.region_name
691701
or AWS_STS_GLOBAL_ENDPOINT_REGION
692702
)
693-
sts_client = AwsProvider.create_sts_session(session, sts_region)
694-
695703
# TODO: pass values from the input
696704
mfa_info = AwsProvider.input_role_mfa_token_and_code()
697705
# TODO: validate MFA ARN here
698706
get_session_token_arguments = {
699707
"SerialNumber": mfa_info.arn,
700708
"TokenCode": mfa_info.totp,
701709
}
702-
session_credentials = sts_client.get_session_token(
703-
**get_session_token_arguments
710+
_, session_credentials = AwsProvider.sts_call_with_partition_failover(
711+
session,
712+
sts_region,
713+
lambda sts_client: sts_client.get_session_token(
714+
**get_session_token_arguments
715+
),
704716
)
705717
mfa_session = Session(
706718
aws_access_key_id=session_credentials["Credentials"]["AccessKeyId"],
@@ -1244,10 +1256,11 @@ def assume_role(
12441256
mfa_info = AwsProvider.input_role_mfa_token_and_code()
12451257
assume_role_arguments["SerialNumber"] = mfa_info.arn
12461258
assume_role_arguments["TokenCode"] = mfa_info.totp
1247-
sts_client = AwsProvider.create_sts_session(
1248-
session, assumed_role_info.sts_region
1259+
_, assumed_credentials = AwsProvider.sts_call_with_partition_failover(
1260+
session,
1261+
assumed_role_info.sts_region,
1262+
lambda sts_client: sts_client.assume_role(**assume_role_arguments),
12491263
)
1250-
assumed_credentials = sts_client.assume_role(**assume_role_arguments)
12511264
# Convert the UTC datetime object to your local timezone
12521265
credentials_expiration_local_time = (
12531266
assumed_credentials["Credentials"]["Expiration"]
@@ -1326,30 +1339,98 @@ def get_checks_to_execute_by_audit_resources(self) -> set[str]:
13261339
)
13271340
raise error
13281341

1342+
@staticmethod
1343+
def sts_call_with_partition_failover(
1344+
session: Session,
1345+
aws_region: str,
1346+
operation: Callable[[Any], Any],
1347+
excluded_regions: set[str] | None = None,
1348+
) -> tuple[str, Any]:
1349+
"""
1350+
Run a bootstrap STS call, moving on when a region cannot be reached.
1351+
1352+
Bootstrap calls happen before anything is known about the credentials, so
1353+
the region they go to is a guess whenever none was configured. On a network
1354+
that routes to only one region of its partition that guess is fatal, and the
1355+
remaining regions of the partition declared in PROWLER_AWS_PARTITION are the
1356+
ones worth trying.
1357+
1358+
Args:
1359+
session (Session): The AWS session object.
1360+
aws_region (str): The region to try first.
1361+
operation (Callable[[Any], Any]): Receives an STS client and performs
1362+
the call.
1363+
excluded_regions (set[str] | None): Regions excluded from the scan,
1364+
tried after the rest of the partition.
1365+
1366+
Returns:
1367+
tuple[str, Any]: The region that answered and whatever the operation
1368+
returned.
1369+
1370+
Raises:
1371+
Exception: Whatever the operation raises, or the last connection error
1372+
when no region could be reached.
1373+
"""
1374+
*fallback_regions, last_region = get_partition_bootstrap_candidates(
1375+
aws_region, session.region_name, excluded_regions
1376+
)
1377+
1378+
for candidate_region in fallback_regions:
1379+
try:
1380+
sts_client = AwsProvider.create_sts_session(session, candidate_region)
1381+
return candidate_region, operation(sts_client)
1382+
# The credentials are not at fault, so the next region is worth trying
1383+
except (
1384+
EndpointConnectionError,
1385+
ConnectTimeoutError,
1386+
ReadTimeoutError,
1387+
) as unreachable:
1388+
logger.warning(
1389+
f"{unreachable.__class__.__name__}[{unreachable.__traceback__.tb_lineno}]: {unreachable}"
1390+
)
1391+
1392+
# Nothing is left to try after the last region, so its error is the answer
1393+
sts_client = AwsProvider.create_sts_session(session, last_region)
1394+
return last_region, operation(sts_client)
1395+
13291396
@staticmethod
13301397
def validate_credentials(
13311398
session: Session,
13321399
aws_region: str,
1400+
excluded_regions: set[str] | None = None,
13331401
) -> AWSCallerIdentity:
13341402
"""
13351403
Validates the AWS credentials using the provided session and AWS region.
1404+
1405+
When the region cannot be reached, the remaining regions of the partition
1406+
declared in PROWLER_AWS_PARTITION are tried before giving up. A credential
1407+
error is returned from the first region instead, since it would be the same
1408+
everywhere.
1409+
13361410
Args:
13371411
session (Session): The AWS session object.
13381412
aws_region (str): The AWS region to validate the credentials.
1413+
excluded_regions (set[str] | None): Regions excluded from the scan,
1414+
tried after the rest of the partition.
13391415
Returns:
1340-
AWSCallerIdentity: An object containing the caller identity information.
1416+
AWSCallerIdentity: An object containing the caller identity information,
1417+
including the region that answered.
13411418
Raises:
13421419
Exception: If an error occurs during the validation process.
13431420
"""
13441421
try:
1345-
sts_client = AwsProvider.create_sts_session(session, aws_region)
1346-
caller_identity = sts_client.get_caller_identity()
1422+
sts_region, caller_identity = AwsProvider.sts_call_with_partition_failover(
1423+
session,
1424+
aws_region,
1425+
lambda sts_client: sts_client.get_caller_identity(),
1426+
excluded_regions,
1427+
)
13471428
# Include the region where the caller_identity has validated the credentials
13481429
return AWSCallerIdentity(
13491430
user_id=caller_identity.get("UserId"),
13501431
account=caller_identity.get("Account"),
13511432
arn=ARN(caller_identity.get("Arn")),
1352-
region=aws_region,
1433+
region=sts_region,
13531434
)
13541435
except ClientError as client_error:
13551436
logger.error(
@@ -1846,6 +1927,49 @@ def get_env_partition_bootstrap_region(
18461927
return regions[0] if regions else None
18471928

18481929

1930+
# An unreachable endpoint costs a connection timeout, so a partition with many
1931+
# regions is not walked in full
1932+
MAX_STS_BOOTSTRAP_ATTEMPTS = 3
1933+
1934+
1935+
def get_partition_bootstrap_candidates(
1936+
aws_region: str,
1937+
session_region: Optional[str] = None,
1938+
excluded_regions: set[str] | None = None,
1939+
) -> list:
1940+
"""
1941+
Get the STS bootstrap regions to try, in order, starting with the chosen one.
1942+
1943+
A deployment reached only through its own region's endpoints has no route to
1944+
the rest of its partition, and which region that is cannot be known from the
1945+
environment alone: a container may carry a region belonging to no partition
1946+
it scans. Offering the remaining regions of the declared partition lets the
1947+
bootstrap succeed without anything having to declare the right one.
1948+
1949+
Args:
1950+
aws_region (str): The region already chosen for the bootstrap call.
1951+
session_region (Optional[str]): The region of the AWS session.
1952+
excluded_regions (set[str] | None): Regions excluded from the scan. They
1953+
go after the rest of the partition, so the bootstrap avoids them
1954+
whenever another region answers and still has them as a last resort.
1955+
1956+
Returns:
1957+
list: The regions to try, preferred first, capped at
1958+
MAX_STS_BOOTSTRAP_ATTEMPTS.
1959+
"""
1960+
excluded_regions = set(excluded_regions or ())
1961+
partition_regions = get_env_partition_regions(session_region) or []
1962+
# sorted() is stable, so the partition order survives on each side of the split
1963+
ordered_regions = sorted(
1964+
partition_regions, key=lambda region: region in excluded_regions
1965+
)
1966+
candidates = [aws_region]
1967+
for region in ordered_regions:
1968+
if region not in candidates:
1969+
candidates.append(region)
1970+
return candidates[:MAX_STS_BOOTSTRAP_ATTEMPTS]
1971+
1972+
18491973
# TODO: This can be moved to another class since it doesn't need self
18501974
def get_aws_region_for_sts(
18511975
session_region: str,

0 commit comments

Comments
 (0)