mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-11 20:56:08 +00:00
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
259 lines
7.8 KiB
Go
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)
|
|
}
|