Skip to main content

tfprivesc

Find IAM privilege escalation paths in a Terraform plan — before you apply.

pip install tfprivesc
tfprivesc scan .
CRITICAL  ci-build can gain full administrator access to the AWS account

    1. ci-build can pass any IAM role to a service
       main.tf:41
    2. ci-build can create a stack that acts with that role
       main.tf:41
    3. cfn-exec trusts cloudformation.amazonaws.com, so a template runs with its permissions
       main.tf:18

    Fix: Scope iam:PassRole with an iam:PassedToService condition.
    PASSROLE_CLOUDFORMATION

That is not a hypothetical. Google's Cloud Threat Horizons H1 2026 attributes an incident to UNC6426 in which a stolen GitHub Actions token reached full AWS administrator in under 72 hours through an over-permissive CloudFormation role. The path is four lines of Terraform, and it is visible in the plan before the role exists.

No AWS credentials. No account access. It reads a JSON file.

Try it in your browser — runs client-side via Pyodide, nothing is uploaded.

Why another scanner

There are plenty of tools that flag a dangerous IAM permission. The problem is not detection, it is volume: a published benchmark found one popular scanner producing 1,193 false positives across 21 clean modules, and a scanner people mute finds nothing at all.

tfprivesc answers a narrower question — which permissions actually chain to administrator — and is built around not wasting your attention:

  • One mistake is one issue. iam:* satisfies a dozen rules; that is one problem, reported once, with the line that caused it. On Bishop Fox's iam-vulnerable this compresses 1,278 routes into 53 issues.
  • Escalation is multi-hop. Reaching a role grants its permissions, which can unlock a rule that did not match on the first pass. Single-pass scanners miss those chains entirely.
  • Silence is never the answer. A policy attached to nobody is dormant, a permission with no target here is latent, and anything unreadable is a gap. "Nothing found" and "did not look" never look the same.
  • Measured, not asserted. Precision is benchmarked on ordinary Terraform with every judgement committed in bench/, so you can disagree with a specific line rather than distrusting a number.

Runs alongside Checkov or Trivy rather than replacing them: they tell you a thousand things are wrong, this tells you which ones reach admin.

Coverage

Detects 39 of the 37 documented AWS escalation techniques, each with a dedicated test fixture that the test suite asserts actually fires. The count exceeds the technique numbering because some documented methods split into genuinely distinct routes, and because plain role assumption — not on the original list — is an escalation whenever the target holds more than you do.

