DEV Community

Cover image for The IaC Supply Chain Nobody's Securing — Part 2: From Policies to Enforcement
Alejandro Velez
Alejandro Velez

Posted on

The IaC Supply Chain Nobody's Securing — Part 2: From Policies to Enforcement

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": [ ... ]
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • VPC module would trigger Policy 1 (~> 5.0 is 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]
    )
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The HTML report immediately reveals:

  • Shared modules: terraform-aws-modules/vpc/aws used 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,

SBOM - Provider Compatibility 1

SBOM - Provider Compatibility 2

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
Enter fullscreen mode Exit fullscreen mode

You can find the repo example in:

GitHub logo thothforge / org-iac-policies

Organization IaC Policy Repository - Governance rules for ThothCTL Framework (OPA/Rego)

Organization IaC Policies

ThothCTL Compatible

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🏗️ 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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

  1. 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.

  2. Soft enforcement before hard — running --enforcement soft for 2 weeks let teams see what would break without actually breaking. This built trust in the policies before blocking deploys.

  3. 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.

  4. 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:

  1. Inventory (Part 1) — generate CycloneDX SBOMs that cover modules, providers, versions, and sourcing metadata
  2. Governance (this post) — define OPA/Rego policies for pinning, staleness, provenance, and approved sources
  3. 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

Supply Chain Security

SLSA & Provenance

CycloneDX & SBOM

AWS Documentation

ThothCTL & Series

✨ Alejandro Velez, Platform Engineering Latam Lead @ GFT | AWS Ambassador

Top comments (0)