Free · MIT · no account

Free Terraform review — in your pull requests, and in your terminal.

One of the two lines on the right deletes the database. The other only looks like it prevents that. TF Pre-Deploy Firewall reads your Terraform against the provider's real schema and reports on the line that caused it — no terraform init, no plan, no state file, no credentials, no account.

It is one binary and one engine, in two shapes: a GitHub Action that comments on the pull request, and a command-line scanner that runs on your machine, in a pre-commit hook, or in any CI — GitLab included. Unlimited repositories, unlimited scans, forever.

Paid plans exist for teams that outgrow it — wider provider coverage, a dashboard, central policy, history, SSO. Nothing you install today stops working if you never buy one, and the scanner never phones home unless you hand it a key.
rds.tf — 2 changed lines PR #482
resource "aws_db_instance" "primary" { identifier = "prod-primary" engine = "postgres" instance_class = "db.r6g.xlarge" - db_name = "appdb" + db_name = "app_production" username = "appuser" password = var.db_password + enable_deletion_protection = true }
Asked to rename the database and turn on deletion protection, an agent produced exactly this. Both lines are wrong, and neither is wrong in a way that reads wrong.
Reduced from a real scan. The findings below are that scan's verbatim output.
Install

An Action, or a command. Two minutes either way.

Pick whichever you already have — you can use both. Same engine, same 34 rules, same findings; only where they land differs. Neither sends anything anywhere, and neither needs an account.

.github/workflows/tf-firewall.yml GitHub Actions
name: tf-predeploy-firewall on: pull_request: paths: ["**/*.tf"] permissions: pull-requests: write contents: read jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } + - uses: foadtalsi/tf-predeploy-firewall@v1
That is the whole file. No key, no secret, no with: block — the defaults are the free scan. fetch-depth: 0 matters: without it the runner has no base branch to diff against.
your terminal CLI
$ pip install \ git+https://github.com/foadtalsi/tf-predeploy-firewall@v1 $ cd infra/terraform $ tf-predeploy-firewall --full-repo-scan 33 finding(s) — 11 medium, 22 low nothing reaches high medium Missing prevent_destroy (9) apigw.tf:19 aws_cloudwatch_log_group.apigw_access
Real output, from this project's own infrastructure. The same command runs on your laptop before you open the pull request, in a pre-commit hook while removing a secret is still an edit, and in Jenkins, GitLab or anything else that can run a shell — it exits non-zero when something reaches your blocking threshold.

What you get for nothing: 34 rules across 80 AWS and Azure resource types, PR comments with the fix inline, SARIF for GitHub Code Scanning, your own custom rules, and a baseline file so an existing repository doesn't drown you on day one. Unlimited repositories, unlimited scans, no seat count, no telemetry.

Try it

Paste your own Terraform. No account.

The same engine and the same rule pack that run in a pull request — not a mockup. Nothing is stored.

Up to 64 KB. A snippet has no previous revision, so rules that compare against one — including destroy-on-rename — only run on a real pull request.

What the scanner says

Paste something and press Scan it.

Or run it on your whole repository

Two commands, from the directory you want to look at. No account, no CI, no terraform init.

pip3 install "tf-predeploy-firewall @ git+https://github.com/foadtalsi/tf-predeploy-firewall@v1"
tf-predeploy-firewall --full-repo-scan

If pip answers error: externally-managed-environment, your Python is one of the managed ones (Debian, or a recent Homebrew). Install it with pipx install and the same quoted spec instead — brew install pipx or sudo apt install pipx first.

MIT-licensed, and it makes no network call without a key. It exits non-zero when something reaches your blocking threshold, so the same command works as a pre-commit hook or a CI step. Reading your cloud account to grade findings on what already exists is opt-in and needs the [aws] extra — the tool prints that command when you ask for it.

What came back

Four findings, on four lines, before anyone ran anything.

This is the scanner's own output for the diff above — not a mockup of one. Two of the four are about lines the diff never touched.

tf-predeploy-firewall · aws 6.59.0 · merge blocked at threshold high
SeverityLocationFinding
critical rds.tf:5 ForceNew change on stateful resource db_name changed from "appdb" to "app_production" — this attribute is ForceNew on aws_db_instance and will destroy + recreate the resource on apply.
critical rds.tf:8 Hardcoded credential password resolves to a hardcoded string literal (via var.db_password), not a secret reference — credentials must not be committed in plain text.
high rds.tf:10 Unknown / hallucinated attribute attribute "enable_deletion_protection" is not a known argument of aws_db_instance — likely hallucinated or deprecated; verify against the provider docs.
medium rds.tf:1 Missing prevent_destroy aws_db_instance is a stateful resource with no lifecycle { prevent_destroy = true } guard.
Line 10

