Files
at-container-registry/scanner/internal/scan/worker.go
T
Evan JarrettandClaude Opus 5 dfd604b106 hold/scanner: stop one undispatchable job freezing all scanning
Vulnerability scanning produced nothing across the whole deployment for
nine days, from 2026-08-25 01:20:48 until a scanner restart on 2026-09-03.
The scanner was connected and idle, the hold's discovery pass kept
reporting unscannedFound=15 every four hours, and no scan_jobs row was
created in that entire window.

hasActiveJobs counted pending, assigned and processing rows globally with
no age bound, and waitForCapacity spins while it is true. dispatchLoop
calls it before popping any candidate, so a single pending row that never
reached a terminal state reported "busy" forever: discovery kept pushing
candidates into unscannedQueue and nothing ever popped them. That is why
the symptom was an empty queue rather than a growing one.

Nothing papered over it because push-triggered enqueue only fires for
owner or a tier with scan_on_push, which in production means pro alone.
All 210 manifests pushed to this hold in that window came from free,
supporter, or accounts with no crew row, so the frozen proactive loop was
the only source of jobs.

Nor could it recover on its own. Only Enqueue and drainPendingJobs
dispatch a pending row, and drainPendingJobs runs only when a scanner
newly connects; reDispatchTimedOut considered assigned rows only. The
hold had been up since Aug 14 and the scanner since Aug 21 on the same
websocket, so the drain path had not run since the row appeared.

So bound the capacity gate to pending rows younger than pendingStaleAfter,
give reDispatchTimedOut a pending reclaim, and check RowsAffected on the
assign UPDATE now that two dispatchers can race for a row. waitForCapacity
warns and names the blocking jobs after ten minutes without capacity,
because the failure mode above was completely silent.

Two adjacent fixes for the same outage. The scanner never called
InitLogger, so log_level and log_shipper were dead config and an idle
scanner was mute, which is what made nine days invisible. And skipReason
now also skips a job whose layers contain nothing tar-shaped: the job that
wedged this queue was an in-toto attestation whose config mediaType is an
ordinary image config, so the existing config-type check missed it and
buildOCILayout would have handed Syft an empty image.

The regression tests were verified against the old logic first: three of
them fail on it and pass on the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPWkeCKcbtGoyXyyeMhSps
2026-09-02 21:12:13 -05:00

259 lines
7.8 KiB
Go

