Skip to content
BETAGuardrails are in beta. The library, the configuration format and the CLI command can still change.

Guardrails / Collectors

Terraform configuration

The Terraform configuration in the checkout: every root directory holding .tf files, the backend it stores state in, the providers and modules it takes on and how tightly they are pinned, the resources it declares, the variables it takes, the lock file that resolves it, and any state file left in the tree. Read out of the files themselves, so it works on a runner with no Terraform installed, and enriched with the versions an initialised root has already resolved when the terraform CLI is there.

Facts keyterraform
Versionv1
Scriptterraform.py
Timeout90 seconds

Tools

This collector reads what these tools report. It never installs one: a tool that is not on the runner is missing evidence, and the guardrails asking for these facts skip rather than fail.

ToolNeededDescription
terraformoptionalTerraform CLI, run only to read back the provider versions an already initialised root resolved. Never init, plan or apply, so nothing it does reaches real infrastructure.

Installing terraform

Install with brew install terraform, or add the hashicorp/setup-terraform step to the workflow. The configuration is read out of the files either way, so this only adds the resolved versions.

Inputs

InputDescriptionDefaultEnvironment
directoryDirectory the configuration is looked for under, relative to the directory the CLI runs in. Discovery walks the whole working directory by default and this only narrows it, so a repository holding its Terraform in one place collects that place alone..GUARDRAIL_INPUT_DIRECTORY

A guardrail that declares an input of the same name passes it through, so the guardrail's configuration in buildnote.json is what decides these values.

A guardrail asks for these facts by name, and reads them back the same way:

json
{
  "collect": ["terraform"]
}
python
terraform = guardrail.facts("terraform")

Facts

The document terraform collects. A path carrying [] is an entry of the list before it, and one carrying [path] is a key of the object before it.

FactMeaning
directoryThe directory input, as the guardrail configured it.
sourcereport when the configuration was read out of the checkout, tool when terraform was also run against an initialised root, none when there is no Terraform here at all.
reasonWhy nothing was found, present only when source is none.
reportsEvery configuration file that was read and understood, in path order. A file the reader could not handle is in unparsed instead.
reports[].pathPath of the file, relative to the directory the CLI runs in.
reports[].modifiedWhen the file was last written, ISO 8601.
reports[].formatWhat it was read as: tf, tfvars or lock.
tool.nameterraform, present only when it was run.
tool.versionVersion of the terraform on the runner.
tool.pathWhere that terraform was found on PATH.
tool.argsArguments of the last invocation. providers -json is tried first and version -json is the fallback, because only the second is supported by every release.
tool.exitCodeWhat that invocation exited with.
tool.durationMsHow long it took.
rootsEvery directory holding at least one .tf file, in path order. A reusable module directory is one of these too, so read backend.type on a root that is not a live one with that in mind.
roots[].pathDirectory of the root, relative to the directory the CLI runs in, . for the working directory itself.
roots[].backend.typeBackend the root stores state in, as the backend label names it. local when the root declares no backend block at all, which is the finding rather than a default worth ignoring.
roots[].backend.attributesEvery attribute the backend block declares, by name. A value is carried only when it is a plain scalar the reader could resolve; anything interpolated, structured or named like a credential is null, so the key is evidence the attribute is set without the secret becoming evidence too.
roots[].backend.encryptedWhether the backend declares its state encrypted, from encrypt or a key attribute for the backends that have one. null when the backend says nothing either way, which is not the same as false.
roots[].backend.lockingWhether the backend declares state locking, from use_lockfile, dynamodb_table, lock or lock_address depending on the backend. null when the backend says nothing either way.
roots[].providersEvery provider the root requires, from required_providers first and then any provider block naming one that was not required.
roots[].providers[].nameLocal name of the provider, as the configuration refers to it.
roots[].providers[].sourceSource address as written, such as hashicorp/aws, or null when only a provider block names it.
roots[].providers[].versionVersion constraint exactly as written, such as ~> 6.0, or null when none is declared.
roots[].providers[].pinnedWhether that constraint pins one exact version. A ~>, a range, or no constraint at all is not pinned.
roots[].modulesEvery module block the root declares, in the order the files declare them.
roots[].modules[].nameLabel of the module block.
roots[].modules[].sourceIts source exactly as written, or null when the source is interpolated rather than literal.
roots[].modules[].versionIts version constraint, which only a registry source can carry, or null.
roots[].modules[].localWhether the source is a relative path inside the repository, so nothing is fetched.
roots[].modules[].registryWhether the source is a module registry address, meaning version is what pins it.
roots[].modules[].gitWhether the source is a git or HTTP URL, meaning the ref in the URL is what pins it and version never applies.
roots[].resourcesHow many resource blocks the root declares, by resource type. data blocks are not counted.
roots[].resourceTypesThose resource types, sorted.
roots[].variablesEvery variable block the root declares. Names only: a default can be a credential and these facts are attached to the build as evidence.
roots[].variables[].nameLabel of the variable block.
roots[].variables[].sensitiveWhether it declares sensitive = true.
roots[].variables[].hasDefaultWhether it declares a default at all. The default itself is never collected.
roots[].varFilesEvery .tfvars file committed beside the root.
roots[].varFiles[].pathPath of the file, relative to the directory the CLI runs in.
roots[].varFiles[].variablesThe variable names it assigns, sorted. The values are never collected.
roots[].lockfile.presentWhether the root commits a .terraform.lock.hcl.
roots[].lockfile.pathPath of that lock file, or null when there is none.
roots[].lockfile.providersEvery provider the lock file resolves.
roots[].lockfile.providers[].nameLast segment of the provider address, matching the local name in the usual case.
roots[].lockfile.providers[].sourceFull address the lock file names, such as registry.terraform.io/hashicorp/aws, so a mirror is recognisable.
roots[].lockfile.providers[].versionVersion it resolves to.
roots[].lockfile.providers[].hashesHow many hashes it records. The hashes themselves are a count rather than a list, because the count is what says whether the lock covers more than one platform.
roots[].unparsedHow many files in this root the reader could not handle. Anything above zero means this root is described from part of its configuration, so treat an absence here as unknown rather than as a finding.
stateFilesEvery .tfstate or .tfstate.backup file left in the tree, which is a finding in itself. The walk skips .terraform, so a working directory Terraform manages is not one of these.
stateFiles[].pathPath of the state file, relative to the directory the CLI runs in.
stateFiles[].bytesIts size in bytes. The contents are never read.
unparsedEvery file the reader could not handle, so a guardrail can tell a configuration that says nothing from one nobody could read.
unparsed[].pathPath of that file.
unparsed[].reasonWhat stopped the reader.
resolvedProvider versions an initialised root has already resolved, read back with terraform. Present only when the tool is on the runner and the root carries a .terraform/providers directory, which is what terraform init leaves behind and what tells an initialised root from a stale one.
resolved[].rootRoot the versions were read in.
resolved[].nameLast segment of the provider address.
resolved[].sourceFull provider address as Terraform names it.
resolved[].versionVersion Terraform selected.

