Recap: Where We Left Off
In Part 1, we covered the IaC supply chain security gap that most teams are ignoring:
- The problem: IaC modules run with full cloud credentials, have no lock file, no cryptographic signing, and no integrity verification — yet nobody treats them as a supply chain risk
- Real incidents: Registry attacks, GPG key expiration, typosquatting risks in the Terraform ecosystem
- CycloneDX 1.6 SBOMs: How to generate IaC-specific SBOMs that cover both modules AND providers with freshness metadata
- Enterprise sourcing patterns: Public registry, private registry, Git forks, artifact proxies — and what each actually mitigates
We now have visibility — a CycloneDX SBOM generated in CI/CD that tells us exactly what we consume, how stale it is, and where it comes from.
But visibility without enforcement is just a dashboard nobody checks. This post turns that SBOM into a governance engine with OPA/Rego policies, dependency graph analysis, and hard enforcement gates.
Version Governance with OPA/Rego Policies
The SBOM generated in Part 1 becomes the input document for Rego policies. This is the key architectural decision: the SBOM is the contract between inventory and governance. Any tool that produces a CycloneDX SBOM with the right properties can feed into these policies.
The Input: CycloneDX SBOM Structure
All policies below evaluate against a CycloneDX 1.6 SBOM generated by thothctl inventory iac --check-versions --report-type cyclonedx. Here's the relevant structure the Rego policies traverse:
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [
{
"type": "library",
"name": "terraform-aws-modules/vpc/aws",
"version": "5.16.0",
"group": "registry.terraform.io/terraform-aws-modules",
"properties": [
{ "name": "iac:version-constraint", "value": "~> 5.0" },
{ "name": "iac:pinned", "value": "false" },
{ "name": "iac:days-since-update", "value": "95" },
{ "name": "iac:latest-available", "value": "5.17.0" },
{ "name": "iac:source-type", "value": "registry" }
],
"externalReferences": [
{ "type": "vcs", "url": "https://github.com/terraform-aws-modules/terraform-aws-vpc" }
]
},
{
"type": "library",
"name": "terraform-aws-modules/rds-aurora/aws",
"version": "9.12.0",
"group": "registry.terraform.io/terraform-aws-modules",
"properties": [
{ "name": "iac:version-constraint", "value": "= 9.12.0" },
{ "name": "iac:pinned", "value": "true" },
{ "name": "iac:days-since-update", "value": "210" },
{ "name": "iac:latest-available", "value": "9.15.2" },
{ "name": "iac:source-type", "value": "registry" }
],
"externalReferences": [
{ "type": "vcs", "url": "https://github.com/terraform-aws-modules/terraform-aws-rds-aurora" }
]
}
],
"dependencies": [ ... ]
}
In this example:
-
VPC module would trigger Policy 1 (
~> 5.0is a range) and Policy 2 WARN (95 days stale) - RDS Aurora module would pass Policy 1 (exactly pinned) but trigger Policy 2 DENY (210 days > 180 threshold)
Policy 1: No Version Ranges in Production
# supply_chain/policy/no_version_ranges.rego
# Evaluates CycloneDX 1.6 SBOM input to enforce exact version pinning.
# DENY: modules using version ranges (~>, >=, >, <) instead of exact pins.
package thothctl.supply_chain
import rego.v1
# DENY: modules using pessimistic constraint operator (~>)
deny contains msg if {
component := input.components[_]
prop := component.properties[_]
prop.name == "iac:version-constraint"
contains(prop.value, "~>")
msg := sprintf(
"Module '%s' uses version range '%s'. Pin to exact version (= x.y.z) for supply chain safety.",
[component.name, prop.value]
)
}
# DENY: modules using open lower-bound range (>=)
deny contains msg if {
component := input.components[_]
prop := component.properties[_]
prop.name == "iac:version-constraint"
contains(prop.value, ">=")
msg := sprintf(
"Module '%s' uses open range '%s'. Pin to exact version (= x.y.z).",
[component.name, prop.value]
)
}
# DENY: modules using greater-than without equality (>)
deny contains msg if {
component := input.components[_]
prop := component.properties[_]
prop.name == "iac:version-constraint"
constraint := prop.value
not contains(constraint, ">=")
regex.match(`>\s*\d`, constraint)
msg := sprintf(
"Module '%s' uses unbounded range '%s'. Pin to exact version (= x.y.z).",
[component.name, constraint]
)
}
# DENY: modules using less-than constraint (<, <=)
deny contains msg if {
component := input.components[_]
prop := component.properties[_]
prop.name == "iac:version-constraint"
regex.match(`<`, prop.value)
msg := sprintf(
"Module '%s' uses upper-bound range '%s'. Pin to exact version (= x.y.z).",
[component.name, prop.value]
)
}
# DENY: modules explicitly marked as not pinned
deny contains msg if {
component := input.components[_]
prop := component.properties[_]
prop.name == "iac:pinned"
prop.value == "false"
msg := sprintf(
"Module '%s' is not pinned to an exact version. Use '= x.y.z' version constraint.",
[component.name]
)
}
Why this matters: On a clean CI runner, terraform init resolves version ranges against the registry. Without a module lock file, today's build may pull a different version than yesterday's — silently. Exact pinning (= 5.16.0) is the only defense.
Policy 2: Maximum Staleness Threshold
# supply_chain/policy/max_staleness.rego
# Evaluates CycloneDX 1.6 SBOM input to enforce module freshness.
# WARN at 90 days stale, DENY at 180 days stale.
# Thresholds configurable via config.yaml (data.config.supply_chain.*).
package thothctl.supply_chain
import rego.v1
# Default thresholds (overridden by data.config.supply_chain if present)
default warn_threshold_days := 90
default deny_threshold_days := 180
# Use config values if available
warn_threshold_days := data.config.supply_chain.max_staleness_days_warn if {
data.config.supply_chain.max_staleness_days_warn
}
deny_threshold_days := data.config.supply_chain.max_staleness_days_deny if {
data.config.supply_chain.max_staleness_days_deny
}
# Allow-listed modules exempt from staleness checks (slow release cycles)
exceptions contains name if {
name := data.config.supply_chain.exceptions.allow_stale[_]
}
# WARN: modules between warn and deny thresholds
warn contains msg if {
component := input.components[_]
component.type == "library"
not component.name in exceptions
prop := component.properties[_]
prop.name == "iac:days-since-update"
days := to_number(prop.value)
days > warn_threshold_days
days <= deny_threshold_days
latest := _get_latest(component)
msg := sprintf(
"Module '%s' is %d days stale (warn threshold: %d days). Consider upgrading to %s.",
[component.name, days, warn_threshold_days, latest]
)
}
# DENY: modules exceeding the hard staleness limit
deny contains msg if {
component := input.components[_]
component.type == "library"
not component.name in exceptions
prop := component.properties[_]
prop.name == "iac:days-since-update"
days := to_number(prop.value)
days > deny_threshold_days
latest := _get_latest(component)
msg := sprintf(
"CRITICAL: Module '%s' is %d days stale (limit: %d days). Upgrade to %s required before deploy.",
[component.name, days, deny_threshold_days, latest]
)
}
# WARN: modules that are multiple major versions behind
warn contains msg if {
component := input.components[_]
component.type == "library"
prop := component.properties[_]
prop.name == "iac:versions-behind"
behind := to_number(prop.value)
behind >= 3
msg := sprintf(
"Module '%s' is %d versions behind latest. Review changelog for breaking changes.",
[component.name, behind]
)
}
# Helper: extract latest available version from component properties
_get_latest(component) := latest if {
prop := component.properties[_]
prop.name == "iac:latest-available"
latest := prop.value
} else := "unknown"
Why this matters: Stale modules miss security patches. A 180-day threshold means the module hasn't been updated in 6 months — likely missing at least one security fix from the upstream maintainer.
Policy 3: Required Source Provenance
# supply_chain/policy/provenance.rego
# Evaluates CycloneDX 1.6 SBOM input to enforce module provenance.
# Requires VCS traceability and approved source registries/organizations.
package thothctl.supply_chain
import rego.v1
# DENY: modules without a VCS external reference (no traceability)
deny contains msg if {
component := input.components[_]
component.type == "library"
not _has_vcs_reference(component)
msg := sprintf(
"Module '%s' has no VCS provenance. Only modules with traceable source repositories are allowed.",
[component.name]
)
}
# DENY: modules from unapproved registries or organizations
deny contains msg if {
component := input.components[_]
component.type == "library"
not _is_approved_source(component)
msg := sprintf(
"Module '%s' is from unapproved source '%s'. Allowed sources: %v",
[component.name, object.get(component, "group", "unknown"), data.approved_sources]
)
}
# WARN: modules from public registry without verified publisher badge
warn contains msg if {
component := input.components[_]
component.type == "library"
_is_public_registry(component)
not _is_verified_publisher(component)
msg := sprintf(
"Module '%s' is from the public registry but not from a verified publisher. Consider using a verified alternative.",
[component.name]
)
}
# DENY: modules sourced from arbitrary URLs (not Git or registry)
deny contains msg if {
component := input.components[_]
component.type == "library"
prop := component.properties[_]
prop.name == "iac:source-type"
prop.value == "http"
msg := sprintf(
"Module '%s' is sourced via plain HTTP URL. Use a Git repository or registry source for integrity.",
[component.name]
)
}
# ── Helpers ─────────────────────────────────────────────────────────────────
_has_vcs_reference(component) if {
ref := component.externalReferences[_]
ref.type == "vcs"
}
_is_approved_source(component) if {
approved := data.approved_sources[_]
startswith(component.group, approved)
}
_is_public_registry(component) if {
startswith(object.get(component, "group", ""), "registry.terraform.io")
}
_is_verified_publisher(component) if {
prop := component.properties[_]
prop.name == "iac:verified-publisher"
prop.value == "true"
}
Policy Configuration
The policies are designed to be configurable without modifying Rego code. Two data files control behavior:
config.yaml — thresholds, exceptions, and feature flags:
# supply_chain/policy/config.yaml
config:
supply_chain:
max_staleness_days_warn: 90
max_staleness_days_deny: 180
require_exact_pinning: true
require_vcs_provenance: true
exceptions:
allow_stale:
- "terraform-aws-modules/transit-gateway/aws" # releases quarterly
approved_sources.json — allowlist of trusted registries and organizations:
{
"approved_sources": [
"registry.terraform.io/terraform-aws-modules",
"registry.terraform.io/hashicorp",
"git::https://github.com/your-org/terraform-modules",
"app.terraform.io/your-org"
]
}
Why this matters: Without an approved source list, any developer can introduce a module from any publisher. This is the IaC equivalent of allowing unrestricted npm installs from unknown packages — except with IAM credentials at stake. The verified publisher check adds a second layer: even within approved registries, unverified publishers get flagged. And blocking plain HTTP sources prevents modules from being fetched without integrity guarantees.
Running Policies in CI/CD
# ─── Evaluate SBOM against all supply chain policies (ThothCTL) ───
thothctl workflow devsecops --phase secure \
--policy-dir ./supply_chain/policy/ \
--enforcement hard
# ─── Or standalone with conftest (recommended for CI) ───
conftest test \
--policy supply_chain/policy/ \
--data supply_chain/policy/config.yaml \
Reports/sbom-cyclonedx-1.6.json
# ─── Or with OPA directly ───
opa eval \
--input Reports/sbom-cyclonedx-1.6.json \
--data ./supply_chain/policy/ \
"data.thothctl.supply_chain.deny"
Module Dependency & Blast Radius Analysis
Beyond flat inventory, understanding how modules compose reveals hidden risk propagation. A single stale module may be consumed by 5 stacks across 3 accounts — the blast radius is invisible without cross-referencing the SBOM.
The CycloneDX SBOM's dependencies graph combined with --check-versions staleness data gives you this visibility automatically:
# ─── Generate SBOM with version checks (includes dependency graph) ───
thothctl inventory iac --check-versions --report-type cyclonedx
# ─── HTML report with per-stack breakdown and staleness badges ───
thothctl inventory iac --check-versions --report-type html
# ─── View in dashboard with filtering ───
thothctl dashboard launch
# Navigate to: SBOM → Dependencies tab
The HTML report immediately reveals:
-
Shared modules:
terraform-aws-modules/vpc/awsused by 3 stacks — updating impacts all three - Staleness clusters: Module groups that drift together (same upstream maintainer)
- Provider version fragmentation: Stacks A/B on aws 5.80, Stack C on 5.82
- Internal vs external: Your-org modules (under your control) vs public registry (supply chain risk)
For example,
Applying at Scale
In practice, managing IaC supply chain across many environments and projects — whether you use Terraform, OpenTofu, Terragrunt, or a combination — requires governance that scales. Here's how:
Centralized Policy Repository
your-org/org-iac-policies/
├── supply_chain/
│ └── policy/
│ ├── no_version_ranges.rego
│ ├── max_staleness.rego
│ ├── provenance.rego
│ ├── config.yaml
│ └── approved_sources.json
├── cost/
│ └── policy/
│ ├── budget.rego
│ ├── resources.rego
│ └── config.yaml
├── compliance/
│ ├── features/ # terraform-compliance BDD
│ └── soc2/policy/
├── shared/
│ └── policy/
│ ├── naming.rego
│ ├── tagging.rego
│ └── regions.rego
└── rules/ # ThothCTL project rules
├── base.toml
├── terraform_module.toml
└── terraform-terragrunt.toml
You can find the repo example in:
thothforge
/
org-iac-policies
Organization IaC Policy Repository - Governance rules for ThothCTL Framework (OPA/Rego)
Organization IaC Policies
Organization-level policy repository for Infrastructure as Code governance. This repository defines the security, compliance, naming, and architectural rules enforced across all IaC projects via ThothCTL.
Structure
org-iac-policies/
├── rules/ # ThothCTL project structure rules
│ ├── base.toml # All project types (mandatory)
│ ├── terraform-terragrunt.toml # Terraform+Terragrunt projects
│ ├── terraform_module.toml # Terraform modules
│ └── cdkv2.toml # CDK v2 projects
├── shared/policy/ # Cross-project OPA/Rego policies
│ ├── hcl/ # Terraform/OpenTofu input
│ │ ├── naming.rego
│ │ ├── tagging.rego
│ │ └── regions.rego
│ └── cloudformation/ # CloudFormation/SAM/CDK input
│ ├── naming.rego
│ ├── tagging.rego
│ └── regions.rego
├── compliance/
│ ├── features/ # Terraform-compliance BDD scenarios
│ │ ├── encryption.feature
│ │ ├── tagging.feature
│ │ └── networking.feature
│ └── soc2/policy/
│ ├── hcl/soc2_controls.rego
│ └── cloudformation/soc2_controls.rego
├── layers/ # Infrastructure layer policies
│ ├── networking/policy/{hcl,cloudformation}/vpc.rego
│ └── security/policy/{hcl,cloudformation}/{encryption,iam}.rego
├── workloads/ # Workload-type…And you can test locally or just add a pre commit task to validate, also this is used as context for intent IaC or coworking with agents.
# All teams reference the same policy repo in CI
# Clone policies locally (or use Git submodules)
git clone https://github.com/your-org/org-iac-policies.git ./policies
thothctl workflow devsecops --phase secure \
--policy-dir ./policies/supply_chain/policy/ \
--enforcement hard
Per-Team Staleness Budgets
Not every team moves at the same speed. The policies read thresholds from config.yaml via OPA's data mechanism, so each team can maintain their own configuration without modifying Rego code:
# supply_chain/policy/config.yaml — team-specific overrides
config:
supply_chain:
max_staleness_days_warn: 60 # stricter for security-critical team
max_staleness_days_deny: 120
require_exact_pinning: true
require_vcs_provenance: true
exceptions:
allow_stale:
- "terraform-aws-modules/transit-gateway/aws" # releases quarterly
# Each team passes their own config alongside the shared policies
conftest test \
--policy supply_chain/policy/ \
--data ./team-config.yaml \
Reports/sbom-cyclonedx-1.6.json
# Or via ThothCTL (policies + config in the same directory)
thothctl workflow devsecops --phase secure \
--policy-dir ./supply_chain/policy/ \
--enforcement hard
The Rego policies use configurable defaults — if no config.yaml is provided, they fall back to 90/180 days:
default warn_threshold_days := 90
default deny_threshold_days := 180
warn_threshold_days := data.config.supply_chain.max_staleness_days_warn if {
data.config.supply_chain.max_staleness_days_warn
}
Weekly Supply Chain Digest
Governance policies catch violations at deploy time — but modules don't stop aging between deploys. A scheduled weekly scan detects staleness drift, newly published CVEs affecting your modules, and unauthorized source changes even when no deployment is in progress:
# ─── Scheduled weekly report (GitHub Actions cron) ───
thothctl inventory iac \
--report-type cyclonedx \
--check-versions \
--project-name "weekly-supply-chain-audit"
# ─── Publish results to vulnerability platform for trending ───
thothctl inventory iac --check-versions --publish-sbom dependency-track
# Compare with previous week's SBOM
# Alert on: new modules added, versions changed, staleness increases
🏗️ Implementation Path
Phase 1: Visibility (Week 1-2)
| Step | Command | Outcome |
|---|---|---|
| Generate first SBOM | thothctl inventory iac --report-type cyclonedx --check-versions |
Know what you consume |
| Identify unpinned modules | Review SBOM properties for iac:pinned = false
|
Find the risky constraints |
| Pin all versions | Replace ~> and >= with = in all .tf files |
Eliminate silent upgrades |
| Commit lock file | git add .terraform.lock.hcl |
Provider consistency |
Phase 2: Governance (Week 3-4)
| Step | Command | Outcome |
|---|---|---|
| Create policy repo | Add Rego policies to shared Git repo | Centralized rules |
| Enable soft enforcement |
--enforcement soft in CI |
See violations without blocking |
| Define approved sources | Create approved_sources.json
|
Whitelist trusted registries |
| Set staleness thresholds | Configure .thothcf.toml per project |
Team-appropriate limits |
Phase 3: Enforcement (Week 5-6)
| Step | Command | Outcome |
|---|---|---|
| Enable hard enforcement |
--enforcement hard in CI |
Block non-compliant deploys |
| SBOM as release artifact | Upload to artifact store per release | Audit trail |
| Weekly cron scan | GitHub Actions schedule | Catch new CVEs + staleness |
| Publish to vulnerability platform |
--publish-sbom defectdojo or --publish-sbom secobserve
|
Centralized vulnerability dashboard |
Publishing to Vulnerability Management Platforms
Once the SBOM and scan results are generated, the next step is feeding them into a centralized vulnerability management platform. ThothCTL supports publishing directly to DefectDojo and SecObserve — no custom scripting required:
# ─── Publish SBOM to DefectDojo ───
thothctl inventory iac --check-versions --publish-sbom defectdojo
# ─── Publish SBOM to SecObserve ───
thothctl inventory iac --check-versions --publish-sbom secobserve
# ─── Publish scan findings (SARIF) to SecObserve ───
thothctl scan iac -t checkov -t kics --publish-to secobserve
Configuration — both integrations use environment variables:
# DefectDojo
export DEFECTDOJO_URL="https://defectdojo.internal.your-org.com"
export DEFECTDOJO_TOKEN="your-api-token"
export DEFECTDOJO_PRODUCT_NAME="infra-platform"
# SecObserve
export SECOBSERVE_URL="https://secobserve.internal.your-org.com"
export SECOBSERVE_API_TOKEN="your-api-token"
export SECOBSERVE_PRODUCT_NAME="infra-platform"
# Dependency-Track
export DTRACK_URL="https://dtrack.internal.your-org.com"
export DTRACK_API_KEY="your-api-key"
| Platform | What it receives | Format | Use case |
|---|---|---|---|
| DefectDojo | SBOM + findings | CycloneDX | Enterprise AppSec programs, compliance dashboards, finding deduplication |
| SecObserve | SBOM + scan results | CycloneDX + SARIF | Lightweight vulnerability & license tracking, open-source teams |
| Dependency-Track | SBOM | CycloneDX POST to API | Component-level risk analysis, policy evaluation |
This closes the loop: inventory generates the SBOM → policies enforce governance → the vulnerability platform provides long-term tracking, trend analysis, and audit evidence across all projects and accounts.
📊 Results in Practice
After 6 weeks of incremental adoption across a platform team managing multi-account AWS environments:
| Metric | Before | After | Impact |
|---|---|---|---|
| Module inventory visibility | 0% (unknown) | 100% (full SBOM) | From blind to complete |
| Unpinned module versions | 31 of 47 (66%) | 0 of 47 (0%) | Silent upgrades eliminated |
| Modules with known CVEs | Unknown | 3 identified, 3 remediated | Proactive security |
| Mean time to detect stale module | Never (quarterly audit) | Immediate (CI/CD) | Continuous awareness |
| Unapproved source modules | 4 (discovered in audit) | 0 (blocked by policy) | Typosquatting prevention |
| CI/CD pipeline failures from supply chain | 2/month (GPG expiry, registry outage) | 0 (private mirrors + pinning) | Reliability |
| Time to produce audit evidence | 2+ days (manual spreadsheet) | 30 seconds (thothctl inventory iac) |
Compliance speed |
What Moved the Needle Most
Pinning all versions in Week 1 — eliminated 66% of the supply chain risk surface in a single day. The hardest part wasn't the change — it was knowing which modules needed pinning. The SBOM made that trivial.
Soft enforcement before hard — running
--enforcement softfor 2 weeks let teams see what would break without actually breaking. This built trust in the policies before blocking deploys.Approved sources policy — caught 4 modules from unknown publishers that had been silently introduced over months. One was a typosquat-adjacent name that warranted immediate investigation.
Weekly digest cron — staleness detection caught a critical security patch in the VPC module that had been available for 45 days without anyone noticing. The weekly alert triggered an upgrade the next sprint.
Conclusion
The IaC supply chain governance pattern is:
- Inventory (Part 1) — generate CycloneDX SBOMs that cover modules, providers, versions, and sourcing metadata
- Governance (this post) — define OPA/Rego policies for pinning, staleness, provenance, and approved sources
- Enforcement — run policies as CI/CD gates that block non-compliant deploys
The technology is straightforward. The challenge is organizational — getting teams to treat IaC dependencies with the same rigor they apply to application dependencies. The 6-week implementation path proves it's achievable incrementally: visibility first, then soft enforcement, then hard gates.
As SLSA (Supply-chain Levels for Software Artifacts) matures, build provenance and artifact signing will become applicable to Terraform modules. The SBOM we're generating today becomes the foundation for full SLSA compliance when that tooling arrives.
What governance policies would you add? Are there patterns I missed, or edge cases your team has hit? I'd love to hear what's blocking your move from visibility to enforcement 👇
References
OPA & Policy-as-Code
- Open Policy Agent Documentation
- OPA Rego Language Reference
- How to Validate Policy-as-Code Without Breaking Builds (JFrog)
- Terraform Policy as Code (OneUptime)
Supply Chain Security
- BoostSecurity: Erosion of Trust — Terraform Registry Vulnerabilities
- HCSEC-2024-04: Terraform Registry Module Supply Chain Security Improvements
- CVE-2024-56802: Tapir Private Registry Vulnerability
SLSA & Provenance
- SLSA: Where the Boundaries Fall (May 2026)
- SLSA Security Levels Specification v1.0
- SLSA Build Provenance and Signing in CI/CD
CycloneDX & SBOM
AWS Documentation
- AWS Prescriptive Guidance: Terraform Provider Best Practices
- AWS Well-Architected Framework: Application Security — Supply Chain
- AWS Security Reference Architecture
ThothCTL & Series
- ThothForge/ThothCTL — GitHub
- Part 1: The IaC Supply Chain Nobody's Securing — Modules, SBOMs, and the Visibility Gap
- Previous: The Autonomous Infrastructure Blueprint
✨ Alejandro Velez, Platform Engineering Latam Lead @ GFT | AWS Ambassador













