mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 06:56:40 +01:00
647 lines
26 KiB
Go
647 lines
26 KiB
Go
package syft
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/wagoodman/go-partybus"
|
|
"github.com/wagoodman/go-progress"
|
|
|
|
"github.com/anchore/syft/internal/archive"
|
|
"github.com/anchore/syft/internal/bus"
|
|
"github.com/anchore/syft/internal/task"
|
|
"github.com/anchore/syft/internal/testutils"
|
|
"github.com/anchore/syft/syft/artifact"
|
|
"github.com/anchore/syft/syft/cataloging"
|
|
"github.com/anchore/syft/syft/cataloging/filecataloging"
|
|
"github.com/anchore/syft/syft/cataloging/pkgcataloging"
|
|
"github.com/anchore/syft/syft/event"
|
|
"github.com/anchore/syft/syft/event/monitor"
|
|
"github.com/anchore/syft/syft/file"
|
|
"github.com/anchore/syft/syft/pkg"
|
|
"github.com/anchore/syft/syft/pkg/cataloger/java"
|
|
"github.com/anchore/syft/syft/sbom"
|
|
"github.com/anchore/syft/syft/source"
|
|
"github.com/anchore/syft/syft/source/directorysource"
|
|
)
|
|
|
|
// Every CreateSBOM call pays to compile the license scanner, so the end-to-end behavior is asserted
|
|
// over one scan of one fixture directory rather than a scan per claim. The fixture holds every shape
|
|
// the claims need; the subtests read the one SBOM.
|
|
//
|
|
// scan root
|
|
//
|
|
// loose.txt an ordinary file, counted by the file catalogers
|
|
// nested/marker.txt excluded by the root-anchored pattern ./nested/marker.txt
|
|
// app.zip nested/marker.txt, nested/keep.txt, nested/excluded.txt (**/excluded.txt)
|
|
// app/data.zip nested/marker.txt, under a directory
|
|
// outer.zip outer/marker.txt
|
|
// deeper/inner.zip nested/marker.txt, two levels down
|
|
// skip/inner-excluded.zip nested/marker.txt, excluded by **/inner-excluded.zip
|
|
// 日本語/café-🎉/unicode.zip nested/marker.txt, under a non-ASCII path
|
|
// pkg.rpm a zip holding a marker, excluded by **/*.rpm
|
|
// KEPT.RPM the same zip, kept: the pattern is lower-case (a distinct base
|
|
// name, so the two coexist on a case-insensitive filesystem)
|
|
// app.war WEB-INF/lib/war-lib-2.0.jar
|
|
// bundle.zip lib/zip-lib-1.2.jar
|
|
// bundle.tar.gz lib/tgz-lib-1.2.jar
|
|
var fixtureExclusions = []string{"./nested/marker.txt", "**/excluded.txt", "**/inner-excluded.zip", "**/*.rpm"}
|
|
|
|
func writeFixture(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
write := func(name string, data []byte) {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o644))
|
|
}
|
|
markerZip := testutils.Zip(t, map[string]string{"nested/marker.txt": "hello"})
|
|
|
|
write("loose.txt", []byte("outside"))
|
|
write("nested/marker.txt", []byte("at the root"))
|
|
write("app.zip", testutils.Zip(t, map[string]string{
|
|
"nested/marker.txt": "hello",
|
|
"nested/keep.txt": "hello",
|
|
"nested/excluded.txt": "hello",
|
|
}))
|
|
write("app/data.zip", markerZip)
|
|
write("outer.zip", testutils.Zip(t, map[string][]byte{
|
|
"outer/marker.txt": []byte("hello"),
|
|
"deeper/inner.zip": markerZip,
|
|
"skip/inner-excluded.zip": markerZip,
|
|
"日本語/café-🎉/unicode.zip": markerZip,
|
|
}))
|
|
write("pkg.rpm", markerZip)
|
|
write("KEPT.RPM", markerZip)
|
|
write("app.war", testutils.Zip(t, map[string][]byte{
|
|
"WEB-INF/lib/war-lib-2.0.jar": testutils.Jar(t, "war-lib", "2.0", nil),
|
|
}))
|
|
write("bundle.zip", testutils.Zip(t, map[string][]byte{
|
|
"lib/zip-lib-1.2.jar": testutils.Jar(t, "zip-lib", "1.2", nil),
|
|
}))
|
|
write("bundle.tar.gz", testutils.TarGz(t, map[string][]byte{
|
|
"lib/tgz-lib-1.2.jar": testutils.Jar(t, "tgz-lib", "1.2", nil),
|
|
}))
|
|
return dir
|
|
}
|
|
|
|
func TestArchiveCataloger(t *testing.T) {
|
|
scanDir := writeFixture(t)
|
|
resolvedScanDir, err := filepath.EvalSymlinks(scanDir)
|
|
require.NoError(t, err)
|
|
|
|
resolvedScanDir = filepath.ToSlash(resolvedScanDir)
|
|
|
|
recorder := &recordingBus{}
|
|
bus.Set(recorder)
|
|
t.Cleanup(func() { bus.Set(nil) })
|
|
|
|
var (
|
|
probeMu sync.Mutex
|
|
seen []string // every .txt and .zip path any resolver handed a cataloger could see
|
|
)
|
|
probe := resolverProbeCataloger{globs: []string{"**/*.txt", "**/*.zip"}, record: func(paths []string) {
|
|
probeMu.Lock()
|
|
defer probeMu.Unlock()
|
|
seen = append(seen, paths...)
|
|
}}
|
|
|
|
// the java catalogers come from the factory, as the CLI builds them: the flag handing recursion
|
|
// to the archive task is derived onto the factory's config. Both legacy search settings are off,
|
|
// the cell of the matrix that looks like a bug: with the feature on they no longer gate anything
|
|
packages := pkgcataloging.DefaultConfig()
|
|
packages.JavaArchive.IncludeIndexedArchives = false
|
|
packages.JavaArchive.IncludeUnindexedArchives = false
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithCatalogers(
|
|
pkgcataloging.NewAlwaysEnabledCatalogerReference(markerCataloger{}),
|
|
pkgcataloging.NewAlwaysEnabledCatalogerReference(probe),
|
|
).
|
|
WithPackagesConfig(packages).
|
|
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
|
|
|
|
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
|
|
|
|
t.Run("markers are found at every depth under archive-relative paths", func(t *testing.T) {
|
|
// the location's RealPath is the outermost archive; FullPath adds the chain of entries within it
|
|
assert.Equal(t, []string{
|
|
"KEPT.RPM!/nested/marker.txt",
|
|
"app.zip!/nested/marker.txt",
|
|
"app/data.zip!/nested/marker.txt",
|
|
"outer.zip!/deeper/inner.zip!/nested/marker.txt",
|
|
"outer.zip!/outer/marker.txt",
|
|
"outer.zip!/日本語/café-🎉/unicode.zip!/nested/marker.txt",
|
|
}, packageLocations(s, "marker-pkg"))
|
|
})
|
|
|
|
t.Run("archive contents do not leak into the parent tree", func(t *testing.T) {
|
|
for _, loc := range packageLocations(s, "marker-pkg") {
|
|
assert.NotContains(t, loc, ".zip/", "a joined path smears the archive's contents into the parent's tree")
|
|
assert.NotContains(t, loc, "!//", "a nested path must not be rooted")
|
|
}
|
|
})
|
|
|
|
t.Run("unicode entry names survive to output", func(t *testing.T) {
|
|
for _, loc := range packageLocations(s, "marker-pkg") {
|
|
assert.True(t, utf8.ValidString(loc))
|
|
}
|
|
})
|
|
|
|
t.Run("a CONTAINS relationship links each archive to what was found inside", func(t *testing.T) {
|
|
var containers []string
|
|
for _, p := range s.Artifacts.Packages.Sorted() {
|
|
if p.Name != "marker-pkg" {
|
|
continue
|
|
}
|
|
for _, rel := range s.Relationships {
|
|
if rel.Type != artifact.ContainsRelationship || rel.To.ID() != p.ID() {
|
|
continue
|
|
}
|
|
if coords, ok := rel.From.(file.Coordinates); ok {
|
|
containers = append(containers, coords.FullNestedPath())
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(containers)
|
|
assert.Equal(t, []string{
|
|
"KEPT.RPM",
|
|
"app.zip",
|
|
"app/data.zip",
|
|
"outer.zip",
|
|
"outer.zip!/deeper/inner.zip",
|
|
"outer.zip!/日本語/café-🎉/unicode.zip",
|
|
}, containers, "the archive file, named in its own parent, contains the package")
|
|
})
|
|
|
|
t.Run("exclusions are the scan's own", func(t *testing.T) {
|
|
// there is no archive-specific exclusion setting: a scan pattern's shape decides whether it
|
|
// reaches inside an archive, and an excluded entry is absent from the archive's filesystem
|
|
// rather than present and skipped
|
|
markers := packageLocations(s, "marker-pkg")
|
|
assert.NotContains(t, markers, "nested/marker.txt", "a root-anchored pattern excludes the file at the scan root")
|
|
assert.Contains(t, markers, "app.zip!/nested/marker.txt", "and says nothing about the same path inside an archive")
|
|
assert.NotContains(t, markers, "pkg.rpm!/nested/marker.txt", "an archive whose own path is excluded is not extracted")
|
|
assert.Contains(t, markers, "KEPT.RPM!/nested/marker.txt", "a lower-case pattern does not match an upper-case name")
|
|
assert.NotContains(t, markers, "outer.zip!/skip/inner-excluded.zip!/nested/marker.txt", "a nested archive is excluded at its own level")
|
|
|
|
assert.Contains(t, seen, "app.zip!/nested/keep.txt")
|
|
assert.NotContains(t, seen, "app.zip!/nested/excluded.txt", "a **/ pattern excludes inside an archive, from the index itself")
|
|
assert.Contains(t, seen, "outer.zip!/deeper/inner.zip", "the probe ran inside outer.zip")
|
|
assert.NotContains(t, seen, "outer.zip!/skip/inner-excluded.zip", "the excluded inner archive is absent from outer's filesystem")
|
|
})
|
|
|
|
t.Run("a jar inside an archive is cataloged exactly once, under the virtual path java gives it", func(t *testing.T) {
|
|
// the failure mode is a duplicate: a jar inside a zip can be reached by the task's walk and by
|
|
// the java cataloger's own unarchiving. The colon-delimited virtual path appears in published
|
|
// SBOMs, so it must not change with the mechanism; for a directory source the archive's path
|
|
// is relative, so the chain has no leading separator
|
|
assert.Equal(t, map[string][]string{
|
|
"war-lib": {"app.war:WEB-INF/lib/war-lib-2.0.jar"},
|
|
"zip-lib": {"bundle.zip:lib/zip-lib-1.2.jar"},
|
|
"tgz-lib": {"bundle.tar.gz:lib/tgz-lib-1.2.jar"},
|
|
}, javaVirtualPaths(s), "with both legacy search settings off, every container is still descended")
|
|
})
|
|
|
|
t.Run("no extraction path reaches the SBOM", func(t *testing.T) {
|
|
// covers syft/archive-content-identity#nested-paths-are-archive-relative: file cataloging is
|
|
// the one path that enumerates every location rather than searching for them, so a resolver
|
|
// root that leaked would land in FileMetadata and gain a CONTAINS edge
|
|
var paths []string
|
|
for coords := range s.Artifacts.FileMetadata {
|
|
paths = append(paths, coords.RealPath)
|
|
}
|
|
for _, p := range s.Artifacts.Packages.Sorted() {
|
|
for _, loc := range p.Locations.ToSlice() {
|
|
paths = append(paths, loc.RealPath, loc.AccessPath)
|
|
}
|
|
}
|
|
for _, rel := range s.Relationships {
|
|
for _, end := range []artifact.Identifiable{rel.From, rel.To} {
|
|
if coords, ok := end.(file.Coordinates); ok {
|
|
paths = append(paths, coords.RealPath)
|
|
}
|
|
}
|
|
}
|
|
require.NotEmpty(t, paths)
|
|
for _, p := range paths {
|
|
assert.NotContains(t, p, "archive-spill-", "the spill file reached the SBOM at %q", p)
|
|
if filepath.IsAbs(p) {
|
|
// the directory resolver has always reported the scan root absolute: it is the path the
|
|
// user asked for. Anything else absolute is a filesystem this feature invented
|
|
assert.Equal(t, resolvedScanDir, p, "only the scanned directory itself may be reported absolute")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("file metadata covers every entry of every archive, and nothing else", func(t *testing.T) {
|
|
// an exact set is also the reproducibility check: anything of a per-run temp directory reaching
|
|
// output would fail it
|
|
var got []string
|
|
for coords := range s.Artifacts.FileMetadata {
|
|
if archive.ContainerPath(coords) == "" {
|
|
continue
|
|
}
|
|
got = append(got, coords.FullNestedPath())
|
|
}
|
|
sort.Strings(got)
|
|
assert.Equal(t, []string{
|
|
"KEPT.RPM!/nested/marker.txt",
|
|
"app.war!/WEB-INF/lib/war-lib-2.0.jar",
|
|
"app.war!/WEB-INF/lib/war-lib-2.0.jar!/META-INF/MANIFEST.MF",
|
|
"app.zip!/nested/keep.txt",
|
|
"app.zip!/nested/marker.txt",
|
|
"app/data.zip!/nested/marker.txt",
|
|
"bundle.tar.gz!/lib/tgz-lib-1.2.jar",
|
|
"bundle.tar.gz!/lib/tgz-lib-1.2.jar!/META-INF/MANIFEST.MF",
|
|
"bundle.zip!/lib/zip-lib-1.2.jar",
|
|
"bundle.zip!/lib/zip-lib-1.2.jar!/META-INF/MANIFEST.MF",
|
|
"outer.zip!/deeper/inner.zip",
|
|
"outer.zip!/deeper/inner.zip!/nested/marker.txt",
|
|
"outer.zip!/outer/marker.txt",
|
|
"outer.zip!/日本語/café-🎉/unicode.zip",
|
|
"outer.zip!/日本語/café-🎉/unicode.zip!/nested/marker.txt",
|
|
}, got)
|
|
})
|
|
|
|
t.Run("file catalogers publish one progress row counting archive contents", func(t *testing.T) {
|
|
// the file catalogers run once over the scan root and again over every archive; each must
|
|
// report into one row carrying the total of every run, or the count runs past the total and
|
|
// renders as an overshot bar that reads as complete mid-walk
|
|
rows := map[string][]*progress.Manual{}
|
|
for _, e := range recorder.events {
|
|
if e.Type != event.CatalogerTaskStarted {
|
|
continue
|
|
}
|
|
info, ok := e.Source.(monitor.GenericTask)
|
|
require.True(t, ok)
|
|
tp, ok := e.Value.(*monitor.TaskProgress)
|
|
require.True(t, ok)
|
|
rows[info.Title.Default] = append(rows[info.Title.Default], tp.Manual)
|
|
}
|
|
for _, title := range []string{"File digests", "File metadata"} {
|
|
published := rows[title]
|
|
require.Len(t, published, 1, "%s must publish exactly one row for the whole scan", title)
|
|
row := published[0]
|
|
assert.Greater(t, row.Current(), int64(len(s.Artifacts.FileMetadata))/2,
|
|
"%s must count the files inside the archives, got %d", title, row.Current())
|
|
if row.Size() >= 0 {
|
|
assert.Equal(t, row.Current(), row.Size(), "%s total must cover every run that reported into it", title)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestArchiveCataloger_featureOff is the one comparison that needs a second scan: with the task
|
|
// disabled, java's own unarchiving reaches the jars its legacy settings admit, under the same virtual
|
|
// path the task gives them.
|
|
func TestArchiveCataloger_featureOff(t *testing.T) {
|
|
scanDir := writeFixture(t)
|
|
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java"))
|
|
|
|
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
|
|
|
|
// indexed archives (zip, war) are searched by default and unindexed ones (tar.gz) are not, so
|
|
// what a user gets without the feature depends on settings the feature makes irrelevant
|
|
assert.Equal(t, map[string][]string{
|
|
"war-lib": {"app.war:WEB-INF/lib/war-lib-2.0.jar"},
|
|
"zip-lib": {"bundle.zip:lib/zip-lib-1.2.jar"},
|
|
}, javaVirtualPaths(s), "java's own recursion finds each once, under the virtual path the task also reports")
|
|
|
|
t.Run("the top-level archive search booleans reach java", func(t *testing.T) {
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithIncludeUnindexedArchives(true))
|
|
|
|
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
|
|
|
|
assert.Contains(t, javaVirtualPaths(s), "tgz-lib")
|
|
assert.False(t, cfg.Packages.JavaArchive.IncludeUnindexedArchives, "the caller's config is not written to")
|
|
})
|
|
}
|
|
|
|
func TestArchiveCataloger_jarReachedThroughALinkIsIdentified(t *testing.T) {
|
|
// a content-addressed layout (pnpm, nix, bazel): the jar is stored under a hash and only the link
|
|
// to it is named like a jar
|
|
var buf bytes.Buffer
|
|
gw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gw)
|
|
jar := testutils.Jar(t, "json-simple", "1.1.1", nil)
|
|
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "store/3f9a1c", Mode: 0o644, Size: int64(len(jar))}))
|
|
_, err := tw.Write(jar)
|
|
require.NoError(t, err)
|
|
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/lib/json-simple-1.1.1.jar", Typeflag: tar.TypeSymlink, Linkname: "../../store/3f9a1c"}))
|
|
require.NoError(t, tw.Close())
|
|
require.NoError(t, gw.Close())
|
|
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "bundle.tar.gz"), buf.Bytes(), 0o644))
|
|
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2))
|
|
s := scanDirWithExclusions(t, dir, cfg)
|
|
|
|
assert.Equal(t, []string{"bundle.tar.gz!/store/3f9a1c"}, packageLocations(s, "json-simple"))
|
|
|
|
// the jar's own files are addressed by its real path, the same one its package location names
|
|
manifest := file.Coordinates{RealPath: "bundle.tar.gz", FullPath: "bundle.tar.gz!/store/3f9a1c!/META-INF/MANIFEST.MF"}
|
|
assert.Contains(t, s.Artifacts.FileMetadata, manifest)
|
|
}
|
|
|
|
func TestArchiveCataloger_defaultFileSelectionCoversRootJars(t *testing.T) {
|
|
// with the default owned-by-package selection, a jar at the root is described by the archive task,
|
|
// so the root file catalogers must run after it or the jar gets no file entries at all
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "app.jar"), testutils.Jar(t, "app", "1.0", nil), 0o644))
|
|
|
|
for name, depth := range map[string]int{"feature off": 0, "feature on": 1} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
|
|
s := scanDirWithExclusions(t, dir, cfg)
|
|
|
|
require.Equal(t, []string{"app.jar"}, packageLocations(s, "app"))
|
|
jar := file.NewCoordinates("app.jar", "")
|
|
assert.Contains(t, s.Artifacts.FileMetadata, jar)
|
|
assert.Contains(t, s.Artifacts.FileDigests, jar)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestArchiveCataloger_defaultFileSelectionCoversNestedJars(t *testing.T) {
|
|
// a nested jar's package is found inside the jar but located in the archive holding it, so that
|
|
// archive's file catalogers must run after it, as the root's do
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
|
|
"lib/inner.jar": testutils.Jar(t, "inner", "1.0", nil),
|
|
}), 0o644))
|
|
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2))
|
|
s := scanDirWithExclusions(t, dir, cfg)
|
|
|
|
require.Equal(t, []string{"outer.zip!/lib/inner.jar"}, packageLocations(s, "inner"))
|
|
jar := file.Coordinates{RealPath: "outer.zip", FullPath: "outer.zip!/lib/inner.jar"}
|
|
assert.Contains(t, s.Artifacts.FileMetadata, jar)
|
|
assert.Contains(t, s.Artifacts.FileDigests, jar)
|
|
}
|
|
|
|
func TestArchiveCataloger_archiveUnknowns(t *testing.T) {
|
|
dir := t.TempDir()
|
|
plain := testutils.Zip(t, map[string]string{"readme.txt": "hi"})
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
|
|
"lib/inner.jar": testutils.Jar(t, "inner", "1.0", nil),
|
|
"lib/plain.zip": plain,
|
|
}), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.zip"), plain, 0o644))
|
|
|
|
scan := func(depth int) map[string][]string {
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithUnknownsConfig(cataloging.DefaultUnknownsConfig()).
|
|
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
|
|
out := map[string][]string{}
|
|
for c, reasons := range scanDirWithExclusions(t, dir, cfg).Artifacts.Unknowns {
|
|
out[c.FullNestedPath()] = reasons
|
|
}
|
|
return out
|
|
}
|
|
|
|
t.Run("an expanded archive is not reported as not cataloged", func(t *testing.T) {
|
|
assert.Empty(t, scan(3))
|
|
})
|
|
|
|
t.Run("an archive past the depth limit is reported once, by the archive cataloger", func(t *testing.T) {
|
|
unknowns := scan(1)
|
|
assert.NotContains(t, unknowns, "outer.zip")
|
|
for _, nested := range []string{"outer.zip!/lib/inner.jar", "outer.zip!/lib/plain.zip"} {
|
|
require.Len(t, unknowns[nested], 1, nested)
|
|
assert.Contains(t, unknowns[nested][0], "depth limit was reached")
|
|
}
|
|
})
|
|
|
|
t.Run("an archive is reported as not cataloged when nesting is off", func(t *testing.T) {
|
|
assert.Equal(t, []string{"archive not cataloged"}, scan(0)["plain.zip"])
|
|
})
|
|
}
|
|
|
|
func TestArchiveCataloger_unsupportedArchiveFormatsAreReported(t *testing.T) {
|
|
dir := t.TempDir()
|
|
var page bytes.Buffer
|
|
gw := gzip.NewWriter(&page)
|
|
_, err := gw.Write([]byte("a man page"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, gw.Close())
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
|
|
"legacy.7z": []byte("7z\xbc\xaf\x27\x1c\x00\x04"),
|
|
"page.1.gz": page.Bytes(),
|
|
}), 0o644))
|
|
|
|
cfg := DefaultCreateSBOMConfig().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithUnknownsConfig(cataloging.DefaultUnknownsConfig()).
|
|
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
|
|
unknowns := map[string][]string{}
|
|
for c, reasons := range scanDirWithExclusions(t, dir, cfg).Artifacts.Unknowns {
|
|
unknowns[c.FullNestedPath()] = reasons
|
|
}
|
|
|
|
assert.Equal(t, map[string][]string{
|
|
"outer.zip!/legacy.7z": {"archive-cataloger: archive not cataloged: archive format is not supported: application/x-7z-compressed"},
|
|
}, unknowns, "a bare compressed file holds no files, so it is not an archive left uncataloged")
|
|
}
|
|
|
|
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 := testutils.Jar(t, "app", "1.0", map[string][]byte{
|
|
"WEB-INF/lib/lib-2.0.jar": testutils.Jar(t, "lib", "2.0", nil),
|
|
})
|
|
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().
|
|
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
|
|
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
|
|
s := scanDirWithExclusions(t, t.TempDir(), cfg)
|
|
|
|
trail, ok := s.Descriptor.Configuration.(configurationAuditTrail)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 3, trail.Archive.MaxDepth)
|
|
assert.Contains(t, trail.Catalogers.Used, task.ArchiveCatalogerTaskName)
|
|
}
|
|
|
|
func TestArchiveCataloger_javaArchiveDepthIsAConfigError(t *testing.T) {
|
|
// depth and limits are not java settings; setting them on java's deprecated copy must fail fast
|
|
// rather than be silently ignored
|
|
for name, set := range map[string]func(*cataloging.ArchiveSearchConfig){
|
|
"max depth": func(c *cataloging.ArchiveSearchConfig) { c.MaxDepth = 3 },
|
|
"max memory bytes": func(c *cataloging.ArchiveSearchConfig) { c.MaxMemoryBytes = 1 },
|
|
"max disk bytes": func(c *cataloging.ArchiveSearchConfig) { c.MaxDiskBytes = 1 },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
supplied := java.DefaultArchiveCatalogerConfig()
|
|
supplied.ArchiveSearchConfig = cataloging.ArchiveSearchConfig{}
|
|
set(&supplied.ArchiveSearchConfig)
|
|
cfg := DefaultCreateSBOMConfig().WithPackagesConfig(pkgcataloging.Config{JavaArchive: supplied})
|
|
|
|
src, err := directorysource.New(directorysource.Config{Path: t.TempDir()})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = src.Close() })
|
|
|
|
_, err = cfg.Create(context.Background(), src)
|
|
require.ErrorContains(t, err, "set them with CreateSBOMConfig.Archive")
|
|
})
|
|
}
|
|
}
|
|
|
|
// packageLocations lists the full path of every location of every package with the name.
|
|
func packageLocations(s *sbom.SBOM, name string) []string {
|
|
var out []string
|
|
for _, p := range s.Artifacts.Packages.Sorted() {
|
|
if p.Name != name {
|
|
continue
|
|
}
|
|
for _, loc := range p.Locations.ToSlice() {
|
|
out = append(out, loc.FullNestedPath())
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// javaVirtualPaths maps each java package name to the virtual paths it was reported under; a name
|
|
// with two entries was cataloged twice.
|
|
func javaVirtualPaths(s *sbom.SBOM) map[string][]string {
|
|
out := map[string][]string{}
|
|
for _, p := range s.Artifacts.Packages.Sorted() {
|
|
if metadata, ok := p.Metadata.(pkg.JavaArchive); ok {
|
|
out[p.Name] = append(out[p.Name], metadata.VirtualPath)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// markerCataloger is a stub package cataloger emitting one package per "marker.txt" it finds, so the
|
|
// end-to-end tests assert behavior without depending on any real cataloger's fixture format.
|
|
type markerCataloger struct{}
|
|
|
|
func (markerCataloger) Name() string { return "marker-cataloger" }
|
|
|
|
func (markerCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) {
|
|
locations, err := resolver.FilesByGlob("**/marker.txt")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var pkgs []pkg.Package
|
|
for _, loc := range locations {
|
|
p := pkg.Package{
|
|
Name: "marker-pkg",
|
|
Version: "1.0.0",
|
|
Type: pkg.BinaryPkg,
|
|
Locations: file.NewLocationSet(loc),
|
|
}
|
|
p.SetID()
|
|
pkgs = append(pkgs, p)
|
|
}
|
|
return pkgs, nil, nil
|
|
}
|
|
|
|
// resolverProbeCataloger reports what the resolver it was handed can see, which is how "absent from
|
|
// the index" is told from "present and skipped" within a scan.
|
|
type resolverProbeCataloger struct {
|
|
globs []string
|
|
record func([]string)
|
|
}
|
|
|
|
func (resolverProbeCataloger) Name() string { return "resolver-probe-cataloger" }
|
|
|
|
func (c resolverProbeCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) {
|
|
locations, err := resolver.FilesByGlob(c.globs...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var paths []string
|
|
for _, loc := range locations {
|
|
paths = append(paths, loc.FullNestedPath())
|
|
}
|
|
c.record(paths)
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// recordingBus captures the progress rows a scan publishes.
|
|
type recordingBus struct {
|
|
mu sync.Mutex
|
|
events []partybus.Event
|
|
}
|
|
|
|
func (p *recordingBus) Publish(e partybus.Event) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.events = append(p.events, e)
|
|
}
|
|
|
|
// scanDirWithExclusions scans with the given exclusion patterns configured on the source, where
|
|
// exclusions live; archive cataloging has no exclusion setting of its own.
|
|
func scanDirWithExclusions(t *testing.T, dir string, cfg *CreateSBOMConfig, exclude ...string) *sbom.SBOM {
|
|
t.Helper()
|
|
src, err := directorysource.New(directorysource.Config{
|
|
Path: dir,
|
|
Exclude: source.ExcludeConfig{Paths: exclude},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = src.Close() })
|
|
|
|
s, err := cfg.Create(context.Background(), src)
|
|
require.NoError(t, err)
|
|
return s
|
|
}
|