package sources import ( "context" "fmt" "log" "net/http" "time" "golang.org/x/time/rate" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/recon" ) // DOSpacesScanner enumerates publicly accessible DigitalOcean Spaces by name // pattern and flags readable objects matching common config-file patterns as // potential API key exposure vectors. // // Credentialless: uses anonymous HTTP to probe public DO Spaces. DO Spaces are // S3-compatible, so the same XML ListBucketResult format is used. type DOSpacesScanner struct { Registry *providers.Registry Limiters *recon.LimiterRegistry // BaseURL overrides the DO Spaces endpoint for tests. // Default: "https://%s.%s.digitaloceanspaces.com" // Must contain two %s placeholders: bucket name and region. BaseURL string client *Client } // Compile-time assertion. var _ recon.ReconSource = (*DOSpacesScanner)(nil) func (d *DOSpacesScanner) Name() string { return "spaces" } func (d *DOSpacesScanner) RateLimit() rate.Limit { return rate.Every(500 * time.Millisecond) } func (d *DOSpacesScanner) Burst() int { return 3 } func (d *DOSpacesScanner) RespectsRobots() bool { return false } func (d *DOSpacesScanner) Enabled(_ recon.Config) bool { return true } // doRegions are the DigitalOcean Spaces regions to iterate. var doRegions = []string{"nyc3", "sfo3", "ams3", "sgp1", "fra1"} func (d *DOSpacesScanner) Sweep(ctx context.Context, _ string, out chan<- recon.Finding) error { client := d.client if client == nil { client = NewClient() } baseURL := d.BaseURL if baseURL == "" { baseURL = "https://%s.%s.digitaloceanspaces.com" } names := bucketNames(d.Registry) if len(names) == 0 { return nil } for _, bucket := range names { if err := ctx.Err(); err != nil { return err } for _, region := range doRegions { if err := ctx.Err(); err != nil { return err } if d.Limiters != nil { if err := d.Limiters.Wait(ctx, d.Name(), d.RateLimit(), d.Burst(), false); err != nil { return err } } endpoint := fmt.Sprintf(baseURL, bucket, region) keys, err := d.listSpace(ctx, client, endpoint) if err != nil { log.Printf("spaces: bucket %q region %q probe failed (skipping): %v", bucket, region, err) continue } for _, key := range keys { if !isConfigFile(key) { continue } out <- recon.Finding{ Source: fmt.Sprintf("do://%s/%s", bucket, key), SourceType: "recon:spaces", Confidence: "medium", DetectedAt: time.Now(), } } } } return nil } // listSpace probes a DO Spaces endpoint via HEAD then parses the S3-compatible // ListBucketResult XML on success. func (d *DOSpacesScanner) listSpace(ctx context.Context, client *Client, endpoint string) ([]string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) if err != nil { return nil, err } resp, err := client.HTTP.Do(req) if err != nil { return nil, err } resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, nil } getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } getResp, err := client.Do(ctx, getReq) if err != nil { return nil, err } defer getResp.Body.Close() // DO Spaces uses S3-compatible XML format. return parseS3ListXML(getResp.Body) }