Terraform / HCL
Infrastructure as code.
What this is
Infrastructure as code. The BLOCKER tier is almost entirely things that are expensive or irreversible to get wrong in a plan: secrets committed in .tf/.tfvars, public exposure without a justification comment, a resource renamed without a moved {} block (plan shows destroy+create on live data), IAM scoped too broadly, unpinned provider/module sources. The HIGH tier is mostly configuration hygiene that prevents surprise diffs and gaps: count where for_each is correct, missing backup retention or encryption on new stateful resources.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile infra-terraform # or name one
One profile pulls these rules in: infra-terraform.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.tf**/*.tfvars**/*.hcl
How to use it — 26 rules
Nothing to run. Once your profile includes Terraform / HCL, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
terraform/secrets-in-config | BLOCKER | Secrets in `.tf`/`.tfvars` — passwords, keys, tokens, connection strings. Reference Secrets Manager / SSM / vault data sources; mark variables `sensitive = true`. |
terraform/public-exposure | BLOCKER | Public exposure: `0.0.0.0/0` ingress on non-public ports, public S3 bucket ACLs/policies, `publicly_accessible = true` on databases — without an explicit justification comment and approval. |
terraform/missing-moved-block | BLOCKER | Resource rename/move without `moved {}` block — plan shows destroy+create; data loss on stateful resources. |
terraform/overly-broad-iam | BLOCKER | Overly broad IAM: `Action: "*"`, `Resource: "*"`, or `iam:PassRole` unscoped — least privilege, scoped ARNs. |
terraform/prevent-destroy-removed | BLOCKER | `prevent_destroy` removed or lifecycle guards deleted on stateful resources (DBs, buckets, tables) without stated intent. |
terraform/unpinned-source | BLOCKER | Provider or module source unpinned — exact version or bounded constraint (`~>`), never floating latest. |
terraform/authoritative-iam-resource | BLOCKER | Authoritative IAM resource used for an additive grant. `aws_iam_policy_attachment` owns every attachment of that policy across the account, and `google_{project,folder,organization}_iam_policy` replaces the whole IAM policy — either one silently revokes access granted anywhere else, and the Google ones can lock operators out of the project. Use `aws_iam_role_policy_attachment` / `google_project_iam_member`. Not when the resource is the documented sole owner of that policy, with a comment saying so. |
terraform/count-vs-for-each | HIGH | `count` on identity-bearing resources where `for_each` is correct — index shifts destroy/recreate siblings. |
terraform/hardcoded-env-values | HIGH | Cross-environment values hardcoded in modules (account IDs, ARNs, CIDRs) — pass as variables. |
terraform/missing-required-tags | HIGH | Missing required tags on new resources (owner, cost-centre, environment — per org tagging policy). |
terraform/wide-open-egress | HIGH | Wide-open egress added without comment. |
terraform/ambiguous-data-source | HIGH | Data sources fetching by name/tag that may match multiple resources — brittle; use IDs where stable. |
terraform/missing-backup-retention | HIGH | New stateful resource without backup/retention configuration (RDS retention, S3 versioning, DynamoDB PITR). |
terraform/missing-encryption | HIGH | Encryption not explicit on new data stores (at-rest KMS, in-transit enforced). |
terraform/empty-list-equality | HIGH | Comparing a collection to `[]`. `var.x == []` is always false and `var.x != []` always true — `[]` is an empty tuple and `==` needs identical types — so `count = var.subnets == [] ? 0 : 1` creates the resource whether or not the list is empty. Use `length(var.x) == 0`. |
terraform/ignore-changes-all | HIGH | `lifecycle { ignore_changes = all }`. Terraform stops proposing any update to the resource, so a later change to its arguments merges, plans as "No changes" and is never applied. List only the attributes something outside Terraform genuinely owns, with a comment naming that owner. |
terraform/sg-inline-and-standalone-rules | HIGH | A security group managed both inline and by standalone rule resources. Inline `ingress`/`egress` blocks re-assert their set on every apply and delete rules added by `aws_vpc_security_group_*_rule`/`aws_security_group_rule` for the same group, so the plan never converges and connectivity flaps. Pick one; new code uses the standalone rule resources only. |
terraform/depends-on-instead-of-reference | HIGH | `depends_on` where an attribute reference would express the dependency — above all on a `module`. It makes everything downstream "known after apply", so every plan shows updates or replacements and reviewers learn to ignore the diff. Reference the upstream attribute; keep `depends_on` for a genuinely hidden dependency, with a comment naming it. |
terraform/remote-state-coupling | HIGH | A new `terraform_remote_state` consumer. It needs read access to the producer's whole state snapshot, secrets included, and breaks when the producer renames an output or moves its backend key. Publish the value deliberately (SSM parameter, `tfe_outputs`, a data source looked up by id) and read that. |
terraform/provider-in-shared-module | HIGH | A `provider` block inside a reusable child module. Callers can no longer use `count`/`for_each`/`depends_on` on it, and removing the module call removes the provider its resources need to be destroyed, leaving orphaned infrastructure. Declare `required_providers` (with `configuration_aliases` if needed) and pass providers in from the root. |
terraform/ignored-input-variable | HIGH | A module variable declared but never referenced. The caller sets it, Terraform accepts it, and nothing changes — `enable_versioning = true` that no resource reads leaves versioning off with no error. Wire it in or delete it. |
terraform/map-duplicate-keys | HIGH | A duplicate key in a map or object literal. Terraform keeps the last value without an error, so `tags = { Environment = "prod", …, Environment = "dev" }` labels production `dev` and environment-scoped policies act on the wrong set. Remove the duplicate. |
terraform/variable-metadata | SUGGESTION | Variables missing `description` and `type`. |
terraform/output-metadata | SUGGESTION | Outputs missing `description`; sensitive outputs not marked `sensitive`. |
terraform/prefer-jsonencode | SUGGESTION | Inline JSON policies over `jsonencode()`/data-source policy documents. |
terraform/repeated-literal | SUGGESTION | Repeated literal in 3+ places — promote to local. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [terraform/secrets-in-config]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 26 rules here, 7 BLOCKER, 15 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/terraform.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# Terraform / HCL Review Rules
## BLOCKER — request changes
- `terraform/secrets-in-config` — **Secrets in `.tf`/`.tfvars`** — passwords, keys, tokens, connection strings. Reference Secrets Manager / SSM / vault data sources; mark variables `sensitive = true`.
- `terraform/public-exposure` — **Public exposure**: `0.0.0.0/0` ingress on non-public ports, public S3 bucket ACLs/policies, `publicly_accessible = true` on databases — without an explicit justification comment and approval.
- `terraform/missing-moved-block` — **Resource rename/move without `moved {}` block** — plan shows destroy+create; data loss on stateful resources.
- `terraform/overly-broad-iam` — **Overly broad IAM**: `Action: "*"`, `Resource: "*"`, or `iam:PassRole` unscoped — least privilege, scoped ARNs.
- `terraform/prevent-destroy-removed` — **`prevent_destroy` removed** or lifecycle guards deleted on stateful resources (DBs, buckets, tables) without stated intent.
- `terraform/unpinned-source` — **Provider or module source unpinned** — exact version or bounded constraint (`~>`), never floating latest.
- `terraform/authoritative-iam-resource` — **Authoritative IAM resource used for an additive grant.**
`aws_iam_policy_attachment` owns every attachment of that policy across the account, and
`google_{project,folder,organization}_iam_policy` replaces the whole IAM policy — either one silently revokes
access granted anywhere else, and the Google ones can lock operators out of the project. Use
`aws_iam_role_policy_attachment` / `google_project_iam_member`. Not when the resource is the documented sole
owner of that policy, with a comment saying so.
## HIGH
- `terraform/count-vs-for-each` — `count` on identity-bearing resources where `for_each` is correct — index shifts destroy/recreate siblings.
- `terraform/hardcoded-env-values` — Cross-environment values hardcoded in modules (account IDs, ARNs, CIDRs) — pass as variables.
- `terraform/missing-required-tags` — Missing required tags on new resources (owner, cost-centre, environment — per org tagging policy).
- `terraform/wide-open-egress` — Wide-open egress added without comment.
- `terraform/ambiguous-data-source` — Data sources fetching by name/tag that may match multiple resources — brittle; use IDs where stable.
- `terraform/missing-backup-retention` — New stateful resource without backup/retention configuration (RDS retention, S3 versioning, DynamoDB PITR).
- `terraform/missing-encryption` — Encryption not explicit on new data stores (at-rest KMS, in-transit enforced).
- `terraform/empty-list-equality` — **Comparing a collection to `[]`.** `var.x == []` is always false and `var.x
!= []` always true — `[]` is an empty tuple and `==` needs identical types — so `count = var.subnets == [] ? 0
: 1` creates the resource whether or not the list is empty. Use `length(var.x) == 0`.
- `terraform/ignore-changes-all` — **`lifecycle { ignore_changes = all }`.** Terraform stops proposing any
update to the resource, so a later change to its arguments merges, plans as "No changes" and is never applied.
List only the attributes something outside Terraform genuinely owns, with a comment naming that owner.
- `terraform/sg-inline-and-standalone-rules` — **A security group managed both inline and by standalone rule
resources.** Inline `ingress`/`egress` blocks re-assert their set on every apply and delete rules added by
`aws_vpc_security_group_*_rule`/`aws_security_group_rule` for the same group, so the plan never converges and
connectivity flaps. Pick one; new code uses the standalone rule resources only.
- `terraform/depends-on-instead-of-reference` — **`depends_on` where an attribute reference would express the
dependency** — above all on a `module`. It makes everything downstream "known after apply", so every plan
shows updates or replacements and reviewers learn to ignore the diff. Reference the upstream attribute; keep
`depends_on` for a genuinely hidden dependency, with a comment naming it.
- `terraform/remote-state-coupling` — **A new `terraform_remote_state` consumer.** It needs read access to the
producer's whole state snapshot, secrets included, and breaks when the producer renames an output or moves its
backend key. Publish the value deliberately (SSM parameter, `tfe_outputs`, a data source looked up by id) and
read that.
- `terraform/provider-in-shared-module` — **A `provider` block inside a reusable child module.** Callers can no
longer use `count`/`for_each`/`depends_on` on it, and removing the module call removes the provider its
resources need to be destroyed, leaving orphaned infrastructure. Declare `required_providers` (with
`configuration_aliases` if needed) and pass providers in from the root.
- `terraform/ignored-input-variable` — **A module variable declared but never referenced.** The caller sets it,
Terraform accepts it, and nothing changes — `enable_versioning = true` that no resource reads leaves
versioning off with no error. Wire it in or delete it.
- `terraform/map-duplicate-keys` — **A duplicate key in a map or object literal.** Terraform keeps the last
value without an error, so `tags = { Environment = "prod", …, Environment = "dev" }` labels production `dev`
and environment-scoped policies act on the wrong set. Remove the duplicate.
## SUGGESTION
- `terraform/variable-metadata` — Variables missing `description` and `type`.
- `terraform/output-metadata` — Outputs missing `description`; sensitive outputs not marked `sensitive`.
- `terraform/prefer-jsonencode` — Inline JSON policies over `jsonencode()`/data-source policy documents.
- `terraform/repeated-literal` — Repeated literal in 3+ places — promote to local.