
terraform plan -refresh-only tells you that something changed outside Terraform. It does not tell you whether that matters, who did it, or what to do. gcp-drift-triage does the next step. For every drifted resource it decides adopt, revert, investigate or ignore, attributes the change to a principal using Cloud Audit Logs, and emits the exact commands and code edits.
The pipeline
- Parse:
terraform show -jsongivesresource_drift. State and live values are diffed with set semantics for lists, and provider-computed noise (etags, update times) is stripped. - Classify with rules: loosening, tightening, cost, reliability, operational, benign or deleted, which gives category, severity and decision. Deterministic.
- Forensics: query Admin Activity audit logs for who changed it, when, and from which IP. Parallel and best effort.
- Attribute: a human loosening access by hand is escalated one level.
- Commands: revert emits
plan -target; adopt emits a code edit plus a refresh-only apply; deleted offers recreate orstate rm.
Then in parallel five analyst calls explain the risky ones and a drafter writes PR text for the adopt set, both validated against the plan and audit evidence. A rule-based judge sets the posture: alert, attention or clean.
The decision table
| Drift | Decision |
|---|---|
allUsers or new IAM member, 0.0.0.0/0 added, deletion_protection off | investigate (critical/high), revert command first |
| Stateful resource deleted in GCP | investigate (critical): recreate or state rm |
| Machine type, instance counts or disk size raised | investigate (medium) |
| Label added, safe flag restored, member removed | adopt, with the exact code edit |
| Image changed by a deploy | adopt (operational) |
| Autoscaler node count, etags, update times | ignore, suggest lifecycle.ignore_changes |
| Attribute no rule covers | investigate, never silently adopted |
Reading the screenshot
The sample "incident weekend" plan has 7 drifted resources and an ALERT posture. A firewall rule gained 0.0.0.0/0 in its source ranges, made by a human at 02:03 from an outside IP; a storage bucket IAM member changed from a named analyst to allUsers; a Cloud SQL instance had deletion_protection flipped to false and a ticket label added; a Pub/Sub topic is missing from GCP with no audit trail. Those four are investigate, with the revert command first and, below it, the code edit to use if the change was intended. Two are adopt: a Cloud Run image bumped from 1.4 to 1.5 by the CI deployer service account, and a cost-center label on a bucket. The GKE node pool's autoscaled node count (3 to 7) is ignored. The page also shows a drafted PR for the adopt set and one rejected model note: it mentioned a principal, analyst@acme.com, that was not in the audit evidence, so it was dropped.
Tested against real drift
I created a bucket, a Pub/Sub topic and an IAM binding with Terraform in a real project, then changed them by hand with gcloud: disabled bucket versioning, added a label, granted a service account read access and deleted the topic. The refresh-only plan reported all three resources, and the tool classified them correctly and attributed each change to the human who made it from real Admin Activity logs, both locally and on Cloud Run. The real run exposed three bugs that synthetic tests missed: reordered IAM member lists looked like new grants, the audit search used the Terraform name instead of the real resource name, and label drift hides under effective_labels. All are fixed with tests. The demo is reproducible from demo/main.tf.
Verification
| Check | Result |
|---|---|
| Offline tests | 50 pass: parsing and set semantics, every rule, actor escalation, hostile-model cases, audit provider, API, CLI |
| Cross-provider eval, 18 runs | 0 invariant violations: decisions, severities and attribution identical to rules-only |
| Real drift end to end | 3 of 3 drifted resources detected and attributed from real audit logs, locally and on Cloud Run |
| Deployed | Unauthenticated and openai provider 403; audit lookups only for the configured project |
In the model eval, gemini-2.5-flash and gpt-5-nano each had 12 notes kept and 2 rejected for naming a principal absent from the audit evidence; no PR text was rejected. Six runs cost about $0.005 on Gemini and $0.001 on gpt-5-nano.
Limits
It sees only what Terraform manages, so unmanaged resources are out of scope. Rules cover common GCP types and attributes; anything else is classified unknown and sent to investigate. Attribution takes the newest Admin Activity event whose resource name contains the real resource name, so a busy resource can be pinned on a later unrelated change; treat it as a strong lead, not proof. Data Access logs and anything older than the 7-day lookup are not searched. Scalar lists are compared as sets, so an order-sensitive attribute would be missed, and a model note can still paraphrase (one invented a display name from an email address), though it cannot change a decision. It was verified on three synthetic plans and one small real drift.
Try it yourself
git clone https://github.com/soodrajesh/gcp-drift-triage
cd gcp-drift-triage
.venv/bin/driftriage triage driftriage/samples/incident_weekend.json --llm none # exit 2 = alert
terraform plan -refresh-only -out=drift.plan && terraform show -json drift.plan > drift.json
.venv/bin/driftriage triage drift.json --audit-project my-project --llm vertex
📢 Have questions or feedback? Drop a comment below or connect with me on Twitter/X@spysood!