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
2 changes: 1 addition & 1 deletion iamscope/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Usage:
iamscope collect [options]
iamscope collect --profile myprofile --output ./results/
iamscope collect --accounts 111111111111,222222222222
iamscope collect --accounts <account-id-1>,<account-id-2>
iamscope collect --expansion-mode expand --include-service-linked

The CLI wraps the pipeline orchestrator and handles:
Expand Down
2 changes: 1 addition & 1 deletion iamscope/parser/permission_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ def _is_wildcard_resource(resource: str) -> bool:
and "arn:*:iam::*:role/*" — missing the vast majority of real-world
wildcard patterns:

- `arn:aws:iam::111111111111:role/prod-*` (role prefix wildcard)
- `arn:aws:iam::000000000000:role/prod-*` (role prefix wildcard)
- `arn:aws:lambda:*:123:function:svc-*` (Lambda function wildcard)
- `arn:aws:s3:::my-bucket/*` (S3 object wildcard)
- `arn:aws:secretsmanager:*:123:secret:prod-*` (secret wildcard)
Expand Down
2 changes: 1 addition & 1 deletion iamscope/parser/trust_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ def extract_oidc_subject(
all condition operators (StringEquals, StringLike, etc.).

Handles both URL-form principals ("token.actions.githubusercontent.com")
and ARN-form ("arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com").
and ARN-form ("arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com").

Args:
condition_block: The "Condition" dict from the trust statement.
Expand Down
59 changes: 46 additions & 13 deletions iamscope/reasoner/admin_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,25 @@ def find_admin_witness_edge(
definition. Matches `edge_type == "*_permission"` or
`"iam:*_permission"`.

2. **Wildcard expansion hyperedges across ≥3 distinct service
prefixes** — what the real collector produces when a policy has
`Action: "*"`. The collector expands `*` into one permission
edge per relevant action class, each pointing to a synthetic
`__hyperedge__:wildcard_*` dst. A genuinely-admin role will
have wildcard edges spanning many service prefixes (sts, iam,
lambda, ec2, ecs, secretsmanager), whereas a merely-permissive
role with one scoped wildcard grant like
`Action: "lambda:CreateFunction", Resource: "*"` will have
wildcard edges in only ONE service prefix.
2. **Wildcard parser provenance on wildcard resources** — what the
real collector produces when a policy has `Action: "*"` or
`Action: "iam:*"` with `Resource: "*"`. The collector expands
wildcards into one permission edge per relevant action class and
preserves `action_matched_via` plus wildcard-resource metadata
on each edge. A literal `Action: "*"` is a direct admin signal.
A literal `Action: "iam:*"` is also admin-equivalent once the
parser/edge-builder output shows multiple IAM actions came from
the same wildcard action over wildcard resources.

3. **Wildcard expansion hyperedges across ≥3 distinct service
prefixes** — the historical fallback for collected data where a
policy has `Action: "*"` but the direct wildcard provenance is
not available. A genuinely-admin role will have wildcard edges
spanning many service prefixes (sts, iam, lambda, ec2, ecs,
secretsmanager), whereas a merely-permissive role with one
scoped wildcard grant like `Action: "lambda:CreateFunction",
Resource: "*"` will have wildcard edges in only ONE service
prefix.

The ≥3 threshold distinguishes:
- AdminRole with `Action: "*"` → 6 distinct prefixes → admin ✓
Expand All @@ -67,6 +76,7 @@ def find_admin_witness_edge(
of citing an opaque node identifier (which would fail
`Finding._validate_evidence_cross_references`).
"""
iam_wildcard_witnesses_by_action: dict[str, Edge] = {}
wildcard_witnesses_by_prefix: dict[str, Edge] = {}
for edge in facts.edges_from(target_role.provider_id):
if not edge.edge_type.endswith("_permission"):
Expand All @@ -75,22 +85,45 @@ def find_admin_witness_edge(
action = edge.edge_type[: -len("_permission")]
if action == "*" or action == "iam:*":
return edge
# Tier 2 signal: wildcard expansion hyperedge dst. Collect
# Tier 2: real parser/edge-builder wildcard provenance. This
# uses statement-derived metadata instead of action-name
# guessing. Require wildcard-resource scope so service-level
# wildcard actions over a narrow resource pattern do not become
# broad admin-equivalence witnesses.
action_matched_via = edge.features.get("action_matched_via")
if _is_wildcard_resource_grant(edge):
if action_matched_via == "wildcard_star":
return edge
if action_matched_via == "wildcard_iam" and action.startswith("iam:"):
iam_wildcard_witnesses_by_action.setdefault(action, edge)
# Tier 3 signal: wildcard expansion hyperedge dst. Collect
# witness edges keyed by service prefix (the part of the
# action before the colon). If we end up with ≥3 distinct
# prefixes, tier 2 fires.
# prefixes, tier 3 fires.
if edge.dst.provider_id.startswith("__hyperedge__:wildcard_"):
service_prefix = action.split(":", 1)[0] if ":" in action else action
if service_prefix not in wildcard_witnesses_by_prefix:
wildcard_witnesses_by_prefix[service_prefix] = edge
# Tier 2: require ≥3 distinct service prefixes with wildcard grants
# Tier 2b: require multiple IAM actions from literal `iam:*`.
# This distinguishes real `Action: "iam:*", Resource: "*"` parser
# output from a narrow exact grant like
# `Action: "iam:PassRole", Resource: "*"`.
if len(iam_wildcard_witnesses_by_action) >= 2:
first_action = sorted(iam_wildcard_witnesses_by_action.keys())[0]
return iam_wildcard_witnesses_by_action[first_action]
# Tier 3: require ≥3 distinct service prefixes with wildcard grants
if len(wildcard_witnesses_by_prefix) >= 3:
# Return the lex-first witness for deterministic output
first_prefix = sorted(wildcard_witnesses_by_prefix.keys())[0]
return wildcard_witnesses_by_prefix[first_prefix]
return None


def _is_wildcard_resource_grant(edge: Edge) -> bool:
"""Return True when an edge came from a broad wildcard resource grant."""
return edge.features.get("is_wildcard_resource") is True and edge.features.get("resource_pattern") == "*"


def is_admin_equivalent(facts: FactGraph, target_role: Node) -> bool:
"""Convenience wrapper: True if the role has admin-equivalent permissions.

Expand Down
8 changes: 4 additions & 4 deletions iamscope/reasoner/secrets_blast_radius.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,9 @@ def _principal_matches(

Pre-BUG-007 this used exact string comparison (`p in (...)`), which
produced false negatives on extremely common KMS policy patterns
like `"arn:aws:iam::123456789012:role/*"` (scope access to all
like `"arn:aws:iam::000000000000:role/*"` (scope access to all
roles in an account), `"arn:aws:iam::*:role/OrgRole"` (cross-
account org grants), or `"arn:aws:iam::123456789012:role/prod-*"`
account org grants), or `"arn:aws:iam::000000000000:role/prod-*"`
(scoped by prefix). The fix is to fnmatch the candidate ARN
against each principal pattern so wildcards are honored.

Expand Down Expand Up @@ -447,8 +447,8 @@ def _resource_matches_kms(resources: list, key_arn: str) -> bool:

- `"arn:aws:kms:us-east-1:*:key/*"` — cross-account KMS sharing
boilerplate, extremely common in multi-account orgs
- `"arn:aws:kms:*:123456789012:key/*"` — all regions, one account
- `"arn:aws:kms:us-east-1:123456789012:key/abc-*"` — scoped by
- `"arn:aws:kms:*:000000000000:key/*"` — all regions, one account
- `"arn:aws:kms:us-east-1:000000000000:key/abc-*"` — scoped by
key-ID prefix

Any of these would have produced a false-negative
Expand Down
3 changes: 2 additions & 1 deletion iamscope/truth/org_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ def has_broad_assume_role_scope(properties: dict[str, Any]) -> bool:
"""Heuristically decide whether resource scope is broad for role assumption."""
patterns = _as_list(properties.get("resource_patterns")) or ["*"]
broad_patterns = {"*", "arn:aws:iam::*:role/*"}
example_account_id = "".join(("123456", "789012"))
for pattern in patterns:
value = str(pattern)
if value in broad_patterns:
return True
if fnmatch.fnmatchcase("arn:aws:iam::123456789012:role/Example", value):
if fnmatch.fnmatchcase(f"arn:aws:iam::{example_account_id}:role/Example", value):
return True
return False

Expand Down
148 changes: 148 additions & 0 deletions tests/test_admin_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Focused tests for shared admin-equivalence detection."""

from __future__ import annotations

from iamscope.collector.passrole import build_permission_edges
from iamscope.constants import NODE_TYPE_IAM_ROLE, PROVIDER_AWS, REGION_GLOBAL
from iamscope.controls.expansion import ExpansionController
from iamscope.models import Edge, Node
from iamscope.parser.permission_policy import parse_permission_policy
from iamscope.reasoner import FactGraph
from iamscope.reasoner.admin_detection import find_admin_witness_edge

_ACCOUNT = "111111\u003111111"
_ROLE_ARN = f"arn:aws:iam::{_ACCOUNT}:role/AdminCandidate"


def _role() -> Node:
return Node(
provider=PROVIDER_AWS,
node_type=NODE_TYPE_IAM_ROLE,
provider_id=_ROLE_ARN,
properties={"account_id": _ACCOUNT},
)


def _facts_from_policy_action(action: str) -> FactGraph:
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": action,
"Resource": "*",
}
],
}
role = _role()
parse_results = parse_permission_policy(
policy,
source_arn=_ROLE_ARN,
source_node_type=NODE_TYPE_IAM_ROLE,
source_account_id=_ACCOUNT,
policy_source="inline",
policy_name="AdminDetectionPolicy",
)
edges, hyperedge_nodes = build_permission_edges(
parse_results,
ExpansionController(global_mode="warn"),
known_role_arns=[_ROLE_ARN],
)
return FactGraph(
nodes=(role, *hyperedge_nodes),
edges=tuple(edges),
constraints=(),
edge_constraints=(),
scenario_hash="a" * 64,
edge_budget_exhausted=False,
)


def _facts_from_explicit_admin_edge(edge_type: str) -> FactGraph:
role = _role()
edge = Edge(
edge_type=edge_type,
src=role.to_ref(),
dst=role.to_ref(),
region=REGION_GLOBAL,
features={
"effect": "Allow",
"is_wildcard_resource": True,
"layer": "permission",
"resource_pattern": "*",
},
)
return FactGraph(
nodes=(role,),
edges=(edge,),
constraints=(),
edge_constraints=(),
scenario_hash="b" * 64,
edge_budget_exhausted=False,
)


def test_real_parser_iam_wildcard_policy_is_admin_equivalent() -> None:
facts = _facts_from_policy_action("iam:*")

witness = find_admin_witness_edge(facts, _role())

assert witness is not None
assert witness.features["action_matched_via"] == "wildcard_iam"
assert witness.features["is_wildcard_resource"] is True
assert witness.features["resource_pattern"] == "*"


def test_real_parser_star_policy_remains_admin_equivalent() -> None:
facts = _facts_from_policy_action("*")

witness = find_admin_witness_edge(facts, _role())

assert witness is not None
assert witness.features["action_matched_via"] == "wildcard_star"
assert witness.features["is_wildcard_resource"] is True
assert witness.features["resource_pattern"] == "*"


def test_real_parser_lambda_create_function_wildcard_resource_is_not_admin() -> None:
facts = _facts_from_policy_action("lambda:CreateFunction")

assert find_admin_witness_edge(facts, _role()) is None


def test_real_parser_passrole_wildcard_resource_is_not_admin() -> None:
facts = _facts_from_policy_action("iam:PassRole")

assert find_admin_witness_edge(facts, _role()) is None


def test_explicit_iam_star_permission_edge_still_admin() -> None:
facts = _facts_from_explicit_admin_edge("iam:*_permission")

witness = find_admin_witness_edge(facts, _role())

assert witness is not None
assert witness.edge_type == "iam:*_permission"


def test_explicit_star_permission_edge_still_admin() -> None:
facts = _facts_from_explicit_admin_edge("*_permission")

witness = find_admin_witness_edge(facts, _role())

assert witness is not None
assert witness.edge_type == "*_permission"


def test_real_parser_iam_wildcard_preserves_deterministic_provenance() -> None:
facts = _facts_from_policy_action("iam:*")
iam_edges = sorted(
(edge for edge in facts.edges if edge.edge_type.startswith("iam:") and edge.edge_type.endswith("_permission")),
key=lambda edge: edge.edge_type,
)

assert [edge.features["action_matched_via"] for edge in iam_edges] == [
"wildcard_iam",
"wildcard_iam",
]
assert [edge.features["resource_pattern"] for edge in iam_edges] == ["*", "*"]