From 5a9483411a8b7c8b3633127a9b16b29e27d43ab3 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue, 20 May 2025 15:28:19 +0200 Subject: [PATCH] v0.2.1 Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- README.md | 33 +++++- gh-recon/close-friends.go | 45 +++++++++ gh-recon/commits.go | 99 ++++++++++-------- gh-recon/deep.go | 179 +++++++++++++++++++++++++++++++++ gh-recon/email.go | 96 ++++++++++++++++++ gh-recon/keys.go | 206 +++++++++++++++++++++++++------------- gh-recon/main.go | 24 +++-- gh-recon/orgs.go | 44 ++++---- gh-recon/socials.go | 31 +++--- gh-recon/user.go | 103 +++++++++++++------ gh-recon/utils.go | 58 ++++++++++- go.mod | 1 + go.sum | 2 + main.go | 140 ++++++++++++++++++++------ 14 files changed, 845 insertions(+), 216 deletions(-) create mode 100644 gh-recon/close-friends.go create mode 100644 gh-recon/deep.go create mode 100644 gh-recon/email.go diff --git a/README.md b/README.md index 9cec051..f0d71f7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ Fetches and aggregates public OSINT data for a GitHub user, leveraging Go and th - Fetch SSH and GPG keys - Enumerate social accounts - Extract unique commit authors (name + email) in both chronological orders +- Find close friends +- Search using an email address +- Export results to JSON +- Deep scan option (clone repositories, regex search, analyze licenses, etc.) ## Disclaimer @@ -25,7 +29,7 @@ This tool is intended for educational purposes only. Use responsibly and ensure ## Prerequisites - Go 1.18+ -- GitHub Personal Access Token (recommended for higher rate limits) +- 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 @@ -67,18 +71,37 @@ gh-recon --username TARGET_USER [--token YOUR_TOKEN] ### Flags -- `--username`: GitHub username to inspect (required) - `--token`: Personal Access Token (optional but recommended) +```txt + -deep + Enable deep scan (clone repos, regex search, analyse licenses, etc.) + -email string + Search accounts by email address + -json string + Write results to specified JSON file + -only-commits + Display only commits with author info + -silent + Suppress all non-essential output + -token string + GitHub personal access token (e.g. ghp_...) + -username string + GitHub username to analyze +``` + ## Example ```bash gh-recon --username anotherhadi --token ghp_ABC123... +gh-recon --email myemail@gmail.com --token ghp_ABC123... +gh-recon --username anotherhadi --json output.json --deep ``` ## Todo -Feel free to contribute! Here are some ideas: +Feel free to contribute! -- Fetch names in License files -- Fetch emails in README files/comments +**Todo:** + +- Find and parse licenses diff --git a/gh-recon/close-friends.go b/gh-recon/close-friends.go new file mode 100644 index 0000000..0981685 --- /dev/null +++ b/gh-recon/close-friends.go @@ -0,0 +1,45 @@ +package ghrecon + +type CloseFriendsResult struct { + Login string + Score int +} + +// CloseFriends returns a list of close friends of the user +// To derive this, we check the following: +// 1. The target has less than 50 Following +// 2. The target's following has less than 20 followers + +func (r Recon) CloseFriends(username string) (response []CloseFriendsResult) { + r.PrintTitle("šŸ§‘ā€šŸ¤ā€šŸ§‘ Close Friends") + + following, resp, err := r.client.Users.ListFollowing(r.ctx, username, nil) + if err != nil { + r.logger.Fatal("Failed to fetch user's close friends", "err", err) + } + if len(following) > 50 { + r.PrintInfo("INFO", "No commits found") + r.PrintNewline() + return []CloseFriendsResult{} + } + WaitForRateLimit(resp) + for _, user := range following { + followers, resp, err := r.client.Users.Get(r.ctx, user.GetLogin()) + WaitForRateLimit(resp) + if err != nil { + continue + } + if followers.GetFollowers() < 20 { + response = append(response, CloseFriendsResult{ + Login: user.GetLogin(), + Score: 1, + }) + } + } + + for _, friend := range response { + r.PrintInfo("Username", "@"+friend.Login) + } + r.PrintNewline() + return +} diff --git a/gh-recon/commits.go b/gh-recon/commits.go index 27d9d68..23b4eab 100644 --- a/gh-recon/commits.go +++ b/gh-recon/commits.go @@ -6,66 +6,87 @@ import ( "github.com/google/go-github/v72/github" ) -func (r Recon) Commits(username string) { - PrintTitle("šŸ™ Commits") +type CommitsResult struct { + Name string + Email string + Occurences int + FirstFoundIn string +} - seenNames := make(map[string]struct{}) - seenEmails := make(map[string]struct{}) - var names, emails []string +func (r Recon) Commits(username string) (response []CommitsResult) { + r.PrintTitle("šŸ™ Commits") - collect := func(order string) error { - opts := &github.SearchOptions{ - Sort: "author-date", - Order: order, - ListOptions: github.ListOptions{PerPage: 100}, - } + results := make(map[string]CommitsResult) + + collect := func(date string) error { for page := 1; page <= 10; page++ { - opts.ListOptions.Page = page result, resp, err := r.client.Search.Commits( r.ctx, - fmt.Sprintf("author:%s", username), - opts, + fmt.Sprintf("author:%s author-date:%s", username, date), + &github.SearchOptions{ + Sort: "author-date", + Order: "desc", + ListOptions: github.ListOptions{PerPage: 100, Page: page}, + }, ) if err != nil { - return fmt.Errorf("fetch page %d (%s): %w", page, order, err) + return fmt.Errorf("fetch page %d (%s): %w", page, date, err) } WaitForRateLimit(resp) if len(result.Commits) == 0 { break } for _, item := range result.Commits { - a := item.Commit.GetAuthor() - name := a.GetName() - email := a.GetEmail() - if _, seen := seenNames[name]; !seen { - seenNames[name] = struct{}{} - names = append(names, name) - PrintInfo( - "Name "+fmt.Sprint(len(names)), - name, - "from "+item.GetRepository().Owner.GetLogin()+"/"+item.GetRepository(). - GetName(), - ) + name := item.Commit.GetAuthor().GetName() + email := item.Commit.GetAuthor().GetEmail() + if SkipResult(name, email) { + // continue } - if _, seen := seenEmails[email]; !seen { - seenEmails[email] = struct{}{} - emails = append(emails, email) - PrintInfo( - "Email "+fmt.Sprint(len(emails)), - email, - "from "+item.GetRepository().Owner.GetLogin()+"/"+item.GetRepository(). + if _, seen := results[name+" - "+email]; !seen { + author := CommitsResult{ + Name: name, + Email: email, + Occurences: 1, + FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository(). GetName(), - ) + } + results[name+" - "+email] = author + } else { + result := results[name+" - "+email] + result.Occurences++ + results[name+" - "+email] = result } } } return nil } - if err := collect("desc"); err != nil { - r.logger.Error("Failed to fetch commits", "err", err, "order", "desc") + // Range of dates to bypass the limit of 1000 results + for _, date := range []string{ + "<2023-01-01", "2023-01-01..2023-12-31", + "2024-01-01..2024-05-31", + "2024-06-01..2024-12-31", + "2025-01-01..2025-05-31", + "2025-06-01..2025-12-31", + ">2026-01-01", + } { + if err := collect(date); err != nil { + r.logger.Error("Failed to fetch commits", "err", err, "date", date) + } } - if err := collect("asc"); err != nil { - r.logger.Error("Failed to fetch commits", "err", err, "order", "asc") + + for _, result := range results { + r.PrintInfo( + "Author", + result.Name+" - "+result.Email, + "first from "+result.FirstFoundIn+" (x"+fmt.Sprint(result.Occurences)+")", + ) + response = append(response, result) } + if len(results) == 0 { + r.PrintInfo("INFO", "No commits found") + } + r.PrintNewline() + + return } diff --git a/gh-recon/deep.go b/gh-recon/deep.go new file mode 100644 index 0000000..9856832 --- /dev/null +++ b/gh-recon/deep.go @@ -0,0 +1,179 @@ +package ghrecon + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/google/go-github/v72/github" +) + +func folderExists(path string) bool { + if stat, err := os.Stat(path); err == nil && stat.IsDir() { + return true + } + return false +} + +type EmailOccurrence struct { + Email string + FoundIn []string +} + +func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) { + emailLocations := make(map[string]map[string]bool) + emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + normalizedRootPath := filepath.Clean(rootPath) + + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + fmt.Printf("Can't access %s: %v\n", path, err) + return err + } + if !d.IsDir() { + content, err := os.ReadFile(path) + if err != nil { + fmt.Printf("Can't read %s: %v\n", path, err) + return nil + } + + currentFileEmails := emailRegex.FindAllString(string(content), -1) + if len(currentFileEmails) > 0 { + relativePath, errRel := filepath.Rel(normalizedRootPath, path) + if errRel != nil { + fmt.Printf("Can't find the relative path %s: %v\n", path, errRel) + relativePath = path + } + + for _, email := range currentFileEmails { + if len(email) > 12 { + if _, ok := emailLocations[email]; !ok { + emailLocations[email] = make(map[string]bool) + } + emailLocations[email][relativePath] = true + } + } + } + } + return nil + }) + if err != nil { + return nil, err + } + + var results []EmailOccurrence + for email, pathSet := range emailLocations { + var paths []string + for path := range pathSet { + paths = append(paths, path) + } + results = append(results, EmailOccurrence{Email: email, FoundIn: paths}) + } + + return results, nil +} + +type DeepResult struct { + Repository string + Owner string + Name string +} + +func (r Recon) Deep(username, excludeRepos string) (response []DeepResult) { + excludeReposList := strings.Split(excludeRepos, ",") + repos, resp, err := r.client.Repositories.ListByUser( + r.ctx, + username, + &github.RepositoryListByUserOptions{ + Type: "all", + }, + ) + if err != nil { + r.logger.Error("Failed to fetch repositories", "err", err) + return + } + + r.PrintTitle("šŸ“¦ Repositories") + if len(repos) == 0 { + r.PrintInfo("INFO", "No repositories found") + } else { + for _, repo := range repos { + response = append(response, DeepResult{ + Repository: repo.GetCloneURL(), + Owner: repo.GetOwner().GetLogin(), + Name: repo.GetName(), + }) + } + } + WaitForRateLimit(resp) + + cmd := exec.Command("git", "--version") + if err := cmd.Run(); err != nil { + r.PrintInfo("ERROR", "Git is not installed, please install it to use this feature") + return + } + + 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, + ) + + destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name + if folderExists(destination) { + r.PrintInfo("INFO", "Directory already exists, skipping") + continue + } + + cmd := exec.Command( + "git", + "clone", + repo.Repository, + destination, + ) + err := cmd.Run() + if err != nil { + r.logger.Error( + "ERROR", + "Failed to clone repository", + "err", + err, + "repo", + repo.Repository, + ) + continue + } + } + r.PrintInfo("INFO", "Cloned all repositories to "+tmp_folder) + + r.PrintInfo("INFO", "Now searching for emails in cloned repositories, this may take a while...") + results, err := findEmailsAndOccurrencesInDir(tmp_folder) + if err != nil { + r.logger.Error("Failed to find emails in directory", "err", err) + return + } + + if len(results) == 0 { + r.PrintInfo("INFO", "No emails found") + } else { + r.PrintInfo("INFO", "Found emails:") + for _, email := range results { + r.PrintInfo("Email", email.Email) + r.PrintInfo("Found in", tmp_folder, email.FoundIn...) + } + } + + r.PrintNewline() + return +} diff --git a/gh-recon/email.go b/gh-recon/email.go new file mode 100644 index 0000000..f83e953 --- /dev/null +++ b/gh-recon/email.go @@ -0,0 +1,96 @@ +package ghrecon + +import ( + "fmt" + + "github.com/google/go-github/v72/github" +) + +type EmailResult struct { + Name string + Email string + Username string + Occurences int + FirstFoundIn string +} + +func (r Recon) Email(email string) (response []EmailResult) { + r.PrintTitle("āœ‰ļø Email") + + results := make(map[string]EmailResult) + + collect := func(date string) error { + for page := 1; page <= 10; page++ { + result, resp, err := r.client.Search.Commits( + r.ctx, + fmt.Sprintf("author-email:%s author-date:%s", email, date), + &github.SearchOptions{ + Sort: "author-date", + Order: "desc", + ListOptions: github.ListOptions{PerPage: 100, Page: page}, + }, + ) + if err != nil { + return fmt.Errorf("fetch page %d (%s): %w", page, date, err) + } + WaitForRateLimit(resp) + if len(result.Commits) == 0 { + break + } + for _, item := range result.Commits { + name := item.Commit.GetAuthor().GetName() + email := item.Commit.GetAuthor().GetEmail() + login := item.GetAuthor().GetLogin() + if login == "" { + login = "Unknown" + } + if SkipResult(name, email) { + continue + } + if _, seen := results[name+" - "+email+" - "+login]; !seen { + author := EmailResult{ + Name: name, + Email: email, + Username: login, + Occurences: 1, + FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository(). + GetName(), + } + results[name+" - "+email+" - "+login] = author + } else { + result := results[name+" - "+email+" - "+login] + result.Occurences++ + results[name+" - "+email+" - "+login] = result + } + } + } + return nil + } + + // Range of dates to bypass the limit of 1000 results + for _, date := range []string{ + "<2023-01-01", "2023-01-01..2023-12-31", + "2024-01-01..2024-05-31", + "2024-06-01..2024-12-31", + "2025-01-01..2025-05-31", + "2025-06-01..2025-12-31", + ">2026-01-01", + } { + if err := collect(date); err != nil { + r.logger.Error("Failed to fetch commits", "err", err, "date", date) + } + } + + for _, result := range results { + r.PrintInfo( + "Author", + result.Name+" - "+result.Email+" - @"+result.Username, + "first from "+result.FirstFoundIn+" (x"+fmt.Sprint(result.Occurences)+")", + ) + response = append(response, result) + } + if len(results) == 0 { + r.PrintInfo("INFO", "No commits found") + } + return +} diff --git a/gh-recon/keys.go b/gh-recon/keys.go index a6d7447..ea23e9a 100644 --- a/gh-recon/keys.go +++ b/gh-recon/keys.go @@ -4,88 +4,151 @@ import ( "fmt" ) -func (r Recon) SshKeys(username string) { +type SSHKeyResult struct { + ID string + Url string + Title string + CreatedAt string + Key string + ReadOnly string + Verified string + LastUsed string + AddedBy string +} + +func (r Recon) SshKeys(username string) (response []SSHKeyResult) { sshKeys, resp, err := r.client.Users.ListKeys(r.ctx, username, nil) if err != nil { r.logger.Error("Failed to fetch ssh keys", "err", err) } else if len(sshKeys) == 0 { - PrintTitle("šŸ”‘ SSH Keys") - r.logger.Info("No SSH Keys found\n") + r.PrintTitle("šŸ”‘ SSH Keys") + r.PrintInfo("INFO", "No SSH Keys found") } else { - PrintTitle("šŸ”‘ SSH Keys") + r.PrintTitle("šŸ”‘ SSH Keys") for i, key := range sshKeys { - PrintInfo("Key n°", fmt.Sprintf("%d", i)) - PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) - PrintInfo("URL", key.GetURL()) - PrintInfo("Title", key.GetTitle()) - PrintInfo("Created At", key.GetCreatedAt().String()) - PrintInfo("Key", key.GetKey()) - PrintInfo("Read Only", fmt.Sprintf("%t", key.GetReadOnly())) - PrintInfo("Verified", fmt.Sprintf("%t", key.GetVerified())) - PrintInfo("Last Used", key.GetLastUsed().String()) - PrintInfo("Added By", key.GetAddedBy()) - fmt.Println() + k := SSHKeyResult{ + ID: fmt.Sprintf("%d", key.GetID()), + Url: key.GetURL(), + Title: key.GetTitle(), + CreatedAt: key.GetCreatedAt().String(), + Key: key.GetKey(), + ReadOnly: fmt.Sprintf("%t", key.GetReadOnly()), + Verified: fmt.Sprintf("%t", key.GetVerified()), + LastUsed: key.GetLastUsed().String(), + AddedBy: key.GetAddedBy(), + } + response = append(response, k) + r.PrintInfo("Key n°", fmt.Sprintf("%d", i)) + r.PrintInfo("ID", k.ID) + r.PrintInfo("URL", k.Url) + r.PrintInfo("Title", k.Title) + r.PrintInfo("Created At", k.CreatedAt) + r.PrintInfo("Key", k.Key) + r.PrintInfo("Read Only", k.ReadOnly) + r.PrintInfo("Verified", k.Verified) + r.PrintInfo("Last Used", k.LastUsed) + r.PrintInfo("Added By", k.AddedBy) + r.PrintNewline() } } + r.PrintNewline() WaitForRateLimit(resp) + return } -func (r Recon) GpgKeys(username string) { +type GPGKeyEmail struct { + Email string + Verified string +} +type GPGKeyResult struct { + ID string + KeyID string + PublicKey string + CreatedAt string + PrimaryKeyID string + RawKey string + Emails []GPGKeyEmail + Subkeys []GPGKeyResult +} + +func (r Recon) GpgKeys(username string) (response []GPGKeyResult) { gpgKeys, resp, err := r.client.Users.ListGPGKeys(r.ctx, username, nil) if err != nil { r.logger.Error("Failed to fetch user's gpg keys", "err", err) } else if len(gpgKeys) == 0 { - PrintTitle("šŸ—ļø GPG Keys") - r.logger.Info("No GPG Keys found\n") + r.PrintTitle("šŸ—ļø GPG Keys") + r.PrintInfo("INFO", "No GPG Keys found") } else { - PrintTitle("šŸ—ļø GPG Keys") + r.PrintTitle("šŸ—ļø GPG Keys") for i, key := range gpgKeys { - PrintInfo("Key n°", fmt.Sprintf("%d", i)) - PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) - PrintInfo("Key ID", key.GetKeyID()) - PrintInfo("Public Key", key.GetPublicKey()) - PrintInfo("Created At", key.GetCreatedAt().String()) - PrintInfo("Expires At", key.GetExpiresAt().String()) - PrintInfo("Can Sign", fmt.Sprintf("%t", key.GetCanSign())) - PrintInfo("Can Encrypt Comms", fmt.Sprintf("%t", key.GetCanEncryptComms())) - PrintInfo("Can Encrypt Storage", fmt.Sprintf("%t", key.GetCanEncryptStorage())) - PrintInfo("Can Certify", fmt.Sprintf("%t", key.GetCanCertify())) - PrintInfo("Primary Key ID", fmt.Sprintf("%d", key.GetPrimaryKeyID())) - PrintInfo("Raw Key", key.GetRawKey()) - PrintInfo("Emails", fmt.Sprintf("%d", len(key.Emails))) - for j, email := range key.Emails { - PrintInfo(" Email n°", fmt.Sprintf("%d", j)) - PrintInfo(" Email", email.GetEmail()) - PrintInfo(" Verified", fmt.Sprintf("%t", email.GetVerified())) + k := GPGKeyResult{ + ID: fmt.Sprintf("%d", key.GetID()), + KeyID: key.GetKeyID(), + PublicKey: key.GetPublicKey(), + CreatedAt: key.GetCreatedAt().String(), + PrimaryKeyID: fmt.Sprintf("%d", key.GetPrimaryKeyID()), + RawKey: key.GetRawKey(), + Emails: []GPGKeyEmail{}, + Subkeys: []GPGKeyResult{}, } - PrintInfo("Subkeys", fmt.Sprintf("%d", len(key.Subkeys))) - for j, subkey := range key.Subkeys { - PrintInfo(" Subkey n°", fmt.Sprintf("%d", j)) - PrintInfo(" Subkey ID", fmt.Sprintf("%d", subkey.GetID())) - PrintInfo(" Subkey Key ID", subkey.GetKeyID()) - PrintInfo(" Subkey Created At", subkey.GetCreatedAt().String()) - PrintInfo(" Subkey Expires At", subkey.GetExpiresAt().String()) - PrintInfo(" Subkey Can Sign", fmt.Sprintf("%t", subkey.GetCanSign())) - PrintInfo( - " Subkey Can Encrypt Comms", - fmt.Sprintf("%t", subkey.GetCanEncryptComms()), - ) - PrintInfo( - " Subkey Can Encrypt Storage", - fmt.Sprintf("%t", subkey.GetCanEncryptStorage()), - ) - PrintInfo(" Subkey Can Certify", fmt.Sprintf("%t", subkey.GetCanCertify())) - PrintInfo(" Subkey Primary Key ID", fmt.Sprintf("%d", subkey.GetPrimaryKeyID())) - PrintInfo(" Subkey Raw Key", subkey.GetRawKey()) - PrintInfo(" Subkey Public Key", subkey.GetPublicKey()) + for _, email := range key.Emails { + email := GPGKeyEmail{ + Email: email.GetEmail(), + Verified: fmt.Sprintf("%t", email.GetVerified()), + } + k.Emails = append(k.Emails, email) } - fmt.Println() + for _, subkey := range key.Subkeys { + subkey := GPGKeyResult{ + ID: fmt.Sprintf("%d", subkey.GetID()), + KeyID: subkey.GetKeyID(), + PublicKey: subkey.GetPublicKey(), + CreatedAt: subkey.GetCreatedAt().String(), + PrimaryKeyID: fmt.Sprintf("%d", subkey.GetPrimaryKeyID()), + RawKey: subkey.GetRawKey(), + } + k.Subkeys = append(k.Subkeys, subkey) + } + response = append(response, k) + r.PrintInfo("Key n°", fmt.Sprintf("%d", i)) + r.PrintInfo("ID", k.ID) + r.PrintInfo("Key ID", k.KeyID) + r.PrintInfo("Public Key", k.PublicKey) + r.PrintInfo("Created At", k.CreatedAt) + r.PrintInfo("Primary Key ID", k.PrimaryKeyID) + r.PrintInfo("Raw Key", k.RawKey) + r.PrintInfo("Emails", fmt.Sprintf("%d", len(k.Emails))) + for j, email := range k.Emails { + r.PrintInfo(" Email n°", fmt.Sprintf("%d", j)) + r.PrintInfo(" Email", email.Email) + r.PrintInfo(" Verified", email.Verified) + } + r.PrintInfo("Subkeys", fmt.Sprintf("%d", len(k.Subkeys))) + for j, subkey := range k.Subkeys { + r.PrintInfo(" Subkey n°", fmt.Sprintf("%d", j)) + r.PrintInfo(" Subkey ID", subkey.ID) + r.PrintInfo(" Subkey Key ID", subkey.KeyID) + r.PrintInfo(" Subkey Created At", subkey.CreatedAt) + r.PrintInfo(" Subkey Primary Key ID", subkey.PrimaryKeyID) + r.PrintInfo(" Subkey Raw Key", subkey.RawKey) + } + + r.PrintNewline() } } + r.PrintNewline() WaitForRateLimit(resp) + return } -func (r Recon) SshSigningKeys(username string) { +type SSHSigningKeyResult struct { + ID string + Title string + CreatedAt string + Key string +} + +func (r Recon) SshSigningKeys(username string) (response []SSHSigningKeyResult) { signingKeys, resp, err := r.client.Users.ListSSHSigningKeys( r.ctx, username, @@ -94,18 +157,27 @@ func (r Recon) SshSigningKeys(username string) { if err != nil { r.logger.Error("Failed to fetch user's ssh signing keys", "err", err) } else if len(signingKeys) == 0 { - PrintTitle("šŸ“ SSH Signing Keys") - r.logger.Info("No SSH Signing Keys found\n") + r.PrintTitle("šŸ“ SSH Signing Keys") + r.PrintInfo("INFO", "No SSH Signing Keys found") } else { - PrintTitle("šŸ“ SSH Signing Keys") + r.PrintTitle("šŸ“ SSH Signing Keys") for i, key := range signingKeys { - PrintInfo("Key n°", fmt.Sprintf("%d", i)) - PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) - PrintInfo("Key", key.GetKey()) - PrintInfo("Title", key.GetTitle()) - PrintInfo("Created At", key.GetCreatedAt().String()) - fmt.Println() + k := SSHSigningKeyResult{ + ID: fmt.Sprintf("%d", key.GetID()), + Title: key.GetTitle(), + CreatedAt: key.GetCreatedAt().String(), + Key: key.GetKey(), + } + r.PrintInfo("Key n°", fmt.Sprintf("%d", i)) + r.PrintInfo("ID", k.ID) + r.PrintInfo("Title", k.Title) + r.PrintInfo("Created At", k.CreatedAt) + r.PrintInfo("Key", k.Key) + r.PrintNewline() + response = append(response, k) } } WaitForRateLimit(resp) + r.PrintNewline() + return response } diff --git a/gh-recon/main.go b/gh-recon/main.go index 08ae099..07f619c 100644 --- a/gh-recon/main.go +++ b/gh-recon/main.go @@ -8,15 +8,25 @@ import ( ) type Recon struct { - client *github.Client - logger *log.Logger - ctx context.Context + client *github.Client + logger *log.Logger + ctx context.Context + silent bool + jsonFile string } -func NewRecon(client *github.Client, logger *log.Logger, ctx context.Context) *Recon { +func NewRecon( + client *github.Client, + logger *log.Logger, + ctx context.Context, + silent bool, + jsonFile string, +) *Recon { return &Recon{ - client: client, - logger: logger, - ctx: ctx, + client: client, + logger: logger, + ctx: ctx, + silent: silent, + jsonFile: jsonFile, } } diff --git a/gh-recon/orgs.go b/gh-recon/orgs.go index e4d1e70..bf7e583 100644 --- a/gh-recon/orgs.go +++ b/gh-recon/orgs.go @@ -4,31 +4,39 @@ import ( "fmt" ) -func (r Recon) Orgs(username string) { +type OrgResult struct { + Login string + ID string + URL string + Description string +} + +func (r Recon) Orgs(username string) (response []OrgResult) { orgs, resp, err := r.client.Organizations.List(r.ctx, username, nil) if err != nil { r.logger.Error("Failed to fetch organizations", "err", err) } else if len(orgs) == 0 { - PrintTitle("šŸ¢ Organizations") - r.logger.Info("No Organizations found\n") + r.PrintTitle("šŸ¢ Organizations") + r.PrintInfo("INFO", "No Organizations found") } else { - PrintTitle("šŸ¢ Organizations") + r.PrintTitle("šŸ¢ Organizations") for i, org := range orgs { - PrintInfo("Orgs n°", fmt.Sprintf("%d", i)) - PrintInfo("Login", org.GetLogin()) - PrintInfo("ID", fmt.Sprintf("%d", org.GetID())) - PrintInfo("Node ID", org.GetNodeID()) - PrintInfo("URL", org.GetURL()) - PrintInfo("Repos URL", org.GetReposURL()) - PrintInfo("Events URL", org.GetEventsURL()) - PrintInfo("Hooks URL", org.GetHooksURL()) - PrintInfo("Issues URL", org.GetIssuesURL()) - PrintInfo("Members URL", org.GetMembersURL()) - PrintInfo("Public Members URL", org.GetPublicMembersURL()) - PrintInfo("Avatar URL", org.GetAvatarURL()) - PrintInfo("Description", org.GetDescription()) - fmt.Println() + o := OrgResult{ + Login: org.GetLogin(), + ID: fmt.Sprintf("%d", org.GetID()), + URL: org.GetURL(), + Description: org.GetDescription(), + } + r.PrintInfo("Organization n°", fmt.Sprintf("%d", i)) + r.PrintInfo("Login", o.Login) + r.PrintInfo("ID", o.ID) + r.PrintInfo("URL", o.URL) + r.PrintInfo("Description", o.Description) + r.PrintNewline() + response = append(response, o) } } + r.PrintNewline() WaitForRateLimit(resp) + return } diff --git a/gh-recon/socials.go b/gh-recon/socials.go index 2638cbc..34a3ede 100644 --- a/gh-recon/socials.go +++ b/gh-recon/socials.go @@ -5,20 +5,19 @@ import ( "fmt" ) -func (r Recon) Socials(username string) { +type SocialResult struct { + Provider string `json:"provider"` + URL string `json:"url"` +} + +func (r Recon) Socials(username string) (response []SocialResult) { resp, err := FetchGitHubAPI(r.client, "", "/users/"+username+"/social_accounts") if err != nil { r.logger.Error("Failed to fetch socials", "err", err) return } - type SocialAccount struct { - Provider string `json:"provider"` - URL string `json:"url"` - } - type SocialAccounts []SocialAccount - - var socialAccounts SocialAccounts + var socialAccounts []SocialResult err = json.Unmarshal(resp, &socialAccounts) if err != nil { r.logger.Error("Failed to unmarshal socials", "err", err) @@ -26,15 +25,17 @@ func (r Recon) Socials(username string) { } if len(socialAccounts) == 0 { - PrintTitle("🐄 Socials") - PrintTitle("No Socials found\n") + r.PrintTitle("🐄 Socials") + r.PrintInfo("INFO", "No commits found") } else { - PrintTitle("🐄 Socials") + r.PrintTitle("🐄 Socials") for i, account := range socialAccounts { - PrintInfo("Social n°", fmt.Sprintf("%d", i)) - PrintInfo("Provider", account.Provider) - PrintInfo("URL", account.URL) - fmt.Println() + r.PrintInfo("Social n°", fmt.Sprintf("%d", i)) + r.PrintInfo("Provider", account.Provider) + r.PrintInfo("URL", account.URL) } } + r.PrintNewline() + + return socialAccounts } diff --git a/gh-recon/user.go b/gh-recon/user.go index 688bb2a..d592e84 100644 --- a/gh-recon/user.go +++ b/gh-recon/user.go @@ -4,7 +4,32 @@ import ( "fmt" ) -func (r Recon) User(username string) { +type UserResult struct { + Username string + ID string + AvatarURL string + GravatarID string + Name string + Company string + Location string + Email string + Hireable string + Bio string + PublicRepos string + PublicGists string + Followers string + Following string + CreatedAt string + UpdatedAt string + SuspendedAt string + TotalPrivateRepos string + PrivateGists string + DiskUsage string + Collaborators string + Plan string +} + +func (r Recon) User(username string) (response UserResult) { user, resp, err := r.client.Users.Get(r.ctx, username) if resp.StatusCode == 404 { r.logger.Fatal("User not found") @@ -13,33 +38,55 @@ func (r Recon) User(username string) { r.logger.Fatal("Failed to fetch user's information", "err", err) } - PrintTitle("šŸ‘¤ User informations") - PrintInfo("Username", user.GetLogin()) - PrintInfo("ID", fmt.Sprintf("%d", user.GetID())) - PrintInfo("Avatar URL", user.GetAvatarURL()) - PrintInfo("Gravatar ID", user.GetGravatarID()) - PrintInfo("Name", user.GetName()) - PrintInfo("Company", user.GetCompany()) - PrintInfo("Location", user.GetLocation()) - PrintInfo("Email", user.GetEmail()) - PrintInfo("Hireable", fmt.Sprintf("%t", user.GetHireable())) - PrintInfo("Bio", user.GetBio()) - PrintInfo("Public Repos", fmt.Sprintf("%d", user.GetPublicRepos())) - PrintInfo("Public Gists", fmt.Sprintf("%d", user.GetPublicGists())) - PrintInfo("Followers", fmt.Sprintf("%d", user.GetFollowers())) - PrintInfo("Following", fmt.Sprintf("%d", user.GetFollowing())) - PrintInfo("Created At", user.GetCreatedAt().String()) - PrintInfo("Updated At", user.GetUpdatedAt().String()) - PrintInfo("Suspended At", user.GetSuspendedAt().String()) - PrintInfo("Type", user.GetType()) - PrintInfo("Site Admin", fmt.Sprintf("%t", user.GetSiteAdmin())) - PrintInfo("Total Private Repos", fmt.Sprintf("%d", user.GetTotalPrivateRepos())) - PrintInfo("Owned Private Repos", fmt.Sprintf("%d", user.GetOwnedPrivateRepos())) - PrintInfo("Private Gists", fmt.Sprintf("%d", user.GetPrivateGists())) - PrintInfo("Disk Usage", fmt.Sprintf("%d", user.GetDiskUsage())) - PrintInfo("Collaborators", fmt.Sprintf("%d", user.GetCollaborators())) - PrintInfo("Plan", user.GetPlan().GetName()) - fmt.Println() + r.PrintTitle("šŸ‘¤ User informations") + u := UserResult{ + Username: user.GetLogin(), + ID: fmt.Sprintf("%d", user.GetID()), + AvatarURL: user.GetAvatarURL(), + GravatarID: user.GetGravatarID(), + Name: user.GetName(), + Company: user.GetCompany(), + Location: user.GetLocation(), + Email: user.GetEmail(), + Hireable: fmt.Sprintf("%t", user.GetHireable()), + Bio: user.GetBio(), + PublicRepos: fmt.Sprintf("%d", user.GetPublicRepos()), + PublicGists: fmt.Sprintf("%d", user.GetPublicGists()), + Followers: fmt.Sprintf("%d", user.GetFollowers()), + Following: fmt.Sprintf("%d", user.GetFollowing()), + CreatedAt: user.GetCreatedAt().String(), + UpdatedAt: user.GetUpdatedAt().String(), + SuspendedAt: user.GetSuspendedAt().String(), + TotalPrivateRepos: fmt.Sprintf("%d", user.GetTotalPrivateRepos()), + PrivateGists: fmt.Sprintf("%d", user.GetPrivateGists()), + DiskUsage: fmt.Sprintf("%d", user.GetDiskUsage()), + Collaborators: fmt.Sprintf("%d", user.GetCollaborators()), + Plan: user.GetPlan().GetName(), + } + r.PrintInfo("Username", u.Username) + r.PrintInfo("ID", u.ID) + r.PrintInfo("Avatar URL", u.AvatarURL) + r.PrintInfo("Gravatar ID", u.GravatarID) + r.PrintInfo("Name", u.Name) + r.PrintInfo("Company", u.Company) + r.PrintInfo("Location", u.Location) + r.PrintInfo("Email", u.Email) + r.PrintInfo("Hireable", u.Hireable) + r.PrintInfo("Bio", u.Bio) + r.PrintInfo("Public Repos", u.PublicRepos) + r.PrintInfo("Public Gists", u.PublicGists) + r.PrintInfo("Followers", u.Followers) + r.PrintInfo("Following", u.Following) + r.PrintInfo("Created At", u.CreatedAt) + r.PrintInfo("Updated At", u.UpdatedAt) + r.PrintInfo("Suspended At", u.SuspendedAt) + r.PrintInfo("Total Private Repos", u.TotalPrivateRepos) + r.PrintInfo("Private Gists", u.PrivateGists) + r.PrintInfo("Disk Usage", u.DiskUsage) + r.PrintInfo("Collaborators", u.Collaborators) + r.PrintInfo("Plan", u.Plan) + r.PrintNewline() WaitForRateLimit(resp) + return u } diff --git a/gh-recon/utils.go b/gh-recon/utils.go index 5f25532..e8864f8 100644 --- a/gh-recon/utils.go +++ b/gh-recon/utils.go @@ -1,9 +1,11 @@ package ghrecon import ( + "encoding/json" "fmt" "io" "net/http" + "os" "strings" "time" @@ -22,9 +24,14 @@ var ( RedStyle = lipgloss.NewStyle().Foreground(Red) ) -func Header() { +func (r Recon) Header() { + if r.silent { + return + } asciiArt := " __ \n ___ _/ / _______ _______ ___ \n / _ `/ _ \\/ __/ -_) __/ _ \\/ _ \\\n \\_, /_//_/_/ \\__/\\__/\\___/_//_/\n/___/ " - fmt.Println(GreyStyle.Render(lipgloss.JoinVertical(lipgloss.Right, asciiArt, "@anotherhadi\n"))) + fmt.Println( + GreyStyle.Render(lipgloss.JoinVertical(lipgloss.Right, asciiArt, "@anotherhadi\n")), + ) } func ParseUsername(username string) error { @@ -83,12 +90,25 @@ func FetchGitHubAPI(github *github.Client, token, path string) ([]byte, error) { return bodyBytes, nil } -func PrintTitle(title string) { +func (r Recon) PrintNewline() { + if r.silent { + return + } + fmt.Println() +} + +func (r Recon) PrintTitle(title string) { + if r.silent { + return + } style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7287fd")) fmt.Println(style.Render(title) + "\n") } -func PrintInfo(key, value string, more ...string) { +func (r Recon) PrintInfo(key, value string, more ...string) { + if r.silent { + return + } if value == "" || value == "0001-01-01 00:00:00 +0000 UTC" { return } @@ -111,3 +131,33 @@ func WaitForRateLimit(resp *github.Response) { time.Sleep(time.Until(resp.Rate.Reset.Time) + time.Second) } } + +func SkipResult(name, email string) bool { + if name == "github-actions[bot]" || name == "github-actions" { + return true + } + if email == "github-actions[bot]@users.noreply.github.com" || + email == "github-actions@github.com" { + return true + } + return false +} + +func (r Recon) WriteJson(data any) { + if r.jsonFile == "" { + return + } + file, err := os.Create(r.jsonFile) + if err != nil { + r.logger.Error("Failed to create JSON file", "err", err) + return + } + defer file.Close() + as_json, _ := json.MarshalIndent(data, "", "\t") + _, err = file.Write(as_json) + if err != nil { + r.logger.Error("Failed to write to JSON file", "err", err) + return + } + r.PrintInfo("INFO", "JSON file created successfully", "file", r.jsonFile) +} diff --git a/go.mod b/go.mod index 3dfc0b8..c09b70f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.1 github.com/google/go-github/v72 v72.0.0 + github.com/spf13/pflag v1.0.6 ) require ( diff --git a/go.sum b/go.sum index d31c4bc..d995850 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/main.go b/main.go index 5b56193..c3b9d0d 100644 --- a/main.go +++ b/main.go @@ -2,43 +2,50 @@ package main import ( "context" - "flag" - "fmt" "os" ghrecon "github.com/anotherhadi/gh-recon/gh-recon" "github.com/charmbracelet/log" "github.com/google/go-github/v72/github" + flag "github.com/spf13/pflag" ) func main() { var username string var token string - flag.StringVar(&username, "username", "", "Target username") - flag.StringVar(&token, "token", "", "Github token") + var onlyCommitsLeak bool + var fromEmail string + var deep bool + var silent bool + var jsonFile string + var excludeRepos string + flag.StringVarP(&username, "username", "u", "", "GitHub username to analyze") + flag.StringVarP(&token, "token", "t", "", "GitHub personal access token (e.g. ghp_...)") + 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( + &deep, + "deep", + "d", + false, + "Enable deep scan (clone repos, regex search, analyse licenses, etc.)", + ) + flag.BoolVarP(&silent, "silent", "s", false, "Suppress all non-essential output") + flag.StringVarP(&jsonFile, "json", "j", "", "Write results to specified JSON file") + flag.StringVar( + &excludeRepos, + "exclude-repo", + "", + "Exclude repos from deep scan (comma-separated list)", + ) flag.Parse() - if username == "" { - fmt.Println("Please provide a username with the --username flag") - os.Exit(1) - } - err := ghrecon.ParseUsername(username) - if err != nil { - log.Error("Invalid username", "err", err) - os.Exit(1) - } - - client := github.NewClient(nil) - if token == "" { - log.Info( - "It's recommended to set a Github token for better rate limits. You can set it using the --token flag.", - ) - } else { - client = client.WithAuthToken(token) - } - - ctx := context.Background() - styles := log.DefaultStyles() styles.Levels[log.InfoLevel] = styles.Levels[log.InfoLevel].Foreground(ghrecon.Grey) logger := log.NewWithOptions(os.Stderr, log.Options{ @@ -47,18 +54,85 @@ func main() { }) logger.SetStyles(styles) + if username == "" && fromEmail == "" { + logger.Error( + "Please provide a username with the --username (-u) flag or an email with the --email (-e) flag", + ) + os.Exit(1) + } else if username != "" { + if err := ghrecon.ParseUsername(username); err != nil { + logger.Error("Invalid username", "err", err) + os.Exit(1) + } + } + + client := github.NewClient(nil) + if token == "" { + if !silent { + logger.Info( + "It's recommended to set a Github token for better rate limits. You can set it using the --token (-t) flag.", + ) + } + } else { + client = client.WithAuthToken(token) + } + + ctx := context.Background() + r := ghrecon.NewRecon( client, logger, ctx, + silent, + jsonFile, ) - ghrecon.Header() - r.User(username) - r.Orgs(username) - r.SshKeys(username) - r.GpgKeys(username) - r.SshSigningKeys(username) - r.Socials(username) - r.Commits(username) + r.Header() + + + if fromEmail != "" { + emailsInfo := r.Email(fromEmail) + r.WriteJson( + map[string]any{ + "Authors": emailsInfo, + }, + ) + return + } + + if onlyCommitsLeak { + commitsInfo := r.Commits(username) + r.WriteJson( + map[string]any{ + "Authors": commitsInfo, + }, + ) + return + } + + userInfo := r.User(username) + orgsInfo := r.Orgs(username) + sshKeysInfo := r.SshKeys(username) + gpgKeysInfo := r.GpgKeys(username) + sshSigningKeysInfo := r.SshSigningKeys(username) + socialsInfo := r.Socials(username) + closeFriendsInfo := r.CloseFriends(username) + commitsInfo := r.Commits(username) + + results := map[string]any{ + "User": userInfo, + "Orgs": orgsInfo, + "SSHKeys": sshKeysInfo, + "GPGKeys": gpgKeysInfo, + "SSHSigningKeys": sshSigningKeysInfo, + "Socials": socialsInfo, + "Commits": commitsInfo, + "CloseFriends": closeFriendsInfo, + } + + if deep { + results["Deep"] = r.Deep(username, excludeRepos) + } + + r.WriteJson(results) }