Init v2, rewrite

This commit is contained in:
Hadi
2025-08-22 21:58:54 +02:00
commit af4046d234
29 changed files with 1864 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+1
View File
@@ -0,0 +1 @@
result/
+14
View File
@@ -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.
+21
View File
@@ -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.
+164
View File
@@ -0,0 +1,164 @@
<div align="center">
<img src="https://raw.githubusercontent.com/anotherhadi/github-recon/main/.github/assets/logo.png" width="120px" />
</div>
<br>
# Github-Recon 🔍
<p>
<a href="https://github.com/anotherhadi/github-recon/releases"><img src="https://img.shields.io/github/release/anotherhadi/github-recon.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/anotherhadi/github-recon?tab=doc"><img src="https://godoc.org/github.com/anotherhadi/github-recon?status.svg" alt="GoDoc"></a>
<a href="https://goreportcard.com/report/github.com/anotherhadi/github-recon"><img src="https://goreportcard.com/badge/github.com/anotherhadi/github-recon" alt="GoReportCard"></a>
</p>
- [🧾 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
<details>
<summary>Click to expand</summary>
**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
];
```
</details>
## 🧪 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 [email protected] # 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.
+41
View File
@@ -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")
}
+26
View File
@@ -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)
}
+31
View File
@@ -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)
}
+32
View File
@@ -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)
}
}
+103
View File
@@ -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")
}
+75
View File
@@ -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)
}
Generated
+27
View File
@@ -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
}
+46
View File
@@ -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});
};
}
+1
View File
@@ -0,0 +1 @@
package github_recon
+91
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package recon
+120
View File
@@ -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
}
+85
View File
@@ -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
}
+304
View File
@@ -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
}
+135
View File
@@ -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
}
+35
View File
@@ -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
}
+45
View File
@@ -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
}
+69
View File
@@ -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
}
+31
View File
@@ -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
)
+58
View File
@@ -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=
+148
View File
@@ -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] <target username or email>\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
}
+39
View File
@@ -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)
}
+121
View File
@@ -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 == "[email protected]" || email == "41898282+github-actions[bot]@users.noreply.github.com" || email == "49699333+dependabot[bot]@users.noreply.github.com" {
return true
}
return false
}