Custom IAM policy restricts iam:PassRole to Bedrock AgentCore to specific roles
Customer-managed IAM policies are examined for Allow statements granting iam:PassRole over every role -- Resource *, or an IAM ARN whose resource field is nothing but wildcards -- where the passed role can reach Bedrock AgentCore: the statement pins iam:PassedToService to an AgentCore principal, or sets no such condition while the policy allows an AgentCore action.
Risk
AgentCore runtimes, gateways, code interpreters, browsers and evaluation configs all run under a role the caller names in the create call.
With iam:PassRole unbounded, any principal holding the policy can hand AgentCore any role in the account, an administrator role included, then reach that role's permissions through agent code it controls. The role itself needs no change.
Run this check with Prowler
Run in Prowler Cloudprowler aws --checks iam_policy_passrole_to_bedrock_agentcore_restrictedRecommendation
Name the roles that may be passed instead of allowing every role. AWS's own AgentCore Evaluations reference policy shows the shape: iam:PassRole on arn:aws:iam::*:role/AgentCoreEvaluationRole* under StringEquals iam:PassedToService = bedrock-agentcore.amazonaws.com. A role-name prefix satisfies this check; * and role/* do not. Keep the condition as well, so the same roles cannot be handed to another service.
Remediation
CLI
aws iam create-policy-version --policy-arn <POLICY_ARN> --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:PassRole"],"Resource":"arn:aws:iam::<ACCOUNT_ID>:role/<AGENTCORE_EXECUTION_ROLE_PREFIX>*","Condition":{"StringEquals":{"iam:PassedToService":"bedrock-agentcore.amazonaws.com"}}}]}' --set-as-defaultTerraform
# Name the roles AgentCore may be handed
resource "aws_iam_policy" "<example_resource_name>" {
name = "<example_resource_name>"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["iam:PassRole"]
# FIX: replace '*' with the execution roles AgentCore is meant to run as
Resource = "arn:aws:iam::${var.account_id}:role/<example_resource_id>*"
Condition = {
StringEquals = { "iam:PassedToService" = "bedrock-agentcore.amazonaws.com" }
}
}]
})
}NativeIaC
# CloudFormation: name the roles AgentCore may be handed
Resources:
<example_resource_name>:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: iam:PassRole
# FIX: replace '*' with the execution roles AgentCore is meant to run as.
# A name prefix is enough -- this is the scope AWS's own AgentCore
# Evaluations reference policy uses.
Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/<example_resource_id>*'
Condition:
StringEquals:
iam:PassedToService: bedrock-agentcore.amazonaws.comOther
Check Code
import re
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.iam.iam_client import iam_client
from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches
AGENTCORE_SERVICE_PREFIX = "bedrock-agentcore"
AGENTCORE_SERVICE_PRINCIPAL = "bedrock-agentcore.amazonaws.com"
# Bedrock AgentCore also reaches IAM through subdomain principals such as
# runtime-identity.bedrock-agentcore.amazonaws.com, so a literal value in that family
# pins the statement to AgentCore just as the base principal does.
AGENTCORE_PRINCIPAL_FAMILY_PATTERN = re.compile(
rf"^([a-z0-9-]+\.)?{AGENTCORE_SERVICE_PREFIX}(-[a-z0-9-]+)?\.amazonaws\.com$",
re.IGNORECASE,
)
# Concrete principals used to ask whether a condition value, READ AS AN IAM PATTERN, covers one of
# them. That is a different question from the regex above, which asks whether the value IS a family
# member, and asking only the second let a wildcard covering the family out of scope entirely. Both
# are needed: the regex catches a literal subdomain nobody enumerated here, and the probes catch a
# pattern that names no principal literally while reaching several.
AGENTCORE_PRINCIPAL_PROBES = (
AGENTCORE_SERVICE_PRINCIPAL,
f"runtime-identity.{AGENTCORE_SERVICE_PRINCIPAL}",
)
# Two role names used to ask whether a resource field names EVERY role rather than some of them:
# the shortest a role name can be, and the longest. Only "*" can span an arbitrary run of
# characters -- "?" matches exactly one -- so a pattern built from literals and "?" alone covers a
# bounded set of names and cannot match both probes, while any pattern that does match both leaves
# no role name outside it.
_SHORTEST_ROLE_NAME = "role/a"
_LONGEST_ROLE_NAME = "role/" + "r0le-name-" * 6 + "abcd" # 64 chars, the IAM maximum
EVERY_ROLE_RESOURCE_PROBES = (_SHORTEST_ROLE_NAME, _LONGEST_ROLE_NAME)
# Operators that COMPARE the request's iam:PassedToService against the statement's own values, so a
# value under one of them names a service this statement can hand a role to. An allow-list, because
# the deny-list this replaced -- two substring tests for "not" and "ifexists" -- dropped the entire
# condition on a one-word operator change, and a dropped condition fell through to the document-wide
# fallback in _targets_agentcore as though the statement pinned nothing at all.
#
# *IfExists and ForAllValues ARE included, unlike _RESTRICTIVE_ATTESTATION_OPERATORS in
# kms/lib/enclave.py which rejects both as vacuous-true when the key is absent. The difference is the
# question being asked: this function asks which services a statement NAMES, not whether the
# statement is restrictive. A value is named whether or not the operator would also admit a request
# that omits the key.
#
# The ARN operators are DELIBERATELY absent, which is why this list is shorter than the trust check's
# and not an oversight in it. iam:PassedToService is a STRING-typed key holding a service principal,
# and AWS documents it as working with the string operators; an ARN operator on it cannot compare
# meaningfully. The trust check needs ArnEquals and ArnLike because aws:SourceArn is ARN-typed. Two
# allow-lists differing by the TYPE of the key they read is correct; differing for no stated reason is
# what gets flagged, so the reason is here.
#
# Consequence worth naming: ArnLikeIfExists on iam:PassedToService contributes no value, so the
# statement takes the no-pin path and reaches every service -- the same route as carrying no condition
# at all. It still FAILs beside an AgentCore action, by that route rather than by being read as a pin.
_PASSED_TO_SERVICE_OPERATORS = frozenset(
f"{qualifier}{operator}{suffix}".lower()
for qualifier in ("", "ForAnyValue:", "ForAllValues:")
for operator in ("StringEquals", "StringEqualsIgnoreCase", "StringLike")
for suffix in ("", "IfExists")
)
def _as_list(value) -> list:
"""Normalize to list: None -> [], scalar -> [val], list -> list."""
if value is None:
return []
return value if isinstance(value, list) else [value]
def _statements(document: dict) -> list:
"""Extract Statement, normalizing single-statement dict to list."""
statements = document.get("Statement", [])
if not isinstance(statements, list):
statements = [statements]
return [statement for statement in statements if isinstance(statement, dict)]
def _covers_passrole(statement: dict) -> bool:
"""Return True if the statement's Action covers iam:PassRole.
The service field is matched as an IAM pattern rather than compared literally, so
`*:PassRole` is read as covering it. A bare "*" still does not count: a statement allowing
every action on every resource is what the administrative-privileges checks report, and
re-reporting it here would duplicate them instead of adding a claim. The `separator != ":"`
guard is what preserves that, since "*" partitions to an empty separator.
"""
for action in _as_list(statement.get("Action")):
if not isinstance(action, str):
continue
service, separator, operation = action.strip().partition(":")
# A statement allowing EVERY action is the administrative-privileges checks' finding,
# whether it is spelled "*" or "*:*"; the separator test covers the bare "*".
if separator != ":" or (service == "*" and operation == "*"):
continue
if not iam_pattern_matches(service, "iam"):
continue
if iam_pattern_matches(operation, "PassRole"):
return True
return False
def _grants_agentcore_action(document: dict) -> bool:
"""Return True if the policy allows at least one bedrock-agentcore action.
This is what puts an otherwise service-agnostic PassRole grant in scope: the same
policy can both create an AgentCore resource and choose the role it runs as. A bare
"*" is again excluded, so an administrator policy is not pulled in on that basis.
The service field is matched as an IAM pattern, as it is everywhere else these checks read
one. It matters most here: ``bedrock-*:CreateAgentRuntime`` is a plausible thing to write, since
one prefix covers bedrock and bedrock-agentcore together. A literal comparison would read it as
no AgentCore reach, which takes an unpinned PassRole grant beside it out of scope entirely and
clears the policy.
"""
for statement in _statements(document):
if statement.get("Effect") != "Allow":
continue
for action in _as_list(statement.get("Action")):
if not isinstance(action, str):
continue
service, separator, _ = action.strip().partition(":")
if separator == ":" and iam_pattern_matches(
service, AGENTCORE_SERVICE_PREFIX
):
return True
return False
def _passed_to_service_values(statement: dict) -> list:
"""Collect the services a statement's iam:PassedToService condition names.
Read through the allow-list above rather than by rejecting operator names. An operator this code
does not recognise must never be silently skipped, because ``_targets_agentcore`` treats "no
values" as "reaches every service" and then consults the whole document. So a skipped condition
inverts the verdict in BOTH directions, on nothing more than a one-word change of operator:
- ``StringEqualsIfExists`` naming AgentCore, on ``Resource: "*"``, yields no values, so a policy
carrying no other AgentCore action falls out of scope entirely -- a PassRole grant on every
role in the account, cleared by a high-severity privilege-escalation check.
- ``StringEqualsIfExists`` naming another service yields no values too, so the document-wide
fallback pulls the statement back in beside any AgentCore action and reports it, when a
statement pinned to sagemaker is out of scope by the same rule spelled ``StringEquals``.
A negated operator is deliberately not read: it names the services the statement will NOT pass
to, and the set it does reach is everything else, which is what "no values" already means here.
"""
values = []
condition = statement.get("Condition", {})
if not isinstance(condition, dict):
return values
for operator, block in condition.items():
if not isinstance(operator, str) or not isinstance(block, dict):
continue
lowered_operator = operator.lower()
if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS:
continue
for key, value in block.items():
if isinstance(key, str) and key.lower() == "iam:passedtoservice":
values.extend(_as_list(value))
return values
def _null_guarded_keys(condition: dict) -> set:
"""Return the lowercased condition keys a ``Null: "false"`` test forces to be present.
The helper the trust check in this PR defines, for the same reason, and byte-identical to it
EXCEPT for the value type read -- see below, and see that file's docstring for the other half.
Both spellings are handled, the scalar and the list, as kms/lib/enclave.py does.
A JSON ``false`` counts as well as the string ``"false"``, because on THIS check's surface IAM
stores and returns both. Measured on a customer-managed policy, which is exactly this check's
population: ``create_policy`` with ``{"Null": {"iam:PassedToService": false}}`` is accepted and
``get_policy_version`` returns a Python ``bool``, unconverted. Reading only the string leaves the
guard invisible, and the consequence is a false report on AWS's own prescribed hardening: a pin
AWS's simulator holds to one service -- absent key ``implicitDeny``, AgentCore ``implicitDeny``
-- would be reported as passing every role to AgentCore.
The trust sibling stays string-only ON PURPOSE, because a trust policy normalizes its scalars
and no bool can reach it: the divergence is measured, not drift.
This also DIVERGES from kms/lib/enclave.py deliberately: that copy tests ``isinstance(value,
str)`` only and carries the same blind spot. Diverging toward the correct reading rather than
inheriting the house copy's defect.
EVERY value must be ``false``, not merely one of them, because IAM ORs the values inside a single
condition operator. ``Null: {key: ["true","false"]}`` therefore means "key absent OR key present",
which is always true and binds NOTHING, yet reading it with ``any`` credited it as a guard and
rescued a defeasible pin -- so ADDING the word ``true`` to a guard list improved the score.
Measured on IAM's own evaluator with the key omitted: ``allowed`` for ``["true","false"]`` and
``["false","true"]``, indistinguishable from carrying no Null block, against ``implicitDeny`` for
``"false"``, ``["false"]`` and ``["false","false"]``. Reachable: ``create_policy`` stores a
multi-value list and ``get_policy_version`` returns it unchanged, and the trust surface preserves
a multi-element list too even though it collapses a single-element one to a scalar.
The ``candidates and`` guard is not decoration: ``all()`` over an empty list is True, so an empty
value list would otherwise be read as the strongest possible guard.
``0`` is NOT a guard, and it is the VALUE comparison that excludes it, not the type test:
``str(0)`` is ``"0"``, which is simply not ``"false"``. The ``isinstance`` merely narrows the
accepted types to the two JSON scalars IAM actually returns here. Spelled out because the
tempting formulation is the broken one -- ``not candidate`` or ``candidate is False`` reads
``0``, ``""``, ``None`` and ``[]`` as guards, since ``isinstance(False, int)`` is True in Python
and falsiness is not the question being asked.
ACCESS ANALYZER DOES NOT CORROBORATE THIS BOUNDARY, so do not cite it as support. Measured: it
reports TYPE_MISMATCH_BOOLEAN for ``"FALSE"`` and for ``" false "``, both of which this helper
CREDITS, as well as for ``0``, which it does not. Its type-checking is therefore stricter than
this helper's casing tolerance in one direction and looser in the other, and the boundary drawn
here is this check's own decision rather than an external one. Over-recognising is the dangerous
direction, because crediting a guard turns a FAIL into a PASS.
"""
guarded = set()
for operator, block in condition.items():
if not isinstance(operator, str) or operator.lower() != "null":
continue
if not isinstance(block, dict):
continue
for key, value in block.items():
if not isinstance(key, str):
continue
candidates = value if isinstance(value, list) else [value]
if candidates and all(
isinstance(candidate, (str, bool))
and str(candidate).strip().lower() == "false"
for candidate in candidates
):
guarded.add(key.lower())
return guarded
def _passed_to_service_pin_is_defeasible(statement: dict) -> bool:
"""Return True if every operator naming iam:PassedToService can be skipped by the caller.
An *IfExists operator is not evaluated when the request omits the key, and ForAllValues is
vacuous-true for an absent key -- kms/lib/enclave.py records these as the same trap. So a
statement whose only pin is one of those reaches every service as well as the one it names,
and cannot be treated as confined to it.
UNLESS the same statement carries ``Null: "false"`` on the same key, which forces the key to be
present and removes the skip. Reading that guard is what stops the check penalising the spelling
AWS prescribes -- "You should always include the Null condition operator ... with a false value".
Without it the hardened form scores worse than the plain one, which is backwards: a caller cannot
omit a key the guard requires, so the guard can only narrow the grant.
Conditions are ANDed, so one non-defeasible operator naming the key holds the request to that
key's values whatever else the statement carries; the pin is defeasible only if all of them are.
"""
defeasible = []
condition = statement.get("Condition", {})
if not isinstance(condition, dict):
return False
guarded = "iam:passedtoservice" in _null_guarded_keys(condition)
for operator, block in condition.items():
if not isinstance(operator, str) or not isinstance(block, dict):
continue
lowered_operator = operator.lower()
if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS:
continue
if not any(
isinstance(key, str) and key.lower() == "iam:passedtoservice"
for key in block
):
continue
defeasible.append(
(
lowered_operator.endswith("ifexists")
or lowered_operator.startswith("forallvalues:")
)
and not guarded
)
return bool(defeasible) and all(defeasible)
def _names_agentcore(values: list) -> bool:
"""Return True if any value can name an AgentCore service principal.
Two questions, and asking only the second was a false PASS on the exact grant this check exists
to catch. Probing the value as a pattern against the ONE base principal, plus a regex asking
whether the value IS a family member, left every wildcard that COVERS the family unrecognised:
*.bedrock-agentcore.amazonaws.com covers 2 known principals read as another service
*-identity.bedrock-agentcore.amazonaws.com covers runtime-identity read as another service
runtime-identity.* covers runtime-identity read as another service
*.bedrock-agentcore.* covers 2 read as another service
Read that way each would pin iam:PassedToService to something other than AgentCore, taking the
statement out of scope -- while both the narrower literal and the no-condition case are reported.
Broadening the grant would flip the verdict the safe way round, which is the shape that never
self-corrects.
So the value is now matched as a pattern against several concrete principals, not one, and a
literal outside that list is still caught by the family regex. ``?`` covers nothing here on
purpose: it matches exactly one character and no principal has a single-character subdomain --
that ``?`` cannot match zero characters is pinned by iam/lib/policy_test.py, not assumed here.
"""
return any(
isinstance(value, str)
and (
AGENTCORE_PRINCIPAL_FAMILY_PATTERN.match(value.strip())
or any(
iam_pattern_matches(value, probe)
for probe in AGENTCORE_PRINCIPAL_PROBES
)
)
for value in values
)
def _targets_agentcore(statement: dict, document: dict) -> bool:
"""Return True if the statement can hand a role to Bedrock AgentCore.
Three cases, in the order they are decided:
1. A condition NAMES AgentCore. The statement is in scope on its own terms, under any operator
that compares the key, *IfExists included. Dropping a spelling before its value is read is
what would clear a PassRole grant on every role in the account: with no values the statement
looks unpinned, so scope falls to a document that allows no AgentCore action.
2. A condition pins the key elsewhere and cannot be skipped. The statement cannot reach
AgentCore however the rest of the policy is shaped, so it is out of scope -- this is what
keeps a grant pinned to sagemaker.amazonaws.com out of the check.
3. Anything else -- no condition on the key, or only a defeasible one -- reaches every service,
so the rest of the policy decides: in scope when the policy also allows an AgentCore action.
"""
values = _passed_to_service_values(statement)
if _names_agentcore(values):
return True
if values and not _passed_to_service_pin_is_defeasible(statement):
return False
return _grants_agentcore_action(document)
def _names_every_role(resource: str) -> bool:
"""Return True if this one resource names every role rather than specific roles.
Decided by matching the resource against the shortest and longest role name a pattern would
have to cover, rather than by a regex demanding the resource field be a run of asterisks. That
regex was both too narrow and, being anchored on a literal ``arn:``, blind to a wildcarded
partition. Four resources naming every role in the account read as specific ones:
role/?* every role whose name has at least one character
*role* every role/... resource there is, since the stars absorb the prefix and the name
arn:aws:iam::* the star spans the account and resource fields
*:aws:iam::123456789012:role/* a leading star matches "arn"
``role/?`` still passes and is the case that shows the rule is not "contains a metacharacter":
``?`` matches exactly one character -- pinned by iam/lib/policy_test.py rather than assumed here
-- so it names single-character roles and nothing else. A name
prefix such as ``role/AgentCoreEvaluationRole*`` passes for the same reason -- it covers a set,
but not every role -- and that is the scope AWS's own AgentCore Evaluations reference policy
uses, so reporting it would report the documented configuration.
Residual, and it is a judgement not a hole: a pattern of exactly 64 ``?`` would match the long
probe and not the short one, so it reads as specific. It names every role whose name is exactly
64 characters, which is a set no operator writes by hand.
A pattern with fewer than six fields is decided structurally, and NOT by probing concrete ARNs.
A probe corpus pins the partition and account it happens to carry, which makes both load-bearing
in the short branch while the six-field branch ignores them: ``arn:aws:iam::555555555555*`` reads
as specific while the identical shape in the probe's own account is reported, and
``arn:aws-us-gov:iam::*`` and ``arn:aws-cn:iam::*`` read as specific merely because no probe
carries those partitions. No probe corpus can fix an account PREFIX -- there is nothing to
enumerate. So: a star in the LAST spelled-out field spans every
field after it, because IAM wildcards match the colon, and what remains is that the fields
actually spelled out must be able to name a role ARN.
The region and account positions get an extra test, and both rest on a fixed property of an IAM
ARN rather than on a corpus:
account an account is twelve digits, so a literal that is not twelve digits names no
account. This is what keeps ``arn:aws:iam::role/Prod*`` and
``arn:aws:iam::12345:role/*`` specific.
region every IAM ARN has an EMPTY region, so any region pattern that cannot match the
empty string names no region. This is what keeps ``arn:aws:iam:role/Prod*``
specific -- the same shape one colon short -- and, at six fields,
``arn:aws:iam:us-east-1:123456789012:role/*``.
BOTH BRANCHES APPLY BOTH TESTS, and the six-field branch not applying them was the defect that
reached review. It tested only that the partition and service fields could name an IAM ARN and
then probed the resource field, so a fully spelled-out ARN naming a region IAM does not have, or
an account of the wrong length, was read as naming every role: a high-severity
privilege-escalation FAIL on a pattern matching no role ARN at all. That is the same defect the
short branch had already been fixed for, surviving in the branch nobody re-read.
The two branches phrase the SAME question differently because the field means something
different in each, and this is the part that is easy to get wrong:
short branch the last spelled-out field carries a star that spans every field after it, so
only its literal HEAD is pinned to a position. ``?`` is discounted there because
it can match the colon and slide the rest of the pattern into a later field --
which is what keeps ``arn:aws:iam:?*`` naming every role.
six fields the field is delimited on both sides, so the WHOLE field must be able to name a
region or an account, and ``?`` is NOT discounted -- it has no colon to match
and must consume exactly one character of a region that has none. That is why
``arn:aws:iam:?:123456789012:role/*`` names no role while ``arn:aws:iam:?*``
names every one.
The PARTITION position deliberately gets no such test, which is why ``arn:xyz*`` still reads as
naming every role. A partition is not a fixed shape -- ``aws``, ``aws-cn``, ``aws-us-gov`` and the
iso partitions differ -- and a PREFIX of one cannot be enumerated, which is the same reason the
probe corpus was abandoned above. Over-reporting an unspellable partition is the safe direction
for a privilege-escalation check; guessing the partition set is not.
"""
candidate = resource.strip()
if candidate == "*":
return True
arn_fields = candidate.split(":", 5)
if len(arn_fields) < 6:
if "*" not in arn_fields[-1]:
return False
if not iam_pattern_matches(arn_fields[0], "arn"):
return False
if len(arn_fields) > 2 and not iam_pattern_matches(arn_fields[2], "iam"):
return False
if len(arn_fields) == 4:
# The last field sits in the REGION position, and every IAM ARN has an EMPTY region. Any
# literal head -- role/Prod in arn:aws:iam:role/Prod* -- can name no region, so the
# pattern matches no role ARN however its star spans. Discounting ? keeps
# arn:aws:iam:?* naming every role, since ? matches the colon.
region_head = arn_fields[3].split("*", 1)[0].replace("?", "")
if region_head:
return False
if len(arn_fields) == 5:
# The last field sits in the ACCOUNT position, and an account is twelve digits. A
# literal head that is not digits -- role/Prod in arn:aws:iam::role/Prod* -- can name no
# account, so the pattern matches no role ARN however its star spans.
account_head = arn_fields[4].split("*", 1)[0].replace("?", "")
if account_head and not account_head.isdigit():
return False
return True
if not iam_pattern_matches(arn_fields[0], "arn"):
return False
if not iam_pattern_matches(arn_fields[2], "iam"):
return False
# Every IAM ARN has an EMPTY region, so a region field that cannot match the empty string names
# no region and the pattern reaches no role. Asked as a pattern match against "" rather than by
# stripping metacharacters, because that is the whole question here: "" and "*" match it, while
# "us-east-1" and "?" do not. ? is deliberately NOT discounted, unlike the short branch above --
# this field is delimited on both sides, so there is no colon for it to match and it must consume
# one character of a region that has none.
if not iam_pattern_matches(arn_fields[3], ""):
return False
account_field = arn_fields[4]
# An account is twelve digits. Two ways a spelled-out field can fail to name one, and the second
# is unreachable in the short branch, where a trailing star always spans the field:
# a literal head that is not digits role/Prod in arn:aws:iam::role/Prod:...
# a starless field of the wrong width 12345, which is digits but names no account
account_head = account_field.split("*", 1)[0].replace("?", "")
if account_head and not account_head.isdigit():
return False
if "*" not in account_field and len(account_field) != 12:
return False
return all(
iam_pattern_matches(arn_fields[5], probe)
for probe in EVERY_ROLE_RESOURCE_PROBES
)
def _allows_any_role(statement: dict) -> bool:
"""Return True if the statement's resources name every role rather than named roles."""
resources = _as_list(statement.get("Resource"))
if not resources:
# NotResource grants every resource but the excluded ones, so every role outside
# that list stays passable.
return "NotResource" in statement
return any(
isinstance(resource, str) and _names_every_role(resource)
for resource in resources
)
def _deny_removes_passrole(statement: dict) -> bool:
"""Return True if a DENY statement's Action removes iam:PassRole.
Separate from _covers_passrole on purpose, because the bare-star exclusion that is right on the
Allow side inverts on the Deny side. There, skipping ``*`` and ``*:*`` avoids duplicating the
administrative-privileges checks; here it meant an unconditional Deny of EVERY action credited
nothing, so a policy that grants no PassRole at all was reported FAIL. The Allow-side guard is
deliberately left alone: relaxing it would reverse the settled decision that admin-level grants
belong to those checks.
Deny expressed as NotAction is still NOT read. Inverting it needs the whole action namespace,
and not crediting it errs toward reporting rather than toward silence.
"""
for action in _as_list(statement.get("Action")):
if not isinstance(action, str):
continue
service, separator, operation = action.strip().partition(":")
if separator != ":":
# A bare "*" denies every action, iam:PassRole among them.
if service == "*":
return True
continue
if not iam_pattern_matches(service, "iam"):
continue
if iam_pattern_matches(operation, "PassRole"):
return True
return False
def _denies_passrole_everywhere(document: dict) -> bool:
"""Return True if an unconditional Deny removes iam:PassRole on every resource."""
for statement in _statements(document):
if statement.get("Effect") != "Deny" or statement.get("Condition"):
continue
if not _deny_removes_passrole(statement):
continue
if any(
isinstance(resource, str) and resource.strip() == "*"
for resource in _as_list(statement.get("Resource"))
):
return True
return False
def _has_unevaluated_notaction(document: dict) -> bool:
"""True if an Allow statement expresses its actions as NotAction.
NotAction under Effect Allow grants everything EXCEPT what it lists, so a policy using
it can grant iam:PassRole while carrying no Action key at all. Reading only Action would
find nothing and report a clean policy. Inverting NotAction correctly means resolving it
against the full action namespace and its interaction with Resource and NotResource,
which is more than this check can honestly claim to do -- so the statement is declared
unevaluated rather than guessed at.
"""
for statement in _statements(document):
if statement.get("Effect") == "Allow" and "NotAction" in statement:
return True
return False
class iam_policy_passrole_to_bedrock_agentcore_restricted(Check):
"""Check whether a customer-managed policy scopes iam:PassRole to Bedrock AgentCore.
A statement granting ``iam:PassRole`` on every role beside any Bedrock AgentCore action lets the
holder hand an arbitrary role to an agent runtime and assume its permissions, which is a
privilege-escalation path. FAIL when the PassRole resource names every role; PASS when it names
a bounded set, when the statement pins ``iam:PassedToService`` to another service, or when no
AgentCore action accompanies it; MANUAL when the policy document could not be read.
Caveats:
Customer-managed policies only, since inline policies are covered by the
``iam_inline_policy_*`` checks and an AWS-managed policy cannot be edited to remediate a
finding. A bare ``Action: "*"`` is left to the administrative-privileges checks. Conditions
do not rescue an unbounded resource, because ``iam:PassedToService`` binds the service and
``iam:AssociatedResourceArn`` the consuming resource, neither the set of roles.
"""
def execute(self) -> Check_Report_AWS:
"""Flag policies allowing PassRole to AgentCore on all roles."""
findings = []
for policy in iam_client.policies.values():
# Only customer-managed policies: the inline population is a separate check,
# and an AWS-managed policy cannot be edited to remediate a finding.
if policy.type != "Custom":
continue
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
if not policy.document:
report.status = "MANUAL"
report.status_extended = (
f"Custom Policy {policy.name} could not be evaluated because its "
"policy document was not retrieved."
)
findings.append(report)
continue
if _has_unevaluated_notaction(policy.document):
report.status = "MANUAL"
report.status_extended = (
f"Custom Policy {policy.name} expresses an Allow statement with "
"NotAction, which this check does not evaluate, so whether it allows "
"iam:PassRole could not be determined; review it manually."
)
findings.append(report)
continue
unrestricted = False
if not _denies_passrole_everywhere(policy.document):
unrestricted = any(
statement.get("Effect") == "Allow"
and _covers_passrole(statement)
and _allows_any_role(statement)
and _targets_agentcore(statement, policy.document)
for statement in _statements(policy.document)
)
if unrestricted:
report.status = "FAIL"
report.status_extended = (
f"Custom Policy {policy.name} allows iam:PassRole to Bedrock AgentCore "
"on every role instead of the specific execution roles AgentCore is "
"meant to run as."
)
else:
report.status = "PASS"
report.status_extended = (
f"Custom Policy {policy.name} does not allow iam:PassRole to Bedrock "
"AgentCore on every role."
)
findings.append(report)
return findingsReferences
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations-prerequisites.html
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html
- https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html