diff --git a/.github/workflows/bump-vuln-deps.yml b/.github/workflows/bump-vuln-deps.yml new file mode 100644 index 00000000000..f9d4310a6ed --- /dev/null +++ b/.github/workflows/bump-vuln-deps.yml @@ -0,0 +1,92 @@ +name: Bump vulnerable dependencies + +on: + schedule: + # Run daily at 05:30 UTC, just after the Go toolchain bumper. + - cron: "30 5 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + bump-vuln-deps: + runs-on: + group: databricks-protected-runner-group-large + labels: linux-ubuntu-latest-large + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + # vulnbump lives in the tools module, which is what this job compiles. + go-version-file: tools/go.mod + + - name: Build vulnbump + run: go -C tools/vulnbump build -o "$RUNNER_TEMP/vulnbump" . + + - name: Bump vulnerable dependencies + id: bump + run: | + set -euo pipefail + + # govulncheck is pinned as a tool dependency in tools/go.mod; -modfile + # resolves it from there while it scans the root module (the working + # directory). Only the root module ships; tools/ and + # bundle/internal/tf/codegen are build- and CI-only, so they are not + # scanned. Its vulnerability database is fetched from vuln.go.dev at + # runtime, so the pinned binary still uses the latest advisories. + # + # -scan module reports every advisory affecting a required module, + # regardless of whether the vulnerable symbol is reachable. In JSON + # mode govulncheck exits 0 on success whether or not it finds anything, + # and non-zero only on a real error; a failure must abort the job + # rather than be silently mistaken for "no vulnerabilities". + scan="$(mktemp)" + if ! go tool -modfile=tools/go.mod govulncheck -scan module -format json > "$scan"; then + echo "::error::govulncheck failed" + exit 1 + fi + + summary_file="$(mktemp)" + "$RUNNER_TEMP/vulnbump" . < "$scan" > "$summary_file" + + if git diff --quiet; then + echo "No vulnerable dependencies to bump." + echo "needed=false" >> "$GITHUB_OUTPUT" + else + echo "needed=true" >> "$GITHUB_OUTPUT" + { + echo "summary<> "$GITHUB_OUTPUT" + fi + + - name: Show diff + if: steps.bump.outputs.needed == 'true' + run: git diff + + - name: Create pull request + if: steps.bump.outputs.needed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + branch: auto/bump-vuln-deps + commit-message: "Bump dependencies with known vulnerabilities" + title: "Bump dependencies with known vulnerabilities" + body: | + Bump dependencies flagged by `govulncheck -scan module` to their fixed versions. + + Each CVE links to its Go advisory page. + + ${{ steps.bump.outputs.summary }} + + Vulnerabilities in the Go standard library are left to the `Bump Go toolchain` workflow. + + If a bump promotes a new direct dependency, double-check its license annotation in `go.mod` and `NOTICE`. + reviewers: simonfaltum,andrewnester,anton-107,denik,janniklasrose,pietern,shreyas-goenka + labels: dependencies diff --git a/tools/go.mod b/tools/go.mod index 48f08859c12..4b9f59bf243 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -6,7 +6,10 @@ toolchain go1.26.3 require github.com/stretchr/testify v1.11.1 -require gopkg.in/yaml.v3 v3.0.1 +require ( + golang.org/x/mod v0.35.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect @@ -214,13 +217,13 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.44.0 // indirect + golang.org/x/vuln v1.3.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -234,5 +237,6 @@ tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/google/yamlfmt/cmd/yamlfmt golang.org/x/tools/cmd/deadcode + golang.org/x/vuln/cmd/govulncheck gotest.tools/gotestsum ) diff --git a/tools/go.sum b/tools/go.sum index e6ce2fb0dae..149d98a01af 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -299,6 +299,8 @@ github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqt github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -324,6 +326,7 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -802,8 +805,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e h1:OXgN37M6hqjaAvb7CJK9vJ+7Z/6lvIm5bXho5poo/Wk= +golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -875,6 +878,8 @@ golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnps golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw= +golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/tools/vulnbump/.gitignore b/tools/vulnbump/.gitignore new file mode 100644 index 00000000000..1ff8482621c --- /dev/null +++ b/tools/vulnbump/.gitignore @@ -0,0 +1 @@ +/vulnbump diff --git a/tools/vulnbump/bumps.go b/tools/vulnbump/bumps.go new file mode 100644 index 00000000000..ca19a381822 --- /dev/null +++ b/tools/vulnbump/bumps.go @@ -0,0 +1,171 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +// stdlibModule is govulncheck's module name for the Go standard library. +// Standard-library fixes map to a toolchain version and are handled by the +// separate "Bump Go toolchain" workflow, so we skip them here. +const stdlibModule = "stdlib" + +// advisoryBaseURL is the canonical advisory page for a GO-YYYY-NNNN ID. The +// page cross-links the CVE alias and the per-package fixed version. +const advisoryBaseURL = "https://pkg.go.dev/vuln/" + +// advisory identifies a Go vulnerability and its CVE alias. +type advisory struct { + ID string // GO-YYYY-NNNN, always present. + CVE string // CVE-YYYY-NNNN, empty until one is assigned (it can lag the Go advisory by days). +} + +// label is the link text for an advisory: the CVE when known, else the Go ID. +func (a advisory) label() string { + if a.CVE != "" { + return a.CVE + } + return a.ID +} + +// bump is a single dependency upgrade: the highest fixed version across all +// advisories affecting a module, plus the advisories it resolves. +type bump struct { + Module string + FixedVersion string + Advisories []advisory +} + +// govulncheckFinding mirrors the "finding" message emitted by +// `govulncheck -format json`. See https://pkg.go.dev/golang.org/x/vuln/internal/govulncheck#Finding. +type govulncheckFinding struct { + OSV string `json:"osv"` + FixedVersion string `json:"fixed_version"` + Trace []struct { + Module string `json:"module"` + } `json:"trace"` +} + +// govulncheckOSV mirrors the "osv" message emitted by `govulncheck -format json`, +// which carries the full advisory record including its CVE aliases. +type govulncheckOSV struct { + ID string `json:"id"` + Aliases []string `json:"aliases"` +} + +// parseBumps reads the concatenated JSON stream from `govulncheck -format json` +// and reduces it to one bump per module, choosing the highest fixed version and +// collecting the advisories it resolves. The standard library is excluded. +func parseBumps(r io.Reader) ([]bump, error) { + // One accumulator per module: the highest fixed version seen and the set of + // advisories resolved by upgrading to it. + type acc struct { + version string + advisories map[string]struct{} + } + byModule := map[string]*acc{} + // govulncheck emits a separate "osv" message per advisory; collect the CVE + // aliases so findings (which carry only the Go ID) can be labelled with it. + cveByID := map[string]string{} + + dec := json.NewDecoder(r) + for { + var msg struct { + Finding *govulncheckFinding `json:"finding"` + OSV *govulncheckOSV `json:"osv"` + } + err := dec.Decode(&msg) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("decode govulncheck output: %w", err) + } + + if o := msg.OSV; o != nil { + cveByID[o.ID] = firstCVE(o.Aliases) + } + + f := msg.Finding + if f == nil || f.FixedVersion == "" || len(f.Trace) == 0 { + continue + } + // trace[0] is the most specific frame; in module scan mode it carries + // the vulnerable module itself. + module := f.Trace[0].Module + if module == "" || module == stdlibModule { + continue + } + + a := byModule[module] + if a == nil { + a = &acc{advisories: map[string]struct{}{}} + byModule[module] = a + } + if a.version == "" || semver.Compare(f.FixedVersion, a.version) > 0 { + a.version = f.FixedVersion + } + a.advisories[f.OSV] = struct{}{} + } + + bumps := make([]bump, 0, len(byModule)) + for module, a := range byModule { + var advisories []advisory + for _, id := range slices.Sorted(maps.Keys(a.advisories)) { + advisories = append(advisories, advisory{ID: id, CVE: cveByID[id]}) + } + bumps = append(bumps, bump{ + Module: module, + FixedVersion: a.version, + Advisories: advisories, + }) + } + slices.SortFunc(bumps, func(a, b bump) int { + return strings.Compare(a.Module, b.Module) + }) + return bumps, nil +} + +// firstCVE returns the first CVE alias in the list, or "" if there is none. +func firstCVE(aliases []string) string { + for _, a := range aliases { + if strings.HasPrefix(a, "CVE-") { + return a + } + } + return "" +} + +// renderSummary formats the bumps as Markdown list items for the PR body. It +// uses reference-style links so the list lines stay short instead of wrapping, +// with the advisory link definitions collected in a block at the end. +func renderSummary(bumps []bump) string { + var list, refs strings.Builder + seen := map[string]struct{}{} + + for _, bump := range bumps { + labels := make([]string, len(bump.Advisories)) + for i, adv := range bump.Advisories { + labels[i] = "[" + adv.label() + "]" + if _, ok := seen[adv.label()]; ok { + continue + } + seen[adv.label()] = struct{}{} + fmt.Fprintf(&refs, "[%s]: %s%s\n", adv.label(), advisoryBaseURL, adv.ID) + } + fmt.Fprintf(&list, "- %s → %s (fixes %s)\n", + bump.Module, bump.FixedVersion, strings.Join(labels, ", ")) + } + + if refs.Len() == 0 { + return list.String() + } + return list.String() + "\n" + refs.String() +} diff --git a/tools/vulnbump/bumps_test.go b/tools/vulnbump/bumps_test.go new file mode 100644 index 00000000000..3220086472c --- /dev/null +++ b/tools/vulnbump/bumps_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleOutput reads the recorded `govulncheck -format json` fixture. Tests run +// from the package directory, so testdata is read directly. +func sampleOutput(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("testdata/govulncheck.json") + require.NoError(t, err) + return string(data) +} + +func TestParseBumps(t *testing.T) { + bumps, err := parseBumps(strings.NewReader(sampleOutput(t))) + require.NoError(t, err) + + // golang.org/x/crypto takes the higher of its two fixed versions (v0.52.0 + // over v0.51.0), advisories are deduplicated and sorted, the stdlib finding + // is dropped, and non-finding messages are ignored. CVE aliases come from + // the "osv" messages; GO-2026-5021's osv has only a GHSA alias, so it keeps + // an empty CVE and later falls back to the Go ID as its label. + assert.Equal(t, []bump{ + { + Module: "golang.org/x/crypto", + FixedVersion: "v0.52.0", + Advisories: []advisory{ + {ID: "GO-2026-5016", CVE: "CVE-2026-39827"}, + {ID: "GO-2026-5021"}, + }, + }, + { + Module: "golang.org/x/net", + FixedVersion: "v0.55.0", + Advisories: []advisory{ + {ID: "GO-2026-5026", CVE: "CVE-2026-39821"}, + }, + }, + }, bumps) +} + +func TestParseBumpsEmpty(t *testing.T) { + bumps, err := parseBumps(strings.NewReader("")) + require.NoError(t, err) + assert.Empty(t, bumps) +} + +func TestParseBumpsInvalid(t *testing.T) { + _, err := parseBumps(strings.NewReader("not json")) + require.Error(t, err) +} + +func TestRenderSummary(t *testing.T) { + bumps := []bump{ + { + Module: "golang.org/x/crypto", + FixedVersion: "v0.52.0", + Advisories: []advisory{ + {ID: "GO-2026-5016", CVE: "CVE-2026-39827"}, + {ID: "GO-2026-5021"}, // no CVE assigned: falls back to the Go ID + }, + }, + } + + // Reference-style links keep the list line short; definitions follow in a + // block. The CVE is the visible label, linking to the Go advisory page. + want := "- golang.org/x/crypto → v0.52.0 (fixes [CVE-2026-39827], [GO-2026-5021])\n" + + "\n" + + "[CVE-2026-39827]: https://pkg.go.dev/vuln/GO-2026-5016\n" + + "[GO-2026-5021]: https://pkg.go.dev/vuln/GO-2026-5021\n" + assert.Equal(t, want, renderSummary(bumps)) +} + +func TestRenderSummaryEmpty(t *testing.T) { + assert.Empty(t, renderSummary(nil)) +} + +func TestFirstCVE(t *testing.T) { + assert.Equal(t, "CVE-2026-39827", firstCVE([]string{"CVE-2026-39827", "GHSA-xxxx-xxxx-xxxx"})) + assert.Equal(t, "CVE-2026-39827", firstCVE([]string{"GHSA-xxxx-xxxx-xxxx", "CVE-2026-39827"})) + assert.Empty(t, firstCVE([]string{"GHSA-xxxx-xxxx-xxxx"})) + assert.Empty(t, firstCVE(nil)) +} + +func TestAdvisoryLabelFallsBackToID(t *testing.T) { + assert.Equal(t, "CVE-2026-39827", advisory{ID: "GO-2026-5016", CVE: "CVE-2026-39827"}.label()) + assert.Equal(t, "GO-2026-5021", advisory{ID: "GO-2026-5021"}.label()) +} + +// TestRenderSummaryNoCVE checks the no-alias path renders the Go ID as both the +// link text and the reference definition. +func TestRenderSummaryNoCVE(t *testing.T) { + bumps := []bump{ + { + Module: "golang.org/x/crypto", + FixedVersion: "v0.52.0", + Advisories: []advisory{{ID: "GO-2026-5021"}}, + }, + } + + want := "- golang.org/x/crypto → v0.52.0 (fixes [GO-2026-5021])\n" + + "\n" + + "[GO-2026-5021]: https://pkg.go.dev/vuln/GO-2026-5021\n" + assert.Equal(t, want, renderSummary(bumps)) +} diff --git a/tools/vulnbump/main.go b/tools/vulnbump/main.go new file mode 100644 index 00000000000..30dfed403a5 --- /dev/null +++ b/tools/vulnbump/main.go @@ -0,0 +1,68 @@ +// Command vulnbump upgrades Go module dependencies that govulncheck reports as +// vulnerable to their fixed versions and prints a Markdown summary of the +// upgrades for use in a pull request body. +// +// It reads the output of `govulncheck -scan module -format json` from stdin and +// applies the upgrades to the module in the directory given as the first +// argument (default "."). End to end it is driven as: +// +// govulncheck -scan module -format json | vulnbump . +// +// .github/workflows/bump-vuln-deps.yml runs it this way across every go.mod. +package main + +import ( + "fmt" + "io" + "os" + "os/exec" +) + +// commandRunner runs a command in dir. It is injected so tests can exercise the +// end-to-end flow without shelling out to the Go toolchain or the network. +type commandRunner func(dir, name string, args ...string) error + +func main() { + dir := "." + if len(os.Args) > 1 { + dir = os.Args[1] + } + if err := run(dir, os.Stdin, os.Stdout, execCommand); err != nil { + fmt.Fprintln(os.Stderr, "vulnbump:", err) + os.Exit(1) + } +} + +// run reads govulncheck JSON from in, applies each bump in dir via runCmd, and +// writes the Markdown summary to out. +func run(dir string, in io.Reader, out io.Writer, runCmd commandRunner) error { + bumps, err := parseBumps(in) + if err != nil { + return err + } + for _, b := range bumps { + if err := runCmd(dir, "go", "get", b.Module+"@"+b.FixedVersion); err != nil { + return err + } + } + if len(bumps) > 0 { + if err := runCmd(dir, "go", "mod", "tidy"); err != nil { + return err + } + } + _, err = io.WriteString(out, renderSummary(bumps)) + return err +} + +// execCommand runs a command in dir, forwarding its output to stderr so it +// surfaces in the workflow log without polluting the Markdown summary on stdout. +func execCommand(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %v in %s: %w", name, args, dir, err) + } + return nil +} diff --git a/tools/vulnbump/main_test.go b/tools/vulnbump/main_test.go new file mode 100644 index 00000000000..9bedcf4f402 --- /dev/null +++ b/tools/vulnbump/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunEndToEnd drives the whole program over the sample govulncheck output: +// it must issue one `go get` per module (highest fixed version), a single +// `go mod tidy`, and print the linked summary. The toolchain is stubbed so the +// test stays hermetic; the workflow runs the same flow against the real `go`. +func TestRunEndToEnd(t *testing.T) { + var calls [][]string + runCmd := func(dir, name string, args ...string) error { + calls = append(calls, append([]string{dir, name}, args...)) + return nil + } + + var out strings.Builder + require.NoError(t, run(".", strings.NewReader(sampleOutput(t)), &out, runCmd)) + + assert.Equal(t, [][]string{ + {".", "go", "get", "golang.org/x/crypto@v0.52.0"}, + {".", "go", "get", "golang.org/x/net@v0.55.0"}, + {".", "go", "mod", "tidy"}, + }, calls) + + assert.Contains(t, out.String(), "golang.org/x/crypto → v0.52.0") + assert.Contains(t, out.String(), "[CVE-2026-39827]") + assert.Contains(t, out.String(), "[CVE-2026-39827]: https://pkg.go.dev/vuln/GO-2026-5016") +} + +func TestRunNoFindings(t *testing.T) { + calls := 0 + runCmd := func(dir, name string, args ...string) error { + calls++ + return nil + } + + var out strings.Builder + require.NoError(t, run(".", strings.NewReader(""), &out, runCmd)) + + // No vulnerabilities means nothing to upgrade and an empty summary, so the + // workflow's `git diff` check leaves it without opening a PR. + assert.Zero(t, calls) + assert.Empty(t, out.String()) +} + +func TestRunStopsOnError(t *testing.T) { + runCmd := func(dir, name string, args ...string) error { + return errors.New("network down") + } + + err := run(".", strings.NewReader(sampleOutput(t)), io.Discard, runCmd) + require.Error(t, err) +} diff --git a/tools/vulnbump/testdata/govulncheck.json b/tools/vulnbump/testdata/govulncheck.json new file mode 100644 index 00000000000..b6fa9ea6e58 --- /dev/null +++ b/tools/vulnbump/testdata/govulncheck.json @@ -0,0 +1,73 @@ +{ + "config": { + "protocol_version": "v1.0.0", + "scanner_name": "govulncheck", + "scan_level": "module" + } +} +{ + "SBOM": { + "go_version": "go1.26.3", + "roots": ["github.com/databricks/cli"] + } +} +{ + "osv": { + "id": "GO-2026-5016", + "aliases": ["CVE-2026-39827", "GHSA-xxxx-xxxx-xxxx"] + } +} +{ + "osv": { + "id": "GO-2026-5026", + "aliases": ["CVE-2026-39821"] + } +} +{ + "osv": { + "id": "GO-2026-5021", + "aliases": ["GHSA-yyyy-yyyy-yyyy"] + } +} +{ + "finding": { + "osv": "GO-2026-5016", + "fixed_version": "v0.52.0", + "trace": [{ "module": "golang.org/x/crypto", "version": "v0.51.0" }] + } +} +{ + "finding": { + "osv": "GO-2026-5016", + "fixed_version": "v0.52.0", + "trace": [{ "module": "golang.org/x/crypto", "version": "v0.51.0" }] + } +} +{ + "finding": { + "osv": "GO-2026-5021", + "fixed_version": "v0.51.0", + "trace": [{ "module": "golang.org/x/crypto", "version": "v0.51.0" }] + } +} +{ + "finding": { + "osv": "GO-2026-5026", + "fixed_version": "v0.55.0", + "trace": [{ "module": "golang.org/x/net", "version": "v0.53.0" }] + } +} +{ + "finding": { + "osv": "GO-2026-5026", + "fixed_version": "v0.55.0", + "trace": [{ "module": "golang.org/x/net", "version": "v0.53.0" }] + } +} +{ + "finding": { + "osv": "GO-2026-5039", + "fixed_version": "go1.26.4", + "trace": [{ "module": "stdlib", "version": "go1.26.3" }] + } +}