0
Reconnaissance BEGINNER 15 min read Updated

GitHub Recon: Finding Secrets in Public Repositories

GitHub recon turns public repositories into a source of live secrets: API keys, tokens, and cloud credentials. This guide covers code search operators, scanning git history with trufflehog and gitleaks, safe triage, and how to lock it all down.

founder & security researcher · 39 writeups
GitHub Recon: Finding Secrets in Public Repositories

GitHub recon is the practice of reading an organisation's public repositories for secrets its developers never meant to ship: API keys, access tokens, cloud credentials, internal hostnames, and config files that were supposed to stay private. The code is public on purpose, but the secrets inside it are almost never public on purpose. That gap is one of the most reliable ways into an environment, and it costs an attacker nothing but patience. This guide walks the whole workflow: what leaks and why, how to search for it with GitHub's own operators, why the git history is where the real treasure sits, how to automate the hunt with trufflehog and gitleaks, how to verify a finding without abusing it, and how a defender shuts the whole thing down.

Read it top to bottom once to build the method, then keep it open as a checklist. Every example here points at a repository you own or are authorized to assess.

Scope

Run this only against repositories and organisations you own or have written permission to assess, or your own lab accounts. The examples use example-org/example-repo and the lab host lab.0xpriv.dev. Searching is passive, but acting on someone else's live credentials without authorization is a crime. Find it, prove it is real with the lightest possible check, and report it.

01Why public repositories leak secrets and what to look for

GitHub recon works because public repositories leak secrets constantly, and they do it for ordinary, human reasons. A developer hardcodes an API key to test something quickly and forgets to pull it back out. A .env file gets committed before anyone writes a .gitignore. A config template ships with a real token instead of a placeholder. A private repo is made public during a cleanup, dragging its entire history along. None of this is exotic. It is the default failure mode of fast-moving teams, which is exactly why recon pays off.

Knowing what a secret looks like is half the battle, because most of them follow recognisable shapes. The table below is the short list worth memorising before you start searching.

What leaksWhat it looks like
AWS keysAKIA... access key id next to a 40-char secret
Cloud / SaaS tokensGitHub ghp_..., Slack xoxb-..., Stripe sk_live_...
Private keysa BEGIN RSA PRIVATE KEY or BEGIN OPENSSH PRIVATE KEY header
Config files.env, config.php, application.yml, .npmrc, id_rsa
Internal hostnamesnames like db-internal.lab.0xpriv.dev or RFC1918 hosts such as 10.10.10.5

Internal hostnames and connection strings matter as much as keys. A database DSN or an internal URL is not a password, but it maps the environment and tells you what a leaked key is for. Treat the whole picture as the prize, not just the token.

the GitHub recon workflow
Target orgexample-org Enumeraterepos, forks Search + scanfiles + history Verify

02GitHub code search operators and useful queries

GitHub code search is the first tool for GitHub recon because it reads the contents of files across indexed repositories, and a few operators narrow a giant haystack to a handful of candidates. You run it logged in, either at github.com/search with the type set to Code or from the command line with gh search code. Searches combine free-text content terms with qualifiers that scope where to look.

bash
# 0xpriv.dev/recon  --  scope every query to a target you are authorized to assess
# content term + org qualifier: find a variable name across one org
gh search code "AWS_SECRET_ACCESS_KEY" --owner example-org

# path qualifier: config files that should never be committed
gh search code "path:**/.env DB_PASSWORD" --owner example-org
gh search code "path:**/application.yml password" --owner example-org

# match the header of a private key in file contents
gh search code "BEGIN RSA PRIVATE KEY" --owner example-org

# narrow by language or repo
gh search code "api_key" --owner example-org --language php
gh search code "ghp_" --repo example-org/example-repo

