From af4046d23443fa699d6db53d8c04d8fc425407fc Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:58:54 +0200 Subject: [PATCH] Init v2, rewrite --- .github/assets/banner.png | Bin 0 -> 23804 bytes .github/assets/logo.png | Bin 0 -> 9529 bytes .gitignore | 1 + CONTRIBUTING.md | 14 ++ LICENSE | 21 ++ README.md | 164 +++++++++++++ cmd/avatar.go | 41 ++++ cmd/email.go | 26 +++ cmd/json.go | 31 +++ cmd/main.go | 32 +++ cmd/print.go | 103 +++++++++ cmd/username.go | 75 ++++++ flake.lock | 27 +++ flake.nix | 46 ++++ github-recon.go | 1 + github-recon/email/commits.go | 91 ++++++++ github-recon/email/spoofing.go | 1 + github-recon/username/close-friends.go | 120 ++++++++++ github-recon/username/commits.go | 85 +++++++ github-recon/username/deep.go | 304 +++++++++++++++++++++++++ github-recon/username/keys.go | 135 +++++++++++ github-recon/username/orgs.go | 35 +++ github-recon/username/socials.go | 45 ++++ github-recon/username/user.go | 69 ++++++ go.mod | 31 +++ go.sum | 58 +++++ settings/settings.go | 148 ++++++++++++ settings/utils.go | 39 ++++ utils/utils.go | 121 ++++++++++ 29 files changed, 1864 insertions(+) create mode 100644 .github/assets/banner.png create mode 100644 .github/assets/logo.png create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/avatar.go create mode 100644 cmd/email.go create mode 100644 cmd/json.go create mode 100644 cmd/main.go create mode 100644 cmd/print.go create mode 100644 cmd/username.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 github-recon.go create mode 100644 github-recon/email/commits.go create mode 100644 github-recon/email/spoofing.go create mode 100644 github-recon/username/close-friends.go create mode 100644 github-recon/username/commits.go create mode 100644 github-recon/username/deep.go create mode 100644 github-recon/username/keys.go create mode 100644 github-recon/username/orgs.go create mode 100644 github-recon/username/socials.go create mode 100644 github-recon/username/user.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 settings/settings.go create mode 100644 settings/utils.go create mode 100644 utils/utils.go diff --git a/.github/assets/banner.png b/.github/assets/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..f07ff0967491888e91eaa95ed3dc4e4ff624a4d0 GIT binary patch literal 23804 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 hqou!lvNABJobz;X45?szJ2!q!%+=Ck-}kaz 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 gF*nb|^Ze(JVRPh*(};> + + + +
+ +# Github-Recon πŸ” + +

+ Latest Release + GoDoc + GoReportCard +

+ +- [🧾 Project Overview](#-project-overview) +- [πŸš€ Features](#-features) +- [⚠️ Disclaimer](#-disclaimer) +- [πŸ“¦ Installation](#-installation) + - [With Go](#with-go) + - [With Nix/NixOS](#with-nixnixos) +- [πŸ§ͺ Usage](#-usage) + - [Flags](#flags) +- [πŸ’‘ Examples](#-examples) +- [πŸ•΅οΈβ€β™‚οΈ Cover your tracks](#-cover-your-tracks) +- [🀝 Contributing](#-contributing) +- [πŸ™ Credits](#-credits) + +## 🧾 Project Overview + +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 + +- Export results to JSON + +**From usernames:** + +- Retrieve basic user profile information (username, ID, avatar, bio, creation + dates) +- Display the avatar in the terminal +- List organizations and roles +- Fetch SSH and GPG keys +- Enumerate social accounts +- Extract unique commit authors (name + email) +- Find close friends +- Deep scan option (clone repositories, regex search, analyze licenses, etc.) +- Levenshtein distance for matching usernames and emails + +**From emails:** + +- Search for a specific email through all Github commits +- Spoof an email to found an user account + +## ⚠️ Disclaimer + +This tool is intended for educational purposes only. Use responsibly and ensure +you have permission to access the data you are querying. + +## πŸ“¦ Installation + +### With Go + +```bash +go install github.com/anotherhadi/github-recon@latest +``` + +### With Nix/NixOS + +
+Click to expand + +**From anywhere (using the repo URL):** + +```bash +nix run github:anotherhadi/github-recon -- [--flags value] target_username_or_email +``` + +**Permanent Installation:** + +```bash +# add the flake to your flake.nix +{ + inputs = { + github-recon.url = "github:anotherhadi/github-recon"; + }; +} + +# then add it to your packages +environment.systemPackages = with pkgs; [ # or home.packages + github-recon +]; +``` + +
+ +## πŸ§ͺ Usage + +```bash +github-recon [--flags value] target_username_or_email +``` + +### Flags + +```txt +-t, --token string Github personal access token (e.g. ghp_aaa...). Can also be set via GITHUB_RECON_TOKEN environment variable. You also need to set the token in $HOME/.config/github-recon/env file if you want to use this tool without passing the token every time. (default "null") +-d, --deepscan Enable deep scan (clone repos, regex search, analyse licenses, etc.) + --max-size int Limit the size of repositories to scan (in MB) (only for deep scan) (default 150) +-e, --exclude-repo strings Exclude repos from deep scan (comma-separated list, only for deep scan) +-r, --refresh Refresh the cache (only for deep scan) +-s, --show-source Show where the information (authors, emails, etc) were found (only for deep scan) +-m, --max-distance int Maximum Levenshtein distance for matching usernames & emails (only for deep scan) (default 20) +-S, --silent Suppress all non-essential output +-h, --hide-avatar Hide the avatar in the output +-j, --json string Write results to specified JSON file +``` + +## πŸ’‘ Examples + +```bash +github-recon anotherhadi --token ghp_ABC123... +github-recon myemail@gmail.com # Find github accounts by email +github-recon anotherhadi --json output.json --deepscan # Clone the repo and search for leaked email +``` + +## πŸ•΅οΈβ€β™‚οΈ Cover your tracks + +Understanding what information about you is publicly visible is the first step +to managing your online presence. github-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: + +- **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. + +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. + +## πŸ™ Credits + +Some features and ideas in this project were inspired by the following tools: + +- [gitrecon](https://github.com/GONZOsint/gitrecon) by GONZOsint +- [gitfive](https://github.com/mxrch/gitfive) by mxrch + +Big thanks to their authors for sharing their work with the community. diff --git a/cmd/avatar.go b/cmd/avatar.go new file mode 100644 index 0000000..69eb4c6 --- /dev/null +++ b/cmd/avatar.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + gopixels "github.com/saran13raj/go-pixels" +) + +func printAvatar(settings github_recon_settings.Settings, url string) { + if settings.HideAvatar || url == "" || settings.Silent { + return + } + + resp, err := http.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + + tmpfile, err := os.CreateTemp("", "avatar-*.png") + if err != nil { + return + } + defer os.Remove(tmpfile.Name()) + + _, err = io.Copy(tmpfile, resp.Body) + if err != nil { + return + } + + output, err := gopixels.FromImagePath(tmpfile.Name(), 30, 25, "halfcell", true) + + if err != nil { + return + } + fmt.Println(output + "\n") +} diff --git a/cmd/email.go b/cmd/email.go new file mode 100644 index 0000000..3bebc1d --- /dev/null +++ b/cmd/email.go @@ -0,0 +1,26 @@ +package main + +import ( + recon "github.com/anotherhadi/github-recon/github-recon/email" + github_recon_settings "github.com/anotherhadi/github-recon/settings" +) + +type EmailResult struct { + DateTime string + Target string + TargetType github_recon_settings.TargetType + + Commit recon.EmailsResult +} + +func email(settings github_recon_settings.Settings, datetime string) { + result := EmailResult{ + Target: settings.Target, + TargetType: settings.TargetType, + DateTime: datetime, + } + + printTitle(settings.Silent, "πŸ‘€ Commits author") + result.Commit = recon.Email(settings) + printStruct(settings, result.Commit, 0) +} diff --git a/cmd/json.go b/cmd/json.go new file mode 100644 index 0000000..0875da1 --- /dev/null +++ b/cmd/json.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "os" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" +) + +func writeJson(s github_recon_settings.Settings, data any) { + if s.JsonOutput == "" { + return + } + file, err := os.Create(s.JsonOutput) + if err != nil { + s.Logger.Error("Failed to create JSON file", "err", err) + return + } + + defer func() { + _ = file.Close() + }() + + as_json, _ := json.MarshalIndent(data, "", "\t") + _, err = file.Write(as_json) + if err != nil { + s.Logger.Error("Failed to write to JSON file", "err", err) + return + } + s.Logger.Info("JSON output written to file", "file", s.JsonOutput) +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..ff1caa8 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "time" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" +) + +func main() { + settings := github_recon_settings.GetSettings() + datetime := time.Now().String() + + if !settings.Silent { + header() + + printStruct(settings, struct { + Target string + TargetType string + DateTime string + }{ + Target: settings.Target, + TargetType: string(settings.TargetType), + DateTime: datetime, + }, 0) + } + + if settings.TargetType == github_recon_settings.TargetUsername { + username(settings, datetime) + } else { + email(settings, datetime) + } +} diff --git a/cmd/print.go b/cmd/print.go new file mode 100644 index 0000000..f2ccd10 --- /dev/null +++ b/cmd/print.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "reflect" + "strings" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/charmbracelet/lipgloss" +) + +var ( + grey = lipgloss.Color("#7d7d7d") + green = lipgloss.Color("#a6e3a1") + blue = lipgloss.Color("#7287fd") + + greyStyle = lipgloss.NewStyle().Foreground(grey) + greenStyle = lipgloss.NewStyle().Foreground(green) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(blue) +) + +func printStruct(settings github_recon_settings.Settings, s any, indent int) { + if settings.Silent { + return + } + + prefix := strings.Repeat(" ", indent) + + v := reflect.ValueOf(s) + t := reflect.TypeOf(s) + + if v.Kind() == reflect.Ptr { + v = v.Elem() + t = t.Elem() + } + + switch v.Kind() { + case reflect.Struct: + if v.NumField() == 0 { + fmt.Println(prefix + greyStyle.Render("No data found")) + fmt.Println("") + return + } + + for i := 0; i < v.NumField(); i++ { + field := t.Field(i).Name + value := v.Field(i) + + if !value.IsValid() || (value.Kind() == reflect.String && value.String() == "") { + continue + } + if value.Kind() == reflect.String && value.String() == "0001-01-01 00:00:00 +0000 UTC" { + continue + } + if (field == "FirstFoundIn" || field == "FoundIn") && !settings.ShowSource { + continue + } + + switch value.Kind() { + case reflect.Struct, reflect.Slice, reflect.Array, reflect.Ptr: + fmt.Println(prefix + greyStyle.Render(field+":")) + printStruct(settings, value.Interface(), indent+1) + case reflect.String: + fmt.Printf("%s%s %s\n", prefix, greyStyle.Render(field+":"), greenStyle.Render(fmt.Sprintf("%q", value.Interface()))) + default: + fmt.Printf("%s%s %s\n", prefix, greyStyle.Render(field+":"), greenStyle.Render(fmt.Sprintf("%v", value.Interface()))) + } + } + fmt.Println("") + + case reflect.Slice, reflect.Array: + if v.Len() == 0 { + fmt.Println(prefix + greyStyle.Render("No data found")) + fmt.Println("") + return + } + for i := 0; i < v.Len(); i++ { + printStruct(settings, v.Index(i).Interface(), indent) + } + + default: + fmt.Println(prefix + greenStyle.Render(fmt.Sprintf("%v", v.Interface()))) + fmt.Println("") + } +} + +func header() { + asciiArt := " __ \n ___ _/ / _______ _______ ___ \n / _ `/ _ \\/ __/ -_) __/ _ \\/ _ \\\n \\_, /_//_/_/ \\__/\\__/\\___/_//_/\n/___/ " + + grey := lipgloss.Color("#7d7d7d") + + greyStyle := lipgloss.NewStyle().Foreground(grey) + fmt.Println( + greyStyle.Render(lipgloss.JoinVertical(lipgloss.Right, asciiArt, "@anotherhadi\n")), + ) +} + +func printTitle(silent bool, title string) { + if silent { + return + } + fmt.Println(titleStyle.Render(title) + "\n") +} diff --git a/cmd/username.go b/cmd/username.go new file mode 100644 index 0000000..013c29d --- /dev/null +++ b/cmd/username.go @@ -0,0 +1,75 @@ +package main + +import ( + recon "github.com/anotherhadi/github-recon/github-recon/username" + github_recon_settings "github.com/anotherhadi/github-recon/settings" +) + +type UsernameResult struct { + DateTime string // Now + Target string + TargetType github_recon_settings.TargetType + + User recon.UserResult + Socials recon.SocialsResult + Orgs recon.OrgsResult + + SshKeys recon.SshKeysResult + SshSigningKeys recon.SshSigningKeysResult + GpgKeys recon.GpgKeysResult + + CloseFriends recon.CloseFriendsResult + + Commits recon.CommitsResult + + DeepScan recon.DeepScanResult +} + +func username(settings github_recon_settings.Settings, datetime string) { + result := UsernameResult{ + Target: settings.Target, + TargetType: settings.TargetType, + DateTime: datetime, + } + + printTitle(settings.Silent, "πŸ‘€ User informations") + result.User = recon.User(settings) + printAvatar(settings, result.User.AvatarURL) + printStruct(settings, result.User, 0) + + printTitle(settings.Silent, "πŸ₯ Socials") + result.Socials = recon.Socials(settings) + printStruct(settings, result.Socials, 0) + + printTitle(settings.Silent, "🏒 Organizations") + result.Orgs = recon.Orgs(settings) + printStruct(settings, result.Orgs, 0) + + printTitle(settings.Silent, "πŸ”‘ SSH Keys") + result.SshKeys = recon.SshKeys(settings) + printStruct(settings, result.SshKeys, 0) + + printTitle(settings.Silent, "πŸ–‹οΈ SSH Signing Keys") + result.SshSigningKeys = recon.SshSigningKeys(settings) + printStruct(settings, result.SshSigningKeys, 0) + + printTitle(settings.Silent, "πŸ” GPG Keys") + result.GpgKeys = recon.GpgKeys(settings) + printStruct(settings, result.GpgKeys, 0) + + printTitle(settings.Silent, "🀝 Close Friends") + result.CloseFriends = recon.CloseFriends(settings) + printStruct(settings, result.CloseFriends, 0) + + printTitle(settings.Silent, "πŸ“ Commits") + result.Commits = recon.Commits(settings) + printStruct(settings, result.Commits, 0) + + if settings.DeepScan { + printTitle(settings.Silent, "πŸ” Deep Scan") + result.DeepScan = recon.DeepScan(settings) + printStruct(settings, result.DeepScan, 0) + } + + writeJson(settings, result) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f418c37 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1755615617, + "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20075955deac2583bb12f07151c2df830ef346b4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..72901df --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + description = "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."; + + inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";}; + + outputs = { + self, + nixpkgs, + }: let + supportedSystems = ["x86_64-linux" "aarch64-linux"]; + + forAllSystems = f: + nixpkgs.lib.genAttrs supportedSystems + (system: f system (import nixpkgs {inherit system;})); + + pname = "github-recon"; + version = "2.0.0"; + + ldflags = ["-s" "-w"]; + in { + packages = forAllSystems (system: pkgs: { + "${pname}" = pkgs.buildGoModule { + inherit pname version ldflags; + + src = ./.; + subPackages = ["cmd"]; + outputs = ["out"]; + installPhase = '' + mkdir -p $out/bin + cp $GOPATH/bin/cmd $out/bin/github-recon + ''; + + vendorHash = "sha256-AD0h0k2n8gPqSBz5qqb0ZON/jWiSEWpeO97xR7cYSy8="; + + meta = with pkgs.lib; { + description = "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."; + homepage = "https://github.com/anotherhadi/github-recon"; + platforms = platforms.unix; + }; + }; + }); + + defaultPackage = + forAllSystems (system: pkgs: self.packages.${system}.${pname}); + }; +} diff --git a/github-recon.go b/github-recon.go new file mode 100644 index 0000000..02faf2e --- /dev/null +++ b/github-recon.go @@ -0,0 +1 @@ +package github_recon diff --git a/github-recon/email/commits.go b/github-recon/email/commits.go new file mode 100644 index 0000000..e580954 --- /dev/null +++ b/github-recon/email/commits.go @@ -0,0 +1,91 @@ +package recon + +import ( + "fmt" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" + "github.com/google/go-github/v72/github" +) + +type EmailsResult []EmailResult + +type EmailResult struct { + Name string + Email string + Username string + Occurrences int + FirstFoundIn string +} + +func Email(s github_recon_settings.Settings) (response EmailsResult) { + results := make(map[string]EmailResult) + + collect := func(date string) error { + for page := 1; page <= 10; page++ { + result, resp, err := s.Client.Search.Commits( + s.Ctx, + fmt.Sprintf("author-email:%s author-date:%s", s.Target, 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) + } + utils.WaitForRateLimit(s, 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 utils.SkipResult(name, email) { + continue + } + if _, seen := results[name+" - "+email+" - "+login]; !seen { + author := EmailResult{ + Name: name, + Email: email, + Username: login, + Occurrences: 1, + FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository(). + GetName(), + } + results[name+" - "+email+" - "+login] = author + } else { + result := results[name+" - "+email+" - "+login] + result.Occurrences++ + 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 { + s.Logger.Error("Failed to fetch commits", "err", err, "date", date) + } + } + + for _, result := range results { + response = append(response, result) + } + + return +} diff --git a/github-recon/email/spoofing.go b/github-recon/email/spoofing.go new file mode 100644 index 0000000..5c98f35 --- /dev/null +++ b/github-recon/email/spoofing.go @@ -0,0 +1 @@ +package recon diff --git a/github-recon/username/close-friends.go b/github-recon/username/close-friends.go new file mode 100644 index 0000000..606bf07 --- /dev/null +++ b/github-recon/username/close-friends.go @@ -0,0 +1,120 @@ +package recon + +import ( + "fmt" + "sort" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" +) + +type CloseFriendsResult []CloseFriendResult + +type CloseFriendResult struct { + Login string + Score int +} + +const ( + maxFollowingForTarget = 50 + maxFollowersForFollowing = 20 + pointPerCriterion = 1 +) + +// 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 (+1 point) +// 3. The target's following follows the target (+1 point) + +func CloseFriends(s github_recon_settings.Settings) (response CloseFriendsResult) { + following, resp, err := s.Client.Users.ListFollowing(s.Ctx, s.Target, nil) + if err != nil { + s.Logger.Error("Failed to fetch user's following list", "err", err) + return + } + + utils.WaitForRateLimit(s, resp) + + if len(following) >= maxFollowingForTarget { + s.Logger.Info("Skipping close friends check", "reason", fmt.Sprintf("Target follows %d or more users (%d)", maxFollowingForTarget, len(following))) + return + } + + if len(following) == 0 { + return + } + + for _, userBeingFollowedByTarget := range following { + loginName := userBeingFollowedByTarget.GetLogin() + if loginName == "" { + continue + } + + currentScore := 0 + + userDetails, userResp, userErr := s.Client.Users.Get(s.Ctx, loginName) + if userErr != nil { + s.Logger.Warn( + "Failed to fetch details for followed user", + "followed_user", + loginName, + "err", + userErr, + ) + if userResp != nil { + utils.WaitForRateLimit(s, userResp) + } + continue + } + utils.WaitForRateLimit(s, userResp) + + if userDetails.GetFollowers() < maxFollowersForFollowing { + currentScore += pointPerCriterion + } + + followsTargetBack, checkErr := checkIfUserFollows(s, loginName, s.Target) + if checkErr != nil { + continue + } else if followsTargetBack { + currentScore += pointPerCriterion + } + + if currentScore > 0 { + response = append(response, CloseFriendResult{ + Login: loginName, + Score: currentScore, + }) + } + } + + if len(response) == 0 { + return + } else { + sort.Slice(response, func(i, j int) bool { + return response[i].Score > response[j].Score + }) + } + + return +} + +// checkIfUserFollows checks if sourceUserLogin follows targetUserLogin. +func checkIfUserFollows(s github_recon_settings.Settings, sourceUserLogin, targetUserLogin string) (bool, error) { + isFollowing, resp, err := s.Client.Users.IsFollowing(s.Ctx, sourceUserLogin, targetUserLogin) + if err != nil { + s.Logger.Warn("Error checking if user follows target", + "source_user_checking", sourceUserLogin, + "target_user_to_check", targetUserLogin, + "err", err) + if resp != nil { + utils.WaitForRateLimit(s, resp) + } + return false, err + } + + if resp != nil { + utils.WaitForRateLimit(s, resp) + } + return isFollowing, nil +} diff --git a/github-recon/username/commits.go b/github-recon/username/commits.go new file mode 100644 index 0000000..f085dd0 --- /dev/null +++ b/github-recon/username/commits.go @@ -0,0 +1,85 @@ +package recon + +import ( + "fmt" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" + "github.com/google/go-github/v72/github" +) + +type CommitsResult []CommitResult + +type CommitResult struct { + Name string + Email string + Occurrences int + FirstFoundIn string +} + +func Commits(s github_recon_settings.Settings) (response CommitsResult) { + results := make(map[string]CommitResult) + + collect := func(date string) error { + for page := 1; page <= 10; page++ { + result, resp, err := s.Client.Search.Commits( + s.Ctx, + fmt.Sprintf("author:%s author-date:%s", s.Target, 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) + } + utils.WaitForRateLimit(s, resp) + if len(result.Commits) == 0 { + break + } + for _, item := range result.Commits { + name := item.Commit.GetAuthor().GetName() + email := item.Commit.GetAuthor().GetEmail() + if utils.SkipResult(name, email) { + continue + } + if _, seen := results[name+" - "+email]; !seen { + author := CommitResult{ + Name: name, + Email: email, + Occurrences: 1, + FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository(). + GetName(), + } + results[name+" - "+email] = author + } else { + result := results[name+" - "+email] + result.Occurrences++ + results[name+" - "+email] = 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 { + s.Logger.Error("Failed to fetch commits", "err", err, "date", date) + } + } + + for _, result := range results { + response = append(response, result) + } + + return +} diff --git a/github-recon/username/deep.go b/github-recon/username/deep.go new file mode 100644 index 0000000..554388b --- /dev/null +++ b/github-recon/username/deep.go @@ -0,0 +1,304 @@ +package recon + +import ( + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" + "github.com/google/go-github/v72/github" +) + +type Authors []AuthorOccurrence + +type AuthorOccurrence struct { + Name string + Levenshtein int + Email string + FoundIn []string +} + +type Emails []EmailOccurrence + +type EmailOccurrence struct { + Email string + Levenshtein int + FoundIn []string +} + +type DeepScanResult struct { + Authors []AuthorOccurrence + Emails []EmailOccurrence +} + +type Repositorie struct { + Repository string + Owner string + Name string + Size int +} + +func findEmailsAndOccurrencesInDir(rootPath string, username string) (Emails, 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 { + return err + } + if !d.IsDir() { + if strings.Contains(path, ".git/logs/") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + + currentFileEmails := emailRegex.FindAllString(string(content), -1) + if len(currentFileEmails) > 0 { + relativePath, errRel := filepath.Rel(normalizedRootPath, path) + if errRel != nil { + 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, + Levenshtein: utils.LevenshteinDistance(username, strings.SplitN(email, "@", 2)[0]), + }) + } + + return results, nil +} + +func DeepScan(s github_recon_settings.Settings) (response DeepScanResult) { + repositories := []Repositorie{} + repos, resp, err := s.Client.Repositories.ListByUser( + s.Ctx, + s.Target, + &github.RepositoryListByUserOptions{ + Type: "all", + }, + ) + + if err != nil { + s.Logger.Error("Failed to fetch repositories", "err", err) + return + } + + // r.PrintTitle("πŸ“¦ Repositories") + + for _, repo := range repos { + if slices.Contains(s.ExcludedRepos, repo.GetName()) || + slices.Contains(s.ExcludedRepos, repo.GetOwner().GetLogin()+"/"+repo.GetName()) { + continue + } + + maxRepoSize := s.MaxRepoSize * 1024 + + if repo.GetSize() > maxRepoSize { + s.Logger.Info("Skipping repository due to size", "repo", repo.GetOwner().GetLogin()+"/"+repo.GetName(), "size_MB", repo.GetSize()/1024, "max_size_MB", maxRepoSize/1024) + continue + } + + repositories = append(repositories, Repositorie{ + Repository: repo.GetCloneURL(), + Owner: repo.GetOwner().GetLogin(), + Name: repo.GetName(), + Size: repo.GetSize(), + }) + } + utils.WaitForRateLimit(s, resp) + + cmd := exec.Command("git", "--version") + if err := cmd.Run(); err != nil { + s.Logger.Error("Git is not installed", "err", err) + return + } + + tmp_folder := "/tmp/ghrecon-" + s.Target + + if utils.DoesFolderExists(tmp_folder) { + if s.Refresh { + s.Logger.Info("Deleting existing folder", "path", tmp_folder) + err := os.RemoveAll(tmp_folder) + if err != nil { + s.Logger.Error("Failed to delete existing folder", "path", tmp_folder, "err", err) + return + } + } + } + + for _, repo := range repositories { + destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name + if utils.DoesFolderExists(destination) { + s.Logger.Info("Directory already downloaded, skipping", "repo", repo.Owner+"/"+repo.Name, "path", destination) + continue + } + + s.Logger.Info("Cloning repository", "repo", repo.Owner+"/"+repo.Name, "path", destination, "size_MB", repo.Size/1024) + + cmd := exec.Command( + "git", + "clone", + repo.Repository, + destination, + ) + err := cmd.Run() + if err != nil { + s.Logger.Error( + "ERROR", + "Failed to clone repository", + "err", + err, + "repo", + repo.Repository, + ) + continue + } + } + s.Logger.Info("Cloned all repositories", "path", tmp_folder) + + authorOccurrences := []AuthorOccurrence{} + mapAuthorToIndex := make(map[string]int) + for _, repo := range repositories { + destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name + if !utils.DoesFolderExists(filepath.Join(destination, ".git")) { + s.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 { + s.Logger.Error("Failed to execute git log (ExitError)", "repo", repo.Owner+"/"+repo.Name, "stderr", string(exitErr.Stderr), "err", logErr) + } else { + s.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 { + s.Logger.Error("Malformed author line from git log", "line", trimmedLine, "repo", repoIdentifier) + continue + } + + authorOccurrences = append(authorOccurrences, AuthorOccurrence{ + Name: authorName, + Email: authorEmail, + FoundIn: []string{repoIdentifier}, + Levenshtein: utils.LevenshteinDistance(s.Target, authorName), + }) + mapAuthorToIndex[trimmedLine] = len(authorOccurrences) - 1 + } + } + } + } + } + slices.SortFunc(authorOccurrences, func(a, b AuthorOccurrence) int { + if a.Levenshtein != b.Levenshtein { + return a.Levenshtein - b.Levenshtein + } + return 1 + }) + + authors := []AuthorOccurrence{} + for _, author := range authorOccurrences { + if author.Levenshtein > s.MaxDistance { + continue + } + if utils.SkipResult(author.Name, author.Email) { + continue + } + authors = append(authors, author) + } + + s.Logger.Info("Searching for emails in cloned repositories", "path", tmp_folder) + emailsFound, err := findEmailsAndOccurrencesInDir(tmp_folder, s.Target) + if err != nil { + s.Logger.Error("Failed to find emails in directory", "err", err) + return + } + slices.SortFunc(emailsFound, func(a, b EmailOccurrence) int { + if a.Levenshtein != b.Levenshtein { + return a.Levenshtein - b.Levenshtein + } + return 1 + }) + + emails := []EmailOccurrence{} + for _, email := range emailsFound { + if email.Levenshtein > s.MaxDistance { + continue + } + emails = append(emails, email) + } + + response.Authors = authors + response.Emails = emails + + return +} diff --git a/github-recon/username/keys.go b/github-recon/username/keys.go new file mode 100644 index 0000000..86ad23a --- /dev/null +++ b/github-recon/username/keys.go @@ -0,0 +1,135 @@ +package recon + +import ( + "fmt" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" +) + +type SshKeysResult []SshKeyResult + +type SshKeyResult struct { + Url string + Title string + CreatedAt string + Key string + ReadOnly string + Verified string + LastUsed string + AddedBy string +} + +func SshKeys(s github_recon_settings.Settings) (response SshKeysResult) { + sshKeys, resp, err := s.Client.Users.ListKeys(s.Ctx, s.Target, nil) + if err != nil { + s.Logger.Error("Failed to fetch ssh keys", "err", err) + return + } + + for _, key := range sshKeys { + k := SshKeyResult{ + 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) + } + + utils.WaitForRateLimit(s, resp) + return +} + +type GpgKeyEmail struct { + Email string + Verified string +} + +type GpgKeysResult []GpgKeyResult + +type GpgKeyResult struct { + KeyID string + PublicKey string + CreatedAt string + PrimaryKeyID string + RawKey string + Emails []GpgKeyEmail + Subkeys []GpgKeyResult +} + +func GpgKeys(s github_recon_settings.Settings) (response GpgKeysResult) { + gpgKeys, resp, err := s.Client.Users.ListGPGKeys(s.Ctx, s.Target, nil) + if err != nil { + s.Logger.Error("Failed to fetch user's gpg keys", "err", err) + return + } + + for _, key := range gpgKeys { + k := GpgKeyResult{ + KeyID: key.GetKeyID(), + PublicKey: key.GetPublicKey(), + CreatedAt: key.GetCreatedAt().String(), + PrimaryKeyID: fmt.Sprintf("%d", key.GetPrimaryKeyID()), + RawKey: key.GetRawKey(), + Emails: []GpgKeyEmail{}, + Subkeys: []GpgKeyResult{}, + } + for _, email := range key.Emails { + email := GpgKeyEmail{ + Email: email.GetEmail(), + Verified: fmt.Sprintf("%t", email.GetVerified()), + } + k.Emails = append(k.Emails, email) + } + for _, subkey := range key.Subkeys { + subkey := GpgKeyResult{ + 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) + } + utils.WaitForRateLimit(s, resp) + return +} + +type SshSigningKeysResult []SshSigningKeyResult + +type SshSigningKeyResult struct { + Title string + CreatedAt string + Key string +} + +func SshSigningKeys(s github_recon_settings.Settings) (response SshSigningKeysResult) { + signingKeys, resp, err := s.Client.Users.ListSSHSigningKeys( + s.Ctx, + s.Target, + nil, + ) + if err != nil { + s.Logger.Error("Failed to fetch user's ssh signing keys", "err", err) + return + } + + for _, key := range signingKeys { + k := SshSigningKeyResult{ + Title: key.GetTitle(), + CreatedAt: key.GetCreatedAt().String(), + Key: key.GetKey(), + } + response = append(response, k) + } + + utils.WaitForRateLimit(s, resp) + return +} diff --git a/github-recon/username/orgs.go b/github-recon/username/orgs.go new file mode 100644 index 0000000..b666ca8 --- /dev/null +++ b/github-recon/username/orgs.go @@ -0,0 +1,35 @@ +package recon + +import ( + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" +) + +type OrgsResult []OrgResult + +type OrgResult struct { + Name string + URL string + Description string +} + +func Orgs(s github_recon_settings.Settings) (response OrgsResult) { + orgs, resp, err := s.Client.Organizations.List(s.Ctx, s.Target, nil) + if err != nil { + s.Logger.Error("Failed to fetch organizations", "err", err) + return + } + + for _, org := range orgs { + o := OrgResult{ + Name: org.GetLogin(), + URL: org.GetURL(), + Description: org.GetDescription(), + } + response = append(response, o) + } + + utils.WaitForRateLimit(s, resp) + + return +} diff --git a/github-recon/username/socials.go b/github-recon/username/socials.go new file mode 100644 index 0000000..22ace0a --- /dev/null +++ b/github-recon/username/socials.go @@ -0,0 +1,45 @@ +package recon + +import ( + "encoding/json" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" +) + +type socialResultInput struct { + Provider string `json:"provider"` + URL string `json:"url"` +} + +type SocialsResult []socialResult + +type socialResult struct { + Provider string + URL string +} + +func Socials(s github_recon_settings.Settings) (response SocialsResult) { + resp, err := utils.FetchGitHubAPI(s.Client, "", "/users/"+s.Target+"/social_accounts") + if err != nil { + s.Logger.Error("Failed to fetch socials", "err", err) + return + } + + var socialAccounts []socialResultInput + err = json.Unmarshal(resp, &socialAccounts) + if err != nil { + s.Logger.Error("Failed to unmarshal socials", "err", err) + return + } + + socials := []socialResult{} + for _, account := range socialAccounts { + socials = append(socials, socialResult{ + URL: account.URL, + Provider: account.Provider, + }) + } + + return socials +} diff --git a/github-recon/username/user.go b/github-recon/username/user.go new file mode 100644 index 0000000..6335a2e --- /dev/null +++ b/github-recon/username/user.go @@ -0,0 +1,69 @@ +package recon + +import ( + "fmt" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/anotherhadi/github-recon/utils" +) + +type UserResult struct { + Username 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 User(s github_recon_settings.Settings) (response UserResult) { + user, resp, err := s.Client.Users.Get(s.Ctx, s.Target) + if resp.StatusCode == 404 { + s.Logger.Fatal("User not found with username") + } + if err != nil { + s.Logger.Fatal("Failed to fetch user's information", "err", err) + } + + u := UserResult{ + Username: user.GetLogin(), + 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(), + } + + utils.WaitForRateLimit(s, resp) + return u +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f65d5ee --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/anotherhadi/github-recon + +go 1.24.5 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/log v0.4.2 + github.com/google/go-github/v72 v72.0.0 + github.com/joho/godotenv v1.5.1 + github.com/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51 + github.com/spf13/pflag v1.0.7 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bf54ec --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= +github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51 h1:H/XUfYcLxI3CBmDlgBpnOeTntRgqWvIoUXnqhCF5a0s= +github.com/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51/go.mod h1:sqhdZVLvqzTEBtmZBuTnFDUW0Lsryw2X2/wrLgqLEYg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/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= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..2d46d80 --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,148 @@ +package github_recon_settings + +import ( + "fmt" + "os" + "strings" + + flag "github.com/spf13/pflag" + + "context" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +type TargetType string + +const ( + TargetUsername TargetType = "Username" + TargetEmail TargetType = "Email" +) + +type Settings struct { + Token string + Target string + TargetType TargetType + ShowSource bool + Refresh bool + MaxRepoSize int + ExcludedRepos []string + JsonOutput string + Silent bool + DeepScan bool + MaxDistance int + HideAvatar bool + + // Internal + Client *github.Client + Logger *log.Logger + Ctx context.Context +} + +func GetSettings() (settings Settings) { + //// Flag settings + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "github-recon [flags] \n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + } + + flag.CommandLine.SetNormalizeFunc(wordSepNormalizeFunc) + flag.CommandLine.SortFlags = false + + //// Flags + flag.StringVarP(&settings.Token, "token", "t", "null", "Github personal access token (e.g. ghp_aaa...). Can also be set via GITHUB_RECON_TOKEN environment variable. You also need to set the token in $HOME/.config/github-recon/env file if you want to use this tool without passing the token every time.") + + // DeepScan + flag.BoolVarP(&settings.DeepScan, "deepscan", "d", false, "Enable deep scan (clone repos, regex search, analyse licenses, etc.)") + flag.IntVar( + &settings.MaxRepoSize, + "max-size", + 150, + "Limit the size of repositories to scan (in MB) (only for deep scan)", + ) + flag.StringSliceVarP( + &settings.ExcludedRepos, + "exclude-repo", + "e", + []string{}, + "Exclude repos from deep scan (comma-separated list, only for deep scan)", + ) + flag.BoolVarP( + &settings.Refresh, + "refresh", + "r", + false, + "Refresh the cache (only for deep scan)", + ) + flag.BoolVarP( + &settings.ShowSource, + "show-source", + "s", + false, + "Show where the information (authors, emails, etc) were found (only for deep scan)", + ) + flag.IntVarP( + &settings.MaxDistance, + "max-distance", + "m", + 20, + "Maximum Levenshtein distance for matching usernames & emails (only for deep scan)", + ) + + flag.BoolVarP(&settings.Silent, "silent", "S", false, "Suppress all non-essential output") + flag.BoolVarP(&settings.HideAvatar, "hide-avatar", "h", false, "Hide the avatar in the output") + flag.StringVarP(&settings.JsonOutput, "json", "j", "", "Write results to specified JSON file") + + //// Parse + flag.Parse() + + //// Setup + settings.Client = github.NewClient(nil) + settings.Logger = log.NewWithOptions(os.Stderr, log.Options{ + ReportCaller: false, + ReportTimestamp: false, + }) + settings.Ctx = context.Background() + + //// Tail + nonFlagArgs := flag.Args() + if len(nonFlagArgs) > 1 { + settings.Logger.Error("Please provide only one target (username or email)") + flag.Usage() + os.Exit(1) + } else if len(nonFlagArgs) < 1 { + settings.Logger.Error("Please provide a target (username or email)") + flag.Usage() + os.Exit(1) + } + + settings.Target = flag.Arg(0) + settings.Target = strings.TrimPrefix(settings.Target, "@") // Remove the @ of the username + + if strings.Contains(settings.Target, " ") { + settings.Logger.Fatal("Target cannot contain spaces") + } + + if strings.Contains(settings.Target, "@") { + settings.TargetType = TargetEmail + } else { + settings.TargetType = TargetUsername + } + + // If token is not set via flag, get it from env + if settings.Token == "null" { + settings.Token = getToken() + } + + if settings.Token == "null" { + settings.Logger.Warn("No Github token provided. You might hit the rate limit. Check the help menu for more information.") + } else { + settings.Client = settings.Client.WithAuthToken(settings.Token) + } + + return +} diff --git a/settings/utils.go b/settings/utils.go new file mode 100644 index 0000000..5b66163 --- /dev/null +++ b/settings/utils.go @@ -0,0 +1,39 @@ +package github_recon_settings + +import ( + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + flag "github.com/spf13/pflag" +) + +func getToken() string { + token := os.Getenv("GITHUB_RECON_TOKEN") + if token != "" { + return token + } + + // Check the $HOME/.config/github-recon/env file for this variable + homedir, err := os.UserHomeDir() + if err != nil { + return "null" + } + godotenv.Load(filepath.Join(homedir, ".config/github-recon/env")) + token = os.Getenv("GITHUB_RECON_TOKEN") + if token != "" { + return token + } + + return "null" +} + +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) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..523d600 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,121 @@ +package utils + +import ( + "fmt" + "io" + "math" + "net/http" + "os" + "time" + + github_recon_settings "github.com/anotherhadi/github-recon/settings" + "github.com/google/go-github/v72/github" +) + +func WaitForRateLimit(settings github_recon_settings.Settings, resp *github.Response) { + if resp.Rate.Remaining == 0 { + settings.Logger.Info( + "Rate limit reached, waiting... (time:" + resp.Rate.Reset.Time.String() + ")", + ) + time.Sleep(time.Until(resp.Rate.Reset.Time) + time.Second) + } +} + +func FetchGitHubAPI(github *github.Client, token, path string) ([]byte, error) { + url := "https://api.github.com" + path + userAgent := "GHRecon/1.0" + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for %s: %w", url, err) + } + + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", userAgent) + + resp, err := github.Client().Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request for %s: %w", url, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf( + "request for %s failed with status %d: %s", + url, + resp.StatusCode, + string(bodyBytes), + ) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf( + "error reading response body for %s: %w", + url, + err, + ) + } + + return bodyBytes, nil +} + +func DoesFolderExists(path string) bool { + if stat, err := os.Stat(path); err == nil && stat.IsDir() { + return true + } + return false +} + +func LevenshteinDistance(s1, s2 string) int { + len1 := len(s1) + len2 := len(s2) + + dp := make([][]int, len1+1) + for i := range dp { + dp[i] = make([]int, len2+1) + } + + for i := 0; i <= len1; i++ { + dp[i][0] = i + } + + for j := 0; j <= len2; j++ { + dp[0][j] = j + } + + for i := 1; i <= len1; i++ { + for j := 1; j <= len2; j++ { + cost := 0 + if s1[i-1] != s2[j-1] { + cost = 1 + } + + dp[i][j] = int( + math.Min( + float64(dp[i-1][j]+1), + math.Min(float64(dp[i][j-1]+1), float64(dp[i-1][j-1]+cost)), + ), + ) + } + } + + return dp[len1][len2] +} + +func SkipResult(name, email string) bool { + if name == "github-actions[bot]" || name == "dependabot[bot]" || name == "github-actions" { + return true + } + if email == "github-actions[bot]@users.noreply.github.com" || + email == "github-actions@github.com" || email == "41898282+github-actions[bot]@users.noreply.github.com" || email == "49699333+dependabot[bot]@users.noreply.github.com" { + return true + } + return false +}