Terraform Module Drift Is Silently Breaking Your Infrastructure
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:
module "vpc" { source = "terraform-aws-modules/vpc/aws" # no version — pulls latest on every init}
# team-b/main.tfmodule "vpc" { source = "terraform-aws-modules/vpc/aws" version = ">= 0.0.0" # accepts anything, including breaking majors}
# team-c/main.tfmodule "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 initon a fresh workstation will pull a different module version than the last apply. - team-b silently pulls breaking major versions. One day
>= 0.0.0resolves 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:
| Code | Severity | What it means |
|---|---|---|
missing-version | error | No constraint at all — non-deterministic |
wildcard-constraint | warning | * accepts anything |
broad-constraint | warning | >= 0.0.0 is effectively the same |
no-upper-bound | warning | >= 3.0 lets breaking majors in |
exact-version | info | Frozen — no patch updates |
prerelease-version | info | Pre-release in production |
Deprecation Tracking
Beyond constraint patterns, MonPhare lets you encode your organization’s specific knowledge about which module versions are dangerous.
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:
monphare scan ./terraformScan a remote repo without cloning it first:
monphare scan https://github.com/terraform-aws-modules/terraform-aws-vpcScan an entire GitHub org (private org requires a token):
export MONPHARE_GIT_TOKEN=ghp_xxxmonphare scan --github my-orgJSON output for parsing in CI:
monphare scan ./terraform --format json --output report.jsonThe --strict flag makes warnings fail the build, useful when you want to enforce constraint hygiene as a hard requirement:
monphare scan ./terraform --strictA GitHub Actions integration looks like this:
- name: Analyze Terraform constraints run: monphare scan ./terraform --strict --format json --output report.jsonExit 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:
# Mermaid format — renders directly in GitHub PRsmonphare graph ./terraform --format mermaid
# DOT format for Graphvizmonphare graph ./terraform --format dot --output deps.dotThis 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.0repo-b: ~> 5.1repo-c: != 5.2.0repo-d: >= 5.3You 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.