Sergen Tanguc
Publication: FEB 2026 Reading time: 6 min

Terraform Module Drift Is Silently Breaking Your Infrastructure

#devops #terraform #opentofu #sre #infrastructure #rust #static-analysis

There is a class of infrastructure bug that is invisible during code review, invisible during terraform plan, and invisible in your monitoring — right up until it causes an incident.

Terraform module version drift.

What Drift Actually Looks Like

You have three teams. Each one uses the same shared VPC module:

team-a/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# no version — pulls latest on every init
}
# team-b/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = ">= 0.0.0"
# accepts anything, including breaking majors
}
# team-c/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "= 2.44.0"
# frozen — misses CVE patches indefinitely
}

None of these will fail a terraform plan. None will cause a CI failure. None will show up in a PR review as obviously wrong. And yet:

  • team-a’s infrastructure is non-deterministic. terraform init on a fresh workstation will pull a different module version than the last apply.
  • team-b silently pulls breaking major versions. One day >= 0.0.0 resolves to 6.0.0 and your subnet structure changes.
  • team-c is running a VPC module with a known security vulnerability. It has been for months.

This is the problem MonPhare was built to solve.

Why terraform plan Isn’t Enough

The reflex answer is “just run terraform plan.” It has real limitations for drift detection at scale.

It’s slow. A full plan requires provider init, credential refresh, and a full state refresh. For large infrastructure this takes minutes. You can’t run it every five minutes across 50 repos.

It requires credentials. Running plans in CI for every repo in an org means managing Terraform credentials for every environment — production included. The blast radius of a misconfigured plan job is real.

It only sees one repo at a time. Cross-repo conflicts — where two teams have incompatible version constraints for the same shared module — are invisible to terraform plan. Neither team’s plan will fail. The conflict only surfaces when you try to standardize or upgrade the module org-wide.

It doesn’t catch semantic risks. >= 0.0.0 is technically a valid constraint. terraform plan will happily accept it. But it’s a ticking clock.

Static analysis addresses these gaps. No credentials, no init, no state — just parse the HCL and reason about what the constraints mean.

How MonPhare Works

MonPhare runs in three passes over one or more local directories or remote repositories.

Pass 1: HCL parsing. Walk the file tree, extract all module, provider, and terraform blocks, collect source and version fields along with file location. The parser handles real-world edge cases that the HCL spec doesn’t cleanly specify: dynamic source values, for_each on modules, workspace overrides.

Pass 2: Graph construction. Build a directed dependency graph from root modules through all transitive references. Each node in the graph carries its constraint set and the repo it came from. This is what enables cross-repo conflict detection — you’re looking at the full picture of who depends on what, not just one repo in isolation.

Pass 3: Constraint resolution. For each module in the graph, collect all version constraints imposed by all callers across all scanned repos. Run semver range intersection. Flag anything that produces a problematic result: empty intersection (conflict), no upper bound, wildcard, exact pin, pre-release, or a version range that matches a known-bad entry in your deprecation config.

The full finding taxonomy:

CodeSeverityWhat it means
missing-versionerrorNo constraint at all — non-deterministic
wildcard-constraintwarning* accepts anything
broad-constraintwarning>= 0.0.0 is effectively the same
no-upper-boundwarning>= 3.0 lets breaking majors in
exact-versioninfoFrozen — no patch updates
prerelease-versioninfoPre-release in production

Deprecation Tracking

Beyond constraint patterns, MonPhare lets you encode your organization’s specific knowledge about which module versions are dangerous.

monphare.yaml
deprecations:
modules:
"terraform-aws-modules/vpc/aws":
- version: "1.0.1"
reason: "Critical security vulnerability in VPC module versions before 3.0"
severity: error
replacement: "terraform-aws-modules/vpc/aws >= 5.0.0"
- version: ">= 2.0.8, < 3.0.0"
reason: "Breaking API changes, migrate to v5"
severity: warning
replacement: "terraform-aws-modules/vpc/aws >= 5.0.0"
providers:
"hashicorp/azurerm":
versions:
- version: "> 0.0.1, < 3.50.0"
reason: "Multiple CVEs in versions before 3.50.0"
severity: error
replacement: ">= 3.50.0"

When any scanned repo uses a module version that falls within a deprecated range, MonPhare flags it with the reason and the recommended replacement. Security teams can maintain this config centrally; individual teams consume it through CI.

Running It

The simplest case — scan a local directory:

Terminal window
monphare scan ./terraform

Scan a remote repo without cloning it first:

Terminal window
monphare scan https://github.com/terraform-aws-modules/terraform-aws-vpc

Scan an entire GitHub org (private org requires a token):

Terminal window
export MONPHARE_GIT_TOKEN=ghp_xxx
monphare scan --github my-org

JSON output for parsing in CI:

Terminal window
monphare scan ./terraform --format json --output report.json

The --strict flag makes warnings fail the build, useful when you want to enforce constraint hygiene as a hard requirement:

Terminal window
monphare scan ./terraform --strict

A GitHub Actions integration looks like this:

- name: Analyze Terraform constraints
run: monphare scan ./terraform --strict --format json --output report.json

Exit codes are explicit: 0 is clean, 1 is warnings with --strict or a runtime error, 2 is constraint errors.

The Dependency Graph

For larger module graphs, the graph command lets you see how modules relate before you upgrade anything:

Terminal window
# Mermaid format — renders directly in GitHub PRs
monphare graph ./terraform --format mermaid
# DOT format for Graphviz
monphare graph ./terraform --format dot --output deps.dot

This is useful for blast radius analysis. Before upgrading a shared module, you can see exactly which repos and which modules depend on it, and whether any of them have constraints that won’t accept the new version.

The Constraint Arithmetic Problem

The part that took the longest to get right wasn’t parsing HCL — it was the semver range intersection logic.

When you have a single constraint like ~> 5.0, it’s straightforward. But when you’re collecting constraints from multiple callers:

repo-a: >= 5.0, < 6.0
repo-b: ~> 5.1
repo-c: != 5.2.0
repo-d: >= 5.3

You need to determine whether there’s any version that satisfies all four simultaneously. The ~> pessimistic operator has different semantics depending on specificity (~> 5.0 means >= 5.0, < 6.0, but ~> 5.0.0 means >= 5.0.0, < 5.1.0). Mixing != exclusions with range constraints requires tracking exclusion sets separately.

Rust’s type system helps here. The constraint types are explicit, the intersection operations are exhaustively pattern-matched, and the test suite covers the combinations that are easy to get wrong.

When to Use It

MonPhare is most useful in two situations:

As a CI gate. Add it to your Terraform repos’ PR checks with --strict. Constraint issues get caught at the PR stage, not during a production apply or an incident postmortem.

As an org-wide audit. When onboarding a new org’s repos into a platform team’s oversight, point MonPhare at the entire org and get a full picture of constraint hygiene before you touch anything. The cross-repo conflict detection is particularly valuable here — it finds incompatibilities that would only surface during a coordinated module upgrade.


The project is on GitHub at tanguc/MonPhare. See the project page for architecture details and installation options.