Repository: google/osv-scanner
Pull request #33 (2022-12-11 23:26:46)

Group json output
Write out grouped JSON field as proposed here: #29, also changes osvscanner library interface to also output these groups.


Patch:
diff --git a/README.md b/README.md
index 6ff6523..ff5e9d9 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ reason = "No external http servers are written in Go lang."
 By default osv-scanner outputs a human readable table. To have osv-scanner output JSON instead, pass the `--json` flag when calling osv-scanner. 
 
 ### Output Format
-```
+```json
 {
   "results": [
     {
@@ -147,13 +147,23 @@ By default osv-scanner outputs a human readable table. To have osv-scanner outpu
               "id": "GHSA-c3h9-896r-86jm",
               "aliases": [
                 "CVE-2021-3121"
-              ]
+              ],
+              // ... Full OSV
             },
             {
               "id": "GO-2021-0053",
               "aliases": [
                 "CVE-2021-3121",
                 "GHSA-c3h9-896r-86jm"
+              ],
+              // ... Full OSV
+            }
+          ],
+          "groups": [
+            {
+              "ids": [
+                "GHSA-c3h9-896r-86jm",
+                "GO-2021-0053"
               ]
             }
           ]
@@ -177,12 +187,22 @@ By default osv-scanner outputs a human readable table. To have osv-scanner outpu
               "id": "GHSA-m5pq-gvj9-9vr8",
               "aliases": [
                 "CVE-2022-24713"
-              ]
+              ],
+              // ... Full OSV
             },
             {
               "id": "RUSTSEC-2022-0013",
               "aliases": [
                 "CVE-2022-24713"
+              ],
+              // ... Full OSV
+            }
+          ],
+          "groups": [
+            {
+              "ids": [
+                "GHSA-m5pq-gvj9-9vr8",
+                "RUSTSEC-2022-0013"
               ]
             }
           ]
diff --git a/internal/grouper/grouper.go b/internal/grouper/grouper.go
index 74d5651..68cd034 100644
--- a/internal/grouper/grouper.go
+++ b/internal/grouper/grouper.go
@@ -16,8 +16,6 @@ func min(a, b int) int {
 	return b
 }
 
-type GroupedVulnerabilities []models.Vulnerability
-
 func hasAliasIntersection(v1, v2 models.Vulnerability) bool {
 	// Check if any aliases intersect.
 	for _, alias := range v1.Aliases {
@@ -30,7 +28,7 @@ func hasAliasIntersection(v1, v2 models.Vulnerability) bool {
 }
 
 // Group groups vulnerabilities by aliases.
-func Group(vulns []models.Vulnerability) []GroupedVulnerabilities {
+func Group(vulns []models.Vulnerability) []models.GroupInfo {
 	// Mapping of `vulns` index to a group ID. A group ID is just another index in the `vulns` slice.
 	groups := make([]int, len(vulns))
 
@@ -51,18 +49,18 @@ func Group(vulns []models.Vulnerability) []GroupedVulnerabilities {
 	}
 
 	// Extract groups into the final result structure.
-	extractedGroups := map[int]GroupedVulnerabilities{}
+	extractedGroups := map[int][]string{}
 	for i, gid := range groups {
-		extractedGroups[gid] = append(extractedGroups[gid], vulns[i])
+		extractedGroups[gid] = append(extractedGroups[gid], vulns[i].ID)
 	}
 
 	// Sort by group ID to maintain stable order for tests.
 	sortedKeys := maps.Keys(extractedGroups)
 	sort.Ints(sortedKeys)
 
-	var result []GroupedVulnerabilities
+	var result []models.GroupInfo
 	for _, key := range sortedKeys {
-		result = append(result, extractedGroups[key])
+		result = append(result, models.GroupInfo{IDs: extractedGroups[key]})
 	}
 	return result
 }
diff --git a/internal/output/reporter.go b/internal/output/reporter.go
index b9de4c0..b135f0b 100644
--- a/internal/output/reporter.go
+++ b/internal/output/reporter.go
@@ -3,6 +3,7 @@ package output
 import (
 	"fmt"
 	"io"
+	"strings"
 
 	"github.com/google/osv-scanner/pkg/models"
 )
@@ -21,6 +22,13 @@ func NewReporter(stdout io.Writer, stderr io.Writer, outputAsJSON bool) *Reporte
 	}
 }
 
+// NewVoidReporter creates a reporter that doesn't report to anywhere
+func NewVoidReporter() *Reporter {
+	stdout := new(strings.Builder)
+	stderr := new(strings.Builder)
+	return NewReporter(stdout, stderr, false)
+}
+
 // PrintError writes the given message to stderr, regardless of if the reporter
 // is outputting as JSON or not
 func (r *Reporter) PrintError(msg string) {
diff --git a/internal/output/table.go b/internal/output/table.go
index 73a729a..74c4f71 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -6,7 +6,6 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/google/osv-scanner/internal/grouper"
 	"github.com/google/osv-scanner/internal/osv"
 	"github.com/google/osv-scanner/pkg/models"
 
@@ -30,7 +29,8 @@ func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.
 					source.Path = sourcePath
 				}
 			}
