Files
at-container-registry/pkg/hold/pds/scan.go
T
2026-04-29 10:37:36 -05:00

211 lines
6.2 KiB
Go

package pds
import (
"context"
"fmt"
"strings"
"atcr.io/pkg/atproto"
"github.com/ipfs/go-cid"
)
// unscannableLayerMediaSubstrings lists layer media-type fragments that
// identify artifact types the scanner intentionally skips. Kept in sync with
// scanner/internal/scan/worker.go's unscannableConfigTypes — that map keys on
// config media types; here we look at *layer* media types because the
// backfill walks the hold's layer index (manifest AT-URIs join layers to
// scan records).
var unscannableLayerMediaSubstrings = []string{
"helm.chart.content",
"in-toto",
"dsse.envelope",
}
// ScanBackfillResult summarizes what BackfillScanStatus did.
type ScanBackfillResult struct {
Scanned int // total scan records examined
AlreadyTagged int // records with non-empty status (or non-failure shape) — left alone
MarkedSkipped int // records rewritten as status=skipped
MarkedFailed int // records rewritten as status=failed
Rewritten int // total rewrites (MarkedSkipped + MarkedFailed) that succeeded
}
// BackfillScanStatus walks every io.atcr.hold.scan record on this hold and
// assigns a status ("skipped" or "failed") to legacy records that pre-date the
// status field. Idempotent — records that already have a non-empty status are
// left alone.
//
// A legacy record is one with empty status, no SBOM blob, and zero
// vulnerability counts. Layer media types are inspected to choose the
// rewrite: helm/in-toto/DSSE → skipped, otherwise → failed. ScannedAt is
// preserved on rewrite so the rescan timer doesn't reset.
//
// Safe to call on a running hold — uses the existing repomgr APIs the same
// way scan-job handling does.
//
// progress, if non-nil, is called periodically with a snapshot of the
// running totals so callers can surface progress to the UI. The callback
// must not retain the pointer past its return.
func (p *HoldPDS) BackfillScanStatus(ctx context.Context, log func(format string, args ...any), progress func(*ScanBackfillResult)) (*ScanBackfillResult, error) {
if log == nil {
log = func(string, ...any) {}
}
if progress == nil {
progress = func(*ScanBackfillResult) {}
}
ri := p.RecordsIndex()
if ri == nil {
return nil, fmt.Errorf("records index not available")
}
const batchSize = 200
res := &ScanBackfillResult{}
var cursor string
for {
records, nextCursor, err := ri.ListRecords(atproto.ScanCollection, batchSize, cursor, true)
if err != nil {
return res, fmt.Errorf("list scan records: %w", err)
}
for _, rec := range records {
if err := ctx.Err(); err != nil {
return res, err
}
res.Scanned++
manifestDigest := "sha256:" + rec.Rkey
_, scanRecord, err := p.GetScanRecord(ctx, manifestDigest)
if err != nil {
log("skip rkey=%s: get failed: %v", rec.Rkey, err)
continue
}
// Already classified — nothing to do.
if scanRecord.Status != "" {
res.AlreadyTagged++
continue
}
// Real data present (legacy successful scan) — treat as ok, don't rewrite.
if scanRecord.SbomBlob != nil || scanRecord.Total != 0 {
res.AlreadyTagged++
continue
}
// Determine artifact type from layer media types.
layers, err := p.ListLayerRecordsForManifest(ctx, scanRecord.Manifest)
if err != nil {
log("skip rkey=%s: list layers failed: %v", rec.Rkey, err)
continue
}
skipped := false
for _, l := range layers {
for _, frag := range unscannableLayerMediaSubstrings {
if strings.Contains(l.MediaType, frag) {
skipped = true
break
}
}
if skipped {
break
}
}
var rewrite *atproto.ScanRecord
if skipped {
rewrite = atproto.NewSkippedScanRecord(
manifestDigest,
scanRecord.Repository,
scanRecord.UserDID,
"backfilled: unscannable artifact type",
scanRecord.ScannerVersion,
)
res.MarkedSkipped++
} else {
rewrite = atproto.NewFailedScanRecord(
manifestDigest,
scanRecord.Repository,
scanRecord.UserDID,
"backfilled: legacy record (no SBOM and zero counts)",
scanRecord.ScannerVersion,
)
res.MarkedFailed++
}
// Preserve original ScannedAt so the rescan timer doesn't reset
// and we don't lose audit signal.
if scanRecord.ScannedAt != "" {
rewrite.ScannedAt = scanRecord.ScannedAt
}
if _, _, err := p.CreateScanRecord(ctx, rewrite); err != nil {
log("rewrite rkey=%s failed: %v", rec.Rkey, err)
continue
}
res.Rewritten++
}
// Report progress at every batch boundary — gives the UI smooth-ish
// updates without locking the loop on every record.
progress(res)
if nextCursor == "" || len(records) == 0 {
break
}
cursor = nextCursor
}
progress(res)
return res, nil
}
// CreateScanRecord creates or updates a scan result record in the hold's PDS
// Uses a deterministic rkey based on the manifest digest, so re-scans upsert
func (p *HoldPDS) CreateScanRecord(ctx context.Context, record *atproto.ScanRecord) (string, cid.Cid, error) {
if record.Type != atproto.ScanCollection {
return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
}
if record.Manifest == "" {
return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
}
// Extract the digest from the manifest AT-URI to use as rkey
manifestDigest, err := atproto.ParseManifestURI(record.Manifest)
if err != nil {
return "", cid.Undef, fmt.Errorf("invalid manifest AT-URI: %w", err)
}
rkey := atproto.ScanRecordKey(manifestDigest)
// Upsert: re-scans update the existing record
rpath, recordCID, _, err := p.repomgr.UpsertRecord(
ctx,
p.uid,
atproto.ScanCollection,
rkey,
record,
)
if err != nil {
return "", cid.Undef, fmt.Errorf("failed to upsert scan record: %w", err)
}
return rpath, recordCID, nil
}
// GetScanRecord retrieves a scan result record by manifest digest
func (p *HoldPDS) GetScanRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ScanRecord, error) {
rkey := atproto.ScanRecordKey(manifestDigest)
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ScanCollection, rkey, cid.Undef)
if err != nil {
return cid.Undef, nil, fmt.Errorf("failed to get scan record: %w", err)
}
scanRecord, ok := val.(*atproto.ScanRecord)
if !ok {
return cid.Undef, nil, fmt.Errorf("unexpected type for scan record: %T", val)
}
return recordCID, scanRecord, nil
}