Skip to content

Lint Rules

The Terraform lexicon ships one source-level lint rule and eight post-synth checks. See Lint Rules for the generated, always-current table; this page is the reasoning, the fix and the credit behind each entry. The Terraform lexicon ships one source-level lint rule and fourteen post-synth checks, plus TF023, which runs only in chant audit and is documented here beside the rest of the family. See Lint Rules for the generated, always-current table of the checks that run in a build; this page is the reasoning, the fix and the credit behind each entry. The Terraform lexicon ships one source-level lint rule and twenty-four post-synth checks, plus TF023, which runs only in chant audit and is documented here beside the rest of the family. See Lint Rules for the generated, always-current table of the checks that run in a build; this page is the reasoning, the fix and the credit behind each entry.

TF001: root module declares no remote backend

Section titled “TF001: root module declares no remote backend”

A terraform block with no backend "<type>" and no cloud {}.

Why it matters. No backend falls back to the local one, which puts terraform.tfstate in the working directory. That file holds every attribute of every managed resource, secrets included, and it is unshared, unlocked and unversioned. The first apply from a second machine or a CI runner starts from an empty state and proposes to create the whole estate again.

The fix. Add a backend "<type>" block (s3, gcs, azurerm, http) or a cloud {} block to the root module’s terraform block, then run terraform init -migrate-state.

Prior art. No tool in the survey covers this; see audit-lineage.ts’s header comment for where it looked. Checkov’s CKV_TF_* family has no rule for a missing backend block, and tflint-ruleset-terraform’s own index has none either. Its closest neighbors check something else entirely (unconstrained provider versions, a terraform.workspace compatibility concern), not the absence of a backend.

A root with no terraform block at all is not flagged: it declares no version constraints either, and that is a different finding.

