
Most AWS breaches don't start with a zero-day. They start with a policy that gives more access than anyone meant to grant. A role gets "Action": "*" for a quick test. A Lambda gets s3:* on "Resource": "*" because nobody had time to scope it down. Six months later, nobody remembers why, and nobody's checking.
Checking by hand doesn't scale past a handful of policies. In this post we'll write a Python script that pulls every IAM policy in an account and flags the risky ones automatically.
What we're building
A script that:
- Lists every IAM policy attached to a user, group, or role
- Reads each policy's actual JSON document
- Flags statements that are too broad — wildcard actions, wildcard resources, or both
- Prints a short report you can run in CI or before an audit
Setup
You need boto3 and read-only IAM permissions. The managed policy IAMReadOnlyAccess covers everything this script calls.
pip install boto3
aws configure # or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN
Step 1: Pull every policy in one call
You could call list_policies, then get_policy, then get_policy_version for every single policy. That's three API calls per policy, and you'd still be missing inline policies on users, groups, and roles.
Instead, use get_account_authorization_details. It returns every user, group, role, and policy in the account — including their attached and inline policies — in one paginated call.
import boto3
iam = boto3.client("iam")
def get_all_entities():
paginator = iam.get_paginator("get_account_authorization_details")
users, groups, roles, policies = [], [], [], []
for page in paginator.paginate():
users.extend(page.get("UserDetailList", []))
groups.extend(page.get("GroupDetailList", []))
roles.extend(page.get("RoleDetailList", []))
policies.extend(page.get("Policies", []))
return users, groups, roles, policies
get_account_authorization_details also accepts a Filter parameter — a list made up of User, Role, Group, LocalManagedPolicy, and AWSManagedPolicy — if you only want a subset, e.g. just customer-managed policies:
paginator.paginate(Filter=["LocalManagedPolicy"])
Step 2: Read the policy document
A customer-managed policy's JSON lives in a specific version, not on the policy object itself. get_account_authorization_details already embeds the default version's document for managed policies, so you don't need a separate get_policy_version call for those. Inline policies (the ones attached directly to a user, group, or role) come with their document inline on the same entity, under UserPolicyList, GroupPolicyList, or RolePolicyList.
One catch that trips people up: the Document / PolicyDocument fields this API returns aren't plain dicts — they're JSON strings, URL-encoded per RFC 3986. Decode and parse both before you touch them:
import json
from urllib.parse import unquote
def parse_document(document):
if isinstance(document, str):
return json.loads(unquote(document))
return document # already a dict in some SDKs/mocks
def iter_policy_documents(users, groups, roles, policies):
# Customer-managed and AWS-managed policies
for policy in policies:
for version in policy.get("PolicyVersionList", []):
if version.get("IsDefaultVersion"):
yield policy["PolicyName"], parse_document(version["Document"])
# Inline policies on users, groups, roles
for entity_list in (users, groups, roles):
for entity in entity_list:
name = entity.get("UserName") or entity.get("GroupName") or entity.get("RoleName")
inline_policies = (
entity.get("UserPolicyList")
or entity.get("GroupPolicyList")
or entity.get("RolePolicyList")
or []
)
for inline in inline_policies:
yield f"{name}:{inline['PolicyName']}", parse_document(inline["PolicyDocument"])
Step 3: Flag the risky statements
A policy document is a dict with a Statement list. Each statement has an Effect, an Action, and a Resource — but either field can be a single string or a list. Normalize both before checking them.
def normalize(value):
if value is None:
return []
return value if isinstance(value, list) else [value]
def is_wildcard(value):
return "*" in value
def flag_statement(statement):
if statement.get("Effect") != "Allow":
return None
actions = normalize(statement.get("Action"))
resources = normalize(statement.get("Resource"))
if is_wildcard(actions) and is_wildcard(resources):
return "CRITICAL: full admin — Action:* on Resource:*"
if is_wildcard(actions):
return "HIGH: wildcard action on a scoped resource"
if is_wildcard(resources):
return "MEDIUM: scoped action on Resource:*"
if "iam:PassRole" in actions and is_wildcard(resources):
return "HIGH: PassRole with no resource scoping — can hand any role to any service"
return None
Step 4: Put it together
def audit():
users, groups, roles, policies = get_all_entities()
findings = []
for name, document in iter_policy_documents(users, groups, roles, policies):
for statement in normalize(document.get("Statement")):
flag = flag_statement(statement)
if flag:
findings.append((name, flag))
return findings
if __name__ == "__main__":
for policy_name, flag in audit():
print(f"[{flag.split(':')[0]}] {policy_name}: {flag}")
Running it against a test account gives output like this (illustrative — your real output depends on your account's policies):
[CRITICAL] legacy-admin-role:inline-policy: CRITICAL: full admin — Action:* on Resource:*
[HIGH] ci-deploy-user:deploy-policy: HIGH: wildcard action on a scoped resource
[MEDIUM] s3-reader-role:read-policy: MEDIUM: scoped action on Resource:*
The full script
Everything above, combined into one file you can run as-is against a read-only role:
import json
from urllib.parse import unquote
import boto3
iam = boto3.client("iam")
def get_all_entities():
paginator = iam.get_paginator("get_account_authorization_details")
users, groups, roles, policies = [], [], [], []
for page in paginator.paginate():
users.extend(page.get("UserDetailList", []))
groups.extend(page.get("GroupDetailList", []))
roles.extend(page.get("RoleDetailList", []))
policies.extend(page.get("Policies", []))
return users, groups, roles, policies
def parse_document(document):
if isinstance(document, str):
return json.loads(unquote(document))
return document
def iter_policy_documents(users, groups, roles, policies):
for policy in policies:
for version in policy.get("PolicyVersionList", []):
if version.get("IsDefaultVersion"):
yield policy["PolicyName"], parse_document(version["Document"])
for entity_list in (users, groups, roles):
for entity in entity_list:
name = entity.get("UserName") or entity.get("GroupName") or entity.get("RoleName")
inline_policies = (
entity.get("UserPolicyList")
or entity.get("GroupPolicyList")
or entity.get("RolePolicyList")
or []
)
for inline in inline_policies:
yield f"{name}:{inline['PolicyName']}", parse_document(inline["PolicyDocument"])
def normalize(value):
if value is None:
return []
return value if isinstance(value, list) else [value]
def is_wildcard(value):
return "*" in value
def flag_statement(statement):
if statement.get("Effect") != "Allow":
return None
actions = normalize(statement.get("Action"))
resources = normalize(statement.get("Resource"))
if is_wildcard(actions) and is_wildcard(resources):
return "CRITICAL: full admin — Action:* on Resource:*"
if is_wildcard(actions):
return "HIGH: wildcard action on a scoped resource"
if is_wildcard(resources):
return "MEDIUM: scoped action on Resource:*"
if "iam:PassRole" in actions and is_wildcard(resources):
return "HIGH: PassRole with no resource scoping — can hand any role to any service"
return None
def audit():
users, groups, roles, policies = get_all_entities()
findings = []
for name, document in iter_policy_documents(users, groups, roles, policies):
for statement in normalize(document.get("Statement")):
flag = flag_statement(statement)
if flag:
findings.append((name, flag))
return findings
if __name__ == "__main__":
for policy_name, flag in audit():
print(f"[{flag.split(':')[0]}] {policy_name}: {flag}")
Where to go from here
This script catches the obvious cases — wildcards and unscoped PassRole. A fuller audit also checks for:
- Policies with no
Conditionblock restricting source IP, MFA, or VPC endpoint - Roles trusted by
"Principal": "*"in their trust policy, not just their permission policy - Unused policies and unused permissions — AWS Access Analyzer's
generate-service-last-accessed-detailscovers this directly
Point it at a read-only role, and run it before your next audit instead of during it.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


