port deb/dpkg cataloger to new generic cataloger pattern (#1288)

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2022-10-25 11:47:32 -04:00 committed by GitHub
parent bd5adbc9b3
commit 52cb7269bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 704 additions and 758 deletions

View File

@ -19,3 +19,12 @@ func TruncateMiddleEllipsis(input string, maxLen int) string {
} }
return input[:maxLen/2] + "..." + input[len(input)-(maxLen/2):] return input[:maxLen/2] + "..." + input[len(input)-(maxLen/2):]
} }
func StringInSlice(a string, list []string) bool {
for _, b := range list {
if b == a {
return true
}
}
return false
}

View File

@ -49,7 +49,7 @@ func CatalogPackages(src *source.Source, cfg cataloger.Config) (*pkg.Catalog, []
} }
// if the catalogers have been configured, use them regardless of input type // if the catalogers have been configured, use them regardless of input type
var catalogers []cataloger.Cataloger var catalogers []pkg.Cataloger
if len(cfg.Catalogers) > 0 { if len(cfg.Catalogers) > 0 {
catalogers = cataloger.AllCatalogers(cfg) catalogers = cataloger.AllCatalogers(cfg)
} else { } else {

16
syft/pkg/cataloger.go Normal file
View File

@ -0,0 +1,16 @@
package pkg
import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/source"
)
// Cataloger describes behavior for an object to participate in parsing container image or file system
// contents for the purpose of discovering Packages. Each concrete implementation should focus on discovering Packages
// for a specific Package Type or ecosystem.
type Cataloger interface {
// Name returns a string that uniquely describes a cataloger
Name() string
// Catalog is given an object to resolve file references and content, this function returns any discovered Packages after analyzing the catalog source.
Catalog(resolver source.FileResolver) ([]Package, []artifact.Relationship, error)
}

View File

@ -754,7 +754,7 @@ func TestMultiplePackages(t *testing.T) {
VersionID: "3.12", VersionID: "3.12",
}} }}
pkgtest.TestGenericParserWithEnv(t, fixture, parseApkDB, &env, expected, expectedRelationships) pkgtest.TestFileParserWithEnv(t, fixture, parseApkDB, &env, expected, expectedRelationships)
} }

View File

