diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..77ba8b2 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,57 @@ +// Package report renders a Markdown vulnerability report from a map of module fixes. +package report + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/hamba/vulnfix/internal/govulncheck" +) + +// Write renders a sorted Markdown vulnerability report to w. +// Modules are ordered alphabetically and their OSVs are sorted by ID, +// producing deterministic output regardless of map iteration order. +func Write(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()) +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..52cf376 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,120 @@ +package report_test + +import ( + "flag" + "os" + "strings" + "testing" + + "github.com/hamba/vulnfix/internal/govulncheck" + "github.com/hamba/vulnfix/internal/report" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var update = flag.Bool("update", false, "update golden files") + +func TestWrite(t *testing.T) { + tests := []struct { + name string + goldenFile string + fixes map[string]govulncheck.Fix + }{ + { + name: "multiple modules and OSVs are sorted alphabetically", + goldenFile: "testdata/multi.md.golden", + fixes: map[string]govulncheck.Fix{ + "stdlib": { + Version: "go1.22.3", + OSVs: []govulncheck.OSV{ + { + ID: "GO-2024-0002", + Aliases: []string{"CVE-2024-99999"}, + Summary: "HTTP/2 server memory exhaustion in stdlib", + References: []string{"https://pkg.go.dev/vuln/GO-2024-0002"}, + }, + }, + }, + "example.com/foo": { + Version: "v1.3.0", + OSVs: []govulncheck.OSV{ + { + ID: "GO-2024-0004", + Aliases: []string{"CVE-2024-12346"}, + Summary: "Denial of service in example.com/foo", + References: []string{"https://pkg.go.dev/vuln/GO-2024-0004"}, + }, + { + ID: "GO-2024-0001", + Aliases: []string{"CVE-2024-12345"}, + Summary: "Remote code execution via crafted input in example.com/foo", + References: []string{"https://pkg.go.dev/vuln/GO-2024-0001"}, + }, + }, + }, + "example.com/bar": { + Version: "v2.0.1", + OSVs: []govulncheck.OSV{ + { + ID: "GO-2024-0005", + Aliases: []string{"CVE-2024-77777"}, + Summary: "SQL injection in example.com/bar", + References: []string{"https://pkg.go.dev/vuln/GO-2024-0005"}, + }, + }, + }, + }, + }, + { + name: "OSV without aliases omits the parenthetical", + goldenFile: "testdata/no_aliases.md.golden", + fixes: map[string]govulncheck.Fix{ + "example.com/foo": { + Version: "v1.2.3", + OSVs: []govulncheck.OSV{ + { + ID: "GO-2024-0001", + Summary: "Remote code execution via crafted input in example.com/foo", + References: []string{"https://pkg.go.dev/vuln/GO-2024-0001"}, + }, + }, + }, + }, + }, + { + name: "OSV without summary or references renders only the heading", + goldenFile: "testdata/no_summary_no_references.md.golden", + fixes: map[string]govulncheck.Fix{ + "example.com/foo": { + Version: "v1.2.3", + OSVs: []govulncheck.OSV{ + { + ID: "GO-2024-0001", + Aliases: []string{"CVE-2024-12345"}, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf strings.Builder + report.Write(&buf, test.fixes) + got := buf.String() + + if *update { + require.NoError(t, os.WriteFile(test.goldenFile, []byte(got), 0o644)) + return + } + + wantBytes, err := os.ReadFile(test.goldenFile) + require.NoError(t, err) + + assert.Equal(t, string(wantBytes), got) + }) + } +} diff --git a/internal/report/testdata/multi.md.golden b/internal/report/testdata/multi.md.golden new file mode 100644 index 0000000..3269496 --- /dev/null +++ b/internal/report/testdata/multi.md.golden @@ -0,0 +1,40 @@ +# Vulnerability Report + +## `example.com/bar` → `v2.0.1` + +### GO-2024-0005 (CVE-2024-77777) + +SQL injection in example.com/bar + +**References** + +- + +## `example.com/foo` → `v1.3.0` + +### GO-2024-0001 (CVE-2024-12345) + +Remote code execution via crafted input in example.com/foo + +**References** + +- + +### GO-2024-0004 (CVE-2024-12346) + +Denial of service in example.com/foo + +**References** + +- + +## `stdlib` → `go1.22.3` + +### GO-2024-0002 (CVE-2024-99999) + +HTTP/2 server memory exhaustion in stdlib + +**References** + +- + diff --git a/internal/report/testdata/no_aliases.md.golden b/internal/report/testdata/no_aliases.md.golden new file mode 100644 index 0000000..5a3d95c --- /dev/null +++ b/internal/report/testdata/no_aliases.md.golden @@ -0,0 +1,12 @@ +# Vulnerability Report + +## `example.com/foo` → `v1.2.3` + +### GO-2024-0001 + +Remote code execution via crafted input in example.com/foo + +**References** + +- + diff --git a/internal/report/testdata/no_summary_no_references.md.golden b/internal/report/testdata/no_summary_no_references.md.golden new file mode 100644 index 0000000..161829a --- /dev/null +++ b/internal/report/testdata/no_summary_no_references.md.golden @@ -0,0 +1,6 @@ +# Vulnerability Report + +## `example.com/foo` → `v1.2.3` + +### GO-2024-0001 (CVE-2024-12345) + diff --git a/main.go b/main.go index 58064d5..80c97ee 100644 --- a/main.go +++ b/main.go @@ -5,15 +5,13 @@ import ( "context" "flag" "fmt" - "io" "os" "os/signal" - "sort" - "strings" "syscall" "github.com/hamba/vulnfix/internal/govulncheck" "github.com/hamba/vulnfix/internal/modfix" + "github.com/hamba/vulnfix/internal/report" ) func main() { @@ -56,52 +54,7 @@ func realMain() int { } defer func() { _ = f.Close() }() - writeReport(f, fixes) + report.Write(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()) -}