Skip to main content

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.

iamprover demo

[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

  1. Describe your principals and their policies (or point at a Terraform plan):
iamprover verify --account examples/account.json --invariants examples/invariants.yaml
  1. 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
  1. Or verify a live account — no Terraform required:
aws iam get-account-authorization-details > gaad.json
iamprover verify --gaad gaad.json --privesc --check-trust
  1. 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.7.1
  with:
    tf-plan: plan.json
    invariants: invariants.yaml
    privesc: "true"                # built-in privilege-escalation invariants
    privesc-unless: "arn:aws:iam::*:role/admin-*"

Pin an exact release as above, or use @v0 to track the latest 0.x release.

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, hijacking existing compute (Lambda code, Glue endpoints, CloudFormation stacks, EC2 instances via SSM / Instance Connect, SageMaker notebooks), and the iam:PassRole chains into Lambda, EC2, CloudFormation, Glue, SageMaker, CodeBuild, and Data Pipeline:

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 new closure relations are new values rather than new flags — which is exactly how v0.7 added one.

PassRole into compute: --closure pass-role (v0.7)

AssumeRole isn't the only way to borrow a role. A principal that can iam:PassRole a role to Lambda — and create a function — can run its own code with that role's permissions, without ever calling sts:AssumeRole. --closure pass-role adds those edges; --closure all follows both relations, so mixed chains (pass a role into Lambda, then assume another role from it) are found too:

iamprover verify --gaad gaad.json --invariants invariants.yaml --closure all

An edge into role R exists when R's trust policy admits a compute service (Lambda, EC2, CloudFormation, Glue, SageMaker, CodeBuild, Data Pipeline), the source may iam:PassRole on R, and the source may launch that service. A iam:PassedToService condition on the PassRole grant doesn't hide the edge — it's free request context, kept on the over-approximating side:

[FAIL] prod-data-read-restricted
    counterexample: arn:aws:iam::111122223333:role/dev
        step 1: iam:passrole on arn:aws:iam::111122223333:role/etl-exec
        step 2: lambda:createfunction on *
        step 3: s3:getobject on arn:aws:s3:::prod-data/

Unlike the --privesc catalog's privesc-passrole-* checks — which flag any principal holding PassRole plus a launch action — closure ties the hand-off to the specific role passed, so it reports only the paths that actually reach a violation of your invariants.

What is modeled (v0.7)

  • Allow/Deny with explicit-deny-overrides-allow and default deny
  • Action / NotAction / Resource / NotResource with * and ? wildcards
  • Case-insensitive action matching (as IAM does it)
  • Condition blocks: 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 a where: clause
  • Resource-based policies (e.g. bucket policies) with Principal: "*" or exact ARNs, unioned with identity-based grants; --check-anonymous verifies invariants for an unauthenticated principal (catches public grants)
  • Terraform plans: users, roles (with trust policies), groups, inline and managed policies, and aws_s3_bucket_policy — resolved through module references, with plan-time unknowns widened (never dropped) and reported as warnings
  • Invariant exemptions by exact ARN or glob
  • Multi-step forbid_chain invariants (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 bounded sts:AssumeRole chains, not just direct grants, with the chain shown in the counterexample
  • iam:PassRole into compute (--closure pass-role / all): passing a role to Lambda, EC2, CloudFormation, Glue, SageMaker, CodeBuild, or Data Pipeline counts as reaching it, checked against both the role's service trust and the caller's launch permission

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.

Validation: IAM Vulnerable

Run against Bishop Fox's IAM Vulnerable lab — a Terraform project with dozens of deliberately exploitable IAM escalation paths — iamprover verify --tf-plan plan.json --privesc --closure all detects all 37 escalation paths that are exploitable in the lab's own Terraform, including the four "tools commonly miss this" cases — offline, from a terraform plan, no AWS account needed. Two of five false-positive traps are flagged (a policy-version grant limited to unmodifiable AWS-managed policies, and an expired date condition) — the conservative direction, by design. Full results and one-command reproduction in docs/VALIDATION.md.

Performance

A realistic 10,000-principal account — three invariants, full assume-role closure — verifies in about 2 s end-to-end, thanks to a sound syntactic prefilter that skips provably-unsatisfiable solver queries. Details, methodology, and worst-case numbers in docs/BENCHMARKS.md.

Documentation

Doc What's in it
examples/README.md Guided walkthrough of every example, with real output
docs/API.md Python API and full CLI reference
docs/ARCHITECTURE.md Pipeline internals and the soundness invariant
docs/BENCHMARKS.md Measured runtime at 100 / 1k / 10k principals
docs/VALIDATION.md Detection results on the IAM Vulnerable lab
docs/COMPARISON.md vs. Access Analyzer, Prowler, Checkov
CONTRIBUTING.md Setup, the one rule (never under-approximate), module map

Roadmap

  • v0.3 — GitHub Action on the Marketplace · privilege-escalation chain detection (iam:PassRole → lambda:CreateFunction, iam:CreateAccessKey, …) as built-in invariants ✅ shipped
  • v0.4 — live-account ingestion via aws iam get-account-authorization-details · cross-account trust analysis · policy variables and tag-based conditions ✅ shipped
  • v0.5 — permission boundaries, SCPs, and RCPs as intersecting bounding layers ✅ shipped
  • v0.6 — access-analyzer-style reachability across the full principal graph (transitive sts:AssumeRole chains) ✅ shipped
  • v0.7 — iam:PassRole into service execution as a closure relation, composable with AssumeRole chains ✅ shipped
  • v0.8+ — more closure relations (code-hijack of existing functions/instances, sts:GetFederationToken), moving from a principal-to-principal graph toward a full identity/capability/resource attack graph

Development

pip install -e ".[dev]"
pytest
ruff check src tests scripts

See CONTRIBUTING.md for the module map and contribution expectations.

License

Apache-2.0

Metadata

Release files for iamprover 0.7.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for iamprover 0.7.1
File Size Uploaded
iamprover-0.7.1.tar.gz 579.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for iamprover 0.7.1
File Interpreter ABI Platform
iamprover-0.7.1-py3-none-any.whl Python 3 none any Details

Total release size: 622.5 kB

Release files / iamprover-0.7.1.tar.gz

Download URL iamprover-0.7.1.tar.gz
Size 579.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a827e5eb0c7890646d2a4705f0391aac10e76d6cf200de21d5b93447f298b8cf
BLAKE2b-256 checksum
How to use checksums
271cb9d3f8483d1c63ec9faafefb9944c4d2ecab224f2c7d38a0db0b36eb93ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / iamprover-0.7.1-py3-none-any.whl

Download URL iamprover-0.7.1-py3-none-any.whl
Size 42.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b082f4861b61b3e0cb7df05f141a32aec878c602780aabc63466ec136536fd2d
BLAKE2b-256 checksum
How to use checksums
f664e949b487a15b382eaeaed7c9e52ee6164c53d278169bbf26df9b00dfee26
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.7.1 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page