From 551442b1bca34bb06c175cfccb48cf6073e138fd Mon Sep 17 00:00:00 2001 From: Tim Olshansky <456103+timols@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:01:20 -0800 Subject: [PATCH] fix(terraform): generate NVD-style terraform_provider CPEs for terraform lock packages (#5261) * fix: generate terraform-provider-specific CPEs for terraform lock packages The terraform-lock-cataloger sets Language to Go, which causes the Go CPE heuristic to generate application-level CPEs for terraform providers. For example, registry.terraform.io/grafana/grafana produces the same CPE as the Grafana application itself, leading grype to flag providers with unrelated CVEs. This adds terraform-specific vendor/product candidate functions that generate CPEs matching the NVD convention for terraform providers (e.g. terraform-provider-grafana instead of grafana), with terraform as the target software qualifier. Signed-off-by: Tim Olshansky <456103+timols@users.noreply.github.com> * fix: align terraform provider CPEs with NVD terraform_provider convention NVD records terraform providers with a fixed "terraform_provider" product and the provider type in target_sw, e.g. cpe:2.3:a:hashicorp:terraform_provider:*:*:*:*:*:vault:*:* Generate :terraform_provider::...: instead of :terraform-provider-::...:terraform, which never appears in NVD. Signed-off-by: Tim Olshansky <456103+timols@users.noreply.github.com> --------- Signed-off-by: Tim Olshansky <456103+timols@users.noreply.github.com> --- .../internal/cpegenerate/generate.go | 10 + .../internal/cpegenerate/generate_test.go | 32 ++++ .../internal/cpegenerate/terraform.go | 56 ++++++ .../internal/cpegenerate/terraform_test.go | 172 ++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 syft/pkg/cataloger/internal/cpegenerate/terraform.go create mode 100644 syft/pkg/cataloger/internal/cpegenerate/terraform_test.go diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate.go b/syft/pkg/cataloger/internal/cpegenerate/generate.go index ae03cabe7..b270bea6b 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate.go @@ -234,6 +234,10 @@ func candidateTargetSw(p pkg.Package) []string { // NVD records CPAN distributions with perl in target_sw, e.g. // cpe:2.3:a:mojolicious:mojolicious:*:*:*:*:*:perl:*:* return []string{"perl"} + case p.Type == pkg.TerraformPkg: + // NVD records terraform providers with the provider type in target_sw, e.g. + // cpe:2.3:a:hashicorp:terraform_provider:*:*:*:*:*:vault:*:* + return candidateTargetSwForTerraformProvider(p) } return []string{cpe.Any} @@ -332,6 +336,9 @@ func candidateVendorsByType(p pkg.Package, vendors fieldCandidateSet) fieldCandi case pkg.WordpressPluginEntry: vendors.clear() vendors.union(candidateVendorsForWordpressPlugin(p)) + case pkg.TerraformLockProviderEntry: + vendors.clear() + vendors.union(candidateVendorsForTerraformProvider(p)) } return vendors } @@ -377,6 +384,9 @@ func candidateProductSet(p pkg.Package) fieldCandidateSet { case pkg.WordpressPluginEntry: products.clear() products.union(candidateProductsForWordpressPlugin(p)) + case pkg.TerraformLockProviderEntry: + products.clear() + products.union(candidateProductsForTerraformProvider(p)) } // it is never OK to have candidates with these values ["" and "*"] (since CPEs will match any other value) diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate_test.go b/syft/pkg/cataloger/internal/cpegenerate/generate_test.go index 4b849a709..6a0730890 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate_test.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate_test.go @@ -902,6 +902,38 @@ func TestGeneratePackageCPEs(t *testing.T) { }, expected: []string{}, }, + { + name: "terraform provider generates NVD terraform_provider CPEs not application CPEs", + p: pkg.Package{ + Name: "registry.terraform.io/grafana/grafana", + Version: "4.45.2", + Type: pkg.TerraformPkg, + Language: pkg.Go, + Metadata: pkg.TerraformLockProviderEntry{ + URL: "registry.terraform.io/grafana/grafana", + Version: "4.45.2", + }, + }, + expected: []string{ + "cpe:2.3:a:grafana:terraform_provider:4.45.2:*:*:*:*:grafana:*:*", + }, + }, + { + name: "terraform provider with different namespace and type", + p: pkg.Package{ + Name: "registry.terraform.io/hashicorp/aws", + Version: "5.72.1", + Type: pkg.TerraformPkg, + Language: pkg.Go, + Metadata: pkg.TerraformLockProviderEntry{ + URL: "registry.terraform.io/hashicorp/aws", + Version: "5.72.1", + }, + }, + expected: []string{ + "cpe:2.3:a:hashicorp:terraform_provider:5.72.1:*:*:*:*:aws:*:*", + }, + }, { name: "rust package", p: pkg.Package{ diff --git a/syft/pkg/cataloger/internal/cpegenerate/terraform.go b/syft/pkg/cataloger/internal/cpegenerate/terraform.go new file mode 100644 index 000000000..67f5ce495 --- /dev/null +++ b/syft/pkg/cataloger/internal/cpegenerate/terraform.go @@ -0,0 +1,56 @@ +package cpegenerate + +import ( + "strings" + + "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/pkg" +) + +// NVD records terraform providers with a fixed "terraform_provider" product and the provider type in target_sw, e.g. +// cpe:2.3:a:hashicorp:terraform_provider:*:*:*:*:*:vault:*:* +const terraformProviderProduct = "terraform_provider" + +func candidateVendorsForTerraformProvider(p pkg.Package) fieldCandidateSet { + vendors := newFieldCandidateSet() + + namespace, _ := parseTerraformProviderName(p.Name) + if namespace != "" { + vendors.addValue(namespace) + } + + return vendors +} + +func candidateProductsForTerraformProvider(p pkg.Package) fieldCandidateSet { + products := newFieldCandidateSet() + + _, providerType := parseTerraformProviderName(p.Name) + if providerType != "" { + products.add(fieldCandidate{ + value: terraformProviderProduct, + disallowSubSelections: true, + disallowDelimiterVariations: true, + }) + } + + return products +} + +func candidateTargetSwForTerraformProvider(p pkg.Package) []string { + _, providerType := parseTerraformProviderName(p.Name) + if providerType == "" { + return []string{cpe.Any} + } + return []string{providerType} +} + +// parseTerraformProviderName extracts the namespace and type from a terraform provider +// source address. The expected format is "registry.terraform.io//". +func parseTerraformProviderName(name string) (namespace, providerType string) { + parts := strings.Split(name, "/") + if len(parts) < 3 { + return "", "" + } + return parts[len(parts)-2], parts[len(parts)-1] +} diff --git a/syft/pkg/cataloger/internal/cpegenerate/terraform_test.go b/syft/pkg/cataloger/internal/cpegenerate/terraform_test.go new file mode 100644 index 000000000..a21b37ddc --- /dev/null +++ b/syft/pkg/cataloger/internal/cpegenerate/terraform_test.go @@ -0,0 +1,172 @@ +package cpegenerate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/pkg" +) + +func Test_candidateVendorsForTerraformProvider(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + expected []string + }{ + { + name: "hashicorp/aws provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/hashicorp/aws", + }, + expected: []string{"hashicorp"}, + }, + { + name: "grafana/grafana provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/grafana/grafana", + }, + expected: []string{"grafana"}, + }, + { + name: "hashicorp/google provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/hashicorp/google", + }, + expected: []string{"hashicorp"}, + }, + { + name: "malformed name with fewer than 3 segments", + pkg: pkg.Package{ + Name: "hashicorp/aws", + }, + expected: []string{}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := candidateVendorsForTerraformProvider(test.pkg).uniqueValues() + assert.ElementsMatch(t, test.expected, actual) + }) + } +} + +func Test_candidateProductsForTerraformProvider(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + expected []string + }{ + { + name: "hashicorp/aws provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/hashicorp/aws", + }, + expected: []string{"terraform_provider"}, + }, + { + name: "grafana/grafana provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/grafana/grafana", + }, + expected: []string{"terraform_provider"}, + }, + { + name: "hashicorp/google provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/hashicorp/google", + }, + expected: []string{"terraform_provider"}, + }, + { + name: "malformed name with fewer than 3 segments", + pkg: pkg.Package{ + Name: "hashicorp/aws", + }, + expected: []string{}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := candidateProductsForTerraformProvider(test.pkg).uniqueValues() + assert.ElementsMatch(t, test.expected, actual) + }) + } +} + +func Test_candidateTargetSwForTerraformProvider(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + expected []string + }{ + { + name: "hashicorp/aws provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/hashicorp/aws", + }, + expected: []string{"aws"}, + }, + { + name: "grafana/grafana provider", + pkg: pkg.Package{ + Name: "registry.terraform.io/grafana/grafana", + }, + expected: []string{"grafana"}, + }, + { + name: "malformed name with fewer than 3 segments", + pkg: pkg.Package{ + Name: "hashicorp/aws", + }, + expected: []string{cpe.Any}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, candidateTargetSwForTerraformProvider(test.pkg)) + }) + } +} + +func Test_parseTerraformProviderName(t *testing.T) { + tests := []struct { + name string + input string + expectedNamespace string + expectedType string + }{ + { + name: "standard provider", + input: "registry.terraform.io/hashicorp/aws", + expectedNamespace: "hashicorp", + expectedType: "aws", + }, + { + name: "provider where namespace matches type", + input: "registry.terraform.io/grafana/grafana", + expectedNamespace: "grafana", + expectedType: "grafana", + }, + { + name: "too few segments", + input: "hashicorp/aws", + expectedNamespace: "", + expectedType: "", + }, + { + name: "single segment", + input: "aws", + expectedNamespace: "", + expectedType: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + namespace, providerType := parseTerraformProviderName(test.input) + assert.Equal(t, test.expectedNamespace, namespace) + assert.Equal(t, test.expectedType, providerType) + }) + } +}