030 Project 30: Terraform Risk Reporter
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
1if risky count > max (default 0) - Exit
0if clean;2on 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.27Full 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.jsonCI sketch:
- run: terraform show -json tfplan > plan.json
- run: go run ./tfrisk -max-risky 0 plan.jsonTests
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
- Severity levels (destroy production tagged resources).
- Allowlist file of addresses that may be destroyed.
- GitHub PR comment body markdown.
- 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