Secret Exposure Risk #

A secret committed to Git is a security incident, not just a mistake. Git history is permanent — even after a file is deleted or a commit is reverted, the secret remains in the history accessible to anyone with repository access. In the Terraform ecosystem, secret exposure paths are more numerous than people realize: committed .tfvars files, insecurely stored state files, CI/CD logs showing sensitive values, and overly verbose plan output. Understanding all these paths is the first step to closing them.

flowchart TD
    A["Secret"] --> B[".tfvars\ncommit"]
    A --> C["State file\nunsecured"]
    A --> D["CI/CD log\noutput"]
    A --> E["Plan output\nverbose"]
    B --> F["🔴 Exposure"]
    C --> F
    D --> F
    E --> F

    style A fill:#f59e0b,stroke:#d97706,color:#fff
    style B fill:#ef4444,stroke:#dc2626,color:#fff
    style C fill:#ef4444,stroke:#dc2626,color:#fff
    style D fill:#ef4444,stroke:#dc2626,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#ef4444,stroke:#dc2626,color:#fff

Secret Exposure Paths #

SECRET EXPOSURE PATHS IN TERRAFORM:

  1. GIT REPOSITORY
     - .tfvars files containing secrets committed
     - Local terraform.tfstate committed
     - Credentials hardcoded in main.tf or the provider config
     Risk: Everything in Git history is PERMANENT and readable by anyone

  2. CI/CD LOGS
     - terraform plan output shows sensitive values if not marked
     - Echo or print credentials in pipeline scripts
     - Provider error messages including request/response bodies
     Risk: CI logs can be read by all team members, even the public if the repo is public

  3. STATE FILE
     - All resource attributes are stored in state, including sensitive ones
     - State files aren't encrypted if stored in S3 without encryption
     - Local terraform.tfstate shared via Slack or email
     Risk: State contains plaintext credentials for all resources

  4. TERRAFORM PLAN OUTPUT
     - Plan output can show variable values before apply
     - Plans posted to PR comments can be read by many people
     Risk: Secrets appear in PRs that may be public or visible to many people

  5. TERRAFORM CLOUD / REMOTE BACKEND
     - Variables not marked sensitive in Terraform Cloud
     - Uncontrolled access to remote state
     Risk: Anyone who can read the workspace can read its variables

.gitignore for Terraform #

The first line of defense is making sure sensitive files can’t be committed.

# .gitignore — required in every Terraform repository

# State files — NEVER commit to Git
*.tfstate
*.tfstate.*
.terraform.tfstate.lock.info

# Local .terraform directory (provider binaries, etc.)
.terraform/

# Override files — usually for local development
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Variable files that may contain credentials
*.tfvars        # Careful: this also excludes production.tfvars which may be needed
*.tfvars.json

# More specific — only exclude files containing secrets
secret.tfvars
secrets.tfvars
credentials.tfvars
*.secret.tfvars

# Terraform plan output (may contain sensitive values)
tfplan
*.tfplan

# Crash logs
crash.log
crash.*.log
# Verify .gitignore works
git status --ignored  # See the ignored files

# Verify no .tfstate files are already tracked
git ls-files | grep ".tfstate"
# If there's output: the file is already tracked, it needs to be removed from tracking

Detecting Already-Committed Secrets #

# git-secrets — prevent commits containing AWS credentials
brew install git-secrets

# Set up in the repository
git secrets --install
git secrets --register-aws

# Scan the existing history
git secrets --scan-history

# truffleHog — more comprehensive scan, including high-entropy strings
pip install truffleHog
trufflehog git file://. --only-verified

# gitleaks — a popular scanner, integrable into CI
brew install gitleaks
gitleaks detect --source . --verbose

# Integrate into a pre-commit hook
# .pre-commit-config.yaml:
repos:
  - repo: https://github.com/zricethezav/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Auditing State for Secrets #

# View all sensitive attributes in state
terraform state pull | jq '.resources[].instances[].attributes | to_entries[] | select(.value | type == "string" and length > 20 and (startswith("AKIA") or contains("password") or contains("secret") or contains("key")))'

# More practical: use terraform-state-mover or a custom script
# to identify values that look like credentials

# A simple script example
terraform state pull | python3 -c "
import json, sys, re

state = json.load(sys.stdin)
patterns = [
    r'AKIA[A-Z0-9]{16}',     # AWS Access Key
    r'(?i)password\": \"[^\"]+\"',  # Things that look like passwords
    r'(?i)secret\": \"[^\"]{8,}\"', # Secrets long enough
]

state_str = json.dumps(state)
for pattern in patterns:
    matches = re.findall(pattern, state_str)
    for match in matches:
        print(f'POTENTIAL SECRET: {match[:50]}...')
"

If a Secret Was Already Committed: Mitigation Steps #

If a secret has already entered Git history, deleting the commit isn’t enough — the secret is still in the history and must be considered compromised.

