mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
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 <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user