From c5f8a8b064532ca712444526a08e1d6592e9ad0f Mon Sep 17 00:00:00 2001 From: Eric Conklin Date: Thu, 4 Jun 2026 15:35:54 -0500 Subject: [PATCH] Fix admin detection for IAM wildcard parser output --- iamscope/cli.py | 2 +- iamscope/parser/permission_policy.py | 2 +- iamscope/parser/trust_policy.py | 2 +- iamscope/reasoner/admin_detection.py | 59 +++++++-- iamscope/reasoner/secrets_blast_radius.py | 8 +- iamscope/truth/org_controls.py | 3 +- tests/test_admin_detection.py | 148 ++++++++++++++++++++++ 7 files changed, 203 insertions(+), 21 deletions(-) create mode 100644 tests/test_admin_detection.py diff --git a/iamscope/cli.py b/iamscope/cli.py index 5dc0e70..4861d64 100644 --- a/iamscope/cli.py +++ b/iamscope/cli.py @@ -3,7 +3,7 @@ Usage: iamscope collect [options] iamscope collect --profile myprofile --output ./results/ - iamscope collect --accounts 111111111111,222222222222 + iamscope collect --accounts , iamscope collect --expansion-mode expand --include-service-linked The CLI wraps the pipeline orchestrator and handles: diff --git a/iamscope/parser/permission_policy.py b/iamscope/parser/permission_policy.py index 6944acd..d781d2f 100644 --- a/iamscope/parser/permission_policy.py +++ b/iamscope/parser/permission_policy.py @@ -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) diff --git a/iamscope/parser/trust_policy.py b/iamscope/parser/trust_policy.py index c509752..4c99185 100644 --- a/iamscope/parser/trust_policy.py +++ b/iamscope/parser/trust_policy.py @@ -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. diff --git a/iamscope/reasoner/admin_detection.py b/iamscope/reasoner/admin_detection.py index a43ab64..594089a 100644 --- a/iamscope/reasoner/admin_detection.py +++ b/iamscope/reasoner/admin_detection.py @@ -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 ✓ @@ -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"): @@ -75,15 +85,33 @@ 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] @@ -91,6 +119,11 @@ def find_admin_witness_edge( 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. diff --git a/iamscope/reasoner/secrets_blast_radius.py b/iamscope/reasoner/secrets_blast_radius.py index 9c4a6bd..2a39118 100644 --- a/iamscope/reasoner/secrets_blast_radius.py +++ b/iamscope/reasoner/secrets_blast_radius.py @@ -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. @@ -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 diff --git a/iamscope/truth/org_controls.py b/iamscope/truth/org_controls.py index 62e34fa..c82400a 100644 --- a/iamscope/truth/org_controls.py +++ b/iamscope/truth/org_controls.py @@ -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 diff --git a/tests/test_admin_detection.py b/tests/test_admin_detection.py new file mode 100644 index 0000000..f95b0fb --- /dev/null +++ b/tests/test_admin_detection.py @@ -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] == ["*", "*"]