DEV Community

quixoticmonk
quixoticmonk

Posted on

Terraform Module Guard: Multi-Engine Policy Validation for Module Sources

It's November already !!! Are you in the naughty list or nice list ?

One of my wishlist items for HCP Terraform or Terraform enterprise has always been the ability to restrict what modules or providers can be used based on some list. Yes, if you have used GitHub actions the desire to have something similar comes from that interface of allowed actions. While that's a wishlist item, I have had clients who had asked for mechanisms to do this using their existing policy as code frameworks. And when I say Policy as Code frameworks or tools, I mean some of these:

  • checkov
  • opa
  • sentinel
  • Or a simple bash script

Whether you're using HCP Terraform with its enterprise features or the community edition with open-source tooling, you need consistent ways to validate module origins. This challenge led me to create terraform-module-guard, a collection of policies that validate Terraform module sources across multiple policy engines. The reason it exists is for purely selfish reasons as I often find myself trying to find that last time I used it which is in my filesystem somewhere.

The Problem

Let's dive into the problem statement further.Terraform modules can be sourced from various locations:

  • Public registries (registry.terraform.io)
  • Private registries
  • Git repositories
  • Local file paths or even HTTP URLs if I am not wrong

Without proper controls, teams might inadvertently use modules from untrusted sources, potentially introducing security vulnerabilities or compliance violations. Different organizations use different policy engines based on their Terraform deployment model:

  • HCP Terraform users typically leverage Sentinel or OPA for policy enforcement
  • Community edition users often rely on open-source tools like OPA, Checkov, or pre-commit hooks

My intention

My intention here is purely as a frame of reference of what can be done to solve this problem across some common PaC frameworks.

Let's start with the configuration elements. In the repo ,allowed-sources.yaml represents an example of what is allowed when considering the sources of the modules. I am using the inline versions of these for OPA and Sentinel. There might be ways to load the file into the evaluations. I might look into that later.

allowed_sources:
  registry:
    - "cloudposse/*"
    - "terraform-aws-modules/*"
    - "aws-ia/*"
  git:
    - "github.com/cloudposse/*"
    - "github.com/terraform-aws-modules/*"
    - "github.com/aws-ia/*"
Enter fullscreen mode Exit fullscreen mode

This is merely an example of the keys and values I wanted to consider. You may have variations of this which you can absolutely add and update the policies with.

Data Set

The repository includes test files that demonstrate the policy validation across all engines. These examples were tested against:

  • Checkov: v3.2.458
  • OPA: v1.9.0

✅ Good Module Sources

Let's define some passing examples. examples/pass/pass.tf:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

module "label" {
  source  = "aws-ia/label/aws"
  version = "~> 0.0.5"
}

module "cloudposse_module" {
  source  = "cloudposse/label/null"
  version = "~> 0.25.0"
}

module "git_terraform_aws" {
  source = "git::ssh://git@github.com/terraform-aws-modules/terraform-aws-s3-bucket.git?ref=v4.0.0"
}

module "git_cloudposse" {
  source = "git::ssh://git@github.com/cloudposse/terraform-null-label.git?ref=0.25.0"
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad Module Sources

Similarly some failing examples. Yes, I do have a passing source mixed in to validate that it does get flagged as passed. (Blame the QA in me) These should be present in the repo as examples/fail/fail.tf:

module "quixoticmonk_glue_1" {
  source = "quixoticmonk/glue/aws"
  version = "~> 0.0.3"
}

module "quixoticmonk_glue_2" {
  source = "quixoticmonk/glue/aws"
  version = "~> 0.0.3"
}

module "docker_secret" {
  source  = "bendrucker/docker-secret/kubernetes"
  version = "1.0.0"

  name      = "my-docker-secret"
  namespace = "default"
  registries = {
    "docker.io" = {
      username = "user"
      password = "pass"
      email    = "user@example.com"
    }
  }
}

module "git_quixoticmonk_s3" {
  source = "git::ssh://git@github.com/quixoticmonk/terraform-aws-s3.git?ref=main"
}

# This should PASS - allowed source
module "allowed_module" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}
Enter fullscreen mode Exit fullscreen mode

1. Sentinel (HCP Terraform)

For teams using HCP Terraform, you can use the Sentinel policy referenced here. Keep in mind that this uses a allowed_$$_sources input inside the policy itself. There may be a better way to define the list of sources and my sentinel kungfu is not that strong , yet. I should probably ask the sentinel team if there is a way to manage the sources.

You can either add this policy as part of a policyset via VCS connection or create a policy inline in the HCP Terraform UI and enforce it on a workspace.

For inline policies:

  • Workspaces -> Policies -> Create a new policy -> Select Sentinel and add the policy in the policy code (Sentinel) section

Create a Terraform workspace which has some of these modules being called in the configuration. Post plan, you should be able to see the details on Sentinel which should look something like below :

This policy validates module sources during plan and apply operations, blocking runs that use unauthorized modules.

2. Checkov

If you have used Terraform and built a CICD pipeline for provisioning infrastructure using Terraform, you would have heard about Checkov which has become a standard for static analysis of the Terraform configuration. I do think it can get noisy without the severity classification. But I personally love the custom checks option which I keep using across the board for many situations where I need some new policies specific to my customer or something unique for my situation.

