diff --git a/syft/pkg/cataloger/java/archive_parser.go b/syft/pkg/cataloger/java/archive_parser.go index e54b73696..0ec5b0054 100644 --- a/syft/pkg/cataloger/java/archive_parser.go +++ b/syft/pkg/cataloger/java/archive_parser.go @@ -9,6 +9,7 @@ import ( "iter" "os" "path" + "regexp" "slices" "strings" @@ -301,6 +302,9 @@ func (j *archiveParser) discoverNameVersionLicense(ctx context.Context, manifest if version == "" { version = selectVersion(manifest, j.fileInfo) } + if version == "" { + version = j.versionFromPropertiesFile(ctx, manifest) + } if len(lics) == 0 { fileLicenses := j.getLicenseFromFileInArchive(ctx) @@ -609,6 +613,44 @@ func (j *archiveParser) getLicenseFromFileInArchive(ctx context.Context) []pkg.L return identified } +// optional "v" + leading digit + version-ish chars; rejects placeholders like "${revision}" +var releaseVersionPattern = regexp.MustCompile(`^v?(\d[0-9A-Za-z.\-_+]*)$`) + +// versionFromPropertiesFile: last-resort ver from root version.properties for uber-jars built +// outside maven/gradle e.g. metabase, "tag=v0.63.5", shading can land a deps file at t same +// path, so be conservative => Main-Class jars only, "version"/"tag" keys only, version shaped values only +func (j *archiveParser) versionFromPropertiesFile(ctx context.Context, manifest *pkg.JavaManifest) string { + if manifest == nil || manifest.Main.MustGet("Main-Class") == "" { + return "" + } + + matches := j.fileManifest.GlobMatch(true, "/version.properties") + if len(matches) != 1 { + return "" + } + + contents, err := intFile.ContentsFromZip(ctx, j.archivePath, matches...) + if err != nil { + log.Debugf("unable to extract version.properties (%s): %v", j.location, err) + return "" + } + + props := map[string]string{} + for line := range strings.Lines(contents[matches[0]]) { + if k, v, ok := strings.Cut(line, "="); ok { + props[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v) + } + } + + for _, key := range []string{"version", "tag"} { + if m := releaseVersionPattern.FindStringSubmatch(props[key]); m != nil { + return m[1] + } + } + + return "" +} + func (j *archiveParser) discoverPkgsFromNestedArchives(ctx context.Context, parentPkg *pkg.Package) ([]pkg.Package, []artifact.Relationship, error) { // we know that all java archives are zip formatted files, so we can use the shared zip helper return discoverPkgsFromZip(ctx, j.location, j.archivePath, j.contentPath, j.fileManifest, parentPkg, j.cfg) diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/Makefile b/syft/pkg/cataloger/java/testdata/jar-metadata/Makefile index 5cdb05b1e..5d9daaaba 100644 --- a/syft/pkg/cataloger/java/testdata/jar-metadata/Makefile +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/Makefile @@ -15,6 +15,9 @@ MULTIPLE_MATCHING = multiple-matching-2.11.5 ORG_MULTIPLE_THENAME = org.multiple-thename MICRONAUT_AOP = micronaut-aop-4.9.11 COMMONS_LANG3 = commons-lang3-3.12.0 +UBER_APP_VERSION_PROPERTIES = uber-app-version-properties +UBER_APP_VERSION_PLACEHOLDER = uber-app-version-placeholder +LIB_WITH_VERSION_PROPERTIES = lib-with-version-properties .DEFAULT_GOAL := fixtures @@ -24,7 +27,7 @@ fixtures: $(CACHE_DIR) # requirement 2: 'fingerprint' goal to determine if the fixture input that indicates any existing cache should be busted fingerprint: $(FINGERPRINT_FILE) -$(CACHE_DIR): $(CACHE_DIR)/$(JACKSON_CORE).jar $(CACHE_DIR)/$(SBT_JACKSON_CORE).jar $(CACHE_DIR)/$(OPENSAML_CORE).jar $(CACHE_DIR)/$(API_ALL_SOURCES).jar $(CACHE_DIR)/$(SPRING_INSTRUMENTATION).jar $(CACHE_DIR)/$(MULTIPLE_MATCHING).jar $(CACHE_DIR)/$(MICRONAUT_AOP).jar $(CACHE_DIR)/$(COMMONS_LANG3).jar +$(CACHE_DIR): $(CACHE_DIR)/$(JACKSON_CORE).jar $(CACHE_DIR)/$(SBT_JACKSON_CORE).jar $(CACHE_DIR)/$(OPENSAML_CORE).jar $(CACHE_DIR)/$(API_ALL_SOURCES).jar $(CACHE_DIR)/$(SPRING_INSTRUMENTATION).jar $(CACHE_DIR)/$(MULTIPLE_MATCHING).jar $(CACHE_DIR)/$(MICRONAUT_AOP).jar $(CACHE_DIR)/$(COMMONS_LANG3).jar $(CACHE_DIR)/$(UBER_APP_VERSION_PROPERTIES).jar $(CACHE_DIR)/$(UBER_APP_VERSION_PLACEHOLDER).jar $(CACHE_DIR)/$(LIB_WITH_VERSION_PROPERTIES).jar $(CACHE_DIR)/$(JACKSON_CORE).jar: mkdir -p $(CACHE_DIR) @@ -62,6 +65,18 @@ $(CACHE_DIR)/$(COMMONS_LANG3).jar: mkdir -p $(CACHE_DIR) cd $(COMMONS_LANG3) && zip -r $(CACHE_PATH)/$(COMMONS_LANG3).jar . +$(CACHE_DIR)/$(UBER_APP_VERSION_PROPERTIES).jar: + mkdir -p $(CACHE_DIR) + cd $(UBER_APP_VERSION_PROPERTIES) && zip -r $(CACHE_PATH)/$(UBER_APP_VERSION_PROPERTIES).jar . + +$(CACHE_DIR)/$(UBER_APP_VERSION_PLACEHOLDER).jar: + mkdir -p $(CACHE_DIR) + cd $(UBER_APP_VERSION_PLACEHOLDER) && zip -r $(CACHE_PATH)/$(UBER_APP_VERSION_PLACEHOLDER).jar . + +$(CACHE_DIR)/$(LIB_WITH_VERSION_PROPERTIES).jar: + mkdir -p $(CACHE_DIR) + cd $(LIB_WITH_VERSION_PROPERTIES) && zip -r $(CACHE_PATH)/$(LIB_WITH_VERSION_PROPERTIES).jar . + # Jenkins plugins typically do not have the version included in the archive name, # so it is important to not include it in the generated test fixture $(CACHE_DIR)/gradle.hpi: diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/META-INF/MANIFEST.MF b/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/META-INF/MANIFEST.MF new file mode 100644 index 000000000..826e1c76b --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Created-By: Apache Maven + diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/version.properties b/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/version.properties new file mode 100644 index 000000000..e252ccc11 --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/lib-with-version-properties/version.properties @@ -0,0 +1 @@ +version=1.2.3 diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/META-INF/MANIFEST.MF b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/META-INF/MANIFEST.MF new file mode 100644 index 000000000..22b39ee02 --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Created-By: build.clj +Main-Class: example.core.bootstrap + diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/version.properties b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/version.properties new file mode 100644 index 000000000..ec854c15d --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-placeholder/version.properties @@ -0,0 +1,2 @@ +version=${revision} +build.date=${build_timestamp} diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/META-INF/MANIFEST.MF b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/META-INF/MANIFEST.MF new file mode 100644 index 000000000..6f80f7c3b --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/META-INF/MANIFEST.MF @@ -0,0 +1,6 @@ +Manifest-Version: 1.0 +Multi-Release: true +Created-By: build.clj +Build-Jdk-Spec: 25 +Main-Class: example.core.bootstrap + diff --git a/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/version.properties b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/version.properties new file mode 100644 index 000000000..cf3400354 --- /dev/null +++ b/syft/pkg/cataloger/java/testdata/jar-metadata/uber-app-version-properties/version.properties @@ -0,0 +1,3 @@ +tag=v0.63.5 +hash=1dca717 +date=2026-08-05 diff --git a/syft/pkg/cataloger/java/version_properties_test.go b/syft/pkg/cataloger/java/version_properties_test.go new file mode 100644 index 000000000..2b9680cf1 --- /dev/null +++ b/syft/pkg/cataloger/java/version_properties_test.go @@ -0,0 +1,124 @@ +package java + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" +) + +func readManifest(t *testing.T, ctx context.Context, parser *archiveParser) *pkg.JavaManifest { + t.Helper() + + matches := parser.fileManifest.GlobMatch(false, manifestGlob) + require.Len(t, matches, 1) + + contents, err := intFile.ContentsFromZip(ctx, parser.archivePath, matches...) + require.NoError(t, err) + + manifest, err := parseJavaManifest(parser.archivePath, strings.NewReader(contents[matches[0]])) + require.NoError(t, err) + + return manifest +} + +func Test_archiveParser_versionFromPropertiesFile(t *testing.T) { + tests := []struct { + name string + fixture string + want string + }{ + { + // "tag=v0.63.5" + name: "release tag from root version.properties", + fixture: "uber-app-version-properties", + want: "0.63.5", + }, + { + // clickhouse-jdbc ships "version=${revision}" must not become a version + name: "unresolved build placeholder is rejected", + fixture: "uber-app-version-placeholder", + want: "", + }, + { + // no Main-Class -> cant tell app uberjar from bundled dep + name: "library without Main-Class is rejected", + fixture: "lib-with-version-properties", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := pkgtest.Context(t) + + fixturePath := generateJavaMetadataJarFixture(t, tt.fixture, "jar") + fixture, err := os.Open(fixturePath) + require.NoError(t, err) + + parser, cleanupFn, err := newJavaArchiveParser(ctx, + file.LocationReadCloser{ + Location: file.NewLocation(fixture.Name()), + ReadCloser: fixture, + }, false, DefaultArchiveCatalogerConfig()) + defer cleanupFn() + require.NoError(t, err) + + assert.Equal(t, tt.want, parser.versionFromPropertiesFile(ctx, readManifest(t, ctx, parser))) + }) + } +} + +func Test_archiveParser_versionFromPropertiesFile_noManifest(t *testing.T) { + ctx := pkgtest.Context(t) + + fixturePath := generateJavaMetadataJarFixture(t, "uber-app-version-properties", "jar") + fixture, err := os.Open(fixturePath) + require.NoError(t, err) + + parser, cleanupFn, err := newJavaArchiveParser(ctx, + file.LocationReadCloser{ + Location: file.NewLocation(fixture.Name()), + ReadCloser: fixture, + }, false, DefaultArchiveCatalogerConfig()) + defer cleanupFn() + require.NoError(t, err) + + assert.Empty(t, parser.versionFromPropertiesFile(ctx, nil)) +} + +func Test_releaseVersionPattern(t *testing.T) { + tests := []struct { + value string + want string + }{ + {"v0.63.5", "0.63.5"}, + {"0.63.5", "0.63.5"}, + {"2.5.1", "2.5.1"}, + {"1.0.0-SNAPSHOT", "1.0.0-SNAPSHOT"}, + {"3", "3"}, + {"${revision}", ""}, + {"trunk-nightly build", ""}, + {"valhalla", ""}, + {"", ""}, + {"v", ""}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + var got string + if m := releaseVersionPattern.FindStringSubmatch(tt.value); m != nil { + got = m[1] + } + assert.Equal(t, tt.want, got) + }) + } +}