├── img ├── banner.png └── elasticsearch.png ├── main.go ├── pkg ├── sources │ ├── sources.go │ ├── anubis.go │ ├── dnsrepo.go │ ├── cebaidu.go │ ├── threatminer.go │ ├── alienvault.go │ ├── threatcrowd.go │ ├── bevigil.go │ ├── subdomaincenter.go │ ├── chaos.go │ ├── threatbook.go │ ├── shodan.go │ ├── dnsdumpster.go │ ├── fullhunt.go │ ├── whoisxmlapi.go │ ├── shrewdeye.go │ ├── c99.go │ ├── dnsarchive.go │ ├── builtwith.go │ ├── virustotal.go │ ├── bufferover.go │ ├── digitalyama.go │ ├── myssl.go │ ├── chinaz.go │ ├── zoomeyeapi.go │ ├── racent.go │ ├── certspotter.go │ ├── hackertarget.go │ ├── reconcloud.go │ ├── digicert.go │ ├── waybackarchive.go │ ├── abuseipdb.go │ ├── rapiddns.go │ ├── censys.go │ ├── driftnet.go │ ├── rsecloud.go │ ├── dnsgrep.go │ ├── windvane.go │ ├── pugrecon.go │ ├── fofa.go │ ├── robtex.go │ ├── urlscan.go │ ├── quake.go │ └── hunter.go ├── llm │ ├── types.go │ ├── llm_inference.py │ ├── validator.go │ ├── downloader.go │ └── model.go ├── config │ └── paths.go ├── elastic │ └── elastic.go ├── session │ └── session.go └── active │ ├── wordlist.go │ ├── cleaner.go │ ├── deep.go │ ├── mksub.go │ └── resolvers.go ├── cmd ├── version.go ├── update.go └── track.go ├── go.mod ├── LICENSE ├── config └── config.yaml └── go.sum /img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samogod/samoscout/HEAD/img/banner.png -------------------------------------------------------------------------------- /img/elasticsearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samogod/samoscout/HEAD/img/elasticsearch.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/samogod/samoscout/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /pkg/sources/sources.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "github.com/samogod/samoscout/pkg/session" 6 | ) 7 | 8 | 9 | type Result struct { 10 | Type string 11 | Source string 12 | Value string 13 | Error error 14 | } 15 | 16 | 17 | type Source interface { 18 | 19 | 20 | Run(ctx context.Context, domain string, s *session.Session) <-chan Result 21 | 22 | 23 | Name() string 24 | } 25 | -------------------------------------------------------------------------------- /pkg/llm/types.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | type Config struct { 4 | NumPredictions int 5 | MaxRecursion int 6 | MaxTokens int 7 | Temperature float32 8 | ResolutionThreads int 9 | Device string 10 | OutputDir string 11 | Verbose bool 12 | } 13 | 14 | type ModelConfig struct { 15 | BlockSize int `json:"block_size"` 16 | VocabSize int `json:"vocab_size"` 17 | NLayer int `json:"n_layer"` 18 | NHead int `json:"n_head"` 19 | NEmbd int `json:"n_embd"` 20 | Dropout float32 `json:"dropout"` 21 | Bias bool `json:"bias"` 22 | } 23 | 24 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | const ( 11 | Version = "1.0.0" 12 | BuildDate = "2025-10-30" 13 | Author = "samogod" 14 | ) 15 | 16 | var versionCmd = &cobra.Command{ 17 | Use: "version", 18 | Short: "Show version information", 19 | Long: "Display version, build date, and author information for samoscout", 20 | Run: func(cmd *cobra.Command, args []string) { 21 | printVersionInfo() 22 | }, 23 | } 24 | 25 | func printVersionInfo() { 26 | color.Green("Current Version: %s", Version) 27 | fmt.Println() 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samogod/samoscout 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/elastic/go-elasticsearch/v8 v8.13.0 7 | github.com/fatih/color v1.16.0 8 | github.com/lib/pq v1.10.9 9 | github.com/sirupsen/logrus v1.9.3 10 | github.com/spf13/cobra v1.8.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/elastic/elastic-transport-go/v8 v8.5.0 // indirect 16 | github.com/go-logr/logr v1.3.0 // indirect 17 | github.com/go-logr/stdr v1.2.2 // indirect 18 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/spf13/pflag v1.0.5 // indirect 22 | go.opentelemetry.io/otel v1.21.0 // indirect 23 | go.opentelemetry.io/otel/metric v1.21.0 // indirect 24 | go.opentelemetry.io/otel/trace v1.21.0 // indirect 25 | golang.org/x/sys v0.14.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/samogod/samoscout/pkg/update" 8 | 9 | "github.com/fatih/color" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var updateVerbose bool 14 | 15 | var updateCmd = &cobra.Command{ 16 | Use: "update", 17 | Short: "Update samoscout to the latest version", 18 | Long: `Update samoscout to the latest version from GitHub releases. 19 | This command will: 20 | - Check for the latest release on GitHub 21 | - Download the appropriate binary for your platform 22 | - Replace the current binary with the new version`, 23 | Example: ` samoscout update 24 | samoscout update -v`, 25 | Run: runUpdate, 26 | } 27 | 28 | func init() { 29 | updateCmd.Flags().BoolVarP(&updateVerbose, "verbose", "v", false, "enable verbose output during update") 30 | } 31 | 32 | func runUpdate(cmd *cobra.Command, args []string) { 33 | fmt.Println() 34 | 35 | if err := update.CheckAndUpdate("v"+Version, updateVerbose); err != nil { 36 | color.Red("Update failed: %v", err) 37 | os.Exit(1) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 samet g. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # samoscout configuration file 2 | 3 | api_keys: 4 | censys: "" 5 | virustotal: "" 6 | bevigil: "" 7 | bufferover: "" 8 | builtwith: "" 9 | c99: "" 10 | certspotter: "" 11 | chaos: "" 12 | chinaz: "" 13 | cloudflare: "" 14 | digitalyama: "" 15 | dnsdb: "" 16 | dnsdumpster: "" 17 | dnsrepo: "" 18 | dnsarchive: "" 19 | driftnet: "" 20 | fofa: "" 21 | fullhunt: "" 22 | gitlab: "" 23 | hunter: "" 24 | jsmon: "" 25 | netlas: "" 26 | pugrecon: "" 27 | quake: "" 28 | redhuntlabs: "" 29 | robtex: "" 30 | rsecloud: "" 31 | securitytrails: "" 32 | shodan: "" 33 | subdomaincenter: "" 34 | threatbook: "" 35 | urlscan: "" 36 | whoisxmlapi: "" 37 | windvane: "" 38 | zoomeyeapi: "" 39 | 40 | default_settings: 41 | timeout: 10 42 | 43 | active_enumeration: 44 | enabled: false 45 | dsieve_top: 50 46 | dsieve_factor: 4 47 | output_dir: ".samoscout_active" 48 | 49 | llm_enumeration: 50 | enabled: false 51 | device: "auto" 52 | num_predictions: 500 53 | max_recursion: 5 54 | max_tokens: 10 55 | temperature: 0.0 56 | run_after_passive: true 57 | run_after_active: false 58 | 59 | database: 60 | enabled: false 61 | host: "localhost" 62 | port: 5432 63 | user: "postgres" 64 | password: "postgres" 65 | 66 | elasticsearch: 67 | enabled: true 68 | url: "http://127.0.0.1:9200" 69 | username: "elastic" 70 | password: "" 71 | index: "samoscout_httpx" -------------------------------------------------------------------------------- /pkg/sources/anubis.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type Anubis struct{} 13 | 14 | func (a *Anubis) Name() string { 15 | return "anubis" 16 | } 17 | 18 | func (a *Anubis) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 19 | results := make(chan Result) 20 | 21 | go func() { 22 | defer close(results) 23 | 24 | url := fmt.Sprintf("https://anubisdb.com/anubis/subdomains/%s", domain) 25 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 26 | if err != nil { 27 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 28 | return 29 | } 30 | 31 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 32 | req.Header.Set("Accept", "application/json") 33 | 34 | resp, err := s.Client.Do(req) 35 | if err != nil { 36 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 37 | return 38 | } 39 | defer resp.Body.Close() 40 | 41 | if resp.StatusCode != http.StatusOK { 42 | results <- Result{Source: a.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 43 | return 44 | } 45 | 46 | var subdomains []string 47 | if err := json.NewDecoder(resp.Body).Decode(&subdomains); err != nil { 48 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 49 | return 50 | } 51 | 52 | seen := make(map[string]bool) 53 | for _, subdomain := range subdomains { 54 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 55 | 56 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 57 | seen[hostname] = true 58 | 59 | select { 60 | case results <- Result{Source: a.Name(), Value: hostname, Type: "subdomain"}: 61 | case <-ctx.Done(): 62 | return 63 | } 64 | } 65 | } 66 | }() 67 | 68 | return results 69 | } 70 | -------------------------------------------------------------------------------- /pkg/sources/dnsrepo.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | type DNSRepo struct{} 14 | 15 | type dnsRepoResponse []struct { 16 | Domain string `json:"domain"` 17 | } 18 | 19 | func (d *DNSRepo) Name() string { 20 | return "dnsrepo" 21 | } 22 | 23 | func (d *DNSRepo) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 24 | results := make(chan Result) 25 | 26 | go func() { 27 | defer close(results) 28 | 29 | if s.Keys.DNSRepo == "" { 30 | return 31 | } 32 | 33 | apiURL := fmt.Sprintf("https://dnsrepo.noc.org/api/?apikey=%s&search=%s", s.Keys.DNSRepo, domain) 34 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 35 | if err != nil { 36 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 37 | return 38 | } 39 | 40 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 41 | req.Header.Set("Accept", "application/json") 42 | 43 | resp, err := s.Client.Do(req) 44 | if err != nil { 45 | results <- Result{Source: d.Name(), Error: fmt.Errorf("request failed: %w", err)} 46 | return 47 | } 48 | defer resp.Body.Close() 49 | 50 | if resp.StatusCode != http.StatusOK { 51 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 52 | return 53 | } 54 | 55 | responseData, err := io.ReadAll(resp.Body) 56 | if err != nil { 57 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to read response: %w", err)} 58 | return 59 | } 60 | 61 | var result dnsRepoResponse 62 | if err := json.Unmarshal(responseData, &result); err != nil { 63 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 64 | return 65 | } 66 | 67 | seen := make(map[string]bool) 68 | for _, sub := range result { 69 | hostname := strings.TrimSpace(strings.ToLower(strings.TrimSuffix(sub.Domain, "."))) 70 | 71 | if hostname != "" && !seen[hostname] { 72 | seen[hostname] = true 73 | 74 | select { 75 | case results <- Result{Source: d.Name(), Value: hostname, Type: "subdomain"}: 76 | case <-ctx.Done(): 77 | return 78 | } 79 | } 80 | } 81 | }() 82 | 83 | return results 84 | } 85 | -------------------------------------------------------------------------------- /pkg/sources/cebaidu.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type Cebaidu struct{} 13 | 14 | type domain struct { 15 | Domain string `json:"domain"` 16 | } 17 | 18 | type cebaiduResponse struct { 19 | Code int64 `json:"code"` 20 | Message string `json:"message"` 21 | Data []domain `json:"data"` 22 | } 23 | 24 | func (c *Cebaidu) Name() string { 25 | return "cebaidu" 26 | } 27 | 28 | func (c *Cebaidu) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 29 | results := make(chan Result) 30 | 31 | go func() { 32 | defer close(results) 33 | 34 | url := fmt.Sprintf("https://ce.baidu.com/index/getRelatedSites?site_address=%s", domain) 35 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 36 | if err != nil { 37 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 38 | return 39 | } 40 | 41 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 42 | req.Header.Set("Accept", "application/json") 43 | 44 | resp, err := s.Client.Do(req) 45 | if err != nil { 46 | results <- Result{Source: c.Name(), Error: fmt.Errorf("API request failed: %w", err)} 47 | return 48 | } 49 | 50 | if resp.StatusCode != http.StatusOK { 51 | resp.Body.Close() 52 | results <- Result{Source: c.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 53 | return 54 | } 55 | 56 | var response cebaiduResponse 57 | err = json.NewDecoder(resp.Body).Decode(&response) 58 | resp.Body.Close() 59 | if err != nil { 60 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 61 | return 62 | } 63 | 64 | if response.Code > 0 { 65 | results <- Result{Source: c.Name(), Error: fmt.Errorf("API error: %d, %s", response.Code, response.Message)} 66 | return 67 | } 68 | 69 | seen := make(map[string]bool) 70 | for _, domainResult := range response.Data { 71 | hostname := strings.TrimSpace(strings.ToLower(domainResult.Domain)) 72 | 73 | if hostname != "" && !seen[hostname] { 74 | seen[hostname] = true 75 | 76 | select { 77 | case results <- Result{Source: c.Name(), Value: hostname, Type: "subdomain"}: 78 | case <-ctx.Done(): 79 | return 80 | } 81 | } 82 | } 83 | }() 84 | 85 | return results 86 | } 87 | -------------------------------------------------------------------------------- /pkg/sources/threatminer.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type ThreatMiner struct{} 14 | 15 | 16 | type ThreatMinerResponse struct { 17 | StatusCode string `json:"status_code"` 18 | StatusMessage string `json:"status_message"` 19 | Results []string `json:"results"` 20 | } 21 | 22 | 23 | func (t *ThreatMiner) Name() string { 24 | return "threatminer" 25 | } 26 | 27 | 28 | func (t *ThreatMiner) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 29 | results := make(chan Result) 30 | 31 | go func() { 32 | defer close(results) 33 | 34 | seen := make(map[string]bool) 35 | url := fmt.Sprintf("https://api.threatminer.org/v2/domain.php?q=%s&rt=5", domain) 36 | 37 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 38 | if err != nil { 39 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 40 | return 41 | } 42 | 43 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 44 | 45 | resp, err := session.Client.Do(req) 46 | if err != nil { 47 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API request failed: %w", err)} 48 | return 49 | } 50 | defer resp.Body.Close() 51 | 52 | if resp.StatusCode != http.StatusOK { 53 | results <- Result{Source: t.Name(), Error: fmt.Errorf("unexpected status code: %d", resp.StatusCode)} 54 | return 55 | } 56 | 57 | var response ThreatMinerResponse 58 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 59 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 60 | return 61 | } 62 | 63 | 64 | if response.StatusCode != "200" { 65 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API returned status code: %s, message: %s", response.StatusCode, response.StatusMessage)} 66 | return 67 | } 68 | 69 | 70 | for _, subdomain := range response.Results { 71 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 72 | 73 | if hostname != "" && !seen[hostname] { 74 | seen[hostname] = true 75 | 76 | select { 77 | case results <- Result{Source: t.Name(), Value: hostname, Type: "subdomain"}: 78 | case <-ctx.Done(): 79 | return 80 | } 81 | } 82 | } 83 | }() 84 | 85 | return results 86 | } 87 | -------------------------------------------------------------------------------- /pkg/sources/alienvault.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type AlienVault struct{} 13 | 14 | type AlienVaultResponse struct { 15 | Detail string `json:"detail"` 16 | Error string `json:"error"` 17 | PassiveDNS []struct { 18 | Hostname string `json:"hostname"` 19 | } `json:"passive_dns"` 20 | } 21 | 22 | func (a *AlienVault) Name() string { 23 | return "alienvault" 24 | } 25 | 26 | func (a *AlienVault) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 27 | results := make(chan Result) 28 | 29 | go func() { 30 | defer close(results) 31 | 32 | url := fmt.Sprintf("https://otx.alienvault.com/api/v1/indicators/domain/%s/passive_dns", domain) 33 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 34 | if err != nil { 35 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 36 | return 37 | } 38 | 39 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 40 | req.Header.Set("Accept", "application/json") 41 | 42 | resp, err := s.Client.Do(req) 43 | if err != nil { 44 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 45 | return 46 | } 47 | defer resp.Body.Close() 48 | 49 | if resp.StatusCode != http.StatusOK { 50 | results <- Result{Source: a.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 51 | return 52 | } 53 | 54 | var alienvaultResp AlienVaultResponse 55 | if err := json.NewDecoder(resp.Body).Decode(&alienvaultResp); err != nil { 56 | results <- Result{Source: a.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 57 | return 58 | } 59 | 60 | if alienvaultResp.Error != "" { 61 | results <- Result{Source: a.Name(), Error: fmt.Errorf("%s, %s", alienvaultResp.Detail, alienvaultResp.Error)} 62 | return 63 | } 64 | 65 | seen := make(map[string]bool) 66 | for _, record := range alienvaultResp.PassiveDNS { 67 | hostname := strings.TrimSpace(strings.ToLower(record.Hostname)) 68 | 69 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 70 | seen[hostname] = true 71 | 72 | select { 73 | case results <- Result{Source: a.Name(), Value: hostname, Type: "subdomain"}: 74 | case <-ctx.Done(): 75 | return 76 | } 77 | } 78 | } 79 | }() 80 | 81 | return results 82 | } 83 | -------------------------------------------------------------------------------- /pkg/config/paths.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | ) 9 | 10 | // windows: C:\Users\{user}\AppData\Roaming\samoscout 11 | // macOS: ~/Library/Application Support/samoscout 12 | // linux: ~/.config/samoscout 13 | func GetConfigDir() string { 14 | var configDir string 15 | 16 | switch runtime.GOOS { 17 | case "windows": 18 | appData := os.Getenv("APPDATA") 19 | if appData == "" { 20 | home, err := os.UserHomeDir() 21 | if err != nil { 22 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 23 | } 24 | appData = filepath.Join(home, "AppData", "Roaming") 25 | } 26 | configDir = filepath.Join(appData, "samoscout") 27 | 28 | case "darwin": 29 | home, err := os.UserHomeDir() 30 | if err != nil { 31 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 32 | } 33 | configDir = filepath.Join(home, "Library", "Application Support", "samoscout") 34 | 35 | default: 36 | xdgConfig := os.Getenv("XDG_CONFIG_HOME") 37 | if xdgConfig == "" { 38 | home, err := os.UserHomeDir() 39 | if err != nil { 40 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 41 | } 42 | xdgConfig = filepath.Join(home, ".config") 43 | } 44 | configDir = filepath.Join(xdgConfig, "samoscout") 45 | } 46 | 47 | return configDir 48 | } 49 | 50 | // windows: C:\Users\{user}\AppData\Local\samoscout 51 | // macOS: ~/Library/Caches/samoscout 52 | // linux: ~/.cache/samoscout 53 | func GetCacheDir() string { 54 | var cacheDir string 55 | 56 | switch runtime.GOOS { 57 | case "windows": 58 | localAppData := os.Getenv("LOCALAPPDATA") 59 | if localAppData == "" { 60 | home, err := os.UserHomeDir() 61 | if err != nil { 62 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 63 | } 64 | localAppData = filepath.Join(home, "AppData", "Local") 65 | } 66 | cacheDir = filepath.Join(localAppData, "samoscout") 67 | 68 | case "darwin": 69 | home, err := os.UserHomeDir() 70 | if err != nil { 71 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 72 | } 73 | cacheDir = filepath.Join(home, "Library", "Caches", "samoscout") 74 | 75 | default: 76 | xdgCache := os.Getenv("XDG_CACHE_HOME") 77 | if xdgCache == "" { 78 | home, err := os.UserHomeDir() 79 | if err != nil { 80 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 81 | } 82 | xdgCache = filepath.Join(home, ".cache") 83 | } 84 | cacheDir = filepath.Join(xdgCache, "samoscout") 85 | } 86 | 87 | return cacheDir 88 | } 89 | 90 | func GetDefaultConfigPath() string { 91 | return filepath.Join(GetConfigDir(), "config.yaml") 92 | } 93 | 94 | func GetLLMCacheDir() string { 95 | return filepath.Join(GetCacheDir(), "llm") 96 | } 97 | 98 | -------------------------------------------------------------------------------- /pkg/sources/threatcrowd.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | 14 | type ThreatCrowd struct{} 15 | 16 | 17 | type ThreatCrowdResponse struct { 18 | ResponseCode string `json:"response_code"` 19 | Subdomains []string `json:"subdomains"` 20 | Undercount string `json:"undercount"` 21 | } 22 | 23 | 24 | func (t *ThreatCrowd) Name() string { 25 | return "threatcrowd" 26 | } 27 | 28 | 29 | func (t *ThreatCrowd) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 30 | results := make(chan Result) 31 | 32 | go func() { 33 | defer close(results) 34 | 35 | seen := make(map[string]bool) 36 | url := fmt.Sprintf("http://ci-www.threatcrowd.org/searchApi/v2/domain/report/?domain=%s", domain) 37 | 38 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 39 | if err != nil { 40 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 41 | return 42 | } 43 | 44 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 45 | 46 | resp, err := session.Client.Do(req) 47 | if err != nil { 48 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API request failed: %w", err)} 49 | return 50 | } 51 | defer resp.Body.Close() 52 | 53 | if resp.StatusCode != http.StatusOK { 54 | results <- Result{Source: t.Name(), Error: fmt.Errorf("unexpected status code: %d", resp.StatusCode)} 55 | return 56 | } 57 | 58 | body, err := io.ReadAll(resp.Body) 59 | if err != nil { 60 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to read response body: %w", err)} 61 | return 62 | } 63 | 64 | var response ThreatCrowdResponse 65 | if err := json.Unmarshal(body, &response); err != nil { 66 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 67 | return 68 | } 69 | 70 | 71 | if response.ResponseCode != "1" { 72 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API returned response code: %s", response.ResponseCode)} 73 | return 74 | } 75 | 76 | 77 | for _, subdomain := range response.Subdomains { 78 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 79 | 80 | if hostname != "" && !seen[hostname] { 81 | seen[hostname] = true 82 | 83 | select { 84 | case results <- Result{Source: t.Name(), Value: hostname, Type: "subdomain"}: 85 | case <-ctx.Done(): 86 | return 87 | } 88 | } 89 | } 90 | }() 91 | 92 | return results 93 | } 94 | -------------------------------------------------------------------------------- /pkg/llm/llm_inference.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import json 5 | import torch 6 | from huggingface_hub import hf_hub_download 7 | from transformers import PreTrainedTokenizerFast 8 | 9 | MODEL_REPO = "HadrianSecurity/subwiz" 10 | 11 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 12 | from gpt_model import GPT 13 | 14 | class LLMInference: 15 | def __init__(self): 16 | model_path = hf_hub_download(repo_id=MODEL_REPO, filename='model.pt') 17 | tokenizer_path = hf_hub_download(repo_id=MODEL_REPO, filename='tokenizer.json') 18 | 19 | self.model = GPT.from_checkpoint(model_path, device='cpu', tokenizer_path=tokenizer_path) 20 | self.model.eval() 21 | self.tokenizer = PreTrainedTokenizerFast( 22 | tokenizer_file=tokenizer_path, 23 | clean_up_tokenization_spaces=True 24 | ) 25 | 26 | def predict(self, subdomains, apex, num_predictions=500, max_tokens=10, temperature=0.0, blocked=None): 27 | blocked = blocked or [] 28 | 29 | tokenizer_input = ",".join(sorted(subdomains)) + "[DELIM]" 30 | x = self.tokenizer.encode(tokenizer_input) 31 | x = [1] * (self.model.config.block_size - len(x)) + x 32 | x = torch.tensor(x) 33 | 34 | blocked_outputs = set(blocked) 35 | 36 | predictions = self.model.generate( 37 | x, 38 | max_new_tokens=max_tokens, 39 | topn=num_predictions, 40 | temperature=temperature, 41 | blocked_outputs=blocked_outputs, 42 | ) 43 | predictions = predictions.int().tolist() 44 | 45 | results = [] 46 | for pred in predictions: 47 | decoded = self.tokenizer.decode(pred).replace(" ", "").rsplit("[DELIM]", 1) 48 | if len(decoded) > 1: 49 | subdomain = decoded[1] 50 | full_domain = subdomain + "." + apex 51 | results.append(full_domain) 52 | 53 | return results 54 | 55 | if __name__ == "__main__": 56 | try: 57 | input_json = sys.stdin.read() 58 | input_data = json.loads(input_json) 59 | 60 | llm = LLMInference() 61 | predictions = llm.predict( 62 | subdomains=input_data.get("subdomains", []), 63 | apex=input_data.get("apex", ""), 64 | num_predictions=input_data.get("num_predictions", 500), 65 | max_tokens=input_data.get("max_tokens", 10), 66 | temperature=input_data.get("temperature", 0.0), 67 | blocked=input_data.get("blocked", []) 68 | ) 69 | 70 | print(json.dumps({"predictions": predictions})) 71 | except Exception as e: 72 | print(json.dumps({"error": str(e)})) 73 | sys.exit(1) 74 | 75 | -------------------------------------------------------------------------------- /pkg/sources/bevigil.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type BeVigil struct{} 13 | 14 | type BeVigilResponse struct { 15 | Domain string `json:"domain"` 16 | Subdomains []string `json:"subdomains"` 17 | } 18 | 19 | func (b *BeVigil) Name() string { 20 | return "bevigil" 21 | } 22 | 23 | func (b *BeVigil) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 24 | results := make(chan Result) 25 | 26 | go func() { 27 | defer close(results) 28 | 29 | if s.Keys.BeVigil == "" { 30 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BeVigil API key not configured")} 31 | return 32 | } 33 | 34 | url := fmt.Sprintf("https://osint.bevigil.com/api/%s/subdomains/", domain) 35 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 36 | if err != nil { 37 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 38 | return 39 | } 40 | 41 | req.Header.Set("X-Access-Token", s.Keys.BeVigil) 42 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 43 | req.Header.Set("Accept", "application/json") 44 | 45 | resp, err := s.Client.Do(req) 46 | if err != nil { 47 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 48 | return 49 | } 50 | defer resp.Body.Close() 51 | 52 | if resp.StatusCode == 401 { 53 | results <- Result{Source: b.Name(), Error: fmt.Errorf("invalid BeVigil API key")} 54 | return 55 | } 56 | 57 | if resp.StatusCode == 429 { 58 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BeVigil rate limit exceeded")} 59 | return 60 | } 61 | 62 | if resp.StatusCode != http.StatusOK { 63 | results <- Result{Source: b.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 64 | return 65 | } 66 | 67 | var bevigilResp BeVigilResponse 68 | if err := json.NewDecoder(resp.Body).Decode(&bevigilResp); err != nil { 69 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 70 | return 71 | } 72 | 73 | seen := make(map[string]bool) 74 | for _, subdomain := range bevigilResp.Subdomains { 75 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 76 | 77 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 78 | seen[hostname] = true 79 | 80 | select { 81 | case results <- Result{Source: b.Name(), Value: hostname, Type: "subdomain"}: 82 | case <-ctx.Done(): 83 | return 84 | } 85 | } 86 | } 87 | }() 88 | 89 | return results 90 | } 91 | -------------------------------------------------------------------------------- /pkg/sources/subdomaincenter.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type SubdomainCenter struct{} 13 | 14 | type SubdomainCenterResponse []string 15 | 16 | func (s *SubdomainCenter) Name() string { 17 | return "subdomaincenter" 18 | } 19 | 20 | func (s *SubdomainCenter) Run(ctx context.Context, domain string, sess *session.Session) <-chan Result { 21 | results := make(chan Result) 22 | 23 | go func() { 24 | defer close(results) 25 | 26 | seen := make(map[string]bool) 27 | 28 | var url string 29 | if sess.Keys.SubdomainCenter != "" { 30 | url = fmt.Sprintf("https://api.subdomain.center/beta/?domain=%s&engine=cuttlefish&auth=%s", domain, sess.Keys.SubdomainCenter) 31 | } else { 32 | url = fmt.Sprintf("https://api.subdomain.center/?domain=%s&engine=cuttlefish", domain) 33 | } 34 | 35 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 36 | if err != nil { 37 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 38 | return 39 | } 40 | 41 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 42 | req.Header.Set("Accept", "application/json") 43 | 44 | resp, err := sess.Client.Do(req) 45 | if err != nil { 46 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 47 | return 48 | } 49 | defer resp.Body.Close() 50 | 51 | if resp.StatusCode == 401 || resp.StatusCode == 403 { 52 | results <- Result{Source: s.Name(), Error: fmt.Errorf("authentication failed - invalid API key")} 53 | return 54 | } 55 | 56 | if resp.StatusCode == 429 { 57 | results <- Result{Source: s.Name(), Error: fmt.Errorf("rate limit exceeded - consider using an API key")} 58 | return 59 | } 60 | 61 | if resp.StatusCode != http.StatusOK { 62 | results <- Result{Source: s.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 63 | return 64 | } 65 | 66 | var response SubdomainCenterResponse 67 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 68 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 69 | return 70 | } 71 | 72 | // Process subdomains 73 | for _, subdomain := range response { 74 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 75 | 76 | if hostname != "" && !seen[hostname] { 77 | seen[hostname] = true 78 | 79 | select { 80 | case results <- Result{Source: s.Name(), Value: hostname, Type: "subdomain"}: 81 | case <-ctx.Done(): 82 | return 83 | } 84 | } 85 | } 86 | }() 87 | 88 | return results 89 | } 90 | -------------------------------------------------------------------------------- /pkg/elastic/elastic.go: -------------------------------------------------------------------------------- 1 | package elastic 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | es8 "github.com/elastic/go-elasticsearch/v8" 12 | "github.com/elastic/go-elasticsearch/v8/esutil" 13 | ) 14 | 15 | type Config struct { 16 | URL string 17 | Username string 18 | Password string 19 | Index string 20 | } 21 | 22 | type Client struct { 23 | es *es8.Client 24 | index string 25 | } 26 | 27 | func New(cfg Config) (*Client, error) { 28 | if cfg.URL == "" { 29 | return nil, errors.New("elasticsearch URL is required") 30 | } 31 | index := cfg.Index 32 | if strings.TrimSpace(index) == "" { 33 | index = "samoscout_httpx" 34 | } 35 | 36 | es, err := es8.NewClient(es8.Config{ 37 | Addresses: []string{cfg.URL}, 38 | Username: cfg.Username, 39 | Password: cfg.Password, 40 | }) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to create elasticsearch client: %w", err) 43 | } 44 | 45 | // Lightweight ping 46 | if _, err := es.Info(); err != nil { 47 | return nil, fmt.Errorf("failed to connect to elasticsearch: %w", err) 48 | } 49 | 50 | return &Client{es: es, index: index}, nil 51 | } 52 | 53 | func (c *Client) IndexJSONLinesFile(ctx context.Context, filename string) error { 54 | f, err := os.Open(filename) 55 | if err != nil { 56 | return fmt.Errorf("failed to open jsonl file: %w", err) 57 | } 58 | defer f.Close() 59 | 60 | bi, err := esutil.NewBulkIndexer(esutil.BulkIndexerConfig{ 61 | Client: c.es, 62 | Index: c.index, 63 | NumWorkers: 4, 64 | }) 65 | if err != nil { 66 | return fmt.Errorf("failed to create bulk indexer: %w", err) 67 | } 68 | 69 | scanner := bufio.NewScanner(f) 70 | buf := make([]byte, 0, 1024*1024) 71 | scanner.Buffer(buf, 8*1024*1024) 72 | 73 | for scanner.Scan() { 74 | line := strings.TrimSpace(scanner.Text()) 75 | if line == "" { 76 | continue 77 | } 78 | 79 | item := esutil.BulkIndexerItem{ 80 | Action: "index", 81 | DocumentID: "", 82 | Body: strings.NewReader(line), 83 | OnFailure: func(ctx context.Context, item esutil.BulkIndexerItem, resp esutil.BulkIndexerResponseItem, err error) { 84 | }, 85 | } 86 | if err := bi.Add(ctx, item); err != nil { 87 | return fmt.Errorf("bulk add failed: %w", err) 88 | } 89 | } 90 | if err := scanner.Err(); err != nil { 91 | return fmt.Errorf("scanner error: %w", err) 92 | } 93 | 94 | if err := bi.Close(ctx); err != nil { 95 | return fmt.Errorf("bulk indexer close failed: %w", err) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | 102 | -------------------------------------------------------------------------------- /pkg/llm/validator.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | validSubdomainRe = regexp.MustCompile( 10 | `^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`, 11 | ) 12 | 13 | validSubdomainStartRe = regexp.MustCompile( 14 | `^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)?$`, 15 | ) 16 | ) 17 | 18 | type Validator struct{} 19 | 20 | func NewValidator() *Validator { 21 | return &Validator{} 22 | } 23 | 24 | func (v *Validator) IsValidSubdomain(s string) bool { 25 | s = strings.ToLower(strings.TrimSpace(s)) 26 | 27 | if s == "" || len(s) > 253 { 28 | return false 29 | } 30 | 31 | if strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") { 32 | return false 33 | } 34 | 35 | if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") { 36 | return false 37 | } 38 | 39 | if strings.Contains(s, "..") || strings.Contains(s, "--") { 40 | return false 41 | } 42 | 43 | return validSubdomainRe.MatchString(s) 44 | } 45 | 46 | func (v *Validator) IsValidSubdomainStart(s string) bool { 47 | s = strings.TrimSpace(s) 48 | return validSubdomainStartRe.MatchString(s) 49 | } 50 | 51 | func (v *Validator) ExtractSubdomain(fullDomain, apex string) (string, bool) { 52 | fullDomain = strings.ToLower(strings.TrimSpace(fullDomain)) 53 | apex = strings.ToLower(strings.TrimSpace(apex)) 54 | 55 | if fullDomain == "" || apex == "" { 56 | return "", false 57 | } 58 | 59 | fullDomain = strings.TrimPrefix(fullDomain, "*.") 60 | 61 | if fullDomain == apex { 62 | return "", true 63 | } 64 | 65 | if !strings.HasSuffix(fullDomain, "."+apex) { 66 | return "", false 67 | } 68 | 69 | subdomain := strings.TrimSuffix(fullDomain, "."+apex) 70 | 71 | if !v.IsValidSubdomain(subdomain) { 72 | return "", false 73 | } 74 | 75 | return subdomain, true 76 | } 77 | 78 | func (v *Validator) NormalizeSubdomains(domains []string, apex string) []string { 79 | seen := make(map[string]bool) 80 | result := []string{} 81 | 82 | for _, domain := range domains { 83 | normalized := strings.ToLower(strings.TrimSpace(domain)) 84 | if normalized == "" { 85 | continue 86 | } 87 | 88 | normalized = strings.TrimPrefix(normalized, "*.") 89 | 90 | if !seen[normalized] && strings.HasSuffix(normalized, apex) { 91 | seen[normalized] = true 92 | result = append(result, normalized) 93 | } 94 | } 95 | 96 | return result 97 | } 98 | 99 | func (v *Validator) FilterBlockedDomains(predictions []string, blocked map[string]bool) []string { 100 | result := []string{} 101 | 102 | for _, pred := range predictions { 103 | normalized := strings.ToLower(strings.TrimSpace(pred)) 104 | if !blocked[normalized] { 105 | result = append(result, pred) 106 | } 107 | } 108 | 109 | return result 110 | } 111 | 112 | -------------------------------------------------------------------------------- /pkg/sources/chaos.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type Chaos struct{} 13 | 14 | type ChaosResponse struct { 15 | Domain string `json:"domain"` 16 | Subdomains []string `json:"subdomains"` 17 | Count int `json:"count"` 18 | } 19 | 20 | func (c *Chaos) Name() string { 21 | return "chaos" 22 | } 23 | 24 | func (c *Chaos) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 25 | results := make(chan Result) 26 | 27 | go func() { 28 | defer close(results) 29 | 30 | if s.Keys.Chaos == "" { 31 | results <- Result{Source: c.Name(), Error: fmt.Errorf("Chaos API key not configured")} 32 | return 33 | } 34 | 35 | url := fmt.Sprintf("https://dns.projectdiscovery.io/dns/%s/subdomains", domain) 36 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 37 | if err != nil { 38 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 39 | return 40 | } 41 | 42 | req.Header.Set("Authorization", s.Keys.Chaos) 43 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 44 | req.Header.Set("Accept", "application/json") 45 | 46 | resp, err := s.Client.Do(req) 47 | if err != nil { 48 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 49 | return 50 | } 51 | defer resp.Body.Close() 52 | 53 | if resp.StatusCode == 401 { 54 | results <- Result{Source: c.Name(), Error: fmt.Errorf("invalid Chaos API key")} 55 | return 56 | } 57 | 58 | if resp.StatusCode == 429 { 59 | results <- Result{Source: c.Name(), Error: fmt.Errorf("Chaos rate limit exceeded")} 60 | return 61 | } 62 | 63 | if resp.StatusCode != http.StatusOK { 64 | results <- Result{Source: c.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 65 | return 66 | } 67 | 68 | var chaosResp ChaosResponse 69 | if err := json.NewDecoder(resp.Body).Decode(&chaosResp); err != nil { 70 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 71 | return 72 | } 73 | 74 | seen := make(map[string]bool) 75 | for _, subdomain := range chaosResp.Subdomains { 76 | cleanSub := strings.TrimSpace(strings.ToLower(subdomain)) 77 | 78 | var fullSubdomain string 79 | if strings.Contains(cleanSub, ".") { 80 | fullSubdomain = cleanSub 81 | } else { 82 | fullSubdomain = fmt.Sprintf("%s.%s", cleanSub, domain) 83 | } 84 | 85 | if fullSubdomain != "" && fullSubdomain != domain && strings.HasSuffix(fullSubdomain, "."+domain) && !seen[fullSubdomain] { 86 | seen[fullSubdomain] = true 87 | 88 | select { 89 | case results <- Result{Source: c.Name(), Value: fullSubdomain, Type: "subdomain"}: 90 | case <-ctx.Done(): 91 | return 92 | } 93 | } 94 | } 95 | }() 96 | 97 | return results 98 | } 99 | -------------------------------------------------------------------------------- /pkg/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "github.com/samogod/samoscout/pkg/config" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | var DebugLog func(string, ...interface{}) 13 | 14 | type Session struct { 15 | Client *http.Client 16 | Config *config.Config 17 | Keys config.APIKeys 18 | } 19 | 20 | type LoggingTransport struct { 21 | Transport http.RoundTripper 22 | } 23 | 24 | func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 25 | if DebugLog != nil { 26 | DebugLog("requesting url: %s", req.URL.String()) 27 | 28 | if len(req.Header) > 0 { 29 | var headers []string 30 | for k, v := range req.Header { 31 | if k != "User-Agent" { 32 | headers = append(headers, fmt.Sprintf("%s: %s", k, strings.Join(v, ", "))) 33 | } 34 | } 35 | if len(headers) > 0 { 36 | DebugLog("request headers: %s", strings.Join(headers, " | ")) 37 | } 38 | } 39 | } 40 | 41 | resp, err := t.Transport.RoundTrip(req) 42 | 43 | if DebugLog != nil { 44 | sourceName := extractSourceName(req.URL.String()) 45 | 46 | if err != nil { 47 | DebugLog("encountered an error with source %s: %v", sourceName, err) 48 | } else { 49 | DebugLog("response for %s: status code %d", req.URL.String(), resp.StatusCode) 50 | 51 | if contentType := resp.Header.Get("Content-Type"); contentType != "" { 52 | DebugLog("response content-type: %s", contentType) 53 | } 54 | 55 | if resp.StatusCode >= 400 { 56 | DebugLog("encountered an error with source %s: unexpected status code %d received from %s", 57 | sourceName, resp.StatusCode, req.URL.String()) 58 | 59 | if resp.Body != nil { 60 | bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 500)) 61 | if readErr == nil && len(bodyBytes) > 0 { 62 | DebugLog("error response body: %s", string(bodyBytes)) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | return resp, err 70 | } 71 | 72 | func extractSourceName(url string) string { 73 | parts := strings.Split(url, "://") 74 | if len(parts) > 1 { 75 | domain := strings.Split(parts[1], "/")[0] 76 | domainParts := strings.Split(domain, ".") 77 | if len(domainParts) > 0 { 78 | return domainParts[0] 79 | } 80 | } 81 | 82 | return "unknown" 83 | } 84 | 85 | func New(cfg *config.Config) (*Session, error) { 86 | baseTransport := &http.Transport{ 87 | MaxIdleConns: 100, 88 | MaxIdleConnsPerHost: 100, 89 | IdleConnTimeout: 90 * time.Second, 90 | DisableKeepAlives: false, 91 | } 92 | 93 | var transport http.RoundTripper = baseTransport 94 | if DebugLog != nil { 95 | transport = &LoggingTransport{Transport: baseTransport} 96 | } 97 | 98 | client := &http.Client{ 99 | Timeout: time.Duration(cfg.DefaultSettings.Timeout*3) * time.Second, 100 | Transport: transport, 101 | } 102 | 103 | return &Session{ 104 | Client: client, 105 | Config: cfg, 106 | Keys: cfg.APIKeys, 107 | }, nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/sources/threatbook.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | 14 | type ThreatBook struct{} 15 | 16 | 17 | type ThreatBookResponse struct { 18 | ResponseCode int64 `json:"response_code"` 19 | VerboseMsg string `json:"verbose_msg"` 20 | Data struct { 21 | Domain string `json:"domain"` 22 | SubDomains struct { 23 | Total string `json:"total"` 24 | Data []string `json:"data"` 25 | } `json:"sub_domains"` 26 | } `json:"data"` 27 | } 28 | 29 | 30 | func (t *ThreatBook) Name() string { 31 | return "threatbook" 32 | } 33 | 34 | 35 | func (t *ThreatBook) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 36 | results := make(chan Result) 37 | 38 | go func() { 39 | defer close(results) 40 | 41 | 42 | if session.Keys.ThreatBook == "" { 43 | results <- Result{Source: t.Name(), Error: fmt.Errorf("ThreatBook API key not configured")} 44 | return 45 | } 46 | 47 | seen := make(map[string]bool) 48 | url := fmt.Sprintf("https://api.threatbook.cn/v3/domain/sub_domains?apikey=%s&resource=%s", session.Keys.ThreatBook, domain) 49 | 50 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 51 | if err != nil { 52 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 53 | return 54 | } 55 | 56 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 57 | 58 | resp, err := session.Client.Do(req) 59 | if err != nil { 60 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API request failed: %w", err)} 61 | return 62 | } 63 | defer resp.Body.Close() 64 | 65 | var response ThreatBookResponse 66 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 67 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 68 | return 69 | } 70 | 71 | 72 | if response.ResponseCode != 0 { 73 | results <- Result{Source: t.Name(), Error: fmt.Errorf("API error code %d: %s", response.ResponseCode, response.VerboseMsg)} 74 | return 75 | } 76 | 77 | 78 | total, err := strconv.ParseInt(response.Data.SubDomains.Total, 10, 64) 79 | if err != nil { 80 | results <- Result{Source: t.Name(), Error: fmt.Errorf("failed to parse total count: %w", err)} 81 | return 82 | } 83 | 84 | 85 | if total > 0 { 86 | for _, subdomain := range response.Data.SubDomains.Data { 87 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 88 | 89 | if hostname != "" && !seen[hostname] { 90 | seen[hostname] = true 91 | 92 | select { 93 | case results <- Result{Source: t.Name(), Value: hostname, Type: "subdomain"}: 94 | case <-ctx.Done(): 95 | return 96 | } 97 | } 98 | } 99 | } 100 | }() 101 | 102 | return results 103 | } 104 | -------------------------------------------------------------------------------- /pkg/sources/shodan.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type Shodan struct{} 14 | 15 | 16 | type ShodanResponse struct { 17 | Domain string `json:"domain"` 18 | Subdomains []string `json:"subdomains"` 19 | Result int `json:"result"` 20 | Error string `json:"error"` 21 | More bool `json:"more"` 22 | } 23 | 24 | 25 | func (s *Shodan) Name() string { 26 | return "shodan" 27 | } 28 | 29 | 30 | func (s *Shodan) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 31 | results := make(chan Result) 32 | 33 | go func() { 34 | defer close(results) 35 | 36 | 37 | if session.Keys.Shodan == "" { 38 | results <- Result{Source: s.Name(), Error: fmt.Errorf("Shodan API key not configured")} 39 | return 40 | } 41 | 42 | seen := make(map[string]bool) 43 | page := 1 44 | 45 | for { 46 | select { 47 | case <-ctx.Done(): 48 | return 49 | default: 50 | } 51 | 52 | searchURL := fmt.Sprintf("https://api.shodan.io/dns/domain/%s?key=%s&page=%d", domain, session.Keys.Shodan, page) 53 | 54 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) 55 | if err != nil { 56 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 57 | return 58 | } 59 | 60 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 61 | 62 | resp, err := session.Client.Do(req) 63 | if err != nil { 64 | results <- Result{Source: s.Name(), Error: fmt.Errorf("API request failed: %w", err)} 65 | return 66 | } 67 | 68 | var response ShodanResponse 69 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 70 | resp.Body.Close() 71 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 72 | return 73 | } 74 | resp.Body.Close() 75 | 76 | if response.Error != "" { 77 | results <- Result{Source: s.Name(), Error: fmt.Errorf("API error: %s", response.Error)} 78 | return 79 | } 80 | 81 | 82 | for _, subdomain := range response.Subdomains { 83 | 84 | var fullDomain string 85 | if strings.HasSuffix(subdomain, ".") { 86 | fullDomain = subdomain + response.Domain 87 | } else { 88 | fullDomain = subdomain + "." + response.Domain 89 | } 90 | 91 | hostname := strings.TrimSpace(strings.ToLower(fullDomain)) 92 | 93 | if hostname != "" && !seen[hostname] { 94 | seen[hostname] = true 95 | 96 | select { 97 | case results <- Result{Source: s.Name(), Value: hostname, Type: "subdomain"}: 98 | case <-ctx.Done(): 99 | return 100 | } 101 | } 102 | } 103 | 104 | if !response.More { 105 | break 106 | } 107 | page++ 108 | } 109 | }() 110 | 111 | return results 112 | } 113 | -------------------------------------------------------------------------------- /pkg/sources/dnsdumpster.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type DNSDumpster struct{} 14 | 15 | 16 | type DNSDumpsterResponse struct { 17 | A []DNSDumpsterRecord `json:"a"` 18 | Ns []DNSDumpsterRecord `json:"ns"` 19 | } 20 | 21 | type DNSDumpsterRecord struct { 22 | Host string `json:"host"` 23 | } 24 | 25 | 26 | func (d *DNSDumpster) Name() string { 27 | return "dnsdumpster" 28 | } 29 | 30 | 31 | func (d *DNSDumpster) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 32 | results := make(chan Result) 33 | 34 | go func() { 35 | defer close(results) 36 | 37 | 38 | if s.Keys.DNSDumpster == "" { 39 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DNSDumpster API key not configured")} 40 | return 41 | } 42 | 43 | 44 | url := fmt.Sprintf("https://api.dnsdumpster.com/domain/%s", domain) 45 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 46 | if err != nil { 47 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 48 | return 49 | } 50 | 51 | 52 | req.Header.Set("X-API-Key", s.Keys.DNSDumpster) 53 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 54 | req.Header.Set("Accept", "application/json") 55 | 56 | resp, err := s.Client.Do(req) 57 | if err != nil { 58 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 59 | return 60 | } 61 | defer resp.Body.Close() 62 | 63 | if resp.StatusCode == 401 { 64 | results <- Result{Source: d.Name(), Error: fmt.Errorf("invalid DNSDumpster API key")} 65 | return 66 | } 67 | 68 | if resp.StatusCode == 429 { 69 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DNSDumpster rate limit exceeded")} 70 | return 71 | } 72 | 73 | if resp.StatusCode != http.StatusOK { 74 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 75 | return 76 | } 77 | 78 | 79 | var dnsResp DNSDumpsterResponse 80 | if err := json.NewDecoder(resp.Body).Decode(&dnsResp); err != nil { 81 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 82 | return 83 | } 84 | 85 | 86 | allRecords := append(dnsResp.A, dnsResp.Ns...) 87 | seen := make(map[string]bool) 88 | 89 | for _, record := range allRecords { 90 | hostname := strings.TrimSpace(strings.ToLower(record.Host)) 91 | 92 | 93 | if hostname != "" && hostname != domain && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 94 | seen[hostname] = true 95 | 96 | select { 97 | case results <- Result{Source: d.Name(), Value: hostname, Type: "subdomain"}: 98 | case <-ctx.Done(): 99 | return 100 | } 101 | } 102 | } 103 | }() 104 | 105 | return results 106 | } 107 | -------------------------------------------------------------------------------- /pkg/llm/downloader.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/samogod/samoscout/pkg/config" 12 | ) 13 | 14 | const ( 15 | HuggingFaceRepo = "HadrianSecurity/subwiz" 16 | ModelFile = "model.pt" 17 | TokenizerFile = "tokenizer.json" 18 | ConfigFile = "config.json" 19 | ) 20 | 21 | type Downloader struct { 22 | cacheDir string 23 | client *http.Client 24 | } 25 | 26 | func NewDownloader() *Downloader { 27 | cacheDir := config.GetLLMCacheDir() 28 | 29 | return &Downloader{ 30 | cacheDir: cacheDir, 31 | client: &http.Client{}, 32 | } 33 | } 34 | 35 | func (d *Downloader) DownloadModel(forceDownload bool) (string, string, error) { 36 | if err := os.MkdirAll(d.cacheDir, 0755); err != nil { 37 | return "", "", fmt.Errorf("failed to create cache directory: %w", err) 38 | } 39 | 40 | modelPath := filepath.Join(d.cacheDir, ModelFile) 41 | tokenizerPath := filepath.Join(d.cacheDir, TokenizerFile) 42 | configPath := filepath.Join(d.cacheDir, ConfigFile) 43 | 44 | if !forceDownload { 45 | if fileExists(modelPath) && fileExists(tokenizerPath) && fileExists(configPath) { 46 | return modelPath, tokenizerPath, nil 47 | } 48 | } 49 | 50 | baseURL := fmt.Sprintf("https://huggingface.co/%s/resolve/main", HuggingFaceRepo) 51 | 52 | fmt.Println("[LLM] Downloading AI model files (first run, ~100MB)...") 53 | 54 | files := []struct { 55 | name string 56 | path string 57 | url string 58 | }{ 59 | {ModelFile, modelPath, baseURL + "/" + ModelFile}, 60 | {TokenizerFile, tokenizerPath, baseURL + "/" + TokenizerFile}, 61 | {ConfigFile, configPath, baseURL + "/" + ConfigFile}, 62 | } 63 | 64 | for _, file := range files { 65 | if forceDownload || !fileExists(file.path) { 66 | fmt.Printf("[LLM] downloading %s...\n", file.name) 67 | if err := d.downloadFile(file.url, file.path); err != nil { 68 | return "", "", fmt.Errorf("failed to download %s: %w", file.name, err) 69 | } 70 | } 71 | } 72 | 73 | fmt.Printf("[LLM] Model cached at %s\n", d.cacheDir) 74 | 75 | return modelPath, tokenizerPath, nil 76 | } 77 | 78 | func (d *Downloader) downloadFile(url, dest string) error { 79 | resp, err := d.client.Get(url) 80 | if err != nil { 81 | return err 82 | } 83 | defer resp.Body.Close() 84 | 85 | if resp.StatusCode != http.StatusOK { 86 | return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) 87 | } 88 | 89 | out, err := os.Create(dest) 90 | if err != nil { 91 | return err 92 | } 93 | defer out.Close() 94 | 95 | _, err = io.Copy(out, resp.Body) 96 | return err 97 | } 98 | 99 | func (d *Downloader) LoadConfig(configPath string) (*ModelConfig, error) { 100 | data, err := os.ReadFile(configPath) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | var config ModelConfig 106 | if err := json.Unmarshal(data, &config); err != nil { 107 | return nil, err 108 | } 109 | 110 | return &config, nil 111 | } 112 | 113 | func fileExists(path string) bool { 114 | _, err := os.Stat(path) 115 | return err == nil 116 | } 117 | 118 | -------------------------------------------------------------------------------- /pkg/sources/fullhunt.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type FullHunt struct{} 14 | 15 | 16 | type FullHuntResponse struct { 17 | Hosts []string `json:"hosts"` 18 | Message string `json:"message"` 19 | Status int `json:"status"` 20 | } 21 | 22 | 23 | func (f *FullHunt) Name() string { 24 | return "fullhunt" 25 | } 26 | 27 | 28 | func (f *FullHunt) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 29 | results := make(chan Result) 30 | 31 | go func() { 32 | defer close(results) 33 | 34 | 35 | if s.Keys.FullHunt == "" { 36 | results <- Result{Source: f.Name(), Error: fmt.Errorf("FullHunt API key not configured")} 37 | return 38 | } 39 | 40 | 41 | url := fmt.Sprintf("https://fullhunt.io/api/v1/domain/%s/subdomains", domain) 42 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 43 | if err != nil { 44 | results <- Result{Source: f.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 45 | return 46 | } 47 | 48 | 49 | req.Header.Set("X-API-KEY", s.Keys.FullHunt) 50 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 51 | req.Header.Set("Accept", "application/json") 52 | 53 | resp, err := s.Client.Do(req) 54 | if err != nil { 55 | results <- Result{Source: f.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 56 | return 57 | } 58 | defer resp.Body.Close() 59 | 60 | if resp.StatusCode == 401 { 61 | results <- Result{Source: f.Name(), Error: fmt.Errorf("invalid FullHunt API key")} 62 | return 63 | } 64 | 65 | if resp.StatusCode == 429 { 66 | results <- Result{Source: f.Name(), Error: fmt.Errorf("FullHunt rate limit exceeded")} 67 | return 68 | } 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | results <- Result{Source: f.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 72 | return 73 | } 74 | 75 | 76 | var fullHuntResp FullHuntResponse 77 | if err := json.NewDecoder(resp.Body).Decode(&fullHuntResp); err != nil { 78 | results <- Result{Source: f.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 79 | return 80 | } 81 | 82 | 83 | if fullHuntResp.Status != 200 && fullHuntResp.Status != 0 { 84 | results <- Result{Source: f.Name(), Error: fmt.Errorf("FullHunt API error: %s (status: %d)", fullHuntResp.Message, fullHuntResp.Status)} 85 | return 86 | } 87 | 88 | 89 | seen := make(map[string]bool) 90 | for _, host := range fullHuntResp.Hosts { 91 | hostname := strings.TrimSpace(strings.ToLower(host)) 92 | 93 | 94 | if hostname != "" && hostname != domain && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 95 | seen[hostname] = true 96 | 97 | select { 98 | case results <- Result{Source: f.Name(), Value: hostname, Type: "subdomain"}: 99 | case <-ctx.Done(): 100 | return 101 | } 102 | } 103 | } 104 | }() 105 | 106 | return results 107 | } 108 | -------------------------------------------------------------------------------- /pkg/sources/whoisxmlapi.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type WhoisXMLAPI struct{} 14 | 15 | 16 | type WhoisXMLAPIResponse struct { 17 | Search string `json:"search"` 18 | Result WhoisXMLAPIResponseResult `json:"result"` 19 | } 20 | 21 | type WhoisXMLAPIResponseResult struct { 22 | Count int `json:"count"` 23 | Records []WhoisXMLAPIResponseRecord `json:"records"` 24 | } 25 | 26 | type WhoisXMLAPIResponseRecord struct { 27 | Domain string `json:"domain"` 28 | FirstSeen int `json:"firstSeen"` 29 | LastSeen int `json:"lastSeen"` 30 | } 31 | 32 | 33 | func (w *WhoisXMLAPI) Name() string { 34 | return "whoisxmlapi" 35 | } 36 | 37 | 38 | func (w *WhoisXMLAPI) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 39 | results := make(chan Result) 40 | 41 | go func() { 42 | defer close(results) 43 | 44 | 45 | if session.Keys.WhoisXMLAPI == "" { 46 | results <- Result{Source: w.Name(), Error: fmt.Errorf("WhoisXMLAPI API key not configured")} 47 | return 48 | } 49 | 50 | seen := make(map[string]bool) 51 | apiURL := fmt.Sprintf("https://subdomains.whoisxmlapi.com/api/v1?apiKey=%s&domainName=%s", session.Keys.WhoisXMLAPI, domain) 52 | 53 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 54 | if err != nil { 55 | results <- Result{Source: w.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 56 | return 57 | } 58 | 59 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 60 | 61 | resp, err := session.Client.Do(req) 62 | if err != nil { 63 | results <- Result{Source: w.Name(), Error: fmt.Errorf("API request failed: %w", err)} 64 | return 65 | } 66 | defer resp.Body.Close() 67 | 68 | if resp.StatusCode == 401 { 69 | results <- Result{Source: w.Name(), Error: fmt.Errorf("invalid WhoisXMLAPI API key")} 70 | return 71 | } 72 | 73 | if resp.StatusCode == 429 { 74 | results <- Result{Source: w.Name(), Error: fmt.Errorf("WhoisXMLAPI rate limit exceeded")} 75 | return 76 | } 77 | 78 | if resp.StatusCode != http.StatusOK { 79 | results <- Result{Source: w.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 80 | return 81 | } 82 | 83 | var response WhoisXMLAPIResponse 84 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 85 | results <- Result{Source: w.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 86 | return 87 | } 88 | 89 | 90 | for _, record := range response.Result.Records { 91 | hostname := strings.TrimSpace(strings.ToLower(record.Domain)) 92 | 93 | if hostname != "" && !seen[hostname] { 94 | seen[hostname] = true 95 | 96 | select { 97 | case results <- Result{Source: w.Name(), Value: hostname, Type: "subdomain"}: 98 | case <-ctx.Done(): 99 | return 100 | } 101 | } 102 | } 103 | }() 104 | 105 | return results 106 | } 107 | -------------------------------------------------------------------------------- /pkg/sources/shrewdeye.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type ShrewdEye struct{} 13 | 14 | func (s *ShrewdEye) Name() string { 15 | return "shrewdeye" 16 | } 17 | 18 | func (s *ShrewdEye) Run(ctx context.Context, domain string, sess *session.Session) <-chan Result { 19 | results := make(chan Result) 20 | 21 | go func() { 22 | defer close(results) 23 | 24 | url := fmt.Sprintf("https://shrewdeye.app/domains/%s.txt", domain) 25 | 26 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 27 | if err != nil { 28 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 29 | return 30 | } 31 | 32 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 33 | req.Header.Set("Accept", "text/plain") 34 | 35 | resp, err := sess.Client.Do(req) 36 | if err != nil { 37 | results <- Result{Source: s.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 38 | return 39 | } 40 | defer resp.Body.Close() 41 | 42 | if resp.StatusCode == 404 { 43 | return 44 | } 45 | 46 | if resp.StatusCode == 429 { 47 | results <- Result{Source: s.Name(), Error: fmt.Errorf("ShrewdEye rate limit exceeded")} 48 | return 49 | } 50 | 51 | if resp.StatusCode != http.StatusOK { 52 | results <- Result{Source: s.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 53 | return 54 | } 55 | 56 | scanner := bufio.NewScanner(resp.Body) 57 | seen := make(map[string]bool) 58 | 59 | for scanner.Scan() { 60 | line := scanner.Text() 61 | 62 | if line == "" { 63 | continue 64 | } 65 | 66 | subdomain := strings.TrimSpace(strings.ToLower(line)) 67 | 68 | if s.isValidSubdomain(subdomain, domain) && !seen[subdomain] { 69 | seen[subdomain] = true 70 | 71 | select { 72 | case results <- Result{Source: s.Name(), Value: subdomain, Type: "subdomain"}: 73 | case <-ctx.Done(): 74 | return 75 | } 76 | } 77 | } 78 | 79 | if err := scanner.Err(); err != nil { 80 | results <- Result{Source: s.Name(), Error: fmt.Errorf("scanner error: %w", err)} 81 | return 82 | } 83 | }() 84 | 85 | return results 86 | } 87 | 88 | func (s *ShrewdEye) isValidSubdomain(subdomain, domain string) bool { 89 | if subdomain == "" || subdomain == domain { 90 | return false 91 | } 92 | 93 | if !strings.HasSuffix(subdomain, "."+domain) && subdomain != domain { 94 | return false 95 | } 96 | 97 | if len(subdomain) == 0 || len(subdomain) > 253 { 98 | return false 99 | } 100 | 101 | if strings.HasPrefix(subdomain, ".") || strings.HasSuffix(subdomain, ".") { 102 | return false 103 | } 104 | 105 | if strings.Contains(subdomain, "..") { 106 | return false 107 | } 108 | 109 | if subdomain != domain && !strings.Contains(subdomain, ".") { 110 | return false 111 | } 112 | 113 | if strings.ContainsAny(subdomain, " \t\n\r") { 114 | return false 115 | } 116 | 117 | return true 118 | } 119 | -------------------------------------------------------------------------------- /pkg/active/wordlist.go: -------------------------------------------------------------------------------- 1 | package active 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | func ExtractKeywords(subdomains []string, rootDomain string) ([]string, error) { 12 | wordCount := make(map[string]int) 13 | 14 | for _, subdomain := range subdomains { 15 | subdomain = strings.ToLower(strings.TrimSpace(subdomain)) 16 | 17 | if strings.HasSuffix(subdomain, "."+rootDomain) { 18 | subdomain = strings.TrimSuffix(subdomain, "."+rootDomain) 19 | } else if subdomain == rootDomain { 20 | continue 21 | } 22 | 23 | parts := strings.Split(subdomain, ".") 24 | for _, part := range parts { 25 | part = strings.TrimSpace(part) 26 | 27 | if part == "" || isNumericOnly(part) { 28 | continue 29 | } 30 | 31 | subParts := splitByDelimiters(part) 32 | for _, subPart := range subParts { 33 | if subPart != "" && !isNumericOnly(subPart) { 34 | wordCount[subPart]++ 35 | } 36 | } 37 | } 38 | } 39 | 40 | type wordFreq struct { 41 | word string 42 | count int 43 | } 44 | 45 | var words []wordFreq 46 | for word, count := range wordCount { 47 | words = append(words, wordFreq{word, count}) 48 | } 49 | 50 | sort.Slice(words, func(i, j int) bool { 51 | if words[i].count == words[j].count { 52 | return words[i].word < words[j].word 53 | } 54 | return words[i].count > words[j].count 55 | }) 56 | 57 | var result []string 58 | for _, w := range words { 59 | result = append(result, w.word) 60 | } 61 | 62 | return result, nil 63 | } 64 | 65 | func isNumericOnly(s string) bool { 66 | for _, r := range s { 67 | if r < '0' || r > '9' { 68 | return false 69 | } 70 | } 71 | return len(s) > 0 72 | } 73 | 74 | func splitByDelimiters(s string) []string { 75 | delimiters := []string{"-", "_", "~"} 76 | for _, delim := range delimiters { 77 | s = strings.ReplaceAll(s, delim, " ") 78 | } 79 | 80 | parts := strings.Fields(s) 81 | return parts 82 | } 83 | 84 | func WriteWordlist(keywords []string, outputPath string) error { 85 | file, err := os.Create(outputPath) 86 | if err != nil { 87 | return fmt.Errorf("failed to create wordlist file: %w", err) 88 | } 89 | defer file.Close() 90 | 91 | writer := bufio.NewWriter(file) 92 | for _, keyword := range keywords { 93 | if _, err := writer.WriteString(keyword + "\n"); err != nil { 94 | return fmt.Errorf("failed to write to wordlist: %w", err) 95 | } 96 | } 97 | 98 | return writer.Flush() 99 | } 100 | 101 | func ReadSubdomains(filePath string) ([]string, error) { 102 | file, err := os.Open(filePath) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to open file: %w", err) 105 | } 106 | defer file.Close() 107 | 108 | var subdomains []string 109 | scanner := bufio.NewScanner(file) 110 | 111 | for scanner.Scan() { 112 | line := strings.TrimSpace(scanner.Text()) 113 | if line != "" && !strings.HasPrefix(line, "#") { 114 | subdomains = append(subdomains, line) 115 | } 116 | } 117 | 118 | if err := scanner.Err(); err != nil { 119 | return nil, fmt.Errorf("error reading file: %w", err) 120 | } 121 | 122 | return subdomains, nil 123 | } 124 | 125 | -------------------------------------------------------------------------------- /pkg/sources/c99.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type C99 struct{} 13 | 14 | type C99Response struct { 15 | Success bool `json:"success"` 16 | Subdomains []struct { 17 | Subdomain string `json:"subdomain"` 18 | IP string `json:"ip"` 19 | Cloudflare bool `json:"cloudflare"` 20 | } `json:"subdomains"` 21 | Error string `json:"error"` 22 | } 23 | 24 | func (c *C99) Name() string { 25 | return "c99" 26 | } 27 | 28 | func (c *C99) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 29 | results := make(chan Result) 30 | 31 | go func() { 32 | defer close(results) 33 | 34 | if s.Keys.C99 == "" { 35 | results <- Result{Source: c.Name(), Error: fmt.Errorf("C99 API key not configured")} 36 | return 37 | } 38 | 39 | url := fmt.Sprintf("https://api.c99.nl/subdomainfinder?key=%s&domain=%s&json", s.Keys.C99, domain) 40 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 41 | if err != nil { 42 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 43 | return 44 | } 45 | 46 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 47 | req.Header.Set("Accept", "application/json") 48 | 49 | resp, err := s.Client.Do(req) 50 | if err != nil { 51 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 52 | return 53 | } 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode == 401 { 57 | results <- Result{Source: c.Name(), Error: fmt.Errorf("invalid C99 API key")} 58 | return 59 | } 60 | 61 | if resp.StatusCode == 429 { 62 | results <- Result{Source: c.Name(), Error: fmt.Errorf("C99 rate limit exceeded")} 63 | return 64 | } 65 | 66 | if resp.StatusCode != http.StatusOK { 67 | results <- Result{Source: c.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 68 | return 69 | } 70 | 71 | var c99Resp C99Response 72 | if err := json.NewDecoder(resp.Body).Decode(&c99Resp); err != nil { 73 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 74 | return 75 | } 76 | 77 | if c99Resp.Error != "" { 78 | results <- Result{Source: c.Name(), Error: fmt.Errorf("API error: %s", c99Resp.Error)} 79 | return 80 | } 81 | 82 | if !c99Resp.Success { 83 | results <- Result{Source: c.Name(), Error: fmt.Errorf("API returned success=false")} 84 | return 85 | } 86 | 87 | seen := make(map[string]bool) 88 | for _, data := range c99Resp.Subdomains { 89 | hostname := strings.TrimSpace(strings.ToLower(data.Subdomain)) 90 | 91 | if !strings.HasPrefix(hostname, ".") && hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 92 | seen[hostname] = true 93 | 94 | select { 95 | case results <- Result{Source: c.Name(), Value: hostname, Type: "subdomain"}: 96 | case <-ctx.Done(): 97 | return 98 | } 99 | } 100 | } 101 | }() 102 | 103 | return results 104 | } 105 | -------------------------------------------------------------------------------- /pkg/sources/dnsarchive.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | type DNSArchive struct{} 14 | 15 | type DNSArchiveResponse []struct { 16 | Domain string `json:"domain"` 17 | } 18 | 19 | func (d *DNSArchive) Name() string { 20 | return "DNSArchive" 21 | } 22 | 23 | func (d *DNSArchive) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 24 | results := make(chan Result) 25 | 26 | go func() { 27 | defer close(results) 28 | 29 | if s.Keys.DNSArchive == "" { 30 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DNSArchive API key not configured")} 31 | return 32 | } 33 | 34 | apiKeyParts := strings.Split(s.Keys.DNSArchive, ":") 35 | if len(apiKeyParts) != 2 { 36 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DNSArchive API key must be in format 'token:apikey'")} 37 | return 38 | } 39 | 40 | token := apiKeyParts[0] 41 | apiKey := apiKeyParts[1] 42 | 43 | url := fmt.Sprintf("https://dnsarchive.net/api/?apikey=%s&search=%s", apiKey, domain) 44 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 45 | if err != nil { 46 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 47 | return 48 | } 49 | 50 | req.Header.Set("X-API-Access", token) 51 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 52 | req.Header.Set("Accept", "application/json") 53 | 54 | resp, err := s.Client.Do(req) 55 | if err != nil { 56 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 57 | return 58 | } 59 | defer resp.Body.Close() 60 | 61 | if resp.StatusCode == 401 { 62 | results <- Result{Source: d.Name(), Error: fmt.Errorf("invalid DNSArchive API credentials")} 63 | return 64 | } 65 | 66 | if resp.StatusCode == 429 { 67 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DNSArchive rate limit exceeded")} 68 | return 69 | } 70 | 71 | if resp.StatusCode != http.StatusOK { 72 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 73 | return 74 | } 75 | 76 | responseData, err := io.ReadAll(resp.Body) 77 | if err != nil { 78 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to read response body: %w", err)} 79 | return 80 | } 81 | 82 | var DNSArchiveResp DNSArchiveResponse 83 | if err := json.Unmarshal(responseData, &DNSArchiveResp); err != nil { 84 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 85 | return 86 | } 87 | 88 | seen := make(map[string]bool) 89 | for _, record := range DNSArchiveResp { 90 | 91 | hostname := strings.TrimSuffix(record.Domain, ".") 92 | hostname = strings.TrimSpace(strings.ToLower(hostname)) 93 | 94 | if hostname != "" && hostname != domain && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 95 | seen[hostname] = true 96 | 97 | select { 98 | case results <- Result{Source: d.Name(), Value: hostname, Type: "subdomain"}: 99 | case <-ctx.Done(): 100 | return 101 | } 102 | } 103 | } 104 | }() 105 | 106 | return results 107 | } 108 | -------------------------------------------------------------------------------- /pkg/sources/builtwith.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type BuiltWith struct{} 13 | 14 | type BuiltWithResponse struct { 15 | Results []BuiltWithResultItem `json:"Results"` 16 | } 17 | 18 | type BuiltWithResultItem struct { 19 | Result BuiltWithResult `json:"Result"` 20 | } 21 | 22 | type BuiltWithResult struct { 23 | Paths []BuiltWithPath `json:"Paths"` 24 | } 25 | 26 | type BuiltWithPath struct { 27 | Domain string `json:"Domain"` 28 | URL string `json:"Url"` 29 | SubDomain string `json:"SubDomain"` 30 | } 31 | 32 | func (b *BuiltWith) Name() string { 33 | return "builtwith" 34 | } 35 | 36 | func (b *BuiltWith) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 37 | results := make(chan Result) 38 | 39 | go func() { 40 | defer close(results) 41 | 42 | if s.Keys.BuiltWith == "" { 43 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BuiltWith API key not configured")} 44 | return 45 | } 46 | 47 | url := fmt.Sprintf("https://api.builtwith.com/v21/api.json?KEY=%s&HIDETEXT=yes&HIDEDL=yes&NOLIVE=yes&NOMETA=yes&NOPII=yes&NOATTR=yes&LOOKUP=%s", s.Keys.BuiltWith, domain) 48 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 49 | if err != nil { 50 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 51 | return 52 | } 53 | 54 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 55 | req.Header.Set("Accept", "application/json") 56 | 57 | resp, err := s.Client.Do(req) 58 | if err != nil { 59 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 60 | return 61 | } 62 | defer resp.Body.Close() 63 | 64 | if resp.StatusCode == 401 { 65 | results <- Result{Source: b.Name(), Error: fmt.Errorf("invalid BuiltWith API key")} 66 | return 67 | } 68 | 69 | if resp.StatusCode == 429 { 70 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BuiltWith rate limit exceeded")} 71 | return 72 | } 73 | 74 | if resp.StatusCode != http.StatusOK { 75 | results <- Result{Source: b.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 76 | return 77 | } 78 | 79 | var builtwithResp BuiltWithResponse 80 | if err := json.NewDecoder(resp.Body).Decode(&builtwithResp); err != nil { 81 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 82 | return 83 | } 84 | 85 | seen := make(map[string]bool) 86 | for _, result := range builtwithResp.Results { 87 | for _, path := range result.Result.Paths { 88 | if path.SubDomain != "" && path.Domain != "" { 89 | hostname := fmt.Sprintf("%s.%s", path.SubDomain, path.Domain) 90 | hostname = strings.TrimSpace(strings.ToLower(hostname)) 91 | 92 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 93 | seen[hostname] = true 94 | 95 | select { 96 | case results <- Result{Source: b.Name(), Value: hostname, Type: "subdomain"}: 97 | case <-ctx.Done(): 98 | return 99 | } 100 | } 101 | } 102 | } 103 | } 104 | }() 105 | 106 | return results 107 | } 108 | -------------------------------------------------------------------------------- /pkg/sources/virustotal.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type VirusTotal struct{} 14 | 15 | 16 | type VirusTotalResponse struct { 17 | Data []struct { 18 | ID string `json:"id"` 19 | Type string `json:"type"` 20 | } `json:"data"` 21 | Meta struct { 22 | Cursor string `json:"cursor"` 23 | } `json:"meta"` 24 | } 25 | 26 | 27 | func (v *VirusTotal) Name() string { 28 | return "virustotal" 29 | } 30 | 31 | 32 | func (v *VirusTotal) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 33 | results := make(chan Result) 34 | 35 | go func() { 36 | defer close(results) 37 | 38 | 39 | if s.Keys.VirusTotal == "" { 40 | results <- Result{Source: v.Name(), Error: fmt.Errorf("VirusTotal API key not configured")} 41 | return 42 | } 43 | 44 | seen := make(map[string]bool) 45 | cursor := "" 46 | 47 | 48 | for { 49 | select { 50 | case <-ctx.Done(): 51 | return 52 | default: 53 | } 54 | 55 | 56 | url := fmt.Sprintf("https://www.virustotal.com/api/v3/domains/%s/subdomains?limit=40", domain) 57 | if cursor != "" { 58 | url = fmt.Sprintf("%s&cursor=%s", url, cursor) 59 | } 60 | 61 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 62 | if err != nil { 63 | results <- Result{Source: v.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 64 | return 65 | } 66 | 67 | 68 | req.Header.Set("x-apikey", s.Keys.VirusTotal) 69 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 70 | req.Header.Set("Accept", "application/json") 71 | 72 | resp, err := s.Client.Do(req) 73 | if err != nil { 74 | results <- Result{Source: v.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 75 | return 76 | } 77 | 78 | if resp.StatusCode == 401 { 79 | resp.Body.Close() 80 | results <- Result{Source: v.Name(), Error: fmt.Errorf("invalid VirusTotal API key")} 81 | return 82 | } 83 | 84 | if resp.StatusCode == 429 { 85 | resp.Body.Close() 86 | results <- Result{Source: v.Name(), Error: fmt.Errorf("VirusTotal rate limit exceeded")} 87 | return 88 | } 89 | 90 | if resp.StatusCode != http.StatusOK { 91 | resp.Body.Close() 92 | results <- Result{Source: v.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 93 | return 94 | } 95 | 96 | 97 | var vtResp VirusTotalResponse 98 | if err := json.NewDecoder(resp.Body).Decode(&vtResp); err != nil { 99 | resp.Body.Close() 100 | results <- Result{Source: v.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 101 | return 102 | } 103 | resp.Body.Close() 104 | 105 | 106 | for _, item := range vtResp.Data { 107 | hostname := strings.TrimSpace(strings.ToLower(item.ID)) 108 | 109 | if hostname != "" && !seen[hostname] { 110 | seen[hostname] = true 111 | 112 | select { 113 | case results <- Result{Source: v.Name(), Value: hostname, Type: "subdomain"}: 114 | case <-ctx.Done(): 115 | return 116 | } 117 | } 118 | } 119 | 120 | 121 | cursor = vtResp.Meta.Cursor 122 | if cursor == "" { 123 | break 124 | } 125 | } 126 | }() 127 | 128 | return results 129 | } 130 | -------------------------------------------------------------------------------- /pkg/sources/bufferover.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type BufferOver struct{} 13 | 14 | type BufferOverResponse struct { 15 | Meta struct { 16 | Errors []string `json:"Errors"` 17 | } `json:"Meta"` 18 | FDNSA []string `json:"FDNS_A"` 19 | RDNS []string `json:"RDNS"` 20 | Results []string `json:"Results"` 21 | } 22 | 23 | func (b *BufferOver) Name() string { 24 | return "bufferover" 25 | } 26 | 27 | func (b *BufferOver) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 28 | results := make(chan Result) 29 | 30 | go func() { 31 | defer close(results) 32 | 33 | if s.Keys.BufferOver == "" { 34 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BufferOver API key not configured")} 35 | return 36 | } 37 | 38 | url := fmt.Sprintf("https://tls.bufferover.run/dns?q=.%s", domain) 39 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 40 | if err != nil { 41 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 42 | return 43 | } 44 | 45 | req.Header.Set("x-api-key", s.Keys.BufferOver) 46 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 47 | req.Header.Set("Accept", "application/json") 48 | 49 | resp, err := s.Client.Do(req) 50 | if err != nil { 51 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 52 | return 53 | } 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode == 401 { 57 | results <- Result{Source: b.Name(), Error: fmt.Errorf("invalid BufferOver API key")} 58 | return 59 | } 60 | 61 | if resp.StatusCode == 429 { 62 | results <- Result{Source: b.Name(), Error: fmt.Errorf("BufferOver rate limit exceeded")} 63 | return 64 | } 65 | 66 | if resp.StatusCode != http.StatusOK { 67 | results <- Result{Source: b.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 68 | return 69 | } 70 | 71 | var bufferoverResp BufferOverResponse 72 | if err := json.NewDecoder(resp.Body).Decode(&bufferoverResp); err != nil { 73 | results <- Result{Source: b.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 74 | return 75 | } 76 | 77 | if len(bufferoverResp.Meta.Errors) > 0 { 78 | results <- Result{Source: b.Name(), Error: fmt.Errorf("API errors: %s", strings.Join(bufferoverResp.Meta.Errors, ", "))} 79 | return 80 | } 81 | 82 | var allSubdomains []string 83 | if len(bufferoverResp.FDNSA) > 0 { 84 | allSubdomains = bufferoverResp.FDNSA 85 | allSubdomains = append(allSubdomains, bufferoverResp.RDNS...) 86 | } else if len(bufferoverResp.Results) > 0 { 87 | allSubdomains = bufferoverResp.Results 88 | } 89 | 90 | seen := make(map[string]bool) 91 | for _, subdomain := range allSubdomains { 92 | parts := strings.Fields(subdomain) 93 | for _, part := range parts { 94 | hostname := strings.TrimSpace(strings.ToLower(part)) 95 | 96 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 97 | seen[hostname] = true 98 | 99 | select { 100 | case results <- Result{Source: b.Name(), Value: hostname, Type: "subdomain"}: 101 | case <-ctx.Done(): 102 | return 103 | } 104 | } 105 | } 106 | } 107 | }() 108 | 109 | return results 110 | } 111 | -------------------------------------------------------------------------------- /pkg/sources/digitalyama.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type DigitalYama struct{} 14 | 15 | 16 | type DigitalYamaResponse struct { 17 | Query string `json:"query"` 18 | Count int `json:"count"` 19 | Subdomains []string `json:"subdomains"` 20 | UsageSummary struct { 21 | QueryCost float64 `json:"query_cost"` 22 | CreditsRemaining float64 `json:"credits_remaining"` 23 | } `json:"usage_summary"` 24 | } 25 | 26 | 27 | type DigitalYamaErrorResponse struct { 28 | Detail []struct { 29 | Loc []string `json:"loc"` 30 | Msg string `json:"msg"` 31 | Type string `json:"type"` 32 | } `json:"detail"` 33 | } 34 | 35 | 36 | func (d *DigitalYama) Name() string { 37 | return "digitalyama" 38 | } 39 | 40 | 41 | func (d *DigitalYama) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 42 | results := make(chan Result) 43 | 44 | go func() { 45 | defer close(results) 46 | 47 | 48 | if s.Keys.DigitalYama == "" { 49 | results <- Result{Source: d.Name(), Error: fmt.Errorf("DigitalYama API key not configured")} 50 | return 51 | } 52 | 53 | 54 | url := fmt.Sprintf("https://api.digitalyama.com/subdomain_finder?domain=%s", domain) 55 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 56 | if err != nil { 57 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 58 | return 59 | } 60 | 61 | 62 | req.Header.Set("x-api-key", s.Keys.DigitalYama) 63 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 64 | req.Header.Set("Accept", "application/json") 65 | 66 | resp, err := s.Client.Do(req) 67 | if err != nil { 68 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 69 | return 70 | } 71 | defer resp.Body.Close() 72 | 73 | 74 | if resp.StatusCode != http.StatusOK { 75 | var errResponse DigitalYamaErrorResponse 76 | if err := json.NewDecoder(resp.Body).Decode(&errResponse); err != nil { 77 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 78 | return 79 | } 80 | 81 | if len(errResponse.Detail) > 0 { 82 | errMsg := errResponse.Detail[0].Msg 83 | results <- Result{Source: d.Name(), Error: fmt.Errorf("%s (HTTP %d)", errMsg, resp.StatusCode)} 84 | } else { 85 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 86 | } 87 | return 88 | } 89 | 90 | 91 | var digitalYamaResp DigitalYamaResponse 92 | if err := json.NewDecoder(resp.Body).Decode(&digitalYamaResp); err != nil { 93 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 94 | return 95 | } 96 | 97 | 98 | seen := make(map[string]bool) 99 | for _, subdomain := range digitalYamaResp.Subdomains { 100 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 101 | 102 | 103 | if hostname != "" && hostname != domain && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 104 | seen[hostname] = true 105 | 106 | select { 107 | case results <- Result{Source: d.Name(), Value: hostname, Type: "subdomain"}: 108 | case <-ctx.Done(): 109 | return 110 | } 111 | } 112 | } 113 | }() 114 | 115 | return results 116 | } 117 | -------------------------------------------------------------------------------- /pkg/sources/myssl.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type MySSL struct{} 13 | 14 | type MySSLResponse struct { 15 | Code int `json:"code"` 16 | Error string `json:"error"` 17 | Data []MySSLSubdomain `json:"data"` 18 | } 19 | 20 | type MySSLSubdomain struct { 21 | Domain string `json:"domain"` 22 | } 23 | 24 | func (m *MySSL) Name() string { 25 | return "myssl" 26 | } 27 | 28 | func (m *MySSL) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 29 | results := make(chan Result) 30 | 31 | go func() { 32 | defer close(results) 33 | 34 | url := fmt.Sprintf("https://myssl.com/api/v1/discover_sub_domain?domain=%s", domain) 35 | 36 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 37 | if err != nil { 38 | results <- Result{Source: m.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 39 | return 40 | } 41 | 42 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 43 | req.Header.Set("Accept", "application/json") 44 | 45 | resp, err := s.Client.Do(req) 46 | if err != nil { 47 | results <- Result{Source: m.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 48 | return 49 | } 50 | defer resp.Body.Close() 51 | 52 | if resp.StatusCode == 429 { 53 | results <- Result{Source: m.Name(), Error: fmt.Errorf("MySSL rate limit exceeded")} 54 | return 55 | } 56 | 57 | if resp.StatusCode != http.StatusOK { 58 | results <- Result{Source: m.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 59 | return 60 | } 61 | 62 | var myssLResponse MySSLResponse 63 | if err := json.NewDecoder(resp.Body).Decode(&myssLResponse); err != nil { 64 | results <- Result{Source: m.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 65 | return 66 | } 67 | 68 | if myssLResponse.Code != 0 { 69 | if len(myssLResponse.Data) == 0 { 70 | results <- Result{Source: m.Name(), Error: fmt.Errorf("API error: %s", myssLResponse.Error)} 71 | return 72 | } 73 | } 74 | 75 | seen := make(map[string]bool) 76 | 77 | for _, subdomainData := range myssLResponse.Data { 78 | subdomain := strings.TrimSpace(strings.ToLower(subdomainData.Domain)) 79 | 80 | if m.isValidSubdomain(subdomain, domain) && !seen[subdomain] { 81 | seen[subdomain] = true 82 | 83 | select { 84 | case results <- Result{Source: m.Name(), Value: subdomain, Type: "subdomain"}: 85 | case <-ctx.Done(): 86 | return 87 | } 88 | } 89 | } 90 | }() 91 | 92 | return results 93 | } 94 | 95 | func (m *MySSL) isValidSubdomain(subdomain, domain string) bool { 96 | if subdomain == "" || subdomain == domain { 97 | return false 98 | } 99 | 100 | if !strings.HasSuffix(subdomain, "."+domain) && subdomain != domain { 101 | return false 102 | } 103 | 104 | if len(subdomain) == 0 || len(subdomain) > 253 { 105 | return false 106 | } 107 | 108 | if strings.HasPrefix(subdomain, ".") || strings.HasSuffix(subdomain, ".") { 109 | return false 110 | } 111 | 112 | if strings.Contains(subdomain, "..") { 113 | return false 114 | } 115 | 116 | if subdomain != domain && !strings.Contains(subdomain, ".") { 117 | return false 118 | } 119 | 120 | if strings.ContainsAny(subdomain, " \t\n\r") { 121 | return false 122 | } 123 | 124 | return true 125 | } 126 | 127 | -------------------------------------------------------------------------------- /pkg/sources/chinaz.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | type Chinaz struct{} 14 | 15 | type ChinazResponse struct { 16 | StateCode int `json:"StateCode"` 17 | Reason string `json:"Reason"` 18 | Result ChinazResult `json:"Result"` 19 | } 20 | 21 | type ChinazResult struct { 22 | ContributingSubdomainList []ChinazSubdomain `json:"ContributingSubdomainList"` 23 | Domain string `json:"Domain"` 24 | AlexaRank int `json:"AlexaRank"` 25 | } 26 | 27 | type ChinazSubdomain struct { 28 | DataUrl string `json:"DataUrl"` 29 | Percent float64 `json:"Percent"` 30 | } 31 | 32 | func (c *Chinaz) Name() string { 33 | return "chinaz" 34 | } 35 | 36 | func (c *Chinaz) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 37 | results := make(chan Result) 38 | 39 | go func() { 40 | defer close(results) 41 | 42 | if s.Keys.Chinaz == "" { 43 | results <- Result{Source: c.Name(), Error: fmt.Errorf("Chinaz API key not configured")} 44 | return 45 | } 46 | 47 | url := fmt.Sprintf("https://apidatav2.chinaz.com/single/alexa?key=%s&domain=%s", s.Keys.Chinaz, domain) 48 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 49 | if err != nil { 50 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 51 | return 52 | } 53 | 54 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 55 | req.Header.Set("Accept", "application/json") 56 | 57 | resp, err := s.Client.Do(req) 58 | if err != nil { 59 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 60 | return 61 | } 62 | defer resp.Body.Close() 63 | 64 | if resp.StatusCode == 401 { 65 | results <- Result{Source: c.Name(), Error: fmt.Errorf("invalid Chinaz API key")} 66 | return 67 | } 68 | 69 | if resp.StatusCode == 429 { 70 | results <- Result{Source: c.Name(), Error: fmt.Errorf("Chinaz rate limit exceeded")} 71 | return 72 | } 73 | 74 | if resp.StatusCode != http.StatusOK { 75 | results <- Result{Source: c.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 76 | return 77 | } 78 | 79 | body, err := io.ReadAll(resp.Body) 80 | if err != nil { 81 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to read response body: %w", err)} 82 | return 83 | } 84 | 85 | var chinazResp ChinazResponse 86 | if err := json.Unmarshal(body, &chinazResp); err != nil { 87 | results <- Result{Source: c.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 88 | return 89 | } 90 | 91 | if chinazResp.StateCode != 0 { 92 | results <- Result{Source: c.Name(), Error: fmt.Errorf("API error: %s (StateCode: %d)", chinazResp.Reason, chinazResp.StateCode)} 93 | return 94 | } 95 | 96 | seen := make(map[string]bool) 97 | for _, subdomain := range chinazResp.Result.ContributingSubdomainList { 98 | hostname := strings.TrimSpace(strings.ToLower(subdomain.DataUrl)) 99 | 100 | if hostname != "" && hostname != domain && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 101 | seen[hostname] = true 102 | 103 | select { 104 | case results <- Result{Source: c.Name(), Value: hostname, Type: "subdomain"}: 105 | case <-ctx.Done(): 106 | return 107 | } 108 | } 109 | } 110 | }() 111 | 112 | return results 113 | } 114 | -------------------------------------------------------------------------------- /pkg/sources/zoomeyeapi.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | type ZoomEyeAPI struct{} 13 | 14 | type ZoomEyeAPIResponse struct { 15 | Status int `json:"status"` 16 | Total int `json:"total"` 17 | List []struct { 18 | Name string `json:"name"` 19 | IP []string `json:"ip"` 20 | } `json:"list"` 21 | } 22 | 23 | func (z *ZoomEyeAPI) Name() string { 24 | return "zoomeye" 25 | } 26 | 27 | func (z *ZoomEyeAPI) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 28 | results := make(chan Result) 29 | 30 | go func() { 31 | defer close(results) 32 | 33 | if session.Keys.ZoomEyeAPI == "" { 34 | results <- Result{Source: z.Name(), Error: fmt.Errorf("ZoomEyeAPI API key not configured")} 35 | return 36 | } 37 | 38 | apiKey := session.Keys.ZoomEyeAPI 39 | seen := make(map[string]bool) 40 | pages := 1 41 | 42 | for currentPage := 1; currentPage <= pages; currentPage++ { 43 | select { 44 | case <-ctx.Done(): 45 | return 46 | default: 47 | } 48 | 49 | apiURL := fmt.Sprintf("https://api.zoomeye.org/domain/search?q=%s&type=1&s=1000&page=%d", domain, currentPage) 50 | 51 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 52 | if err != nil { 53 | results <- Result{Source: z.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 54 | return 55 | } 56 | 57 | req.Header.Set("API-KEY", apiKey) 58 | req.Header.Set("Accept", "application/json") 59 | req.Header.Set("Content-Type", "application/json") 60 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 61 | 62 | resp, err := session.Client.Do(req) 63 | if err != nil { 64 | results <- Result{Source: z.Name(), Error: fmt.Errorf("API request failed: %w", err)} 65 | return 66 | } 67 | 68 | if resp.StatusCode == 401 { 69 | resp.Body.Close() 70 | results <- Result{Source: z.Name(), Error: fmt.Errorf("invalid ZoomEyeAPI API key")} 71 | return 72 | } 73 | 74 | if resp.StatusCode == 403 { 75 | resp.Body.Close() 76 | results <- Result{Source: z.Name(), Error: fmt.Errorf("ZoomEyeAPI access forbidden - check API key permissions")} 77 | return 78 | } 79 | 80 | if resp.StatusCode == 429 { 81 | resp.Body.Close() 82 | results <- Result{Source: z.Name(), Error: fmt.Errorf("ZoomEyeAPI rate limit exceeded")} 83 | return 84 | } 85 | 86 | if resp.StatusCode != http.StatusOK { 87 | resp.Body.Close() 88 | results <- Result{Source: z.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 89 | return 90 | } 91 | 92 | var response ZoomEyeAPIResponse 93 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 94 | resp.Body.Close() 95 | results <- Result{Source: z.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 96 | return 97 | } 98 | resp.Body.Close() 99 | 100 | if currentPage == 1 { 101 | pages = int(response.Total/1000) + 1 102 | } 103 | 104 | for _, item := range response.List { 105 | hostname := strings.TrimSpace(strings.ToLower(item.Name)) 106 | 107 | if hostname != "" && !seen[hostname] { 108 | seen[hostname] = true 109 | 110 | select { 111 | case results <- Result{Source: z.Name(), Value: hostname, Type: "subdomain"}: 112 | case <-ctx.Done(): 113 | return 114 | } 115 | } 116 | } 117 | } 118 | }() 119 | 120 | return results 121 | } 122 | -------------------------------------------------------------------------------- /pkg/sources/racent.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | type Racent struct{} 14 | 15 | type RacentResponse struct { 16 | Data struct { 17 | List []struct { 18 | DNSNames []string `json:"dnsnames"` 19 | } `json:"list"` 20 | } `json:"data"` 21 | } 22 | 23 | func (r *Racent) Name() string { 24 | return "racent" 25 | } 26 | 27 | func (r *Racent) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 28 | results := make(chan Result) 29 | 30 | go func() { 31 | defer close(results) 32 | 33 | url := fmt.Sprintf("https://face.racent.com/tool/query_ctlog?keyword=%s", domain) 34 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 35 | if err != nil { 36 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 37 | return 38 | } 39 | 40 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 41 | req.Header.Set("Accept", "application/json") 42 | 43 | resp, err := s.Client.Do(req) 44 | if err != nil { 45 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 46 | return 47 | } 48 | defer resp.Body.Close() 49 | 50 | if resp.StatusCode != http.StatusOK { 51 | results <- Result{Source: r.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 52 | return 53 | } 54 | 55 | body, err := io.ReadAll(resp.Body) 56 | if err != nil { 57 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to read response body: %w", err)} 58 | return 59 | } 60 | 61 | bodyStr := string(body) 62 | if strings.Contains(bodyStr, "CTLog 查询超过限制") { 63 | results <- Result{Source: r.Name(), Error: fmt.Errorf("CTLog query limit exceeded")} 64 | return 65 | } 66 | 67 | var racentResp RacentResponse 68 | if err := json.Unmarshal(body, &racentResp); err != nil { 69 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 70 | return 71 | } 72 | 73 | seen := make(map[string]bool) 74 | 75 | for _, item := range racentResp.Data.List { 76 | for _, subdomain := range item.DNSNames { 77 | hostname := strings.TrimSpace(strings.ToLower(subdomain)) 78 | 79 | if hostname != "" && hostname != domain && 80 | strings.HasSuffix(hostname, "."+domain) && 81 | r.isValidHostname(hostname) && 82 | !seen[hostname] { 83 | seen[hostname] = true 84 | 85 | select { 86 | case results <- Result{Source: r.Name(), Value: hostname, Type: "subdomain"}: 87 | case <-ctx.Done(): 88 | return 89 | } 90 | } 91 | } 92 | } 93 | }() 94 | 95 | return results 96 | } 97 | 98 | // isValidHostname validates a hostname 99 | func (r *Racent) isValidHostname(hostname string) bool { 100 | if len(hostname) == 0 || len(hostname) > 253 { 101 | return false 102 | } 103 | 104 | // Check for leading or trailing dots 105 | if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 106 | return false 107 | } 108 | 109 | if strings.Contains(hostname, "..") { 110 | return false 111 | } 112 | 113 | // Must contain at least one dot 114 | if !strings.Contains(hostname, ".") { 115 | return false 116 | } 117 | 118 | // Check for valid characters 119 | for _, char := range hostname { 120 | if !((char >= 'a' && char <= 'z') || 121 | (char >= '0' && char <= '9') || 122 | char == '-' || char == '.') { 123 | return false 124 | } 125 | } 126 | 127 | // Check for leading or trailing hyphens 128 | if strings.HasPrefix(hostname, "-") || strings.HasSuffix(hostname, "-") { 129 | return false 130 | } 131 | 132 | if strings.Contains(hostname, "--") { 133 | return false 134 | } 135 | 136 | return true 137 | } 138 | -------------------------------------------------------------------------------- /pkg/sources/certspotter.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type CertSpotter struct{} 14 | 15 | 16 | type CertSpotterObject struct { 17 | ID string `json:"id"` 18 | DNSNames []string `json:"dns_names"` 19 | } 20 | 21 | 22 | func (c *CertSpotter) Name() string { 23 | return "certspotter" 24 | } 25 | 26 | 27 | func (c *CertSpotter) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 28 | results := make(chan Result) 29 | 30 | go func() { 31 | defer close(results) 32 | 33 | 34 | if s.Keys.CertSpotter == "" { 35 | results <- Result{Source: c.Name(), Error: fmt.Errorf("CertSpotter API key not configured")} 36 | return 37 | } 38 | 39 | url := fmt.Sprintf("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names", domain) 40 | response, err := c.makeRequest(ctx, url, s) 41 | if err != nil { 42 | results <- Result{Source: c.Name(), Error: err} 43 | return 44 | } 45 | 46 | seen := make(map[string]bool) 47 | c.processResults(response, domain, seen, results) 48 | 49 | if len(response) == 0 { 50 | return 51 | } 52 | 53 | lastID := response[len(response)-1].ID 54 | for { 55 | select { 56 | case <-ctx.Done(): 57 | return 58 | default: 59 | } 60 | 61 | nextURL := fmt.Sprintf("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names&after=%s", domain, lastID) 62 | response, err := c.makeRequest(ctx, nextURL, s) 63 | if err != nil { 64 | results <- Result{Source: c.Name(), Error: err} 65 | return 66 | } 67 | 68 | if len(response) == 0 { 69 | break 70 | } 71 | 72 | c.processResults(response, domain, seen, results) 73 | 74 | lastID = response[len(response)-1].ID 75 | } 76 | }() 77 | 78 | return results 79 | } 80 | 81 | func (c *CertSpotter) makeRequest(ctx context.Context, url string, s *session.Session) ([]CertSpotterObject, error) { 82 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create request: %w", err) 85 | } 86 | 87 | req.Header.Set("Authorization", "Bearer "+s.Keys.CertSpotter) 88 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 89 | req.Header.Set("Accept", "application/json") 90 | 91 | resp, err := s.Client.Do(req) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to execute request: %w", err) 94 | } 95 | defer resp.Body.Close() 96 | 97 | if resp.StatusCode == 401 { 98 | return nil, fmt.Errorf("invalid CertSpotter API key") 99 | } 100 | 101 | if resp.StatusCode == 429 { 102 | return nil, fmt.Errorf("CertSpotter rate limit exceeded") 103 | } 104 | 105 | if resp.StatusCode != http.StatusOK { 106 | return nil, fmt.Errorf("HTTP error: %d", resp.StatusCode) 107 | } 108 | 109 | var response []CertSpotterObject 110 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 111 | return nil, fmt.Errorf("failed to decode JSON: %w", err) 112 | } 113 | 114 | return response, nil 115 | } 116 | 117 | func (c *CertSpotter) processResults(response []CertSpotterObject, domain string, seen map[string]bool, results chan<- Result) { 118 | for _, cert := range response { 119 | for _, dnsName := range cert.DNSNames { 120 | hostname := strings.TrimSpace(strings.ToLower(dnsName)) 121 | 122 | if hostname != "" && strings.HasSuffix(hostname, "."+domain) && !seen[hostname] { 123 | seen[hostname] = true 124 | 125 | select { 126 | case results <- Result{Source: c.Name(), Value: hostname, Type: "subdomain"}: 127 | default: 128 | } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /cmd/track.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "github.com/samogod/samoscout/pkg/orchestrator" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | "github.com/fatih/color" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | trackStatus string 16 | trackAll bool 17 | ) 18 | 19 | var trackCmd = &cobra.Command{ 20 | Use: "track [domain]", 21 | Short: "Query subdomain tracking database", 22 | Long: `Query subdomain tracking database for a specific domain or all domains`, 23 | Run: runTrack, 24 | } 25 | 26 | func init() { 27 | trackCmd.Flags().StringVar(&trackStatus, "status", "", "filter by status (active, dead, new)") 28 | trackCmd.Flags().BoolVar(&trackAll, "all", false, "query all domains") 29 | rootCmd.AddCommand(trackCmd) 30 | } 31 | 32 | func runTrack(cmd *cobra.Command, args []string) { 33 | if !trackAll && len(args) == 0 { 34 | color.Red("Error: either provide a domain or use --all flag") 35 | cmd.Help() 36 | os.Exit(1) 37 | } 38 | 39 | if trackAll && len(args) > 0 { 40 | color.Red("Error: cannot use both domain and --all flag together") 41 | cmd.Help() 42 | os.Exit(1) 43 | } 44 | 45 | orch, err := orchestrator.NewOrchestrator(configFile) 46 | if err != nil { 47 | color.Red("Failed to initialize orchestrator: %v", err) 48 | os.Exit(1) 49 | } 50 | 51 | db := orch.GetDB() 52 | if db == nil || !db.IsEnabled() { 53 | color.Red("Error: Database is not enabled. Please enable it in config.yaml") 54 | os.Exit(1) 55 | } 56 | 57 | if trackStatus != "" { 58 | trackStatus = strings.ToUpper(trackStatus) 59 | } 60 | 61 | var records []struct { 62 | Domain string 63 | Subdomain string 64 | Status string 65 | FirstSeen string 66 | LastSeen string 67 | } 68 | 69 | if trackAll { 70 | results, err := db.QueryAllSubdomains(trackStatus) 71 | if err != nil { 72 | color.Red("Failed to query database: %v", err) 73 | os.Exit(1) 74 | } 75 | for _, r := range results { 76 | records = append(records, struct { 77 | Domain string 78 | Subdomain string 79 | Status string 80 | FirstSeen string 81 | LastSeen string 82 | }{ 83 | Domain: r.Domain, 84 | Subdomain: r.Subdomain, 85 | Status: r.Status, 86 | FirstSeen: r.FirstSeen.Format("2006-01-02 15:04:05"), 87 | LastSeen: r.LastSeen.Format("2006-01-02 15:04:05"), 88 | }) 89 | } 90 | } else { 91 | domain := args[0] 92 | results, err := db.QuerySubdomains(domain, trackStatus) 93 | if err != nil { 94 | color.Red("Failed to query database: %v", err) 95 | os.Exit(1) 96 | } 97 | 98 | if len(results) == 0 { 99 | color.Yellow("[INF] Domain %s not found in database.", domain) 100 | os.Exit(0) 101 | } 102 | 103 | for _, r := range results { 104 | records = append(records, struct { 105 | Domain string 106 | Subdomain string 107 | Status string 108 | FirstSeen string 109 | LastSeen string 110 | }{ 111 | Domain: r.Domain, 112 | Subdomain: r.Subdomain, 113 | Status: r.Status, 114 | FirstSeen: r.FirstSeen.Format("2006-01-02 15:04:05"), 115 | LastSeen: r.LastSeen.Format("2006-01-02 15:04:05"), 116 | }) 117 | } 118 | } 119 | 120 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 121 | fmt.Fprintln(w, color.CyanString("DOMAIN\tSUBDOMAIN\tSTATUS\tFIRST_SEEN\tLAST_SEEN")) 122 | fmt.Fprintln(w, strings.Repeat("-", 100)) 123 | 124 | for _, r := range records { 125 | statusColor := color.GreenString 126 | if r.Status == "DEAD" { 127 | statusColor = color.RedString 128 | } else if r.Status == "NEW" { 129 | statusColor = color.YellowString 130 | } 131 | 132 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", 133 | r.Domain, 134 | r.Subdomain, 135 | statusColor(r.Status), 136 | r.FirstSeen, 137 | r.LastSeen, 138 | ) 139 | } 140 | w.Flush() 141 | 142 | color.Green("\nTotal records: %d", len(records)) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/sources/hackertarget.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type HackerTarget struct{} 14 | 15 | 16 | func (h *HackerTarget) Name() string { 17 | return "hackertarget" 18 | } 19 | 20 | 21 | func (h *HackerTarget) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 22 | results := make(chan Result) 23 | 24 | go func() { 25 | defer close(results) 26 | 27 | 28 | url := fmt.Sprintf("https://api.hackertarget.com/hostsearch/?q=%s", domain) 29 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 30 | if err != nil { 31 | results <- Result{Source: h.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 32 | return 33 | } 34 | 35 | 36 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 37 | req.Header.Set("Accept", "text/plain") 38 | 39 | resp, err := s.Client.Do(req) 40 | if err != nil { 41 | results <- Result{Source: h.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 42 | return 43 | } 44 | defer resp.Body.Close() 45 | 46 | if resp.StatusCode == 429 { 47 | results <- Result{Source: h.Name(), Error: fmt.Errorf("HackerTarget rate limit exceeded")} 48 | return 49 | } 50 | 51 | if resp.StatusCode != http.StatusOK { 52 | results <- Result{Source: h.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 53 | return 54 | } 55 | 56 | 57 | scanner := bufio.NewScanner(resp.Body) 58 | seen := make(map[string]bool) 59 | 60 | for scanner.Scan() { 61 | line := scanner.Text() 62 | if line == "" { 63 | continue 64 | } 65 | 66 | 67 | 68 | subdomains := h.extractSubdomains(line, domain) 69 | 70 | for _, subdomain := range subdomains { 71 | if subdomain != "" && !seen[subdomain] { 72 | seen[subdomain] = true 73 | 74 | select { 75 | case results <- Result{Source: h.Name(), Value: subdomain, Type: "subdomain"}: 76 | case <-ctx.Done(): 77 | return 78 | } 79 | } 80 | } 81 | } 82 | 83 | 84 | if err := scanner.Err(); err != nil { 85 | results <- Result{Source: h.Name(), Error: fmt.Errorf("scanner error: %w", err)} 86 | } 87 | }() 88 | 89 | return results 90 | } 91 | 92 | 93 | func (h *HackerTarget) extractSubdomains(line, domain string) []string { 94 | var subdomains []string 95 | 96 | 97 | 98 | parts := strings.Split(line, ",") 99 | if len(parts) > 0 { 100 | hostname := strings.TrimSpace(strings.ToLower(parts[0])) 101 | 102 | 103 | if hostname != "" && hostname != domain && 104 | strings.HasSuffix(hostname, "."+domain) && 105 | h.isValidHostname(hostname) { 106 | subdomains = append(subdomains, hostname) 107 | } 108 | } 109 | 110 | 111 | words := strings.Fields(line) 112 | for _, word := range words { 113 | 114 | if strings.Contains(word, ".") { 115 | 116 | cleanWord := strings.Trim(word, ".,;:!?()[]{}\"'/\\") 117 | cleanWord = strings.ToLower(cleanWord) 118 | 119 | 120 | if cleanWord != "" && cleanWord != domain && 121 | strings.HasSuffix(cleanWord, "."+domain) && 122 | h.isValidHostname(cleanWord) { 123 | subdomains = append(subdomains, cleanWord) 124 | } 125 | } 126 | } 127 | 128 | return subdomains 129 | } 130 | 131 | 132 | func (h *HackerTarget) isValidHostname(hostname string) bool { 133 | if len(hostname) == 0 || len(hostname) > 253 { 134 | return false 135 | } 136 | 137 | 138 | if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 139 | return false 140 | } 141 | 142 | if strings.Contains(hostname, "..") { 143 | return false 144 | } 145 | 146 | 147 | if !strings.Contains(hostname, ".") { 148 | return false 149 | } 150 | 151 | return true 152 | } 153 | -------------------------------------------------------------------------------- /pkg/sources/reconcloud.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "github.com/samogod/samoscout/pkg/session" 9 | "strings" 10 | ) 11 | 12 | 13 | type ReconCloud struct{} 14 | 15 | 16 | type ReconCloudResponse struct { 17 | MsgType string `json:"msg_type"` 18 | RequestID string `json:"request_id"` 19 | OnCache bool `json:"on_cache"` 20 | Step string `json:"step"` 21 | CloudAssetsList []CloudAssetsList `json:"cloud_assets_list"` 22 | } 23 | 24 | 25 | type CloudAssetsList struct { 26 | Key string `json:"key"` 27 | Domain string `json:"domain"` 28 | CloudProvider string `json:"cloud_provider"` 29 | } 30 | 31 | 32 | func (r *ReconCloud) Name() string { 33 | return "reconcloud" 34 | } 35 | 36 | 37 | func (r *ReconCloud) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 38 | results := make(chan Result) 39 | 40 | go func() { 41 | defer close(results) 42 | 43 | 44 | url := fmt.Sprintf("https://recon.cloud/api/search?domain=%s", domain) 45 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 46 | if err != nil { 47 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 48 | return 49 | } 50 | 51 | 52 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 53 | req.Header.Set("Accept", "application/json") 54 | 55 | resp, err := s.Client.Do(req) 56 | if err != nil { 57 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to execute request: %w", err)} 58 | return 59 | } 60 | defer resp.Body.Close() 61 | 62 | if resp.StatusCode == 429 { 63 | results <- Result{Source: r.Name(), Error: fmt.Errorf("ReconCloud rate limit exceeded")} 64 | return 65 | } 66 | 67 | if resp.StatusCode != http.StatusOK { 68 | results <- Result{Source: r.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 69 | return 70 | } 71 | 72 | 73 | var response ReconCloudResponse 74 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 75 | results <- Result{Source: r.Name(), Error: fmt.Errorf("failed to decode JSON: %w", err)} 76 | return 77 | } 78 | 79 | 80 | seen := make(map[string]bool) 81 | if len(response.CloudAssetsList) > 0 { 82 | for _, cloudAsset := range response.CloudAssetsList { 83 | hostname := strings.TrimSpace(strings.ToLower(cloudAsset.Domain)) 84 | 85 | 86 | if hostname != "" && hostname != domain && 87 | strings.HasSuffix(hostname, "."+domain) && 88 | r.isValidHostname(hostname) && !seen[hostname] { 89 | seen[hostname] = true 90 | 91 | select { 92 | case results <- Result{Source: r.Name(), Value: hostname, Type: "subdomain"}: 93 | case <-ctx.Done(): 94 | return 95 | } 96 | } 97 | } 98 | } 99 | }() 100 | 101 | return results 102 | } 103 | 104 | 105 | func (r *ReconCloud) isValidHostname(hostname string) bool { 106 | if len(hostname) == 0 || len(hostname) > 253 { 107 | return false 108 | } 109 | 110 | 111 | if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 112 | return false 113 | } 114 | 115 | if strings.Contains(hostname, "..") { 116 | return false 117 | } 118 | 119 | 120 | if !strings.Contains(hostname, ".") { 121 | return false 122 | } 123 | 124 | 125 | for _, char := range hostname { 126 | if !((char >= 'a' && char <= 'z') || 127 | (char >= '0' && char <= '9') || 128 | char == '-' || char == '.') { 129 | return false 130 | } 131 | } 132 | 133 | 134 | if strings.HasPrefix(hostname, "-") || strings.HasSuffix(hostname, "-") { 135 | return false 136 | } 137 | 138 | if strings.Contains(hostname, "--") { 139 | return false 140 | } 141 | 142 | return true 143 | } 144 | -------------------------------------------------------------------------------- /pkg/llm/model.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/samogod/samoscout/pkg/config" 15 | ) 16 | 17 | //go:embed llm_inference.py gpt_model.py 18 | var pythonScripts embed.FS 19 | 20 | type Model struct { 21 | scriptPath string 22 | config *ModelConfig 23 | } 24 | 25 | func extractPythonScripts() (string, error) { 26 | scriptsDir := filepath.Join(config.GetCacheDir(), "python_scripts") 27 | 28 | if err := os.MkdirAll(scriptsDir, 0755); err != nil { 29 | return "", fmt.Errorf("failed to create scripts directory: %w", err) 30 | } 31 | 32 | scripts := []string{"llm_inference.py", "gpt_model.py"} 33 | 34 | for _, scriptName := range scripts { 35 | scriptPath := filepath.Join(scriptsDir, scriptName) 36 | 37 | content, err := pythonScripts.ReadFile(scriptName) 38 | if err != nil { 39 | return "", fmt.Errorf("failed to read embedded script %s: %w", scriptName, err) 40 | } 41 | 42 | if err := os.WriteFile(scriptPath, content, 0755); err != nil { 43 | return "", fmt.Errorf("failed to write script %s: %w", scriptName, err) 44 | } 45 | } 46 | 47 | return filepath.Join(scriptsDir, "llm_inference.py"), nil 48 | } 49 | 50 | func LoadModel(modelPath, tokenizerPath string, device string) (*Model, error) { 51 | configPath := filepath.Join(filepath.Dir(modelPath), ConfigFile) 52 | downloader := NewDownloader() 53 | config, err := downloader.LoadConfig(configPath) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to load model config: %w", err) 56 | } 57 | 58 | scriptPath, err := extractPythonScripts() 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to extract Python scripts: %w", err) 61 | } 62 | 63 | return &Model{ 64 | scriptPath: scriptPath, 65 | config: config, 66 | }, nil 67 | } 68 | 69 | func (m *Model) Close() error { 70 | return nil 71 | } 72 | 73 | type InferenceRequest struct { 74 | Subdomains []string `json:"subdomains"` 75 | Apex string `json:"apex"` 76 | NumPredictions int `json:"num_predictions"` 77 | MaxTokens int `json:"max_tokens"` 78 | Temperature float64 `json:"temperature"` 79 | Blocked []string `json:"blocked"` 80 | } 81 | 82 | type InferenceResponse struct { 83 | Predictions []string `json:"predictions"` 84 | Error string `json:"error,omitempty"` 85 | } 86 | 87 | func getPythonCommand() string { 88 | if runtime.GOOS == "windows" { 89 | return "python" 90 | } 91 | return "python3" 92 | } 93 | 94 | func (m *Model) GenerateDomains( 95 | ctx context.Context, 96 | subdomains []string, 97 | apex string, 98 | numPredictions int, 99 | maxTokens int, 100 | temperature float64, 101 | blocked []string, 102 | ) ([]string, error) { 103 | 104 | req := InferenceRequest{ 105 | Subdomains: subdomains, 106 | Apex: apex, 107 | NumPredictions: numPredictions, 108 | MaxTokens: maxTokens, 109 | Temperature: temperature, 110 | Blocked: blocked, 111 | } 112 | 113 | reqJSON, err := json.Marshal(req) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to marshal request: %w", err) 116 | } 117 | 118 | pythonCmd := getPythonCommand() 119 | cmd := exec.CommandContext(ctx, pythonCmd, m.scriptPath) 120 | cmd.Stdin = strings.NewReader(string(reqJSON)) 121 | output, err := cmd.CombinedOutput() 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to run inference with '%s': %w\nOutput: %s\nHint: Ensure Python 3.7+ is installed and in PATH", pythonCmd, err, string(output)) 124 | } 125 | 126 | var resp InferenceResponse 127 | if err := json.Unmarshal(output, &resp); err != nil { 128 | return nil, fmt.Errorf("failed to parse response: %w, output: %s", err, string(output)) 129 | } 130 | 131 | if resp.Error != "" { 132 | return nil, fmt.Errorf("inference error: %s", resp.Error) 133 | } 134 | 135 | return resp.Predictions, nil 136 | } 137 | 138 | func (m *Model) GetConfig() *ModelConfig { 139 | return m.config 140 | } 141 | -------------------------------------------------------------------------------- /pkg/sources/digicert.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | 14 | type DigiCert struct{} 15 | 16 | 17 | func (d *DigiCert) Name() string { 18 | return "digicert" 19 | } 20 | 21 | 22 | func (d *DigiCert) Run(ctx context.Context, domain string, s *session.Session) <-chan Result { 23 | results := make(chan Result) 24 | 25 | go func() { 26 | defer close(results) 27 | 28 | url := fmt.Sprintf("https://ssltools.digicert.com/chainTester/webservice/ctsearch/search?keyword=%s", domain) 29 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 30 | if err != nil { 31 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 32 | return 33 | } 34 | 35 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 36 | req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") 37 | 38 | resp, err := s.Client.Do(req) 39 | if err != nil { 40 | results <- Result{Source: d.Name(), Error: fmt.Errorf("API request failed: %w", err)} 41 | return 42 | } 43 | 44 | if resp.StatusCode != http.StatusOK { 45 | resp.Body.Close() 46 | results <- Result{Source: d.Name(), Error: fmt.Errorf("HTTP error: %d", resp.StatusCode)} 47 | return 48 | } 49 | 50 | body, err := io.ReadAll(resp.Body) 51 | resp.Body.Close() 52 | if err != nil { 53 | results <- Result{Source: d.Name(), Error: fmt.Errorf("failed to read response body: %w", err)} 54 | return 55 | } 56 | 57 | src := string(body) 58 | 59 | 60 | seen := make(map[string]bool) 61 | subdomains := d.extractSubdomains(src, domain, seen) 62 | 63 | for _, subdomain := range subdomains { 64 | select { 65 | case results <- Result{Source: d.Name(), Value: subdomain, Type: "subdomain"}: 66 | case <-ctx.Done(): 67 | return 68 | } 69 | } 70 | }() 71 | 72 | return results 73 | } 74 | 75 | 76 | func (d *DigiCert) extractSubdomains(content, domain string, seen map[string]bool) []string { 77 | var subdomains []string 78 | 79 | 80 | 81 | domainRegex := regexp.MustCompile(`([a-zA-Z0-9.-]+\.` + regexp.QuoteMeta(domain) + `)`) 82 | matches := domainRegex.FindAllStringSubmatch(content, -1) 83 | 84 | for _, match := range matches { 85 | if len(match) > 1 { 86 | hostname := strings.TrimSpace(strings.ToLower(match[1])) 87 | 88 | 89 | if d.isValidHostname(hostname) && !seen[hostname] { 90 | seen[hostname] = true 91 | subdomains = append(subdomains, hostname) 92 | } 93 | } 94 | } 95 | 96 | 97 | if len(subdomains) == 0 { 98 | generalRegex := regexp.MustCompile(`([a-zA-Z0-9.-]+\.[a-zA-Z0-9.-]+)`) 99 | generalMatches := generalRegex.FindAllStringSubmatch(content, -1) 100 | 101 | for _, match := range generalMatches { 102 | if len(match) > 1 { 103 | hostname := strings.TrimSpace(strings.ToLower(match[1])) 104 | 105 | 106 | if strings.Contains(hostname, domain) && d.isValidHostname(hostname) && !seen[hostname] { 107 | seen[hostname] = true 108 | subdomains = append(subdomains, hostname) 109 | } 110 | } 111 | } 112 | } 113 | 114 | return subdomains 115 | } 116 | 117 | 118 | func (d *DigiCert) isValidHostname(hostname string) bool { 119 | if hostname == "" { 120 | return false 121 | } 122 | 123 | 124 | if len(hostname) < 3 { 125 | return false 126 | } 127 | 128 | 129 | if strings.Contains(hostname, " ") || strings.Contains(hostname, "\t") || strings.Contains(hostname, "\n") { 130 | return false 131 | } 132 | 133 | 134 | if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 135 | return false 136 | } 137 | 138 | 139 | if !strings.Contains(hostname, ".") { 140 | return false 141 | } 142 | 143 | 144 | if strings.Contains(hostname, "..") { 145 | return false 146 | } 147 | 148 | 149 | if strings.Contains(hostname, "*") { 150 | return false 151 | } 152 | 153 | return true 154 | } 155 | -------------------------------------------------------------------------------- /pkg/sources/waybackarchive.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | "github.com/samogod/samoscout/pkg/session" 11 | "strings" 12 | ) 13 | 14 | 15 | type WaybackArchive struct{} 16 | 17 | 18 | func (w *WaybackArchive) Name() string { 19 | return "waybackarchive" 20 | } 21 | 22 | 23 | func (w *WaybackArchive) Run(ctx context.Context, domain string, session *session.Session) <-chan Result { 24 | results := make(chan Result) 25 | 26 | go func() { 27 | defer close(results) 28 | 29 | seen := make(map[string]bool) 30 | apiURL := fmt.Sprintf("http://web.archive.org/cdx/search/cdx?url=*.%s/*&output=txt&fl=original&collapse=urlkey", domain) 31 | 32 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 33 | if err != nil { 34 | results <- Result{Source: w.Name(), Error: fmt.Errorf("failed to create request: %w", err)} 35 | return 36 | } 37 | 38 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") 39 | 40 | resp, err := session.Client.Do(req) 41 | if err != nil { 42 | results <- Result{Source: w.Name(), Error: fmt.Errorf("API request failed: %w", err)} 43 | return 44 | } 45 | defer resp.Body.Close() 46 | 47 | if resp.StatusCode != http.StatusOK { 48 | results <- Result{Source: w.Name(), Error: fmt.Errorf("unexpected status code: %d", resp.StatusCode)} 49 | return 50 | } 51 | 52 | 53 | scanner := bufio.NewScanner(resp.Body) 54 | for scanner.Scan() { 55 | select { 56 | case <-ctx.Done(): 57 | return 58 | default: 59 | } 60 | 61 | line := scanner.Text() 62 | if line == "" { 63 | continue 64 | } 65 | 66 | 67 | if decodedLine, err := url.QueryUnescape(line); err == nil { 68 | line = decodedLine 69 | } 70 | 71 | 72 | subdomains := w.extractSubdomains(line, domain) 73 | 74 | for _, subdomain := range subdomains { 75 | 76 | subdomain = strings.ToLower(subdomain) 77 | subdomain = strings.TrimPrefix(subdomain, "25") 78 | subdomain = strings.TrimPrefix(subdomain, "2f") 79 | 80 | hostname := strings.TrimSpace(subdomain) 81 | 82 | if w.isValidHostname(hostname, domain) && !seen[hostname] { 83 | seen[hostname] = true 84 | 85 | select { 86 | case results <- Result{Source: w.Name(), Value: hostname, Type: "subdomain"}: 87 | case <-ctx.Done(): 88 | return 89 | } 90 | } 91 | } 92 | } 93 | 94 | if err := scanner.Err(); err != nil { 95 | results <- Result{Source: w.Name(), Error: fmt.Errorf("scanner error: %w", err)} 96 | } 97 | }() 98 | 99 | return results 100 | } 101 | 102 | 103 | func (w *WaybackArchive) extractSubdomains(line, domain string) []string { 104 | var subdomains []string 105 | 106 | 107 | domainRegex := regexp.MustCompile(`([a-zA-Z0-9.-]+\.[a-zA-Z0-9.-]+)`) 108 | matches := domainRegex.FindAllStringSubmatch(line, -1) 109 | 110 | for _, match := range matches { 111 | if len(match) > 1 { 112 | hostname := strings.TrimSpace(strings.ToLower(match[1])) 113 | 114 | 115 | if strings.HasSuffix(hostname, "."+domain) || hostname == domain { 116 | subdomains = append(subdomains, hostname) 117 | } 118 | } 119 | } 120 | 121 | return subdomains 122 | } 123 | 124 | 125 | func (w *WaybackArchive) isValidHostname(hostname, domain string) bool { 126 | if hostname == "" { 127 | return false 128 | } 129 | 130 | 131 | if strings.Contains(hostname, "..") || strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 132 | return false 133 | } 134 | 135 | 136 | if !strings.Contains(hostname, ".") { 137 | return false 138 | } 139 | 140 | 141 | if !strings.HasSuffix(hostname, "."+domain) && hostname != domain { 142 | return false 143 | } 144 | 145 | 146 | for _, char := range hostname { 147 | if !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-') { 148 | return false 149 | } 150 | } 151 | 152 | 153 | if len(hostname) > 253 { 154 | return false 155 | } 156 | 157 | return true 158 | } 159 | -------------------------------------------------------------------------------- /pkg/active/cleaner.go: -------------------------------------------------------------------------------- 1 | package active 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "sort" 9 | ) 10 | 11 | type WordlistCleaner struct { 12 | regexes []*regexp.Regexp 13 | } 14 | 15 | func NewWordlistCleaner() *WordlistCleaner { 16 | patterns := []string{ 17 | `[\!(,%]`, // ignore noisy characters 18 | `.{100,}`, // ignore lines with more than 100 characters (overly specific) 19 | `[0-9]{4,}`, // ignore lines with 4 or more consecutive digits (likely an id) 20 | `[0-9]{3,}$`, // ignore lines where the last 3 or more characters are digits (likely an id) 21 | `[a-z0-9]{32}`, // likely MD5 hash or similar 22 | `[0-9]+[A-Z0-9]{5,}`, // number followed by 5 or more numbers and uppercase letters (almost all noise) 23 | `\/.*\/.*\/.*\/.*\/.*\/.*\/`, // ignore lines more than 6 directories deep (overly specific) 24 | `\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`, // ignore UUIDs 25 | `[0-9]+[a-zA-Z]+[0-9]+[a-zA-Z]+[0-9]+`, // ignore multiple numbers and letters mixed together (likely noise) 26 | `\.(png|jpg|jpeg|gif|svg|bmp|ttf|avif|wav|mp4|aac|ajax|css|all)$`, // ignore low value filetypes 27 | `^$`, // ignores blank lines 28 | } 29 | 30 | var regexes []*regexp.Regexp 31 | for _, pattern := range patterns { 32 | re, err := regexp.Compile(pattern) 33 | if err == nil { 34 | regexes = append(regexes, re) 35 | } 36 | } 37 | 38 | return &WordlistCleaner{ 39 | regexes: regexes, 40 | } 41 | } 42 | 43 | func (wc *WordlistCleaner) CleanWordlist(words []string) []string { 44 | var filtered []string 45 | for _, word := range words { 46 | if !wc.shouldFilter(word) { 47 | filtered = append(filtered, word) 48 | } 49 | } 50 | 51 | uniqueMap := make(map[string]bool) 52 | for _, word := range filtered { 53 | uniqueMap[word] = true 54 | } 55 | 56 | var result []string 57 | for word := range uniqueMap { 58 | result = append(result, word) 59 | } 60 | sort.Strings(result) 61 | 62 | return result 63 | } 64 | 65 | func (wc *WordlistCleaner) CleanWordlistFile(inputFile, outputFile string) (int, int, error) { 66 | words, err := readWordlistFile(inputFile) 67 | if err != nil { 68 | return 0, 0, fmt.Errorf("failed to read wordlist: %w", err) 69 | } 70 | 71 | originalSize := len(words) 72 | 73 | cleaned := wc.CleanWordlist(words) 74 | newSize := len(cleaned) 75 | 76 | if err := writeWordlistFile(cleaned, outputFile); err != nil { 77 | return 0, 0, fmt.Errorf("failed to write cleaned wordlist: %w", err) 78 | } 79 | 80 | return originalSize, newSize, nil 81 | } 82 | 83 | func (wc *WordlistCleaner) shouldFilter(line string) bool { 84 | for _, re := range wc.regexes { 85 | if re.MatchString(line) { 86 | return true 87 | } 88 | } 89 | return false 90 | } 91 | 92 | func readWordlistFile(filePath string) ([]string, error) { 93 | file, err := os.Open(filePath) 94 | if err != nil { 95 | return nil, err 96 | } 97 | defer file.Close() 98 | 99 | var words []string 100 | scanner := bufio.NewScanner(file) 101 | for scanner.Scan() { 102 | line := scanner.Text() 103 | words = append(words, line) 104 | } 105 | 106 | return words, scanner.Err() 107 | } 108 | 109 | func writeWordlistFile(words []string, filePath string) error { 110 | file, err := os.Create(filePath) 111 | if err != nil { 112 | return err 113 | } 114 | defer file.Close() 115 | 116 | writer := bufio.NewWriter(file) 117 | for _, word := range words { 118 | if _, err := writer.WriteString(word + "\n"); err != nil { 119 | return err 120 | } 121 | } 122 | 123 | return writer.Flush() 124 | } 125 | 126 | func CleanAndSaveWordlist(inputFile, outputFile string, verbose bool) error { 127 | cleaner := NewWordlistCleaner() 128 | 129 | if verbose { 130 | fmt.Printf("[DBG] cleaning wordlist: %s\n", inputFile) 131 | } 132 | 133 | originalSize, newSize, err := cleaner.CleanWordlistFile(inputFile, outputFile) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | removed := originalSize - newSize 139 | 140 | if verbose { 141 | fmt.Printf("[DBG] removed %d lines, wordlist now has %d lines\n", removed, newSize) 142 | } 143 | 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/sources/abuseipdb.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "github.com/samogod/samoscout/pkg/session" 10 | "strings" 11 | ) 12 | 13 | // AbuseIPDB struct for the AbuseIPDB source 14 | type AbuseIPDB struct{} 15 | 16 | // Regex pattern to match