commit af4046d23443fa699d6db53d8c04d8fc425407fc Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Fri Aug 22 21:58:54 2025 +0200 Init v2, rewrite diff --git a/.github/assets/banner.png b/.github/assets/banner.png new file mode 100644 index 0000000..f07ff09 Binary files /dev/null and b/.github/assets/banner.png differ diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000..df29cdc Binary files /dev/null and b/.github/assets/logo.png differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cd791b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2b6774f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Everybody is invited and welcome to contribute to this repo. There is a lot to +do... Check the issues! + +The process is straight-forward. + +- Read + [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) + by Kubernetes. (but skip step 0 and 1) +- [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) + this repo. +- Write your changes (bug fixe, new feature, issues fix, ...). +- Create a Pull Request against the main branch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5615ec7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed98397 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +
+ +
+ +
+ +# 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 +}