Formally verify security invariants over AWS IAM policies using an SMT solver (Z3), with counterexample traces.
Project description
iamprover
Formally verify security invariants over AWS IAM policies — with proofs, not pattern-matching.
Most IAM scanners grep for known misconfigurations. iamprover does something stronger: it encodes
IAM policy-evaluation semantics into an SMT solver (Z3) and
proves that your declared security invariants hold — or hands you a concrete counterexample
(principal, action, resource) showing exactly how they break.
[FAIL] prod-data-read-restricted — Only the data-team role may read the prod-data bucket
counterexample: arn:aws:iam::111122223333:role/ci-runner
can perform s3:getobject
on resource arn:aws:s3:::prod-data/A
[PASS] audit-logs-untouchable — No principal may perform any S3 action on the audit-logs bucket
[PASS] no-iam-mutation — No principal may mutate IAM (privilege-escalation surface)
2/3 invariants proven, 1 violated
The [PASS] lines are not "no findings" — they are proofs over all possible actions and
resources, including every wildcard expansion.
Why
Individually-correct IAM policies compose into globally-unsafe states: a broad s3:Get* on one
role quietly bypasses the least-privilege story your prod bucket policy tells. This tool grew out
of research on exactly that failure mode —
Security Invariants in Distributed Cloud Systems,
which model-checks how per-service security enforcement breaks under cross-service composition.
iamprover applies the same idea to real cloud policies: declare system-level invariants, verify
them mechanically, get violation traces when they fail.
Install
pip install iamprover
Quickstart
- Describe your principals and their policies (or point at a Terraform plan):
iamprover verify --account examples/account.json --invariants examples/invariants.yaml
- Or gate a Terraform change in CI:
terraform show -json plan > plan.json
iamprover verify --tf-plan plan.json --invariants invariants.yaml # exit 2 on violation
- Or verify a live account — no Terraform required:
aws iam get-account-authorization-details > gaad.json
iamprover verify --gaad gaad.json --privesc --check-trust
- Or use the GitHub Action to gate every pull request:
- uses: hashicorp/setup-terraform@v3
- run: terraform plan -out plan && terraform show -json plan > plan.json
- uses: UTKARSH698/iamprover@v0.4.0
with:
tf-plan: plan.json
invariants: invariants.yaml
privesc: "true" # built-in privilege-escalation invariants
privesc-unless: "arn:aws:iam::*:role/admin-*"
Invariants are declared in YAML:
invariants:
- id: prod-data-read-restricted
description: Only the data-team role may read objects in the prod-data bucket
forbid:
action: "s3:GetObject"
resource: "arn:aws:s3:::prod-data/*"
unless_principal:
- "arn:aws:iam::111122223333:role/data-team"
- id: no-passrole-lambda-escalation
description: No single principal may both pass a role and create a Lambda
forbid_chain: # fails only if ONE principal can do EVERY step
- action: "iam:PassRole"
- action: "lambda:CreateFunction"
Privilege-escalation invariants (v0.3)
--privesc enables a built-in catalog of the well-known AWS IAM escalation
paths — policy-version rewrites, credential minting, policy attachment,
trust-policy rewrites, code hijacking, and the iam:PassRole chains into
Lambda, EC2, CloudFormation, Glue, and SageMaker:
iamprover verify --tf-plan plan.json --privesc \
--privesc-unless "arn:aws:iam::111122223333:role/ops-admin"
Chains are verified compositionally: privesc-passrole-ec2 fails only if the
solver finds a single principal allowed both iam:PassRole and
ec2:RunInstances (each step checked as an independent request, so condition
gates still count) — counterexamples list every step:
[FAIL] privesc-passrole-ec2 — Principal can launch an EC2 instance with a privileged instance profile ...
counterexample: arn:aws:iam::111122223333:role/dev
step 1: iam:passrole on *
step 2: ec2:runinstances on *
Live accounts & cross-account trust (v0.4)
Point iamprover at a real account instead of a Terraform plan. --gaad ingests
the output of aws iam get-account-authorization-details, flattening group
memberships and managed-policy attachments and picking each policy's default
version:
aws iam get-account-authorization-details > gaad.json
iamprover verify --gaad gaad.json --invariants invariants.yaml --privesc
--check-trust analyzes every role's trust policy for grants that reach outside
its own account. External or public (Principal: "*") assume-role grants are
flagged; a grant is "guarded" when scoped by sts:ExternalId,
aws:PrincipalOrgID, aws:SourceAccount, or similar. Allowlist known partners
with --trusted-account:
iamprover verify --gaad gaad.json --check-trust --trusted-account 444455556666
[TRUST-FAIL] arn:aws:iam::111122223333:role/partner-access
assumable by arn:aws:iam::999988887777:root
UNGUARDED — no ExternalId / org / source-account condition
[TRUST-INFO] arn:aws:iam::111122223333:role/vendor-scoped
assumable by arn:aws:iam::444455556666:root
guarded by sts:ExternalId
Policy variables (${aws:username}, ${aws:PrincipalTag/team}, …) are
widened to * in Action/Resource and treated as unknown in conditions —
always in the over-approximating direction, so they never hide a violation.
Tag-based condition keys are modeled as free request-context variables the
solver searches over.
Bounding layers: permission boundaries, SCPs, RCPs (v0.5)
Identity- and resource-based policies only grant access; permission
boundaries, Service Control Policies (SCPs), and Resource Control Policies
(RCPs) only bound it — each must independently contain a matching Allow
or that path is closed, and an explicit Deny in any of them blocks access
outright. A permission boundary bounds identity-based access only; an RCP
bounds resource-based access only; an SCP bounds both, account-wide:
iamprover verify --account account.json --invariants invariants.yaml \
--scp org-scp.json --scp ou-scp.json --rcp account-rcp.json
--scp/--rcp are repeatable — pass one file per applicable layer (e.g. one
per OU level) and each is treated as an independent cap, so the effective
bound is their intersection while a deny in any one of them still applies.
Permission boundaries attach per-principal: set permission_boundary on a
principal in an --account file, or they're resolved automatically from
PermissionsBoundaryArn when using --gaad. Absent any of these, evaluation
is identical to v0.4.
Whole-system reachability: transitive AssumeRole chains (v0.6)
Every invariant above checks one principal's direct permissions. But a
principal with no direct access can still reach it by assuming a role that
has it: --closure assume-role widens every invariant to also cover
principals reachable through sts:AssumeRole chains, not just direct grants:
iamprover verify --gaad gaad.json --invariants invariants.yaml --closure assume-role
An edge exists between two principals when the source's identity policy
grants an assume-role action on the target and the target's trust policy
allows the source — both sides are checked independently, matching how AWS
actually evaluates sts:AssumeRole. Guarded trust conditions (ExternalId,
org id, source account) don't block the edge: a guard is a value the
assuming principal must supply, not a barrier to whether the relationship
exists, so it stays on the over-approximating side. Counterexamples show the
full chain:
[FAIL] no-s3-read
counterexample: arn:aws:iam::111122223333:role/a
step 1: sts:assumerole on arn:aws:iam::111122223333:role/b
step 2: s3:getobject on arn:aws:s3:::prod-data/x
Chain length is bounded by --max-hops (default 4) — AWS environments rarely
need deep AssumeRole chains, so a bounded search gives predictable runtime on
large live-account graphs while still catching realistic escalation paths.
--closure is deliberately a mode, not a boolean, so future closure
relations (e.g. iam:PassRole into service execution) can be added as new
values without another flag.
What is modeled (v0.6)
- Allow/Deny with explicit-deny-overrides-allow and default deny
Action/NotAction/Resource/NotResourcewith*and?wildcards- Case-insensitive action matching (as IAM does it)
Conditionblocks:StringEquals/Like(and Not/Arn variants),Bool,IpAddress/NotIpAddress(IPv4 CIDR, exact via bitvector encoding). The solver searches over all request contexts and counterexamples include the context (with context aws:multifactorauthpresent = true, …); invariants can pin context with awhere:clause- Resource-based policies (e.g. bucket policies) with
Principal: "*"or exact ARNs, unioned with identity-based grants;--check-anonymousverifies invariants for an unauthenticated principal (catches public grants) - Terraform plans: inline policies, managed policies via attachments (resolved by ARN or
configuration reference), and
aws_s3_bucket_policy - Invariant exemptions by exact ARN or glob
- Multi-step
forbid_chaininvariants (one principal holding every step) and a built-in privilege-escalation catalog (--privesc) - Live-account ingestion (
--gaad) with group/managed-policy flattening; cross-account trust analysis (--check-trust); policy variables and tag-based condition keys - Permission boundaries (identity-path bound), SCPs (identity- and
resource-path bound), and RCPs (resource-path bound), each as an
independent, intersecting cap (
--scp/--rcp, repeatable) - Whole-system reachability (
--closure assume-role): invariants extend over principals reachable through boundedsts:AssumeRolechains, not just direct grants, with the chain shown in the counterexample
Soundness note: unsupported condition operators degrade safely — treated as always-true on
Allow and always-false on Deny — so permissions are only ever over-approximated: iamprover may
flag violations a condition would prevent (false positives), but within the modeled fragment it
will not miss one (no false negatives). Trust the PASSes; investigate the FAILs.
Roadmap
v0.3 — GitHub Action on the Marketplace · privilege-escalation chain detection (✅ shippediam:PassRole→lambda:CreateFunction,iam:CreateAccessKey, …) as built-in invariantsv0.4 — live-account ingestion via✅ shippedaws iam get-account-authorization-details· cross-account trust analysis · policy variables and tag-based conditionsv0.5 — permission boundaries, SCPs, and RCPs as intersecting bounding layers✅ shippedv0.6 — access-analyzer-style reachability across the full principal graph (transitive✅ shippedsts:AssumeRolechains)- v0.7+ — richer closure relations beyond
sts:AssumeRole(e.g.iam:PassRoleinto service execution), moving from a principal-to-principal graph toward a full identity/capability/resource attack graph
Development
pip install -e ".[dev]"
pytest
License
Apache-2.0
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file iamprover-0.6.0.tar.gz.
File metadata
- Download URL: iamprover-0.6.0.tar.gz
- Upload date:
- Size: 35.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d71ac8bc2401f83a5df2036982bb85d4e5caa87398d80e1d34c881eb5e8634a4
|
|
| MD5 |
d5d71c576912ca79ec95277acdfecb3f
|
|
| BLAKE2b-256 |
a10789bccc23294483ccc61450c7f7ac1dadcac86132240cabf427bc57031558
|
Provenance
The following attestation bundles were made for iamprover-0.6.0.tar.gz:
Publisher:
publish.yml on UTKARSH698/iamprover
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
iamprover-0.6.0.tar.gz -
Subject digest:
d71ac8bc2401f83a5df2036982bb85d4e5caa87398d80e1d34c881eb5e8634a4 - Sigstore transparency entry: 2211565733
- Sigstore integration time:
-
Permalink:
UTKARSH698/iamprover@a199162c67b188f3a8cc4e5fb7a8ac3bbfe16514 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/UTKARSH698
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a199162c67b188f3a8cc4e5fb7a8ac3bbfe16514 -
Trigger Event:
push
-
Statement type:
File details
Details for the file iamprover-0.6.0-py3-none-any.whl.
File metadata
- Download URL: iamprover-0.6.0-py3-none-any.whl
- Upload date:
- Size: 33.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
41daaeac27928aca164c0a5aebe34be2cba56868ce73a18c3150cb5bcbe933df
|
|
| MD5 |
2f8d36bc51f07b30e1c4e48489285ffc
|
|
| BLAKE2b-256 |
1ddf1accbc49972e9b856926c57aefacfa5105f4bb2d278fa308c606f46ff7d0
|
Provenance
The following attestation bundles were made for iamprover-0.6.0-py3-none-any.whl:
Publisher:
publish.yml on UTKARSH698/iamprover
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
iamprover-0.6.0-py3-none-any.whl -
Subject digest:
41daaeac27928aca164c0a5aebe34be2cba56868ce73a18c3150cb5bcbe933df - Sigstore transparency entry: 2211565763
- Sigstore integration time:
-
Permalink:
UTKARSH698/iamprover@a199162c67b188f3a8cc4e5fb7a8ac3bbfe16514 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/UTKARSH698
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a199162c67b188f3a8cc4e5fb7a8ac3bbfe16514 -
Trigger Event:
push
-
Statement type: