Compare commits

..
28 Commits
Author SHA1 Message Date
Hadi df9fff8e97 format
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:10:46 +02:00
Hadi 926bf7239e return authors
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:10:31 +02:00
Hadi 5b79b6492b Filter by repo size
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:03:39 +02:00
Hadi 2137925043 add commit inspection for deep mode
Signed-off-by: Hadi <[email protected]>
2025-05-28 13:41:15 +02:00
Hadi 274b393e53 edit readme
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:10:42 +02:00
Hadi 0041c0c132 edit readme instructions & flags
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:02:19 +02:00
Hadi d3fdfdcdc5 comments & sorting flags
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:00:12 +02:00
Hadi 4f087cb7f0 ignore errors
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:57:50 +02:00
Hadi befb546292 refactor main
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:56:03 +02:00
Hadi 9a825bc7cc remove New func
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:46:34 +02:00
Hadi 72b8635832 remove @ from username
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:44:57 +02:00
Hadi 2ba8695674 edit flags sorting
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:44:49 +02:00
Hadi f8eb7d58ba add flag normalization
Signed-off-by: Hadi <[email protected]>
2025-05-27 20:30:29 +02:00
Hadi 637d9811f2 change struct
Signed-off-by: Hadi <[email protected]>
2025-05-27 20:01:05 +02:00
Hadi c498e7bfdd typo
Signed-off-by: Hadi <[email protected]>
2025-05-27 16:12:01 +02:00
Hadi 7e2b6dd426 Cover your tracks!
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:45:28 +02:00
Hadi 05b449afcd add --max-size & --refresh
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:33:00 +02:00
Hadi b4392f972d edit close friends algo
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:12:40 +02:00
Hadi e4cec2b07a Avoid double newline
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:12:33 +02:00
Hadi 15458ab78e not anymore, now by year/half-year
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:26:30 +02:00
Hadi deec50febf fix typo
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:19:14 +02:00
Hadi de47073083 edit readme
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:14:32 +02:00
Hadi a3f4b19382 add logo
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:02:23 +02:00
Hadi afdd30d34b add assets
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:00:15 +02:00
Hadi 58ac92bff8 rephrase
Signed-off-by: Hadi <[email protected]>
2025-05-20 17:00:38 +02:00
Hadi ce96d1f307 cleaner version
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:42:49 +02:00
Hadi bd734f11af format
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:32:20 +02:00
Hadi 1ff0e222c7 update flake
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:30:08 +02:00
17 changed files with 479 additions and 193 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+10
View File
@@ -0,0 +1,10 @@
# Contributing
Everybody is invited and welcome to contribute to this repo. There is a lot to do... Check the issues!
The process is straight-forward.
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes. (but skip step 0 and 1)
- [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) this repo.
- Write your changes (bug fixe, new feature, issues fix, ...).
- Create a Pull Request against the main branch.
+50 -33
View File
@@ -1,4 +1,10 @@
# GH-Recon <div align="center">
<img src="https://raw.githubusercontent.com/anotherhadi/gh-recon/main/.github/assets/logo.png" width="120px" />
</div>
<br>
# GH-Recon 🔍
<p> <p>
<a href="https://github.com/anotherhadi/gh-recon/releases"><img src="https://img.shields.io/github/release/anotherhadi/gh-recon.svg" alt="Latest Release"></a> <a href="https://github.com/anotherhadi/gh-recon/releases"><img src="https://img.shields.io/github/release/anotherhadi/gh-recon.svg" alt="Latest Release"></a>
@@ -6,41 +12,45 @@
<a href="https://goreportcard.com/report/github.com/anotherhadi/gh-recon"><img src="https://goreportcard.com/badge/github.com/anotherhadi/gh-recon" alt="GoReportCard"></a> <a href="https://goreportcard.com/report/github.com/anotherhadi/gh-recon"><img src="https://goreportcard.com/badge/github.com/anotherhadi/gh-recon" alt="GoReportCard"></a>
</p> </p>
## Project Overview ## 🧾 Project Overview
Fetches and aggregates public OSINT data for a GitHub user, leveraging Go and the GitHub API. Retrieves and aggregates public OSINT data about a GitHub user using Go and the GitHub API.
Finds hidden emails in commit history, previous usernames, friends, other GitHub accounts, and more.
## Features ## 🚀 Features
- Retrieve basic user profile information (username, ID, avatar, bio, creation dates) - Retrieve basic user profile information (username, ID, avatar, bio, creation dates)
- List organizations and roles - List organizations and roles
- Fetch SSH and GPG keys - Fetch SSH and GPG keys
- Enumerate social accounts - Enumerate social accounts
- Extract unique commit authors (name + email) in both chronological orders - Extract unique commit authors (name + email)
- Find close friends - Find close friends
- Search using an email address - Find Github accounts using an email address
- Export results to JSON - Export results to JSON
- Deep scan option (clone repositories, regex search, analyze licenses, etc.) - Deep scan option (clone repositories, regex search, analyze licenses, etc.)
## Disclaimer ## ⚠️ Disclaimer
This tool is intended for educational purposes only. Use responsibly and ensure you have permission to access the data you are querying. This tool is intended for educational purposes only. Use responsibly and ensure you have permission to access the data you are querying.
## Prerequisites ## 📋 Prerequisites
- Go 1.18+ - Go 1.18+
- GitHub Personal Access Token (recommended for higher rate limits): Create a GitHub API token with no permissions/no scope. This will be equivalent to public GitHub access, but it will allow access to use the GitHub Search API. - GitHub Personal Access Token (recommended for higher rate limits): Create a GitHub API token with no permissions/no scope. This will be equivalent to public GitHub access, but it will allow access to use the GitHub Search API.
## Installation ## 📦 Installation
### With Go ### With Go
```bash ```bash
go get github.com/anotherhadi/gh-recon go install github.com/anotherhadi/gh-recon@latest
``` ```
### With Nix/NixOS ### With Nix/NixOS
<details>
<summary>Click to expand</summary>
**From anywhere (using the repo URL):** **From anywhere (using the repo URL):**
```bash ```bash
@@ -63,7 +73,9 @@ environment.systemPackages = with pkgs; [ # or home.packages
]; ];
``` ```
## Usage </details>
## 🧪 Usage
```bash ```bash
gh-recon --username TARGET_USER [--token YOUR_TOKEN] gh-recon --username TARGET_USER [--token YOUR_TOKEN]
@@ -71,37 +83,42 @@ gh-recon --username TARGET_USER [--token YOUR_TOKEN]
### Flags ### Flags
- `--token`: Personal Access Token (optional but recommended)
```txt ```txt
-deep -u, --username string GitHub username to analyze
Enable deep scan (clone repos, regex search, analyse licenses, etc.) -t, --token string GitHub personal access token (e.g. ghp_...)
-email string -e, --email string Search accounts by email address
Search accounts by email address -d, --deep Enable deep scan (clone repos, regex search, analyse licenses, etc.)
-json string --max-size int Limit the size of repositories to scan (in MB) (only for deep scan) (default 150)
Write results to specified JSON file --exclude-repo string Exclude repos from deep scan (comma-separated list, only for deep scan)
-only-commits -r, --refresh Refresh the cache (only for deep scan)
Display only commits with author info -c, --only-commits Display only commits with author info
-silent -s, --silent Suppress all non-essential output
Suppress all non-essential output -j, --json string Write results to specified JSON file
-token string
GitHub personal access token (e.g. ghp_...)
-username string
GitHub username to analyze
``` ```
## Example ## 💡 Examples
```bash ```bash
gh-recon --username anotherhadi --token ghp_ABC123... gh-recon --username anotherhadi --token ghp_ABC123...
gh-recon --email [email protected] --token ghp_ABC123... gh-recon --email [email protected]
gh-recon --username anotherhadi --json output.json --deep gh-recon --username anotherhadi --json output.json --deep
``` ```
## Todo ## 🕵️‍♂️ Cover your tracks
Feel free to contribute! Understanding what information about you is publicly visible is the first step to managing your online presence. gh-recon can help you identify your own publicly available data on GitHub. Here’s how you can take steps to protect your privacy and security:
**Todo:** - **Review your public profile**: Regularly check your GitHub profile and repositories to ensure that you are not unintentionally exposing sensitive information.
- **Manage email exposure**: Use GitHub's settings to control which email addresses are visible on your profile and in commit history. You can also use a no-reply email address for commits. Delete/modify any sensitive information in your commit history.
- **Be Mindful of Repository Content**: Avoid including sensitive information in your repositories, such as API keys, passwords, emails or personal data. Use `.gitignore` to exclude files that contain sensitive information.
- Find and parse licenses You can also use a tool like [TruffleHog](github.com/trufflesecurity/trufflehog) to scan your repositories specifically for exposed secrets and tokens.
**Useful links:**
- [Blocking command line pushes that expose your personal email address](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/blocking-command-line-pushes-that-expose-your-personal-email-address)
- [No-reply email address](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address)
## 🤝 Contributing
Feel free to contribute! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
+2 -2
View File
@@ -13,7 +13,7 @@
(system: f system (import nixpkgs { inherit system; })); (system: f system (import nixpkgs { inherit system; }));
pname = "gh-recon"; pname = "gh-recon";
version = "0.2.0"; version = "0.2.1";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
@@ -24,7 +24,7 @@
src = ./.; src = ./.;
vendorHash = "sha256-CPk8B8FKEoN8qff6WV/iBf0eVjTBMVfJQvlVcti6dfM="; vendorHash = "sha256-S8IzmdiVvBtnQQl0AewGZ1yuitvrdnVQ/Jf2230g3Mg=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = description =
+110 -17
View File
@@ -1,45 +1,138 @@
package ghrecon package ghrecon
import (
"fmt"
"sort"
)
type CloseFriendsResult struct { type CloseFriendsResult struct {
Login string Login string
Score int Score int
} }
const (
maxFollowingForTarget = 50
maxFollowersForFollowing = 20
pointPerCriterion = 1
)
// CloseFriends returns a list of close friends of the user // CloseFriends returns a list of close friends of the user
// To derive this, we check the following: // To derive this, we check the following:
// 1. The target has less than 50 Following // 1. The target has less than 50 Following
// 2. The target's following has less than 20 followers // 2. The target's following has less than 20 followers (+1 point)
// 3. The target's following follows the target (+1 point)
func (r Recon) CloseFriends(username string) (response []CloseFriendsResult) { func (r Recon) CloseFriends(username string) (response []CloseFriendsResult) {
r.PrintTitle("🧑‍🤝‍🧑 Close Friends") r.PrintTitle("🧑‍🤝‍🧑 Close Friends")
following, resp, err := r.client.Users.ListFollowing(r.ctx, username, nil) following, resp, err := r.Client.Users.ListFollowing(r.Ctx, username, nil)
if err != nil { if err != nil {
r.logger.Fatal("Failed to fetch user's close friends", "err", err) r.Logger.Error("Failed to fetch user's following list", "user", username, "err", err)
}
if len(following) > 50 {
r.PrintInfo("INFO", "No commits found")
r.PrintNewline() r.PrintNewline()
return []CloseFriendsResult{} return
} }
WaitForRateLimit(resp) WaitForRateLimit(resp)
for _, user := range following {
followers, resp, err := r.client.Users.Get(r.ctx, user.GetLogin()) if len(following) >= maxFollowingForTarget {
WaitForRateLimit(resp) r.PrintInfo(
if err != nil { "INFO",
fmt.Sprintf(
"%s follows %d or more users (%d). Skipping close friends check.",
username,
maxFollowingForTarget,
len(following),
),
)
r.PrintNewline()
return
}
if len(following) == 0 {
r.PrintInfo("INFO", fmt.Sprintf("%s is not following anyone.", username))
r.PrintNewline()
return
}
for _, userBeingFollowedByTarget := range following {
loginName := userBeingFollowedByTarget.GetLogin()
if loginName == "" {
r.Logger.Warn("User in following list has an empty login", "target_user", username)
continue continue
} }
if followers.GetFollowers() < 20 {
currentScore := 0
userDetails, userResp, userErr := r.Client.Users.Get(r.Ctx, loginName)
if userErr != nil {
r.Logger.Warn(
"Failed to fetch details for followed user",
"followed_user",
loginName,
"err",
userErr,
)
if userResp != nil {
WaitForRateLimit(userResp)
}
continue
}
WaitForRateLimit(userResp)
if userDetails.GetFollowers() < maxFollowersForFollowing {
currentScore += pointPerCriterion
}
followsTargetBack, checkErr := r.checkIfUserFollows(loginName, username)
if checkErr != nil {
} else if followsTargetBack {
currentScore += pointPerCriterion
}
if currentScore > 0 {
response = append(response, CloseFriendsResult{ response = append(response, CloseFriendsResult{
Login: user.GetLogin(), Login: loginName,
Score: 1, Score: currentScore,
}) })
} }
} }
for _, friend := range response { if len(response) == 0 {
r.PrintInfo("Username", "@"+friend.Login) r.PrintInfo(
"INFO",
fmt.Sprintf("No close friends found for %s based on the criteria.", username),
)
} else {
sort.Slice(response, func(i, j int) bool {
return response[i].Score > response[j].Score
})
for i, friend := range response {
r.PrintInfo(
fmt.Sprintf("Friend n°%d", i+1),
"@"+friend.Login,
"Score: "+fmt.Sprintf("%d", friend.Score),
)
} }
}
r.PrintNewline() r.PrintNewline()
return return
} }
// checkIfUserFollows checks if sourceUserLogin follows targetUserLogin.
func (r Recon) checkIfUserFollows(sourceUserLogin, targetUserLogin string) (bool, error) {
isFollowing, resp, err := r.Client.Users.IsFollowing(r.Ctx, sourceUserLogin, targetUserLogin)
if err != nil {
r.Logger.Warn("Error checking if user follows target",
"source_user_checking", sourceUserLogin,
"target_user_to_check", targetUserLogin,
"err", err)
if resp != nil {
WaitForRateLimit(resp)
}
return false, err
}
if resp != nil {
WaitForRateLimit(resp)
}
return isFollowing, nil
}
+4 -4
View File
@@ -20,8 +20,8 @@ func (r Recon) Commits(username string) (response []CommitsResult) {
collect := func(date string) error { collect := func(date string) error {
for page := 1; page <= 10; page++ { for page := 1; page <= 10; page++ {
result, resp, err := r.client.Search.Commits( result, resp, err := r.Client.Search.Commits(
r.ctx, r.Ctx,
fmt.Sprintf("author:%s author-date:%s", username, date), fmt.Sprintf("author:%s author-date:%s", username, date),
&github.SearchOptions{ &github.SearchOptions{
Sort: "author-date", Sort: "author-date",
@@ -40,7 +40,7 @@ func (r Recon) Commits(username string) (response []CommitsResult) {
name := item.Commit.GetAuthor().GetName() name := item.Commit.GetAuthor().GetName()
email := item.Commit.GetAuthor().GetEmail() email := item.Commit.GetAuthor().GetEmail()
if SkipResult(name, email) { if SkipResult(name, email) {
// continue continue
} }
if _, seen := results[name+" - "+email]; !seen { if _, seen := results[name+" - "+email]; !seen {
author := CommitsResult{ author := CommitsResult{
@@ -71,7 +71,7 @@ func (r Recon) Commits(username string) (response []CommitsResult) {
">2026-01-01", ">2026-01-01",
} { } {
if err := collect(date); err != nil { if err := collect(date); err != nil {
r.logger.Error("Failed to fetch commits", "err", err, "date", date) r.Logger.Error("Failed to fetch commits", "err", err, "date", date)
} }
} }
+154 -34
View File
@@ -13,11 +13,10 @@ import (
"github.com/google/go-github/v72/github" "github.com/google/go-github/v72/github"
) )
func folderExists(path string) bool { type AuthorOccurrence struct {
if stat, err := os.Stat(path); err == nil && stat.IsDir() { Name string
return true Email string
} FoundIn []string
return false
} }
type EmailOccurrence struct { type EmailOccurrence struct {
@@ -25,6 +24,19 @@ type EmailOccurrence struct {
FoundIn []string FoundIn []string
} }
type DeepResult struct {
Repositories []Repositorie
Authors []AuthorOccurrence
Emails []EmailOccurrence
}
type Repositorie struct {
Repository string
Owner string
Name string
Size int
}
func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) { func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) {
emailLocations := make(map[string]map[string]bool) emailLocations := make(map[string]map[string]bool)
emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
@@ -36,6 +48,9 @@ func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) {
return err return err
} }
if !d.IsDir() { if !d.IsDir() {
if strings.Contains(path, ".git/logs/") {
return nil
}
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
fmt.Printf("Can't read %s: %v\n", path, err) fmt.Printf("Can't read %s: %v\n", path, err)
@@ -78,23 +93,18 @@ func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) {
return results, nil return results, nil
} }
type DeepResult struct { func (r Recon) Deep(username, excludeRepos string, refresh bool) (response DeepResult) {
Repository string repositories := []Repositorie{}
Owner string
Name string
}
func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) {
excludeReposList := strings.Split(excludeRepos, ",") excludeReposList := strings.Split(excludeRepos, ",")
repos, resp, err := r.client.Repositories.ListByUser( repos, resp, err := r.Client.Repositories.ListByUser(
r.ctx, r.Ctx,
username, username,
&github.RepositoryListByUserOptions{ &github.RepositoryListByUserOptions{
Type: "all", Type: "all",
}, },
) )
if err != nil { if err != nil {
r.logger.Error("Failed to fetch repositories", "err", err) r.Logger.Error("Failed to fetch repositories", "err", err)
return return
} }
@@ -103,10 +113,32 @@ func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) {
r.PrintInfo("INFO", "No repositories found") r.PrintInfo("INFO", "No repositories found")
} else { } else {
for _, repo := range repos { for _, repo := range repos {
response = append(response, DeepResult{ if slices.Contains(excludeReposList, repo.GetName()) ||
slices.Contains(excludeReposList, repo.GetOwner().GetLogin()+"/"+repo.GetName()) {
continue
}
maxRepoSize := r.MaxRepoSize * 1024
if repo.GetSize() > maxRepoSize {
r.PrintInfo(
"INFO",
"Skipping repository "+repo.GetOwner().GetLogin()+"/"+repo.GetName()+" due to size", fmt.Sprintf(
"%d",
repo.GetSize()/1024,
)+"MB > "+fmt.Sprintf(
"%d",
maxRepoSize/1024,
)+"MB",
)
continue
}
repositories = append(repositories, Repositorie{
Repository: repo.GetCloneURL(), Repository: repo.GetCloneURL(),
Owner: repo.GetOwner().GetLogin(), Owner: repo.GetOwner().GetLogin(),
Name: repo.GetName(), Name: repo.GetName(),
Size: repo.GetSize(),
}) })
} }
} }
@@ -119,23 +151,30 @@ func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) {
} }
tmp_folder := "/tmp/ghrecon-" + username tmp_folder := "/tmp/ghrecon-" + username
for _, repo := range response {
if slices.Contains(excludeReposList, repo.Name) ||
slices.Contains(excludeReposList, repo.Owner+"/"+repo.Name) {
r.PrintInfo("INFO", "Skipping repository", repo.Owner+"/"+repo.Name)
continue
}
r.PrintInfo(
"Downloading repository",
repo.Owner+"/"+repo.Name,
)
if folderExists(tmp_folder) {
if refresh {
r.PrintInfo("INFO", "Deleting existing folder "+tmp_folder)
err := os.RemoveAll(tmp_folder)
if err != nil {
r.PrintInfo("ERROR", "Failed to delete existing folder "+tmp_folder)
}
}
}
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if folderExists(destination) { if folderExists(destination) {
r.PrintInfo("INFO", "Directory already exists, skipping") r.PrintInfo("INFO", "Directory already downloaded, skipping "+repo.Owner+"/"+repo.Name)
continue continue
} }
r.PrintInfo(
"Downloading",
repo.Owner+"/"+repo.Name,
fmt.Sprintf("%d", repo.Size/1024)+"MB",
)
cmd := exec.Command( cmd := exec.Command(
"git", "git",
"clone", "clone",
@@ -144,7 +183,7 @@ func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) {
) )
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
r.logger.Error( r.Logger.Error(
"ERROR", "ERROR",
"Failed to clone repository", "Failed to clone repository",
"err", "err",
@@ -157,23 +196,104 @@ func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) {
} }
r.PrintInfo("INFO", "Cloned all repositories to "+tmp_folder) r.PrintInfo("INFO", "Cloned all repositories to "+tmp_folder)
authorOccurrences := []AuthorOccurrence{}
mapAuthorToIndex := make(map[string]int)
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if !folderExists(filepath.Join(destination, ".git")) {
r.Logger.Error(
"No .git directory found, cannot run git log.",
"repo",
repo.Owner+"/"+repo.Name,
"path",
destination,
)
} else {
gitLogCmd := exec.Command("git", "log", "--all", "--format=%aN <%aE>")
gitLogCmd.Dir = destination
logOutput, logErr := gitLogCmd.Output()
if logErr != nil {
if exitErr, ok := logErr.(*exec.ExitError); ok {
r.Logger.Error("Failed to execute git log (ExitError)", "repo", repo.Owner+"/"+repo.Name, "stderr", string(exitErr.Stderr), "err", logErr)
} else {
r.Logger.Error("Failed to execute git log", "repo", repo.Owner+"/"+repo.Name, "err", logErr)
}
} else {
lines := strings.Split(string(logOutput), "\n")
repoIdentifier := repo.Owner + "/" + repo.Name
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
continue
}
if index, exists := mapAuthorToIndex[trimmedLine]; exists {
isRepoListed := false
for _, foundRepo := range authorOccurrences[index].FoundIn {
if foundRepo == repoIdentifier {
isRepoListed = true
break
}
}
if !isRepoListed {
authorOccurrences[index].FoundIn = append(authorOccurrences[index].FoundIn, repoIdentifier)
slices.Sort(authorOccurrences[index].FoundIn)
}
} else {
parts := strings.SplitN(trimmedLine, " <", 2)
var authorName, authorEmail string
if len(parts) == 2 {
authorName = parts[0]
authorEmail = strings.TrimSuffix(parts[1], ">")
} else if len(parts) == 1 {
authorName = "-"
authorEmail = strings.TrimPrefix(strings.TrimSuffix(parts[0], ">"), "<")
} else {
r.Logger.Error("Malformed author line from git log", "line", trimmedLine, "repo", repoIdentifier)
continue
}
authorOccurrences = append(authorOccurrences, AuthorOccurrence{
Name: authorName,
Email: authorEmail,
FoundIn: []string{repoIdentifier},
})
mapAuthorToIndex[trimmedLine] = len(authorOccurrences) - 1
}
}
}
}
}
for _, author := range authorOccurrences {
r.PrintInfo(
"Author",
author.Name+" <"+author.Email+">",
"found in:"+strings.Join(author.FoundIn, ", "),
)
}
r.PrintInfo("INFO", "Now searching for emails in cloned repositories, this may take a while...") r.PrintInfo("INFO", "Now searching for emails in cloned repositories, this may take a while...")
results, err := findEmailsAndOccurrencesInDir(tmp_folder) emails, err := findEmailsAndOccurrencesInDir(tmp_folder)
if err != nil { if err != nil {
r.logger.Error("Failed to find emails in directory", "err", err) r.Logger.Error("Failed to find emails in directory", "err", err)
return return
} }
if len(results) == 0 { if len(emails) == 0 {
r.PrintInfo("INFO", "No emails found") r.PrintInfo("INFO", "No emails found")
} else { } else {
r.PrintInfo("INFO", "Found emails:") r.PrintInfo("INFO", "Found emails:")
for _, email := range results { for _, email := range emails {
r.PrintInfo("Email", email.Email) r.PrintInfo("Email", email.Email, "found in:"+strings.Join(email.FoundIn, ", "))
r.PrintInfo("Found in", tmp_folder, email.FoundIn...)
} }
} }
response.Repositories = repositories
response.Authors = authorOccurrences
response.Emails = emails
r.PrintNewline() r.PrintNewline()
return return
} }
+5 -3
View File
@@ -21,8 +21,8 @@ func (r Recon) Email(email string) (response []EmailResult) {
collect := func(date string) error { collect := func(date string) error {
for page := 1; page <= 10; page++ { for page := 1; page <= 10; page++ {
result, resp, err := r.client.Search.Commits( result, resp, err := r.Client.Search.Commits(
r.ctx, r.Ctx,
fmt.Sprintf("author-email:%s author-date:%s", email, date), fmt.Sprintf("author-email:%s author-date:%s", email, date),
&github.SearchOptions{ &github.SearchOptions{
Sort: "author-date", Sort: "author-date",
@@ -77,7 +77,7 @@ func (r Recon) Email(email string) (response []EmailResult) {
">2026-01-01", ">2026-01-01",
} { } {
if err := collect(date); err != nil { if err := collect(date); err != nil {
r.logger.Error("Failed to fetch commits", "err", err, "date", date) r.Logger.Error("Failed to fetch commits", "err", err, "date", date)
} }
} }
@@ -92,5 +92,7 @@ func (r Recon) Email(email string) (response []EmailResult) {
if len(results) == 0 { if len(results) == 0 {
r.PrintInfo("INFO", "No commits found") r.PrintInfo("INFO", "No commits found")
} }
r.PrintNewline()
return return
} }
+20 -9
View File
@@ -17,9 +17,9 @@ type SSHKeyResult struct {
} }
func (r Recon) SshKeys(username string) (response []SSHKeyResult) { func (r Recon) SshKeys(username string) (response []SSHKeyResult) {
sshKeys, resp, err := r.client.Users.ListKeys(r.ctx, username, nil) sshKeys, resp, err := r.Client.Users.ListKeys(r.Ctx, username, nil)
if err != nil { if err != nil {
r.logger.Error("Failed to fetch ssh keys", "err", err) r.Logger.Error("Failed to fetch ssh keys", "err", err)
} else if len(sshKeys) == 0 { } else if len(sshKeys) == 0 {
r.PrintTitle("🔑 SSH Keys") r.PrintTitle("🔑 SSH Keys")
r.PrintInfo("INFO", "No SSH Keys found") r.PrintInfo("INFO", "No SSH Keys found")
@@ -48,9 +48,11 @@ func (r Recon) SshKeys(username string) (response []SSHKeyResult) {
r.PrintInfo("Verified", k.Verified) r.PrintInfo("Verified", k.Verified)
r.PrintInfo("Last Used", k.LastUsed) r.PrintInfo("Last Used", k.LastUsed)
r.PrintInfo("Added By", k.AddedBy) r.PrintInfo("Added By", k.AddedBy)
if i != len(sshKeys)-1 {
r.PrintNewline() r.PrintNewline()
} }
} }
}
r.PrintNewline() r.PrintNewline()
WaitForRateLimit(resp) WaitForRateLimit(resp)
return return
@@ -72,9 +74,9 @@ type GPGKeyResult struct {
} }
func (r Recon) GpgKeys(username string) (response []GPGKeyResult) { func (r Recon) GpgKeys(username string) (response []GPGKeyResult) {
gpgKeys, resp, err := r.client.Users.ListGPGKeys(r.ctx, username, nil) gpgKeys, resp, err := r.Client.Users.ListGPGKeys(r.Ctx, username, nil)
if err != nil { if err != nil {
r.logger.Error("Failed to fetch user's gpg keys", "err", err) r.Logger.Error("Failed to fetch user's gpg keys", "err", err)
} else if len(gpgKeys) == 0 { } else if len(gpgKeys) == 0 {
r.PrintTitle("🗝️ GPG Keys") r.PrintTitle("🗝️ GPG Keys")
r.PrintInfo("INFO", "No GPG Keys found") r.PrintInfo("INFO", "No GPG Keys found")
@@ -122,6 +124,9 @@ func (r Recon) GpgKeys(username string) (response []GPGKeyResult) {
r.PrintInfo(" Email n°", fmt.Sprintf("%d", j)) r.PrintInfo(" Email n°", fmt.Sprintf("%d", j))
r.PrintInfo(" Email", email.Email) r.PrintInfo(" Email", email.Email)
r.PrintInfo(" Verified", email.Verified) r.PrintInfo(" Verified", email.Verified)
if j != len(k.Emails)-1 {
r.PrintNewline()
}
} }
r.PrintInfo("Subkeys", fmt.Sprintf("%d", len(k.Subkeys))) r.PrintInfo("Subkeys", fmt.Sprintf("%d", len(k.Subkeys)))
for j, subkey := range k.Subkeys { for j, subkey := range k.Subkeys {
@@ -131,11 +136,15 @@ func (r Recon) GpgKeys(username string) (response []GPGKeyResult) {
r.PrintInfo(" Subkey Created At", subkey.CreatedAt) r.PrintInfo(" Subkey Created At", subkey.CreatedAt)
r.PrintInfo(" Subkey Primary Key ID", subkey.PrimaryKeyID) r.PrintInfo(" Subkey Primary Key ID", subkey.PrimaryKeyID)
r.PrintInfo(" Subkey Raw Key", subkey.RawKey) r.PrintInfo(" Subkey Raw Key", subkey.RawKey)
} if j != len(k.Subkeys)-1 {
r.PrintNewline() r.PrintNewline()
} }
} }
if i != len(gpgKeys)-1 {
r.PrintNewline()
}
}
}
r.PrintNewline() r.PrintNewline()
WaitForRateLimit(resp) WaitForRateLimit(resp)
return return
@@ -149,13 +158,13 @@ type SSHSigningKeyResult struct {
} }
func (r Recon) SshSigningKeys(username string) (response []SSHSigningKeyResult) { func (r Recon) SshSigningKeys(username string) (response []SSHSigningKeyResult) {
signingKeys, resp, err := r.client.Users.ListSSHSigningKeys( signingKeys, resp, err := r.Client.Users.ListSSHSigningKeys(
r.ctx, r.Ctx,
username, username,
nil, nil,
) )
if err != nil { if err != nil {
r.logger.Error("Failed to fetch user's ssh signing keys", "err", err) r.Logger.Error("Failed to fetch user's ssh signing keys", "err", err)
} else if len(signingKeys) == 0 { } else if len(signingKeys) == 0 {
r.PrintTitle("📝 SSH Signing Keys") r.PrintTitle("📝 SSH Signing Keys")
r.PrintInfo("INFO", "No SSH Signing Keys found") r.PrintInfo("INFO", "No SSH Signing Keys found")
@@ -173,7 +182,9 @@ func (r Recon) SshSigningKeys(username string) (response []SSHSigningKeyResult)
r.PrintInfo("Title", k.Title) r.PrintInfo("Title", k.Title)
r.PrintInfo("Created At", k.CreatedAt) r.PrintInfo("Created At", k.CreatedAt)
r.PrintInfo("Key", k.Key) r.PrintInfo("Key", k.Key)
if i != len(signingKeys)-1 {
r.PrintNewline() r.PrintNewline()
}
response = append(response, k) response = append(response, k)
} }
} }
+6 -21
View File
@@ -8,25 +8,10 @@ import (
) )
type Recon struct { type Recon struct {
client *github.Client Client *github.Client
logger *log.Logger Logger *log.Logger
ctx context.Context Ctx context.Context
silent bool Silent bool
jsonFile string JsonFile string
} MaxRepoSize int
func NewRecon(
client *github.Client,
logger *log.Logger,
ctx context.Context,
silent bool,
jsonFile string,
) *Recon {
return &Recon{
client: client,
logger: logger,
ctx: ctx,
silent: silent,
jsonFile: jsonFile,
}
} }
+4 -2
View File
@@ -12,9 +12,9 @@ type OrgResult struct {
} }
func (r Recon) Orgs(username string) (response []OrgResult) { func (r Recon) Orgs(username string) (response []OrgResult) {
orgs, resp, err := r.client.Organizations.List(r.ctx, username, nil) orgs, resp, err := r.Client.Organizations.List(r.Ctx, username, nil)
if err != nil { if err != nil {
r.logger.Error("Failed to fetch organizations", "err", err) r.Logger.Error("Failed to fetch organizations", "err", err)
} else if len(orgs) == 0 { } else if len(orgs) == 0 {
r.PrintTitle("🏢 Organizations") r.PrintTitle("🏢 Organizations")
r.PrintInfo("INFO", "No Organizations found") r.PrintInfo("INFO", "No Organizations found")
@@ -32,7 +32,9 @@ func (r Recon) Orgs(username string) (response []OrgResult) {
r.PrintInfo("ID", o.ID) r.PrintInfo("ID", o.ID)
r.PrintInfo("URL", o.URL) r.PrintInfo("URL", o.URL)
r.PrintInfo("Description", o.Description) r.PrintInfo("Description", o.Description)
if i != len(orgs)-1 {
r.PrintNewline() r.PrintNewline()
}
response = append(response, o) response = append(response, o)
} }
} }
+3 -3
View File
@@ -11,16 +11,16 @@ type SocialResult struct {
} }
func (r Recon) Socials(username string) (response []SocialResult) { func (r Recon) Socials(username string) (response []SocialResult) {
resp, err := FetchGitHubAPI(r.client, "", "/users/"+username+"/social_accounts") resp, err := FetchGitHubAPI(r.Client, "", "/users/"+username+"/social_accounts")
if err != nil { if err != nil {
r.logger.Error("Failed to fetch socials", "err", err) r.Logger.Error("Failed to fetch socials", "err", err)
return return
} }
var socialAccounts []SocialResult var socialAccounts []SocialResult
err = json.Unmarshal(resp, &socialAccounts) err = json.Unmarshal(resp, &socialAccounts)
if err != nil { if err != nil {
r.logger.Error("Failed to unmarshal socials", "err", err) r.Logger.Error("Failed to unmarshal socials", "err", err)
return return
} }
+3 -3
View File
@@ -30,12 +30,12 @@ type UserResult struct {
} }
func (r Recon) User(username string) (response UserResult) { func (r Recon) User(username string) (response UserResult) {
user, resp, err := r.client.Users.Get(r.ctx, username) user, resp, err := r.Client.Users.Get(r.Ctx, username)
if resp.StatusCode == 404 { if resp.StatusCode == 404 {
r.logger.Fatal("User not found") r.Logger.Fatal("User not found")
} }
if err != nil { if err != nil {
r.logger.Fatal("Failed to fetch user's information", "err", err) r.Logger.Fatal("Failed to fetch user's information", "err", err)
} }
r.PrintTitle("👤 User informations") r.PrintTitle("👤 User informations")
+22 -11
View File
@@ -25,7 +25,7 @@ var (
) )
func (r Recon) Header() { func (r Recon) Header() {
if r.silent { if r.Silent {
return return
} }
asciiArt := " __ \n ___ _/ / _______ _______ ___ \n / _ `/ _ \\/ __/ -_) __/ _ \\/ _ \\\n \\_, /_//_/_/ \\__/\\__/\\___/_//_/\n/___/ " asciiArt := " __ \n ___ _/ / _______ _______ ___ \n / _ `/ _ \\/ __/ -_) __/ _ \\/ _ \\\n \\_, /_//_/_/ \\__/\\__/\\___/_//_/\n/___/ "
@@ -66,7 +66,9 @@ func FetchGitHubAPI(github *github.Client, token, path string) ([]byte, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("error executing request for %s: %w", url, err) return nil, fmt.Errorf("error executing request for %s: %w", url, err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
@@ -91,14 +93,14 @@ func FetchGitHubAPI(github *github.Client, token, path string) ([]byte, error) {
} }
func (r Recon) PrintNewline() { func (r Recon) PrintNewline() {
if r.silent { if r.Silent {
return return
} }
fmt.Println() fmt.Println()
} }
func (r Recon) PrintTitle(title string) { func (r Recon) PrintTitle(title string) {
if r.silent { if r.Silent {
return return
} }
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7287fd")) style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7287fd"))
@@ -106,7 +108,7 @@ func (r Recon) PrintTitle(title string) {
} }
func (r Recon) PrintInfo(key, value string, more ...string) { func (r Recon) PrintInfo(key, value string, more ...string) {
if r.silent { if r.Silent {
return return
} }
if value == "" || value == "0001-01-01 00:00:00 +0000 UTC" { if value == "" || value == "0001-01-01 00:00:00 +0000 UTC" {
@@ -144,20 +146,29 @@ func SkipResult(name, email string) bool {
} }
func (r Recon) WriteJson(data any) { func (r Recon) WriteJson(data any) {
if r.jsonFile == "" { if r.JsonFile == "" {
return return
} }
file, err := os.Create(r.jsonFile) file, err := os.Create(r.JsonFile)
if err != nil { if err != nil {
r.logger.Error("Failed to create JSON file", "err", err) r.Logger.Error("Failed to create JSON file", "err", err)
return return
} }
defer file.Close() defer func() {
_ = file.Close()
}()
as_json, _ := json.MarshalIndent(data, "", "\t") as_json, _ := json.MarshalIndent(data, "", "\t")
_, err = file.Write(as_json) _, err = file.Write(as_json)
if err != nil { if err != nil {
r.logger.Error("Failed to write to JSON file", "err", err) r.Logger.Error("Failed to write to JSON file", "err", err)
return return
} }
r.PrintInfo("INFO", "JSON file created successfully", "file", r.jsonFile) r.PrintInfo("INFO", "JSON file created successfully", "file", r.JsonFile)
}
func folderExists(path string) bool {
if stat, err := os.Stat(path); err == nil && stat.IsDir() {
return true
}
return false
} }
+54 -35
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"os" "os"
"strings"
ghrecon "github.com/anotherhadi/gh-recon/gh-recon" ghrecon "github.com/anotherhadi/gh-recon/gh-recon"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
@@ -19,16 +20,13 @@ func main() {
var silent bool var silent bool
var jsonFile string var jsonFile string
var excludeRepos string var excludeRepos string
var maxRepoSize int
var refresh bool
// FLAGS
flag.StringVarP(&username, "username", "u", "", "GitHub username to analyze") flag.StringVarP(&username, "username", "u", "", "GitHub username to analyze")
flag.StringVarP(&token, "token", "t", "", "GitHub personal access token (e.g. ghp_...)") flag.StringVarP(&token, "token", "t", "", "GitHub personal access token (e.g. ghp_...)")
flag.StringVarP(&fromEmail, "email", "e", "", "Search accounts by email address") flag.StringVarP(&fromEmail, "email", "e", "", "Search accounts by email address")
flag.BoolVarP(
&onlyCommitsLeak,
"only-commits",
"c",
false,
"Display only commits with author info",
)
flag.BoolVarP( flag.BoolVarP(
&deep, &deep,
"deep", "deep",
@@ -36,60 +34,81 @@ func main() {
false, false,
"Enable deep scan (clone repos, regex search, analyse licenses, etc.)", "Enable deep scan (clone repos, regex search, analyse licenses, etc.)",
) )
flag.BoolVarP(&silent, "silent", "s", false, "Suppress all non-essential output") flag.IntVar(
flag.StringVarP(&jsonFile, "json", "j", "", "Write results to specified JSON file") &maxRepoSize,
"max-size",
150,
"Limit the size of repositories to scan (in MB) (only for deep scan)",
)
flag.StringVar( flag.StringVar(
&excludeRepos, &excludeRepos,
"exclude-repo", "exclude-repo",
"", "",
"Exclude repos from deep scan (comma-separated list)", "Exclude repos from deep scan (comma-separated list, only for deep scan)",
) )
flag.BoolVarP(
&refresh,
"refresh",
"r",
false,
"Refresh the cache (only for deep scan)",
)
flag.BoolVarP(
&onlyCommitsLeak,
"only-commits",
"c",
false,
"Display only commits with author info",
)
flag.BoolVarP(&silent, "silent", "s", false, "Suppress all non-essential output")
flag.StringVarP(&jsonFile, "json", "j", "", "Write results to specified JSON file")
// FLAGS SETTINGS
flag.CommandLine.SetNormalizeFunc(wordSepNormalizeFunc)
flag.CommandLine.SortFlags = false
flag.Parse() flag.Parse()
styles := log.DefaultStyles() // INITIALIZE RECON OBJECT
styles.Levels[log.InfoLevel] = styles.Levels[log.InfoLevel].Foreground(ghrecon.Grey)
logger := log.NewWithOptions(os.Stderr, log.Options{ r := &ghrecon.Recon{
Client: github.NewClient(nil),
Logger: log.NewWithOptions(os.Stderr, log.Options{
ReportCaller: false, ReportCaller: false,
ReportTimestamp: false, ReportTimestamp: false,
}) }),
logger.SetStyles(styles) Ctx: context.Background(),
Silent: silent,
JsonFile: jsonFile,
MaxRepoSize: maxRepoSize,
}
// CHECK FLAGS
if username == "" && fromEmail == "" { if username == "" && fromEmail == "" {
logger.Error( r.Logger.Fatal(
"Please provide a username with the --username (-u) flag or an email with the --email (-e) flag", "Please provide a username with the --username (-u) flag or an email with the --email (-e) flag",
) )
os.Exit(1)
} else if username != "" { } else if username != "" {
username = strings.TrimPrefix(username, "@")
if err := ghrecon.ParseUsername(username); err != nil { if err := ghrecon.ParseUsername(username); err != nil {
logger.Error("Invalid username", "err", err) r.Logger.Fatal("Invalid username", "err", err)
os.Exit(1)
} }
} }
client := github.NewClient(nil)
if token == "" { if token == "" {
if !silent { r.PrintInfo(
logger.Info( "INFO",
"It's recommended to set a Github token for better rate limits. You can set it using the --token (-t) flag.", "It's recommended to set a Github token for better rate limits. You can set it using the --token (-t) flag.",
) )
}
} else { } else {
client = client.WithAuthToken(token) r.Client = r.Client.WithAuthToken(token)
} }
ctx := context.Background() // START
r := ghrecon.NewRecon(
client,
logger,
ctx,
silent,
jsonFile,
)
r.Header() r.Header()
if fromEmail != "" { if fromEmail != "" {
emailsInfo := r.Email(fromEmail) emailsInfo := r.Email(fromEmail)
r.WriteJson( r.WriteJson(
@@ -131,7 +150,7 @@ func main() {
} }
if deep { if deep {
results["Deep"] = r.Deep(username, excludeRepos) results["Deep"] = r.Deep(username, excludeRepos, refresh)
} }
r.WriteJson(results) r.WriteJson(results)
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"strings"
flag "github.com/spf13/pflag"
)
func wordSepNormalizeFunc(f *flag.FlagSet, name string) flag.NormalizedName {
from := []string{".", "_"}
to := "-"
for _, sep := range from {
name = strings.ReplaceAll(name, sep, to)
}
return flag.NormalizedName(name)
}