Rule Technique Severity
IAM_ATTACH_GROUP_POLICY Managed policy attachment on your own IAM group critical
IAM_ATTACH_USER_POLICY Managed policy attachment on an IAM user critical
IAM_CREATE_POLICY_VERSION New default version on an attached managed policy critical
IAM_CREATE_ROLE_ATTACH Create a role, make it assumable, attach admin critical
IAM_CREATE_ROLE_PUT_POLICY Create a role, write admin inline, assume it critical
IAM_CREATE_USER_CHAIN Mint a brand new administrator user critical
IAM_PUT_GROUP_POLICY Inline policy write on your own IAM group critical
IAM_PUT_USER_POLICY Inline policy write on an IAM user critical
IAM_UPDATE_TRUST_AND_ATTACH Rewrite a role's trust policy and attach admin to it critical
IAM_UPDATE_TRUST_AND_PUT Rewrite a role's trust policy and write admin inline critical
CODEBUILD_UPDATE_PROJECT Repoint an existing CodeBuild project and run it high
EC2_INSTANCE_CONNECT_SSH Push an SSH key to an existing EC2 instance high
GLUE_UPDATE_DEVENDPOINT Replace the SSH key on an existing Glue endpoint high
IAM_ADD_USER_TO_GROUP Self-service membership of an IAM group high
IAM_ATTACH_ROLE_POLICY Managed policy attachment on an assumable role high
IAM_CREATE_ACCESS_KEY Issue access keys for another IAM user high
IAM_CREATE_LOGIN_PROFILE Set a console password on another IAM user high
IAM_PUT_ROLE_POLICY Inline policy write on an assumable role high
IAM_UPDATE_ASSUME_ROLE_POLICY Rewrite a role's trust policy and assume it high
IAM_UPDATE_LOGIN_PROFILE Reset the console password of another IAM user high
LAMBDA_UPDATE_FUNCTION_CODE Rewrite the code of an existing Lambda function high
LAMBDA_UPDATE_FUNCTION_CONFIG Add a malicious layer to an existing Lambda high
PASSROLE_APPSTREAM_IMAGEBUILDER Pass a role to a new AppStream image builder high
PASSROLE_CLOUDFORMATION Pass a role to a new CloudFormation stack high
PASSROLE_CODEBUILD_BATCH Pass a role to a new CodeBuild project and batch-build it high
PASSROLE_CODEBUILD_CREATE Pass a role to a new CodeBuild project high
PASSROLE_CODESTAR Pass a role to a new CodeStar project high
PASSROLE_DATAPIPELINE Pass a role to a Data Pipeline definition high
PASSROLE_EC2_RUNINSTANCES Pass a role to a new EC2 instance high
PASSROLE_GLUE_DEVENDPOINT Pass a role to a new Glue development endpoint high
PASSROLE_LAMBDA_ADDPERMISSION Pass a role to a Lambda and invoke it cross-account high
PASSROLE_LAMBDA_CREATE Pass a role to a new Lambda function and invoke it high
PASSROLE_LAMBDA_EVENTSOURCE Pass a role to a Lambda and trigger it via an event source high
PASSROLE_LAMBDA_UPDATECONFIG Attach a different role to an existing Lambda high
PASSROLE_SAGEMAKER_NOTEBOOK Pass a role to a new SageMaker notebook high
SAGEMAKER_PRESIGNED_URL Open an existing SageMaker notebook via presigned URL high
SSM_SEND_COMMAND Run arbitrary commands on an existing EC2 instance high
SSM_START_SESSION Open an SSM session on an existing EC2 instance high
STS_ASSUME_ROLE_CHAIN Assume a role that grants more than you have high

Managed policy attachments, group membership inheritance, instance-profile indirection, account-root trust delegation, and the contents of common AWS-managed policies are modelled. An AWS-managed ARN outside that set is reported as a coverage gap, never assumed harmless.

Not implemented: permission boundaries, SCPs, and condition-key evaluation. Statements carrying a Condition are skipped and listed under coverage.

Design notes

Modules and for_each are first-class. Terraform describes modules inconsistently — planned_values nests them under child_modules with full prefixed addresses, while configuration nests them under module_calls with local ones, and references inside a module may be var.x wired in by the parent. All three are reconciled, including cross-module variable wiring, so a module-structured repository analyses correctly rather than silently returning nothing. count/for_each instances are matched on their key.

Plans have no ARNs. Most attributes are known after apply, and jsonencode() wrapped around an unknown ARN makes the entire policy document unknown. So resources are keyed on Terraform address, and relationships are recovered from the configuration block's reference graph rather than values.

Conservative by default. A statement scoped to a literal ARN is not matched against a resource whose ARN is unknown, and statements carrying a Condition are skipped rather than assumed. Both trade recall for precision, which is the right direction for a tool that runs in CI.

Some methods need no target. iam:CreateUser + iam:CreateAccessKey + iam:AttachUserPolicy mints a fresh administrator — nothing in the plan has to exist first, and no single action in that list looks like admin. Rules may omit requires.target entirely for these.

Three ways to have no finding. A path that exists is a finding. A policy granting escalation but attached to nobody is dormant. A permission genuinely held with no resource in this plan to aim it at — lambda:UpdateFunctionCode where no Lambda is declared — is latent. Only the first fails CI, but reporting nothing for the other two would imply they are fine.

Silence is never the answer. A policy granting iam:AttachUserPolicy that is attached to nobody creates no path today — but reporting nothing at all would read as a broken tool. Those are listed as dormant: not exploitable now, live the moment anything is attached. They are kept out of findings so CI does not fail on them.

