From 19a71708b0398c646d43e60bfbc3b8f5fd38698e Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Fri, 13 Sep 2024 16:59:26 -0400 Subject: [PATCH] add jvm version comparison Signed-off-by: Alex Goodman --- grype/version/constraint.go | 2 + grype/version/format.go | 28 ++++---- grype/version/jvm_constraint.go | 43 +++++++++++ grype/version/jvm_constraint_test.go | 69 ++++++++++++++++++ grype/version/jvm_version.go | 102 +++++++++++++++++++++++++++ grype/version/jvm_version_test.go | 101 ++++++++++++++++++++++++++ grype/version/version.go | 15 +++- internal/regex_helpers.go | 45 ++++++++++++ internal/regex_helpers_test.go | 70 ++++++++++++++++++ 9 files changed, 461 insertions(+), 14 deletions(-) create mode 100644 grype/version/jvm_constraint.go create mode 100644 grype/version/jvm_constraint_test.go create mode 100644 grype/version/jvm_version.go create mode 100644 grype/version/jvm_version_test.go create mode 100644 internal/regex_helpers.go create mode 100644 internal/regex_helpers_test.go diff --git a/grype/version/constraint.go b/grype/version/constraint.go index ec463ae53ef..00a83ce56e0 100644 --- a/grype/version/constraint.go +++ b/grype/version/constraint.go @@ -29,6 +29,8 @@ func GetConstraint(constStr string, format Format) (Constraint, error) { return newKBConstraint(constStr) case PortageFormat: return newPortageConstraint(constStr) + case JVMFormat: + return newJvmConstraint(constStr) case UnknownFormat: return newFuzzyConstraint(constStr, "unknown") } diff --git a/grype/version/format.go b/grype/version/format.go index 9164456e75a..3057f5b989f 100644 --- a/grype/version/format.go +++ b/grype/version/format.go @@ -18,6 +18,7 @@ const ( GemFormat PortageFormat GolangFormat + JVMFormat ) type Format int @@ -34,6 +35,7 @@ var formatStr = []string{ "Gem", "Portage", "Go", + "JVM", } var Formats = []Format{ @@ -46,6 +48,8 @@ var Formats = []Format{ KBFormat, GemFormat, PortageFormat, + GolangFormat, + JVMFormat, } func ParseFormat(userStr string) Format { @@ -70,35 +74,33 @@ func ParseFormat(userStr string) Format { return GemFormat case strings.ToLower(PortageFormat.String()), "portage": return PortageFormat + case strings.ToLower(JVMFormat.String()), "jvm", "jre", "jdk", "openjdk", "jep223": } return UnknownFormat } func FormatFromPkgType(t pkg.Type) Format { - var format Format switch t { case pkg.ApkPkg: - format = ApkFormat + return ApkFormat case pkg.DebPkg: - format = DebFormat + return DebFormat case pkg.JavaPkg: - format = MavenFormat + return MavenFormat case pkg.RpmPkg: - format = RpmFormat + return RpmFormat case pkg.GemPkg: - format = GemFormat + return GemFormat case pkg.PythonPkg: - format = PythonFormat + return PythonFormat case pkg.KbPkg: - format = KBFormat + return KBFormat case pkg.PortagePkg: - format = PortageFormat + return PortageFormat case pkg.GoModulePkg: - format = GolangFormat - default: - format = UnknownFormat + return GolangFormat } - return format + return UnknownFormat } func (f Format) String() string { diff --git a/grype/version/jvm_constraint.go b/grype/version/jvm_constraint.go new file mode 100644 index 00000000000..bae6e06436e --- /dev/null +++ b/grype/version/jvm_constraint.go @@ -0,0 +1,43 @@ +package version + +import "fmt" + +var _ Constraint = (*jvmConstraint)(nil) + +type jvmConstraint struct { + raw string + expression constraintExpression +} + +func newJvmConstraint(raw string) (jvmConstraint, error) { + constraints, err := newConstraintExpression(raw, newJvmComparator) + if err != nil { + return jvmConstraint{}, err + } + return jvmConstraint{ + expression: constraints, + raw: raw, + }, nil +} + +func (g jvmConstraint) String() string { + if g.raw == "" { + return "none (jvm)" + } + return fmt.Sprintf("%s (jvm)", g.raw) +} + +func (g jvmConstraint) Satisfied(version *Version) (bool, error) { + if g.raw == "" { + return true, nil // the empty constraint is always satisfied + } + return g.expression.satisfied(version) +} + +func newJvmComparator(unit constraintUnit) (Comparator, error) { + ver, err := newJvmVersion(unit.version) + if err != nil { + return nil, fmt.Errorf("unable to parse constraint version (%s): %w", unit.version, err) + } + return ver, nil +} diff --git a/grype/version/jvm_constraint_test.go b/grype/version/jvm_constraint_test.go new file mode 100644 index 00000000000..e767346f357 --- /dev/null +++ b/grype/version/jvm_constraint_test.go @@ -0,0 +1,69 @@ +package version + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestVersionConstraintJVM(t *testing.T) { + tests := []testCase{ + // pre jep 223 versions + {version: "1.7.0_80", constraint: "< 1.8.0", satisfied: true}, + {version: "1.8.0_131", constraint: "> 1.8.0", satisfied: true}, + {version: "1.8.0_131", constraint: "< 1.8.0_132", satisfied: true}, + {version: "1.8.0_131-b11", constraint: "< 1.8.0_132", satisfied: true}, + + {version: "1.7.0_80", constraint: "> 1.8.0", satisfied: false}, + {version: "1.8.0_131", constraint: "< 1.8.0", satisfied: false}, + {version: "1.8.0_131", constraint: "> 1.8.0_132", satisfied: false}, + {version: "1.8.0_131-b11", constraint: "> 1.8.0_132", satisfied: false}, + + {version: "1.7.0_80", constraint: "= 1.8.0", satisfied: false}, + {version: "1.8.0_131", constraint: "= 1.8.0", satisfied: false}, + {version: "1.8.0_131", constraint: "= 1.8.0_132", satisfied: false}, + {version: "1.8.0_131-b11", constraint: "= 1.8.0_132", satisfied: false}, + + {version: "1.8.0_80", constraint: "= 1.8.0_80", satisfied: true}, + {version: "1.8.0_131", constraint: ">= 1.8.0_131", satisfied: true}, + {version: "1.8.0_131", constraint: "= 1.8.0_131-b001", satisfied: true}, // builds should not matter + {version: "1.8.0_131-ea-b11", constraint: "= 1.8.0_131-ea", satisfied: true}, + + // jep 223 versions + {version: "8.0.4", constraint: "> 8.0.3", satisfied: true}, + {version: "8.0.4", constraint: "< 8.0.5", satisfied: true}, + {version: "9.0.0", constraint: "> 8.0.5", satisfied: true}, + {version: "9.0.0", constraint: "< 9.1.0", satisfied: true}, + {version: "11.0.4", constraint: "<= 11.0.4", satisfied: true}, + {version: "11.0.5", constraint: "> 11.0.4", satisfied: true}, + + {version: "8.0.4", constraint: "< 8.0.3", satisfied: false}, + {version: "8.0.4", constraint: "> 8.0.5", satisfied: false}, + {version: "9.0.0", constraint: "< 8.0.5", satisfied: false}, + {version: "9.0.0", constraint: "> 9.1.0", satisfied: false}, + {version: "11.0.4", constraint: "> 11.0.4", satisfied: false}, + {version: "11.0.5", constraint: "< 11.0.4", satisfied: false}, + + // mixed versions + {version: "1.8.0_131", constraint: "< 9.0.0", satisfied: true}, // 1.8.0_131 -> 8.0.131 + {version: "9.0.0", constraint: "> 1.8.0_131", satisfied: true}, // 1.8.0_131 -> 8.0.131 + {version: "1.8.0_131", constraint: "<= 8.0.131", satisfied: true}, + {version: "1.8.0_131", constraint: "> 7.0.79", satisfied: true}, + {version: "1.8.0_131", constraint: "= 8.0.131", satisfied: true}, + {version: "1.8.0_131", constraint: ">= 9.0.0", satisfied: false}, + {version: "9.0.1", constraint: "< 8.0.131", satisfied: false}, + + // pre-release versions + {version: "1.8.0_131-ea", constraint: "< 1.8.0_131", satisfied: true}, + {version: "1.8.0_131", constraint: "> 1.8.0_131-ea", satisfied: true}, + {version: "9.0.0-ea", constraint: "< 9.0.0", satisfied: true}, + {version: "9.0.0-ea", constraint: "> 1.8.0_131", satisfied: true}, + } + + for _, test := range tests { + t.Run(test.version+"_constraint_"+test.constraint, func(t *testing.T) { + constraint, err := newJvmConstraint(test.constraint) + require.NoError(t, err) + test.assertVersionConstraint(t, JVMFormat, constraint) + }) + } +} diff --git a/grype/version/jvm_version.go b/grype/version/jvm_version.go new file mode 100644 index 00000000000..b23cc882b9a --- /dev/null +++ b/grype/version/jvm_version.go @@ -0,0 +1,102 @@ +package version + +import ( + "fmt" + hashiVer "github.com/anchore/go-version" + "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/log" + "regexp" + "strings" +) + +var _ Comparator = (*jvmVersion)(nil) + +var preJep223VersionPattern = regexp.MustCompile(`^1\.(?P\d+)(\.(?P\d+)(_(?P\d+))?(-(?P[^b][^-]+))?(-b(?P\d+))?)?`) + +type jvmVersion struct { + isPreJep223 bool + semVer *hashiVer.Version +} + +func newJvmVersion(raw string) (*jvmVersion, error) { + isPreJep233 := strings.HasPrefix(raw, "1.") + + if isPreJep233 { + // convert the pre-JEP 223 version to semver + raw = convertPreJep223Version(raw) + } + verObj, err := hashiVer.NewVersion(raw) + if err != nil { + return nil, fmt.Errorf("unable to create semver obj for JVM version: %w", err) + } + + return &jvmVersion{ + isPreJep223: isPreJep233, + semVer: verObj, + }, nil +} + +func (v *jvmVersion) Compare(other *Version) (int, error) { + if other.Format != JVMFormat { + return -1, fmt.Errorf("unable to compare JVM to given format: %s", other.Format) + } + + if other.rich.jvmVersion == nil { + return -1, fmt.Errorf("given empty jvmVersion object") + } + + return other.rich.jvmVersion.compare(*v), nil +} + +func (v jvmVersion) compare(other jvmVersion) int { + return v.semVer.Compare(other.semVer) +} + +func convertPreJep223Version(version string) string { + // convert the following pre JEP 223 version strings to semvers + // 1.8.0_302-b08 --> 8.0.302+8 + // 1.9.0-ea-b19 --> 9.0.0-ea+19 + // NOTE: this makes an assumption that the old update field is the patch version in semver... + // this is NOT strictly in the spec, but for 1.8 this tends to be true (especially for temurin-based builds) + version = strings.TrimSpace(version) + + matches := internal.MatchNamedCaptureGroups(preJep223VersionPattern, version) + if len(matches) == 0 { + log.WithFields("version", version).Trace("unable to convert pre-JEP 223 JVM version") + return version + } + + // extract relevant parts from the matches + majorVersion := trim0sFromLeft(matches["major"]) + minorVersion := trim0sFromLeft(matches["minor"]) + patchVersion := trim0sFromLeft(matches["patch"]) + preRelease := trim0sFromLeft(matches["prerelease"]) + build := trim0sFromLeft(matches["build"]) + + if minorVersion == "" { + minorVersion = "0" + } + if patchVersion == "" { + patchVersion = "0" + } + + // build the semver string + var semver strings.Builder + semver.WriteString(fmt.Sprintf("%s.%s.%s", majorVersion, minorVersion, patchVersion)) + + if preRelease != "" { + semver.WriteString(fmt.Sprintf("-%s", preRelease)) + } + if build != "" { + semver.WriteString(fmt.Sprintf("+%s", build)) + } + + return semver.String() +} + +func trim0sFromLeft(v string) string { + if v == "0" { + return v + } + return strings.TrimLeft(v, "0") +} diff --git a/grype/version/jvm_version_test.go b/grype/version/jvm_version_test.go new file mode 100644 index 00000000000..b54b6fb15ab --- /dev/null +++ b/grype/version/jvm_version_test.go @@ -0,0 +1,101 @@ +package version + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestVersionJVM(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + // pre jep223 versions + {"1.8", "1.8.0", 0}, + {"1.8.0", "1.8.0_0", 0}, + {"1.8.0", "1.8.0", 0}, + {"1.7.0", "1.8.0", -1}, + {"1.8.0_131", "1.8.0_131", 0}, + {"1.8.0_131", "1.8.0_132", -1}, + + // builds should not matter + {"1.8.0_131", "1.8.0_130", 1}, + {"1.8.0_131", "1.8.0_132-b11", -1}, + {"1.8.0_131-b11", "1.8.0_132-b11", -1}, + {"1.8.0_131-b11", "1.8.0_131-b12", 0}, + {"1.8.0_131-b11", "1.8.0_131-b10", 0}, + {"1.8.0_131-b11", "1.8.0_131", 0}, + {"1.8.0_131-b11", "1.8.0_131-b11", 0}, + + // jep223 versions (semver) + {"8.0.4", "8.0.4", 0}, + {"8.0.4", "8.0.5", -1}, + {"8.0.4", "8.0.3", 1}, + {"8.0.4", "8.0.4+b1", 0}, + + // mix comparison + {"1.8.0_131", "8.0.4", 1}, // 1.8.0_131 --> 8.0.131 + {"8.0.4", "1.8.0_131", -1}, // doesn't matter which side the comparison is on + {"1.8.0_131-b002", "8.0.131+b2", 0}, // builds should not matter + {"1.8.0_131-b002", "8.0.131+b1", 0}, // builds should not matter + {"1.6.0", "8.0.1", -1}, // 1.6.0 --> 6.0.0 + + // prerelease + {"1.8.0_13-ea-b002", "1.8.0_13-ea-b001", 0}, + {"1.8.0_13-ea", "1.8.0_13-ea-b001", 0}, + {"1.8.0_13-ea-b002", "8.0.13-ea+b2", 0}, + {"1.8.0_13-ea-b002", "8.0.13+b2", -1}, + {"1.8.0_13-b002", "8.0.13-ea+b2", 1}, + + // pre 1.8 (when the jep 223 was introduced) + {"1.7.0", "7.0.0", 0}, // there is no v7 of the JVM, but we want to honor this comparison since it may be someone mistakenly using the wrong version format + } + + for _, test := range tests { + name := test.v1 + "_vs_" + test.v2 + t.Run(name, func(t *testing.T) { + v1, err := newJvmVersion(test.v1) + require.NotNil(t, v1) + require.NoError(t, err) + + v2, err := newJvmVersion(test.v2) + require.NotNil(t, v2) + require.NoError(t, err) + + actual := v1.compare(*v2) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestVersionJVM_invalid(t *testing.T) { + tests := []struct { + name string + version string + wantErr require.ErrorAssertionFunc + }{ + { + name: "outside of pre jep223 major version range", + version: "2.8.0_131-b11", + wantErr: require.Error, + }, + { + name: "invalid version", + version: "1.a", + wantErr: require.Error, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + v, err := newJvmVersion(tt.version) + assert.Nil(t, v) + tt.wantErr(t, err) + }) + } +} diff --git a/grype/version/version.go b/grype/version/version.go index 3819ecb22d8..6c7fcf4b281 100644 --- a/grype/version/version.go +++ b/grype/version/version.go @@ -28,6 +28,7 @@ type rich struct { kbVer *kbVersion portVer *portageVersion pep440version *pep440Version + jvmVersion *jvmVersion } func NewVersion(raw string, format Format) (*Version, error) { @@ -45,7 +46,15 @@ func NewVersion(raw string, format Format) (*Version, error) { } func NewVersionFromPkg(p pkg.Package) (*Version, error) { - ver, err := NewVersion(p.Version, FormatFromPkgType(p.Type)) + format := FormatFromPkgType(p.Type) + + if format == UnknownFormat { + if _, ok := p.Metadata.(pkg.JavaVMInstallationMetadata); ok { + format = JVMFormat + } + } + + ver, err := NewVersion(p.Version, format) if err != nil { return nil, err } @@ -96,6 +105,10 @@ func (v *Version) populate() error { ver := newPortageVersion(v.Raw) v.rich.portVer = &ver return nil + case JVMFormat: + ver, err := newJvmVersion(v.Raw) + v.rich.jvmVersion = ver + return err case UnknownFormat: // use the raw string + fuzzy constraint return nil diff --git a/internal/regex_helpers.go b/internal/regex_helpers.go new file mode 100644 index 00000000000..7130f21a89c --- /dev/null +++ b/internal/regex_helpers.go @@ -0,0 +1,45 @@ +package internal + +import "regexp" + +// MatchNamedCaptureGroups takes a regular expression and string and returns all of the named capture group results in a map. +// This is only for the first match in the regex. Callers shouldn't be providing regexes with multiple capture groups with the same name. +func MatchNamedCaptureGroups(regEx *regexp.Regexp, content string) map[string]string { + // note: we are looking across all matches and stopping on the first non-empty match. Why? Take the following example: + // input: "cool something to match against" pattern: `((?Pmatch) (?Pagainst))?`. Since the pattern is + // encapsulated in an optional capture group, there will be results for each character, but the results will match + // on nothing. The only "true" match will be at the end ("match against"). + allMatches := regEx.FindAllStringSubmatch(content, -1) + var results map[string]string + for _, match := range allMatches { + // fill a candidate results map with named capture group results, accepting empty values, but not groups with + // no names + for nameIdx, name := range regEx.SubexpNames() { + if nameIdx > len(match) || len(name) == 0 { + continue + } + if results == nil { + results = make(map[string]string) + } + results[name] = match[nameIdx] + } + // note: since we are looking for the first best potential match we should stop when we find the first one + // with non-empty results. + if !isEmptyMap(results) { + break + } + } + return results +} + +func isEmptyMap(m map[string]string) bool { + if len(m) == 0 { + return true + } + for _, value := range m { + if value != "" { + return false + } + } + return true +} diff --git a/internal/regex_helpers_test.go b/internal/regex_helpers_test.go new file mode 100644 index 00000000000..1c483775309 --- /dev/null +++ b/internal/regex_helpers_test.go @@ -0,0 +1,70 @@ +package internal + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatchCaptureGroups(t *testing.T) { + tests := []struct { + name string + input string + pattern string + expected map[string]string + }{ + { + name: "go-case", + input: "match this thing", + pattern: `(?Pmatch).*(?Pthing)`, + expected: map[string]string{ + "name": "match", + "version": "thing", + }, + }, + { + name: "only matches the first instance", + input: "match this thing batch another think", + pattern: `(?P[mb]atch).*?(?Pthin[gk])`, + expected: map[string]string{ + "name": "match", + "version": "thing", + }, + }, + { + name: "nested capture groups", + input: "cool something to match against", + pattern: `((?Pmatch) (?Pagainst))`, + expected: map[string]string{ + "name": "match", + "version": "against", + }, + }, + { + name: "nested optional capture groups", + input: "cool something to match against", + pattern: `((?Pmatch) (?Pagainst))?`, + expected: map[string]string{ + "name": "match", + "version": "against", + }, + }, + { + name: "nested optional capture groups with larger match", + input: "cool something to match against match never", + pattern: `.*?((?Pmatch) (?P(against|never)))?`, + expected: map[string]string{ + "name": "match", + "version": "against", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := MatchNamedCaptureGroups(regexp.MustCompile(test.pattern), test.input) + assert.Equal(t, test.expected, actual) + }) + } +}