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
57 changes: 57 additions & 0 deletions internal/report/report.go
Original file line number Diff line number Diff line change
@@ -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())
}
120 changes: 120 additions & 0 deletions internal/report/report_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
40 changes: 40 additions & 0 deletions internal/report/testdata/multi.md.golden
Original file line number Diff line number Diff line change
@@ -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**

- <https://pkg.go.dev/vuln/GO-2024-0005>

## `example.com/foo` → `v1.3.0`

### GO-2024-0001 (CVE-2024-12345)

Remote code execution via crafted input in example.com/foo

**References**

- <https://pkg.go.dev/vuln/GO-2024-0001>

### GO-2024-0004 (CVE-2024-12346)

Denial of service in example.com/foo

**References**

- <https://pkg.go.dev/vuln/GO-2024-0004>

## `stdlib` → `go1.22.3`

### GO-2024-0002 (CVE-2024-99999)

HTTP/2 server memory exhaustion in stdlib

**References**

- <https://pkg.go.dev/vuln/GO-2024-0002>

12 changes: 12 additions & 0 deletions internal/report/testdata/no_aliases.md.golden
Original file line number Diff line number Diff line change
@@ -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**

- <https://pkg.go.dev/vuln/GO-2024-0001>

6 changes: 6 additions & 0 deletions internal/report/testdata/no_summary_no_references.md.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Vulnerability Report

## `example.com/foo` → `v1.2.3`

### GO-2024-0001 (CVE-2024-12345)

51 changes: 2 additions & 49 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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())
}
Loading