Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

git2mail extracts the email addresses a GitHub account exposes through its public activity: repository commits, profile events, and repository search. It comes in two shapes: a command-line tool, where you type commands in a terminal (the text window on your computer: Terminal on Mac and Linux, PowerShell on Windows), and an anonymous web scan - a browser page, no account and no install, capped at GitHub’s anonymous quota (its allowance for unauthenticated calls) - described on the project site.

Responsible use. git2mail collects only publicly available data through the GitHub REST API. Use it solely for lawful, authorized OSINT research, in accordance with the GitHub Terms of Service and applicable laws: email addresses are personal data in many jurisdictions, and the results/ and report files this tool writes hold them. The handling duties are covered in detail in Data quality, prevention & limitations.

git2mail scan   --url <github URL> [--token …|--token-file …] [--json <path>]
git2mail search --query <q> [--language <l>] [--limit N] [--include-forks]
                [--token …|--token-file …] [--json <path>]
git2mail email  <address> [--token …|--token-file …] [--json <path>]
git2mail emails <file> [--token …|--token-file …] [--json <path>]
git2mail user   <username> [--variant-pattern <p>] [--include-forks]
                [--token …|--token-file …] [--json <path>]
git2mail org    <org> [--limit N] [--include-forks]
                [--token …|--token-file …] [--json <path>]

How to read the commands above: text in [square brackets] is optional, | means “or”, and <lowercase words> stand for values you fill in: never type the brackets themselves.

Forked repositories are skipped by default on user, org and search (their commit history mirrors the upstream project’s); --include-forks scans them too.

search also accepts filter flags: --stars, --forks, --size, --created, --pushed, --license, --topic, --user and --org, with concrete examples in GitHub API usage and git2mail search --help.

scan accepts a repository URL or a profile URL; search runs a repository search query and scans every result. email and emails reverse one address (or a list) into GitHub accounts; user investigates a profile (identity history, external contributions, generated email variants, secondary accounts, SSH keys); org investigates an organization’s members and repositories. The investigation commands (user, email, emails, org) report on screen like the scan commands, and through the opt-in JSON report (--json <path>) when you want to save them (see Results).

What is in this guide

  • Scanning: scan & search - the two core subcommands: URL rules, keyword search, advanced qualifiers, scripting notes.
  • User investigation - the user subcommand: identity history via the noreply technique, email variants, secondary accounts, SSH keys, external contributions.
  • Organization investigation - the org subcommand: members, repositories, per-repository emails.
  • Reverse email lookup - the email/emails subcommands: the candidate-search heuristic, confidence grades, bulk mode.
  • Reports & files on disk - terminal output, exit codes, JSON report shapes, developer correlation, result file layout, sanitization and permissions.
  • Data quality, prevention & limitations - what the findings can prove, how to harden your own exposure, coverage limits.
  • GitHub API usage - requests, pagination, egress (proxies and Tor), rate-limit semantics, caps, search behavior.

Installation

The easiest way: download the ready-made program from the releases page: no installation needed. If you already have the Rust toolchain, you can instead install from crates.io:

cargo install git2mail --locked

Building from source needs the Rust toolchain plus cmake, a C compiler and pkg-config (the bundled TLS library compiles C code; on macOS, start with xcode-select --install then brew install cmake pkg-config).

Running the downloaded binary

Pick the file for your computer: git2mail-windows-v<version>.exe (Windows), git2mail-macos-v<version> (Mac, Apple Silicon) or git2mail-linux-static-v<version> (Linux; “static” means one single file, nothing else to install). The builds are signed and logged by GitHub’s artifact attestation (a receipt GitHub generates at build time, proving this exact file came from this repository’s official build pipeline); verifying requires the GitHub CLI gh: gh attestation verify --repo extiop/git2mail <file>, as described in each release’s notes. Replace <version> below with the version you downloaded, without the leading v.

Then open a terminal in your Downloads folder and run the program from there:

  • Windows: open the Downloads folder in File Explorer, type powershell in the address bar and press Enter, then run the program with its real file name, for example ./git2mail-windows-v0.8.0.exe scan --url https://github.com/octocat/Hello-World (a small repository: the anonymous quickstart in the README completes on it). If Windows shows a “Windows protected your PC” screen, first verify the file’s attestation (gh attestation verify --repo extiop/git2mail <file>, with the GitHub CLI gh installed), then click More info and Run anyway (the program is not digitally signed).
  • Mac: in Terminal, type cd ~/Downloads then chmod +x git2mail-macos-* (make it executable) and run a first scan, for example ./git2mail-macos-v0.8.0 scan --url https://github.com/octocat/Hello-World. Running the file bare prints the help and exits. If the terminal instead prints zsh: killed with no dialog, macOS silently refused the quarantined file: clear the block with xattr -d com.apple.quarantine git2mail-macos-* and run it again. If macOS says it “cannot be opened because it is from an unidentified developer”: right-click the file, choose Open then Open again, or clear the block with the same xattr command. The downloaded program is built for Apple Silicon Macs; on an Intel Mac, build from source instead (above).
  • Linux: chmod +x git2mail-linux-static-* then run a first scan, for example ./git2mail-linux-static-v0.8.0 scan --url https://github.com/octocat/Hello-World.

The tool is not on Homebrew: get the release binary (above) or build from source with Cargo.

Tokens

Getting a token

Create a classic token at https://github.com/settings/tokens with no scopes at all (reading public data needs none) or a fine-grained token with “Public repositories (read-only)”.

tokens.example.json shows the file format --token-file expects: one JSON object mapping a numeric index ("0", "1", …) to a token string.

Authentication is optional. With no token, requests are anonymous and subject to GitHub’s low anonymous rate limits. Tokens come from --token, or from a JSON file passed to --token-file (see tokens.example.json); the format of each token is validated at startup and a token that fails validation aborts the run before any request is sent.

All tokens are managed by a token pool that allocates them round-robin and reacts to rate limiting; GitHub API usage specifies the exact behavior.

For unattended jobs (CI, cron), write the token to a 0600 file at runtime and pass it with --token-file (( umask 077; printf '{"0": "%s"}' "$GH_TOKEN" > tokens.json ); the umask is what makes the file 0600 when it is created; if tokens.json already exists, chmod 600 tokens.json instead), rather than leaking it through a process list.

Interrupting a run

Ctrl-C aborts the run immediately: nothing is written, not even a partial report, so a truncated scan can never look complete. Re-run the same command to start over.

