From 47a45d72a95226b9dc65bf322923e32bdf6bb250 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Fri, 9 May 2025 18:49:36 +0200 Subject: [PATCH] Init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .gitignore | 1 + LICENSE | 21 ++++++++ README.md | 52 ++++++++++++++++++ commits.go | 74 +++++++++++++++++++++++++ flake.lock | 27 ++++++++++ flake.nix | 41 ++++++++++++++ go.mod | 27 ++++++++++ go.sum | 50 +++++++++++++++++ keys.go | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 47 ++++++++++++++++ orgs.go | 47 ++++++++++++++++ social-media.go | 48 +++++++++++++++++ user.go | 64 ++++++++++++++++++++++ utils.go | 100 ++++++++++++++++++++++++++++++++++ 14 files changed, 739 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 commits.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 keys.go create mode 100644 main.go create mode 100644 orgs.go create mode 100644 social-media.go create mode 100644 user.go create mode 100644 utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cd791b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5615ec7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b91ad6d --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# GH-Recon + +## Project Overview + +Fetches and aggregates public OSINT data for a GitHub user, leveraging Go and the GitHub API. + +## Features + +- Retrieve basic user profile information (username, ID, avatar, bio, creation dates) +- List organizations and roles +- Fetch SSH and GPG keys +- Enumerate social accounts +- Extract unique commit authors (name + email) in both chronological orders + +## Disclaimer + +This tool is intended for educational purposes only. Use responsibly and ensure you have permission to access the data you are querying. + +## Prerequisites + +- Go 1.18+ +- GitHub Personal Access Token (recommended for higher rate limits) + +## Installation + +```bash +go get github.com/anotherhadi/gh-recon +``` + +## Usage + +```bash +gh-recon --username TARGET_USER [--token YOUR_TOKEN] +``` + +### Flags + +- `--username`: GitHub username to inspect (required) +- `--token`: Personal Access Token (optional but recommended) + +## Example + +```bash +gh-recon --username anotherhadi --token ghp_ABC123... +``` + +## Todo + +Feel free to contribute! Here are some ideas: + +- Fetch names in License files +- Fetch emails in README files/comments diff --git a/commits.go b/commits.go new file mode 100644 index 0000000..3635fb8 --- /dev/null +++ b/commits.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "fmt" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func commits(client *github.Client, ctx context.Context, username string) { + // Commits + fmt.Println( + GreyStyle.Render("[")+GreenStyle.Render("+")+GreyStyle.Render("]"), + GreyStyle.Render("Commits:\n"), + ) + + seenNames := make(map[string]struct{}) + seenEmails := make(map[string]struct{}) + var names, emails []string + + collect := func(order string) error { + opts := &github.SearchOptions{ + Sort: "author-date", + Order: order, // "desc" ou "asc" + ListOptions: github.ListOptions{PerPage: 100}, + } + for page := 1; page <= 10; page++ { + opts.ListOptions.Page = page + result, resp, err := client.Search.Commits( + ctx, + fmt.Sprintf("author:%s", username), + opts, + ) + if err != nil { + return fmt.Errorf("fetch page %d (%s): %w", page, order, err) + } + WaitForRateLimit(resp) + if len(result.Commits) == 0 { + break + } + for _, item := range result.Commits { + a := item.Commit.GetAuthor() + name := a.GetName() + email := a.GetEmail() + if _, seen := seenNames[name]; !seen { + seenNames[name] = struct{}{} + names = append(names, name) + } + if _, seen := seenEmails[email]; !seen { + seenEmails[email] = struct{}{} + emails = append(emails, email) + } + } + } + return nil + } + + if err := collect("desc"); err != nil { + log.Error("Failed to fetch commits", "err", err) + return + } + if err := collect("asc"); err != nil { + log.Error("Failed to fetch commits", "err", err) + return + } + + for i, name := range names { + PrintInfo("Name "+fmt.Sprint(i+1), name) + } + for i, email := range emails { + PrintInfo("Email "+fmt.Sprint(i+1), email) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3ed609e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1746663147, + "narHash": "sha256-Ua0drDHawlzNqJnclTJGf87dBmaO/tn7iZ+TCkTRpRc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "dda3dcd3fe03e991015e9a74b22d35950f264a54", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f4848c7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + 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"; }; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; + + forAllSystems = f: + nixpkgs.lib.genAttrs supportedSystems + (system: f system (import nixpkgs { inherit system; })); + + pname = "gh-recon"; + version = "0.1.0"; + + ldflags = [ "-s" "-w" ]; + + in { + packages = forAllSystems (system: pkgs: { + "${pname}" = pkgs.buildGoModule { + inherit pname version ldflags; + + src = ./.; + + vendorHash = "sha256-CPk8B8FKEoN8qff6WV/iBf0eVjTBMVfJQvlVcti6dfM="; + + meta = with pkgs.lib; { + description = + "Fetches and aggregates public OSINT data for a GitHub user."; + homepage = "https://github.com/anotherhadi/gh-recon"; + platforms = platforms.unix; + }; + }; + }); + + defaultPackage = + forAllSystems (system: pkgs: self.packages.${system}.${pname}); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3dfc0b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/anotherhadi/gh-recon + +go 1.24.2 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/log v0.4.1 + github.com/google/go-github/v72 v72.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d31c4bc --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +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/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= +github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..a75c367 --- /dev/null +++ b/keys.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func keys(client *github.Client, ctx context.Context, username string) { + sshKeys, resp, err := client.Users.ListKeys(ctx, username, nil) + if err != nil { + log.Error("Failed to fetch user's keys", "err", err) + os.Exit(1) + } + if len(sshKeys) == 0 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("No keys found for user "+username), + ) + } else { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("+")+ + GreyStyle.Render("]"), + GreyStyle.Render("Keys:\n"), + ) + } + for _, key := range sshKeys { + PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) + PrintInfo("URL", key.GetURL()) + PrintInfo("Title", key.GetTitle()) + PrintInfo("Created At", key.GetCreatedAt().String()) + PrintInfo("Key", key.GetKey()) + PrintInfo("Read Only", fmt.Sprintf("%t", key.GetReadOnly())) + PrintInfo("Verified", fmt.Sprintf("%t", key.GetVerified())) + PrintInfo("Last Used", key.GetLastUsed().String()) + PrintInfo("Added By", key.GetAddedBy()) + fmt.Println() + } + WaitForRateLimit(resp) + + gpgKeys, resp, err := client.Users.ListGPGKeys(ctx, username, nil) + if err != nil { + log.Error("Failed to fetch user's GPG keys", "err", err) + } + + if len(gpgKeys) == 0 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("No GPG keys found\n"), + ) + } else { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("+")+ + GreyStyle.Render("]"), + GreyStyle.Render("GPG Keys:\n"), + ) + } + for _, key := range gpgKeys { + PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) + PrintInfo("Key ID", key.GetKeyID()) + PrintInfo("Public Key", key.GetPublicKey()) + PrintInfo("Created At", key.GetCreatedAt().String()) + PrintInfo("Expires At", key.GetExpiresAt().String()) + PrintInfo("Can Sign", fmt.Sprintf("%t", key.GetCanSign())) + PrintInfo("Can Encrypt Comms", fmt.Sprintf("%t", key.GetCanEncryptComms())) + PrintInfo("Can Encrypt Storage", fmt.Sprintf("%t", key.GetCanEncryptStorage())) + PrintInfo("Can Certify", fmt.Sprintf("%t", key.GetCanCertify())) + PrintInfo("Primary Key ID", fmt.Sprintf("%d", key.GetPrimaryKeyID())) + PrintInfo("Raw Key", key.GetRawKey()) + PrintInfo("Emails", fmt.Sprintf("%d", len(key.Emails))) + for _, email := range key.Emails { + PrintInfo("\tEmail", email.GetEmail()) + PrintInfo("\tVerified", fmt.Sprintf("%t", email.GetVerified())) + } + PrintInfo("Subkeys", fmt.Sprintf("%d", len(key.Subkeys))) + for _, subkey := range key.Subkeys { + PrintInfo("\tSubkey ID", fmt.Sprintf("%d", subkey.GetID())) + PrintInfo("\tSubkey Key ID", subkey.GetKeyID()) + PrintInfo("\tSubkey Created At", subkey.GetCreatedAt().String()) + PrintInfo("\tSubkey Expires At", subkey.GetExpiresAt().String()) + PrintInfo("\tSubkey Can Sign", fmt.Sprintf("%t", subkey.GetCanSign())) + PrintInfo( + "\tSubkey Can Encrypt Comms", + fmt.Sprintf("%t", subkey.GetCanEncryptComms()), + ) + PrintInfo( + "\tSubkey Can Encrypt Storage", + fmt.Sprintf("%t", subkey.GetCanEncryptStorage()), + ) + PrintInfo("\tSubkey Can Certify", fmt.Sprintf("%t", subkey.GetCanCertify())) + PrintInfo("\tSubkey Primary Key ID", fmt.Sprintf("%d", subkey.GetPrimaryKeyID())) + PrintInfo("\tSubkey Raw Key", subkey.GetRawKey()) + PrintInfo("\tSubkey Public Key", subkey.GetPublicKey()) + } + fmt.Println() + } + WaitForRateLimit(resp) + + signingKeys, resp, err := client.Users.ListSSHSigningKeys( + ctx, + username, + nil, + ) + if err != nil { + log.Error("Failed to fetch user's signing keys", "err", err) + } + if len(signingKeys) == 0 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("No signing keys found\n"), + ) + } else { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("+")+ + GreyStyle.Render("]"), + GreyStyle.Render("Signing Keys:\n"), + ) + } + for _, key := range signingKeys { + PrintInfo("ID", fmt.Sprintf("%d", key.GetID())) + PrintInfo("Key", key.GetKey()) + PrintInfo("Title", key.GetTitle()) + PrintInfo("Created At", key.GetCreatedAt().String()) + fmt.Println() + } + + WaitForRateLimit(resp) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3534a3a --- /dev/null +++ b/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func main() { + var username string + var token string + flag.StringVar(&username, "username", "", "Target username") + flag.StringVar(&token, "token", "", "Github token") + flag.Parse() + + if username == "" { + fmt.Println("Please provide a username with the --username flag") + os.Exit(1) + } + err := parseUsername(username) + if err != nil { + log.Error("Invalid username", "err", err) + os.Exit(1) + } + + client := github.NewClient(nil) + if token == "" { + log.Info( + "It's recommended to set a Github token for better rate limits. You can set it using the --token flag.", + ) + } else { + client = client.WithAuthToken(token) + } + + ctx := context.Background() + + header() + userInfo(client, ctx, username) + orgs(client, ctx, username) + keys(client, ctx, username) + socials(client, username) + commits(client, ctx, username) +} diff --git a/orgs.go b/orgs.go new file mode 100644 index 0000000..9dfa743 --- /dev/null +++ b/orgs.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "fmt" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func orgs(client *github.Client, ctx context.Context, username string) { + orgs, resp, err := client.Organizations.List(ctx, username, nil) + if err != nil { + log.Error("Failed to fetch user's organizations", "err", err) + } + if len(orgs) == 0 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("No organizations found for user "+username), + ) + } else { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("+")+ + GreyStyle.Render("]"), + GreyStyle.Render("Organizations:\n"), + ) + } + for _, org := range orgs { + PrintInfo("Login", org.GetLogin()) + PrintInfo("ID", fmt.Sprintf("%d", org.GetID())) + PrintInfo("Node ID", org.GetNodeID()) + PrintInfo("URL", org.GetURL()) + PrintInfo("Repos URL", org.GetReposURL()) + PrintInfo("Events URL", org.GetEventsURL()) + PrintInfo("Hooks URL", org.GetHooksURL()) + PrintInfo("Issues URL", org.GetIssuesURL()) + PrintInfo("Members URL", org.GetMembersURL()) + PrintInfo("Public Members URL", org.GetPublicMembersURL()) + PrintInfo("Avatar URL", org.GetAvatarURL()) + PrintInfo("Description", org.GetDescription()) + fmt.Println() + } + WaitForRateLimit(resp) +} diff --git a/social-media.go b/social-media.go new file mode 100644 index 0000000..902f835 --- /dev/null +++ b/social-media.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func socials(client *github.Client, username string) { + resp, err := FetchGitHubAPI(client, "", "/users/"+username+"/social_accounts") + if err != nil { + log.Error("Failed to fetch user's social media accounts", "err", err) + } + + type SocialAccount struct { + Provider string `json:"provider"` + URL string `json:"url"` + } + type SocialAccounts []SocialAccount + + var socialAccounts SocialAccounts + err = json.Unmarshal(resp, &socialAccounts) + if err != nil { + log.Error("Failed to unmarshal social media accounts", "err", err) + } + if len(socialAccounts) == 0 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("No social media accounts found\n"), + ) + } else { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("+")+ + GreyStyle.Render("]"), + GreyStyle.Render("Social Media Accounts:\n"), + ) + } + for _, account := range socialAccounts { + PrintInfo("Provider", account.Provider) + PrintInfo("URL", account.URL) + fmt.Println() + } +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..8f4e228 --- /dev/null +++ b/user.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/log" + "github.com/google/go-github/v72/github" +) + +func userInfo(client *github.Client, ctx context.Context, username string) { + fmt.Println( + GreyStyle.Render("[")+ + GreenStyle.Render("-")+ + GreyStyle.Render("]"), + GreyStyle.Render("Fetching user info...\n"), + ) + + user, resp, err := client.Users.Get(ctx, username) + if resp.StatusCode == 404 { + fmt.Println( + GreyStyle.Render("[")+ + RedStyle.Render("x")+ + GreyStyle.Render("]"), + GreyStyle.Render("Error:"), + RedStyle.Render("User not found"), + ) + os.Exit(1) + } + if err != nil { + log.Error("Failed to fetch user's information", "err", err) + os.Exit(1) + } + + PrintInfo("Username", user.GetLogin()) + PrintInfo("ID", fmt.Sprintf("%d", user.GetID())) + PrintInfo("Avatar URL", user.GetAvatarURL()) + PrintInfo("Gravatar ID", user.GetGravatarID()) + PrintInfo("Name", user.GetName()) + PrintInfo("Company", user.GetCompany()) + PrintInfo("Location", user.GetLocation()) + PrintInfo("Email", user.GetEmail()) + PrintInfo("Hireable", fmt.Sprintf("%t", user.GetHireable())) + PrintInfo("Bio", user.GetBio()) + PrintInfo("Public Repos", fmt.Sprintf("%d", user.GetPublicRepos())) + PrintInfo("Public Gists", fmt.Sprintf("%d", user.GetPublicGists())) + PrintInfo("Followers", fmt.Sprintf("%d", user.GetFollowers())) + PrintInfo("Following", fmt.Sprintf("%d", user.GetFollowing())) + PrintInfo("Created At", user.GetCreatedAt().String()) + PrintInfo("Updated At", user.GetUpdatedAt().String()) + PrintInfo("Suspended At", user.GetSuspendedAt().String()) + PrintInfo("Type", user.GetType()) + PrintInfo("Site Admin", fmt.Sprintf("%t", user.GetSiteAdmin())) + PrintInfo("Total Private Repos", fmt.Sprintf("%d", user.GetTotalPrivateRepos())) + PrintInfo("Owned Private Repos", fmt.Sprintf("%d", user.GetOwnedPrivateRepos())) + PrintInfo("Private Gists", fmt.Sprintf("%d", user.GetPrivateGists())) + PrintInfo("Disk Usage", fmt.Sprintf("%d", user.GetDiskUsage())) + PrintInfo("Collaborators", fmt.Sprintf("%d", user.GetCollaborators())) + PrintInfo("Plan", user.GetPlan().GetName()) + fmt.Println() + + WaitForRateLimit(resp) +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..989c5e3 --- /dev/null +++ b/utils.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "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 header() { + 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 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 PrintInfo(key, value string) { + if value == "" || value == "0" || value == "0001-01-01 00:00:00 +0000 UTC" { + return + } + fmt.Printf("%s %s\n", GreyStyle.Render(key+":"), GreenStyle.Render(value)) +} + +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) + } +}