Scan Git repository history for leaked API keys tokens and credentials.
Executive Summary
Exposed .git directories are one of the most common and damaging misconfigurations in web deployments. When developers deploy applications directly from a git clone without blocking access to the .git/ folder, attackers gain access to the full version control history — including every secret ever committed, even if later deleted.
The result? Complete credential compromise. An attacker can reconstruct database passwords, API keys, cloud access tokens, private keys, and deploy credentials that were committed months or years ago and "cleaned up" in a subsequent commit.
Key Insight: Deleting a secret in a new commit does NOT remove it from git history. The original blob objects remain fully accessible and reconstructible from the
.git/objects/directory. CERT-X-GEN detects both the directory exposure and the direct credential leakage in readable git metadata files.
Quick Stats
| Metric | Value |
|---|---|
| CVSS Score | 9.8 (Critical) |
| CWE | CWE-312 (Cleartext Storage of Sensitive Information) |
| Affected Systems | Any web server without /.git/ access controls |
| Detection Complexity | Low–Medium (requires HTTP + regex scanning) |
| Exploitation Difficulty | Trivial (curl + git commands) |
| FOFA Coverage | 219+ indexed exposed instances |
Run this check with CERT-X-GEN — the polyglot security scanner that executes templates in real programming languages. Get the templates | Install CERT-X-GEN
Template source:
git-history-secret-scan.go
Understanding the Vulnerability
How It Happens
Modern development teams use git for version control. The typical vulnerable deployment pattern is:
Developer machine Production server
┌─────────────────┐ ┌──────────────────────────────┐
│ git clone │ │ /var/www/html/ │
│ myapp/ │ scp / rsync │ index.php │
│ ├── .git/ ───────────────────►│ app.js ← exposed! │
│ ├── index.php │ │ .git/ ← DANGER! │
│ └── app.js │ │ HEAD │
└─────────────────┘ │ config ← has creds │
│ logs/HEAD ← has SHAs │
│ objects/ ← has blobs │
└──────────────────────────────┘The developer copies the app files to the server — including the hidden .git/ directory — and the web server happily serves it without any access restrictions.
The Three Layers of Exposure
| Layer | Files | What's Exposed | Severity |
|---|---|---|---|
| L1 — Directory Accessible | .git/HEAD | Branch name, confirms git repo exposure | Medium |
| L2 — Config Leak | .git/config | Remote URLs (may contain embedded credentials), repository metadata | High–Critical |
| L3 — Secret in History | COMMIT_EDITMSG, logs/HEAD, logs/refs/heads/* | API keys, passwords, tokens in commit messages and log entries | Critical |
Why "Deleting" Secrets Doesn't Help
Commit A: "add DB config" ← DB_PASSWORD=Passw0rd123! is stored in blob object
↓
Commit B: "cleanup secrets" ← .env removed from tree, but blob object REMAINS
↓
git object store still has the blob
curl /.git/objects/ab/cdef... → returns the original file contentThe git object model is append-only by design. Without running git filter-repo or BFG Repo Cleaner AND force-pushing, the secrets remain permanently accessible.
Why Traditional Scanners Fail
The YAML Limitation
Traditional YAML-based scanners like Nuclei can detect the presence of a .git directory but cannot correlate findings across multiple files or extract and classify secrets:
# What a YAML scanner CAN do:
id: git-directory-exposure
requests:
- method: GET
path:
- "{{BaseURL}}/.git/HEAD"
matchers:
- type: word
words:
- "ref: refs/heads/"This produces a single low-confidence "exposed git directory" finding with no context about actual impact.
| Capability | YAML | CERT-X-GEN |
|---|---|---|
Detect .git/HEAD accessible | ||
Fetch and parse .git/config | ||
| Extract remote URL with embedded creds | ||
Scan logs/HEAD for secret patterns | ||
| Match 13+ secret pattern types | ||
| Redact matched secrets in output | ||
| Severity escalation based on findings | ||
| Detect creds-in-URL vs creds-in-files | ||
| Confidence Level | ~30% | 95% |
The Detection Gap
A YAML scanner tells you "the .git directory is accessible." CERT-X-GEN tells you "the .git directory is accessible, the remote URL contains a GitHub deploy token, and a Stripe live key was found in 3 git log files."
The CERT-X-GEN Approach
The template uses Go's standard library to perform a systematic multi-layer inspection of all accessible git metadata files, classify findings by severity, and redact sensitive values to avoid storing live credentials in scan reports.
Detection Strategy
┌──────────────────────────────────────────────────────────────────┐
│ CERT-X-GEN DETECTION FLOW │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Scanner ──► GET /.git/HEAD │
│ │ ├── 200 + "ref: refs/heads/" → LAYER 1 CONFIRMED │
│ │ └── 404 / redirect / non-git body → STOP │
│ ▼ │
│ Scanner ──► GET /.git/config │
│ │ ├── Parse [remote "origin"] url │
│ │ ├── Regex: credentials in URL? │
│ │ │ └── YES → CRITICAL: Creds in Remote URL │
│ │ └── Store config evidence │
│ ▼ │
│ Scanner ──► GET /.git/COMMIT_EDITMSG │
│ GET /.git/logs/HEAD │
│ GET /.git/logs/refs/heads/main │
│ GET /.git/logs/refs/heads/master │
│ GET /.git/refs/heads/main │
│ GET /.git/refs/heads/master │
│ GET /.git/config (re-scan for secrets) │
│ │ ├── Run 13 secret patterns against each file │
│ │ ├── Deduplicate across files │
│ │ ├── Redact matched values (show first/last 4 chars) │
│ │ └── Match found? → CRITICAL: Secrets in History │
│ ▼ │
│ Output: JSON findings array, severity-escalated, redacted │
│ │
└──────────────────────────────────────────────────────────────────┘Key Advantages
- Multi-layer correlated detection: Single pass covers 3 distinct exposure vectors
- Severity escalation: Base finding starts at MEDIUM, upgrades to CRITICAL when evidence warrants
- Credential redaction: Matched secrets are masked (
sk_l****...****0C) — proof without risk - 13 secret pattern types: AWS, GitHub, Stripe, Google, Slack, Twilio, generic API keys, passwords, DB URLs, private keys, bearer tokens, credentials in URLs
- Port-aware scanning: Correctly handles non-standard ports (
:8765,:3000,:8888) viaCERT_X_GEN_TARGET_PORT - TLS-flexible: Disables certificate verification for self-signed staging/dev deployments
- Redirect rejection: Does not follow redirects on
.git/HEAD— a redirect means the path is protected
Attack Flow Visualization
Complete Attack Chain (Real-World Attacker Perspective)
Phase 1: Discovery
# Attacker discovers exposed .git directory
curl https://victim.com/.git/HEAD
# → ref: refs/heads/mainPhase 2: Reconnaissance
# Extract remote URL (may contain deploy credentials)
curl https://victim.com/.git/config
# → url = https://deploy:ghp_TOKEN@github.com/company/app.git
# Read recent commit history
curl https://victim.com/.git/logs/HEAD
# → commit SHAs + messages (may contain secrets in commit messages)Phase 3: History Reconstruction
# Download pack files to reconstruct full history
curl https://victim.com/.git/packed-refs
curl https://victim.com/.git/objects/pack/pack-<hash>.idx
curl https://victim.com/.git/objects/pack/pack-<hash>.pack
# Extract blobs from object store using commit SHAs
git init recovered && cd recovered
git fetch https://victim.com/.git
git checkout FETCH_HEAD
# → Full source code + all historical .env filesPhase 4: Credential Extraction
# Enumerate all commits and search for secrets
git log --all --full-history -- "*.env" "*.key" "*.conf"
git show <commit-sha>:.env
# → DB_PASSWORD=Passw0rd123!
# → AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI...Severity Decision Tree
GET /.git/HEAD
│
├── NOT 200 or not git content ──────────────────► No findings (exit)
│
└── 200 + "ref: refs/heads/..." ──────────────────► MEDIUM: .git Exposed
│
├── .git/config accessible?
│ └── remote URL has credentials? ──► CRITICAL: Creds in Config
│
└── Any secret pattern matched in log/commit files?
└── YES ──────────────────────────► CRITICAL: Secrets in HistoryTemplate Deep Dive
Secret Pattern Engine
The template implements 13 secret detection patterns covering the most common credential types found in repositories:
var secretPatterns = []SecretPattern{
{Name: "AWS Access Key ID",
Pattern: regexp.MustCompile(`(?i)AKIA[0-9A-Z]{16}`), Redact: true},
{Name: "GitHub Token",
Pattern: regexp.MustCompile(`gh[pousr]_[A-Za-z0-9_]{36,255}`), Redact: true},
{Name: "Stripe Key",
Pattern: regexp.MustCompile(`(?:r|s)k_(live|test)_[A-Za-z0-9]{24,}`), Redact: true},
{Name: "Database URL with Credentials",
Pattern: regexp.MustCompile(`(?i)(mysql|postgres|mongodb|redis|amqp|ftp)://[^:]+:[^@\s]+@`),
Redact: true},
{Name: "Private Key Header",
Pattern: regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
Redact: false}, // Key header itself is not sensitive
// ... 8 more patterns
}Credential Redaction
Matched secrets are never stored in plain text in scan results:
func scanForSecrets(content string) []secretMatch {
// ...
if sp.Redact && len(preview) > 12 {
// Show first 4 + last 4 chars with asterisks in between
// sk_live_51Hzwrt... → sk_l****...****lo2C
preview = preview[:4] + strings.Repeat("*", len(preview)-8) + preview[len(preview)-4:]
}
}This means scan reports are safe to share with stakeholders — they prove the secret exists without reproducing the live credential.
Port-Aware URL Construction
A key engineering decision: the template respects CERT_X_GEN_TARGET_PORT and handles non-standard ports correctly.
func normalizeTarget(host string, port int) []string {
// Strip any existing scheme from host
// Handle host:port notation in the host string itself
switch {
case port == 443:
return []string{"https://" + host}
case port == 80 || port == 0:
return []string{"http://" + host, "https://" + host}
default:
// Non-standard port: try http first, then https
return []string{
fmt.Sprintf("http://%s:%d", host, port),
fmt.Sprintf("https://%s:%d", host, port),
}
}
}This is critical because many development and staging servers with exposed .git directories run on ports like 3000, 7777, or 8888.
Usage Guide
Basic Usage
# Scan a single target
cxg scan --scope example.com --template templates/git-history-secret-scan/git-history-secret-scan.go
# With explicit port (critical for non-standard ports)
cxg scan --scope example.com:3000 --template templates/git-history-secret-scan/git-history-secret-scan.go
# JSON output with verbose logging
cxg scan --scope @targets.txt --template templates/git-history-secret-scan/git-history-secret-scan.go --output-format json --timeout 30s -vv
# HTTPS target
cxg scan --scope secure.example.com:443 --template templates/git-history-secret-scan/git-history-secret-scan.goTargets File Format
# git-history-targets.txt
example.com:80
staging.company.com:3000
192.168.1.100:8080
dev.internal.app:8765Direct Template Execution
# Build and run directly
cd templates/git-history-secret-scan
go build -o git-history-secret-scan git-history-secret-scan.go
# Run with env vars (engine mode)
CERT_X_GEN_TARGET_HOST=example.com CERT_X_GEN_TARGET_PORT=80 CERT_X_GEN_MODE=engine ./git-history-secret-scan
# Run with CLI flags
./git-history-secret-scan --target example.com --port 3000Local Docker Test Environment
# 1. Create a test git repo with simulated secrets
mkdir -p /tmp/git-test && cd /tmp/git-test
git init testrepo && cd testrepo
git config user.email 'test@test.com' && git config user.name 'Test'
git remote add origin https://deploy:ghp_FAKTOKEN@github.com/example/app.git
echo 'readme' > readme.txt && git add . && git commit -m 'sk_live_FAKESTRIPEKEY added'
# 2. Start nginx serving the repo without .git protection
docker run -d --name git-test -p 8765:8765 \
-v $(pwd):/usr/share/nginx/html:ro \
-v /path/to/nginx.conf:/etc/nginx/conf.d/default.conf \
nginx:alpine
# 3. Verify and scan
curl http://localhost:8765/.git/HEAD
cxg scan --scope localhost:8765 \
--template templates/git-history-secret-scan/git-history-secret-scan.go \
--output-format json --timeout 30s -vv
# 4. Cleanup
docker rm -f git-testExpected Output — CRITICAL Finding (Secrets Detected)
[
{
"template_id": "git-history-secret-scan",
"severity": "critical",
"confidence": 95,
"title": "Secrets Found in Exposed Git History (4 pattern(s) matched)",
"description": "The exposed .git directory on http://localhost:8765 contains files with secret credential patterns. 4 secret(s) matched across 4 git file(s): COMMIT_EDITMSG, logs/HEAD, logs/refs/heads/master, config.",
"evidence": {
"secret_matches": [
{"pattern_name": "Stripe Key", "preview": "sk_l****...****lo2C"},
{"pattern_name": "GitHub Token", "preview": "ghp_****...****FAKE"},
{"pattern_name": "Credentials in URL", "preview": "http****...****git"}
],
"affected_git_files": ["COMMIT_EDITMSG", "logs/HEAD", "logs/refs/heads/master", "config"],
"total_secrets": 4
},
"cwe": "CWE-312",
"cvss_score": 9.8
},
{
"template_id": "git-history-secret-scan",
"severity": "critical",
"confidence": 90,
"title": "Git Config Exposes Credentials in Remote URL",
"description": "The .git/config file contains a remote URL with embedded credentials.",
"evidence": {
"git_config": {
"config_url": "http://localhost:8765/.git/config",
"remote_origin_url": "https://deploy:ghp_abc123@github.com/example/app.git",
"credentials_in_remote_url": true
}
},
"cwe": "CWE-312",
"cvss_score": 9.8
}
]Expected Output — Clean Target (No Exposure)
[]An empty array with exit code 0 means no .git directory was found accessible. The engine logs [-] /.git/HEAD not accessible (status=404) to stderr.
Real-World Test Results
The template was tested against live FOFA-discovered targets and a local Docker environment:
FOFA Scan (5 Targets — body="ref: refs/heads/")
| Target | Port | Country | .git/HEAD | Secrets Found | Notes |
|---|---|---|---|---|---|
| nips.nostr.com | 80 | DE | 404 | N/A | Patched since FOFA crawl |
| 161.35.13.177 | 3000 | US | 404 | N/A | HTML error page |
| 8.155.171.67 | 8888 | CN | 200 (non-git) | N/A | Chinese app, blocked path |
| 144.91.115.224 | 443 | DE | Empty | N/A | Connection refused |
| 45.33.68.26 | 80 | US | 404 | N/A | Patched since FOFA crawl |
Key Finding: All 5 FOFA targets had patched or removed the .git exposure since FOFA's last crawl. The template correctly returned [] with zero false positives and graceful handling for all unreachable or protected targets.
Docker Local Test (Confirmed Vulnerable Instance)
| Target | Port | .git/HEAD | .git/config | Secrets Found | Severity |
|---|---|---|---|---|---|
| localhost | 8765 | ref: refs/heads/master | Remote URL with credentials | 4 patterns × 4 files | CRITICAL |
Detected patterns:
- Stripe Key (
sk_live_*) inCOMMIT_EDITMSG - GitHub Token (
ghp_*) inCOMMIT_EDITMSG,logs/HEAD,logs/refs/heads/master - Credentials in URL in
config
Scan duration: 0.45 seconds for 1 target — extremely fast due to Go's efficient HTTP client.
Defense & Remediation
Immediate Actions (Do These Now)
1. Block .git/ at the web server
# nginx — add to server block
location ~ /\.git {
deny all;
return 404;
}# Apache — add to .htaccess or VirtualHost
RedirectMatch 404 /\.git
# OR
<DirectoryMatch "^/.*/\.git/">
Order deny,allow
Deny from all
</DirectoryMatch>2. Rotate ALL secrets found in history
Do not assume a deleted secret is safe. Rotate every credential that ever appeared in any commit:
- Database passwords → change immediately
- API keys → revoke and regenerate
- Cloud access keys (AWS, GCP, Azure) → deactivate and rotate
- Deploy tokens → revoke from VCS provider
3. Purge secrets from git history
# Using git-filter-repo (recommended over BFG for modern repos)
pip install git-filter-repo
git filter-repo --path .env --invert-paths
git filter-repo --replace-text <(echo 'AKIAIOSFODNN7EXAMPLE==>***REDACTED***')
# Force push to all remotes
git push --force --all
git push --force --tags
# All clones must be re-cloned (old clones retain the history)Deployment Best Practices
Never deploy directly from a git clone. Use a CI/CD pipeline that copies only application files:
# GitHub Actions — secure deployment
- name: Deploy application files only
run: |
rsync -av --exclude='.git' --exclude='.env' \
./app/ user@server:/var/www/html/Use .gitignore aggressively:
# Never commit these
.env
.env.*
*.key
*.pem
config/secrets.yml
credentials.json
aws_credentialsPre-commit secret scanning:
# Install Gitleaks pre-commit hook
gitleaks protect --staged --config .gitleaks.tomlDetection Checklist
Configuration:
- Block
/.git/at web server level (nginx/Apache/Caddy) - Use CI/CD pipelines — never
scporrsyncwith.git/included - Store secrets in vault (HashiCorp Vault, AWS Secrets Manager, Doppler)
- Use environment variables — never hardcode in source files
Remediation:
- Rotate all credentials found in any historical commit
- Use
git filter-repoto purge secrets from history - Notify all stakeholders with access to old clones
Monitoring:
- Run CERT-X-GEN git-history-secret-scan on all web assets regularly
- Set up Gitleaks in CI/CD pre-commit and pre-push hooks
- Enable GitHub/GitLab secret scanning on all repositories
Framework-Specific Server Configurations
| Server | Secure Configuration |
|---|---|
| nginx | location ~ /\.git { deny all; return 404; } |
| Apache | RedirectMatch 404 /\.git |
| Caddy | respond /\.git/* "Not Found" 404 |
| IIS | Request filtering → deny .git path |
| Traefik | Middleware with stripPrefix + 404 response |
Extending the Template
Adding New Secret Patterns
// Add to secretPatterns slice in the template
{
Name: "Anthropic API Key",
Pattern: regexp.MustCompile(`sk-ant-[A-Za-z0-9\-_]{40,}`),
Redact: true,
},
{
Name: "OpenAI API Key",
Pattern: regexp.MustCompile(`sk-[A-Za-z0-9]{48}`),
Redact: true,
},
{
Name: "Cloudflare API Token",
Pattern: regexp.MustCompile(`[A-Za-z0-9_-]{40}cloudflare`),
Redact: true,
},Scanning Additional Git Files
// Extend secretScanFiles to include more git metadata
secretScanFiles := []string{
"COMMIT_EDITMSG",
"logs/HEAD",
"logs/refs/heads/main",
"logs/refs/heads/master",
"logs/refs/heads/develop", // Add common branch names
"refs/heads/main",
"refs/heads/master",
"config",
"description", // Sometimes contains repo info
"info/exclude", // May reveal what was intentionally excluded
}Pack File Object Reconstruction (Advanced)
For deep history scanning, extend the template to download and parse git pack files:
// Fetch pack index to enumerate all object SHAs
func fetchPackIndex(ctx context.Context, client *http.Client, baseURL string) []string {
body, status, _ := fetchGitFile(ctx, client, baseURL, "objects/info/packs")
if status != 200 { return nil }
// Parse pack filenames from response
// Download .idx files to enumerate blob SHAs
// Download and decompress .pack files for blob content
// ... (advanced reconstruction logic)
}Integration with CI/CD
# GitHub Actions — scan on every deployment
name: Git Exposure Scan
on:
push:
branches: [main, staging]
jobs:
git-exposure-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Git History Secret Scan
run: |
cxg scan \
--scope ${{ secrets.STAGING_URL }} \
--template templates/git-history-secret-scan/git-history-secret-scan.go \
--output-format json \
--timeout 30s \
--output git-exposure-results.json
- name: Check for critical findings
run: |
CRITICAL=$(jq '.findings | map(select(.severity == "critical")) | length' git-exposure-results.json)
if [ "$CRITICAL" -gt "0" ]; then
echo "CRITICAL: .git directory exposed with $CRITICAL finding(s)"
exit 1
fiReferences
CVE Database & Advisories
| Reference | Description |
|---|---|
| CWE-312 | Cleartext Storage of Sensitive Information |
| CWE-538 | Insertion of Sensitive Information into Externally-Accessible File or Directory |
| OWASP A05:2021 | Security Misconfiguration |
| OWASP WSTG-CONF-05 | Enumerate Infrastructure and Application Admin Interfaces |
Tools & Resources
- Gitleaks — Secret scanner for git repos (pre-commit)
- git-filter-repo — Rewrite git history to remove secrets
- BFG Repo Cleaner — Faster alternative to filter-branch
- truffleHog — Finds secrets in git history
- GitHound — Hunts for secrets in GitHub searches
- FOFA Query —
body="ref: refs/heads/"finds 219+ exposed instances
Research & Write-ups
- Mao, S. (2014). "Hacking websites with git" — Original research on
.gitdirectory exposure - HackerOne Reports — Numerous critical bounties for exposed
.gitdirectories on production servers - SANS Institute — "The Dangers of Exposed .git Directories in Production"
Need help hardening your environment?
Talk to the Bugb security team directly.