The guard that isn't one

The argument is deletion_protection. enable_deletion_protection does not exist on aws_db_instance — the provider declares 95 arguments and that is not one of them.

It is a good hallucination: the right concept, the right value, a name that reads like a dozen other AWS arguments. Approving this PR feels like approving a safety improvement.

Line 5

The rename that drops the instance

db_name is ForceNew on aws_db_instance. Changing it does not rename anything: Terraform destroys the instance and creates a new, empty one.

Thirteen of that resource's arguments behave this way and engine_version — the one people assume — is not among them. This is knowledge about a specific provider version, which is why the scanner reads it from the provider rather than from a list someone maintained by hand.

Where this fits

The check that would catch this needs the credentials you don't want to give a generated branch.

Terraform can answer some of these questions itself. It is worth being exact about which ones, and what each answer costs.

Question terraform validate terraform plan This scanner
Is enable_deletion_protection a real argument? yes — after init yes yes
Does changing db_name destroy the instance? no yes yes
Is there a credential in terraform.tfvars? no no yes
Needs provider downloads yes yes no
Requires cloud credentials and state no yes no
Where the answer appears build log build log on the line, in the PR

Only plan knows about the destroy — and running plan means handing real cloud credentials to a branch nobody has read yet. That is the gap: the pull request most worth planning is the one least safe to plan. This reads the same provider schema statically, from a single binary, with nothing to authenticate to.

If your pipeline already runs plan somewhere safe, pass the JSON with plan-json and three more checks turn on: a confirmed replace, a sensitive attribute drifting that this PR never touched, and a plan that destroys more resources than your threshold allows.

And if you want it, one more option. force_destroy = true on a bucket this pull request invents and force_destroy = true on the bucket holding your backups are the same eleven characters — nothing in the repository tells them apart, so the rule has to score both the same. Give the scan a read-only role and it asks your account which one this is, and says so in the finding. It is the only thing it uses the access for.

What it reads

A secret moved out of main.tf has to land somewhere.

Scanning resource blocks and stopping there would miss most of a mature repository. These are read too, and findings say where a value actually lives.

module calls
A mature repo is mostly module calls, and a password passed to a module is exactly as hardcoded as one passed to a resource. Names can't be schema-checked — they're someone else's variables — but values are checked in full.
*.tfvars
terraform.tfvars, *.auto.tfvars and their JSON forms, walking nested objects and lists. Only files git actually tracks — a properly gitignored tfvars is never read, which is the correct answer, because the finding is this secret is in the repository.
locals, variable defaults
password = var.db_password whose variable declares default = "changeme" is a hardcoded credential one indirection away, and the more common of the two. Resolution is directory-scoped the way Terraform scopes it.
terragrunt.hcl
The inputs and remote_state.config maps, recursively. Values that reference a dependency output can't be evaluated statically and are skipped rather than guessed at.
module & provider sources
A module with no version, or a ?ref=main anyone can move. Generated Terraform almost never pins one, and an unpinned dependency means the plan that was reviewed isn't necessarily the plan that runs.
data sources
Same value-based checks as resources.
$ tf-predeploy-firewall --repo-dir . --base-ref origin/main
medium main.tf:4 module.vpc registry module "terraform-aws-modules/vpc/aws" declares no version — Terraform will take the newest release each time the module is re-initialised high main.tf:12 aws_security_group_rule.allow_all "cidr_blocks" includes 0.0.0.0/0 (open to the entire internet) — common tutorial copy-paste, narrow this range criticalterraform.tfvars:2 datadog_api_key "datadog_api_key" is a hardcoded credential in a .tfvars file — variable values belong outside the repository. If this file is already committed, the value is disclosed: rotate it.
Verbatim findings, reformatted for width. Credentials are matched by attribute name, by known token format (AWS keys, PEM blocks, JWTs, GitHub tokens), and by entropy — a 30-character random string is flagged as a probable machine-generated secret even when no known format matches.
Where the free line is

Everything that scans is free. What a plan adds is how much it knows.

Every rule, the HCL parsing, the PR comment, the SARIF output, your own custom rules — MIT, in the repository, no account. What those rules run against is a generated knowledge base of a provider's resource types: every valid argument at every nesting depth, which of them force a destroy, and which types are stateful enough to need a guard.

Resource typesEmbeddedWith a plan
AWS 6.59.039
Azure 4.81.041
Carrying ForceNew data75
Deliveryin the binary

