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 π
+
+
+
+
+
+
+
+- [π§Ύ 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
+}