From 8e8d225b0b83f085ea513bacfdfe27c11e86938e Mon Sep 17 00:00:00 2001 From: Adam Chovanec Date: Wed, 10 Sep 2025 16:04:09 +0200 Subject: [PATCH 1/3] chore: use syft API for decoding CPEs Signed-off-by: Adam Chovanec --- .../cpe_target_software_to_pkg_type.go | 58 --------------- .../internal/only_vulnerable_targets.go | 6 +- grype/pkg/cpe_provider.go | 72 +++---------------- grype/pkg/cpe_provider_test.go | 28 ++++++-- grype/pkg/provider.go | 2 +- 5 files changed, 34 insertions(+), 132 deletions(-) delete mode 100644 grype/internal/cpe_target_software_to_pkg_type.go diff --git a/grype/internal/cpe_target_software_to_pkg_type.go b/grype/internal/cpe_target_software_to_pkg_type.go deleted file mode 100644 index 8ed402c6a91..00000000000 --- a/grype/internal/cpe_target_software_to_pkg_type.go +++ /dev/null @@ -1,58 +0,0 @@ -package internal - -import ( - "strings" - - "github.com/anchore/syft/syft/pkg" -) - -// CPETargetSoftwareToPackageType is derived from looking at target_software attributes in the NVD dataset -// TODO: ideally this would be driven from the store, where we can resolve ecosystem aliases directly -func CPETargetSoftwareToPackageType(tsw string) pkg.Type { - tsw = strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(tsw)) - switch tsw { - case "alpine", "apk": - return pkg.ApkPkg - case "debian", "dpkg": - return pkg.DebPkg - case "java", "maven", "ant", "gradle", "jenkins", "jenkins_ci", "kafka", "logstash", "mule", "nifi", "solr", "spark", "storm", "struts", "tomcat", "zookeeper", "log4j": - return pkg.JavaPkg - case "javascript", "node", "nodejs", "node.js", "npm", "yarn", "apache", "jquery", "next.js", "prismjs": - return pkg.NpmPkg - case "c", "c++", "c/c++", "conan", "gnu_c++", "qt": - return pkg.ConanPkg - case "dart": - return pkg.DartPubPkg - case "redhat", "rpm", "redhat_enterprise_linux", "rhel", "suse", "suse_linux", "opensuse", "opensuse_linux", "fedora", "centos", "oracle_linux", "ol": - return pkg.RpmPkg - case "elixir", "hex": - return pkg.HexPkg - case "erlang": - return pkg.ErlangOTPPkg - case ".net", ".net_framework", "asp", "asp.net", "dotnet", "dotnet_framework", "c#", "csharp", "nuget": - return pkg.DotnetPkg - case "ruby", "gem", "nokogiri", "ruby_on_rails": - return pkg.GemPkg - case "rust", "cargo", "crates": - return pkg.RustPkg - case "python", "pip", "pypi", "flask": - return pkg.PythonPkg - case "kb", "knowledgebase", "msrc", "mskb", "microsoft": - return pkg.KbPkg - case "portage", "gentoo": - return pkg.PortagePkg - case "go", "golang", "gomodule": - return pkg.GoModulePkg - case "linux_kernel", "linux", "z/linux": - return pkg.LinuxKernelPkg - case "php": - return pkg.PhpComposerPkg - case "swift": - return pkg.SwiftPkg - case "wordpress", "wordpress_plugin", "wordpress_": - return pkg.WordpressPluginPkg - case "lua", "luarocks": - return pkg.LuaRocksPkg - } - return "" -} diff --git a/grype/matcher/internal/only_vulnerable_targets.go b/grype/matcher/internal/only_vulnerable_targets.go index ddb153fb18e..593a1a1defd 100644 --- a/grype/matcher/internal/only_vulnerable_targets.go +++ b/grype/matcher/internal/only_vulnerable_targets.go @@ -7,12 +7,12 @@ import ( "github.com/facebookincubator/nvdtools/wfn" "github.com/scylladb/go-set/strset" - "github.com/anchore/grype/grype/internal" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/search" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/syft/syft/cpe" syftPkg "github.com/anchore/syft/syft/pkg" + syftCPE "github.com/anchore/syft/syft/pkg/cataloger/common/cpe" ) // OnlyVulnerableTargets returns a criteria object that tests vulnerability qualifiers against the package vulnerability rules. @@ -83,7 +83,7 @@ func refuteTargetSoftwareByPackageAttributes(p pkg.Package, vuln vulnerability.V mismatchWithUnknownLanguage := syftPkg.LanguageByName(targetSW) != p.Language && isUnknownTarget(targetSW) unspecifiedTargetSW := targetSW == wfn.Any || targetSW == wfn.NA matchesByLanguage := syftPkg.LanguageByName(targetSW) == p.Language - matchesByPackageType := internal.CPETargetSoftwareToPackageType(targetSW) == p.Type + matchesByPackageType := syftCPE.TargetSoftwareToPackageType(targetSW) == p.Type if unspecifiedTargetSW || matchesByLanguage || matchesByPackageType || mismatchWithUnknownLanguage { return true, "" } @@ -164,7 +164,7 @@ func normalizeTargetSoftwares(ts []string) *strset.Set { normalizedTargetSWs := strset.New() for _, ts := range ts { // Attempt to normalize target sw to package type, e.g. node and nodejs should match - pt := string(internal.CPETargetSoftwareToPackageType(ts)) + pt := string(syftCPE.TargetSoftwareToPackageType(ts)) if pt == "" && ts != "*" && ts != "?" && ts != "-" { // normalizing failed; preserve raw cpe target sw string as the type // unless it is wildcard diff --git a/grype/pkg/cpe_provider.go b/grype/pkg/cpe_provider.go index 2e4e7bccb6e..d82d262b2c8 100644 --- a/grype/pkg/cpe_provider.go +++ b/grype/pkg/cpe_provider.go @@ -1,14 +1,11 @@ package pkg import ( - "bufio" "fmt" "io" "strings" - "github.com/anchore/grype/grype/internal" - "github.com/anchore/syft/syft/cpe" - "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -19,13 +16,18 @@ type CPELiteralMetadata struct { CPE string } -func cpeProvider(userInput string) ([]Package, Context, *sbom.SBOM, error) { +func cpeProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { reader, ctx, err := getCPEReader(userInput) if err != nil { return nil, Context{}, nil, err } - return decodeCPEsFromReader(reader, ctx) + s, _, _, err := format.Decode(reader) + if s == nil { + return nil, Context{}, nil, fmt.Errorf("unable to decode cpe: %w", err) + } + + return FromCollection(s.Artifacts.Packages, config.SynthesisConfig), ctx, s, nil } func getCPEReader(userInput string) (r io.Reader, ctx Context, err error) { @@ -39,61 +41,3 @@ func getCPEReader(userInput string) (r io.Reader, ctx Context, err error) { } return nil, ctx, errDoesNotProvide } - -func decodeCPEsFromReader(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) { - scanner := bufio.NewScanner(reader) - var packages []Package - var syftPkgs []pkg.Package - - for scanner.Scan() { - rawLine := scanner.Text() - p, syftPkg, err := cpeToPackage(rawLine) - if err != nil { - return nil, Context{}, nil, err - } - - if p != nil { - packages = append(packages, *p) - } - if syftPkg != nil { - syftPkgs = append(syftPkgs, *syftPkg) - } - } - - if err := scanner.Err(); err != nil { - return nil, Context{}, nil, err - } - - s := &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(syftPkgs...), - }, - } - - return packages, ctx, s, nil -} - -func cpeToPackage(rawLine string) (*Package, *pkg.Package, error) { - c, err := cpe.New(rawLine, "") - if err != nil { - return nil, nil, fmt.Errorf("unable to decode cpe %q: %w", rawLine, err) - } - - syftPkg := pkg.Package{ - Name: c.Attributes.Product, - Version: c.Attributes.Version, - CPEs: []cpe.CPE{c}, - Type: internal.CPETargetSoftwareToPackageType(c.Attributes.TargetSW), - } - - syftPkg.SetID() - - return &Package{ - ID: ID(c.Attributes.BindToFmtString()), - CPEs: syftPkg.CPEs, - Name: syftPkg.Name, - Version: syftPkg.Version, - Type: syftPkg.Type, - Language: syftPkg.Language, - }, &syftPkg, nil -} diff --git a/grype/pkg/cpe_provider_test.go b/grype/pkg/cpe_provider_test.go index bbc5b0bdf21..90466020980 100644 --- a/grype/pkg/cpe_provider_test.go +++ b/grype/pkg/cpe_provider_test.go @@ -84,12 +84,14 @@ func Test_CPEProvider(t *testing.T) { }, }, { - name: "takes CPE 2.3 format", - userInput: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", + name: "takes multiple CPEs", + userInput: `cpe:/a:apache:log4j:2.14.1 + cpe:2.3:a:f5:nginx:*:*:*:*:*:*:*:*`, context: Context{ Source: &source.Description{ Metadata: CPELiteralMetadata{ - CPE: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", + CPE: `cpe:/a:apache:log4j:2.14.1 + cpe:2.3:a:f5:nginx:*:*:*:*:*:*:*:*`, }, }, }, @@ -101,6 +103,13 @@ func Test_CPEProvider(t *testing.T) { cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""), }, }, + { + Name: "nginx", + Version: "", + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:f5:nginx:*:*:*:*:*:*:*:*", ""), + }, + }, }, sbom: &sbom.SBOM{ Artifacts: sbom.Artifacts{ @@ -110,11 +119,18 @@ func Test_CPEProvider(t *testing.T) { CPEs: []cpe.CPE{ cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""), }, - }), + }, + pkg.Package{ + Name: "nginx", + Version: "", + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:f5:nginx:*:*:*:*:*:*:*:*", ""), + }, + }, + ), }, }, }, - { name: "invalid prefix", userInput: "dir:test-fixtures/cpe", @@ -137,7 +153,7 @@ func Test_CPEProvider(t *testing.T) { tc.wantErr = require.NoError } - packages, ctx, gotSBOM, err := cpeProvider(tc.userInput) + packages, ctx, gotSBOM, err := cpeProvider(tc.userInput, ProviderConfig{}) tc.wantErr(t, err) if err != nil { diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go index dd1637034eb..c5071933cbb 100644 --- a/grype/pkg/provider.go +++ b/grype/pkg/provider.go @@ -141,7 +141,7 @@ func provide(userInput string, config ProviderConfig, applyChannel func(d *distr return packages, ctx, s, err } - packages, ctx, s, err = cpeProvider(userInput) + packages, ctx, s, err = cpeProvider(userInput, config) if !errors.Is(err, errDoesNotProvide) { log.WithFields("input", userInput).Trace("interpreting input as a CPE") return packages, ctx, s, err From 4501ddf84f0a508ce11352a4802282227566ab94 Mon Sep 17 00:00:00 2001 From: Adam Chovanec Date: Tue, 18 Nov 2025 10:34:59 +0100 Subject: [PATCH 2/3] feat: scan a list of CPEs Signed-off-by: Adam Chovanec --- cmd/grype/cli/commands/root.go | 1 + grype/pkg/cpe_provider.go | 1 + grype/pkg/provider.go | 2 +- grype/pkg/syft_sbom_provider.go | 8 ++++++++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index ecda8a7e23a..d13ba4400e8 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -67,6 +67,7 @@ You can also explicitly specify the scheme to use: {{.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 package URLs from a path on disk {{.appName}} PURL read a single package PURL directly (e.g. pkg:apk/openssl@3.2.1?distro=alpine-3.20.3) + {{.appName}} cpes:path/to/cpes/file read a newline separated file of package CPEs from a path on disk {{.appName}} CPE read a single CPE directly (e.g. cpe:2.3:a:openssl:openssl:3.0.14:*:*:*:*:*) You can also pipe in Syft JSON directly: diff --git a/grype/pkg/cpe_provider.go b/grype/pkg/cpe_provider.go index d82d262b2c8..ad666fa1349 100644 --- a/grype/pkg/cpe_provider.go +++ b/grype/pkg/cpe_provider.go @@ -11,6 +11,7 @@ import ( ) const cpeInputPrefix = "cpe:" +const cpeListPrefix = "cpes:" type CPELiteralMetadata struct { CPE string diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go index c5071933cbb..c001bb2f29d 100644 --- a/grype/pkg/provider.go +++ b/grype/pkg/provider.go @@ -143,7 +143,7 @@ func provide(userInput string, config ProviderConfig, applyChannel func(d *distr packages, ctx, s, err = cpeProvider(userInput, config) if !errors.Is(err, errDoesNotProvide) { - log.WithFields("input", userInput).Trace("interpreting input as a CPE") + log.WithFields("input", userInput).Trace("interpreting input as a one or more CPEs") return packages, ctx, s, err } diff --git a/grype/pkg/syft_sbom_provider.go b/grype/pkg/syft_sbom_provider.go index b66eec5dae3..70ad4d0aafd 100644 --- a/grype/pkg/syft_sbom_provider.go +++ b/grype/pkg/syft_sbom_provider.go @@ -90,6 +90,10 @@ func getSBOMReader(userInput string) (io.ReadSeeker, string, error) { filepath := strings.TrimPrefix(userInput, purlInputPrefix) return openFile(filepath) + case explicitlySpecifyingCPEList(userInput): + filepath := strings.TrimPrefix(userInput, cpeListPrefix) + return openFile(filepath) + case explicitlySpecifyingSBOM(userInput): filepath := strings.TrimPrefix(userInput, "sbom:") return openFile(filepath) @@ -177,3 +181,7 @@ func explicitlySpecifyingSBOM(userInput string) bool { func explicitlySpecifyingPurlList(userInput string) bool { return strings.HasPrefix(userInput, purlInputPrefix) } + +func explicitlySpecifyingCPEList(userInput string) bool { + return strings.HasPrefix(userInput, cpeListPrefix) +} From 481fa2df79a9db150243a791dff3a18062fc5c85 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Tue, 17 Mar 2026 11:04:09 -0400 Subject: [PATCH 3/3] chore: restore cpe 2.3 test Signed-off-by: Keith Zantow --- grype/pkg/cpe_provider_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/grype/pkg/cpe_provider_test.go b/grype/pkg/cpe_provider_test.go index 90466020980..cce36f61bcf 100644 --- a/grype/pkg/cpe_provider_test.go +++ b/grype/pkg/cpe_provider_test.go @@ -83,6 +83,37 @@ func Test_CPEProvider(t *testing.T) { }, }, }, + { + name: "takes CPE 2.3 format", + userInput: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", + context: Context{ + Source: &source.Description{ + Metadata: CPELiteralMetadata{ + CPE: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", + }, + }, + }, + pkgs: []Package{ + { + Name: "log4j", + Version: "2.14.1", + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""), + }, + }, + }, + sbom: &sbom.SBOM{ + Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(pkg.Package{ + Name: "log4j", + Version: "2.14.1", + CPEs: []cpe.CPE{ + cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""), + }, + }), + }, + }, + }, { name: "takes multiple CPEs", userInput: `cpe:/a:apache:log4j:2.14.1