fix vuln scanner db not refreshing

This commit is contained in:
Evan Jarrett
2026-02-19 22:08:02 -06:00
parent 200d8a7bb9
commit 76383ec764
8 changed files with 126 additions and 46 deletions
+7
View File
@@ -34,6 +34,7 @@ type vulnBadgeData struct {
ScannedAt string
Found bool // true if scan record exists
Error bool // true if hold unreachable or error
ScanFailed bool // true if scan record exists but scan failed (no blobs)
Digest string // for the detail modal link
HoldEndpoint string // for the detail modal link
}
@@ -127,6 +128,10 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// A failed scan has nil blobs (no SBOM generated) and zero counts.
// Successful scans always have an SBOM blob even with 0 vulnerabilities.
scanFailed := scanRecord.SbomBlob == nil && scanRecord.Total == 0
h.renderBadge(w, vulnBadgeData{
Critical: scanRecord.Critical,
High: scanRecord.High,
@@ -135,6 +140,7 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Total: scanRecord.Total,
ScannedAt: scanRecord.ScannedAt,
Found: true,
ScanFailed: scanFailed,
Digest: digest,
HoldEndpoint: holdDID,
})
@@ -194,6 +200,7 @@ func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest strin
Total: scanRecord.Total,
ScannedAt: scanRecord.ScannedAt,
Found: true,
ScanFailed: scanRecord.SbomBlob == nil && scanRecord.Total == 0,
Digest: fullDigest,
HoldEndpoint: holdDID,
}
+7
View File
@@ -38,6 +38,13 @@ func mockScanRecord(critical, high, medium, low, total int64) string {
"total": total,
"scannerVersion": "atcr-scanner-v1.0.0",
"scannedAt": "2025-01-15T10:30:00Z",
// Successful scans always have an SBOM blob
"sbomBlob": map[string]any{
"$type": "blob",
"ref": map[string]any{"$link": "bafkreigv3xw47pk7cbeahkmttetf4smxyluwlu3jmteo2nzke2oa7dbhhm"},
"mimeType": "application/spdx+json",
"size": 1234,
},
}
envelope := map[string]any{
"uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123",
@@ -1,6 +1,8 @@
{{ define "vuln-badge" }}
{{ if .Error }}
{{/* Silently hide on error / no scan record — scan badges are non-critical */}}
{{ else if .ScanFailed }}
{{/* Scan failed (no SBOM blob) — don't show misleading "Clean" badge */}}
{{ else if eq .Total 0 }}
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
{{ else }}
+40 -20
View File
@@ -9,6 +9,7 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/hold/pds"
"github.com/go-chi/chi/v5"
)
@@ -27,17 +28,6 @@ type CrewMemberView struct {
AddedAt time.Time
}
// CrewSkeletonView is the minimal crew member data for skeleton rendering.
// Contains only data available from the MST walk (no network calls).
type CrewSkeletonView struct {
RKey string
DID string
Role string
Permissions []string
Tier string
AddedAt time.Time
}
// resolveHandle attempts to resolve a DID to a handle
// Returns empty string if resolution fails
func resolveHandle(ctx context.Context, did string) string {
@@ -61,8 +51,8 @@ type TierOption struct {
}
// handleCrewTab returns the crew tab content (HTMX partial).
// Only does the MST walk — no handle resolution or usage queries.
// Each row lazy-loads its details via handleCrewMemberInfo.
// Includes usage data (fast bulk SQL query) for correct sort order.
// Handles are lazy-loaded per-row via handleCrewMemberInfo.
func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) {
crew, err := ui.pds.ListCrewMembers(r.Context())
if err != nil {
@@ -70,35 +60,65 @@ func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) {
return
}
allQuotas, err := ui.pds.GetAllUserQuotas(r.Context())
if err != nil {
slog.Warn("Failed to get user quotas for crew tab", "error", err)
allQuotas = make(map[string]*pds.QuotaStats)
}
defaultTier := "default"
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
defaultTier = ui.quotaMgr.GetDefaultTier()
}
var skeletons []CrewSkeletonView
var crewViews []CrewMemberView
for _, member := range crew {
tier := member.Record.Tier
if tier == "" {
tier = defaultTier
}
skeletons = append(skeletons, CrewSkeletonView{
view := CrewMemberView{
RKey: member.Rkey,
DID: member.Record.Member,
Role: member.Record.Role,
Permissions: member.Record.Permissions,
Tier: tier,
AddedAt: parseTime(member.Record.AddedAt),
})
}
usage := int64(0)
if q, ok := allQuotas[member.Record.Member]; ok {
usage = q.TotalSize
}
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
if limit := ui.quotaMgr.GetTierLimit(tier); limit != nil {
view.TierLimit = formatHumanBytes(*limit)
if *limit > 0 {
view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
}
} else {
view.TierLimit = "Unlimited"
}
} else {
view.TierLimit = "Unlimited"
}
view.CurrentUsage = usage
view.UsageHuman = formatHumanBytes(usage)
crewViews = append(crewViews, view)
}
sort.Slice(skeletons, func(i, j int) bool {
return skeletons[i].AddedAt.After(skeletons[j].AddedAt)
sort.Slice(crewViews, func(i, j int) bool {
return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage
})
data := struct {
Crew []CrewSkeletonView
Crew []CrewMemberView
}{
Crew: skeletons,
Crew: crewViews,
}
ui.renderTemplate(w, "partials/tab_crew.html", data)
}
@@ -55,9 +55,14 @@
</td>
<td>
<span class="badge badge-primary badge-sm">{{.Tier}}</span>
<br><small class="text-base-content/50">{{.TierLimit}}</small>
</td>
<td class="text-base-content/30 text-sm">
<span class="loading loading-spinner loading-xs"></span>
<td>
<div class="flex flex-col gap-1 min-w-24">
<span class="text-sm">{{.UsageHuman}}</span>
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress>
<small class="text-base-content/50">{{.UsagePercent}}%</small>
</div>
</td>
<td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td>
<td></td>
+4 -3
View File
@@ -567,12 +567,13 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage)
"seq", msg.Seq, "error", err)
} else {
// Create a scan record with zero counts and nil blobs — marks it as
// "scanned" so the proactive scheduler won't retry until rescan interval
// "scanned" so the proactive scheduler won't retry until rescan interval.
// Nil blobs signal failure to the appview (successful scans always have blobs).
scanRecord := atproto.NewScanRecord(
manifestDigest, repository, userDID,
nil, nil, // no SBOM or vuln report
nil, nil, // no SBOM or vuln report — signals scan failure
0, 0, 0, 0, 0,
"failed: "+truncateError(msg.Error, 200),
"atcr-scanner-v1.0.0",
)
if _, _, err := sb.pds.CreateScanRecord(ctx, scanRecord); err != nil {
slog.Error("Failed to store failure scan record",
+42 -20
View File
@@ -32,10 +32,15 @@ import (
// Global vulnerability database (shared across workers)
var (
vulnDB vulnerability.Provider
vulnDBLock sync.RWMutex
vulnDB vulnerability.Provider
vulnDBLock sync.RWMutex
vulnDBLoaded time.Time // when the current vulnDB was loaded
)
// vulnDBRefreshAge is how long a cached DB is considered fresh.
// Set 1 day before the 5-day MaxAllowedBuiltAge so we refresh proactively.
const vulnDBRefreshAge = 4 * 24 * time.Hour
// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ([]byte, string, scanner.VulnerabilitySummary, error) {
slog.Info("Scanning for vulnerabilities with Grype")
@@ -119,10 +124,12 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) (
return reportJSON, digest, summary, nil
}
// loadVulnDatabase loads the Grype vulnerability database (with caching)
// loadVulnDatabase loads the Grype vulnerability database with caching and
// automatic refresh. The cached DB is returned if loaded less than
// vulnDBRefreshAge ago. On a stale or missing DB, it downloads a fresh copy.
func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) {
vulnDBLock.RLock()
if vulnDB != nil {
if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
vulnDBLock.RUnlock()
return vulnDB, nil
}
@@ -131,7 +138,8 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
vulnDBLock.Lock()
defer vulnDBLock.Unlock()
if vulnDB != nil {
// Double-check after acquiring write lock
if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
return vulnDB, nil
}
@@ -149,9 +157,21 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
MaxAllowedBuiltAge: 5 * 24 * time.Hour, // 5 days
}
// Try loading existing DB first (no network)
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
if err != nil {
return nil, fmt.Errorf("failed to load vulnerability database (status=%v): %w", status, err)
slog.Warn("Vulnerability database load failed, attempting update", "error", err)
// Download fresh DB
if updateErr := updateVulnDatabase(vulnDBPath); updateErr != nil {
return nil, fmt.Errorf("failed to update vulnerability database: %w (original: %w)", updateErr, err)
}
// Retry loading after update
store, status, err = grype.LoadVulnerabilityDB(distConfig, installConfig, false)
if err != nil {
return nil, fmt.Errorf("failed to load vulnerability database after update (status=%v): %w", status, err)
}
}
slog.Info("Vulnerability database loaded",
@@ -159,17 +179,14 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
"schemaVersion", status.SchemaVersion)
vulnDB = store
vulnDBLoaded = time.Now()
return vulnDB, nil
}
// initializeVulnDatabase downloads the vulnerability database on startup
// initializeVulnDatabase ensures a fresh vulnerability database exists on startup.
func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
slog.Info("Initializing vulnerability database", "path", vulnDBPath)
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
return fmt.Errorf("failed to create database directory: %w", err)
}
grpeTmpDir := filepath.Join(tmpDir, "grype-dl")
if err := os.MkdirAll(grpeTmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
@@ -185,6 +202,17 @@ func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
}
}()
return updateVulnDatabase(vulnDBPath)
}
// updateVulnDatabase downloads a fresh vulnerability database if needed.
// The curator internally checks whether an update is necessary (DB missing,
// stale, or update-check cooldown expired) so this is safe to call often.
func updateVulnDatabase(vulnDBPath string) error {
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
return fmt.Errorf("failed to create database directory: %w", err)
}
distConfig := distribution.DefaultConfig()
installConfig := installation.Config{
DBRootDir: vulnDBPath,
@@ -203,20 +231,14 @@ func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
return fmt.Errorf("failed to create database curator: %w", err)
}
status := curator.Status()
if !status.Built.IsZero() && status.Error == nil {
slog.Info("Vulnerability database already exists", "built", status.Built)
return nil
}
slog.Info("Downloading vulnerability database (this may take 5-10 minutes)...")
slog.Info("Checking vulnerability database for updates...")
updated, err := curator.Update()
if err != nil {
return fmt.Errorf("failed to download vulnerability database: %w", err)
return fmt.Errorf("failed to update vulnerability database: %w", err)
}
if updated {
slog.Info("Vulnerability database downloaded successfully")
slog.Info("Vulnerability database updated successfully")
} else {
slog.Info("Vulnerability database is up to date")
}
+17 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
@@ -83,7 +84,11 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
result, err := wp.processJob(ctx, job)
if err != nil {
slog.Error("Scan job failed",
logLevel := slog.LevelError
if strings.HasPrefix(err.Error(), "skipped:") {
logLevel = slog.LevelInfo
}
slog.Log(ctx, logLevel, "Scan job failed",
"worker_id", id,
"repository", job.Repository,
"error", err)
@@ -100,9 +105,20 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
}
}
// 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
}
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
startTime := time.Now()
// Skip non-container OCI artifacts (Helm charts, WASM modules, etc.)
if unscannableConfigTypes[job.Config.MediaType] {
return nil, fmt.Errorf("skipped: unscannable artifact type %s", job.Config.MediaType)
}
// Ensure tmp dir exists
if err := ensureDir(wp.cfg.Vuln.TmpDir); err != nil {
return nil, fmt.Errorf("failed to create tmp dir: %w", err)