···11{{/* Search results partial - renders repository cards in a grid */}}
22{{ template "card-grid" (dict
33 "Repositories" .Repositories
44+ "Columns" 4
45 "EmptyIcon" "search-x"
56 "EmptyMessage" "No repositories found matching your search."
67 "EmptySubtext" "Try a different search term or browse the homepage."
···77 "fmt"
88 "log/slog"
99 "os"
1010- "path/filepath"
1110 "sync"
1211 "sync/atomic"
1312 "time"
···126125 return reportJSON, digest, summary, nil
127126}
128127128128+// grypeDBConfig returns the distribution and installation configs used for
129129+// all Grype DB load/update calls. Kept in one place so both the initial load
130130+// and the periodic reload see identical settings.
131131+func grypeDBConfig(vulnDBPath string) (distribution.Config, installation.Config) {
132132+ return distribution.DefaultConfig(), installation.Config{
133133+ DBRootDir: vulnDBPath,
134134+ ValidateAge: true,
135135+ ValidateChecksum: true,
136136+ MaxAllowedBuiltAge: 14 * 24 * time.Hour, // tolerates upstream publish gaps
137137+ }
138138+}
139139+129140// loadVulnDatabase loads the Grype vulnerability database with caching and
130141// automatic refresh. The cached DB is returned if loaded less than
131131-// vulnDBRefreshAge ago. On a stale or missing DB, it downloads a fresh copy.
142142+// vulnDBRefreshAge ago. On a stale or missing DB, Grype downloads a fresh copy
143143+// in the same call (update=true) — a single curator handles everything so
144144+// there is no chance of a double-curator update+load seeing different state.
132145func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) {
133146 vulnDBLock.RLock()
134147 if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
···155168 }
156169 }
157170158158- slog.Info("Loading Grype vulnerability database", "path", vulnDBPath)
171171+ slog.Info("Loading Grype vulnerability database", "path", vulnDBPath, "tmpdir", os.Getenv("TMPDIR"))
159172160160- if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
173173+ if err := os.MkdirAll(vulnDBPath, 0o755); err != nil {
161174 return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
162175 }
163176164164- distConfig := distribution.DefaultConfig()
165165- installConfig := installation.Config{
166166- DBRootDir: vulnDBPath,
167167- ValidateAge: true,
168168- ValidateChecksum: true,
169169- MaxAllowedBuiltAge: 14 * 24 * time.Hour, // 2 weeks — tolerates upstream publish gaps
170170- }
177177+ distConfig, installConfig := grypeDBConfig(vulnDBPath)
171178172172- // Try loading existing DB first (no network)
173173- store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
179179+ // update=true: a single grype curator checks the upstream feed, downloads
180180+ // if needed, activates, and then opens the reader — all in one call. If
181181+ // the upstream is unreachable but the on-disk DB is still valid, it falls
182182+ // back to serving the existing DB.
183183+ store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, true)
174184 if err != nil {
175175- slog.Warn("Vulnerability database load failed, attempting update", "error", err)
176176-177177- // Download fresh DB
178178- if updateErr := updateVulnDatabase(vulnDBPath); updateErr != nil {
179179- return nil, fmt.Errorf("failed to update vulnerability database: %w (original: %w)", updateErr, err)
180180- }
181181-182182- // Retry loading after update
183183- store, status, err = grype.LoadVulnerabilityDB(distConfig, installConfig, false)
184184- if err != nil {
185185- return nil, fmt.Errorf("failed to load vulnerability database after update (status=%v): %w", status, err)
186186- }
185185+ return nil, fmt.Errorf("failed to load vulnerability database: %w", err)
187186 }
188187188188+ age := "unknown"
189189+ if !status.Built.IsZero() {
190190+ age = time.Since(status.Built).Round(time.Minute).String()
191191+ }
189192 slog.Info("Vulnerability database loaded",
190193 "built", status.Built,
191191- "schemaVersion", status.SchemaVersion)
194194+ "age", age,
195195+ "schemaVersion", status.SchemaVersion,
196196+ "path", status.Path)
192197193198 if vulnDB != nil {
194199 vulnDB.Close()
···198203 return vulnDB, nil
199204}
200205201201-// initializeVulnDatabase ensures a fresh vulnerability database exists on startup.
202202-func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
206206+// initializeVulnDatabase primes the in-memory DB cache on startup so the first
207207+// scan doesn't pay the download cost. Caller is responsible for TMPDIR being
208208+// set to a path on the same filesystem as vulnDBPath — see WorkerPool.Start.
209209+func initializeVulnDatabase(vulnDBPath string) error {
203210 slog.Info("Initializing vulnerability database", "path", vulnDBPath)
204204-205205- grpeTmpDir := filepath.Join(tmpDir, "grype-dl")
206206- if err := os.MkdirAll(grpeTmpDir, 0755); err != nil {
207207- return fmt.Errorf("failed to create temp directory: %w", err)
208208- }
209209-210210- oldTmpDir := os.Getenv("TMPDIR")
211211- os.Setenv("TMPDIR", grpeTmpDir)
212212- defer func() {
213213- if oldTmpDir != "" {
214214- os.Setenv("TMPDIR", oldTmpDir)
215215- } else {
216216- os.Unsetenv("TMPDIR")
217217- }
218218- }()
219219-220220- return updateVulnDatabase(vulnDBPath)
221221-}
222222-223223-// updateVulnDatabase downloads a fresh vulnerability database if needed.
224224-// The curator internally checks whether an update is necessary (DB missing,
225225-// stale, or update-check cooldown expired) so this is safe to call often.
226226-func updateVulnDatabase(vulnDBPath string) error {
227227- if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
228228- return fmt.Errorf("failed to create database directory: %w", err)
229229- }
230230-231231- distConfig := distribution.DefaultConfig()
232232- installConfig := installation.Config{
233233- DBRootDir: vulnDBPath,
234234- ValidateAge: true,
235235- ValidateChecksum: true,
236236- MaxAllowedBuiltAge: 14 * 24 * time.Hour,
237237- }
238238-239239- downloader, err := distribution.NewClient(distConfig)
240240- if err != nil {
241241- return fmt.Errorf("failed to create database downloader: %w", err)
242242- }
243243-244244- curator, err := installation.NewCurator(installConfig, downloader)
245245- if err != nil {
246246- return fmt.Errorf("failed to create database curator: %w", err)
247247- }
248248-249249- slog.Info("Checking vulnerability database for updates...")
250250- updated, err := curator.Update()
251251- if err != nil {
252252- return fmt.Errorf("failed to update vulnerability database: %w", err)
253253- }
254254-255255- if updated {
256256- slog.Info("Vulnerability database updated successfully")
257257- } else {
258258- slog.Info("Vulnerability database is up to date")
259259- }
260260-261261- return nil
211211+ _, err := loadVulnDatabase(context.Background(), vulnDBPath)
212212+ return err
262213}
263214264215func countVulnerabilitiesBySeverity(matches match.Matches) scanner.VulnerabilitySummary {
+14-5
scanner/internal/scan/worker.go
···37373838// Start launches worker goroutines
3939func (wp *WorkerPool) Start(ctx context.Context) {
4040+ // Point TMPDIR at the configured tmp dir so Grype's DB download
4141+ // (go-getter zstd decompression can be 1 GB+) and stereoscope's layer
4242+ // extraction both land on the same partition as the scanner volume —
4343+ // NOT on /tmp, which is typically tmpfs with ~400 MB and would silently
4444+ // fail mid-extract. This must be set before any scanner/grype goroutine
4545+ // starts and must never be restored to a smaller default mid-process.
4646+ if wp.cfg.Vuln.TmpDir != "" {
4747+ if err := os.MkdirAll(wp.cfg.Vuln.TmpDir, 0o755); err != nil {
4848+ slog.Warn("Failed to create scanner tmp dir", "path", wp.cfg.Vuln.TmpDir, "error", err)
4949+ }
5050+ os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir)
5151+ }
5252+4053 // Initialize vuln database on startup if enabled
4154 if wp.cfg.Vuln.Enabled {
4255 go func() {
4343- if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath, wp.cfg.Vuln.TmpDir); err != nil {
5656+ if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath); err != nil {
4457 slog.Error("Failed to initialize vulnerability database", "error", err)
4558 slog.Warn("Vulnerability scanning will be disabled until database is available")
4659 }
4760 }()
4861 }
4949-5050- // Point TMPDIR at the configured tmp dir so stereoscope's internal
5151- // layer extraction uses the same partition (not /tmp which may be small)
5252- os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir)
53625463 for i := 0; i < wp.cfg.Scanner.Workers; i++ {
5564 wp.wg.Add(1)