Your Secrets Are Still on GitHub

I regularly look over projects in the crypto ecosystem and jump on calls with friends and people in the space to help set direction, audit code, and build scalable systems. Recently a friend’s project launched so I took a look at the public repo, going through security, perf, and stability. I always check .env and .env.example out of habit and this time I happened to notice that a value had been redacted through git filter-branch.

It wasn’t anywhere in the visual history, but I suspected it had been at some point and that it was definitely live on at least one machine. I also knew there was a chance it was still somewhere on GitHub, even if everything visible had been force-pushed away.

Dangling Commits

When you force-push or use git filter-branch to rewrite history, you’re updating what the branch points to. The old commits become “dangling,” unreachable from any branch or tag. Most people assume that means they’re gone, but they’re not.

GitHub retains these orphaned commits in the repository’s object pool. There’s no documented retention policy or cleanup schedule. GitHub’s own engineering blog describes infrastructure for eventually cleaning up unreachable objects, but there’s no documented timeline or guarantee. Removal requires a support ticket and them determining the severity of the leak. In practice, dangling commits persist for an indeterminate period and are accessible by SHA if you know where to look.

Even better, GitHub’s Events API records every push which includes the SHA that was displaced. So if someone pushes a commit with a private key, panics (understandably), and force-pushes to remove it, the Events API still has the exact SHA of the dangerous commit. And with that SHA you can fetch the commit directly from GitHub, even though it’s not reachable from any branch.

Dedicated secret scanning tools like TruffleHog don’t check dangling commits by default, instead they scan reachable history. TruffleHog does have a github-experimental command that can find dangling commits through SHA enumeration, but it’s a separate alpha-status command that takes 20+ minutes even on small repos.

Building the Scanner

I built a Claude skill to automate this. You point it at a GitHub repo (or an entire org), and it:

  1. Clones the repo and fetches all refs, including hidden PR refs that a normal clone misses
  2. Scans every reachable blob for secret patterns: private keys, API keys, mnemonics, database URLs, cloud credentials
  3. Queries the Events API for orphaned commit SHAs from force-pushes
  4. Fetches each dangling commit directly from GitHub and scans those too
  5. For any discovered private keys, derives the public address and checks for on-chain activity

The first time I ran it against my friend’s repo, it found a private key in a dangling commit. A normal scan would have found nothing.

I developed the skill further and ran it across other repos in the organization. Two more API keys and another private key, again only in dangling commits. Of course, before writing up any of this I reached out to them and helped secure the repos.

3 for 3

Since then I’ve run this against a few other organizations. Many have leaked at least API keys through dangling commits. Not all private keys (those are rarer), but API keys for services like Etherscan, Alchemy, and similar. Lower impact than a private key, but still things that shouldn’t be public.

This pattern is a natural outcome as the crypto space evolves with LLMs. It’s extremely easy to put together a project now with secure, well audited smart contracts. The risk though is that the tooling is so simple, it’s very easy to accidentally commit an .env or hardcode a key. Of course you can run your LLM to fix it, but the public dangling commits mean it’s still out there.

Prevention

Set up .gitignore first. Before you write any code, before you create any .env files. This sounds obvious but it’s the number one reason keys end up in history.

Use a private repo as your development environment. Keep a private repo where you do your actual work, and push to your public repo only through a release workflow. I have a GitHub Action that triggers on releases in the private repo and forwards to public. This gives you a few advantages:

  • Early on, if you leak keys in the private repo, they’re not public. You can fix history and push clean to public later
  • The public repo only contains intentional, finished work. No half-done experiments, no debugging commits
  • You can work freely without worrying about what’s visible, then control exactly what goes public

Pre-commit hooks help but aren’t enough. Tools like git-secrets can catch secrets before they’re committed, but they only work if every developer has them installed. Anyone who clones fresh, works on a new machine, or runs git commit --no-verify bypasses them entirely. They’re a good layer, but rarely something you set up on a brand new project.

What To Do If It Happens