Two habits make the difference. Search for the variable name, not the value, because AWS_SECRET_ACCESS_KEY or DB_PASSWORD appears right next to whatever was assigned to it. And scope every query with --owner or --repo so you stay on the target you are allowed to test. The web UI uses the same qualifiers: org:example-org path:.npmrc _authToken reads the same way in the search bar.

Code search has blind spots

GitHub code search only indexes the default branch of a repository, and only the current version of each file. It does not read other branches, and it does not read deleted content. A secret that was removed in a later commit will not show up here at all, which is the single most important limitation to understand before you trust an empty result.

03Why git history matters: deleted is not gone

Git history is where most real secrets hide, because a secret deleted from the current files still lives in every old commit that ever contained it. When a developer notices a committed key and removes it, the removal is just another commit. The key is still sitting in the parent commit, fully intact, and anyone who clones the repository gets the entire history by default. Code search cannot see it. A history scan can.

This is why the hunt has two stages: search the visible files first, then clone the repository and walk its commits. The diagram shows the trap. The current tree looks clean, but the object sitting one commit back is not.

a removed secret still lives in an old commit
Commit Aadds .env with key Commit Bdeletes .env Current treelooks clean key still in A forever

You can confirm this by hand in seconds. Clone the repository, find the commit that touched the sensitive file, and read it back out of history.

a secret that is gone from HEAD but alive in history
$ git clone https://github.com/example-org/example-repo
$ cd example-repo
$ git log --oneline --all -- .env
a1b2c3d remove committed .env (oops)
f4e5d6c add initial service config
$ git show f4e5d6c:.env
DB_PASSWORD=S3rv1ce-Db-Pass!
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

04Automated scanning with trufflehog and gitleaks

Automated scanners do the history walk for you and match hundreds of secret patterns in one pass, which is why trufflehog and gitleaks are the backbone of serious GitHub recon. Doing a few extractions by hand builds the intuition, but no human greps every commit of a busy repository. The two tools overlap and are worth running together, because each catches things the other misses.

trufflehog (by Truffle Security) scans a repository and its full git history, and its best feature is verification: it takes a candidate secret and actively checks it against the provider to tell you whether the credential is still live. Point it at a repo by URL or at a local clone.

bash
# scan a single repo straight from GitHub, including its history
trufflehog github --repo=https://github.com/example-org/example-repo --only-verified

# scan a local clone's full git history
git clone https://github.com/example-org/example-repo
trufflehog git file://example-repo --only-verified

The --only-verified flag is the one that saves your time: it drops the thousands of dead and example keys and leaves the credentials that actually authenticate right now.

trufflehog reporting a verified, live credential
$ trufflehog git file://example-repo --only-verified
Found verified result
Detector Type: AWS
Decoder Type: PLAIN
Verified: true
Repository: https://github.com/example-org/example-repo
Commit: f4e5d6c9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d
File: .env

gitleaks (an open-source project under the gitleaks org) scans git history by default and runs on a large ruleset of regex patterns. It is fast, easy to drop into a pipeline, and writes a clean JSON report you can triage later.

bash
# scan the full history of a local clone and write a JSON report
gitleaks detect --source example-repo --report-format json --report-path leaks.json --verbose

# scan only the current files, no history (quick sanity pass)
gitleaks detect --source example-repo --no-git
gitleaks flagging a finding in an old commit
$ gitleaks detect --source example-repo --verbose
Finding:     AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/...
Secret:      wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
RuleID:      aws-access-token
File:        .env
Commit:      f4e5d6c9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d
2 leaks found

05Scanning a whole organisation and its forks

Scanning a whole organisation widens GitHub recon from one repository to every repository a target owns, and that is usually where the overlooked one hides. trufflehog takes an --org flag and will enumerate and scan all of an organisation's public repositories in a single run. The oldest, least-watched side project is often the one with the committed key, so breadth matters more than depth here.

bash
# scan every public repo in an organisation
trufflehog github --org=example-org --only-verified

# include forks: a fork can carry a secret the upstream never had
trufflehog github --org=example-org --include-forks --only-verified

