Skip to content

Commit 0ca67ef

Browse files
committed
Handle account ID principals in cross-account trust checks
Trust policies can list 12-digit account IDs in addition to arns ARNs. Normalizing principals through a shared helper (and exercising exclusions/current-account filtering in tests) keeps --flag-trust-policies from missing those roles that are assumable from other accounts when only the account ID is supplied.
1 parent e226ccc commit 0ca67ef

File tree

5 files changed

+84
-21
lines changed

5 files changed

+84
-21
lines changed

cloudsplaining/scan/assume_role_policy_document.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@
77
# or https://opensource.org/licenses/BSD-3-Clause
88
from __future__ import annotations
99

10-
import contextlib
1110
import logging
1211
from typing import Any
1312

14-
from policy_sentry.util.arns import get_account_from_arn
15-
1613
from cloudsplaining.scan.resource_policy_document import (
1714
ResourcePolicyDocument,
1815
ResourceStatement,
@@ -22,6 +19,7 @@
2219
DEFAULT_EXCLUSIONS,
2320
Exclusions,
2421
)
22+
from cloudsplaining.shared.utils import get_account_id_from_principal
2523

2624
logger = logging.getLogger(__name__)
2725

@@ -158,19 +156,17 @@ def role_assumable_by_cross_account_principals(self) -> list[str]:
158156
if self.effect.lower() != "allow":
159157
return []
160158

161-
other_account_principals = []
162-
for principal in self.principals:
163-
# Check if this is an AWS IAM principal from another account
164-
if principal.startswith("arn:aws:iam::"):
165-
with contextlib.suppress(Exception):
166-
principal_account_id = get_account_from_arn(principal)
167-
# Only include if it's from a different account (or if we don't know our current account)
168-
# and the account is not in the known-accounts exclusion list
169-
if (
170-
self.current_account_id is None or principal_account_id != self.current_account_id
171-
) and principal_account_id not in self.exclusions.known_accounts:
172-
other_account_principals.append(principal)
173-
return other_account_principals
159+
return [
160+
principal
161+
for principal in self.principals
162+
if (
163+
principal_account_id := get_account_id_from_principal(principal)
164+
)
165+
and (
166+
self.current_account_id is None or principal_account_id != self.current_account_id
167+
)
168+
and principal_account_id not in self.exclusions.known_accounts
169+
]
174170

175171
@property
176172
def role_assumable_by_any_principal(self) -> list[str]:

cloudsplaining/shared/exclusions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ def _exclude_actions(self) -> list[str]:
6666
always_exclude_actions = [x.lower() for x in exclude_actions]
6767
return always_exclude_actions
6868

69-
def _known_accounts(self) -> list[str]:
69+
def _known_accounts(self) -> set[str]:
7070
provided_known_accounts = self.config.get("known-accounts", [])
7171
# Normalize for comparisons - remove empty strings
72-
known_accounts = [account for account in provided_known_accounts if account.strip()]
72+
known_accounts = {account for account in provided_known_accounts if account.strip()}
7373
return known_accounts
7474

7575
def is_action_always_included(self, action_in_question: str) -> bool | str:

cloudsplaining/shared/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# or https://opensource.org/licenses/BSD-3-Clause
88
from __future__ import annotations
99

10+
import contextlib
1011
import json
1112
import logging
1213
import os
@@ -20,6 +21,7 @@
2021
remove_actions_not_matching_access_level,
2122
)
2223
from policy_sentry.querying.all import get_all_service_prefixes
24+
from policy_sentry.util.arns import get_account_from_arn
2325

2426
all_service_prefixes = get_all_service_prefixes()
2527
logger = logging.getLogger(__name__)
@@ -169,3 +171,15 @@ def write_json_to_file(file: str, content: str) -> None:
169171
os.remove(file)
170172

171173
Path(file).write_text(json.dumps(content, indent=4, default=str), encoding="utf-8")
174+
175+
176+
def get_account_id_from_principal(principal: str) -> str | None:
177+
"""Return the AWS account ID for a principal ARN or bare 12-digit account identifier."""
178+
principal_stripped = principal.strip()
179+
if principal_stripped.startswith("arn:aws:iam::"):
180+
with contextlib.suppress(Exception):
181+
return get_account_from_arn(principal_stripped)
182+
return None
183+
if principal_stripped.isdigit() and len(principal_stripped) == 12:
184+
return principal_stripped
185+
return None

