Skip to content

Commit 42c786b

Browse files
authored
fix: UnmarshalText/MarshalText for VendorProduct should handle escaping colons (#4699)
also refactored cpe-repo-gen so that vendor-product info is defined in one place
1 parent 3b3967e commit 42c786b

3 files changed

Lines changed: 163 additions & 43 deletions

File tree

vulnfeeds/cmd/mirrors/cpe-repo-gen/main.go

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -77,37 +77,23 @@ type CPEFeed struct {
7777
Products []CPEProduct `json:"products"`
7878
}
7979

80-
// VendorProduct contains a CPE's Vendor and Product strings.
81-
type VendorProduct struct {
82-
Vendor string
83-
Product string
84-
}
85-
8680
// VendorProducts in this denylist are known non-OSS and/or have generic
8781
// product names, which cause undesired and incorrect repository attribution
8882
// when resolved via Debian copyright metadata.
89-
var DebianCopyrightDenylist = []VendorProduct{
90-
{"apple", "pdfkit"},
91-
{"f-secure", "safe"},
92-
{"ibm", "workflow"},
93-
{"inductiveautomation", "ignition"},
94-
{"jetbrains", "hub"},
95-
{"microsoft", "onedrive"},
96-
{"mirametrix", "glance"},
97-
{"nintext", "workflow"},
98-
{"oracle", "workflow"},
99-
{"thrivethemes", "ignition"},
100-
{"vmware", "horizon"},
83+
var DebianCopyrightDenylist = []cves.VendorProduct{
84+
{Vendor: "apple", Product: "pdfkit"},
85+
{Vendor: "f-secure", Product: "safe"},
86+
{Vendor: "ibm", Product: "workflow"},
87+
{Vendor: "inductiveautomation", Product: "ignition"},
88+
{Vendor: "jetbrains", Product: "hub"},
89+
{Vendor: "microsoft", Product: "onedrive"},
90+
{Vendor: "mirametrix", Product: "glance"},
91+
{Vendor: "nintext", Product: "workflow"},
92+
{Vendor: "oracle", Product: "workflow"},
93+
{Vendor: "thrivethemes", Product: "ignition"},
94+
{Vendor: "vmware", Product: "horizon"},
10195
}
10296

103-
// MarshalText is a helper for JSON rendering of a map with a struct key.
104-
func (vp VendorProduct) MarshalText() ([]byte, error) { //nolint:unparam
105-
return []byte(vp.Vendor + ":" + vp.Product), nil
106-
}
107-
108-
// VendorProductToRepoMap maps a VendorProduct to a repo URL.
109-
type VendorProductToRepoMap map[VendorProduct][]string
110-
11197
const (
11298
OutputDirDefault = "."
11399
projectID = "oss-vdb"
@@ -158,7 +144,7 @@ func LoadCPEsFromJSONDir(dir string) ([]CPE, error) {
158144
}
159145

160146
// Outputs a JSON file of the product-to-repo map.
161-
func outputProductToRepoMap(prm VendorProductToRepoMap, f io.Writer) error {
147+
func outputProductToRepoMap(prm cves.VendorProductToRepoMap, f io.Writer) error {
162148
productsWithoutRepos := 0
163149
for p := range prm {
164150
if len(prm[p]) == 0 {
@@ -331,10 +317,10 @@ func MaybeGetSourceRepoFromDebian(mdir string, pkg string) string {
331317
}
332318

333319
// Analyze CPE Dictionary and return a product-to-repo map and a reference description frequency table.
334-
func analyzeCPEDictionary(cpes []CPE) (productToRepo VendorProductToRepoMap, descriptionFrequency map[string]int) {
335-
productToRepo = make(VendorProductToRepoMap)
320+
func analyzeCPEDictionary(cpes []CPE) (productToRepo cves.VendorProductToRepoMap, descriptionFrequency map[string]int) {
321+
productToRepo = make(cves.VendorProductToRepoMap)
336322
descriptionFrequency = make(map[string]int)
337-
MaybeTryDebian := make(map[VendorProduct]bool)
323+
MaybeTryDebian := make(map[cves.VendorProduct]bool)
338324
for _, c := range cpes {
339325
if c.Deprecated {
340326
logger.Info("Skipping deprecated", slog.String("cpe", c.Name))
@@ -360,26 +346,26 @@ func analyzeCPEDictionary(cpes []CPE) (productToRepo VendorProductToRepoMap, des
360346
repo = strings.ToLower(repo)
361347
}
362348
// If we already have an entry for this repo, don't add it again.
363-
if slices.Contains(productToRepo[VendorProduct{parsedCPE.Vendor, parsedCPE.Product}], repo) {
349+
if slices.Contains(productToRepo[cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}], repo) {
364350
continue
365351
}
366352
logger.Info("Liking", slog.String("repo", repo), slog.String("vendor", parsedCPE.Vendor), slog.String("product", parsedCPE.Product), slog.String("type", r.Type))
367-
productToRepo[VendorProduct{parsedCPE.Vendor, parsedCPE.Product}] = append(productToRepo[VendorProduct{parsedCPE.Vendor, parsedCPE.Product}], repo)
353+
productToRepo[cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}] = append(productToRepo[cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}], repo)
368354
// If this was queued for trying to find via Debian, and subsequently found, dequeue it.
369355
if *DebianMetadataPath != "" {
370-
delete(MaybeTryDebian, VendorProduct{parsedCPE.Vendor, parsedCPE.Product})
356+
delete(MaybeTryDebian, cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product})
371357
}
372358
}
373359
// If we've arrived to this point, we've exhausted the
374360
// references and not calculated any repos for the product,
375361
// flag for trying Debian afterwards.
376362
// We may encounter another CPE item that *does* have a viable reference in the meantime.
377-
if len(productToRepo[VendorProduct{parsedCPE.Vendor, parsedCPE.Product}]) == 0 && *DebianMetadataPath != "" {
363+
if len(productToRepo[cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}]) == 0 && *DebianMetadataPath != "" {
378364
// Check the denylist though.
379-
if slices.Contains(DebianCopyrightDenylist, VendorProduct{parsedCPE.Vendor, parsedCPE.Product}) {
365+
if slices.Contains(DebianCopyrightDenylist, cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}) {
380366
continue
381367
}
382-
MaybeTryDebian[VendorProduct{parsedCPE.Vendor, parsedCPE.Product}] = true
368+
MaybeTryDebian[cves.VendorProduct{Vendor: parsedCPE.Vendor, Product: parsedCPE.Product}] = true
383369
}
384370
}
385371
// Try any Debian possible ones as a last resort.
@@ -403,7 +389,7 @@ func analyzeCPEDictionary(cpes []CPE) (productToRepo VendorProductToRepoMap, des
403389
logger.Info("Disregarding derived repo as unusable", slog.String("repo", repo), slog.String("vendor", vp.Vendor), slog.String("product", vp.Product))
404390
continue
405391
}
406-
productToRepo[VendorProduct{vp.Vendor, vp.Product}] = append(productToRepo[VendorProduct{vp.Vendor, vp.Product}], repo)
392+
productToRepo[cves.VendorProduct{Vendor: vp.Vendor, Product: vp.Product}] = append(productToRepo[cves.VendorProduct{Vendor: vp.Vendor, Product: vp.Product}], repo)
407393
}
408394
}
409395
}
@@ -412,8 +398,8 @@ func analyzeCPEDictionary(cpes []CPE) (productToRepo VendorProductToRepoMap, des
412398
}
413399

414400
// validateRepos takes a VendorProductToRepoMap and removes any entries where the repository fails remote validation.
415-
func validateRepos(prm VendorProductToRepoMap) (validated VendorProductToRepoMap) {
416-
validated = make(VendorProductToRepoMap)
401+
func validateRepos(prm cves.VendorProductToRepoMap) (validated cves.VendorProductToRepoMap) {
402+
validated = make(cves.VendorProductToRepoMap)
417403
logger.Info("Validating repos", slog.Int("products", len(prm)))
418404
// This is likely to be time consuming, so give an impatient log watcher something to gauge progress by.
419405
entryCount := 0

vulnfeeds/cves/versions.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -895,15 +895,27 @@ func ParseCPE(formattedString string) (*models.CPEString, error) {
895895

896896
func (vp *VendorProduct) UnmarshalText(text []byte) error {
897897
s := strings.Split(string(text), ":")
898-
if len(s) < 2 {
899-
return fmt.Errorf("expected at least 2 parts, got %d", len(s))
898+
if len(s) != 2 {
899+
return fmt.Errorf("expected exactly 2 parts, got %d", len(s))
900+
}
901+
var err error
902+
vp.Vendor, err = url.QueryUnescape(s[0])
903+
if err != nil {
904+
return err
905+
}
906+
vp.Product, err = url.QueryUnescape(s[1])
907+
if err != nil {
908+
return err
900909
}
901-
vp.Vendor = s[0]
902-
vp.Product = s[1]
903910

904911
return nil
905912
}
906913

914+
// MarshalText is a helper for JSON rendering of a map with a struct key.
915+
func (vp VendorProduct) MarshalText() ([]byte, error) {
916+
return []byte(url.QueryEscape(vp.Vendor) + ":" + url.QueryEscape(vp.Product)), nil
917+
}
918+
907919
func RefAcceptable(ref models.Reference, tagDenyList []string) bool {
908920
for _, deniedTag := range tagDenyList {
909921
if slices.Contains(ref.Tags, deniedTag) {

vulnfeeds/cves/versions_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,3 +1677,125 @@ func TestBuildVersionRange(t *testing.T) {
16771677
})
16781678
}
16791679
}
1680+
1681+
func TestVendorProduct_MarshalText(t *testing.T) {
1682+
tests := []struct {
1683+
name string
1684+
vp VendorProduct
1685+
want string
1686+
wantErr bool
1687+
}{
1688+
{
1689+
name: "simple",
1690+
vp: VendorProduct{
1691+
Vendor: "google",
1692+
Product: "chrome",
1693+
},
1694+
want: "google:chrome",
1695+
},
1696+
{
1697+
name: "with spaces",
1698+
vp: VendorProduct{
1699+
Vendor: "adobe",
1700+
Product: "acrobat reader",
1701+
},
1702+
want: "adobe:acrobat+reader",
1703+
},
1704+
{
1705+
name: "with colons",
1706+
vp: VendorProduct{
1707+
Vendor: "foo:bar",
1708+
Product: "baz",
1709+
},
1710+
want: "foo%3Abar:baz",
1711+
},
1712+
{
1713+
name: "empty",
1714+
vp: VendorProduct{
1715+
Vendor: "",
1716+
Product: "",
1717+
},
1718+
want: ":",
1719+
},
1720+
}
1721+
for _, tt := range tests {
1722+
t.Run(tt.name, func(t *testing.T) {
1723+
got, err := tt.vp.MarshalText()
1724+
if (err != nil) != tt.wantErr {
1725+
t.Errorf("VendorProduct.MarshalText() error = %v, wantErr %v", err, tt.wantErr)
1726+
return
1727+
}
1728+
if string(got) != tt.want {
1729+
t.Errorf("VendorProduct.MarshalText() = %v, want %v", string(got), tt.want)
1730+
}
1731+
})
1732+
}
1733+
}
1734+
1735+
func TestVendorProduct_UnmarshalText(t *testing.T) {
1736+
tests := []struct {
1737+
name string
1738+
text string
1739+
want VendorProduct
1740+
wantErr bool
1741+
}{
1742+
{
1743+
name: "simple",
1744+
text: "google:chrome",
1745+
want: VendorProduct{
1746+
Vendor: "google",
1747+
Product: "chrome",
1748+
},
1749+
},
1750+
{
1751+
name: "with spaces",
1752+
text: "adobe:acrobat+reader",
1753+
want: VendorProduct{
1754+
Vendor: "adobe",
1755+
Product: "acrobat reader",
1756+
},
1757+
},
1758+
{
1759+
name: "with colons encoded",
1760+
text: "foo%3Abar:baz",
1761+
want: VendorProduct{
1762+
Vendor: "foo:bar",
1763+
Product: "baz",
1764+
},
1765+
},
1766+
{
1767+
name: "empty",
1768+
text: ":",
1769+
want: VendorProduct{
1770+
Vendor: "",
1771+
Product: "",
1772+
},
1773+
},
1774+
{
1775+
name: "no colon",
1776+
text: "invalid",
1777+
wantErr: true,
1778+
},
1779+
{
1780+
name: "too many colons",
1781+
text: "too:many:colons",
1782+
wantErr: true,
1783+
},
1784+
{
1785+
name: "bad encoding",
1786+
text: "bad%encoding:foo",
1787+
wantErr: true,
1788+
},
1789+
}
1790+
for _, tt := range tests {
1791+
t.Run(tt.name, func(t *testing.T) {
1792+
var vp VendorProduct
1793+
if err := vp.UnmarshalText([]byte(tt.text)); (err != nil) != tt.wantErr {
1794+
t.Errorf("VendorProduct.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr)
1795+
}
1796+
if !tt.wantErr && vp != tt.want {
1797+
t.Errorf("VendorProduct.UnmarshalText() = %v, want %v", vp, tt.want)
1798+
}
1799+
})
1800+
}
1801+
}

0 commit comments

Comments
 (0)