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 <namespace>:terraform_provider:<version>:...:<type> instead of
<namespace>:terraform-provider-<type>:<version>:...: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>
This commit is contained in:
Tim Olshansky
2026-11-03 22:01:20 -05:00
committed by GitHub
parent 639de5db64
commit 551442b1bc
4 changed files with 270 additions and 0 deletions
@@ -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)
@@ -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{
@@ -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/<namespace>/<type>".
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]
}
@@ -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)
})
}
}