How Git-Sourced Terraform Modules Work

Terraform supports sourcing modules directly from Git repositories. The canonical form uses the git:: prefix in the source argument of a module block:

infrastructure/networking/main.tf HCL
module "vpc" {
  source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=main"

  cidr_block   = "10.0.0.0/16"
  environment  = "production"
  enable_nat   = true
}

module "bastion" {
  source = "git::https://github.com/my-org/terraform-modules.git//bastion?ref=v2.1.0"

  vpc_id        = module.vpc.vpc_id
  subnet_id     = module.vpc.public_subnet_ids[0]
  instance_type = "t3.micro"
}

When a developer runs terraform init, Terraform clones the referenced repository and unpacks the specified subdirectory into .terraform/modules/. This directory is almost universally listed in .gitignore — meaning the actual HCL code that will be applied to your cloud infrastructure is never committed to your application repository and never visible during a standard CI/CD pipeline scan.

This is a deliberate design pattern promoted by HashiCorp for DRY infrastructure: a central module registry repository that multiple consuming repositories reference. It keeps infrastructure definitions modular. It also creates a significant security scanning gap.

What gets fetched: The full contents of the referenced Git repository subdirectory — including all .tf files, data sources, locals, and resource definitions — are placed into .terraform/modules/<module_name>/ on the local filesystem during terraform init.

The Scanner Blind Spot

Every major IaC security scanner — tfsec, Checkov, KICS (Keeping Infrastructure as Code Secure), Terrascan — works by reading .tf files from a directory path you provide. In CI/CD pipelines, that path is typically the repo root or a specific directory. Here is what a standard pipeline step looks like:

.github/workflows/security.yml YAML
name: IaC Security Scan
on: [push, pull_request]

jobs:
  iac-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # ← Notice: no terraform init step before scanning
      - name: Run tfsec
        uses: aquasecurity/[email protected]
        with:
          working_directory: ./infrastructure

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: infrastructure

The scanner runs against the checked-out repository. The .terraform/modules/ directory does not exist yet — it only appears after terraform init is executed. And even in pipelines that do run terraform init before scanning, most scanners default to scanning the current working directory, not recursing into .terraform/modules/.

What each scanner scans by default

Scanner Local .tf files .terraform/modules/ Git-sourced modules Notes
tfsec Yes Partial No Scans .terraform/modules if --include-modules flag is set and init has run
Checkov Yes Partial No Requires --download-external-modules true and runs terraform init internally
KICS Yes No No File-based scanning only; does not resolve module sources
Terrascan Yes No No No support for scanning resolved Git module sources
Trivy (config) Yes Partial No Scans local .terraform dir if present; does not initiate module download

The core problem: Most CI/CD templates do not run terraform init before the security scan step, because doing so requires cloud credentials (for remote state backends) and network access to the module registry. Even when init is run, the scanner must be explicitly configured to recurse into .terraform/modules/ — a flag that is off by default in every tool listed above.

Real Vulnerability Examples

The following patterns represent real misconfiguration classes observed in public and private Terraform module repositories. They are not hypothetical — variants of each have appeared in widely-forked open-source Terraform modules on GitHub.

Example 1: Security Group with SSH open to the world

A shared bastion module defines a security group with SSH access. The cidr_blocks parameter defaults to 0.0.0.0/0 for "convenience during development" and was never tightened before the module was tagged and promoted to production use.

.terraform/modules/bastion/main.tf  (fetched from git) HCL
variable "allowed_ssh_cidrs" {
  type    = list(string)
  default = ["0.0.0.0/0"]   # ← never overridden by callers
}

resource "aws_security_group" "bastion_sg" {
  name   = "${var.environment}-bastion"
  vpc_id = var.vpc_id

  ingress {
    description = "SSH access"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = var.allowed_ssh_cidrs
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The consuming repository's scanner sees only this in the calling module:

infrastructure/bastion/main.tf  (what the scanner sees) HCL
module "bastion" {
  source        = "git::https://github.com/my-org/terraform-modules.git//bastion?ref=v2.1.0"
  vpc_id        = module.vpc.vpc_id
  subnet_id     = module.vpc.public_subnet_ids[0]
  instance_type = "t3.micro"
  # allowed_ssh_cidrs not set — defaults to ["0.0.0.0/0"]
}

# tfsec output: 0 issues found
# Checkov output: Passed checks: 1, Failed checks: 0
# Actual deployed resource: SSH port 22 open to the internet

Example 2: S3 bucket with public ACL default

A storage module accepts a bucket_acl variable for flexibility. The default was set to public-read for a static website use case when the module was first created, and never changed despite being reused for general-purpose storage in a dozen other consuming repositories.

.terraform/modules/storage/s3.tf  (fetched from git) HCL
variable "bucket_acl" {
  type    = string
  default = "public-read"   # ← CIS AWS Foundations v1.4 violation
}

resource "aws_s3_bucket" "this" {
  bucket = "${var.bucket_prefix}-${var.environment}"
}

resource "aws_s3_bucket_acl" "this" {
  bucket = aws_s3_bucket.this.id
  acl    = var.bucket_acl
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = false   # ← must be true for non-public buckets
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

This pattern violates CIS AWS Foundations Benchmark control 2.1.5 (Ensure that S3 Buckets are configured with 'Block public access') and NIST 800-53 SC-7. The misconfiguration exists entirely within the module and is invisible from the consuming repository's scan results.

Example 3: Overpermissive IAM role with wildcard actions

Perhaps the highest-severity variant: an IAM role module that attaches a policy with wildcard actions. This is a genuine ESCA (Escalate and Compromise All) misconfiguration pattern — whoever assumes this role can perform any action on any resource in the account.

.terraform/modules/iam/role.tf  (fetched from git) HCL
resource "aws_iam_role_policy" "this" {
  name   = "${var.role_name}-policy"
  role   = aws_iam_role.this.id

  # Originally written for a CI/CD role during a deadline push.
  # "We'll tighten this up later."  — famous last words.
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "*"          # ← wildcard — full account access
      Resource  = "*"
    }]
  })
}

