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:#fffSecret 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:#fffSecret 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
.gitignoreis the first defense —.tfstate,.tfvarscontaining 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 = trueon 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 →