diff --git a/internal/task/package_tasks.go b/internal/task/package_tasks.go index ff7f622fb..eb090fac8 100644 --- a/internal/task/package_tasks.go +++ b/internal/task/package_tasks.go @@ -6,6 +6,7 @@ import ( "github.com/anchore/syft/syft/pkg/cataloger/alpine" "github.com/anchore/syft/syft/pkg/cataloger/arch" "github.com/anchore/syft/syft/pkg/cataloger/binary" + bitnamiSbomCataloger "github.com/anchore/syft/syft/pkg/cataloger/bitnami" "github.com/anchore/syft/syft/pkg/cataloger/cpp" "github.com/anchore/syft/syft/pkg/cataloger/dart" "github.com/anchore/syft/syft/pkg/cataloger/debian" @@ -150,6 +151,7 @@ func DefaultPackageTaskFactories() PackageTaskFactories { pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "linux", "kernel", ), newSimplePackageTaskFactory(sbomCataloger.NewCataloger, "sbom"), // note: not evidence of installed packages + newSimplePackageTaskFactory(bitnamiSbomCataloger.NewCataloger, "bitnami", pkgcataloging.InstalledTag), newSimplePackageTaskFactory(wordpress.NewWordpressPluginCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "wordpress"), } } diff --git a/syft/pkg/cataloger/bitnami/cataloger.go b/syft/pkg/cataloger/bitnami/cataloger.go new file mode 100644 index 000000000..402863120 --- /dev/null +++ b/syft/pkg/cataloger/bitnami/cataloger.go @@ -0,0 +1,83 @@ +/* +Package bitnami provides a concrete Cataloger implementation for capturing packages embedded within Bitnami SBOM files. +*/ +package bitnami + +import ( + "bytes" + "context" + "fmt" + "io" + + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/format" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +const catalogerName = "bitnami-cataloger" + +// NewCataloger returns a new SBOM cataloger object loaded from saved SBOM JSON. +func NewCataloger() pkg.Cataloger { + return generic.NewCataloger(catalogerName). + WithParserByGlobs(parseSBOM, + "/opt/bitnami/**/*.spdx", + ) +} + +// TODO: this is copied from the sbom-cataloger +// it should probably be slimmed down so as not to duplicate +// parts of the SBOM cataloger that it doesn't need. +func parseSBOM(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { + readSeeker, err := adaptToReadSeeker(reader) + if err != nil { + return nil, nil, fmt.Errorf("unable to read SBOM file %q: %w", reader.Location.RealPath, err) + } + s, _, _, err := format.Decode(readSeeker) + if err != nil { + return nil, nil, err + } + + if s == nil { + log.WithFields("path", reader.Location.RealPath).Trace("file is not an SBOM") + return nil, nil, nil + } + + var pkgs []pkg.Package + relationships := s.Relationships + for _, p := range s.Artifacts.Packages.Sorted() { + // replace all locations on the package with the location of the SBOM file. + // Why not keep the original list of locations? Since the "locations" field is meant to capture + // where there is evidence of this file, and the catalogers have not run against any file other than, + // the SBOM, this is the only location that is relevant for this cataloger. + p.Locations = file.NewLocationSet( + reader.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation), + ) + p.FoundBy = catalogerName + + pkgs = append(pkgs, p) + relationships = append(relationships, artifact.Relationship{ + From: p, + To: reader.Location.Coordinates, + Type: artifact.DescribedByRelationship, + }) + } + + return pkgs, relationships, nil +} + +func adaptToReadSeeker(reader io.Reader) (io.ReadSeeker, error) { + // with the stereoscope API and default file.Resolver implementation here in syft, odds are very high that + // the underlying reader is already a ReadSeeker, so we can just return it as-is. We still want to + if rs, ok := reader.(io.ReadSeeker); ok { + return rs, nil + } + + log.Debug("SBOM cataloger reader is not a ReadSeeker, reading entire SBOM into memory") + + var buff bytes.Buffer + _, err := io.Copy(&buff, reader) + return bytes.NewReader(buff.Bytes()), err +} diff --git a/syft/pkg/cataloger/bitnami/cataloger_test.go b/syft/pkg/cataloger/bitnami/cataloger_test.go new file mode 100644 index 000000000..4ff82ad4a --- /dev/null +++ b/syft/pkg/cataloger/bitnami/cataloger_test.go @@ -0,0 +1,41 @@ +package bitnami + +import ( + "testing" + + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBitnamiCataloger(t *testing.T) { + tests := []struct { + name string + fixture string + wantPkgs []pkg.Package + wantError require.ErrorAssertionFunc + }{ + { + name: "simple-redis-sbom", + fixture: "test-fixtures", + // TODO: add package assertions + }, + // TODO: add another test case or too, maybe an error case + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgtest.NewCatalogTester(). + FromDirectory(t, tt.fixture). + ExpectsAssertion(func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) { + for i, p := range pkgs { + assert.Equal(t, p.Name, tt.wantPkgs[i].Name) + assert.Equal(t, p.Version, tt.wantPkgs[i].Version) + } + }). + TestCataloger(t, NewCataloger()) + }) + } +} diff --git a/syft/pkg/cataloger/bitnami/test-fixtures/opt/bitnami/redis/.spdx-redis.spdx b/syft/pkg/cataloger/bitnami/test-fixtures/opt/bitnami/redis/.spdx-redis.spdx new file mode 100644 index 000000000..734e8f1f5 --- /dev/null +++ b/syft/pkg/cataloger/bitnami/test-fixtures/opt/bitnami/redis/.spdx-redis.spdx @@ -0,0 +1,42 @@ +{ + "SPDXID": "SPDXRef-redis", + "spdxVersion": "SPDX-2.3", + "creationInfo": { + "created": "2024-08-08T11:12:35.680Z", + "creators": [ + "Organization: VMware, Inc." + ] + }, + "name": "SPDX document for Redis(R) 7.4.0", + "dataLicense": "CC0-1.0", + "documentDescribes": [ + "SPDXRef-redis" + ], + "documentNamespace": "redis-7.4.0", + "packages": [ + { + "SPDXID": "SPDXRef-redis", + "name": "redis", + "versionInfo": "7.4.0-0", + "downloadLocation": "http://download.redis.io/releases/redis-7.4.0.tar.gz", + "licenseConcluded": "RSALv2", + "licenseDeclared": "RSALv2", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:*:redis:redis:7.4.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:bitnami/redis@7.4.0-0?arch=arm64&distro=debian-12" + } + ], + "copyrightText": "NOASSERTION" + } + ], + "files": [], + "relationships": [] +} \ No newline at end of file