DEV Community

Adil Khan
Adil Khan

Posted on

# I built a CLI that catches dangerous Terraform changes before you apply them

Before every terraform apply I was doing the same thing. Read the plan output. Switch to the AWS console. Check security groups. Try to remember what depends on what.

Then a security group with port 22 open to 0.0.0.0/0 slipped through. Caught it fast, nothing broke — but I kept thinking about it. That whole review process was just me, manually, every time, hoping I didn't miss anything in 300 lines of text. That's not a process. That's vibes.

So I built IACGuard. This is how it works and what I got wrong along the way.


The gap in terraform plan

The output doesn't tell you what matters. A production database being replaced and a tag change look identical in the plan — same format, same indentation, same weight. You have to already know what's dangerous to spot it.

The second gap is pipelines. You can fail a PR on test failures or linting. You can't natively fail a PR because the plan replaces a production RDS instance. That gap means risky infrastructure changes get the same review as safe ones, which under time pressure is basically no review.


What IACGuard does

Reads a Terraform plan file, runs deterministic rules against every planned change, outputs a risk report.

iacguard plan results
─────────────────────────────────────────────────────────
Critical : 1  |  High : 0  |  Medium : 0  |  Low : 0
Resources analyzed : 5  |  Changes : 5
─────────────────────────────────────────────────────────

  CRITICAL  SG001  aws_vpc_security_group_ingress_rule.sg_inbound_ssh  [CREATE]
           Security group ingress rule 'sg_inbound_ssh' allows SSH (port 22)
           from the entire internet (0.0.0.0/0 or ::/0).

─────────────────────────────────────────────────────────
[iacguard] Rules checked   : 3
[iacguard] Drift check     : SKIPPED (use --region to enable)
─────────────────────────────────────────────────────────
Enter fullscreen mode Exit fullscreen mode

Exit code 0 = nothing critical. Exit code 1 = stop and look at this before applying.

pip install iacguard
Enter fullscreen mode Exit fullscreen mode

Here is the Architecture Below

iacguard architecture


The first version used AI for detection. That was wrong.

My original plan was to send the whole Terraform plan to Claude and let it decide what was risky.

plan.json → Claude API → risk scores + findings
Enter fullscreen mode Exit fullscreen mode

The problem is non-determinism. LLMs give different answers on different runs. That's fine for generating text. It's not fine when your tool blocks deployments. If iacguard exits with code 1 in a CI pipeline, that exit code has to mean the same thing every single time — not "Claude was in a cautious mood today."

So the architecture changed:

plan.json → parser → rule engine (deterministic) → findings → output
                                                            ↓
                                              AI explanation (--explain, optional)
Enter fullscreen mode Exit fullscreen mode

Rules decide what's risky. AI only explains what the rules already found. AI never runs in CI mode at all.


The parser

terraform show -json gives you a JSON file. The useful part is resource_changes — every resource being created, updated, or destroyed.

{
  "address": "aws_db_instance.primary",
  "mode": "managed",
  "type": "aws_db_instance",
  "change": {
    "actions": ["delete", "create"],
    "before": { "instance_class": "db.t3.medium" },
    "after":  { "instance_class": "db.t3.large" },
    "replacing": true
  }
}
Enter fullscreen mode Exit fullscreen mode

The non-obvious parts:

actions can be ["delete", "create"] — that's a replacement, not two separate operations. The parser normalizes everything to CREATE, UPDATE, DESTROY, or REPLACE before rules run.

Data sources (mode: "data") and no-ops get filtered out. They're reads, not changes.

change.before is null for creates. change.after is null for destroys. Rules have to handle both.

Resources inside modules have addresses like module.vpc.aws_security_group.bastion. Readable in output, full address preserved for accuracy.

I spent more time on the parser than anything else. If it reads something wrong, every rule downstream gets bad data.


The three rules

Each rule is a pure Python function. Takes a ResourceChange, returns a Finding or None. No shared state, independently testable.

RDS001 — database replacement

An RDS replacement deletes the database and recreates it. Anything written between delete and restore is gone. The rule fires on actions == ["delete", "create"] or change.replacing == True — both checked because real plans sometimes only set one.

