Skip to content

Commit 263e729

Browse files
authored
Add a workflow to auto-bump vulnerable dependencies (#5437)
Adds a daily workflow that upgrades the root module's dependencies flagged by `govulncheck` to their fixed versions and opens a PR, alongside the existing `Bump Go toolchain` workflow. `govulncheck -scan module` reports every advisory affecting a required module regardless of reachability, which is broader than trivy (GHSA-fed, so it lags `golang.org/x/*` advisories). The new `tools/vulnbump` command consumes the scan's JSON, bumps each affected dependency to its highest fixed version via `go get` + `go mod tidy`, and renders the PR summary. - Standard-library advisories are skipped and left to the `Bump Go toolchain` workflow. - A `govulncheck` error aborts the job rather than being mistaken for "no vulnerabilities". - The summary labels each advisory with its CVE (read from the scan output, no extra lookup). - govulncheck is pinned as a tool dependency in `tools/go.mod` (bumpable by dependabot); its database is fetched from https://vuln.go.dev at runtime, so scans still use the latest advisories. Parsing, version selection, and summary rendering live in `tools/vulnbump` with unit and end-to-end tests. This pull request and its description were written by Isaac.
1 parent 79c6de3 commit 263e729

9 files changed

Lines changed: 591 additions & 5 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Bump vulnerable dependencies
2+
3+
on:
4+
schedule:
5+
# Run daily at 05:30 UTC, just after the Go toolchain bumper.
6+
- cron: "30 5 * * *"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
pull-requests: write
12+
13+
jobs:
14+
bump-vuln-deps:
15+
runs-on:
16+
group: databricks-protected-runner-group-large
17+
labels: linux-ubuntu-latest-large
18+
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22+
23+
- name: Setup Go
24+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
25+
with:
26+
# vulnbump lives in the tools module, which is what this job compiles.
27+
go-version-file: tools/go.mod
28+
29+
- name: Build vulnbump
30+
run: go -C tools/vulnbump build -o "$RUNNER_TEMP/vulnbump" .
31+
32+
- name: Bump vulnerable dependencies
33+
id: bump
34+
run: |
35+
set -euo pipefail
36+
37+
# govulncheck is pinned as a tool dependency in tools/go.mod; -modfile
38+
# resolves it from there while it scans the root module (the working
39+
# directory). Only the root module ships; tools/ and
40+
# bundle/internal/tf/codegen are build- and CI-only, so they are not
41+
# scanned. Its vulnerability database is fetched from vuln.go.dev at
42+
# runtime, so the pinned binary still uses the latest advisories.
43+
#
44+
# -scan module reports every advisory affecting a required module,
45+
# regardless of whether the vulnerable symbol is reachable. In JSON
46+
# mode govulncheck exits 0 on success whether or not it finds anything,
47+
# and non-zero only on a real error; a failure must abort the job
48+
# rather than be silently mistaken for "no vulnerabilities".
49+
scan="$(mktemp)"
50+
go tool -modfile=tools/go.mod govulncheck -scan module -format json > "$scan"
51+
52+
summary_file="$(mktemp)"
53+
"$RUNNER_TEMP/vulnbump" . < "$scan" > "$summary_file"
54+
55+
if git diff --quiet; then
56+
echo "No vulnerable dependencies to bump."
57+
echo "needed=false" >> "$GITHUB_OUTPUT"
58+
else
59+
echo "needed=true" >> "$GITHUB_OUTPUT"
60+
{
61+
echo "summary<<SUMMARY_EOF"
62+
cat "$summary_file"
63+
echo "SUMMARY_EOF"
64+
} >> "$GITHUB_OUTPUT"
65+
fi
66+
67+
- name: Show diff
68+
if: steps.bump.outputs.needed == 'true'
69+
run: git diff
70+
71+
- name: Create pull request
72+
if: steps.bump.outputs.needed == 'true'
73+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
74+
with:
75+
# A fixed branch means a daily run updates the existing open PR in
76+
# place rather than opening a new one; no branch-suffix is needed.
77+
branch: auto/bump-vuln-deps
78+
commit-message: "Bump dependencies with known vulnerabilities"
79+
title: "Bump dependencies with known vulnerabilities"
80+
body: |
81+
Bump dependencies flagged by `govulncheck -scan module` to their fixed versions.
82+
83+
Each CVE links to its Go advisory page.
84+
85+
${{ steps.bump.outputs.summary }}
86+
87+
Vulnerabilities in the Go standard library are left to the `Bump Go toolchain` workflow.
88+
89+
If a bump promotes a new direct dependency, double-check its license annotation in `go.mod` and `NOTICE`.
90+
reviewers: simonfaltum,andrewnester,anton-107,denik,janniklasrose,pietern,shreyas-goenka
91+
labels: dependencies

tools/go.mod

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ toolchain go1.26.4
66

77
require github.com/stretchr/testify v1.11.1
88

9-
require gopkg.in/yaml.v3 v3.0.1
9+
require (
10+
golang.org/x/mod v0.35.0
11+
gopkg.in/yaml.v3 v3.0.1
12+
)
1013

1114
require (
1215
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
@@ -214,13 +217,13 @@ require (
214217
go.uber.org/zap v1.27.0 // indirect
215218
go.yaml.in/yaml/v3 v3.0.4 // indirect
216219
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
217-
golang.org/x/mod v0.35.0 // indirect
218220
golang.org/x/sync v0.20.0 // indirect
219221
golang.org/x/sys v0.43.0 // indirect
220-
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
222+
golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e // indirect
221223
golang.org/x/term v0.39.0 // indirect
222224
golang.org/x/text v0.36.0 // indirect
223225
golang.org/x/tools v0.44.0 // indirect
226+
golang.org/x/vuln v1.3.0 // indirect
224227
google.golang.org/protobuf v1.36.10 // indirect
225228
gopkg.in/ini.v1 v1.67.0 // indirect
226229
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -234,5 +237,6 @@ tool (
234237
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
235238
github.com/google/yamlfmt/cmd/yamlfmt
236239
golang.org/x/tools/cmd/deadcode
240+
golang.org/x/vuln/cmd/govulncheck
237241
gotest.tools/gotestsum
238242
)

tools/go.sum

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqt
299299
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
300300
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
301301
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
302+
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
303+
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
302304
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
303305
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
304306
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
324326
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
325327
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
326328
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
329+
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
327330
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
328331
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
329332
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=
802805
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
803806
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
804807
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
805-
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
806-
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
808+
golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e h1:OXgN37M6hqjaAvb7CJK9vJ+7Z/6lvIm5bXho5poo/Wk=
809+
golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
807810
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
808811
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
809812
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
875878
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
876879
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
877880
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
881+
golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw=
882+
golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE=
878883
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
879884
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
880885
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

tools/vulnbump/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/vulnbump

tools/vulnbump/bumps.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"maps"
9+
"slices"
10+
"strings"
11+
12+
"golang.org/x/mod/semver"
13+
)
14+
15+
// stdlibModule is govulncheck's module name for the Go standard library.
16+
// Standard-library fixes map to a toolchain version and are handled by the
17+
// separate "Bump Go toolchain" workflow, so we skip them here.
18+
const stdlibModule = "stdlib"
19+
20+
// advisoryBaseURL is the canonical advisory page for a GO-YYYY-NNNN ID. The
21+
// page cross-links the CVE alias and the per-package fixed version.
22+
const advisoryBaseURL = "https://pkg.go.dev/vuln/"
23+
24+
// advisory identifies a Go vulnerability and its CVE alias.
25+
type advisory struct {
26+
ID string // GO-YYYY-NNNN, always present.
27+
CVE string // CVE-YYYY-NNNN, empty until one is assigned (it can lag the Go advisory by days).
28+
}
29+
30+
// label is the link text for an advisory: the CVE when known, else the Go ID.
31+
func (a advisory) label() string {
32+
if a.CVE != "" {
33+
return a.CVE
34+
}
35+
return a.ID
36+
}
37+
38+
// bump is a single dependency upgrade: the highest fixed version across all
39+
// advisories affecting a module, plus the advisories it resolves.
40+
type bump struct {
41+
Module string
42+
FixedVersion string
43+
Advisories []advisory
44+
}
45+
46+
// govulncheckFinding mirrors the "finding" message emitted by
47+
// `govulncheck -format json`. See https://pkg.go.dev/golang.org/x/vuln/internal/govulncheck#Finding.
48+
type govulncheckFinding struct {
49+
OSV string `json:"osv"`
50+
FixedVersion string `json:"fixed_version"`
51+
Trace []struct {
52+
Module string `json:"module"`
53+
} `json:"trace"`
54+
}
55+
56+
// govulncheckOSV mirrors the "osv" message emitted by `govulncheck -format json`,
57+
// which carries the full advisory record including its CVE aliases.
58+
type govulncheckOSV struct {
59+
ID string `json:"id"`
60+
Aliases []string `json:"aliases"`
61+
}
62+
63+
// parseBumps reads the concatenated JSON stream from `govulncheck -format json`
64+
// and reduces it to one bump per module, choosing the highest fixed version and
65+
// collecting the advisories it resolves. The standard library is excluded.
66+
func parseBumps(r io.Reader) ([]bump, error) {
67+
// One accumulator per module: the highest fixed version seen and the set of
68+
// advisories resolved by upgrading to it.
69+
type acc struct {
70+
version string
71+
advisories map[string]struct{}
72+
}
73+
byModule := map[string]*acc{}
74+
// govulncheck emits a separate "osv" message per advisory; collect the CVE
75+
// aliases so findings (which carry only the Go ID) can be labelled with it.
76+
cveByID := map[string]string{}
77+
78+
dec := json.NewDecoder(r)
79+
for {
80+
var msg struct {
81+
Finding *govulncheckFinding `json:"finding"`
82+
OSV *govulncheckOSV `json:"osv"`
83+
}
84+
err := dec.Decode(&msg)
85+
if errors.Is(err, io.EOF) {
86+
break
87+
}
88+
if err != nil {
89+
return nil, fmt.Errorf("decode govulncheck output: %w", err)
90+
}
91+
92+
if o := msg.OSV; o != nil {
93+
cveByID[o.ID] = firstCVE(o.Aliases)
94+
}
95+
96+
f := msg.Finding
97+
if f == nil || f.FixedVersion == "" || len(f.Trace) == 0 {
98+
continue
99+
}
100+
// trace[0] is the most specific frame; in module scan mode it carries
101+
// the vulnerable module itself.
102+
module := f.Trace[0].Module
103+
if module == "" || module == stdlibModule {
104+
continue
105+
}
106+
107+
a := byModule[module]
108+
if a == nil {
109+
a = &acc{advisories: map[string]struct{}{}}
110+
byModule[module] = a
111+
}
112+
if a.version == "" || semver.Compare(f.FixedVersion, a.version) > 0 {
113+
a.version = f.FixedVersion
114+
}
115+
a.advisories[f.OSV] = struct{}{}
116+
}
117+
118+
bumps := make([]bump, 0, len(byModule))
119+
for module, a := range byModule {
120+
var advisories []advisory
121+
for _, id := range slices.Sorted(maps.Keys(a.advisories)) {
122+
advisories = append(advisories, advisory{ID: id, CVE: cveByID[id]})
123+
}
124+
bumps = append(bumps, bump{
125+
Module: module,
126+
FixedVersion: a.version,
127+
Advisories: advisories,
128+
})
129+
}
130+
slices.SortFunc(bumps, func(a, b bump) int {
131+
return strings.Compare(a.Module, b.Module)
132+
})
133+
return bumps, nil
134+
}
135+
136+
// firstCVE returns the first CVE alias in the list, or "" if there is none.
137+
func firstCVE(aliases []string) string {
138+
for _, a := range aliases {
139+
if strings.HasPrefix(a, "CVE-") {
140+
return a
141+
}
142+
}
143+
return ""
144+
}
145+
146+
// renderSummary formats the bumps as Markdown list items for the PR body. It
147+
// uses reference-style links so the list lines stay short instead of wrapping,
148+
// with the advisory link definitions collected in a block at the end.
149+
func renderSummary(bumps []bump) string {
150+
var list, refs strings.Builder
151+
seen := map[string]struct{}{}
152+
153+
for _, bump := range bumps {
154+
labels := make([]string, len(bump.Advisories))
155+
for i, adv := range bump.Advisories {
156+
labels[i] = "[" + adv.label() + "]"
157+
if _, ok := seen[adv.label()]; ok {
158+
continue
159+
}
160+
seen[adv.label()] = struct{}{}
161+
fmt.Fprintf(&refs, "[%s]: %s%s\n", adv.label(), advisoryBaseURL, adv.ID)
162+
}
163+
fmt.Fprintf(&list, "- %s → %s (fixes %s)\n",
164+
bump.Module, bump.FixedVersion, strings.Join(labels, ", "))
165+
}
166+
167+
if refs.Len() == 0 {
168+
return list.String()
169+
}
170+
return list.String() + "\n" + refs.String()
171+
}

0 commit comments

Comments
 (0)