Published
How to Turn Terraform Drift Detection Into Audit-Ready AWS Evidence
A practical pipeline for detecting Terraform drift in AWS, attributing changes, normalizing evidence, validating coverage, and retaining history.
Terraform drift detection is often treated as a yes-or-no check: run a plan, see changes, open a ticket. That is enough for operations, but weak as security or compliance evidence. A useful evidence pipeline has to show what population was checked, what Terraform believed before the check, what the provider reported from AWS, which resources changed, whether the change was expected, who or what caused it, how the result was validated, and whether the condition was later remediated or accepted.
The design goal is not to archive every raw plan forever. The goal is to produce a small, reproducible evidence record from authoritative inputs. A practical flow is: Terraform configuration and saved state -> refresh-only plan -> normalized drift records -> AWS Config and CloudTrail correlation -> validation -> retained evidence history. Each stage answers a different question and should fail visibly when it cannot produce a trustworthy answer.
Start with the distinction between configuration, state, and runtime
Terraform reconciles three different views of infrastructure: configuration declares intent, state records Terraform’s last known managed objects, and the provider reads the current remote system. HashiCorp documents refresh-only operations specifically for reconciling state with the real infrastructure without changing deployed resources. That makes refresh-only planning useful for drift detection, but the resulting plan still needs interpretation. A non-empty drift result means Terraform observed a difference between prior state and the refreshed remote object; it does not automatically tell you whether that difference violates policy or whether the change happened outside every approved workflow.
For an evidence job, preserve the identity of the configuration revision and workspace before running the check. Record the Git commit, Terraform version, provider lock-file hash, workspace or backend identifier, AWS account, Region scope, and collection timestamp. If the same drift result cannot later be tied back to the configuration and execution context that produced it, the artifact is difficult to validate.
Use refresh-only planning as the collector, not as the evidence format
The Terraform CLI supports refresh-only plans and detailed exit codes. With -detailed-exitcode, Terraform returns 0 for an empty diff, 1 for an error, and 2 for a successful plan that contains changes. That is useful for automation because the collector can distinguish ‘no drift’ from ‘collector failed’ without parsing human-readable output. Save the plan to a file, then convert it to machine-readable JSON for normalization.
terraform init -input=false
terraform plan \
-refresh-only \
-detailed-exitcode \
-out=tfplan
rc=$?
if [ "$rc" -eq 1 ]; then
echo "terraform collection failed" >&2
exit 1
fi
terraform show -json tfplan > tfplan.json
exit "$rc"
Do not use the raw JSON plan as the long-term evidence record. HashiCorp explicitly notes that terraform show -json can expose sensitive values in plain text. Treat the plan file and its JSON representation as sensitive intermediate material. Parse only the fields needed for the evidence claim, redact or omit values that are not necessary, and retain the normalized record in the evidence store. The raw plan can follow a shorter, access-controlled retention policy if operational troubleshooting requires it.
Normalize resource_drift into a stable evidence schema
Terraform’s JSON plan format includes resource_drift, which describes changes Terraform detected while comparing refreshed remote objects with prior state. The change representation includes resource addresses and change actions, while the plan also carries Terraform and format-version context. A collector should translate that provider-specific structure into a smaller schema that remains useful even when the reporting system changes.
{
"collector": "terraform-refresh-only",
"collected_at": "2026-10-19T12:00:00Z",
"repository_revision": "<git-sha>",
"terraform_version": "<version>",
"aws_account_id": "<account-id>",
"region": "us-east-1",
"workspace": "prod-network",
"resource_address": "module.vpc.aws_security_group.app",
"resource_type": "aws_security_group",
"actions": ["update"],
"classification": "unreviewed-drift",
"source_plan_sha256": "<digest>",
"validation_status": "pending"
}
The normalized record should avoid copying complete before and after objects unless the review needs them. Instead, capture the resource address, resource type, action set, selected changed attributes, and a digest or protected reference to the source artifact. This preserves provenance without turning the evidence database into a second Terraform state store.
Define the denominator before calculating drift coverage
A clean plan is meaningful only for the resources and workspaces that were actually evaluated. The evidence job therefore needs an expected population. For Terraform-managed infrastructure, that population can be defined from an inventory of production workspaces, repositories, account mappings, and required Regions. A successful run should state how many expected workspaces were checked and how many failed to produce a trustworthy result.
- Expected workspaces or root modules in scope.
- Expected AWS accounts and Regions for each workspace.
- Configuration revision and provider lock-file fingerprint.
- Collector status: clean, drift detected, execution error, authentication error, or incomplete.
- Managed resource count observed in the plan or state context.
- Drifted resource count and oldest unresolved drift record.
- Timestamp of the last successful collection for every expected workspace.
Keep ‘no drift’ separate from ‘not checked.’ An expired AWS session, provider initialization failure, locked state, unreachable backend, or unsupported provider response must not collapse into a green result. Evidence automation should fail closed when collection is incomplete.
Use AWS Config to validate runtime history independently
Terraform tells you that its managed view changed. AWS Config can provide an independent runtime history for supported AWS resource types. AWS Config records configuration items when recorded resources change and exposes historical configuration through GetResourceConfigHistory. For an important drift record, correlate the Terraform resource to its AWS resource identifier and retrieve configuration items around the drift window.
This second path helps answer whether AWS independently observed the same configuration transition and when it appeared. It also exposes a critical boundary: AWS Config can only validate resources and periods that were actually being recorded. Store recorder coverage, resource type support, account, Region, and the configuration-item capture time with the validation result. If Config was not recording the resource, the correct status is ‘independent validation unavailable,’ not ‘validated.‘
Use CloudTrail for change attribution, not for drift detection itself
CloudTrail event history provides recent management events by Region and supports lookup by attributes such as resource name, event name, event source, and user name. That makes it useful for attribution after a drift record is detected. Query a bounded time window around the AWS Config transition or the collector’s first observed drift, then preserve the matching CloudTrail event ID, event source, event name, principal identity reference, and event time.
Do not claim that failure to find a CloudTrail event proves no API change occurred. Event history is Region-scoped and covers the past 90 days of management events, and not every configuration fact maps cleanly to one lookup attribute. The attribution result should therefore have explicit states such as matched, multiple-candidates, not-found-within-window, outside-event-history-window, or lookup-error. That is much more useful than forcing every drift record into a named actor.
Separate detection, classification, and disposition
Not every drift item is a security incident. A resource can change because of an approved emergency action, an autoscaling behavior intentionally excluded from configuration, a service-managed attribute, a manual break-glass change, or an unauthorized modification. Detection should remain mechanical; classification should add context; disposition should record what the team decided to do.
- Detect: collect refresh-only plans for the complete expected workspace population.
- Normalize: convert resource_drift into a stable record without copying unnecessary secrets.
- Correlate: check AWS Config history and CloudTrail events where those sources apply.
- Classify: approved change, expected service-managed difference, policy exception, unexplained drift, or collector false positive.
- Remediate: revert runtime state, update Terraform configuration, import or remove ownership, or document an approved exception.
- Revalidate: rerun the collector and preserve the first subsequent clean result or the continuing exception state.
- Retain: keep enough history to show detection time, decision, remediation, and restoration of the expected state.
Metrics should reveal blind spots, not just count drift
Useful drift metrics need denominators and age. Track expected workspaces, successfully evaluated workspaces, workspaces with collection errors, resources with detected drift, unresolved drift age, independently validated drift records, and records lacking attribution. A single ‘drift count’ can improve while collection coverage quietly degrades, so coverage and collector health belong on the same dashboard.
For evidence retention, store each collection run as an immutable summary record instead of overwriting the latest state. That makes it possible to reconstruct when a resource first diverged, how long it remained unresolved, whether collection itself failed during that period, and which validation sources supported the final disposition.
Failure modes worth testing deliberately
- Terraform returns exit code 1 and the pipeline incorrectly records a clean run.
- A new production workspace is created but never added to the expected population.
- Provider credentials can read some resources but not the full managed scope.
- Raw terraform show -json artifacts are retained with sensitive values exposed.
- AWS Config validation is marked successful even though the resource type or Region was not being recorded.
- CloudTrail attribution silently searches the wrong Region or a window older than event history supports.
- Drift is detected and remediated, but no subsequent clean run is retained to prove closure.
- A service-managed or intentionally ignored attribute generates recurring noise until operators stop trusting the detector.
Test the collector with a controlled non-production change that should produce drift, verify that the normalized record is created, confirm that the independent AWS source shows the corresponding runtime change when applicable, and then restore the expected configuration through the normal workflow. The test should also verify the negative path: break collector credentials or backend access and confirm the run becomes incomplete rather than clean.
Where this fits in compliance engineering
This pattern can support configuration-management, continuous-monitoring, evidence-collection, and infrastructure-as-code implementation work. The useful artifact is not merely a Terraform plan. It is the chain of provenance showing scope, collection status, detected difference, independent runtime evidence where available, ownership, disposition, and revalidation. That can be mapped into an organization’s NIST, RMF, FISMA, or FedRAMP evidence model as appropriate.
A drift collector does not itself establish compliance, authorization, certification, or an ATO, and it does not replace independent assessment. Its value is narrower and more operational: it turns infrastructure divergence into a measurable, reviewable process with explicit coverage and failure states. That is the level at which Terraform becomes useful evidence rather than just a deployment tool.
Related resources
- NIST SP 800-53 Controls Field Kit
- FedRAMP 20x KSI Evidence Kit for AWS
- How to Validate AWS CloudTrail Coverage Across a Multi-Account Organization
Sources
- terraform plan command reference | Terraform | HashiCorp Developer
- terraform show command reference | Terraform | HashiCorp Developer
- JSON output format | Terraform | HashiCorp Developer
- Use refresh-only mode to sync Terraform state | Terraform | HashiCorp Developer
- GetResourceConfigHistory - AWS Config
- Working with CloudTrail event history - AWS CloudTrail
Continue reading
- FedRAMP 20x Evidence in AWS: From Snapshots to Persistent KSI Validation
How to design AWS evidence for FedRAMP 20x around persistent KSI verification, validation, configuration history, metrics over time, and reproducible collection.
- How to Validate AWS CloudTrail Coverage Across a Multi-Account Organization
A practical method for validating CloudTrail coverage across AWS Organizations, Regions, event selectors, delivery health, and log integrity.
- How to Diagnose Zabbix SNMP Polling Delays Without Adding More Pollers
A Zabbix 7.4 troubleshooting workflow for SNMP queue delays, asynchronous poller saturation, device timeouts, preprocessing, and safe capacity changes.
Written by
Tymur Chmeruk
Cloud Security & Infrastructure Engineer · Baltimore–Washington Metro · [email protected]