package sources import ( "context" "encoding/json" "fmt" "io" "net/http" "time" "golang.org/x/time/rate" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/recon" ) // JenkinsSource scrapes publicly accessible Jenkins build consoles for leaked // API keys. Many Jenkins instances are exposed to the internet without // authentication, and build console output frequently contains printed // environment variables or secrets passed via command-line arguments. type JenkinsSource struct { BaseURL string Registry *providers.Registry Limiters *recon.LimiterRegistry Client *Client } var _ recon.ReconSource = (*JenkinsSource)(nil) func (s *JenkinsSource) Name() string { return "jenkins" } func (s *JenkinsSource) RateLimit() rate.Limit { return rate.Every(3 * time.Second) } func (s *JenkinsSource) Burst() int { return 2 } func (s *JenkinsSource) RespectsRobots() bool { return true } func (s *JenkinsSource) Enabled(_ recon.Config) bool { return true } // jenkinsJobsResponse represents the Jenkins API jobs listing. type jenkinsJobsResponse struct { Jobs []jenkinsJob `json:"jobs"` } type jenkinsJob struct { Name string `json:"name"` URL string `json:"url"` Color string `json:"color"` } func (s *JenkinsSource) Sweep(ctx context.Context, _ string, out chan<- recon.Finding) error { base := s.BaseURL if base == "" { return nil // No default; Jenkins instances are discovered via dorking } client := s.Client if client == nil { client = NewClient() } queries := BuildQueries(s.Registry, "jenkins") if len(queries) == 0 { return nil } for _, q := range queries { if err := ctx.Err(); err != nil { return err } if s.Limiters != nil { if err := s.Limiters.Wait(ctx, s.Name(), s.RateLimit(), s.Burst(), false); err != nil { return err } } // List jobs from the Jenkins API. jobsURL := fmt.Sprintf("%s/api/json?tree=jobs[name,url,color]", base) req, err := http.NewRequestWithContext(ctx, http.MethodGet, jobsURL, nil) if err != nil { continue } req.Header.Set("Accept", "application/json") resp, err := client.Do(ctx, req) if err != nil { continue } var jobs jenkinsJobsResponse if err := json.NewDecoder(resp.Body).Decode(&jobs); err != nil { _ = resp.Body.Close() continue } _ = resp.Body.Close() for _, job := range jobs.Jobs { if err := ctx.Err(); err != nil { return err } if s.Limiters != nil { if err := s.Limiters.Wait(ctx, s.Name(), s.RateLimit(), s.Burst(), false); err != nil { return err } } // Fetch the last build console output. consoleURL := fmt.Sprintf("%s/job/%s/lastBuild/consoleText", base, job.Name) consoleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, consoleURL, nil) if err != nil { continue } consoleResp, err := client.Do(ctx, consoleReq) if err != nil { continue } body, err := io.ReadAll(io.LimitReader(consoleResp.Body, 256*1024)) _ = consoleResp.Body.Close() if err != nil { continue } if ciLogKeyPattern.Match(body) { out <- recon.Finding{ ProviderName: q, Source: consoleURL, SourceType: "recon:jenkins", Confidence: "medium", DetectedAt: time.Now(), } } } } return nil }