terraken

Terraken

Docs

Everything the shipped binary accepts and everything it prints. This page documents v0.9.0, and it documents what exists rather than what is planned.

Install

With a Go toolchain

go install github.com/dbhq-uk/terraken/cmd/terraken@latest

Or 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 tfplan
terraform show -json tfplan > plan.json
terraken plan.json

Or 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

FlagWhat it does
--format terminal|md|json|html|gateOutput 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|infoExit 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|infoOnly 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.
--plainNo 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-colorNever 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.
--movedInstead 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.
--versionPrint 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 --min-level high plan.json
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 shown

Levels

Four, and deliberately no medium: a middle bucket is where findings go to be ignored.

LevelWhat reaches it
criticalA 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.
highAny other destroy, or any replacement. The existing object is destroyed either way, whatever is or is not inside it.
lowAn update in place, or a resource that is being forgotten from state but left running.
infoA 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 --min-level critical plan.json
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 shown

It 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.

terraken --format md plan.json
| 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_COLOR turns it back on when the destination is a pipe, which a CI log that renders ANSI and a pager held open with less -R both need.
  • NO_COLOR wins if both are set, because turning colour off should never be the setting that loses.
  • --no-colour and its American alias --no-color both work. --plain goes further and drops the box drawing too.

The JSON shape

terraken --format json plan.json
{
  "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:

  • findings is 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.
  • counts always describes the whole plan, even under --min-level. It is not recounted around the filter.
  • hidden and hidden_below are omitted entirely on an unfiltered run, so read .hidden // 0 rather than assuming the key exists.
  • reason, replace_paths, module, provider and annotations are 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.

CodeWhat it means
your-ruleA 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-radiusWhat 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-orderingWhich 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-blockA 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-applyAttributes Terraform cannot know until apply, named by path. No claim about them can be checked in review, and saying so is the feature.
sensitiveAttributes Terraform marked sensitive, named by path. The values are not printed, and neither is anything else.
unrecognised-providerA 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-changedWhich 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-thisWhich 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-thisWhich 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-operationAn 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-reorderedA 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-differentlyBoth 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-whitespaceBoth sides are the same text laid out differently: a trailing newline, an indent, a CRLF against an LF.
same-number-written-differentlyBoth sides are the same number written another way, such as 80 against the string form of 80.
null-on-one-side-empty-on-the-otherOne side is null and the other is an empty list, object or string.
every-changed-attribute-written-differentlyThe 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 plan.json
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 low

Exit codes

CodeWhen
0The 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.
2Something 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.

GitHub Actions
- uses: dbhq-uk/terraken@v0.9.0
  with:
    plan: plan.json
    fail-on: critical
InputWhat it does
planPath to the plan JSON, or - to read it from standard input. Required.
fail-onExit 1 if any finding reaches this level. Empty by default, which is no gate at all.
summaryAppend the markdown report to the job summary. True by default.
versionWhich 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.

Three things it will not do

  • It takes a file, and runs nothing

    Terraken reads a plan file, or the same JSON piped in on standard input. It never runs terraform, never reads a cloud credential, never makes a network call and never applies anything. The only file it writes is the one you name with --out, created mode 0600 on Unix because the report lists every resource in the plan. Windows has no Unix permission bits, so there the file takes the directory's ACLs instead.

  • It never prints an attribute's value, in any format

    Not masked, not redacted, not truncated. Values are not in the output at all. Masking depends on Terraform having marked a value sensitive, and that marking is best-effort: a live credential was found in a real plan that Terraform had not marked. Paths, counts, levels and Terraken's own sentences are all it will ever show you - including when it is telling you that one of those paths holds a credential.

  • It is deterministic, with no model in the loop

    The same plan always produces the same verdict. Nothing is sent anywhere, there is no model to talk you round, and no ranking that cannot be read straight off the plan. Findings are sorted most severe first with ties broken on the resource address, so two runs of the same plan are byte for byte identical.

And what it is not for

Terraken does not model consequences, validate against provider schemas, check policy or estimate cost. Other tools do those, and it is better to be plain about the line than to imply a wider one. It also never writes to your configuration: a tool that generates the moved blocks for you is a different tool for a different moment, useful to the person doing the rename rather than the person reviewing it.