Skip to content

Commit 8102d9d

Browse files
committed
Add vex.MergeDocuments() function
This commit addsa a new vex.MergeDocumentsWithOptions() function which is a port of the function created for vexctl. The idea is that projects using the library can merge docs without needing to import the vexctl CLI. Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]>
1 parent 5dfa450 commit 8102d9d

File tree

2 files changed

+115
-2
lines changed

2 files changed

+115
-2
lines changed

pkg/vex/functions_documents.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,122 @@ SPDX-License-Identifier: Apache-2.0
66
package vex
77

88
import (
9+
"crypto/sha256"
10+
"errors"
11+
"fmt"
912
"sort"
13+
"strings"
1014
)
1115

16+
type MergeOptions struct {
17+
DocumentID string // ID to use in the new document
18+
Author string // Author to use in the new document
19+
AuthorRole string // Role of the document author
20+
Products []string // Product IDs to consider
21+
Vulnerabilities []string // IDs of vulnerabilities to merge
22+
}
23+
24+
// MergeDocuments is a convenience wrapper over MergeDocumentsWithOptions
25+
// that does not take options.
26+
func MergeDocuments(docs []*VEX) (*VEX, error) {
27+
return MergeDocumentsWithOptions(&MergeOptions{}, docs)
28+
}
29+
30+
// Merge combines the statements from a number of documents into
31+
// a new one, preserving time context from each of them.
32+
func MergeDocumentsWithOptions(mergeOpts *MergeOptions, docs []*VEX) (*VEX, error) {
33+
if len(docs) == 0 {
34+
return nil, fmt.Errorf("at least one vex document is required to merge")
35+
}
36+
37+
docID := mergeOpts.DocumentID
38+
// If no document id is specified we compute a
39+
// deterministic ID using the merged docs
40+
if docID == "" {
41+
ids := []string{}
42+
for i, d := range docs {
43+
if d.ID == "" {
44+
ids = append(ids, fmt.Sprintf("VEX-DOC-%d", i))
45+
} else {
46+
ids = append(ids, d.ID)
47+
}
48+
}
49+
50+
sort.Strings(ids)
51+
h := sha256.New()
52+
h.Write([]byte(strings.Join(ids, ":")))
53+
// Hash the sorted IDs list
54+
docID = fmt.Sprintf("merged-vex-%x", h.Sum(nil))
55+
}
56+
57+
newDoc := New()
58+
59+
newDoc.ID = docID
60+
if author := mergeOpts.Author; author != "" {
61+
newDoc.Author = author
62+
}
63+
if authorRole := mergeOpts.AuthorRole; authorRole != "" {
64+
newDoc.AuthorRole = authorRole
65+
}
66+
67+
ss := []Statement{}
68+
69+
// Create an inverse dict of products and vulnerabilities to filter
70+
// these will only be used if ids to filter on are defined in the options.
71+
iProds := map[string]struct{}{}
72+
iVulns := map[string]struct{}{}
73+
for _, id := range mergeOpts.Products {
74+
iProds[id] = struct{}{}
75+
}
76+
for _, id := range mergeOpts.Vulnerabilities {
77+
iVulns[id] = struct{}{}
78+
}
79+
80+
for _, doc := range docs {
81+
for _, s := range doc.Statements { //nolint:gocritic // this IS supposed to copy
82+
matchesProduct := false
83+
for id := range iProds {
84+
if s.MatchesProduct(id, "") {
85+
matchesProduct = true
86+
break
87+
}
88+
}
89+
if len(iProds) > 0 && !matchesProduct {
90+
continue
91+
}
92+
93+
matchesVuln := false
94+
for id := range iVulns {
95+
if s.Vulnerability.Matches(id) {
96+
matchesVuln = true
97+
break
98+
}
99+
}
100+
if len(iVulns) > 0 && !matchesVuln {
101+
continue
102+
}
103+
104+
// If statement does not have a timestamp, cascade
105+
// the timestamp down from the document.
106+
// See https://github.com/chainguard-dev/vex/issues/49
107+
if s.Timestamp == nil {
108+
if doc.Timestamp == nil {
109+
return nil, errors.New("unable to cascade timestamp from doc to timeless statement")
110+
}
111+
s.Timestamp = doc.Timestamp
112+
}
113+
114+
ss = append(ss, s)
115+
}
116+
}
117+
118+
SortStatements(ss, *newDoc.Metadata.Timestamp)
119+
120+
newDoc.Statements = ss
121+
122+
return &newDoc, nil
123+
}
124+
12125
// SortDocuments sorts and returns a slice of documents based on their date.
13126
// VEXes should be applied sequentially in chronological order as they capture
14127
// knowledge about an artifact as it changes over time.

pkg/vex/functions_documents_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"github.com/stretchr/testify/require"
1212
)
1313

14-
func TestMerge(t *testing.T) {
14+
func TestMergeDocumentsWithOptions(t *testing.T) {
1515
doc1, err := Open("testdata/v001-1.vex.json")
1616
require.NoError(t, err)
1717
doc2, err := Open("testdata/v001-2.vex.json")
@@ -84,7 +84,7 @@ func TestMerge(t *testing.T) {
8484
shouldErr: false,
8585
},
8686
} {
87-
doc, err := MergeDocuments(&tc.opts, tc.docs)
87+
doc, err := MergeDocumentsWithOptions(&tc.opts, tc.docs)
8888
if tc.shouldErr {
8989
require.Error(t, err)
9090
continue

0 commit comments

Comments
 (0)