If you discover leaked keys in your repo’s history:

  1. Make the repo private immediately. This is the fastest way to stop the bleeding while you deal with the rest
  2. Rotate every leaked key. Generate new keys, update your services, invalidate the old ones. This is the only step that actually matters for security. As long as the old key is rotated, it doesn’t matter if someone finds the dangling commit
  3. Delete and recreate the repo. This clears the object pool and all dangling commits, but only if the repo has no forks. If forks exist, all commits remain accessible through the fork network. GitHub has confirmed this is by design
  4. Assume the key was compromised. Don’t talk yourself into “nobody probably found it.” Treat it as a confirmed leak and act accordingly
--- name: github-secret-scanning description: "Force-pushing doesn't delete commits from GitHub. Dangling commits persist, and leaked keys are findable. Here's how I scan for them." allowed-tools: - Bash - Read - Grep - Glob - Agent --- ## Git Secret Scanner with Dangling Commit Recovery Scan a GitHub repository (or entire org) for leaked secrets in both reachable history and force-pushed dangling commits that persist on GitHub. Detects 20+ prefixed token types, crypto keys, PEM keys, webhook URLs, database credentials, and JWTs. ## Signals to apply - User asks to scan a repo for leaked keys, secrets, or credentials - User mentions force-pushed secrets or dangling commits - Security audit of a GitHub repository or organization - User wants to check if a previous secret cleanup was effective ## Implementation steps 1. Verify prerequisites: git, gh (authenticated), optionally cast (Foundry) for address derivation 2. Clone the target repo and fetch all refs including hidden PR refs: git clone <repo_url> /tmp/secret-scan-<ts>/<repo_name> git fetch origin '+refs/*:refs/remotes/origin-all/*' 3. Scan reachable history using diff-based searching (git log -p --all -G '<pattern>'). Run these pattern groups, excluding node_modules/vendor/dist/build/lib: - Env variable assignments: PRIVATE_KEY, SECRET_KEY, API_KEY, MNEMONIC, DATABASE_URL, AWS_SECRET_ACCESS_KEY, etc. - Crypto private keys: 0x + 64 hex chars in variable assignments, string literals, config (not ABI/bytecode) - Prefixed service tokens (zero false positives): ghp_, gho_, ghs_, github_pat_, sk_live_, sk_test_, rk_live_, whsec_, xoxb-, xoxp-, sk-ant-api03-, sk-proj-, SG., AKIA, AIzaSy, glpat-, npm_, pypi-, hf_, dop_v1_ - PEM private keys: -----BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY----- - Webhook URLs: hooks.slack.com/services/, discord.com/api/webhooks/ - Database connection strings: postgres/mongodb+srv/mysql/redis with embedded credentials - JWT tokens: eyJ... (decode payload, check for service_role or admin claims) Filter out placeholders: empty, your_, _here, example, REDACTED, \${...}, process.env 4. Find dangling commits using three methods: a. GitHub Events API (last 30 days): extract SHAs from PushEvent payloads, especially zero-commit pushes (force-push fingerprint) b. GH Archive (back to 2015, public repos only): download hourly files from data.gharchive.org, filter for zero-commit PushEvents c. GitHub Actions runs, PR review comments, and deployments API for additional historical SHAs 5. Fetch each dangling commit (git fetch origin <sha>, REST API, or .patch URL). Scan using both diff scanning (git show <sha>) AND file enumeration (git ls-tree) for all pattern groups above. 6. Verify discovered secrets: - Crypto keys: derive address with cast wallet address, check balance/nonce via RPC, check recent transaction history via Blockscout API (curl -s https://base.blockscout.com/api/v2/addresses/<addr>/transactions). Flag if wallet was active within the last week. - AWS keys: verify with aws sts get-caller-identity (read-only) - GitHub tokens: verify with gh api /user using the found token - Other tokens: flag for manual rotation 7. Clean up: rm -rf /tmp/secret-scan-<ts>/ ## Key constraints - Never output full private keys in reports. Use truncated form (first 6, last 4 chars) or refer by commit SHA and file path - Always print full public addresses so they are easy to look up - Never store discovered keys to disk. All key material stays in memory only - Events API retains only 30 days of history and caps at 300 events - GH Archive only covers public repos. For private repos, Events API is the only automated discovery method - Skip blobs from node_modules/, vendor/, .yarn/, dist/, build/ paths - Prefixed service tokens are always real findings, no context filtering needed

This post includes an agent skill — a structured prompt that teaches coding agents to implement this pattern.