From 0fb5cb4cc6b5e387395ea749f3ed96112f4b3c18 Mon Sep 17 00:00:00 2001 From: Eric Conklin Date: Thu, 4 Jun 2026 14:23:49 -0500 Subject: [PATCH] Fix low-blast reasoner correctness bugs --- iamscope/reasoner/cross_account_trust.py | 16 ++- iamscope/reasoner/passrole_ecs.py | 24 +++- iamscope/reasoner/passrole_lambda.py | 24 +++- iamscope/reasoner/s3_bucket_takeover.py | 85 +++++++++---- .../fixture_a_validated_critical.json | 2 +- .../fixture_b_wildcard_inconclusive.json | 2 +- .../fixture_c_blocked_by_scp.json | 2 +- tests/test_cross_account_reasoner.py | 114 +++++++++++++++++- tests/test_passrole_ecs_reasoner.py | 43 +++++++ tests/test_passrole_lambda_reasoner.py | 42 +++++++ tests/test_s3_bucket_takeover_reasoner.py | 37 +++++- 11 files changed, 342 insertions(+), 49 deletions(-) diff --git a/iamscope/reasoner/cross_account_trust.py b/iamscope/reasoner/cross_account_trust.py index f5f54c7..4b72973 100644 --- a/iamscope/reasoner/cross_account_trust.py +++ b/iamscope/reasoner/cross_account_trust.py @@ -61,6 +61,7 @@ from typing import Any from iamscope.constants import ( + CONSTRAINT_TYPE_SCP, NAKED_BROAD, NAKED_CONDITIONED, NAKED_CRITICAL, @@ -429,13 +430,18 @@ def _evaluate_edge( ) # ---- Check 4: no_scp_blocks_sts_assumerole. - # Walk all SCP bindings on this edge. In V1, all bindings on - # trust edges are SCPs (permission boundaries don't bind to - # trust edges), so we don't filter by constraint_type — every - # binding is in scope for this check. + # Walk only SCP bindings on this edge. The binding sidecar can + # attach SCP, TRUST_CONDITION, PERMISSION_BOUNDARY, and other + # constraints to trust edges; non-SCP constraints must not be + # treated as SCP blockers or SCP ambiguity. check_4_state = CheckState.PASS check_4_reason = "no SCP bindings observed on this edge" - bindings = facts.bindings_for_edge(edge_id) + bindings = [ + binding + for binding in facts.bindings_for_edge(edge_id) + if (constraint := facts.constraint_by_id(binding.constraint_id)) is not None + and constraint.constraint_type == CONSTRAINT_TYPE_SCP + ] if bindings: edge_constraint_refs.update(f"{b.edge_id}|{b.constraint_id}" for b in bindings) constraint_refs.update(b.constraint_id for b in bindings) diff --git a/iamscope/reasoner/passrole_ecs.py b/iamscope/reasoner/passrole_ecs.py index a11e47b..72a5da3 100644 --- a/iamscope/reasoner/passrole_ecs.py +++ b/iamscope/reasoner/passrole_ecs.py @@ -66,6 +66,7 @@ from __future__ import annotations +import fnmatch import logging from typing import Any @@ -1109,8 +1110,7 @@ def _check_passed_to_service( found_in_supported_op = True values = value if isinstance(value, list) else [value] for v in values: - v_lower = str(v).lower() - if v_lower == _ECS_TASKS_SERVICE_PRINCIPAL: + if self._passed_to_service_matches(operator, str(v), _ECS_TASKS_SERVICE_PRINCIPAL): scoped_to_ecs_tasks = True else: other_services.append(str(v)) @@ -1145,6 +1145,26 @@ def _check_passed_to_service( f"(not ECS) — PassRole cannot pass to an ECS task role", ) + def _passed_to_service_matches( + self, + operator: str, + condition_value: str, + service_principal: str, + ) -> bool: + """Evaluate supported iam:PassedToService operators. + + StringEquals preserves exact string semantics. StringLike uses + AWS-style glob matching, case-insensitively, so values such as + `ecs-tasks.*`, `*.amazonaws.com`, and `*` match ECS tasks. + """ + condition_value_lower = condition_value.lower() + service_principal_lower = service_principal.lower() + if operator == "StringEquals": + return condition_value_lower == service_principal_lower + if operator == "StringLike": + return fnmatch.fnmatchcase(service_principal_lower, condition_value_lower) + return False + # --------------------------------------------------------------- # Admin-equivalence read # --------------------------------------------------------------- diff --git a/iamscope/reasoner/passrole_lambda.py b/iamscope/reasoner/passrole_lambda.py index 4be7213..7d54882 100644 --- a/iamscope/reasoner/passrole_lambda.py +++ b/iamscope/reasoner/passrole_lambda.py @@ -66,6 +66,7 @@ from __future__ import annotations +import fnmatch import logging from typing import Any @@ -1089,8 +1090,7 @@ def _check_passed_to_service( found_in_supported_op = True values = value if isinstance(value, list) else [value] for v in values: - v_lower = str(v).lower() - if v_lower == _LAMBDA_SERVICE_PRINCIPAL: + if self._passed_to_service_matches(operator, str(v), _LAMBDA_SERVICE_PRINCIPAL): scoped_to_lambda = True else: other_services.append(str(v)) @@ -1125,6 +1125,26 @@ def _check_passed_to_service( f"(not Lambda) — PassRole cannot pass to a Lambda execution role", ) + def _passed_to_service_matches( + self, + operator: str, + condition_value: str, + service_principal: str, + ) -> bool: + """Evaluate supported iam:PassedToService operators. + + StringEquals preserves exact string semantics. StringLike uses + AWS-style glob matching, case-insensitively, so values such as + `lambda.*`, `*.amazonaws.com`, and `*` match Lambda. + """ + condition_value_lower = condition_value.lower() + service_principal_lower = service_principal.lower() + if operator == "StringEquals": + return condition_value_lower == service_principal_lower + if operator == "StringLike": + return fnmatch.fnmatchcase(service_principal_lower, condition_value_lower) + return False + # --------------------------------------------------------------- # Admin-equivalence read # --------------------------------------------------------------- diff --git a/iamscope/reasoner/s3_bucket_takeover.py b/iamscope/reasoner/s3_bucket_takeover.py index d2579e2..c209abc 100644 --- a/iamscope/reasoner/s3_bucket_takeover.py +++ b/iamscope/reasoner/s3_bucket_takeover.py @@ -229,43 +229,45 @@ def _build_finding( ) ) - # ---- Check 3: no_scp_blocks_put_bucket_policy - # Each check gets its own constraint_refs accumulator so the - # evidence_refs attributed to check 3 are ONLY the SCPs it - # evaluated, not a contaminated mix of SCPs and boundaries. - check_3_constraint_refs: set[str] = set() - check_3_state, check_3_reason, check_3_blockers = self._check_scp_blockers( - facts, - witness_edge, - check_3_constraint_refs, - edge_constraint_refs, + # ---- Check 3: target_bucket_collected + # Dangling bucket nodes are materialized from policy references + # when collection did not directly return the resource. Preserve + # the signal but demote to inconclusive rather than claiming a + # validated takeover against an uncollected bucket. + is_dangling_bucket = bool(bucket.properties.get("is_dangling_reference", False)) + check_3_state = CheckState.UNKNOWN if is_dangling_bucket else CheckState.PASS + check_3_reason = ( + "target bucket was referenced by policy but not directly collected" + if is_dangling_bucket + else "target bucket was directly collected" ) - blockers.extend(check_3_blockers) check_results.append( Check( - name="no_scp_blocks_put_bucket_policy", - description=("No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence"), - state=check_3_state, - evidence_refs=( - tuple(sorted(check_3_constraint_refs)) if check_3_constraint_refs else (witness_edge.edge_id,) + name="target_bucket_collected", + description=( + "Target S3 bucket was directly collected, not only materialized from a dangling policy reference" ), + state=check_3_state, + evidence_refs=(witness_edge.edge_id,), reason=check_3_reason, ) ) trace.append( TraceEntry( step=3, - action="check_no_scp_blocks_put_bucket_policy", - inputs=(witness_edge.edge_id,), + action="check_target_bucket_collected", + inputs=(bucket.provider_id,), result=check_3_state.value.upper(), reason=check_3_reason, ) ) - constraint_refs.update(check_3_constraint_refs) - # ---- Check 4: no_boundary_blocks_put_bucket_policy + # ---- Check 4: no_scp_blocks_put_bucket_policy + # Each check gets its own constraint_refs accumulator so the + # evidence_refs attributed to this check are ONLY the SCPs it + # evaluated, not a contaminated mix of SCPs and boundaries. check_4_constraint_refs: set[str] = set() - check_4_state, check_4_reason, check_4_blockers = self._check_boundary_blockers( + check_4_state, check_4_reason, check_4_blockers = self._check_scp_blockers( facts, witness_edge, check_4_constraint_refs, @@ -274,8 +276,8 @@ def _build_finding( blockers.extend(check_4_blockers) check_results.append( Check( - name="no_boundary_blocks_put_bucket_policy", - description=("No permission boundary blocks s3:PutBucketPolicy on this edge"), + name="no_scp_blocks_put_bucket_policy", + description=("No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence"), state=check_4_state, evidence_refs=( tuple(sorted(check_4_constraint_refs)) if check_4_constraint_refs else (witness_edge.edge_id,) @@ -286,7 +288,7 @@ def _build_finding( trace.append( TraceEntry( step=4, - action="check_no_boundary_blocks_put_bucket_policy", + action="check_no_scp_blocks_put_bucket_policy", inputs=(witness_edge.edge_id,), result=check_4_state.value.upper(), reason=check_4_reason, @@ -294,7 +296,38 @@ def _build_finding( ) constraint_refs.update(check_4_constraint_refs) - # ---- Check 5: principal_is_actionable + # ---- Check 5: no_boundary_blocks_put_bucket_policy + check_5_constraint_refs: set[str] = set() + check_5_state, check_5_reason, check_5_blockers = self._check_boundary_blockers( + facts, + witness_edge, + check_5_constraint_refs, + edge_constraint_refs, + ) + blockers.extend(check_5_blockers) + check_results.append( + Check( + name="no_boundary_blocks_put_bucket_policy", + description=("No permission boundary blocks s3:PutBucketPolicy on this edge"), + state=check_5_state, + evidence_refs=( + tuple(sorted(check_5_constraint_refs)) if check_5_constraint_refs else (witness_edge.edge_id,) + ), + reason=check_5_reason, + ) + ) + trace.append( + TraceEntry( + step=5, + action="check_no_boundary_blocks_put_bucket_policy", + inputs=(witness_edge.edge_id,), + result=check_5_state.value.upper(), + reason=check_5_reason, + ) + ) + constraint_refs.update(check_5_constraint_refs) + + # ---- Check 6: principal_is_actionable # Filter out service principals and root. Service principals # (*.amazonaws.com) represent infrastructure, not attacker- # controlled entities. Root accounts are always "admin" and @@ -316,7 +349,7 @@ def _build_finding( ) trace.append( TraceEntry( - step=5, + step=6, action="check_principal_is_actionable", inputs=(principal.provider_id,), result="PASS", diff --git a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_a_validated_critical.json b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_a_validated_critical.json index cc8d959..41e3ea1 100644 --- a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_a_validated_critical.json +++ b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_a_validated_critical.json @@ -1 +1 @@ -{"findings":[{"assumptions":[],"blockers_observed":[],"evidence":{"condition_context_assumed":[],"constraint_refs":[],"edge_constraint_refs":[],"edge_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"witness edge resolves to specific target bucket","result":"PASS","step":2},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no SCP bindings observed","result":"PASS","step":3},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no permission boundary bindings observed","result":"PASS","step":4},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":5}],"statement_digests":["111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031"],"statement_sources":{"111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"048c21f6da3d84d70b2d70435a564459d883540c997ca847ae74ca41c6d0706e","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"all checks PASS; principal can rewrite bucket policy and take full control of bucket contents","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"witness_edge_is_clean","reason":"witness edge resolves to specific target bucket","state":"pass"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_scp_blocks_put_bucket_policy","reason":"no SCP bindings observed","state":"pass"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"critical","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Validated S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"validated"}],"metadata":{"canonical_hash":"320c41814ad68d80f04ee50e91c9236a76d3ea49c68504bcc24470205b80113b","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":0,"inconclusive":0,"precondition_only":0,"validated":1}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file +{"findings":[{"assumptions":[],"blockers_observed":[],"evidence":{"condition_context_assumed":[],"constraint_refs":[],"edge_constraint_refs":[],"edge_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"witness edge resolves to specific target bucket","result":"PASS","step":2},{"action":"check_target_bucket_collected","inputs":["arn:aws:s3:::corp-secrets"],"reason":"target bucket was directly collected","result":"PASS","step":3},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no SCP bindings observed","result":"PASS","step":4},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no permission boundary bindings observed","result":"PASS","step":5},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":6}],"statement_digests":["111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111"],"statement_sources":{"111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"e6091fadcb0f61074bf72938dc5269c03c163c962b57c326e757851e22f0ac69","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"all checks PASS; principal can rewrite bucket policy and take full control of bucket contents","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"witness_edge_is_clean","reason":"witness edge resolves to specific target bucket","state":"pass"},{"description":"Target S3 bucket was directly collected, not only materialized from a dangling policy reference","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"target_bucket_collected","reason":"target bucket was directly collected","state":"pass"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_scp_blocks_put_bucket_policy","reason":"no SCP bindings observed","state":"pass"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"critical","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Validated S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"validated"}],"metadata":{"canonical_hash":"4ed979a6bdda9488af1eb7ef32948317358a0dbb060d7c38a49b9680e59a92d0","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":0,"inconclusive":0,"precondition_only":0,"validated":1}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file diff --git a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_b_wildcard_inconclusive.json b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_b_wildcard_inconclusive.json index c539307..d9803fb 100644 --- a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_b_wildcard_inconclusive.json +++ b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_b_wildcard_inconclusive.json @@ -1 +1 @@ -{"findings":[{"assumptions":[],"blockers_observed":[],"evidence":{"condition_context_assumed":[],"constraint_refs":[],"edge_constraint_refs":[],"edge_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"witness edge is wildcard-expansion hyperedge or wildcard-resource (target bucket iterated from all buckets)","result":"UNKNOWN","step":2},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"no SCP bindings observed","result":"PASS","step":3},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"no permission boundary bindings observed","result":"PASS","step":4},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":5}],"statement_digests":["111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031"],"statement_sources":{"111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"1e2b24d7a2aa8ecb1377d5778701f556bc6bb52d17204b2661b0c6555dec289c","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"check(s) UNKNOWN: witness_edge_is_clean","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"witness_edge_is_clean","reason":"witness edge is wildcard-expansion hyperedge or wildcard-resource (target bucket iterated from all buckets)","state":"unknown"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"no_scp_blocks_put_bucket_policy","reason":"no SCP bindings observed","state":"pass"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"high","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Inconclusive S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"inconclusive"}],"metadata":{"canonical_hash":"41c506640123d9a0ee084317f951e120ca57bbe04af2e1462eb19d78d1b8b764","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":0,"inconclusive":1,"precondition_only":0,"validated":0}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file +{"findings":[{"assumptions":[],"blockers_observed":[],"evidence":{"condition_context_assumed":[],"constraint_refs":[],"edge_constraint_refs":[],"edge_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"witness edge is wildcard-expansion hyperedge or wildcard-resource (target bucket iterated from all buckets)","result":"UNKNOWN","step":2},{"action":"check_target_bucket_collected","inputs":["arn:aws:s3:::corp-secrets"],"reason":"target bucket was directly collected","result":"PASS","step":3},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"no SCP bindings observed","result":"PASS","step":4},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"reason":"no permission boundary bindings observed","result":"PASS","step":5},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":6}],"statement_digests":["111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111"],"statement_sources":{"111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"7072cb21a0c744de44bda7517f9126d34d89d6c5b6e8628fb6f93bc139b24ae6","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"check(s) UNKNOWN: witness_edge_is_clean","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"witness_edge_is_clean","reason":"witness edge is wildcard-expansion hyperedge or wildcard-resource (target bucket iterated from all buckets)","state":"unknown"},{"description":"Target S3 bucket was directly collected, not only materialized from a dangling policy reference","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"target_bucket_collected","reason":"target bucket was directly collected","state":"pass"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"no_scp_blocks_put_bucket_policy","reason":"no SCP bindings observed","state":"pass"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["0e4f6b8396c298e1b2880feb066c021ff62e4f08698a9cd2ae4d890d71761845"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"high","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Inconclusive S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"inconclusive"}],"metadata":{"canonical_hash":"4832830039b0250982e043f13d646466692d452a07c27bf40f3bacf5b595ac15","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":0,"inconclusive":1,"precondition_only":0,"validated":0}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file diff --git a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_c_blocked_by_scp.json b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_c_blocked_by_scp.json index 67fc33a..f90122b 100644 --- a/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_c_blocked_by_scp.json +++ b/tests/fixtures/expected_output/findings/s3_bucket_takeover/fixture_c_blocked_by_scp.json @@ -1 +1 @@ -{"findings":[{"assumptions":[],"blockers_observed":[{"constraint_id":"5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5","edge_id":"ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057","kind":"scp","reason":"constraint 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 affects PutBucketPolicy"}],"evidence":{"condition_context_assumed":[],"constraint_refs":["5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"edge_constraint_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057:5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"edge_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"witness edge resolves to specific target bucket","result":"PASS","step":2},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"SCP 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 blocks (complete)","result":"FAIL","step":3},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no permission boundary bindings observed","result":"PASS","step":4},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":5}],"statement_digests":["111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031"],"statement_sources":{"111111\u003111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"a3fba64de2a177e49033164bb923b2db2e5d1ab04d56145ade77bb861fb76774","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"SCP blocks s3:PutBucketPolicy","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"witness_edge_is_clean","reason":"witness edge resolves to specific target bucket","state":"pass"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"name":"no_scp_blocks_put_bucket_policy","reason":"SCP 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 blocks (complete)","state":"fail"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"info","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Blocked S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"blocked"}],"metadata":{"canonical_hash":"dd4fa21f0a97d01d0114e0532448f50ddca4cbddeb47b3622e6aa216c067496b","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":1,"inconclusive":0,"precondition_only":0,"validated":0}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file +{"findings":[{"assumptions":[],"blockers_observed":[{"constraint_id":"5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5","edge_id":"ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057","kind":"scp","reason":"constraint 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 affects PutBucketPolicy"}],"evidence":{"condition_context_assumed":[],"constraint_refs":["5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"edge_constraint_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057:5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"edge_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"node_refs":["3698aa56e2b6f8b7e5769b63328e45d96642bcba2cd6bc9de5e4b259586b9de6","d4afe9f6244e0163de4e0d5b157205fc6797c1d476f1e67908cff3d7f46b2599"],"reasoning_trace":[{"action":"check_principal_has_put_bucket_policy_permission","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"permission edge witnessed","result":"PASS","step":1},{"action":"check_witness_edge_is_clean","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"witness edge resolves to specific target bucket","result":"PASS","step":2},{"action":"check_target_bucket_collected","inputs":["arn:aws:s3:::corp-secrets"],"reason":"target bucket was directly collected","result":"PASS","step":3},{"action":"check_no_scp_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"SCP 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 blocks (complete)","result":"FAIL","step":4},{"action":"check_no_boundary_blocks_put_bucket_policy","inputs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"reason":"no permission boundary bindings observed","result":"PASS","step":5},{"action":"check_principal_is_actionable","inputs":["arn:aws:iam::111111\u003111111:user/Alice"],"reason":"principal is an attacker-controllable user or role","result":"PASS","step":6}],"statement_digests":["111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111"],"statement_sources":{"111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u003111111\u0031111":["arn:aws:iam::111111\u003111111:policy/S3Mgmt",0,"s3:PutBucketPolicy grant"]}},"finding_id":"67de97b83a1e45a94ca3584bea811d0837ada8f1245c028ff86a8d6554c3aec7","finding_key":"9da3b827bc084540c2988c18f15602875cf18cae8dc2f3ae6a49681e444c526b","pattern_id":"s3_bucket_takeover","pattern_title":"S3 Bucket Takeover","pattern_version":"1.0.0","reasoner_exit_reason":"SCP blocks s3:PutBucketPolicy","required_checks":[{"description":"Principal has a permission edge for s3:PutBucketPolicy (enumeration invariant)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_has_put_bucket_policy_permission","reason":"permission edge witnessed","state":"pass"},{"description":"Permission edge for s3:PutBucketPolicy resolves to a specific target bucket (clean witness proves the edge's target)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"witness_edge_is_clean","reason":"witness edge resolves to specific target bucket","state":"pass"},{"description":"Target S3 bucket was directly collected, not only materialized from a dangling policy reference","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"target_bucket_collected","reason":"target bucket was directly collected","state":"pass"},{"description":"No SCP blocks s3:PutBucketPolicy on this edge with complete governance confidence","evidence_refs":["5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5"],"name":"no_scp_blocks_put_bucket_policy","reason":"SCP 5367b8ead62235ac6c8e64554eb270ff09b558111f007f2467dad988e1f703c5 blocks (complete)","state":"fail"},{"description":"No permission boundary blocks s3:PutBucketPolicy on this edge","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"no_boundary_blocks_put_bucket_policy","reason":"no permission boundary bindings observed","state":"pass"},{"description":"Principal is an attacker-controllable user or role (not a service principal or root)","evidence_refs":["ace127988856b808ad4e8d905a13462eca50cdab22afa554898e2bb0e6b8c057"],"name":"principal_is_actionable","reason":"principal is an attacker-controllable user or role","state":"pass"}],"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","severity":"info","source":{"node_type":"IAMUser","provider":"aws","provider_id":"arn:aws:iam::111111\u003111111:user/Alice","region":"-"},"target":{"node_type":"S3Bucket","provider":"aws","provider_id":"arn:aws:s3:::corp-secrets","region":"-"},"title":"Blocked S3 bucket takeover: arn:aws:iam::111111\u003111111:user/Alice can call s3:PutBucketPolicy on arn:aws:s3:::corp-secrets","verdict":"blocked"}],"metadata":{"canonical_hash":"ed54bcb7e18c08f959174facc521594e8e472e71e4d6673224cae5a8118d0924","collector":"iamscope","collector_version":"0.2.0","findings_count":1,"hash_scope":"canonical_hash excludes canonical_hash, reasoning_timestamp, reasoning_duration_seconds","id_algorithm":"sha256_null_separated_v2","reasoners_run":["s3_bucket_takeover"],"reasoners_skipped":{},"reasoning_duration_seconds":0.0,"reasoning_timestamp":"2026-01-01T00:00:00Z","verdict_breakdown":{"blocked":1,"inconclusive":0,"precondition_only":0,"validated":0}},"reasoner_versions":{"s3_bucket_takeover":"1.0.0"},"scenario_hash":"ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss","schema_version":"1.0","source_tool":"iamscope","source_tool_version":"0.2.0"} \ No newline at end of file diff --git a/tests/test_cross_account_reasoner.py b/tests/test_cross_account_reasoner.py index accd58a..ac30a18 100644 --- a/tests/test_cross_account_reasoner.py +++ b/tests/test_cross_account_reasoner.py @@ -21,6 +21,9 @@ from iamscope.constants import ( ACTION_CLASS_STS_ASSUME_ROLE, + CONSTRAINT_TYPE_PERMISSION_BOUNDARY, + CONSTRAINT_TYPE_SCP, + CONSTRAINT_TYPE_TRUST_CONDITION, NAKED_BROAD, NAKED_CONDITIONED, NAKED_CRITICAL, @@ -41,7 +44,7 @@ SEVERITY_MEDIUM, VALIDATED_STATE_DENIED, ) -from iamscope.models import Edge, EdgeConstraint, Node +from iamscope.models import Constraint, Edge, EdgeConstraint, Node from iamscope.reasoner import ( CheckState, CrossAccountTrustReasoner, @@ -233,17 +236,43 @@ def _edge_constraint( ) +def _constraint( + *, + constraint_id: str, + constraint_type: str, + parse_status: str = "complete", +) -> Constraint: + return Constraint( + provider=PROVIDER_AWS, + constraint_type=constraint_type, + scope_type="OU", + scope_id="ou-prod-12345", + policy_id=constraint_id, + statement_id=constraint_id, + region=REGION_GLOBAL, + properties={ + "deny_actions": ["sts:AssumeRole"], + "deny_not_actions": [], + "exception_principal_patterns": [], + "parse_status": parse_status, + "policy_name": constraint_id, + "resource_patterns": ["*"], + }, + ) + + def _make_facts( *, nodes: tuple[Node, ...], edges: tuple[Edge, ...], + constraints: tuple[Constraint, ...] = (), edge_constraints: tuple[EdgeConstraint, ...] = (), scenario_hash: str = "deadbeef" * 8, ) -> FactGraph: return FactGraph( nodes=nodes, edges=edges, - constraints=(), + constraints=constraints, edge_constraints=edge_constraints, scenario_hash=scenario_hash, edge_budget_exhausted=False, @@ -393,7 +422,10 @@ def test_evidence_bundle_has_statement_digest(self) -> None: class TestFixtureBBroadNakedBlockedBySCP: """SCP with complete confidence blocks the trust → blocked/info.""" - _SCP_CONSTRAINT_ID = "scp_constraint_xyz" + _SCP_CONSTRAINT_ID = _constraint( + constraint_id="scp_constraint_xyz", + constraint_type=CONSTRAINT_TYPE_SCP, + ).constraint_id def _build(self) -> FactGraph: target = _target_role_node() @@ -404,9 +436,13 @@ def _build(self) -> FactGraph: naked_trust=NAKED_BROAD, cross_account=True, ) + scp = _constraint( + constraint_id="scp_constraint_xyz", + constraint_type=CONSTRAINT_TYPE_SCP, + ) ec = _edge_constraint( edge_id=edge.edge_id, - constraint_id=self._SCP_CONSTRAINT_ID, + constraint_id=scp.constraint_id, governance_confidence="complete", likely_blocking=True, binding_reason="SCP DenyAssumeRole at OU ou-prod", @@ -414,6 +450,7 @@ def _build(self) -> FactGraph: return _make_facts( nodes=(target, external), edges=(edge,), + constraints=(scp,), edge_constraints=(ec,), ) @@ -436,6 +473,59 @@ def test_constraint_id_in_evidence(self) -> None: assert self._SCP_CONSTRAINT_ID in f.evidence.constraint_refs +class TestTrustEdgeBindingTypeFiltering: + """Only parent constraints typed as SCP can affect the SCP trust-edge check.""" + + def _build_with_binding_type(self, constraint_type: str) -> FactGraph: + target = _target_role_node() + external = _account_root_node(_EXTERNAL_ACCOUNT) + edge = _trust_edge( + src=external, + dst=target, + naked_trust=NAKED_BROAD, + cross_account=True, + ) + constraint = _constraint( + constraint_id=f"{constraint_type.lower()}_constraint_xyz", + constraint_type=constraint_type, + ) + binding = _edge_constraint( + edge_id=edge.edge_id, + constraint_id=constraint.constraint_id, + governance_confidence="complete", + likely_blocking=True, + binding_reason=f"{constraint_type} binding on trust edge", + ) + return _make_facts( + nodes=(target, external), + edges=(edge,), + constraints=(constraint,), + edge_constraints=(binding,), + ) + + def test_permission_boundary_binding_does_not_create_scp_blocker(self) -> None: + finding = CrossAccountTrustReasoner().run(self._build_with_binding_type(CONSTRAINT_TYPE_PERMISSION_BOUNDARY))[0] + check_4 = next(c for c in finding.required_checks if c.name == "no_scp_blocks_sts_assumerole") + assert finding.verdict is Verdict.VALIDATED + assert check_4.state is CheckState.PASS + assert not any(blocker.kind == "scp" for blocker in finding.blockers_observed) + + def test_trust_condition_binding_does_not_create_scp_unknown(self) -> None: + finding = CrossAccountTrustReasoner().run(self._build_with_binding_type(CONSTRAINT_TYPE_TRUST_CONDITION))[0] + check_4 = next(c for c in finding.required_checks if c.name == "no_scp_blocks_sts_assumerole") + assert finding.verdict is Verdict.VALIDATED + assert check_4.state is CheckState.PASS + assert not any(blocker.kind == "scp" for blocker in finding.blockers_observed) + + def test_actual_scp_binding_still_blocks(self) -> None: + finding = CrossAccountTrustReasoner().run(self._build_with_binding_type(CONSTRAINT_TYPE_SCP))[0] + check_4 = next(c for c in finding.required_checks if c.name == "no_scp_blocks_sts_assumerole") + assert finding.verdict is Verdict.BLOCKED + assert finding.severity == SEVERITY_INFO + assert check_4.state is CheckState.FAIL + assert any(blocker.kind == "scp" for blocker in finding.blockers_observed) + + # --------------------------------------------------------------------------- # Fixture C: narrow_naked_weak_conditions → validated/medium # --------------------------------------------------------------------------- @@ -516,9 +606,14 @@ def _build(self) -> FactGraph: naked_trust=NAKED_CRITICAL, cross_account=True, ) + scp = _constraint( + constraint_id="scp_partial_xyz", + constraint_type=CONSTRAINT_TYPE_SCP, + parse_status="partial", + ) ec = _edge_constraint( edge_id=edge.edge_id, - constraint_id="scp_partial_xyz", + constraint_id=scp.constraint_id, governance_confidence="partial", likely_blocking=False, binding_reason="SCP parse partial — could not evaluate fully", @@ -526,6 +621,7 @@ def _build(self) -> FactGraph: return _make_facts( nodes=(target, external), edges=(edge,), + constraints=(scp,), edge_constraints=(ec,), ) @@ -587,9 +683,14 @@ def _build(self) -> FactGraph: naked_trust=NAKED_CRITICAL, cross_account=True, ) + scp = _constraint( + constraint_id="scp_unsupported_xyz", + constraint_type=CONSTRAINT_TYPE_SCP, + parse_status="unsupported", + ) ec = _edge_constraint( edge_id=edge.edge_id, - constraint_id="scp_unsupported_xyz", + constraint_id=scp.constraint_id, governance_confidence="needs_review", likely_blocking=False, binding_reason="SCP uses unsupported syntax — manual review required", @@ -597,6 +698,7 @@ def _build(self) -> FactGraph: return _make_facts( nodes=(target, external), edges=(edge,), + constraints=(scp,), edge_constraints=(ec,), ) diff --git a/tests/test_passrole_ecs_reasoner.py b/tests/test_passrole_ecs_reasoner.py index b3d3e7a..ef1275a 100644 --- a/tests/test_passrole_ecs_reasoner.py +++ b/tests/test_passrole_ecs_reasoner.py @@ -1018,6 +1018,49 @@ def test_check_8_pass(self) -> None: assert check_8.state is CheckState.PASS +class TestPassedToServiceStringLikeSemantics: + """iam:PassedToService preserves StringEquals exactness and StringLike glob semantics.""" + + def _check_state_for(self, raw_conditions: dict[str, Any]) -> CheckState: + facts, _, _, _ = _build_admin_chain(raw_conditions=raw_conditions) + finding = PassRoleEcsReasoner().run(facts)[0] + check_8 = next(c for c in finding.required_checks if c.name == "passrole_condition_scoped_to_ecs_or_absent") + return check_8.state + + def test_stringequals_exact_ecs_tasks_passes(self) -> None: + assert ( + self._check_state_for({"StringEquals": {"iam:PassedToService": "ecs-tasks.amazonaws.com"}}) + is CheckState.PASS + ) + + def test_stringlike_ecs_tasks_glob_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "ecs-tasks.*"}}) is CheckState.PASS + + def test_stringlike_amazonaws_glob_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "*.amazonaws.com"}}) is CheckState.PASS + + def test_stringlike_star_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "*"}}) is CheckState.PASS + + def test_stringlike_wrong_service_fails(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "lambda.*"}}) is CheckState.FAIL + + def test_stringequals_glob_is_exact_and_fails(self) -> None: + assert self._check_state_for({"StringEquals": {"iam:PassedToService": "*.amazonaws.com"}}) is CheckState.FAIL + + def test_unsupported_operator_is_unknown(self) -> None: + assert ( + self._check_state_for({"StringNotEquals": {"iam:PassedToService": "ecs-tasks.amazonaws.com"}}) + is CheckState.UNKNOWN + ) + + def test_stringlike_list_passes_when_any_value_matches(self) -> None: + assert ( + self._check_state_for({"StringLike": {"iam:PassedToService": ["lambda.*", "ecs-tasks.*"]}}) + is CheckState.PASS + ) + + # --------------------------------------------------------------------------- # Fixture H: determinism double-run # --------------------------------------------------------------------------- diff --git a/tests/test_passrole_lambda_reasoner.py b/tests/test_passrole_lambda_reasoner.py index 5425021..dcad8a3 100644 --- a/tests/test_passrole_lambda_reasoner.py +++ b/tests/test_passrole_lambda_reasoner.py @@ -1068,6 +1068,48 @@ def test_check_8_pass(self) -> None: assert check_8.state is CheckState.PASS +class TestPassedToServiceStringLikeSemantics: + """iam:PassedToService preserves StringEquals exactness and StringLike glob semantics.""" + + def _check_state_for(self, raw_conditions: dict[str, Any]) -> CheckState: + facts, _, _, _ = _build_admin_chain(raw_conditions=raw_conditions) + finding = PassRoleLambdaReasoner().run(facts)[0] + check_8 = next(c for c in finding.required_checks if c.name == "passrole_condition_scoped_to_lambda_or_absent") + return check_8.state + + def test_stringequals_exact_lambda_passes(self) -> None: + assert ( + self._check_state_for({"StringEquals": {"iam:PassedToService": "lambda.amazonaws.com"}}) is CheckState.PASS + ) + + def test_stringlike_exact_lambda_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "lambda.amazonaws.com"}}) is CheckState.PASS + + def test_stringlike_lambda_glob_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "lambda.*"}}) is CheckState.PASS + + def test_stringlike_amazonaws_glob_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "*.amazonaws.com"}}) is CheckState.PASS + + def test_stringlike_star_passes(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "*"}}) is CheckState.PASS + + def test_stringlike_wrong_service_fails(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": "ec2.*"}}) is CheckState.FAIL + + def test_stringequals_glob_is_exact_and_fails(self) -> None: + assert self._check_state_for({"StringEquals": {"iam:PassedToService": "*.amazonaws.com"}}) is CheckState.FAIL + + def test_unsupported_operator_is_unknown(self) -> None: + assert ( + self._check_state_for({"StringNotEquals": {"iam:PassedToService": "lambda.amazonaws.com"}}) + is CheckState.UNKNOWN + ) + + def test_stringlike_list_passes_when_any_value_matches(self) -> None: + assert self._check_state_for({"StringLike": {"iam:PassedToService": ["ec2.*", "lambda.*"]}}) is CheckState.PASS + + # --------------------------------------------------------------------------- # Fixture H: determinism double-run # --------------------------------------------------------------------------- diff --git a/tests/test_s3_bucket_takeover_reasoner.py b/tests/test_s3_bucket_takeover_reasoner.py index c523618..4d91bfa 100644 --- a/tests/test_s3_bucket_takeover_reasoner.py +++ b/tests/test_s3_bucket_takeover_reasoner.py @@ -25,7 +25,7 @@ REGION_GLOBAL, ) from iamscope.models import Constraint, Edge, EdgeConstraint, Node -from iamscope.reasoner import FactGraph, S3BucketTakeoverReasoner +from iamscope.reasoner import CheckState, FactGraph, S3BucketTakeoverReasoner _ACCOUNT = "111111\u003111111" _ALICE_ARN = f"arn:aws:iam::{_ACCOUNT}:user/Alice" @@ -263,12 +263,13 @@ def test_role_to_bucket_validated_critical(self) -> None: assert findings[0].verdict.value == "validated" assert findings[0].severity == "critical" - def test_all_five_checks_present(self) -> None: + def test_all_six_checks_present(self) -> None: f = S3BucketTakeoverReasoner().run(_build_alice_to_bucket_a())[0] check_names = [c.name for c in f.required_checks] - assert len(check_names) == 5 + assert len(check_names) == 6 assert "principal_has_put_bucket_policy_permission" in check_names assert "witness_edge_is_clean" in check_names + assert "target_bucket_collected" in check_names assert "no_scp_blocks_put_bucket_policy" in check_names assert "no_boundary_blocks_put_bucket_policy" in check_names assert "principal_is_actionable" in check_names @@ -279,10 +280,36 @@ def test_title_mentions_takeover(self) -> None: assert "s3" in f.title.lower() def test_trace_steps_contiguous(self) -> None: - """Trace step numbering must be 1,2,3,4,5 (invariant).""" + """Trace step numbering must be 1,2,3,4,5,6 (invariant).""" f = S3BucketTakeoverReasoner().run(_build_alice_to_bucket_a())[0] steps = [t.step for t in f.evidence.reasoning_trace] - assert steps == [1, 2, 3, 4, 5] + assert steps == [1, 2, 3, 4, 5, 6] + + +class TestDanglingBucketReferences: + def test_dangling_bucket_emits_inconclusive_high(self) -> None: + alice = _user(_ALICE_ARN) + bucket = _bucket(_BUCKET_A) + bucket.properties["is_dangling_reference"] = True + edge = _pbp_edge(src=alice, bucket_arn=_BUCKET_A) + facts = _make_facts(nodes=(alice, bucket), edges=(edge,)) + + findings = S3BucketTakeoverReasoner().run(facts) + + assert len(findings) == 1 + finding = findings[0] + assert finding.verdict.value == "inconclusive" + assert finding.severity == "high" + assert "target_bucket_collected" in finding.reasoner_exit_reason + check = next(c for c in finding.required_checks if c.name == "target_bucket_collected") + assert check.state is CheckState.UNKNOWN + assert "not directly collected" in check.reason + + def test_non_dangling_clean_bucket_still_validated_critical(self) -> None: + findings = S3BucketTakeoverReasoner().run(_build_alice_to_bucket_a()) + assert len(findings) == 1 + assert findings[0].verdict.value == "validated" + assert findings[0].severity == "critical" # ---------------------------------------------------------------------------