The embedded packs are not a crippled demo. They cover the types most repositories use most, they detect real problems on real code, and they will never be taken away. They are also not enough for a real estate of Terraform — which is the honest reason to charge for the rest.

Why it is generated, not curated

This data used to be hand-written, and was wrong in both directions. It listed 29 arguments for aws_instance; the provider declares 71 — so launch_template, cpu_options and hibernation were all reported as hallucinated at severity high. That is a blocked pull request on valid Terraform.

It also claimed identifier was ForceNew on aws_db_instance. It isn't — the provider renames in place. A confident, wrong warning about destroying a production database.

Packs are now generated from terraform providers schema -json and, for the ForceNew flags the schema doesn't expose, from the provider's own source. Both packs come from the same generator and the same provider release, so they never disagree about a type they share — the paid one simply covers more. A type in neither is skipped rather than guessed at, so wider coverage only ever adds findings.

Resources from other providers still get every rule that reads values rather than a schema — credentials, entropy, open CIDRs, your own rules. The scan says so on stderr rather than leaving you to infer it.

Adoption

Pointed at a repo that already exists, it will find a lot. That is usually where these tools die.

Every finding may be true and collectively they're useless: the only available response is to lower the threshold until it stops complaining, which is the same as uninstalling it.

A baseline records what was already there. Run it once, commit the file, and pass it on every scan. Baselined findings still appear in the PR comment, in their own collapsed section — the debt stays visible — but they don't block a merge. Anything not in the file does.

The bleeding stops immediately and the backlog gets paid down on its own schedule, which is the only sequence that works on a codebase nobody has time to fix at once.

Matching is on category + resource + file, deliberately not on line number: a baseline that broke every time someone added a line above it would be abandoned in a week.

once, at adoption
$ tf-predeploy-firewall --repo-dir . \ --full-repo-scan \ --write-baseline .tf-firewall-baseline.json
.github/workflows/tf-firewall.yml
- uses: foadtalsi/tf-predeploy-firewall@v1 with: block-threshold: high baseline: .tf-firewall-baseline.json
GitLab CI is supported too, with a Code Quality report that renders in the MR widget.
Rules

Every check is data, not code.

All 21 built-in rules across 9 categories — what each looks for, its severity, its wording, the documentation it links to — are declarations in a YAML pack embedded in the binary. Print it, change it, run against your version. No Go toolchain, no release.

A pattern that misfires on your repository is something you can correct today rather than file an issue about.

Add extends: builtin and your pack layers over the built-ins by rule id — override one to change its severity, disabled: true to switch one off, a new id to add your own. Without it, --rules replaces the built-ins outright, and the scan says so loudly, because running on fewer rules than you think looks exactly like a clean repository.

the honest answer to "what does it look for?"
$ tf-predeploy-firewall --print-rules > my-rules.yaml $ tf-predeploy-firewall --rules my-rules.yaml rule pack my-rules.yaml extends the built-in rules: 19 inherited, 1 overridden (hardcoded_credential), 1 added (no_public_acl), 1 disabled (placeholder_name)
Findings also export as SARIF 2.1.0 for GitHub Code Scanning, each carrying its full rule explanation — so an alert opened by someone who never ran the scan still says what the rule detects and why it blocks.
Pricing

Free is the product. The plans are for when it stops being enough.

Most repositories never need a plan, and we would rather you find that out than be told it. What you pay for is volume and reach — more provider types, more repositories, more people, and the governance features a team eventually asks for. The scanner itself is never limited, never phones home unless you give it a key, and never fails your build because of us.

Free
$0/forever
Unlimited repos · no account
  • The whole scanner, MIT-licensed — GitHub Action and CLI
  • 34 rules across 80 AWS + Azure resource types
  • PR comments, inline fix suggestions, SARIF output
  • Your own custom rules, and a baseline for existing repos
  • No credentials, no telemetry, no seat count
Install it

Nothing below takes this away.

Starter
$99/mo
5 repos · 3 seats
  • All 2,840 AWS + Azure types, with ForceNew data for 2,539 of them
  • Inline PR comments and one-click fix suggestions
  • Per-finding waivers with a written justification and optional expiry
  • 30 days of scan history
  • Email support
Growth
$490/mo
50 repos · 15 seats
  • Everything in Starter
  • Centrally-managed detection policy, per repo
  • 1 year of history, CSV and PDF export
  • Weekly email digest and a findings trend chart
  • Cost-impact estimates above a threshold you set
  • Priority support
Scale
Custom
Unlimited repos & seats
  • Everything in Growth
  • Unlimited history retention
  • SSO / SAML and SCIM provisioning
  • Organisation audit log
  • SOC 2 / PCI DSS control mapping
  • Dedicated onboarding and an SLA

