TerraformXX · Supply Chain: Providers and ModulesProduction Terraform
CI/CD Dependencies and Supply Chain
What you'll learn
- Identify the runner image, the secret store, and the artifact store as supply-chain components in the CI/CD pipeline
- Pin a runner image to an immutable digest so every job runs on the same bytes
- Configure short-lived OIDC credentials so the runner never holds a long-lived cloud secret
- Build a defence-in-depth pipeline with PR-time checks, plan-time gates, and apply-time approvals
- Configure the audit trail: who ran what, when, against which plan, with which approval
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
The CI/CD pipeline is the gate between a developer’s laptop and a production apply. The gate is software. The gate has dependencies. The gate runs third-party binaries. The gate holds credentials. The gate is, in its own right, a supply chain.
When the pipeline is compromised, every protection upstream of it is bypassed. A signed plan file is unsigned at the runner. A least-privilege IAM role is assumed by a malicious job. A pinned Terraform version is overridden by a poisoned image.
The pipeline is the supply chain. The pipeline must be hardened.
The pipeline as supply chain
PR opened
|
v
[runner image: hashicorp/setup-terraform@v2 on ubuntu-24.04]
|
v
[secrets: OIDC -> AWS IAM role]
|
v
[terraform init]: downloads providers from registry
|
v
[terraform plan -out=tfplan]: writes plan binary
|
v
[artifact store]: tfplan uploaded, retention 30d
|
v
[human approval]: gate on the GitHub environment
|
v
[terraform apply tfplan]: executes the saved plan
Each arrow is a supply-chain hop. Each hop is a place where a malicious actor can substitute bytes, redirect credentials, or alter the plan.
The runner image
The runner image is the operating system on which every job runs. The image contains:
- The operating system (Ubuntu 24.04, Debian 12, etc.)
- The Terraform CLI (installed by
hashicorp/setup-terraform) - The provider binaries (downloaded by
terraform init) - The shell utilities (
bash,jq,git) - Any pre-installed CA certificates or package mirrors
If the image is wrong, everything inside it is wrong. A
runner image with a poisoned curl will exfiltrate the
plan file. A runner image with a backdoored git will
push the state file to a malicious remote. A runner image
whose CA bundle has been altered will MITM the OIDC
exchange.
The discipline: pin the image to an immutable digest.
jobs:
plan:
runs-on: ubuntu-24.04
container:
image: ghcr.io/acme/terraform-runner@sha256:abc123...
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
The @sha256:abc123... is the digest. The image is
identified by its content hash, not by a mutable tag.
A tag can be re-pointed; a digest cannot. The next job
that runs on the same digest runs on the same bytes.
# Severity: READ-ONLY
docker images --digests ghcr.io/acme/terraform-runner
REPOSITORY TAG DIGEST IMAGE ID
ghcr.io/acme/terraform-runner latest sha256:abc123... 7 days ago 1234abcd
A digest pull is reproducible. A tag pull is not.
The secret store
The runner needs credentials to talk to the cloud. The credentials are:
- Long-lived access keys. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY. Stored in the CI’s secret manager. Reused across jobs. The blast radius is the key’s policy; the lifetime is the rotation cadence.
- OIDC-federated short-lived credentials. The runner assumes an IAM role at job start. The credentials are minted by the cloud; they expire in minutes; they are never stored.
The OIDC discipline is the production default.
jobs:
plan:
runs-on: ubuntu-24.04
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
role-session-name: terraform-plan-${{ github.run_id }}
aws-region: eu-west-2
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -out=tfplan -input=false -no-color
The id-token: write permission is required for OIDC.
The aws-actions/configure-aws-credentials step assumes
the role. The session name is unique per run. The
credentials expire within an hour.
The discipline:
- No long-lived keys. If a long-lived key is in the secret store, it is a vulnerability. The key has the role’s policy attached; the key is rotated manually; the key is reused. OIDC eliminates all three.
- Role per pipeline stage. The plan role is read-only. The apply role is read-write. A compromise of the plan stage does not give the apply role.
- Role session name as audit trail. The session name
includes
${{ github.run_id }}. CloudTrail logs the session name. The audit can map every API call back to a specific PR.
The artifact store
The plan file travels from the plan job to the apply job as a CI artifact. The artifact store is a third supply chain component:
- The store must be private. A misapplied bucket policy makes the plan file publicly downloadable.
- The retention must be short. A 365-day retention is a 365-day window during which the plan file contains every sensitive variable the apply will use.
- The download must be authenticated. Anyone with workflow access can pull the artifact.
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.env }}
path: tfplan
retention-days: 30
30 days is a common minimum for SOX-regulated environments; longer for regulated industries; shorter for fast-moving internal projects. The retention period is the credential lifetime.
Defence in depth
A single layer of defence is a single point of failure. The production pipeline has at least four layers.
PR time Plan time Apply time
-------- --------- ----------
fmt / lint terraform validate human approval
tflint terraform plan (GitHub env gate)
tfsec saved plan uploaded terraform apply
checkov policy check saved plan audit
trivial secrets OIDC assume plan role saved plan retained
Each layer is a gate. A failure at any layer stops the pipeline.
- PR time. Static analysis only.
tflint,tfsec,checkov,pre-commit-terraform. The PR cannot merge until the checks pass. - Plan time. Real validation.
terraform validateagainst the configuration;terraform planagainst the cloud; the saved plan uploaded as an artifact; a policy check (OPA, Sentinel) against the plan. - Apply time. Human approval. The GitHub
environmentrequires a reviewer; the reviewer reads the plan summary; the apply job runs against the saved plan file.
The three layers are independent. A failure of the PR checks does not bypass the plan checks. A failure of the plan checks does not bypass the apply approval. The defence is in the depth.
The audit trail
The audit trail answers four questions:
- Who proposed the change?
- Who approved the change?
- What did the change do?
- What was the actual outcome?
The artefacts:
- The PR. The author, the reviewers, the comments, the diff. Permanent in GitHub.
- The plan. The plan file (binary) and the JSON plan. Retained per the artifact store’s policy.
- The approval. The GitHub environment’s required reviewers. Permanent in GitHub.
- The apply log. The Terraform output, the exit code, the API calls. Retained per the runner’s policy.
- The cloud audit. CloudTrail (or equivalent). The IAM session name maps to the GitHub run ID. The audit can reconstruct every API call from the PR.
# Severity: READ-ONLY
# Map a CloudTrail event to a GitHub run.
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=terraform-plan-1234567890
{
"Events": [
{
"EventName": "CreateBucket",
"UserIdentity": {
"SessionContext": {
"SessionIssuer": {
"UserName": "terraform-plan-1234567890"
}
}
},
"EventTime": "2026-08-13T11:42:00Z"
}
]
}
The session name terraform-plan-1234567890 maps to
the GitHub run 1234567890. The audit answers all four
questions.
The threat model
Three realistic threats and how the layers respond.
Threat 1: A malicious dependency is added to a
module. The PR checks run tfsec and checkov. The
dependency is detected. The PR cannot merge. The
defence is the PR layer.
Threat 2: A developer’s laptop is compromised; a
malicious plan is proposed. The plan time runs
terraform validate against the cloud. The plan shows
unexpected resource changes. The policy check rejects
the plan. The defence is the plan layer.
Threat 3: The runner image is compromised; the apply executes a different plan than the one approved. The apply uses the saved plan file, which is downloaded as an artifact. The artifact is hashed; the apply verifies the hash. The defence is the apply layer plus the artifact integrity check.
Production failure modes
-
Mutable runner tag. The pipeline uses
ubuntu-24.04(a tag, not a digest). The tag is re-pointed at a new image. The next job runs on the new bytes. A supply-chain attack that compromises the new image bypasses every layer above. The fix is to pin to a digest and to refresh the digest on a controlled cadence. -
Long-lived cloud keys in CI. The runner holds AWS_ACCESS_KEY_ID in its secret store. The keys have the apply role’s policy. A leaked key is full compromise. The fix is OIDC and per-stage roles.
-
Public artifact bucket. The plan file is uploaded to a bucket with a misapplied policy. The plan file is publicly downloadable. A plan file that includes
var.db_passwordis a credential disclosure. The fix is to audit the bucket policy on the same cadence as the IAM policy. -
-auto-approvein CI. The apply job runs with-auto-approve. The apply computes its own plan. The reviewer approved a different plan. The fix is to pass the saved plan file as the positional argument. -
No apply-time gate. The apply job runs without a GitHub
environmentrequirement. Any merged PR can apply. The fix is to require a manual approval on theproductionenvironment. -
Audit log retention too short. The CI logs are retained for 7 days. A security review 30 days later cannot answer “who ran this apply”. The fix is to retain CI logs for at least the duration of the regulatory requirement (typically 1 year for SOX, longer for HIPAA).
Security implications
- The runner is the most privileged component in the pipeline. A compromised runner is a credential disclosure and a state exfiltration.
- The secret store is the credential boundary. Long-lived keys are the antipattern; OIDC is the default.
- The artifact store is the credential store for the apply. A plan file is a credential if the configuration contains sensitive variables.
- The audit trail is the post-incident control. Without it, a security review is a guess.
Performance implications
- OIDC minting is a network round trip per job. The cost is seconds; the benefit is no key rotation.
- Plan artifact upload is a network round trip per plan stage. The cost is the plan file size; the benefit is the audit trail.
- The defence-in-depth checks (
tfsec,checkov, OPA) add minutes to the pipeline. The cost is a longer PR cycle; the benefit is catching the bug before production.
What comes next
The next lesson walks through a concrete supply-chain incident: a maintainer’s account is compromised, a malicious version is published, and the team has 30 minutes to contain it.
Verification
- The runner image is pinned to a digest, not a tag.
- The CI uses OIDC, not long-lived cloud keys.
- The plan role is read-only; the apply role is read-write.
- The apply job uses a saved plan file as the
positional argument; no
-auto-approve. - The apply job requires a manual approval on the
GitHub
productionenvironment. - The artifact store retention is configured and audited quarterly.
Knowledge check · 7 questions
Q1. Why should the runner image be pinned to a digest, not a tag?
Q2. What is the production default for cloud credentials in the CI/CD pipeline?
Q3. A plan file uploaded as a CI artifact is, by default, public.
Q4. Why does the apply job use a saved plan file as the positional argument instead of `-auto-approve`?
Q5. Which of the following belong in a defence-in-depth pipeline? (Select all that apply.)
Q6. What is the role of the role-session-name in OIDC-federated CI?
Q7. An engineer proposes a PR that adds a new `null_resource` with a `local-exec` provisioner that writes the state file to a public S3 bucket. The plan stage runs `terraform plan`. What catches this?
Passing score: 75%. Answers are checked in this browser.