class RDS001(RuleBase):
    rule_id  = "RDS001"
    severity = Severity.CRITICAL

    def check(self, change, all_changes):
        if change.resource_type not in {"aws_db_instance", "aws_rds_cluster"}:
            return None
        if change.action != Action.REPLACE and not change.replacing:
            return None
        return Finding(
            ...
            message=f"RDS instance '{change.name}' will be replaced — potential data loss.",
            recommendation="Ensure a snapshot exists before applying."
        )
Enter fullscreen mode Exit fullscreen mode

SG001 — SSH open to the internet

This one had a bug I only found by running it on my own infrastructure.

The rule originally covered aws_security_group and aws_security_group_rule. When I ran it against my real Terraform code — which has port 22 open to 0.0.0.0/0 — it caught nothing.

Why: my code uses aws_vpc_security_group_ingress_rule, a newer resource type from AWS provider v5+. Different structure. from_port, to_port, and cidr_ipv4 are top-level fields, not nested inside an ingress block.

I had test fixtures for the old types. Hadn't thought to write one for the new type. You don't know what you haven't tested until real infrastructure breaks it.

Fixed rule covers all three:

aws_security_group                   → ingress[] array
aws_security_group_rule              → type=ingress fields
aws_vpc_security_group_ingress_rule  → top-level from_port/to_port/cidr_ipv4
Enter fullscreen mode Exit fullscreen mode

Fires if from_port <= 22 <= to_port. A rule with from_port=0, to_port=65535 exposes SSH. IPv6 (::/0) also checked.

S3001 — missing public access block

Medium severity, not Critical. AWS accounts can have account-level Block Public Access settings that protect every bucket regardless of what Terraform configures. Flagging this Critical would false-positive on any account with account-level protection — which is a lot of accounts.

The output is:

S3 bucket 'assets' has no explicit block_public_access configuration.
Account-level settings may still protect this bucket.
Enter fullscreen mode Exit fullscreen mode

The engineer decides. The tool surfaces it without over-reacting.


CI mode

--ci flag: JSON to stdout, no color, just exit codes.

iacguard plan --plan plan.json --ci
Enter fullscreen mode Exit fullscreen mode

Exit 0 = clean. Exit 1 = critical finding. Exit 2 = tool error.

- name: IACGuard
  run: |
    pip install iacguard
    terraform show -json tfplan > plan.json
    iacguard plan --plan plan.json --ci
Enter fullscreen mode Exit fullscreen mode

PR blocks on exit 1. That's it.


Testing

Every rule has two tests minimum: a plan that triggers it and one that doesn't. All tests use real Terraform plan JSON files — no synthetic JSON built in test code. Synthetic fixtures pass even when the parser would fail on a real plan.

pytest tests/ -v
# 15 passed in 0.01s
Enter fullscreen mode Exit fullscreen mode

What I got wrong

The original design had eight subcommands, a React dashboard, Kubernetes scanning, a custom cost engine, AI-generated fix code, and multi-region support — all in v1. I went through multiple rounds of design review, including running the spec past other LLMs to pressure-test it. Most of that got cut. What shipped is one command, three rules, and a CLI. It works on real infrastructure. The original would have shipped half-finished on everything.

The other mistake: I assumed I knew the Terraform plan JSON structure well enough to write the parser from memory. I didn't generate real plan files and read them first. aws_vpc_security_group_ingress_rule showing up as a bug in my fixtures is a direct result of that. Next time I'm reading real data before writing code that parses it.


What's next

Blast radius is the main one — given a resource being changed, compute what else depends on it from the Terraform graph. You change a security group, IACGuard tells you which EC2 instances, load balancers, and RDS clusters are downstream. No free CLI tool does this cleanly in a pre-deploy workflow.

After that: more rules, drift detection (Terraform state vs live AWS), and a browser graph for the dependency chain.


Try it

pip install iacguard

terraform plan -out=tfplan
terraform show -json tfplan > plan.json

iacguard plan --plan plan.json
iacguard plan --plan plan.json --ci
Enter fullscreen mode Exit fullscreen mode

Source: https://github.com/adil-khan-723/iacguard

If you hit a resource type the rules miss, open an issue or PR. Each rule is one file.

Top comments (0)