// Package scan implements the vulnerability scanning pipeline:
// extract layers → generate SBOM → scan vulnerabilities → send result.
package scan
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"runtime"
"strings"
"sync"
"time"
scanner "atcr.io/scanner"
"atcr.io/scanner/internal/client"
"atcr.io/scanner/internal/config"
"atcr.io/scanner/internal/queue"
)
// SkipError is returned by processJob when the scanner intentionally bypasses
// an artifact type it can't analyze (helm charts, in-toto attestations, DSSE).
// The worker dispatches these to hold via SendSkipped so the hold can mark
// the scan record "skipped" instead of "failed". Skipped records are never
// retried by the stale-scan loop; failures are.
type SkipError struct {
Reason string
}
func (e *SkipError) Error() string { return "skipped: " + e.Reason }
// WorkerPool manages a pool of scan workers
type WorkerPool struct {
cfg *config.Config
queue *queue.JobQueue
client *client.HoldClient
wg sync.WaitGroup
}
// NewWorkerPool creates a new worker pool
func NewWorkerPool(cfg *config.Config, q *queue.JobQueue, c *client.HoldClient) *WorkerPool {
return &WorkerPool{
cfg: cfg,
queue: q,
client: c,
}
}
// Start launches worker goroutines
func (wp *WorkerPool) Start(ctx context.Context) {
// Point TMPDIR at the configured tmp dir so Grype's DB download
// (go-getter zstd decompression can be 1 GB+) and stereoscope's layer
// extraction both land on the same partition as the scanner volume —
// NOT on /tmp, which is typically tmpfs with ~400 MB and would silently
// fail mid-extract. This must be set before any scanner/grype goroutine
// starts and must never be restored to a smaller default mid-process.
if wp.cfg.Vuln.TmpDir != "" {
if err := os.MkdirAll(wp.cfg.Vuln.TmpDir, 0o755); err != nil {
slog.Warn("Failed to create scanner tmp dir", "path", wp.cfg.Vuln.TmpDir, "error", err)
}
os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir)
}
// Initialize vuln database on startup if enabled
if wp.cfg.Vuln.Enabled {
go func() {
if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath); err != nil {
slog.Error("Failed to initialize vulnerability database", "error", err)
slog.Warn("Vulnerability scanning will be disabled until database is available")
}
}()
}
for i := 0; i < wp.cfg.Scanner.Workers; i++ {
wp.wg.Add(1)
go wp.worker(ctx, i)
}
slog.Info("Scanner worker pool started", "workers", wp.cfg.Scanner.Workers)
}
// Wait blocks until all workers finish
func (wp *WorkerPool) Wait() {
wp.wg.Wait()
}
func (wp *WorkerPool) worker(ctx context.Context, id int) {
defer wp.wg.Done()
slog.Info("Scanner worker started", "worker_id", id)
for {
job := wp.queue.Dequeue()
if job == nil {
slog.Info("Scanner worker shutting down", "worker_id", id)
return
}
slog.Info("Processing scan job",
"worker_id", id,
"repository", job.Repository,
"tag", job.Tag,
"digest", job.ManifestDigest,
"tier", job.Tier)
result, err := wp.processJob(ctx, job)
if err != nil {
var skipErr *SkipError
if errors.As(err, &skipErr) {
slog.Info("Scan job skipped",
"worker_id", id,
"repository", job.Repository,
"reason", skipErr.Reason)
wp.client.SendSkipped(job.Seq, skipErr.Reason)
} else {
slog.Error("Scan job failed",
"worker_id", id,
"repository", job.Repository,
"error", err)
wp.client.SendError(job.Seq, err.Error())
}
} else {
wp.client.SendResult(job.Seq, result)
slog.Info("Scan job completed",
"worker_id", id,
"repository", job.Repository,
"vulnerabilities", result.Summary.Total)
}
// Free large scan artifacts and trigger GC before the cooldown
// so memory is reclaimed between jobs. Syft/Grype allocate heavily
// and Go's GC needs idle time to catch up under sustained load.
result = nil
runtime.GC()
// Cooldown between scans to reduce sustained memory pressure
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}
// unscannable config media types — these are OCI artifacts that aren't
// container images so Syft/Grype can't analyze their layers.
var unscannableConfigTypes = map[string]bool{
"application/vnd.cncf.helm.config.v1+json": true, // Helm charts
"application/vnd.in-toto+json": true, // In-toto attestations
"application/vnd.dsse.envelope.v1+json": true, // DSSE envelopes (SLSA)
}
// skipReason reports why a job cannot be scanned, or "" when it can be.
func skipReason(job *scanner.ScanJob) string {
if unscannableConfigTypes[job.Config.MediaType] {
return fmt.Sprintf("unscannable artifact type %s", job.Config.MediaType)
}
// A buildx attestation manifest carries an ordinary image config with a
// single in-toto or DSSE payload as its layer, so the config media type
// alone does not identify it. buildOCILayout drops every non-tar layer,
// which would hand Syft an image with nothing in it.
if len(job.Layers) > 0 && !hasScannableLayer(job.Layers) {
return fmt.Sprintf("no scannable layers (%s)", job.Layers[0].MediaType)
}
return ""
}
// hasScannableLayer mirrors the layer filter in buildOCILayout: anything that
// is not a tar of some flavour is not something Syft can read.
func hasScannableLayer(layers []scanner.BlobDescriptor) bool {
for _, layer := range layers {
if layer.Digest == "" {
continue
}
if layer.MediaType == "" || strings.Contains(layer.MediaType, "tar") {
return true
}
}
return false
}
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
startTime := time.Now()
// Skip non-container OCI artifacts (Helm charts, in-toto, DSSE, etc.).
// Returning *SkipError tells the worker dispatch loop to send a "skipped"
// message rather than an "error" — the hold marks these records as
// permanently skipped and won't retry them on the rescan interval.
if reason := skipReason(job); reason != "" {
return nil, &SkipError{Reason: reason}
}
// Ensure tmp dir exists
if err := ensureDir(wp.cfg.Vuln.TmpDir); err != nil {
return nil, fmt.Errorf("failed to create tmp dir: %w", err)
}
// Check total compressed image size before downloading
if wp.cfg.Vuln.MaxImageSize > 0 {
var totalSize int64
for _, layer := range job.Layers {
totalSize += layer.Size
}
totalSize += job.Config.Size
if totalSize > wp.cfg.Vuln.MaxImageSize {
return nil, fmt.Errorf("image too large: %d bytes compressed (limit %d bytes)", totalSize, wp.cfg.Vuln.MaxImageSize)
}
}
// Step 1: Build OCI image layout from hold via presigned URLs
slog.Info("Building OCI layout", "repository", job.Repository)
ociLayoutDir, cleanup, err := buildOCILayout(job, wp.cfg.Vuln.TmpDir, wp.cfg.Hold.Secret)
if err != nil {
return nil, fmt.Errorf("failed to build OCI layout: %w", err)
}
defer cleanup()
// Step 2: Generate SBOM with Syft
slog.Info("Generating SBOM", "repository", job.Repository)
sbomResult, sbomJSON, sbomDigest, err := generateSBOM(ctx, ociLayoutDir)
if err != nil {
return nil, fmt.Errorf("failed to generate SBOM: %w", err)
}
result := &scanner.ScanResult{
ManifestDigest: job.ManifestDigest,
SBOM: sbomJSON,
SBOMDigest: sbomDigest,
}
// Step 3: Scan SBOM with Grype (if enabled)
if wp.cfg.Vuln.Enabled {
slog.Info("Scanning for vulnerabilities", "repository", job.Repository, "handle", job.UserHandle)
vulnJSON, vulnDigest, summary, err := scanVulnerabilities(ctx, sbomResult, wp.cfg.Vuln.DBPath)
if err != nil {
return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err)
}
result.VulnReport = vulnJSON
result.VulnDigest = vulnDigest
result.Summary = &summary
}
sbomResult = nil // release SBOM catalog for GC
duration := time.Since(startTime)
slog.Info("Scan pipeline completed",
"repository", job.Repository,
"duration", duration)
return result, nil
}
func ensureDir(path string) error {
return os.MkdirAll(path, 0755)
}