Forks deserve their own pass. A developer forks a repository into their personal account, commits a working config with real credentials to test locally, and never pushes it upstream. The fork is still public, still searchable, and still tied by name to the target. The --include-forks flag tells trufflehog to follow them. The same logic applies to the personal accounts of known employees, which you find from the organisation's member list and from commit author data: people leak their work secrets in their own repos all the time.

Breadth before depth

A quick verified-only scan across every repo and fork beats an exhaustive scan of one repo. Sweep wide first, rank by what came back verified, then dig into the handful of repositories that actually produced a live hit.

06Verifying and triaging a found secret without abusing it

Verifying a found secret means proving it is real and live with the lightest possible touch, and then stopping. This is the line between a professional assessment and a breach. The goal of recon is to show that a valid credential is exposed and to report it, not to see how far it goes. One read-only identity check is enough to prove the point.

Pick the single call that confirms the key works and reveals who it belongs to, without reading data, listing resources, or changing anything. For an AWS key, that is the identity lookup. For a GitHub token, it is the current-user endpoint, which also returns the token's scopes in a response header.

bash
# AWS: confirm the key is live and whose account it is. reads nothing else.
AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... aws sts get-caller-identity

# GitHub token: who it is and what scopes it has (-I shows the X-OAuth-Scopes header)
curl -sI -H "Authorization: token ghp_REDACTED" https://api.github.com/user

Record the proof, redact the secret in your notes and report, and move on. The steps below are the whole triage routine.

Classify the secret

Identify the provider and type from its prefix or format, so you know which single read-only call will confirm it.

Confirm it is live

Run one identity or whoami call. A verified scanner result plus one manual check is all the proof you need.

Scope the impact

Note what the credential is and what it could reach, from its type and scopes, without actually exercising that access.

Report and stop

Write it up with the repo, commit hash, and file path, redact the value, and hand it to the owner to rotate.

The hard line

Do not use the credential beyond a single identity check. No listing buckets, no reading mail, no creating resources, no lateral movement. Even in an authorized engagement, confirm exposure and report it unless your rules of engagement explicitly say otherwise in writing.

07A worked example: scanning one repo end to end

A worked example ties the whole GitHub recon method together on a single repository, from search to a verified, triaged finding. Say your scope lists example-org. You start with code search, find nothing in the current files, and then do the history scan that code search cannot. That gap is the lesson of the whole exercise.

search comes up empty, history does not
$ gh search code "AWS_SECRET_ACCESS_KEY" --owner example-org
no code results matched your search
$ trufflehog github --org=example-org --include-forks --only-verified
Found verified result
Detector Type: AWS
Verified: true
Repository: https://github.com/example-org/example-repo
Commit: f4e5d6c
File: .env

Code search found nothing because the .env was deleted months ago, so it is absent from the current default branch. trufflehog found it because it read the history, and it marked the result verified, which means the key still authenticates. Now you take exactly one confirming step and then write the finding up.

one read-only check, then stop
$ aws sts get-caller-identity
{
  "UserId": "AIDAEXAMPLEEXAMPLE",
  "Account": "210987654321",
  "Arn": "arn:aws:iam::210987654321:user/ci-deploy"
}

That single response is the entire proof: a live AWS key, tied to a ci-deploy user, committed in example-repo at commit f4e5d6c. The report writes itself, the secret gets redacted, and the owner rotates it. No bucket was listed, nothing was touched. That restraint is what makes it an assessment and not an incident.

08Detecting and monitoring leaked secrets

Detecting leaked secrets is the defender's side of the same coin, and it is mostly about watching the same surfaces an attacker searches. The strongest signal is GitHub's own secret scanning, which GitHub runs on public repositories and which alerts you when a recognised credential format is pushed. Beyond that, treat your public code as something to monitor continuously, not to check once.

