From a0fceb36dfeed557e4157f11b9eccd56fcefdc28 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:43:30 +0200 Subject: [PATCH] init enumerate Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- back/internal/api/handler/enumerate.go | 296 ++++++++++++++ back/internal/api/router.go | 8 + front/astro.config.mjs | 6 +- front/src/components/EnumeratePage.svelte | 448 ++++++++++++++++++++++ front/src/components/HomePage.svelte | 18 +- front/src/components/Nav.svelte | 2 + front/src/components/SearchBar.svelte | 6 +- front/src/pages/enumerate.astro | 16 + 8 files changed, 793 insertions(+), 7 deletions(-) create mode 100644 back/internal/api/handler/enumerate.go create mode 100644 front/src/components/EnumeratePage.svelte create mode 100644 front/src/pages/enumerate.astro diff --git a/back/internal/api/handler/enumerate.go b/back/internal/api/handler/enumerate.go new file mode 100644 index 0000000..c151750 --- /dev/null +++ b/back/internal/api/handler/enumerate.go @@ -0,0 +1,296 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/anotherhadi/iknowyou/internal/respond" + "github.com/anotherhadi/iknowyou/internal/tools" +) + +// ansiRe strips all ANSI/VT100 escape sequences (CSI, OSC, etc.). +// RunWithPTY only strips OSC sequences; CSI colour codes need this. +var ansiRe = regexp.MustCompile(`\x1b[\x5b-\x5f][0-9;]*[A-Za-z]|\x1b[^[\x5b-\x5f]`) + +type EnumerateHandler struct { + demo bool +} + +func NewEnumerateHandler(_ string, demo bool) *EnumerateHandler { + return &EnumerateHandler{demo: demo} +} + +// --- Generate --- + +type generateRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` +} + +type emailEntry struct { + Address string `json:"address"` + Username string `json:"username"` + Domain string `json:"domain"` +} + +type generateResponse struct { + Usernames []string `json:"usernames"` + Emails []emailEntry `json:"emails"` +} + +var emailDomains = []string{ + "gmail.com", + "outlook.com", + "hotmail.com", + "yahoo.com", + "protonmail.com", + "proton.me", + "icloud.com", + "live.com", + "msn.com", +} + +var accentMap = map[rune]rune{ + 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', + 'à': 'a', 'â': 'a', 'ä': 'a', 'á': 'a', + 'ù': 'u', 'û': 'u', 'ü': 'u', 'ú': 'u', + 'ô': 'o', 'ö': 'o', 'ó': 'o', + 'î': 'i', 'ï': 'i', 'í': 'i', + 'ç': 'c', 'ñ': 'n', 'ß': 's', +} + +func normalizeName(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if mapped, ok := accentMap[r]; ok { + b.WriteRune(mapped) + } else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == ' ' || r == '_' || r == '.' { + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +func buildUsernames(first, last string) []string { + f := strings.ReplaceAll(normalizeName(first), "-", "") + l := strings.ReplaceAll(normalizeName(last), "-", "") + if f == "" || l == "" { + return nil + } + fi := string([]rune(f)[0]) + li := string([]rune(l)[0]) + + patterns := []string{ + f + l, + l + f, + fi + l, + f + "." + l, + l + "." + f, + fi + "." + l, + f + "_" + l, + l + "_" + f, + fi + "_" + l, + f + li, + f + "." + li, + fi + li, + } + + seen := map[string]bool{} + var result []string + for _, p := range patterns { + // Skip patterns that are too short to be realistic usernames/emails. + if len([]rune(p)) < 4 { + continue + } + if p != "" && !seen[p] { + seen[p] = true + result = append(result, p) + } + } + return result +} + +// GET /api/enumerate/status +type statusResponse struct { + UserScannerAvailable bool `json:"user_scanner_available"` +} + +func (h *EnumerateHandler) Status(w http.ResponseWriter, r *http.Request) { + _, err := exec.LookPath("user-scanner") + respond.JSON(w, http.StatusOK, statusResponse{UserScannerAvailable: err == nil}) +} + +// POST /api/enumerate/generate +func (h *EnumerateHandler) Generate(w http.ResponseWriter, r *http.Request) { + var req generateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + + var usernames []string + if req.Username != "" { + u := normalizeName(req.Username) + if u == "" { + respond.Error(w, http.StatusBadRequest, "invalid username") + return + } + usernames = []string{u} + } else { + if req.FirstName == "" || req.LastName == "" { + respond.Error(w, http.StatusBadRequest, "provide either username or first_name+last_name") + return + } + usernames = buildUsernames(req.FirstName, req.LastName) + if len(usernames) == 0 { + respond.Error(w, http.StatusBadRequest, "could not generate usernames from provided names") + return + } + } + + var emails []emailEntry + for _, u := range usernames { + for _, d := range emailDomains { + emails = append(emails, emailEntry{ + Address: u + "@" + d, + Username: u, + Domain: d, + }) + } + } + + respond.JSON(w, http.StatusOK, generateResponse{Usernames: usernames, Emails: emails}) +} + +// --- Check Email --- + +type checkEmailRequest struct { + Email string `json:"email"` + Quick bool `json:"quick"` +} + +type checkEmailResponse struct { + Status string `json:"status"` // "found" | "not_found" | "maybe" + Reason string `json:"reason"` +} + +// userScannerCheck runs user-scanner via PTY (required for output). +// flag is either "-e" (email) or "-u" (username). +// Office365 is excluded — it's a known false positive. +// quick=true uses a shorter timeout for a faster but incomplete scan. +func userScannerCheck(ctx context.Context, flag, target string, quick bool) (status, reason string, sites []string) { + defer func() { + if r := recover(); r != nil { + status, reason, sites = "maybe", "internal error during scan", nil + } + }() + + if _, err := exec.LookPath("user-scanner"); err != nil { + return "maybe", "user-scanner not available", nil + } + + scanTimeout := 90 * time.Second + if quick { + scanTimeout = 15 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, scanTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "user-scanner", flag, target, "--only-found") + output, _ := tools.RunWithPTY(ctx, cmd) + + // Strip ANSI codes, then normalise PTY line endings (\r\n → \n). + output = ansiRe.ReplaceAllString(output, "") + output = strings.ReplaceAll(output, "\r\n", "\n") + output = strings.ReplaceAll(output, "\r", "\n") + + // Strip banner (first 8 lines) + if lines := strings.SplitN(output, "\n", 10); len(lines) > 8 { + output = strings.Join(lines[8:], "\n") + } + + for _, line := range strings.Split(output, "\n") { + if !strings.Contains(line, "[✔]") { + continue + } + // Office365 is a known false positive — skip it. + if strings.Contains(line, "Office365") { + continue + } + parts := strings.Fields(line) + if len(parts) >= 2 { + sites = append(sites, strings.TrimRight(parts[1], ":")) + } + } + + if len(sites) > 0 { + return "found", fmt.Sprintf("found via user-scanner (%d result(s))", len(sites)), sites + } + return "not_found", "not found via user-scanner", nil +} + +// POST /api/enumerate/check-email +func (h *EnumerateHandler) CheckEmail(w http.ResponseWriter, r *http.Request) { + if h.demo { + respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode") + return + } + var req checkEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Email == "" { + respond.Error(w, http.StatusBadRequest, "email is required") + return + } + + status, reason, _ := userScannerCheck(r.Context(), "-e", req.Email, req.Quick) + respond.JSON(w, http.StatusOK, checkEmailResponse{Status: status, Reason: reason}) +} + +// --- Check Username --- + +type checkUsernameRequest struct { + Username string `json:"username"` + Quick bool `json:"quick"` +} + +type checkUsernameResponse struct { + Status string `json:"status"` + Reason string `json:"reason"` + Sites []string `json:"sites"` +} + +// POST /api/enumerate/check-username +func (h *EnumerateHandler) CheckUsername(w http.ResponseWriter, r *http.Request) { + if h.demo { + respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode") + return + } + var req checkUsernameRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Username == "" { + respond.Error(w, http.StatusBadRequest, "username is required") + return + } + + status, reason, sites := userScannerCheck(r.Context(), "-u", req.Username, req.Quick) + if sites == nil { + sites = []string{} + } + respond.JSON(w, http.StatusOK, checkUsernameResponse{Status: status, Reason: reason, Sites: sites}) +} diff --git a/back/internal/api/router.go b/back/internal/api/router.go index 19bb572..d24c119 100644 --- a/back/internal/api/router.go +++ b/back/internal/api/router.go @@ -29,6 +29,7 @@ func NewRouter( searchHandler := handler.NewSearchHandler(manager, demo) toolsHandler := handler.NewToolsHandler(factories) configHandler := handler.NewConfigHandler(configPath, factories, demo) + enumerateHandler := handler.NewEnumerateHandler(configPath, demo) searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3) @@ -45,6 +46,13 @@ func NewRouter( r.Get("/{name}", toolsHandler.Get) }) + r.Route("/enumerate", func(r chi.Router) { + r.Get("/status", enumerateHandler.Status) + r.Post("/generate", enumerateHandler.Generate) + r.Post("/check-email", enumerateHandler.CheckEmail) + r.Post("/check-username", enumerateHandler.CheckUsername) + }) + r.Route("/config", func(r chi.Router) { r.Get("/", configHandler.Get) diff --git a/front/astro.config.mjs b/front/astro.config.mjs index 223fec2..49eed6d 100644 --- a/front/astro.config.mjs +++ b/front/astro.config.mjs @@ -30,7 +30,11 @@ export default defineConfig({ ], server: { proxy: { - "/api": "http://localhost:8080", + "/api": { + target: "http://localhost:8080", + timeout: 120000, + proxyTimeout: 120000, + }, }, }, }, diff --git a/front/src/components/EnumeratePage.svelte b/front/src/components/EnumeratePage.svelte new file mode 100644 index 0000000..8f49439 --- /dev/null +++ b/front/src/components/EnumeratePage.svelte @@ -0,0 +1,448 @@ + + +
{generateError}
+ {/if} ++ Enter a name or username above to generate candidates. +
+ {/if} + + + {#if entries.length > 0} +| Value | +Type | +Status | ++ |
|---|---|---|---|
|
+
+ {entry.value}
+
+ {#if entry.sites && entry.sites.length > 0}
+
+ {#each entry.sites as site}
+ {site}
+ {/each}
+
+ {/if}
+ {#if entry.reason && entry.status !== "idle" && entry.status !== "checking"}
+ {entry.reason}
+ {/if}
+ |
+ + + {entry.kind === "email" ? "email" : "username"} + + | +
+ {#if entry.status === "checking"}
+
+ Checking
+
+ {:else if entry.status === "found"}
+
+ |
+
+
+
+
+ |
+
+ {filteredEntries.length} entries shown +
++ Generate usernames and email addresses from a name, then verify their existence. +
+