@ -42,7 +42,7 @@ func newMonitor() (*progress.Manual, *progress.Manual) {
// In order to efficiently retrieve contents from a underlying container image the content fetch requests are // In order to efficiently retrieve contents from a underlying container image the content fetch requests are
// done in bulk. Specifically, all files of interest are collected from each catalogers and accumulated into a single // done in bulk. Specifically, all files of interest are collected from each catalogers and accumulated into a single
// request. // request.
func Catalog(resolver source.FileResolver, release *linux.Release, catalogers ...Cataloger) (*pkg.Catalog, []artifact.Relationship, error) { func Catalog(resolver source.FileResolver, release *linux.Release, catalogers ...pkg.Cataloger) (*pkg.Catalog, []artifact.Relationship, error) {
catalog := pkg.NewCatalog() catalog := pkg.NewCatalog()
var allRelationships []artifact.Relationship var allRelationships []artifact.Relationship

View File

@ -9,7 +9,6 @@ import (
"strings" "strings"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/alpm" "github.com/anchore/syft/syft/pkg/cataloger/alpm"
"github.com/anchore/syft/syft/pkg/cataloger/apkdb" "github.com/anchore/syft/syft/pkg/cataloger/apkdb"
@ -28,24 +27,13 @@ import (
"github.com/anchore/syft/syft/pkg/cataloger/ruby" "github.com/anchore/syft/syft/pkg/cataloger/ruby"
"github.com/anchore/syft/syft/pkg/cataloger/rust" "github.com/anchore/syft/syft/pkg/cataloger/rust"
"github.com/anchore/syft/syft/pkg/cataloger/swift" "github.com/anchore/syft/syft/pkg/cataloger/swift"
"github.com/anchore/syft/syft/source"
) )
const AllCatalogersPattern = "all" const AllCatalogersPattern = "all"
// Cataloger describes behavior for an object to participate in parsing container image or file system
// contents for the purpose of discovering Packages. Each concrete implementation should focus on discovering Packages
// for a specific Package Type or ecosystem.
type Cataloger interface {
// Name returns a string that uniquely describes a cataloger
Name() string
// Catalog is given an object to resolve file references and content, this function returns any discovered Packages after analyzing the catalog source.
Catalog(resolver source.FileResolver) ([]pkg.Package, []artifact.Relationship, error)
}
// ImageCatalogers returns a slice of locally implemented catalogers that are fit for detecting installations of packages. // ImageCatalogers returns a slice of locally implemented catalogers that are fit for detecting installations of packages.
func ImageCatalogers(cfg Config) []Cataloger { func ImageCatalogers(cfg Config) []pkg.Cataloger {
return filterCatalogers([]Cataloger{ return filterCatalogers([]pkg.Cataloger{
alpm.NewAlpmdbCataloger(), alpm.NewAlpmdbCataloger(),
ruby.NewGemSpecCataloger(), ruby.NewGemSpecCataloger(),
python.NewPythonPackageCataloger(), python.NewPythonPackageCataloger(),
@ -62,8 +50,8 @@ func ImageCatalogers(cfg Config) []Cataloger {
} }
// DirectoryCatalogers returns a slice of locally implemented catalogers that are fit for detecting packages from index files (and select installations) // DirectoryCatalogers returns a slice of locally implemented catalogers that are fit for detecting packages from index files (and select installations)
func DirectoryCatalogers(cfg Config) []Cataloger { func DirectoryCatalogers(cfg Config) []pkg.Cataloger {
return filterCatalogers([]Cataloger{ return filterCatalogers([]pkg.Cataloger{
alpm.NewAlpmdbCataloger(), alpm.NewAlpmdbCataloger(),
ruby.NewGemFileLockCataloger(), ruby.NewGemFileLockCataloger(),
python.NewPythonIndexCataloger(), python.NewPythonIndexCataloger(),
@ -89,8 +77,8 @@ func DirectoryCatalogers(cfg Config) []Cataloger {
} }
// AllCatalogers returns all implemented catalogers // AllCatalogers returns all implemented catalogers
func AllCatalogers(cfg Config) []Cataloger { func AllCatalogers(cfg Config) []pkg.Cataloger {
return filterCatalogers([]Cataloger{ return filterCatalogers([]pkg.Cataloger{
alpm.NewAlpmdbCataloger(), alpm.NewAlpmdbCataloger(),
ruby.NewGemFileLockCataloger(), ruby.NewGemFileLockCataloger(),
ruby.NewGemSpecCataloger(), ruby.NewGemSpecCataloger(),
@ -128,7 +116,7 @@ func RequestedAllCatalogers(cfg Config) bool {
return false return false
} }
func filterCatalogers(catalogers []Cataloger, enabledCatalogerPatterns []string) []Cataloger { func filterCatalogers(catalogers []pkg.Cataloger, enabledCatalogerPatterns []string) []pkg.Cataloger {
// if cataloger is not set, all applicable catalogers are enabled by default // if cataloger is not set, all applicable catalogers are enabled by default
if len(enabledCatalogerPatterns) == 0 { if len(enabledCatalogerPatterns) == 0 {
return catalogers return catalogers
@ -138,7 +126,7 @@ func filterCatalogers(catalogers []Cataloger, enabledCatalogerPatterns []string)
return catalogers return catalogers
} }
} }
var keepCatalogers []Cataloger var keepCatalogers []pkg.Cataloger
for _, cataloger := range catalogers { for _, cataloger := range catalogers {
if contains(enabledCatalogerPatterns, cataloger.Name()) { if contains(enabledCatalogerPatterns, cataloger.Name()) {
keepCatalogers = append(keepCatalogers, cataloger) keepCatalogers = append(keepCatalogers, cataloger)

View File

@ -10,7 +10,7 @@ import (
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
var _ Cataloger = (*dummy)(nil) var _ pkg.Cataloger = (*dummy)(nil)
type dummy struct { type dummy struct {
name string name string
@ -147,7 +147,7 @@ func Test_filterCatalogers(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var catalogers []Cataloger var catalogers []pkg.Cataloger
for _, n := range tt.catalogers { for _, n := range tt.catalogers {
catalogers = append(catalogers, dummy{name: n}) catalogers = append(catalogers, dummy{name: n})
} }

View File

@ -90,5 +90,5 @@ func TestParseConanfile(t *testing.T) {
// TODO: relationships are not under test // TODO: relationships are not under test
var expectedRelationships []artifact.Relationship var expectedRelationships []artifact.Relationship
pkgtest.TestGenericParser(t, fixture, parseConanfile, expected, expectedRelationships) pkgtest.TestFileParser(t, fixture, parseConanfile, expected, expectedRelationships)
} }

View File

@ -35,5 +35,5 @@ func TestParseConanlock(t *testing.T) {
// TODO: relationships are not under test // TODO: relationships are not under test
var expectedRelationships []artifact.Relationship var expectedRelationships []artifact.Relationship
pkgtest.TestGenericParser(t, fixture, parseConanlock, expected, expectedRelationships) pkgtest.TestFileParser(t, fixture, parseConanlock, expected, expectedRelationships)
} }

View File

@ -98,5 +98,5 @@ func TestParsePubspecLock(t *testing.T) {
// TODO: relationships are not under test // TODO: relationships are not under test
var expectedRelationships []artifact.Relationship var expectedRelationships []artifact.Relationship
pkgtest.TestGenericParser(t, fixture, parsePubspecLock, expected, expectedRelationships) pkgtest.TestFileParser(t, fixture, parsePubspecLock, expected, expectedRelationships)
} }

View File

@ -4,230 +4,14 @@ Package dpkg provides a concrete Cataloger implementation for Debian package DB
package deb package deb
import ( import (
"fmt"
"io"
"path"
"path/filepath"
"sort"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/pkg/cataloger/generic"
) )
const ( const catalogerName = "dpkgdb-cataloger"
md5sumsExt = ".md5sums"
conffilesExt = ".conffiles"
docsPath = "/usr/share/doc"
)
type Cataloger struct{} // NewDpkgdbCataloger returns a new Deb package cataloger capable of parsing DPKG status DB files.
func NewDpkgdbCataloger() *generic.Cataloger {
// NewDpkgdbCataloger returns a new Deb package cataloger object. return generic.NewCataloger(catalogerName).
func NewDpkgdbCataloger() *Cataloger { WithParserByGlobs(parseDpkgDB, pkg.DpkgDBGlob)
return &Cataloger{}
}
// Name returns a string that uniquely describes a cataloger
func (c *Cataloger) Name() string {
return "dpkgdb-cataloger"
}
// Catalog is given an object to resolve file references and content, this function returns any discovered Packages after analyzing dpkg support files.
func (c *Cataloger) Catalog(resolver source.FileResolver) ([]pkg.Package, []artifact.Relationship, error) {
dbFileMatches, err := resolver.FilesByGlob(pkg.DpkgDBGlob)
if err != nil {
return nil, nil, fmt.Errorf("failed to find dpkg status files's by glob: %w", err)
}
var allPackages []pkg.Package
for _, dbLocation := range dbFileMatches {
dbContents, err := resolver.FileContentsByLocation(dbLocation)
if err != nil {
return nil, nil, err
}
pkgs, err := parseDpkgStatus(dbContents)
internal.CloseAndLogError(dbContents, dbLocation.VirtualPath)
if err != nil {
log.Warnf("dpkg cataloger: unable to catalog package=%+v: %w", dbLocation.RealPath, err)
continue
}
for i := range pkgs {
p := &pkgs[i]
p.FoundBy = c.Name()
p.Locations.Add(dbLocation)
// the current entry only has what may have been listed in the status file, however, there are additional
// files that are listed in multiple other locations. We should retrieve them all and merge the file lists
// together.
mergeFileListing(resolver, dbLocation, p)
// fetch additional data from the copyright file to derive the license information
addLicenses(resolver, dbLocation, p)
p.SetID()
}
allPackages = append(allPackages, pkgs...)
}
return allPackages, nil, nil
}
func addLicenses(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) {
// get license information from the copyright file
copyrightReader, copyrightLocation := fetchCopyrightContents(resolver, dbLocation, p)
if copyrightReader != nil && copyrightLocation != nil {
defer internal.CloseAndLogError(copyrightReader, copyrightLocation.VirtualPath)
// attach the licenses
p.Licenses = parseLicensesFromCopyright(copyrightReader)
// keep a record of the file where this was discovered
p.Locations.Add(*copyrightLocation)
}
}
func mergeFileListing(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) {
metadata := p.Metadata.(pkg.DpkgMetadata)
// get file listing (package files + additional config files)
files, infoLocations := getAdditionalFileListing(resolver, dbLocation, p)
loopNewFiles:
for _, newFile := range files {
for _, existingFile := range metadata.Files {
if existingFile.Path == newFile.Path {
// skip adding this file since it already exists
continue loopNewFiles
}
}
metadata.Files = append(metadata.Files, newFile)
}
// sort files by path
sort.SliceStable(metadata.Files, func(i, j int) bool {
return metadata.Files[i].Path < metadata.Files[j].Path
})
// persist alterations
p.Metadata = metadata
// persist location information from each new source of information
p.Locations.Add(infoLocations...)
}
func getAdditionalFileListing(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) ([]pkg.DpkgFileRecord, []source.Location) {
// ensure the default value for a collection is never nil since this may be shown as JSON
var files = make([]pkg.DpkgFileRecord, 0)
var locations []source.Location
md5Reader, md5Location := fetchMd5Contents(resolver, dbLocation, p)
if md5Reader != nil && md5Location != nil {
defer internal.CloseAndLogError(md5Reader, md5Location.VirtualPath)
// attach the file list
files = append(files, parseDpkgMD5Info(md5Reader)...)
// keep a record of the file where this was discovered
locations = append(locations, *md5Location)
}
conffilesReader, conffilesLocation := fetchConffileContents(resolver, dbLocation, p)
if conffilesReader != nil && conffilesLocation != nil {
defer internal.CloseAndLogError(conffilesReader, conffilesLocation.VirtualPath)
// attach the file list
files = append(files, parseDpkgConffileInfo(conffilesReader)...)
// keep a record of the file where this was discovered
locations = append(locations, *conffilesLocation)
}
return files, locations
}
func fetchMd5Contents(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) (io.ReadCloser, *source.Location) {
var md5Reader io.ReadCloser
var err error
parentPath := filepath.Dir(dbLocation.RealPath)
// look for /var/lib/dpkg/info/NAME:ARCH.md5sums
name := md5Key(p)
location := resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", name+md5sumsExt))
if location == nil {
// the most specific key did not work, fallback to just the name
// look for /var/lib/dpkg/info/NAME.md5sums
location = resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", p.Name+md5sumsExt))
}
// this is unexpected, but not a show-stopper
if location != nil {
md5Reader, err = resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb md5 contents (package=%s): %+v", p.Name, err)
}
}
return md5Reader, location
}
func fetchConffileContents(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) (io.ReadCloser, *source.Location) {
var reader io.ReadCloser
var err error
parentPath := filepath.Dir(dbLocation.RealPath)
// look for /var/lib/dpkg/info/NAME:ARCH.conffiles
name := md5Key(p)
location := resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", name+conffilesExt))
if location == nil {
// the most specific key did not work, fallback to just the name
// look for /var/lib/dpkg/info/NAME.conffiles
location = resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", p.Name+conffilesExt))
}
// this is unexpected, but not a show-stopper
if location != nil {
reader, err = resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb conffiles contents (package=%s): %+v", p.Name, err)
}
}
return reader, location
}
func fetchCopyrightContents(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) (io.ReadCloser, *source.Location) {
// look for /usr/share/docs/NAME/copyright files
name := p.Name
copyrightPath := path.Join(docsPath, name, "copyright")
location := resolver.RelativeFileByPath(dbLocation, copyrightPath)
// we may not have a copyright file for each package, ignore missing files
if location == nil {
return nil, nil
}
reader, err := resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb copyright contents (package=%s): %w", p.Name, err)
}
return reader, location
}
func md5Key(p *pkg.Package) string {
metadata := p.Metadata.(pkg.DpkgMetadata)
contentKey := p.Name
if metadata.Architecture != "" && metadata.Architecture != "all" {
contentKey = contentKey + ":" + metadata.Architecture
}
return contentKey
} }

View File

@ -3,138 +3,81 @@ package deb
import ( import (
"testing" "testing"
"github.com/go-test/deep"
"github.com/stretchr/testify/assert"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
func TestDpkgCataloger(t *testing.T) { func TestDpkgCataloger(t *testing.T) {
tests := []struct { expected := []pkg.Package{
name string
sources map[string][]string
expected []pkg.Package
}{
{ {
name: "go-case", Name: "libpam-runtime",
sources: map[string][]string{ Version: "1.1.8-3.6",
"libpam-runtime": { FoundBy: "dpkgdb-cataloger",
"/var/lib/dpkg/status", Licenses: []string{"GPL-1", "GPL-2", "LGPL-2.1"},
"/var/lib/dpkg/info/libpam-runtime.md5sums", Locations: source.NewLocationSet(
"/var/lib/dpkg/info/libpam-runtime.conffiles", source.NewVirtualLocation("/var/lib/dpkg/status", "/var/lib/dpkg/status"),
"/usr/share/doc/libpam-runtime/copyright", source.NewVirtualLocation("/var/lib/dpkg/info/libpam-runtime.md5sums", "/var/lib/dpkg/info/libpam-runtime.md5sums"),
}, source.NewVirtualLocation("/var/lib/dpkg/info/libpam-runtime.conffiles", "/var/lib/dpkg/info/libpam-runtime.conffiles"),
}, source.NewVirtualLocation("/usr/share/doc/libpam-runtime/copyright", "/usr/share/doc/libpam-runtime/copyright"),
expected: []pkg.Package{ ),
{ Type: pkg.DebPkg,
Name: "libpam-runtime", MetadataType: pkg.DpkgMetadataType,
Version: "1.1.8-3.6", Metadata: pkg.DpkgMetadata{
FoundBy: "dpkgdb-cataloger", Package: "libpam-runtime",
Licenses: []string{"GPL-1", "GPL-2", "LGPL-2.1"}, Source: "pam",
Type: pkg.DebPkg, Version: "1.1.8-3.6",
MetadataType: pkg.DpkgMetadataType, Architecture: "all",
Metadata: pkg.DpkgMetadata{ Maintainer: "Steve Langasek <vorlon@debian.org>",
Package: "libpam-runtime", InstalledSize: 1016,
Source: "pam", Description: `Runtime support for the PAM library
Version: "1.1.8-3.6",
Architecture: "all",
Maintainer: "Steve Langasek <vorlon@debian.org>",
InstalledSize: 1016,
Description: `Runtime support for the PAM library
Contains configuration files and directories required for Contains configuration files and directories required for
authentication to work on Debian systems. This package is required authentication to work on Debian systems. This package is required
on almost all installations.`, on almost all installations.`,
Files: []pkg.DpkgFileRecord{ Files: []pkg.DpkgFileRecord{
{ {
Path: "/etc/pam.conf", Path: "/etc/pam.conf",
Digest: &file.Digest{ Digest: &file.Digest{
Algorithm: "md5", Algorithm: "md5",
Value: "87fc76f18e98ee7d3848f6b81b3391e5", Value: "87fc76f18e98ee7d3848f6b81b3391e5",
},
IsConfigFile: true,
},
{
Path: "/etc/pam.d/other",
Digest: &file.Digest{
Algorithm: "md5",
Value: "31aa7f2181889ffb00b87df4126d1701",
},
IsConfigFile: true,
},
{Path: "/lib/x86_64-linux-gnu/libz.so.1.2.11", Digest: &file.Digest{
Algorithm: "md5",
Value: "55f905631797551d4d936a34c7e73474",
}},
{Path: "/usr/share/doc/zlib1g/changelog.Debian.gz", Digest: &file.Digest{
Algorithm: "md5",
Value: "cede84bda30d2380217f97753c8ccf3a",
}},
{Path: "/usr/share/doc/zlib1g/changelog.gz", Digest: &file.Digest{
Algorithm: "md5",
Value: "f3c9dafa6da7992c47328b4464f6d122",
}},
{Path: "/usr/share/doc/zlib1g/copyright", Digest: &file.Digest{
Algorithm: "md5",
Value: "a4fae96070439a5209a62ae5b8017ab2",
}},
}, },
IsConfigFile: true,
}, },
{
Path: "/etc/pam.d/other",
Digest: &file.Digest{
Algorithm: "md5",
Value: "31aa7f2181889ffb00b87df4126d1701",
},
IsConfigFile: true,
},
{Path: "/lib/x86_64-linux-gnu/libz.so.1.2.11", Digest: &file.Digest{
Algorithm: "md5",
Value: "55f905631797551d4d936a34c7e73474",
}},
{Path: "/usr/share/doc/zlib1g/changelog.Debian.gz", Digest: &file.Digest{
Algorithm: "md5",
Value: "cede84bda30d2380217f97753c8ccf3a",
}},
{Path: "/usr/share/doc/zlib1g/changelog.gz", Digest: &file.Digest{
Algorithm: "md5",
Value: "f3c9dafa6da7992c47328b4464f6d122",
}},
{Path: "/usr/share/doc/zlib1g/copyright", Digest: &file.Digest{
Algorithm: "md5",
Value: "a4fae96070439a5209a62ae5b8017ab2",
}},
}, },
}, },
}, },
} }
for _, test := range tests { c := NewDpkgdbCataloger()
t.Run(test.name, func(t *testing.T) {
img := imagetest.GetFixtureImage(t, "docker-archive", "image-dpkg")
s, err := source.NewFromImage(img, "")
if err != nil {
t.Fatal(err)
}
c := NewDpkgdbCataloger()
resolver, err := s.FileResolver(source.SquashedScope)
if err != nil {
t.Errorf("could not get resolver error: %+v", err)
}
actual, _, err := c.Catalog(resolver)
if err != nil {
t.Fatalf("failed to catalog: %+v", err)
}
if len(actual) != len(test.expected) {
for _, a := range actual {
t.Logf(" %+v", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(test.expected))
}
// test sources...
for idx := range actual {
a := &actual[idx]
// we will test the sources separately
var sourcesList = make([]string, len(a.Locations.ToSlice()))
for i, s := range a.Locations.ToSlice() {
sourcesList[i] = s.RealPath
}
a.Locations = source.NewLocationSet()
assert.ElementsMatch(t, sourcesList, test.sources[a.Name])
}
// test remaining fields...
for _, d := range deep.Equal(actual, test.expected) {
t.Errorf("diff: %+v", d)
}
})
}
pkgtest.NewCatalogTester().
WithImageResolver(t, "image-dpkg").
IgnoreLocationLayer(). // this fixture can be rebuilt, thus the layer ID will change
Expects(expected, nil).
TestCataloger(t, c)
} }

View File

@ -0,0 +1,247 @@
package deb
import (
"fmt"
"io"
"path"
"path/filepath"
"sort"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
const (
md5sumsExt = ".md5sums"
conffilesExt = ".conffiles"
docsPath = "/usr/share/doc"
)
func newDpkgPackage(d pkg.DpkgMetadata, dbLocation source.Location, resolver source.FileResolver, release *linux.Release) pkg.Package {
p := pkg.Package{
Name: d.Package,
Version: d.Version,
Locations: source.NewLocationSet(dbLocation),
PURL: packageURL(d, release),
Type: pkg.DebPkg,
MetadataType: pkg.DpkgMetadataType,
Metadata: d,
}
// the current entry only has what may have been listed in the status file, however, there are additional
// files that are listed in multiple other locations. We should retrieve them all and merge the file lists
// together.
mergeFileListing(resolver, dbLocation, &p)
// fetch additional data from the copyright file to derive the license information
addLicenses(resolver, dbLocation, &p)
p.SetID()
return p
}
// PackageURL returns the PURL for the specific Debian package (see https://github.com/package-url/purl-spec)
func packageURL(m pkg.DpkgMetadata, distro *linux.Release) string {
if distro == nil {
return ""
}
if distro.ID != "debian" && !internal.StringInSlice("debian", distro.IDLike) {
return ""
}
qualifiers := map[string]string{
pkg.PURLQualifierArch: m.Architecture,
}
if m.Source != "" {
if m.SourceVersion != "" {
qualifiers[pkg.PURLQualifierUpstream] = fmt.Sprintf("%s@%s", m.Source, m.SourceVersion)
} else {
qualifiers[pkg.PURLQualifierUpstream] = m.Source
}
}
return packageurl.NewPackageURL(
packageurl.TypeDebian,
distro.ID,
m.Package,
m.Version,
pkg.PURLQualifiers(
qualifiers,
distro,
),
"",
).ToString()
}
func addLicenses(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) {
metadata := p.Metadata.(pkg.DpkgMetadata)
// get license information from the copyright file
copyrightReader, copyrightLocation := fetchCopyrightContents(resolver, dbLocation, metadata)
if copyrightReader != nil && copyrightLocation != nil {
defer internal.CloseAndLogError(copyrightReader, copyrightLocation.VirtualPath)
// attach the licenses
p.Licenses = parseLicensesFromCopyright(copyrightReader)
// keep a record of the file where this was discovered
p.Locations.Add(*copyrightLocation)
}
}
func mergeFileListing(resolver source.FileResolver, dbLocation source.Location, p *pkg.Package) {
metadata := p.Metadata.(pkg.DpkgMetadata)
// get file listing (package files + additional config files)
files, infoLocations := getAdditionalFileListing(resolver, dbLocation, metadata)
loopNewFiles:
for _, newFile := range files {
for _, existingFile := range metadata.Files {
if existingFile.Path == newFile.Path {
// skip adding this file since it already exists
continue loopNewFiles
}
}
metadata.Files = append(metadata.Files, newFile)
}
// sort files by path
sort.SliceStable(metadata.Files, func(i, j int) bool {
return metadata.Files[i].Path < metadata.Files[j].Path
})
// persist alterations
p.Metadata = metadata
// persist location information from each new source of information
p.Locations.Add(infoLocations...)
}
func getAdditionalFileListing(resolver source.FileResolver, dbLocation source.Location, m pkg.DpkgMetadata) ([]pkg.DpkgFileRecord, []source.Location) {
// ensure the default value for a collection is never nil since this may be shown as JSON
var files = make([]pkg.DpkgFileRecord, 0)
var locations []source.Location
md5Reader, md5Location := fetchMd5Contents(resolver, dbLocation, m)
if md5Reader != nil && md5Location != nil {
defer internal.CloseAndLogError(md5Reader, md5Location.VirtualPath)
// attach the file list
files = append(files, parseDpkgMD5Info(md5Reader)...)
// keep a record of the file where this was discovered
locations = append(locations, *md5Location)
}
conffilesReader, conffilesLocation := fetchConffileContents(resolver, dbLocation, m)
if conffilesReader != nil && conffilesLocation != nil {
defer internal.CloseAndLogError(conffilesReader, conffilesLocation.VirtualPath)
// attach the file list
files = append(files, parseDpkgConffileInfo(conffilesReader)...)
// keep a record of the file where this was discovered
locations = append(locations, *conffilesLocation)
}
return files, locations
}
func fetchMd5Contents(resolver source.FileResolver, dbLocation source.Location, m pkg.DpkgMetadata) (io.ReadCloser, *source.Location) {
var md5Reader io.ReadCloser
var err error
if resolver == nil {
return nil, nil
}
parentPath := filepath.Dir(dbLocation.RealPath)
// look for /var/lib/dpkg/info/NAME:ARCH.md5sums
name := md5Key(m)
location := resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", name+md5sumsExt))
if location == nil {
// the most specific key did not work, fallback to just the name
// look for /var/lib/dpkg/info/NAME.md5sums
location = resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", m.Package+md5sumsExt))
}
// this is unexpected, but not a show-stopper
if location != nil {
md5Reader, err = resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb md5 contents (package=%s): %+v", m.Package, err)
}
}
return md5Reader, location
}
func fetchConffileContents(resolver source.FileResolver, dbLocation source.Location, m pkg.DpkgMetadata) (io.ReadCloser, *source.Location) {
var reader io.ReadCloser
var err error
if resolver == nil {
return nil, nil
}
parentPath := filepath.Dir(dbLocation.RealPath)
// look for /var/lib/dpkg/info/NAME:ARCH.conffiles
name := md5Key(m)
location := resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", name+conffilesExt))
if location == nil {
// the most specific key did not work, fallback to just the name
// look for /var/lib/dpkg/info/NAME.conffiles
location = resolver.RelativeFileByPath(dbLocation, path.Join(parentPath, "info", m.Package+conffilesExt))
}
// this is unexpected, but not a show-stopper
if location != nil {
reader, err = resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb conffiles contents (package=%s): %+v", m.Package, err)
}
}
return reader, location
}
func fetchCopyrightContents(resolver source.FileResolver, dbLocation source.Location, m pkg.DpkgMetadata) (io.ReadCloser, *source.Location) {
if resolver == nil {
return nil, nil
}
// look for /usr/share/docs/NAME/copyright files
copyrightPath := path.Join(docsPath, m.Package, "copyright")
location := resolver.RelativeFileByPath(dbLocation, copyrightPath)
// we may not have a copyright file for each package, ignore missing files
if location == nil {
return nil, nil
}
reader, err := resolver.FileContentsByLocation(*location)
if err != nil {
log.Warnf("failed to fetch deb copyright contents (package=%s): %w", m.Package, err)
}
return reader, location
}
func md5Key(metadata pkg.DpkgMetadata) string {
contentKey := metadata.Package
if metadata.Architecture != "" && metadata.Architecture != "all" {
contentKey = contentKey + ":" + metadata.Architecture
}
return contentKey
}

View File

@ -1,20 +1,19 @@
package pkg package deb
import ( import (
"strings"
"testing" "testing"
"github.com/go-test/deep" "github.com/google/go-cmp/cmp"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
) )
func TestDpkgMetadata_pURL(t *testing.T) { func Test_packageURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
distro *linux.Release distro *linux.Release
metadata DpkgMetadata metadata pkg.DpkgMetadata
expected string expected string
}{ }{
{ {
@ -22,8 +21,23 @@ func TestDpkgMetadata_pURL(t *testing.T) {
distro: &linux.Release{ distro: &linux.Release{
ID: "debian", ID: "debian",
VersionID: "11", VersionID: "11",
IDLike: []string{
"debian",
},
}, },
metadata: DpkgMetadata{ metadata: pkg.DpkgMetadata{
Package: "p",
Version: "v",
},
expected: "pkg:deb/debian/p@v?distro=debian-11",
},
{
name: "missing ID_LIKE",
distro: &linux.Release{
ID: "debian",
VersionID: "11",
},
metadata: pkg.DpkgMetadata{
Package: "p", Package: "p",
Version: "v", Version: "v",
}, },
@ -32,10 +46,13 @@ func TestDpkgMetadata_pURL(t *testing.T) {
{ {
name: "with arch info", name: "with arch info",
distro: &linux.Release{ distro: &linux.Release{
ID: "ubuntu", ID: "ubuntu",
IDLike: []string{
"debian",
},
VersionID: "16.04", VersionID: "16.04",
}, },
metadata: DpkgMetadata{ metadata: pkg.DpkgMetadata{
Package: "p", Package: "p",
Version: "v", Version: "v",
Architecture: "a", Architecture: "a",
@ -44,19 +61,22 @@ func TestDpkgMetadata_pURL(t *testing.T) {
}, },
{ {
name: "missing distro", name: "missing distro",
metadata: DpkgMetadata{ metadata: pkg.DpkgMetadata{
Package: "p", Package: "p",
Version: "v", Version: "v",
}, },
expected: "pkg:deb/p@v", expected: "",
}, },
{ {
name: "with upstream qualifier with source pkg name info", name: "with upstream qualifier with source pkg name info",
distro: &linux.Release{ distro: &linux.Release{
ID: "debian", ID: "debian",
VersionID: "11", VersionID: "11",
IDLike: []string{
"debian",
},
}, },
metadata: DpkgMetadata{ metadata: pkg.DpkgMetadata{
Package: "p", Package: "p",
Source: "s", Source: "s",
Version: "v", Version: "v",
@ -68,8 +88,11 @@ func TestDpkgMetadata_pURL(t *testing.T) {
distro: &linux.Release{ distro: &linux.Release{
ID: "debian", ID: "debian",
VersionID: "11", VersionID: "11",
IDLike: []string{
"debian",
},
}, },
metadata: DpkgMetadata{ metadata: pkg.DpkgMetadata{
Package: "p", Package: "p",
Source: "s", Source: "s",
Version: "v", Version: "v",
@ -81,51 +104,9 @@ func TestDpkgMetadata_pURL(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL(test.distro) actual := packageURL(test.metadata, test.distro)
if actual != test.expected { if diff := cmp.Diff(test.expected, actual); diff != "" {
dmp := diffmatchpatch.New() t.Errorf("unexpected packageURL (-want +got):\n%s", diff)
diffs := dmp.DiffMain(test.expected, actual, true)
t.Errorf("diff: %s", dmp.DiffPrettyText(diffs))
}
})
}
}
func TestDpkgMetadata_FileOwner(t *testing.T) {
tests := []struct {
metadata DpkgMetadata
expected []string
}{
{
metadata: DpkgMetadata{
Files: []DpkgFileRecord{
{Path: "/somewhere"},
{Path: "/else"},
},
},
expected: []string{
"/else",
"/somewhere",
},
},
{
metadata: DpkgMetadata{
Files: []DpkgFileRecord{
{Path: "/somewhere"},
{Path: ""},
},
},
expected: []string{
"/somewhere",
},
},
}
for _, test := range tests {
t.Run(strings.Join(test.expected, ","), func(t *testing.T) {
actual := test.metadata.OwnedFiles()
for _, d := range deep.Equal(test.expected, actual) {
t.Errorf("diff: %+v", d)
} }
}) })
} }

View File

@ -4,7 +4,8 @@ import (
"os" "os"
"testing" "testing"
"github.com/go-test/deep" "github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
) )
func TestParseLicensesFromCopyright(t *testing.T) { func TestParseLicensesFromCopyright(t *testing.T) {
@ -38,31 +39,15 @@ func TestParseLicensesFromCopyright(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) { t.Run(test.fixture, func(t *testing.T) {
file, err := os.Open(test.fixture) f, err := os.Open(test.fixture)
if err != nil { require.NoError(t, err)
t.Fatal("Unable to read: ", err) t.Cleanup(func() { require.NoError(t, f.Close()) })
actual := parseLicensesFromCopyright(f)
if diff := cmp.Diff(test.expected, actual); diff != "" {
t.Errorf("unexpected package licenses (-want +got):\n%s", diff)
} }
defer func() {
err := file.Close()
if err != nil {
t.Fatal("closing file failed:", err)
}
}()
actual := parseLicensesFromCopyright(file)
if len(actual) != len(test.expected) {
for _, a := range actual {
t.Logf(" %+v", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(test.expected))
}
diffs := deep.Equal(actual, test.expected)
for _, d := range diffs {
t.Errorf("diff: %+v", d)
}
}) })
} }
} }

View File

@ -13,7 +13,10 @@ import (
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/source"
) )
var ( var (
@ -21,20 +24,24 @@ var (
sourceRegexp = regexp.MustCompile(`(?P<name>\S+)( \((?P<version>.*)\))?`) sourceRegexp = regexp.MustCompile(`(?P<name>\S+)( \((?P<version>.*)\))?`)
) )
func newDpkgPackage(d pkg.DpkgMetadata) *pkg.Package { func parseDpkgDB(resolver source.FileResolver, env *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
return &pkg.Package{ metadata, err := parseDpkgStatus(reader)
Name: d.Package, if err != nil {
Version: d.Version, return nil, nil, fmt.Errorf("unable to catalog dpkg DB=%q: %w", reader.RealPath, err)
Type: pkg.DebPkg,
MetadataType: pkg.DpkgMetadataType,
Metadata: d,
} }
var pkgs []pkg.Package
for _, m := range metadata {
pkgs = append(pkgs, newDpkgPackage(m, reader.Location, resolver, env.LinuxRelease))
}
return pkgs, nil, nil
} }
// parseDpkgStatus is a parser function for Debian DB status contents, returning all Debian packages listed. // parseDpkgStatus is a parser function for Debian DB status contents, returning all Debian packages listed.
func parseDpkgStatus(reader io.Reader) ([]pkg.Package, error) { func parseDpkgStatus(reader io.Reader) ([]pkg.DpkgMetadata, error) {
buffedReader := bufio.NewReader(reader) buffedReader := bufio.NewReader(reader)
var packages []pkg.Package var metadata []pkg.DpkgMetadata
continueProcessing := true continueProcessing := true
for continueProcessing { for continueProcessing {
@ -46,40 +53,44 @@ func parseDpkgStatus(reader io.Reader) ([]pkg.Package, error) {
return nil, err return nil, err
} }
} }
if entry == nil {
p := newDpkgPackage(entry) continue
if pkg.IsValid(p) {
packages = append(packages, *p)
} }
metadata = append(metadata, *entry)
} }
return packages, nil return metadata, nil
} }
// parseDpkgStatusEntry returns an individual Dpkg entry, or returns errEndOfPackages if there are no more packages to parse from the reader. // parseDpkgStatusEntry returns an individual Dpkg entry, or returns errEndOfPackages if there are no more packages to parse from the reader.
func parseDpkgStatusEntry(reader *bufio.Reader) (pkg.DpkgMetadata, error) { func parseDpkgStatusEntry(reader *bufio.Reader) (*pkg.DpkgMetadata, error) {
var retErr error var retErr error
dpkgFields, err := extractAllFields(reader) dpkgFields, err := extractAllFields(reader)
if err != nil { if err != nil {
if !errors.Is(err, errEndOfPackages) { if !errors.Is(err, errEndOfPackages) {
return pkg.DpkgMetadata{}, err return nil, err
}
if len(dpkgFields) == 0 {
return nil, err
} }
retErr = err retErr = err
} }
entry := pkg.DpkgMetadata{ entry := pkg.DpkgMetadata{}
// ensure the default value for a collection is never nil since this may be shown as JSON
Files: make([]pkg.DpkgFileRecord, 0),
}
err = mapstructure.Decode(dpkgFields, &entry) err = mapstructure.Decode(dpkgFields, &entry)
if err != nil { if err != nil {
return pkg.DpkgMetadata{}, err return nil, err
} }
name, version := extractSourceVersion(entry.Source) sourceName, sourceVersion := extractSourceVersion(entry.Source)
if version != "" { if sourceVersion != "" {
entry.SourceVersion = version entry.SourceVersion = sourceVersion
entry.Source = name entry.Source = sourceName
}
if entry.Package == "" {
return nil, retErr
} }
// there may be an optional conffiles section that we should persist as files // there may be an optional conffiles section that we should persist as files
@ -89,7 +100,12 @@ func parseDpkgStatusEntry(reader *bufio.Reader) (pkg.DpkgMetadata, error) {
} }
} }
return entry, retErr if entry.Files == nil {
// ensure the default value for a collection is never nil since this may be shown as JSON
entry.Files = make([]pkg.DpkgFileRecord, 0)
}
return &entry, retErr
} }
func extractAllFields(reader *bufio.Reader) (map[string]interface{}, error) { func extractAllFields(reader *bufio.Reader) (map[string]interface{}, error) {

View File

@ -5,41 +5,37 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"testing" "testing"
"github.com/go-test/deep" "github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
"github.com/anchore/syft/syft/source"
) )
func compareEntries(t *testing.T, left, right pkg.DpkgMetadata) { func Test_parseDpkgStatus(t *testing.T) {
t.Helper()
if diff := deep.Equal(left, right); diff != nil {
t.Error(diff)
}
}
func TestSinglePackage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
expected pkg.DpkgMetadata expected []pkg.DpkgMetadata
fixturePath string fixturePath string
}{ }{
{ {
name: "Test Single Package", name: "single package",
fixturePath: filepath.Join("test-fixtures", "status", "single"), fixturePath: "test-fixtures/status/single",
expected: pkg.DpkgMetadata{ expected: []pkg.DpkgMetadata{
Package: "apt", {
Source: "apt-dev", Package: "apt",
Version: "1.8.2", Source: "apt-dev",
Architecture: "amd64", Version: "1.8.2",
InstalledSize: 4064, Architecture: "amd64",
Maintainer: "APT Development Team <deity@lists.debian.org>", InstalledSize: 4064,
Description: `commandline package manager Maintainer: "APT Development Team <deity@lists.debian.org>",
Description: `commandline package manager
This package provides commandline tools for searching and This package provides commandline tools for searching and
managing as well as querying information about packages managing as well as querying information about packages
as a low-level access to all features of the libapt-pkg library. as a low-level access to all features of the libapt-pkg library.
@ -53,53 +49,55 @@ func TestSinglePackage(t *testing.T) {
* apt-cdrom to use removable media as a source for packages * apt-cdrom to use removable media as a source for packages
* apt-config as an interface to the configuration settings * apt-config as an interface to the configuration settings
* apt-key as an interface to manage authentication keys`, * apt-key as an interface to manage authentication keys`,
Files: []pkg.DpkgFileRecord{ Files: []pkg.DpkgFileRecord{
{ {
Path: "/etc/apt/apt.conf.d/01autoremove", Path: "/etc/apt/apt.conf.d/01autoremove",
Digest: &file.Digest{ Digest: &file.Digest{
Algorithm: "md5", Algorithm: "md5",
Value: "76120d358bc9037bb6358e737b3050b5", Value: "76120d358bc9037bb6358e737b3050b5",
},
IsConfigFile: true,
}, },
IsConfigFile: true, {
}, Path: "/etc/cron.daily/apt-compat",
{ Digest: &file.Digest{
Path: "/etc/cron.daily/apt-compat", Algorithm: "md5",
Digest: &file.Digest{ Value: "49e9b2cfa17849700d4db735d04244f3",
Algorithm: "md5", },
Value: "49e9b2cfa17849700d4db735d04244f3", IsConfigFile: true,
}, },
IsConfigFile: true, {
}, Path: "/etc/kernel/postinst.d/apt-auto-removal",
{ Digest: &file.Digest{
Path: "/etc/kernel/postinst.d/apt-auto-removal", Algorithm: "md5",
Digest: &file.Digest{ Value: "4ad976a68f045517cf4696cec7b8aa3a",
Algorithm: "md5", },
Value: "4ad976a68f045517cf4696cec7b8aa3a", IsConfigFile: true,
}, },
IsConfigFile: true, {
}, Path: "/etc/logrotate.d/apt",
{ Digest: &file.Digest{
Path: "/etc/logrotate.d/apt", Algorithm: "md5",
Digest: &file.Digest{ Value: "179f2ed4f85cbaca12fa3d69c2a4a1c3",
Algorithm: "md5", },
Value: "179f2ed4f85cbaca12fa3d69c2a4a1c3", IsConfigFile: true,
}, },
IsConfigFile: true,
}, },
}, },
}, },
}, },
{ {
name: "parse storage notation", name: "single package with installed size",
fixturePath: filepath.Join("test-fixtures", "status", "installed-size-4KB"), fixturePath: "test-fixtures/status/installed-size-4KB",
expected: pkg.DpkgMetadata{ expected: []pkg.DpkgMetadata{
Package: "apt", {
Source: "apt-dev", Package: "apt",
Version: "1.8.2", Source: "apt-dev",
Architecture: "amd64", Version: "1.8.2",
InstalledSize: 4000, Architecture: "amd64",
Maintainer: "APT Development Team <deity@lists.debian.org>", InstalledSize: 4000,
Description: `commandline package manager Maintainer: "APT Development Team <deity@lists.debian.org>",
Description: `commandline package manager
This package provides commandline tools for searching and This package provides commandline tools for searching and
managing as well as querying information about packages managing as well as querying information about packages
as a low-level access to all features of the libapt-pkg library. as a low-level access to all features of the libapt-pkg library.
@ -113,41 +111,13 @@ func TestSinglePackage(t *testing.T) {
* apt-cdrom to use removable media as a source for packages * apt-cdrom to use removable media as a source for packages
* apt-config as an interface to the configuration settings * apt-config as an interface to the configuration settings
* apt-key as an interface to manage authentication keys`, * apt-key as an interface to manage authentication keys`,
Files: []pkg.DpkgFileRecord{},
},
}, },
}} },
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
file, err := os.Open(test.fixturePath)
if err != nil {
t.Fatal("Unable to read test_fixtures/single: ", err)
}
defer func() {
err := file.Close()
if err != nil {
t.Fatal("closing file failed:", err)
}
}()
reader := bufio.NewReader(file)
entry, err := parseDpkgStatusEntry(reader)
if err != nil {
t.Fatal("Unable to read file contents: ", err)
}
compareEntries(t, entry, test.expected)
})
}
}
func TestMultiplePackages(t *testing.T) {
tests := []struct {
name string
expected []pkg.DpkgMetadata
}{
{ {
name: "Test Multiple Package", name: "multiple entries",
fixturePath: "test-fixtures/status/multiple",
expected: []pkg.DpkgMetadata{ expected: []pkg.DpkgMetadata{
{ {
Package: "no-version", Package: "no-version",
@ -237,30 +207,18 @@ func TestMultiplePackages(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
file, err := os.Open("test-fixtures/status/multiple") f, err := os.Open(test.fixturePath)
if err != nil { require.NoError(t, err)
t.Fatal("Unable to read: ", err) t.Cleanup(func() { require.NoError(t, f.Close()) })
}
defer func() {
err := file.Close()
if err != nil {
t.Fatal("closing file failed:", err)
}
}()
pkgs, err := parseDpkgStatus(file) reader := bufio.NewReader(f)
if err != nil {
t.Fatal("Unable to read file contents: ", err)
}
if len(pkgs) != 3 { entries, err := parseDpkgStatus(reader)
t.Fatalf("unexpected number of entries: %d", len(pkgs)) require.NoError(t, err)
}
for idx, entry := range pkgs { if diff := cmp.Diff(test.expected, entries); diff != "" {
compareEntries(t, entry.Metadata.(pkg.DpkgMetadata), test.expected[idx]) t.Errorf("unexpected entry (-want +got):\n%s", diff)
} }
}) })
} }
} }
@ -304,23 +262,23 @@ func TestSourceVersionExtract(t *testing.T) {
} }
} }
func assertAs(expected error) assert.ErrorAssertionFunc { func requireAs(expected error) require.ErrorAssertionFunc {
return func(t assert.TestingT, err error, i ...interface{}) bool { return func(t require.TestingT, err error, i ...interface{}) {
return assert.ErrorAs(t, err, &expected) require.ErrorAs(t, err, &expected)
} }
} }
func Test_parseDpkgStatus(t *testing.T) { func Test_parseDpkgStatus_negativeCases(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
want []pkg.Package want []pkg.Package
wantErr assert.ErrorAssertionFunc wantErr require.ErrorAssertionFunc
}{ }{
{ {
name: "no more packages", name: "no more packages",
input: `Package: apt`, input: `Package: apt`,
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "duplicated key", name: "duplicated key",
@ -328,14 +286,14 @@ func Test_parseDpkgStatus(t *testing.T) {
Package: apt-get Package: apt-get
`, `,
wantErr: assertAs(errors.New("duplicate key discovered: Package")), wantErr: requireAs(errors.New("duplicate key discovered: Package")),
}, },
{ {
name: "no match for continuation", name: "no match for continuation",
input: ` Package: apt input: ` Package: apt
`, `,
wantErr: assertAs(errors.New("no match for continuation: line: ' Package: apt'")), wantErr: requireAs(errors.New("no match for continuation: line: ' Package: apt'")),
}, },
{ {
name: "find keys", name: "find keys",
@ -348,6 +306,8 @@ Installed-Size: 10kib
{ {
Name: "apt", Name: "apt",
Type: "deb", Type: "deb",
PURL: "pkg:deb/debian/apt?distro=debian-10",
Locations: source.NewLocationSet(source.NewLocation("place")),
MetadataType: "DpkgMetadata", MetadataType: "DpkgMetadata",
Metadata: pkg.DpkgMetadata{ Metadata: pkg.DpkgMetadata{
Package: "apt", Package: "apt",
@ -356,16 +316,18 @@ Installed-Size: 10kib
}, },
}, },
}, },
wantErr: assert.NoError, wantErr: require.NoError,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(tt.input)) pkgtest.NewCatalogTester().
got, err := parseDpkgStatus(r) FromString("place", tt.input).
tt.wantErr(t, err, fmt.Sprintf("parseDpkgStatus")) WithErrorAssertion(tt.wantErr).
assert.Equal(t, tt.want, got) WithLinuxRelease(linux.Release{ID: "debian", VersionID: "10"}).
Expects(tt.want, nil).
TestParser(t, parseDpkgDB)
}) })
} }
} }
@ -376,53 +338,53 @@ func Test_handleNewKeyValue(t *testing.T) {
line string line string
wantKey string wantKey string
wantVal interface{} wantVal interface{}
wantErr assert.ErrorAssertionFunc wantErr require.ErrorAssertionFunc
}{ }{
{ {
name: "cannot parse field", name: "cannot parse field",
line: "blabla", line: "blabla",
wantErr: assertAs(errors.New("cannot parse field from line: 'blabla'")), wantErr: requireAs(errors.New("cannot parse field from line: 'blabla'")),
}, },
{ {
name: "parse field", name: "parse field",
line: "key: val", line: "key: val",
wantKey: "key", wantKey: "key",
wantVal: "val", wantVal: "val",
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "parse installed size", name: "parse installed size",
line: "InstalledSize: 128", line: "InstalledSize: 128",
wantKey: "InstalledSize", wantKey: "InstalledSize",
wantVal: 128, wantVal: 128,
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "parse installed kib size", name: "parse installed kib size",
line: "InstalledSize: 1kib", line: "InstalledSize: 1kib",
wantKey: "InstalledSize", wantKey: "InstalledSize",
wantVal: 1024, wantVal: 1024,
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "parse installed kb size", name: "parse installed kb size",
line: "InstalledSize: 1kb", line: "InstalledSize: 1kb",
wantKey: "InstalledSize", wantKey: "InstalledSize",
wantVal: 1000, wantVal: 1000,
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "parse installed-size mb", name: "parse installed-size mb",
line: "Installed-Size: 1 mb", line: "Installed-Size: 1 mb",
wantKey: "InstalledSize", wantKey: "InstalledSize",
wantVal: 1000000, wantVal: 1000000,
wantErr: assert.NoError, wantErr: require.NoError,
}, },
{ {
name: "fail parsing installed-size", name: "fail parsing installed-size",
line: "Installed-Size: 1bla", line: "Installed-Size: 1bla",
wantKey: "", wantKey: "",
wantErr: assertAs(fmt.Errorf("unhandled size name: %s", "bla")), wantErr: requireAs(fmt.Errorf("unhandled size name: %s", "bla")),
}, },
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -4,7 +4,8 @@ import (
"os" "os"
"testing" "testing"
"github.com/go-test/deep" "github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
@ -40,29 +41,14 @@ func TestMD5SumInfoParsing(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) { t.Run(test.fixture, func(t *testing.T) {
file, err := os.Open(test.fixture) f, err := os.Open(test.fixture)
if err != nil { require.NoError(t, err)
t.Fatal("Unable to read: ", err) t.Cleanup(func() { require.NoError(t, f.Close()) })
}
defer func() {
err := file.Close()
if err != nil {
t.Fatal("closing file failed:", err)
}
}()
actual := parseDpkgMD5Info(file) actual := parseDpkgMD5Info(f)
if len(actual) != len(test.expected) { if diff := cmp.Diff(test.expected, actual); diff != "" {
for _, a := range actual { t.Errorf("unexpected md5 files (-want +got):\n%s", diff)
t.Logf(" %+v", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(test.expected))
}
diffs := deep.Equal(actual, test.expected)
for _, d := range diffs {
t.Errorf("diff: %+v", d)
} }
}) })
@ -89,29 +75,14 @@ func TestConffileInfoParsing(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) { t.Run(test.fixture, func(t *testing.T) {
file, err := os.Open(test.fixture) f, err := os.Open(test.fixture)
if err != nil { require.NoError(t, err)
t.Fatal("Unable to read: ", err) t.Cleanup(func() { require.NoError(t, f.Close()) })
}
defer func() {
err := file.Close()
if err != nil {
t.Fatal("closing file failed:", err)
}
}()
actual := parseDpkgConffileInfo(file) actual := parseDpkgConffileInfo(f)
if len(actual) != len(test.expected) { if diff := cmp.Diff(test.expected, actual); diff != "" {
for _, a := range actual { t.Errorf("unexpected md5 files (-want +got):\n%s", diff)
t.Logf(" %+v", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(test.expected))
}
diffs := deep.Equal(actual, test.expected)
for _, d := range diffs {
t.Errorf("diff: %+v", d)
} }
}) })

View File

@ -209,5 +209,5 @@ func TestParseDotnetDeps(t *testing.T) {
} }
var expectedRelationships []artifact.Relationship var expectedRelationships []artifact.Relationship
pkgtest.TestGenericParser(t, fixture, parseDotnetDeps, expected, expectedRelationships) pkgtest.TestFileParser(t, fixture, parseDotnetDeps, expected, expectedRelationships)
} }

View File

@ -1,37 +0,0 @@
package pkgtest
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
func AssertPackagesEqual(t testing.TB, expected, actual []pkg.Package) {
if diff := cmp.Diff(expected, actual,
cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes
cmp.Comparer(
func(x, y source.LocationSet) bool {
xs := x.ToSlice()
ys := y.ToSlice()
if len(xs) != len(ys) {
return false
}
for i, xe := range xs {
ye := ys[i]
if !(cmp.Equal(xe.Coordinates, ye.Coordinates) && cmp.Equal(xe.VirtualPath, ye.VirtualPath)) {
return false
}
}
return true
},
),
); diff != "" {
t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
}
}

View File

@ -1,37 +1,175 @@
package pkgtest package pkgtest
import ( import (
"io"
"os" "os"
"strings"
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic" "github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
func TestGenericParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { type locationComparer func(x, y source.Location) bool
t.Helper()
TestGenericParserWithEnv(t, fixturePath, parser, nil, expectedPkgs, expectedRelationships) type CatalogTester struct {
expectedPkgs []pkg.Package
expectedRelationships []artifact.Relationship
env *generic.Environment
reader source.LocationReadCloser
resolver source.FileResolver
wantErr require.ErrorAssertionFunc
compareOptions []cmp.Option
locationComparer locationComparer
} }
func TestGenericParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { func NewCatalogTester() *CatalogTester {
return &CatalogTester{
wantErr: require.NoError,
locationComparer: func(x, y source.Location) bool {
return cmp.Equal(x.Coordinates, y.Coordinates) && cmp.Equal(x.VirtualPath, y.VirtualPath)
},
}
}
func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester {
t.Helper() t.Helper()
fixture, err := os.Open(fixturePath)
fixture, err := os.Open(path)
require.NoError(t, err) require.NoError(t, err)
actualPkgs, actualRelationships, err := parser(nil, env, source.LocationReadCloser{ p.reader = source.LocationReadCloser{
Location: source.NewLocation(fixture.Name()), Location: source.NewLocation(fixture.Name()),
ReadCloser: fixture, ReadCloser: fixture,
}) }
return p
}
func (p *CatalogTester) FromString(location, data string) *CatalogTester {
p.reader = source.LocationReadCloser{
Location: source.NewLocation(location),
ReadCloser: io.NopCloser(strings.NewReader(data)),
}
return p
}
func (p *CatalogTester) WithLinuxRelease(r linux.Release) *CatalogTester {
if p.env == nil {
p.env = &generic.Environment{}
}
p.env.LinuxRelease = &r
return p
}
func (p *CatalogTester) WithEnv(env *generic.Environment) *CatalogTester {
p.env = env
return p
}
func (p *CatalogTester) WithError() *CatalogTester {
p.wantErr = require.Error
return p
}
func (p *CatalogTester) WithErrorAssertion(a require.ErrorAssertionFunc) *CatalogTester {
p.wantErr = a
return p
}
func (p *CatalogTester) WithResolver(r source.FileResolver) *CatalogTester {
p.resolver = r
return p
}
func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester {
t.Helper()
img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName)
s, err := source.NewFromImage(img, fixtureName)
require.NoError(t, err) require.NoError(t, err)
AssertPackagesEqual(t, expectedPkgs, actualPkgs) r, err := s.FileResolver(source.SquashedScope)
require.NoError(t, err)
p.resolver = r
return p
}
if diff := cmp.Diff(expectedRelationships, actualRelationships); diff != "" { func (p *CatalogTester) IgnoreLocationLayer() *CatalogTester {
p.locationComparer = func(x, y source.Location) bool {
return cmp.Equal(x.Coordinates.RealPath, y.Coordinates.RealPath) && cmp.Equal(x.VirtualPath, y.VirtualPath)
}
return p
}
func (p *CatalogTester) Expects(pkgs []pkg.Package, relationships []artifact.Relationship) *CatalogTester {
p.expectedPkgs = pkgs
p.expectedRelationships = relationships
return p
}
func (p *CatalogTester) TestParser(t *testing.T, parser generic.Parser) {
t.Helper()
pkgs, relationships, err := parser(p.resolver, p.env, p.reader)
p.wantErr(t, err)
p.assertPkgs(t, pkgs, relationships)
}
func (p *CatalogTester) TestCataloger(t *testing.T, cataloger pkg.Cataloger) {
t.Helper()
pkgs, relationships, err := cataloger.Catalog(p.resolver)
p.wantErr(t, err)
p.assertPkgs(t, pkgs, relationships)
}
func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) {
t.Helper()
p.compareOptions = append(p.compareOptions,
cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes
cmp.Comparer(
func(x, y source.LocationSet) bool {
xs := x.ToSlice()
ys := y.ToSlice()
if len(xs) != len(ys) {
return false
}
for i, xe := range xs {
ye := ys[i]
if !p.locationComparer(xe, ye) {
return false
}
}
return true
},
),
)
if diff := cmp.Diff(p.expectedPkgs, pkgs, p.compareOptions...); diff != "" {
t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
}
if diff := cmp.Diff(p.expectedRelationships, relationships, p.compareOptions...); diff != "" {
t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff) t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff)
} }
} }
func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
t.Helper()
NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
}
func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
t.Helper()
NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
}

View File

@ -1,22 +1,16 @@
package pkg package pkg
import ( import (
"fmt"
"sort" "sort"
"github.com/scylladb/go-set/strset" "github.com/scylladb/go-set/strset"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/linux"
) )
const DpkgDBGlob = "**/var/lib/dpkg/{status,status.d/**}" const DpkgDBGlob = "**/var/lib/dpkg/{status,status.d/**}"
var ( var _ FileOwner = (*DpkgMetadata)(nil)
_ FileOwner = (*DpkgMetadata)(nil)
_ urlIdentifier = (*DpkgMetadata)(nil)
)
// DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described // DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described
// at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section. // at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section.
@ -39,40 +33,6 @@ type DpkgFileRecord struct {
IsConfigFile bool `json:"isConfigFile"` IsConfigFile bool `json:"isConfigFile"`
} }
// PackageURL returns the PURL for the specific Debian package (see https://github.com/package-url/purl-spec)
func (m DpkgMetadata) PackageURL(distro *linux.Release) string {
var namespace string
if distro != nil {
namespace = distro.ID
}
qualifiers := map[string]string{
PURLQualifierArch: m.Architecture,
}
if m.Source != "" {
if m.SourceVersion != "" {
qualifiers[PURLQualifierUpstream] = fmt.Sprintf("%s@%s", m.Source, m.SourceVersion)
} else {
qualifiers[PURLQualifierUpstream] = m.Source
}
}
return packageurl.NewPackageURL(
// TODO: replace with `packageurl.TypeDebian` upon merge of https://github.com/package-url/packageurl-go/pull/21
// TODO: or, since we're now using an Anchore fork of this module, we could do this sooner.
"deb",
namespace,
m.Package,
m.Version,
PURLQualifiers(
qualifiers,
distro,
),
"",
).ToString()
}
func (m DpkgMetadata) OwnedFiles() (result []string) { func (m DpkgMetadata) OwnedFiles() (result []string) {
s := strset.New() s := strset.New()
for _, f := range m.Files { for _, f := range m.Files {

View File

@ -66,24 +66,6 @@ func TestPackageURL(t *testing.T) {
}, },
expected: "pkg:npm/name@v0.1.0", expected: "pkg:npm/name@v0.1.0",
}, },
{
name: "deb",
distro: &linux.Release{
ID: "ubuntu",
VersionID: "20.04",
},
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: DebPkg,
Metadata: DpkgMetadata{
Package: "name",
Version: "v0.1.0",
Architecture: "amd64",
},
},
expected: "pkg:deb/ubuntu/name@v0.1.0?arch=amd64&distro=ubuntu-20.04",
},
{ {
name: "rpm", name: "rpm",
distro: &linux.Release{ distro: &linux.Release{
@ -199,6 +181,7 @@ func TestPackageURL(t *testing.T) {
expectedTypes.Remove(string(ConanPkg)) expectedTypes.Remove(string(ConanPkg))
expectedTypes.Remove(string(DartPubPkg)) expectedTypes.Remove(string(DartPubPkg))
expectedTypes.Remove(string(DotnetPkg)) expectedTypes.Remove(string(DotnetPkg))
expectedTypes.Remove(string(DebPkg))
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {