Metadata-Version: 2.5
Name: tfprivesc
Version: 0.1.1
Summary: Find IAM privilege escalation paths in a Terraform plan, before you apply
Project-URL: Homepage, https://github.com/bhavik-kanejiya/tfprivesc
Project-URL: Source, https://github.com/bhavik-kanejiya/tfprivesc
Project-URL: Issues, https://github.com/bhavik-kanejiya/tfprivesc/issues
Project-URL: Changelog, https://github.com/bhavik-kanejiya/tfprivesc/blob/main/CHANGELOG.md
Project-URL: Demo, https://bhavik-kanejiya.github.io/tfprivesc/
License: MIT
License-File: LICENSE
Keywords: aws,devsecops,iac,iam,privilege-escalation,sarif,security,static-analysis,terraform
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# tfprivesc

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

```bash
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](https://bhavik-kanejiya.github.io/tfprivesc/) — 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/`](./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/`](./bench/). Every
finding is adjudicated by hand and the judgements are committed, because a
precision figure nobody can audit is a marketing claim.

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

## Edge cases

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

```bash
python3 edge-cases/run.py
```

## GitHub Action

```yaml
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.1
        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:

```yaml
      - uses: bhavik-kanejiya/tfprivesc@v0.1.1
        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:

```bash
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:

```hcl
# 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

```bash
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](https://github.com/RhinoSecurityLabs/AWS-IAM-Privilege-Escalation)
(BSD 3-Clause).
