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'siam-vulnerablethis 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: uncertainrather than suppressing it. -
Permission boundaries and SCPs are not modelled.
-
Resources referenced through
datasources 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.pyrather than generated by Terraform. Regenerate with./scripts/gen_fixture.shbefore 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
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 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e7d151927e08c727c8049bb3f15ead3d7192cb162aa97c7ee7bf93dcf8daf698
|
|
| MD5 |
7188b3fc8a9785b6dc936b75cc97830b
|
|
| BLAKE2b-256 |
cb0ae912960e0751f5e416cd668336d56c77c7329186c42f77a605b3980555c0
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e22dbefe399742dfa0025cfc2d98f8ba5edd28340cc1e26dbd9cde187c8845d
|
|
| MD5 |
5d3a91f9a6cfdf0ab421bfea14fe7d20
|
|
| BLAKE2b-256 |
5dc5bc7589815efb8730c5b3be253063a84e2aabaeda4d6e992aaa5e6dbd001d
|