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 # or name one
One profile pulls these rules in: infra.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.tf**/*.tfvars**/*.hcl
How to use it — 17 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/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/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 17 rules here, 6 BLOCKER, 7 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.
## 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).
## 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.