resource "aws_iam_role" "this" {
  name = var.role_name
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

Checkov rule CKV_AWS_40 and tfsec rule AWS007 would both flag the wildcard Action immediately if they scanned this file. They do not flag it because they never see it. The consuming repository's IaC scan passes clean.

Example 4: Hardcoded credentials in a module variable default

Less common but occurring in internal module repositories: database connection strings, API keys, or SMTP credentials embedded as default values in variables — left from local testing and accidentally committed.

.terraform/modules/rds/variables.tf  (fetched from git) HCL
variable "db_password" {
  type        = string
  description = "RDS master password"
  default     = "Sup3rS3cur3P@ss2024!"  # ← committed credential
  sensitive   = true
}

variable "smtp_api_key" {
  type    = string
  default = "SG.kHxxxxxxxxxxxxxxxxxxxxxxxxxx"  # ← SendGrid key format
}

Secrets scanners like Gitleaks, TruffleHog, or Detect-Secrets also do not scan .terraform/modules/ because it is gitignored. The credential lives in the module Git repository — which may be a separate private repo scanned by a different team (or not at all).

Branch Refs and Configuration Drift

The problem is amplified significantly when module sources are pinned to a branch name rather than a specific commit SHA or tag. Consider this common pattern:

infrastructure/networking/main.tf HCL
# Pinned to branch — dangerous
module "vpc" {
  source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=main"
}

# Pinned to tag — better, but tag can be moved or deleted
module "vpc" {
  source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=v2.1.0"
}

# Pinned to SHA — the only truly immutable reference
module "vpc" {
  source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=a3f8c2d1e4b7096f12345678901234567890abcd"
}

When a module source references ref=main, the module code fetched by terraform init depends entirely on the current state of that branch at the moment of the run. A misconfiguration introduced in the module repository at any point between your last scan and your next terraform apply will be deployed — even if your IaC scanner would have caught it in the local file.

This is a supply chain integrity problem: your IaC deployment pipeline is pulling external code without pinning it to a verified, immutable reference, and without scanning it.

Real-world consequence: If a malicious actor compromises your internal module registry (through a dependency confusion attack, a compromised GitHub account, or a subdomain takeover on a self-hosted Gitea instance), they can push a backdoored module that is automatically pulled by every consuming repository on the next terraform init — and deployed without triggering any security alert in your CI/CD pipeline.

Terragrunt Compounds the Problem

Terragrunt, the popular Terraform wrapper by Gruntwork, exacerbates this issue through its terraform.source pattern. Instead of inline module blocks, Terragrunt uses a terragrunt.hcl file that specifies the module source at the directory level:

environments/production/networking/terragrunt.hcl HCL
terraform {
  source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=v3.2.1"
}

inputs = {
  cidr_block  = "10.0.0.0/16"
  environment = "production"
}

Terragrunt's downloaded modules are placed into a cache directory outside the repository (typically ~/.terragrunt-cache/ or a configurable path). This means the module code is not only absent from source control but absent from the CI workspace entirely — it lives in an ephemeral cache that is rebuilt on every run.

Most IaC scanners do not understand terragrunt.hcl files at all. They see a directory with no *.tf files in it and report zero findings — which is indistinguishable from a clean scan result.

Checkov scan output on a Terragrunt repository (typical) shell
$ checkov -d environments/production/networking/

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

# 0 checks run. No findings. Not because the infrastructure
# is secure — because the scanner found no .tf files to scan.

Full Attack Scenario

Combining the above, here is a realistic end-to-end scenario that results in a critical misconfiguration being deployed to production infrastructure without triggering any security alert:

Step 1 Dev team creates shared Terraform module in my-org/terraform-modules. Module opens SSH to 0.0.0.0/0 by default.
Step 2 Module repo has its own CI scan. tfsec flags the issue. But the module is merged anyway because "it's just a default — callers should override it."
Step 3 Infrastructure repo references the module via Git source. No one overrides allowed_ssh_cidrs.
Step 4 Infrastructure repo's CI scan runs tfsec on local .tf files. 0 findings — module code is never scanned.
Step 5 terraform apply runs. Security group created with SSH open to the internet. EC2 instance exposed.

The misconfiguration passed through three stages — the module repo's own scan, the consuming repo's scan, and a human code review — without being caught in the context in which it matters: the consuming repository's deployment.

How to Close the Gap

There is no single-step fix. A complete solution requires changes to both how modules are sourced and how scanning is performed.

1. Pin to commit SHAs, not branches or floating tags

Immutable references prevent the module code from changing between your scan and your apply. Use the full 40-character SHA:

infrastructure/bastion/main.tf HCL
module "bastion" {
  # SHA-pinned — this exact commit is what will always be deployed
  source = "git::https://github.com/my-org/terraform-modules.git//bastion?ref=a3f8c2d1e4b7096f12345678901234567890abcd"

  vpc_id        = module.vpc.vpc_id
  allowed_ssh_cidrs = ["10.0.0.0/8"]  # explicitly override defaults
}

2. Run terraform init before scanning in CI

Use a read-only backend configuration or a dummy backend to allow terraform init to run without real cloud credentials, purely to download module sources:

.github/workflows/security.yml YAML
steps:
  - uses: actions/checkout@v4

  - name: Setup Terraform
    uses: hashicorp/setup-terraform@v3

  - name: Initialise modules (no backend)
    run: terraform init -backend=false
    working-directory: ./infrastructure

  - name: Run tfsec including downloaded modules
    run: tfsec ./infrastructure --include-modules

  - name: Run Checkov with external module download
    run: checkov -d infrastructure --download-external-modules true

Note: terraform init -backend=false downloads modules and providers without initialising the state backend. This works for scanning purposes and does not require AWS/GCP/Azure credentials.

3. Scan the module repository independently — and enforce it

The module repository should have its own IaC security scanning pipeline that blocks merges on policy violations. Critically, the scan should also enforce that no variable defaults create a vulnerable configuration even when not overridden by callers:

terraform-modules/.github/workflows/security.yml YAML
name: Module Security Gate
on:
  pull_request:
    branches: [main]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: tfsec — block on HIGH and CRITICAL
        uses: aquasecurity/[email protected]
        with:
          minimum_severity: HIGH
      - name: Checkov — enforce no variable defaults create violations
        uses: bridgecrewio/checkov-action@master
        with:
          soft_fail: false
          check: CKV_AWS_25,CKV_AWS_40,CKV2_AWS_12  # SG, IAM, public access

4. Use the Terraform Registry with published modules (not raw Git)

The public Terraform Registry and private registries (Terraform Cloud, Spacelift, Atlantis) perform their own validation on module publication. While this does not replace scanning, modules distributed via a registry can be version-locked more reliably and audited centrally:

infrastructure/bastion/main.tf HCL
# Registry source — version locked, not raw Git
module "bastion" {
  source  = "my-org/bastion/aws"
  version = "= 2.1.0"  # exact version pin, not >= or ~>
}

Detection: Auditing Your Existing Module Graph

To quickly identify which of your Terraform repositories are using Git-sourced modules today, run the following across your infrastructure repositories:

Find all Git-sourced module references shell
# Find all git:: module sources across your infrastructure repos
grep -r 'source\s*=\s*"git::' --include="*.tf" .

# Find all branch-pinned (non-SHA) refs — highest risk
grep -rP 'source\s*=\s*"git::.*\?ref=(?!([0-9a-f]{40}))' --include="*.tf" .

# Find Terragrunt sources
grep -r 'source\s*=\s*"git::' --include="*.hcl" .

# List all currently downloaded modules and their origins
terraform providers

For each git:: source found, verify:

  1. Is the ref a full 40-character commit SHA?
  2. Does the module repository have its own IaC security scanning pipeline with enforced blocking?
  3. Does your consuming repository's CI pipeline run terraform init -backend=false before the scanner?
  4. Does your scanner run with --include-modules (tfsec) or --download-external-modules true (Checkov)?

If the answer to any of these is no, you have an unscanned attack surface in your infrastructure pipeline.

"The most dangerous misconfiguration is the one your scanner has never seen. In Terraform at scale, that is every module your team did not write locally."

The problem is not unique to Terraform. Pulumi stack references, Bicep modules sourced from OCI registries, and AWS CDK L3 constructs sourced from private npm packages all exhibit variations of the same pattern: code that influences your deployed infrastructure but lives outside the path your security scanner is pointed at. The principle — scan everything that runs, not just everything that is committed — applies universally.