Three signals are worth wiring up. Run trufflehog or gitleaks on a schedule against your own organisation, so you find a leak the same way an attacker would, only first. Subscribe to GitHub secret scanning alerts for every public repo. And watch provider-side logs: an AWS key appearing from a new region or a GitHub token used from an unfamiliar address, soon after a commit, is the shape of a leaked credential being tried. Alert on the credential use, not just on the commit, because the commit is often months old by the time anyone looks.

09Shutting it down: how to stop secrets reaching GitHub

Shutting down secret leaks is a layered job, because no single control catches everything, and the most important move is the one people skip: rotate, do not just delete. Deleting a secret from the current files leaves it alive in history and still valid at the provider, so a quiet delete fixes nothing. The box below is the full set, from keeping secrets out of commits to limiting the damage when one slips through.

How to shut it down
Scan before the commit lands. Add a pre-commit hook so a secret is caught on the developer's machine, before it ever reaches a remote. gitleaks ships a ready-made hook for this.
Rotate on exposure, never just delete. Treat any committed secret as burned. Rotate it at the provider so the old value stops working, then clean the history. Deleting the file alone leaves a live key in every old commit.
Use a secret manager. Keep credentials in a vault or your platform's secret store and inject them at runtime, so there is no value in the repository to leak in the first place.
Turn on push protection and secret scanning. GitHub push protection blocks a recognised secret at push time, and secret scanning alerts on what already landed. Both are free on public repositories.
Issue least-privilege, short-lived tokens. A scoped, expiring credential limits the blast radius of the one that inevitably slips through, and makes a leaked key far less useful to whoever finds it.

The pre-commit hook is the cheapest win, so wire it in first. gitleaks plugs straight into the pre-commit framework:

yaml
# .pre-commit-config.yaml  --  block secrets before they are committed
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

10Frequently asked questions

Is GitHub recon legal if the repositories are public?FAQ

Reading and searching public code is generally fine, but using a credential you find is not. The moment you authenticate with someone else's exposed key beyond proving it is live, you are accessing a system without authorization, which is a crime in most places. Keep it to scoped, authorized targets and report what you find.

Why does code search miss secrets that trufflehog finds?FAQ

GitHub code search only indexes the current version of files on the default branch. A secret that was committed and later removed is gone from the current files, so search returns nothing, but it still exists in the git history. trufflehog and gitleaks read the full history, which is why they catch what search cannot.

trufflehog or gitleaks: which should I use?FAQ

Run both. gitleaks is fast and great in a pipeline, and it scans history out of the box. trufflehog adds live verification, so it tells you which of the hundreds of candidate keys still authenticate right now. Using them together gives you broad coverage plus a short, verified shortlist to act on.

I committed a secret by accident. Is deleting the file enough?FAQ

No. Deleting the file in a new commit leaves the secret in every old commit, and anyone who cloned the repo already has it. Assume it is compromised: rotate the credential at the provider first so the old value stops working, then rewrite history to remove it. Rotation is the step that actually matters.

Does GitHub secret scanning make my own hunting pointless?FAQ

No. Secret scanning and push protection cover the formats GitHub recognises, but they miss custom tokens, internal hostnames, and secrets in formats without a known signature. Running your own trufflehog and gitleaks sweeps on a schedule catches the rest, and it finds leaks before an outsider does.

11Wrapping up

GitHub recon comes down to one habit: assume the secret is in the history, not the current files. Search the visible code with GitHub's operators to get oriented, then clone and scan the full history with trufflehog and gitleaks, because that is where the removed-but-never-rotated keys live. When something turns up, prove it is real with a single read-only check and report it, nothing more. On the defender's side the lesson flips neatly: keep secrets out of commits with pre-commit scanning and a secret manager, turn on push protection, and when one does leak, rotate it rather than quietly deleting it. The hunt and the fix are the same story told from two sides.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Recon →

discussion

0 comments
`code` supported · comments are reviewed before they appear