mirror of
https://github.com/anchore/syft.git
synced 2026-10-13 22:57:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247b054ab5 | ||
|
|
9e43725951 | ||
|
|
4f39287216 | ||
|
|
7a69e2129b | ||
|
|
f6996f7b9a | ||
|
|
bd523bdb5d |
@@ -13,7 +13,7 @@ GOLANGCILINT_VERSION = v1.50.1
|
||||
GOSIMPORTS_VERSION = v0.3.4
|
||||
BOUNCER_VERSION = v0.4.0
|
||||
CHRONICLE_VERSION = v0.4.2
|
||||
GORELEASER_VERSION = v1.13.0
|
||||
GORELEASER_VERSION = v1.13.1
|
||||
YAJSV_VERSION = v1.4.1
|
||||
COSIGN_VERSION = v1.13.1
|
||||
QUILL_VERSION = v0.2.0
|
||||
|
||||
@@ -22,7 +22,6 @@ func Tasks(app *config.Application) ([]Task, error) {
|
||||
generateCatalogFileMetadataTask,
|
||||
generateCatalogFileDigestsTask,
|
||||
generateCatalogSecretsTask,
|
||||
generateCatalogFileClassificationsTask,
|
||||
generateCatalogContentsTask,
|
||||
}
|
||||
|
||||
@@ -162,34 +161,6 @@ func generateCatalogSecretsTask(app *config.Application) (Task, error) {
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func generateCatalogFileClassificationsTask(app *config.Application) (Task, error) {
|
||||
if !app.FileClassification.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TODO: in the future we could expose out the classifiers via configuration
|
||||
classifierCataloger, err := file.NewClassificationCataloger(file.DefaultClassifiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(app.FileClassification.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := classifierCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.FileClassifications = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func generateCatalogContentsTask(app *config.Application) (Task, error) {
|
||||
if !app.FileContents.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.2.2
|
||||
github.com/anchore/go-logger v0.0.0-20220728155337-03b66a5207d8
|
||||
github.com/anchore/stereoscope v0.0.0-20221006201143-d24c9d626b33
|
||||
github.com/anchore/stereoscope v0.0.0-20221130153459-3b80d983223f
|
||||
github.com/docker/docker v20.10.17+incompatible
|
||||
github.com/google/go-containerregistry v0.11.0
|
||||
github.com/in-toto/in-toto-golang v0.4.1-0.20221018183522-731d0640b65f
|
||||
|
||||
@@ -280,8 +280,8 @@ github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZV
|
||||
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E=
|
||||
github.com/anchore/packageurl-go v0.1.1-0.20220428202044-a072fa3cb6d7 h1:kDrYkTSM9uIxaX/P9s0F4nKYNM+hnSgLJdLpqvsaQ/g=
|
||||
github.com/anchore/packageurl-go v0.1.1-0.20220428202044-a072fa3cb6d7/go.mod h1:Blo6OgJNiYF41ufcgHKkbCKF2MDOMlrqhXv/ij6ocR4=
|
||||
github.com/anchore/stereoscope v0.0.0-20221006201143-d24c9d626b33 h1:Y+9aMJMTEMH+kJJFEaxqrF6X1t9CkjpWjOzaCo1q5vM=
|
||||
github.com/anchore/stereoscope v0.0.0-20221006201143-d24c9d626b33/go.mod h1:WOWtswyDxLkBnizq9LihYa9uw88r1FhBvJo7g//Ljcc=
|
||||
github.com/anchore/stereoscope v0.0.0-20221130153459-3b80d983223f h1:JB4foD0R//XJ6oAtjK9h9ABjHhmzUAakjQRKMckPhnA=
|
||||
github.com/anchore/stereoscope v0.0.0-20221130153459-3b80d983223f/go.mod h1:Oa0EpewvxiynI8zxLGj2SZ6gSoGtBPQrbZNBrYNBsvE=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
@@ -1376,6 +1376,7 @@ github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc=
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
type ClassificationCataloger struct {
|
||||
classifiers []Classifier
|
||||
}
|
||||
|
||||
func NewClassificationCataloger(classifiers []Classifier) (*ClassificationCataloger, error) {
|
||||
return &ClassificationCataloger{
|
||||
classifiers: classifiers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *ClassificationCataloger) Catalog(resolver source.FileResolver) (map[source.Coordinates][]Classification, error) {
|
||||
results := make(map[source.Coordinates][]Classification)
|
||||
|
||||
numResults := 0
|
||||
for _, location := range allRegularFiles(resolver) {
|
||||
for _, classifier := range i.classifiers {
|
||||
result, err := classifier.Classify(resolver, location)
|
||||
if err != nil {
|
||||
log.Warnf("file classification cataloger failed with class=%q at location=%+v: %+v", classifier.Class, location, err)
|
||||
continue
|
||||
}
|
||||
if result != nil {
|
||||
results[location.Coordinates] = append(results[location.Coordinates], *result)
|
||||
numResults++
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Debugf("file classifier discovered %d results", numResults)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_PositiveCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixtureDir string
|
||||
location string
|
||||
expected []Classification
|
||||
expectedErr func(assert.TestingT, error, ...interface{}) bool
|
||||
}{
|
||||
{
|
||||
name: "positive-libpython3.7.so",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "libpython3.7.so",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "python-binary",
|
||||
Metadata: map[string]string{
|
||||
"version": "3.7.4a-vZ9",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "positive-python3.6",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "python3.6",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "python-binary",
|
||||
Metadata: map[string]string{
|
||||
"version": "3.6.3a-vZ9",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "positive-patchlevel.h",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "patchlevel.h",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "cpython-source",
|
||||
Metadata: map[string]string{
|
||||
"version": "3.9-aZ5",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "positive-go",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "go",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "go-binary",
|
||||
Metadata: map[string]string{
|
||||
"version": "1.14",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "positive-go-hint",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "VERSION",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "go-binary-hint",
|
||||
Metadata: map[string]string{
|
||||
"version": "1.15",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "positive-busybox",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
location: "[", // note: busybox is a link to [
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "busybox-binary",
|
||||
VirtualPath: "busybox",
|
||||
Metadata: map[string]string{
|
||||
"version": "3.33.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
c, err := NewClassificationCataloger(DefaultClassifiers)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
src, err := source.NewFromDirectory(test.fixtureDir)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
actualResults, err := c.Catalog(resolver)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
ok := false
|
||||
for actualLoc, actualClassification := range actualResults {
|
||||
if test.location == actualLoc.RealPath {
|
||||
ok = true
|
||||
assert.Equal(t, test.expected, actualClassification)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("could not find test location=%q", test.location)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixtureImage string
|
||||
location string
|
||||
expected []Classification
|
||||
expectedErr func(assert.TestingT, error, ...interface{}) bool
|
||||
}{
|
||||
{
|
||||
name: "busybox-regression",
|
||||
fixtureImage: "image-busybox",
|
||||
location: "/bin/[",
|
||||
expected: []Classification{
|
||||
{
|
||||
Class: "busybox-binary",
|
||||
VirtualPath: "/bin/busybox",
|
||||
Metadata: map[string]string{
|
||||
"version": "1.35.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
c, err := NewClassificationCataloger(DefaultClassifiers)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureImage)
|
||||
src, err := source.NewFromImage(img, "test-img")
|
||||
test.expectedErr(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
actualResults, err := c.Catalog(resolver)
|
||||
test.expectedErr(t, err)
|
||||
|
||||
ok := false
|
||||
for actuaLoc, actualClassification := range actualResults {
|
||||
if actuaLoc.RealPath == test.location {
|
||||
ok = true
|
||||
assert.Equal(t, test.expected, actualClassification)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("could not find test location=%q", test.location)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_NegativeCases(t *testing.T) {
|
||||
|
||||
c, err := NewClassificationCataloger(DefaultClassifiers)
|
||||
assert.NoError(t, err)
|
||||
|
||||
src, err := source.NewFromDirectory("test-fixtures/classifiers/negative")
|
||||
assert.NoError(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
assert.NoError(t, err)
|
||||
|
||||
actualResults, err := c.Catalog(resolver)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(actualResults))
|
||||
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"text/template"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
var DefaultClassifiers = []Classifier{
|
||||
{
|
||||
Class: "python-binary",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)python(?P<version>[0-9]+\.[0-9]+)$`),
|
||||
regexp.MustCompile(`(.*/|^)libpython(?P<version>[0-9]+\.[0-9]+).so.*$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
`(?m)(?P<version>{{ .version }}\.[0-9]+[-_a-zA-Z0-9]*)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Class: "cpython-source",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)patchlevel.h$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
`(?m)#define\s+PY_VERSION\s+"?(?P<version>[0-9\.\-_a-zA-Z]+)"?`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Class: "go-binary",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)go$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
`(?m)go(?P<version>[0-9]+\.[0-9]+(\.[0-9]+|beta[0-9]+|alpha[0-9]+|rc[0-9]+)?)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Class: "nodejs-binary",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)node$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
// regex that matches node.js/vx.y.z
|
||||
`(?m)node\.js\/v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Class: "go-binary-hint",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)VERSION$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
`(?m)go(?P<version>[0-9]+\.[0-9]+(\.[0-9]+|beta[0-9]+|alpha[0-9]+|rc[0-9]+)?)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Class: "busybox-binary",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(.*/|^)busybox$`),
|
||||
},
|
||||
EvidencePatternTemplates: []string{
|
||||
`(?m)BusyBox\s+v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type Classifier struct {
|
||||
Class string
|
||||
FilepathPatterns []*regexp.Regexp
|
||||
EvidencePatternTemplates []string
|
||||
}
|
||||
|
||||
type Classification struct {
|
||||
Class string `json:"class"`
|
||||
VirtualPath string `json:"virtual_path"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
func (c Classifier) Classify(resolver source.FileResolver, location source.Location) (*Classification, error) {
|
||||
doesFilepathMatch, filepathNamedGroupValues := FilepathMatches(c.FilepathPatterns, location)
|
||||
if !doesFilepathMatch {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
contentReader, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer internal.CloseAndLogError(contentReader, location.VirtualPath)
|
||||
|
||||
// TODO: there is room for improvement here, as this may use an excessive amount of memory. Alternate approach is to leverage a RuneReader.
|
||||
contents, err := io.ReadAll(contentReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *Classification
|
||||
for _, patternTemplate := range c.EvidencePatternTemplates {
|
||||
tmpl, err := template.New("").Parse(patternTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse classifier template=%q : %w", patternTemplate, err)
|
||||
}
|
||||
|
||||
patternBuf := &bytes.Buffer{}
|
||||
err = tmpl.Execute(patternBuf, filepathNamedGroupValues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to render template: %w", err)
|
||||
}
|
||||
|
||||
pattern, err := regexp.Compile(patternBuf.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to compile rendered regex=%q: %w", patternBuf.String(), err)
|
||||
}
|
||||
|
||||
if !pattern.Match(contents) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchMetadata := internal.MatchNamedCaptureGroups(pattern, string(contents))
|
||||
if result == nil {
|
||||
result = &Classification{
|
||||
Class: c.Class,
|
||||
VirtualPath: location.VirtualPath,
|
||||
Metadata: matchMetadata,
|
||||
}
|
||||
} else {
|
||||
for key, value := range matchMetadata {
|
||||
result.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FilepathMatches(patterns []*regexp.Regexp, location source.Location) (bool, map[string]string) {
|
||||
for _, path := range []string{location.RealPath, location.VirtualPath} {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern.MatchString(path) {
|
||||
return true, internal.MatchNamedCaptureGroups(pattern, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestFilepathMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
location source.Location
|
||||
patterns []string
|
||||
expectedMatches bool
|
||||
expectedNamedGroups map[string]string
|
||||
}{
|
||||
{
|
||||
name: "simple-filename-match",
|
||||
location: source.Location{
|
||||
Coordinates: source.Coordinates{
|
||||
RealPath: "python2.7",
|
||||
},
|
||||
},
|
||||
patterns: []string{
|
||||
`python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: true,
|
||||
},
|
||||
{
|
||||
name: "filepath-match",
|
||||
location: source.Location{
|
||||
Coordinates: source.Coordinates{
|
||||
RealPath: "/usr/bin/python2.7",
|
||||
},
|
||||
},
|
||||
patterns: []string{
|
||||
`python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: true,
|
||||
},
|
||||
{
|
||||
name: "virtual-filepath-match",
|
||||
location: source.Location{
|
||||
VirtualPath: "/usr/bin/python2.7",
|
||||
},
|
||||
patterns: []string{
|
||||
`python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: true,
|
||||
},
|
||||
{
|
||||
name: "full-filepath-match",
|
||||
location: source.Location{
|
||||
VirtualPath: "/usr/bin/python2.7",
|
||||
},
|
||||
patterns: []string{
|
||||
`.*/bin/python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: true,
|
||||
},
|
||||
{
|
||||
name: "anchored-filename-match-FAILS",
|
||||
location: source.Location{
|
||||
Coordinates: source.Coordinates{
|
||||
RealPath: "/usr/bin/python2.7",
|
||||
},
|
||||
},
|
||||
patterns: []string{
|
||||
`^python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: false,
|
||||
},
|
||||
{
|
||||
name: "empty-filename-match-FAILS",
|
||||
location: source.Location{},
|
||||
patterns: []string{
|
||||
`^python([0-9]+\.[0-9]+)$`,
|
||||
},
|
||||
expectedMatches: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var patterns []*regexp.Regexp
|
||||
for _, p := range test.patterns {
|
||||
patterns = append(patterns, regexp.MustCompile(p))
|
||||
}
|
||||
actualMatches, actualNamedGroups := FilepathMatches(patterns, test.location)
|
||||
assert.Equal(t, test.expectedMatches, actualMatches)
|
||||
assert.Equal(t, test.expectedNamedGroups, actualNamedGroups)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
Location source.Coordinates `json:"location"`
|
||||
Metadata *FileMetadataEntry `json:"metadata,omitempty"`
|
||||
Contents string `json:"contents,omitempty"`
|
||||
Digests []file.Digest `json:"digests,omitempty"`
|
||||
Classifications []file.Classification `json:"classifications,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Location source.Coordinates `json:"location"`
|
||||
Metadata *FileMetadataEntry `json:"metadata,omitempty"`
|
||||
Contents string `json:"contents,omitempty"`
|
||||
Digests []file.Digest `json:"digests,omitempty"`
|
||||
}
|
||||
|
||||
type FileMetadataEntry struct {
|
||||
|
||||
@@ -102,23 +102,17 @@ func toFile(s sbom.SBOM) []model.File {
|
||||
digests = digestsForLocation
|
||||
}
|
||||
|
||||
var classifications []file.Classification
|
||||
if classificationsForLocation, exists := artifacts.FileClassifications[coordinates]; exists {
|
||||
classifications = classificationsForLocation
|
||||
}
|
||||
|
||||
var contents string
|
||||
if contentsForLocation, exists := artifacts.FileContents[coordinates]; exists {
|
||||
contents = contentsForLocation
|
||||
}
|
||||
|
||||
results = append(results, model.File{
|
||||
ID: string(coordinates.ID()),
|
||||
Location: coordinates,
|
||||
Metadata: toFileMetadataEntry(coordinates, metadata),
|
||||
Digests: digests,
|
||||
Classifications: classifications,
|
||||
Contents: contents,
|
||||
ID: string(coordinates.ID()),
|
||||
Location: coordinates,
|
||||
Metadata: toFileMetadataEntry(coordinates, metadata),
|
||||
Digests: digests,
|
||||
Contents: contents,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ package apkdb
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
@@ -22,28 +19,73 @@ import (
|
||||
// integrity check
|
||||
var _ generic.Parser = parseApkDB
|
||||
|
||||
// parseApkDb parses individual packages from a given Alpine DB file. For more information on specific fields
|
||||
// see https://wiki.alpinelinux.org/wiki/Apk_spec .
|
||||
// parseApkDB parses packages from a given APK installed DB file. For more
|
||||
// information on specific fields, see https://wiki.alpinelinux.org/wiki/Apk_spec.
|
||||
//
|
||||
//nolint:funlen
|
||||
func parseApkDB(_ source.FileResolver, env *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
// larger capacity for the scanner.
|
||||
const maxScannerCapacity = 1024 * 1024
|
||||
// a new larger buffer for the scanner
|
||||
bufScan := make([]byte, maxScannerCapacity)
|
||||
pkgs := make([]pkg.Package, 0)
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(bufScan, maxScannerCapacity)
|
||||
onDoubleLF := func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for i := 0; i < len(data); i++ {
|
||||
if i > 0 && data[i-1] == '\n' && data[i] == '\n' {
|
||||
return i + 1, data[:i-1], nil
|
||||
|
||||
var apks []pkg.ApkMetadata
|
||||
var currentEntry pkg.ApkMetadata
|
||||
entryParsingInProgress := false
|
||||
fileParsingCtx := newApkFileParsingContext()
|
||||
|
||||
// creating a dedicated append-like function here instead of using `append(...)`
|
||||
// below since there is nontrivial logic to be performed for each finalized apk
|
||||
// entry.
|
||||
appendApk := func(p pkg.ApkMetadata) {
|
||||
if files := fileParsingCtx.files; len(files) >= 1 {
|
||||
// attached accumulated files to current package
|
||||
p.Files = files
|
||||
|
||||
// reset file parsing for next use
|
||||
fileParsingCtx = newApkFileParsingContext()
|
||||
}
|
||||
|
||||
nilFieldsToEmptySlice(&p)
|
||||
apks = append(apks, p)
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if line == "" {
|
||||
// i.e. apk entry separator
|
||||
|
||||
if entryParsingInProgress {
|
||||
// current entry is complete
|
||||
appendApk(currentEntry)
|
||||
}
|
||||
|
||||
entryParsingInProgress = false
|
||||
|
||||
// zero-out currentEntry for use by any future entry
|
||||
currentEntry = pkg.ApkMetadata{}
|
||||
|
||||
continue
|
||||
}
|
||||
if !atEOF {
|
||||
return 0, nil, nil
|
||||
|
||||
field := parseApkField(line)
|
||||
if field == nil {
|
||||
log.Warnf("unable to parse field data from line %q", line)
|
||||
continue
|
||||
}
|
||||
// deliver the last token (which could be an empty string)
|
||||
return 0, data, bufio.ErrFinalToken
|
||||
|
||||
entryParsingInProgress = true
|
||||
|
||||
field.apply(¤tEntry, fileParsingCtx)
|
||||
}
|
||||
|
||||
if entryParsingInProgress {
|
||||
// There was no final empty line, so currentEntry hasn't been added to the
|
||||
// collection yet; but we've now reached the end of scanning, so let's be sure to
|
||||
// add currentEntry to the collection.
|
||||
appendApk(currentEntry)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse APK installed DB file: %w", err)
|
||||
}
|
||||
|
||||
var r *linux.Release
|
||||
@@ -51,128 +93,192 @@ func parseApkDB(_ source.FileResolver, env *generic.Environment, reader source.L
|
||||
r = env.LinuxRelease
|
||||
}
|
||||
|
||||
scanner.Split(onDoubleLF)
|
||||
for scanner.Scan() {
|
||||
metadata, err := parseApkDBEntry(strings.NewReader(scanner.Text()))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if metadata != nil {
|
||||
pkgs = append(pkgs, newPackage(*metadata, r, reader.Location))
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse APK DB file: %w", err)
|
||||
pkgs := make([]pkg.Package, 0, len(apks))
|
||||
for _, apk := range apks {
|
||||
pkgs = append(pkgs, newPackage(apk, r, reader.Location))
|
||||
}
|
||||
|
||||
return pkgs, discoverPackageDependencies(pkgs), nil
|
||||
}
|
||||
|
||||
// parseApkDBEntry reads and parses a single pkg.ApkMetadata element from the stream, returning nil if their are no more entries.
|
||||
//
|
||||
func parseApkField(line string) *apkField {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
f := apkField{
|
||||
name: parts[0],
|
||||
value: parts[1],
|
||||
}
|
||||
|
||||
return &f
|
||||
}
|
||||
|
||||
type apkField struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
//nolint:funlen
|
||||
func parseApkDBEntry(reader io.Reader) (*pkg.ApkMetadata, error) {
|
||||
var entry pkg.ApkMetadata
|
||||
pkgFields := make(map[string]interface{})
|
||||
func (f apkField) apply(p *pkg.ApkMetadata, ctx *apkFileParsingContext) {
|
||||
switch f.name {
|
||||
// APKINDEX field parsing
|
||||
|
||||
// We want sane defaults for collections, i.e. an empty array instead of null.
|
||||
pkgFields["D"] = []string{}
|
||||
pkgFields["p"] = []string{}
|
||||
files := make([]pkg.ApkFileRecord, 0)
|
||||
|
||||
var fileRecord *pkg.ApkFileRecord
|
||||
lastFile := "/"
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.SplitN(line, ":", 2)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
case "P":
|
||||
p.Package = f.value
|
||||
case "o":
|
||||
p.OriginPackage = f.value
|
||||
case "m":
|
||||
p.Maintainer = f.value
|
||||
case "V":
|
||||
p.Version = f.value
|
||||
case "L":
|
||||
p.License = f.value
|
||||
case "A":
|
||||
p.Architecture = f.value
|
||||
case "U":
|
||||
p.URL = f.value
|
||||
case "T":
|
||||
p.Description = f.value
|
||||
case "S":
|
||||
i, err := strconv.Atoi(f.value)
|
||||
if err != nil {
|
||||
log.Warnf("unable to parse value %q for field %q: %w", f.value, f.name, err)
|
||||
return
|
||||
}
|
||||
|
||||
key := fields[0]
|
||||
value := strings.TrimSpace(fields[1])
|
||||
|
||||
switch key {
|
||||
case "D", "p":
|
||||
entries := strings.Split(value, " ")
|
||||
pkgFields[key] = entries
|
||||
case "F":
|
||||
currentFile := "/" + value
|
||||
|
||||
newFileRecord := pkg.ApkFileRecord{
|
||||
Path: currentFile,
|
||||
}
|
||||
files = append(files, newFileRecord)
|
||||
fileRecord = &files[len(files)-1]
|
||||
|
||||
// future aux references are relative to previous "F" records
|
||||
lastFile = currentFile
|
||||
continue
|
||||
case "R":
|
||||
newFileRecord := pkg.ApkFileRecord{
|
||||
Path: path.Join(lastFile, value),
|
||||
}
|
||||
files = append(files, newFileRecord)
|
||||
fileRecord = &files[len(files)-1]
|
||||
case "a", "M":
|
||||
ownershipFields := strings.Split(value, ":")
|
||||
if len(ownershipFields) < 3 {
|
||||
log.Warnf("unexpected APK ownership field: %q", value)
|
||||
continue
|
||||
}
|
||||
if fileRecord == nil {
|
||||
log.Warnf("ownership field with no parent record: %q", value)
|
||||
continue
|
||||
}
|
||||
fileRecord.OwnerUID = ownershipFields[0]
|
||||
fileRecord.OwnerGID = ownershipFields[1]
|
||||
fileRecord.Permissions = ownershipFields[2]
|
||||
// note: there are more optional fields available that we are not capturing, e.g.:
|
||||
// "0:0:755:Q1JaDEHQHBbizhEzoWK1YxuraNU/4="
|
||||
case "Z":
|
||||
if fileRecord == nil {
|
||||
log.Warnf("checksum field with no parent record: %q", value)
|
||||
continue
|
||||
}
|
||||
fileRecord.Digest = processChecksum(value)
|
||||
case "I", "S":
|
||||
// coerce to integer
|
||||
iVal, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APK int: '%+v'", value)
|
||||
}
|
||||
pkgFields[key] = iVal
|
||||
default:
|
||||
pkgFields[key] = value
|
||||
p.Size = i
|
||||
case "I":
|
||||
i, err := strconv.Atoi(f.value)
|
||||
if err != nil {
|
||||
log.Warnf("unable to parse value %q for field %q: %w", f.value, f.name, err)
|
||||
return
|
||||
}
|
||||
|
||||
p.InstalledSize = i
|
||||
case "D":
|
||||
deps := parseListValue(f.value)
|
||||
p.Dependencies = deps
|
||||
case "p":
|
||||
provides := parseListValue(f.value)
|
||||
p.Provides = provides
|
||||
case "C":
|
||||
p.Checksum = f.value
|
||||
case "c":
|
||||
p.GitCommit = f.value
|
||||
|
||||
// File/directory field parsing:
|
||||
|
||||
case "F":
|
||||
directory := path.Join("/", f.value)
|
||||
|
||||
ctx.files = append(ctx.files, pkg.ApkFileRecord{Path: directory})
|
||||
ctx.indexOfLatestDirectory = len(ctx.files) - 1
|
||||
case "M":
|
||||
i := ctx.indexOfLatestDirectory
|
||||
latest := ctx.files[i]
|
||||
|
||||
var ok bool
|
||||
latest.OwnerUID, latest.OwnerGID, latest.Permissions, ok = processFileInfo(f.value)
|
||||
if !ok {
|
||||
log.Warnf("unexpected value for APK ACL field %q: %q", f.name, f.value)
|
||||
return
|
||||
}
|
||||
|
||||
// save updated directory
|
||||
ctx.files[i] = latest
|
||||
case "R":
|
||||
var regularFile string
|
||||
|
||||
dirIndex := ctx.indexOfLatestDirectory
|
||||
if dirIndex < 0 {
|
||||
regularFile = path.Join("/", f.value)
|
||||
} else {
|
||||
latestDirPath := ctx.files[dirIndex].Path
|
||||
regularFile = path.Join(latestDirPath, f.value)
|
||||
}
|
||||
|
||||
ctx.files = append(ctx.files, pkg.ApkFileRecord{Path: regularFile})
|
||||
ctx.indexOfLatestRegularFile = len(ctx.files) - 1
|
||||
case "a":
|
||||
i := ctx.indexOfLatestRegularFile
|
||||
latest := ctx.files[i]
|
||||
|
||||
var ok bool
|
||||
latest.OwnerUID, latest.OwnerGID, latest.Permissions, ok = processFileInfo(f.value)
|
||||
if !ok {
|
||||
log.Warnf("unexpected value for APK ACL field %q: %q", f.name, f.value)
|
||||
return
|
||||
}
|
||||
|
||||
// save updated file
|
||||
ctx.files[i] = latest
|
||||
case "Z":
|
||||
i := ctx.indexOfLatestRegularFile
|
||||
latest := ctx.files[i]
|
||||
latest.Digest = processChecksum(f.value)
|
||||
|
||||
// save updated file
|
||||
ctx.files[i] = latest
|
||||
}
|
||||
}
|
||||
|
||||
func processFileInfo(v string) (uid, gid, perms string, ok bool) {
|
||||
ok = false
|
||||
|
||||
fileInfo := strings.Split(v, ":")
|
||||
if len(fileInfo) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
// By default, mapstructure compares field names in a *case-insensitive* manner.
|
||||
// That would be the wrong approach here, since these apk files use case
|
||||
// *sensitive* field names (e.g. 'P' vs. 'p').
|
||||
MatchName: func(mapKey, fieldName string) bool {
|
||||
return mapKey == fieldName
|
||||
},
|
||||
Result: &entry,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
uid = fileInfo[0]
|
||||
gid = fileInfo[1]
|
||||
perms = fileInfo[2]
|
||||
|
||||
// note: there are more optional fields available that we are not capturing,
|
||||
// e.g.: "0:0:755:Q1JaDEHQHBbizhEzoWK1YxuraNU/4="
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// apkFileParsingContext helps keep track of what file data has been captured so far for the APK currently being parsed.
|
||||
type apkFileParsingContext struct {
|
||||
files []pkg.ApkFileRecord
|
||||
indexOfLatestDirectory int
|
||||
indexOfLatestRegularFile int
|
||||
}
|
||||
|
||||
func newApkFileParsingContext() *apkFileParsingContext {
|
||||
return &apkFileParsingContext{
|
||||
indexOfLatestDirectory: -1, // no directories yet
|
||||
indexOfLatestRegularFile: -1, // no regular files yet
|
||||
}
|
||||
}
|
||||
|
||||
// parseListValue parses a space-separated list from an apk entry field value.
|
||||
func parseListValue(value string) []string {
|
||||
items := strings.Split(value, " ")
|
||||
if len(items) >= 1 {
|
||||
return items
|
||||
}
|
||||
|
||||
if err := decoder.Decode(pkgFields); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse APK metadata: %w", err)
|
||||
}
|
||||
if entry.Package == "" {
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func nilFieldsToEmptySlice(p *pkg.ApkMetadata) {
|
||||
if p.Dependencies == nil {
|
||||
p.Dependencies = []string{}
|
||||
}
|
||||
|
||||
entry.Files = files
|
||||
if p.Provides == nil {
|
||||
p.Provides = []string{}
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
if p.Files == nil {
|
||||
p.Files = []pkg.ApkFileRecord{}
|
||||
}
|
||||
}
|
||||
|
||||
func processChecksum(value string) *file.Digest {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package apkdb
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -60,16 +60,15 @@ func TestExtraFileAttributes(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, err := os.Open("test-fixtures/extra-file-attributes")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, f.Close()) })
|
||||
fixturePath := "test-fixtures/extra-file-attributes"
|
||||
lrc := newLocationReadCloser(t, fixturePath)
|
||||
|
||||
reader := bufio.NewReader(f)
|
||||
pkgs, _, err := parseApkDB(nil, new(generic.Environment), lrc)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, pkgs, 1)
|
||||
metadata := pkgs[0].Metadata.(pkg.ApkMetadata)
|
||||
|
||||
entry, err := parseApkDBEntry(reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(entry.Files, test.expected.Files); diff != "" {
|
||||
if diff := cmp.Diff(test.expected.Files, metadata.Files); diff != "" {
|
||||
t.Errorf("Files mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
@@ -615,17 +614,14 @@ func TestSinglePackageDetails(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.fixture, func(t *testing.T) {
|
||||
f, err := os.Open(test.fixture)
|
||||
lrc := newLocationReadCloser(t, test.fixture)
|
||||
|
||||
pkgs, _, err := parseApkDB(nil, new(generic.Environment), lrc)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, f.Close()) })
|
||||
require.Len(t, pkgs, 1)
|
||||
metadata := pkgs[0].Metadata.(pkg.ApkMetadata)
|
||||
|
||||
reader := bufio.NewReader(f)
|
||||
|
||||
entry, err := parseApkDBEntry(reader)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, entry)
|
||||
|
||||
if diff := cmp.Diff(*entry, test.expected); diff != "" {
|
||||
if diff := cmp.Diff(test.expected, metadata); diff != "" {
|
||||
t.Errorf("Entry mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
@@ -765,7 +761,6 @@ func TestMultiplePackages(t *testing.T) {
|
||||
}}
|
||||
|
||||
pkgtest.TestFileParserWithEnv(t, fixture, parseApkDB, &env, expectedPkgs, expectedRelationships)
|
||||
|
||||
}
|
||||
|
||||
func Test_processChecksum(t *testing.T) {
|
||||
@@ -791,15 +786,15 @@ func Test_processChecksum(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, &tt.want, processChecksum(tt.value))
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, &test.want, processChecksum(test.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_discoverPackageDependencies(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
genFn func() ([]pkg.Package, []artifact.Relationship)
|
||||
@@ -934,9 +929,10 @@ func Test_discoverPackageDependencies(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs, wantRelationships := tt.genFn()
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
pkgs, wantRelationships := test.genFn()
|
||||
gotRelationships := discoverPackageDependencies(pkgs)
|
||||
d := cmp.Diff(wantRelationships, gotRelationships, cmpopts.IgnoreUnexported(pkg.Package{}, source.LocationSet{}))
|
||||
if d != "" {
|
||||
@@ -1007,3 +1003,89 @@ func TestPackageDbDependenciesByParse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseApkDB_expectedPkgNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
fixture string
|
||||
wantPkgNames []string
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
fixture: "very-large-entries",
|
||||
wantPkgNames: []string{
|
||||
"ca-certificates-bundle",
|
||||
"glibc-locale-posix",
|
||||
"wolfi-baselayout",
|
||||
"glibc",
|
||||
"libcrypto3",
|
||||
"libssl3",
|
||||
"zlib",
|
||||
"apk-tools",
|
||||
"ncurses-terminfo-base",
|
||||
"ncurses",
|
||||
"bash",
|
||||
"libcap",
|
||||
"bubblewrap",
|
||||
"busybox",
|
||||
"libbrotlicommon1",
|
||||
"libbrotlidec1",
|
||||
"libnghttp2-14",
|
||||
"libcurl4",
|
||||
"curl",
|
||||
"expat",
|
||||
"libpcre2-8-0",
|
||||
"git",
|
||||
"binutils",
|
||||
"libstdc++-dev",
|
||||
"libgcc",
|
||||
"libstdc++",
|
||||
"gmp",
|
||||
"isl",
|
||||
"mpfr",
|
||||
"mpc",
|
||||
"gcc",
|
||||
"linux-headers",
|
||||
"glibc-dev",
|
||||
"make",
|
||||
"pkgconf",
|
||||
"build-base",
|
||||
"go",
|
||||
"tree",
|
||||
"sdk",
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.fixture, func(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-fixtures", test.fixture)
|
||||
lrc := newLocationReadCloser(t, fixturePath)
|
||||
|
||||
pkgs, _, err := parseApkDB(nil, new(generic.Environment), lrc)
|
||||
test.wantErr(t, err)
|
||||
|
||||
names := toPackageNames(pkgs)
|
||||
if diff := cmp.Diff(test.wantPkgNames, names); diff != "" {
|
||||
t.Errorf("Packages mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func toPackageNames(pkgs []pkg.Package) []string {
|
||||
names := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func newLocationReadCloser(t *testing.T, path string) source.LocationReadCloser {
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { f.Close() })
|
||||
|
||||
return source.NewLocationReadCloser(source.NewLocation(path), f)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
package binary
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
const catalogerName = "binary-cataloger"
|
||||
|
||||
func NewCataloger() *Cataloger {
|
||||
return &Cataloger{}
|
||||
}
|
||||
|
||||
// Cataloger is the cataloger responsible for surfacing evidence of a very limited set of binary files,
|
||||
// which have been identified by the classifiers. The Cataloger is _NOT_ a place to catalog any and every
|
||||
// binary, but rather the specific set that has been curated to be important, predominantly related to toolchain-
|
||||
// related runtimes like Python, Go, Java, or Node. Some exceptions can be made for widely-used binaries such
|
||||
// as busybox.
|
||||
type Cataloger struct{}
|
||||
|
||||
// Name returns a string that uniquely describes the Cataloger
|
||||
func (c Cataloger) Name() string {
|
||||
return catalogerName
|
||||
}
|
||||
|
||||
// Catalog is given an object to resolve file references and content, this function returns any discovered Packages
|
||||
// after analyzing the catalog source.
|
||||
func (c Cataloger) Catalog(resolver source.FileResolver) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
var packages []pkg.Package
|
||||
var relationships []artifact.Relationship
|
||||
|
||||
for _, classifier := range defaultClassifiers {
|
||||
locations, err := resolver.FilesByGlob(classifier.FileGlob)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, location := range locations {
|
||||
reader, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
locationReader := source.NewLocationReadCloser(location, reader)
|
||||
newPkgs, err := classifier.EvidenceMatcher(classifier, locationReader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
newPackages:
|
||||
for i := range newPkgs {
|
||||
newPkg := &newPkgs[i]
|
||||
for j := range packages {
|
||||
p := &packages[j]
|
||||
// consolidate identical packages found in different locations,
|
||||
// but continue to track each location
|
||||
if packagesMatch(p, newPkg) {
|
||||
p.Locations.Add(newPkg.Locations.ToSlice()...)
|
||||
continue newPackages
|
||||
}
|
||||
}
|
||||
packages = append(packages, *newPkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages, relationships, nil
|
||||
}
|
||||
|
||||
// packagesMatch returns true if the binary packages "match" based on basic criteria
|
||||
func packagesMatch(p1 *pkg.Package, p2 *pkg.Package) bool {
|
||||
if p1.Name != p2.Name ||
|
||||
p1.Version != p2.Version ||
|
||||
p1.Language != p2.Language ||
|
||||
p1.Type != p2.Type {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package binary
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_PositiveCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixtureDir string
|
||||
expected pkg.Package
|
||||
}{
|
||||
{
|
||||
name: "positive-libpython3.7.so",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "python",
|
||||
Version: "3.7.4a-vZ9",
|
||||
Locations: singleLocation("libpython3.7.so"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "python-binary-lib",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-python3.6",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "python",
|
||||
Version: "3.6.3a-vZ9",
|
||||
Locations: singleLocation("python3.6"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "python-binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-patchlevel.h",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "python",
|
||||
Version: "3.9-aZ5",
|
||||
Locations: singleLocation("patchlevel.h"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "cpython-source",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-go",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "go",
|
||||
Version: "1.14",
|
||||
Locations: singleLocation("go"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "go-binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-node",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "node",
|
||||
Version: "19.2.1",
|
||||
Locations: singleLocation("node"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "nodejs-binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-go-hint",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "go",
|
||||
Version: "1.15",
|
||||
Locations: singleLocation("VERSION"),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "go-binary-hint",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive-busybox",
|
||||
fixtureDir: "test-fixtures/classifiers/positive",
|
||||
expected: pkg.Package{
|
||||
Name: "busybox",
|
||||
Version: "3.33.3",
|
||||
Locations: singleLocation("["), // note: busybox is a link to [
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "busybox-binary",
|
||||
VirtualPath: "busybox",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c := NewCataloger()
|
||||
|
||||
src, err := source.NewFromDirectory(test.fixtureDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
require.NoError(t, err)
|
||||
|
||||
packages, _, err := c.Catalog(resolver)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok := false
|
||||
for _, p := range packages {
|
||||
if test.expected.Locations.ToSlice()[0].RealPath == p.Locations.ToSlice()[0].RealPath {
|
||||
ok = true
|
||||
assertPackagesAreEqual(t, test.expected, p)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("could not find test location=%q", test.expected.Locations.ToSlice()[0].RealPath)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixtureImage string
|
||||
expected pkg.Package
|
||||
}{
|
||||
{
|
||||
name: "busybox-regression",
|
||||
fixtureImage: "image-busybox",
|
||||
expected: pkg.Package{
|
||||
Name: "busybox",
|
||||
Version: "1.35.0",
|
||||
Locations: singleLocation("/bin/["),
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: "busybox-binary",
|
||||
VirtualPath: "/bin/busybox",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c := NewCataloger()
|
||||
|
||||
img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureImage)
|
||||
src, err := source.NewFromImage(img, "test-img")
|
||||
require.NoError(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
require.NoError(t, err)
|
||||
|
||||
packages, _, err := c.Catalog(resolver)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok := false
|
||||
for _, p := range packages {
|
||||
if test.expected.Locations.ToSlice()[0].RealPath == p.Locations.ToSlice()[0].RealPath {
|
||||
ok = true
|
||||
assertPackagesAreEqual(t, test.expected, p)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("could not find test location=%q", test.expected.Locations.ToSlice()[0].RealPath)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierCataloger_DefaultClassifiers_NegativeCases(t *testing.T) {
|
||||
c := NewCataloger()
|
||||
|
||||
src, err := source.NewFromDirectory("test-fixtures/classifiers/negative")
|
||||
assert.NoError(t, err)
|
||||
|
||||
resolver, err := src.FileResolver(source.SquashedScope)
|
||||
assert.NoError(t, err)
|
||||
|
||||
actualResults, _, err := c.Catalog(resolver)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(actualResults))
|
||||
}
|
||||
|
||||
func singleLocation(s string) source.LocationSet {
|
||||
return source.NewLocationSet(source.NewLocation(s))
|
||||
}
|
||||
|
||||
func assertPackagesAreEqual(t *testing.T, expected pkg.Package, p pkg.Package) {
|
||||
meta1 := expected.Metadata.(pkg.BinaryMetadata)
|
||||
meta2 := p.Metadata.(pkg.BinaryMetadata)
|
||||
if expected.Name != p.Name ||
|
||||
expected.Version != p.Version ||
|
||||
meta1.Classifier != meta2.Classifier {
|
||||
assert.Failf(t, "packages not equal", "%v != %v", expected, p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package binary
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"text/template"
|
||||
|
||||
"github.com/anchore/packageurl-go"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
var emptyPURL = packageurl.PackageURL{}
|
||||
|
||||
// classifier is a generic package classifier that can be used to match a package definition
|
||||
// to a file that meets the given content criteria of the evidenceMatcher.
|
||||
type classifier struct {
|
||||
Class string
|
||||
|
||||
// FileGlob is a selector to narrow down file inspection using the **/glob* syntax
|
||||
FileGlob string
|
||||
|
||||
// EvidenceMatcher is what will be used to match against the file in the source
|
||||
// location. If the matcher returns a package, the file will be considered a candidate.
|
||||
EvidenceMatcher evidenceMatcher
|
||||
|
||||
// Information below is used to specify the Package information when returned
|
||||
|
||||
// Package is the name to use for the package
|
||||
Package string
|
||||
|
||||
// Language is the language to classify this package as
|
||||
Language pkg.Language
|
||||
|
||||
// Type is the package type to use for the package
|
||||
Type pkg.Type
|
||||
|
||||
// PURL is the Package URL to use when generating a package
|
||||
PURL packageurl.PackageURL
|
||||
|
||||
// CPEs are the specific CPEs we want to include for this binary with updated version information
|
||||
CPEs []pkg.CPE
|
||||
}
|
||||
|
||||
// evidenceMatcher is a function called to catalog Packages that match some sort of evidence
|
||||
type evidenceMatcher func(classifier classifier, reader source.LocationReadCloser) ([]pkg.Package, error)
|
||||
|
||||
func fileNameTemplateVersionMatcher(fileNamePattern string, contentTemplate string) evidenceMatcher {
|
||||
pat := regexp.MustCompile(fileNamePattern)
|
||||
return func(classifier classifier, reader source.LocationReadCloser) ([]pkg.Package, error) {
|
||||
if !pat.MatchString(reader.RealPath) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
filepathNamedGroupValues := internal.MatchNamedCaptureGroups(pat, reader.RealPath)
|
||||
|
||||
tmpl, err := template.New("").Parse(contentTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse classifier template=%q : %w", contentTemplate, err)
|
||||
}
|
||||
|
||||
patternBuf := &bytes.Buffer{}
|
||||
err = tmpl.Execute(patternBuf, filepathNamedGroupValues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to render template: %w", err)
|
||||
}
|
||||
|
||||
tmplPattern, err := regexp.Compile(patternBuf.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to compile rendered regex=%q: %w", patternBuf.String(), err)
|
||||
}
|
||||
|
||||
contents, err := getContents(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get read contents for file: %w", err)
|
||||
}
|
||||
|
||||
matchMetadata := internal.MatchNamedCaptureGroups(tmplPattern, string(contents))
|
||||
|
||||
version, ok := matchMetadata["version"]
|
||||
if ok {
|
||||
return singlePackage(classifier, reader, version), nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fileContentsVersionMatcher(pattern string) evidenceMatcher {
|
||||
pat := regexp.MustCompile(pattern)
|
||||
return func(classifier classifier, reader source.LocationReadCloser) ([]pkg.Package, error) {
|
||||
contents, err := getContents(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get read contents for file: %w", err)
|
||||
}
|
||||
|
||||
matchMetadata := internal.MatchNamedCaptureGroups(pat, string(contents))
|
||||
version, ok := matchMetadata["version"]
|
||||
if ok {
|
||||
return singlePackage(classifier, reader, version), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mustPURL(purl string) packageurl.PackageURL {
|
||||
p, err := packageurl.FromString(purl)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid PURL: %s", p))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func singlePackage(classifier classifier, reader source.LocationReadCloser, version string) []pkg.Package {
|
||||
var cpes []pkg.CPE
|
||||
for _, cpe := range classifier.CPEs {
|
||||
cpe.Version = version
|
||||
cpes = append(cpes, cpe)
|
||||
}
|
||||
|
||||
p := pkg.Package{
|
||||
Name: classifier.Package,
|
||||
Version: version,
|
||||
Language: pkg.Binary,
|
||||
Locations: source.NewLocationSet(reader.Location),
|
||||
Type: pkg.BinaryPkg,
|
||||
CPEs: cpes,
|
||||
MetadataType: pkg.BinaryMetadataType,
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: classifier.Class,
|
||||
RealPath: reader.RealPath,
|
||||
VirtualPath: reader.VirtualPath,
|
||||
},
|
||||
}
|
||||
|
||||
if classifier.Type != "" {
|
||||
p.Type = classifier.Type
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(classifier.PURL, emptyPURL) {
|
||||
purl := classifier.PURL
|
||||
purl.Version = version
|
||||
p.PURL = purl.ToString()
|
||||
}
|
||||
|
||||
if classifier.Language != "" {
|
||||
p.Language = classifier.Language
|
||||
}
|
||||
|
||||
p.SetID()
|
||||
|
||||
return []pkg.Package{p}
|
||||
}
|
||||
|
||||
func getContents(reader source.LocationReadCloser) ([]byte, error) {
|
||||
unionReader, err := unionreader.GetUnionReader(reader.ReadCloser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get union reader for file: %w", err)
|
||||
}
|
||||
|
||||
// TODO: there may be room for improvement here, as this may use an excessive amount of memory. Alternate approach is to leverage a RuneReader.
|
||||
contents, err := io.ReadAll(unionReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get contents for file: %w", err)
|
||||
}
|
||||
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// singleCPE returns a []pkg.CPE based on the cpe string or panics if the CPE is invalid
|
||||
func singleCPE(cpe string) []pkg.CPE {
|
||||
return []pkg.CPE{
|
||||
pkg.MustCPE(cpe),
|
||||
}
|
||||
}
|
||||
+20
-29
@@ -1,7 +1,6 @@
|
||||
package generic
|
||||
package binary
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -14,35 +13,27 @@ func Test_ClassifierCPEs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
classifier Classifier
|
||||
classifier classifier
|
||||
cpes []string
|
||||
}{
|
||||
{
|
||||
name: "no CPEs",
|
||||
fixture: "test-fixtures/version.txt",
|
||||
classifier: Classifier{
|
||||
Package: "some-app",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(".*/version.txt"),
|
||||
},
|
||||
EvidencePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
},
|
||||
CPEs: []pkg.CPE{},
|
||||
classifier: classifier{
|
||||
Package: "some-app",
|
||||
FileGlob: ".*/version.txt",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
CPEs: []pkg.CPE{},
|
||||
},
|
||||
cpes: nil,
|
||||
},
|
||||
{
|
||||
name: "one CPE",
|
||||
fixture: "test-fixtures/version.txt",
|
||||
classifier: Classifier{
|
||||
Package: "some-app",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(".*/version.txt"),
|
||||
},
|
||||
EvidencePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
},
|
||||
classifier: classifier{
|
||||
Package: "some-app",
|
||||
FileGlob: ".*/version.txt",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
CPEs: []pkg.CPE{
|
||||
pkg.MustCPE("cpe:2.3:a:some:app:*:*:*:*:*:*:*:*"),
|
||||
},
|
||||
@@ -54,14 +45,10 @@ func Test_ClassifierCPEs(t *testing.T) {
|
||||
{
|
||||
name: "multiple CPEs",
|
||||
fixture: "test-fixtures/version.txt",
|
||||
classifier: Classifier{
|
||||
Package: "some-app",
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(".*/version.txt"),
|
||||
},
|
||||
EvidencePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
},
|
||||
classifier: classifier{
|
||||
Package: "some-app",
|
||||
FileGlob: ".*/version.txt",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(`(?m)my-verison:(?P<version>[0-9.]+)`),
|
||||
CPEs: []pkg.CPE{
|
||||
pkg.MustCPE("cpe:2.3:a:some:app:*:*:*:*:*:*:*:*"),
|
||||
pkg.MustCPE("cpe:2.3:a:some:apps:*:*:*:*:*:*:*:*"),
|
||||
@@ -83,9 +70,13 @@ func Test_ClassifierCPEs(t *testing.T) {
|
||||
location := locations[0]
|
||||
readCloser, err := resolver.FileContentsByLocation(location)
|
||||
require.NoError(t, err)
|
||||
p, _, err := test.classifier.Examine(source.NewLocationReadCloser(location, readCloser))
|
||||
pkgs, err := test.classifier.EvidenceMatcher(test.classifier, source.NewLocationReadCloser(location, readCloser))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, pkgs, 1)
|
||||
|
||||
p := pkgs[0]
|
||||
|
||||
var cpes []string
|
||||
for _, c := range p.CPEs {
|
||||
cpes = append(cpes, pkg.CPEString(c))
|
||||
@@ -0,0 +1,60 @@
|
||||
package binary
|
||||
|
||||
import "github.com/anchore/syft/syft/pkg"
|
||||
|
||||
var defaultClassifiers = []classifier{
|
||||
{
|
||||
Class: "python-binary",
|
||||
FileGlob: "**/python*",
|
||||
EvidenceMatcher: fileNameTemplateVersionMatcher(
|
||||
`(.*/|^)python(?P<version>[0-9]+\.[0-9]+)$`,
|
||||
`(?m)(?P<version>{{ .version }}\.[0-9]+[-_a-zA-Z0-9]*)`),
|
||||
Package: "python",
|
||||
},
|
||||
{
|
||||
Class: "python-binary-lib",
|
||||
FileGlob: "**/libpython*.so*",
|
||||
EvidenceMatcher: fileNameTemplateVersionMatcher(
|
||||
`(.*/|^)libpython(?P<version>[0-9]+\.[0-9]+).so.*$`,
|
||||
`(?m)(?P<version>{{ .version }}\.[0-9]+[-_a-zA-Z0-9]*)`),
|
||||
Package: "python",
|
||||
},
|
||||
{
|
||||
Class: "cpython-source",
|
||||
FileGlob: "**/patchlevel.h",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(
|
||||
`(?m)#define\s+PY_VERSION\s+"?(?P<version>[0-9\.\-_a-zA-Z]+)"?`),
|
||||
Package: "python",
|
||||
},
|
||||
{
|
||||
Class: "go-binary",
|
||||
FileGlob: "**/go",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(
|
||||
`(?m)go(?P<version>[0-9]+\.[0-9]+(\.[0-9]+|beta[0-9]+|alpha[0-9]+|rc[0-9]+)?)`),
|
||||
Package: "go",
|
||||
},
|
||||
{
|
||||
Class: "nodejs-binary",
|
||||
FileGlob: "**/node",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(
|
||||
`(?m)node\.js\/v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`),
|
||||
Package: "node",
|
||||
Language: pkg.JavaScript,
|
||||
PURL: mustPURL("pkg:generic/node@version"),
|
||||
CPEs: singleCPE("cpe:2.3:a:nodejs:node.js:*:*:*:*:*:*:*:*"),
|
||||
},
|
||||
{
|
||||
Class: "go-binary-hint",
|
||||
FileGlob: "**/VERSION",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(
|
||||
`(?m)go(?P<version>[0-9]+\.[0-9]+(\.[0-9]+|beta[0-9]+|alpha[0-9]+|rc[0-9]+)?)`),
|
||||
Package: "go",
|
||||
},
|
||||
{
|
||||
Class: "busybox-binary",
|
||||
FileGlob: "**/busybox",
|
||||
EvidenceMatcher: fileContentsVersionMatcher(
|
||||
`(?m)BusyBox\s+v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`),
|
||||
Package: "busybox",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# this should match node 19.2.1
|
||||
node.js/v19.2.1
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/alpm"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/apkdb"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/cpp"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/dart"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/deb"
|
||||
@@ -40,7 +41,6 @@ func ImageCatalogers(cfg Config) []pkg.Cataloger {
|
||||
python.NewPythonPackageCataloger(),
|
||||
php.NewPHPComposerInstalledCataloger(),
|
||||
javascript.NewJavascriptPackageCataloger(),
|
||||
javascript.NewNodeBinaryCataloger(),
|
||||
deb.NewDpkgdbCataloger(),
|
||||
rpm.NewRpmDBCataloger(),
|
||||
java.NewJavaCataloger(cfg.Java()),
|
||||
@@ -49,6 +49,7 @@ func ImageCatalogers(cfg Config) []pkg.Cataloger {
|
||||
dotnet.NewDotnetDepsCataloger(),
|
||||
portage.NewPortageCataloger(),
|
||||
sbom.NewSBOMCataloger(),
|
||||
binary.NewCataloger(),
|
||||
}, cfg.Catalogers)
|
||||
}
|
||||
|
||||
@@ -61,7 +62,6 @@ func DirectoryCatalogers(cfg Config) []pkg.Cataloger {
|
||||
python.NewPythonPackageCataloger(),
|
||||
php.NewPHPComposerLockCataloger(),
|
||||
javascript.NewJavascriptLockCataloger(),
|
||||
javascript.NewNodeBinaryCataloger(),
|
||||
deb.NewDpkgdbCataloger(),
|
||||
rpm.NewRpmDBCataloger(),
|
||||
rpm.NewFileCataloger(),
|
||||
@@ -78,6 +78,7 @@ func DirectoryCatalogers(cfg Config) []pkg.Cataloger {
|
||||
portage.NewPortageCataloger(),
|
||||
haskell.NewHackageCataloger(),
|
||||
sbom.NewSBOMCataloger(),
|
||||
binary.NewCataloger(),
|
||||
}, cfg.Catalogers)
|
||||
}
|
||||
|
||||
@@ -91,7 +92,6 @@ func AllCatalogers(cfg Config) []pkg.Cataloger {
|
||||
python.NewPythonPackageCataloger(),
|
||||
javascript.NewJavascriptLockCataloger(),
|
||||
javascript.NewJavascriptPackageCataloger(),
|
||||
javascript.NewNodeBinaryCataloger(),
|
||||
deb.NewDpkgdbCataloger(),
|
||||
rpm.NewRpmDBCataloger(),
|
||||
rpm.NewFileCataloger(),
|
||||
@@ -111,6 +111,7 @@ func AllCatalogers(cfg Config) []pkg.Cataloger {
|
||||
portage.NewPortageCataloger(),
|
||||
haskell.NewHackageCataloger(),
|
||||
sbom.NewSBOMCataloger(),
|
||||
binary.NewCataloger(),
|
||||
}, cfg.Catalogers)
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ func (c *Cataloger) Catalog(resolver source.FileResolver) ([]pkg.Package, []arti
|
||||
for _, req := range c.selectFiles(resolver) {
|
||||
location, parser := req.Location, req.Parser
|
||||
|
||||
log.WithFields("path", location.RealPath).Trace("parsing file contents")
|
||||
|
||||
contentReader, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil {
|
||||
logger.WithFields("location", location.RealPath, "error", err).Warn("unable to fetch contents")
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/unionreader"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
// Classifier is a generic package classifier that can be used to match a package definition
|
||||
// to a file that meets the given content criteria of the EvidencePatternTemplates.
|
||||
type Classifier struct {
|
||||
Package string
|
||||
// FilepathPatterns is a list of regular expressions that will be used to match against the file path of a given
|
||||
// source location. If any of the patterns match, the file will be considered a candidate for parsing.
|
||||
// If no patterns are provided, the reader is automatically considered a candidate.
|
||||
FilepathPatterns []*regexp.Regexp
|
||||
// EvidencePatterns is a list of regular expressions that will be used to match against the file contents of a
|
||||
// given file in the source location. If any of the patterns match, the file will be considered a candidate for parsing.
|
||||
EvidencePatterns []*regexp.Regexp
|
||||
// CPEs are the specific CPEs we want to include for this binary with updated version information
|
||||
CPEs []pkg.CPE
|
||||
}
|
||||
|
||||
func (c Classifier) Examine(reader source.LocationReadCloser) (p *pkg.Package, r *artifact.Relationship, err error) {
|
||||
doesFilepathMatch := true
|
||||
if len(c.FilepathPatterns) > 0 {
|
||||
doesFilepathMatch, _ = file.FilepathMatches(c.FilepathPatterns, reader.Location)
|
||||
}
|
||||
|
||||
if !doesFilepathMatch {
|
||||
return nil, nil, fmt.Errorf("location: %s did not match any patterns for package=%q", reader.Location, c.Package)
|
||||
}
|
||||
|
||||
contents, err := getContents(reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to get read contents for file: %w", err)
|
||||
}
|
||||
|
||||
var classifiedPackage *pkg.Package
|
||||
for _, evidencePattern := range c.EvidencePatterns {
|
||||
if !evidencePattern.Match(contents) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchMetadata := internal.MatchNamedCaptureGroups(evidencePattern, string(contents))
|
||||
version, ok := matchMetadata["version"]
|
||||
if !ok {
|
||||
log.Debugf("no version found in binary from pattern %v", evidencePattern)
|
||||
continue
|
||||
}
|
||||
|
||||
var cpes []pkg.CPE
|
||||
for _, cpe := range c.CPEs {
|
||||
cpe.Version = version
|
||||
if err == nil {
|
||||
cpes = append(cpes, cpe)
|
||||
}
|
||||
}
|
||||
|
||||
classifiedPackage = &pkg.Package{
|
||||
Name: path.Base(reader.VirtualPath),
|
||||
Version: version,
|
||||
Language: pkg.Binary,
|
||||
Locations: source.NewLocationSet(reader.Location),
|
||||
Type: pkg.BinaryPkg,
|
||||
CPEs: cpes,
|
||||
MetadataType: pkg.BinaryMetadataType,
|
||||
Metadata: pkg.BinaryMetadata{
|
||||
Classifier: c.Package,
|
||||
RealPath: reader.RealPath,
|
||||
VirtualPath: reader.VirtualPath,
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
return classifiedPackage, nil, nil
|
||||
}
|
||||
|
||||
func getContents(reader source.LocationReadCloser) ([]byte, error) {
|
||||
unionReader, err := unionreader.GetUnionReader(reader.ReadCloser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get union reader for file: %w", err)
|
||||
}
|
||||
|
||||
contents, err := io.ReadAll(unionReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get contents for file: %w", err)
|
||||
}
|
||||
|
||||
return contents, nil
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package golang
|
||||
|
||||
import (
|
||||
"debug/buildinfo"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
@@ -14,31 +16,20 @@ func scanFile(reader unionreader.UnionReader, filename string) ([]*debug.BuildIn
|
||||
// with more than one binary
|
||||
readers, err := unionreader.GetReaders(reader)
|
||||
if err != nil {
|
||||
log.Warnf("golang cataloger: failed to open a binary: %v", err)
|
||||
log.WithFields("error", err).Warnf("failed to open a golang binary")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var builds []*debug.BuildInfo
|
||||
for _, r := range readers {
|
||||
bi, err := buildinfo.Read(r)
|
||||
|
||||
// note: the stdlib does not export the error we need to check for
|
||||
bi, err := getBuildInfo(r)
|
||||
if err != nil {
|
||||
if err.Error() == "not a Go executable" {
|
||||
// since the cataloger can only select executables and not distinguish if they are a go-compiled
|
||||
// binary, we should not show warnings/logs in this case.
|
||||
return nil, nil
|
||||
}
|
||||
// in this case we could not read the or parse the file, but not explicitly because it is not a
|
||||
// go-compiled binary (though it still might be).
|
||||
// TODO: We should change this back to "warn" eventually.
|
||||
// But right now it's catching too many cases where the reader IS NOT a Go binary at all.
|
||||
// It'd be great to see how we can get those cases to be detected and handled above before we get to
|
||||
// this point in execution.
|
||||
log.Infof("golang cataloger: unable to read buildinfo (file=%q): %v", filename, err)
|
||||
return nil, nil
|
||||
log.WithFields("file", filename, "error", err).Warn("unable to read golang buildinfo")
|
||||
continue
|
||||
}
|
||||
if bi == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
builds = append(builds, bi)
|
||||
}
|
||||
|
||||
@@ -46,3 +37,28 @@ func scanFile(reader unionreader.UnionReader, filename string) ([]*debug.BuildIn
|
||||
|
||||
return builds, archs
|
||||
}
|
||||
|
||||
func getBuildInfo(r io.ReaderAt) (bi *debug.BuildInfo, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// this can happen in cases where a malformed binary is passed in can be initially parsed, but not
|
||||
// used without error later down the line. This is the case with :
|
||||
// https://github.com/llvm/llvm-project/blob/llvmorg-15.0.6/llvm/test/Object/Inputs/macho-invalid-dysymtab-bad-size
|
||||
err = fmt.Errorf("recovered from panic: %v", r)
|
||||
}
|
||||
}()
|
||||
bi, err = buildinfo.Read(r)
|
||||
|
||||
// note: the stdlib does not export the error we need to check for
|
||||
if err != nil {
|
||||
if err.Error() == "not a Go executable" {
|
||||
// since the cataloger can only select executables and not distinguish if they are a go-compiled
|
||||
// binary, we should not show warnings/logs in this case.
|
||||
return
|
||||
}
|
||||
// in this case we could not read the or parse the file, but not explicitly because it is not a
|
||||
// go-compiled binary (though it still might be).
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package golang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_getBuildInfo(t *testing.T) {
|
||||
type args struct {
|
||||
r io.ReaderAt
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantBi *debug.BuildInfo
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "recover from panic",
|
||||
args: args{
|
||||
r: nil, // trying to use a nil reader will cause a panic
|
||||
},
|
||||
wantBi: nil, // we should not return anything useful
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBi, err := getBuildInfo(tt.args.r)
|
||||
if !tt.wantErr(t, err, fmt.Sprintf("getBuildInfo(%v)", tt.args.r)) {
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, tt.wantBi, gotBi, "getBuildInfo(%v)", tt.args.r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ Package javascript provides a concrete Cataloger implementation for JavaScript e
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
)
|
||||
|
||||
@@ -20,8 +19,3 @@ func NewJavascriptLockCataloger() *generic.Cataloger {
|
||||
WithParserByGlobs(parseYarnLock, "**/yarn.lock").
|
||||
WithParserByGlobs(parsePnpmLock, "**/pnpm-lock.yaml")
|
||||
}
|
||||
|
||||
func NewNodeBinaryCataloger() *generic.Cataloger {
|
||||
return generic.NewCataloger("node-binary-cataloger").
|
||||
WithParserByMimeTypes(parseNodeBinary, internal.ExecutableMIMETypeSet.List()...)
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
var nodeClassifier = generic.Classifier{
|
||||
Package: "node.js", // Note: this purposely matches the "node.js" string to aid nvd vuln matching
|
||||
FilepathPatterns: []*regexp.Regexp{
|
||||
// note: should we just parse all files resolved with executable mimetypes
|
||||
// regexp that matches node binary
|
||||
regexp.MustCompile(`(.*/|^)node$`),
|
||||
},
|
||||
EvidencePatterns: []*regexp.Regexp{
|
||||
// regex that matches node.js/vx.y.z
|
||||
regexp.MustCompile(`(?m)node\.js\/v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`),
|
||||
},
|
||||
CPEs: []pkg.CPE{
|
||||
pkg.MustCPE("cpe:2.3:a:nodejs:node.js:*:*:*:*:*:*:*:*"),
|
||||
},
|
||||
}
|
||||
|
||||
func parseNodeBinary(_ source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
p, _, err := nodeClassifier.Examine(reader)
|
||||
if err != nil {
|
||||
log.Trace("failed to find node.js package: %+v", err)
|
||||
return nil, nil, nil // we can silently fail here to reduce warning noise
|
||||
}
|
||||
|
||||
// TODO add node specific metadata to the packages to help with vulnerability matching
|
||||
if p != nil {
|
||||
p.Language = pkg.JavaScript
|
||||
p.SetID()
|
||||
return []pkg.Package{*p}, nil, nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pkg
|
||||
|
||||
type FileMetadata struct {
|
||||
Classifier string `mapstructure:"Classifier" json:"classifier"`
|
||||
RealPath string `mapstructure:"RealPath" json:"realPath"`
|
||||
VirtualPath string `mapstructure:"VirtualPath" json:"virtualPath"`
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
CPP Language = "c++"
|
||||
Haskell Language = "haskell"
|
||||
Binary Language = "binary"
|
||||
File Language = "file"
|
||||
)
|
||||
|
||||
// AllLanguages is a set of all programming languages detected by syft.
|
||||
|
||||
+6
-10
@@ -18,13 +18,12 @@ type SBOM struct {
|
||||
}
|
||||
|
||||
type Artifacts struct {
|
||||
PackageCatalog *pkg.Catalog
|
||||
FileMetadata map[source.Coordinates]source.FileMetadata
|
||||
FileDigests map[source.Coordinates][]file.Digest
|
||||
FileClassifications map[source.Coordinates][]file.Classification
|
||||
FileContents map[source.Coordinates]string
|
||||
Secrets map[source.Coordinates][]file.SearchResult
|
||||
LinuxDistribution *linux.Release
|
||||
PackageCatalog *pkg.Catalog
|
||||
FileMetadata map[source.Coordinates]source.FileMetadata
|
||||
FileDigests map[source.Coordinates][]file.Digest
|
||||
FileContents map[source.Coordinates]string
|
||||
Secrets map[source.Coordinates][]file.SearchResult
|
||||
LinuxDistribution *linux.Release
|
||||
}
|
||||
|
||||
type Descriptor struct {
|
||||
@@ -55,9 +54,6 @@ func (s SBOM) AllCoordinates() []source.Coordinates {
|
||||
for coordinates := range s.Artifacts.FileContents {
|
||||
set.Add(coordinates)
|
||||
}
|
||||
for coordinates := range s.Artifacts.FileClassifications {
|
||||
set.Add(coordinates)
|
||||
}
|
||||
for coordinates := range s.Artifacts.FileDigests {
|
||||
set.Add(coordinates)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestPackagesCmdFlags(t *testing.T) {
|
||||
name: "squashed-scope-flag-hidden-packages",
|
||||
args: []string{"packages", "-o", "json", "-s", "squashed", hiddenPackagesImage},
|
||||
assertions: []traitAssertion{
|
||||
assertPackageCount(162),
|
||||
assertPackageCount(163),
|
||||
assertNotInOutput("vsftpd"), // hidden package
|
||||
assertSuccessfulReturnCode,
|
||||
},
|
||||
@@ -114,7 +114,7 @@ func TestPackagesCmdFlags(t *testing.T) {
|
||||
name: "all-layers-scope-flag",
|
||||
args: []string{"packages", "-o", "json", "-s", "all-layers", hiddenPackagesImage},
|
||||
assertions: []traitAssertion{
|
||||
assertPackageCount(163), // packages are now deduplicated for this case
|
||||
assertPackageCount(164), // packages are now deduplicated for this case
|
||||
assertInOutput("all-layers"),
|
||||
assertInOutput("vsftpd"), // hidden package
|
||||
assertSuccessfulReturnCode,
|
||||
@@ -127,7 +127,7 @@ func TestPackagesCmdFlags(t *testing.T) {
|
||||
"SYFT_PACKAGE_CATALOGER_SCOPE": "all-layers",
|
||||
},
|
||||
assertions: []traitAssertion{
|
||||
assertPackageCount(163), // packages are now deduplicated for this case
|
||||
assertPackageCount(164), // packages are now deduplicated for this case
|
||||
assertInOutput("all-layers"),
|
||||
assertInOutput("vsftpd"), // hidden package
|
||||
assertSuccessfulReturnCode,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ func TestPackageDeduplication(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scope: source.AllLayersScope,
|
||||
packageCount: 172, // without deduplication this would be 618
|
||||
packageCount: 173, // without deduplication this would be 618
|
||||
instanceCount: map[string]int{
|
||||
"basesystem": 1,
|
||||
"wget": 1,
|
||||
@@ -40,7 +41,7 @@ func TestPackageDeduplication(t *testing.T) {
|
||||
},
|
||||
{
|
||||
scope: source.SquashedScope,
|
||||
packageCount: 170,
|
||||
packageCount: 171,
|
||||
instanceCount: map[string]int{
|
||||
"basesystem": 1,
|
||||
"wget": 1,
|
||||
@@ -62,6 +63,12 @@ func TestPackageDeduplication(t *testing.T) {
|
||||
t.Run(string(tt.scope), func(t *testing.T) {
|
||||
sbom, _ := catalogFixtureImage(t, "image-vertical-package-dups", tt.scope, nil)
|
||||
|
||||
for _, p := range sbom.Artifacts.PackageCatalog.Sorted() {
|
||||
if p.Type == pkg.BinaryPkg {
|
||||
assert.NotEmpty(t, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.packageCount, sbom.Artifacts.PackageCatalog.PackageCount())
|
||||
for name, expectedInstanceCount := range tt.instanceCount {
|
||||
pkgs := sbom.Artifacts.PackageCatalog.PackagesByName(name)
|
||||
|
||||
Reference in New Issue
Block a user