Part 3 — Infrastructure as Code and the deployment pipeline
The pipeline that builds and ships your software has, by design, the credentials to change production. That makes it one of the highest-value targets you own. This Part secures the code that defines infrastructure, the CI/CD system that runs it, and the integrity of what comes out the other end.
Chapter 11 — IaC security
In one sentence
Infrastructure as Code makes every cloud misconfiguration a reviewable, testable, gate-able diff — but only if you scan the plan, protect the state, pin the modules, and detect drift.
Where we are
You know cloud misconfigurations (Part 2). IaC (Terraform, Pulumi, CloudFormation, CDK, Bicep) is how you prevent them at review time instead of finding them in production. It also introduces its own risks: the state file is a secrets store, modules are a supply chain, and reality drifts from code.
How it works
Scan the plan, not just the code.
Static scan of .tf files catches obvious issues but misses
anything computed, from variables, or
from modules.
Scan the PLAN (terraform plan
-out=tfplan; terraform show
-json tfplan > plan.json) sees the ACTUAL resource attributes
that will be created -> far fewer
false negatives. Do this in CI.
Scanners: Checkov, tfsec (now in Trivy), KICS, Terrascan, Snyk IaC,
`trivy config`, cfn-nag / cfn-guard / cfn-lint (CFN),
Regula. Run 2 (they have different rule coverage), fail the
build on HIGH+ with a documented, time-boxed exception file.
Policy as code — encode YOUR rules, not just the scanner's defaults.
OPA/Rego + conftest policy tests against plan.json / k8s manifests.
Sentinel Terraform Cloud/Enterprise native policy.
CloudFormation Guard (cfn-guard) rules for CFN/CDK output.
Checkov custom policies Python/YAML.
Rules worth writing:
- no security group with 0.0.0.0/0 on 22/3389/5432/6379/9200/27017
- every S3 bucket: BlockPublicAcls, encryption with a customer CMK,
versioning
- every RDS: storage_encrypted, no public access, deletion protection,
backup retention >= N
- IMDSv2 required on every launch template / instance
- no IAM policy with Action "*" or "iam:*" on Resource "*"
- no iam:PassRole without a resource scope + PassedToService condition
- CloudTrail multi-region + log file validation on
- all resources tagged owner/env/data-classification
- only approved regions
These become PR-blocking checks -> the misconfig never merges.
Protect the state.
Terraform state (.tfstate) contains EVERY resource attribute IN
PLAINTEXT -- including RDS master passwords, generated secrets, private
keys, IAM policy documents. It is a credentials file.
[ ] NEVER commit state to git. Add *.tfstate* to .gitignore; scan for
it (gitleaks).
[ ] Remote backend: S3 with SSE-KMS (customer CMK) + versioning +
Block Public Access + a bucket policy restricting to the CI role
and a break-glass role; DynamoDB table for state locking. Or
Terraform Cloud/Enterprise / Spacelift / env0 with encryption and
RBAC.
[ ] Tight IAM on the state bucket -- reading it = reading every secret
in the estate.
[ ] `terraform output` marks sensitive values, but `terraform show` /
`state pull` still expose them -- treat those commands as
privileged.
[ ] Prefer generating secrets OUTSIDE Terraform (in Vault / Secrets
Manager with a rotation Lambda) so they never enter state at all.
Pin the supply chain.
[ ] Provider lockfile (.terraform.lock.hcl) committed, with checksums
for all platforms your CI + devs use (`terraform providers lock
-platform=...`).
[ ] Module sources pinned: a version constraint for registry modules;
a commit SHA (not a branch) for `git::` sources. No `ref=main`.
[ ] Prefer a PRIVATE module registry with reviewed, versioned modules
(the "paved road" for infra -- Ch 13).
[ ] Vet third-party modules like any dependency (G1 Ch 47): what
providers do they configure, what data sources do they read, do
they have `local-exec` provisioners running scripts?
[ ] `local-exec` / `remote-exec` provisioners run arbitrary commands on
the CI runner -- treat a module that uses them as code execution.
Detect drift.
Reality diverges from code: a click-ops hotfix, an auto-scaling change,
an attacker's backdoor (a new IAM policy, an opened SG, a new admission
webhook).
[ ] Scheduled `terraform plan` (detects drift as a non-empty plan) with
an alert on unexpected changes.
[ ] AWS Config rules + conformance packs; CloudFormation drift
detection; GCP Config Validator; Azure Policy compliance.
[ ] Continuous cloud posture scan (Prowler/ScoutSuite/Steampipe,
cloud-native Security Hub/SCC/Defender) -- the backstop for
whatever IaC doesn't manage.
[ ] Alert specifically on security-relevant drift: SG opened, policy
broadened, logging disabled, encryption removed, public access
enabled.
Worked example
A conftest policy that blocks a real mistake in review.
# policy/no_public_ssh.rego
package main
deny[msg] {
rc := input.resource_changes[_]
rc.type == "aws_security_group_rule"
v := rc.change.after
v.type == "ingress"
v.from_port <= 22
v.to_port >= 22
cidr := v.cidr_blocks[_]
cidr == "0.0.0.0/0"
msg := sprintf("SG rule %s opens SSH to the world", [rc.address])
}
# in CI:
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test --policy policy/ plan.json # exit 1 -> PR blocked
A developer adds `cidr_blocks = ["0.0.0.0/0"]` to a bastion SG "just
for testing". The PR check fails with the message above. They add their
office CIDR instead, or route via SSM Session Manager (no SG needed).
The misconfiguration never reaches an account.
Practice (60 min)
- Take a Terraform config (yours, or TerraGoat / CfnGoat). Run
checkovandtrivy configagainst the source; then run againstplan.json. Compare findings — note what only the plan scan caught. - Write three conftest/OPA policies encoding rules your org cares about
(no public admin ports, mandatory encryption with a customer CMK, no
iam:PassRolewithResource "*"). Make them fail on a bad plan, pass on a fixed one. - Move a local state file to an S3 + DynamoDB backend with SSE-KMS and a
restrictive bucket policy. Then
terraform state pull | grep -i password— see for yourself what's in there. Rotate anything real. - Introduce drift by hand (open an SG in the console). Run
terraform planand a Prowler scan — confirm both surface it. - Add all of the above (plan scan + conftest + drift check) as CI jobs.
Common mistakes
- Scanning
.tffiles only. Computed values, module outputs, and variable-driven config are invisible until you scan the plan. - State in git, or in an unencrypted/over-shared bucket. It's a plaintext dump of every secret in the estate.
- Secrets generated inside Terraform. They land in state. Generate them in a secrets manager instead.
- Modules pinned to a branch.
ref=mainis a mutable dependency — the same problem as@v3for GitHub Actions (G1 Ch 47). - No drift detection. IaC that isn't reconciled is documentation, and an attacker's changes hide in the gap.
local-execprovisioners treated as config. They're arbitrary command execution on your CI runner.- Exceptions with no expiry. A
# checkov:skipwith no ticket and no date is permanent debt.
Check yourself
- Why scan the Terraform plan rather than the
.tfsource? - What sensitive material is in a Terraform state file, and where should state live?
- How do you pin the IaC supply chain (providers and modules)?
- What is configuration drift and name two ways to detect it.
- Why is a module that uses
local-execa code-execution concern? - Give three policy-as-code rules you'd make PR-blocking.
(Answers: Appendix G.)
Further reading
- Tools: Checkov, tfsec/Trivy, KICS, Terrascan,
conftest/OPA, CloudFormation Guard, Sentinel docs;terraform-compliance(BDD-style). - Guidance: "Terraform state best practices" (HashiCorp); the CIS benchmarks the scanners implement; OWASP IaC Security guidance.
- Practice: Bridgecrew TerraGoat / CfnGoat / CDKGoat;
cloud-custodianfor policy-driven remediation. - Talk: "Policy as Code" talks from HashiConf / KubeCon; the OPA/Rego playground and "Styra Academy".