Allow for CPE strings that can later be sanitized (#844)

This commit is contained in:
Alex Goodman 2022-02-23 15:18:12 -05:00 committed by GitHub
parent 256e85bc12
commit 7eea98fcc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 104 additions and 20 deletions

View file

@ -10,6 +10,10 @@ import (
type CPE = wfn.Attributes
const (
allowedCPEPunctuation = "-!\"#$%&'()+,./:;<=>@[]^`{|}~"
)
// 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
@ -20,12 +24,35 @@ const cpeRegexString = ((`^([c][pP][eE]:/[AHOaho]?(:[A-Za-z0-9\._\-~%]*){0,6})`)
var cpeRegex = regexp.MustCompile(cpeRegexString)
// NewCPE 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 NewCPE(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 := newCPEWithoutValidation(cpeStr)
if err != nil {
return CPE{}, fmt.Errorf("unable to prase CPE string: %w", err)
}
// ensure that this CPE can be validated after being fully sanitized
if validateCPEString(CPEString(c)) != nil {
return CPE{}, err
}
// we don't return the sanitized string, as this is a concern for later when creating CPE 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
}
func validateCPEString(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
if !cpeRegex.MatchString(cpeStr) {
return CPE{}, fmt.Errorf("failed to parse CPE=%q as it doesn't match the regex=%s", cpeStr, cpeRegexString)
return fmt.Errorf("failed to parse CPE=%q as it doesn't match the regex=%s", cpeStr, cpeRegexString)
}
return nil
}
func newCPEWithoutValidation(cpeStr string) (CPE, error) {
value, err := wfn.Parse(cpeStr)
if err != nil {
return CPE{}, fmt.Errorf("failed to parse CPE=%q: %w", cpeStr, err)
@ -60,6 +87,9 @@ func MustCPE(cpeStr string) CPE {
}
func normalizeCpeField(field string) string {
// replace spaces with underscores (per section 5.3.2 of the CPE spec v 2.3)
field = strings.ReplaceAll(field, " ", "_")
// keep dashes and forward slashes unescaped
if field == "*" {
return wfn.Any
@ -71,10 +101,9 @@ func normalizeCpeField(field string) string {
// It correctly removes slashes that are followed by allowed puncts.
// This is to allow for a correct round trip parsing of cpes with quoted characters.
func stripSlashes(s string) string {
const allowedPunct = "-!\"#$%&'()+,./:;<=>@[]^`{|}!~"
sb := strings.Builder{}
for i, c := range s {
if c == '\\' && i+1 < len(s) && strings.ContainsRune(allowedPunct, rune(s[i+1])) {
if c == '\\' && i+1 < len(s) && strings.ContainsRune(allowedCPEPunctuation, rune(s[i+1])) {
continue
} else {
sb.WriteRune(c)
@ -110,12 +139,13 @@ func CPEString(c CPE) string {
// end up becoming "prefix" instead causing loss of information and
// incorrect CPEs being generated.
func sanitize(s string) string {
const allowedPunct = "-!\"#$%&'()+,./:;<=>@[]^`{|}!~"
// replace spaces with underscores
in := strings.ReplaceAll(s, " ", "_")
// escape allowable punctuation per section 5.3.2 in the CPE 2.3 spec
sb := strings.Builder{}
for _, c := range in {
if strings.ContainsRune(allowedPunct, c) {
if strings.ContainsRune(allowedCPEPunctuation, c) {
sb.WriteRune('\\')
}
sb.WriteRune(c)

View file

@ -4,9 +4,11 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func must(c CPE, e error) CPE {
@ -91,10 +93,9 @@ func Test_CPEParser(t *testing.T) {
WFN CPE `json:"wfn"`
}{}
out, err := ioutil.ReadFile("test-fixtures/cpe-data.json")
if err != nil {
t.Fatal("Unable to read test-fixtures/cpe-data.json: ", err)
}
json.Unmarshal(out, &testCases)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(out, &testCases))
for _, test := range testCases {
t.Run(test.CPEString, func(t *testing.T) {
c1, err := NewCPE(test.CPEString)
@ -110,19 +111,72 @@ func Test_CPEParser(t *testing.T) {
}
func Test_InvalidCPE(t *testing.T) {
testCases := []string{
"cpe:2.3:a:some-vendor:name:1:3.2:*:*:*:*:*:*:*",
"cpe:2.3:a:some-vendor:name:1^:*:*:*:*:*:*:*",
"cpe:2.3:a:some-vendor:name:**:*:*:*:*:*:*:*",
"cpe:2.3:a:some-vendor:name:*\\:*:*:*:*:*:*:*",
type testcase struct {
name string
in string
expected string
expectedErr bool
}
for _, test := range testCases {
t.Run(test, func(t *testing.T) {
_, err := NewCPE(test)
assert.Error(t, err)
assert.Contains(t, fmt.Sprint(err), "regex")
tests := []testcase{
{
// 5.3.2: The underscore (x5f) MAY be used, and it SHOULD be used in place of whitespace characters (which SHALL NOT be used)
name: "translates spaces",
in: "cpe:2.3:a:some-vendor:name:1 2:*:*:*:*:*:*:*",
expected: "cpe:2.3:a:some-vendor:name:1_2:*:*:*:*:*:*:*",
},
{
// it isn't easily possible in the string formatted string to detect improper escaping of : (it will fail parsing)
name: "unescaped ':' cannot be helped -- too many fields",
in: "cpe:2.3:a:some-vendor:name:::*:*:*:*:*:*:*",
expectedErr: true,
},
{
name: "too few fields",
in: "cpe:2.3:a:some-vendor:name:*:*:*:*:*:*:*",
expected: "cpe:2.3:a:some-vendor:name:*:*:*:*:*:*:*:*",
},
// Note: though the CPE spec does not allow for ? and * as escaped character input, these seem to be allowed in
// the NVD CPE validator for this reason these edge cases were removed
}
// the wfn library does not account for escapes of . and -
exceptions := ".-"
// it isn't easily possible in the string formatted string to detect improper escaping of : (it will fail parsing)
skip := ":"
// make escape exceptions for section 5.3.2 of the CPE spec (2.3)
for _, char := range allowedCPEPunctuation {
if strings.Contains(skip, string(char)) {
continue
}
in := fmt.Sprintf("cpe:2.3:a:some-vendor:name:*:%s:*:*:*:*:*:*", string(char))
exp := fmt.Sprintf(`cpe:2.3:a:some-vendor:name:*:\%s:*:*:*:*:*:*`, string(char))
if strings.Contains(exceptions, string(char)) {
exp = in
}
tests = append(tests, testcase{
name: fmt.Sprintf("allowes future escape of character (%s)", string(char)),
in: in,
expected: exp,
expectedErr: false,
})
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
c, err := NewCPE(test.in)
if test.expectedErr {
assert.Error(t, err)
if t.Failed() {
t.Logf("got CPE: %q details: %+v", CPEString(c), c)
}
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, CPEString(c))
})
}
}