A collector that cannot finish prints what it had along with an incomplete key saying why, so the facts below the point it stopped are absent. A check that cares reads incomplete before the rest.

Example facts

The example facts collected by terraform collector:

json
{
  "directory": ".",
  "source": "report",
  "reports": [
    {
      "path": ".terraform.lock.hcl",
      "modified": "2026-03-04T10:15:00Z",
      "format": "lock"
    },
    {
      "path": "main.tf",
      "modified": "2026-03-04T10:15:00Z",
      "format": "tf"
    }
  ],
  "roots": [
    {
      "path": ".",
      "backend": {
        "type": "s3",
        "attributes": {
          "bucket": "widget-terraform-state",
          "key": "live/widget.tfstate",
          "region": "eu-west-1",
          "encrypt": true,
          "use_lockfile": true
        },
        "encrypted": true,
        "locking": true
      },
      "providers": [
        {
          "name": "aws",
          "source": "hashicorp/aws",
          "version": "6.4.0",
          "pinned": true
        },
        {
          "name": "random",
          "source": "hashicorp/random",
          "version": "~> 3.6",
          "pinned": false
        }
      ],
      "modules": [
        {
          "name": "events",
          "source": "terraform-aws-modules/sqs/aws",
          "version": "4.2.1",
          "local": false,
          "registry": true,
          "git": false
        }
      ],
      "resources": {
        "aws_s3_bucket": 1,
        "aws_s3_bucket_versioning": 1
      },
      "resourceTypes": [
        "aws_s3_bucket",
        "aws_s3_bucket_versioning"
      ],
      "variables": [
        {
          "name": "region",
          "sensitive": false,
          "hasDefault": true
        },
        {
          "name": "api_token",
          "sensitive": true,
          "hasDefault": false
        }
      ],
      "varFiles": [],
      "lockfile": {
        "present": true,
        "path": ".terraform.lock.hcl",
        "providers": [
          {
            "name": "aws",
            "source": "registry.terraform.io/hashicorp/aws",
            "version": "6.4.0",
            "hashes": 3
          },
          {
            "name": "random",
            "source": "registry.terraform.io/hashicorp/random",
            "version": "3.6.3",
            "hashes": 2
          }
        ]
      },
      "unparsed": 0
    }
  ],
  "stateFiles": [],
  "unparsed": []
}

Collected for

GuardrailCategoryInputs
iac/terraform-modules-pinnediacnone
iac/terraform-no-plaintext-secretsiacnone
iac/terraform-providers-pinnediacnone
iac/terraform-remote-stateiacnone
iac/terraform-state-encryptediacnone
iac/terraform-state-lockingiacnone

All collectors

Buildnote Limited
Registered in England and Wales, Reg: 16140412