-			for _, group := range grouper.Group(pkg.Vulnerabilities) {
+
+			for _, group := range pkg.Groups {
 				outputRow := table.Row{source.Path}
 				shouldMerge := false
 				if pkg.Package.Ecosystem == "GIT" {
@@ -43,9 +43,9 @@ func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.
 				var ids []string
 				var links []string
 
-				for _, vuln := range group {
-					ids = append(ids, vuln.ID)
-					links = append(links, osv.BaseVulnerabilityURL+text.Bold.EscapeSeq()+vuln.ID+text.Reset.EscapeSeq())
+				for _, vuln := range group.IDs {
+					ids = append(ids, vuln)
+					links = append(links, osv.BaseVulnerabilityURL+text.Bold.EscapeSeq()+vuln+text.Reset.EscapeSeq())
 				}
 
 				outputRow = append(outputRow, strings.Join(links, "\n"))
diff --git a/pkg/models/results.go b/pkg/models/results.go
index e42d4fd..16354b1 100644
--- a/pkg/models/results.go
+++ b/pkg/models/results.go
@@ -1,5 +1,7 @@
 package models
 
+import "time"
+
 // Combined vulnerabilities found for the scanned packages
 type VulnerabilityResults struct {
 	Results []PackageSource `json:"results"`
@@ -29,11 +31,39 @@ type VulnerabilityFlattened struct {
 	Vulnerability Vulnerability
 }
 
-// Vulnerability represents a vulnerability entry from OSV.
 type Vulnerability struct {
-	ID      string   `json:"id"`
-	Aliases []string `json:"aliases"`
-	// TODO(ochang): Add other fields.
+	SchemaVersion string    `json:"schema_version"`
+	ID            string    `json:"id"`
+	Modified      time.Time `json:"modified"`
+	Published     time.Time `json:"published"`
+	Aliases       []string  `json:"aliases"`
+	Summary       string    `json:"summary"`
+	Details       string    `json:"details"`
+	Affected      []struct {
+		Package struct {
+			Ecosystem string `json:"ecosystem,omitempty"`
+			Name      string `json:"name,omitempty"`
+			Purl      string `json:"purl,omitempty"`
+		} `json:"package"`
+		Ranges []struct {
+			Type   string `json:"type"`
+			Events []struct {
+				Introduced   string `json:"introduced,omitempty"`
+				Fixed        string `json:"fixed,omitempty"`
+				LastAffected string `json:"last_affected,omitempty"`
+				Limit        string `json:"limit,omitempty"`
+			} `json:"events"`
+			DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
+		} `json:"ranges"`
+		Versions          []string               `json:"versions,omitempty"`
+		DatabaseSpecific  map[string]interface{} `json:"database_specific,omitempty"`
+		EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"`
+	} `json:"affected"`
+	References []struct {
+		Type string `json:"type"`
+		URL  string `json:"url"`
+	} `json:"references"`
+	DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
 }
 
 type SourceInfo struct {
@@ -47,14 +77,19 @@ func (s SourceInfo) String() string {
 
 // Vulnerabilities grouped by sources
 type PackageSource struct {
-	Source   SourceInfo     `json:"packageSource"`
+	Source   SourceInfo     `json:"source"`
 	Packages []PackageVulns `json:"packages"`
 }
 
 // Vulnerabilities grouped by package
 type PackageVulns struct {
-	Package         PackageInfo
+	Package         PackageInfo     `json:"package"`
 	Vulnerabilities []Vulnerability `json:"vulnerabilities"`
+	Groups          []GroupInfo     `json:"groups"`
+}
+
+type GroupInfo struct {
+	IDs []string `json:"ids"`
 }
 
 // Specific package information
diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
index 62cf21c..db1d094 100644
--- a/pkg/osvscanner/osvscanner.go
+++ b/pkg/osvscanner/osvscanner.go
@@ -258,9 +258,7 @@ func filterResponse(r *output.Reporter, query osv.BatchedQuery, resp *osv.Batche
 // Perform osv scanner action, with optional reporter to output information
 func DoScan(actions ScannerActions, r *output.Reporter) (models.VulnerabilityResults, error) {
 	if r == nil {
-		stdout := new(strings.Builder)
-		stderr := new(strings.Builder)
-		r = output.NewReporter(stdout, stderr, false)
+		r = output.NewVoidReporter()
 	}
 
 	configManager := config.ConfigManager{
@@ -341,5 +339,5 @@ func DoScan(actions ScannerActions, r *output.Reporter) (models.VulnerabilityRes
 		return models.VulnerabilityResults{}, fmt.Errorf("failed to hydrate OSV response: %v", err)
 	}
 
-	return groupResponse(r, query, hydratedResp, &configManager), nil
+	return groupResponseBySource(r, query, hydratedResp), nil
 }
diff --git a/pkg/osvscanner/vulnerability_result.go b/pkg/osvscanner/vulnerability_result.go
index cc35a06..d06cacf 100644
--- a/pkg/osvscanner/vulnerability_result.go
+++ b/pkg/osvscanner/vulnerability_result.go
@@ -3,14 +3,15 @@ package osvscanner
 import (
 	"fmt"
 
+	"github.com/google/osv-scanner/internal/grouper"
 	"github.com/google/osv-scanner/internal/osv"
 	"github.com/google/osv-scanner/internal/output"
-	"github.com/google/osv-scanner/pkg/config"
 	"github.com/google/osv-scanner/pkg/models"
 )
 
-// Convert raw OSV API response into structured vulnerability information.
-func groupResponse(r *output.Reporter, query osv.BatchedQuery, resp *osv.HydratedBatchedResponse, configManager *config.ConfigManager) models.VulnerabilityResults {
+// groupResponseBySource converts raw OSV API response into structured vulnerability information
+// grouped by source location.
+func groupResponseBySource(r *output.Reporter, query osv.BatchedQuery, resp *osv.HydratedBatchedResponse) models.VulnerabilityResults {
 	output := models.VulnerabilityResults{
 		Results: []models.PackageSource{},
 	}
@@ -44,6 +45,8 @@ func groupResponse(r *output.Reporter, query osv.BatchedQuery, resp *osv.Hydrate
 		}
 
 		pkg.Vulnerabilities = response.Vulns
+
+		pkg.Groups = grouper.Group(pkg.Vulnerabilities)
 		groupedBySource[query.Source] = append(groupedBySource[query.Source], pkg)
 	}
 


Test patch:
diff --git a/internal/grouper/grouper_test.go b/internal/grouper/grouper_test.go
index dded526..a62a6cb 100644
--- a/internal/grouper/grouper_test.go
+++ b/internal/grouper/grouper_test.go
@@ -64,24 +64,24 @@ func TestGroup(t *testing.T) {
 
 	for _, tc := range []struct {
 		vulns []models.Vulnerability
-		want  []GroupedVulnerabilities
+		want  []models.GroupInfo
 	}{
 		{
 			vulns: []models.Vulnerability{
 				v1, v2, v3, v4, v5, v6, v7, v8,
 			},
-			want: []GroupedVulnerabilities{
+			want: []models.GroupInfo{
 				{
-					v1, v2, v3,
+					IDs: []string{v1.ID, v2.ID, v3.ID},
 				},
 				{
-					v4, v5, v6,
+					IDs: []string{v4.ID, v5.ID, v6.ID},
 				},
 				{
-					v7,
+					IDs: []string{v7.ID},
 				},
 				{
-					v8,
+					IDs: []string{v8.ID},
 				},
 			},
 		},
@@ -89,18 +89,18 @@ func TestGroup(t *testing.T) {
 			vulns: []models.Vulnerability{
 				v8, v2, v1, v5, v7, v4, v6, v3,
 			},
-			want: []GroupedVulnerabilities{
+			want: []models.GroupInfo{
 				{
-					v8,
+					IDs: []string{v8.ID},
 				},
 				{
-					v2, v1, v3,
+					IDs: []string{v2.ID, v1.ID, v3.ID},
 				},
 				{
-					v5, v4, v6,
+					IDs: []string{v5.ID, v4.ID, v6.ID},
 				},
 				{
-					v7,
+					IDs: []string{v7.ID},
 				},
 			},
 		},
diff --git a/pkg/osvscanner/vulnerability_result_test.go b/pkg/osvscanner/vulnerability_result_test.go
new file mode 100644
index 0000000..224e374
--- /dev/null
+++ b/pkg/osvscanner/vulnerability_result_test.go
@@ -0,0 +1,32 @@
+package osvscanner
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/internal/output"
+	"github.com/google/osv-scanner/pkg/config"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+func Test_groupResponseBySource(t *testing.T) {
+	type args struct {
+		r             *output.Reporter
+		query         osv.BatchedQuery
+		resp          *osv.HydratedBatchedResponse
+		configManager *config.ConfigManager
+	}
+	tests := []struct {
+		name string
+		args args
+		want models.VulnerabilityResults
+	}{}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := groupResponseBySource(tt.args.r, tt.args.query, tt.args.resp); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("groupResponse() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}