A gap list must be worth reading. Resources are separated by whether not modelling them is a hole or a scope decision: an unmodelled aws_iam_policy_attachment can grant permissions and is a real gap; an aws_iam_access_key is a credential and grants nothing; an S3 bucket is not IAM at all. Before this split, 40 of 49 reported gaps on iam-vulnerable were access keys — which teaches readers to skip the section, and then the gaps that matter are invisible.

Coverage gaps are reported, not hidden. Anything the analyser could not resolve appears in coverage.unresolved. Silence means "nothing found", never "nothing looked at".

Escalation is a fixpoint, not a path. Reaching a role grants its permissions, which can unlock rules that did not match on the first pass, so search.py iterates to saturation instead of running a shortest-path search. See tests/fixtures/chained_group_to_role/ for a two-hop path that a single pass cannot find.

A group is not an attacker. IAM groups have no credentials and cannot call an API; their permissions reach an attacker only through membership, which is modelled separately. Reporting the group as a source double-counts every member's paths.

One route against many targets is one route. A permission on "*" matches every candidate, so a single grant can produce dozens of findings whose text is word-for-word identical. Routes are collapsed on the sentence a reader sees, with the target count alongside: "can write an inline policy onto an IAM user (47 targets)".

Display logic lives on the model, not in the renderers. The CLI table and the browser have drifted three times — a stale wheel, a dropped result key, and a field deduplicated in one renderer but not the other. Both now read the same properties off FindingGroup, and a test asserts they agree field by field.

One mistake is one issue. A single over-broad grant like iam:* satisfies many rules at once. Findings are grouped by (principal, outcome), and when every route shares one enabling statement that line is named as the root cause — fixing it closes all of them. Without this, coverage growth makes the output worse rather than better.

Severity comes from what the target actually holds. Passing a role that happens to have AdministratorAccess is critical; passing one with no permissions yet is low, and says so — the escalation is real but there is nothing to gain until someone grants that role something.

Reaching * ends the chain. Once an attacker holds unrestricted access every remaining rule matches. Expansion stops there rather than reporting the same access under a dozen names.

Principals that already hold * on * are not attackers. They have nowhere to escalate to, so they are listed under coverage rather than matched against every rule.

Consequences are not findings. Once a principal reaches administrator every rule technically matches. Findings enabled only by permissions the analyser synthesised mid-chain are suppressed, so one escalation is reported once.

Known limitations

  • Only sees resources Terraform manages. A pre-existing role referenced by a data source is invisible, so paths through it are missed.

  • Conditions are not evaluated. Statements carrying one are skipped, not assumed.

  • A Deny that cannot be proven to apply marks the finding confidence: uncertain rather than suppressing it.

  • Permission boundaries and SCPs are not modelled.

  • Resources referenced through data sources are invisible: only what Terraform manages appears in the plan.

  • A policy document built with jsonencode() around a value computed during apply — a role ARN, typically — is entirely unknown at plan time and cannot be analysed. It is reported as a gap.

    This bias runs the wrong way and is worth stating plainly. Writing a scoped policy means referencing the ARN you are scoping to, which makes the document unknown; writing Resource = "*" is a constant and stays perfectly readable. Careless policies are visible to this tool and careful ones often are not. That is lucky for precision and bad for recall: "no findings" is weaker evidence than it looks, which is why the gap count belongs beside every result.

  • Fixture plans are currently synthesised by scripts/synth_plan.py rather than generated by Terraform. Regenerate with ./scripts/gen_fixture.sh before relying on real-world plan compatibility.

Benchmark

Recall is measured against Bishop Fox's iam-vulnerable; precision against a corpus of ordinary, well-written Terraform in bench/. Every finding is adjudicated by hand and the judgements are committed, because a precision figure nobody can audit is a marketing claim.

./bench/generate.sh && python3 bench/run.py

Edge cases

Twenty labelled plans probing the true/false positive boundary live in edge-cases/, including the documented misses.

python3 edge-cases/run.py

GitHub Action

