-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathscript.py
More file actions
executable file
·529 lines (421 loc) · 19.6 KB
/
Copy pathscript.py
File metadata and controls
executable file
·529 lines (421 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
#!/usr/bin/env python3
"""
GitHub Email Finder
A tool to extract email addresses from a GitHub user's commit history.
"""
import argparse
import json
import os
import re
import sys
import time
import site
from collections import Counter, defaultdict
from typing import Dict, List, Optional, Set, Tuple
# Add user site-packages to path if modules are not found
try:
import requests
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn
except ImportError:
# Add user site-packages to path
user_site = site.getusersitepackages()
if user_site not in sys.path:
sys.path.insert(0, user_site)
try:
import requests
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn
except ImportError:
print("Required packages not found. Please install them using:")
print("pip3 install --user requests rich")
sys.exit(1)
class GitHubEmailFinder:
"""Class to extract email addresses from GitHub commit history."""
def __init__(self, token: Optional[str] = None, verbose: bool = False):
"""
Initialize the GitHub Email Finder.
Args:
token: GitHub personal access token (optional but recommended)
verbose: Enable verbose output
"""
self.token = token
self.verbose = verbose
self.session = requests.Session()
self.console = Console()
# Set up headers for GitHub API
self.headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "GitHub-Email-Finder",
}
if self.token:
self.headers["Authorization"] = f"token {self.token}"
def get_user_repos(self, username: str) -> List[str]:
"""
Get a list of repositories for a GitHub user.
Args:
username: GitHub username
Returns:
List of repository names
"""
repos = []
page = 1
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]Fetching repositories..."),
transient=True,
) as progress:
task = progress.add_task("", total=None)
while True:
url = f"https://api.github.com/users/{username}/repos"
params = {"per_page": 100, "page": page}
try:
response = self.session.get(url, headers=self.headers, params=params)
response.raise_for_status()
repo_data = response.json()
if not repo_data:
break
repos.extend([repo["full_name"] for repo in repo_data])
# Check if we have more pages
if len(repo_data) < 100:
break
page += 1
time.sleep(0.5) # Respect rate limits
except requests.RequestException as e:
self.console.print(f"[bold red]Error fetching repositories: {e}[/]")
break
return repos
def get_user_contributions(self, username: str) -> List[str]:
"""
Find repositories that a user has contributed to (not just their own).
This is more complex as GitHub API doesn't directly provide this info.
We'll use the search API with limited results as a sample.
Args:
username: GitHub username
Returns:
List of repository names
"""
contrib_repos = []
# Search for repositories where the user has contributed
url = "https://api.github.com/search/issues"
params = {
"q": f"author:{username} type:pr is:merged",
"per_page": 100,
}
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]Searching for contributions..."),
transient=True,
) as progress:
task = progress.add_task("", total=None)
try:
response = self.session.get(url, headers=self.headers, params=params)
response.raise_for_status()
data = response.json()
for item in data.get("items", []):
if "repository_url" in item:
repo_url = item["repository_url"]
repo_name = "/".join(repo_url.split("/")[-2:])
contrib_repos.append(repo_name)
except requests.RequestException as e:
self.console.print(f"[bold red]Error finding contributions: {e}[/]")
return list(set(contrib_repos)) # Remove duplicates
def get_commit_emails(self, repo: str, username: str) -> Dict[str, int]:
"""
Extract email addresses from commit history for a specific repository.
Args:
repo: Repository name (format: owner/repo)
username: GitHub username to filter commits
Returns:
Dictionary of email addresses and their occurrence count
"""
emails = Counter()
page = 1
with Progress(
SpinnerColumn(),
TextColumn(f"[bold blue]Scanning {repo}..."),
transient=True,
) as progress:
task = progress.add_task("", total=None)
while True:
url = f"https://api.github.com/repos/{repo}/commits"
params = {"author": username, "per_page": 100, "page": page}
try:
response = self.session.get(url, headers=self.headers, params=params)
# Skip if repo is not found or we don't have access
if response.status_code in (404, 403):
if self.verbose:
self.console.print(f"[yellow]Skipping {repo} - {response.status_code}[/]")
break
response.raise_for_status()
commits = response.json()
if not commits:
break
# Extract emails from commit data
for commit in commits:
if "commit" in commit and "author" in commit["commit"]:
author = commit["commit"]["author"]
if "email" in author and author["email"]:
email = author["email"].lower()
# Skip GitHub's noreply emails
if not self._is_github_noreply_email(email):
emails[email] += 1
if "committer" in commit and "commit" in commit and "committer" in commit["commit"]:
committer = commit["commit"]["committer"]
if "email" in committer and committer["email"]:
email = committer["email"].lower()
# Skip GitHub's noreply emails
if not self._is_github_noreply_email(email):
emails[email] += 1
# Check if we have more pages
if len(commits) < 100:
break
page += 1
time.sleep(0.5) # Respect rate limits
except requests.RequestException as e:
if self.verbose:
self.console.print(f"[yellow]Error scanning {repo}: {e}[/]")
break
return dict(emails)
def get_commits_by_email(self, repo: str, username: str, target_email: str) -> List[Tuple[str, str]]:
"""
Find commits in a repository authored/committed by a user with a specific email.
Args:
repo: Repository name (format: owner/repo)
username: GitHub username to filter commits
target_email: Email address to match against commit author/committer
Returns:
List of (sha, html_url) tuples for matching commits
"""
target_email = target_email.lower()
matches = []
seen_shas = set()
page = 1
with Progress(
SpinnerColumn(),
TextColumn(f"[bold blue]Scanning {repo}..."),
transient=True,
) as progress:
task = progress.add_task("", total=None)
while True:
url = f"https://api.github.com/repos/{repo}/commits"
params = {"author": username, "per_page": 100, "page": page}
try:
response = self.session.get(url, headers=self.headers, params=params)
# Skip if repo is not found or we don't have access
if response.status_code in (404, 403):
if self.verbose:
self.console.print(f"[yellow]Skipping {repo} - {response.status_code}[/]")
break
response.raise_for_status()
commits = response.json()
if not commits:
break
# Match commits whose author or committer used the target email
for commit in commits:
sha = commit.get("sha")
if not sha or sha in seen_shas:
continue
commit_data = commit.get("commit", {})
emails = set()
for role in ("author", "committer"):
person = commit_data.get(role) or {}
if person.get("email"):
emails.add(person["email"].lower())
if target_email in emails:
html_url = commit.get("html_url") or f"https://github.com/{repo}/commit/{sha}"
matches.append((sha, html_url))
seen_shas.add(sha)
# Check if we have more pages
if len(commits) < 100:
break
page += 1
time.sleep(0.5) # Respect rate limits
except requests.RequestException as e:
if self.verbose:
self.console.print(f"[yellow]Error scanning {repo}: {e}[/]")
break
return matches
def _is_github_noreply_email(self, email: str) -> bool:
"""
Check if an email is a GitHub noreply email.
Args:
email: Email address to check
Returns:
True if it's a GitHub noreply email, False otherwise
"""
# Common GitHub noreply patterns
noreply_patterns = [
"@users.noreply.github.com",
"noreply@github.com",
"@noreply.github.com",
"@noreply.githubassets.com"
]
return any(pattern in email for pattern in noreply_patterns)
def find_emails(self, username: str, include_contributions: bool = False) -> Dict[str, Dict[str, int]]:
"""
Find email addresses from all repositories for a user.
Args:
username: GitHub username
include_contributions: Whether to include repos the user contributed to
Returns:
Dictionary mapping email addresses to repositories and counts
"""
# Dictionary to store emails and their sources
email_sources = defaultdict(dict)
# Get user's own repositories
self.console.print(f"[bold green]Finding repositories for [blue]{username}[/]...[/]")
repos = self.get_user_repos(username)
# Get repositories user has contributed to
contributed_repos = []
if include_contributions:
self.console.print(f"[bold green]Finding repositories [blue]{username}[/] has contributed to...[/]")
contributed_repos = self.get_user_contributions(username)
# Combine and remove duplicates
all_repos = list(set(repos + contributed_repos))
if not all_repos:
self.console.print("[bold yellow]No repositories found for this user.[/]")
return {}
# Extract emails from each repository
self.console.print(f"[bold green]Found [blue]{len(all_repos)}[/] repositories. Scanning for email addresses...[/]")
for repo in all_repos:
repo_emails = self.get_commit_emails(repo, username)
# Add emails to our sources dictionary
for email, count in repo_emails.items():
email_sources[email][repo] = count
return dict(email_sources)
def find_commits_by_email(self, username: str, target_email: str,
include_contributions: bool = False) -> Dict[str, List[Tuple[str, str]]]:
"""
Find all commits by a user that use a specific email address.
Args:
username: GitHub username
target_email: Email address to match against commit author/committer
include_contributions: Whether to include repos the user contributed to
Returns:
Dictionary mapping repository names to lists of (sha, html_url) tuples
"""
# Get user's own repositories
self.console.print(f"[bold green]Finding repositories for [blue]{username}[/]...[/]")
repos = self.get_user_repos(username)
# Get repositories user has contributed to
contributed_repos = []
if include_contributions:
self.console.print(f"[bold green]Finding repositories [blue]{username}[/] has contributed to...[/]")
contributed_repos = self.get_user_contributions(username)
# Combine and remove duplicates
all_repos = list(set(repos + contributed_repos))
if not all_repos:
self.console.print("[bold yellow]No repositories found for this user.[/]")
return {}
self.console.print(
f"[bold green]Found [blue]{len(all_repos)}[/] repositories. "
f"Scanning for commits using [cyan]{target_email}[/]...[/]"
)
commits_by_repo = {}
for repo in all_repos:
matches = self.get_commits_by_email(repo, username, target_email)
if matches:
commits_by_repo[repo] = matches
return commits_by_repo
def display_commit_links(self, commits_by_repo: Dict[str, List[Tuple[str, str]]],
username: str, target_email: str):
"""
Display links to commits made by a user with a specific email.
Args:
commits_by_repo: Dictionary mapping repos to (sha, html_url) tuples
username: GitHub username
target_email: The email address that was searched for
"""
total_commits = sum(len(matches) for matches in commits_by_repo.values())
if not total_commits:
self.console.print(
f"[bold yellow]No commits by [blue]{username}[/] using "
f"[cyan]{target_email}[/] were found.[/]"
)
return
self.console.print(Panel(
f"Found [bold cyan]{total_commits}[/] commit(s) by [bold blue]{username}[/] "
f"using [bold cyan]{target_email}[/]",
expand=False
))
for repo in sorted(commits_by_repo):
for _, html_url in commits_by_repo[repo]:
self.console.print(html_url)
def display_results(self, email_sources: Dict[str, Dict[str, int]], username: str):
"""
Display the results in a formatted table.
Args:
email_sources: Dictionary mapping emails to repo sources and counts
username: GitHub username
"""
if not email_sources:
self.console.print(f"[bold yellow]No email addresses found for {username}.[/]")
return
total_emails = len(email_sources)
# Create a table for the results
table = Table(title=f"Email Addresses for {username}")
table.add_column("Email", style="cyan")
table.add_column("Occurrences", justify="right", style="green")
table.add_column("Sources", style="blue")
# Add rows to the table
for email, sources in sorted(email_sources.items(),
key=lambda x: sum(x[1].values()),
reverse=True):
total_count = sum(sources.values())
sources_list = ", ".join([f"{repo} ({count})" for repo, count in sources.items()])
table.add_row(
email,
str(total_count),
sources_list
)
# Print results
self.console.print(Panel(
f"Found [bold cyan]{total_emails}[/] unique email address(es) for [bold blue]{username}[/]",
expand=False
))
self.console.print(table)
def main():
"""Main function to run the email finder tool."""
parser = argparse.ArgumentParser(description="Find email addresses from GitHub commit history")
parser.add_argument("username", help="GitHub username to search")
parser.add_argument("--token", "-t", help="GitHub personal access token (recommended to avoid rate limits)")
parser.add_argument("--email", "-e", help="Instead of listing emails, list links to commits made with this specific email")
parser.add_argument("--contributions", "-c", action="store_true", help="Include repositories the user contributed to")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
args = parser.parse_args()
# Check for token in environment if not provided
token = args.token or os.environ.get("GITHUB_TOKEN")
# Initialize the finder
finder = GitHubEmailFinder(token=token, verbose=args.verbose)
# Show warning if no token provided
console = Console()
if not token:
console.print("[bold yellow]Warning: No GitHub token provided. Rate limits may apply.[/]")
try:
if args.email:
# Find links to commits made with a specific email
commits_by_repo = finder.find_commits_by_email(
args.username, args.email, args.contributions
)
finder.display_commit_links(commits_by_repo, args.username, args.email)
else:
# Find email addresses
email_sources = finder.find_emails(args.username, args.contributions)
# Display results
finder.display_results(email_sources, args.username)
except KeyboardInterrupt:
console.print("\n[bold yellow]Search interrupted by user.[/]")
sys.exit(1)
except Exception as e:
console.print(f"[bold red]Error: {e}[/]")
if args.verbose:
import traceback
console.print(traceback.format_exc())
sys.exit(1)
if __name__ == "__main__":
main()