# Run specific module source validation
checkov -f main.tf --external-checks-dir checkov/ --check CKV_TF_MODULE_SOURCE
Enter fullscreen mode Exit fullscreen mode

For examples/pass/pass.tf:

Passed checks: 5, Failed checks: 0, Skipped checks: 0

Check: CKV_TF_MODULE_SOURCE: "Ensure module source is from allowed list"
    PASSED for resource: vpc
    PASSED for resource: label
    PASSED for resource: cloudposse_module
    PASSED for resource: git_terraform_aws
    PASSED for resource: git_cloudposse
Enter fullscreen mode Exit fullscreen mode

Running against examples/fail/fail.tf. Yes, I did sneak in a passing source to verify if this actually does what I am expecting it to do.

Passed checks: 1, Failed checks: 4, Skipped checks: 0

Check: CKV_TF_MODULE_SOURCE: "Ensure module source is from allowed list"
    PASSED for resource: allowed_module

    FAILED for resource: quixoticmonk_glue_1
    File: /fail.tf:4-7
        4 | module "quixoticmonk_glue_1" {
        5 |   source = "quixoticmonk/glue/aws"
        6 |   version = "~> 0.0.3"
        7 | }

    FAILED for resource: quixoticmonk_glue_2
    File: /fail.tf:9-12
        9  | module "quixoticmonk_glue_2" {
        10 |   source = "quixoticmonk/glue/aws"
        11 |   version = "~> 0.0.3"
        12 | }

    FAILED for resource: docker_secret
    File: /fail.tf:14-27
        14 | module "docker_secret" {
        15 |   source  = "bendrucker/docker-secret/kubernetes"
        16 |   version = "1.0.0"

    FAILED for resource: git_quixoticmonk_s3
    File: /fail.tf:30-32
        30 | module "git_quixoticmonk_s3" {
        31 |   source = "git::ssh://git@github.com/quixoticmonk/terraform-aws-s3.git?ref=main"
        32 | }
Enter fullscreen mode Exit fullscreen mode

3. Open Policy Agent (OPA)

If you prefer OPA , you could use the example policy from the opa directory against a Terraform plan json. The steps would look like below:

# Generate Terraform plan in JSON format
terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

# Execute policy against Terraform plan JSON
opa exec --decision terraform/module_sources/deny --bundle opa/ tfplan.json

# Fail on violations (recommended for CI/CD)
opa exec --decision terraform/module_sources/deny --bundle opa/ --fail-non-empty tfplan.json
Enter fullscreen mode Exit fullscreen mode

And what do we see...

For the pass scenarios present in examples/pass/opa/input.json

{
  "result": [
    {
      "path": "examples/pass/opa/input.json",
      "result": [] # No violations.
    }
  ]
}
Exit code: 0
Enter fullscreen mode Exit fullscreen mode

Running against examples/fail/opa/input.json with --fail-non-empty:

{
  "result": [
    {
      "path": "examples/fail/opa/input.json",
      "result": [
        "Module 'docker_secret' uses disallowed source: bendrucker/docker-secret/kubernetes",
        "Module 'git_quixoticmonk_s3' uses disallowed source: git::ssh://git@github.com/quixoticmonk/terraform-aws-s3.git?ref=main",
        "Module 'quixoticmonk_glue_1' uses disallowed source: quixoticmonk/glue/aws",
        "Module 'quixoticmonk_glue_2' uses disallowed source: quixoticmonk/glue/aws"
      ]
    }
  ]
}
Exit code: 1
Enter fullscreen mode Exit fullscreen mode

The exit code on opa exec is really helpful when you are running this in a CICD pipeline if you are writing tests to validate your policies as well.

4. Pre-commit Hooks

You could technically include any of the above in the pre-commit step as a hook. I decided to create a bash script which effectively does some string manipulation to verify if the source is allowed. I had Amazon Q whip up this quick script to verify if the module source is in the allowed list or not.

repos:
  - repo: local
    hooks:
      - id: validate-module-sources
        name: Validate module sources
        entry: ./pre-commit/validate-module-sources.sh
        language: script
        files: \.tf$
Enter fullscreen mode Exit fullscreen mode

Test Results:

Running against examples/pass/pass.tf:

✅ No violations found - all module sources are allowed
Enter fullscreen mode Exit fullscreen mode

Running against examples/fail/fail.tf:

ERROR: Disallowed module source in examples/fail/fail.tf: quixoticmonk/glue/aws
ERROR: Disallowed module source in examples/fail/fail.tf: quixoticmonk/glue/aws
ERROR: Disallowed module source in examples/fail/fail.tf: bendrucker/docker-secret/kubernetes
ERROR: Disallowed module source in examples/fail/fail.tf: git::ssh://git@github.com/quixoticmonk/terraform-aws-s3.git?ref=main
Enter fullscreen mode Exit fullscreen mode

How can you get started ?

The repository includes examples to test and play around with:

  • examples/pass/ - Valid module sources that should pass validation
  • examples/fail/ - Invalid sources that should trigger policy violations

Whether you're using HCP Terraform's enterprise features or building with community tools, you should have the ability to implement consistent module source validation across your entire Terraform ecosystem.

Top comments (0)