or foad@talsi.dev

Want to see the paid side first? 50 scans on the full 2,840-type pack, 1 seat, 7 days of history, no card. When the scans run out nothing breaks — you drop back to the free scan, no check turns red, and you pick a tier when you're ready rather than on a countdown.

FAQ

Before you ask

No, and they're complementary. Those check a resource's configuration against known misconfiguration rules — unencrypted storage, a public bucket, an open security group. Those rules are about the state a resource ends up in.

This checks the change: whether an argument exists in the provider you're actually pinned to, whether editing it destroys the resource, whether the value resolves to a literal secret, whether a guard that used to be there still is. A repository can pass every tfsec rule and still contain the diff at the top of this page. Most teams run both.

No, and it's structural rather than a promise. The paid pack is fetched once, cached on the runner for 24 hours, then revalidated with an ETag. If the control plane is slow, down or unreachable, the scan runs on the cached pack; if there's no cache yet, it falls back to the pack compiled into the binary and says so on stderr.

The same applies to usage reporting, which is what enforces your plan's limits: if it can't be reached, the scan still completes. Point TFPDF_CACHE_DIR at a directory your CI already restores and the steady state is zero network calls.

Without a licence key, nothing at all — no account, no network call outside GitHub, no telemetry.

With one: the repository name, how many findings there were and whether the scan blocked. Not your Terraform, not the finding contents, not your diff. The dashboard shows counts and categories because that is all it is given.

Then most of this still applies. A ForceNew attribute destroys your database whoever typed it, a credential in terraform.tfvars is committed either way, and an unpinned module drifts on its own schedule.

What changed with generated code is volume and confidence — more diffs, arriving faster, written in a register that reads reviewed. The failure modes were always there; there are just more of them now, and they look better.

On a mature estate, loud on the first run — which is why the baseline exists. Commit one and only new findings block; the pre-existing ones stay visible in a collapsed section instead of being deleted or ignored.

Beyond that: values that can't be resolved statically are skipped rather than guessed at, resource types with no pack are skipped rather than assumed, and every rule's severity and wording is a YAML declaration you can override without waiting for a release.

The provider knowledge base the rules run against, regenerated on each provider release: 1,699 AWS and 1,141 Azure resource types with their full argument surface and ForceNew flags, against the 39 and 41 compiled into the free binary.

The free packs are not a crippled demo — they cover the types most repositories use most and detect real problems on real code. They are also not enough for a large estate of Terraform, which is the honest reason to charge for the rest. Plus the operational half: the dashboard, waivers, centrally-managed policy, history, SSO and SCIM.

Which means the answer for most people is nothing, for a long time. A plan is what you buy when the scan starts skipping types you actually use, or when enough people need to see the results in one place that a pull-request comment stops being where the conversation happens. Neither is a day-one problem, and pretending otherwise is how a free tier turns into a trap.

No. It never runs terraform, never reads state, and authenticates to nothing — a static binary reading the files in your repository against a schema it already has. That is the default and it is the whole product.

There are two things you can hand it if you want more, and both are off until you switch them on.

A plan. If you already run terraform plan somewhere safe, pass the JSON for three extra checks. You run the plan; this never does.

A read-only role. One question the repository cannot answer is whether the bucket a finding is about already exists and already holds something. force_destroy = true is written identically on a bucket this PR invents and on the one holding your backups, so without that answer the rule has to score both the same — too loud for one, far too quiet for the other. Given a read-only role it asks, and writes what it saw into the finding.

What "read-only" means here is narrow and checkable, not a promise: the scan may call sts:GetCallerIdentity and s3:ListObjectsV2, and a guard in the code refuses every other operation before the request is built. The policy we ask for does not include s3:GetObject — it can learn that a bucket is not empty and cannot read a single object in it. Credentials stay in your CI and never reach us. Refuse the permission, let the role expire, take it away later: severities go back to what they were and nothing fails.

AWS and Azure have packs. That limit is deliberate: a pack is only worth shipping once its two hand-curated halves — which resource types lose data when destroyed, and what they cost — have been thought through, and a half-considered pack produces confident wrong answers.

Resources from any other provider are still checked by every rule that reads values rather than a schema: credentials, entropy, open CIDRs, placeholder names, your own rules. What they don't get is the schema half, and the scan tells you which providers those were instead of letting you assume they passed.

Run it on one repository and read what comes back.

Sixteen lines of YAML, no account, no card, nothing to cancel. Decide about a plan the day the free scan stops being enough — not before.

Curious about the paid side instead? , no card.