MITIGATION STEPS IF A SECRET IS COMMITTED:

  STEP 1 (IMMEDIATE): Rotate the credential
    Consider the credential compromised even without evidence of misuse
    Create new credentials, deactivate the old ones
    This must be done BEFORE trying to clean Git history

  STEP 2: Clean the Git history
    git filter-repo --path file-with-secret --invert-paths
    # Or use BFG Repo Cleaner for large repositories

    # Force push to the remote (coordinate with the team first!)
    git push --force-with-lease

  STEP 3: Notify everyone with a clone
    Everyone with a local clone needs to:
    git fetch --all
    git reset --hard origin/main

  STEP 4: Check whether the repository was ever forked or mirrored
    If so: forks and mirrors also contain the secret
    Contact the owners to clean or delete the forks

  STEP 5: Audit access
    Check CloudTrail / audit logs to see if the credential was used
    If there's suspicious usage: incident response procedure

  STEP 6: Post-mortem
    Identify how the secret got committed
    Update .gitignore and pre-commit hooks
    Train the team on secret management

Terraform Plan: Preventing Secrets in the Output #

# Mark all sensitive variables
variable "db_password" {
  type      = string
  sensitive = true  # ← The value won't appear in the plan output
}

variable "api_key" {
  type      = string
  sensitive = true
}

# Sensitive outputs must also be marked
output "db_connection_string" {
  value     = "postgresql://admin:***@${aws_db_instance.main.endpoint}/${var.db_name}"
  sensitive = true  # Won't appear in regular terraform output
}
# In the CI pipeline: make sure logs don't show sensitive values
# Add masking for known sensitive values

# GitHub Actions: mask a secret from the logs
echo "::add-mask::${{ secrets.DB_PASSWORD }}"

# Or: run the plan in JSON format and filter sensitive values
# before showing them in the PR comment
terraform plan -out=tfplan -json | jq 'del(.[] | .sensitive_values)' > plan_filtered.json

flowchart LR
    A[".gitignore\n.tfvars"] --> B["Prevent\ncommit"]
    C["CI masking\nsensitive"] --> D["Prevent\nlog leak"]
    E["State encryption\naccess control"] --> F["Prevent\nstate leak"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#f59e0b,stroke:#d97706,color:#fff
    style F fill:#10b981,stroke:#059669,color:#fff

Secret Scanning in the CI/CD Pipeline #

Detecting accidentally exposed secrets must be done automatically.

# GitHub Actions: secret scanning
name: Secret Scan
on: [pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Run gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Manual scan with gitleaks
gitleaks detect --source . --verbose

# Scan only recent changes
gitleaks detect --source . --log-opts="--since=2024-01-01"

Emergency Response Plan #

# If a secret has leaked to Git:

# 1. ROTATE THE SECRET IMMEDIATELY
# Don't wait — assume the secret is compromised
aws iam delete-access-key --access-key-id AKIA... --user-name terraform-ci
aws iam create-access-key --user-name terraform-ci

# 2. Remove it from Git history
# Use BFG Repo-Cleaner
java -jar bfg.jar --delete-files terraform.tfstate repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive

# 3. Force push (coordinate with the team!)
git push --force --all

# 4. Audit access using the leaked credential
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA...

# 5. Review and harden the process to prevent recurrence

Secret Detection Tools #

# Install and use secret scanning tools

# gitleaks (Git secret scanner)
brew install gitleaks
gitleaks detect --source . --verbose

# truffleHog (deep Git history scan)
pip install trufflehog
trufflehog git file://. --only-verified

# detect-secrets (Yelp)
pip install detect-secrets
detect-secrets scan > .secrets.baseline
detect-secrets audit .secrets.baseline
# CI/CD: secret scanning on every PR
name: Secret Scan
on: [pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Scan the full history
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

State File Encryption at Rest #

# State files contain sensitive values
# Make sure the backend encrypts the state

# S3 backend with KMS encryption
terraform {
  backend "s3" {
    bucket         = "terraform-state-prod"
    key            = "infrastructure/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    kms_key_id     = "alias/terraform-state"
  }
}

# GCS backend with encryption
terraform {
  backend "gcs" {
    bucket = "terraform-state-prod"
    prefix = "infrastructure"
    encryption_key = "projects/my-project/locations/global/keyRings/terraform/cryptoKeys/state"
  }
}

Summary #

  • Secret exposure paths are everywhere: Git history, CI logs, state files, plan output, and remote backends — each path needs to be closed with the right control.
  • A correct .gitignore is the first defense — .tfstate, .tfvars containing secrets, and .terraform/ must not enter Git.
  • Use a pre-commit hook with gitleaks or git-secrets to detect secrets before they’re committed, not after.
  • A committed secret must be considered compromised — the first step is credential rotation, not cleaning Git history.
  • sensitive = true on variables and outputs prevents values from appearing in plan output and the terminal, but doesn’t prevent values from entering state.
  • Audit state regularly to detect values that look like credentials — state contains plaintext copies of all resource attributes.

← Previous: Credential Rotation Strategy   Next: Least Privilege →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact