IaC Drift Detection: Terraform, OpenTofu, and the Config-Crawl Problem

Sources

In a mature platform organization, the Git repository is the singular source of truth. But in production, that truth is often a lie. Infrastructure Drift is the divergence between the desired state defined in your IaC (Terraform/OpenTofu) and the actual state of your cloud provider.

For a Security Architect, drift isn't just a "sync issue"—it's an unapproved change. In a regulated environment (SOC2, PCI-DSS), drift is a compliance failure. If a developer opens a security group port manually during a 3 AM incident and forgets to codify it, you have a permanent hole in your perimeter that your security scanners might miss, but your IaC definitely does.

The Mechanics: How Drift is Actually Detected

Terraform and OpenTofu do not "watch" your infrastructure in real-time. They rely on a pull-based refresh model. When you run a plan, the core engine calls the Read function of every provider resource in the state file.

# The industry-standard primitive for CI-based drift detection terraform plan -refresh-only -detailed-exitcode

The -detailed-exitcode flag is the only way to automate this without fragile regex parsing of stdout:

Observe vs. Act: The Critical Distinction

A common junior mistake is scheduling a terraform apply -auto-approve to \"fix drift.\" This is dangerous. If a resource was deleted out-of-band (e.g., a production DB instance), a blind apply will attempt to recreate it, potentially causing catastrophic data loss or downtime. Detection must be separated from remediation.

The Config-Crawl Problem

Config-Crawl is the slow, entropic decay of your IaC. It starts with a \"temporary\" manual change to fix a production outage, followed by a \"quick\" tag update via the console. Over six months, your state file becomes a ghost of the actual infrastructure.

This creates two primary security risks:

  1. Shadow Infrastructure: Resources created manually that aren't in state. Since the core only reads what's in the state file, these resources are invisible to drift detection.
  2. The False Sense of Security: Your security auditors look at your Git repo and see a hardened perimeter, while the actual cloud environment is wide open.

The Drift Cycle Architecture

\n\n+----------------+ +------------------+ +------------------+ \n| Git Repo | ----> | Provisioning | ----> | Cloud Provider | \n| (Desired State)| | (Terraform/Tofu) | | (Actual State) | \n+----------------+ +------------------+ +------------------+ \n ^ | \n | | \n | [ DRIFT ] <------+ \n | (Manual Change) \n | \n +-----------+-----------+ \n | Drift Pipeline | \n | 1. Observe (Plan) | \n | 2. Classify (JSON) | \n | 3. Remediate (Target) | \n +------------------------+ \n\n

Classification Framework

Not all drift is created equal. Treating a tag change the same as an IAM policy change leads to \"alert fatigue\" and operational toil.

\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
BucketExamplesRisk LevelRemediation Strategy
CosmeticTags, Descriptions, Resource NamesLowAuto-remediate via targeted apply
Security-RelevantSG Rules, IAM Policies, Public Access BlocksCriticalImmediate Page → Human Review
StructuralResource Deleted, Instance Type changedHighHuman Review (check for data loss)
Provider NoiseComputed Timestamps, Provider IDsNoneSuppress via ignore_changes
\n

Implementation Guide

1. The Scheduled Drift Detector (GitHub Actions)

name: Infrastructure Drift Detection on: schedule: - cron: '0 0 * * *' # Daily at midnight workflow_dispatch: jobs: detect-drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: Terraform Init run: terraform init - name: Check for Drift id: drift run: | terraform plan -refresh-only -detailed-exitcode -out=drift.tfplan echo "exit_code=$?" >> $GITHUB_OUTPUT - name: Handle Drift if: steps.drift.outputs.exit_code == '2' run: | terraform show -json drift.tfplan > drift.json # Trigger alert (e.g., Slack or PagerDuty) curl -X POST -H 'Content-type: application/json' \\ --data '{\"text\":\"🚨 Infrastructure Drift Detected in Production!\"}' \\ $

2. Filtering for Security-Critical Drift

To avoid alert fatigue, use a script to parse the JSON plan and only page for security resources.

#!/bin/bash # Parse tfplan.json and alert only on critical resource types PLAN_JSON=\"drift.json\" # Critical resources that should NEVER drift CRITICAL_TYPES=(\"aws_security_group_rule\" \"aws_iam_policy\" \"aws_iam_role\" \"aws_s3_bucket_public_access_block\") # Extract changed resources CHANGES=$(jq -r '.resource_changes[] | select(.change.actions[] == \"update\") | .type' $PLAN_JSON) for TYPE in $CHANGES; do for CRITICAL in \"${CRITICAL_TYPES[@]}\"; do if [[ \"$TYPE\" == \"$CRITICAL\" ]]; then echo \"CRITICAL DRIFT: $TYPE has been modified out-of-band!\" # Exit with error to fail the CI build and trigger alerts exit 1 fi done done echo \"No critical drift detected.\" exit 0

3. Enforcing the Baseline with OPA

Prevent drift from being codified by using Open Policy Agent (OPA) to ensure that any \"reconciliation\" doesn't accidentally open the perimeter.

package terraform.drift import future.keywords.if # Deny any change that opens a security group to the world deny[msg] { resource := input.resource_changes[_] resource.type == \"aws_security_group_rule\" resource.change.after.cidr_blocks[_] == \"0.0.0.0/0\" msg := sprintf(\"Drift reconciliation attempt violates security baseline: %s opens to 0.0.0.0/0\", [resource.address]) }

Production Realities

Who Should Skip This

References & Further Reading