030 Project 30: Terraform Risk Reporter

Updated

September 8, 2026

030 Build a Terraform Risk Reporter

Use a Terraform plan JSON to fail CI when deletes or replacements touch critical resources. This sits on top of the plan parser idea and turns analysis into a gate.

plan.json -> find delete/replace -> match risk rules -> exit 1 if risky

Problem statement

tf-risk [-max-risky N] [-deny-regex RE] <plan.json>
  • Flag every delete or replace as risky by default
  • Optional deny regex on address (e.g. aws_db_instance)
  • Exit 1 if risky count > max (default 0)
  • Exit 0 if clean; 2 on usage/parse errors

Acceptance criteria

  • Detects delete and replace
  • Prints each risky address with actions
  • Exit code suitable for CI
  • Deny regex optional
  • Stdlib only

Setup

mkdir tfrisk && cd tfrisk
go mod init example.com/tfrisk
# go 1.27

Full main.go

package main

import (
    "encoding/json"
    "flag"
    "fmt"
    "os"
    "regexp"
)

type plan struct {
    ResourceChanges []struct {
        Address string `json:"address"`
        Type    string `json:"type"`
        Change  struct {
            Actions []string `json:"actions"`
        } `json:"change"`
    } `json:"resource_changes"`
}

func hasAction(actions []string, x string) bool {
    for _, a := range actions {
        if a == x {
            return true
        }
    }
    return false
}

func isReplace(actions []string) bool {
    return len(actions) == 2 && actions[0] == "delete" && actions[1] == "create"
}

func isRisky(actions []string) bool {
    return hasAction(actions, "delete") || isReplace(actions)
}

func main() {
    maxRisky := flag.Int("max-risky", 0, "allowed risky changes before fail")
    deny := flag.String("deny-regex", "", "if set, only these addresses are risky when deleted/replaced")
    flag.Parse()
    if flag.NArg() != 1 {
        fmt.Fprintln(os.Stderr, "usage: tf-risk [flags] <plan.json>")
        os.Exit(2)
    }

    var denyRE *regexp.Regexp
    if *deny != "" {
        var err error
        denyRE, err = regexp.Compile(*deny)
        if err != nil {
            fmt.Fprintln(os.Stderr, "deny-regex:", err)
            os.Exit(2)
        }
    }

    b, err := os.ReadFile(flag.Arg(0))
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }
    var p plan
    if err := json.Unmarshal(b, &p); err != nil {
        fmt.Fprintln(os.Stderr, "json:", err)
        os.Exit(2)
    }

    risky := 0
    for _, rc := range p.ResourceChanges {
        a := rc.Change.Actions
        if !isRisky(a) {
            continue
        }
        if denyRE != nil && !denyRE.MatchString(rc.Address) && !denyRE.MatchString(rc.Type) {
            // when deny-regex set: only matching resources are "policy risks"
            // still count all deletes as informational? here: skip non-matching
            fmt.Printf("INFO: %s actions=%v (not in deny-regex)\n", rc.Address, a)
            continue
        }
        risky++
        kind := "delete"
        if isReplace(a) {
            kind = "replace"
        }
        fmt.Printf("RISK(%s): %s actions=%v\n", kind, rc.Address, a)
    }

    fmt.Printf("risky changes=%d max=%d\n", risky, *maxRisky)
    if risky > *maxRisky {
        os.Exit(1)
    }
}

Policy modes

Mode Flags Behavior
Strict default any delete/replace fails
Budget -max-risky 2 allow up to 2
Critical only -deny-regex 'aws_db\|aws_rds' only matching addresses gate

Run and verification

# fixture from project 29
go run . -max-risky 0 plan.json; echo exit:$?

go run . -deny-regex 'aws_db_instance' plan.json

CI sketch:

- run: terraform show -json tfplan > plan.json
- run: go run ./tfrisk -max-risky 0 plan.json

Tests

package main

import "testing"

func TestIsReplace(t *testing.T) {
    if !isReplace([]string{"delete", "create"}) {
        t.Fatal()
    }
    if isReplace([]string{"delete"}) {
        t.Fatal()
    }
}

func TestIsRisky(t *testing.T) {
    if !isRisky([]string{"delete"}) || !isRisky([]string{"delete", "create"}) {
        t.Fatal()
    }
    if isRisky([]string{"create"}) {
        t.Fatal()
    }
}

Stretch goals

  1. Severity levels (destroy production tagged resources).
  2. Allowlist file of addresses that may be destroyed.
  3. GitHub PR comment body markdown.
  4. Combine with cost estimator (project 38).

Pitfalls

Pitfall Fix
Treating update as risk only delete/replace
Silent success on bad JSON exit 2
Regex on wrong field match address and type

Learning goals

  • Enforce infrastructure policy as code
  • CI exit codes for plan gates
  • Reduce risky deploys with automated checks