From d02cdcc7e0fe0b2bd4d3d0a9add086fddfbbfeaa Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 6 Apr 2026 16:31:14 +0300 Subject: [PATCH] feat(15-03): add Grafana and Sentry ReconSource implementations - GrafanaSource: search dashboards via /api/search, fetch detail via /api/dashboards/uid - SentrySource: search issues via /api/0/issues, fetch events for key detection - Register all 5 log aggregator sources in RegisterAll (67 sources total) - Tests use httptest mocks for each API endpoint --- pkg/recon/sources/grafana.go | 140 +++++++++++++++++++++++++++ pkg/recon/sources/grafana_test.go | 122 ++++++++++++++++++++++++ pkg/recon/sources/sentry.go | 152 ++++++++++++++++++++++++++++++ pkg/recon/sources/sentry_test.go | 118 +++++++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 pkg/recon/sources/grafana.go create mode 100644 pkg/recon/sources/grafana_test.go create mode 100644 pkg/recon/sources/sentry.go create mode 100644 pkg/recon/sources/sentry_test.go diff --git a/pkg/recon/sources/grafana.go b/pkg/recon/sources/grafana.go new file mode 100644 index 0000000..fda8782 --- /dev/null +++ b/pkg/recon/sources/grafana.go @@ -0,0 +1,140 @@ +package sources + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +// GrafanaSource searches exposed Grafana instances for API keys in dashboard +// configurations, panel queries, and data source settings. Many Grafana +// deployments enable anonymous access, exposing dashboards publicly. +type GrafanaSource struct { + BaseURL string + Registry *providers.Registry + Limiters *recon.LimiterRegistry + Client *Client +} + +var _ recon.ReconSource = (*GrafanaSource)(nil) + +func (s *GrafanaSource) Name() string { return "grafana" } +func (s *GrafanaSource) RateLimit() rate.Limit { return rate.Every(2 * time.Second) } +func (s *GrafanaSource) Burst() int { return 3 } +func (s *GrafanaSource) RespectsRobots() bool { return false } +func (s *GrafanaSource) Enabled(_ recon.Config) bool { return true } + +// grafanaSearchResult represents a Grafana dashboard search result. +type grafanaSearchResult struct { + UID string `json:"uid"` + Title string `json:"title"` +} + +// grafanaDashboardResponse represents the full dashboard detail response. +type grafanaDashboardResponse struct { + Dashboard json.RawMessage `json:"dashboard"` +} + +func (s *GrafanaSource) Sweep(ctx context.Context, query string, out chan<- recon.Finding) error { + base := s.BaseURL + if base == "" { + base = "http://localhost:3000" + } + client := s.Client + if client == nil { + client = NewClient() + } + + queries := BuildQueries(s.Registry, "grafana") + 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 + } + } + + // Search for dashboards matching keyword. + searchURL := fmt.Sprintf( + "%s/api/search?query=%s&type=dash-db&limit=10", + base, url.QueryEscape(q), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err != nil { + continue + } + + resp, err := client.Do(ctx, req) + if err != nil { + continue + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + _ = resp.Body.Close() + if err != nil { + continue + } + + var results []grafanaSearchResult + if err := json.Unmarshal(data, &results); err != nil { + continue + } + + // Fetch each dashboard detail and scan for keys. + for _, dash := range results { + 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 + } + } + + dashURL := fmt.Sprintf("%s/api/dashboards/uid/%s", base, dash.UID) + dashReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dashURL, nil) + if err != nil { + continue + } + + dashResp, err := client.Do(ctx, dashReq) + if err != nil { + continue + } + + dashData, err := io.ReadAll(io.LimitReader(dashResp.Body, 512*1024)) + _ = dashResp.Body.Close() + if err != nil { + continue + } + + if ciLogKeyPattern.Match(dashData) { + out <- recon.Finding{ + ProviderName: q, + Source: fmt.Sprintf("%s/d/%s/%s", base, dash.UID, dash.Title), + SourceType: "recon:grafana", + Confidence: "medium", + DetectedAt: time.Now(), + } + } + } + } + return nil +} diff --git a/pkg/recon/sources/grafana_test.go b/pkg/recon/sources/grafana_test.go new file mode 100644 index 0000000..163331e --- /dev/null +++ b/pkg/recon/sources/grafana_test.go @@ -0,0 +1,122 @@ +package sources + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +func TestGrafana_Name(t *testing.T) { + s := &GrafanaSource{} + if s.Name() != "grafana" { + t.Fatalf("expected grafana, got %s", s.Name()) + } +} + +func TestGrafana_Enabled(t *testing.T) { + s := &GrafanaSource{} + if !s.Enabled(recon.Config{}) { + t.Fatal("GrafanaSource should always be enabled") + } +} + +func TestGrafana_Sweep(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"uid":"abc123","title":"API-Monitoring"}]`)) + }) + mux.HandleFunc("/api/dashboards/uid/abc123", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "dashboard": { + "panels": [ + { + "title": "Key Usage", + "targets": [ + {"expr": "api_key = sk-proj-ABCDEF1234567890abcdef"} + ] + } + ] + } + }`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + reg := providers.NewRegistryFromProviders([]providers.Provider{ + {Name: "openai", Keywords: []string{"sk-proj-"}}, + }) + + s := &GrafanaSource{ + BaseURL: srv.URL, + Registry: reg, + Client: NewClient(), + } + + out := make(chan recon.Finding, 10) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := s.Sweep(ctx, "", out) + close(out) + if err != nil { + t.Fatalf("Sweep error: %v", err) + } + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if len(findings) == 0 { + t.Fatal("expected at least one finding from Grafana") + } + if findings[0].SourceType != "recon:grafana" { + t.Fatalf("expected recon:grafana, got %s", findings[0].SourceType) + } +} + +func TestGrafana_Sweep_NoDashboards(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + reg := providers.NewRegistryFromProviders([]providers.Provider{ + {Name: "openai", Keywords: []string{"sk-proj-"}}, + }) + + s := &GrafanaSource{ + BaseURL: srv.URL, + Registry: reg, + Client: NewClient(), + } + + out := make(chan recon.Finding, 10) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := s.Sweep(ctx, "", out) + close(out) + if err != nil { + t.Fatalf("Sweep error: %v", err) + } + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if len(findings) != 0 { + t.Fatalf("expected no findings, got %d", len(findings)) + } +} diff --git a/pkg/recon/sources/sentry.go b/pkg/recon/sources/sentry.go new file mode 100644 index 0000000..24acad1 --- /dev/null +++ b/pkg/recon/sources/sentry.go @@ -0,0 +1,152 @@ +package sources + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +// SentrySource searches exposed Sentry instances for API keys in error reports. +// Self-hosted Sentry installations may have the API accessible without +// authentication, exposing error events that commonly contain API keys in +// request headers, environment variables, and stack traces. +type SentrySource struct { + BaseURL string + Registry *providers.Registry + Limiters *recon.LimiterRegistry + Client *Client +} + +var _ recon.ReconSource = (*SentrySource)(nil) + +func (s *SentrySource) Name() string { return "sentry" } +func (s *SentrySource) RateLimit() rate.Limit { return rate.Every(2 * time.Second) } +func (s *SentrySource) Burst() int { return 3 } +func (s *SentrySource) RespectsRobots() bool { return false } +func (s *SentrySource) Enabled(_ recon.Config) bool { return true } + +// sentryIssue represents a Sentry issue from the issues list API. +type sentryIssue struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// sentryEvent represents a Sentry event from the events API. +type sentryEvent struct { + EventID string `json:"eventID"` + Tags json.RawMessage `json:"tags"` + Context json.RawMessage `json:"context"` + Entries json.RawMessage `json:"entries"` +} + +func (s *SentrySource) Sweep(ctx context.Context, query string, out chan<- recon.Finding) error { + base := s.BaseURL + if base == "" { + base = "https://sentry.example.com" + } + client := s.Client + if client == nil { + client = NewClient() + } + + queries := BuildQueries(s.Registry, "sentry") + 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 + } + } + + // Search issues matching keyword. + issuesURL := fmt.Sprintf( + "%s/api/0/issues/?query=%s&limit=10", + base, url.QueryEscape(q), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuesURL, nil) + if err != nil { + continue + } + + resp, err := client.Do(ctx, req) + if err != nil { + continue + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + _ = resp.Body.Close() + if err != nil { + continue + } + + var issues []sentryIssue + if err := json.Unmarshal(data, &issues); err != nil { + continue + } + + // Fetch events for each issue. + for _, issue := range issues { + 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 + } + } + + eventsURL := fmt.Sprintf("%s/api/0/issues/%s/events/?limit=5", base, issue.ID) + evReq, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsURL, nil) + if err != nil { + continue + } + + evResp, err := client.Do(ctx, evReq) + if err != nil { + continue + } + + evData, err := io.ReadAll(io.LimitReader(evResp.Body, 512*1024)) + _ = evResp.Body.Close() + if err != nil { + continue + } + + var events []sentryEvent + if err := json.Unmarshal(evData, &events); err != nil { + continue + } + + for _, ev := range events { + content := string(ev.Tags) + string(ev.Context) + string(ev.Entries) + if ciLogKeyPattern.MatchString(content) { + out <- recon.Finding{ + ProviderName: q, + Source: fmt.Sprintf("%s/issues/%s/events/%s", base, issue.ID, ev.EventID), + SourceType: "recon:sentry", + Confidence: "medium", + DetectedAt: time.Now(), + } + } + } + } + } + return nil +} diff --git a/pkg/recon/sources/sentry_test.go b/pkg/recon/sources/sentry_test.go new file mode 100644 index 0000000..7fd0411 --- /dev/null +++ b/pkg/recon/sources/sentry_test.go @@ -0,0 +1,118 @@ +package sources + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +func TestSentry_Name(t *testing.T) { + s := &SentrySource{} + if s.Name() != "sentry" { + t.Fatalf("expected sentry, got %s", s.Name()) + } +} + +func TestSentry_Enabled(t *testing.T) { + s := &SentrySource{} + if !s.Enabled(recon.Config{}) { + t.Fatal("SentrySource should always be enabled") + } +} + +func TestSentry_Sweep(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/0/issues/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Route between issues list and events based on path depth. + if r.URL.Path == "/api/0/issues/" { + _, _ = w.Write([]byte(`[{"id":"42","title":"KeyError in handler"}]`)) + return + } + // Events endpoint: /api/0/issues/42/events/ + _, _ = w.Write([]byte(`[{ + "eventID": "evt-001", + "tags": [{"key": "api_key", "value": "sk-proj-ABCDEF1234567890abcdef"}], + "context": {"api_key": "sk-proj-ABCDEF1234567890abcdef"}, + "entries": [{"type": "request", "data": {"api_key": "sk-proj-ABCDEF1234567890abcdef"}}] + }]`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + reg := providers.NewRegistryFromProviders([]providers.Provider{ + {Name: "openai", Keywords: []string{"sk-proj-"}}, + }) + + s := &SentrySource{ + BaseURL: srv.URL, + Registry: reg, + Client: NewClient(), + } + + out := make(chan recon.Finding, 10) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := s.Sweep(ctx, "", out) + close(out) + if err != nil { + t.Fatalf("Sweep error: %v", err) + } + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if len(findings) == 0 { + t.Fatal("expected at least one finding from Sentry") + } + if findings[0].SourceType != "recon:sentry" { + t.Fatalf("expected recon:sentry, got %s", findings[0].SourceType) + } +} + +func TestSentry_Sweep_NoIssues(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/0/issues/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + reg := providers.NewRegistryFromProviders([]providers.Provider{ + {Name: "openai", Keywords: []string{"sk-proj-"}}, + }) + + s := &SentrySource{ + BaseURL: srv.URL, + Registry: reg, + Client: NewClient(), + } + + out := make(chan recon.Finding, 10) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := s.Sweep(ctx, "", out) + close(out) + if err != nil { + t.Fatalf("Sweep error: %v", err) + } + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if len(findings) != 0 { + t.Fatalf("expected no findings, got %d", len(findings)) + } +}