Files
keyhunter/pkg/recon/sources/dospaces.go
salvacybersec 13905eb5ee feat(12-03): implement AzureBlobScanner, DOSpacesScanner, and all cloud scanner tests
- AzureBlobScanner enumerates public Azure Blob containers with XML listing
- DOSpacesScanner enumerates public DO Spaces across 5 regions (S3-compatible XML)
- httptest-based tests for all four scanners: sweep, empty registry, ctx cancel, metadata
- All sources credentialless, compile-time interface assertions
2026-04-06 12:26:01 +03:00

127 lines
3.3 KiB
Go

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)
}