Init v2, rewrite

This commit is contained in:
Hadi
2025-08-22 21:58:54 +02:00
commit af4046d234
29 changed files with 1864 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
package recon
import (
"fmt"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github"
)
type EmailsResult []EmailResult
type EmailResult struct {
Name string
Email string
Username string
Occurrences int
FirstFoundIn string
}
func Email(s github_recon_settings.Settings) (response EmailsResult) {
results := make(map[string]EmailResult)
collect := func(date string) error {
for page := 1; page <= 10; page++ {
result, resp, err := s.Client.Search.Commits(
s.Ctx,
fmt.Sprintf("author-email:%s author-date:%s", s.Target, date),
&github.SearchOptions{
Sort: "author-date",
Order: "desc",
ListOptions: github.ListOptions{PerPage: 100, Page: page},
},
)
if err != nil {
return fmt.Errorf("fetch page %d (%s): %w", page, date, err)
}
utils.WaitForRateLimit(s, resp)
if len(result.Commits) == 0 {
break
}
for _, item := range result.Commits {
name := item.Commit.GetAuthor().GetName()
email := item.Commit.GetAuthor().GetEmail()
login := item.GetAuthor().GetLogin()
if login == "" {
login = "Unknown"
}
if utils.SkipResult(name, email) {
continue
}
if _, seen := results[name+" - "+email+" - "+login]; !seen {
author := EmailResult{
Name: name,
Email: email,
Username: login,
Occurrences: 1,
FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository().
GetName(),
}
results[name+" - "+email+" - "+login] = author
} else {
result := results[name+" - "+email+" - "+login]
result.Occurrences++
results[name+" - "+email+" - "+login] = result
}
}
}
return nil
}
// Range of dates to bypass the limit of 1000 results
for _, date := range []string{
"<2023-01-01", "2023-01-01..2023-12-31",
"2024-01-01..2024-05-31",
"2024-06-01..2024-12-31",
"2025-01-01..2025-05-31",
"2025-06-01..2025-12-31",
">2026-01-01",
} {
if err := collect(date); err != nil {
s.Logger.Error("Failed to fetch commits", "err", err, "date", date)
}
}
for _, result := range results {
response = append(response, result)
}
return
}
+1
View File
@@ -0,0 +1 @@
package recon
+120
View File
@@ -0,0 +1,120 @@
package recon
import (
"fmt"
"sort"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type CloseFriendsResult []CloseFriendResult
type CloseFriendResult struct {
Login string
Score int
}
const (
maxFollowingForTarget = 50
maxFollowersForFollowing = 20
pointPerCriterion = 1
)
// CloseFriends returns a list of close friends of the user
// To derive this, we check the following:
// 1. The target has less than 50 Following
// 2. The target's following has less than 20 followers (+1 point)
// 3. The target's following follows the target (+1 point)
func CloseFriends(s github_recon_settings.Settings) (response CloseFriendsResult) {
following, resp, err := s.Client.Users.ListFollowing(s.Ctx, s.Target, nil)
if err != nil {
s.Logger.Error("Failed to fetch user's following list", "err", err)
return
}
utils.WaitForRateLimit(s, resp)
if len(following) >= maxFollowingForTarget {
s.Logger.Info("Skipping close friends check", "reason", fmt.Sprintf("Target follows %d or more users (%d)", maxFollowingForTarget, len(following)))
return
}
if len(following) == 0 {
return
}
for _, userBeingFollowedByTarget := range following {
loginName := userBeingFollowedByTarget.GetLogin()
if loginName == "" {
continue
}
currentScore := 0
userDetails, userResp, userErr := s.Client.Users.Get(s.Ctx, loginName)
if userErr != nil {
s.Logger.Warn(
"Failed to fetch details for followed user",
"followed_user",
loginName,
"err",
userErr,
)
if userResp != nil {
utils.WaitForRateLimit(s, userResp)
}
continue
}
utils.WaitForRateLimit(s, userResp)
if userDetails.GetFollowers() < maxFollowersForFollowing {
currentScore += pointPerCriterion
}
followsTargetBack, checkErr := checkIfUserFollows(s, loginName, s.Target)
if checkErr != nil {
continue
} else if followsTargetBack {
currentScore += pointPerCriterion
}
if currentScore > 0 {
response = append(response, CloseFriendResult{
Login: loginName,
Score: currentScore,
})
}
}
if len(response) == 0 {
return
} else {
sort.Slice(response, func(i, j int) bool {
return response[i].Score > response[j].Score
})
}
return
}
// checkIfUserFollows checks if sourceUserLogin follows targetUserLogin.
func checkIfUserFollows(s github_recon_settings.Settings, sourceUserLogin, targetUserLogin string) (bool, error) {
isFollowing, resp, err := s.Client.Users.IsFollowing(s.Ctx, sourceUserLogin, targetUserLogin)
if err != nil {
s.Logger.Warn("Error checking if user follows target",
"source_user_checking", sourceUserLogin,
"target_user_to_check", targetUserLogin,
"err", err)
if resp != nil {
utils.WaitForRateLimit(s, resp)
}
return false, err
}
if resp != nil {
utils.WaitForRateLimit(s, resp)
}
return isFollowing, nil
}
+85
View File
@@ -0,0 +1,85 @@
package recon
import (
"fmt"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github"
)
type CommitsResult []CommitResult
type CommitResult struct {
Name string
Email string
Occurrences int
FirstFoundIn string
}
func Commits(s github_recon_settings.Settings) (response CommitsResult) {
results := make(map[string]CommitResult)
collect := func(date string) error {
for page := 1; page <= 10; page++ {
result, resp, err := s.Client.Search.Commits(
s.Ctx,
fmt.Sprintf("author:%s author-date:%s", s.Target, date),
&github.SearchOptions{
Sort: "author-date",
Order: "desc",
ListOptions: github.ListOptions{PerPage: 100, Page: page},
},
)
if err != nil {
return fmt.Errorf("fetch page %d (%s): %w", page, date, err)
}
utils.WaitForRateLimit(s, resp)
if len(result.Commits) == 0 {
break
}
for _, item := range result.Commits {
name := item.Commit.GetAuthor().GetName()
email := item.Commit.GetAuthor().GetEmail()
if utils.SkipResult(name, email) {
continue
}
if _, seen := results[name+" - "+email]; !seen {
author := CommitResult{
Name: name,
Email: email,
Occurrences: 1,
FirstFoundIn: item.GetRepository().Owner.GetLogin() + "/" + item.GetRepository().
GetName(),
}
results[name+" - "+email] = author
} else {
result := results[name+" - "+email]
result.Occurrences++
results[name+" - "+email] = result
}
}
}
return nil
}
// Range of dates to bypass the limit of 1000 results
for _, date := range []string{
"<2023-01-01", "2023-01-01..2023-12-31",
"2024-01-01..2024-05-31",
"2024-06-01..2024-12-31",
"2025-01-01..2025-05-31",
"2025-06-01..2025-12-31",
">2026-01-01",
} {
if err := collect(date); err != nil {
s.Logger.Error("Failed to fetch commits", "err", err, "date", date)
}
}
for _, result := range results {
response = append(response, result)
}
return
}
+304
View File
@@ -0,0 +1,304 @@
package recon
import (
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
"github.com/google/go-github/v72/github"
)
type Authors []AuthorOccurrence
type AuthorOccurrence struct {
Name string
Levenshtein int
Email string
FoundIn []string
}
type Emails []EmailOccurrence
type EmailOccurrence struct {
Email string
Levenshtein int
FoundIn []string
}
type DeepScanResult struct {
Authors []AuthorOccurrence
Emails []EmailOccurrence
}
type Repositorie struct {
Repository string
Owner string
Name string
Size int
}
func findEmailsAndOccurrencesInDir(rootPath string, username string) (Emails, error) {
emailLocations := make(map[string]map[string]bool)
emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
normalizedRootPath := filepath.Clean(rootPath)
err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
if strings.Contains(path, ".git/logs/") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
currentFileEmails := emailRegex.FindAllString(string(content), -1)
if len(currentFileEmails) > 0 {
relativePath, errRel := filepath.Rel(normalizedRootPath, path)
if errRel != nil {
relativePath = path
}
for _, email := range currentFileEmails {
if len(email) > 12 {
if _, ok := emailLocations[email]; !ok {
emailLocations[email] = make(map[string]bool)
}
emailLocations[email][relativePath] = true
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
var results []EmailOccurrence
for email, pathSet := range emailLocations {
var paths []string
for path := range pathSet {
paths = append(paths, path)
}
results = append(results, EmailOccurrence{
Email: email, FoundIn: paths,
Levenshtein: utils.LevenshteinDistance(username, strings.SplitN(email, "@", 2)[0]),
})
}
return results, nil
}
func DeepScan(s github_recon_settings.Settings) (response DeepScanResult) {
repositories := []Repositorie{}
repos, resp, err := s.Client.Repositories.ListByUser(
s.Ctx,
s.Target,
&github.RepositoryListByUserOptions{
Type: "all",
},
)
if err != nil {
s.Logger.Error("Failed to fetch repositories", "err", err)
return
}
// r.PrintTitle("📦 Repositories")
for _, repo := range repos {
if slices.Contains(s.ExcludedRepos, repo.GetName()) ||
slices.Contains(s.ExcludedRepos, repo.GetOwner().GetLogin()+"/"+repo.GetName()) {
continue
}
maxRepoSize := s.MaxRepoSize * 1024
if repo.GetSize() > maxRepoSize {
s.Logger.Info("Skipping repository due to size", "repo", repo.GetOwner().GetLogin()+"/"+repo.GetName(), "size_MB", repo.GetSize()/1024, "max_size_MB", maxRepoSize/1024)
continue
}
repositories = append(repositories, Repositorie{
Repository: repo.GetCloneURL(),
Owner: repo.GetOwner().GetLogin(),
Name: repo.GetName(),
Size: repo.GetSize(),
})
}
utils.WaitForRateLimit(s, resp)
cmd := exec.Command("git", "--version")
if err := cmd.Run(); err != nil {
s.Logger.Error("Git is not installed", "err", err)
return
}
tmp_folder := "/tmp/ghrecon-" + s.Target
if utils.DoesFolderExists(tmp_folder) {
if s.Refresh {
s.Logger.Info("Deleting existing folder", "path", tmp_folder)
err := os.RemoveAll(tmp_folder)
if err != nil {
s.Logger.Error("Failed to delete existing folder", "path", tmp_folder, "err", err)
return
}
}
}
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if utils.DoesFolderExists(destination) {
s.Logger.Info("Directory already downloaded, skipping", "repo", repo.Owner+"/"+repo.Name, "path", destination)
continue
}
s.Logger.Info("Cloning repository", "repo", repo.Owner+"/"+repo.Name, "path", destination, "size_MB", repo.Size/1024)
cmd := exec.Command(
"git",
"clone",
repo.Repository,
destination,
)
err := cmd.Run()
if err != nil {
s.Logger.Error(
"ERROR",
"Failed to clone repository",
"err",
err,
"repo",
repo.Repository,
)
continue
}
}
s.Logger.Info("Cloned all repositories", "path", tmp_folder)
authorOccurrences := []AuthorOccurrence{}
mapAuthorToIndex := make(map[string]int)
for _, repo := range repositories {
destination := tmp_folder + "/" + repo.Owner + "/" + repo.Name
if !utils.DoesFolderExists(filepath.Join(destination, ".git")) {
s.Logger.Error(
"No .git directory found, cannot run git log.",
"repo",
repo.Owner+"/"+repo.Name,
"path",
destination,
)
} else {
gitLogCmd := exec.Command("git", "log", "--all", "--format=%aN <%aE>")
gitLogCmd.Dir = destination
logOutput, logErr := gitLogCmd.Output()
if logErr != nil {
if exitErr, ok := logErr.(*exec.ExitError); ok {
s.Logger.Error("Failed to execute git log (ExitError)", "repo", repo.Owner+"/"+repo.Name, "stderr", string(exitErr.Stderr), "err", logErr)
} else {
s.Logger.Error("Failed to execute git log", "repo", repo.Owner+"/"+repo.Name, "err", logErr)
}
} else {
lines := strings.Split(string(logOutput), "\n")
repoIdentifier := repo.Owner + "/" + repo.Name
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
continue
}
if index, exists := mapAuthorToIndex[trimmedLine]; exists {
isRepoListed := false
for _, foundRepo := range authorOccurrences[index].FoundIn {
if foundRepo == repoIdentifier {
isRepoListed = true
break
}
}
if !isRepoListed {
authorOccurrences[index].FoundIn = append(authorOccurrences[index].FoundIn, repoIdentifier)
slices.Sort(authorOccurrences[index].FoundIn)
}
} else {
parts := strings.SplitN(trimmedLine, " <", 2)
var authorName, authorEmail string
if len(parts) == 2 {
authorName = parts[0]
authorEmail = strings.TrimSuffix(parts[1], ">")
} else if len(parts) == 1 {
authorName = "-"
authorEmail = strings.TrimPrefix(strings.TrimSuffix(parts[0], ">"), "<")
} else {
s.Logger.Error("Malformed author line from git log", "line", trimmedLine, "repo", repoIdentifier)
continue
}
authorOccurrences = append(authorOccurrences, AuthorOccurrence{
Name: authorName,
Email: authorEmail,
FoundIn: []string{repoIdentifier},
Levenshtein: utils.LevenshteinDistance(s.Target, authorName),
})
mapAuthorToIndex[trimmedLine] = len(authorOccurrences) - 1
}
}
}
}
}
slices.SortFunc(authorOccurrences, func(a, b AuthorOccurrence) int {
if a.Levenshtein != b.Levenshtein {
return a.Levenshtein - b.Levenshtein
}
return 1
})
authors := []AuthorOccurrence{}
for _, author := range authorOccurrences {
if author.Levenshtein > s.MaxDistance {
continue
}
if utils.SkipResult(author.Name, author.Email) {
continue
}
authors = append(authors, author)
}
s.Logger.Info("Searching for emails in cloned repositories", "path", tmp_folder)
emailsFound, err := findEmailsAndOccurrencesInDir(tmp_folder, s.Target)
if err != nil {
s.Logger.Error("Failed to find emails in directory", "err", err)
return
}
slices.SortFunc(emailsFound, func(a, b EmailOccurrence) int {
if a.Levenshtein != b.Levenshtein {
return a.Levenshtein - b.Levenshtein
}
return 1
})
emails := []EmailOccurrence{}
for _, email := range emailsFound {
if email.Levenshtein > s.MaxDistance {
continue
}
emails = append(emails, email)
}
response.Authors = authors
response.Emails = emails
return
}
+135
View File
@@ -0,0 +1,135 @@
package recon
import (
"fmt"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type SshKeysResult []SshKeyResult
type SshKeyResult struct {
Url string
Title string
CreatedAt string
Key string
ReadOnly string
Verified string
LastUsed string
AddedBy string
}
func SshKeys(s github_recon_settings.Settings) (response SshKeysResult) {
sshKeys, resp, err := s.Client.Users.ListKeys(s.Ctx, s.Target, nil)
if err != nil {
s.Logger.Error("Failed to fetch ssh keys", "err", err)
return
}
for _, key := range sshKeys {
k := SshKeyResult{
Url: key.GetURL(),
Title: key.GetTitle(),
CreatedAt: key.GetCreatedAt().String(),
Key: key.GetKey(),
ReadOnly: fmt.Sprintf("%t", key.GetReadOnly()),
Verified: fmt.Sprintf("%t", key.GetVerified()),
LastUsed: key.GetLastUsed().String(),
AddedBy: key.GetAddedBy(),
}
response = append(response, k)
}
utils.WaitForRateLimit(s, resp)
return
}
type GpgKeyEmail struct {
Email string
Verified string
}
type GpgKeysResult []GpgKeyResult
type GpgKeyResult struct {
KeyID string
PublicKey string
CreatedAt string
PrimaryKeyID string
RawKey string
Emails []GpgKeyEmail
Subkeys []GpgKeyResult
}
func GpgKeys(s github_recon_settings.Settings) (response GpgKeysResult) {
gpgKeys, resp, err := s.Client.Users.ListGPGKeys(s.Ctx, s.Target, nil)
if err != nil {
s.Logger.Error("Failed to fetch user's gpg keys", "err", err)
return
}
for _, key := range gpgKeys {
k := GpgKeyResult{
KeyID: key.GetKeyID(),
PublicKey: key.GetPublicKey(),
CreatedAt: key.GetCreatedAt().String(),
PrimaryKeyID: fmt.Sprintf("%d", key.GetPrimaryKeyID()),
RawKey: key.GetRawKey(),
Emails: []GpgKeyEmail{},
Subkeys: []GpgKeyResult{},
}
for _, email := range key.Emails {
email := GpgKeyEmail{
Email: email.GetEmail(),
Verified: fmt.Sprintf("%t", email.GetVerified()),
}
k.Emails = append(k.Emails, email)
}
for _, subkey := range key.Subkeys {
subkey := GpgKeyResult{
KeyID: subkey.GetKeyID(),
PublicKey: subkey.GetPublicKey(),
CreatedAt: subkey.GetCreatedAt().String(),
PrimaryKeyID: fmt.Sprintf("%d", subkey.GetPrimaryKeyID()),
RawKey: subkey.GetRawKey(),
}
k.Subkeys = append(k.Subkeys, subkey)
}
response = append(response, k)
}
utils.WaitForRateLimit(s, resp)
return
}
type SshSigningKeysResult []SshSigningKeyResult
type SshSigningKeyResult struct {
Title string
CreatedAt string
Key string
}
func SshSigningKeys(s github_recon_settings.Settings) (response SshSigningKeysResult) {
signingKeys, resp, err := s.Client.Users.ListSSHSigningKeys(
s.Ctx,
s.Target,
nil,
)
if err != nil {
s.Logger.Error("Failed to fetch user's ssh signing keys", "err", err)
return
}
for _, key := range signingKeys {
k := SshSigningKeyResult{
Title: key.GetTitle(),
CreatedAt: key.GetCreatedAt().String(),
Key: key.GetKey(),
}
response = append(response, k)
}
utils.WaitForRateLimit(s, resp)
return
}
+35
View File
@@ -0,0 +1,35 @@
package recon
import (
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type OrgsResult []OrgResult
type OrgResult struct {
Name string
URL string
Description string
}
func Orgs(s github_recon_settings.Settings) (response OrgsResult) {
orgs, resp, err := s.Client.Organizations.List(s.Ctx, s.Target, nil)
if err != nil {
s.Logger.Error("Failed to fetch organizations", "err", err)
return
}
for _, org := range orgs {
o := OrgResult{
Name: org.GetLogin(),
URL: org.GetURL(),
Description: org.GetDescription(),
}
response = append(response, o)
}
utils.WaitForRateLimit(s, resp)
return
}
+45
View File
@@ -0,0 +1,45 @@
package recon
import (
"encoding/json"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type socialResultInput struct {
Provider string `json:"provider"`
URL string `json:"url"`
}
type SocialsResult []socialResult
type socialResult struct {
Provider string
URL string
}
func Socials(s github_recon_settings.Settings) (response SocialsResult) {
resp, err := utils.FetchGitHubAPI(s.Client, "", "/users/"+s.Target+"/social_accounts")
if err != nil {
s.Logger.Error("Failed to fetch socials", "err", err)
return
}
var socialAccounts []socialResultInput
err = json.Unmarshal(resp, &socialAccounts)
if err != nil {
s.Logger.Error("Failed to unmarshal socials", "err", err)
return
}
socials := []socialResult{}
for _, account := range socialAccounts {
socials = append(socials, socialResult{
URL: account.URL,
Provider: account.Provider,
})
}
return socials
}
+69
View File
@@ -0,0 +1,69 @@
package recon
import (
"fmt"
github_recon_settings "github.com/anotherhadi/github-recon/settings"
"github.com/anotherhadi/github-recon/utils"
)
type UserResult struct {
Username string
AvatarURL string
GravatarID string
Name string
Company string
Location string
Email string
Hireable string
Bio string
PublicRepos string
PublicGists string
Followers string
Following string
CreatedAt string
UpdatedAt string
SuspendedAt string
TotalPrivateRepos string
PrivateGists string
DiskUsage string
Collaborators string
Plan string
}
func User(s github_recon_settings.Settings) (response UserResult) {
user, resp, err := s.Client.Users.Get(s.Ctx, s.Target)
if resp.StatusCode == 404 {
s.Logger.Fatal("User not found with username")
}
if err != nil {
s.Logger.Fatal("Failed to fetch user's information", "err", err)
}
u := UserResult{
Username: user.GetLogin(),
AvatarURL: user.GetAvatarURL(),
GravatarID: user.GetGravatarID(),
Name: user.GetName(),
Company: user.GetCompany(),
Location: user.GetLocation(),
Email: user.GetEmail(),
Hireable: fmt.Sprintf("%t", user.GetHireable()),
Bio: user.GetBio(),
PublicRepos: fmt.Sprintf("%d", user.GetPublicRepos()),
PublicGists: fmt.Sprintf("%d", user.GetPublicGists()),
Followers: fmt.Sprintf("%d", user.GetFollowers()),
Following: fmt.Sprintf("%d", user.GetFollowing()),
CreatedAt: user.GetCreatedAt().String(),
UpdatedAt: user.GetUpdatedAt().String(),
SuspendedAt: user.GetSuspendedAt().String(),
TotalPrivateRepos: fmt.Sprintf("%d", user.GetTotalPrivateRepos()),
PrivateGists: fmt.Sprintf("%d", user.GetPrivateGists()),
DiskUsage: fmt.Sprintf("%d", user.GetDiskUsage()),
Collaborators: fmt.Sprintf("%d", user.GetCollaborators()),
Plan: user.GetPlan().GetName(),
}
utils.WaitForRateLimit(s, resp)
return u
}