bucket discovery engine: CT domain -> candidate names -> listing index

CMS SANs are custom domains, not bucket names, so matching SANs finds almost
no buckets. This adds internal/discover: from each non-matching hostname it
derives candidate names (domain label + subdomain label + ~40 suffixes) plus a
built-in wordlist, and anonymously tests listing on S3, GCS and (optionally)
DigitalOcean Spaces. Every 200 is indexed (keys/sizes/dates only).

Bounded hard: global BUCKET_SCAN_QPS rate limit, 12h per-name dedup, random
sampled domain queue, MaxPerDomain cap. Never downloads contents.

Fix: GCS candidates now use the virtual-host form
(<bucket>.storage.googleapis.com) so buckets no longer collide on the
cloud_assets unique key; previously they all shared storage.googleapis.com and
overwrote each other.

Live: 30 open buckets in ~5 min (e.g. estimator-dev, image-prod,
update-media, mos-images S3; several GCS buckets), 11k+ objects indexed.
This commit is contained in:
StasonJatham committed 2026-09-22 15:53:31 +02:00
1 parent 816fa572df
commit a899362e18
9 files changed
+615 -32

No files matched your search

+43
View File
@@ -20,6 +20,7 @@ import (
"software.sslmate.com/src/certspotter/monitor"
"tea.karlcom.de/karl/bucketeer/internal/config"
"tea.karlcom.de/karl/bucketeer/internal/discover"
"tea.karlcom.de/karl/bucketeer/internal/dns"
"tea.karlcom.de/karl/bucketeer/internal/index"
"tea.karlcom.de/karl/bucketeer/internal/metrics"
@@ -145,6 +146,35 @@ func run() error {
}
scanner := &scan.Scanner{Prober: scanProber, Indexer: indexer, Enricher: enricher, Store: st, BackfillWorkers: backfillWorkers}
var discoverer *discover.Discoverer
if cfg.BucketScanEnabled {
discoverer = discover.New(discover.Config{
QPS: cfg.BucketScanQPS, Workers: cfg.BucketScanWorkers, Queue: cfg.BucketScanQueue,
S3: true, GCS: true, DOSpaces: cfg.BucketScanDO,
DORegions: []string{"nyc3", "sfo3", "fra1", "ams3", "sgp1"},
Indexer: indexer, Metrics: m,
OnOpen: func(uctx context.Context, c model.Candidate, detail string, entries []model.Entry) {
if _, err := st.EnsureAsset(uctx, c); err != nil {
return
}
_ = st.UpdateExposure(uctx, c.Provider.String(), c.Service.String(), c.Hostname,
model.ExposurePublicRead, detail, true, true)
if n, err := st.ReplaceEntries(uctx, c.Provider.String(), c.Service.String(), c.Hostname, entries); err == nil && n > 0 {
m.EntriesIndexed.Add(uint64(n))
}
slog.Info("open bucket discovered", "event", "open_bucket",
"host", c.Hostname, "provider", c.Provider.String(), "service", c.Service.String(), "entries", len(entries))
},
})
discoverer.Start(ctx)
for _, name := range discover.Wordlist() {
discoverer.SubmitName(name)
}
slog.Info("bucket name discovery enabled", "event", "startup",
"qps", cfg.BucketScanQPS, "workers", cfg.BucketScanWorkers, "do_spaces", cfg.BucketScanDO,
"wordlist", len(discover.Wordlist()))
}
var prober *probe.Prober
if cfg.ActiveProbing {
pcfg := probe.Config{
@@ -184,6 +214,10 @@ func run() error {
return err
}
var discoverField interface{ Submit(host string) }
if discoverer != nil {
discoverField = discoverer
}
p := pipeline.New(pipeline.Config{
DirectQueueSize: cfg.DirectQueueSize,
DNSQueueSize: cfg.DNSQueueSize,
@@ -196,6 +230,7 @@ func run() error {
Enricher: enricher,
Write: write,
Metrics: m,
Discover: discoverField,
})
p.Start(ctx)
@@ -237,6 +272,10 @@ func run() error {
prober.Close()
prober.Wait()
}
if discoverer != nil {
discoverer.Close()
discoverer.Wait()
}
close(drained)
}()
select {
@@ -339,6 +378,10 @@ func logStats(ctx context.Context, m *metrics.Metrics, p *pipeline.Pipeline, st
"probe_public", m.ProbePublic.Load(),
)
}
attrs = append(attrs,
"bucket_candidates", m.BucketCandidates.Load(),
"bucket_open", m.BucketOpen.Load(),
)
slog.Info("periodic stats", attrs...)
}
}