tfsec
tfsec is a static analyzer built for exactly one job: reading Terraform source and flagging misconfigurations before anything is ever applied. It doesn't run terraform plan, doesn't need a provider's API credentials, and doesn't touch state — it parses the HCL you wrote, walks the resource tree it finds, and checks each block against a library of provider-specific rules for the shapes that tend to end up in an incident report: a security group open to the whole internet, a storage bucket without encryption, an IAM policy with a wildcard action on a wildcard resource. By the end of this page you should know how tfsec's parser and rule engine actually work, how to read and suppress a finding correctly, how to write a custom check in both of the formats tfsec supports, and — because this is the one fact about tfsec that changes what you should recommend to a team — exactly what Aqua Security did to tfsec's engine and why that means checking its current maintenance status before treating it as the default choice.
Imagine you're building something from an instruction booklet, and before you glue anything together, a very careful friend reads the whole booklet page by page and circles any step that looks dangerous — "step 14 says leave the door unlocked," "step 22 says don't use the safety catch." Your friend never actually builds the thing and never watches you build it; they just read the plan and flag what looks wrong in the plan itself. That's tfsec: it reads your Terraform instructions, not the building it eventually becomes.
What tfsec is, and the problem it solves
☺ Like you're 10: It's a spell-checker for Terraform specifically — narrow on purpose, so it can be fast and need nothing but the files sitting in front of it.
tfsec was created by Liam Grace (GitHub handle liamg) as an open-source, Terraform-specific static analysis tool, written in Go, and it became an Aqua Security project as Aqua took over its stewardship and continued its development under the Apache-2.0 license — the same arrangement that later brought Trivy under Aqua's umbrella. Where a tool like Checkov deliberately covers many infrastructure-as-code formats at once — Terraform, CloudFormation, Kubernetes manifests, Dockerfiles, Helm, Serverless Framework, ARM templates — tfsec stayed narrow by design: Terraform HCL, and nothing else. That narrowness bought two things a broader tool trades away. First, speed: a single static Go binary with no interpreter to start and no plugin ecosystem to load scans a typical module in well under a second. Second, an unusually light footprint for what it checks — no cloud credentials, no Terraform state file, no network call to a provider API, and critically, no requirement to actually run terraform plan first. tfsec reads your .tf files the way a linter reads source code: directly, offline, and before anything downstream of the source even exists.
The problem that solves is a sequencing one. A terraform plan requires provider credentials and, for anything beyond a trivial module, real network access to the cloud account being planned against — which means running it in a pull-request check means handing a CI runner credentials scoped at least to read the target account, for every PR, including ones that will never be merged. tfsec sidesteps that entirely: it can run in a pre-commit hook on a laptop with zero cloud access, in a PR check with zero cloud credentials configured, and it will catch the same open security group or unencrypted bucket a plan-aware scanner would catch downstream, just earlier and cheaper. See infrastructure as code hardening for where this fits among the other gates a Terraform change passes through, and IaC security & policy as code for the wider category tfsec is one implementation of.
tfsec's whole value proposition is a trade: it can only see what's written in the source, so it's fast, free of credentials, and safe to run anywhere — but it's blind to anything that only becomes known once Terraform actually talks to a provider. A count or for_each driven by a remote data source, a value that depends on a resource created earlier in the same apply, an attribute a provider computes and fills in after the fact — none of that is visible to a tool that never leaves the source tree. Keep that trade in mind through the rest of this page; it explains most of tfsec's gotchas.
Architecture: parsing HCL directly, no plan required
☺ Like you're 10: tfsec reads your Terraform files the way you'd read a recipe — straight off the page — instead of waiting to see the finished dish.
tfsec's pipeline runs entirely on the machine invoking it. Pointed at a directory, it walks every .tf and .tf.json file — including modules it can resolve locally or that were already fetched into .terraform/modules — and parses them with an HCL syntax parser into an abstract representation of the resource tree: providers, resources, data sources, variables, and the expressions connecting them. Where an expression can be resolved from information already in the source — a hardcoded CIDR block, a literal boolean, a variable with a default — tfsec evaluates it. Where a value genuinely depends on something outside the source (a remote data source, a value only a provider computes), tfsec treats it as unknown rather than guessing.
That parsed tree is then checked against tfsec's rule set — a combination of built-in rules compiled into the binary and, optionally, custom checks loaded from a directory you point it at. Every match becomes a finding: a rule ID, a severity, the exact file and line range, and a short remediation note. Nothing in that pipeline talks to a cloud provider, reads Terraform state, or requires terraform plan to have run first — the entire evaluation happens against source text.
The built-in rule set: providers, IDs, and severities
☺ Like you're 10: Every rule lives in a folder named after the cloud it checks, has an ID you can look up, and gets a severity label so you know how loudly to worry.
tfsec's built-in rules are organized into provider packages — aws, azure, google, kubernetes, digitalocean, github, openstack, oracle, and a cross-cutting general package for checks like hardcoded secrets in a variable default. Each rule carries a legacy tfsec ID in the pattern <provider>-<service>-<check> — for example aws-vpc-no-public-ingress-sgr for a security group rule with an unrestricted source CIDR — plus, since Aqua unified its scanners' IDs, an AVD (Aqua Vulnerability Database) alias like AVD-AWS-0107 for the same check. Findings are ranked CRITICAL, HIGH, MEDIUM, LOW, or INFO. Treat exact legacy IDs as worth confirming against the ruleset actually installed before hardcoding them into a suppression file or a CI allowlist — IDs have shifted before during Aqua's ongoing consolidation of its scanners, a point this page returns to below.
# infra/security_group.tf — the shape a misconfig rule exists to catch
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # aws-vpc-no-public-ingress-sgr flags exactly this shape
}
}$ tfsec infra/
Result #1 CRITICAL Security group rule allows ingress from the internet.
────────────────────────────────────────────────────────────────────────
infra/security_group.tf:2-9
────────────────────────────────────────────────────────────────────────
via aws_security_group.web (web-sg)
via ingress[0]
────────────────────────────────────────────────────────────────────────
ID aws-vpc-no-public-ingress-sgr (AVD-AWS-0107)
Impact An open security group is one of the most direct paths to compromise
Resolution Set a more restrictive CIDR range for the ingress rule
────────────────────────────────────────────────────────────────────────
1 potential problems detected.A finding you've reviewed and accepted gets suppressed with an inline comment directly above the offending attribute or block — not disabled globally — so the exception is visible in the diff that introduced it and reviewable by whoever approves the pull request:
resource "aws_security_group" "bastion" {
name = "bastion-sg"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
#tfsec:ignore:aws-vpc-no-public-ingress-sgr:exp:2025-12-31 -- bastion; narrowed further by the NACL in network.tf
cidr_blocks = ["0.0.0.0/0"]
}
}The optional :exp:YYYY-MM-DD suffix gives the ignore a shelf life — after that date tfsec reports the finding again rather than suppressing it silently forever, which is what keeps an "accepted risk, revisit later" comment from quietly becoming permanent. Broader policy lives in a config file rather than scattered comments — .tfsec/config.yml, or any path passed to --config-file:
# .tfsec/config.yml minimum_severity: MEDIUM exclude_downloaded_modules: true # don't scan modules you don't own the source of exclude: - aws-iam-no-policy-wildcards # org-wide accepted risk, tracked in the security backlog severity_overrides: aws-s3-encryption-customer-key: HIGH # this org treats it as HIGH, not tfsec's built-in default
Writing custom checks: MatchSpec YAML and Rego
☺ Like you're 10: The built-in rules cover the industry's common mistakes; a custom check is you teaching tfsec your own team's specific rule.
The built-in rule set encodes broadly agreed-on bad practice — the CIS-benchmark-shaped stuff. A platform team almost always also has its own rules that no public ruleset would know about: "every S3 bucket name must start with acme- so the backup job can find it," "no resource in the shared VPC module may set force_destroy = true." tfsec supports authoring exactly that as a custom check, loaded at scan time with --custom-check-dir, and it has supported two different formats across its life — worth knowing both, since which one a given team's checks were written in depends on when they were written.
The original format is a declarative MatchSpec, written in YAML: you name the resource type and attribute to inspect, and an action (isPresent, isEmpty, equals, startsWith, regex, and similar) to test it against. No code, just structured matching:
# .tfsec/custom-checks/naming.yml
---
checks:
- code: CUS001
description: S3 bucket names must start with the acme- prefix
impact: Buckets that don't follow the naming convention break the backup automation
resolution: Rename the bucket to start with acme-
requiredTypes:
- resource
requiredLabels:
- aws_s3_bucket
severity: LOW
matchSpec:
name: bucket
action: startsWith
value: "acme-"The second format arrived once tfsec's engine was merged into Aqua's shared policy library alongside Trivy's — Rego custom checks, the same policy language OPA & Conftest covers generally, evaluated against tfsec's own parsed resource tree instead of a raw Terraform plan JSON document:
# .tfsec/custom-checks/force-destroy.rego
package user.custom002
__rego_metadata__ := {
"id": "CUS002",
"avd_id": "AVD-USR-0002",
"title": "S3 buckets may not set force_destroy to true",
"severity": "MEDIUM",
"type": "Custom Check",
}
__rego_input__ := {
"selector": [{"type": "cloud", "subtypes": [{"service": "s3", "provider": "aws"}]}],
}
deny[res] {
bucket := input.aws.s3.buckets[_]
bucket.forceDestroy.value == true
res := bucket.forceDestroy
}Rego custom checks buy real expressiveness over MatchSpec's flat attribute matching — cross-resource logic, aggregation across a whole module, anything a single matchSpec block can't express — at the cost of writing actual policy code instead of filling in a YAML template. The __rego_metadata__/__rego_input__ block shape shown above reflects the API as tfsec's engine looked heading into its merger with Trivy's; treat the exact field names as worth checking against whatever's installed rather than copying verbatim, since this is precisely the surface that was in flux during the consolidation covered next.
Day-to-day commands
☺ Like you're 10: One command to scan, a few flags to decide what counts as a failure, and a couple more for machine-readable output.
# the basic scan — current directory, all severities, human-readable table $ tfsec . # gate on severity — CI usually only wants to fail on real risk $ tfsec . --minimum-severity HIGH # suppress specific rule IDs org-wide instead of one inline comment per hit $ tfsec . --exclude aws-iam-no-policy-wildcards,aws-s3-encryption-customer-key # load your team's own checks alongside the built-ins $ tfsec . --custom-check-dir ./tfsec-custom-checks # point at a config file explicitly rather than relying on .tfsec/config.yml discovery $ tfsec . --config-file .tfsec/config.yml # machine-readable output for a dashboard or a code-scanning integration $ tfsec . --format json --out results.json $ tfsec . --format sarif --out results.sarif # report everything but never fail the build itself — for a rollout period, not steady state $ tfsec . --soft-fail # skip modules pulled from a registry or git source — you don't own that source $ tfsec . --exclude-downloaded-modules # resolve variables from a real tfvars file so conditional logic evaluates correctly $ tfsec . --tfvars-file environments/prod.tfvars
Flag names above reflect tfsec as it existed through its most actively developed years; run tfsec --help against whatever's actually installed before relying on any of them verbatim; a pinned older binary and today's documentation can disagree.
Gotchas and failure modes
☺ Like you're 10: Most surprises come back to the same fact — tfsec only ever sees the words in your files, never the thing those words eventually build.
- Source-only evaluation misses provider-computed values. A value that depends on a data source's live lookup, a resource created earlier in the same apply, or anything a provider only fills in after the fact is invisible to tfsec by design — it evaluates what the source can prove, not what the deployed resource will actually end up holding. A clean tfsec scan is not proof the deployed resource is configured safely; it's proof the parts visible in source are.
- An inline ignore on a
for_eachorcountblock covers every instance, not one. There's no way to suppress a finding for a single expanded copy of a resource while leaving the rest gated — the ignore comment applies to the block as written, before expansion. - Downloaded modules get scanned by default. Point tfsec at a root module that pulls a third-party module from a registry, and by default that vendored code gets scanned too — which can flood a report with findings in code your team doesn't own and can't fix directly.
--exclude-downloaded-modulesor the equivalent config-file key scopes the scan to code you actually maintain. - No credentials means no live-state verification, ever. tfsec cannot tell you whether the deployed resource still matches the source that supposedly created it — that's a live-account job for a cloud security posture tool, not a source-scanning one. See cloud security posture for the check that actually looks at what's running, not just what was written.
- Rule IDs have shifted during Aqua's engine consolidation. A suppression list built against an older tfsec release can reference an ID that's since been renamed or given an AVD alias as the primary form. Re-validate a
.tfsec/config.ymlexclude list against a fresh scan's actual output periodically rather than assuming it still matches everything it once did.
Where tfsec stands today — verify before defaulting to it
☺ Like you're 10: The team that built tfsec now spends most of its energy on a different tool that does the same job plus three others — so ask how healthy the original tool still is before betting a new pipeline on it.
tfsec's rule engine didn't stay a standalone project. Aqua Security folded tfsec's Terraform-checking logic into the same shared policy library it uses across its scanner portfolio, and that engine now lives inside Trivy's trivy config misconfiguration scanner — the two tools check Terraform against substantially the same rules today, because underneath they're running much of the same code rather than two independently maintained implementations. Aqua has publicly signaled tfsec as a project moving toward being superseded by Trivy as the actively developed home for that logic, with the standalone binary continuing to exist but receiving less attention as new development focuses on the unified tool. Most legacy tfsec rule IDs and the #tfsec:ignore:<id> suppression syntax carry over cleanly if a team migrates to trivy config, which is a large part of why that migration is a realistic option rather than a rewrite.
This page describes tfsec's design and rule engine accurately as a piece of technology — but whether the standalone tfsec binary is still receiving active feature development, security patches, or new rules for newer provider resource types is a fact that changes over time and that you should verify directly against the tfsec GitHub repository's own README and release history before presenting it as the default choice for a new pipeline. If standalone development has slowed further or stopped since this page was written, the practical recommendation is usually to point new work at trivy config instead, which runs the same underlying engine under active maintenance — see the Trivy tool page for that scanner in full. Don't take "the concepts on this page are accurate" to mean "the binary is necessarily the right pick today" — those are two separate questions.
tfsec vs. Checkov vs. Trivy config vs. Terrascan
☺ Like you're 10: They all read infrastructure code looking for mistakes — they just disagree on how many kinds of code to read and which language to write new rules in.
The real choice is rarely "which one catches more bugs" in the abstract — the four tools below check overlapping sets of the same well-known bad patterns. It's which one matches how narrow or broad a pipeline's infrastructure-as-code surface actually is, and which policy-authoring language a team wants to standardize on.
| Tool | Scope | Strength | Trade-off |
|---|---|---|---|
| tfsec | Terraform HCL only | Narrow, fast, zero credentials, zero setup beyond the binary | Standalone development has slowed since the Trivy merge — verify current maintenance before defaulting to it |
| Checkov | Terraform (source and plan JSON), CloudFormation, Kubernetes, Dockerfile, Helm, Serverless, ARM, plus secrets | Broadest single-tool framework coverage and by far the largest built-in policy count | Heavier Python runtime; slower on large repos than a static Go binary |
| Trivy config | Terraform (source and plan), CloudFormation, Kubernetes, Dockerfile, Helm, ARM/GDM | Runs tfsec's own absorbed engine, actively developed, and shares one binary with Trivy's vulnerability, secret, and license scanners | One scanner inside a broader tool rather than a tool built around this one job — no tfsec-specific CLI ergonomics |
| Terrascan | Terraform, Kubernetes, Helm, Kustomize, CloudFormation, ARM | Rego-native from the start, useful when a team has already standardized every policy — Kubernetes admission, CI gates, infrastructure — on Rego | Smaller community and rule library than Checkov |
A reasonable default in 2026: if a pipeline is Terraform-only and wants the fastest possible pre-commit check with nothing else to install, trivy config scoped to just the tf source scanner gets tfsec's exact rule engine under active maintenance without betting on the standalone project's future. If the org already writes multiple infrastructure-as-code formats, Checkov's breadth covers more ground from one tool. If policy authoring is already standardized on Rego for Kubernetes admission control through OPA & Conftest or Kyverno, Terrascan or Trivy's Rego custom checks let infrastructure policy follow the same convention rather than introducing a second policy language. Where any of these findings end up once a pipeline runs more than one of them is covered in vulnerability management & triage.
Recon the Robot: tfsec just failed the pipeline before I even had a plan to reconcile against. A security group with 0.0.0.0/0 on port 22, caught reading the source, never even reached my loop.
Foxy: Hold on — isn't that the same check that's supposedly living inside Trivy now? Why are we still pointing at the standalone binary at all?
Recon the Robot: Because it's what this repo was already wired to. But you're asking the right question — I checked the tfsec repo's own README before I'd tell anyone to keep defaulting to it here. That's not a one-time check; it's worth another look before the next pipeline gets built on it.
Timmy the Turtle: And the ignore comment on the bastion host's rule — it's got an expiry on it. December 31st. I'm not letting "accepted risk" quietly become "forgotten risk."
Rocky the Raccoon: Question for the room — this thing never touches state or a live account, right? So if I drift a security group open by hand after the apply, tfsec never sees it again.
Recon the Robot: Correct, and that's exactly the boundary. Source-only stops on the source. Drift after apply is my job, and Sol still walks the live posture separately. Three different tools, three different moments — none of them covers all three.
1. What does tfsec actually parse to find issues, and name two things it deliberately never needs to run — what does that trade buy, and what does it cost? 2. Give the two formats tfsec supports for writing a custom check, and the key difference in what each can express. 3. What happened to tfsec's rule engine, and what should you verify before recommending the standalone tfsec binary as the default choice for a new pipeline? 4. You suppress a finding with #tfsec:ignore:aws-vpc-no-public-ingress-sgr:exp:2025-12-31. What happens on and after that date? 5. Give one concrete example of a security issue tfsec's source-only scanning cannot catch, and name the kind of tool that can.
Check your answers
- tfsec parses Terraform HCL source directly into a resource tree — it never runs
terraform planand never needs cloud provider credentials. That buys speed and safety to run anywhere, including a PR check with zero cloud access configured; it costs visibility into anything only knowable once Terraform actually talks to a provider, such as a value a data source or another resource computes at apply time. - A declarative YAML MatchSpec (resource type, attribute, and a matching action like
startsWithorequals) for straightforward attribute-level rules, and a Rego policy for anything needing real expressiveness — cross-resource logic or aggregation a flat MatchSpec can't represent. - Aqua Security folded tfsec's rule engine into its shared policy library, and that same engine now runs inside Trivy's
trivy configscanner, with tfsec signaled as moving toward being superseded by Trivy as the actively developed home for that logic. Before defaulting to standalone tfsec, check the tfsec GitHub repository's own README and release history directly, since maintenance status changes over time and isn't something to assume from a description of how the tool works. - Before the expiry date, that specific finding stays suppressed on that resource. On or after December 31, 2025, tfsec reports the finding again as if the ignore comment weren't there — the expiry keeps an "accepted for now" exception from silently becoming permanent.
- Any answer describing something only knowable at or after apply time: for example, a security group that was deployed correctly but later hand-edited to open a port (tfsec never re-checks live state — a reconciliation loop or a cloud security posture management tool catches that), or a value only a provider computes after the fact. The right tool for that gap is a live-state or posture tool, not a source-only static analyzer.