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.
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.
with: block —
the defaults are the free scan. fetch-depth: 0 matters: without
it the runner has no base branch to diff against.
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.
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.
Paste something and press Scan it.
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.
pip install "tf-predeploy-firewall @ git+https://github.com/foadtalsi/tf-predeploy-firewall@v1"
tf-predeploy-firewall --full-repo-scan
If pip is not on your PATH, use
py -m pip install instead.
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.
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.
| Severity | Location | Finding |
|---|---|---|
| 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.
|
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.
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.
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.
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.
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.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.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.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.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 types | Embedded | With a plan |
|---|---|---|
| AWS 6.59.0 | 39 | 1,699 |
| Azure 4.81.0 | 41 | 1,141 |
| Carrying ForceNew data | 75 | 2,539 |
| Delivery | in the binary | cached 24h on the runner |
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.
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.
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.
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.
- 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
Nothing below takes this away.
- 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
- 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
- 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
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.
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.