···11+package scanner
22+33+import (
44+ "archive/tar"
55+ "compress/gzip"
66+ "context"
77+ "encoding/json"
88+ "fmt"
99+ "io"
1010+ "log/slog"
1111+ "os"
1212+ "path/filepath"
1313+ "strings"
1414+)
1515+1616+// extractLayers extracts all image layers from storage to a temporary directory
1717+// Returns the directory path and a cleanup function
1818+func (w *Worker) extractLayers(ctx context.Context, job *ScanJob) (string, func(), error) {
1919+ // Create temp directory for extraction
2020+ // Use the database directory as the base (since we're in a scratch container with no /tmp)
2121+ scanTmpBase := filepath.Join(w.config.Database.Path, "scanner-tmp")
2222+ if err := os.MkdirAll(scanTmpBase, 0755); err != nil {
2323+ return "", nil, fmt.Errorf("failed to create scanner temp base: %w", err)
2424+ }
2525+2626+ tmpDir, err := os.MkdirTemp(scanTmpBase, "scan-*")
2727+ if err != nil {
2828+ return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
2929+ }
3030+3131+ cleanup := func() {
3232+ if err := os.RemoveAll(tmpDir); err != nil {
3333+ slog.Warn("Failed to clean up temp directory", "dir", tmpDir, "error", err)
3434+ }
3535+ }
3636+3737+ // Create image directory structure
3838+ imageDir := filepath.Join(tmpDir, "image")
3939+ if err := os.MkdirAll(imageDir, 0755); err != nil {
4040+ cleanup()
4141+ return "", nil, fmt.Errorf("failed to create image directory: %w", err)
4242+ }
4343+4444+ // Download and extract config blob
4545+ slog.Info("Downloading config blob", "digest", job.Config.Digest)
4646+ configPath := filepath.Join(imageDir, "config.json")
4747+ if err := w.downloadBlob(ctx, job.Config.Digest, configPath); err != nil {
4848+ cleanup()
4949+ return "", nil, fmt.Errorf("failed to download config blob: %w", err)
5050+ }
5151+5252+ // Validate config is valid JSON
5353+ configData, err := os.ReadFile(configPath)
5454+ if err != nil {
5555+ cleanup()
5656+ return "", nil, fmt.Errorf("failed to read config: %w", err)
5757+ }
5858+ var configObj map[string]interface{}
5959+ if err := json.Unmarshal(configData, &configObj); err != nil {
6060+ cleanup()
6161+ return "", nil, fmt.Errorf("invalid config JSON: %w", err)
6262+ }
6363+6464+ // Create layers directory for extracted content
6565+ layersDir := filepath.Join(imageDir, "layers")
6666+ if err := os.MkdirAll(layersDir, 0755); err != nil {
6767+ cleanup()
6868+ return "", nil, fmt.Errorf("failed to create layers directory: %w", err)
6969+ }
7070+7171+ // Download and extract each layer in order (creating overlayfs-style filesystem)
7272+ rootfsDir := filepath.Join(imageDir, "rootfs")
7373+ if err := os.MkdirAll(rootfsDir, 0755); err != nil {
7474+ cleanup()
7575+ return "", nil, fmt.Errorf("failed to create rootfs directory: %w", err)
7676+ }
7777+7878+ for i, layer := range job.Layers {
7979+ slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
8080+8181+ // Download layer blob to temp file
8282+ layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
8383+ if err := w.downloadBlob(ctx, layer.Digest, layerPath); err != nil {
8484+ cleanup()
8585+ return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
8686+ }
8787+8888+ // Extract layer on top of rootfs (overlayfs style)
8989+ if err := w.extractTarGz(layerPath, rootfsDir); err != nil {
9090+ cleanup()
9191+ return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err)
9292+ }
9393+9494+ // Remove layer tar.gz to save space
9595+ os.Remove(layerPath)
9696+ }
9797+9898+ // Check what was extracted
9999+ entries, err := os.ReadDir(rootfsDir)
100100+ if err != nil {
101101+ slog.Warn("Failed to read rootfs directory", "error", err)
102102+ } else {
103103+ slog.Info("Successfully extracted image",
104104+ "layers", len(job.Layers),
105105+ "rootfs", rootfsDir,
106106+ "topLevelEntries", len(entries),
107107+ "sampleEntries", func() []string {
108108+ var samples []string
109109+ for i, e := range entries {
110110+ if i >= 10 {
111111+ break
112112+ }
113113+ samples = append(samples, e.Name())
114114+ }
115115+ return samples
116116+ }())
117117+ }
118118+119119+ return rootfsDir, cleanup, nil
120120+}
121121+122122+// downloadBlob downloads a blob from storage to a local file
123123+func (w *Worker) downloadBlob(ctx context.Context, digest, destPath string) error {
124124+ // Convert digest to storage path using distribution's sharding scheme
125125+ // Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
126126+ // where 47 is the first 2 characters of the hash for directory sharding
127127+ blobPath := blobPathForDigest(digest)
128128+129129+ // Open blob from storage driver
130130+ reader, err := w.driver.Reader(ctx, blobPath, 0)
131131+ if err != nil {
132132+ return fmt.Errorf("failed to open blob %s: %w", digest, err)
133133+ }
134134+ defer reader.Close()
135135+136136+ // Create destination file
137137+ dest, err := os.Create(destPath)
138138+ if err != nil {
139139+ return fmt.Errorf("failed to create destination file: %w", err)
140140+ }
141141+ defer dest.Close()
142142+143143+ // Copy blob data to file
144144+ if _, err := io.Copy(dest, reader); err != nil {
145145+ return fmt.Errorf("failed to copy blob data: %w", err)
146146+ }
147147+148148+ return nil
149149+}
150150+151151+// extractTarGz extracts a tar.gz file to a destination directory (overlayfs style)
152152+func (w *Worker) extractTarGz(tarGzPath, destDir string) error {
153153+ // Open tar.gz file
154154+ file, err := os.Open(tarGzPath)
155155+ if err != nil {
156156+ return fmt.Errorf("failed to open tar.gz: %w", err)
157157+ }
158158+ defer file.Close()
159159+160160+ // Create gzip reader
161161+ gzr, err := gzip.NewReader(file)
162162+ if err != nil {
163163+ return fmt.Errorf("failed to create gzip reader: %w", err)
164164+ }
165165+ defer gzr.Close()
166166+167167+ // Create tar reader
168168+ tr := tar.NewReader(gzr)
169169+170170+ // Extract each file
171171+ for {
172172+ header, err := tr.Next()
173173+ if err == io.EOF {
174174+ break
175175+ }
176176+ if err != nil {
177177+ return fmt.Errorf("failed to read tar header: %w", err)
178178+ }
179179+180180+ // Build target path (clean to prevent path traversal)
181181+ target := filepath.Join(destDir, filepath.Clean(header.Name))
182182+183183+ // Ensure target is within destDir (security check)
184184+ if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
185185+ slog.Warn("Skipping path outside destination", "path", header.Name)
186186+ continue
187187+ }
188188+189189+ switch header.Typeflag {
190190+ case tar.TypeDir:
191191+ // Create directory
192192+ if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
193193+ return fmt.Errorf("failed to create directory %s: %w", target, err)
194194+ }
195195+196196+ case tar.TypeReg:
197197+ // Create parent directory
198198+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
199199+ return fmt.Errorf("failed to create parent directory: %w", err)
200200+ }
201201+202202+ // Create file
203203+ outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
204204+ if err != nil {
205205+ return fmt.Errorf("failed to create file %s: %w", target, err)
206206+ }
207207+208208+ // Copy file contents
209209+ if _, err := io.Copy(outFile, tr); err != nil {
210210+ outFile.Close()
211211+ return fmt.Errorf("failed to write file %s: %w", target, err)
212212+ }
213213+ outFile.Close()
214214+215215+ case tar.TypeSymlink:
216216+ // Create symlink
217217+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
218218+ return fmt.Errorf("failed to create parent directory for symlink: %w", err)
219219+ }
220220+221221+ // Remove existing file/symlink if it exists
222222+ os.Remove(target)
223223+224224+ if err := os.Symlink(header.Linkname, target); err != nil {
225225+ slog.Warn("Failed to create symlink", "target", target, "link", header.Linkname, "error", err)
226226+ }
227227+228228+ case tar.TypeLink:
229229+ // Create hard link
230230+ linkTarget := filepath.Join(destDir, filepath.Clean(header.Linkname))
231231+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
232232+ return fmt.Errorf("failed to create parent directory for hardlink: %w", err)
233233+ }
234234+235235+ // Remove existing file if it exists
236236+ os.Remove(target)
237237+238238+ if err := os.Link(linkTarget, target); err != nil {
239239+ slog.Warn("Failed to create hardlink", "target", target, "link", linkTarget, "error", err)
240240+ }
241241+242242+ default:
243243+ slog.Debug("Skipping unsupported tar entry type", "type", header.Typeflag, "name", header.Name)
244244+ }
245245+ }
246246+247247+ return nil
248248+}
249249+250250+// blobPathForDigest converts a digest to a storage path using distribution's sharding scheme
251251+// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
252252+// where 47 is the first 2 characters of the hash for directory sharding
253253+func blobPathForDigest(digest string) string {
254254+ // Split digest into algorithm and hash
255255+ parts := strings.SplitN(digest, ":", 2)
256256+ if len(parts) != 2 {
257257+ // Fallback for malformed digest
258258+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
259259+ }
260260+261261+ algorithm := parts[0]
262262+ hash := parts[1]
263263+264264+ // Use first 2 characters for sharding
265265+ if len(hash) < 2 {
266266+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
267267+ }
268268+269269+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
270270+}
+351
pkg/hold/scanner/grype.go
···11+package scanner
22+33+import (
44+ "context"
55+ "crypto/sha256"
66+ "encoding/json"
77+ "fmt"
88+ "log/slog"
99+ "os"
1010+ "path/filepath"
1111+ "sync"
1212+1313+ "github.com/anchore/grype/grype"
1414+ "github.com/anchore/grype/grype/db/v6/distribution"
1515+ "github.com/anchore/grype/grype/db/v6/installation"
1616+ "github.com/anchore/grype/grype/distro"
1717+ "github.com/anchore/grype/grype/match"
1818+ "github.com/anchore/grype/grype/matcher"
1919+ "github.com/anchore/grype/grype/matcher/dotnet"
2020+ "github.com/anchore/grype/grype/matcher/golang"
2121+ "github.com/anchore/grype/grype/matcher/java"
2222+ "github.com/anchore/grype/grype/matcher/javascript"
2323+ "github.com/anchore/grype/grype/matcher/python"
2424+ "github.com/anchore/grype/grype/matcher/ruby"
2525+ "github.com/anchore/grype/grype/matcher/stock"
2626+ grypePkg "github.com/anchore/grype/grype/pkg"
2727+ "github.com/anchore/grype/grype/vulnerability"
2828+ "github.com/anchore/syft/syft/sbom"
2929+)
3030+3131+// Global vulnerability database (shared across workers)
3232+var (
3333+ vulnDB vulnerability.Provider
3434+ vulnDBLock sync.RWMutex
3535+)
3636+3737+// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
3838+// Returns vulnerability report JSON, digest, summary, and any error
3939+func (w *Worker) scanVulnerabilities(ctx context.Context, s *sbom.SBOM) ([]byte, string, VulnerabilitySummary, error) {
4040+ slog.Info("Scanning for vulnerabilities with Grype")
4141+4242+ // Load vulnerability database (cached globally)
4343+ store, err := w.loadVulnDatabase(ctx)
4444+ if err != nil {
4545+ return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to load vulnerability database: %w", err)
4646+ }
4747+4848+ // Create package context from SBOM (need distro for synthesis)
4949+ var grypeDistro *distro.Distro
5050+ if s.Artifacts.LinuxDistribution != nil {
5151+ grypeDistro = distro.FromRelease(s.Artifacts.LinuxDistribution, nil)
5252+ if grypeDistro != nil {
5353+ slog.Info("Using distro for package synthesis",
5454+ "name", grypeDistro.Name(),
5555+ "version", grypeDistro.Version,
5656+ "type", grypeDistro.Type,
5757+ "codename", grypeDistro.Codename)
5858+ }
5959+ }
6060+6161+ // Convert Syft packages to Grype packages WITH distro info
6262+ synthesisConfig := grypePkg.SynthesisConfig{
6363+ GenerateMissingCPEs: true,
6464+ Distro: grypePkg.DistroConfig{
6565+ Override: grypeDistro,
6666+ },
6767+ }
6868+ grypePackages := grypePkg.FromCollection(s.Artifacts.Packages, synthesisConfig)
6969+7070+ slog.Info("Converted packages for vulnerability scanning",
7171+ "syftPackages", s.Artifacts.Packages.PackageCount(),
7272+ "grypePackages", len(grypePackages),
7373+ "distro", func() string {
7474+ if s.Artifacts.LinuxDistribution != nil {
7575+ return fmt.Sprintf("%s %s", s.Artifacts.LinuxDistribution.Name, s.Artifacts.LinuxDistribution.Version)
7676+ }
7777+ return "none"
7878+ }())
7979+8080+ // Create matchers
8181+ matchers := matcher.NewDefaultMatchers(matcher.Config{
8282+ Java: java.MatcherConfig{},
8383+ Ruby: ruby.MatcherConfig{},
8484+ Python: python.MatcherConfig{},
8585+ Dotnet: dotnet.MatcherConfig{},
8686+ Javascript: javascript.MatcherConfig{},
8787+ Golang: golang.MatcherConfig{},
8888+ Stock: stock.MatcherConfig{},
8989+ })
9090+9191+ // Create package context with the same distro we used for synthesis
9292+ pkgContext := grypePkg.Context{
9393+ Source: &s.Source,
9494+ Distro: grypeDistro,
9595+ }
9696+9797+ // Create vulnerability matcher
9898+ vulnerabilityMatcher := &grype.VulnerabilityMatcher{
9999+ VulnerabilityProvider: store,
100100+ Matchers: matchers,
101101+ NormalizeByCVE: true,
102102+ }
103103+104104+ // Find vulnerabilities
105105+ slog.Info("Matching vulnerabilities",
106106+ "packages", len(grypePackages),
107107+ "distro", func() string {
108108+ if grypeDistro != nil {
109109+ return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
110110+ }
111111+ return "none"
112112+ }())
113113+ allMatches, _, err := vulnerabilityMatcher.FindMatches(grypePackages, pkgContext)
114114+ if err != nil {
115115+ return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to find vulnerabilities: %w", err)
116116+ }
117117+118118+ slog.Info("Vulnerability matching complete",
119119+ "totalMatches", allMatches.Count())
120120+121121+ // If we found 0 matches, log some diagnostic info
122122+ if allMatches.Count() == 0 {
123123+ slog.Warn("No vulnerability matches found - this may indicate an issue",
124124+ "distro", func() string {
125125+ if grypeDistro != nil {
126126+ return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
127127+ }
128128+ return "none"
129129+ }(),
130130+ "packages", len(grypePackages),
131131+ "databaseBuilt", func() string {
132132+ vulnDBLock.RLock()
133133+ defer vulnDBLock.RUnlock()
134134+ if vulnDB == nil {
135135+ return "not loaded"
136136+ }
137137+ // We can't easily get the build date here without exposing internal state
138138+ return "loaded"
139139+ }())
140140+ }
141141+142142+ // Count vulnerabilities by severity
143143+ summary := w.countVulnerabilitiesBySeverity(*allMatches)
144144+145145+ slog.Info("Vulnerability scan complete",
146146+ "critical", summary.Critical,
147147+ "high", summary.High,
148148+ "medium", summary.Medium,
149149+ "low", summary.Low,
150150+ "total", summary.Total)
151151+152152+ // Create vulnerability report JSON
153153+ report := map[string]interface{}{
154154+ "matches": allMatches.Sorted(),
155155+ "source": s.Source,
156156+ "distro": s.Artifacts.LinuxDistribution,
157157+ "descriptor": map[string]interface{}{
158158+ "name": "grype",
159159+ "version": "v0.102.0", // TODO: Get actual Grype version
160160+ },
161161+ "summary": summary,
162162+ }
163163+164164+ // Encode report to JSON
165165+ reportJSON, err := json.MarshalIndent(report, "", " ")
166166+ if err != nil {
167167+ return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to encode vulnerability report: %w", err)
168168+ }
169169+170170+ // Calculate digest
171171+ hash := sha256.Sum256(reportJSON)
172172+ digest := fmt.Sprintf("sha256:%x", hash)
173173+174174+ slog.Info("Vulnerability report generated", "size", len(reportJSON), "digest", digest)
175175+176176+ // Upload report blob to storage
177177+ if err := w.uploadBlob(ctx, digest, reportJSON); err != nil {
178178+ return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to upload vulnerability report: %w", err)
179179+ }
180180+181181+ return reportJSON, digest, summary, nil
182182+}
183183+184184+// loadVulnDatabase loads the Grype vulnerability database (with caching)
185185+func (w *Worker) loadVulnDatabase(ctx context.Context) (vulnerability.Provider, error) {
186186+ // Check if database is already loaded
187187+ vulnDBLock.RLock()
188188+ if vulnDB != nil {
189189+ vulnDBLock.RUnlock()
190190+ return vulnDB, nil
191191+ }
192192+ vulnDBLock.RUnlock()
193193+194194+ // Acquire write lock to load database
195195+ vulnDBLock.Lock()
196196+ defer vulnDBLock.Unlock()
197197+198198+ // Check again (another goroutine might have loaded it)
199199+ if vulnDB != nil {
200200+ return vulnDB, nil
201201+ }
202202+203203+ slog.Info("Loading Grype vulnerability database", "path", w.config.Scanner.VulnDBPath)
204204+205205+ // Ensure database directory exists
206206+ if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
207207+ return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
208208+ }
209209+210210+ // Configure database distribution
211211+ distConfig := distribution.DefaultConfig()
212212+213213+ // Configure database installation
214214+ installConfig := installation.Config{
215215+ DBRootDir: w.config.Scanner.VulnDBPath,
216216+ ValidateAge: true,
217217+ ValidateChecksum: true,
218218+ MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
219219+ }
220220+221221+ // Load database (should already be downloaded by initializeVulnDatabase)
222222+ store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
223223+ if err != nil {
224224+ return nil, fmt.Errorf("failed to load vulnerability database (status=%v): %w (hint: database may still be downloading)", status, err)
225225+ }
226226+227227+ slog.Info("Vulnerability database loaded",
228228+ "status", status,
229229+ "built", status.Built,
230230+ "location", status.Path,
231231+ "schemaVersion", status.SchemaVersion)
232232+233233+ // Check database file size to verify it has content
234234+ if stat, err := os.Stat(status.Path); err == nil {
235235+ slog.Info("Vulnerability database file stats",
236236+ "size", stat.Size(),
237237+ "sizeMB", stat.Size()/1024/1024)
238238+ }
239239+240240+ // Cache database globally
241241+ vulnDB = store
242242+243243+ slog.Info("Vulnerability database loaded successfully")
244244+ return vulnDB, nil
245245+}
246246+247247+// countVulnerabilitiesBySeverity counts vulnerabilities by severity level
248248+func (w *Worker) countVulnerabilitiesBySeverity(matches match.Matches) VulnerabilitySummary {
249249+ summary := VulnerabilitySummary{}
250250+251251+ for m := range matches.Enumerate() {
252252+ summary.Total++
253253+254254+ // Get severity from vulnerability metadata
255255+ if m.Vulnerability.Metadata != nil {
256256+ severity := m.Vulnerability.Metadata.Severity
257257+ switch severity {
258258+ case "Critical":
259259+ summary.Critical++
260260+ case "High":
261261+ summary.High++
262262+ case "Medium":
263263+ summary.Medium++
264264+ case "Low":
265265+ summary.Low++
266266+ }
267267+ }
268268+ }
269269+270270+ return summary
271271+}
272272+273273+// initializeVulnDatabase downloads and initializes the vulnerability database on startup
274274+func (w *Worker) initializeVulnDatabase(ctx context.Context) error {
275275+ slog.Info("Initializing vulnerability database", "path", w.config.Scanner.VulnDBPath)
276276+277277+ // Ensure database directory exists
278278+ if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
279279+ return fmt.Errorf("failed to create vulnerability database directory: %w", err)
280280+ }
281281+282282+ // Create temp directory for Grype downloads (scratch container has no /tmp)
283283+ tmpDir := filepath.Join(w.config.Database.Path, "tmp")
284284+ if err := ensureDir(tmpDir); err != nil {
285285+ return fmt.Errorf("failed to create temp directory: %w", err)
286286+ }
287287+288288+ // Set TMPDIR environment variable so Grype uses our temp directory
289289+ oldTmpDir := os.Getenv("TMPDIR")
290290+ os.Setenv("TMPDIR", tmpDir)
291291+ defer func() {
292292+ if oldTmpDir != "" {
293293+ os.Setenv("TMPDIR", oldTmpDir)
294294+ } else {
295295+ os.Unsetenv("TMPDIR")
296296+ }
297297+ }()
298298+299299+ // Configure database distribution
300300+ distConfig := distribution.DefaultConfig()
301301+302302+ // Configure database installation
303303+ installConfig := installation.Config{
304304+ DBRootDir: w.config.Scanner.VulnDBPath,
305305+ ValidateAge: true,
306306+ ValidateChecksum: true,
307307+ MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
308308+ }
309309+310310+ // Create distribution client for downloading
311311+ downloader, err := distribution.NewClient(distConfig)
312312+ if err != nil {
313313+ return fmt.Errorf("failed to create database downloader: %w", err)
314314+ }
315315+316316+ // Create curator to manage database
317317+ curator, err := installation.NewCurator(installConfig, downloader)
318318+ if err != nil {
319319+ return fmt.Errorf("failed to create database curator: %w", err)
320320+ }
321321+322322+ // Check if database already exists
323323+ status := curator.Status()
324324+ if !status.Built.IsZero() && status.Error == nil {
325325+ slog.Info("Vulnerability database already exists", "built", status.Built, "schema", status.SchemaVersion)
326326+ return nil
327327+ }
328328+329329+ // Download database (this may take several minutes)
330330+ slog.Info("Downloading vulnerability database (this may take 5-10 minutes)...")
331331+ updated, err := curator.Update()
332332+ if err != nil {
333333+ return fmt.Errorf("failed to download vulnerability database: %w", err)
334334+ }
335335+336336+ if updated {
337337+ slog.Info("Vulnerability database downloaded successfully")
338338+ } else {
339339+ slog.Info("Vulnerability database is up to date")
340340+ }
341341+342342+ return nil
343343+}
344344+345345+// ensureDir creates a directory if it doesn't exist
346346+func ensureDir(path string) error {
347347+ if err := os.MkdirAll(path, 0755); err != nil {
348348+ return fmt.Errorf("failed to create directory %s: %w", path, err)
349349+ }
350350+ return nil
351351+}
+67
pkg/hold/scanner/job.go
···11+package scanner
22+33+import (
44+ "time"
55+66+ "atcr.io/pkg/atproto"
77+)
88+99+// ScanJob represents a vulnerability scanning job for a container image
1010+type ScanJob struct {
1111+ // ManifestDigest is the digest of the manifest to scan
1212+ ManifestDigest string
1313+1414+ // Repository is the repository name (e.g., "alice/myapp")
1515+ Repository string
1616+1717+ // Tag is the tag name (e.g., "latest")
1818+ Tag string
1919+2020+ // UserDID is the DID of the user who owns this image
2121+ UserDID string
2222+2323+ // UserHandle is the handle of the user (for display)
2424+ UserHandle string
2525+2626+ // Config is the image config blob descriptor
2727+ Config atproto.BlobReference
2828+2929+ // Layers are the image layer blob descriptors (in order)
3030+ Layers []atproto.BlobReference
3131+3232+ // EnqueuedAt is when this job was enqueued
3333+ EnqueuedAt time.Time
3434+}
3535+3636+// ScanResult represents the result of a vulnerability scan
3737+type ScanResult struct {
3838+ // Job is the original scan job
3939+ Job *ScanJob
4040+4141+ // VulnerabilitiesJSON is the raw Grype JSON output
4242+ VulnerabilitiesJSON []byte
4343+4444+ // Summary contains vulnerability counts by severity
4545+ Summary VulnerabilitySummary
4646+4747+ // SBOMDigest is the digest of the SBOM blob (if SBOM was generated)
4848+ SBOMDigest string
4949+5050+ // VulnDigest is the digest of the vulnerability report blob
5151+ VulnDigest string
5252+5353+ // ScannedAt is when the scan completed
5454+ ScannedAt time.Time
5555+5656+ // ScannerVersion is the version of the scanner used
5757+ ScannerVersion string
5858+}
5959+6060+// VulnerabilitySummary contains counts of vulnerabilities by severity
6161+type VulnerabilitySummary struct {
6262+ Critical int `json:"critical"`
6363+ High int `json:"high"`
6464+ Medium int `json:"medium"`
6565+ Low int `json:"low"`
6666+ Total int `json:"total"`
6767+}
+226
pkg/hold/scanner/queue.go
···11+package scanner
22+33+import (
44+ "context"
55+ "fmt"
66+ "log/slog"
77+ "sync"
88+99+ "atcr.io/pkg/atproto"
1010+)
1111+1212+// Queue manages a pool of workers for scanning container images
1313+type Queue struct {
1414+ jobs chan *ScanJob
1515+ results chan *ScanResult
1616+ workers int
1717+ wg sync.WaitGroup
1818+ ctx context.Context
1919+ cancel context.CancelFunc
2020+}
2121+2222+// NewQueue creates a new scanner queue with the specified number of workers
2323+func NewQueue(workers int, bufferSize int) *Queue {
2424+ ctx, cancel := context.WithCancel(context.Background())
2525+2626+ return &Queue{
2727+ jobs: make(chan *ScanJob, bufferSize),
2828+ results: make(chan *ScanResult, bufferSize),
2929+ workers: workers,
3030+ ctx: ctx,
3131+ cancel: cancel,
3232+ }
3333+}
3434+3535+// Start starts the worker pool
3636+// The workerFunc is called for each job to perform the actual scanning
3737+func (q *Queue) Start(workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
3838+ slog.Info("Starting scanner worker pool", "workers", q.workers)
3939+4040+ for i := 0; i < q.workers; i++ {
4141+ q.wg.Add(1)
4242+ go q.worker(i, workerFunc)
4343+ }
4444+4545+ // Start result handler goroutine
4646+ q.wg.Add(1)
4747+ go q.resultHandler()
4848+}
4949+5050+// worker processes jobs from the queue
5151+func (q *Queue) worker(id int, workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
5252+ defer q.wg.Done()
5353+5454+ slog.Info("Scanner worker started", "worker_id", id)
5555+5656+ for {
5757+ select {
5858+ case <-q.ctx.Done():
5959+ slog.Info("Scanner worker shutting down", "worker_id", id)
6060+ return
6161+6262+ case job, ok := <-q.jobs:
6363+ if !ok {
6464+ slog.Info("Scanner worker: jobs channel closed", "worker_id", id)
6565+ return
6666+ }
6767+6868+ slog.Info("Scanner worker processing job",
6969+ "worker_id", id,
7070+ "repository", job.Repository,
7171+ "tag", job.Tag,
7272+ "digest", job.ManifestDigest)
7373+7474+ result, err := workerFunc(q.ctx, job)
7575+ if err != nil {
7676+ slog.Error("Scanner worker failed to process job",
7777+ "worker_id", id,
7878+ "repository", job.Repository,
7979+ "tag", job.Tag,
8080+ "error", err)
8181+ continue
8282+ }
8383+8484+ // Send result to results channel
8585+ select {
8686+ case q.results <- result:
8787+ slog.Info("Scanner worker completed job",
8888+ "worker_id", id,
8989+ "repository", job.Repository,
9090+ "tag", job.Tag,
9191+ "vulnerabilities", result.Summary.Total)
9292+ case <-q.ctx.Done():
9393+ return
9494+ }
9595+ }
9696+ }
9797+}
9898+9999+// resultHandler processes scan results (for logging and metrics)
100100+func (q *Queue) resultHandler() {
101101+ defer q.wg.Done()
102102+103103+ for {
104104+ select {
105105+ case <-q.ctx.Done():
106106+ return
107107+108108+ case result, ok := <-q.results:
109109+ if !ok {
110110+ return
111111+ }
112112+113113+ // Log the result
114114+ slog.Info("Scan completed",
115115+ "repository", result.Job.Repository,
116116+ "tag", result.Job.Tag,
117117+ "digest", result.Job.ManifestDigest,
118118+ "critical", result.Summary.Critical,
119119+ "high", result.Summary.High,
120120+ "medium", result.Summary.Medium,
121121+ "low", result.Summary.Low,
122122+ "total", result.Summary.Total,
123123+ "scanner", result.ScannerVersion)
124124+ }
125125+ }
126126+}
127127+128128+// Enqueue adds a job to the queue
129129+func (q *Queue) Enqueue(jobAny any) error {
130130+ // Type assert to ScanJob (can be map or struct from HandleNotifyManifest)
131131+ var job *ScanJob
132132+133133+ switch v := jobAny.(type) {
134134+ case *ScanJob:
135135+ job = v
136136+ case map[string]interface{}:
137137+ // Convert map to ScanJob (from HandleNotifyManifest)
138138+ job = &ScanJob{
139139+ ManifestDigest: v["manifestDigest"].(string),
140140+ Repository: v["repository"].(string),
141141+ Tag: v["tag"].(string),
142142+ UserDID: v["userDID"].(string),
143143+ UserHandle: v["userHandle"].(string),
144144+ }
145145+146146+ // Parse config blob reference
147147+ if configMap, ok := v["config"].(map[string]interface{}); ok {
148148+ job.Config = atproto.BlobReference{
149149+ Digest: configMap["digest"].(string),
150150+ Size: convertToInt64(configMap["size"]),
151151+ MediaType: configMap["mediaType"].(string),
152152+ }
153153+ }
154154+155155+ // Parse layers
156156+ if layersSlice, ok := v["layers"].([]interface{}); ok {
157157+ slog.Info("Parsing layers from scan job",
158158+ "layersFound", len(layersSlice))
159159+ job.Layers = make([]atproto.BlobReference, len(layersSlice))
160160+ for i, layerAny := range layersSlice {
161161+ if layerMap, ok := layerAny.(map[string]interface{}); ok {
162162+ job.Layers[i] = atproto.BlobReference{
163163+ Digest: layerMap["digest"].(string),
164164+ Size: convertToInt64(layerMap["size"]),
165165+ MediaType: layerMap["mediaType"].(string),
166166+ }
167167+ }
168168+ }
169169+ } else {
170170+ slog.Warn("No layers found in scan job map",
171171+ "layersType", fmt.Sprintf("%T", v["layers"]),
172172+ "layersValue", v["layers"])
173173+ }
174174+ default:
175175+ return fmt.Errorf("invalid job type: %T", jobAny)
176176+ }
177177+178178+ select {
179179+ case q.jobs <- job:
180180+ slog.Info("Enqueued scan job",
181181+ "repository", job.Repository,
182182+ "tag", job.Tag,
183183+ "digest", job.ManifestDigest)
184184+ return nil
185185+ case <-q.ctx.Done():
186186+ return q.ctx.Err()
187187+ }
188188+}
189189+190190+// Shutdown gracefully shuts down the queue, waiting for all workers to finish
191191+func (q *Queue) Shutdown() {
192192+ slog.Info("Shutting down scanner queue")
193193+194194+ // Close the jobs channel to signal no more jobs
195195+ close(q.jobs)
196196+197197+ // Wait for all workers to finish
198198+ q.wg.Wait()
199199+200200+ // Close results channel
201201+ close(q.results)
202202+203203+ // Cancel context
204204+ q.cancel()
205205+206206+ slog.Info("Scanner queue shut down complete")
207207+}
208208+209209+// Len returns the number of jobs currently in the queue
210210+func (q *Queue) Len() int {
211211+ return len(q.jobs)
212212+}
213213+214214+// convertToInt64 converts an interface{} number to int64, handling both float64 and int64
215215+func convertToInt64(v interface{}) int64 {
216216+ switch n := v.(type) {
217217+ case float64:
218218+ return int64(n)
219219+ case int64:
220220+ return n
221221+ case int:
222222+ return int64(n)
223223+ default:
224224+ return 0
225225+ }
226226+}
+123
pkg/hold/scanner/storage.go
···11+package scanner
22+33+import (
44+ "context"
55+ "crypto/sha256"
66+ "encoding/json"
77+ "fmt"
88+ "log/slog"
99+ "time"
1010+1111+ "atcr.io/pkg/atproto"
1212+)
1313+1414+// storeResults uploads scan results and creates ORAS manifest records in the hold's PDS
1515+func (w *Worker) storeResults(ctx context.Context, job *ScanJob, sbomDigest, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error {
1616+ if !w.config.Scanner.VulnEnabled {
1717+ slog.Info("Vulnerability scanning disabled, skipping result storage")
1818+ return nil
1919+ }
2020+2121+ slog.Info("Storing scan results as ORAS artifact",
2222+ "repository", job.Repository,
2323+ "subjectDigest", job.ManifestDigest,
2424+ "vulnDigest", vulnDigest)
2525+2626+ // Create ORAS manifest for vulnerability report
2727+ orasManifest := map[string]interface{}{
2828+ "schemaVersion": 2,
2929+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
3030+ "artifactType": "application/vnd.atcr.vulnerabilities+json",
3131+ "config": map[string]interface{}{
3232+ "mediaType": "application/vnd.oci.empty.v1+json",
3333+ "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", // Empty JSON object
3434+ "size": 2,
3535+ },
3636+ "subject": map[string]interface{}{
3737+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
3838+ "digest": job.ManifestDigest,
3939+ "size": 0, // We don't have the size, but it's optional
4040+ },
4141+ "layers": []map[string]interface{}{
4242+ {
4343+ "mediaType": "application/json",
4444+ "digest": vulnDigest,
4545+ "size": len(vulnJSON),
4646+ "annotations": map[string]string{
4747+ "org.opencontainers.image.title": "vulnerability-report.json",
4848+ },
4949+ },
5050+ },
5151+ "annotations": map[string]string{
5252+ "io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical),
5353+ "io.atcr.vuln.high": fmt.Sprintf("%d", summary.High),
5454+ "io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium),
5555+ "io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low),
5656+ "io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total),
5757+ "io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339),
5858+ "io.atcr.vuln.scannerVersion": w.getScannerVersion(),
5959+ },
6060+ }
6161+6262+ // Encode ORAS manifest to JSON
6363+ orasManifestJSON, err := json.Marshal(orasManifest)
6464+ if err != nil {
6565+ return fmt.Errorf("failed to encode ORAS manifest: %w", err)
6666+ }
6767+6868+ // Calculate ORAS manifest digest
6969+ orasDigest := fmt.Sprintf("sha256:%x", sha256Bytes(orasManifestJSON))
7070+7171+ // Upload ORAS manifest blob to storage
7272+ if err := w.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil {
7373+ return fmt.Errorf("failed to upload ORAS manifest blob: %w", err)
7474+ }
7575+7676+ // Create manifest record in hold's PDS
7777+ if err := w.createManifestRecord(ctx, job, orasDigest, orasManifestJSON, summary); err != nil {
7878+ return fmt.Errorf("failed to create manifest record: %w", err)
7979+ }
8080+8181+ slog.Info("Successfully stored scan results", "orasDigest", orasDigest)
8282+ return nil
8383+}
8484+8585+// createManifestRecord creates an ORAS manifest record in the hold's PDS
8686+func (w *Worker) createManifestRecord(ctx context.Context, job *ScanJob, orasDigest string, orasManifestJSON []byte, summary VulnerabilitySummary) error {
8787+ // Create ManifestRecord from ORAS manifest
8888+ record, err := atproto.NewManifestRecord(job.Repository, orasDigest, orasManifestJSON)
8989+ if err != nil {
9090+ return fmt.Errorf("failed to create manifest record: %w", err)
9191+ }
9292+9393+ // Set SBOM/vulnerability specific fields
9494+ record.OwnerDID = job.UserDID
9595+ record.ScannedAt = time.Now().Format(time.RFC3339)
9696+ record.ScannerVersion = w.getScannerVersion()
9797+9898+ // Add hold DID (this ORAS artifact is stored in the hold's PDS)
9999+ record.HoldDID = w.pds.DID()
100100+101101+ // Convert digest to record key (remove "sha256:" prefix)
102102+ rkey := orasDigest[len("sha256:"):]
103103+104104+ // Store record in hold's PDS
105105+ slog.Info("Creating manifest record in hold's PDS",
106106+ "collection", atproto.ManifestCollection,
107107+ "rkey", rkey,
108108+ "ownerDid", job.UserDID)
109109+110110+ _, _, err = w.pds.CreateManifestRecord(ctx, record, rkey)
111111+ if err != nil {
112112+ return fmt.Errorf("failed to put record in PDS: %w", err)
113113+ }
114114+115115+ slog.Info("Manifest record created successfully", "uri", fmt.Sprintf("at://%s/%s/%s", w.pds.DID(), atproto.ManifestCollection, rkey))
116116+ return nil
117117+}
118118+119119+// sha256Bytes calculates SHA256 hash of byte slice
120120+func sha256Bytes(data []byte) []byte {
121121+ hash := sha256.Sum256(data)
122122+ return hash[:]
123123+}
+128
pkg/hold/scanner/syft.go
···11+package scanner
22+33+import (
44+ "context"
55+ "crypto/sha256"
66+ "fmt"
77+ "log/slog"
88+ "os"
99+1010+ "github.com/anchore/syft/syft"
1111+ "github.com/anchore/syft/syft/format"
1212+ "github.com/anchore/syft/syft/format/spdxjson"
1313+ "github.com/anchore/syft/syft/sbom"
1414+ "github.com/anchore/syft/syft/source/directorysource"
1515+)
1616+1717+// generateSBOM generates an SBOM using Syft from an extracted image directory
1818+// Returns the SBOM object, SBOM JSON, its digest, and any error
1919+func (w *Worker) generateSBOM(ctx context.Context, imageDir string) (*sbom.SBOM, []byte, string, error) {
2020+ slog.Info("Generating SBOM with Syft", "imageDir", imageDir)
2121+2222+ // Check if directory exists and is accessible
2323+ entries, err := os.ReadDir(imageDir)
2424+ if err != nil {
2525+ return nil, nil, "", fmt.Errorf("failed to read image directory: %w", err)
2626+ }
2727+ slog.Info("Image directory contents",
2828+ "path", imageDir,
2929+ "entries", len(entries),
3030+ "sampleFiles", func() []string {
3131+ var samples []string
3232+ for i, e := range entries {
3333+ if i >= 20 {
3434+ break
3535+ }
3636+ samples = append(samples, e.Name())
3737+ }
3838+ return samples
3939+ }())
4040+4141+ // Create Syft source from directory
4242+ src, err := directorysource.NewFromPath(imageDir)
4343+ if err != nil {
4444+ return nil, nil, "", fmt.Errorf("failed to create Syft source: %w", err)
4545+ }
4646+ defer src.Close()
4747+4848+ // Generate SBOM
4949+ slog.Info("Running Syft cataloging")
5050+ sbomResult, err := syft.CreateSBOM(ctx, src, nil)
5151+ if err != nil {
5252+ return nil, nil, "", fmt.Errorf("failed to generate SBOM: %w", err)
5353+ }
5454+5555+ if sbomResult == nil {
5656+ return nil, nil, "", fmt.Errorf("Syft returned nil SBOM")
5757+ }
5858+5959+ slog.Info("SBOM generated",
6060+ "packages", sbomResult.Artifacts.Packages.PackageCount(),
6161+ "distro", func() string {
6262+ if sbomResult.Artifacts.LinuxDistribution != nil {
6363+ return fmt.Sprintf("%s %s", sbomResult.Artifacts.LinuxDistribution.Name, sbomResult.Artifacts.LinuxDistribution.Version)
6464+ }
6565+ return "none"
6666+ }())
6767+6868+ // Encode SBOM to SPDX JSON format
6969+ encoder, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig())
7070+ if err != nil {
7171+ return nil, nil, "", fmt.Errorf("failed to create SPDX encoder: %w", err)
7272+ }
7373+7474+ sbomJSON, err := format.Encode(*sbomResult, encoder)
7575+ if err != nil {
7676+ return nil, nil, "", fmt.Errorf("failed to encode SBOM to SPDX JSON: %w", err)
7777+ }
7878+7979+ // Calculate digest
8080+ hash := sha256.Sum256(sbomJSON)
8181+ digest := fmt.Sprintf("sha256:%x", hash)
8282+8383+ slog.Info("SBOM encoded", "format", "spdx-json", "size", len(sbomJSON), "digest", digest)
8484+8585+ // Upload SBOM blob to storage
8686+ if err := w.uploadBlob(ctx, digest, sbomJSON); err != nil {
8787+ return nil, nil, "", fmt.Errorf("failed to upload SBOM blob: %w", err)
8888+ }
8989+9090+ return sbomResult, sbomJSON, digest, nil
9191+}
9292+9393+// uploadBlob uploads a blob to storage
9494+func (w *Worker) uploadBlob(ctx context.Context, digest string, data []byte) error {
9595+ // Convert digest to storage path (same format as distribution uses)
9696+ // Path format: /docker/registry/v2/blobs/sha256/ab/abcd1234.../data
9797+ algorithm := "sha256"
9898+ digestHex := digest[len("sha256:"):]
9999+ if len(digestHex) < 2 {
100100+ return fmt.Errorf("invalid digest: %s", digest)
101101+ }
102102+103103+ blobPath := fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data",
104104+ algorithm,
105105+ digestHex[:2],
106106+ digestHex)
107107+108108+ slog.Info("Uploading blob to storage", "digest", digest, "size", len(data), "path", blobPath)
109109+110110+ // Write blob to storage
111111+ writer, err := w.driver.Writer(ctx, blobPath, false)
112112+ if err != nil {
113113+ return fmt.Errorf("failed to create storage writer: %w", err)
114114+ }
115115+ defer writer.Close()
116116+117117+ if _, err := writer.Write(data); err != nil {
118118+ writer.Cancel(ctx)
119119+ return fmt.Errorf("failed to write blob data: %w", err)
120120+ }
121121+122122+ if err := writer.Commit(ctx); err != nil {
123123+ return fmt.Errorf("failed to commit blob: %w", err)
124124+ }
125125+126126+ slog.Info("Successfully uploaded blob", "digest", digest)
127127+ return nil
128128+}
+116
pkg/hold/scanner/worker.go
···11+package scanner
22+33+import (
44+ "context"
55+ "fmt"
66+ "log/slog"
77+ "time"
88+99+ "atcr.io/pkg/hold"
1010+ "atcr.io/pkg/hold/pds"
1111+ "github.com/distribution/distribution/v3/registry/storage/driver"
1212+)
1313+1414+// Worker performs vulnerability scanning on container images
1515+type Worker struct {
1616+ config *hold.Config
1717+ driver driver.StorageDriver
1818+ pds *pds.HoldPDS
1919+ queue *Queue
2020+}
2121+2222+// NewWorker creates a new scanner worker
2323+func NewWorker(config *hold.Config, driver driver.StorageDriver, pds *pds.HoldPDS) *Worker {
2424+ return &Worker{
2525+ config: config,
2626+ driver: driver,
2727+ pds: pds,
2828+ }
2929+}
3030+3131+// Start starts the worker pool and initializes vulnerability database
3232+func (w *Worker) Start(queue *Queue) {
3333+ w.queue = queue
3434+3535+ // Initialize vulnerability database on startup if scanning is enabled
3636+ if w.config.Scanner.VulnEnabled {
3737+ go func() {
3838+ ctx := context.Background()
3939+ if err := w.initializeVulnDatabase(ctx); err != nil {
4040+ slog.Error("Failed to initialize vulnerability database", "error", err)
4141+ slog.Warn("Vulnerability scanning will be disabled until database is available")
4242+ }
4343+ }()
4444+ }
4545+4646+ queue.Start(w.processJob)
4747+}
4848+4949+// processJob processes a single scan job
5050+func (w *Worker) processJob(ctx context.Context, job *ScanJob) (*ScanResult, error) {
5151+ slog.Info("Processing scan job",
5252+ "repository", job.Repository,
5353+ "tag", job.Tag,
5454+ "digest", job.ManifestDigest,
5555+ "layers", len(job.Layers))
5656+5757+ startTime := time.Now()
5858+5959+ // Step 1: Extract image layers from storage
6060+ slog.Info("Extracting image layers", "repository", job.Repository)
6161+ imageDir, cleanup, err := w.extractLayers(ctx, job)
6262+ if err != nil {
6363+ return nil, fmt.Errorf("failed to extract layers: %w", err)
6464+ }
6565+ defer cleanup()
6666+6767+ // Step 2: Generate SBOM with Syft
6868+ slog.Info("Generating SBOM", "repository", job.Repository)
6969+ sbomResult, _, sbomDigest, err := w.generateSBOM(ctx, imageDir)
7070+ if err != nil {
7171+ return nil, fmt.Errorf("failed to generate SBOM: %w", err)
7272+ }
7373+7474+ // Step 3: Scan SBOM with Grype (if enabled)
7575+ var vulnJSON []byte
7676+ var vulnDigest string
7777+ var summary VulnerabilitySummary
7878+7979+ if w.config.Scanner.VulnEnabled {
8080+ slog.Info("Scanning for vulnerabilities", "repository", job.Repository)
8181+ vulnJSON, vulnDigest, summary, err = w.scanVulnerabilities(ctx, sbomResult)
8282+ if err != nil {
8383+ return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err)
8484+ }
8585+ }
8686+8787+ // Step 4: Upload results to storage and create ORAS manifests
8888+ slog.Info("Storing scan results", "repository", job.Repository)
8989+ err = w.storeResults(ctx, job, sbomDigest, vulnDigest, vulnJSON, summary)
9090+ if err != nil {
9191+ return nil, fmt.Errorf("failed to store results: %w", err)
9292+ }
9393+9494+ duration := time.Since(startTime)
9595+ slog.Info("Scan job completed",
9696+ "repository", job.Repository,
9797+ "tag", job.Tag,
9898+ "duration", duration,
9999+ "vulnerabilities", summary.Total)
100100+101101+ return &ScanResult{
102102+ Job: job,
103103+ VulnerabilitiesJSON: vulnJSON,
104104+ Summary: summary,
105105+ SBOMDigest: sbomDigest,
106106+ VulnDigest: vulnDigest,
107107+ ScannedAt: time.Now(),
108108+ ScannerVersion: w.getScannerVersion(),
109109+ }, nil
110110+}
111111+112112+// getScannerVersion returns the version string for the scanner
113113+func (w *Worker) getScannerVersion() string {
114114+ // TODO: Get actual Syft and Grype versions dynamically
115115+ return "syft-v1.36.0/grype-v0.102.0"
116116+}