Compare commits

..
38 Commits
Author SHA1 Message Date
Hadi df9fff8e97 format
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:10:46 +02:00
Hadi 926bf7239e return authors
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:10:31 +02:00
Hadi 5b79b6492b Filter by repo size
Signed-off-by: Hadi <[email protected]>
2025-05-28 14:03:39 +02:00
Hadi 2137925043 add commit inspection for deep mode
Signed-off-by: Hadi <[email protected]>
2025-05-28 13:41:15 +02:00
Hadi 274b393e53 edit readme
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:10:42 +02:00
Hadi 0041c0c132 edit readme instructions & flags
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:02:19 +02:00
Hadi d3fdfdcdc5 comments & sorting flags
Signed-off-by: Hadi <[email protected]>
2025-05-28 10:00:12 +02:00
Hadi 4f087cb7f0 ignore errors
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:57:50 +02:00
Hadi befb546292 refactor main
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:56:03 +02:00
Hadi 9a825bc7cc remove New func
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:46:34 +02:00
Hadi 72b8635832 remove @ from username
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:44:57 +02:00
Hadi 2ba8695674 edit flags sorting
Signed-off-by: Hadi <[email protected]>
2025-05-28 09:44:49 +02:00
Hadi f8eb7d58ba add flag normalization
Signed-off-by: Hadi <[email protected]>
2025-05-27 20:30:29 +02:00
Hadi 637d9811f2 change struct
Signed-off-by: Hadi <[email protected]>
2025-05-27 20:01:05 +02:00
Hadi c498e7bfdd typo
Signed-off-by: Hadi <[email protected]>
2025-05-27 16:12:01 +02:00
Hadi 7e2b6dd426 Cover your tracks!
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:45:28 +02:00
Hadi 05b449afcd add --max-size & --refresh
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:33:00 +02:00
Hadi b4392f972d edit close friends algo
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:12:40 +02:00
Hadi e4cec2b07a Avoid double newline
Signed-off-by: Hadi <[email protected]>
2025-05-21 10:12:33 +02:00
Hadi 15458ab78e not anymore, now by year/half-year
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:26:30 +02:00
Hadi deec50febf fix typo
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:19:14 +02:00
Hadi de47073083 edit readme
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:14:32 +02:00
Hadi a3f4b19382 add logo
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:02:23 +02:00
Hadi afdd30d34b add assets
Signed-off-by: Hadi <[email protected]>
2025-05-20 22:00:15 +02:00
Hadi 58ac92bff8 rephrase
Signed-off-by: Hadi <[email protected]>
2025-05-20 17:00:38 +02:00
Hadi ce96d1f307 cleaner version
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:42:49 +02:00
Hadi bd734f11af format
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:32:20 +02:00
Hadi 1ff0e222c7 update flake
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:30:08 +02:00
Hadi 5a9483411a v0.2.1
Signed-off-by: Hadi <[email protected]>
2025-05-20 15:28:32 +02:00
Hadi 7f74f5d28f update
Signed-off-by: Hadi <[email protected]>
2025-05-10 00:52:13 +02:00
Hadi bd99649b4e refactor
Signed-off-by: Hadi <[email protected]>
2025-05-10 00:51:43 +02:00
Hadi be6a9fbc6b add badges
Signed-off-by: Hadi <[email protected]>
2025-05-09 23:54:11 +02:00
Hadi 4b75b6f755 rename
Signed-off-by: Hadi <[email protected]>
2025-05-09 23:24:21 +02:00
Hadi d02479748e edit not found message
Signed-off-by: Hadi <[email protected]>
2025-05-09 23:23:11 +02:00
Hadi 9eee8e60be change utils
Signed-off-by: Hadi <[email protected]>
2025-05-09 23:22:12 +02:00
Hadi 4a3a06fa30 add commit leak url
Signed-off-by: Hadi <[email protected]>
2025-05-09 23:22:03 +02:00
Hadi b6ae831ba0 Add Nix/NixOS instructions
Signed-off-by: Hadi <[email protected]>
2025-05-09 18:57:43 +02:00
Hadi 47a45d72a9 Init
Signed-off-by: Hadi <[email protected]>
2025-05-09 18:49:36 +02:00
38 changed files with 1320 additions and 1957 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+3 -7
View File
@@ -1,14 +1,10 @@
# Contributing # Contributing
Everybody is invited and welcome to contribute to this repo. There is a lot to Everybody is invited and welcome to contribute to this repo. There is a lot to do... Check the issues!
do... Check the issues!
The process is straight-forward. The process is straight-forward.
- Read - 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)
[How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) - [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) this repo.
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, ...). - Write your changes (bug fixe, new feature, issues fix, ...).
- Create a Pull Request against the main branch. - Create a Pull Request against the main branch.
+40 -139
View File
@@ -1,78 +1,49 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/anotherhadi/github-recon/main/.github/assets/logo.png" width="120px" /> <img src="https://raw.githubusercontent.com/anotherhadi/gh-recon/main/.github/assets/logo.png" width="120px" />
</div> </div>
<br> <br>
# Github-Recon 🔍 # GH-Recon 🔍
<p> <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://github.com/anotherhadi/gh-recon/releases"><img src="https://img.shields.io/github/release/anotherhadi/gh-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://pkg.go.dev/github.com/anotherhadi/gh-recon?tab=doc"><img src="https://godoc.org/github.com/anotherhadi/gh-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> <a href="https://goreportcard.com/report/github.com/anotherhadi/gh-recon"><img src="https://goreportcard.com/badge/github.com/anotherhadi/gh-recon" alt="GoReportCard"></a>
</p> </p>
- [🧾 Project Overview](#-project-overview)
- [🚀 Features](#-features)
- [⚠️ Disclaimer](#%EF%B8%8F-disclaimer)
- [📦 Installation](#-installation)
- [With Go](#with-go)
- [With Nix/NixOS](#with-nixnixos)
- [🧪 Usage](#-usage)
- [Flags](#flags)
- [Token](#token)
- [How does the email spoofing work?](#how-does-the-email-spoofing-work)
- [💡 Examples](#-examples)
- [🕵️‍♂️ Cover your tracks](#%EF%B8%8F%EF%B8%8F-cover-your-tracks)
- [🤝 Contributing](#-contributing)
- [🙏 Credits](#-credits)
## 🧾 Project Overview ## 🧾 Project Overview
Retrieves and aggregates public OSINT data about a GitHub user using Go and the Retrieves and aggregates public OSINT data about a GitHub user using Go and the GitHub API.
GitHub API. Finds hidden emails in commit history, previous usernames, friends, Finds hidden emails in commit history, previous usernames, friends, other GitHub accounts, and more.
other GitHub accounts, and more.
<details>
<summary>Screenshot</summary>
<img src="https://raw.githubusercontent.com/anotherhadi/github-recon/main/.github/assets/example.png" alt="example screenshot">
</details>
## 🚀 Features ## 🚀 Features
- Export results to JSON - Retrieve basic user profile information (username, ID, avatar, bio, creation dates)
**From usernames:**
- Retrieve basic user profile information (username, ID, avatar, bio, creation
date)
- Display avatars directly in the terminal
- List organizations and roles - List organizations and roles
- Fetch SSH and GPG keys - Fetch SSH and GPG keys
- Enumerate social accounts - Enumerate social accounts
- Extract unique commit authors (name + email) - Extract unique commit authors (name + email)
- Find close friends - Find close friends
- Deep scan option (clone repositories, run regex searches, analyze licenses, - Find Github accounts using an email address
etc.) - Export results to JSON
- Use Levenshtein distance for matching usernames and emails - Deep scan option (clone repositories, regex search, analyze licenses, etc.)
- TruffleHog integration to find secrets
**From emails:**
- Search for a specific email across all GitHub commits
- Spoof an email to discover the associated user account
## ⚠️ Disclaimer ## ⚠️ Disclaimer
This tool is intended for educational purposes only. Use responsibly and ensure This tool is intended for educational purposes only. Use responsibly and ensure you have permission to access the data you are querying.
you have permission to access the data you are querying.
## 📋 Prerequisites
- Go 1.18+
- GitHub Personal Access Token (recommended for higher rate limits): Create a GitHub API token with no permissions/no scope. This will be equivalent to public GitHub access, but it will allow access to use the GitHub Search API.
## 📦 Installation ## 📦 Installation
### With Go ### With Go
```bash ```bash
go install github.com/anotherhadi/github-recon@latest go install github.com/anotherhadi/gh-recon@latest
``` ```
### With Nix/NixOS ### With Nix/NixOS
@@ -83,7 +54,7 @@ go install github.com/anotherhadi/github-recon@latest
**From anywhere (using the repo URL):** **From anywhere (using the repo URL):**
```bash ```bash
nix run github:anotherhadi/github-recon -- [--flags value] target_username_or_email nix run github:anotherhadi/gh-recon -- --username TARGET_USER [--token YOUR_TOKEN]
``` ```
**Permanent Installation:** **Permanent Installation:**
@@ -92,13 +63,13 @@ nix run github:anotherhadi/github-recon -- [--flags value] target_username_or_em
# add the flake to your flake.nix # add the flake to your flake.nix
{ {
inputs = { inputs = {
github-recon.url = "github:anotherhadi/github-recon"; gh-recon.url = "github:anotherhadi/gh-recon";
}; };
} }
# then add it to your packages # then add it to your packages
environment.systemPackages = with pkgs; [ # or home.packages environment.systemPackages = with pkgs; [ # or home.packages
inputs.github-recon.defaultPackage.${pkgs.system} gh-recon
]; ];
``` ```
@@ -107,102 +78,41 @@ environment.systemPackages = with pkgs; [ # or home.packages
## 🧪 Usage ## 🧪 Usage
```bash ```bash
github-recon [--flags value] target_username_or_email gh-recon --username TARGET_USER [--token YOUR_TOKEN]
``` ```
### Flags ### Flags
```txt ```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") -u, --username string GitHub username to analyze
-d, --deepscan Enable deep scan (clone repos, regex search, analyse licenses, etc.) -t, --token string GitHub personal access token (e.g. ghp_...)
--max-size int Limit the size of repositories to scan (in MB) (only for deep scan) (default 150) -e, --email string Search accounts by email address
-e, --exclude-repo strings Exclude repos from deep scan (comma-separated list, only for deep scan) -d, --deep Enable deep scan (clone repos, regex search, analyse licenses, etc.)
-r, --refresh Refresh the cache (only for deep scan) --max-size int Limit the size of repositories to scan (in MB) (only for deep scan) (default 150)
-s, --show-source Show where the information (authors, emails, etc) were found (only for deep scan) --exclude-repo string Exclude repos from deep scan (comma-separated list, only for deep scan)
-m, --max-distance int Maximum Levenshtein distance for matching usernames & emails (only for deep scan) (default 20) -r, --refresh Refresh the cache (only for deep scan)
--trufflehog Run trufflehog on cloned repositories (only for deep scan) (default true) -c, --only-commits Display only commits with author info
-S, --silent Suppress all non-essential output -s, --silent Suppress all non-essential output
--spoof-email Spoof email (only for email mode) (default true) -j, --json string Write results to specified JSON file
-a, --print-avatar Show the avatar in the output
-j, --json string Write results to specified JSON file
``` ```
### Token
For the best experience, provide a **GitHub Personal Access Token**. Without a
token, you will quickly hit the **rate limit** and have to wait.
- For **basic usage**, you can create a token **without any permissions**.
- For the **email spoofing feature**, you need to add the **`repo`** and
**`delete_repo`** permissions.
You can set the token in multiple ways:
- **Command-line flag**:
```bash
github-recon -t "ghp_xxx..."
```
- **Environment variable**:
```bash
export GITHUB_RECON_TOKEN=ghp_xxx...
```
- **Config file**: Create the file `~/.config/github-recon/env` and add:
```env
GITHUB_RECON_TOKEN=ghp_xxx...
```
> [!WARNING]
> For safety, it is recommended to create the Personal Access Token on a
> **separate GitHub account** rather than your main account. This way, if
> anything goes wrong, your primary account remains safe.
### How does the email spoofing work?
Here’s the process:
1. Create a new repository.
2. Make a commit using the **target's email** as the author.
3. Push the commit to GitHub.
4. Observe which GitHub account the commit is linked to. This method **always
works**, but it only reveals the account if the email is set as the user’s
**primary email**.
All of these steps are handled **automatically by the tool**, so you just need
to provide the target email.
## 💡 Examples ## 💡 Examples
```bash ```bash
github-recon anotherhadi --token ghp_ABC123... gh-recon --username anotherhadi --token ghp_ABC123...
github-recon [email protected] # Find github accounts by email gh-recon --email [email protected]
github-recon anotherhadi --json output.json --deepscan # Clone the repo and search for leaked email gh-recon --username anotherhadi --json output.json --deep
``` ```
## 🕵️‍♂️ Cover your tracks ## 🕵️‍♂️ Cover your tracks
Understanding what information about you is publicly visible is the first step Understanding what information about you is publicly visible is the first step to managing your online presence. gh-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:
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 - **Review your public profile**: Regularly check your GitHub profile and repositories to ensure that you are not unintentionally exposing sensitive information.
repositories to ensure that you are not unintentionally exposing sensitive - **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.
information. - **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.
- **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) You can also use a tool like [TruffleHog](github.com/trufflesecurity/trufflehog) to scan your repositories specifically for exposed secrets and tokens.
to scan your repositories specifically for exposed secrets and tokens.
**Useful links:** **Useful links:**
@@ -212,12 +122,3 @@ to scan your repositories specifically for exposed secrets and tokens.
## 🤝 Contributing ## 🤝 Contributing
Feel free to contribute! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. 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.
-31
View File
@@ -1,31 +0,0 @@
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)
}
-40
View File
@@ -1,40 +0,0 @@
package main
import (
"log"
recon_email "github.com/anotherhadi/github-recon/github-recon/email"
recon_username "github.com/anotherhadi/github-recon/github-recon/username"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
func main() {
settings, err := github_recon_settings.GetSettings()
if err != nil {
log.Fatal(err)
}
if !settings.Silent {
utils.Header()
utils.PrintStruct(settings, struct {
Target string
TargetType string
}{
Target: settings.Target,
TargetType: string(settings.TargetType),
}, 0)
}
if settings.TargetType == github_recon_settings.TargetUsername {
result, err := recon_username.Username(settings)
if err != nil {
log.Fatal(err)
}
writeJson(settings, result)
} else {
result := recon_email.Email(settings)
writeJson(settings, result)
}
}
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1756787288, "lastModified": 1746663147,
"narHash": "sha256-rw/PHa1cqiePdBxhF66V7R+WAP8WekQ0mCDG4CFqT8Y=", "narHash": "sha256-Ua0drDHawlzNqJnclTJGf87dBmaO/tn7iZ+TCkTRpRc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d0fc30899600b9b3466ddb260fd83deb486c32f1", "rev": "dda3dcd3fe03e991015e9a74b22d35950f264a54",
"type": "github" "type": "github"
}, },
"original": { "original": {
+29 -34
View File
@@ -1,46 +1,41 @@
{ {
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."; description =
"GH-Recon: Fetches and aggregates public OSINT data for a GitHub user, leveraging Go and the GitHub API.";
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";}; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; };
outputs = { outputs = { self, nixpkgs }:
self, let
nixpkgs, supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f: forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs { inherit system; }));
pname = "github-recon"; pname = "gh-recon";
version = "2.1.0"; version = "0.2.1";
ldflags = ["-s" "-w"]; ldflags = [ "-s" "-w" ];
in {
packages = forAllSystems (system: pkgs: {
"${pname}" = pkgs.buildGoModule {
inherit pname version ldflags;
src = ./.; in {
subPackages = ["cmd"]; packages = forAllSystems (system: pkgs: {
outputs = ["out"]; "${pname}" = pkgs.buildGoModule {
installPhase = '' inherit pname version ldflags;
mkdir -p $out/bin
cp $GOPATH/bin/cmd $out/bin/github-recon
'';
vendorHash = "sha256-AD0h0k2n8gPqSBz5qqb0ZON/jWiSEWpeO97xR7cYSy8="; src = ./.;
meta = with pkgs.lib; { vendorHash = "sha256-S8IzmdiVvBtnQQl0AewGZ1yuitvrdnVQ/Jf2230g3Mg=";
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"; meta = with pkgs.lib; {
platforms = platforms.unix; description =
"Fetches and aggregates public OSINT data for a GitHub user.";
homepage = "https://github.com/anotherhadi/gh-recon";
platforms = platforms.unix;
};
}; };
}; });
});
defaultPackage = defaultPackage =
forAllSystems (system: pkgs: self.packages.${system}.${pname}); forAllSystems (system: pkgs: self.packages.${system}.${pname});
}; };
} }
+138
View File
@@ -0,0 +1,138 @@
package ghrecon
import (
"fmt"
"sort"
)
type CloseFriendsResult 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 (r Recon) CloseFriends(username string) (response []CloseFriendsResult) {
r.PrintTitle("🧑‍🤝‍🧑 Close Friends")
following, resp, err := r.Client.Users.ListFollowing(r.Ctx, username, nil)
if err != nil {
r.Logger.Error("Failed to fetch user's following list", "user", username, "err", err)
r.PrintNewline()
return
}
WaitForRateLimit(resp)
if len(following) >= maxFollowingForTarget {
r.PrintInfo(
"INFO",
fmt.Sprintf(
"%s follows %d or more users (%d). Skipping close friends check.",
username,
maxFollowingForTarget,
len(following),
),
)
r.PrintNewline()
return
}
if len(following) == 0 {
r.PrintInfo("INFO", fmt.Sprintf("%s is not following anyone.", username))
r.PrintNewline()
return
}
for _, userBeingFollowedByTarget := range following {
loginName := userBeingFollowedByTarget.GetLogin()
if loginName == "" {
r.Logger.Warn("User in following list has an empty login", "target_user", username)
continue
}
currentScore := 0
userDetails, userResp, userErr := r.Client.Users.Get(r.Ctx, loginName)
if userErr != nil {
r.Logger.Warn(
"Failed to fetch details for followed user",
"followed_user",
loginName,
"err",
userErr,
)
if userResp != nil {
WaitForRateLimit(userResp)
}
continue
}
WaitForRateLimit(userResp)
if userDetails.GetFollowers() < maxFollowersForFollowing {
currentScore += pointPerCriterion
}
followsTargetBack, checkErr := r.checkIfUserFollows(loginName, username)
if checkErr != nil {
} else if followsTargetBack {
currentScore += pointPerCriterion
}
if currentScore > 0 {
response = append(response, CloseFriendsResult{
Login: loginName,
Score: currentScore,
})
}
}
if len(response) == 0 {
r.PrintInfo(
"INFO",
fmt.Sprintf("No close friends found for %s based on the criteria.", username),
)
} else {
sort.Slice(response, func(i, j int) bool {
return response[i].Score > response[j].Score
})
for i, friend := range response {
r.PrintInfo(
fmt.Sprintf("Friend n°%d", i+1),
"@"+friend.Login,
"Score: "+fmt.Sprintf("%d", friend.Score),
)
}
}
r.PrintNewline()
return
}
// checkIfUserFollows checks if sourceUserLogin follows targetUserLogin.
func (r Recon) checkIfUserFollows(sourceUserLogin, targetUserLogin string) (bool, error) {
isFollowing, resp, err := r.Client.Users.IsFollowing(r.Ctx, sourceUserLogin, targetUserLogin)
if err != nil {
r.Logger.Warn("Error checking if user follows target",
"source_user_checking", sourceUserLogin,
"target_user_to_check", targetUserLogin,
"err", err)
if resp != nil {
WaitForRateLimit(resp)
}
return false, err
}
if resp != nil {
WaitForRateLimit(resp)
}
return isFollowing, nil
}
+92
View File
@@ -0,0 +1,92 @@
package ghrecon
import (
"fmt"
"github.com/google/go-github/v72/github"
)
type CommitsResult struct {
Name string
Email string
Occurences int
FirstFoundIn string
}
func (r Recon) Commits(username string) (response []CommitsResult) {
r.PrintTitle("🐙 Commits")
results := make(map[string]CommitsResult)
collect := func(date string) error {
for page := 1; page <= 10; page++ {
result, resp, err := r.Client.Search.Commits(
r.Ctx,
fmt.Sprintf("author:%s author-date:%s", username, date),
&github.SearchOptions{
Sort: "author-date",
Order: "desc",
ListOptions: github.ListOptions{PerPage: 100, Page: page},
},
)
if err != nil {
return fmt.Errorf("fetch page %d (%s): %w", page, date, err)
}
WaitForRateLimit(resp)
if len(result.Commits) == 0 {
break
}
for _, item := range result.Commits {
name := item.Commit.GetAuthor().GetName()
email := item.Commit.GetAuthor().GetEmail()
if SkipResult(name, email) {
continue
}
if _, seen := results[name+" - "+email]; !seen {
author := CommitsResult{
Name: name,
Email: email,
Occurences: 1,
FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository().
GetName(),
}
results[name+" - "+email] = author
} else {
result := results[name+" - "+email]
result.Occurences++
results[name+" - "+email] = result
}
}
}
return nil
}
// Range of dates to bypass the limit of 1000 results
for _, date := range []string{
"<2023-01-01", "2023-01-01..2023-12-31",
"2024-01-01..2024-05-31",
"2024-06-01..2024-12-31",
"2025-01-01..2025-05-31",
"2025-06-01..2025-12-31",
">2026-01-01",
} {
if err := collect(date); err != nil {
r.Logger.Error("Failed to fetch commits", "err", err, "date", date)
}
}
for _, result := range results {
r.PrintInfo(
"Author",
result.Name+" - "+result.Email,
"first from "+result.FirstFoundIn+" (x"+fmt.Sprint(result.Occurences)+")",
)
response = append(response, result)
}
if len(results) == 0 {
r.PrintInfo("INFO", "No commits found")
}
r.PrintNewline()
return
}
+299
View File
@@ -0,0 +1,299 @@
package ghrecon
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/google/go-github/v72/github"
)
type AuthorOccurrence struct {
Name string
Email string
FoundIn []string
}
type EmailOccurrence struct {
Email string
FoundIn []string
}
type DeepResult struct {
Repositories []Repositorie
Authors []AuthorOccurrence
Emails []EmailOccurrence
}
type Repositorie struct {
Repository string
Owner string
Name string
Size int
}
func findEmailsAndOccurrencesInDir(rootPath string) ([]EmailOccurrence, error) {
emailLocations := make(map[string]map[string]bool)
emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
normalizedRootPath := filepath.Clean(rootPath)
err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Can't access %s: %v\n", path, err)
return err
}
if !d.IsDir() {
if strings.Contains(path, ".git/logs/") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
fmt.Printf("Can't read %s: %v\n", path, err)
return nil
}
currentFileEmails := emailRegex.FindAllString(string(content), -1)
if len(currentFileEmails) > 0 {
relativePath, errRel := filepath.Rel(normalizedRootPath, path)
if errRel != nil {
fmt.Printf("Can't find the relative path %s: %v\n", path, errRel)
relativePath = path
}
for _, email := range currentFileEmails {
if len(email) > 12 {
if _, ok := emailLocations[email]; !ok {
emailLocations[email] = make(map[string]bool)
}
emailLocations[email][relativePath] = true
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
var results []EmailOccurrence
for email, pathSet := range emailLocations {
var paths []string
for path := range pathSet {
paths = append(paths, path)
}
results = append(results, EmailOccurrence{Email: email, FoundIn: paths})
}
return results, nil
}
func (r Recon) Deep(username, excludeRepos string, refresh bool) (response DeepResult) {
repositories := []Repositorie{}
excludeReposList := strings.Split(excludeRepos, ",")
repos, resp, err := r.Client.Repositories.ListByUser(
r.Ctx,
username,
&github.RepositoryListByUserOptions{
Type: "all",
},
)
if err != nil {
r.Logger.Error("Failed to fetch repositories", "err", err)
return
}
r.PrintTitle("📦 Repositories")
if len(repos) == 0 {
r.PrintInfo("INFO", "No repositories found")
} else {
for _, repo := range repos {
if slices.Contains(excludeReposList, repo.GetName()) ||
slices.Contains(excludeReposList, repo.GetOwner().GetLogin()+"/"+repo.GetName()) {
continue
}
maxRepoSize := r.MaxRepoSize * 1024
if repo.GetSize() > maxRepoSize {
r.PrintInfo(
"INFO",
"Skipping repository "+repo.GetOwner().GetLogin()+"/"+repo.GetName()+" due to size", fmt.Sprintf(
"%d",
repo.GetSize()/1024,
)+"MB > "+fmt.Sprintf(
"%d",
maxRepoSize/1024,
)+"MB",
)
continue
}
repositories = append(repositories, Repositorie{
Repository: repo.GetCloneURL(),
Owner: repo.GetOwner().GetLogin(),
Name: repo.GetName(),
Size: repo.GetSize(),
})
}
}
WaitForRateLimit(resp)
cmd := exec.Command("git", "--version")
if err := cmd.Run(); err != nil {
r.PrintInfo("ERROR", "Git is not installed, please install it to use this feature")
return
}
tmp_folder := "/tmp/ghrecon-" + username
if folderExists(tmp_folder) {
if refresh {
r.PrintInfo("INFO", "Deleting existing folder "+tmp_folder)
err := os.RemoveAll(tmp_folder)
if err != nil {
r.PrintInfo("ERROR", "Failed to delete existing folder "+tmp_folder)
}
}
}
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if folderExists(destination) {
r.PrintInfo("INFO", "Directory already downloaded, skipping "+repo.Owner+"/"+repo.Name)
continue
}
r.PrintInfo(
"Downloading",
repo.Owner+"/"+repo.Name,
fmt.Sprintf("%d", repo.Size/1024)+"MB",
)
cmd := exec.Command(
"git",
"clone",
repo.Repository,
destination,
)
err := cmd.Run()
if err != nil {
r.Logger.Error(
"ERROR",
"Failed to clone repository",
"err",
err,
"repo",
repo.Repository,
)
continue
}
}
r.PrintInfo("INFO", "Cloned all repositories to "+tmp_folder)
authorOccurrences := []AuthorOccurrence{}
mapAuthorToIndex := make(map[string]int)
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if !folderExists(filepath.Join(destination, ".git")) {
r.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 {
r.Logger.Error("Failed to execute git log (ExitError)", "repo", repo.Owner+"/"+repo.Name, "stderr", string(exitErr.Stderr), "err", logErr)
} else {
r.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 {
r.Logger.Error("Malformed author line from git log", "line", trimmedLine, "repo", repoIdentifier)
continue
}
authorOccurrences = append(authorOccurrences, AuthorOccurrence{
Name: authorName,
Email: authorEmail,
FoundIn: []string{repoIdentifier},
})
mapAuthorToIndex[trimmedLine] = len(authorOccurrences) - 1
}
}
}
}
}
for _, author := range authorOccurrences {
r.PrintInfo(
"Author",
author.Name+" <"+author.Email+">",
"found in:"+strings.Join(author.FoundIn, ", "),
)
}
r.PrintInfo("INFO", "Now searching for emails in cloned repositories, this may take a while...")
emails, err := findEmailsAndOccurrencesInDir(tmp_folder)
if err != nil {
r.Logger.Error("Failed to find emails in directory", "err", err)
return
}
if len(emails) == 0 {
r.PrintInfo("INFO", "No emails found")
} else {
r.PrintInfo("INFO", "Found emails:")
for _, email := range emails {
r.PrintInfo("Email", email.Email, "found in:"+strings.Join(email.FoundIn, ", "))
}
}
response.Repositories = repositories
response.Authors = authorOccurrences
response.Emails = emails
r.PrintNewline()
return
}
@@ -1,32 +1,29 @@
package recon package ghrecon
import ( import (
"fmt" "fmt"
"strings"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github" "github.com/google/go-github/v72/github"
) )
type CommitsResult []CommitResult type EmailResult struct {
type CommitResult struct {
Name string Name string
Email string Email string
Username string Username string
Occurrences int Occurences int
FirstFoundIn string FirstFoundIn string
} }
func Commits(s github_recon_settings.Settings) (response CommitsResult) { func (r Recon) Email(email string) (response []EmailResult) {
results := make(map[string]CommitResult) r.PrintTitle("✉️ Email")
results := make(map[string]EmailResult)
collect := func(date string) error { collect := func(date string) error {
for page := 1; page <= 10; page++ { for page := 1; page <= 10; page++ {
result, resp, err := s.Client.Search.Commits( result, resp, err := r.Client.Search.Commits(
s.Ctx, r.Ctx,
fmt.Sprintf("author-email:%s author-date:%s", s.Target, date), fmt.Sprintf("author-email:%s author-date:%s", email, date),
&github.SearchOptions{ &github.SearchOptions{
Sort: "author-date", Sort: "author-date",
Order: "desc", Order: "desc",
@@ -36,7 +33,7 @@ func Commits(s github_recon_settings.Settings) (response CommitsResult) {
if err != nil { if err != nil {
return fmt.Errorf("fetch page %d (%s): %w", page, date, err) return fmt.Errorf("fetch page %d (%s): %w", page, date, err)
} }
utils.WaitForRateLimit(s, resp) WaitForRateLimit(resp)
if len(result.Commits) == 0 { if len(result.Commits) == 0 {
break break
} }
@@ -47,23 +44,23 @@ func Commits(s github_recon_settings.Settings) (response CommitsResult) {
if login == "" { if login == "" {
login = "Unknown" login = "Unknown"
} }
if utils.SkipResult(name, email) { if SkipResult(name, email) {
continue continue
} }
if _, seen := results[strings.ToLower(name)+" - "+strings.ToLower(email)+" - "+strings.ToLower(login)]; !seen { if _, seen := results[name+" - "+email+" - "+login]; !seen {
author := CommitResult{ author := EmailResult{
Name: name, Name: name,
Email: email, Email: email,
Username: login, Username: login,
Occurrences: 1, Occurences: 1,
FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository(). FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository().
GetName(), GetName(),
} }
results[strings.ToLower(name)+" - "+strings.ToLower(email)+" - "+strings.ToLower(login)] = author results[name+" - "+email+" - "+login] = author
} else { } else {
result := results[strings.ToLower(name)+" - "+strings.ToLower(email)+" - "+strings.ToLower(login)] result := results[name+" - "+email+" - "+login]
result.Occurrences++ result.Occurences++
results[strings.ToLower(name)+" - "+strings.ToLower(email)+" - "+strings.ToLower(login)] = result results[name+" - "+email+" - "+login] = result
} }
} }
} }
@@ -80,13 +77,22 @@ func Commits(s github_recon_settings.Settings) (response CommitsResult) {
">2026-01-01", ">2026-01-01",
} { } {
if err := collect(date); err != nil { if err := collect(date); err != nil {
s.Logger.Error("Failed to fetch commits", "err", err, "date", date) r.Logger.Error("Failed to fetch commits", "err", err, "date", date)
} }
} }
for _, result := range results { for _, result := range results {
r.PrintInfo(
"Author",
result.Name+" - "+result.Email+" - @"+result.Username,
"first from "+result.FirstFoundIn+" (x"+fmt.Sprint(result.Occurences)+")",
)
response = append(response, result) response = append(response, result)
} }
if len(results) == 0 {
r.PrintInfo("INFO", "No commits found")
}
r.PrintNewline()
return return
} }
+194
View File
@@ -0,0 +1,194 @@
package ghrecon
import (
"fmt"
)
type SSHKeyResult struct {
ID string
Url string
Title string
CreatedAt string
Key string
ReadOnly string
Verified string
LastUsed string
AddedBy string
}
func (r Recon) SshKeys(username string) (response []SSHKeyResult) {
sshKeys, resp, err := r.Client.Users.ListKeys(r.Ctx, username, nil)
if err != nil {
r.Logger.Error("Failed to fetch ssh keys", "err", err)
} else if len(sshKeys) == 0 {
r.PrintTitle("🔑 SSH Keys")
r.PrintInfo("INFO", "No SSH Keys found")
} else {
r.PrintTitle("🔑 SSH Keys")
for i, key := range sshKeys {
k := SSHKeyResult{
ID: fmt.Sprintf("%d", key.GetID()),
Url: key.GetURL(),
Title: key.GetTitle(),
CreatedAt: key.GetCreatedAt().String(),
Key: key.GetKey(),
ReadOnly: fmt.Sprintf("%t", key.GetReadOnly()),
Verified: fmt.Sprintf("%t", key.GetVerified()),
LastUsed: key.GetLastUsed().String(),
AddedBy: key.GetAddedBy(),
}
response = append(response, k)
r.PrintInfo("Key n°", fmt.Sprintf("%d", i))
r.PrintInfo("ID", k.ID)
r.PrintInfo("URL", k.Url)
r.PrintInfo("Title", k.Title)
r.PrintInfo("Created At", k.CreatedAt)
r.PrintInfo("Key", k.Key)
r.PrintInfo("Read Only", k.ReadOnly)
r.PrintInfo("Verified", k.Verified)
r.PrintInfo("Last Used", k.LastUsed)
r.PrintInfo("Added By", k.AddedBy)
if i != len(sshKeys)-1 {
r.PrintNewline()
}
}
}
r.PrintNewline()
WaitForRateLimit(resp)
return
}
type GPGKeyEmail struct {
Email string
Verified string
}
type GPGKeyResult struct {
ID string
KeyID string
PublicKey string
CreatedAt string
PrimaryKeyID string
RawKey string
Emails []GPGKeyEmail
Subkeys []GPGKeyResult
}
func (r Recon) GpgKeys(username string) (response []GPGKeyResult) {
gpgKeys, resp, err := r.Client.Users.ListGPGKeys(r.Ctx, username, nil)
if err != nil {
r.Logger.Error("Failed to fetch user's gpg keys", "err", err)
} else if len(gpgKeys) == 0 {
r.PrintTitle("🗝️ GPG Keys")
r.PrintInfo("INFO", "No GPG Keys found")
} else {
r.PrintTitle("🗝️ GPG Keys")
for i, key := range gpgKeys {
k := GPGKeyResult{
ID: fmt.Sprintf("%d", key.GetID()),
KeyID: key.GetKeyID(),
PublicKey: key.GetPublicKey(),
CreatedAt: key.GetCreatedAt().String(),
PrimaryKeyID: fmt.Sprintf("%d", key.GetPrimaryKeyID()),
RawKey: key.GetRawKey(),
Emails: []GPGKeyEmail{},
Subkeys: []GPGKeyResult{},
}
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{
ID: fmt.Sprintf("%d", subkey.GetID()),
KeyID: subkey.GetKeyID(),
PublicKey: subkey.GetPublicKey(),
CreatedAt: subkey.GetCreatedAt().String(),
PrimaryKeyID: fmt.Sprintf("%d", subkey.GetPrimaryKeyID()),
RawKey: subkey.GetRawKey(),
}
k.Subkeys = append(k.Subkeys, subkey)
}
response = append(response, k)
r.PrintInfo("Key n°", fmt.Sprintf("%d", i))
r.PrintInfo("ID", k.ID)
r.PrintInfo("Key ID", k.KeyID)
r.PrintInfo("Public Key", k.PublicKey)
r.PrintInfo("Created At", k.CreatedAt)
r.PrintInfo("Primary Key ID", k.PrimaryKeyID)
r.PrintInfo("Raw Key", k.RawKey)
r.PrintInfo("Emails", fmt.Sprintf("%d", len(k.Emails)))
for j, email := range k.Emails {
r.PrintInfo(" Email n°", fmt.Sprintf("%d", j))
r.PrintInfo(" Email", email.Email)
r.PrintInfo(" Verified", email.Verified)
if j != len(k.Emails)-1 {
r.PrintNewline()
}
}
r.PrintInfo("Subkeys", fmt.Sprintf("%d", len(k.Subkeys)))
for j, subkey := range k.Subkeys {
r.PrintInfo(" Subkey n°", fmt.Sprintf("%d", j))
r.PrintInfo(" Subkey ID", subkey.ID)
r.PrintInfo(" Subkey Key ID", subkey.KeyID)
r.PrintInfo(" Subkey Created At", subkey.CreatedAt)
r.PrintInfo(" Subkey Primary Key ID", subkey.PrimaryKeyID)
r.PrintInfo(" Subkey Raw Key", subkey.RawKey)
if j != len(k.Subkeys)-1 {
r.PrintNewline()
}
}
if i != len(gpgKeys)-1 {
r.PrintNewline()
}
}
}
r.PrintNewline()
WaitForRateLimit(resp)
return
}
type SSHSigningKeyResult struct {
ID string
Title string
CreatedAt string
Key string
}
func (r Recon) SshSigningKeys(username string) (response []SSHSigningKeyResult) {
signingKeys, resp, err := r.Client.Users.ListSSHSigningKeys(
r.Ctx,
username,
nil,
)
if err != nil {
r.Logger.Error("Failed to fetch user's ssh signing keys", "err", err)
} else if len(signingKeys) == 0 {
r.PrintTitle("📝 SSH Signing Keys")
r.PrintInfo("INFO", "No SSH Signing Keys found")
} else {
r.PrintTitle("📝 SSH Signing Keys")
for i, key := range signingKeys {
k := SSHSigningKeyResult{
ID: fmt.Sprintf("%d", key.GetID()),
Title: key.GetTitle(),
CreatedAt: key.GetCreatedAt().String(),
Key: key.GetKey(),
}
r.PrintInfo("Key n°", fmt.Sprintf("%d", i))
r.PrintInfo("ID", k.ID)
r.PrintInfo("Title", k.Title)
r.PrintInfo("Created At", k.CreatedAt)
r.PrintInfo("Key", k.Key)
if i != len(signingKeys)-1 {
r.PrintNewline()
}
response = append(response, k)
}
}
WaitForRateLimit(resp)
r.PrintNewline()
return response
}
+17
View File
@@ -0,0 +1,17 @@
package ghrecon
import (
"context"
"github.com/charmbracelet/log"
"github.com/google/go-github/v72/github"
)
type Recon struct {
Client *github.Client
Logger *log.Logger
Ctx context.Context
Silent bool
JsonFile string
MaxRepoSize int
}
+44
View File
@@ -0,0 +1,44 @@
package ghrecon
import (
"fmt"
)
type OrgResult struct {
Login string
ID string
URL string
Description string
}
func (r Recon) Orgs(username string) (response []OrgResult) {
orgs, resp, err := r.Client.Organizations.List(r.Ctx, username, nil)
if err != nil {
r.Logger.Error("Failed to fetch organizations", "err", err)
} else if len(orgs) == 0 {
r.PrintTitle("🏢 Organizations")
r.PrintInfo("INFO", "No Organizations found")
} else {
r.PrintTitle("🏢 Organizations")
for i, org := range orgs {
o := OrgResult{
Login: org.GetLogin(),
ID: fmt.Sprintf("%d", org.GetID()),
URL: org.GetURL(),
Description: org.GetDescription(),
}
r.PrintInfo("Organization n°", fmt.Sprintf("%d", i))
r.PrintInfo("Login", o.Login)
r.PrintInfo("ID", o.ID)
r.PrintInfo("URL", o.URL)
r.PrintInfo("Description", o.Description)
if i != len(orgs)-1 {
r.PrintNewline()
}
response = append(response, o)
}
}
r.PrintNewline()
WaitForRateLimit(resp)
return
}
+41
View File
@@ -0,0 +1,41 @@
package ghrecon
import (
"encoding/json"
"fmt"
)
type SocialResult struct {
Provider string `json:"provider"`
URL string `json:"url"`
}
func (r Recon) Socials(username string) (response []SocialResult) {
resp, err := FetchGitHubAPI(r.Client, "", "/users/"+username+"/social_accounts")
if err != nil {
r.Logger.Error("Failed to fetch socials", "err", err)
return
}
var socialAccounts []SocialResult
err = json.Unmarshal(resp, &socialAccounts)
if err != nil {
r.Logger.Error("Failed to unmarshal socials", "err", err)
return
}
if len(socialAccounts) == 0 {
r.PrintTitle("🐥 Socials")
r.PrintInfo("INFO", "No commits found")
} else {
r.PrintTitle("🐥 Socials")
for i, account := range socialAccounts {
r.PrintInfo("Social n°", fmt.Sprintf("%d", i))
r.PrintInfo("Provider", account.Provider)
r.PrintInfo("URL", account.URL)
}
}
r.PrintNewline()
return socialAccounts
}
@@ -1,14 +1,12 @@
package recon package ghrecon
import ( import (
"fmt" "fmt"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
) )
type UserResult struct { type UserResult struct {
Username string Username string
ID string
AvatarURL string AvatarURL string
GravatarID string GravatarID string
Name string Name string
@@ -31,17 +29,19 @@ type UserResult struct {
Plan string Plan string
} }
func User(s github_recon_settings.Settings) (response UserResult, err error) { func (r Recon) User(username string) (response UserResult) {
user, resp, err := s.Client.Users.Get(s.Ctx, s.Target) user, resp, err := r.Client.Users.Get(r.Ctx, username)
if resp.StatusCode == 404 { if resp.StatusCode == 404 {
return UserResult{}, nil r.Logger.Fatal("User not found")
} }
if err != nil { if err != nil {
return UserResult{}, fmt.Errorf("failed to fetch user's information") r.Logger.Fatal("Failed to fetch user's information", "err", err)
} }
r.PrintTitle("👤 User informations")
u := UserResult{ u := UserResult{
Username: user.GetLogin(), Username: user.GetLogin(),
ID: fmt.Sprintf("%d", user.GetID()),
AvatarURL: user.GetAvatarURL(), AvatarURL: user.GetAvatarURL(),
GravatarID: user.GetGravatarID(), GravatarID: user.GetGravatarID(),
Name: user.GetName(), Name: user.GetName(),
@@ -63,7 +63,30 @@ func User(s github_recon_settings.Settings) (response UserResult, err error) {
Collaborators: fmt.Sprintf("%d", user.GetCollaborators()), Collaborators: fmt.Sprintf("%d", user.GetCollaborators()),
Plan: user.GetPlan().GetName(), Plan: user.GetPlan().GetName(),
} }
r.PrintInfo("Username", u.Username)
r.PrintInfo("ID", u.ID)
r.PrintInfo("Avatar URL", u.AvatarURL)
r.PrintInfo("Gravatar ID", u.GravatarID)
r.PrintInfo("Name", u.Name)
r.PrintInfo("Company", u.Company)
r.PrintInfo("Location", u.Location)
r.PrintInfo("Email", u.Email)
r.PrintInfo("Hireable", u.Hireable)
r.PrintInfo("Bio", u.Bio)
r.PrintInfo("Public Repos", u.PublicRepos)
r.PrintInfo("Public Gists", u.PublicGists)
r.PrintInfo("Followers", u.Followers)
r.PrintInfo("Following", u.Following)
r.PrintInfo("Created At", u.CreatedAt)
r.PrintInfo("Updated At", u.UpdatedAt)
r.PrintInfo("Suspended At", u.SuspendedAt)
r.PrintInfo("Total Private Repos", u.TotalPrivateRepos)
r.PrintInfo("Private Gists", u.PrivateGists)
r.PrintInfo("Disk Usage", u.DiskUsage)
r.PrintInfo("Collaborators", u.Collaborators)
r.PrintInfo("Plan", u.Plan)
r.PrintNewline()
utils.WaitForRateLimit(s, resp) WaitForRateLimit(resp)
return u, nil return u
} }
+174
View File
@@ -0,0 +1,174 @@
package ghrecon
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/google/go-github/v72/github"
)
var (
Grey = lipgloss.Color("#7d7d7d")
Green = lipgloss.Color("#a6e3a1")
Red = lipgloss.Color("#f38ba8")
GreyStyle = lipgloss.NewStyle().Foreground(Grey)
GreenStyle = lipgloss.NewStyle().Foreground(Green)
RedStyle = lipgloss.NewStyle().Foreground(Red)
)
func (r Recon) Header() {
if r.Silent {
return
}
asciiArt := " __ \n ___ _/ / _______ _______ ___ \n / _ `/ _ \\/ __/ -_) __/ _ \\/ _ \\\n \\_, /_//_/_/ \\__/\\__/\\___/_//_/\n/___/ "
fmt.Println(
GreyStyle.Render(lipgloss.JoinVertical(lipgloss.Right, asciiArt, "@anotherhadi\n")),
)
}
func ParseUsername(username string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if strings.Contains(username, " ") {
return fmt.Errorf("username cannot contain spaces")
}
if strings.Contains(username, "@") {
return fmt.Errorf("username cannot contain @")
}
return nil
}
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 (r Recon) PrintNewline() {
if r.Silent {
return
}
fmt.Println()
}
func (r Recon) PrintTitle(title string) {
if r.Silent {
return
}
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7287fd"))
fmt.Println(style.Render(title) + "\n")
}
func (r Recon) PrintInfo(key, value string, more ...string) {
if r.Silent {
return
}
if value == "" || value == "0001-01-01 00:00:00 +0000 UTC" {
return
}
if strings.HasSuffix(key, "n°") {
fmt.Printf(" %s %s", GreyStyle.Render(key), value)
} else {
fmt.Printf(" %s %s", GreyStyle.Render(key+":"), value)
}
if len(more) > 0 {
fmt.Printf(" %s", GreyStyle.Render("("+strings.Join(more, ", ")+")"))
}
fmt.Println()
}
func WaitForRateLimit(resp *github.Response) {
if resp.Rate.Remaining == 0 {
log.Info(
"Rate limit reached, waiting for reset... (time:" + resp.Rate.Reset.Time.String() + ")",
)
time.Sleep(time.Until(resp.Rate.Reset.Time) + time.Second)
}
}
func SkipResult(name, email string) bool {
if name == "github-actions[bot]" || name == "github-actions" {
return true
}
if email == "github-actions[bot]@users.noreply.github.com" ||
email == "[email protected]" {
return true
}
return false
}
func (r Recon) WriteJson(data any) {
if r.JsonFile == "" {
return
}
file, err := os.Create(r.JsonFile)
if err != nil {
r.Logger.Error("Failed to create JSON file", "err", err)
return
}
defer func() {
_ = file.Close()
}()
as_json, _ := json.MarshalIndent(data, "", "\t")
_, err = file.Write(as_json)
if err != nil {
r.Logger.Error("Failed to write to JSON file", "err", err)
return
}
r.PrintInfo("INFO", "JSON file created successfully", "file", r.JsonFile)
}
func folderExists(path string) bool {
if stat, err := os.Stat(path); err == nil && stat.IsDir() {
return true
}
return false
}
-1
View File
@@ -1 +0,0 @@
package github_recon
-44
View File
@@ -1,44 +0,0 @@
package recon
import (
"time"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type EmailResult struct {
DateTime string
Target string
TargetType github_recon_settings.TargetType
Commits CommitsResult
Spoofing *SpoofingResult
}
func Email(settings github_recon_settings.Settings) EmailResult {
result := EmailResult{
Target: settings.Target,
TargetType: settings.TargetType,
DateTime: time.Now().String(),
}
utils.PrintTitle(settings.Silent, "👤 Commits author")
result.Commits = Commits(settings)
utils.PrintStruct(settings, result.Commits, 0)
if settings.SpoofEmail {
if settings.Token == "null" {
settings.Logger.Warn("Skipping email spoofing test, please provide a Github token")
} else {
utils.PrintTitle(settings.Silent, "🎭 Spoofing test")
result.Spoofing = Spoofing(settings)
if result.Spoofing != nil && result.Spoofing.AvatarURL != "" {
utils.PrintAvatar(settings, result.Spoofing.AvatarURL)
}
utils.PrintStruct(settings, result.Spoofing, 0)
}
}
return result
}
-130
View File
@@ -1,130 +0,0 @@
package recon
import (
"math/rand"
"time"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github"
)
type SpoofingResult struct {
Username string
Name string
Email string
Url string
AvatarURL string
}
func RandomString(n int) string {
letters := []rune("abcdefghijklmnopqrstuvwxyz")
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func Spoofing(s github_recon_settings.Settings) (response *SpoofingResult) {
response = &SpoofingResult{}
name := "gh-recon-spoofing-" + RandomString(8)
private := true
autoInit := true
repo, resp, err := s.Client.Repositories.Create(s.Ctx, "", &github.Repository{
Name: &name,
Private: &private,
AutoInit: &autoInit,
})
if err != nil {
s.Logger.Error("Error while creating repo", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
defer func() {
_, err = s.Client.Repositories.Delete(s.Ctx, repo.Owner.GetLogin(), name)
if err != nil {
s.Logger.Error("Error while deleting repo", "err", err)
}
}()
branch := repo.GetDefaultBranch()
if branch == "" {
branch = "main"
}
refName := "heads/" + branch
authorName := "GITHUB-RECON-SPOOFING"
authorEmail := s.Target
author := &github.CommitAuthor{
Name: &authorName,
Email: &authorEmail,
}
ref, resp, err := s.Client.Git.GetRef(s.Ctx, repo.Owner.GetLogin(), name, refName)
if err != nil {
s.Logger.Error("Error while getting ref", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
parentCommit, resp, err := s.Client.Git.GetCommit(s.Ctx, repo.Owner.GetLogin(), name, ref.GetObject().GetSHA())
if err != nil {
s.Logger.Error("Error while getting parent commit", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
commitMessage := "Spoofed empty commit"
commit := &github.Commit{
Author: author,
Message: &commitMessage,
Tree: &github.Tree{SHA: parentCommit.Tree.SHA},
Parents: []*github.Commit{parentCommit},
}
newCommit, resp, err := s.Client.Git.CreateCommit(s.Ctx, repo.Owner.GetLogin(), name, commit, nil)
if err != nil {
s.Logger.Error("Error while creating spoofed empty commit", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
ref.Object.SHA = newCommit.SHA
_, resp, err = s.Client.Git.UpdateRef(s.Ctx, repo.Owner.GetLogin(), name, ref, false)
if err != nil {
s.Logger.Error("Error while updating ref to spoofed commit", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
const maxRetries = 5
const retryDelay = 2 * time.Second
for i := 0; i < maxRetries; i++ {
commits, _, err := s.Client.Repositories.ListCommits(s.Ctx, repo.Owner.GetLogin(), name, nil)
if err != nil {
s.Logger.Error("Error while listing commits", "err", err)
return
}
if len(commits) > 1 {
last := commits[0]
response.Username = last.GetAuthor().GetLogin()
response.Name = last.GetAuthor().GetName()
response.Email = last.GetAuthor().GetEmail()
response.Url = last.GetAuthor().GetHTMLURL()
response.AvatarURL = last.GetAuthor().GetAvatarURL()
break
}
s.Logger.Info("Only one commit found, retrying...", "attempt", i+1)
time.Sleep(retryDelay)
}
if response.Username == "" && response.Name == "" && response.Email == "" {
return nil
}
return
}
-169
View File
@@ -1,169 +0,0 @@
package recon
import (
"errors"
"fmt"
"sort"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github"
)
type CloseFriendsResult []CloseFriendResult
type CloseFriendResult struct {
Name string
Username string
Orgs []string
Company string
Location string
Score int
}
const (
maxTargetFollowing = 50
maxFollowersForCandidate = 20
pointsPerCondition = 1
)
// CloseFriends returns a list of "close friends" for the target user.
// A candidate is considered closer if:
// 1. The target follows fewer than 50 people.
// 2. The candidate has fewer than 20 followers (+1 point).
// 3. The candidate follows the target back (+1 point).
// 4. The candidate shares at least one organization with the target (+1 point).
func CloseFriends(s github_recon_settings.Settings) (results CloseFriendsResult) {
targetFollowing, resp, err := s.Client.Users.ListFollowing(s.Ctx, s.Target, &github.ListOptions{PerPage: 100})
if err != nil {
s.Logger.Error("Failed to fetch target's following list", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
targetOrgs, err := getOrgs(s, s.Target)
if err != nil {
s.Logger.Error("Failed to fetch target's organizations", "err", err)
targetOrgs = []*github.Organization{}
}
if len(targetFollowing) > maxTargetFollowing {
s.Logger.Info("Skipping close friends check",
"reason",
fmt.Sprintf("Target follows %d or more users (limit: %d)", len(targetFollowing), maxTargetFollowing),
)
return
}
if len(targetFollowing) == 0 {
return
}
for _, candidate := range targetFollowing {
candidateLogin := candidate.GetLogin()
if candidateLogin == "" {
continue
}
score := 0
candidateDetails, userResp, err := s.Client.Users.Get(s.Ctx, candidateLogin)
if err != nil {
s.Logger.Warn("Failed to fetch details for candidate",
"candidate", candidateLogin,
"err", err,
)
if userResp != nil {
utils.WaitForRateLimit(s, userResp)
}
continue
}
utils.WaitForRateLimit(s, userResp)
// Condition: candidate has few followers
if candidateDetails.GetFollowers() < maxFollowersForCandidate {
score += pointsPerCondition
}
// Condition: candidate follows target back
followsBack, err := checkIfUserFollows(s, candidateLogin, s.Target)
if err == nil && followsBack {
score += pointsPerCondition
}
// Condition: same organization
candidateOrgs, _ := getOrgs(s, candidateLogin)
if isInSameOrg(targetOrgs, candidateOrgs) {
score += pointsPerCondition
}
// Add candidate if they matched at least one condition
if score > 0 {
results = append(results, CloseFriendResult{
Name: candidateDetails.GetName(),
Username: candidateLogin,
Orgs: candidateOrgsToNames(candidateOrgs),
Score: score,
Location: candidateDetails.GetLocation(),
Company: candidateDetails.GetCompany(),
})
}
}
if len(results) > 0 {
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
}
return
}
// checkIfUserFollows checks if sourceUser follows targetUser.
func checkIfUserFollows(s github_recon_settings.Settings, sourceUser, targetUser string) (bool, error) {
isFollowing, resp, err := s.Client.Users.IsFollowing(s.Ctx, sourceUser, targetUser)
if err != nil {
s.Logger.Warn("Error checking if user follows target",
"source", sourceUser,
"target", targetUser,
"err", err,
)
if resp != nil {
utils.WaitForRateLimit(s, resp)
}
return false, err
}
if resp != nil {
utils.WaitForRateLimit(s, resp)
}
return isFollowing, nil
}
func candidateOrgsToNames(orgs []*github.Organization) []string {
var orgNames []string
for _, org := range orgs {
if org.GetLogin() != "" {
orgNames = append(orgNames, org.GetLogin())
}
}
return orgNames
}
func getOrgs(s github_recon_settings.Settings, user string) ([]*github.Organization, error) {
orgs, resp, err := s.Client.Organizations.List(s.Ctx, user, nil)
if err != nil {
return nil, errors.New("failed to fetch organizations for user")
}
utils.WaitForRateLimit(s, resp)
return orgs, nil
}
func isInSameOrg(orgsA, orgsB []*github.Organization) bool {
for _, orgA := range orgsA {
for _, orgB := range orgsB {
if orgA.GetLogin() == orgB.GetLogin() {
return true
}
}
}
return false
}
-105
View File
@@ -1,105 +0,0 @@
package recon
import (
"fmt"
"strings"
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 {
emails := []string{
item.Commit.GetAuthor().GetEmail(),
item.Commit.GetCommitter().GetEmail(),
item.GetAuthor().GetEmail(),
item.GetCommitter().GetEmail(),
}
names := []string{
item.Commit.GetAuthor().GetName(),
item.Commit.GetCommitter().GetName(),
item.GetAuthor().GetName(),
item.GetCommitter().GetName(),
}
for i := range names {
name := names[i]
email := emails[i]
if utils.SkipResult(name, email) {
continue
}
if name == "" || email == "" {
continue
}
if _, seen := results[strings.ToLower(name)+" - "+strings.ToLower(email)]; !seen {
author := CommitResult{
Name: name,
Email: email,
Occurrences: 1,
FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository().
GetName(),
}
results[strings.ToLower(name)+" - "+strings.ToLower(email)] = author
} else if i == 0 {
result := results[strings.ToLower(name)+" - "+strings.ToLower(email)]
result.Occurrences++
results[strings.ToLower(name)+" - "+strings.ToLower(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
}
-394
View File
@@ -1,394 +0,0 @@
package recon
import (
"encoding/json"
"fmt"
"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 []Author
type Author struct {
Name string
Levenshtein int
Email string
FoundIn []string
}
type Emails []Email
type Email struct {
Email string
Levenshtein int
FoundIn []string
}
type Secrets []Secret
type Secret struct {
Repositorie string
Raw map[string]any
}
type DeepScanResult struct {
Authors Authors
Emails Emails
Secrets Secrets
}
type Repositorie struct {
Repository string
Owner string
Name string
Size int
}
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
}
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)
if len(repositories) == 0 {
s.Logger.Info("No repositories found for the user, skipping deep scan.")
return
}
authorOccurrences := Authors{}
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, Author{
Name: authorName,
Email: authorEmail,
FoundIn: []string{repoIdentifier},
Levenshtein: levenshteinDistanceAuthor(s.Target, authorName, authorEmail),
})
mapAuthorToIndex[trimmedLine] = len(authorOccurrences) - 1
}
}
}
}
}
slices.SortFunc(authorOccurrences, func(a, b Author) int {
if a.Levenshtein != b.Levenshtein {
return a.Levenshtein - b.Levenshtein
}
return 1
})
authors := Authors{}
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 Email) int {
if a.Levenshtein != b.Levenshtein {
return a.Levenshtein - b.Levenshtein
}
return 1
})
emails := Emails{}
for _, email := range emailsFound {
if email.Levenshtein > s.MaxDistance {
continue
}
emails = append(emails, email)
}
response.Authors = authors
response.Emails = emails
s.Logger.Info("Searching for secrets in cloned repositories", "path", tmp_folder)
if s.Trufflehog {
cmd := exec.Command("trufflehog", "--version")
if err := cmd.Run(); err != nil {
s.Logger.Warn("Trufflehog is not installed, skipping secret scanning.")
} else {
secrets, err := truffleHog(tmp_folder)
if err != nil {
s.Logger.Error("Failed to run trufflehog", "err", err)
} else {
response.Secrets = secrets
}
}
}
return
}
func truffleHog(tmpFolder string) (Secrets, error) {
allSecrets := Secrets{}
directories, err := os.ReadDir(tmpFolder)
if err != nil {
return nil, fmt.Errorf("failed to read tmp folder: %w", err)
}
for _, dir := range directories {
if !dir.IsDir() {
continue
}
innerPath := filepath.Join(tmpFolder, dir.Name())
innerDirectories, err := os.ReadDir(innerPath)
if err != nil {
return nil, fmt.Errorf("failed to read inner tmp folder: %w", err)
}
for _, innerDir := range innerDirectories {
if !innerDir.IsDir() {
continue
}
repoPath := filepath.Join(innerPath, innerDir.Name())
cmd := exec.Command("trufflehog", "git", "file://"+repoPath, "--json", "--log-level=-1", "--results=verified")
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.ExitCode() > 1 {
return nil, fmt.Errorf("failed to execute trufflehog (ExitError): %s", string(exitErr.Stderr))
}
} else {
return nil, fmt.Errorf("failed to execute trufflehog: %w", err)
}
}
decoder := json.NewDecoder(strings.NewReader(string(output)))
for decoder.More() {
var result map[string]any
if err := decoder.Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse trufflehog output: %w", err)
}
allSecrets = append(allSecrets, Secret{
Repositorie: dir.Name() + "/" + innerDir.Name(),
Raw: result,
})
}
}
}
return allSecrets, nil
}
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.Type().IsRegular() {
if strings.Contains(path, ".git/logs/") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
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 Emails
for email, pathSet := range emailLocations {
var paths []string
for path := range pathSet {
paths = append(paths, path)
}
results = append(results, Email{
Email: email, FoundIn: paths,
Levenshtein: utils.LevenshteinDistance(username, strings.SplitN(email, "@", 2)[0]),
})
}
return results, nil
}
func levenshteinDistanceAuthor(target, name, email string) int {
if strings.Contains(email, "@") {
email = strings.SplitN(email, "@", 2)[0]
}
return slices.Min([]int{utils.LevenshteinDistance(target, name), utils.LevenshteinDistance(target, email)})
}
-135
View File
@@ -1,135 +0,0 @@
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
}
-80
View File
@@ -1,80 +0,0 @@
package recon
import (
"time"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type UsernameResult struct {
DateTime string // Now
Target string
TargetType github_recon_settings.TargetType
User UserResult
Socials SocialsResult
Orgs OrgsResult
SshKeys SshKeysResult
SshSigningKeys SshSigningKeysResult
GpgKeys GpgKeysResult
CloseFriends CloseFriendsResult
Commits CommitsResult
DeepScan DeepScanResult
}
func Username(settings github_recon_settings.Settings) (result UsernameResult, err error) {
result = UsernameResult{
Target: settings.Target,
TargetType: settings.TargetType,
DateTime: time.Now().String(),
}
utils.PrintTitle(settings.Silent, "👤 User informations")
result.User, err = User(settings)
if err != nil {
return
}
utils.PrintAvatar(settings, result.User.AvatarURL)
utils.PrintStruct(settings, result.User, 0)
utils.PrintTitle(settings.Silent, "🐥 Socials")
result.Socials = Socials(settings)
utils.PrintStruct(settings, result.Socials, 0)
utils.PrintTitle(settings.Silent, "🏢 Organizations")
result.Orgs = Orgs(settings)
utils.PrintStruct(settings, result.Orgs, 0)
utils.PrintTitle(settings.Silent, "🔑 SSH Keys")
result.SshKeys = SshKeys(settings)
utils.PrintStruct(settings, result.SshKeys, 0)
utils.PrintTitle(settings.Silent, "🖋️ SSH Signing Keys")
result.SshSigningKeys = SshSigningKeys(settings)
utils.PrintStruct(settings, result.SshSigningKeys, 0)
utils.PrintTitle(settings.Silent, "🔐 GPG Keys")
result.GpgKeys = GpgKeys(settings)
utils.PrintStruct(settings, result.GpgKeys, 0)
utils.PrintTitle(settings.Silent, "🤝 Close Friends")
result.CloseFriends = CloseFriends(settings)
utils.PrintStruct(settings, result.CloseFriends, 0)
utils.PrintTitle(settings.Silent, "📝 Commits")
result.Commits = Commits(settings)
utils.PrintStruct(settings, result.Commits, 0)
if settings.DeepScan {
utils.PrintTitle(settings.Silent, "🔍 Deep Scan")
result.DeepScan = DeepScan(settings)
utils.PrintStruct(settings, result.DeepScan, 0)
}
return
}
-35
View File
@@ -1,35 +0,0 @@
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
@@ -1,45 +0,0 @@
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
}
+4 -7
View File
@@ -1,14 +1,12 @@
module github.com/anotherhadi/github-recon module github.com/anotherhadi/gh-recon
go 1.24.5 go 1.24.2
require ( require (
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.1
github.com/google/go-github/v72 v72.0.0 github.com/google/go-github/v72 v72.0.0
github.com/joho/godotenv v1.5.1 github.com/spf13/pflag v1.0.6
github.com/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51
github.com/spf13/pflag v1.0.7
) )
require ( require (
@@ -26,6 +24,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // 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 golang.org/x/sys v0.30.0 // indirect
) )
+4 -10
View File
@@ -4,8 +4,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 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/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 h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
@@ -23,8 +23,6 @@ github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi65
github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -38,18 +36,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51/go.mod h1:sqhdZVLvqzTEBtmZBuTnFDUW0Lsryw2X2/wrLgqLEYg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 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/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"context"
"os"
"strings"
ghrecon "github.com/anotherhadi/gh-recon/gh-recon"
"github.com/charmbracelet/log"
"github.com/google/go-github/v72/github"
flag "github.com/spf13/pflag"
)
func main() {
var username string
var token string
var onlyCommitsLeak bool
var fromEmail string
var deep bool
var silent bool
var jsonFile string
var excludeRepos string
var maxRepoSize int
var refresh bool
// FLAGS
flag.StringVarP(&username, "username", "u", "", "GitHub username to analyze")
flag.StringVarP(&token, "token", "t", "", "GitHub personal access token (e.g. ghp_...)")
flag.StringVarP(&fromEmail, "email", "e", "", "Search accounts by email address")
flag.BoolVarP(
&deep,
"deep",
"d",
false,
"Enable deep scan (clone repos, regex search, analyse licenses, etc.)",
)
flag.IntVar(
&maxRepoSize,
"max-size",
150,
"Limit the size of repositories to scan (in MB) (only for deep scan)",
)
flag.StringVar(
&excludeRepos,
"exclude-repo",
"",
"Exclude repos from deep scan (comma-separated list, only for deep scan)",
)
flag.BoolVarP(
&refresh,
"refresh",
"r",
false,
"Refresh the cache (only for deep scan)",
)
flag.BoolVarP(
&onlyCommitsLeak,
"only-commits",
"c",
false,
"Display only commits with author info",
)
flag.BoolVarP(&silent, "silent", "s", false, "Suppress all non-essential output")
flag.StringVarP(&jsonFile, "json", "j", "", "Write results to specified JSON file")
// FLAGS SETTINGS
flag.CommandLine.SetNormalizeFunc(wordSepNormalizeFunc)
flag.CommandLine.SortFlags = false
flag.Parse()
// INITIALIZE RECON OBJECT
r := &ghrecon.Recon{
Client: github.NewClient(nil),
Logger: log.NewWithOptions(os.Stderr, log.Options{
ReportCaller: false,
ReportTimestamp: false,
}),
Ctx: context.Background(),
Silent: silent,
JsonFile: jsonFile,
MaxRepoSize: maxRepoSize,
}
// CHECK FLAGS
if username == "" && fromEmail == "" {
r.Logger.Fatal(
"Please provide a username with the --username (-u) flag or an email with the --email (-e) flag",
)
} else if username != "" {
username = strings.TrimPrefix(username, "@")
if err := ghrecon.ParseUsername(username); err != nil {
r.Logger.Fatal("Invalid username", "err", err)
}
}
if token == "" {
r.PrintInfo(
"INFO",
"It's recommended to set a Github token for better rate limits. You can set it using the --token (-t) flag.",
)
} else {
r.Client = r.Client.WithAuthToken(token)
}
// START
r.Header()
if fromEmail != "" {
emailsInfo := r.Email(fromEmail)
r.WriteJson(
map[string]any{
"Authors": emailsInfo,
},
)
return
}
if onlyCommitsLeak {
commitsInfo := r.Commits(username)
r.WriteJson(
map[string]any{
"Authors": commitsInfo,
},
)
return
}
userInfo := r.User(username)
orgsInfo := r.Orgs(username)
sshKeysInfo := r.SshKeys(username)
gpgKeysInfo := r.GpgKeys(username)
sshSigningKeysInfo := r.SshSigningKeys(username)
socialsInfo := r.Socials(username)
closeFriendsInfo := r.CloseFriends(username)
commitsInfo := r.Commits(username)
results := map[string]any{
"User": userInfo,
"Orgs": orgsInfo,
"SSHKeys": sshKeysInfo,
"GPGKeys": gpgKeysInfo,
"SSHSigningKeys": sshSigningKeysInfo,
"Socials": socialsInfo,
"Commits": commitsInfo,
"CloseFriends": closeFriendsInfo,
}
if deep {
results["Deep"] = r.Deep(username, excludeRepos, refresh)
}
r.WriteJson(results)
}
-176
View File
@@ -1,176 +0,0 @@
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
PrintAvatar bool
SpoofEmail bool
Trufflehog bool
// Internal
Client *github.Client
Logger *log.Logger
Ctx context.Context
}
func GetDefaultSettings() Settings {
return Settings{
Token: "null",
Target: "",
TargetType: TargetUsername,
ShowSource: false,
Refresh: false,
MaxRepoSize: 150,
ExcludedRepos: []string{},
JsonOutput: "",
Silent: false,
DeepScan: false,
MaxDistance: 20,
PrintAvatar: true,
SpoofEmail: true,
Trufflehog: true,
Client: github.NewClient(nil),
Logger: log.NewWithOptions(os.Stderr, log.Options{
ReportCaller: false,
ReportTimestamp: false,
}),
Ctx: context.WithValue(context.Background(), github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true),
}
}
func GetSettings() (settings Settings, err error) {
settings = GetDefaultSettings()
//// 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", settings.Token, "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", settings.DeepScan, "Enable deep scan (clone repos, regex search, analyse licenses, etc.)")
flag.IntVar(
&settings.MaxRepoSize,
"max-size",
settings.MaxRepoSize,
"Limit the size of repositories to scan (in MB) (only for deep scan)",
)
flag.StringSliceVarP(
&settings.ExcludedRepos,
"exclude-repo",
"e",
settings.ExcludedRepos,
"Exclude repos from deep scan (comma-separated list, only for deep scan)",
)
flag.BoolVarP(
&settings.Refresh,
"refresh",
"r",
settings.Refresh,
"Refresh the cache (only for deep scan)",
)
flag.BoolVarP(
&settings.ShowSource,
"show-source",
"s",
settings.ShowSource,
"Show where the information (authors, emails, etc) were found (only for deep scan)",
)
flag.IntVarP(
&settings.MaxDistance,
"max-distance",
"m",
settings.MaxDistance,
"Maximum Levenshtein distance for matching usernames & emails (only for deep scan)",
)
flag.BoolVar(
&settings.Trufflehog,
"trufflehog",
settings.Trufflehog,
"Run trufflehog on cloned repositories (only for deep scan)",
)
flag.BoolVarP(&settings.Silent, "silent", "S", settings.Silent, "Suppress all non-essential output")
flag.BoolVarP(&settings.SpoofEmail, "spoof-email", "", settings.SpoofEmail, "Spoof email (only for email mode)")
flag.BoolVarP(&settings.PrintAvatar, "print-avatar", "a", settings.PrintAvatar, "Show the avatar in the output")
flag.StringVarP(&settings.JsonOutput, "json", "j", settings.JsonOutput, "Write results to specified JSON file")
//// Parse
flag.Parse()
//// 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) == 0 {
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, " ") {
err = fmt.Errorf("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 == "" {
settings.Token = GetToken()
}
if settings.Token == "null" || settings.Token == "" {
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
}
-40
View File
@@ -1,40 +0,0 @@
package github_recon_settings
import (
"os"
"path/filepath"
"strings"
"github.com/joho/godotenv"
flag "github.com/spf13/pflag"
)
// GetToken retrieves the GitHub token from the environment variable or config file
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)
}
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"strings"
flag "github.com/spf13/pflag"
)
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)
}
-175
View File
@@ -1,175 +0,0 @@
package utils
import (
"fmt"
"io"
"net/http"
"os"
"reflect"
"sort"
"strings"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/charmbracelet/lipgloss"
gopixels "github.com/saran13raj/go-pixels"
)
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)
if !v.IsValid() {
return
}
t := reflect.TypeOf(s)
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
if v.IsNil() {
return
}
v = v.Elem()
t = v.Type()
}
switch v.Kind() {
case reflect.Struct:
if v.NumField() == 0 {
fmt.Println(prefix + greyStyle.Render("No data found"))
fmt.Println("")
return
}
printed := 0
for i := 0; i < v.NumField(); i++ {
field := t.Field(i).Name
value := v.Field(i)
if !value.CanInterface() {
continue
}
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
}
printed++
switch value.Kind() {
case reflect.Struct, reflect.Slice, reflect.Array, reflect.Ptr, reflect.Map, reflect.Interface:
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())))
}
}
if printed == 0 {
fmt.Println(prefix + greyStyle.Render("No data found"))
}
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)
}
case reflect.Map:
if v.Len() == 0 {
fmt.Println(prefix + greyStyle.Render("No data found"))
return
}
keys := v.MapKeys()
keyStrs := make([]string, len(keys))
for i, k := range keys {
keyStrs[i] = fmt.Sprintf("%v", k.Interface())
}
sort.Strings(keyStrs)
for _, keyStr := range keyStrs {
for _, k := range keys {
if fmt.Sprintf("%v", k.Interface()) == keyStr {
val := v.MapIndex(k)
fmt.Println(prefix + greyStyle.Render(fmt.Sprintf("%v:", k.Interface())))
PrintStruct(settings, val.Interface(), indent+1)
}
}
}
default:
fmt.Println(prefix + greenStyle.Render(fmt.Sprintf("%v", v.Interface())))
}
}
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")
}
func PrintAvatar(settings github_recon_settings.Settings, url string) {
if !settings.PrintAvatar || 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")
}
-121
View File
@@ -1,121 +0,0 @@
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 == "GITHUB-RECON-SPOOFING" || name == "dependabot[bot]" || name == "github-actions" || name == "GitHub Actions" {
return true
}
if email == "github-actions[bot]@users.noreply.github.com" || email == "[email protected]" ||
email == "[email protected]" || email == "41898282+github-actions[bot]@users.noreply.github.com" || email == "49699333+dependabot[bot]@users.noreply.github.com" {
return true
}
return false
}