terraform {
required_version = ">= 1.5.0"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
provider "null" {}
resource "null_resource" "first" {
triggers = {
name = "first"
}
}
resource "null_resource" "second" {
triggers = {
name = "second"
}
}

TF002: provider implied by the root has no required_providers entry

Section titled “TF002: provider implied by the root has no required_providers entry”

A provider "<name>" {} block, or a resource/data type prefix (aws_instance implies aws), with no matching entry in the terraform block’s required_providers, or an entry that is missing source or version.

Why it matters. With no version constraint, terraform init installs whatever release of the provider is current the day someone first runs it, and every teammate and CI runner after that installs whatever is current the day they first run it. A provider release that changes a resource’s defaults or removes an attribute becomes a plan diff nobody asked for, instead of a version-check failure caught before it can run.

The fix. Add the provider to required_providers, with both source ("hashicorp/aws") and version ("~> 5.0") set.

Prior art. tflint’s terraform_required_providers checks the identical condition (equivalent): every implied provider needs a required_providers entry with source and version. HashiCorp’s own reference Sentinel policy require-all-providers-have-version-constraint ships the same check (equivalent). AWS’s prescriptive guidance for Terraform names it directly: “CI must fail builds when a provider version constraint is undefined” (equivalent).

terraform {
required_version = ">= 1.5.0"
}
resource "google_compute_instance" "vm" {
name = "vm"
}

TF003: root module’s terraform block has no required_version

Section titled “TF003: root module’s terraform block has no required_version”

A terraform block with no required_version attribute.

Why it matters. Sits beside TF002 on the same block: without required_version, a root can be applied by whatever Terraform binary happens to be on the runner’s PATH. A binary upgrade that changes behavior surfaces as a surprise plan diff instead of a version-check failure before anything runs.

The fix. Add required_version = ">= <lowest supported version>" to the terraform block.

Prior art. tflint’s terraform_required_version checks the identical condition (equivalent). HashiCorp’s style guide names version pinning directly, including required_version for the binary (overlaps: the guide’s advice covers provider and module pinning in the same breath, not just this one attribute). HashiCorp’s reference Sentinel policy restrict-terraform-versions checks an allowed range once required_version is set, a narrower question than presence (overlaps).

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
## TF004: registry-sourced module block has no version
A `module` block whose `source` parses as a Terraform registry address
(`namespace/name/target-system`, optionally hostname-prefixed) with no
`version`.
**Why it matters.** With no `version` constraint, `terraform init` resolves
to the newest release of the module every time it runs. A registry module
that changes what it provisions between releases becomes an unreviewed
change the next time anyone runs `init`, not a diff caught by a pinned
version bump.
**The fix.** Add a `version` constraint to the module block (e.g. `~> 5.0`).
**Prior art.** tflint's `terraform_module_version` checks the identical
condition (equivalent). checkov's `CKV_TF_2` checks the same thing for a
registry source (equivalent).
```hcl
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
}

TF005: git/hg module source is unpinned, or pinned to a mutable ref

Section titled “TF005: git/hg module source is unpinned, or pinned to a mutable ref”

A module block whose source is a git or hg source (an explicit git::/hg:: prefix, the github.com/bitbucket.org shorthands, an scp-style address, or anything ending in .git) with no ?ref=, or a ?ref= that is neither a semver-shaped tag nor a full 40-hex commit SHA.

Why it matters. An unpinned git source resolves to whatever the branch’s tip happens to be the moment terraform init runs, which can differ between two runs an hour apart. A ?ref= pinned to a branch name (main, master, develop, trunk, or any other moving target) has exactly the same problem, just with an extra step: it looks pinned until the branch moves again.

The fix. Pin ?ref= to a tag (v1.2.0) or a full 40-character commit SHA, never a branch name.

Prior art. tflint’s terraform_module_pinned_source checks the identical condition in its default flexible style, rejecting the same four branch names (equivalent); its semver style additionally requires the ref to parse as a semantic version, which this rule’s own default already does unconditionally, since chant has no per-project rule configuration yet. checkov’s CKV_TF_1 also requires a git module source to carry a ?ref=, but its lenient fallback (CKV_TF_2) accepts any ref matching \d\.\d, so v1.2-dev passes checkov and fails here (extends: a strict superset of checkov’s check). KICS’s 3a81fc06-566f-492a-91dd-7448e409e2cd checks only the git:: prefix, missing the github.com/bitbucket.org shorthands and scp-style sources this rule also covers (overlaps).

A local (./, ../) source is never flagged: it can’t be pinned to a ref at all, the same reasoning checkov’s own module-pinning checks use when they report UNKNOWN rather than a failure for one.

module "vpc" {
source = "git::https://example.com/vpc.git"
}

TF006: a sensitive variable declares a default

Section titled “TF006: a sensitive variable declares a default”

A variable marked sensitive = true that also has a default, whatever the default’s value is.

Why it matters. sensitive = true says the value must not be seen, and a default is a value committed to the repository, so the two together contradict each other: the secret is already in git, and the flag only hides it from plan output, which was never where the exposure was. An empty or null default is the same finding for a second reason, because it turns a required secret into an optional one and every environment that forgets to pass the variable silently gets the committed value.

The fix. Delete the default. Pass the value in at apply time, from a tfvars file kept out of git, a TF_VAR_ environment variable, or a workspace variable.

Prior art. Azure’s AVM ruleset ships avm_terraform_sensitive_variable_default_disallowed (equivalent). The official tflint ruleset has no equivalent, which is part of why it is worth shipping.

variable "deploy_token" {
type = string
description = "Token the deploy job authenticates with"
sensitive = true
default = "carried-over-from-the-old-staging-account"
}

TF007: a secret-shaped literal in a variable default or a locals value

Section titled “TF007: a secret-shaped literal in a variable default or a locals value”

A variable default or a locals value that is a committed literal and looks like a credential, by either heuristic in src/lint/secret-shape.ts: the name reads like a credential (password, passwd, secret, token, api_key, private_key, access_key, case-insensitive and word-bounded, with the _file, _path, _arn, _id and _name locator suffixes excluded), or the value matches a credential shape (an AWS key id, a JWT, a PEM header, a high-entropy token). Placeholders such as changeme and CHANGE_ME are excluded, and so is anything that is a reference rather than a constant.

Why it matters. A committed credential is in git history, which no later edit removes, and Terraform copies it into state and into plan output on every run. This is the check with the largest gap behind it in the survey: tfsec shipped it, trivy dropped it when it absorbed tfsec, and no maintained tool covers it today.

The fix. Remove the literal, mark the variable sensitive = true, and read the value from a secret manager data source or an input at apply time. Then rotate the credential, because it is already public to everyone who can clone the repository.

Prior art. tfsec’s general-secrets-sensitive-in-variable and general-secrets-sensitive-in-local, both equivalent. Both are cited at the v0.61.3 tag: tfsec’s docs site 404s above that version, and the checks did not survive the move into trivy, so the pinned repository path is the only stable citation. Neither trivy-checks nor KICS, checkov, tflint or any of its rulesets has a replacement.

variable "db_password" {
type = string
description = "Password for the application database user"
default = "hunter2-prod-db"
}
locals {
ci_token = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
}

TF008: a provider block configures a credential inline

Section titled “TF008: a provider block configures a credential inline”

A provider block whose body sets a credential attribute to a literal: credentials, client_certificate, client_key, shared_credentials_file, or any attribute whose name matches the credential-name heuristic (access_key, secret_key, token, password, client_secret, and the rest). A value that is a reference, a placeholder, or a path to a key file is not a finding.

Why it matters. A provider’s credential is used by every resource that provider manages, so it is the highest-value secret in the root, and it is readable by everyone who can clone the repository. Every provider already accepts the same credential from the environment, a shared config file, or a role assumption.

The fix. Delete the credential from the provider block and let the provider find it the way it is designed to. Then rotate it.

Prior art. The most corroborated rule in the survey, and the only Terraform-language-shaped check some of these tools ship at all. Checkov’s CKV_AWS_41 and its six credentials.py siblings, KICS’s d7b9d850-3e06-4a75-852f-c46c2e92240b, and semgrep’s aws-provider-static-credentials are all credited as overlaps: each is written per provider, while this rule is written against attribute names and so covers every provider. Snyk ships the same idea as SNYK-CC-TF-74, cited here by name only: Snyk IaC is a product rather than an open rule source, so it is not in chant’s prior-art registry. Semgrep is registered without an SPDX id, because its rules ship under the proprietary Semgrep Rules License v1.0 even though its engine is LGPL-2.1.

provider "aws" {
region = "eu-west-1"
access_key = "AKIA2E0XYZ4PQRSTUVWX"
secret_key = "8Jd0hVn2XqB7sLtR4mYcE1zPgWfKa5UiQ3oNrTbA"
}

TF009: a credential-named variable is not marked sensitive

Section titled “TF009: a credential-named variable is not marked sensitive”

A variable whose name matches the credential-name heuristic and that does not set sensitive = true. Only the name is read: a variable with no default is still reported, because the finding is about where the value goes at run time.

Why it matters. Without the flag Terraform echoes the value in plan and apply output, which puts it in CI logs, in the comment a plan bot posts on a pull request, and in every terminal scrollback. The flag does not keep the value out of state, where it is stored in plaintext regardless, which is why the remediation says both things.

The fix. Add sensitive = true, and keep the state remote and encrypted.

Prior art. No tool in the survey checks this. The authority is HashiCorp’s own style guide, whose variables section says: “For sensitive variables, such as passwords and private keys, set the sensitive parameter to true.” That section is credited as lineage as well as authority, the way the other specification entries in the registry are: it is where the rule was written down first.

variable "db_password" {
type = string
description = "Password for the application database user"
}

A variable block with no type, or with a type that trims to empty.

Why it matters. Without a type constraint Terraform accepts whatever it is given and infers the type from the value, so a variable meant to hold a list arrives as a string from a TF_VAR_ environment variable, or a number arrives as "3" from a tfvars file. The failure then surfaces deep inside a resource argument rather than at the module boundary where the wrong value came in.

The fix. Declare the constraint: string, number, bool, list(string), an object({...}).

Prior art. tflint’s terraform_typed_variables, a member of its recommended preset, and KICS’s fc5109bf-01fd-49fb-8bde-4492b543c34a, both equivalent. KICS also fails a type that trims to empty, which is why this rule treats a blank one as absent.

variable "instance_count" {
description = "How many web instances to run"
default = 3
}

A variable block with no description, or a blank one. Report-only.

Why it matters. A module’s variables are its API. The description is what terraform-docs renders into the README, what the HCP Terraform variable UI shows next to the input box, and what a caller reads before deciding what to pass. Without it the only documentation is the variable’s name.

The fix. Add a description saying what the value is for and what a valid one looks like.

Prior art. Three, all equivalent: tflint’s terraform_documented_variables, KICS’s 2a153952-2544-4687-bcc9-cc8fea814a9b, and HashiCorp’s reference Sentinel policy validate-variables-have-descriptions. KICS files it as INFO and tflint keeps it out of recommended, so chant tiers it report-only for the same reason.

variable "region" {
type = string
default = "eu-west-1"
}

An output block with no description, or a blank one. Report-only.

Why it matters. The other half of a module’s API. An output is what a caller wires into the next module, and the description is the only place to say what the value actually is, an id or an ARN, a full URL or a bare hostname, and whether it is stable enough to depend on.

The fix. Add a description saying what the value is and what a caller can rely on it for.

Prior art. tflint’s terraform_documented_outputs and KICS’s 59312e8a-a64e-41e7-a252-618533dd1ea8, both equivalent.

output "bucket_name" {
value = aws_s3_bucket.assets.bucket
}

TF013: lifecycle ignore_changes is set to all

Section titled “TF013: lifecycle ignore_changes is set to all”

A resource or data block whose lifecycle block sets ignore_changes to the bare keyword all. A list of named attributes is not reported.

Why it matters. all tells Terraform to stop reconciling every attribute of the resource after it is created. A manual console edit, a deleted security group rule, a downgraded instance class: none of it appears in a plan again. The masking is silent, because a plan that finds nothing looks exactly like a plan that was told to look at nothing, and it switches off the observe half of the lifecycle chant exists to run.

The fix. Replace all with the specific attributes that genuinely change outside Terraform, so everything else is still reconciled. Naming them is what makes the intent auditable later.

Prior art. The community Redeploy ruleset’s terraform_ignore_changes_all (equivalent). Nothing in the official tflint ruleset, KICS, checkov or trivy covers it.

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
lifecycle {
ignore_changes = all
}
}

TF014: a child module configures a provider block

Section titled “TF014: a child module configures a provider block”

A provider block inside a module the root calls, setting anything other than alias. A provider block in the root module is where providers belong, and its contents are TF008’s business, not this rule’s.

Why it matters. A module that configures its own provider takes the choice away from whoever calls it. The caller cannot point it at another region or account, and two calls of the same module in one root cannot differ, which is the whole reason to have a module. The worse half is removal: Terraform needs a provider configuration to destroy the resources a module created, and deleting the module call deletes the configuration the destroy needs, so the root is left unable to plan until the module is temporarily put back.

The fix. Configure the provider in the root module and pass it in:

module "cdn" {
source = "./modules/cdn"
providers = {
aws = aws
aws.replica = aws.replica
}
}

A block that declares nothing but alias is a slot for the caller to fill, not a configuration, so it is never reported.

Prior art. Azure Verified Modules’ avm_terraform_provider_block_disallowed checks the same condition (equivalent). Google Cloud’s reusable-modules guidance and AWS’s prescriptive guidance on structure both state the rule directly (equivalent), which is why both are credited beside the ruleset that automates it.

This rule needs the module descent to see anything at all, so it fires on chant build, where a root’s local modules are parsed as child scopes. In chant audit a nested directory is discovered as a root module of its own, so there is no caller to make it a child, and the rule stays quiet. See Module descent and scope.

# The provider block TF014 reports: this module configures its own region, so
# its caller cannot point it anywhere else, and Terraform cannot remove the
# module without deleting the provider its own destroy needs.
provider "aws" {
region = "us-east-1"
profile = "cdn"
}
variable "bucket" {
type = string
}
resource "aws_s3_bucket" "assets" {
bucket = var.bucket
}

TF015: a child module declares a backend or cloud block

Section titled “TF015: a child module declares a backend or cloud block”

A terraform block inside a called module carrying a backend "<type>" or a cloud {} block. This is TF001’s mirror image: TF001 wants a root to declare one, and this rule wants a child module to declare neither.

Why it matters. State belongs to the root module, one state for the whole tree however many modules it calls, and Terraform says so: a backend block may appear only in a root module. Depending on the version the block is ignored with a warning or the root refuses to initialize, and either way the module is making a claim about where state lives that it does not get to make. The usual cause is a directory that used to be a root and kept its terraform block when someone factored it out into a module.

The fix. Delete the backend/cloud block from the module. The rest of its terraform block is welcome to stay: required_version and required_providers are meaningful in a child module and are not reported here.

Prior art. No tool in the survey checks this. tflint’s official ruleset has nothing about backends at all, Redeploy’s community ruleset has nothing either, and the Azure Verified Modules rules stop at the provider block TF014 credits. The one source that states the rule is a document rather than a linter, Google Cloud’s reusable-modules guidance, and it is credited as such (equivalent). See audit-lineage.ts for where the survey looked.

Like TF014, this rule reads a child scope and so fires on chant build. See Module descent and scope.

# This directory used to be a root module and kept its backend when it became
# a child. State belongs to the root that calls it, so the block below is the
# TF015 finding. `required_version` beside it is fine and is not reported.
terraform {
required_version = ">= 1.5.0"
backend "local" {
path = "cdn.tfstate"
}
}
resource "aws_s3_bucket" "assets" {
bucket = "assets"
}

TF016: an attribute value is a quoted interpolation of a single expression

Section titled “TF016: an attribute value is a quoted interpolation of a single expression”

An attribute written x = "${var.y}", the pre-0.12 style Terraform deprecated in 2019. Report-only, with a deterministic fix.

Why it matters. The quotes buy nothing and cost readability. The expression is already an expression, and wrapping it in a template makes every value in the file look like a string whether it is one or not, which hides the real type of an argument that takes a list or a bool.

The fix. Drop the quotes and the ${}. chant audit renders the patch in its quick-wins section, because the edit is mechanical and touches only the flagged line.

Prior art. tflint’s terraform_deprecated_interpolation, credited as an overlap rather than an equivalent for two reasons. tflint also reports the deprecated interpolation in an object KEY ("${var.k}" = value); that is a different line shape whose fix is not a simple unquote, so this rule leaves it alone rather than emit a diff it cannot justify. And chant’s version reads the block’s source text rather than its parsed body, which is the one rule in the family that has to: @cdktf/hcl2json renders x = var.y and x = "${var.y}" as the same string, so a check over the parse alone would report every reference in the root. The quotes only survive in the text.

variable "assets_bucket" {
type = string
description = "Name of the bucket the site is served from"
}
resource "aws_s3_bucket" "assets" {
bucket = "${var.assets_bucket}"
}
output "assets_bucket_arn" {
description = "ARN of the assets bucket"
value = "${aws_s3_bucket.assets.arn}"
}

A module call that carries the depends_on meta-argument. Report-only.

Why it matters. depends_on on a module applies to every resource inside it, including the ones that had no reason to wait, so the whole module serializes behind the named dependency and a plan that could have run in parallel does not. The ordering is also invisible from inside the module, where the resource that actually needed it lives.

The fix. Remove depends_on and pass an attribute of the dependency into a module input. Terraform derives the same edge from the reference, only narrower and self-documenting.

Prior art. Redeploy’s terraform_module_depends_on (equivalent).

module "cdn" {
source = "./modules/cdn"
bucket = aws_s3_bucket.assets.bucket
depends_on = [aws_s3_bucket.assets]
}

TF018: an output returns a whole resource or data source

Section titled “TF018: an output returns a whole resource or data source”

An output whose value is a bare type.name reference to a managed resource, or data.type.name for a data source, with no attribute selected. Report-only. A bare module.x is not reported: a module’s outputs are already a curated surface, which is the property this rule asks for.

Why it matters. Exporting the whole resource exports every attribute the provider schema happens to have, which is a much larger promise than the module meant to make. Consumers reach into whatever they find, the module can no longer change any of it, and a provider upgrade that adds or renames an attribute moves the module’s public interface without anyone editing it. Sensitive attributes ride along too.

The fix. Return the attribute the caller needs: .id, .arn, .endpoint.

Prior art. Redeploy’s terraform_output_resource (equivalent).

output "web_instance" {
description = "The web instance"
value = aws_instance.web
}

TF019: a meta-argument is explicitly set to its default of false

Section titled “TF019: a meta-argument is explicitly set to its default of false”

sensitive or ephemeral on a variable or an output, or prevent_destroy or create_before_destroy inside a lifecycle block, written as a literal false. Report-only, with a deterministic fix. An expression such as prevent_destroy = var.protect is a real decision and is not reported.

Why it matters. All four default to false, so writing it changes nothing and reads as though someone considered the setting and turned it off. That is exactly the impression a reviewer should not get from a variable called password.

The fix. Delete the line. This is the first TF rule with a mechanical fix, so chant audit renders the deletion as a diff in its quick-wins section, the same way it does for a merge-worthy deterministic rule.

Prior art. Redeploy’s terraform_redundant_default (equivalent), which covers the same four arguments in the same contexts, also reports only a literal false, and also ships an autofix that removes the line.

variable "log_level" {
type = string
description = "Log level the application starts with"
default = "info"
sensitive = false
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
lifecycle {
prevent_destroy = false
create_before_destroy = false
}
}

TF020: a declaration nothing in its module references

Section titled “TF020: a declaration nothing in its module references”

A variable, an entry in a locals block, a data source, or an aliased provider that no other block in the same module scope refers to. Report-only.

Why it matters. An unused declaration is not a defect, which is why this never fails a build. It is still worth a finding: a variable nobody reads is usually the leftover of a refactor, and the next person to touch the file has to prove that before deleting it. An unused data source costs more than attention, since it is read from the provider’s API on every plan, with the permission that read requires, for a value nothing consumes.

The fix. Delete it, or reference it where it was meant to be used.

Scope is per module, the way Terraform’s own namespaces are: a var.region declared inside module.cdn is the child module’s variable, and a reference to var.region in the root says nothing about it. The index behind this rule is keyed the same way.

Prior art. tflint’s terraform_unused_declarations, credited as an overlap rather than an equivalent because of how the two find their references. tflint evaluates expressions; chant scans strings. hcl2json hands back an expression as a string with its ${...} wrapper intact, so chant’s index is a set of regexes over those strings, which makes it generous in one direction (a name inside a prose description reads as a reference, so the declaration is not reported) and blind in another (a variable supplied only through TF_VAR_ is not a reference at all, and a root’s variables often are exactly that). Both errors are why the rule is report-only. See hcl/references.ts for the scan and what it collects.

A provider block with no alias is never reported: it is the default configuration for its type and every resource of that type uses it without naming it. Only an aliased provider has to be asked for by name.

variable "region" {
type = string
default = "us-east-1"
}
variable "retention_days" {
type = number
validation {
condition = var.retention_days > 0
error_message = "retention_days must be positive."
}
}
locals {
common_tags = { Team = "platform" }
bucket_name = "assets"
}
data "aws_ami" "ubuntu" {
most_recent = true
}
provider "aws" {
alias = "replica"
region = "eu-west-1"
}
resource "aws_s3_bucket" "assets" {
bucket = local.bucket_name
region = var.region
}

Four findings there: var.retention_days (read only by its own validation block, which does not count as a use), local.common_tags, data.aws_ami.ubuntu and the aws.replica provider. var.region and local.bucket_name are read by the bucket and are not reported.

TF021: a count builds instance identities out of count.index

Section titled “TF021: a count builds instance identities out of count.index”

A resource whose count is a numeric literal of two or more, or length(var.x) / length(local.x), and whose body uses count.index to build an identity-bearing attribute: a name, a bucket, a key, a tags.Name. Report-only.

Why it matters. count addresses instances by position: aws_instance.web[0], [1], [2]. Remove the middle element of the list the count came from and every later instance shifts down one index, so Terraform plans to destroy and recreate all of them to move each identity onto the next position. With for_each the instances are addressed by key, and removing one element removes exactly one instance.

The fix. Use for_each over a map or a set, then terraform state mv the existing indexed instances onto their new keys so the switch itself does not recreate anything.

Prior art. The community Redeploy ruleset’s terraform_prefer_for_each (overlaps) reports every count over a collection. This rule is deliberately narrower: both conditions have to hold, plural count and an identity built from count.index, so a count.index that only picks a subnet out of a list is not reported and neither is the count = var.enabled ? 1 : 0 switch, which for_each does not replace. choudoufu’s RuleCountIndex (internal/live/lint/count_index.go) is the survey’s deepest treatment of which attributes carry an instance’s identity, and is credited the same way (overlaps). HashiCorp’s style guide states the preference itself in its dynamic resource count section (overlaps).

resource "aws_instance" "web" {
count = 3
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}"
}
}

TF022: a credential-named resource attribute holds a plaintext literal

Section titled “TF022: a credential-named resource attribute holds a plaintext literal”

An attribute of a resource or data block whose name matches the credential-name heuristic and whose value is a committed literal. The walk descends into nested blocks, so a credential inside connection {} or assume_role {} is not invisible.

Why it matters. The value is in git history, in state, and in plan output, and the blast radius is whatever the resource is: an RDS master password, an API token on a provider resource, the data of a Kubernetes secret. This is broader and noisier than TF007, which is why the name heuristic gates it rather than the value heuristic alone.

The fix. Replace the literal with a reference: a sensitive variable, or a data source that reads the value from a secret manager at apply time. Then rotate the credential.

Prior art. tfsec’s general-secrets-no-plaintext-exposure, the v1 consolidation of its three general/secrets checks, and KICS’s a88baa34-e2ad-44ea-ad6f-8cac87bc7c71, both overlaps. tfsec’s consolidated check also covers the variable and locals cases TF007 reports separately, and the KICS query is a regex sweep over every file type rather than a rule about Terraform attributes.

resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
username = "app"
password = "Pr0dDbP4ssw0rd"
}

TF023: Terraform state is committed to the repository

Section titled “TF023: Terraform state is committed to the repository”

A terraform.tfstate, a terraform.tfstate.backup, any other *.tfstate, or a .terraform/ directory, tracked in the repository being audited.

This is the one TF rule that never runs during chant build. It is not a check over a parsed root module at all: it reads the file list chant audit discovers, so it lives in core beside the other lexicon-independent families (packages/core/src/audit/terraform-state.ts, catalogued in core’s own RULE_CATALOG) and fires whether or not the terraform lexicon is installed. On a local path the root .gitignore is what separates “committed” from “merely on disk”, since almost every Terraform working tree has an ignored .terraform/ in it; a fetched repository’s file list is already exactly its tracked files. Nothing reads the contents of either: the finding is that the path is there.

Why it matters. A state file records every attribute of every managed resource in plaintext JSON, including the ones providers mark sensitive, so a committed state file is a committed secret store, readable by everyone with clone access and preserved in history after any later deletion. .terraform/ is milder, downloaded providers and vendored child modules, but it is hundreds of megabytes of machine-generated content and the local backend keeps its state there too.

The fix. Delete the file or directory from version control, add *.tfstate, *.tfstate.* and .terraform/ to .gitignore, move the state to a remote backend (see TF001), and rotate every credential the file held.

Prior art. No linter or scanner in the survey checks for committed state. HashiCorp’s style guide names the exact files in its .gitignore section, “Your terraform.tfstate state file, including terraform.tfstate.* backup state files … Your .terraform directory”, so the document is both the authority and the first written statement of the rule, and is credited as both.

# fixtures/TF023/positive/ is a directory rather than a single file: the finding
# is a path in the repository, not a block in a root module.
#
# positive/main.tf a perfectly ordinary root
# positive/terraform.tfstate committed beside it

TF024: live root declares a backend or cloud block

Section titled “TF024: live root declares a backend or cloud block”

A root that is live (terraform.binary: "choudoufu" plus a declared estate, as a live block or an estate.chdf.hcl sidecar) whose terraform block also carries a backend "<type>" or cloud {} block.

Why it matters. Under live markers there is no state to store: prior state is a projection of the tofu-estate and tofu-address tags, rebuilt from the live system every run. choudoufu refuses the combination at init, before any command runs (“Both a backend and a live configuration are present”), so the root never plans. TF001 is the same block read the other way round and does not fire on a live root.

The fix. Remove the backend or cloud block from a live root. If the root is meant to stay on stock state, drop the estate declaration instead and switch terraform.binary back to terraform or tofu.

Prior art. choudoufu RuleStateBackend (internal/live/lint), equivalent: the identical condition, checked the same way at init.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
backend "s3" {
bucket = "tfstate"
key = "fixture/terraform.tfstate"
}
}
resource "null_resource" "first" {}

TF025: live root references a non-default workspace

Section titled “TF025: live root references a non-default workspace”

A live root whose configuration references terraform.workspace, or whose terraform.roots.<name>.workspace names anything but default.

Why it matters. choudoufu refuses every workspace but default, along with workspace new and workspace select: a workspace is a second state file, and a live root has none. A terraform.workspace reference in a live root can only ever read default, which is a sign the configuration was written for a stock multi-workspace layout and has not been adapted.

The fix. Replace the terraform.workspace reference with a variable or the estate name, and drop the workspace setting from the root’s entry in terraform.roots. Separate environments become separate estates.

Prior art. No tool in the survey covers this. choudoufu refuses the workspace at its CLI argument layer (internal/command/live_mode.go) rather than through a lint rule, so there is no rule constant to cite; see audit-lineage.ts for the note.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
}
resource "null_resource" "first" {
triggers = {
workspace = "${terraform.workspace}"
}
}

TF026: live root’s delete: “never” against its policy block

Section titled “TF026: live root’s delete: “never” against its policy block”

A live root whose terraform.roots.<name>.delete is "never", but whose policy block leaves undeclared_tagged at its default ("delete") or sets it to anything but "keep", "untag" or "report".

Why it matters. chant’s delete: "never" | "owned-only" | "gated" maps onto choudoufu’s policy block: undeclared_tagged governs what happens to a resource this estate marked that the configuration no longer declares, and defaults to "delete" when the policy block omits it, or the root declares no policy block at all. delete: "never" promises that TerraformApplyOp never proposes deleting a resource it owns; a root making that promise without actually turning the quadrant’s default off is a promise the configuration cannot keep.

The fix. Add a policy block (nested in the root’s live block) setting undeclared_tagged to "keep", "untag" or "report", or change the root’s delete to "owned-only" or "gated" if an owned orphan really should be deleted.

Prior art. No tool in the survey covers this. choudoufu’s own internal/live/lint package checks a policy block for internal validity (an unrecognized or wrong-quadrant verb, an unscoped undeclared_untagged = "delete"), never against a delete: "never" | "owned-only" | "gated" classification — that vocabulary is chant’s own, applied here to a tool that does not itself carry the concept. See audit-lineage.ts for the full reasoning.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
}
resource "null_resource" "first" {}

TF101: terraformApply’s planFile must reference a preceding terraformPlan step

Section titled “TF101: terraformApply’s planFile must reference a preceding terraformPlan step”

A terraformApply step’s planFile is a string or template literal in source, or a reference to a step that is not a terraformPlan call.

Why it matters. The entire point of the plan/apply split is to review exactly what will change, then apply exactly that. Spelling the plan path out as a literal breaks the link between the two steps: the file applied can be stale, hand-edited by something else in the meantime, or simply the wrong plan for this root. Carrying the path forward from the producing step (stepOutput(plan, "planFile") or plan.out.planFile) is what keeps apply honest about which plan it is running.

The fix. Reference the preceding terraformPlan step’s output instead of a literal path: stepOutput(plan, "planFile") or plan.out.planFile, where plan is the const a terraformPlan(...) call was assigned to.

Prior art. No tool in the survey covers this. TF101 is a rule about chant’s own Op-builder DSL, not about a Terraform root. Nothing in the survey (linters, scanners, policy engines, or HashiCorp’s own Sentinel examples) models “does this plan file trace back to a specific preceding plan step”, because that question does not exist outside chant’s own authoring surface. See audit-lineage.ts’s header comment for the full reasoning.

When the referenced identifier cannot be resolved at all in the same file, this rule stays silent rather than guess.

Live roots are checked the same way. For one release this rule skipped a terraformApply call over a choudoufu live root, because choudoufu refused -out and apply <planfile> and the activity ran a bare apply -auto-approve with no plan file to pair. choudoufu v0.13.0 admits both under a live block (choudoufu #878), and there the pairing carries more weight than on a stock root: the apply re-plans the live system and compares its own fresh plan against the file it was handed, refusing with exit status 3 when they differ. A plan file spelled out as a literal path is an approval for a run nobody can trace back to a plan step, so the exemption is gone with the condition that produced it, and this rule reads no project config at all.

const plan = terraformPlan("app", { id: "plan" });
const apply = terraformApply("app", { planFile: "/tmp/plan.out" });

To write custom rules for your project, see the lint-rules authoring guide.

A root module’s parse does not stop at its own directory. A module block whose source is a local path (./modules/cdn, ../shared) names a directory on disk, and that directory is parsed as a child scope of the root that calls it. Its blocks are keyed <root>/module.<name>/<address>, one key segment per call, which is the address terraform show -json writes in child_modules[] and the one choudoufu’s marker tags carry.

This is what lets TF014 and TF015 exist at all, and what makes TF020 answer per module rather than per repository. Every other rule reads a child module’s blocks exactly as it reads a root’s, with two groups of exceptions: TF001, TF002, TF003, TF024 and TF025 are about how a ROOT is assembled and skip a child’s blocks (a module beneath a root with a perfectly good backend must not make TF001 fire), and TF014 and TF015 are the mirror, reading only child modules. Each rule’s own file says which it is.

A finding inside a child module names the call that reached it:

TF014: Child module block "provider.aws" configures a provider (profile, region). ...
Callers: app -> module.cdn (main.tf:13).

The chain is tflint’s idea, printed there as a Callers: block under the diagnostic. A chant post-synth finding carries no source ranges of its own, so the chain goes in the message, where every output format shows it.

What is followed. terraform.callModuleType in chant.config.* spells the policy, with tflint’s three values and its default:

export default {
lexicons: ["terraform"],
terraform: {
callModuleType: "local", // the default
roots: { app: { dir: "./terraform/app" } },
},
};
  • local reads modules sourced from a relative path. A registry or git source is not fetched, so its contents are never linted and every finding about it is reported against the module block at the call site, which is where TF004 and TF005 report already. The skipped calls are named in a build warning rather than passed over in silence.
  • none reads each root’s own directory and nothing else.
  • all is tflint’s “fetch registry and git modules too”, which needs a terraform init first so the modules are on disk. chant fetches nothing, so this is refused with a message rather than quietly behaving like local.

Two refusals keep the walk inside the repository it was pointed at. A source that resolves outside the project root is not read, and a source already on the current call chain is a cycle and stops there. Both are build warnings naming the call site.

chant audit sees the same rules but not the same scopes: audit discovery already treats every directory of .tf files as a root module of its own, and the audit hook is handed one directory’s text with no filesystem beneath it. So a child module is audited, as a root rather than as a child, and the two rules that ask “is this inside a called module?” report on chant build only.

The TypeScript lint’s chant-disable comments (see Disable Directives) don’t apply here: a post-synth check like TF001 reads the parsed HCL model, not a ts.SourceFile, so it has no AST position to disable at. Terraform gets its own comment forms instead, read from the raw .tf text in a second pass over the file (hcl2json drops comments entirely), so terraform fmt leaves every one of them alone. Every form works identically through chant build and chant audit, and a suppressed finding is always counted in the build/audit summary, never dropped silently.

# chant-ignore: TF010, TF011
variable "region" {
type = string
}

# chant-ignore on the line before a block (or an attribute) suppresses the listed rule ids on that block. Write all instead of a list to suppress every rule id there.

# chant-ignore-file: TF011

# chant-ignore-file suppresses a rule id for the whole file, but only when it is the first non-blank line of the file. Anywhere else it has no effect and is itself reported as a finding, the same constraint tflint’s tflint-ignore-file enforces: a file-level suppression that could sit anywhere in the file is one a reader has to read the whole file to rule out, so it isn’t findable.

# chant-ignore-block: TF001
terraform {
required_version = ">= 1.5.0"
}

# chant-ignore-block anchors to the next block rather than to a finding’s entity, borrowed from KICS’s kics-scan ignore-block. This is the form TF001 needs: its finding is about a backend block that is not there, so there is no attribute or sub-block a plain # chant-ignore could sit in front of. The comment goes on the terraform block itself instead, the nearest thing the missing backend has to a location. A plain # chant-ignore placed on the same terraform block works too, because the block TF001 reports against and the block the comment precedes happen to be the same one here; reach for # chant-ignore-block on purpose whenever what’s wrong is an absence, so the comment reads as “nothing here should trigger a finding” rather than “this block has a problem.” trivy’s own suppression comments can’t do this at all, because trivy’s ctx.entities never carries a location for a block that isn’t present; chant’s does, because the terraform lexicon always parses a root’s terraform block into an entity even when the thing it’s missing lives inside it.

Add exp:2026-12-31 to any of the three forms to make the suppression expire:

# chant-ignore: TF010 exp:2026-12-31

Past that date the suppression stops suppressing and is reported once as its own finding, so an ignore written for a known-in-progress fix doesn’t rot into a permanent blind spot.

A project can lock a rule id against being ignored at all. In chant.config.ts:

export default {
lint: {
rules: {
TF008: ["error", { ignorable: false }],
},
},
};

A # chant-ignore (in any form) that names an id configured ignorable: false has no suppressing effect on that id, and is itself reported as a finding, matching tflint’s own ignorable = false setting. Every rule is ignorable by default; TF008 (hardcoded provider credentials) is the kind of rule a project might want to lock this way.