diff --git a/.gitignore b/.gitignore index 1de2299..3988b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,23 @@ +# system / editor .DS_Store -repos/ + +# python __pycache__/ +*.pyc + +# claude code state +.claude/ + +# secrets — NEVER commit +.token +**/.token + +# session artefacts (cloned target repos, tool clones, reference downloads) +GONZOsint/ +repos/ +email-tools/ +gitrecon/ +ghintel.html + +# local service script +move_repos.py diff --git a/docs.md b/docs.md new file mode 100644 index 0000000..c185799 --- /dev/null +++ b/docs.md @@ -0,0 +1,325 @@ +# Extraction methods + +This document describes how the standalone web tool +(`gitcolombo.html` — a single file, open it in a browser) and the +Python CLI (`gitcolombo.py`) discover identities (name + email pairs) +from GitHub. + +All methods rely on the public GitHub REST API (no auth required, but a +personal access token raises the rate limit from 60 to 5000 requests +per hour). + +--- + +## Mode A — Find real email by username + +Goal: given a GitHub username, return a ranked list of `(email, name)` +identities that most likely belong to the real person behind the +account. + +This is the same general approach taken by +[gitrecon](https://github.com/GONZOsint/gitrecon). + +### Step 1 — Profile lookup + +``` +GET https://api.github.com/users/{username} +``` + +We read `name`, `login`, `public_repos`, `created_at`. The `name` +field is later used to rank identities — an email paired with a +commit-author name that matches the profile name is treated as +high-confidence. + +### Step 2 — PGP keys (strongest signal) + +``` +GET https://api.github.com/users/{username}/gpg_keys +``` + +Returns the PGP public keys the user has uploaded to GitHub. Each +key (and each subkey) exposes its UIDs as a list of objects: + +```json +[ + { + "key_id": "D6C7A19E9CFF4BB8", + "revoked": false, + "emails": [ + {"email": "real@example.com", "verified": true} + ], + "subkeys": [ + { + "key_id": "1053D098A0070FEA", + "revoked": false, + "emails": [...] + } + ] + } +] +``` + +Why this is the strongest signal in this tool: + +- The user uploaded the key themselves — this is direct identity disclosure, not + inferred from commit metadata. +- `verified: true` means GitHub sent a confirmation email to that address and the + user clicked the link. This is **cryptographically and procedurally verified**, + unlike `commit.author.email`, which is freely settable in `git config user.email`. +- The endpoint is public, no auth required, and unaffected by "Keep my email + address private". + +We walk both top-level keys and their `subkeys[]`, skipping any with +`revoked: true`. Emails are deduplicated against later sources, so a +match between a PGP UID and a commit email lights up both signals and +boosts ranking. + +Limitations: many users never upload a PGP key. None of the surveyed +peer tools (gitrecon, EmailFinder, GitFive, Gitmails, etc.) use this +endpoint, so it is a unique source. + +### Step 3 — Public events (recent commits) + +``` +GET https://api.github.com/users/{username}/events/public?per_page=100 +``` + +The `/events/public` endpoint returns up to ~90 days of the user's +public activity. Each entry has a `type` and a `payload`. We are only +interested in `PushEvent`: + +```json +{ + "type": "PushEvent", + "repo": { "name": "owner/repo" }, + "payload": { + "commits": [ + { + "sha": "...", + "author": { "email": "real@example.com", "name": "Real Name" }, + "message": "..." + } + ] + } +} +``` + +For every `PushEvent.payload.commits[]` we collect +`author.email` + `author.name`. + +Why this works: when the user pushes from their local machine, git +stamps the commit with whatever email is configured locally +(`user.email`). GitHub keeps that email in the public event feed even +if the user later enables "Keep my email address private" in the web +UI — events that already happened are not rewritten. + +### Step 4 — Commit search across all public repos + +``` +GET https://api.github.com/search/commits?q=author:{username}&per_page=100&page={n} +``` + +Unlike `/events/public`, the commit-search index spans **every public +commit on GitHub** that has been associated (by email or login) with the +target account, including commits in repositories the user does not +own. It also surfaces old commits that fell out of the 90-day events +window. + +The endpoint caps results at 1000 commits = 10 pages of 100. Rate limit +is stricter than core: 10 req/min unauthenticated, 30 req/min with a +token (vs 5000/hr for core endpoints). + +For each `item` we read both the author/committer pair from +`item.commit.{author, committer}` and parse trailers from +`item.commit.message` (see the "Commit-message trailer extraction" +section). + +This is the same approach GitFive uses for its "lite" search mode and +the reason it surfaces emails that older tools miss. + +### Step 5 — Commits fallback + +If the steps above returned nothing useful (e.g. the user has been +inactive for more than ~90 days, or every event email is +`@users.noreply.github.com`), we fall back to scanning commits in +the user's own repositories: + +``` +GET https://api.github.com/users/{username}/repos?per_page=100&sort=updated +``` + +We keep non-fork repositories and, for each one, query: + +``` +GET https://api.github.com/repos/{owner}/{repo}/commits?author={username}&per_page=100 +``` + +For every returned commit we read both author and committer: + +- `commit.author.email` / `commit.author.name` +- `commit.committer.email` / `commit.committer.name` + +The `?author=` filter on the commits endpoint accepts either a +GitHub login or an email — using the login restricts results to +commits GitHub has actually linked to this account. + +### Step 6 — Ranking + +Each unique `(email, name)` pair is annotated with: + +- `count` — how often it was seen +- `sources` — which events/repos it came from +- `matchesProfile` — whether `name` (case-insensitive, trimmed) equals + the profile `name` from Step 1 + +Identities where `matchesProfile === true` are shown first, then +remaining ones sorted by `count` descending. + +--- + +## Mode B — Extract emails from commits + +Goal: dump every `(email, login)` pair that appears as a commit author +across a user's or org's repositories — useful for mapping a team or +auditing a single repo. + +### Step 1 — Resolve target + +The input is either `username` or `owner/repo`. + +For an `owner/repo`: + +``` +GET https://api.github.com/repos/{owner}/{repo} +``` + +For a `username`, we paginate through: + +``` +GET https://api.github.com/users/{username}/repos?per_page=100&page={n} +``` + +Forks are skipped when the **Skip forks** checkbox is on (default). + +### Step 2 — Walk commits + +For every repository: + +``` +GET https://api.github.com/repos/{owner}/{repo}/commits?per_page=100&page={n} +``` + +For every commit object: + +```json +{ + "sha": "...", + "commit": { + "author": { "email": "alice@example.com", "name": "Alice" }, + "committer": { "email": "bob@example.com", "name": "Bob" } + }, + "author": { "login": "alice42" }, + "committer": { "login": "bob42" } +} +``` + +We collect: + +- `commit.author.email` + top-level `author.login` +- `commit.committer.email` + top-level `committer.login` + +(Top-level `author.login` is GitHub's resolved account for the commit; +it may be `null` for commits whose email isn't tied to any GitHub +account.) + +Pagination stops when the API returns an empty array. When +**Only first 100 commits/repo** is enabled, only page 1 is requested. + +### Step 3 — Dedupe + +Identities are deduplicated by lowercased email. Duplicates increment +a `count` field instead of producing new rows. + +--- + +## Commit-message trailer extraction + +For every commit body the tool sees (whether from `/events`, +`/search/commits`, or `/repos/{owner}/{repo}/commits`), it scans the +message for well-known git trailers and treats each match as an +additional identity. The regex is anchored per line and matches: + +``` +^(Signed-off-by|Co-authored-by|Reviewed-by|Tested-by|Reported-by|Acked-by|Suggested-by|Cc):\s+(name)\s+<(email)>$ +``` + +| Trailer | Where it usually comes from | +|---|---| +| `Signed-off-by:` | `git commit -s` — DCO projects (Linux kernel, Docker, kubernetes, many enterprise projects with CLAs) | +| `Co-authored-by:` | GitHub's UI when squash-merging a PR with multiple contributors | +| `Reviewed-by:` | Patch-review workflows (kernel, libvirt, qemu, …) | +| `Tested-by:` / `Acked-by:` / `Reported-by:` / `Suggested-by:` | Kernel-style review chains | +| `Cc:` | Notification chain in mailing-list patches | + +Why this is a strong signal: trailer lines are added **intentionally** +by tooling or by humans during patch review, and they typically point +to real mailboxes (no point in CC-ing or signing-off as a fake +address — it defeats the workflow's purpose). Even when the commit +author's email is a noreply, the trailers often carry plain emails. + +Tool comparison: among the 14 surveyed peer tools, only `fue` (Ruby, +GraphQL-based) and `GitFive` parse these trailers. Gitcolombo's +implementation mirrors fue's regex while staying fully in-browser +(Mode A + Mode B) and in the Python CLI's `--search` flow. + +Each trailer email is tagged with a `trailer` badge in the UI (hover +to see the trailer types it appeared in). The Python CLI prints the +roles for each identity in square brackets, e.g. +`[signed-off-by, reviewed-by, author]`. + +## Filters + +### Ignore `@users.noreply.github.com` + +When the checkbox is enabled, any email matching the regular +expression `/@users\.noreply\.github\.com$/i` is dropped before being +recorded. This is the address GitHub generates for users who turn on +"Keep my email address private" — it is *not* a real mailbox and +matching it back to a person requires extracting the leading numeric +prefix (the user's internal numeric id) which doesn't directly reveal +their identity. + +These emails are still useful as a signal that the user is privacy-conscious, +which is why the option is a toggle, not a hard filter. + +### Skip forks (Mode B only) + +Fork repositories are noisy: their commit history is dominated by the +upstream project, not the user being investigated. The checkbox is on +by default. + +### Limit to first 100 commits/repo (Mode B only) + +Each repo's commit list is paginated. Stopping after page 1 keeps the +total request count predictable; turn this off if you need full +coverage on small projects. + +--- + +## Rate limits + +GitHub allows 60 unauthenticated requests per hour per IP and 5000 +requests per hour per authenticated token. The tool surfaces a clear +message on `403`/`429` responses and shows whatever partial results +were already collected. + +A token only needs the default `public_repo` read scope — paste a +fine-grained PAT with no extra permissions and you're set. + +--- + +## References + +- gitrecon — +- ghintel — (UI inspiration) +- GitHub REST API — diff --git a/gitcolombo.html b/gitcolombo.html new file mode 100644 index 0000000..82a15c5 --- /dev/null +++ b/gitcolombo.html @@ -0,0 +1,1112 @@ + + + + +gitcolombo // github osint + + + + + +
+ + +
+
// extract identities from public github activity
+ +
+ +
+ + +
+ + +
+ + + +
+
+ + +
+
+
+ + + + + + + + + + +
+ + + +
+ +
+
+ +
+ +
+ + + +
+ + + + + + + + + + diff --git a/gitcolombo.py b/gitcolombo.py index c226447..36e6592 100755 --- a/gitcolombo.py +++ b/gitcolombo.py @@ -41,10 +41,39 @@ RESOLVE_WORKERS = 8 DEFAULT_REPOS_DIR = "repos" +GITHUB_GPG_KEYS_URL = "https://api.github.com/users/{nickname}/gpg_keys" +GITHUB_SEARCH_COMMITS_URL = ( + "https://api.github.com/search/commits?q=author:{nickname}" + "&per_page={per_page}&page={page}" +) +GITHUB_SEARCH_MAX_PAGES = 10 # /search/* caps results at 1000 + +# Well-known git trailer keys (DCO sign-off, GitHub co-authorship, kernel reviews). +# A real email in any of these is a strong identity signal: trailers are +# typically added intentionally by tooling (`git commit -s`, GitHub UI's +# "Co-authored-by", patch-review workflows) rather than being auto-generated. +TRAILER_RE = re.compile( + r"^(?PSigned-off-by|Co-authored-by|Reviewed-by|Tested-by|" + r"Reported-by|Acked-by|Suggested-by|Cc):\s+" + r"(?P[^<]+?)\s+<(?P[^>]+)>\s*$", + re.MULTILINE | re.IGNORECASE, +) + SYSTEM_EMAILS = frozenset({"noreply@github.com"}) logger = logging.getLogger("gitcolombo") +# Service noreply addresses from any vendor (github, anthropic, gitlab, ...) +# plus GitHub's user-private `{id}+{login}@users.noreply.github.com` pattern. +SYSTEM_EMAIL_RE = re.compile( + r'(^(?:noreply|no-reply|donotreply|do-not-reply)@|@users\.noreply\.github\.com$)', + re.IGNORECASE, +) + + +def is_system_email(email): + return bool(email and SYSTEM_EMAIL_RE.search(email)) + # ---------- HTTP helpers ---------- @@ -71,6 +100,28 @@ def _http_get_json(url: str): # ---------- GitHub API ---------- +def _gh_authed(url: str, token: str | None): + """Like _http_get_json but with optional bearer token (for higher rate limits).""" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": HTTP_USER_AGENT, + } + if token: + headers["Authorization"] = "Bearer " + token + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + payload = resp.read() + except (urllib.error.URLError, TimeoutError) as exc: + logger.debug("GET %s failed: %s", url, exc) + return None + try: + return json.loads(payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.debug("Bad JSON from %s: %s", url, exc) + return None + + def get_public_repos_count(nickname: str) -> int: data = _http_get_json(GITHUB_USER_URL.format(nickname=nickname)) if not data: @@ -111,6 +162,154 @@ def resolve_github_username(repo_url: str, commit_hash: str) -> str | None: return match.group(1) if match else None +def get_gpg_keys_emails(nickname: str, token: str | None = None): + """Fetch user-uploaded PGP keys via /users/{u}/gpg_keys and yield emails. + + These emails come from the key's UIDs — the user uploaded them themselves, + so this is a direct identity disclosure. `verified=True` means GitHub has + confirmed the user controls that mailbox. + + Yields dicts: {email, verified, key_id, created_at, source}. + """ + keys = _gh_authed(GITHUB_GPG_KEYS_URL.format(nickname=nickname), token) + if not keys: + return + seen: set[str] = set() + + def _walk(key, source): + if not key or key.get("revoked"): + return + key_id = key.get("key_id", "") + created = key.get("created_at", "") + for entry in (key.get("emails") or []): + email = entry.get("email") + if not email: + continue + k = email.lower() + if k in seen: + continue + seen.add(k) + yield { + "email": email, + "verified": bool(entry.get("verified")), + "key_id": key_id, + "created_at": created, + "source": source, + } + + for key in keys: + yield from _walk(key, "primary") + for sub in (key.get("subkeys") or []): + yield from _walk(sub, "subkey") + + +def print_gpg_results(results, ignore_noreply: bool = True) -> bool: + """Pretty-print get_gpg_keys_emails() output. Returns True if printed.""" + rows = [ + r for r in results + if not (ignore_noreply and is_system_email(r["email"])) + ] + if not rows: + return False + print("PGP key UIDs (uploaded by the user, public via /users/{u}/gpg_keys):") + print(DELIMITER) + rows.sort(key=lambda r: (not r["verified"], r["email"])) + for r in rows: + flag = "verified" if r["verified"] else "unverified" + print(" {:40} [{}] key_id={} ({})".format( + r["email"], flag, r["key_id"] or "?", r["source"], + )) + print() + return True + + +def search_commits_by_author(nickname: str, token: str | None = None): + """Use /search/commits?q=author:{u} to find commits across all of public GitHub. + + Also extracts well-known git trailers from each commit message body + (Signed-off-by, Co-authored-by, Reviewed-by, etc.). + + Yields dicts: {email, name, role, repo, sha, date}. + """ + seen: set[tuple[str, str, str]] = set() + for page in range(1, GITHUB_SEARCH_MAX_PAGES + 1): + url = GITHUB_SEARCH_COMMITS_URL.format( + nickname=nickname, per_page=GITHUB_PER_PAGE, page=page, + ) + data = _gh_authed(url, token) + if not data: + return + items = data.get("items") or [] + if not items: + return + for item in items: + commit = item.get("commit") or {} + repo = (item.get("repository") or {}).get("full_name", "") + sha = item.get("sha", "") + date = (commit.get("author") or {}).get("date", "") + message = commit.get("message") or "" + for role in ("author", "committer"): + who = commit.get(role) or {} + email = who.get("email") + name = who.get("name") or "" + if not email: + continue + key = (email.lower(), name.lower(), role) + if key in seen: + continue + seen.add(key) + yield {"email": email, "name": name, "role": role, + "repo": repo, "sha": sha, "date": date} + # trailers in the commit message body + for tm in TRAILER_RE.finditer(message): + t_key = tm.group("key").lower() + t_name = (tm.group("name") or "").strip() + t_email = (tm.group("email") or "").strip() + if not t_email: + continue + # reject malformed names: ':' implies another trailer label was + # crammed onto the same line; '@' implies a @-mention or stray + # handle. Real personal names don't contain either. + if ":" in t_name or "@" in t_name: + continue + key = (t_email.lower(), t_name.lower(), t_key) + if key in seen: + continue + seen.add(key) + yield {"email": t_email, "name": t_name, "role": t_key, + "repo": repo, "sha": sha, "date": date} + if len(items) < GITHUB_PER_PAGE: + return + + +def print_search_results(results, ignore_noreply: bool = True) -> None: + """Pretty-print search_commits_by_author() output grouped by (email, name).""" + groups: dict[tuple[str, str], list[dict]] = {} + for r in results: + if ignore_noreply and is_system_email(r["email"]): + continue + key = (r["email"], r["name"]) + groups.setdefault(key, []).append(r) + + if not groups: + print("No public commits found via /search/commits.") + return + + print(f"Found {len(groups)} unique (email, name) identities:") + print(DELIMITER) + ordered = sorted(groups.items(), key=lambda kv: -len(kv[1])) + for (email, name), rows in ordered: + repos = sorted({r["repo"] for r in rows if r["repo"]}) + roles = sorted({r["role"] for r in rows}) + print("{} <{}> x{} [{}]".format( + name or "", email, len(rows), ", ".join(roles), + )) + for repo in repos[:5]: + print(f" repo: {repo}") + if len(repos) > 5: + print(f" ... +{len(repos) - 5} more repos") + + # ---------- Filesystem helpers ---------- def find_all_repos_recursively(path: str) -> list[str]: @@ -385,6 +584,15 @@ def _parse_args() -> argparse.Namespace: "--nickname", type=str, help="download repos from GitHub by nickname", ) + parser.add_argument( + "--search", type=str, metavar="USERNAME", + help="API-only path: query /users/{u}/gpg_keys + /search/commits " + "for emails (no cloning, ~1000 commit results max)", + ) + parser.add_argument( + "--no-ignore-noreply", action="store_true", + help="do not filter service noreply addresses from --search results", + ) parser.add_argument( "-r", "--recursive", action="store_true", help="recursive directory processing", @@ -420,6 +628,17 @@ def main() -> None: format="-" * 40 + "\n%(levelname)s: %(message)s", ) + if args.search: + token = os.environ.get("GITHUB_TOKEN") + ignore = not args.no_ignore_noreply + gpg = list(get_gpg_keys_emails(args.search, token=token)) + had_gpg = print_gpg_results(gpg, ignore_noreply=ignore) + results = list(search_commits_by_author(args.search, token=token)) + print_search_results(results, ignore_noreply=ignore) + if not had_gpg and not results: + print("No emails found via /gpg_keys or /search/commits.") + return + sources = _collect_sources(args) if not sources: print("Run me with git repo link or path!") diff --git a/test_gitcolombo_e2e.py b/test_gitcolombo_e2e.py new file mode 100644 index 0000000..4594167 --- /dev/null +++ b/test_gitcolombo_e2e.py @@ -0,0 +1,229 @@ +"""Live end-to-end tests for gitcolombo — hit the real GitHub API. + +These are intentionally NOT unit tests. They make real HTTP requests to +api.github.com and are skipped automatically in CI (when $CI is set, +which both GitHub Actions and most other runners do by default). + +Run locally: + python3 -m unittest test_gitcolombo_e2e -v + # optional, much higher rate limits: + GITHUB_TOKEN=ghp_… python3 -m unittest test_gitcolombo_e2e -v +""" +import os +import unittest + +import gitcolombo + + +IS_CI = bool(os.environ.get("CI")) + + +@unittest.skipIf(IS_CI, "live network test — skipped in CI ($CI is set)") +class TestGpgKeysLive(unittest.TestCase): + """/users/{u}/gpg_keys against a real user known to have a verified PGP UID. + + Target: `sindresorhus`. His primary key (key_id D6C7A19E9CFF4BB8, uploaded + 2016-04) lists `sindresorhus@gmail.com` with `verified: true`. The key has + been stable for years; if GitHub ever returns a different shape or the user + rotates keys, swap the target rather than weakening the assertion. + """ + + TARGET = "sindresorhus" + EXPECTED_EMAIL = "sindresorhus@gmail.com" + + def setUp(self): + self.token = os.environ.get("GITHUB_TOKEN") + + def test_returns_at_least_one_entry(self): + results = list(gitcolombo.get_gpg_keys_emails(self.TARGET, token=self.token)) + self.assertGreater( + len(results), 0, + f"expected at least one GPG email for @{self.TARGET}", + ) + + def test_verified_email_present(self): + results = list(gitcolombo.get_gpg_keys_emails(self.TARGET, token=self.token)) + verified = {r["email"].lower() for r in results if r["verified"]} + self.assertIn( + self.EXPECTED_EMAIL.lower(), verified, + f"expected verified UID {self.EXPECTED_EMAIL} in {sorted(verified)}", + ) + + def test_entry_shape(self): + results = list(gitcolombo.get_gpg_keys_emails(self.TARGET, token=self.token)) + match = next( + (r for r in results if r["email"].lower() == self.EXPECTED_EMAIL.lower()), + None, + ) + self.assertIsNotNone(match, "expected entry not found") + self.assertTrue(match["verified"]) + self.assertIn(match["source"], ("primary", "subkey")) + self.assertTrue(match["key_id"], "key_id should not be empty") + + +@unittest.skipIf(IS_CI, "live network test — skipped in CI ($CI is set)") +class TestTrailersLive(unittest.TestCase): + """Verify trailer parsing against /search/commits results for a kernel maintainer. + + Linux kernel commits ALWAYS have `Signed-off-by:` (DCO is enforced), + and most also carry one or more of `Acked-by:`, `Reviewed-by:`, + `Tested-by:`, `Reported-by:`, `Cc:` from the patch-review chain. + These tests assert that the extractor surfaces ≥ 2 distinct trailer + types from a single `--search gregkh` run, plus a specific check for + `signed-off-by` which is guaranteed by DCO. + + Target: gregkh (Greg Kroah-Hartman). His commit pool on GitHub + spans hundreds of repos (including kernel mirrors), so /search/commits + will return diverse trailer types regardless of GitHub indexing + fluctuations. + """ + + TARGET = "gregkh" + + TRAILER_KEYS = frozenset({ + "signed-off-by", "co-authored-by", "reviewed-by", "tested-by", + "reported-by", "acked-by", "suggested-by", "cc", + }) + + @classmethod + def setUpClass(cls): + token = os.environ.get("GITHUB_TOKEN") + cls.results = list(gitcolombo.search_commits_by_author( + cls.TARGET, token=token, + )) + cls.roles_seen = {r["role"] for r in cls.results} + cls.trailer_roles = cls.roles_seen & cls.TRAILER_KEYS + + def test_collected_some_identities(self): + self.assertGreater( + len(self.results), 50, + "expected dozens of identities from kernel maintainer's commits", + ) + + def test_signed_off_by_present(self): + # DCO-enforced project — Signed-off-by must appear. + self.assertIn("signed-off-by", self.trailer_roles) + + def test_at_least_two_distinct_trailer_types(self): + self.assertGreaterEqual( + len(self.trailer_roles), 2, + f"expected ≥2 trailer kinds, saw {sorted(self.trailer_roles)}", + ) + + def test_trailer_entries_have_email_and_role(self): + trailer_entries = [r for r in self.results if r["role"] in self.TRAILER_KEYS] + self.assertGreater(len(trailer_entries), 0) + for entry in trailer_entries[:5]: + self.assertTrue(entry["email"]) + self.assertIn("@", entry["email"]) + self.assertTrue(entry["repo"]) + + +class TestSystemEmailFilter(unittest.TestCase): + """is_system_email() classifies vendor noreply addresses across domains.""" + + SYSTEM = [ + "noreply@github.com", + "noreply@anthropic.com", # Claude Code Co-Authored-By trailer + "no-reply@gitlab.com", + "donotreply@example.org", + "DoNotReply@example.org", # case-insensitive + "12345+ghost@users.noreply.github.com", + ] + + REAL = [ + "alice@example.com", + "torvalds@linux-foundation.org", + "noreply-thing@example.com", # not a noreply, just starts with noreply- + "support@noreply.example.com", # noreply is in the domain but mailbox is real + ] + + def test_system_emails_classified(self): + for e in self.SYSTEM: + self.assertTrue(gitcolombo.is_system_email(e), f"expected system: {e}") + + def test_real_emails_not_classified(self): + for e in self.REAL: + self.assertFalse(gitcolombo.is_system_email(e), f"expected real: {e}") + + +class TestTrailerRegex(unittest.TestCase): + """Offline sanity check on the trailer regex. + + Not strictly e2e — but a fast deterministic guard that the regex + can extract every supported trailer type from a canonical message. + Runs in CI too (no network). + """ + + SAMPLE_MESSAGE = ( + "usb: serial: cp210x: add support for a new device\n" + "\n" + "This patch adds support for the newly released widget.\n" + "\n" + "Reported-by: Foo Bar \n" + "Tested-by: Tester One \n" + "Reviewed-by: Bob Reviewer \n" + "Acked-by: Carol Maintainer \n" + "Cc: stable@vger.kernel.org \n" + "Co-authored-by: Alice Coder \n" + "Suggested-by: Dave Idea \n" + "Signed-off-by: Greg Kroah-Hartman \n" + ) + + def test_all_trailer_types_match(self): + keys = {m.group("key").lower() + for m in gitcolombo.TRAILER_RE.finditer(self.SAMPLE_MESSAGE)} + expected = { + "signed-off-by", "co-authored-by", "reviewed-by", "tested-by", + "reported-by", "acked-by", "suggested-by", "cc", + } + self.assertEqual(keys, expected) + + def test_email_extraction(self): + emails = {m.group("email").lower() + for m in gitcolombo.TRAILER_RE.finditer(self.SAMPLE_MESSAGE)} + self.assertIn("gregkh@linuxfoundation.org", emails) + self.assertIn("alice@nowhere.dev", emails) + self.assertIn("foo@bar.com", emails) + + def test_no_match_on_plain_prose(self): + # "Signed-off-by:" mentioned in body text without proper format must not match + prose = "We use Signed-off-by lines in this project but here it's just text." + self.assertEqual(list(gitcolombo.TRAILER_RE.finditer(prose)), []) + + def test_rejects_crammed_line_with_mention_and_label(self): + # Regression: a Linux kernel commit body line of the form + # Reported-by: Foo Bar (@kaitas) Signed-off-by: Linus + # used to be captured with the entire mush as the "name". The lazy + # regex now still matches, but the post-filter on ':' / '@' must drop + # the entry instead of producing the malformed name. + from gitcolombo import search_commits_by_author # noqa: F401 + msg = ( + "Reported-by: Foo Bar (@kaitas) Signed-off-by: Linus Torvalds " + "\n" + ) + # simulate the post-filter step used inside search_commits_by_author + captured = [] + for tm in gitcolombo.TRAILER_RE.finditer(msg): + name = (tm.group("name") or "").strip() + if ":" in name or "@" in name: + continue + captured.append(name) + self.assertEqual(captured, [], f"should have rejected, got: {captured}") + + +@unittest.skipIf(IS_CI, "live network test — skipped in CI ($CI is set)") +class TestGpgKeysNoResult(unittest.TestCase): + """A user without uploaded PGP keys must yield zero results, not crash. + + Target: `GONZOsint` — verified during the methodology audit to have 0 keys. + """ + + def test_empty_result_for_user_without_keys(self): + token = os.environ.get("GITHUB_TOKEN") + results = list(gitcolombo.get_gpg_keys_emails("GONZOsint", token=token)) + self.assertEqual(results, []) + + +if __name__ == "__main__": + unittest.main()