TerraformXX · Supply Chain: Providers and ModulesProduction Terraform
A Supply Chain Incident
What you'll learn
- Recognise the early signals of a third-party supply-chain compromise
- Execute the containment checklist: disable the source, freeze consumers, audit the blast radius
- Audit which stacks are pinned to the compromised version using the lock file and the configuration
- Roll back to the previous good version with `terraform init -upgrade` and a deliberate plan
- Conduct a blameless post-mortem that identifies the controls that should have caught the incident earlier
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A third-party supply-chain incident is not a “what if”.
It is a “when”. A maintainer’s account is phished; a
build server is compromised; a malicious version is
published under a legitimate tag; consumers download it
on the next terraform init; the malicious code runs
with the consumer’s credentials.
The question is not whether you have a runbook. The question is whether the runbook is rehearsed. This lesson walks through a realistic incident: the detection, the containment, the audit, the rollback, and the post-mortem. The lock file is the hero of the story.
The incident
A team has been using third-party/cloud-storage/aws
at version 2.3.0 for nine months. The module is pinned
to ~> 2.0. The lock file records the SHA-256 of the
module’s tarball. The team’s CI runs terraform plan
on every PR.
Friday, 14:00 UTC. A security advisory lands on the Registry:
GHSA-xxxx-yyyy-zzzz: third-party/cloud-storage/aws 2.4.0
contains a backdoored `local-exec` provisioner that
exfiltrates AWS credentials to attacker-controlled
endpoint.
Patched in: 2.4.1
Affected: 2.4.0 (published 2026-08-13 12:32 UTC)
Severity: Critical
The team’s first instinct is to check whether anyone is
on 2.4.0. The pin is ~> 2.0, which allows 2.x.x
including 2.4.0. Any consumer who has run
terraform init -upgrade since 12:32 UTC is on the
malicious version.
Detection
The first signal is usually not the advisory. It is one of three:
- A CVE advisory from the Registry or the maintainer. The Registry publishes security advisories; the maintainer may email consumers; GitHub Dependabot may alert.
- An unexpected plan. A consumer’s
terraform planshows newnull_resourceorlocal-execblocks that were not in the previous version. The PR review catches it. - A cloud-provider alert. AWS GuardDuty flags an outbound API call from a CI runner to an unknown IP. The cloud-provider’s anomaly detection is the last-line signal.
For this team, the first signal is the GitHub Dependabot
alert at 14:15 UTC, fifteen minutes after the malicious
version was published. Dependabot scans the manifest of
the team’s module references and flags 2.4.0 as
vulnerable.
Dependabot alert
----------------
Package: third-party/cloud-storage/aws
Vulnerable: 2.4.0
Patched: 2.4.1
Severity: Critical
Manifest: infra/production/main.tf
The alert is the trigger. The runbook begins.
Containment
The first goal is to stop the bleeding. Three actions in the first 30 minutes:
-
Disable the source. Add a
replaceblock or a pin to the known-good version (= 2.3.0) in every consumer. This stops newinit -upgraderuns from picking up2.4.0.# Pin to known-good; reject 2.4.0 entirely. module "storage" { source = "third-party/cloud-storage/aws" version = "= 2.3.0" } -
Freeze the registry pipeline. If the team operates a private registry mirror, set the mirror to reject
2.4.0and to refuse to fetch new metadata for the namespace. This stops any out-of-band consumer from pulling the malicious version.# Severity: CONFIGURATION terralistctl delete-version \ third-party/cloud-storage/aws 2.4.0 -
Alert the consumers. Post in the team’s chat: “We have a supply-chain incident. Do not run
terraform init -upgradeuntil further notice. Pin to= 2.3.0if you are not already.”
The discipline is to contain before investigating. The audit can take hours; the containment must take minutes.
The audit
The audit answers: who is on 2.4.0?
For providers, the audit is terraform providers and
the lock file. For modules, the audit is the
configuration’s version = attribute plus the team’s
inventory of root modules.
# Severity: READ-ONLY
# Find every root module that references the affected module.
grep -rl 'source.*third-party/cloud-storage/aws' \
--include='*.tf' /var/infra/
/var/infra/production-eu-west-2/main.tf
/var/infra/production-us-east-1/main.tf
/var/infra/staging-eu-west-2/main.tf
/var/infra/staging-us-east-1/main.tf
/var/infra/dev-eu-west-2/main.tf
For each file, check the version =:
# Severity: READ-ONLY
for f in $(grep -rl 'source.*third-party/cloud-storage/aws' \
--include='*.tf' /var/infra/); do
echo "=== $f ==="
grep -A 2 'module.*storage' "$f" | grep version
done
=== /var/infra/production-eu-west-2/main.tf ===
version = "~> 2.0"
=== /var/infra/production-us-east-1/main.tf ===
version = "~> 2.0"
=== /var/infra/staging-eu-west-2/main.tf ===
version = "= 2.3.0"
=== /var/infra/staging-us-east-1/main.tf ===
version = "~> 2.0"
=== /var/infra/dev-eu-west-2/main.tf ===
version = ">= 2.0.0"
The audit’s output:
| Stack | Pin | On 2.4.0? |
|---|---|---|
| production-eu-west-2 | ~> 2.0 | Likely yes (if init upgraded since 12:32) |
| production-us-east-1 | ~> 2.0 | Likely yes |
| staging-eu-west-2 | = 2.3.0 | No |
| staging-us-east-1 | ~> 2.0 | Likely yes |
| dev-eu-west-2 | >= 2.0.0 | Likely yes |
For the ~> 2.0 consumers, the audit must check the
lock file or the module cache to confirm which version
was last downloaded. If the consumer’s CI has run
init -upgrade since 12:32 UTC, the consumer is on
2.4.0.
# Severity: READ-ONLY
# In each consumer's working directory:
terraform providers
The output lists the resolved versions of providers,
not modules. For modules, the audit relies on the
.terraform/modules/ directory or on the CI logs.
For this team, the audit confirms:
production-eu-west-2: last CI run 13:50 UTC. Plan output references module2.4.0. Compromised.production-us-east-1: last CI run 13:55 UTC. Same. Compromised.staging-eu-west-2: pinned to= 2.3.0. Safe.staging-us-east-1: last CI run 13:30 UTC, before the malicious publish. Safe (but the pin would have allowed it; would have been compromised on next upgrade).dev-eu-west-2: last CI run 14:05 UTC. Plan output references2.4.0. Compromised.
Three production stacks are on 2.4.0.
The blast radius of the lock file
The lock file is what saved the team from a worse incident. Without the lock file:
- Every
initwould resolve the latest matching version. - All five stacks would be on
2.4.0after the next CI run. - The blast radius would be the entire estate.
With the lock file:
- The lock file pinned each consumer to the version it last audited.
- Three stacks upgraded to
2.4.0after a deliberate or automatedinit -upgrade. - Two stacks did not upgrade; they remain on
2.3.0.
The lock file is the blast-radius limit. The team’s
discipline of committing the lock file and running
init (not init -upgrade) on every PR is the
control that kept the incident scoped.
The rollback
For each compromised stack, the rollback is:
-
Pin to the known-good version. Edit the configuration to pin to
= 2.3.0(or to>= 2.4.1if the team has reviewed the patch and trusts it). -
Force the module re-download. Run
terraform init -upgradeto refresh the module cache against the new pin.# Severity: CONFIGURATION terraform init -upgrade -
Plan to confirm no unwanted changes. The plan should show no resource changes; if the malicious module created any resources (an IAM role, an S3 bucket, a Lambda function), the plan will show their destruction.
# Severity: READ-ONLY terraform plan -out=tfplan -
Apply deliberately. The apply removes the malicious resources. The audit trail records the rollback.
# Severity: SERVICE-IMPACT terraform apply tfplan
For this team:
production-eu-west-2: pinned to= 2.3.0, init upgraded, plan shows destruction of one malicious IAM role, apply executed, audit logged.production-us-east-1: same.dev-eu-west-2: same.
Total time from alert to rollback completion: 90 minutes. The CI/CD pipeline’s saved-plan pattern meant the rollback plan was reviewed by a second engineer before apply. The audit log records every action.
Credential rotation
The malicious version exfiltrated AWS credentials. The rollback stops the exfiltration; it does not undo the exposure. The next step is credential rotation.
For this team:
- Rotate the OIDC trust. The CI’s OIDC trust policy is reviewed; the trust is preserved (it is not compromised); the trust’s session duration is shortened if possible.
- Rotate the IAM role’s trust policy. The
terraform-planandterraform-applyroles’ trust policies are reviewed for any changes the exfiltration may have made. - Audit CloudTrail. The audit looks for any API
call made by a CI session that was not expected:
CreateAccessKey,PutBucketPolicy,AssumeRoleto an unknown account.
# Severity: READ-ONLY
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=terraform-apply-* \
--start-time 2026-08-13T12:00:00Z \
--end-time 2026-08-13T15:00:00Z \
--query 'Events[?EventName==`CreateAccessKey` || EventName==`PutBucketPolicy`]'
If the audit shows unexpected API calls, the investigation expands. If the audit is clean, the incident is contained.
The post-mortem
The post-mortem is blameless. The questions:
- Detection. Was the alert timely? GitHub Dependabot alerted within 15 minutes. The team’s detection capability is good.
- Containment. Was the team able to stop the bleeding quickly? The team contained in 30 minutes. The capability is good.
- Blast radius. How many stacks were affected? Three of five. The blast-radius limit (the pin + the lock file) held; the limit was tighter than expected.
- What could have reduced the blast radius further?
- An exact pin (
= 2.3.0) on every production stack would have kept all three production stacks on2.3.0. The team adopted~> 2.0for agility. The trade-off was wrong for this module; the action is to switch to= 2.3.0and review on upgrade. - A pre-merge scan (
tfsec,checkov) at PR time would have flagged thelocal-execprovisioner in2.4.0before the CI ever ran. The team did not havecheckovin the PR pipeline; the action is to add it. - A registry mirror with version pinning would have
refused to serve
2.4.0to any consumer. The team does not have a mirror; the action is to evaluate one.
- An exact pin (
The post-mortem is the next iteration of the runbook. The controls are tightened; the next incident is smaller.
Production failure modes
-
No lock file committed. Every consumer’s
initresolves the latest version. All five stacks download2.4.0on the next run. The blast radius is the entire estate. The fix is to commit the lock file and to require it in CI. -
Range pin too permissive. A consumer pins to
>= 2.0.0. The malicious2.4.0is in range. The fix is to pin to a bounded range (~> 2.0) and to review upgrades on a PR. -
No audit inventory. The team does not know which root modules reference the affected module. The audit takes hours; the rollback is delayed. The fix is to maintain an inventory of every third-party module in use.
-
Rollback without a plan. The team edits the pin and runs
apply. The apply destroys legitimate resources because the malicious version had created a competing resource. The fix is to plan first; review the plan; apply deliberately. -
No credential rotation. The team rolls back the module but does not rotate the IAM role’s trust policy. The attacker retains access via the exfiltrated credentials. The fix is to rotate credentials as part of the rollback.
-
No blameless post-mortem. The team moves on after the rollback. The controls that should have caught the incident are not improved. The next incident is the same shape. The fix is to institutionalise the post-mortem.
Security implications
- The lock file is the audit trail of what was last downloaded. The pin is the constraint on what is allowed. Together they define the supply-chain boundary.
- The OIDC credentials are short-lived. Even if exfiltrated, the blast radius is the session lifetime. The mitigation is automatic.
- The audit log (CloudTrail + GitHub Actions) is the forensic record. Without it, the investigation is a guess.
Performance implications
- The containment is manual. It takes 30 minutes for a small team; hours for a large one. The mitigation is the pre-merge scan; the cost is a few minutes per PR.
- The rollback is a normal apply. The cost is the apply itself; the malicious resources are destroyed in the same apply.
What comes next
The supply-chain lessons complete the production Terraform curriculum. The next module covers operational disciplines: monitoring Terraform at scale, incident response, and the long-term maintenance of an IaC estate.
Verification
- Every root module in the estate is inventoried with its third-party module references and pins.
- Every consumer’s CI runs
terraform init(notinit -upgrade) on every PR. - The dependency lock file is committed for every root module that uses providers.
- The team’s incident runbook includes the supply-chain containment checklist above.
- The team’s quarterly review includes a re-evaluation of every third-party module’s maintenance signal.
Knowledge check · 7 questions
Q1. What is the first action when a third-party module is reported compromised?
Q2. How does the audit determine which stacks are on the compromised version?
Q3. The dependency lock file (`.terraform.lock.hcl`) records provider hashes, while module versions are pinned by the `version =` attribute in the configuration.
Q4. What is the role of the lock file in a supply-chain incident?
Q5. Which of the following are part of the rollback procedure? (Select all that apply.)
Q6. Why does the rollback include credential rotation?
Q7. A malicious version is published at 12:32 UTC. A consumer's CI last ran at 09:00 UTC, before the publish. What does the consumer's next CI run see?
Passing score: 75%. Answers are checked in this browser.