Skip to content

Commit c7f7c5f

Browse files
cwayne18Copilot
andauthored
golang: infer main-module version from image tag when build info is (devel) (#3)
* golang: infer main-module version from image tag when build info is (devel) A Go binary built from a checkout carries no comparable main-module version: build info reports "(devel)". A project like k3s sets its version through `-ldflags -X pkg.Version=...`, which writes a separate variable that never reaches buildinfo.Main.Version. The scanner sent "(devel)" to OSV, which cannot range-match it, so OSV returned advisories already fixed in the running version and the main module's always-present pclntab symbols made every one of them land as a false positive. In image mode, when a binary's main-module version is non-comparable, recover a version from the image reference tag. The mapping is kept honest so it can never silently under-report a real vulnerability: - Main module only: dependencies keep their real build-info versions. - Semver gated: the tag is used only when it normalizes to a full MAJOR.MINOR.PATCH semver. latest, digests and date-stamps infer nothing. - k3s/rke2 aware: Docker tags cannot contain '+', so v1.36.3+k3s1 ships as v1.36.3-k3s1. A trailing -k3sN / -rke2rN is converted back to +k3sN / +rke2rN so it matches the versions those advisories are filed under. - Provenance: any finding whose version was inferred carries an image-tag-version evidence note in both text and JSON output, so an inferred version is never mistaken for one read from the artifact. When no plausible tag exists the original "(devel)" is kept, which over-reports rather than guessing a version that could hide a real finding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * golang: sharpen inferred-version provenance wording and pin the semver gate Lead the provenance note with "version not in build info" so a reader of text or JSON output cannot mistake an inferred version for one read from the binary. For a generic image a clean-semver tag is a real guess that could read too high, and this label is what keeps such a finding honest. Add a test asserting golang.org/x/mod/semver.IsValid accepts the normalized "+k3sN" build-metadata form, so the gate can never regress into rejecting the very case the fallback exists to enable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: cwayne18 <cwayne18@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 818e18b commit c7f7c5f

6 files changed

Lines changed: 407 additions & 3 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.0
55
require (
66
github.com/glebarez/go-sqlite v1.22.0
77
github.com/knqyf263/go-rpmdb v0.1.1
8+
golang.org/x/mod v0.38.0
89
)
910

1011
require (

internal/analyze/analyze.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ func registryFor(opts Options) *ecosystem.Registry {
289289
golang.New(golang.Options{
290290
VersionOverride: opts.Version,
291291
GoVersion: opts.GoVersion,
292+
Image: opts.Image,
292293
Logf: opts.Logf,
293294
}),
294295
ospkg.New(ospkg.Options{

internal/ecosystem/golang/golang.go

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ type Plugin struct {
3636
// ("1.24.0"). It matters for stdlib findings, which are toolchain-specific.
3737
GoVersion string
3838

39+
// Image is the reference of the image being scanned, in image mode ("" for
40+
// rootfs or source mode). It is the only place a version can be recovered
41+
// for a main module whose build info reports "(devel)": `go build` from a
42+
// checkout stamps no comparable version, so the image tag is the fallback.
43+
// See mainModuleVersion for the safeguards that keep this from guessing.
44+
Image string
45+
3946
// Logf receives progress messages. Never nil after New.
4047
Logf func(format string, args ...any)
4148
}
@@ -44,6 +51,7 @@ type Plugin struct {
4451
type Options struct {
4552
VersionOverride string
4653
GoVersion string
54+
Image string
4755
Logf func(format string, args ...any)
4856
}
4957

@@ -56,6 +64,7 @@ func New(opts Options) *Plugin {
5664
return &Plugin{
5765
VersionOverride: opts.VersionOverride,
5866
GoVersion: opts.GoVersion,
67+
Image: opts.Image,
5968
Logf: logf,
6069
}
6170
}
@@ -103,6 +112,51 @@ func mainModulePath(bin binscan.Binary) string {
103112
// binary's build info parsed exactly once per run.
104113
type state struct {
105114
binaries []binary
115+
116+
// inferredNote, when non-empty, records that this component's Version was
117+
// derived from the image tag rather than read from build info. The analysis
118+
// phase attaches it as evidence to every finding so a reader can never
119+
// mistake an inferred version for one read from the artifact.
120+
inferredNote string
121+
}
122+
123+
// mainModuleVersion resolves the version to report for a binary's own main
124+
// module, and a provenance note when that version was not the one build info
125+
// reported.
126+
//
127+
// Go stamps no comparable version on a main module built from a checkout: build
128+
// info reports "(devel)" (see isDevelVersion), which OSV cannot range-match, so
129+
// it returns advisories already fixed in the running version and every one of
130+
// them lands as a false positive against the module's own always-present code.
131+
//
132+
// The fallback is the image tag, but it is applied with the single hard rule
133+
// this tool never bends: it must not silently under-report. So it fires only for
134+
// the main module here, only when build info gave nothing comparable, and only
135+
// when the tag normalizes to real semver (moduleVersionFromImageTag refuses
136+
// "latest", digests and date-stamps). When it cannot infer, it returns the
137+
// original version unchanged -- which keeps today's behavior of querying OSV
138+
// with "(devel)" and over-reporting, the safe direction, rather than guessing a
139+
// version that could hide a real vulnerability.
140+
func (p *Plugin) mainModuleVersion(rawVersion string) (version, note string) {
141+
if !isDevelVersion(rawVersion) {
142+
return rawVersion, ""
143+
}
144+
if p.Image == "" {
145+
return rawVersion, ""
146+
}
147+
inferred, tag, ok := moduleVersionFromImageTag(p.Image)
148+
if !ok {
149+
return rawVersion, ""
150+
}
151+
reported := rawVersion
152+
if reported == "" {
153+
reported = "(empty)"
154+
}
155+
// Lead with the fact that this version is not from the artifact. For a
156+
// generic image a clean-semver tag is a genuine guess that could read too
157+
// high, and this label is the only thing that keeps such a finding honest.
158+
note = fmt.Sprintf("version not in build info (reported %s); inferred from image tag %q", reported, tag)
159+
return inferred, note
106160
}
107161

108162
// DetectImage implements ecosystem.ImageAnalyzer.
@@ -162,8 +216,19 @@ func (p *Plugin) groupAll(root string, bins []binscan.Binary) []ecosystem.Compon
162216
// enumeration that left it out would miss the CVEs most likely to
163217
// apply to all of them at once.
164218
g.add(StdlibModule, binscan.NormalizeGoVersion(bin.Info.GoVersion), rel, bin.Path, main)
165-
if m := bin.Info.Main; m.Path != "" && m.Version != "" {
166-
g.add(m.Path, m.Version, rel, bin.Path, main)
219+
if m := bin.Info.Main; m.Path != "" {
220+
// The main module's build-info version can be "(devel)" for a
221+
// binary built from a checkout, which OSV cannot match; recover a
222+
// comparable version from the image tag when it is safe to. Only
223+
// the main module is treated this way -- dependencies carry real
224+
// versions -- and a note is kept so the inference is visible.
225+
ver, note := p.mainModuleVersion(m.Version)
226+
if ver != "" {
227+
g.add(m.Path, ver, rel, bin.Path, main)
228+
if note != "" {
229+
g.markInferred(m.Path, ver, note)
230+
}
231+
}
167232
}
168233
for _, dep := range bin.Info.Deps {
169234
m := dep
@@ -186,13 +251,24 @@ func (p *Plugin) group(root string, bins []binscan.Binary, modules []string) []e
186251
rel := target.Rel(root, bin.Path)
187252
for _, module := range modules {
188253
version := p.VersionOverride
254+
var note string
189255
if version == "" {
190256
version = bin.ModuleVersion(module)
257+
// The requested module can be this binary's own main module,
258+
// which has the same "(devel)" defect groupAll works around;
259+
// give it the same image-tag fallback so a targeted scan is not
260+
// stuck with a version OSV cannot match.
261+
if mainModulePath(bin) == module {
262+
version, note = p.mainModuleVersion(version)
263+
}
191264
}
192265
if version == "" {
193266
continue // module not linked into this binary
194267
}
195268
g.add(module, version, rel, bin.Path, mainModulePath(bin))
269+
if note != "" {
270+
g.markInferred(module, version, note)
271+
}
196272
}
197273
}
198274
return g.components()
@@ -245,6 +321,16 @@ func (g *grouper) components() []ecosystem.Component {
245321
return out
246322
}
247323

324+
// markInferred records on an already-added component that its version was
325+
// derived from the image tag rather than read from build info. The note travels
326+
// into the analysis phase through Component.Extra so every finding for the
327+
// component can carry the provenance as evidence.
328+
func (g *grouper) markInferred(module, version, note string) {
329+
if c, ok := g.byKey[module+"@"+version]; ok {
330+
c.Extra.(*state).inferredNote = note
331+
}
332+
}
333+
248334
// wantedModules resolves subjects to the module paths to look for, and reports
249335
// separately whether one of them asked for everything.
250336
//
@@ -321,7 +407,19 @@ func (p *Plugin) AnalyzeImage(ctx context.Context, img *target.Image, items []ec
321407
logf: p.Logf,
322408
}
323409
for _, req := range requests {
324-
out = append(out, evaluate(ctx, ec, req.ID, req.Advisory))
410+
f := evaluate(ctx, ec, req.ID, req.Advisory)
411+
if st.inferredNote != "" {
412+
// The version this finding was decided against was recovered
413+
// from the image tag, not read from the binary. Recording it
414+
// as evidence keeps the heuristic honest: a reader sees the
415+
// inference and its source rather than trusting a version
416+
// that was never in the artifact.
417+
f.Evidence = append(f.Evidence, ecosystem.Evidence{
418+
Origin: "image-tag-version",
419+
Detail: st.inferredNote,
420+
})
421+
}
422+
out = append(out, f)
325423
}
326424
}
327425
}

internal/ecosystem/golang/golang_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,90 @@ func fakeBinary(path string, deps map[string]string) binscan.Binary {
143143
return binscan.Binary{Path: path, Info: info}
144144
}
145145

146+
// mainVersionBinary is a binary whose own main module carries the given path and
147+
// version, used to exercise the "(devel)" main-module fallback.
148+
func mainVersionBinary(path, mainPath, mainVersion string) binscan.Binary {
149+
return binscan.Binary{Path: path, Info: &buildinfo.BuildInfo{
150+
Main: debug.Module{Path: mainPath, Version: mainVersion},
151+
GoVersion: "go1.24.0",
152+
}}
153+
}
154+
155+
// mainComponent returns the component for module out of an inventory, or nil.
156+
func mainComponent(comps []ecosystem.Component, module string) *ecosystem.Component {
157+
for i := range comps {
158+
if comps[i].Name == module {
159+
return &comps[i]
160+
}
161+
}
162+
return nil
163+
}
164+
165+
func TestGroupAllInfersMainVersionFromImageTag(t *testing.T) {
166+
const root = "/tmp/extract"
167+
const mod = "github.com/k3s-io/k3s"
168+
bins := []binscan.Binary{mainVersionBinary(root+"/bin/k3s", mod, "(devel)")}
169+
170+
p := New(Options{Image: "docker.io/rancher/k3s:v1.36.3-k3s1"})
171+
comps := p.groupAll(root, bins)
172+
173+
c := mainComponent(comps, mod)
174+
if c == nil {
175+
t.Fatalf("main module %s missing from inventory", mod)
176+
}
177+
if c.Version != "v1.36.3+k3s1" {
178+
t.Errorf("version = %q, want v1.36.3+k3s1 (inferred from image tag)", c.Version)
179+
}
180+
note := c.Extra.(*state).inferredNote
181+
if note == "" || !strings.Contains(note, "v1.36.3-k3s1") {
182+
t.Errorf("inferredNote = %q, want a provenance note citing the image tag", note)
183+
}
184+
}
185+
186+
func TestGroupAllKeepsDevelWhenTagNotUsable(t *testing.T) {
187+
const root = "/tmp/extract"
188+
const mod = "github.com/k3s-io/k3s"
189+
bins := []binscan.Binary{mainVersionBinary(root+"/bin/k3s", mod, "(devel)")}
190+
191+
// A floating tag is not a version: inference is refused and the original
192+
// "(devel)" is kept, which over-reports rather than guessing.
193+
p := New(Options{Image: "docker.io/rancher/k3s:latest"})
194+
comps := p.groupAll(root, bins)
195+
196+
c := mainComponent(comps, mod)
197+
if c == nil {
198+
t.Fatalf("main module %s missing from inventory", mod)
199+
}
200+
if c.Version != "(devel)" {
201+
t.Errorf("version = %q, want (devel) unchanged", c.Version)
202+
}
203+
if note := c.Extra.(*state).inferredNote; note != "" {
204+
t.Errorf("inferredNote = %q, want empty (nothing was inferred)", note)
205+
}
206+
}
207+
208+
func TestGroupAllRealMainVersionIsUntouched(t *testing.T) {
209+
const root = "/tmp/extract"
210+
const mod = "example.com/app"
211+
bins := []binscan.Binary{mainVersionBinary(root+"/bin/app", mod, "v1.0.0")}
212+
213+
// Even with an image tag present, a main module that already has a real
214+
// version must not be overwritten by the tag.
215+
p := New(Options{Image: "example.com/app:v9.9.9"})
216+
comps := p.groupAll(root, bins)
217+
218+
c := mainComponent(comps, mod)
219+
if c == nil {
220+
t.Fatalf("main module %s missing from inventory", mod)
221+
}
222+
if c.Version != "v1.0.0" {
223+
t.Errorf("version = %q, want v1.0.0 (build-info version preserved)", c.Version)
224+
}
225+
if note := c.Extra.(*state).inferredNote; note != "" {
226+
t.Errorf("inferredNote = %q, want empty", note)
227+
}
228+
}
229+
146230
func TestGroupComponents(t *testing.T) {
147231
const root = "/tmp/extract"
148232
bins := []binscan.Binary{
@@ -239,6 +323,47 @@ func TestGroupAllEnumeratesEveryLinkedModule(t *testing.T) {
239323
}
240324
}
241325

326+
func TestAnalyzeImageAttachesInferredVersionEvidence(t *testing.T) {
327+
dir := t.TempDir()
328+
binPath := filepath.Join(dir, "k3s")
329+
if err := os.WriteFile(binPath, []byte("not a real elf, just needs to be readable"), 0o644); err != nil {
330+
t.Fatal(err)
331+
}
332+
333+
const mod = "github.com/k3s-io/k3s"
334+
const note = "version not in build info (reported (devel)); inferred from image tag \"v1.36.3-k3s1\""
335+
comp := ecosystem.Component{
336+
Ecosystem: "Go",
337+
Name: mod,
338+
Version: "v1.36.3+k3s1",
339+
PURL: purl(mod, "v1.36.3+k3s1"),
340+
Extra: &state{
341+
binaries: []binary{{path: binPath, rel: "/bin/k3s", main: mod}},
342+
inferredNote: note,
343+
},
344+
}
345+
// A requested id with no advisory: evaluate returns undetermined without
346+
// needing a real Go binary, and we only care that provenance is attached.
347+
item := ecosystem.WorkItem{Component: comp, Requested: []string{"CVE-2024-0001"}, Targeted: true}
348+
349+
findings, err := New(Options{}).AnalyzeImage(context.Background(), &target.Image{}, []ecosystem.WorkItem{item})
350+
if err != nil {
351+
t.Fatal(err)
352+
}
353+
if len(findings) != 1 {
354+
t.Fatalf("got %d findings, want 1", len(findings))
355+
}
356+
var found bool
357+
for _, e := range findings[0].Evidence {
358+
if e.Origin == "image-tag-version" && e.Detail == note {
359+
found = true
360+
}
361+
}
362+
if !found {
363+
t.Errorf("finding is missing image-tag-version provenance evidence: %+v", findings[0].Evidence)
364+
}
365+
}
366+
242367
func TestGroupAllSharesOneComponentAcrossBinaries(t *testing.T) {
243368
const root = "/tmp/extract"
244369
bins := []binscan.Binary{

0 commit comments

Comments
 (0)