diff --git a/README.md b/README.md index 41acb65..975d6c6 100644 --- a/README.md +++ b/README.md @@ -37,4 +37,25 @@ You can also apply fixes from a saved report: ```bash vulnfix < govulncheck-report.json +``` + +Optionally write a Markdown CVE report: + +```bash +govulncheck -json ./... | vulnfix -o report.md +``` + +## How It Works + +`vulnfix` parses the `govulncheck -json` output and collects the minimum fixed +version for each vulnerable module. It then runs `go get @` for +each affected dependency and follows up with `go mod tidy` to keep the module +graph clean. + +Special pseudo-modules are handled automatically: +| Module | Action | +|-----------------|--------------------------------------------------------------------| +| `stdlib` | Updates the `go` directive via `go get go@` | +| `toolchain` | Updates the `toolchain` directive via `go get toolchain@` | +| everything else | Regular `go get @` | diff --git a/internal/govulncheck/parse.go b/internal/govulncheck/parse.go index 96b5779..5ab44ca 100644 --- a/internal/govulncheck/parse.go +++ b/internal/govulncheck/parse.go @@ -12,24 +12,6 @@ import ( "golang.org/x/mod/semver" ) -// These types implement the govulncheck -json message protocol. -// The JSON tags mirror golang.org/x/vuln/internal/govulncheck and -// golang.org/x/vuln/internal/osv, which are not importable externally. - -type message struct { - Finding *finding `json:"finding"` -} - -type finding struct { - OSV string `json:"osv"` - FixedVersion string `json:"fixed_version"` - Trace []frame `json:"trace"` -} - -type frame struct { - Module string `json:"module"` -} - const ( // GoStdModulePath is the pseudo-module path used by govulncheck for // standard library vulnerabilities. @@ -40,15 +22,41 @@ const ( GoToolchainPath = "toolchain" ) -// ParseFixed reads govulncheck -json output from r and returns a map of module -// path to the minimum version that fixes all reachable vulnerabilities. Only -// finding messages are considered, so modules that are imported but whose -// vulnerable symbols are never called are not included. When a module has -// multiple findings the highest fix version is used. Versions have no leading "v". -func ParseFixed(r io.Reader) (map[string]string, error) { +// OSV holds the metadata of one vulnerability that contributed a finding for a module. +type OSV struct { + // ID is the Go vulnerability database identifier, e.g. "GO-2024-0001". + ID string + + // Aliases contains alternate identifiers such as CVE or GHSA IDs. + Aliases []string + + // Summary is a short human-readable description of the vulnerability. + Summary string + + // References are URLs with more information (advisories, fixes, etc.). + References []string +} + +// Fix describes the upgrade needed for one module and the vulnerabilities it resolves. +type Fix struct { + // Version is the minimum version that fixes all reachable vulnerabilities + // for this module, including its natural prefix: "v1.2.3" for regular + // modules, "go1.22.3" for stdlib, "go1.23.0" for toolchain. + Version string + + // OSVs are the vulnerabilities that had actual findings against this module. + OSVs []OSV +} + +// Parse reads govulncheck -json output from r and returns a map of module path +// to Fix. Only finding messages are considered; modules whose vulnerable +// symbols are never called are not included. +func Parse(r io.Reader) (map[string]Fix, error) { dec := json.NewDecoder(r) - fixed := map[string]string{} + osvs := map[string]*osvEntry{} + fixes := map[string]Fix{} + for { var msg message if err := dec.Decode(&msg); err != nil { @@ -58,17 +66,83 @@ func ParseFixed(r io.Reader) (map[string]string, error) { return nil, fmt.Errorf("parsing govulncheck JSON: %w", err) } + if msg.OSV != nil { + osvs[msg.OSV.ID] = msg.OSV + } + if msg.Finding == nil || len(msg.Finding.Trace) == 0 { continue } mod := msg.Finding.Trace[0].Module - fixedVer := msg.Finding.FixedVersion - fixedVer = strings.TrimPrefix(fixedVer, "v") - fixedVer = strings.TrimPrefix(fixedVer, "go") - if existing, ok := fixed[mod]; !ok || semver.Compare("v"+fixedVer, "v"+existing) > 0 { - fixed[mod] = fixedVer + ver := msg.Finding.FixedVersion // keep natural prefix ("v..." or "go...") + + f := fixes[mod] + if f.Version == "" || semver.Compare("v"+normalizeVersion(ver), "v"+normalizeVersion(f.Version)) > 0 { + f.Version = ver + } + + osvID := msg.Finding.OSV + if !hasOSV(f.OSVs, osvID) { + o := OSV{ID: osvID} + if entry, ok := osvs[osvID]; ok { + o.Aliases = entry.Aliases + o.Summary = entry.Summary + for _, ref := range entry.References { + o.References = append(o.References, ref.URL) + } + } + f.OSVs = append(f.OSVs, o) + } + + fixes[mod] = f + } + + return fixes, nil +} + +func hasOSV(osvs []OSV, id string) bool { + for _, o := range osvs { + if o.ID == id { + return true } } - return fixed, nil + return false +} + +func normalizeVersion(v string) string { + v = strings.TrimPrefix(v, "v") + v = strings.TrimPrefix(v, "go") + return v +} + +// These types implement the govulncheck -json message protocol. +// The JSON tags mirror golang.org/x/vuln/internal/govulncheck and +// golang.org/x/vuln/internal/osv, which are not importable externally. + +type message struct { + Finding *finding `json:"finding"` + OSV *osvEntry `json:"osv"` +} + +type finding struct { + OSV string `json:"osv"` + FixedVersion string `json:"fixed_version"` + Trace []frame `json:"trace"` +} + +type frame struct { + Module string `json:"module"` +} + +type osvEntry struct { + ID string `json:"id"` + Aliases []string `json:"aliases"` + Summary string `json:"summary"` + References []osvRef `json:"references"` +} + +type osvRef struct { + Type string `json:"type"` + URL string `json:"url"` } diff --git a/internal/govulncheck/parse_test.go b/internal/govulncheck/parse_test.go index d180719..4ced39b 100644 --- a/internal/govulncheck/parse_test.go +++ b/internal/govulncheck/parse_test.go @@ -9,41 +9,36 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseFixed(t *testing.T) { +func TestParse_ParsesFixes(t *testing.T) { tests := []struct { - name string - file string - want map[string]string + name string + file string + wantMod string + wantVer string }{ { - name: "regular module vulnerability", - file: "testdata/module.json", - want: map[string]string{ - "example.com/foo": "1.2.3", - }, + name: "regular module vulnerability", + file: "testdata/module.json", + wantMod: "example.com/foo", + wantVer: "v1.2.3", }, { - name: "stdlib vulnerability", - file: "testdata/stdlib.json", - want: map[string]string{ - "stdlib": "1.22.3", - }, + name: "stdlib vulnerability", + file: "testdata/stdlib.json", + wantMod: "stdlib", + wantVer: "go1.22.3", }, { - name: "toolchain vulnerability", - file: "testdata/toolchain.json", - want: map[string]string{ - "toolchain": "1.23.0", - }, + name: "toolchain vulnerability", + file: "testdata/toolchain.json", + wantMod: "toolchain", + wantVer: "go1.23.0", }, { - name: "multiple vulnerabilities picks highest fixed version per module", - file: "testdata/multi.json", - want: map[string]string{ - "example.com/foo": "1.3.0", - "example.com/bar": "2.0.1", - "stdlib": "1.22.3", - }, + name: "multiple vulnerabilities picks highest fixed version per module", + file: "testdata/multi.json", + wantMod: "example.com/foo", + wantVer: "v1.3.0", }, } @@ -55,10 +50,61 @@ func TestParseFixed(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = f.Close() }) - got, err := govulncheck.ParseFixed(f) + fixes, err := govulncheck.Parse(f) require.NoError(t, err) - assert.Equal(t, test.want, got) + require.Contains(t, fixes, test.wantMod) + assert.Equal(t, test.wantVer, fixes[test.wantMod].Version) }) } } + +func TestParse_MultipleVulnerabilities(t *testing.T) { + t.Parallel() + + f, err := os.Open("testdata/multi.json") + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + fixes, err := govulncheck.Parse(f) + + require.NoError(t, err) + + // Each module carries its highest fixed version with its natural prefix. + assert.Equal(t, "v1.3.0", fixes["example.com/foo"].Version) + assert.Equal(t, "v2.0.1", fixes["example.com/bar"].Version) + assert.Equal(t, "go1.22.3", fixes["stdlib"].Version) + + // example.com/foo has two contributing OSVs. + fooOSVs := fixes["example.com/foo"].OSVs + require.Len(t, fooOSVs, 2) + ids := []string{fooOSVs[0].ID, fooOSVs[1].ID} + assert.ElementsMatch(t, []string{"GO-2024-0001", "GO-2024-0004"}, ids) + + // stdlib and bar each have exactly one contributing OSV. + require.Len(t, fixes["stdlib"].OSVs, 1) + assert.Equal(t, "GO-2024-0002", fixes["stdlib"].OSVs[0].ID) + + require.Len(t, fixes["example.com/bar"].OSVs, 1) + assert.Equal(t, "GO-2024-0005", fixes["example.com/bar"].OSVs[0].ID) +} + +func TestParse_OSVMetadata(t *testing.T) { + t.Parallel() + + f, err := os.Open("testdata/module.json") + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + fixes, err := govulncheck.Parse(f) + + require.NoError(t, err) + require.Contains(t, fixes, "example.com/foo") + + osv := fixes["example.com/foo"].OSVs[0] + assert.Equal(t, "GO-2024-0001", osv.ID) + assert.Equal(t, []string{"CVE-2024-12345", "GHSA-aaaa-bbbb-cccc"}, osv.Aliases) + assert.Equal(t, "Remote code execution via crafted input in example.com/foo", osv.Summary) + assert.Contains(t, osv.References, "https://pkg.go.dev/vuln/GO-2024-0001") + assert.Contains(t, osv.References, "https://www.cve.org/CVERecord?id=CVE-2024-12345") +} diff --git a/internal/govulncheck/testdata/module.json b/internal/govulncheck/testdata/module.json index 19ecfd0..a87cc86 100644 --- a/internal/govulncheck/testdata/module.json +++ b/internal/govulncheck/testdata/module.json @@ -1,4 +1,4 @@ {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.3.0","db":"https://vuln.go.dev","scan_level":"symbol","scan_mode":"source"}} -{"osv":{"id":"GO-2024-0001","affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.2.3"}]}]}]}} +{"osv":{"id":"GO-2024-0001","aliases":["CVE-2024-12345","GHSA-aaaa-bbbb-cccc"],"summary":"Remote code execution via crafted input in example.com/foo","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0001"},{"type":"WEB","url":"https://www.cve.org/CVERecord?id=CVE-2024-12345"}],"affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.2.3"}]}]}]}} {"finding":{"osv":"GO-2024-0001","fixed_version":"v1.2.3","trace":[{"module":"example.com/foo","version":"v1.0.0"}]}} diff --git a/internal/govulncheck/testdata/multi.json b/internal/govulncheck/testdata/multi.json index 383a4fc..d3712ef 100644 --- a/internal/govulncheck/testdata/multi.json +++ b/internal/govulncheck/testdata/multi.json @@ -1,8 +1,8 @@ {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.3.0","db":"https://vuln.go.dev","scan_level":"symbol","scan_mode":"source"}} -{"osv":{"id":"GO-2024-0001","affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.2.3"}]}]}]}} -{"osv":{"id":"GO-2024-0004","affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.2.4"},{"fixed":"1.3.0"}]}]}]}} -{"osv":{"id":"GO-2024-0002","affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.22.3"}]}]}]}} -{"osv":{"id":"GO-2024-0005","affected":[{"package":{"name":"example.com/bar","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.1"}]}]}]}} +{"osv":{"id":"GO-2024-0001","aliases":["CVE-2024-12345"],"summary":"Remote code execution via crafted input in example.com/foo","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0001"}],"affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.2.3"}]}]}]}} +{"osv":{"id":"GO-2024-0004","aliases":["CVE-2024-12346"],"summary":"Denial of service in example.com/foo","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0004"}],"affected":[{"package":{"name":"example.com/foo","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.2.4"},{"fixed":"1.3.0"}]}]}]}} +{"osv":{"id":"GO-2024-0002","aliases":["CVE-2024-99999"],"summary":"HTTP/2 server memory exhaustion in stdlib","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0002"}],"affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.22.3"}]}]}]}} +{"osv":{"id":"GO-2024-0005","aliases":["CVE-2024-77777"],"summary":"SQL injection in example.com/bar","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0005"}],"affected":[{"package":{"name":"example.com/bar","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.1"}]}]}]}} {"finding":{"osv":"GO-2024-0001","fixed_version":"v1.2.3","trace":[{"module":"example.com/foo","version":"v1.0.0"}]}} {"finding":{"osv":"GO-2024-0004","fixed_version":"v1.3.0","trace":[{"module":"example.com/foo","version":"v1.2.6"}]}} {"finding":{"osv":"GO-2024-0002","fixed_version":"go1.22.3","trace":[{"module":"stdlib","version":"go1.21.0"}]}} diff --git a/internal/govulncheck/testdata/stdlib.json b/internal/govulncheck/testdata/stdlib.json index bbf9655..6c442e8 100644 --- a/internal/govulncheck/testdata/stdlib.json +++ b/internal/govulncheck/testdata/stdlib.json @@ -1,4 +1,4 @@ {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.3.0","db":"https://vuln.go.dev","scan_level":"symbol","scan_mode":"source"}} -{"osv":{"id":"GO-2024-0002","affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.22.3"}]}]}]}} +{"osv":{"id":"GO-2024-0002","aliases":["CVE-2024-99999"],"summary":"HTTP/2 server memory exhaustion in stdlib","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0002"},{"type":"WEB","url":"https://www.cve.org/CVERecord?id=CVE-2024-99999"}],"affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.22.3"}]}]}]}} {"finding":{"osv":"GO-2024-0002","fixed_version":"go1.22.3","trace":[{"module":"stdlib","version":"go1.21.0"}]}} diff --git a/internal/govulncheck/testdata/toolchain.json b/internal/govulncheck/testdata/toolchain.json index 8e75644..8ae629b 100644 --- a/internal/govulncheck/testdata/toolchain.json +++ b/internal/govulncheck/testdata/toolchain.json @@ -1,4 +1,4 @@ {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.3.0","db":"https://vuln.go.dev","scan_level":"symbol","scan_mode":"source"}} -{"osv":{"id":"GO-2024-0003","affected":[{"package":{"name":"toolchain","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.23.0"}]}]}]}} +{"osv":{"id":"GO-2024-0003","aliases":["CVE-2024-88888"],"summary":"Toolchain build cache poisoning","references":[{"type":"ADVISORY","url":"https://pkg.go.dev/vuln/GO-2024-0003"}],"affected":[{"package":{"name":"toolchain","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.23.0"}]}]}]}} {"finding":{"osv":"GO-2024-0003","fixed_version":"go1.23.0","trace":[{"module":"toolchain","version":"go1.22.0"}]}} diff --git a/internal/modfix/apply.go b/internal/modfix/apply.go index bf64502..bd6e31f 100644 --- a/internal/modfix/apply.go +++ b/internal/modfix/apply.go @@ -7,12 +7,12 @@ import ( "fmt" "os" "os/exec" - - "github.com/hamba/vulnfix/internal/govulncheck" + "strings" ) // Apply upgrades each module in fixes to its fixed version by running // "go get", then cleans up the module graph with "go mod tidy". +// fixes is a map of module path to the minimum fixed version. // All commands run inside dir. ctx controls cancellation. func Apply(ctx context.Context, dir string, fixes map[string]string) error { for mod, ver := range fixes { @@ -28,18 +28,17 @@ func Apply(ctx context.Context, dir string, fixes map[string]string) error { return nil } -// moduleArg returns the argument to pass to "go get" for the given module -// and version. stdlib and toolchain use special go-directive syntax. func moduleArg(mod, ver string) string { switch mod { - case govulncheck.GoStdModulePath: - // "go get go@1.22.3" updates the go directive in go.mod. - return "go@" + ver - case govulncheck.GoToolchainPath: - // "go get toolchain@go1.22.3" updates the toolchain directive in go.mod. - return "toolchain@go" + ver + case "stdlib": + // ver is "go1.22.3"; "go get go@1.22.3" updates the go directive in go.mod. + return "go@" + strings.TrimPrefix(ver, "go") + case "toolchain": + // ver is "go1.23.0"; "go get toolchain@go1.23.0" updates the toolchain directive. + return "toolchain@" + ver default: - return mod + "@v" + ver + // ver is "v1.2.3". + return mod + "@" + ver } } diff --git a/internal/modfix/apply_test.go b/internal/modfix/apply_test.go index 7033fa2..087567d 100644 --- a/internal/modfix/apply_test.go +++ b/internal/modfix/apply_test.go @@ -18,12 +18,12 @@ var update = flag.Bool("update", false, "update golden files") func TestApply(t *testing.T) { // Copy testdata/apply into a temp directory so we don't mutate the source. tmpDir := t.TempDir() - require.NoError(t, copyDir("testdata/apply", tmpDir)) + copyDir(t, "testdata/apply", tmpDir) fixes := map[string]string{ - "stdlib": "1.22.3", - "toolchain": "1.23.0", - "golang.org/x/mod": "0.8.0", + "stdlib": "go1.22.3", + "toolchain": "go1.23.0", + "golang.org/x/mod": "v0.8.0", } err := modfix.Apply(context.Background(), tmpDir, fixes) @@ -46,8 +46,10 @@ func TestApply(t *testing.T) { } // copyDir copies all regular files from src into dst (non-recursively). -func copyDir(src, dst string) error { - return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { +func copyDir(t *testing.T, src, dst string) { + t.Helper() + + err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } @@ -57,4 +59,5 @@ func copyDir(src, dst string) error { } return os.WriteFile(filepath.Join(dst, d.Name()), data, 0o644) }) + require.NoError(t, err) } diff --git a/main.go b/main.go index 466d7a6..58064d5 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,11 @@ import ( "context" "flag" "fmt" + "io" "os" "os/signal" + "sort" + "strings" "syscall" "github.com/hamba/vulnfix/internal/govulncheck" @@ -19,25 +22,86 @@ func main() { func realMain() int { dir := flag.String("C", ".", "change to `dir` before running vulnfix") + outFile := flag.String("o", "", "write CVE report to `file`") flag.Parse() ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - fixes, err := govulncheck.ParseFixed(os.Stdin) + fixes, err := govulncheck.Parse(os.Stdin) if err != nil { - fmt.Printf("vulnfix: %v", err) + fmt.Fprintf(os.Stderr, "vulnfix: %v\n", err) return 1 } if len(fixes) == 0 { - fmt.Println("vulnfix: no fixable vulnerabilities found") + fmt.Fprintln(os.Stderr, "vulnfix: no fixable vulnerabilities found") return 0 } - if err = modfix.Apply(ctx, *dir, fixes); err != nil { - fmt.Printf("vulnfix: %v", err) + versions := make(map[string]string, len(fixes)) + for mod, fix := range fixes { + versions[mod] = fix.Version + } + if err = modfix.Apply(ctx, *dir, versions); err != nil { + fmt.Fprintf(os.Stderr, "vulnfix: %v\n", err) return 1 } + + if *outFile != "" && *outFile != "-" { + f, err := os.Create(*outFile) + if err != nil { + fmt.Fprintf(os.Stderr, "vulnfix: %v\n", err) + return 1 + } + defer func() { _ = f.Close() }() + + writeReport(f, fixes) + } return 0 } + +// writeReport writes a Markdown CVE report for fixes to w. +// Modules and their OSVs are sorted for deterministic output. +func writeReport(w io.Writer, fixes map[string]govulncheck.Fix) { + modules := make([]string, 0, len(fixes)) + for mod := range fixes { + modules = append(modules, mod) + } + sort.Strings(modules) + + var b strings.Builder + b.WriteString("# Vulnerability Report\n\n") + + for _, mod := range modules { + fix := fixes[mod] + fmt.Fprintf(&b, "## `%s` → `%s`\n\n", mod, fix.Version) + + osvs := fix.OSVs + sort.Slice(osvs, func(i, j int) bool { + return osvs[i].ID < osvs[j].ID + }) + + for _, o := range osvs { + // Heading: OSV ID with optional aliases. + if len(o.Aliases) > 0 { + fmt.Fprintf(&b, "### %s (%s)\n\n", o.ID, strings.Join(o.Aliases, ", ")) + } else { + fmt.Fprintf(&b, "### %s\n\n", o.ID) + } + + if o.Summary != "" { + fmt.Fprintf(&b, "%s\n\n", o.Summary) + } + + if len(o.References) > 0 { + b.WriteString("**References**\n\n") + for _, ref := range o.References { + fmt.Fprintf(&b, "- <%s>\n", ref) + } + b.WriteByte('\n') + } + } + } + _, _ = io.WriteString(w, b.String()) +}