feat: Record where CPEs come from (#2552)

Syft can get CPEs from several source, including generating them based on
package data, finding them in the NVD CPE dictionary, or finding them declared
in a manifest or existing SBOM. Record where Syft got CPEs so that consumers of
SBOMs can reason about how trustworthy they are.

Signed-off-by: Will Murphy <will.murphy@anchore.com>
This commit is contained in:
William Murphy
2024-02-02 16:17:52 +00:00
committed by GitHub
parent 4fe50f4169
commit b7a6d5e946
47 changed files with 2674 additions and 287 deletions
+39
View File
@@ -0,0 +1,39 @@
package cpe
import "sort"
type BySourceThenSpecificity []CPE
func (b BySourceThenSpecificity) Len() int {
return len(b)
}
func (b BySourceThenSpecificity) Less(i, j int) bool {
sourceOrder := map[Source]int{
NVDDictionaryLookupSource: 1,
DeclaredSource: 2,
GeneratedSource: 3,
}
getRank := func(source Source) int {
if rank, exists := sourceOrder[source]; exists {
return rank
}
return 4 // Sourced we don't know about can't be assigned special priority, so
// are considered ties.
}
iSource := b[i].Source
jSource := b[j].Source
rankI, rankJ := getRank(iSource), getRank(jSource)
if rankI != rankJ {
return rankI < rankJ
}
return isMoreSpecific(b[i].Attributes, b[j].Attributes)
}
func (b BySourceThenSpecificity) Swap(i, j int) {
b[i], b[j] = b[j], b[i]
}
var _ sort.Interface = (*BySourceThenSpecificity)(nil)
@@ -0,0 +1,74 @@
package cpe
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBySourceThenSpecificity(t *testing.T) {
tests := []struct {
name string
input []CPE
want []CPE
}{
{
name: "empty case",
},
{
name: "nvd before generated",
input: []CPE{
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", GeneratedSource),
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
},
want: []CPE{
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", GeneratedSource),
},
},
{
name: "declared before generated",
input: []CPE{
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", GeneratedSource),
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", DeclaredSource),
},
want: []CPE{
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", DeclaredSource),
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*", GeneratedSource),
},
},
{
name: "most specific attributes of equal sources",
input: []CPE{
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*", NVDDictionaryLookupSource),
},
want: []CPE{
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
},
},
{
name: "most specific attributes of unknown sources",
input: []CPE{
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", ""),
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*", "some-other-unknown-source"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", "some-unknown-source"),
},
want: []CPE{
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*", "some-other-unknown-source"),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", ""),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", "some-unknown-source"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sort.Sort(BySourceThenSpecificity(tt.input))
assert.Equal(t, tt.want, tt.input)
})
}
}
+23 -16
View File
@@ -6,15 +6,22 @@ import (
var _ sort.Interface = (*BySpecificity)(nil)
type BySpecificity []CPE
type BySpecificity []Attributes
func (c BySpecificity) Len() int { return len(c) }
func (c BySpecificity) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c BySpecificity) Less(i, j int) bool {
iScore := weightedCountForSpecifiedFields(c[i])
jScore := weightedCountForSpecifiedFields(c[j])
return isMoreSpecific(c[i], c[j])
}
// Returns true if i is more specific than j, with some
// tie breaking mechanisms to make sorting equally-specific cpe Attributes
// deterministic.
func isMoreSpecific(i, j Attributes) bool {
iScore := weightedCountForSpecifiedFields(i)
jScore := weightedCountForSpecifiedFields(j)
// check weighted sort first
if iScore != jScore {
@@ -22,28 +29,28 @@ func (c BySpecificity) Less(i, j int) bool {
}
// sort longer fields to top
if countFieldLength(c[i]) != countFieldLength(c[j]) {
return countFieldLength(c[i]) > countFieldLength(c[j])
if countFieldLength(i) != countFieldLength(j) {
return countFieldLength(i) > countFieldLength(j)
}
// if score and length are equal then text sort
// note that we are not using String from the syft pkg
// as we are not encoding/decoding this CPE string so we don't
// need the proper quoted version of the CPE.
return c[i].BindToFmtString() < c[j].BindToFmtString()
// as we are not encoding/decoding this Attributes string so we don't
// need the proper quoted version of the Attributes.
return i.BindToFmtString() < j.BindToFmtString()
}
func countFieldLength(cpe CPE) int {
func countFieldLength(cpe Attributes) int {
return len(cpe.Part + cpe.Vendor + cpe.Product + cpe.Version + cpe.TargetSW)
}
func weightedCountForSpecifiedFields(cpe CPE) int {
checksForSpecifiedField := []func(cpe CPE) (bool, int){
func(cpe CPE) (bool, int) { return cpe.Part != "", 2 },
func(cpe CPE) (bool, int) { return cpe.Vendor != "", 3 },
func(cpe CPE) (bool, int) { return cpe.Product != "", 4 },
func(cpe CPE) (bool, int) { return cpe.Version != "", 1 },
func(cpe CPE) (bool, int) { return cpe.TargetSW != "", 1 },
func weightedCountForSpecifiedFields(cpe Attributes) int {
checksForSpecifiedField := []func(cpe Attributes) (bool, int){
func(cpe Attributes) (bool, int) { return cpe.Part != "", 2 },
func(cpe Attributes) (bool, int) { return cpe.Vendor != "", 3 },
func(cpe Attributes) (bool, int) { return cpe.Product != "", 4 },
func(cpe Attributes) (bool, int) { return cpe.Version != "", 1 },
func(cpe Attributes) (bool, int) { return cpe.TargetSW != "", 1 },
}
weightedCount := 0
+56 -56
View File
@@ -10,81 +10,81 @@ import (
func Test_BySpecificity(t *testing.T) {
tests := []struct {
name string
input []CPE
expected []CPE
input []Attributes
expected []Attributes
}{
{
name: "sort strictly by wfn *",
input: []CPE{
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:some:*:*"),
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
input: []Attributes{
MustAttributes("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:*:package:1:*:*:*:*:some:*:*"),
MustAttributes("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*"),
MustAttributes("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
},
expected: []CPE{
Must("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*"),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:some:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
expected: []Attributes{
MustAttributes("cpe:2.3:a:some:package:1:*:*:*:*:some:*:*"),
MustAttributes("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:*:package:1:*:*:*:*:some:*:*"),
MustAttributes("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
},
},
{
name: "sort strictly by field length",
input: []CPE{
Must("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:333:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:666666:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:4444:*:*"),
input: []Attributes{
MustAttributes("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:333:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:666666:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:4444:*:*"),
},
expected: []CPE{
Must("cpe:2.3:a:1:666666:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:4444:*:*"),
Must("cpe:2.3:a:1:1:333:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
expected: []Attributes{
MustAttributes("cpe:2.3:a:1:666666:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:4444:*:*"),
MustAttributes("cpe:2.3:a:1:1:333:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
},
},
{
name: "sort by mix of field length and specificity",
input: []CPE{
Must("cpe:2.3:a:1:666666:*:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:*:1:1:*:*:*:*:4444:*:*"),
Must("cpe:2.3:a:1:*:333:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
input: []Attributes{
MustAttributes("cpe:2.3:a:1:666666:*:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:*:1:1:*:*:*:*:4444:*:*"),
MustAttributes("cpe:2.3:a:1:*:333:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
},
expected: []CPE{
Must("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:1:666666:*:*:*:*:*:1:*:*"),
Must("cpe:2.3:a:*:1:1:*:*:*:*:4444:*:*"),
Must("cpe:2.3:a:1:*:333:*:*:*:*:*:*:*"),
expected: []Attributes{
MustAttributes("cpe:2.3:a:55555:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:22:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:1:1:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:1:666666:*:*:*:*:*:1:*:*"),
MustAttributes("cpe:2.3:a:*:1:1:*:*:*:*:4444:*:*"),
MustAttributes("cpe:2.3:a:1:*:333:*:*:*:*:*:*:*"),
},
},
{
name: "sort by mix of field length, specificity, dash",
input: []CPE{
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine_keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine-keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine-keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine_keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
input: []Attributes{
MustAttributes("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine_keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine-keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine-keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine_keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
},
expected: []CPE{
Must("cpe:2.3:a:alpine-keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine-keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine_keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine_keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
expected: []Attributes{
MustAttributes("cpe:2.3:a:alpine-keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine-keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine_keys:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine_keys:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine:alpine-keys:2.3-r1:*:*:*:*:*:*:*"),
MustAttributes("cpe:2.3:a:alpine:alpine_keys:2.3-r1:*:*:*:*:*:*:*"),
},
},
}
+69 -30
View File
@@ -8,9 +8,29 @@ import (
"github.com/facebookincubator/nvdtools/wfn"
)
// CPE contains the attributes of an NVD Attributes and a string
// describing where Syft got the Attributes, e.g. generated by heuristics
// vs looked up in the NVD Attributes dictionary
type CPE struct {
Attributes Attributes
Source Source
}
type Source string
func (c Source) String() string {
return string(c)
}
const (
GeneratedSource Source = "syft-generated"
NVDDictionaryLookupSource Source = "nvd-cpe-dictionary"
DeclaredSource Source = "declared"
)
const Any = ""
type CPE struct {
type Attributes struct {
Part string
Vendor string
Product string
@@ -24,19 +44,19 @@ type CPE struct {
Language string
}
func (c CPE) asAttributes() wfn.Attributes {
func (c Attributes) asAttributes() wfn.Attributes {
return wfn.Attributes(c)
}
func fromAttributes(a wfn.Attributes) CPE {
return CPE(a)
func fromAttributes(a wfn.Attributes) Attributes {
return Attributes(a)
}
func (c CPE) BindToFmtString() string {
func (c Attributes) BindToFmtString() string {
return c.asAttributes().BindToFmtString()
}
func NewWithAny() CPE {
func NewWithAny() Attributes {
return fromAttributes(*(wfn.NewAttributesWithAny()))
}
@@ -46,36 +66,55 @@ const (
// This regex string is taken from
// https://csrc.nist.gov/schema/cpe/2.3/cpe-naming_2.3.xsd which has the official cpe spec
// This first part matches CPE urls and the second part matches binding strings
// This first part matches Attributes urls and the second part matches binding strings
const cpeRegexString = ((`^([c][pP][eE]:/[AHOaho]?(:[A-Za-z0-9\._\-~%]*){0,6})`) +
// Or match the CPE binding string
// Or match the Attributes binding string
// Note that we had to replace '`' with '\x60' to escape the backticks
`|(cpe:2\.3:[aho\*\-](:(((\?*|\*?)([a-zA-Z0-9\-\._]|(\\[\\\*\?!"#$$%&'\(\)\+,/:;<=>@\[\]\^\x60\{\|}~]))+(\?*|\*?))|[\*\-])){5}(:(([a-zA-Z]{2,3}(-([a-zA-Z]{2}|[0-9]{3}))?)|[\*\-]))(:(((\?*|\*?)([a-zA-Z0-9\-\._]|(\\[\\\*\?!"#$$%&'\(\)\+,/:;<=>@\[\]\^\x60\{\|}~]))+(\?*|\*?))|[\*\-])){4})$`)
var cpeRegex = regexp.MustCompile(cpeRegexString)
// New will parse a formatted CPE string and return a CPE object. Some input, such as the existence of whitespace
// characters is allowed, however, a more strict validation is done after this sanitization process.
func New(cpeStr string) (CPE, error) {
// get a CPE object based on the given string --don't validate yet since it may be possible to escape select cases on the callers behalf
c, err := newWithoutValidation(cpeStr)
func New(value string, source Source) (CPE, error) {
attributes, err := NewAttributes(value)
if err != nil {
return CPE{}, fmt.Errorf("unable to parse CPE string: %w", err)
}
// ensure that this CPE can be validated after being fully sanitized
if ValidateString(c.String()) != nil {
return CPE{}, err
}
return CPE{
Attributes: attributes,
Source: source,
}, nil
}
// we don't return the sanitized string, as this is a concern for later when creating CPE strings. In fact, since
// NewAttributes will parse a formatted Attributes string and return a Attributes object. Some input, such as the existence of whitespace
// characters is allowed, however, a more strict validation is done after this sanitization process.
func NewAttributes(cpeStr string) (Attributes, error) {
// get a Attributes object based on the given string --don't validate yet since it may be possible to escape select cases on the callers behalf
c, err := newWithoutValidation(cpeStr)
if err != nil {
return Attributes{}, fmt.Errorf("unable to parse Attributes string: %w", err)
}
// ensure that this Attributes can be validated after being fully sanitized
if ValidateString(c.String()) != nil {
return Attributes{}, err
}
// we don't return the sanitized string, as this is a concern for later when creating Attributes strings. In fact, since
// sanitization is lossy (whitespace is replaced, not escaped) it's important that the raw values are left as.
return c, nil
}
// Must returns a CPE or panics if the provided string is not valid
func Must(cpeStr string) CPE {
c, err := New(cpeStr)
func Must(cpeStr string, source Source) CPE {
c := MustAttributes(cpeStr)
return CPE{
Attributes: c,
Source: source,
}
}
func MustAttributes(cpeStr string) Attributes {
c, err := NewAttributes(cpeStr)
if err != nil {
panic(err)
}
@@ -83,22 +122,22 @@ func Must(cpeStr string) CPE {
}
func ValidateString(cpeStr string) error {
// We should filter out all CPEs that do not match the official CPE regex
// The facebook nvdtools parser can sometimes incorrectly parse invalid CPE strings
// We should filter out all CPEs that do not match the official Attributes regex
// The facebook nvdtools parser can sometimes incorrectly parse invalid Attributes strings
if !cpeRegex.MatchString(cpeStr) {
return fmt.Errorf("failed to parse CPE=%q as it doesn't match the regex=%s", cpeStr, cpeRegexString)
return fmt.Errorf("failed to parse Attributes=%q as it doesn't match the regex=%s", cpeStr, cpeRegexString)
}
return nil
}
func newWithoutValidation(cpeStr string) (CPE, error) {
func newWithoutValidation(cpeStr string) (Attributes, error) {
value, err := wfn.Parse(cpeStr)
if err != nil {
return CPE{}, fmt.Errorf("failed to parse CPE=%q: %w", cpeStr, err)
return Attributes{}, fmt.Errorf("failed to parse Attributes=%q: %w", cpeStr, err)
}
if value == nil {
return CPE{}, fmt.Errorf("failed to parse CPE=%q", cpeStr)
return Attributes{}, fmt.Errorf("failed to parse Attributes=%q", cpeStr)
}
syftCPE := fromAttributes(*value)
@@ -120,7 +159,7 @@ func newWithoutValidation(cpeStr string) (CPE, error) {
}
func normalizeField(field string) string {
// replace spaces with underscores (per section 5.3.2 of the CPE spec v 2.3)
// replace spaces with underscores (per section 5.3.2 of the Attributes spec v 2.3)
field = strings.ReplaceAll(field, " ", "_")
// keep dashes and forward slashes unescaped
@@ -144,8 +183,8 @@ func stripSlashes(s string) string {
return sb.String()
}
func (c CPE) String() string {
output := CPE{}
func (c Attributes) String() string {
output := Attributes{}
output.Vendor = sanitize(c.Vendor)
output.Product = sanitize(c.Product)
output.Language = sanitize(c.Language)
+26 -26
View File
@@ -12,38 +12,38 @@ import (
"github.com/stretchr/testify/require"
)
func Test_New(t *testing.T) {
func Test_NewAttributes(t *testing.T) {
tests := []struct {
name string
input string
expected CPE
expected Attributes
}{
{
name: "gocase",
input: `cpe:/a:10web:form_maker:1.0.0::~~~wordpress~~`,
expected: Must(`cpe:2.3:a:10web:form_maker:1.0.0:*:*:*:*:wordpress:*:*`),
expected: MustAttributes(`cpe:2.3:a:10web:form_maker:1.0.0:*:*:*:*:wordpress:*:*`),
},
{
name: "dashes",
input: `cpe:/a:7-zip:7-zip:4.56:beta:~~~windows~~`,
expected: Must(`cpe:2.3:a:7-zip:7-zip:4.56:beta:*:*:*:windows:*:*`),
expected: MustAttributes(`cpe:2.3:a:7-zip:7-zip:4.56:beta:*:*:*:windows:*:*`),
},
{
name: "URL escape characters",
input: `cpe:/a:%240.99_kindle_books_project:%240.99_kindle_books:6::~~~android~~`,
expected: Must(`cpe:2.3:a:\$0.99_kindle_books_project:\$0.99_kindle_books:6:*:*:*:*:android:*:*`),
expected: MustAttributes(`cpe:2.3:a:\$0.99_kindle_books_project:\$0.99_kindle_books:6:*:*:*:*:android:*:*`),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := New(test.input)
actual, err := NewAttributes(test.input)
if err != nil {
t.Fatalf("got an error while creating CPE: %+v", err)
t.Fatalf("got an error while creating Attributes: %+v", err)
}
if d := cmp.Diff(actual, test.expected); d != "" {
t.Errorf("CPE mismatch (-want +got):\n%s", d)
t.Errorf("Attributes mismatch (-want +got):\n%s", d)
}
})
@@ -82,9 +82,9 @@ func Test_normalizeCpeField(t *testing.T) {
func Test_CPEParser(t *testing.T) {
var testCases []struct {
CPEString string `json:"cpe-string"`
CPEUrl string `json:"cpe-url"`
WFN CPE `json:"wfn"`
CPEString string `json:"cpe-string"`
CPEUrl string `json:"cpe-url"`
WFN Attributes `json:"wfn"`
}
out, err := os.ReadFile("test-fixtures/cpe-data.json")
require.NoError(t, err)
@@ -92,9 +92,9 @@ func Test_CPEParser(t *testing.T) {
for _, test := range testCases {
t.Run(test.CPEString, func(t *testing.T) {
c1, err := New(test.CPEString)
c1, err := NewAttributes(test.CPEString)
assert.NoError(t, err)
c2, err := New(test.CPEUrl)
c2, err := NewAttributes(test.CPEUrl)
assert.NoError(t, err)
assert.Equal(t, c1, c2)
assert.Equal(t, c1, test.WFN)
@@ -161,11 +161,11 @@ func Test_InvalidCPE(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
c, err := New(test.in)
c, err := NewAttributes(test.in)
if test.expectedErr {
assert.Error(t, err)
if t.Failed() {
t.Logf("got CPE: %q details: %+v", c, c)
t.Logf("got Attributes: %q details: %+v", c, c)
}
return
}
@@ -179,12 +179,12 @@ func Test_RoundTrip(t *testing.T) {
tests := []struct {
name string
cpe string
parsedCPE CPE
parsedCPE Attributes
}{
{
name: "normal",
cpe: "cpe:2.3:a:some-vendor:name:3.2:*:*:*:*:*:*:*",
parsedCPE: CPE{
parsedCPE: Attributes{
Part: "a",
Vendor: "some-vendor",
Product: "name",
@@ -194,7 +194,7 @@ func Test_RoundTrip(t *testing.T) {
{
name: "escaped colon",
cpe: "cpe:2.3:a:some-vendor:name:1\\:3.2:*:*:*:*:*:*:*",
parsedCPE: CPE{
parsedCPE: Attributes{
Part: "a",
Vendor: "some-vendor",
Product: "name",
@@ -204,7 +204,7 @@ func Test_RoundTrip(t *testing.T) {
{
name: "escaped forward slash",
cpe: "cpe:2.3:a:test\\/some-vendor:name:3.2:*:*:*:*:*:*:*",
parsedCPE: CPE{
parsedCPE: Attributes{
Part: "a",
Vendor: "test/some-vendor",
Product: "name",
@@ -215,13 +215,13 @@ func Test_RoundTrip(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// CPE string must be preserved through a round trip
assert.Equal(t, test.cpe, Must(test.cpe).String())
// The parsed CPE must be the same after a round trip
assert.Equal(t, Must(test.cpe), Must(Must(test.cpe).String()))
// The test case parsed CPE must be the same after parsing the input string
assert.Equal(t, test.parsedCPE, Must(test.cpe))
// The test case parsed CPE must produce the same string as the input cpe
// Attributes string must be preserved through a round trip
assert.Equal(t, test.cpe, MustAttributes(test.cpe).String())
// The parsed Attributes must be the same after a round trip
assert.Equal(t, MustAttributes(test.cpe), MustAttributes(MustAttributes(test.cpe).String()))
// The test case parsed Attributes must be the same after parsing the input string
assert.Equal(t, test.parsedCPE, MustAttributes(test.cpe))
// The test case parsed Attributes must produce the same string as the input cpe
assert.Equal(t, test.parsedCPE.String(), test.cpe)
})
}
+17 -15
View File
@@ -1,25 +1,27 @@
package cpe
import (
"fmt"
"sort"
)
func Merge(a, b []CPE) (result []CPE) {
aCPEs := make(map[string]CPE)
// keep all CPEs from a and create a quick string-based lookup
for _, aCPE := range a {
aCPEs[aCPE.BindToFmtString()] = aCPE
result = append(result, aCPE)
// Merge returns unique SourcedCPEs that are found in A or B
// Two SourcedCPEs are identical if their source and normalized string are identical
func Merge(a, b []CPE) []CPE {
var result []CPE
dedupe := make(map[string]CPE)
key := func(scpe CPE) string {
return fmt.Sprintf("%s:%s", scpe.Source.String(), scpe.Attributes.BindToFmtString())
}
// keep all unique CPEs from b
for _, bCPE := range b {
if _, exists := aCPEs[bCPE.BindToFmtString()]; !exists {
result = append(result, bCPE)
}
for _, s := range a {
dedupe[key(s)] = s
}
sort.Sort(BySpecificity(result))
for _, s := range b {
dedupe[key(s)] = s
}
for _, val := range dedupe {
result = append(result, val)
}
sort.Sort(BySourceThenSpecificity(result))
return result
}
+9 -7
View File
@@ -16,18 +16,20 @@ func Test_Merge(t *testing.T) {
name: "merge, removing duplicates and ordered",
input: [][]CPE{
{
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*", DeclaredSource),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", GeneratedSource),
},
{
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", DeclaredSource),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", GeneratedSource),
},
},
expected: []CPE{
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*"),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*", NVDDictionaryLookupSource),
Must("cpe:2.3:a:some:package:1:*:*:*:*:*:*:*", DeclaredSource),
Must("cpe:2.3:a:*:package:1:*:*:*:*:*:*:*", DeclaredSource),
Must("cpe:2.3:a:some:package:*:*:*:*:*:*:*:*", GeneratedSource),
},
},
}