Design principles

  • GitHub REST API only. No HTML scraping, no binary git dependency: every fact git2mail reports comes from the documented API.
  • Minimal dependencies. Everything doable with std is done with std; every dependency is taken with default-features = false and explicit features. Errors are hand-made (an #[non_exhaustive] enum with explicit From conversions), panics never occur on the runtime path, and the test suite runs fully offline against a local HTTP mock.
  • Blocking I/O with bounded parallelism. Scans use reqwest::blocking over std threads; the multi-repository fan-outs (the keyword search and the org scan, shared with the bulk reverse lookup) spawn min(token_count, work_count) workers when tokens are configured, each dedicated the next live token (retired or parked tokens are skipped), draining a shared queue; an anonymous run uses a single shared-quota worker, and if every configured token is already dead the run still fails loudly. No async runtime.

Scanning: scan & search

The two core subcommands recover email addresses from GitHub activity: scan targets one repository or one profile, search runs a keyword search and scans every matching repository.

Scanning a repository or a profile

git2mail scan --url https://github.com/denoland/deno

git2mail scan --url https://github.com/denoland

Use a token to authenticate and fetch many more commits:

git2mail scan --url "$TARGET_URL" --token "$YOUR_TOKEN"

A token passed with --token stays saved on your computer in the record of commands you have typed, and other programs running at the same time can see it: prefer --token-file, which reads the token(s) from a file instead. When both are provided, --token wins for that run and a warning is logged (a whitespace-only --token counts as absent and falls back to --token-file).

If you don’t have a token yet, you can still try the tool: simply leave --token out. GitHub then allows only about 60 requests per hour, so a token is recommended for any real scan.

Repository URL rules

The repository URL must follow the following format:

git://github.com/some-user/my-repo[.git]
git@github.com:some-user/my-repo[.git]
https://github.com/some-user/my-repo[.git]
ssh://git@github.com/group/app[.git]
  • Every URL must point at github.com (or www.github.com); any other host is rejected as an invalid URL before the scan runs. The API base is GitHub’s, so a URL pointing at another server (a GitLab or Gitea remote, for instance) would otherwise be silently scanned against the github.com namesake.
  • A trailing .git suffix is kept verbatim as part of the repository name: it is passed through to the GitHub API request and included in the output file name (results/repository/some-user_my-repo.git.log, the .log extension appended after the passthrough name). Since the GitHub API only resolves repository names without the suffix (GET /repos/some-user/my-repo.git answers 404, verified live), a scan with a .git URL always fails with “GitHub resource not found”: the suffix documents a passthrough guarantee, not a usable input form: drop it before scanning.
  • A trailing / on the URL is a leftover from copy-pasting, never part of the repository name: it is trimmed before the request (https://github.com/some-user/my-repo/ targets some-user/my-repo, not the guaranteed-404 my-repo%2F). A link copied from a file browsing page (/tree/<branch>/…) targets a subfolder, not the repository root: paste the repository root URL instead. The other github.com page-navigation leftovers from copy-pasting (?tab=repositories, ?plain=1#L12, #readme…) are stripped the same way, since no GitHub login or repository name contains ? or #. The URL scheme is case-insensitive too: HTTPS://GITHUB.COM/some-user/my-repo parses exactly like its lowercase form (login/repository case is preserved).
  • A profile URL has no name after its separator to trim, so a trailing / there is rejected as an invalid URL.

The profile URL must follow the following format:

git://github.com/some-user
git@github.com:some-user
https://github.com/some-user
ssh://git@github.com/some-user

Result paths and provenance

With several tokens, make your own copy of tokens.example.json with your tokens in it, to look through many more commits:

git2mail scan --url "$TARGET_URL" --token-file "$YOUR_TOKEN_FILE"

With dummy values:

git2mail scan --url https://github.com/denoland/deno --token-file tokens.json

In this mode, your results will be stored per profile with the following relative path, from your current working directory: results/profile/$author.log. If a repository is defined, it will be at results/repository/$author_$repository.log. A name the tool had to change (sanitized) carries a 16-hex-digit suffix derived from the original name, so two different targets whose names collapse onto the same sanitized file name cannot silently overwrite each other on a case-sensitive filesystem; on a case-insensitive one (macOS, Windows), two targets differing only by case share a file name. An unchanged name keeps its exact file name, and the exact target is always printed with every report. The run always prints the real path of every file it writes. The JSON report records when it ran (generated_at), which version produced it (tool_version) and, on a keyword search, the exact query, language filter and qualifiers used (request); qualifiers are GitHub’s key:value search filters such as stars:>100, so two runs with different qualifiers never write indistinguishable reports. Archive the full command line next to the report for complete reproducibility (--variant-pattern templates and file paths are not part of the report).

To also get a JSON report (a structured text file you can open in any text editor, and other software can read) in addition to the on-screen results:

git2mail scan --url "$TARGET_URL" --json "$YOUR_REPORT_PATH"

Result files and JSON reports are written to a temporary file first, then renamed, so a file is never left half-written even if you open it while a scan is still running.

The profile events timeline cap

GitHub caps the profile events timeline at 300 events (its documented API ceiling): the scan never walks past the cap and a warning says so when the cap cuts a prolific profile’s listing, instead of the events stage failing on the out-of-window request. When the events hold no email, the scan falls back to the target’s repositories’ commits, as always.

Search: metadata as a keyword

You want to search, for instance, for some Rust developers that create nmap related tools:

git2mail search --query nmap --language rust --token-file tokens.json

You can also limit the number of repositories scanned this way, for instance:

git2mail search --query nmap --language rust --token-file tokens.json --limit 5

Without --limit, the search defaults to scanning 5 repositories.

In this mode, your results will be stored per query with the following relative path, from your current working directory: results/keyword/$query.log. If a language is defined, it will be at results/keyword/$query_$language.log. As everywhere else, a name the tool had to change (sanitized) carries a 16-hex-digit suffix derived from the original name; the run always prints the real path of every file it writes.

GitHub’s repository search includes forks: like the profile and organization investigations, forked repositories are skipped with a warning (their commit history mirrors the upstream project’s, so scanning them would report other projects’ developers under the fork’s name). Pass --include-forks to scan them too when the forks themselves are your subject.

A keyword search scans several repositories, so its JSON report also breaks the findings down per project: per_repository maps each repository’s owner/name to the emails its commits exposed (only the repositories where at least one email showed up are listed, and the key is omitted entirely when no repository produced one), and the request object records the exact query, language filter and qualifiers that produced the report (with generated_at and tool_version, two runs with different qualifiers never write indistinguishable reports).

Advanced search qualifiers

The search subcommand also accepts the optional qualifiers of GitHub’s advanced search. They all compose with --query, --language and --limit, and only the ones you provide are appended to the GitHub query:

FlagQualifierExample values
--stars <RANGE>stars:>100, >=50, 10..50, 100..*
--forks <RANGE>forks:>10, 5..20
--size <RANGE>size:>1000, 100..5000
--created <DATE_RANGE>created:>2020-01-01, >=2020, 2019-01-01..2020-01-01
--pushed <DATE_RANGE>pushed:>2024-01-01, >=2023
--license <ID>license:mit, apache-2.0
--topic <NAME>topic:osint, recon
--user <LOGIN>user:octocat (mutually exclusive with --org)
--org <LOGIN>org:acme (mutually exclusive with --user)

Quote range values that contain > or < so the shell does not read them as redirections: --stars '>100'.

For instance, to scan well-established MIT-licensed Rust repositories of one organization, recently pushed:

git2mail search --query nmap --language rust --stars '>100' --license mit --org acme --pushed '>2024-01-01' --token-file tokens.json

To target one user’s repositories on a topic:

git2mail search --query nmap --topic osint --user octocat --token-file tokens.json

--user and --org are mutually exclusive (the GitHub search API accepts only one owner filter). Values are validated client-side before any request: an obviously malformed range or date (say --stars abc or --created 2020-13-01) fails fast with a clear invalid search filter error instead of silently returning no result from the API. Count and date ranges follow the GitHub search range syntax (>, >=, <, <=, start..end, with * for an open end); dates accept the YYYY, YYYY-MM and YYYY-MM-DD granularities. An inverted range (--stars 50..10, --created 2020-01-01..2019-01-01) is well-formed but rejected client-side like every other malformed value; for dates this matters because GitHub itself does not reject an inverted range: it silently swaps the sides and quietly covers the opposite window. The stricter name rules are validated client-side too: a --user/--org value must be a valid GitHub login (ASCII letters, digits or hyphens; a login with . or _ would silently match nothing) and a --license/--topic value must hold at least one alphanumeric character (a separator-only ... or -- would silently match nothing). Only one --topic value is accepted per run; to combine topics, pass them in the query itself (--query "topic:osint topic:recon"). The repository listing itself is capped at GitHub’s documented 1000-result search ceiling: the over-ceiling page is never requested (the API answers it with a raw 422), so a query matching more than 1000 repositories scans at most the first 1000. The qualifiers are not part of the output file name: results are stored per query/language, so two runs differing only by their qualifiers write to the same results/keyword/$query_$language.log file, and the last run overwrites the previous one. For a systematic sweep over qualifiers, give each run its own --json report-<qualifier>.json path: the text file is the working list, the JSON reports are the archive.

Scripting notes

  • Exit code 0 means the run completed, 1 that it failed, 2 that the command line itself was invalid, and 3 that the run completed but found nothing (an empty scan/search result): branch on 3 to tell “the target is live but carries no email” apart from a real failure.
  • Only warnings and the report lines reach the terminal, and warnings go to stderr: a pipeline can detect a partial run by merging the streams and matching the [!] marker warnings carry, for example git2mail … 2>&1 | grep '^\[!\]' (PowerShell: git2mail … 2>&1 | Select-String '^\[!\]').
  • The exit codes and the on-disk layout are documented in Reports & files on disk; see also Data quality, prevention & limitations for what the reports can and cannot prove.

Combining with other OSINT tools

This tool can be combined with other OSINT tooling. For instance, you can use git2mail to retrieve Gmail addresses, then investigate some of them further with GHunt (for lawful research only, as everywhere in this guide).

User investigation (user)

The user subcommand investigates one GitHub profile end to end, 100% API (no clone, no HTML scraping):

git2mail user "jane-doe" --token "$YOUR_TOKEN"

This investigation walks many pages: a token is strongly recommended (an anonymous account gets only 60 requests per hour and the walk (the page-by-page listing traversal) aborts when the quota is spent).

It accepts the shared --token/--token-file/--json flags and reports, both in the terminal log and in the opt-in JSON report. The observed email addresses are also saved to results/user/<username>.log, one per line, the same paste-able file the scan and org subcommands write.

What the investigation collects

  • Profile: the account fields that carry OSINT value (name, company, blog, location, public email, bio, creation date, account type, Twitter/X handle and public repository count). An organization target is rejected with a clear error pointing at the org subcommand.
  • Usernames/names history: the noreply technique: GitHub hides users’ real email in commits behind an alias like 12345+jane@users.noreply.github.com, and the number in that alias is the account’s ID, so matching it exposes usernames the account used in the past. The username in the alias is written by the commit author’s Git configuration, not by GitHub: treat it as strong evidence, not as a verified fact. The target’s own repositories’ commits are scanned, and every author/committer email shaped <id>+<username>@users.noreply.github.com whose numeric id equals the target’s reveals a username the account used in the past (with the commit names seen with it and the repositories where it appeared). The older pre-2017 form <id>@users.noreply.github.com attributes the current username only.
  • Emails: every email used on the target’s repositories, with the names and repositories where each was seen. Noreply addresses are excluded: they feed the histories instead, and a malformed noreply payload (a non-numeric id…) is dropped entirely. Forked repositories are skipped with a warning: their commit history mirrors the upstream project’s, so scanning them would report other projects’ developers. Pass --include-forks to scan them too when the forks themselves are your subject.
  • Variants: the likely email spellings {first}.{last}, {first}{last}, {initial}{last}, {first}_{last}, {initial}_{last}, {first}, {last}, {initial}.{last} applied to the first/last names extracted from the profile name and the commit names seen with the target’s noreply id (a name that appears only with a real, non-noreply email does not feed the guessing), combined with the domains deduced from the emails already found (noreply excluded). Variants actually observed in the commits are reported as matched (deduplicated by email); the others as generated (candidates to check elsewhere, not findings). Names are lowercased and accents removed (é→e, plus ß→ss, æ→ae, œ→oe, ø→o); names in other alphabets are kept as written. The candidate names and domains are ranked by raw sighting weight and capped (at most 64 name pairs and 64 domains are expanded, the generated list at 1000 candidates, with warnings), so commit-forged metadata cannot drown the real ones. Beyond the eight built-in patterns, --variant-pattern <PATTERN> (repeatable, at most 64 patterns) probes your own conventions, e.g. git2mail user janedoe --variant-pattern '{last}{first}'; the placeholders are {first}, {last} and {initial} (the domain is not part of the pattern), only printable ASCII is allowed (whitespace, @, control and zero-width characters are rejected client-side), the expansion is lowercased so the literal part matches observed emails case-insensitively like the built-ins, the custom patterns are applied after the built-ins (deduplicated against them) and are checked against the commit data fetched from the API like every other candidate. On a broad target the built-ins alone can fill the 1000-candidate generated list; a warning then names the custom patterns as possibly under-listed (matched custom candidates are always reported). To check a generated candidate, run git2mail email <candidate>.
  • Local identities (machine-local addresses): emails like jeanpierre@My-Computer.local, jeanpierre@box.lan or a single-label domain like jeanpierre@mycomputer. This is a heuristic: it flags the documented mDNS/workgroup conventions and dot-less domains only, and a false positive merely adds one candidate to the local_identities list.
  • Potential secondary accounts (heuristics, reported with their evidence): an email recurring across two or more of the target’s repositories whose names never overlap the target’s own names (co-author), and noreply pairs (id+username or legacy <id> form) whose id differs from the target’s but whose username matches a known target username (noreply-username) or whose commit names match a known target name (noreply-name). The web-flow identity GitHub stamps on commits made through its UI (name github, address noreply@github.com) is never reported as a secondary account: it is automation, not a person (the email itself stays in the emails list).
  • SSH keys: the account’s public keys (GET /users/{u}/keys, paginated). The public endpoint exposes the key id and material only: the user-set title and creation date exist on the authenticated self listing, not here.
  • External contributions: commits authored by the target outside their own repositories (search/commits q=author:<user> -user:<user>, paginated, capped at GitHub’s documented 1000-result search ceiling), attributed to the repositories found.

Like every repository scan, each target repository’s commit walk covers the full default-branch history (the branch a project presents as its main line) with no local budget: the walk ends when GitHub’s listing ends, so the report never silently pretends it saw only part of a history.

JSON report shape

With --json, the enriched report carries the documented shape:

{
  "generated_at": "2026-02-14T18:22:33Z",
  "tool_version": "0.8.0",
  "profile": { "login": "jane-doe", "id": 42, "name": "Jane Dupont", "...": null },
  "usernames_history": [{ "username": "oldname", "names": ["jane d."], "repos": ["jane/old"] }],
  "names_history": [{ "name": "jane dupont", "repos": ["jane/old"] }],
  "emails": [{ "email": "jane@example.com", "names": ["jane dupont"], "repos": ["jane/one"] }],
  "variants": {
    "matched": [{ "email": "jane.dupont@acme.com", "pattern": "first.last", "domain": "acme.com" }],
    "generated": ["janedupont@acme.com", "jdupont@acme.com"]
  },
  "secondary_accounts": [{ "kind": "co-author", "identity": "co@acme.com", "names": ["co worker"], "repos": ["jane/one", "jane/two"] }],
  "ssh_keys": [{ "id": 1, "key": "ssh-ed25519 AAAA…" }],
  "external_contributions": [{ "repository": "acme/lib", "url": "https://github.com/acme/lib", "commits": 3 }],
  "local_identities": [{ "email": "jeanpierre@My-Computer.local", "names": [], "repos": ["jane/one"] }]
}

(generated_at and tool_version open the block: every report carries them.)

API-only deltas

Two API-only deltas are assumed and documented (the clone-based tools iterate all branches):

  1. the commit walk covers the default branch only: the API can address other branches and tags (a sha parameter), but a per-branch walk multiplies the request cost by the branch count and is not attempted, so the histories and emails are the ones visible on the default branches;
  2. the noreply history only reveals usernames for targets that committed at least once with the id+username@users.noreply.github.com form, and only usernames matching GitHub’s full username shape (1-39 ASCII letters, digits or hyphens, without a leading/trailing hyphen or consecutive hyphens): a commit email carrying a free-form or impossible-shape username payload falls back to the legacy id-only handling.

A rate-limit or transport failure aborts the investigation; only a repository that is private, deleted or empty is skipped with a warning, so the report can never silently miss whole repositories.

Organization investigation (org)

The org subcommand investigates one GitHub organization: its public members, its public repositories and the developers’ emails found scanning them.

git2mail org "acme" --token-file tokens.json

As with the user investigation, a token is strongly recommended: an org scan walks every listed repository and aborts on a spent anonymous quota. Forked repositories are skipped with a warning (like the user investigation): their commit history mirrors the upstream project’s, so scanning them would report other projects’ developers. Pass --include-forks to scan them too when the forks themselves are your subject.

It accepts the shared --token/--token-file/--json flags plus an optional --limit N capping the number of scanned repositories (all of them by default), and reports, both in the terminal log (ORG / MEMBERS / REPOSITORIES / EMAILS) and in the opt-in JSON report.

What the investigation collects

  • Org: the GET /orgs/{org} fields that carry OSINT value (login, name, blog). A missing organization is a clean not-found error.
  • Members: the organization’s publicly visible member usernames (just names on GitHub, no contact details; many companies hide this list entirely, and the endpoint only lists members who chose to publicize: a short list is not a malfunction). The emails below come from the repositories’ commits, not from these members. To investigate one member, run git2mail user <username>.
  • Repositories: the public repositories (GET /orgs/{org}/repos, paginated).
  • Emails: the developers’ emails found scanning the organization’s repositories’ commits with the same worker-per-token parallelism as the parallel search, reported per repository and globally (noreply addresses excluded, like every scan). The found addresses are also saved to results/org/<org>.log, one per line, the same paste-able file the scan and user subcommands write. For an exposure audit, the per_repository map is the finding: it tells you which repository leaks which addresses; attribute a person to an address with git2mail email <address> before contacting them.

JSON report shape

With --json, the report carries the documented shape:

{
  "generated_at": "2026-02-14T18:40:12Z",
  "tool_version": "0.8.0",
  "org": { "login": "acme", "name": "Acme Corp", "blog": "https://acme.example" },
  "members": ["alice", "bob"],
  "repositories": ["one", "two"],
  "emails": {
    "per_repository": { "acme/one": ["alice@acme.com"], "acme/two": ["bob@acme.com"] },
    "all": ["alice@acme.com", "bob@acme.com"]
  },
  "correlation": {
    "authors": [
      {
        "author": "Alice", "emails": ["alice@acme.com"], "total_hits": 1,
        "repos": [{ "name": "acme/one", "language": null }],
        "first_seen_repo": "acme/one",
        "last_commit_date": "2024-06-01T12:00:00Z"
      }
    ]
  },
  "scanned": { "repos": 2, "pages": 9 }
}

per_repository is keyed by the owner/name full names the scan reports, and only lists the repositories where at least one email showed up; scanned.pages counts the successful API requests of the whole investigation (org info, members, repositories listing and the workers’ commit pages). The correlation object follows the same shape as the scan and search reports, aggregating the developers found across the organization’s repositories. A repository that is private, deleted or empty is skipped with a warning; a rate-limit or transport failure aborts the investigation, and so does a dedicated token dying mid-scan (a revoked token is not a spent quota: the run aborts with the token’s real error instead of writing a complete-looking report).

Reverse email lookup (email & emails)

The email subcommand traces one email address back to its GitHub account; the emails subcommand runs the same lookup in bulk over the token pool.

git2mail email "jane.dupont@acme.com" --token "$YOUR_TOKEN"
git2mail emails addresses.txt --token-file tokens.json --json bulk.json

A token is strongly recommended here too: one lookup walks the full default-branch history of every candidate repository (until a commit-author match ends the lookup early), far beyond the anonymous 60-requests-per-hour budget.

GitHub exposes no email search API, so this subcommand is a documented heuristic - a stated, test-pinned search strategy, not an exact index - 100% API (no clone, no scraping).

How the heuristic works

  1. Candidate repositories: the repository search endpoint is queried with the tokens of the local part (split on ., _, - and +, so jane.dupont → jane dupont), with the raw local part when it differs (so janedupont is searched too) and with the domain itself (acme.com); at most three queries. The repositories whose name, description or topics mention these tokens become candidates: every candidate the search serves is verified, with no local cap.
  2. Commit verification: each candidate repository’s (default-branch) commits are scanned for a commit whose author email is exactly the target: the local part case-insensitively, the domain matched exactly (no subdomain look-alike), and the lookup stops early once the strongest attribution is found. The commits listing is never narrowed by the API author parameter: the commit author login is not necessarily the email local part, and a narrowing would silently drop the target’s own commits.
  3. Account attribution: a matching commit whose API payload embeds a GitHub author object resolves the account with the commit-author confidence (login, numeric id, repository and commit sha as evidence). When the author object is absent (deleted account), the repository owner is only a weak candidate, reported with the repo-owner-weak confidence and a null id: a presumption, never overstated.

Anti-false-positive rules: a GitHub noreply address (@users.noreply.github.com) is rejected upfront with a clear error (it never carries a real mailbox; the user subcommand’s noreply technique covers those), a malformed address is rejected client-side before any request, the email match is exact, and the confidence field states only what the API payload actually proved. An unmatched address is reported as unmatched (a finding, not an error).

Single-address JSON report shape

With --json, the report carries the documented shape:

{
  "email": "jane.dupont@acme.com",
  "account": { "login": "janedoe", "id": 42, "confidence": "commit-author" },
  "evidence": { "repository": "acme/lib", "commit_sha": "abc123", "author_email": "jane.dupont@acme.com" },
  "candidates": [{ "login": "janedoe", "id": 42, "confidence": "commit-author" }]
}

Limitations of the heuristic

Documented API-only deltas:

  1. the candidate search can only surface repositories whose name, description or topics mention the local-part tokens or the domain (forked candidates are kept: the lookup wants any repository carrying the address): an email never used in a public commit of one of those candidates cannot be found;
  2. the commit walk covers the default branch only (per-branch walking multiplies the request cost and is not attempted);
  3. the repo-owner-weak attribution presumes the repository owner is the login behind the matching commit: the commit carries no GitHub author object, so nothing in the payload actually proves it, and a repository owner may be an organization rather than a user, so resolve the account type before acting on a weak attribution.

Bulk mode (emails)

The file holds one address per line (# comments and blank lines ignored; a leading UTF-8 BOM, as written by Windows editors, is stripped); duplicated addresses are deduplicated and noreply or malformed lines are skipped with a warning (they cannot be reversed, they are not scan failures). The addresses are processed with the same worker-per-token parallelism as the parallel search, and the report keeps the file order with a matched/unmatched/skipped summary (total counts only processed addresses):

{
  "generated_at": "2026-02-14T18:22:33Z",
  "tool_version": "0.8.0",
  "results": [
    {
      "generated_at": "2026-02-14T18:22:41Z",
      "tool_version": "0.8.0",
      "email": "jane.dupont@acme.com", "account": null, "evidence": null, "candidates": []
    }
  ],
  "skipped_addresses": [],
  "summary": { "total": 1, "matched": 0, "unmatched": 1, "skipped": 0 }
}

Every report also carries generated_at (the UTC timestamp of the run) and tool_version (shown here); the one-address report of git2mail email carries them too, and so does every entry of the bulk results list (the moment its own lookup finished).

A worker whose dedicated token died (spent quota or rejected token) stops pulling work while the other tokens keep draining the queue; any other worker failure aborts the bulk run. The addresses a stopped worker never reached are never counted as a verified no-match: they are absent from results, listed in skipped_addresses (file order, so re-running the interrupted lookups needs no diff of two files), counted in summary.skipped and warned about in the log. “Skipped” names two different things: the read-time skips (noreply/malformed lines, warned about when the file is read) never enter the pipeline and are not counted in summary.skipped, which counts only the work a stopped worker never reached.

Results

Results are always saved in a results folder inside the folder you ran the command from, not next to the git2mail program itself (./results/…). The text output is skipped when a scan yields no email; the --json report (if requested) is still written, with empty emails. These files hold personal data (real people’s email addresses): store and share them with the same care as the data itself, delete them when you are done, and add results/ to your .gitignore if you run the tool inside a git repository.

Terminal markers

Report lines start with one of four markers: [+] (a finding or a success), [!] (a warning or an uncertain result), [-] (an error or an empty result) and [.] (an informational listing line: the email report’s candidate accounts, the user report’s secondary-account listings). The [!] warning lines on stderr are a stable, grep-able signal (^\[!\]): a pipeline can detect partial runs without parsing the JSON.

Section headers like ============ ORG ============ and the ---- separators are skippable decoration: screen readers and scripts can jump straight to the marked lines.

The scan and search terminal reports end with a summary of the top 5 authors by commit count (their dominant language included); the complete author list is in the JSON report’s correlation.authors.

Exit codes

0: the run completed (an empty result is still a completed run: email with no match, org with no public email). 1: the run failed (a network, authentication or API error); a failed emails (bulk) run also writes the partial report of the lookups it already paid for before propagating the failure. 2: the command line itself is invalid (clap argument error). 3: the run completed but found nothing (an empty scan or search result: the target is live but carries no recoverable email; the JSON report is still written when --json was requested), so scripting can tell “live but empty” apart from a real failure.

Layout

ScanPath
Repository (scan --url …/repo)results/repository/<owner>[_<repo>].log
Profile (scan --url https://github.com/<user>)results/profile/<user>.log
Search (search --query …)results/keyword/<query>[_<language>].log
User investigation (user <username>)results/user/<username>.log
Organization investigation (org <org>)results/org/<org>.log

Every text result file carries the .log extension so a double-click opens it natively (Notepad, TextEdit, VS Code); the name before the extension is the sanitized target name (see “File names” below). Each .log file is a complete snapshot of its run, not an append-only log: a rerun replaces it atomically.

Startup proxy check

Before the first request, the run resolves the environment’s proxy variables with the same precedence rules as the HTTP stack, mirrored conservatively: a value the check cannot vouch for is warned about rather than trusted. What is actually used is announced in the log, and a set-but-unusable variable is named with a loud warning instead of the stack’s silent fallback to a direct connection; proxy variable values are never logged (a proxy URL can carry embedded credentials), only names and schemes are. The full semantics (variables, schemes, Tor, caveats) live in Network egress.

Text format (default)

One email address per line, sorted case-insensitively, deduplicated across every scanned page and repository.

Addresses on the users.noreply.github.com domain (GitHub’s alias addresses, which are not real mailboxes) are excluded from the results.

JSON report (--json <path> or --json -)

Every report stamps its own provenance: generated_at (the UTC timestamp of the run, YYYY-MM-DDTHH:MM:SSZ) and tool_version (the git2mail --version that wrote it). A search report additionally records the request that produced it (request.query, request.language and every advanced qualifier actually provided), so two runs with different qualifiers never write indistinguishable reports. The report still does not know the full command line (a --variant-pattern list or a token file path, for instance): archive the exact command next to the report for full reproducibility. A --json path that is a symbolic link is refused before the run (the atomic replace would destroy the link): write to a timestamped file name and swap the symlink yourself.

--json <path> additionally writes a JSON report at the path you give (for example --json report.json). With --json -, the same JSON document is printed to stdout instead (only the JSON goes to stdout; the log stays on stderr), which makes the report pipeable into other tools.

{
  "target": "<scan target>",
  "mode": "repository|profile|search",
  "request": { "query": "nmap", "language": "rust", "qualifiers": { "stars": ">100" } },
  "emails": ["…"],
  "per_repository": { "owner/repo": ["email@example.com"] },
  "scanned": { "repos": 12, "pages": 30, "partial": false },
  "generated_at": "2026-02-14T18:22:33Z",
  "tool_version": "0.8.0",
  "correlation": {
    "authors": [
      {
        "author": "Alice",
        "emails": ["alice@example.com"],
        "repos": [
          { "name": "a/one", "language": "Rust" },
          { "name": "b/two", "language": null }
        ],
        "languages": ["Rust", "Go"],
        "first_seen_repo": "a/one",
        "total_hits": 3,
        "last_commit_date": "2024-06-01T12:00:00Z"
      }
    ]
  }
}

request appears only on a search report (the query provenance: the qualifiers that were not provided are omitted, not null).

Forked repositories are skipped on the user, org and search subcommands (a warning names the --include-forks opt-in when the skip fires). The reverse-email candidate search keeps forks: it wants any repository carrying the address, including forks of the subject’s own work.

per_repository appears on the parallel scans (a keyword search): it maps each repository’s owner/name to the emails found in its commits, so a multi-repository run can be read per project without re-running it. Only the repositories where at least one email showed up are listed, and the key is omitted entirely when no repository produced one. The report is written after the text output, at the end of the scan. The correlation block groups the emails by commit author: for each author it lists every address seen in their commits, the repositories they appear in (with the repository’s primary language; language is null for scan runs, which do not spend an extra API call on it; only search fills it), the languages ordered dominant first (most commits, ties alphabetical), the repository where the author was first seen (with a parallel multi-token scan this follows worker completion order and may vary between runs), and the total number of hits. last_commit_date is the most recent commit author date seen for the author (the raw ISO 8601 UTC stamp of the API payload): a freshness indicator for the exposure (self-declared, forgeable commit metadata, exactly like the author name - never a proof). scan, search and org can also export this same per-author report as a CSV spreadsheet with --csv <path> (one row per author: author_name, emails, repositories, dominant_language, commits, multi-valued cells joined with ;, RFC 4180 escaping so a hostile commit name cannot forge spreadsheet rows). A commit without a detected (non-noreply) email is not attributed; a commit with no author name is attributed under the email itself.

scanned.partial is true when the scan stopped early (one of your tokens ran out or was refused mid-scan, so some listed repositories were never checked) and the reported emails may be incomplete; the matching warning names the flag in the log. A report with partial: true must never be mistaken for a complete one. Scans that run from start to finish always report partial: false (they fail instead of reporting incompletely); other limits (the search limit) are announced by their own warnings and do not set the flag.

OSINT reports (user, email, emails, org)

The investigation subcommands write a JSON report to the path given by --json (org and user also write the same one-email-per-line text file as the scan subcommands, at results/org/<org>.log and results/user/<username>.log):

  • user: identity history, external contributions, generated email variants and the addresses they matched, potential secondary accounts, public SSH keys (full example blocks with every field in the user investigation guide);
  • org: the report also carries a correlation object with the same per-author shape as the scan reports (person, emails, repositories and commit counts): the remediation view attributing an exposure to a person;
  • email / emails: the reverse lookup, listing the candidate GitHub accounts with a confidence grade (commit-author when a commit of the candidate account carries the looked-up address, repo-owner-weak when the address matches a repository it owns), the candidate accounts considered, the commit that proved the match (repository, sha, author email), and the GitHub account owning the address (heuristic: the search surfaces the candidate repositories, each verified over its full default-branch history, until a commit-author match ends the lookup early); org additionally writes a text file results/org/<org>.log with one found email per line, ready to paste into a spreadsheet or outreach tool. An emails (bulk) report carries skipped_addresses (an empty list on a clean run): on an interrupted run it is the file-order list of the addresses a stopped worker never reached (the summary.skipped count is this list’s length), so re-running the interrupted lookups needs no diff of two files. Every report carries generated_at and tool_version. The bulk run has no CSV deliverable (its report carries no per-author correlation): the --json report is its file output. On the other subcommands (user, email, emails) --csv is accepted, warned about, and ignored.

Developer correlation

Every scan also aggregates its results per author: for each author found in the scanned commits, it correlates the attributes git2mail already collects: the emails they used, the repositories they were seen in with their primary language, the first repository where they appeared and their total commit count. Authors are sorted by commit count and each author’s languages are ordered dominant first, so the report answers “who are the main developers behind this topic, and what do they write in?”. With a parallel multi-token scan, the repository order (and the “first seen” field) follows worker completion and may vary between runs; the email sets and counts do not.

For a copy-paste list of addresses, use the plain-text results/ file (one email per line, ready for a spreadsheet); the names, languages and per-repository counts live in the JSON report, or as a --csv spreadsheet (one row per developer: name, emails, repositories, dominant language, commit count).

The report lives in the opt-in --json output, as a correlation object added on top of the unchanged legacy fields (the plain-text result files stay byte-identical):

git2mail search --query nmap --language rust --token-file tokens.json --json report.json
{
  "target": "nmap",
  "mode": "search",
  "emails": ["alice@example.com"],
  "scanned": { "repos": 2, "pages": 3, "partial": false },
  "correlation": {
    "authors": [
      {
        "author": "Alice",
        "emails": ["alice@example.com"],
        "repos": [
          { "name": "a/one", "language": "Rust" },
          { "name": "b/two", "language": "Go" }
        ],
        "languages": ["Rust", "Go"],
        "first_seen_repo": "a/one",
        "total_hits": 3,
        "last_commit_date": "2024-06-01T12:00:00Z"
      }
    ]
  }
}

scanned.partial is true when the parallel scan kept its findings although a dedicated token died mid-scan (whole listed repositories may then have been left unscanned): the JSON report keeps partial: true even when the run itself exits 0.

The primary language comes free with the repository-search metadata, so search runs attribute a language to every repository that has a detected primary language (GitHub reports language: null when it has none, and the report passes it through, like scan (repository/profile) runs which do not spend an extra API call on it). The same author seen in several repositories (across pages, workers or scan stages) is merged: emails deduplicated, hit counts summed. The terminal log also prints a short summary of the top 5 authors (by commit count) with their dominant language; the full author list lives in the JSON report’s correlation.authors, and the plain-text result files keep the legacy one-email-per-line format, byte-identical.

File permissions

Result and report files hold personal data, so a FIRST publish creates them readable by the owner only (0600 on Unix) instead of the usual world-readable default; republishing preserves a mode you set on an existing file (a tightened 0600 or a deliberately shared 0644).

File names

Scan targets become file names after sanitization:

  • path separators and reserved characters (/ \ : ? * " < > |) and control or invisible characters are replaced by _; the directory component is sanitized too;
  • trailing dots and spaces are stripped, and an empty name becomes _;
  • Windows reserved device names (CON, NUL, COM1, …), with or without extension, are prefixed with _ (CON.txt becomes _CON.txt);
  • a name the sanitizer changed carries a 16-hex-digit hash of the raw name (a/b becomes a_b-<hash>): distinct raw names that collapse onto the same sanitized form cannot silently overwrite each other (a rerun of the same query still refreshes its own file by design). An unchanged name keeps its exact file name, whatever its case; on a case-insensitive filesystem (macOS APFS, Windows NTFS) two targets differing only by case share one file name - the exact target is visible in every report line and in the JSON report;
  • a name longer than 200 bytes is cut on a character boundary and carries the same hash (taken over the full raw name, so two distinct long queries sharing a prefix stay distinct), and the write can no longer fail after the scan completed.

Data quality, prevention & limitations

What the reports can prove, how to protect yourself, and where the tool’s coverage ends.

Data quality

One’s email can be spoofed in commits: an address read in a commit proves nothing about who owns the mailbox. Cross-check the confidence grades of the email reports and the per-author summary of the correlation report (--json) before drawing conclusions. To open a cited piece of evidence, run git2mail email <address>: its report carries the repository and the commit sha (the long identifier after /commit/ in a GitHub URL) that proves the attribution: build the commit URL as https://github.com/<owner>/<repo>/commit/<sha>. The scan and search reports carry repositories and counts but no commit sha, so the reverse lookup is the evidence step.

A note on pull requests: when a contributor’s pull request was squash-merged (combined into one commit) through GitHub’s web interface, the squashed commit’s author email is the contributor’s private id+username@users.noreply.github.com alias (GitHub stamps its own web-flow identity, noreply@github.com, as the committer). The scan and search subcommands filter out the users.noreply.github.com domain, so the squashed commit contributes nothing to them: a prolific pull-request contributor can therefore stay invisible to the scan and search subcommands; the user subcommand recovers their identities through the commit history instead.

Prevention

Harden your account privacy by enabling these settings:

  • Keep my email addresses private
  • Block command line pushes that expose my email

These settings protect only future commits: an address you committed with before stays in the repository’s history and clones. Run git2mail user <your-username> on yourself for the full picture of what this tool recovers about you (history, guessed addresses, secondary accounts), and rewrite old history (for example with git filter-repo) if you need it gone.

Limitations

This project is limited to GitHub repositories. The user investigation reads both the commit author and committer emails when they differ (the committer is a real second channel, rarely different but sometimes the only one that leaks); the repository and keyword scans read the commit author email.

Per-subcommand coverage deltas live in the page of the feature they constrain: the default-branch-only commit walk and the noreply-history preconditions in User investigation, the candidate-search visibility limits of the reverse lookup in Reverse email lookup, and the search ceilings in Scanning: scan & search.

GitHub API usage

Requests

  • Every endpoint is queried with per_page=100 (the maximum page size).
  • The Authorization: token <token> header is sent only when the request is bound to a token; anonymous requests omit it.
  • User agent and Accept headers are set once, in the client.

Pagination

All listings (repository commits, profile events, profile repositories, search results) go through a single paginated loop:

  1. fetch page n;
  2. if the page is empty, the listing is complete;
  3. otherwise parse it and continue with page n + 1.

Two guards bound the loop:

  • Short-page guard. Listings fetched page by page (repository commits, profile events, search results, the user investigation’s identity listings, the email candidate search, and the commit verification of one email candidate) stop when a page comes back underfilled, instead of walking into the error page GitHub serves past the real end of a listing. The simple listings (the profile fallback repository enumeration, the public repositories and keys of a user, organization members and repositories) end only when a page comes back empty.
  • Token-pool bound. Every page request participates in the rate-limit handling below; the loop can never spin: when every token is exhausted, the scan fails with an explicit error instead of retrying forever.

Network egress: proxies and Tor

The HTTP client honors the standard proxy environment variables with the same precedence rules the HTTP stack applies, and git2mail checks them at startup, announces the proxy actually used, and loudly warns instead of silently falling back when a configured proxy is unusable (the check mirrors the stack conservatively: a value it cannot vouch for is warned about, which only ever over-warns - never the reverse).

What the stack honors

  • HTTPS_PROXY (or https_proxy) is the primary variable for git2mail’s traffic: the tool is HTTPS-only, so an HTTP_PROXY value is never used for it (announced as an informational note, since it usually means a proxy is configured but scoped elsewhere).
  • ALL_PROXY (or all_proxy) is the fallback, used only when the primary variable is absent or unusable.
  • Proxy schemes: the http and https schemes are accepted case-insensitively (HTTP://proxy:8080 works); the socks schemes (socks5://, socks5h://, socks4://, socks4a://) are matched case-sensitively and lowercase-only - SOCKS5:// is not recognized by the stack (a warning names it). A scheme-less bare authority is assumed http.
  • A set-but-empty variable counts as set and shadows its lowercase twin: the fallback (if any) is used instead - when no fallback exists, the run goes direct - and the warning names the outcome.
  • A set NO_PROXY can bypass the proxy for some hosts: warned about when a proxy is in use.
  • A set REQUEST_METHOD (the stack’s CGI marker) makes the stack discard every proxy variable: warned about when any proxy variable is set.
  • Proxy values are never logged: a proxy URL can carry embedded credentials.

Tor

Route a run through Tor with a socks5h URL, no flag needed:

HTTPS_PROXY=socks5h://127.0.0.1:9050 git2mail scan --url "$TARGET_URL" --token-file tokens.json

socks5h asks the proxy to resolve hostnames (verified against the bundled HTTP stack: the socks5h scheme maps to proxy-side DNS resolution); plain socks5 resolves hostnames locally, which leaks every lookup’s hostname to the local resolver - prefer socks5h for Tor.

Two honesty notes, stated plainly:

  • The startup check proves the proxy at launch, not the whole run’s path. It resolves the environment with the stack’s own precedence rules and announces the proxy in use, but it is not a continuous guarantee: before a sensitive run, verify your exit IP.
  • Anonymous runs through Tor are practically unusable: GitHub’s anonymous quota (about 60 requests per hour) is shared by every user of that exit node, so a shared exit is spent before your first scan finishes. With a token, the quota is per token (5,000 requests per hour), but expect more frequent secondary-limit pauses (the tool parks a token until its window passes and rotates to the next one); the park semantics are described in Rate limits.

GitHub applies the same rate limits and the same abuse detection whatever the request’s origin; this guide documents what the stack honors, not techniques to avoid either.

Rate limits

GitHub rate limits come in two flavors, and the client reacts to each one differently.

Primary limit (quota)

A 403 whose x-ratelimit-remaining header is 0 means a quota is spent, and which quota decides the reaction:

  • on the core endpoints, the token’s hourly quota is spent: the token is parked until the instant the x-ratelimit-reset header names (capped at one hour so a hostile header cannot park it indefinitely), then returns automatically, and the request is retried with the next available token. A spent quota is a pause, not a death: a long parallel scan gets its token back at the reset. When the header is absent (a middlebox stripped it), there is no known window to wait for: the token is retired (removed from the allocation pool for the rest of the run) instead of being retried on a guessed window. A retired token never comes back;
  • on the /search/ endpoints (or any response carrying X-RateLimit-Resource: search), the spent quota is the separate per-minute search quota: the token’s hourly core quota is untouched, so the token is only parked for the short search window. Retiring a healthy token over a hot search minute would drain multi-token pools one token per minute.

When a run stops because every token is unusable, the error names the cause: an hourly allowance drained pool stops with “every token’s allowance is used up” (RateLimitExhausted), while a pool parked by secondary windows stops with the dedicated paused message (“wait a few minutes”, RateLimitPaused).

A 403 with x-ratelimit-remaining above 0 is a genuine refusal (for example a private or missing resource) and is not treated as rate limiting, unless it carries a Retry-After header, the secondary-limit body, or a primary-limit body whose quota header a middlebox stripped (the body text “API rate limit exceeded” is treated as transient too, since the missing header makes the two indistinguishable). Any of these three park the token instead (see below).

Secondary limit (abuse detection)

A 429 is never treated as a spent quota, because “too many requests” is a transient condition, so the token is parked, never retired, even when the response carries x-ratelimit-remaining: 0. The same holds for a 403 carrying a Retry-After header or the documented secondary-limit body without an exhausted core quota: the token is taken out of the allocation pool until its window passes (Retry-After honored as-is, capped at one hour so a hostile header cannot park a token indefinitely), then returns automatically, and the request rotates immediately to another live token when one exists. Parked tokens can still serve other workers through the shared pool. A window that has already elapsed (Retry-After: 0) is retried immediately at no cost. An HTTP 401 (revoked or invalid token) retires the token like a spent quota.

When a request fails on a token and another token is available, the request is transparently retried on the next token; the caller sees a failure only when no token can serve the request.

Caps and limits

LimitValueEffect
Events timeline300 eventsGitHub caps profile events; the listing stops there honestly
Repository commitsfull historythe default-branch walk ends when GitHub’s listing ends (a short page); nothing is truncated locally
Search results1000GitHub’s documented search ceiling; reaching it ends the listing with a warning
Reverse-email candidatesfull listingevery candidate repository the search serves is verified, each over its full default-branch history (until a commit-author match ends the lookup early)
Name pairs x domains64 x 64variant-generation inputs are truncated before generation
Generated variants1000global cap on generated candidate addresses
Custom patterns64--variant-pattern accepts at most 64 patterns
Bulk addresses file64 MiBa bulk addresses file larger than 64 MiB is refused before reading
URL length2048 bytesa URL longer than 2048 bytes is rejected before any request

search runs q=<query> against https://api.github.com/search/repositories. --language appends the language: qualifier, and nine further qualifiers compose with the query: --stars, --forks and --size take count ranges (>100, 10..50), --created and --pushed take date ranges (>2020-01-01, 2019-01-01..2020-01-01), and --license, --topic, --user and --org take plain values. Every provided qualifier is validated client-side before any request: a malformed range or an impossible date fails fast with an explicit error instead of surfacing as an empty result set from the API. --limit bounds how many repository results are scanned.

A 422 on the first page means the API rejected the query itself (an over-long or invalid q, or an inaccessible --user/--org login): the run fails explicitly instead of degrading into an empty report. A 422 past the first page is GitHub’s documented window boundary: the scan ends with an explicit warning that the collected repositories were discarded, rather than reporting a truncated set as complete.