Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion iamscope/reasoner/chain_walking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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::<ACCOUNT>:root` admits any principal in the same
Expand All @@ -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).
"""
Expand All @@ -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
)
11 changes: 10 additions & 1 deletion iamscope/reasoner/fact_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
"""
Expand All @@ -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))
61 changes: 61 additions & 0 deletions tests/test_admin_reachability_reasoner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
_role,
_trust_edge,
_user,
_wildcard_trust_edge,
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions tests/test_assume_role_chain_reasoner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down