parallel backfill, persist scanned exposure, random-sample DNS queue

- scanner.run now persists exposure_state/detail/capabilities for scanned
  assets (previously only the index was written).
- Backfill runs a bounded worker pool (16) so it verifies reachable assets
  without saturating the DB pool; DB_MAX_CONNS raised for the live test.
- CNAME enrichment queue is now a bounded random sample instead of a FIFO.
  The FIFO filled with the first N hostnames seen at startup and dropped the
  rest, so CNAME discovery only ever saw stale hostnames; random replacement
  gives representative coverage at bounded memory (same fix as the prober).

Verified live:
- all four known-public resources scan + index (DO, AWS s3-<region>, GCS,
  Azure container path), 38-1000 objects each.
- backfill of every CT-discovered 'reachable' asset (725) and 'auth_required'
  (12) completed with 0 listable: those are MRAP/non-bucket 400s and
  list-denied buckets, confirming the checks are actually running.
This commit is contained in:
StasonJatham committed 2026-09-22 15:09:34 +02:00
1 parent 62a7d2eb2e
commit 171c9b37eb
6 files changed
+196 -57

No files matched your search

+80 -36
View File
@@ -8,6 +8,8 @@ import (
"context"
"net"
"strings"
"sync"
"sync/atomic"
"tea.karlcom.de/karl/bucketeer/internal/classify"
"tea.karlcom.de/karl/bucketeer/internal/dns"
@@ -37,10 +39,11 @@ type Enricher interface {
}
type Scanner struct {
Prober Prober
Indexer Lister
Enricher Enricher
Store Store
Prober Prober
Indexer Lister
Enricher Enricher
Store Store
BackfillWorkers int
}
// Result is the outcome of scanning one resolved provider resource.
@@ -107,47 +110,84 @@ type BackfillReport struct {
// Backfill re-probes assets already in the database (optionally filtered by
// exposure_state) with the current checks and indexes any that are readable.
// This recovers findings discovered before a check was improved.
// This is how already-discovered "reachable" endpoints actually get verified.
// It runs a bounded worker pool so it stays interactive.
func (s *Scanner) Backfill(ctx context.Context, exposure string, limit int) (BackfillReport, error) {
targets, err := s.Store.ListProbeTargets(ctx, exposure, limit)
if err != nil {
return BackfillReport{}, err
}
var rep BackfillReport
for _, t := range targets {
if err := ctx.Err(); err != nil {
return rep, err
}
c := model.Candidate{
Hostname: t.Hostname, Original: t.Hostname, Resource: t.Resource, Region: t.Region,
Provider: model.ParseProvider(t.Provider), Service: model.ParseService(t.Service),
Source: model.SourceManual,
}
pr := s.Prober.ProbeDetailed(ctx, c)
rep.Probed++
if pr.Exposure == model.ExposurePublicRead {
rep.Readable++
}
if pr.Listable {
rep.Listable++
}
if err := s.Store.UpdateExposure(ctx, t.Provider, t.Service, t.Hostname,
pr.Exposure, pr.Detail, pr.Listable, pr.ObjectReadable); err != nil {
rep.Errors++
continue
}
if pr.Listable && s.Indexer != nil {
entries, err := s.Indexer.List(ctx, c)
if err == nil && len(entries) > 0 {
if n, werr := s.Store.ReplaceEntries(ctx, t.Provider, t.Service, t.Hostname, entries); werr == nil {
rep.Indexed += n
} else {
rep.Errors++
workers := s.BackfillWorkers
if workers <= 0 {
workers = 32
}
var (
probed, readable, listable, indexed, errs atomic.Int64
wg sync.WaitGroup
jobs = make(chan model.ProbeTarget)
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
if ctx.Err() != nil {
return
}
s.probeTarget(ctx, t, &probed, &readable, &listable, &indexed, &errs)
}
}()
}
for _, t := range targets {
select {
case jobs <- t:
case <-ctx.Done():
close(jobs)
wg.Wait()
return s.report(&probed, &readable, &listable, &indexed, &errs), ctx.Err()
}
}
close(jobs)
wg.Wait()
return s.report(&probed, &readable, &listable, &indexed, &errs), nil
}
func (s *Scanner) probeTarget(ctx context.Context, t model.ProbeTarget, probed, readable, listable, indexed, errs *atomic.Int64) {
c := model.Candidate{
Hostname: t.Hostname, Original: t.Hostname, Resource: t.Resource, Region: t.Region,
Provider: model.ParseProvider(t.Provider), Service: model.ParseService(t.Service),
Source: model.SourceManual,
}
pr := s.Prober.ProbeDetailed(ctx, c)
probed.Add(1)
if pr.Exposure == model.ExposurePublicRead {
readable.Add(1)
}
if pr.Listable {
listable.Add(1)
}
if err := s.Store.UpdateExposure(ctx, t.Provider, t.Service, t.Hostname,
pr.Exposure, pr.Detail, pr.Listable, pr.ObjectReadable); err != nil {
errs.Add(1)
return
}
if pr.Listable && s.Indexer != nil {
entries, err := s.Indexer.List(ctx, c)
if err == nil && len(entries) > 0 {
if n, werr := s.Store.ReplaceEntries(ctx, t.Provider, t.Service, t.Hostname, entries); werr == nil {
indexed.Add(int64(n))
} else {
errs.Add(1)
}
}
}
return rep, nil
}
func (s *Scanner) report(probed, readable, listable, indexed, errs *atomic.Int64) BackfillReport {
return BackfillReport{
Probed: int(probed.Load()), Readable: int(readable.Load()),
Listable: int(listable.Load()), Indexed: int(indexed.Load()), Errors: int(errs.Load()),
}
}
func (s *Scanner) run(ctx context.Context, c model.Candidate, chain []string) Result {
@@ -161,6 +201,10 @@ func (s *Scanner) run(ctx context.Context, c model.Candidate, chain []string) Re
pr := s.Prober.ProbeDetailed(ctx, c)
r.Exposure, r.Detail = pr.Exposure.String(), pr.Detail
r.Listable, r.ObjectReadable = pr.Listable, pr.ObjectReadable
if err := s.Store.UpdateExposure(ctx, c.Provider.String(), c.Service.String(), c.Hostname,
pr.Exposure, pr.Detail, pr.Listable, pr.ObjectReadable); err != nil {
r.Error = "store: " + err.Error()
}
if pr.Listable && s.Indexer != nil {
entries, err := s.Indexer.List(ctx, c)
if err == nil && len(entries) > 0 {