Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module>@<version>` 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@<version>` |
| `toolchain` | Updates the `toolchain` directive via `go get toolchain@<version>` |
| everything else | Regular `go get <module>@<version>` |
136 changes: 105 additions & 31 deletions internal/govulncheck/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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"`
}
102 changes: 74 additions & 28 deletions internal/govulncheck/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}

Expand All @@ -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")
}
2 changes: 1 addition & 1 deletion internal/govulncheck/testdata/module.json
Original file line number Diff line number Diff line change
@@ -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"}]}}

8 changes: 4 additions & 4 deletions internal/govulncheck/testdata/multi.json
Original file line number Diff line number Diff line change
@@ -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"}]}}
Expand Down
2 changes: 1 addition & 1 deletion internal/govulncheck/testdata/stdlib.json
Original file line number Diff line number Diff line change
@@ -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"}]}}

2 changes: 1 addition & 1 deletion internal/govulncheck/testdata/toolchain.json
Original file line number Diff line number Diff line change
@@ -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"}]}}

21 changes: 10 additions & 11 deletions internal/modfix/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
Loading
Loading