|
3 | 3 | from datetime import datetime |
4 | 4 | from functools import lru_cache |
5 | 5 | from re import fullmatch |
6 | | -from typing import Optional |
| 6 | +from typing import Any, Callable, Optional |
7 | 7 |
|
8 | 8 | from boto3.session import Session |
9 | 9 | from botocore.config import Config |
10 | 10 | 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 | +) |
12 | 19 | from botocore.session import Session as BotocoreSession |
13 | 20 | from colorama import Fore, Style |
14 | 21 | from pytz import utc |
@@ -263,7 +270,10 @@ def __init__( |
263 | 270 | caller_identity = self.validate_credentials( |
264 | 271 | session=self.session.current_session, |
265 | 272 | aws_region=sts_region, |
| 273 | + excluded_regions=excluded_regions, |
266 | 274 | ) |
| 275 | + # Later STS calls go where validation got an answer, not where it timed out |
| 276 | + sts_region = caller_identity.region |
267 | 277 |
|
268 | 278 | logger.info("Credentials validated") |
269 | 279 | ######## |
@@ -690,17 +700,19 @@ def setup_session( |
690 | 700 | or session.region_name |
691 | 701 | or AWS_STS_GLOBAL_ENDPOINT_REGION |
692 | 702 | ) |
693 | | - sts_client = AwsProvider.create_sts_session(session, sts_region) |
694 | | - |
695 | 703 | # TODO: pass values from the input |
696 | 704 | mfa_info = AwsProvider.input_role_mfa_token_and_code() |
697 | 705 | # TODO: validate MFA ARN here |
698 | 706 | get_session_token_arguments = { |
699 | 707 | "SerialNumber": mfa_info.arn, |
700 | 708 | "TokenCode": mfa_info.totp, |
701 | 709 | } |
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 | + ), |
704 | 716 | ) |
705 | 717 | mfa_session = Session( |
706 | 718 | aws_access_key_id=session_credentials["Credentials"]["AccessKeyId"], |
@@ -1244,10 +1256,11 @@ def assume_role( |
1244 | 1256 | mfa_info = AwsProvider.input_role_mfa_token_and_code() |
1245 | 1257 | assume_role_arguments["SerialNumber"] = mfa_info.arn |
1246 | 1258 | 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), |
1249 | 1263 | ) |
1250 | | - assumed_credentials = sts_client.assume_role(**assume_role_arguments) |
1251 | 1264 | # Convert the UTC datetime object to your local timezone |
1252 | 1265 | credentials_expiration_local_time = ( |
1253 | 1266 | assumed_credentials["Credentials"]["Expiration"] |
@@ -1326,30 +1339,98 @@ def get_checks_to_execute_by_audit_resources(self) -> set[str]: |
1326 | 1339 | ) |
1327 | 1340 | raise error |
1328 | 1341 |
|
| 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 | + |
1329 | 1396 | @staticmethod |
1330 | 1397 | def validate_credentials( |
1331 | 1398 | session: Session, |
1332 | 1399 | aws_region: str, |
| 1400 | + excluded_regions: set[str] | None = None, |
1333 | 1401 | ) -> AWSCallerIdentity: |
1334 | 1402 | """ |
1335 | 1403 | 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 | +
|
1336 | 1410 | Args: |
1337 | 1411 | session (Session): The AWS session object. |
1338 | 1412 | 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. |
1339 | 1415 | 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. |
1341 | 1418 | Raises: |
1342 | 1419 | Exception: If an error occurs during the validation process. |
1343 | 1420 | """ |
1344 | 1421 | 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 | + ) |
1347 | 1428 | # Include the region where the caller_identity has validated the credentials |
1348 | 1429 | return AWSCallerIdentity( |
1349 | 1430 | user_id=caller_identity.get("UserId"), |
1350 | 1431 | account=caller_identity.get("Account"), |
1351 | 1432 | arn=ARN(caller_identity.get("Arn")), |
1352 | | - region=aws_region, |
| 1433 | + region=sts_region, |
1353 | 1434 | ) |
1354 | 1435 | except ClientError as client_error: |
1355 | 1436 | logger.error( |
@@ -1846,6 +1927,49 @@ def get_env_partition_bootstrap_region( |
1846 | 1927 | return regions[0] if regions else None |
1847 | 1928 |
|
1848 | 1929 |
|
| 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 | + |
1849 | 1973 | # TODO: This can be moved to another class since it doesn't need self |
1850 | 1974 | def get_aws_region_for_sts( |
1851 | 1975 | session_region: str, |
|
0 commit comments