Install
With a Go toolchain
go install github.com/dbhq-uk/terraken/cmd/terraken@latestOr download a binary from the releases page (external site). A release ships terraken and tken, built from the same package, so use whichever name is free on your machine. Builds are for Linux, macOS, Windows, FreeBSD, OpenBSD and Solaris; there is no native Windows build, so on Windows it wants WSL.
Use
Terraken reads a plan that Terraform or OpenTofu has already produced:
terraform plan -out tfplanterraform show -json tfplan > plan.jsonterraken plan.jsonOr read it from standard input and never write the plan to disk at all. This is the better habit: plan JSON can hold a credential in the clear, and a file on a shared runner is one more place for one to be left.
terraform show -json tfplan | terraken -Flags go before the file. Go's flag parsing stops at the first positional argument, so a flag written after the filename is not read. Terraken notices that case and tells you what to type instead of exiting on a bare usage block.
Flags
| Flag | What it does |
|---|---|
--format terminal|md|json|html|gate | Output format. Default terminal. Any other value is an error rather than a fallback. gate is a machine-first verdict with its own versioned schema, terraken.gate/v1, so a caller parsing it does not break when the human report changes shape. |
--out <path> | Write the report to a file instead of standard output, and print one line naming it. Works for every format. The file is created mode 0600 on Unix, and never contains colour. Windows has no Unix permission bits, so there it takes the directory's ACLs. |
--fail-on critical|high|low|info | Exit 1 if any finding reaches this level. Off by default, and measured against every finding rather than against what was displayed. |
--min-level critical|high|low|info | Only show findings at this level or above. The counts stay complete and the report says how many were held back. It changes what you read, never what was found and never the exit code. |
--plain | No colour, and ASCII only: no box drawing anywhere in the output. For a pipeline, a log viewer, or a console that renders box characters badly. |
--no-colour, --no-color | Never colour terminal output. The British spelling is canonical and the American one is an alias, so guessing wrong does not cost you a run. |
--rules <path> | Evaluate your own rules from a JSON file alongside the built-in findings, marked as yours rather than the tool's judgement. A rule matches on action, type, module, attribute path presence, and the tool's own classification - and it may test a value without the value ever reaching the output. A rule file that does not load stops the run with exit 2 rather than falling through to an unruled report. |
--moved | Instead of the report, print the moved blocks this plan looks like it forgot, as HCL. Redirect it into a .tf file. Where more than one create matches the deleted resource equally well, it refuses and says so rather than guessing - the block is copy-pasteable, and naming the wrong resource adopts a decommissioned object's state under a live address. |
--version | Print the version and exit, without needing a plan. |
--min-level and --fail-on are independent on purpose. Filtering changes what you read, never what was found: the counts stay complete, the report says how many findings were held back and what the bar was, and the gate is measured against every finding. Turning the volume down cannot turn a gate off.
terraken 5 findings terraform 1.16.1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HOW MUCH OF THIS COULD BE CHECKED ───────────────────────────────────────── 2
2 of 5 changes were read in full, and the 2 notes below are what the rest of
this plan does not say
3 of 5 changes carry values Terraform will not know until it applies them,
so no claim about those values can be checked now
this plan records nothing that changed underneath the estate, and that means
one of two things it does not distinguish: either nothing drifted, or
refresh never ran and nobody looked
CRITICAL ────────────────────────────────────────────────────────────────── 1
azurerm_postgresql_flexible_server.main
destroy, then create
├ holds data, so destroying it loses that data
├ an attribute changed that cannot be updated in place
├ forces replacement zone
├ changes 3 attributes
│ fqdn
│ id
│ zone
├ destroyed before the replacement is created
├ these values are not known until apply, so no claim about them can be
│ checked in review
│ fqdn
│ id
└ these values are sensitive and are redacted in all output
administrator_password
HIGH ────────────────────────────────────────────────────────────────────── 1
azurerm_subnet.app
destroy
├ its configuration block was removed
├ had 6 attributes
│ address_prefixes
│ name
│ private_endpoint_network_policies
│ resource_group_name
│ service_endpoints
│ virtual_network_name
└ possible missed moved block
6 of 6 attributes match azurerm_subnet.application
moved { from = azurerm_subnet.app to = azurerm_subnet.application }
verify the pairing before using that block
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
These are the top-level attributes the plan shows as changing, so a change
inside one of them is counted once. The count is not a measure of how big the
change is.
1 critical 1 high 1 low 2 info 3 below high not shownLevels
Four, and deliberately no medium: a middle bucket is where findings go to be ignored.
| Level | What reaches it |
|---|---|
critical | A destroy or a replacement of a resource type that holds data, so destroying it loses that data. This is the only escalation in the tool, and it applies to destruction alone: updating a database in place does not lose data. |
high | Any other destroy, or any replacement. The existing object is destroyed either way, whatever is or is not inside it. |
low | An update in place, or a resource that is being forgotten from state but left running. |
info | A create, a data source read, an import and a no-op. Nothing existing is being taken away. |
A resource type Terraken has not been curated against is never assumed safe. A destroy of an unrecognised type keeps its base level and is annotated to say that whether destroying it loses data has not been assessed.
Credentials in the plan file
Terraform marks a value sensitive when a provider schema says the attribute is sensitive, or when a sensitive variable flows into it. That marking is best-effort, and a live API token was once found in a real plan sitting in the clear - at variables.cloudflare_api_token.value, where a variable declared sensitive = true still lands in plaintext, because a plan's top-level variables block records a value and carries no sensitivity information at all.
Printing nothing protects the report. It does nothing for you, because you still have the file. So every report also names values that look like credentials and that Terraform did not mark: root variables, resource attributes and outputs, checked against private key headers, published token formats, connection strings with a password in them, attributes named as secrets, and long high-entropy strings under a neutral name.
terraken 5 findings terraform 1.16.1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CREDENTIALS IN THE PLAN FILE ────────────────────────────────────────────── 6
(root variables) cloudflare_api_token
an attribute named as a secret, not marked sensitive
terraform_data.app input.api_token
a GitHub token
terraform_data.app input.database_url
a connection string with an embedded password
terraform_data.aws input.access_key_id
an AWS access key id
terraform_data.cdn input.edge_handle
a long, high-entropy string
terraform_data.signing input.material
a private key
Treat this plan file as a secret: store it accordingly, and rotate whatever
it turns out to hold. Editing the file does not undo the exposure.
WHAT THIS PLAN SAYS ABOUT ITSELF ────────────────────────────────────────── 3
errored no
complete yes
applyable yes
HOW MUCH OF THIS COULD BE CHECKED ───────────────────────────────────────── 3
0 of 5 changes were read in full, and the 3 notes below are what the rest of
this plan does not say
5 of 5 changes carry values Terraform will not know until it applies them,
so no claim about those values can be checked now
this plan records nothing that changed underneath the estate, and that means
one of two things it does not distinguish: either nothing drifted, or
refresh never ran and nobody looked
1 of 1 outputs hold a value Terraform will not know until it applies this,
so what they will contain cannot be checked now
This is pattern matching over the plan's own values: it misses credentials it
does not recognise, and it names values that are not credentials. Treat it as
a reason to check, never as a clean bill of health.
5 info 5 below critical not shownIt never prints the value it detected - not masked, not truncated, not as a length. The output is a path and a class, which is the only form in which "there is a token at this path" can be said safely, and it is why the advice is to rotate rather than to edit. Editing the file does not undo the exposure: the value has been written to disk, and wherever that file has been is where the credential has been.
This is pattern matching. It misses credentials it does not recognise and it names values that are not credentials, and every format says so beside every result. A plan with nothing suspicious produces nothing at all, rather than a clean bill of health the technique cannot support.
It is not a gate. A credential in the file never changes a finding's level and never fails a build, because --fail-on takes a severity and this is not one - and a heuristic this rough must not be able to fail a build on its own. gate reports it in an exposure array so a pipeline can stop on it as its own policy. Nothing hides it either: --min-level filters findings and has no effect on this block, which is why the capture above still shows it with every finding held back.
Output formats
terminal
The default. One section per severity present, most severe first, and none at all for a severity with nothing in it. The report is set to the width of the stream it is going to, clamped between 60 and 100 columns, so a report redirected to a file is not set to the width of whatever terminal launched it. --plain drops colour and every box-drawing character, for a pipeline or a console that renders them badly.
md
A table for a pull request comment or a job summary, with the evidence behind each annotation in a collapsed section underneath, including the suggested moved block where there is one.
| Level | Change | Resource | Notes |
|---|---|---|---|
| CRITICAL | destroy, then create | `azurerm_postgresql_flexible_server.main` | holds data, so destroying it loses that data; an attribute changed that cannot be updated in place; forces replacement: `zone`; this plan destroys the existing object before creating its replacement |
1 finding.json
Indented JSON for machines. The shape is below.
html
One self-contained document: inline CSS, no external stylesheet, no font, no image and no script. It opens from a file:// URL with nothing else on disk and follows prefers-color-scheme, so it reads the same way in a dark editor as in a light browser. Anything taken from the plan is escaped before it is written, because a resource address can carry a key somebody else chose.
--out writes any format to a path and prints one line naming it, so nothing else lands on standard output. The file is created mode 0600: the report names every resource in the plan, which on a shared runner is a map of the estate. Colour is never written to a file.
Colour
Colour is emitted only when the output is going to a terminal, so a pipe and a file both come out plain. Beyond that:
- NO_COLOR (external site) set to anything non-empty switches colour off, whatever its value. An empty value means nothing and does not opt in.
FORCE_COLORturns it back on when the destination is a pipe, which a CI log that renders ANSI and a pager held open withless -Rboth need.NO_COLORwins if both are set, because turning colour off should never be the setting that loses.--no-colourand its American alias--no-colorboth work.--plaingoes further and drops the box drawing too.
The JSON shape
{
"terraform_version": "1.9.8",
"format_version": "1.2",
"findings": [
{
"address": "azurerm_postgresql_flexible_server.main",
"type": "azurerm_postgresql_flexible_server",
"provider": "registry.terraform.io/hashicorp/azurerm",
"kind": "replace",
"level": "critical",
"reason": "an attribute changed that cannot be updated in place",
"replace_paths": [
"zone"
],
"replace_order": "destroy-before-create",
"data_loss": true,
"annotations": [
{
"code": "replacement-ordering",
"detail": "this plan destroys the existing object before creating its replacement",
"summary": "destroyed before the replacement is created"
}
]
}
],
"counts": {
"critical": 1
}
}Four things about it that are easy to get wrong:
findingsis always an array, never null, even on a clean plan. A nil slice would marshal as null and break iteration on the one plan whose answer is good news.countsalways describes the whole plan, even under--min-level. It is not recounted around the filter.hiddenandhidden_beloware omitted entirely on an unfiltered run, so read.hidden // 0rather than assuming the key exists.reason,replace_paths,module,providerandannotationsare omitted when empty rather than emitted as nulls.
A possible-missed-moved-block annotation carries a moved object beside its sentence, holding the two addresses, the matched and compared attribute counts and whether the pairing crossed a module boundary. It is there so a consumer that cannot print a paragraph is not left with a bare code.
Annotation codes
An annotation is extra context attached to a finding. Annotations are reported separately from the level rather than folded into it, so their reasoning is always visible.
| Code | What it means |
|---|---|
your-rule | A finding from one of your own rules rather than from the tool's judgement, carrying your message and your severity. Its own code, so a consumer can tell the two apart without reading prose. |
blast-radius | What else in the plan depends on a resource being destroyed or replaced, transitively, with the nearest distance to each. Set on destructive changes only. A resource nothing depends on is not annotated rather than annotated with a zero. |
replacement-ordering | Which way round a replacement happens: whether the plan destroys the existing object before creating its replacement, or the other way about. Read from the order of the actions array, which is the whole of what a plan says about create_before_destroy. It states the sequence and nothing following from it - create-before-destroy is not a promise that the resource is there throughout - and it never moves the level, because both orderings still destroy the old object. |
possible-missed-moved-block | A destroy and a create that look like one resource renamed without a moved block. It carries the two addresses, the matched and compared attribute counts, and whether the pairing crossed a module boundary. |
unverifiable-until-apply | Attributes Terraform cannot know until apply, named by path. No claim about them can be checked in review, and saying so is the feature. |
sensitive | Attributes Terraform marked sensitive, named by path. The values are not printed, and neither is anything else. |
unrecognised-provider | A resource being destroyed whose provider is not one Terraken has been curated against, so whether destroying it loses data has not been assessed. An unrecognised type is never assumed safe. |
attributes-changed | Which top-level attributes this change touches, and how many. A create sets them, a delete had them, an update or replacement changes them. Names only, never values - and a change inside one attribute is counted once, so the number is not a measure of how big the change is. |
destroyed-before-this | Which resources depending on this one are destroyed before it is. Order only, from Terraform's own dependency rules: no window, no duration, and steps with no dependency between them are not ordered against each other at all. |
changed-after-this | Which resources depending on this one are created or updated after it is created. Reported only where this resource is itself created, because a plan that only destroys something has no create for anything to follow. |
unsupported-operation | An operation this build cannot read at all - an action verb it has never seen, or a sequence Terraform does not document. Nothing below it is assessed, because nothing is known about what it does. It carries the plan's whole action sequence, quoted, and it is unranked rather than ranked low. |
same-elements-reordered | A changed list holds the same elements in a different order. A fact, not a verdict: order is significant for a container command or an ordered rule list, so the call is yours. |
same-json-written-differently | Both sides parse as JSON and hold the same data with the keys in a different order. Policy documents and anything a provider round-trips as a JSON blob. |
same-text-different-whitespace | Both sides are the same text laid out differently: a trailing newline, an indent, a CRLF against an LF. |
same-number-written-differently | Both sides are the same number written another way, such as 80 against the string form of 80. |
null-on-one-side-empty-on-the-other | One side is null and the other is an empty list, object or string. |
every-changed-attribute-written-differently | The roll-up: every attribute the plan shows as changed on this resource fell into one of the classes above. It is a strict claim, so one real change anywhere on the resource silences it. |
None of the rewrite classes moves a finding's level, and none of them says a change is harmless. Each one has a case where the difference is real: order is significant for a container command, whitespace is significant in a script and in anything hashed, a number and its string form are different types, and null and empty are not the same thing to Terraform everywhere. Terraken names the kind of difference and leaves the ruling to you.
terraken 5 findings terraform 1.9.8
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HOW MUCH OF THIS COULD BE CHECKED ───────────────────────────────────────── 1
5 of 5 changes were read in full, and the note below is what the rest of
this plan does not say
this plan records nothing that changed underneath the estate, and that means
one of two things it does not distinguish: either nothing drifted, or
refresh never ran and nobody looked
LOW ─────────────────────────────────────────────────────────────────────── 5
aws_ecs_task_definition.api
update in place
├ changes 1 attribute
│ container_definitions
├ same text, different whitespace
│ container_definitions
└ every attribute this plan shows as changed here is a difference in how the
value is written
aws_iam_policy.pipeline
update in place
├ changes 1 attribute
│ policy
├ same JSON, written differently
│ policy
└ every attribute this plan shows as changed here is a difference in how the
value is written
aws_instance.bastion
update in place
├ changes 3 attributes
│ instance_type
│ metadata
│ user_data
├ same text, different whitespace
│ user_data
└ same JSON, written differently
metadata
aws_lb_target_group.app
update in place
├ changes 2 attributes
│ load_balancing_anomaly_mitigation
│ tags
├ null on one side, empty on the other
│ load_balancing_anomaly_mitigation
│ tags
└ every attribute this plan shows as changed here is a difference in how the
value is written
aws_security_group.web
update in place
├ changes 1 attribute
│ ingress
├ same elements, different order
│ ingress[0].cidr_blocks
├ same number, written differently
│ ingress[0].from_port
│ ingress[0].to_port
└ every attribute this plan shows as changed here is a difference in how the
value is written
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
These are the top-level attributes the plan shows as changing, so a change
inside one of them is counted once. The count is not a measure of how big the
change is.
Whitespace is significant in a script, in a YAML document carried as a string
and in anything hashed, so whether a whitespace change matters is yours to
judge.
Each of these classes has a case where the difference is real. Order matters
in a container command, and whitespace matters in a script. This says what
kind of difference each one is and rules on none of them.
A consumer that compares the document byte for byte still sees a change, so
whether a rewritten document matters is yours to judge.
Null and empty are not the same thing to Terraform everywhere, where null can
mean inherit a default and empty means explicitly none, so whether this one
matters is yours to judge.
Order is significant for some attributes, such as a container command or an
ordered rule list, so whether a reordering matters is yours to judge.
A number and its string form are different types, and a type change can
matter, so whether this one does is yours to judge.
5 lowExit codes
| Code | When |
|---|---|
0 | The report was written. This is the answer whenever --fail-on is not set, whatever the plan contains. |
1 | --fail-on was set and a finding reached that level, measured against every finding rather than the displayed ones. |
2 | Something was wrong with the invocation or the input: an unknown flag or format, an unknown level name, no file argument, a plan that would not load, or a write that failed. |
The separation matters in a pipeline. 1 means the tool worked and the plan failed your gate; 2 means the tool did not work, and treating the two as one number is how a broken step gets read as a passing one.
In CI
terraken --format md plan.json >> "$GITHUB_STEP_SUMMARY"terraken --fail-on critical plan.json--fail-on is off by default, because a tool that blocks by default gets switched off on day one rather than adopted. Adopt it read-only first, see what it says about the plans you are already approving, and turn the gate on afterwards.
There is a GitHub Action in the repository that does both in one step, and one step is the point: the same run produces the summary and the exit code, so the two cannot disagree and a large plan is not assessed twice.
- uses: dbhq-uk/terraken@v0.9.0
with:
plan: plan.json
fail-on: critical| Input | What it does |
|---|---|
plan | Path to the plan JSON, or - to read it from standard input. Required. |
fail-on | Exit 1 if any finding reaches this level. Empty by default, which is no gate at all. |
summary | Append the markdown report to the job summary. True by default. |
version | Which release to install. Defaults to the ref the action was pinned at, so pinning the action pins the binary and the two cannot disagree. |
Pin the action to a tag. It never installs from @latest, and a released action must not change what it runs because somebody pushed to a branch.