tofulock

tofulock is an experimental open-source supply-chain integrity tool for
Terraform / OpenTofu modules. It verifies pinned module provenance, detects
drift in CI, and can emit signed in-toto / DSSE attestations as module-approval
evidence.
Experimental. The CLI, lockfile format, and attestation predicate may
change between versions. Use it, file issues — but don't depend on stability yet.
The gap
Terraform and OpenTofu write a lock file (.terraform.lock.hcl), but it pins
providers only — never modules. Their docs are explicit: the lock file
"tracks only provider dependencies," and a remote module is re-resolved to the
newest version satisfying its constraint unless you pin an exact version. So a
module block constrained to ~> 5.0 — or even pinned to a git tag like
?ref=v1.2.0 — is not content-pinned: a moved tag, a force-pushed branch, or
a re-published registry version can change what you actually deploy, and init
won't notice. Registry modules can't be pinned to a commit at all. tofulock
records the exact commit behind every module in a small sidecar lockfile and
fails CI when reality drifts from it.
Install
Download a checksummed archive from the
latest GitHub release, or
install from source:
go install github.com/Conalh/tofulock@latest # needs Go 1.25+
# or from a clone:
go build -o tofulock .
git must be on your PATH — tofulock uses git ls-remote to resolve refs to
commits and never downloads module content during lock/verify. Remote Git
sources must use HTTPS or SSH. Put HTTPS credentials in a Git credential helper
and SSH keys in an agent; tofulock rejects URL passwords so secrets cannot be
copied into lockfiles or attestations.
Quickstart
Try it against the bundled example (examples/basic):
$ tofulock lock ./examples/basic
skip local_app (local)
locked network v4.1.2 @ 8a0b697adfbc
locked vpc_git v5.8.1 @ 25322b6b6be6
locked vpc_registry 5.8.1 @ 25322b6b6be6
wrote .tofulock.lock.json (3 locked, 1 skipped, 0 error)
$ tofulock verify ./examples/basic
ok network v4.1.2 @ 8a0b697adfbc
ok vpc_git v5.8.1 @ 25322b6b6be6
ok vpc_registry 5.8.1 @ 25322b6b6be6
OK: every locked module matches its recorded pin.
lock writes .tofulock.lock.json (commit it). verify re-resolves every
locked module and exits non-zero if a tag moved, a ref was re-pointed, a
constraint now selects a different version, or the source repository,
subdirectory, or constraint changed without re-locking.
tofulock list [dir] # show module calls and their classified source kind
tofulock lock [dir] [--json] [--force] # resolve git & registry modules to commits, write the lockfile
tofulock verify [dir] [--json] # re-resolve and fail (exit 1) on any drift
tofulock attest [dir] --key K # emit an in-toto module-provenance record (signed with --key)
tofulock verify-attest [dir] --key K.pub # verify a signed attestation against the lockfile
tofulock keygen --out signer # generate an ed25519 signing keypair
lock refuses to overwrite a known-good lockfile when some modules fail to
resolve, so a transient git ls-remote or registry blip can't destroy your
recorded pins — pass --force to write anyway.
Use in CI (GitHub Actions)
Fail the build the moment a locked module's tag is re-pointed, a branch
advances, or a registry constraint starts resolving to a new version:
# .github/workflows/module-integrity.yml
name: module integrity
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: Conalh/tofulock@v0.6.1
with:
directory: .
verify exits non-zero on drift; add --json (CLI) for a machine-readable
report. The Action builds the source and dependency lockfile from the Action ref
you selected; it does not download a second copy of tofulock. Pin the Action to
a full commit SHA when your workflow requires immutable third-party Actions.
Network boundary. Module configuration is active network input:
verify contacts the declared Git remotes and registry hosts. For pull
requests from untrusted forks, use a GitHub-hosted or otherwise
network-isolated runner. Do not give that job access to an internal network or
long-lived credentials.
OpenTofu
.tofu / .tofu.json files are discovered alongside .tf, honoring
OpenTofu's precedence rule (a main.tofu shadows main.tf entirely). One
difference needs a flag: OpenTofu resolves bare registry addresses like
terraform-aws-modules/vpc/aws against registry.opentofu.org, while
tofulock defaults to registry.terraform.io. Tell tofulock to match the tool
you deploy with:
tofulock lock . --registry-host registry.opentofu.org
tofulock verify . --registry-host registry.opentofu.org
# or: export TOFULOCK_REGISTRY_HOST=registry.opentofu.org
Use the same host for lock and verify (in CI, set the env var or the
action's registry-host input), or verify may report version drift that
tofu init would never see. Git sources and explicit-host registry addresses
are unaffected. See examples/opentofu for a locked .tofu config.
Sign & verify attestations
tofulock can emit a signed module-provenance record: an
in-toto statement with one subject per pinned module
(digested by git commit), wrapped in a DSSE envelope and signed with ed25519.
tofulock keygen --out signer # signer.key (secret) + signer.pub
tofulock attest . --key signer.key \
--approved-by you@example.com \
--out tofulock.attestation.dsse.json # signed DSSE envelope
tofulock verify-attest . --key signer.pub \
--att tofulock.attestation.dsse.json # verify signature + subjects vs lockfile
verify-attest confirms the signature and that every attested commit still
matches the current lockfile (and that the lockfile's SHA-256 is unchanged),
exiting non-zero otherwise. Omit --key on attest to print the unsigned
in-toto statement (example:
examples/basic/tofulock.attestation.json).
The record uses the in-toto / DSSE envelope format (the same shape cosign
produces), so keyless Sigstore/Rekor signing is a roadmap item rather than a
rewrite. The predicate maps the evidence to SOC2 / FedRAMP / PCI-style
change-control concepts — it is an aid to producing change-control evidence, not
a compliance certification.
What tofulock does not protect against
tofulock proves the module you deploy is the same one you pinned. It does
not:
- Vet module contents. A pinned commit can still contain malicious or
vulnerable code — tofulock proves it hasn't changed, not that it's safe.
- Audit the first pin (trust-on-first-use). The commit captured at
lock
time is trusted as-is; tofulock catches drift after that point.
- Cover providers. Providers already have native checksum locking; tofulock
is only about modules.
- Lock transitive / nested modules that a pinned module itself calls — only
the modules in the configuration directory you point it at (for now).
- Hash non-git registry or archive downloads (tarballs,
s3::,
https://….zip) — these are reported as skipped.
- Manage keys or provide a transparency log. You hold the ed25519 key; there
is no Sigstore/Rekor identity yet.
- Decide policy. It produces evidence and a pass/fail drift gate; it does not
decide which modules are allowed.
Source coverage
| Source kind |
list |
lock |
verify |
git (git::, shorthand, ?ref=) |
✅ |
✅ pinned to commit SHA |
✅ drift detection |
registry (ns/name/provider) |
✅ |
✅ constraint → version → commit |
✅ version + commit drift |
local (./…) |
✅ |
skipped (versioned with root) |
n/a |
archive (s3::, https://….zip) |
✅ |
skipped (roadmap) |
roadmap |
A git commit SHA is a content hash (a Merkle root over the tree), so pinning a
git module to its resolved commit is genuine content verification. Registry
modules are resolved through the Module Registry Protocol (service discovery →
version list → constraint selection → download endpoint), then pinned to the
commit behind the selected version — covering the git-backed modules that make
up the bulk of the public registry. verify flags two kinds of registry drift:
the constraint now selecting a newer version, and a published version being
re-pointed to a different commit.
Version selection matches Terraform/OpenTofu semantics: a bare constraint
like ~> 5.0 selects the highest stable version (prereleases are skipped),
while an exact pin like 6.6.1-rc1 or a range that mentions a prerelease can
select a prerelease. With no constraint, the highest stable version wins.
Terragrunt: a terragrunt.hcl terraform { source = … } block is
discovered and locked/verified/attested like any module — git and tfr://
registry sources resolve normally; an interpolated source (${local.…}) is
reported as unresolvable rather than silently skipped. See
examples/terragrunt.
Lockfile
.tofulock.lock.json is sorted by module name and timestamp-free, so it is
byte-stable across runs and produces clean review diffs:
{
"version": 1,
"tool": "tofulock",
"modules": [
{
"name": "vpc_git",
"source": "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git//?ref=v5.8.1",
"type": "git",
"constraint": "v5.8.1",
"clone_url": "https://github.com/terraform-aws-modules/terraform-aws-vpc.git",
"resolved_commit": "…",
"digest": "git:sha1:…",
"status": "locked"
}
]
}
Design
Module discovery uses HashiCorp's own
terraform-config-inspect,
so source parsing matches Terraform/OpenTofu semantics rather than being
re-implemented. Git resolution shells out to git ls-remote, which returns the
commit SHA for a ref without cloning.
main.go
└─ internal/
├─ cli/ command dispatch + per-command cmd_*.go (list / lock / verify / attest / …)
├─ tfmod/ module-call discovery via terraform-config-inspect
├─ terragrunt/ terragrunt.hcl terraform{} source discovery
├─ resolve/ source classification + git ref → commit resolution
├─ registry/ Module Registry Protocol: discovery, version select, download
├─ lock/ resolution engine shared by lock and verify
├─ lockfile/ deterministic lockfile read/write
├─ attest/ in-toto statement + DSSE envelope + ed25519 signing
└─ util/ tiny shared helpers (query-string parsing)
See THREAT_MODEL.md for the trust assumptions and
CONTRIBUTING.md to build and test.
Roadmap
- OCI (
oci://) module sources.
- Keyless signing via Sigstore/Fulcio with Rekor transparency-log inclusion.
- Non-git registry & archive sources (
s3::, gcs::, https://….zip) via
downloaded content hash.
- HCL (
.hcl) lockfile output alongside JSON.
- Approved-module allowlists and source-host pinning.
License
MIT — see LICENSE.