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` resolvesto the newest release of the module every time it runs. A registry modulethat changes what it provisions between releases becomes an unreviewedchange the next time anyone runs `init`, not a diff caught by a pinnedversion bump.
**The fix.** Add a `version` constraint to the module block (e.g. `~> 5.0`).
**Prior art.** tflint's `terraform_module_version` checks the identicalcondition (equivalent). checkov's `CKV_TF_2` checks the same thing for aregistry source (equivalent).
```hclmodule "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"}TF010: a variable declares no type
Section titled “TF010: a variable declares no type”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}TF011: a variable declares no description
Section titled “TF011: a variable declares no description”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"}TF012: an output declares no description
Section titled “TF012: an output declares no description”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}"}TF017: a module block uses depends_on
Section titled “TF017: a module block uses depends_on”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 itTF024: 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.
Module descent and scope
Section titled “Module descent and scope”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" } }, },};localreads 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 themoduleblock 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.nonereads each root’s own directory and nothing else.allis tflint’s “fetch registry and git modules too”, which needs aterraform initfirst so the modules are on disk. chant fetches nothing, so this is refused with a message rather than quietly behaving likelocal.
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.
Suppressing a finding
Section titled “Suppressing a finding”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, TF011variable "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: TF001terraform { 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-31Past 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.
Un-ignorable rules
Section titled “Un-ignorable rules”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.