From 620da4bc86d90c4bfa7b02b24899fdf6bcf0f1eb Mon Sep 17 00:00:00 2001 From: Eric Conklin Date: Thu, 4 Jun 2026 16:41:35 -0500 Subject: [PATCH] Fix wildcard trust chain walking --- iamscope/reasoner/chain_walking.py | 24 ++++++- iamscope/reasoner/fact_graph.py | 11 ++- tests/test_admin_reachability_reasoner.py | 61 ++++++++++++++++ tests/test_assume_role_chain_reasoner.py | 87 +++++++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/iamscope/reasoner/chain_walking.py b/iamscope/reasoner/chain_walking.py index 14d1bcf..9585b13 100644 --- a/iamscope/reasoner/chain_walking.py +++ b/iamscope/reasoner/chain_walking.py @@ -25,6 +25,7 @@ from __future__ import annotations +from iamscope.constants import NODE_TYPE_WILDCARD_PRINCIPAL, TRUST_SCOPE_ANY_AWS_PRINCIPAL from iamscope.models import Edge, Node from iamscope.reasoner.fact_graph import FactGraph @@ -76,7 +77,7 @@ def find_admitting_trust_edge( Walks the next role's trust policy via `facts.trust_policy_of` and returns the first edge whose src admits current_arn. Considers - both direct ARN match and account-root principals. + direct ARN match, account-root principals, and wildcard principals. Account-root admission rule: a trust edge whose src is `arn:aws:iam:::root` admits any principal in the same @@ -86,6 +87,13 @@ def find_admitting_trust_edge( to the account's IAM policies, effectively trusting any IAM principal in that account. + Wildcard-principal admission rule: a trust edge whose principal is + `Principal: "*"` admits any current principal, but callers must + classify that edge as ambiguous rather than clean. The resolver + preserves this via `WildcardPrincipal`, `is_wildcard_principal`, and + `trust_scope=any_aws_principal`; matching it here prevents chain + reasoners from silently dropping wildcard-trust hops. + Returns None if no admitting trust edge exists. Callers should skip the hop entirely in that case (the chain is broken). """ @@ -104,4 +112,18 @@ def find_admitting_trust_edge( cur_parts = current_arn.split(":") if len(cur_parts) >= 5 and cur_parts[4] == src_account: return trust_edge + # Wildcard principal: admits any principal, but is ambiguous. + if _is_wildcard_trust_principal(trust_edge): + return trust_edge return None + + +def _is_wildcard_trust_principal(trust_edge: Edge) -> bool: + """Return True for current resolver/parser wildcard-principal trust shapes.""" + features = trust_edge.features or {} + return ( + trust_edge.src.provider_id == "*" + or trust_edge.src.node_type == NODE_TYPE_WILDCARD_PRINCIPAL + or features.get("is_wildcard_principal") is True + or features.get("trust_scope") == TRUST_SCOPE_ANY_AWS_PRINCIPAL + ) diff --git a/iamscope/reasoner/fact_graph.py b/iamscope/reasoner/fact_graph.py index 4c98901..164e01a 100644 --- a/iamscope/reasoner/fact_graph.py +++ b/iamscope/reasoner/fact_graph.py @@ -26,7 +26,7 @@ from dataclasses import dataclass, field from typing import Any -from iamscope.constants import NODE_TYPE_HYPEREDGE +from iamscope.constants import NODE_TYPE_HYPEREDGE, NODE_TYPE_WILDCARD_PRINCIPAL, TRUST_SCOPE_ANY_AWS_PRINCIPAL from iamscope.models import Constraint, Edge, EdgeConstraint, Node from iamscope.reasoner.verdict import CheckState from iamscope.truth.probe_overlay import ProbeRecord @@ -367,6 +367,8 @@ def _is_unknown_witness(edge: Edge) -> bool: - dst is a __hyperedge__ node (wildcard expansion was suppressed) - features.is_wildcard_resource == True (source policy used wildcard resource, expansion code resolved it but may have missed nuances) + - trust src is a wildcard principal (trust admits broadly but does + not prove a clean principal-specific trust witness) - features.has_conditions == True (statement has a Condition block, runtime context not evaluable from the static graph) """ @@ -375,4 +377,11 @@ def _is_unknown_witness(edge: Edge) -> bool: features: dict[str, Any] = edge.features or {} if features.get("is_wildcard_resource", False): return True + if ( + edge.src.provider_id == "*" + or edge.src.node_type == NODE_TYPE_WILDCARD_PRINCIPAL + or features.get("is_wildcard_principal") is True + or features.get("trust_scope") == TRUST_SCOPE_ANY_AWS_PRINCIPAL + ): + return True return bool(features.get("has_conditions", False)) diff --git a/tests/test_admin_reachability_reasoner.py b/tests/test_admin_reachability_reasoner.py index 8798061..eb84030 100644 --- a/tests/test_admin_reachability_reasoner.py +++ b/tests/test_admin_reachability_reasoner.py @@ -40,6 +40,7 @@ _role, _trust_edge, _user, + _wildcard_trust_edge, ) # --------------------------------------------------------------------------- @@ -241,6 +242,66 @@ def test_conditioned_trust_downgrades_reachable_admin_to_inconclusive(self) -> N assert check.state.value == "unknown" +class TestWildcardTrustPrincipalReachability: + def test_wildcard_trust_path_to_admin_is_inconclusive(self) -> None: + alice = _user(_ALICE_ARN) + admin = _role(_ADMIN_ARN) + perm = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_ADMIN_ARN) + trust = _wildcard_trust_edge(target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, admin), + edges=(perm, trust, admin_grant), + ) + + findings = AdminReachabilityReasoner().run(facts) + alice_f = next(f for f in findings if f.source.provider_id == _ALICE_ARN) + + assert alice_f.verdict.value == "inconclusive" + check = next( + c for c in alice_f.required_checks if c.name == "at_least_one_reachable_chain_uses_clean_witnesses" + ) + assert check.state.value == "unknown" + assert trust.edge_id in alice_f.evidence.edge_refs + + def test_account_root_trust_path_remains_validated(self) -> None: + alice = _user(_ALICE_ARN) + admin = _role(_ADMIN_ARN) + root_arn = "arn:aws:iam::111111\u003111111:root" + perm = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_ADMIN_ARN) + trust = _trust_edge(principal_arn=root_arn, target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, admin), + edges=(perm, trust, admin_grant), + ) + + findings = AdminReachabilityReasoner().run(facts) + alice_f = next(f for f in findings if f.source.provider_id == _ALICE_ARN) + + assert alice_f.verdict.value == "validated" + check = next( + c for c in alice_f.required_checks if c.name == "at_least_one_reachable_chain_uses_clean_witnesses" + ) + assert check.state.value == "pass" + + def test_unrelated_trust_still_produces_no_reachability(self) -> None: + alice = _user(_ALICE_ARN) + admin = _role(_ADMIN_ARN) + bob_arn = "arn:aws:iam::111111\u003111111:user/Bob" + perm = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_ADMIN_ARN) + unrelated_trust = _trust_edge(principal_arn=bob_arn, target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, admin), + edges=(perm, unrelated_trust, admin_grant), + ) + + findings = AdminReachabilityReasoner().run(facts) + + assert not any(f.source.provider_id == _ALICE_ARN for f in findings) + + class TestPermissionBoundaryReachability: def _boundary(self) -> Constraint: return Constraint( diff --git a/tests/test_assume_role_chain_reasoner.py b/tests/test_assume_role_chain_reasoner.py index 6bf0068..6921132 100644 --- a/tests/test_assume_role_chain_reasoner.py +++ b/tests/test_assume_role_chain_reasoner.py @@ -37,7 +37,9 @@ VALIDATED_STATE_DENIED, ) from iamscope.models import Constraint, Edge, EdgeConstraint, Node +from iamscope.parser.trust_policy import parse_trust_policy from iamscope.reasoner import AssumeRoleChainReasoner, FactGraph +from iamscope.resolver.cross_account import build_trust_edges from iamscope.truth.probe_overlay import ProbeRecord # --------------------------------------------------------------------------- @@ -164,6 +166,27 @@ def _trust_edge( ) +def _wildcard_trust_edge( + *, + target_arn: str, +) -> Edge: + """Trust edge: target role has Principal: \"*\" wildcard trust.""" + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "sts:AssumeRole", + } + ], + } + trust_results = parse_trust_policy(policy, target_arn, _ACCOUNT) + edges = build_trust_edges(trust_results, _role(target_arn)) + assert len(edges) == 1 + return edges[0] + + def _admin_grant_edge(role_arn: str) -> Edge: """The admin-equivalence permission edge: role has iam:* on itself.""" return Edge( @@ -1010,6 +1033,70 @@ def test_conditioned_trust_hop_is_inconclusive(self) -> None: assert check.state.value == "unknown" +class TestWildcardTrustPrincipal: + def test_wildcard_trust_hop_emits_inconclusive_chain(self) -> None: + """Principal: \"*\" trust admits the hop, but only as ambiguous evidence.""" + alice = _user(_ALICE_ARN) + devops = _role(_DEVOPS_ARN) + admin = _role(_ADMIN_ARN) + perm_1 = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_DEVOPS_ARN) + trust_1 = _wildcard_trust_edge(target_arn=_DEVOPS_ARN) + perm_2 = _assume_perm_edge(src_arn=_DEVOPS_ARN, dst_arn=_ADMIN_ARN) + trust_2 = _trust_edge(principal_arn=_DEVOPS_ARN, target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, devops, admin), + edges=(perm_1, trust_1, perm_2, trust_2, admin_grant), + ) + + findings = AssumeRoleChainReasoner().run(facts) + + assert len(findings) == 1 + finding = findings[0] + assert finding.verdict.value == "inconclusive" + check = next(c for c in finding.required_checks if c.name == "no_hop_traverses_hyperedge") + assert check.state.value == "unknown" + assert trust_1.edge_id in finding.evidence.edge_refs + + def test_account_root_trust_hop_remains_validated(self) -> None: + alice = _user(_ALICE_ARN) + devops = _role(_DEVOPS_ARN) + admin = _role(_ADMIN_ARN) + root_arn = f"arn:aws:iam::{_ACCOUNT}:root" + perm_1 = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_DEVOPS_ARN) + trust_1 = _trust_edge(principal_arn=root_arn, target_arn=_DEVOPS_ARN) + perm_2 = _assume_perm_edge(src_arn=_DEVOPS_ARN, dst_arn=_ADMIN_ARN) + trust_2 = _trust_edge(principal_arn=_DEVOPS_ARN, target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, devops, admin), + edges=(perm_1, trust_1, perm_2, trust_2, admin_grant), + ) + + finding = AssumeRoleChainReasoner().run(facts)[0] + + assert finding.verdict.value == "validated" + check = next(c for c in finding.required_checks if c.name == "no_hop_traverses_hyperedge") + assert check.state.value == "pass" + + def test_unrelated_trust_still_produces_no_chain(self) -> None: + alice = _user(_ALICE_ARN) + bob_arn = f"arn:aws:iam::{_ACCOUNT}:user/Bob" + devops = _role(_DEVOPS_ARN) + admin = _role(_ADMIN_ARN) + perm_1 = _assume_perm_edge(src_arn=_ALICE_ARN, dst_arn=_DEVOPS_ARN) + unrelated_trust = _trust_edge(principal_arn=bob_arn, target_arn=_DEVOPS_ARN) + perm_2 = _assume_perm_edge(src_arn=_DEVOPS_ARN, dst_arn=_ADMIN_ARN) + trust_2 = _trust_edge(principal_arn=_DEVOPS_ARN, target_arn=_ADMIN_ARN) + admin_grant = _admin_grant_edge(_ADMIN_ARN) + facts = _make_facts( + nodes=(alice, devops, admin), + edges=(perm_1, unrelated_trust, perm_2, trust_2, admin_grant), + ) + + assert AssumeRoleChainReasoner().run(facts) == [] + + class TestHyperedgeOnHop: def test_wildcard_resource_first_hop_inconclusive(self) -> None: """Wildcard sts:AssumeRole resource on the Alice → DevOps hop."""