Skip to content

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. Everything is read from the files themselves, so it works on a runner with no Terraform installed. When the terraform CLI is there, the versions an initialised root has already resolved are added.

Facts keyterraform
Versionv1
Scriptterraform.py
Timeout90 seconds

Tools ​

This collector reads what these tools report, but it never installs them. If a tool isn't on the runner, the evidence is missing and the guardrails that need these facts skip instead of failing.

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. By default discovery walks the whole working directory; this only narrows it, so a repository that keeps its Terraform in one place collects just that place..GUARDRAIL_INPUT_DIRECTORY

When a guardrail declares an input with the same name, it passes its value through. That means you set these values in the guardrail's configuration in buildnote.json.

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

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

Facts ​

These are the fields of the document terraform collects. In a path, [] means each entry of the list before it, and [path] means a key of the object before it.

FactMeaning
directoryThe directory set by the guardrail's directory input, relative to the directory the CLI runs in. Every other path this collector reports is also relative to the directory the CLI runs in, so it resolves from where you invoked the CLI, not from wherever the collector happened to run.
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 counts as one too, so keep that in mind when reading backend.type on a root that is not live.
roots[].pathDirectory of the root, relative to the directory the CLI runs in.
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 itself a finding, not a default to ignore.
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 shows 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, relative to the directory the CLI runs in, 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. Only the count is kept, not the hashes, because the count is what shows whether the lock covers more than one platform.
roots[].unparsedHow many files in this root the reader could not handle. Above zero, this root is described from only part of its configuration, so treat an absence here as unknown, not 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 apart from one that could not be read.
unparsed[].pathPath of that file, relative to the directory the CLI runs in.
unparsed[].reasonWhy the reader could not handle it.
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.

If a collector can't finish, it prints what it gathered so far along with an incomplete key that says why. Facts after the point where it stopped are missing, so a check that depends on them should read incomplete first.

Example facts ​

Here are the facts the terraform collector gathers from an example project:

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
terraform/modules-pinnedterraformnone
terraform/no-plaintext-secretsterraformnone
terraform/providers-pinnedterraformnone
terraform/remote-stateterraformnone
terraform/state-encryptedterraformnone
terraform/state-lockingterraformnone

All collectors

Buildnote Limited
Registered in England and Wales, Reg: 16140412