diff --git a/scanner/internal/scan/grype.go b/scanner/internal/scan/grype.go index 90025d1..c3f67f3 100644 --- a/scanner/internal/scan/grype.go +++ b/scanner/internal/scan/grype.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "os" + "runtime/debug" "strings" "sync" "sync/atomic" @@ -158,7 +159,7 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ( "distro": s.Artifacts.LinuxDistribution, "descriptor": map[string]any{ "name": "grype", - "version": "v0.107.1", + "version": grypeVersion(), }, "summary": summary, } @@ -174,6 +175,49 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ( return reportJSON, digest, summary, nil } +// grypeModulePath is the module whose version the report descriptor reports. +const grypeModulePath = "github.com/anchore/grype" + +// grypeVersion reports the Grype version actually linked into this binary. +// +// The descriptor exists so a stored report says which tool produced its +// findings, which is the first thing anyone asks when two scans of one image +// disagree. It used to be a hardcoded string, and by the time anyone noticed it +// claimed v0.107.1 while the module had moved to v0.118.0, so every report ever +// written misreported its own provenance. +// +// Reading it from the build info is what stops that recurring: a build stamp or +// another constant would have the same failure mode, just relocated to a step +// someone has to remember. Computed once, since it cannot change while the +// process runs. +var grypeVersion = sync.OnceValue(func() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + // Only reachable in a binary built without module information. + return unknownVersion + } + return grypeVersionFrom(bi.Deps) +}) + +// unknownVersion is what the descriptor carries when the build cannot say. +// Better than a plausible-looking lie, which is what the constant was. +const unknownVersion = "unknown" + +// grypeVersionFrom finds the Grype module in a dependency list. +// +// Split out because a test binary's build info carries no dependency list at +// all, so the lookup cannot be exercised through debug.ReadBuildInfo. Verified +// against a real build: `go version -m bin/atcr-scanner` lists +// `dep github.com/anchore/grype v0.118.0`, which is what this returns there. +func grypeVersionFrom(deps []*debug.Module) string { + for _, dep := range deps { + if dep != nil && dep.Path == grypeModulePath { + return dep.Version + } + } + return unknownVersion +} + // grypeDBConfig returns the distribution and installation configs used for // all Grype DB load/update calls. Kept in one place so both the initial load // and the periodic reload see identical settings. diff --git a/scanner/internal/scan/grype_test.go b/scanner/internal/scan/grype_test.go index 6282d6a..9e176f8 100644 --- a/scanner/internal/scan/grype_test.go +++ b/scanner/internal/scan/grype_test.go @@ -3,6 +3,9 @@ package scan import ( "context" "errors" + "os" + "runtime/debug" + "strings" "testing" "time" @@ -252,3 +255,59 @@ func TestLoadVulnDatabase_ColdStartFailureIsAnError(t *testing.T) { t.Errorf("loader called %d times, want 1", *calls) } } + +// TestGrypeVersionFrom covers the report descriptor's provenance lookup. The +// descriptor used to be a hardcoded "v0.107.1" against a v0.118.0 dependency, +// so every stored report misreported which tool produced its findings. +// +// This tests grypeVersionFrom rather than grypeVersion because a test binary's +// build info carries no dependency list, so the real call returns "unknown" +// here no matter what the code does. +func TestGrypeVersionFrom(t *testing.T) { + tests := []struct { + name string + deps []*debug.Module + want string + }{ + { + name: "finds grype among its neighbours", + deps: []*debug.Module{ + {Path: "github.com/anchore/syft", Version: "v1.51.1"}, + {Path: grypeModulePath, Version: "v0.118.0"}, + {Path: "github.com/gorilla/websocket", Version: "v1.5.4"}, + }, + want: "v0.118.0", + }, + { + name: "absent grype is unknown, not a guess", + deps: []*debug.Module{{Path: "github.com/anchore/syft", Version: "v1.51.1"}}, + want: unknownVersion, + }, + {name: "no dependencies at all", deps: nil, want: unknownVersion}, + { + name: "a nil entry does not panic", + deps: []*debug.Module{nil, {Path: grypeModulePath, Version: "v0.118.0"}}, + want: "v0.118.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := grypeVersionFrom(tt.deps); got != tt.want { + t.Errorf("grypeVersionFrom() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestGrypeVersionIsNotTheOldConstant guards the actual regression: the value +// must never again be a literal baked into the source. +func TestGrypeVersionIsNotTheOldConstant(t *testing.T) { + src, err := os.ReadFile("grype.go") + if err != nil { + t.Fatalf("read grype.go: %v", err) + } + if strings.Contains(string(src), `"v0.107.1"`) { + t.Error("the hardcoded Grype version is back in grype.go") + } +}