test/scanning/test_trust_policies.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,38 @@ def test_assume_role_assumable_by_cross_account_principals(self):
201201
["arn:aws:iam::098765432109:role/testrole"],
202202
)
203203

204+
# Test bare account ID principals
205+
statement_account_ids = dict(
206+
Effect="Allow",
207+
Principal={"AWS": ["123456789012", "210987654321"]},
208+
Action=["sts:AssumeRole"],
209+
)
210+
assume_role_account_ids = AssumeRoleStatement(statement_account_ids)
211+
self.assertListEqual(
212+
assume_role_account_ids.role_assumable_by_cross_account_principals,
213+
["123456789012", "210987654321"],
214+
)
215+
216+
# Test bare account ID filtering by current account
217+
assume_role_account_ids_filtered = AssumeRoleStatement(
218+
statement_account_ids, current_account_id="123456789012"
219+
)
220+
self.assertListEqual(
221+
assume_role_account_ids_filtered.role_assumable_by_cross_account_principals,
222+
["210987654321"],
223+
)
224+
225+
# Test bare account ID filtering with exclusions
226+
exclusions_account_ids_config = {"known-accounts": ["210987654321"]}
227+
exclusions_account_ids = Exclusions(exclusions_account_ids_config)
228+
assume_role_account_ids_exclusions = AssumeRoleStatement(
229+
statement_account_ids, exclusions=exclusions_account_ids
230+
)
231+
self.assertListEqual(
232+
assume_role_account_ids_exclusions.role_assumable_by_cross_account_principals,
233+
["123456789012"],
234+
)
235+
204236
# Test conditions that should return empty results
205237
# No sts:AssumeRole action
206238
statement_no_assume_role = dict(
@@ -232,14 +264,23 @@ def test_assume_role_assumable_by_cross_account_principals(self):
232264
"Principal": {"AWS": "arn:aws:iam::098765432109:user/testuser"},
233265
"Action": "sts:AssumeRole",
234266
},
267+
{
268+
"Effect": "Allow",
269+
"Principal": {"AWS": "210987654321"},
270+
"Action": "sts:AssumeRole",
271+
},
235272
],
236273
}
237274
policy_doc = AssumeRolePolicyDocument(policy)
238-
expected_policy = ["arn:aws:iam::123456789012:root", "arn:aws:iam::098765432109:user/testuser"]
275+
expected_policy = [
276+
"arn:aws:iam::123456789012:root",
277+
"arn:aws:iam::098765432109:user/testuser",
278+
"210987654321",
279+
]
239280
self.assertListEqual(policy_doc.role_assumable_by_cross_account_principals, expected_policy)
240281

241282
# Test AssumeRolePolicyDocument with exclusions
242-
partial_exclusions_config_doc = {"known-accounts": ["123456789012"]}
283+
partial_exclusions_config_doc = {"known-accounts": ["123456789012", "210987654321"]}
243284
partial_exclusions_doc = Exclusions(partial_exclusions_config_doc)
244285
policy_doc_with_exclusions = AssumeRolePolicyDocument(policy, exclusions=partial_exclusions_doc)
245286
expected_policy_with_exclusions = ["arn:aws:iam::098765432109:user/testuser"]

test/shared/test_utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import unittest
2-
from cloudsplaining.shared.utils import remove_wildcard_only_actions, remove_read_level_actions
2+
from cloudsplaining.shared.utils import (
3+
get_account_id_from_principal,
4+
remove_read_level_actions,
5+
remove_wildcard_only_actions,
6+
)
37

48

59
class TestUtils(unittest.TestCase):
@@ -20,3 +24,11 @@ def test_remove_read_level_actions(self):
2024
result = remove_read_level_actions(actions)
2125
expected_result = ["ecr:PutImage"]
2226
self.assertListEqual(result, expected_result)
27+
28+
def test_get_account_id_from_principal(self):
29+
self.assertEqual(
30+
get_account_id_from_principal("arn:aws:iam::123456789012:role/test"),
31+
"123456789012",
32+
)
33+
self.assertEqual(get_account_id_from_principal(" 210987654321"), "210987654321")
34+
self.assertIsNone(get_account_id_from_principal("invalid"))

0 commit comments

Comments
 (0)