From 22edfe66ca0ab7fbd207cfbfe0dbec46bb58281c Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 24 Sep 2026 13:36:52 -0400 Subject: [PATCH] fix: link nested java packages to their enclosing jar when nested cataloging is on the legacy recursion links a nested jar to the jar that holds it: a `DEPENDENCY_OF` edge from inner to outer, and `JavaArchive.Parent` on the inner package. Inside an extracted archive java called `parse(ctx, nil)`, so both went away as soon as the feature was on. What replaced them was `file CONTAINS package`, which SPDX and CycloneDX drop, so those formats lost all war-to-jar structure. - `archive.Traversal` gains `Parent` and `Packages` (the packages found describing that archive, filled in by the task after its sub-pipeline runs) - java picks the enclosing jar's main package from the parent traversal (the one whose virtual path is the archive's own; pom-derived aux packages carry a suffixed one) and passes it to `parse`, then sets `Parent` the same way the legacy path does Signed-off-by: Alex Goodman --- internal/archive/traversal.go | 9 +++++++ internal/task/archive_tasks.go | 14 +++++++++- syft/archive_cataloger_test.go | 38 ++++++++++++++++++++++++++++ syft/pkg/cataloger/java/cataloger.go | 29 ++++++++++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/internal/archive/traversal.go b/internal/archive/traversal.go index d6e3711de..20a21f303 100644 --- a/internal/archive/traversal.go +++ b/internal/archive/traversal.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" ) // Traversal describes the archive whose contents a cataloger is running against. The archive @@ -20,6 +21,14 @@ type Traversal struct { // Digests are of the archive file itself, taken during extraction; a cataloger running inside the // archive cannot compute them since the archive is not inside itself. Digests []file.Digest + + // Parent is the traversal of the archive this one is nested in, nil for an archive at the scan root. + Parent *Traversal + + // Packages are those the catalogers found describing this archive (located at it), filled in once + // they have run. Catalogers inside a nested archive read them through Parent, as java does to link a + // nested jar to the jar that holds it. + Packages []pkg.Package } // ContentsArchivePath is the ArchivePath every file inside the archive carries: the chain to it, built diff --git a/internal/task/archive_tasks.go b/internal/task/archive_tasks.go index 538bd9221..c81a50e40 100644 --- a/internal/task/archive_tasks.go +++ b/internal/task/archive_tasks.go @@ -203,7 +203,7 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi // extracted files keep the filesystem ID of where the archive was found; the nesting chain is // carried separately as the archive path. The chain names real paths, not the link the archive was // reached by, so it is stable and joins to the archive's package location by prefix. - traversal := &archive.Traversal{Location: location} + traversal := &archive.Traversal{Location: location, Parent: archive.TraversalFromContext(ctx)} archivePath := traversal.ContentsArchivePath() extracted, err := archive.Extract(ctx, archiveContent, location.FileSystemID, archivePath, c.limiter, c.exclusions) if errors.Is(err, archive.ErrDiskLimitReached) { @@ -240,6 +240,7 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi c.progress.Increment() scratch := c.runSubPipeline(ctx, extracted, location.Coordinates) + traversal.Packages = packagesAt(scratch, location.Coordinates) children := c.discoverArchives(extracted) mergeArchiveResults(location.Coordinates, archivePath, children, scratch, builder) c.progress.AtomicStage.Set(fmt.Sprintf("%s archives (%s)", humanize.Comma(c.progress.Current()), archivePath)) @@ -277,6 +278,17 @@ func (c *archiveCataloger) isAncestor(digests []file.Digest) bool { return false } +// packagesAt returns the packages located at the given coordinates, i.e. those describing the file there. +func packagesAt(s *sbom.SBOM, coordinates file.Coordinates) []pkg.Package { + var out []pkg.Package + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Locations.CoordinateSet().Contains(coordinates) { + out = append(out, p) + } + } + return out +} + func recordArchiveUnknown(builder sbomsync.Builder, coordinates file.Coordinates, reason string) { appendUnknowns(builder, ArchiveCatalogerTaskName, []unknown.CoordinateError{{Coordinates: coordinates, Reason: errors.New(reason)}}) } diff --git a/syft/archive_cataloger_test.go b/syft/archive_cataloger_test.go index 21dbba0e9..f1ed901fe 100644 --- a/syft/archive_cataloger_test.go +++ b/syft/archive_cataloger_test.go @@ -381,6 +381,44 @@ func TestArchiveCataloger_defaultFileSelectionCoversRootJars(t *testing.T) { } } +func TestArchiveCataloger_javaParentLinkageMatchesTheLegacyPath(t *testing.T) { + // a consumer walking java DEPENDENCY_OF edges (the only structure SPDX and CycloneDX keep) must see + // the same war-to-jar linkage whether or not the archive task does the recursion + dir := t.TempDir() + war := buildZipBytesRaw(t, map[string][]byte{ + "META-INF/MANIFEST.MF": []byte("Manifest-Version: 1.0\nImplementation-Title: app\nImplementation-Version: 1.0\n"), + "WEB-INF/lib/lib-2.0.jar": jarBytes(t, "lib", "2.0"), + }) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.war"), war, 0o644)) + + for name, depth := range map[string]int{"feature off": 0, "feature on": 2} { + t.Run(name, func(t *testing.T) { + cfg := DefaultCreateSBOMConfig(). + WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")). + WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth)) + s := scanDirWithExclusions(t, dir, cfg) + + var edges []string + for _, rel := range s.Relationships { + from, fromOK := rel.From.(pkg.Package) + to, toOK := rel.To.(pkg.Package) + if fromOK && toOK && rel.Type == artifact.DependencyOfRelationship { + edges = append(edges, from.Name+" -> "+to.Name) + } + } + assert.Equal(t, []string{"lib -> app"}, edges) + + var parent string + for _, p := range s.Artifacts.Packages.Sorted() { + if m, ok := p.Metadata.(pkg.JavaArchive); ok && p.Name == "lib" && m.Parent != nil { + parent = m.Parent.Name + } + } + assert.Equal(t, "app", parent) + }) + } +} + func TestArchiveCataloger_descriptorRecordsArchiveConfig(t *testing.T) { // a consumer must be able to tell from the SBOM alone how deep archive contents were searched cfg := DefaultCreateSBOMConfig(). diff --git a/syft/pkg/cataloger/java/cataloger.go b/syft/pkg/cataloger/java/cataloger.go index c64508ff4..88170be4f 100644 --- a/syft/pkg/cataloger/java/cataloger.go +++ b/syft/pkg/cataloger/java/cataloger.go @@ -82,7 +82,18 @@ func (c *archiveCataloger) catalogExtracted(ctx context.Context, trav *archive.T return nil, nil, err } - pkgs, relationships, err := parser.parse(ctx, nil) + // link to the jar this one is nested in, as the legacy recursion does: a DEPENDENCY_OF edge from + // parse, and Parent on whatever does not already have one + parent := enclosingJavaPackage(trav) + pkgs, relationships, err := parser.parse(ctx, parent) + if parent != nil { + for i := range pkgs { + if metadata, ok := pkgs[i].Metadata.(pkg.JavaArchive); ok && metadata.Parent == nil { + metadata.Parent = parent + pkgs[i].Metadata = metadata + } + } + } if err != nil { // attributed to the archive, as the generic cataloger attributes a parser failure to its file return pkgs, relationships, unknown.New(trav.Location, err) @@ -90,6 +101,22 @@ func (c *archiveCataloger) catalogExtracted(ctx context.Context, trav *archive.T return pkgs, relationships, nil } +// enclosingJavaPackage returns the main java package of the archive the traversal's archive is nested +// in, if there is one. Of the java packages located at an archive, the main one carries the archive's +// own virtual path; those found from embedded pom files carry it with their group and artifact appended. +func enclosingJavaPackage(trav *archive.Traversal) *pkg.Package { + if trav.Parent == nil { + return nil + } + virtualPath := archive.VirtualPath(trav.Parent.Location) + for _, p := range trav.Parent.Packages { + if metadata, ok := p.Metadata.(pkg.JavaArchive); ok && metadata.VirtualPath == virtualPath { + return &p + } + } + return nil +} + // isJavaArchiveName reports whether the path matches the globs this cataloger opens files by. func isJavaArchiveName(path string) bool { for _, glob := range archiveFormatGlobs {