Audit and Secret Scanning: Detecting Exposed
Despite best practices, secrets leak: a developer commits a .env file, an error message logs an API key, or a third-party library exposes a password. Secret scanning tools continuously monitor your repository and infrastructure for exposed credentials, alerting you in seconds so you can revoke them before attackers use them. This article covers pre-commit hooks, GitHub's native secret scanning, GitGuardian API integration, and incident response: how to revoke leaked credentials quickly and prevent future leaks.
According to GitGuardian's 2025 research, over 12 million secrets are exposed in public repositories annually, and over 45 percent of exposed secrets remain on public servers for at least two weeks. Pre-commit scanning reduces this window dramatically—catching secrets before they reach Git history, where removal is permanent.
Pre-Commit Hooks: Preventing Commits
A pre-commit hook runs before Git commits code, blocking commits that contain secrets. Use the detect-secrets library:
pip install detect-secrets
Initialize secret detection in your repository:
detect-secrets scan > .secrets.baseline
This creates a baseline file of known (safe) secrets. Configure a pre-commit hook:
# Create .git/hooks/pre-commit
#!/bin/bash
detect-secrets scan --baseline .secrets.baseline
exit $?
Make it executable:
chmod +x .git/hooks/pre-commit
Or use the pre-commit framework (more robust):
pip install pre-commit
Create .pre-commit-config.yaml:
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
args: ['protect', '--verbose', '--redact']
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: no-commit-to-branch
args: [--branch, main]
- id: check-ast
- id: detect-private-key
Install the hooks:
pre-commit install
Now, when a developer tries to commit a secret, the hook blocks it:
$ git commit -m "Add API config"
detect-secrets............................................................FAILED
- hook id: detect-secrets
- exit code: 1
Potential secrets found in modified files:
.env:
AWS Access Key: Line 2
Scanning with Gitleaks
Gitleaks is a specialized tool for finding hardcoded secrets and private keys:
# Scan the entire repository
gitleaks detect --source . --verbose
# Scan only recent commits
gitleaks detect --source . --log-opts="-10"
# Redact findings in output
gitleaks detect --source . --redact --json > findings.json
Sample output:
{
"Description": "AWS Access Key",
"StartLine": 2,
"EndLine": 2,
"StartColumn": 12,
"EndColumn": 45,
"Match": "AKIA2ECDSCF5C7XAMPLE",
"Secret": "AKIA2ECDSCF5C7XAMPLE",
"File": ".env",
"Commit": "abc123def456",
"Author": "[email protected]"
}
Integrating GitHub Secret Scanning
GitHub's built-in secret scanning automatically monitors public repositories for exposed credentials. Enable it in repository settings:
Settings → Code security → Secret scanning → Enable
GitHub partners with secret providers (AWS, Stripe, etc.) who are notified of exposed keys and can revoke them. When a secret is detected:
- GitHub alerts the repository owner.
- The provider (e.g., AWS) may auto-revoke the key.
- A comment is posted on the commit.
For private repositories, use GitHub Advanced Security (requires a paid plan).
Using GitGuardian for Real-Time Monitoring
GitGuardian scans public and private repositories 24/7 for exposed secrets:
pip install gitguardian-cli
Install GitGuardian's pre-commit hook:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/GitGuardian/ggshield
rev: v1.25.0
hooks:
- id: ggshield
language: python
entry: ggshield secret scan pre-commit
stages: [commit]
Or run scans manually:
ggshield secret scan . --recursive
Sample output:
Incident #1: 3 policy breaks
❌ Stripe API Key [Stripe] (High severity)
❌ Generic Password [Generic] (Medium severity)
❌ Private Key [SSH] (Critical severity)
Files scanned: 42, Secrets found: 3
Handling Exposed Secrets: Incident Response
If a secret is exposed, act immediately:
import boto3
import os
from datetime import datetime
class SecretIncidentResponse:
"""Automated response to secret exposure."""
def __init__(self):
self.iam_client = boto3.client("iam")
self.secrets_client = boto3.client("secretsmanager")
def revoke_access_key(self, access_key_id: str):
"""Revoke an exposed AWS access key."""
# Deactivate (disable) the key
try:
self.iam_client.update_access_key_status(
AccessKeyId=access_key_id,
Status="Inactive"
)
print(f"Access key {access_key_id} deactivated")
except Exception as e:
print(f"Failed to deactivate key: {e}")
def rotate_database_password(self, secret_name: str):
"""Rotate a database password in Secrets Manager."""
import secrets
import psycopg2
# Fetch current secret
response = self.secrets_client.get_secret_value(SecretId=secret_name)
current = json.loads(response["SecretString"])
# Generate new password
new_password = secrets.token_urlsafe(32)
# Update in database
conn = psycopg2.connect(
host=current["host"],
user=current["username"],
password=current["password"],
database="postgres"
)
cursor = conn.cursor()
cursor.execute(
f"ALTER USER {current['username']} WITH PASSWORD %s",
(new_password,)
)
conn.commit()
cursor.close()
conn.close()
# Update in Secrets Manager
current["password"] = new_password
self.secrets_client.put_secret_value(
SecretId=secret_name,
SecretString=json.dumps(current)
)
print(f"Database password rotated at {datetime.utcnow()}")
def revoke_api_key(self, provider: str, key_id: str):
"""Revoke an API key (example for Stripe)."""
if provider == "stripe":
import stripe
try:
stripe.APIResource.api_key = os.getenv("STRIPE_API_KEY")
# Stripe API keys cannot be revoked; create a new one instead
print("Stripe keys cannot be revoked; create a new key and update application config")
except Exception as e:
print(f"Failed to revoke Stripe key: {e}")
# Usage
incident = SecretIncidentResponse()
# Upon detection of exposed AWS key
incident.revoke_access_key("AKIAXYZABC123DEFGH")
# Upon detection of exposed database password
incident.rotate_database_password("prod/database/postgres")
# Log the incident
print("Incident logged and reported to security team")
Secret Scanning Best Practices Checklist
Create a .gitsecure file (documentation):
# Secret Security Checklist
## Pre-Commit
- [ ] Run `pre-commit run --all-files` before pushing
- [ ] Never commit `.env`, `*.pem`, `*.key`, `*secret*`
- [ ] Review `.gitignore` for sensitive file patterns
## Post-Commit
- [ ] Check GitHub secret scanning alerts weekly
- [ ] Review GitGuardian dashboard daily
- [ ] Investigate any exposure reports
## Incident Response
- [ ] Exposed key found → revoke within 5 minutes
- [ ] Rotate secrets within 1 hour
- [ ] Check audit logs for unauthorized access
- [ ] Notify security and compliance teams
- [ ] Document root cause and remediation
## Repository
- [ ] Enable GitHub secret scanning
- [ ] Require status checks for PRs (no commits with secrets)
- [ ] Archive old credentials (do not delete; audit trail)
Automating Secret Scanning in CI/CD
Add scanning to your CI/CD pipeline to block merges with exposed secrets:
# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Scan entire history
- name: Scan for secrets
run: |
pip install gitleaks
gitleaks detect --source . --exit-code 1
- name: Scan with detect-secrets
run: |
pip install detect-secrets
detect-secrets scan --baseline .secrets.baseline
detect-secrets audit .secrets.baseline
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Scan dependencies for known vulnerabilities
run: |
pip install bandit safety
safety check --json
bandit -r src/ -f json
Failing jobs block PRs:
✗ secret-scan: Gitleaks found secrets
✗ dependency-scan: 2 known vulnerabilities in dependencies
Scanning vs Secrets Management Comparison
| Tool | Real-Time | Pre-Commit | CI/CD | Cost | Best For |
|---|---|---|---|---|---|
| detect-secrets | No | Yes | Yes | Free | Local prevention |
| gitleaks | No | Yes | Yes | Free | Aggressive scanning |
| GitHub Secret Scanning | Yes | No | Yes | Included (public repos) | GitHub users |
| GitGuardian | Yes | Yes | Yes | $99+/month | Enterprise, compliance |
| git-secrets | No | Yes | Limited | Free | Older projects |
Key Takeaways
- Use pre-commit hooks (detect-secrets, gitleaks) to prevent secrets from reaching Git history in the first place.
- Enable GitHub secret scanning on all repositories; for private repos, use GitHub Advanced Security.
- Integrate GitGuardian for 24/7 real-time monitoring of exposed secrets in public and private repos.
- Upon exposure, revoke the credential within minutes and rotate all related secrets within hours.
- Automate secret scanning in CI/CD to block merges if secrets are detected.
Frequently Asked Questions
What do I do if I accidentally committed a secret?
Immediately revoke the secret in the service dashboard. Then remove it from Git history: git filter-branch (old way) or git-filter-repo (recommended). For example: git filter-repo --invert-paths --path .env removes .env from all commits. Force-push the rewritten history: git push origin --force. Note: any collaborators must rebase their branches.
Can I use git-secrets instead of pre-commit hooks?
Yes, but less robustly. git-secrets is simpler and has fewer dependencies, making it good for individual developers. Pre-commit hooks (detect-secrets, gitleaks) are more comprehensive and team-friendly. Use both: git-secrets locally, pre-commit for team standardization.
What if a secret was exposed for hours before we detected it?
Check audit logs (AWS CloudTrail, database logs, API logs) for unauthorized access using the exposed credential. If found, assume the account was compromised: rotate all credentials, review access logs, and check for lateral movement. For critical services, consider a full security audit.
Do I need to rotate every secret that appears in scan results?
No. Pre-commit hooks sometimes flag false positives (test strings, dummy values). Review each finding to determine if it is a real secret. For test data, add it to .secrets.baseline so it is not flagged repeatedly. For real secrets, revoke immediately.
How do I suppress false positives in detect-secrets?
View and audit the baseline:
# Review what was flagged
detect-secrets audit .secrets.baseline
# Mark items as confirmed (real) or false positives
# Then re-baseline with approved items
detect-secrets scan > .secrets.baseline
Or exclude patterns:
detect-secrets scan --exclude-lines='test.*=.*' --baseline .secrets.baseline