Sergen Tanguc
Selected Works
// Case Study

MonPhare

Your Terraform modules are drifting. This tool catches it before prod does.

Terraform and OpenTofu module constraint analyzer — catches version drift, deprecated modules, and risky constraints across all your repos before production does.

Rust Terraform OpenTofu DevOps Static Analysis CLI
View on GitHub

mon-far — French for “my lighthouse”. Because someone has to shine a light on your Terraform constraints.

I built this after spending a weekend debugging a production incident that traced back to a module version constraint nobody had noticed was wrong. The module had no upper bound, a breaking major version dropped, and terraform init on a fresh CI runner pulled it in silently. No warning. No error. Just broken infra.

The fix took twenty minutes. Finding the cause took four hours.

The Problem

Across a large platform team’s repos, this is happening silently right now:

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

terraform init is non-deterministic for team-a. Team-b silently pulls a breaking major on the next run. Team-c misses CVE patches for months. None of this surfaces in code review. It surfaces in a 2am incident.

The frustrating part is that these aren’t hard problems to catch — you just need something that actually looks. That’s what MonPhare does.

What It Catches

IssueRiskExample
Missing versionNon-deterministic initssource = "aws-modules/vpc/aws" (no version)
Too broadAccepts breaking changesversion = ">= 0.0.0"
WildcardSame risk, different syntaxversion = "*"
No upper boundMajor version creepversion = ">= 3.0"
Exact pinFrozen — no security patchesversion = "= 2.44.0"
Pre-release in prodInstability riskversion = "~> 2.0.0-beta"
Cross-repo conflictIncompatible ranges across teamsrepo-a: >= 5.0 vs repo-b: < 5.0
Deprecated moduleKnown CVE, retired versionvpc/aws at 1.0.1
Deprecated providerSecurity team-flagged rangeazurerm < 3.50.0
Deprecated runtimeTerraform/OpenTofu too oldterraform < 0.13.0

How It Works

Static analysis of HCL across one or more repositories. No Terraform binary, no terraform init, no credentials required to get a full constraint picture.

Three passes under the hood:

  1. HCL parsing — walk the file tree, extract all module, provider, and terraform blocks, collect source and version fields with file location
  2. Graph construction — build a directed dependency graph from root modules down through all transitive references; track which repo each constraint comes from for cross-repo conflict detection
  3. Constraint resolution — for each module, collect all version constraints from all callers across all scanned repos, run semver range intersection, flag anything problematic

The deprecation rules come from a monphare.yaml config you define yourself — specific module versions with CVE references, provider version ranges your security team has flagged, runtime version floors. The tool doesn’t know what “deprecated” means for your environment; you tell it.

Real Output

MonPhare v0.3.0 [FAILED] 3 errors, 3 warnings
Scanned: 1 files, 4 modules, 3 providers
+------+-----------------------+----------------+----------+-----------+
| Sev | Resource | Issue | Current | File |
+------+-----------------------+----------------+----------+-----------+
| ERR | module.vpc_no_version | No version | - | main.tf:0 |
| ERR | module.git_module | No version | - | main.tf:0 |
| ERR | provider.aws | No version | - | main.tf:0 |
| WARN | resource.google | No upper bound | - | main.tf:0 |
| WARN | resource.azurerm | No upper bound | - | main.tf:0 |
| WARN | provider.google | Too broad | >= 0.0.0 | main.tf:0 |
| INFO | resource.eks_exact | Exact version | - | main.tf:0 |
+------+-----------------------+----------------+----------+-----------+
Fix errors to pass.

CLI Usage

Terminal window
# scan a local directory
monphare scan ./terraform
# scan a remote repo directly (public repos, no token needed)
monphare scan https://github.com/terraform-aws-modules/terraform-aws-vpc
# scan an entire GitHub org
export MONPHARE_GIT_TOKEN=ghp_xxx
monphare scan --github my-org
# output JSON for CI integration
monphare scan ./terraform --format json --output report.json
# strict mode: exit code 1 on warnings (useful in CI)
monphare scan ./terraform --strict
# visualize the dependency graph as Mermaid (renders in GitHub)
monphare graph ./terraform --format mermaid

It also supports GitLab, Azure DevOps, and Bitbucket for the remote scanning path.

CI Integration

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

Exit codes: 0 = clean, 1 = warnings (with --strict) or runtime error, 2 = constraint errors.

Installation

Terminal window
# Homebrew
brew tap tanguc/tap && brew install monphare
# Docker
docker run --rm -v "$(pwd):/workspace" ghcr.io/tanguc/monphare scan /workspace
# From source
cargo install --path .

Pre-built binaries for Linux (x86_64, ARM64), macOS (Intel, Apple Silicon), and Windows on the releases page.

What I Learned Building It

HCL is a surprisingly irregular format. The grammar documentation has real gaps, and production Terraform code uses patterns the spec doesn’t clearly define — dynamic source values, for_each on modules, workspace-specific overrides. Building a tolerant parser that handles these edge cases took longer than all the analysis logic combined.

The constraint arithmetic was the other hard part. Semver range intersection sounds straightforward until you’re mixing ~>, >=, !=, and exact pins in the same constraint set. The cross-repo conflict case adds another layer: collect constraints from N repos, check whether any combination of them produces an empty intersection. Get the intersection logic wrong and you either miss real conflicts or flag constraints that are actually fine.

Rust was the right choice here. The type system forces you to make the semver representation explicit and handle every edge case at compile time rather than discovering it in a test at runtime. It also meant the binary is fast enough to scan a large org’s repos without anyone complaining.