provider capability model, on-demand scan/backfill, more providers

Readability capabilities (exists/object_readable/listable) are now tracked
separately instead of a single exposure flag, matching how providers differ:
- Azure 'Blob' mode = object readable, not listable; 'Container' = listable
- Cloudflare R2 public URLs are never anonymously listable (root 500); R2 is
  object-read only and is no longer probed for listing
- S3/GCS: 200 list = listable; 403 + readable index.html/robots.txt =
  object readable only
New columns: cloud_assets.listable, cloud_assets.object_readable.

Azure: account-level container listing is never anonymous; discover containers
by probing a bounded common-name set (or the container from a URL path), then
list blobs.

Classifier: added the legacy AWS '.s3-<region>.amazonaws.com' bucket form.

On-demand scan: POST /api/scan {target} classifies a host or URL, follows CNAME
for custom domains, probes, and indexes readable resources; Azure container
comes from the URL path. UI has a Scan box.

Backfill: POST /api/reprobe re-probes already-discovered assets with the current
checks and indexes any readable ones, recovering findings from earlier probes.

DNS: CNAME-only enrichment (A/AAAA resolved only for matches), 64 workers,
larger queue.

Verified live against real public resources:
  veteran1.nyc3.digitaloceanspaces.com            listable, entries
  bucket-01.s3-eu-west-1.amazonaws.com            listable, entries
  tartugeo-podcast.storage.googleapis.com         listable, entries
  eroticfeeldev.blob.core.windows.net/images      listable, entries
This commit is contained in:
StasonJatham committed 2026-09-22 14:47:49 +02:00
1 parent a366905a43
commit 62a7d2eb2e
23 files changed
+936 -165

No files matched your search

+53 -5
View File
@@ -143,6 +143,50 @@ func nonNil(s []string) []string {
return s
}
// ListProbeTargets returns persisted assets to re-probe (for backfill). An
// empty exposure matches all.
func (s *Store) ListProbeTargets(ctx context.Context, exposure string, limit int) ([]model.ProbeTarget, error) {
if limit <= 0 || limit > 100000 {
limit = 10000
}
rows, err := s.pool.Query(ctx, `
SELECT provider, service, provider_hostname, resource_identifier, region
FROM cloud_assets
WHERE ($1 = '' OR exposure_state = $1)
ORDER BY last_seen DESC
LIMIT $2`, exposure, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.ProbeTarget{}
for rows.Next() {
var t model.ProbeTarget
if err := rows.Scan(&t.Provider, &t.Service, &t.Hostname, &t.Resource, &t.Region); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// EnsureAsset upserts a manually scanned asset (which may not have appeared in
// CT) and returns its id.
func (s *Store) EnsureAsset(ctx context.Context, c model.Candidate) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO cloud_assets
(provider, service, hostname, provider_hostname, resource_identifier,
region, resource_specific, public_semantic, first_seen, last_seen)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), now())
ON CONFLICT (provider, service, provider_hostname)
DO UPDATE SET last_seen = now()
RETURNING id`,
c.Provider.String(), c.Service.String(), c.Original, c.Hostname, c.Resource,
c.Region, c.ResourceSpecific, c.PublicSemantic.String()).Scan(&id)
return id, err
}
// IndexEntry is one indexed resource item for the UI.
type IndexEntry struct {
ID int64 `json:"id"`
@@ -267,12 +311,13 @@ func (s *Store) CountAssets(ctx context.Context, f AssetFilter) (int, error) {
}
// UpdateExposure records the outcome of an optional reachability probe.
func (s *Store) UpdateExposure(ctx context.Context, provider, service, providerHostname string, state model.ExposureState, detail string) error {
func (s *Store) UpdateExposure(ctx context.Context, provider, service, providerHostname string, state model.ExposureState, detail string, listable, objectReadable bool) error {
_, err := s.pool.Exec(ctx, `
UPDATE cloud_assets
SET exposure_state = $4, exposure_detail = $5, probed_at = now()
SET exposure_state = $4, exposure_detail = $5, probed_at = now(),
listable = $6, object_readable = $7
WHERE provider = $1 AND service = $2 AND provider_hostname = $3`,
provider, service, providerHostname, state.String(), detail)
provider, service, providerHostname, state.String(), detail, listable, objectReadable)
return err
}
@@ -288,6 +333,8 @@ type Asset struct {
PublicSemantic string `json:"public_semantic"`
Exposure string `json:"exposure_state"`
ExposureDetail string `json:"exposure_detail"`
Listable bool `json:"listable"`
ObjectReadable bool `json:"object_readable"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
ProbedAt *time.Time `json:"probed_at"`
@@ -327,7 +374,8 @@ func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error)
}
rows, err := s.pool.Query(ctx, `
SELECT id, provider, service, hostname, provider_hostname, resource_identifier,
region, public_semantic, exposure_state, exposure_detail, first_seen, last_seen, probed_at
region, public_semantic, exposure_state, exposure_detail, listable, object_readable,
first_seen, last_seen, probed_at
FROM cloud_assets
WHERE ($1 = '' OR provider = $1)
AND ($2 = '' OR service = $2)
@@ -345,7 +393,7 @@ func (s *Store) ListAssets(ctx context.Context, f AssetFilter) ([]Asset, error)
var a Asset
if err := rows.Scan(&a.ID, &a.Provider, &a.Service, &a.Hostname, &a.ProviderHost,
&a.Resource, &a.Region, &a.PublicSemantic, &a.Exposure, &a.ExposureDetail,
&a.FirstSeen, &a.LastSeen, &a.ProbedAt); err != nil {
&a.Listable, &a.ObjectReadable, &a.FirstSeen, &a.LastSeen, &a.ProbedAt); err != nil {
return nil, err
}
out = append(out, a)