Lint Rules
The Terraform lexicon ships one source-level lint rule and forty-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 {}, or one whose
only backend is backend "local".
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. backend "local" asks for the same file by name and gets the same three problems, so
the check reads the backend block’s type label rather than its presence
(#2218) and reports both, naming which one it found. cloud {} counts as
remote: the state is held by Terraform Cloud or HCP, not by the machine that
plans.
The fix. Add a backend "<type>" block naming a remote type (s3, gcs,
azurerm, http) or a cloud {} block to the root module’s terraform
block, then run terraform init -migrate-state.
A root that keeps local state on purpose says so instead of switching. Put a
# chant-ignore-block: TF001 on the line above its terraform block, with
the reason on the line above that, the way the shipped examples do:
# The state stays on local disk on purpose: this example is run by hand from a# checkout, and a remote backend would make a first apply need credentials and# a bucket nobody has.# chant-ignore-block: TF001terraform { backend "local" { path = "terraform.tfstate" }}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" }}The second positive, the root that names the local backend instead of falling back into it:
terraform { required_version = ">= 1.5.0"
backend "local" { path = "terraform.tfstate" }
required_providers { null = { source = "hashicorp/null" version = "~> 3.2" } }}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
Section titled “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).
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"}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 hides the resource from drift
detection.
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 a
remote 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.
Unlike TF001, this rule does not read the backend’s type label, and the fixture
below declares backend "local" on purpose. What a child module may not do is
name a backend at all; Terraform’s own refusal (“A backend block can only
appear in the root module”) does not read the label either.
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: @cdktn/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 .gitignore files are what separate “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.
Every .gitignore between the file and the scan root is read, the root one
included, so infra/terraform.tfstate is not reported when infra/.gitignore
ignores it, and auditing from the repository root and from infra/ agree
(#2528; up to v0.80.0 only
the root .gitignore was read).
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" {}TF027: live root’s policy block deletes what chant does not own
Section titled “TF027: live root’s policy block deletes what chant does not own”A live root whose policy block sets undeclared_untagged = "delete", with
or without a scope block beside it.
Why it matters. The undeclared_untagged quadrant covers a live resource
this estate never marked and the configuration does not declare: a resource
chant does not own. "delete" there is account-scoped reconciliation, and it
can reach a resource nothing in the project has ever named. chant never
proposes deleting a resource it does not own, and neither does an Op it
generates, so the setting is refused on any live root the project builds.
TerraformApplyOp refuses the same thing when it is built, but only for a
project whose root it can resolve from a chant.config.json; this check reads
the parsed HCL, so it fires whatever the config file is written in, and on a
live root with no apply Op at all.
The fix. Remove undeclared_untagged = "delete" from the policy block.
Where the estate really should reclaim orphans, narrow its own ownership
answer instead: undeclared_tagged governs the resources this estate marked,
and its default is already "delete".
Prior art. choudoufu RulePolicyScope
(internal/live/lint/policy.go),
extends. choudoufu refuses the same assignment when no scope block narrows
the purge, on the reasoning of its own issue #67. chant refuses it either way:
a scope block bounds the sweep to a service, type or region, and every
resource inside that boundary is still one this estate never marked.
terraform { required_version = ">= 1.5.0"
live { estate = "fixture-estate"
policy { undeclared_untagged = "delete" } }}
resource "null_resource" "first" {}TF028: live root watched by a stock-mode watch Op
Section titled “TF028: live root watched by a stock-mode watch Op”A root that runs choudoufu with a declared estate, named by a
TerraformWatchOp built without live: true.
Why it matters. live is declared, not detected: a composite is built by
chant build without reading a root module’s .tf files, so the Op author
says which shape the Plan phase takes and, until this check, nothing compared
that answer with the root. Getting it wrong is silent and lossy. live: true
swaps the Plan step for choudoufuLivePlan, whose single read of the live
system answers three questions: whether the estate drifted, how many live
resources sit at a declared identity carrying no marker, and how many of those
an exact content match makes claimable. A stock terraformPlan step answers
the first alone, so the watch reports drift and stays quiet about every
unowned and adoptable resource in the estate.
The fix. Set live: true on the TerraformWatchOp that names the root.
Drop the root’s estate instead if it is not a live root. TerraformWatchOp
also refuses the mismatch outright where it can read the root’s mode from a
chant.config.json; this check covers the projects on a chant.config.ts,
where that read returns nothing.
Prior art. No tool in the survey covers this. The check reads a chant Op’s
own configuration against a root’s mode, and no surveyed tool has an Op model
to read: choudoufu lints HCL, and so does every tflint ruleset, tfsec and
trivy-checks, and the reference Sentinel policies. See audit-lineage.ts.
terraform { required_version = ">= 1.5.0"
live { estate = "fixture-estate" }}
resource "null_resource" "first" {}The Op that turns that root into a finding, missing the one field:
const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate" });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 the wrong plan for
this root. Carrying the path forward from the producing step
(stepOutput(plan, "planFile") or plan.out.planFile) ties the apply to the
plan it runs.
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 instead of falling back tolocal.
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.
Where the walk stops. The boundary is the project root, or a root’s own
directory when its dir lies outside the project. terraform.moduleRoot
names another directory instead, absolute or relative to the project root, and
a source that resolves outside it is refused the same way. chant workspace graph sets it to the workspace root when it reads a terraform or
choudoufu member through a reader project of its own
(#2874), so a member root
that calls ../modules/x keeps its modules. A project rarely needs it, since
widening the boundary means reading modules the project doesn’t own.
chant audit walks the same scopes. Discovery reads each directory of .tf
files, drops the ones another directory calls with a relative source (they
are child modules, not roots), and hands the audit the directory each root was
read from. The parse then follows the same local module calls a build
follows, so TF014 and TF015 report under chant audit with the same Callers:
line, and the root-only rules still see only a root’s own blocks. A scope is
named after its directory there (envs/prod reads as envs.prod in a
finding), since an audit has no terraform.roots to take names from. The two
refusals above still apply, but an audit has no warning list to print them on,
so there they are silent.
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.
TF029: live root names its estate twice
Section titled “TF029: live root names its estate twice”A root that names its estate in both terraform.roots.<name>.estate and its
own HCL, in a live { } block or an estate.chdf.hcl sidecar.
Why it matters. The two are not equal in authority. A declaration is what
choudoufu reads for itself; terraform.roots.<name>.estate is what this
lexicon would otherwise hand it as -estate. choudoufu refuses that flag
beside a declaration outright — “This configuration’s live block is what names
its estate, and -estate=... would name a second one for this run only” — so
the lexicon does not pass it, and the config value is never used.
A setting written down and silently ignored is worse than one that errors. An
author who moves an environment by editing roots.app.estate gets a run
against the old estate and nothing tells them the edit was disregarded. The
diagnostic names both values even when they agree today, because two settings
that happen to match still means one of them does nothing, and the next edit to
the ignored one is the incident this rule exists to prevent.
The fix. Pick one. A root whose directory is shared across environments
declares no estate in HCL and names it per root in chant.config. That is the
environment dimension, and it is why terraform.roots.<name>.estate exists. A
root with its own live block already has an estate and needs nothing in
chant.config.
TF030: a security group rule opens a sensitive port to the internet
Section titled “TF030: a security group rule opens a sensitive port to the internet”An ingress rule that admits 0.0.0.0/0 or ::/0 on SSH (22), RDP (3389),
MySQL (3306) or PostgreSQL (5432), the same four ports the aws lexicon’s
WAW019 checks. TF030 reads all three shapes Terraform has for a rule:
ingressonaws_security_group, written as nested blocks or as an attribute list (the provider types it asset(object(...)), and the parse reads both spellings the same way), withcidr_blocksandipv6_cidr_blocks;- a standalone
aws_security_group_rulewhosetypeis"ingress"; - an
aws_vpc_security_group_ingress_rule, withcidr_ipv4andcidr_ipv6.
Ports are a range. Protocol tcp or udp (or 6, 17) reaches every port
from from_port to to_port, so 0-65535 reaches all four; protocol -1
(or "all") reaches every port whatever the range says. ICMP and other
port-less protocols are never reported, and tcp with 0-0 is port 0, not
every port. Egress rules are never reported.
Why it matters. These are the ports automated scanners try first. A database or a remote shell reachable from every address on the internet is one leaked or guessed credential away from being someone else’s, and the security group is the layer that should have stopped the connection before any password was asked for.
The fix. Narrow the CIDR to the sources that need the port: an office
range, a VPN, the VPC’s own CIDR. Better still, take the port off the internet
altogether and reach the host through a bastion, a VPN or SSM Session Manager,
or let the database accept connections only from its clients’ security group
(source_security_group_id, referenced_security_group_id), which TF030 does
not report.
What absence means. An aws_security_group with no ingress admits
nothing: the provider’s default is no rules, and any rules the group does have
live in standalone resources, which TF030 reads in their own right. So a
missing ingress is closed, not unknown, and nothing is reported for it.
What is not determined. TF030 evaluates no expressions. It reports a
finding only when literals prove all three facts: the rule is ingress, a CIDR
is open, and the ports reach a sensitive one. When the answer depends on a value
the parse hands back as an expression, such as cidr_blocks = var.allowed_ssh_cidrs, from_port = var.ssh_port, type = var.direction,
ingress = local.rules, a for_each over each.value, or a
dynamic "ingress" block, it emits one info finding that starts
Not determined: and names each attribute it could not read. It never turns
an unread value into a warning or an error. One known fact still settles a
rule by itself: cidr_blocks = var.allowed on port 443 is silent, because no
CIDR makes 443 one of the four ports, and so is a private CIDR literal on
from_port = var.port.
Prior art. Every credit overlaps; none checks quite the same condition.
Checkov’s CKV_AWS_24
and CKV_AWS_25 check
22 and 3389, one port per id, read the same three shapes, and read tcp
0-0 as every port. tfsec’s
aws-vpc-no-public-ingress-sgr
flags a /0 on any port. trivy-checks’
aws-ec2-no-public-ingress-sgr
checks SSH and RDP. KICS’s
381c3f2a-ef6f-4eff-99f7-b169cda3422c
checks a longer port list. HashiCorp’s reference Sentinel policies
restrict-ingress-sg-rule-ssh
and
restrict-ingress-sg-rule-rdp
read only aws_security_group_rule, and run against a plan rather than the
source.
The fixtures under fixtures/TF030/ are vendored from public repositories
under MIT-0 and Apache-2.0, each with its source, path, commit and licence in a
comment header. The one below is
aws-samples/terraform-ec2-image-builder-container-hardening-pipeline’s
sec-groups.tf. TF030 reports its second ingress rule, whose 1025-65535
range takes in MySQL, RDP and PostgreSQL, and not the first, which opens 443:
resource "aws_security_group" "image_builder_sg" { depends_on = [ aws_vpc.hardening_pipeline ] name = "${var.image_name}-sg" description = "Security group for EC2 Image Builder" vpc_id = aws_vpc.hardening_pipeline.id
ingress { description = "TLS" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { description = "Ephemeral" from_port = 1025 to_port = 65535 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
egress { description = "Allow all eggress" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] ipv6_cidr_blocks = ["::/0"] }
tags = local.core_tags}TF031: an IAM policy allows a wildcard Action or Resource
Section titled “TF031: an IAM policy allows a wildcard Action or Resource”An Allow statement whose Action or Resource is the literal "*", or a
list that holds it. The rule reads aws_iam_policy, aws_iam_role_policy,
aws_iam_user_policy and aws_iam_group_policy (policy), aws_iam_role
(assume_role_policy and every inline_policy block), and the
aws_iam_policy_document data source (its statement blocks). Like the aws
lexicon’s WAW020, it reads the trust policy too. A trust policy has no
Resource, so in practice only Action = "*" fires there.
Why it matters. Action = "*" grants every AWS action, including the IAM
ones that let the holder grant itself anything else. Resource = "*" beside a
scoped action still reaches every bucket, table or key in the account,
including the ones created after the policy was written. Both are easy to
write while debugging and are rarely taken out again.
The fix. Name the actions the workload calls and the ARNs it touches. A few
actions accept only Resource = "*" (ecr:GetAuthorizationToken,
sts:GetCallerIdentity, most Describe* and List* calls). Put those in a
statement of their own and keep it with a # chant-ignore-block: TF031 above
the block, so the exception is written down where a reviewer sees it.
What the rule can read. A policy argument is a string, and the HCL parse does not evaluate it, so TF031 reads three shapes:
jsonencode({ ... }). The parse keeps the call as one string with HCL object syntax inside, and TF031 reads the literal parts of that argument as structure. An expression inside it (a reference, a function call, a conditional, a template string) is skipped as one unknown value, so a literalAction = "*"besideResource = aws_s3_bucket.logs.arnstill fires.- A heredoc or quoted string with no interpolation. The parse hands this over as the JSON text itself, and TF031 reads it with a JSON parser. This is more than #2286 expected: a plain heredoc turned out to be readable.
- The
aws_iam_policy_documentdata source, whosestatementblocks are plain HCL.effectdefaults to"Allow"in the provider, so a statement without one is an allow. A policy argument that is exactlydata.aws_iam_policy_document.<name>.jsonnaming a document in the same module is not reported a second time; the finding is on the document.
A Deny statement never fires. Deny with Action = "*" is the usual guard
rail, for example denying everything from outside an address range.
What absence means. A resource with no policy argument, a role with no
inline_policy blocks, and a data source with no statement are
unconfigured, not insecure. TF031 reports nothing for them.
What is not determined. TF031 does not evaluate expressions. When the value
it needs cannot be read from the source, it reports one info diagnostic
starting Not determined: that names the attribute and the reason, never a
warning. That happens for:
- a policy that is a reference (
var.policy, a module output, a document declared in another module) or afile()ortemplatefile()call; - a heredoc or string with
${...}interpolation, or one that does not parse as JSON; - a
jsonencode()whose whole argument is an expression (jsonencode(local.policy)), or whoseStatementis built byconcat()or a for-expression; - an
Action,ResourceorEffectthat is an expression which could be"*"(a variable, a local, a function call); - a
dynamic "statement"block.
Some expressions can never be "*", and TF031 reads them as scoped rather than
not determined: an attribute of a managed resource or data source
(aws_s3_bucket.logs.arn) is an ARN or an id, and a template whose literal
text already holds something other than * ("arn:aws:s3:::${var.bucket}/*")
cannot equal *.
Out of scope: NotAction and NotResource, service wildcards such as s3:*,
Condition blocks (a wildcard under a condition still fires), the documents
an aws_iam_policy_document merges in through source_policy_documents or
override_policy_documents, and resource-based policies (S3 bucket, SQS, KMS,
VPC endpoint), where Resource = "*" usually means the resource itself and
the risk is in Principal.
Prior art. tfsec’s
aws-iam-no-policy-wildcards
(overlaps: it also flags service wildcards such as s3:*). checkov’s
CKV_AWS_63
(overlaps: Action "*" on the policy resources, plus the SSO permission-set
inline policy TF031 does not read) and
CKV_AWS_49
(extends: the same on the data source, where TF031 also checks resources),
and CKV_AWS_355
and CKV_AWS_356
(overlaps: Resource "*" only for actions that can be restricted). trivy-checks
is not credited: its aws-iam-no-policy-wildcards is a deprecated stub with no
rule body.
resource "aws_iam_policy" "privesc-AssumeRole-high-priv-policy" { name = "privesc-AssumeRole-high-priv-policy" path = "/" description = "Allows privesc via targeted sts:AssumeRole"
# Terraform's "jsonencode" function converts a # Terraform expression result to valid JSON syntax. policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = "*" Resource = "*" }, ] })}From fixtures/TF031/positive-jsonencode.tf, vendored from
BishopFox/iam-vulnerable
(MIT).
TF032: an ECS container definition passes a credential as a plaintext environment value
Section titled “TF032: an ECS container definition passes a credential as a plaintext environment value”An aws_ecs_task_definition whose container_definitions gives a container an
environment entry with a literal, secret-shaped value. The Terraform
counterpart of the aws lexicon’s WAW046.
Why it matters. An environment value is stored verbatim in the task
definition. Anyone allowed ecs:DescribeTaskDefinition can read it, the console
shows it, and it sits in Terraform state and plan output beside the commit that
introduced it. ECS has a field for exactly this case: secrets, whose
valueFrom names a Secrets Manager secret or an SSM parameter that the agent
resolves when the task starts.
The fix. Move the entry from environment to secrets with a valueFrom,
grant the task execution role read access to that secret or parameter, and
rotate the value that was committed.
secrets = [ { name = "DB_PASSWORD", valueFrom = aws_secretsmanager_secret.db_password.arn },]What it can read. container_definitions is a JSON string, and hcl2json
does not evaluate expressions, so jsonencode([...]) reaches the rule as the
call’s source text. The rule reads that text with the shared literal reader
(src/hcl/jsonencode.ts) and never evaluates anything. A reference or function
call in one field (image = var.image, value = var.db_password) leaves that
field unknown and the literal fields around it readable. A plain string with
no interpolation is parsed as JSON, which covers a heredoc that contains none.
Which entries fire. The judgment is src/lint/secret-shape.ts, the same
module TF007, TF008, TF009 and TF022 use, so “secret-shaped” means one thing
across the family. An entry fires when its value is a literal string of at
least 8 characters that is not a placeholder (changeme, example,
your-api-key, a run of one character), and either:
- its
name, lowercased with every non-alphanumeric run folded to_, contains one ofpassword,passwd,secret,token,api_key,private_keyoraccess_keyas a whole_-separated word, and does not end in_file,_path,_arn,_idor_name; or - the value has a credential’s shape on its own, whatever the name: a vendor key format core’s secret scanner knows (AWS key ids, GitHub, Slack, Google, Stripe), a connection string with real credentials in it, a JWT, a PEM private key, or a long high-entropy token.
The word boundary is what keeps TOKENIZER and KEYSTORE_TYPE quiet. The
locator suffixes keep DB_SECRET_ARN and API_KEY_PATH quiet, since a value
that points at a credential is the shape the fix asks for. credential is not
on the list, unlike WAW046’s pattern: in an environment the common name
containing it is GOOGLE_APPLICATION_CREDENTIALS, which holds a file path. The
8-character floor keeps PASSWORD_MIN_LENGTH = "8" and
TOKEN_TTL = "3600" quiet. Below 8 characters a literal is not reported, so
"hunter2" passes; the floor is shared with TF022 and not tuned per rule.
A numeric or boolean value is never a finding.
What absence means. A container with no environment is not insecure, and
neither is one that passes every credential through secrets: the rule never
reads secrets at all. A task definition without container_definitions is
not reported either, since the attribute is required and that is a plan error.
What is not determined. When the definitions cannot be read as literal
structure, the rule says so in one info diagnostic starting
Not determined:, never a warning or an error. That happens when
container_definitions is a reference (local.defs,
data.template_file.app.rendered), a file() or templatefile() call, a
heredoc or string with interpolation, or jsonencode of something other than
a literal list (jsonencode(concat(...)), jsonencode(local.containers)). It
is reported per container when a container’s environment is an expression
(environment = local.env_vars, a for expression), or when an entry in it
is. A credential in any of those places can be neither found nor ruled out
without evaluating HCL, which this rule does not attempt. An entry whose
value alone is a reference is skipped without an info. A reference is not
a committed literal, and where the referenced value comes from is TF007’s and
TF022’s question.
Where this does not reach. A plaintext password on aws_db_instance,
aws_rds_cluster, aws_elasticache_replication_group and similar resources is
an ordinary attribute, and TF022 already reports it with the same heuristic.
TF032 exists because a credential inside container_definitions is inside a
JSON string, which TF022’s attribute walk sees only as one string named
container_definitions.
Prior art. tfsec’s
aws-ecs-no-plaintext-secrets
and its successor in trivy-checks,
aws-ecs-no-plaintext-secrets
(AVD-AWS-0036), both overlaps. They fire on a sensitive environment name or a
value their secret scanner flags, after evaluating the definition, so they
also see what TF032 reports as not determined, and their name list is their
own. Checkov has no Terraform check for ECS environment values. Its ECS checks
cover privilege, a read-only root filesystem, host processes, the task role
and EFS encryption.
The failing fixture, vendored from
edstem-tech/paylink-platform
(MIT), fires on DB_PASSWORD and PAYMENT_API_KEY:
resource "aws_ecs_task_definition" "api" { family = "paylink-api" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = "1024" memory = "2048" task_role_arn = aws_iam_role.task.arn execution_role_arn = aws_iam_role.execution.arn
container_definitions = jsonencode([{ name = "api" image = "${var.ecr_repo}:latest"
portMappings = [{ containerPort = 8080 }]
environment = [ { name = "SPRING_PROFILES_ACTIVE", value = "prod" }, { name = "DB_PASSWORD", value = "Pl-Pr0d-Db-2025!" }, { name = "PAYMENT_API_KEY", value = "pk_live_4RtYuIoP2aSdF6gH8jK" } ]
healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] interval = 10 timeout = 2 retries = 3 } }])}The other fixtures under fixtures/TF032/ cover the remaining cases. The
passing ones are vapor/penny-bot (secrets through valueFrom), trivy-checks’
own passing heredoc, and testdrivenio/django-ecs-terraform (reference values,
a container with no environment, and a data.template_file definition reported
as not determined). The failing ones are trivy-checks’ failing heredoc and
govuk-forms/forms-admin, where the Postgres sidecar’s password fires while the
two containers fed from local.forms_admin_env_vars are not determined.
The flat encryption and immutability rules (TF033 to TF037)
Section titled “The flat encryption and immutability rules (TF033 to TF037)”TF033 to TF037 are the Terraform counterparts of the aws lexicon’s WAW021, WAW025, WAW026, WAW028 and WAW054. Each reads one or two scalar attributes, and each was a perfect rename in the fidelity probe. That is the hazard. A WAW rule reads a template chant synthesized, where an absent property is false. A Terraform configuration someone else wrote is three-valued: an attribute is present, absent and insecure, or absent and set by a provider or AWS default the configuration never mentions. The probe found that WAW026, bridged, reports every correctly encrypted SQS queue.
So each rule below answers, for its own resource, what absence means, cited to
the provider schema and docs (hashicorp/aws v6.67.0) and to the AWS default.
The answers differ:
| Rule | Resource and attribute | Absent means |
|---|---|---|
| TF033 | aws_db_instance.storage_encrypted | insecure, reported |
| TF033 | aws_rds_cluster.storage_encrypted, Aurora | not determined |
| TF033 | aws_rds_cluster.storage_encrypted, Multi-AZ DB cluster | insecure, reported |
| TF034 | aws_sns_topic.kms_master_key_id | insecure, reported |
| TF035 | aws_sqs_queue.sqs_managed_sse_enabled / kms_master_key_id | secure, silent |
| TF036 | aws_ebs_volume.encrypted | not determined |
| TF037 | aws_ecr_repository.image_tag_mutability | insecure, reported |
All five share the not-determined convention TF030 to TF032 use. A finding
needs a literal that proves it. When the value a rule needs is a reference, a
template, a conditional or a function call, the rule reports one info
diagnostic starting Not determined: that names the attribute and why, and
never a warning or an error. A key argument that names an attribute of a
managed resource or data source (aws_kms_key.sns.arn) is read as set,
because the provider computes a key’s id or ARN and it is never empty; a
var.kms_key can be null and is not determined.
TF033: an RDS DB instance or cluster does not encrypt its storage
Section titled “TF033: an RDS DB instance or cluster does not encrypt its storage”An aws_db_instance or aws_rds_cluster whose storage_encrypted is false,
or absent where the default is false. Severity error, as WAW021.
Why it matters. The storage, its automated backups, snapshots and read replicas are all unencrypted, and encryption cannot be turned on later in place: the database has to be restored from an encrypted copy of a snapshot, which means a cut-over.
The fix. Set storage_encrypted = true, and kms_key_id for a customer
managed key. For an existing database, copy a snapshot with encryption on,
restore from the copy and cut over.
What absence means. It depends on the resource and on how the database is created.
aws_db_instance: insecure. storage_encrypted is optional and not computed,
and the provider docs say “The default is false if not specified.” The plain
create path and the s3_import path send that false to AWS. The replica
(replicate_source_db), snapshot (snapshot_identifier) and point-in-time
(restore_to_point_in_time) paths never send storage_encrypted at all: the
instance takes its encryption from the source, or for a cross-region replica
from kms_key_id. For those TF033 reports not determined unless
storage_encrypted = true is written. A literal snapshot_identifier = "" is
not a source, because the provider reads it with GetOk.
aws_rds_cluster running Aurora (engine aurora-mysql or
aurora-postgresql, or absent, which provider v5 defaulted to aurora): not
determined. storage_encrypted is optional and computed, and AWS has
encrypted every new Aurora cluster at rest
since 2026-02-16 with an AWS-owned key, whatever StorageEncrypted says. The
API then reports StorageEncrypted: false with StorageEncryptionType: sse-rds. A cluster created before that date with encryption off is
unencrypted, and nothing in the configuration says when the cluster was
created. Most Aurora clusters in an existing estate predate the change, so
TF033 does not read absence (or an explicit false) as secure. It reports not
determined and suggests writing storage_encrypted = true.
engine_mode = "serverless" (Serverless v1) was always encrypted and is
silent.
aws_rds_cluster running a Multi-AZ DB cluster (engine mysql or
postgres): insecure. The provider docs say the default is false, and the
Aurora change does not cover these. A cluster restored or replicated from a
source (snapshot_identifier, restore_to_point_in_time,
replication_source_identifier, global_cluster_identifier) is not
determined.
Not checked: aws_rds_cluster_instance, whose storage_encrypted is
computed-only and never appears in a configuration; aws_rds_global_cluster;
and whether the key is customer managed.
Prior art. tfsec’s
aws-rds-encrypt-instance-storage-data
and
aws-rds-encrypt-cluster-storage-data,
their successors in trivy-checks
(instance,
cluster),
checkov’s
CKV_AWS_16
and
CKV_AWS_96,
and KICS’s
DB Instance Storage Not Encrypted.
All overlap: each reports an absent storage_encrypted on every cluster and
every restored instance, where TF033 answers not determined.
resource "aws_db_instance" "default" {
name = var.dbname engine = "mysql" option_group_name = aws_db_option_group.default.name parameter_group_name = aws_db_parameter_group.default.name db_subnet_group_name = aws_db_subnet_group.default.name vpc_security_group_ids = ["${aws_security_group.default.id}"]
identifier = "rds-${local.resource_prefix.value}" engine_version = "8.0" # Latest major version instance_class = "db.t3.micro" allocated_storage = "20" username = "admin" password = var.password apply_immediately = true multi_az = false backup_retention_period = 0 storage_encrypted = false skip_final_snapshot = true monitoring_interval = 0 publicly_accessible = trueFrom fixtures/TF033/positive.tf (the first twenty-one lines of the block),
vendored from
bridgecrewio/terragoat
(Apache-2.0), a deliberately vulnerable estate.
TF034: an SNS topic has no server-side encryption
Section titled “TF034: an SNS topic has no server-side encryption”An aws_sns_topic with no kms_master_key_id, or with kms_master_key_id = "". Severity warning, as WAW025.
Why it matters. SNS keeps a message until every subscription has it. With no key, SNS stores those messages without server-side encryption, and the payloads of alarms, events and notifications often carry account ids, resource names and customer data.
The fix. Set kms_master_key_id to a customer managed KMS key, or to
"alias/aws/sns" for the AWS managed one. A customer managed key also lets a
key policy decide which publishers may use it.
What absence means. Insecure, and reported. kms_master_key_id is optional
with no default, and
SNS server-side encryption
is on only when the topic names a KMS key. Unlike SQS, no AWS default turns it
on for new topics. kms_master_key_id = null is the same as absent.
Prior art. tfsec’s
aws-sns-enable-topic-encryption,
trivy-checks’
aws-sns-enable-topic-encryption,
checkov’s
CKV_AWS_26
and KICS’s
SNS Topic Not Encrypted
are equivalent: they agree that absence is insecure.
resource "aws_sns_topic" "user_updates" { name = "user-updates-topic" kms_master_key_id = ""}From fixtures/TF034/positive.tf, vendored from KICS’s own test corpus
(Checkmarx/kics,
Apache-2.0). fixtures/TF034/positive-absent.tf, from
radian-software/riju
(MIT), is the same finding for a topic that never mentions a key.
TF035: an SQS queue turns its server-side encryption off
Section titled “TF035: an SQS queue turns its server-side encryption off”An aws_sqs_queue with sqs_managed_sse_enabled = false and no
kms_master_key_id. Severity warning, as WAW026.
Why it matters. Turning SSE-SQS off stores message bodies unencrypted, and
it is never an accident of omission: someone wrote false. It is often a
leftover from a queue created outside Terraform before SQS encrypted by
default, as in the fixture below.
The fix. Remove the line (new queues are encrypted by default), set it to
true, or set kms_master_key_id to "alias/aws/sqs" or a customer managed
key.
What absence means. Secure, and silent. This is the case that made the
bridge unshippable. sqs_managed_sse_enabled is optional and computed, the
provider docs say Terraform “will only perform drift detection of its value
when present in a configuration”, and since October 2022
SQS encrypts every new queue with SSE-SQS by default
when it is created over HTTPS, which the provider always uses. A queue that sets
neither attribute is encrypted. A queue created before that date and never
changed may not be; the configuration cannot show its age, and TF035 does not
guess. A kms_master_key_id from a variable with sqs_managed_sse_enabled
unset is silent too: whether the variable is a key or null, the queue is
encrypted.
Not determined: sqs_managed_sse_enabled from an expression with no key, and
sqs_managed_sse_enabled = false beside a key from a variable.
Prior art. tfsec’s
aws-sqs-enable-queue-encryption,
trivy-checks’
aws-sqs-enable-queue-encryption,
checkov’s
CKV_AWS_27
and KICS’s
SQS With SSE Disabled
overlap: all four report a queue that sets neither attribute, which TF035
reads as encrypted.
resource "aws_sqs_queue" "notify_internal_tasks_queue" { provider = aws.core_services name = "${var.celery_queue_prefix}notify-internal-tasks" # This queue was created outside of terraform and has this value set to false in staging and production. sqs_managed_sse_enabled = false max_message_size = var.sqs_max_message_size # This queue was created outside of terraform and has this value set to default in staging and production. visibility_timeout_seconds = var.sqs_visibility_timeout_default}From fixtures/TF035/positive.tf, vendored from
cds-snc/notification-terraform
(MIT). fixtures/TF035/negative-absent.tf is a queue from the same file that
sets neither attribute, and TF035 is silent on it.
TF036: an EBS volume does not ask for encryption
Section titled “TF036: an EBS volume does not ask for encryption”An aws_ebs_volume with encrypted = false. Severity warning, as WAW028.
Why it matters. An unencrypted volume leaves its data, its snapshots and every volume restored from those snapshots unencrypted.
The fix. Set encrypted = true, and kms_key_id for a customer managed
key, or remove the line and rely on the Region’s EBS encryption by default,
made explicit as below.
What absence means. Not determined. encrypted is optional, computed and
forces replacement. Whether a volume created without it is encrypted depends
on the Region’s
EBS encryption by default,
which is account state the parse cannot see, and a volume built from
snapshot_id also follows its snapshot. TF036 reports one info diagnostic.
One configuration does settle absence: the volume depends_on an
aws_ebs_encryption_by_default in the same module whose enabled is absent
(the provider default is true) or true, with the same provider and
region. The depends_on matters. Without it Terraform may create the volume
first, and encryption by default “has no effect on existing EBS volumes”.
encrypted = false is a finding even though the account default can override
it, because the literal is wrong either way. With encryption by default off,
the volume is unencrypted. With it on, AWS encrypts the volume anyway (“you
cannot disable it for individual volumes”), the volume stops matching the
configuration, and since encrypted forces replacement Terraform plans to
replace the volume on every apply. TerraGoat’s source records this in a
comment: #encrypted = false # Setting this causes the volume to be recreated on apply.
Prior art. trivy-checks’
aws-ec2-enable-volume-encryption,
checkov’s
CKV_AWS_3
and KICS’s
EBS Volume Encryption Disabled
overlap: they report an unset encrypted as unencrypted. tfsec’s page for the
same check exists, but its frozen rule index no longer lists it, so it is not
credited.
resource "aws_ebs_volume" "vol2" { availability_zone = "us-west-2a" size = 40 tags { Name = "HelloWorld" } encrypted = false}From fixtures/TF036/positive.tf, vendored from config-lint’s example files
(stelligent/config-lint,
MIT).
TF037: an ECR repository lets an image tag be repointed
Section titled “TF037: an ECR repository lets an image tag be repointed”An aws_ecr_repository whose image_tag_mutability is MUTABLE,
MUTABLE_WITH_EXCLUSION, or absent. Severity error, as WAW054.
Why it matters. With mutable tags, anyone who can push can move prod or
v1.4.2 to different image content after it was deployed, scanned or signed.
The next task that pulls the tag runs something nobody reviewed, and the
deployment record still names the old tag.
The fix. Set image_tag_mutability = "IMMUTABLE" and push a new tag for
every build. If a workflow needs a few moving tags (latest, stage), use
IMMUTABLE_WITH_EXCLUSION and name them in
image_tag_mutability_exclusion_filter blocks; every other tag stays
immutable.
What absence means. Insecure, and reported. image_tag_mutability is
optional with Default: MUTABLE in the provider schema.
Provider v6 accepts four values, two of them added for the
exclusion filters AWS shipped in 2025.
IMMUTABLE_WITH_EXCLUSION is silent: the excluded tags are a written-down
exception. A filter that is nothing but * matches every tag and is reported,
and a filter from an expression or a dynamic block is not determined.
MUTABLE_WITH_EXCLUSION keeps every tag mutable except the ones its filters
name, and is reported.
Prior art. tfsec’s
aws-ecr-enforce-immutable-repository,
trivy-checks’
aws-ecr-enforce-immutable-repository,
checkov’s
CKV_AWS_51
and KICS’s
ECR Image Tag Not Immutable
overlap: they agree on absence, and none of them reads
IMMUTABLE_WITH_EXCLUSION, which they report and TF037 passes.
resource aws_ecr_repository "repository" { name = "${local.resource_prefix.value}-repository" image_tag_mutability = "MUTABLE"From fixtures/TF037/positive.tf (the first three lines of the block),
vendored from
bridgecrewio/terragoat
(Apache-2.0).
TF038: an OCI module source names no tag or digest, or a mutable tag
Section titled “TF038: an OCI module source names no tag or digest, or a mutable tag”A module block whose source is an OpenTofu oci:// address with no tag
and no digest, or with a mutable tag such as latest. Severity warning.
Why it matters. OpenTofu resolves an oci:// source with no tag to the
latest tag, so an unpinned source and ?tag=latest are the same thing. A
push to the registry changes what the root provisions with no diff to
review. A digest cannot move. An exact version tag is read as pinned, but a
registry can still repoint it, which is why this is a warning and why the
message prefers a digest.
The fix. Pin the source with ?digest=sha256:... or an exact version tag
(?tag=1.4.0). The rule also reads the container-image spellings
oci://host/repo:1.4.0 and oci://host/repo@sha256:.... A colon in the host
is a registry port, never a tag. A source that names a digest passes whatever
tag sits beside it. The tags read as mutable are latest, main, master,
develop, dev, stable, edge and nightly.
Prior art. No tool in the survey covers oci:// module sources. The
OpenTofu module sources page
documents the tag and digest query arguments and the latest default.
The suffix spellings are not documented there and are not verified against
OpenTofu’s parser.
module "network" { source = "oci://registry.example.com/platform/network"}From fixtures/TF038/positive.tf.
TF039: a registry module version is a range
Section titled “TF039: a registry module version is a range”A registry module block whose version is a range (~> 1.4, >= 1.4,
>= 1.0, < 2.0) rather than an exact version (1.4.0 or = 1.4.0).
Severity warning, and off by default.
Why it matters. TF004 passes any version, so a range passes it. A
release inside the range changes what the root provisions with no diff to
review, and a reader of a change cannot say which module release it picked
up. Ranges are common and often intended, so this is for projects that pin
modules.
The fix. Pin an exact version: version = "1.4.0".
Off by default. TF039 is a report-only rule, so the recommended preset
leaves it out. Turn it on with lint.presets: { terraform: "all" }, or for
this rule alone with a lint.rules entry ({ TF039: "warning" }), which
keeps it under any preset. It is a separate rule rather than an option on
TF004 because a post-synth check takes no options, while a preset already
carries the on/off choice per rule id. A module with no version is TF004’s
finding and is skipped here.
Prior art. tflint’s terraform_module_version has an exact style that
rejects a range. It is not credited in the lineage file because that mapping
was not checked against the rule’s source.
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"}From fixtures/TF039/positive.tf.
TF040: a root has no committed .terraform.lock.hcl
Section titled “TF040: a root has no committed .terraform.lock.hcl”A root module that uses a provider (a provider block, or a resource or
data block whose type implies one) with no .terraform.lock.hcl beside its
.tf files. Severity warning, and off by default.
Why it matters. The lock file records the provider versions and hashes
terraform init selected. Without it every init resolves the
required_providers constraints again, so a provider release changes a plan
with no diff. None of TF001 to TF029 checks for it.
The fix. Run terraform init in the root and commit the file it writes.
How “committed” is known. The rule tests for the file’s presence next to
the root’s .tf files in the source tree being parsed. It does not ask git
whether the file is tracked, so a lock file that exists but is gitignored
counts as present. The check runs where the root’s directory is known: a
build over terraform.roots, and chant audit over a local path. A fetched
repository and inline source carry no directory, and the rule reports nothing
for them. A root that implies no provider, and a live choudoufu root, are
skipped.
Off by default. TF040 is a report-only rule, since many estates run
init in CI and never commit the file. Enable it as described under TF039.
chant audit runs every rule, so TF040 is also marked to stay out of the
audit unless the project’s lint.rules names it. The audit reads lint.rules
from chant.config.ts or chant.config.json without running the config, and
a lint.rules entry for TF040 turns it on in both a build and an audit.
Prior art. No tool in the survey covers this; see the lineage file.
resource "aws_s3_bucket" "b" { bucket = "x"}A root whose directory holds only this file triggers the rule. The test builds
that directory in tf040.test.ts.
TF041: a Terragrunt dependency lets mock_outputs stand in for apply
Section titled “TF041: a Terragrunt dependency lets mock_outputs stand in for apply”A dependency block that sets mock_outputs and either sets no
mock_outputs_allowed_terraform_commands or lists apply in it. Severity
warning. The rule reads terragrunt.hcl and root.hcl, in a build over
terraform.roots and in chant audit.
Why it matters. Terragrunt returns the mocks when the upstream unit has no
outputs yet and the running command is allowed. A missing list and an empty
list both mean every command (shouldReturnMockOutputs in Terragrunt
v1.1.6). An apply that runs before the upstream unit is applied then writes
the mock values to real state and reports success.
The fix. Set mock_outputs_allowed_terraform_commands = ["validate", "plan"]. A list that is an expression, such as local.mock_commands, is not
read and passes.
Prior art. No tool in the survey covers this; see the lineage file.
dependency "vpc" { config_path = "../vpc"
mock_outputs = { vpc_id = "vpc-00000000" }}From fixtures/TF041/positive/terragrunt.hcl. positive-apply lists apply.
TF042: a Terragrunt dependency sets skip_outputs with mock_outputs
Section titled “TF042: a Terragrunt dependency sets skip_outputs with mock_outputs”A dependency block with skip_outputs = true and mock_outputs. Severity
warning.
Why it matters. With skip_outputs = true Terragrunt never reads the
upstream unit’s outputs, so the mocks are the only values the dependency can
return. Every plan and apply they are allowed for runs on mock data, however
far the upstream unit has been applied. The pair is sometimes intended, to
break a cycle; the values are then configuration.
The fix. Drop skip_outputs, or pass the values as inputs where a
reader of the unit can see them. A skip_outputs that is not a literal true
is not read.
Prior art. No tool in the survey covers this; see the lineage file.
dependency "vpc" { config_path = "../vpc" skip_outputs = true
mock_outputs = { vpc_id = "vpc-00000000" } mock_outputs_allowed_terraform_commands = ["validate", "plan"]}From fixtures/TF042/positive/terragrunt.hcl.
TF043: a Terragrunt terraform.source names no version
Section titled “TF043: a Terragrunt terraform.source names no version”The source of a unit’s terraform block is a git address with no ?ref=, a
tfr:// address with no ?version=, or an oci:// address with no tag and
no digest. Severity warning.
Why it matters. A git source with no ref follows the default branch, a
tfr:// source follows the registry’s latest release, and an oci:// source
pulls latest. A push or a release changes the unit with no diff to review.
TF005 and TF038 cover module blocks; a unit has no module block.
The fix. Add ?ref= with a tag or commit SHA, ?version= with an exact
version, or ?digest=sha256:... (or ?tag=) for oci://. A local path and a
source built from an expression (${get_repo_root()}/modules/vpc) are not
read. The rule asks whether a version is named, not whether it is mutable, so
?ref=main passes.
Prior art. No tool in the survey covers this; TF005 and TF038 are the
same check on module blocks.
terraform { source = "git::https://github.com/example/modules.git//vpc"}From fixtures/TF043/positive-git/terragrunt.hcl. positive-tfr and
positive-oci cover the other two schemes.
TF044: a Terragrunt config keeps state in a local backend
Section titled “TF044: a Terragrunt config keeps state in a local backend”A remote_state block with backend = "local", or a generate block whose
contents declare backend "local". Severity warning.
Why it matters. State on the runner’s disk is gone when the job ends, a later job cannot read what an earlier one applied, and a git-range run, which checks out each side in a temporary directory, never sees it.
The fix. Use a shared backend such as S3 or GCS. A throwaway sandbox can
keep a local backend with # chant-ignore TF044 above the block.
Prior art. No tool in the survey covers this; see the lineage file.
remote_state { backend = "local"
config = { path = "${get_parent_terragrunt_dir()}/.state/${path_relative_to_include()}/terraform.tfstate" }}From fixtures/TF044/positive/terragrunt.hcl. positive-generate writes the
backend from a generate block.
TF045: no Terragrunt config sets terragrunt_version_constraint
Section titled “TF045: no Terragrunt config sets terragrunt_version_constraint”None of the terragrunt.hcl and root.hcl files in the scan sets
terragrunt_version_constraint. Severity warning, and off by default.
Why it matters. The attribute makes Terragrunt refuse a release outside the range. Without it each machine and CI image runs whatever it installed, and a behaviour change between releases reaches a plan with no diff.
The fix. Set it once in the root config every unit includes:
terragrunt_version_constraint = ">= 1.1.0".
What the rule can know. It reports once per scan, anchored on root.hcl
when there is one, and only when the scan holds a config that includes no
other file. A scan made only of units that include a parent it did not read
(one unit directory passed alone) is not determined, since the parent may set
the attribute. A file with another name, passed to Terragrunt with
--config, is not read.
Off by default. TF045 is a report-only rule, since many repos pin
Terragrunt in the toolchain instead. Enable it with lint.presets: { terraform: "all" } or a lint.rules entry.
Prior art. No tool in the survey covers this; see the lineage file.
remote_state { backend = "s3" # ...}From fixtures/TF045/positive/root.hcl, which sets no constraint. The fixture
directory also holds a unit that includes it.