package sources import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "golang.org/x/time/rate" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/recon" ) // SecurityTrailsSource searches SecurityTrails DNS/subdomain data for API key // exposure. It enumerates subdomains for a target domain and probes config // endpoints, and also checks DNS history records (TXT records may contain keys). type SecurityTrailsSource struct { APIKey string BaseURL string Registry *providers.Registry Limiters *recon.LimiterRegistry Client *Client // ProbeBaseURL overrides the scheme+host used when probing discovered // subdomains. Tests set this to the httptest server URL. ProbeBaseURL string } var _ recon.ReconSource = (*SecurityTrailsSource)(nil) func (s *SecurityTrailsSource) Name() string { return "securitytrails" } func (s *SecurityTrailsSource) RateLimit() rate.Limit { return rate.Every(2 * time.Second) } func (s *SecurityTrailsSource) Burst() int { return 5 } func (s *SecurityTrailsSource) RespectsRobots() bool { return false } func (s *SecurityTrailsSource) Enabled(_ recon.Config) bool { return s.APIKey != "" } // securityTrailsSubdomains represents the subdomain listing API response. type securityTrailsSubdomains struct { Subdomains []string `json:"subdomains"` } func (s *SecurityTrailsSource) Sweep(ctx context.Context, query string, out chan<- recon.Finding) error { base := s.BaseURL if base == "" { base = "https://api.securitytrails.com/v1" } client := s.Client if client == nil { client = NewClient() } if query == "" || !strings.Contains(query, ".") { return nil } // Phase 1: Enumerate subdomains. if s.Limiters != nil { if err := s.Limiters.Wait(ctx, s.Name(), s.RateLimit(), s.Burst(), false); err != nil { return err } } subURL := fmt.Sprintf("%s/domain/%s/subdomains?children_only=false", base, query) subReq, err := http.NewRequestWithContext(ctx, http.MethodGet, subURL, nil) if err != nil { return err } subReq.Header.Set("APIKEY", s.APIKey) subResp, err := client.Do(ctx, subReq) if err != nil { return nil // non-fatal } subData, err := io.ReadAll(io.LimitReader(subResp.Body, 512*1024)) _ = subResp.Body.Close() if err != nil { return nil } var subResult securityTrailsSubdomains if err := json.Unmarshal(subData, &subResult); err != nil { return nil } // Build FQDNs and limit to 20. var fqdns []string for _, sub := range subResult.Subdomains { fqdns = append(fqdns, sub+"."+query) if len(fqdns) >= 20 { break } } // Probe config endpoints on each subdomain. probeClient := &http.Client{Timeout: 5 * time.Second} for _, fqdn := range fqdns { if err := ctx.Err(); err != nil { return err } s.probeSubdomain(ctx, probeClient, fqdn, out) } // Phase 2: Check DNS history for key patterns in TXT records. if s.Limiters != nil { if err := s.Limiters.Wait(ctx, s.Name(), s.RateLimit(), s.Burst(), false); err != nil { return err } } dnsURL := fmt.Sprintf("%s/domain/%s", base, query) dnsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dnsURL, nil) if err != nil { return nil } dnsReq.Header.Set("APIKEY", s.APIKey) dnsResp, err := client.Do(ctx, dnsReq) if err != nil { return nil } dnsData, err := io.ReadAll(io.LimitReader(dnsResp.Body, 512*1024)) _ = dnsResp.Body.Close() if err != nil { return nil } if ciLogKeyPattern.Match(dnsData) { out <- recon.Finding{ ProviderName: query, Source: dnsURL, SourceType: "recon:securitytrails", Confidence: "medium", DetectedAt: time.Now(), } } return nil } // probeSubdomain checks well-known config endpoints for key patterns. func (s *SecurityTrailsSource) probeSubdomain(ctx context.Context, probeClient *http.Client, subdomain string, out chan<- recon.Finding) { for _, ep := range configProbeEndpoints { if err := ctx.Err(); err != nil { return } var probeURL string if s.ProbeBaseURL != "" { probeURL = s.ProbeBaseURL + "/" + subdomain + ep } else { probeURL = "https://" + subdomain + ep } req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) if err != nil { continue } resp, err := probeClient.Do(req) if err != nil { continue } body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) _ = resp.Body.Close() if err != nil { continue } if resp.StatusCode == http.StatusOK && ciLogKeyPattern.Match(body) { out <- recon.Finding{ ProviderName: subdomain, Source: probeURL, SourceType: "recon:securitytrails", Confidence: "high", DetectedAt: time.Now(), } } } }