From 78f1d44e3cecc7673085a60d9299891780fc4245 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Wed, 21 Oct 2026 11:37:45 -0400 Subject: [PATCH] fix: restore case-insensitive java globbing for licenses, version and nested jars The `archiveEntries` refactor dropped `GlobMatch`'s case-insensitive flag, so with nested cataloging off (the default) three lookups that main matched regardless of case became case-sensitive: - license files under `/META-INF/*` and `/*` (`meta-inf/LICENSE` was missed) - `/version.properties` - the `**/*.jar`, `**/*.war` check behind the "nested archives not cataloged" unknown, so `LIB.JAR` slipped past it `archiveEntries` now has a `globFold` for those call sites. The zip side restores `GlobMatch(true, ...)`, and the resolver side matches case-folded over every entry, since resolver globs are case-sensitive. Signed-off-by: Alex Goodman --- internal/file/zip_file_manifest.go | 13 +++-- internal/file/zip_file_manifest_test.go | 6 ++- syft/pkg/cataloger/java/archive_entries.go | 36 +++++++++++++- .../cataloger/java/archive_entries_test.go | 49 +++++++++++++++++++ syft/pkg/cataloger/java/archive_parser.go | 8 +-- 5 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 syft/pkg/cataloger/java/archive_entries_test.go diff --git a/internal/file/zip_file_manifest.go b/internal/file/zip_file_manifest.go index 4ab1d73a6..34a2cb66d 100644 --- a/internal/file/zip_file_manifest.go +++ b/internal/file/zip_file_manifest.go @@ -46,7 +46,7 @@ func (z ZipFileManifest) Add(entry string, info os.FileInfo) { } // GlobMatch returns the path keys to files (not directories) that match the given value(s). -func (z ZipFileManifest) GlobMatch(patterns ...string) []string { +func (z ZipFileManifest) GlobMatch(caseInsensitive bool, patterns ...string) []string { uniqueMatches := strset.New() for _, pattern := range patterns { @@ -58,7 +58,11 @@ func (z ZipFileManifest) GlobMatch(patterns ...string) []string { // We want to match globs as if entries begin with a leading slash (akin to an absolute path) // so that glob logic is consistent inside and outside of ZIP archives - normalizedEntry := normalizeZipEntryName(entry) + normalizedEntry := normalizeZipEntryName(caseInsensitive, entry) + + if caseInsensitive { + pattern = strings.ToLower(pattern) + } matches, err := doublestar.Match(pattern, normalizedEntry) if err != nil { @@ -78,7 +82,10 @@ func (z ZipFileManifest) GlobMatch(patterns ...string) []string { } // normalizeZipEntryName takes the given path entry and ensures it is prefixed with "/". -func normalizeZipEntryName(entry string) string { +func normalizeZipEntryName(caseInsensitive bool, entry string) string { + if caseInsensitive { + entry = strings.ToLower(entry) + } if !strings.HasPrefix(entry, "/") { return "/" + entry } diff --git a/internal/file/zip_file_manifest_test.go b/internal/file/zip_file_manifest_test.go index e0c0e5ee6..50b798882 100644 --- a/internal/file/zip_file_manifest_test.go +++ b/internal/file/zip_file_manifest_test.go @@ -122,6 +122,10 @@ func TestZipFileManifest_GlobMatch(t *testing.T) { "**/a-file.txt", []string{"some-dir/a-file.txt"}, }, + { + "**/A-file.txt", + []string{"some-dir/a-file.txt"}, + }, { "**/*.zip", []string{"nested.zip"}, @@ -132,7 +136,7 @@ func TestZipFileManifest_GlobMatch(t *testing.T) { t.Run(tc.glob, func(t *testing.T) { glob := tc.glob - results := z.GlobMatch(glob) + results := z.GlobMatch(true, glob) require.ElementsMatch(t, tc.expected, results) }) diff --git a/syft/pkg/cataloger/java/archive_entries.go b/syft/pkg/cataloger/java/archive_entries.go index f1f22d486..ed4af5325 100644 --- a/syft/pkg/cataloger/java/archive_entries.go +++ b/syft/pkg/cataloger/java/archive_entries.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/bmatcuk/doublestar/v4" "github.com/scylladb/go-set/strset" "github.com/anchore/syft/internal" @@ -21,6 +22,10 @@ type archiveEntries interface { // ("/META-INF/*"); the names returned have none. glob(patterns ...string) []string + // globFold is glob ignoring case, for names java tooling does not agree on the case of + // ("META-INF/LICENSE" vs "meta-inf/license", "X.JAR"). + globFold(patterns ...string) []string + // contents reads the named entries, keyed by the same names. contents(ctx context.Context, names ...string) (map[string]string, error) } @@ -40,7 +45,11 @@ func newZipEntries(ctx context.Context, archivePath string) (*zipEntries, error) } func (z *zipEntries) glob(patterns ...string) []string { - return z.fileManifest.GlobMatch(patterns...) + return z.fileManifest.GlobMatch(false, patterns...) +} + +func (z *zipEntries) globFold(patterns ...string) []string { + return z.fileManifest.GlobMatch(true, patterns...) } func (z *zipEntries) contents(ctx context.Context, names ...string) (map[string]string, error) { @@ -73,6 +82,31 @@ func (r *resolverEntries) glob(patterns ...string) []string { return names } +// globFold walks every entry, since a resolver's globs are case-sensitive. The callers ask for a handful +// of names once per archive, so the walk is cheap next to cataloging the archive. +func (r *resolverEntries) globFold(patterns ...string) []string { + var folded []string + for _, pattern := range patterns { + folded = append(folded, strings.ToLower(pattern)) + } + seen := strset.New() + var names []string + for location := range r.resolver.AllLocations(context.Background()) { + name := strings.TrimPrefix(location.LocalPath(), "/") + if seen.Has(name) { + continue + } + for _, pattern := range folded { + if ok, _ := doublestar.Match(pattern, "/"+strings.ToLower(name)); ok { + seen.Add(name) + names = append(names, name) + break + } + } + } + return names +} + func (r *resolverEntries) contents(_ context.Context, names ...string) (map[string]string, error) { out := make(map[string]string, len(names)) for _, name := range names { diff --git a/syft/pkg/cataloger/java/archive_entries_test.go b/syft/pkg/cataloger/java/archive_entries_test.go new file mode 100644 index 000000000..908ced76b --- /dev/null +++ b/syft/pkg/cataloger/java/archive_entries_test.go @@ -0,0 +1,49 @@ +package java + +import ( + "archive/zip" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" +) + +// java tooling doesn't agree on case ("meta-inf/LICENSE", "LIB.JAR"), and the license, version and +// nested-archive lookups have always matched regardless of it +func Test_archiveEntries_globFoldIgnoresCase(t *testing.T) { + names := []string{"meta-inf/LICENSE.txt", "Version.Properties", "lib/DEP.JAR", "META-INF/MANIFEST.MF"} + + archivePath := filepath.Join(t.TempDir(), "app.jar") + f, err := os.Create(archivePath) + require.NoError(t, err) + zw := zip.NewWriter(f) + for _, name := range names { + _, err := zw.Create(name) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) + + fromZip, err := newZipEntries(context.Background(), archivePath) + require.NoError(t, err) + + var rooted []string + for _, name := range names { + rooted = append(rooted, "/"+name) + } + fromResolver := &resolverEntries{resolver: file.NewMockResolverForPaths(rooted...)} + + for name, entries := range map[string]archiveEntries{"zip": fromZip, "resolver": fromResolver} { + t.Run(name, func(t *testing.T) { + assert.ElementsMatch(t, []string{"meta-inf/LICENSE.txt", "META-INF/MANIFEST.MF"}, entries.globFold("/META-INF/*")) + assert.Equal(t, []string{"Version.Properties"}, entries.globFold("/version.properties")) + assert.Equal(t, []string{"lib/DEP.JAR"}, entries.globFold("**/*.jar", "**/*.war")) + assert.Empty(t, entries.glob("/version.properties"), "glob itself stays case-sensitive") + }) + } +} diff --git a/syft/pkg/cataloger/java/archive_parser.go b/syft/pkg/cataloger/java/archive_parser.go index 99d7010ff..76b566618 100644 --- a/syft/pkg/cataloger/java/archive_parser.go +++ b/syft/pkg/cataloger/java/archive_parser.go @@ -230,7 +230,7 @@ func (j *archiveParser) parse(ctx context.Context, parentPkg *pkg.Package) ([]pk } else if !archive.NestedCatalogingEnabled(ctx) { // nothing will catalog these, so record them as unknowns // .jar and .war files are present in archives, are others? or generally just consider them top-level? - nestedArchives := j.entries.glob("**/*.jar", "**/*.war") + nestedArchives := j.entries.globFold("**/*.jar", "**/*.war") if len(nestedArchives) > 0 { slices.Sort(nestedArchives) errs = unknown.Appendf(errs, j.location, "nested archives not cataloged: %v", strings.Join(nestedArchives, ", ")) @@ -606,7 +606,7 @@ func (j *archiveParser) getLicenseFromFileInArchive(ctx context.Context) []pkg.L for _, glob := range []string{"/META-INF/*", "/*"} { var licenseMatches []string - for _, f := range j.entries.glob(glob) { + for _, f := range j.entries.globFold(glob) { if licenses.IsLicenseFile(path.Base(f)) { licenseMatches = append(licenseMatches, f) } @@ -657,7 +657,7 @@ func (j *archiveParser) versionFromPropertiesFile(ctx context.Context, manifest return "" } - matches := j.entries.glob("/version.properties") + matches := j.entries.globFold("/version.properties") if len(matches) != 1 { return "" } @@ -700,7 +700,7 @@ func (j *archiveParser) discoverPkgsFromNestedArchives(ctx context.Context, pare // associating each discovered package to the given parent package. func discoverPkgsFromZip(ctx context.Context, location file.Location, archivePath, contentPath string, fileManifest intFile.ZipFileManifest, parentPkg *pkg.Package, cfg ArchiveCatalogerConfig) ([]pkg.Package, []artifact.Relationship, error) { // search and parse pom.properties files & fetch the contents - openers, err := intFile.ExtractFromZipToUniqueTempFile(ctx, archivePath, contentPath, fileManifest.GlobMatch(archiveFormatGlobs...)...) + openers, err := intFile.ExtractFromZipToUniqueTempFile(ctx, archivePath, contentPath, fileManifest.GlobMatch(false, archiveFormatGlobs...)...) if err != nil { return nil, nil, fmt.Errorf("unable to extract files from zip: %w", err) }