From 5fb14cf4454fbb798ec20a8138a6f0a8aae7aca5 Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Fri, 12 Jun 2026 10:00:34 -0400 Subject: [PATCH 1/3] govulncheck: make output less verbose --- .../passes/govulncheck/govulncheck.go | 223 ++++++++++++++---- .../passes/govulncheck/govulncheck_test.go | 83 ++++++- 2 files changed, 255 insertions(+), 51 deletions(-) diff --git a/pkg/analysis/passes/govulncheck/govulncheck.go b/pkg/analysis/passes/govulncheck/govulncheck.go index d70bfd76..0c74600e 100644 --- a/pkg/analysis/passes/govulncheck/govulncheck.go +++ b/pkg/analysis/passes/govulncheck/govulncheck.go @@ -13,6 +13,8 @@ import ( "sort" "strings" + "golang.org/x/mod/semver" + "github.com/grafana/plugin-validator/pkg/analysis" "github.com/grafana/plugin-validator/pkg/analysis/passes/archive" "github.com/grafana/plugin-validator/pkg/analysis/passes/nestedmetadata" @@ -39,6 +41,13 @@ var Analyzer = &analysis.Analyzer{ }, } +type vulnInfo struct { + id string + summary string + module string + fixedVersion string +} + func run(pass *analysis.Pass) (interface{}, error) { govulncheckBin, err := exec.LookPath("govulncheck") if err != nil { @@ -77,7 +86,7 @@ func run(pass *analysis.Pass) (interface{}, error) { ) moduleDirs = nil } - sourceFindings := make(map[string]struct{}) + sourceFindings := make(map[string]*vulnInfo) for _, moduleDir := range moduleDirs { stdout, ok, failureDetail, err := runGovulncheckJSON(govulncheckBin, moduleDir, moduleDir, "-json", "./...") if err != nil { @@ -101,7 +110,7 @@ func run(pass *analysis.Pass) (interface{}, error) { continue } scansPerformed++ - osvIDs, err := parseCalledFindings(bytes.NewReader(stdout)) + vulns, err := parseCalledFindings(bytes.NewReader(stdout)) if err != nil { logme.Errorln("Error parsing govulncheck source output", "error", err) scanFailures++ @@ -113,15 +122,17 @@ func run(pass *analysis.Pass) (interface{}, error) { ) continue } - for id := range osvIDs { - sourceFindings[id] = struct{}{} + for id, info := range vulns { + if sourceFindings[id] == nil { + sourceFindings[id] = info + } } } findingsReported += len(sourceFindings) reportSourceFindings(pass, sourceFindings) } - binaryFindings := make(map[string]map[string]struct{}) + binaryFindings := make(map[string]*vulnInfo) binaryPaths, err := getBackendBinaries(pass) if err != nil { pass.ReportResult( @@ -155,7 +166,7 @@ func run(pass *analysis.Pass) (interface{}, error) { continue } scansPerformed++ - osvIDs, err := parseAllFindings(bytes.NewReader(stdout)) + vulns, err := parseAllFindings(bytes.NewReader(stdout)) if err != nil { logme.Errorln("Error parsing govulncheck binary output", "error", err) scanFailures++ @@ -167,11 +178,10 @@ func run(pass *analysis.Pass) (interface{}, error) { ) continue } - for id := range osvIDs { + for id, info := range vulns { if binaryFindings[id] == nil { - binaryFindings[id] = make(map[string]struct{}) + binaryFindings[id] = info } - binaryFindings[id][filepath.Base(binaryPath)] = struct{}{} } } findingsReported += len(binaryFindings) @@ -214,12 +224,12 @@ func runGovulncheckJSON(govulncheckBin, dir, target string, args ...string) ([]b } // parseCalledFindings decodes the govulncheck `-json` NDJSON stream and -// returns the set of OSV IDs whose Finding contains a call-site frame -// (i.e. the vulnerable symbol is reachable from user code, not merely -// present in a transitive dependency). -func parseCalledFindings(r io.Reader) (map[string]struct{}, error) { +// returns vulns whose Finding contains a call-site frame (i.e. the vulnerable +// symbol is reachable from user code, not merely present in a transitive dep). +func parseCalledFindings(r io.Reader) (map[string]*vulnInfo, error) { dec := json.NewDecoder(r) - called := make(map[string]struct{}) + summaries := make(map[string]string) + called := make(map[string]*vulnInfo) for { var msg Message if err := dec.Decode(&msg); err != nil { @@ -228,19 +238,35 @@ func parseCalledFindings(r io.Reader) (map[string]struct{}, error) { } return nil, err } + if msg.OSV != nil && msg.OSV.ID != "" { + summaries[msg.OSV.ID] = msg.OSV.Summary + } if msg.Finding == nil || msg.Finding.OSV == "" { continue } if isCalled(msg.Finding) { - called[msg.Finding.OSV] = struct{}{} + id := msg.Finding.OSV + if called[id] == nil { + called[id] = &vulnInfo{id: id} + } + if semver.Compare(msg.Finding.FixedVersion, called[id].fixedVersion) > 0 { + called[id].fixedVersion = msg.Finding.FixedVersion + } + if called[id].module == "" { + called[id].module = firstModule(msg.Finding.Trace) + } } } + for id, info := range called { + info.summary = summaries[id] + } return called, nil } -func parseAllFindings(r io.Reader) (map[string]struct{}, error) { +func parseAllFindings(r io.Reader) (map[string]*vulnInfo, error) { dec := json.NewDecoder(r) - found := make(map[string]struct{}) + summaries := make(map[string]string) + found := make(map[string]*vulnInfo) for { var msg Message if err := dec.Decode(&msg); err != nil { @@ -249,10 +275,25 @@ func parseAllFindings(r io.Reader) (map[string]struct{}, error) { } return nil, err } + if msg.OSV != nil && msg.OSV.ID != "" { + summaries[msg.OSV.ID] = msg.OSV.Summary + } if msg.Finding == nil || msg.Finding.OSV == "" { continue } - found[msg.Finding.OSV] = struct{}{} + id := msg.Finding.OSV + if found[id] == nil { + found[id] = &vulnInfo{id: id} + } + if semver.Compare(msg.Finding.FixedVersion, found[id].fixedVersion) > 0 { + found[id].fixedVersion = msg.Finding.FixedVersion + } + if found[id].module == "" { + found[id].module = firstModule(msg.Finding.Trace) + } + } + for id, info := range found { + info.summary = summaries[id] } return found, nil } @@ -382,50 +423,142 @@ func isGoBinaryCandidate(path string) (bool, error) { return false, fmt.Errorf("%s is not a Go binary: %w", path, err) } -func reportSourceFindings(pass *analysis.Pass, osvIDs map[string]struct{}) { - if len(osvIDs) == 0 { +func reportSourceFindings(pass *analysis.Pass, findings map[string]*vulnInfo) { + if len(findings) == 0 { return } - ids := sortedKeys(osvIDs) + modGroups, stdlibGroup := splitGroups(groupByDep(findings)) + var lines []string + if stdlibGroup != nil { + lines = append(lines, "Update Go toolchain to "+goToolchainVersion(stdlibGroup.fixedVersion)+" or later ("+strings.Join(stdlibGroup.ids, ", ")+")") + } + if len(modGroups) > 0 { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, "Update the following dependencies:") + for _, g := range modGroups { + lines = append(lines, "• "+depVersion(g)+" ("+strings.Join(g.ids, ", ")+")") + } + } + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, "Run `govulncheck ./...` in your plugin source for full details.") pass.ReportResult( pass.AnalyzerName, govulncheckIssueFound, - fmt.Sprintf("govulncheck source scan reports %d reachable vulnerabilit%s", len(ids), pluralY(len(ids))), - fmt.Sprintf( - "Run govulncheck https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck in your plugin source to see details. Reachable OSV IDs: %s", - strings.Join(ids, ", "), - ), + fmt.Sprintf("govulncheck source scan reports %d reachable vulnerabilit%s", len(findings), pluralY(len(findings))), + strings.Join(lines, "\n"), ) } -func reportBinaryFindings(pass *analysis.Pass, binaryFindings map[string]map[string]struct{}) { - if len(binaryFindings) == 0 { +func reportBinaryFindings(pass *analysis.Pass, findings map[string]*vulnInfo) { + if len(findings) == 0 { return } - ids := make([]string, 0, len(binaryFindings)) - for id := range binaryFindings { - ids = append(ids, id) + modGroups, stdlibGroup := splitGroups(groupByDep(findings)) + var lines []string + if stdlibGroup != nil { + lines = append(lines, "Update Go toolchain to "+goToolchainVersion(stdlibGroup.fixedVersion)+" or later ("+strings.Join(stdlibGroup.ids, ", ")+")") } - sort.Strings(ids) - - parts := make([]string, 0, len(ids)) - for _, id := range ids { - parts = append(parts, fmt.Sprintf("%s (%s)", id, strings.Join(sortedKeys(binaryFindings[id]), ", "))) + if len(modGroups) > 0 { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, "Update the following dependencies:") + for _, g := range modGroups { + lines = append(lines, "• "+depVersion(g)+" ("+strings.Join(g.ids, ", ")+")") + } } - pass.ReportResult( pass.AnalyzerName, govulncheckIssueFound, - fmt.Sprintf("govulncheck binary scan reports %d vulnerabilit%s", len(ids), pluralY(len(ids))), - "Detected OSV IDs in backend binaries: "+strings.Join(parts, "; "), + fmt.Sprintf("govulncheck binary scan reports %d vulnerabilit%s", len(findings), pluralY(len(findings))), + strings.Join(lines, "\n"), ) } -func sortedKeys(values map[string]struct{}) []string { - keys := make([]string, 0, len(values)) - for value := range values { - keys = append(keys, value) +type depGroup struct { + module string + fixedVersion string + ids []string +} + +// groupByDep groups vulns by their vulnerable module, taking the maximum fix +// version per module so the user sees one upgrade target per dependency. +func groupByDep(findings map[string]*vulnInfo) []depGroup { + byModule := make(map[string]*depGroup) + for _, info := range findings { + mod := info.module + if mod == "" || mod == "std" { + mod = "stdlib" + } + g, ok := byModule[mod] + if !ok { + byModule[mod] = &depGroup{module: mod, fixedVersion: info.fixedVersion, ids: []string{info.id}} + continue + } + if semver.Compare(info.fixedVersion, g.fixedVersion) > 0 { + g.fixedVersion = info.fixedVersion + } + g.ids = append(g.ids, info.id) + } + groups := make([]depGroup, 0, len(byModule)) + for _, g := range byModule { + sort.Strings(g.ids) + groups = append(groups, *g) + } + // Non-stdlib modules alphabetically first, stdlib last. + sort.Slice(groups, func(i, j int) bool { + if groups[i].module == "stdlib" { + return false + } + if groups[j].module == "stdlib" { + return true + } + return groups[i].module < groups[j].module + }) + return groups +} + +// goToolchainVersion strips the "v" prefix from a Go stdlib fix version for +// go.mod-compatible display (e.g. "v1.26.4" → "1.26.4"). Returns +// "unknown" when no fixed version is available so the output is never blank. +func goToolchainVersion(fixedVersion string) string { + v := strings.TrimPrefix(fixedVersion, "v") + if v == "" { + return "unknown" + } + return v +} + +// depVersion formats a module dep group for display, e.g. "golang.org/x/net v0.55.0". +// Falls back to " (no fixed version)" when fixedVersion is absent. +func depVersion(g depGroup) string { + if g.fixedVersion == "" { + return g.module + " (no fixed version available)" + } + return g.module + " " + g.fixedVersion +} + +// splitGroups separates module dep groups from the stdlib group. +func splitGroups(groups []depGroup) (modGroups []depGroup, stdlib *depGroup) { + for i := range groups { + if groups[i].module == "stdlib" { + stdlib = &groups[i] + } else { + modGroups = append(modGroups, groups[i]) + } + } + return +} + +func firstModule(trace []Frame) string { + for _, f := range trace { + if f.Module != "" { + return f.Module + } } - sort.Strings(keys) - return keys + return "" } diff --git a/pkg/analysis/passes/govulncheck/govulncheck_test.go b/pkg/analysis/passes/govulncheck/govulncheck_test.go index 59822041..083d86a9 100644 --- a/pkg/analysis/passes/govulncheck/govulncheck_test.go +++ b/pkg/analysis/passes/govulncheck/govulncheck_test.go @@ -81,6 +81,77 @@ func TestParseCalledFindings_CountsPositionInAnyTraceFrame(t *testing.T) { } } +func TestParseCalledFindings_CapturesSummaryAndFixedVersion(t *testing.T) { + got, err := parseCalledFindings(strings.NewReader(sampleNDJSON)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + info, ok := got["GO-2024-AAAA"] + if !ok { + t.Fatalf("expected GO-2024-AAAA in result") + } + if info.summary != "Some vuln in pkg/foo" { + t.Errorf("expected summary %q, got %q", "Some vuln in pkg/foo", info.summary) + } + if info.fixedVersion != "v1.2.3" { + t.Errorf("expected fixedVersion %q, got %q", "v1.2.3", info.fixedVersion) + } + if info.module != "example.com/foo" { + t.Errorf("expected module %q, got %q", "example.com/foo", info.module) + } +} + +func TestRun_BinaryDetailContainsSummaryAndFixHint(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"osv":{"id":"GO-2024-BIN","summary":"dangerous syscall usage"}}\n' +printf '{"finding":{"osv":"GO-2024-BIN","fixed_version":"v2.0.0","trace":[{"module":"example.com/mod","version":"v1.2.3"}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + archiveDir := t.TempDir() + writeCurrentTestBinary(t, filepath.Join(archiveDir, "test-plugin_linux_amd64")) + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + archive.Analyzer: archiveDir, + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diagnostics)) + } + detail := diagnostics[0].Detail + if !strings.Contains(detail, "GO-2024-BIN") { + t.Errorf("expected OSV ID in detail, got %q", detail) + } + if !strings.Contains(detail, "example.com/mod") { + t.Errorf("expected module path in detail, got %q", detail) + } + if !strings.Contains(detail, "v2.0.0") { + t.Errorf("expected fixed version in detail, got %q", detail) + } + if !strings.Contains(detail, "Update the following dependencies") { + t.Errorf("expected dependencies section in detail, got %q", detail) + } +} + func TestRun_SkipsSilentlyWhenGovulncheckNotInstalled(t *testing.T) { t.Setenv("PATH", t.TempDir()) @@ -230,8 +301,8 @@ printf '{"finding":{"osv":"GO-2024-BIN","trace":[{"module":"example.com/mod","ve if !strings.Contains(diagnostics[0].Title, "binary scan reports 1") { t.Fatalf("expected binary scan title, got %q", diagnostics[0].Title) } - if !strings.Contains(diagnostics[0].Detail, "GO-2024-BIN") || !strings.Contains(diagnostics[0].Detail, binaryName) { - t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + if !strings.Contains(diagnostics[0].Detail, "GO-2024-BIN") { + t.Fatalf("expected OSV ID in detail, got %q", diagnostics[0].Detail) } } @@ -274,8 +345,8 @@ printf '{"finding":{"osv":"GO-2024-EXACT","trace":[{"module":"example.com/mod"," if diagnostics[0].Name != govulncheckIssueFound.Name { t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) } - if !strings.Contains(diagnostics[0].Detail, "GO-2024-EXACT") || !strings.Contains(diagnostics[0].Detail, binaryName) { - t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + if !strings.Contains(diagnostics[0].Detail, "GO-2024-EXACT") { + t.Fatalf("expected OSV ID in detail, got %q", diagnostics[0].Detail) } } @@ -321,8 +392,8 @@ printf '{"finding":{"osv":"GO-2024-DECOY","trace":[{"module":"example.com/mod"," if diagnostics[0].Name != govulncheckIssueFound.Name { t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) } - if !strings.Contains(diagnostics[0].Detail, "GO-2024-DECOY") || !strings.Contains(diagnostics[0].Detail, binaryName) { - t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + if !strings.Contains(diagnostics[0].Detail, "GO-2024-DECOY") { + t.Fatalf("expected OSV ID in detail, got %q", diagnostics[0].Detail) } } From 8cf02f5dfbdd364122c87570eddda4ef5e3f244c Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Tue, 16 Jun 2026 16:57:52 -0400 Subject: [PATCH 2/3] address pr feedback --- .../passes/govulncheck/govulncheck.go | 20 +---- .../passes/govulncheck/govulncheck_test.go | 78 ++++++++++++++++++- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/pkg/analysis/passes/govulncheck/govulncheck.go b/pkg/analysis/passes/govulncheck/govulncheck.go index 0c74600e..4295fd4b 100644 --- a/pkg/analysis/passes/govulncheck/govulncheck.go +++ b/pkg/analysis/passes/govulncheck/govulncheck.go @@ -43,7 +43,6 @@ var Analyzer = &analysis.Analyzer{ type vulnInfo struct { id string - summary string module string fixedVersion string } @@ -123,7 +122,7 @@ func run(pass *analysis.Pass) (interface{}, error) { continue } for id, info := range vulns { - if sourceFindings[id] == nil { + if sourceFindings[id] == nil || semver.Compare(info.fixedVersion, sourceFindings[id].fixedVersion) > 0 { sourceFindings[id] = info } } @@ -228,7 +227,6 @@ func runGovulncheckJSON(govulncheckBin, dir, target string, args ...string) ([]b // symbol is reachable from user code, not merely present in a transitive dep). func parseCalledFindings(r io.Reader) (map[string]*vulnInfo, error) { dec := json.NewDecoder(r) - summaries := make(map[string]string) called := make(map[string]*vulnInfo) for { var msg Message @@ -238,9 +236,6 @@ func parseCalledFindings(r io.Reader) (map[string]*vulnInfo, error) { } return nil, err } - if msg.OSV != nil && msg.OSV.ID != "" { - summaries[msg.OSV.ID] = msg.OSV.Summary - } if msg.Finding == nil || msg.Finding.OSV == "" { continue } @@ -257,15 +252,11 @@ func parseCalledFindings(r io.Reader) (map[string]*vulnInfo, error) { } } } - for id, info := range called { - info.summary = summaries[id] - } return called, nil } func parseAllFindings(r io.Reader) (map[string]*vulnInfo, error) { dec := json.NewDecoder(r) - summaries := make(map[string]string) found := make(map[string]*vulnInfo) for { var msg Message @@ -275,9 +266,6 @@ func parseAllFindings(r io.Reader) (map[string]*vulnInfo, error) { } return nil, err } - if msg.OSV != nil && msg.OSV.ID != "" { - summaries[msg.OSV.ID] = msg.OSV.Summary - } if msg.Finding == nil || msg.Finding.OSV == "" { continue } @@ -292,9 +280,6 @@ func parseAllFindings(r io.Reader) (map[string]*vulnInfo, error) { found[id].module = firstModule(msg.Finding.Trace) } } - for id, info := range found { - info.summary = summaries[id] - } return found, nil } @@ -554,6 +539,9 @@ func splitGroups(groups []depGroup) (modGroups []depGroup, stdlib *depGroup) { return } +// firstModule returns the first non-empty module in the trace. The govulncheck +// trace is ordered vulnerable-symbol-first → entry-point-last, so the first +// frame's module is the vulnerable dependency. func firstModule(trace []Frame) string { for _, f := range trace { if f.Module != "" { diff --git a/pkg/analysis/passes/govulncheck/govulncheck_test.go b/pkg/analysis/passes/govulncheck/govulncheck_test.go index 083d86a9..63c64cc2 100644 --- a/pkg/analysis/passes/govulncheck/govulncheck_test.go +++ b/pkg/analysis/passes/govulncheck/govulncheck_test.go @@ -81,7 +81,7 @@ func TestParseCalledFindings_CountsPositionInAnyTraceFrame(t *testing.T) { } } -func TestParseCalledFindings_CapturesSummaryAndFixedVersion(t *testing.T) { +func TestParseCalledFindings_CapturesFixedVersionAndModule(t *testing.T) { got, err := parseCalledFindings(strings.NewReader(sampleNDJSON)) if err != nil { t.Fatalf("parse error: %v", err) @@ -90,12 +90,10 @@ func TestParseCalledFindings_CapturesSummaryAndFixedVersion(t *testing.T) { if !ok { t.Fatalf("expected GO-2024-AAAA in result") } - if info.summary != "Some vuln in pkg/foo" { - t.Errorf("expected summary %q, got %q", "Some vuln in pkg/foo", info.summary) - } if info.fixedVersion != "v1.2.3" { t.Errorf("expected fixedVersion %q, got %q", "v1.2.3", info.fixedVersion) } + // Trace is root-first; lastModule returns the last frame's module. if info.module != "example.com/foo" { t.Errorf("expected module %q, got %q", "example.com/foo", info.module) } @@ -474,6 +472,78 @@ printf '{"finding":{"osv":"GO-2024-NESTED","trace":[{"package":"p","function":"A } } +func TestRun_SourceDetailFormatWithCalledVulns(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + // Three findings: two called (stdlib + third-party), one module-only. + // Traces are vulnerable-symbol-first per the govulncheck spec: the dep + // frame is first (with Position showing the call site in user code), and + // the user entry point is last (no Position). The module-only finding has + // no Position in any frame so isCalled() returns false — it must not + // appear in the output. + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"finding":{"osv":"GO-2024-SRC-STD","fixed_version":"v1.26.0","trace":[{"module":"stdlib","version":"v1.24.0","package":"net/http","function":"Handle","position":{"filename":"/src/main.go","line":10}},{"module":"example.com/plugin","function":"main"}]}}\n' +printf '{"finding":{"osv":"GO-2024-SRC-MOD","fixed_version":"v2.0.0","trace":[{"module":"example.com/vuln","version":"v1.0.0","package":"example.com/vuln","function":"Exec","position":{"filename":"/src/main.go","line":20}},{"module":"example.com/plugin","function":"run"}]}}\n' +printf '{"finding":{"osv":"GO-2024-SRC-SKIP","fixed_version":"v3.0.0","trace":[{"module":"example.com/other","version":"v2.0.0"}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + sourceDir := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceDir, "go.mod"), []byte("module example.com/plugin\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + sourcecode.Analyzer: sourceDir, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + d := diagnostics[0] + if d.Name != govulncheckIssueFound.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, d.Name) + } + if !strings.Contains(d.Title, "source scan reports 2 reachable") { + t.Errorf("expected title to report 2 reachable vulns, got %q", d.Title) + } + if !strings.Contains(d.Detail, "Update Go toolchain to 1.26.0 or later") { + t.Errorf("expected toolchain section in detail, got %q", d.Detail) + } + if !strings.Contains(d.Detail, "GO-2024-SRC-STD") { + t.Errorf("expected stdlib OSV in toolchain section, got %q", d.Detail) + } + if !strings.Contains(d.Detail, "Update the following dependencies:") { + t.Errorf("expected dependencies section in detail, got %q", d.Detail) + } + if !strings.Contains(d.Detail, "example.com/vuln v2.0.0") { + t.Errorf("expected module and version in detail, got %q", d.Detail) + } + if !strings.Contains(d.Detail, "GO-2024-SRC-MOD") { + t.Errorf("expected module OSV in dependencies section, got %q", d.Detail) + } + if strings.Contains(d.Detail, "GO-2024-SRC-SKIP") { + t.Errorf("module-only finding should be excluded, got %q", d.Detail) + } + if !strings.Contains(d.Detail, "Run `govulncheck ./...`") { + t.Errorf("expected govulncheck hint in detail, got %q", d.Detail) + } +} + func TestRun_ReportsScanFailureForNonGoBackendBinary(t *testing.T) { binDir := t.TempDir() fakeGovulncheck := filepath.Join(binDir, "govulncheck") From bee6cb58b09d997dffe9b8aa4de74b09c1491ef0 Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Tue, 16 Jun 2026 18:43:48 -0400 Subject: [PATCH 3/3] fix test --- .../passes/govulncheck/govulncheck_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/analysis/passes/govulncheck/govulncheck_test.go b/pkg/analysis/passes/govulncheck/govulncheck_test.go index 63c64cc2..9988ed64 100644 --- a/pkg/analysis/passes/govulncheck/govulncheck_test.go +++ b/pkg/analysis/passes/govulncheck/govulncheck_test.go @@ -14,15 +14,18 @@ import ( ) // Sample drawn from real `govulncheck -json` output. NDJSON: one Message per line. -// Two distinct findings: GO-2024-AAAA is "called" (has a frame with a Position -// in user code), GO-2024-BBBB is module-level only (no Position). Only the -// first should be counted. +// Two distinct findings: GO-2024-AAAA is "called" — its trace is ordered +// vulnerable-symbol-first (example.com/foo) → plugin entry point last +// (example.com/plugin), matching govulncheck's frame ordering, with the +// user-code Position on the entry frame. The two frames use distinct modules +// so the test pins firstModule to trace[0] (the dependency). GO-2024-BBBB is +// module-level only (no Position). Only the first should be counted. const sampleNDJSON = ` {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","go_version":"go1.26.3","scan_level":"symbol"}} {"progress":{"message":"Scanning your code and 42 packages across 3 dependent modules for known vulnerabilities..."}} {"osv":{"id":"GO-2024-AAAA","summary":"Some vuln in pkg/foo"}} {"osv":{"id":"GO-2024-BBBB","summary":"Module-only finding"}} -{"finding":{"osv":"GO-2024-AAAA","fixed_version":"v1.2.3","trace":[{"module":"example.com/foo","version":"v1.2.0","package":"example.com/foo","function":"Vulnerable","position":{"filename":"/src/plugin/main.go","line":42}},{"module":"example.com/foo","version":"v1.2.0","package":"example.com/foo","function":"main"}]}} +{"finding":{"osv":"GO-2024-AAAA","fixed_version":"v1.2.3","trace":[{"module":"example.com/foo","version":"v1.2.0","package":"example.com/foo","function":"Vulnerable"},{"module":"example.com/plugin","package":"example.com/plugin","function":"main","position":{"filename":"/src/plugin/main.go","line":42}}]}} {"finding":{"osv":"GO-2024-BBBB","fixed_version":"v2.0.0","trace":[{"module":"example.com/bar","version":"v0.1.0"}]}} ` @@ -93,7 +96,9 @@ func TestParseCalledFindings_CapturesFixedVersionAndModule(t *testing.T) { if info.fixedVersion != "v1.2.3" { t.Errorf("expected fixedVersion %q, got %q", "v1.2.3", info.fixedVersion) } - // Trace is root-first; lastModule returns the last frame's module. + // firstModule returns trace[0].Module — the vulnerable dependency + // (example.com/foo), not the plugin's own entry-point module + // (example.com/plugin) which appears last in the trace. if info.module != "example.com/foo" { t.Errorf("expected module %q, got %q", "example.com/foo", info.module) }