diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index e70a8c90c62..3548eba8815 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -65,7 +65,8 @@ You can also explicitly specify the scheme to use: {{.appName}} file:path/to/yourfile read directly from a file on disk {{.appName}} sbom:path/to/syft.json read Syft JSON from path on disk {{.appName}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required) - {{.appName}} purl:path/to/purl/file read a newline separated file of purls from a path on disk + {{.appName}} purl:path/to/purl/file read a newline separated file of package URLs from a path on disk + {{.appName}} pkg:PURL read a single package PURL directly (e.g. pkg:apk/openssl@3.2.1?distro=alpine-3.20.3) You can also pipe in Syft JSON directly: syft yourimage:tag -o json | {{.appName}} diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go index 41f4cf6dee7..0d44d9eb887 100644 --- a/grype/pkg/provider.go +++ b/grype/pkg/provider.go @@ -26,9 +26,9 @@ func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom return packages, ctx, s, err } - packages, err = purlProvider(userInput) + packages, ctx, err = purlProvider(userInput) if !errors.Is(err, errDoesNotProvide) { - return packages, Context{}, s, err + return packages, ctx, s, err } return syftProvider(userInput, config) diff --git a/grype/pkg/purl_provider.go b/grype/pkg/purl_provider.go index 28d48fa2192..a64a05d73ed 100644 --- a/grype/pkg/purl_provider.go +++ b/grype/pkg/purl_provider.go @@ -8,98 +8,185 @@ import ( "strings" "github.com/mitchellh/go-homedir" + "github.com/scylladb/go-set/strset" "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/pkg" ) const ( - purlInputPrefix = "purl:" - cpesQualifierKey = "cpes" + purlInputPrefix = "purl:" + singlePurlInputPrefix = "pkg:" + cpesQualifierKey = "cpes" ) -type errEmptyPurlFile struct { - purlFilepath string -} - -func (e errEmptyPurlFile) Error() string { - return fmt.Sprintf("purl file is empty: %s", e.purlFilepath) -} - -func purlProvider(userInput string) ([]Package, error) { - p, err := getPurlPackages(userInput) - return p, err -} - -func getPurlPackages(userInput string) ([]Package, error) { +func purlProvider(userInput string) ([]Package, Context, error) { reader, err := getPurlReader(userInput) if err != nil { - return nil, err + return nil, Context{}, err } return decodePurlFile(reader) } -func decodePurlFile(reader io.Reader) ([]Package, error) { +func decodePurlFile(reader io.Reader) ([]Package, Context, error) { scanner := bufio.NewScanner(reader) - packages := []Package{} + var packages []Package + var ctx Context + distros := make(map[string]*strset.Set) for scanner.Scan() { rawLine := scanner.Text() - purl, err := packageurl.FromString(rawLine) + p, err := purlToPackage(rawLine, distros) if err != nil { - return nil, fmt.Errorf("unable to decode purl %s: %w", rawLine, err) + return nil, Context{}, err } + if p != nil { + packages = append(packages, *p) + } + } - cpes := []cpe.CPE{} - epoch := "0" - for _, qualifier := range purl.Qualifiers { - if qualifier.Key == cpesQualifierKey { - rawCpes := strings.Split(qualifier.Value, ",") - for _, rawCpe := range rawCpes { - c, err := cpe.New(rawCpe, "") - if err != nil { - return nil, fmt.Errorf("unable to decode cpe %s in purl %s: %w", rawCpe, rawLine, err) - } - cpes = append(cpes, c) - } - } + if err := scanner.Err(); err != nil { + return nil, ctx, err + } - if qualifier.Key == "epoch" { - epoch = qualifier.Value + // if there is one distro (with one version) represented, use that + if len(distros) == 1 { + for name, versions := range distros { + if versions.Size() == 1 { + version := versions.List()[0] + var codename string + // if there are no digits in the version, it is likely a codename + if !strings.ContainsAny(version, "0123456789") { + codename = version + version = "" + } + ctx.Distro = &linux.Release{ + Name: name, + ID: name, + IDLike: []string{name}, + Version: version, + VersionCodename: codename, + } } } + } + + return packages, ctx, nil +} + +func purlToPackage(rawLine string, distros map[string]*strset.Set) (*Package, error) { + purl, err := packageurl.FromString(rawLine) + if err != nil { + return nil, fmt.Errorf("unable to decode purl %s: %w", rawLine, err) + } + + cpes := []cpe.CPE{} + epoch := "0" + var upstreams []UpstreamPackage + + pkgType := pkg.TypeByName(purl.Type) - if purl.Type == packageurl.TypeRPM && !strings.HasPrefix(purl.Version, fmt.Sprintf("%s:", epoch)) { - purl.Version = fmt.Sprintf("%s:%s", epoch, purl.Version) + for _, qualifier := range purl.Qualifiers { + switch qualifier.Key { + case cpesQualifierKey: + rawCpes := strings.Split(qualifier.Value, ",") + for _, rawCpe := range rawCpes { + c, err := cpe.New(rawCpe, "") + if err != nil { + return nil, fmt.Errorf("unable to decode cpe %s in purl %s: %w", rawCpe, rawLine, err) + } + cpes = append(cpes, c) + } + case pkg.PURLQualifierEpoch: + epoch = qualifier.Value + case pkg.PURLQualifierUpstream: + upstreams = append(upstreams, parseUpstream(purl.Name, qualifier.Value, pkgType)...) + case pkg.PURLQualifierDistro: + name, version := parseDistroQualifier(qualifier.Value) + if name != "" { + if _, ok := distros[name]; !ok { + distros[name] = strset.New() + } + distros[name].Add(version) + } } + } - packages = append(packages, Package{ - ID: ID(purl.String()), - CPEs: cpes, - Name: purl.Name, - Version: purl.Version, - Type: pkg.TypeByName(purl.Type), - Language: pkg.LanguageByName(purl.Type), - PURL: purl.String(), - }) + if purl.Type == packageurl.TypeRPM && !strings.HasPrefix(purl.Version, fmt.Sprintf("%s:", epoch)) { + purl.Version = fmt.Sprintf("%s:%s", epoch, purl.Version) } - if err := scanner.Err(); err != nil { - return nil, err + return &Package{ + ID: ID(purl.String()), + CPEs: cpes, + Name: purl.Name, + Version: purl.Version, + Type: pkgType, + Language: pkg.LanguageByName(purl.Type), + PURL: purl.String(), + Upstreams: upstreams, + }, nil +} + +func parseDistroQualifier(value string) (string, string) { + fields := strings.SplitN(value, "-", 2) + switch len(fields) { + case 2: + return fields[0], fields[1] + case 1: + return fields[0], "" } - return packages, nil + return "", "" } -func getPurlReader(userInput string) (r io.Reader, err error) { - if !explicitlySpecifyingPurl(userInput) { - return nil, errDoesNotProvide +func parseUpstream(pkgName string, value string, pkgType pkg.Type) []UpstreamPackage { + switch pkgType { + case pkg.RpmPkg: + return handleSourceRPM(pkgName, value) + case pkg.DebPkg: + return handleDebianSource(pkgName, value) } + return nil +} - path := strings.TrimPrefix(userInput, purlInputPrefix) +func handleDebianSource(pkgName string, value string) []UpstreamPackage { + fields := strings.Split(value, "@") + switch len(fields) { + case 2: + if fields[0] == pkgName { + return nil + } + return []UpstreamPackage{ + { + Name: fields[0], + Version: fields[1], + }, + } + case 1: + if fields[0] == pkgName { + return nil + } + return []UpstreamPackage{ + { + Name: fields[0], + }, + } + } + return nil +} - return openPurlFile(path) +func getPurlReader(userInput string) (r io.Reader, err error) { + switch { + case strings.HasPrefix(userInput, purlInputPrefix): + path := strings.TrimPrefix(userInput, purlInputPrefix) + return openPurlFile(path) + case strings.HasPrefix(userInput, singlePurlInputPrefix): + return strings.NewReader(userInput), nil + } + return nil, errDoesNotProvide } func openPurlFile(path string) (*os.File, error) { @@ -113,13 +200,5 @@ func openPurlFile(path string) (*os.File, error) { return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err) } - if !fileHasContent(f) { - return nil, errEmptyPurlFile{path} - } - return f, nil } - -func explicitlySpecifyingPurl(userInput string) bool { - return strings.HasPrefix(userInput, purlInputPrefix) -} diff --git a/grype/pkg/purl_provider_test.go b/grype/pkg/purl_provider_test.go index c01ddfca3ad..5b5bb0c8f3b 100644 --- a/grype/pkg/purl_provider_test.go +++ b/grype/pkg/purl_provider_test.go @@ -3,49 +3,222 @@ package pkg import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/linux" + "github.com/anchore/syft/syft/pkg" ) -func Test_PurlProvider_Fails(t *testing.T) { - //GIVEN +func Test_PurlProvider(t *testing.T) { tests := []struct { name string userInput string + context Context + pkgs []Package + wantErr require.ErrorAssertionFunc }{ - {"fails on path with nonexistant file", "purl:tttt/empty.txt"}, - {"fails on invalid path", "purl:~&&"}, - {"fails on empty purl file", "purl:test-fixtures/empty.json"}, - {"fails on invalid purl in file", "purl:test-fixtures/invalid-purl.txt"}, - {"fails on invalid cpe in file", "purl:test-fixtures/invalid-cpe.txt"}, - {"fails on invalid user input", "dir:test-fixtures/empty.json"}, + { + name: "takes a single purl", + userInput: "pkg:apk/curl@7.61.1", + context: Context{}, + pkgs: []Package{ + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1", + }, + }, + }, + { + name: "os with codename", + userInput: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit", + context: Context{ + Distro: &linux.Release{ + Name: "debian", + ID: "debian", + IDLike: []string{"debian"}, + VersionCodename: "jessie", // important! + }, + }, + pkgs: []Package{ + { + Name: "sysv-rc", + Version: "2.88dsf-59", + Type: pkg.DebPkg, + PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit", + Upstreams: []UpstreamPackage{ + { + Name: "sysvinit", + }, + }, + }, + }, + }, + { + name: "takes multiple purls", + userInput: "purl:test-fixtures/purl/valid-purl.txt", + context: Context{ + Distro: &linux.Release{ + Name: "debian", + ID: "debian", + IDLike: []string{"debian"}, + Version: "8", + }, + }, + pkgs: []Package{ + { + Name: "sysv-rc", + Version: "2.88dsf-59", + Type: pkg.DebPkg, + PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit", + Upstreams: []UpstreamPackage{ + { + Name: "sysvinit", + }, + }, + }, + { + Name: "ant", + Version: "1.10.8", + Type: pkg.JavaPkg, + PURL: "pkg:maven/org.apache.ant/ant@1.10.8", + }, + { + Name: "log4j-core", + Version: "2.14.1", + Type: pkg.JavaPkg, + PURL: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + }, + }, + }, + { + name: "infer context when distro is present for single purl", + userInput: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", + context: Context{ + Distro: &linux.Release{ + Name: "alpine", + ID: "alpine", + IDLike: []string{"alpine"}, + Version: "3.20.3", + }, + }, + pkgs: []Package{ + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", + }, + }, + }, + { + name: "infer context when distro is present for multiple similar purls", + userInput: "purl:test-fixtures/purl/homogeneous-os.txt", + context: Context{ + Distro: &linux.Release{ + Name: "alpine", + ID: "alpine", + IDLike: []string{"alpine"}, + Version: "3.20.3", + }, + }, + pkgs: []Package{ + { + Name: "openssl", + Version: "3.2.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", + }, + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", + }, + }, + }, + { + name: "different distro info in purls does not infer context", + userInput: "purl:test-fixtures/purl/different-os.txt", + context: Context{ + // important: no distro info inferred + }, + pkgs: []Package{ + { + Name: "openssl", + Version: "3.2.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", + }, + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2", + }, + }, + }, + { + name: "fails on path with nonexistant file", + userInput: "purl:tttt/empty.txt", + wantErr: require.Error, + }, + { + name: "fails on invalid path", + userInput: "purl:~&&", + wantErr: require.Error, + }, + { + name: "allow empty purl file", + userInput: "purl:test-fixtures/purl/empty.json", + }, + { + name: "fails on invalid purl in file", + userInput: "purl:test-fixtures/purl/invalid-purl.txt", + wantErr: require.Error, + }, + { + name: "fails on invalid cpe in file", + userInput: "purl:test-fixtures/purl/invalid-cpe.txt", + wantErr: require.Error, + }, + { + name: "invalid prefix", + userInput: "dir:test-fixtures/purl", + wantErr: require.Error, + }, + } + + opts := []cmp.Option{ + cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Metadata", "Language", "CPEs"), } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - //WHEN - packages, err := purlProvider(tc.userInput) - - //THEN - assert.Nil(t, packages) - assert.Error(t, err) - assert.NotEqual(t, "", err.Error()) - }) - } -} + if tc.wantErr == nil { + tc.wantErr = require.NoError + } -func Test_CsvProvide(t *testing.T) { - //GIVEN - expected := []string{"curl", "ant", "log4j-core"} + packages, ctx, err := purlProvider(tc.userInput) - //WHEN - packages, err := purlProvider("purl:test-fixtures/valid-purl.txt") + tc.wantErr(t, err) + if err != nil { + require.Nil(t, packages) + return + } - //THEN - packageNames := []string{} - for _, pkg := range packages { - assert.NotEmpty(t, pkg.ID) - packageNames = append(packageNames, pkg.Name) + if d := cmp.Diff(tc.context, ctx, opts...); d != "" { + t.Errorf("unexpected context (-want +got):\n%s", d) + } + require.Len(t, packages, len(tc.pkgs)) + for idx, expected := range tc.pkgs { + if d := cmp.Diff(expected, packages[idx], opts...); d != "" { + t.Errorf("unexpected context (-want +got):\n%s", d) + } + } + }) } - assert.NoError(t, err) - assert.Equal(t, expected, packageNames) } diff --git a/grype/pkg/syft_sbom_provider.go b/grype/pkg/syft_sbom_provider.go index 1f7056f9816..056742a94a9 100644 --- a/grype/pkg/syft_sbom_provider.go +++ b/grype/pkg/syft_sbom_provider.go @@ -17,14 +17,6 @@ import ( "github.com/anchore/syft/syft/sbom" ) -type errEmptySBOM struct { - sbomFilepath string -} - -func (e errEmptySBOM) Error() string { - return fmt.Sprintf("SBOM file is empty: %s", e.sbomFilepath) -} - func syftSBOMProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { s, err := getSBOM(userInput) if err != nil { @@ -126,25 +118,6 @@ func decodeStdin(r io.Reader) (io.ReadSeeker, *inputInfo, error) { return reader, newInputInfo("", "sbom"), nil } -// fileHasContent returns a bool indicating whether the given file has data that could possibly be utilized in -// downstream processing. -func fileHasContent(f *os.File) bool { - if f == nil { - return false - } - - info, err := f.Stat() - if err != nil { - return false - } - - if size := info.Size(); size > 0 { - return true - } - - return false -} - func stdinReader() (io.Reader, error) { isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect() if err != nil { @@ -180,10 +153,6 @@ func openFile(path string) (*os.File, error) { return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err) } - if !fileHasContent(f) { - return nil, errEmptySBOM{path} - } - return f, nil } diff --git a/grype/pkg/syft_sbom_provider_test.go b/grype/pkg/syft_sbom_provider_test.go index a0b0018bad3..2d2e48614d1 100644 --- a/grype/pkg/syft_sbom_provider_test.go +++ b/grype/pkg/syft_sbom_provider_test.go @@ -1,13 +1,11 @@ package pkg import ( - "os" "strings" "testing" "github.com/go-test/deep" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" @@ -345,18 +343,3 @@ var springImageTestCase = struct { }, }, } - -func TestGetSBOMReader_EmptySBOM(t *testing.T) { - sbomFile, err := os.CreateTemp("", "empty.sbom") - require.NoError(t, err) - defer func() { - err := sbomFile.Close() - assert.NoError(t, err) - }() - - filepath := sbomFile.Name() - userInput := "sbom:" + filepath - - _, err = getSBOMReader(userInput) - assert.ErrorAs(t, err, &errEmptySBOM{}) -} diff --git a/grype/pkg/test-fixtures/purl/different-os.txt b/grype/pkg/test-fixtures/purl/different-os.txt new file mode 100644 index 00000000000..43a3b446bd5 --- /dev/null +++ b/grype/pkg/test-fixtures/purl/different-os.txt @@ -0,0 +1,2 @@ +pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3 +pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2 diff --git a/grype/pkg/test-fixtures/empty.json b/grype/pkg/test-fixtures/purl/empty.json similarity index 100% rename from grype/pkg/test-fixtures/empty.json rename to grype/pkg/test-fixtures/purl/empty.json diff --git a/grype/pkg/test-fixtures/purl/homogeneous-os.txt b/grype/pkg/test-fixtures/purl/homogeneous-os.txt new file mode 100644 index 00000000000..b7383632c75 --- /dev/null +++ b/grype/pkg/test-fixtures/purl/homogeneous-os.txt @@ -0,0 +1,2 @@ +pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3 +pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3 diff --git a/grype/pkg/test-fixtures/invalid-cpe.txt b/grype/pkg/test-fixtures/purl/invalid-cpe.txt similarity index 100% rename from grype/pkg/test-fixtures/invalid-cpe.txt rename to grype/pkg/test-fixtures/purl/invalid-cpe.txt diff --git a/grype/pkg/test-fixtures/invalid-purl.txt b/grype/pkg/test-fixtures/purl/invalid-purl.txt similarity index 100% rename from grype/pkg/test-fixtures/invalid-purl.txt rename to grype/pkg/test-fixtures/purl/invalid-purl.txt diff --git a/grype/pkg/test-fixtures/purl/valid-purl.txt b/grype/pkg/test-fixtures/purl/valid-purl.txt new file mode 100644 index 00000000000..b06f91da4e8 --- /dev/null +++ b/grype/pkg/test-fixtures/purl/valid-purl.txt @@ -0,0 +1,3 @@ +pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit +pkg:maven/org.apache.ant/ant@1.10.8 +pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 diff --git a/grype/pkg/test-fixtures/valid-purl.txt b/grype/pkg/test-fixtures/valid-purl.txt deleted file mode 100644 index fe82c18ed6a..00000000000 --- a/grype/pkg/test-fixtures/valid-purl.txt +++ /dev/null @@ -1,3 +0,0 @@ -pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie&cpes=cpe%3A2.3%3Aa%3Abenjamin_curtis%3Aphpbugtracker%3A1.0.3%3A%2A%3A%2A%3A%2A%3A%2A%3A%2A%3A%2A%3A%2A -pkg:maven/org.apache.ant/ant@1.10.8 -pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index a9a1e820e22..8ad561f7eee 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit a9a1e820e22d52c94bd70dd5bfce8f29bbdb7ce4 +Subproject commit 8ad561f7eee84ebf3026812dd6f945946a1faa31