permissions:
  contents: read
  security-events: write     # required for the code scanning upload

jobs:
  iam:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: bhavik-kanejiya/tfprivesc@v0.1.0
        with:
          working-directory: infra
          fail-on: high
          baseline: .tfprivesc-baseline.json   # optional, see below

Findings appear as annotations on the pull request and in the Security tab. Give it an existing plan file instead of working-directory if your pipeline already produces one:

      - uses: bhavik-kanejiya/tfprivesc@v0.1.0
        with:
          plan: plan.json

Pin the tag. An unpinned scanner can start failing your builds because someone else shipped a rule, which is a bad way to find out a tool exists.

Adopting it on an existing repository

Switching any scanner on mid-project fails the build on every path that was already there, and the usual response is continue-on-error: true — after which the tool stops mattering. Record what exists, then gate on what changes:

tfprivesc plan.json --write-baseline .tfprivesc-baseline.json   # once
tfprivesc plan.json --baseline .tfprivesc-baseline.json          # in CI

CI now answers "did this pull request add a path?" rather than "does this repository have paths?". Commit the baseline and review it like any other change — each entry carries the rule, the principal and the impact, so it is readable in a diff.

Entries are keyed on rule and resource address, so a rename resurfaces the finding. That is deliberate: matching on policy content instead would keep suppressing a finding after someone edited the policy. Accepted entries that no longer match anything are reported as stale, because a baseline quietly accumulating dead entries stops describing the repository.

Suppressing a finding

Put a comment directly above the resource that grants the permission:

# tfprivesc:ignore=IAM_PUT_ROLE_POLICY  reviewed 2026-08, break-glass role
resource "aws_iam_user_policy" "ci_inline" {

A bare # tfprivesc:ignore suppresses every rule for that resource. Suppression is per-resource and per-rule on purpose: muting one finding should never silently mute the next one.

Development

pip install -e ".[dev]"
make            # compile rules, check purity, run tests

Adding a rule: write rules/<name>.yaml, create tests/fixtures/<name>/ with main.tf, plan.json and expected.json, then make. The build fails if a rule has no fixture.

The core is stdlib-only so the same engine runs under Pyodide in the browser demo; make purity enforces it, and scripts/build_web.py refuses to bundle a wheel older than src/. The payload the browser consumes is shaped by report/web.py, not assembled in JavaScript, and a test asserts every top-level result key either reaches the UI or is a documented omission — the demo has silently dropped a key twice, and that check is why it cannot again.

Terraform sources are optional everywhere. analyse_plan() accepts either a source_dir on disk or tf_sources={"main.tf": text} in memory — the browser uses the latter, which is why line numbers and tfprivesc:ignore still work there.

License

Apache-2.0. Escalation techniques derived from Rhino Security Labs (BSD 3-Clause).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tfprivesc-0.1.0.tar.gz (106.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tfprivesc-0.1.0-py3-none-any.whl (49.4 kB view details)

Uploaded Python 3

File details

Details for the file tfprivesc-0.1.0.tar.gz.

File metadata

  • Download URL: tfprivesc-0.1.0.tar.gz
  • Upload date:
  • Size: 106.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.11.9

File hashes

Hashes for tfprivesc-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e7d151927e08c727c8049bb3f15ead3d7192cb162aa97c7ee7bf93dcf8daf698
MD5 7188b3fc8a9785b6dc936b75cc97830b
BLAKE2b-256 cb0ae912960e0751f5e416cd668336d56c77c7329186c42f77a605b3980555c0

See more details on using hashes here.

File details

Details for the file tfprivesc-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: tfprivesc-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 49.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.11.9

File hashes

Hashes for tfprivesc-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8e22dbefe399742dfa0025cfc2d98f8ba5edd28340cc1e26dbd9cde187c8845d
MD5 5d3a91f9a6cfdf0ab421bfea14fe7d20
BLAKE2b-256 5dc5bc7589815efb8730c5b3be253063a84e2aabaeda4d6e992aaa5e6dbd001d

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.1

2 files

This release

0.1.0 This release

2 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