From 53fc4c6d1a81f55db2ed75baf04c20c7c0b3e4a7 Mon Sep 17 00:00:00 2001 From: Jeremy Katz Date: Wed, 10 Jun 2026 15:46:30 -0400 Subject: [PATCH] fix(alpine): use NVD CPE version ranges to populate Introduced field Follow the pattern used by the Alpine security tracker to use data from NVD to be able to give some information on Introduced versions for vulnerabilities. This includes the same rewriting rules used there. This avoids over-reporting, for example CVE-2024-3094 should only show for xz 5.6.0 through 5.6.1-r2, not for earlier versions of xz --- vulnfeeds/cmd/converters/alpine/main.go | 58 ++++- vulnfeeds/cmd/converters/alpine/main_test.go | 236 +++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 vulnfeeds/cmd/converters/alpine/main_test.go diff --git a/vulnfeeds/cmd/converters/alpine/main.go b/vulnfeeds/cmd/converters/alpine/main.go index 720664d8f3a..741bd39c697 100644 --- a/vulnfeeds/cmd/converters/alpine/main.go +++ b/vulnfeeds/cmd/converters/alpine/main.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/google/osv/vulnfeeds/conversion" "github.com/google/osv/vulnfeeds/conversion/writer" "github.com/google/osv/vulnfeeds/models" "github.com/google/osv/vulnfeeds/utility/logger" @@ -195,10 +196,11 @@ func generateAlpineOSV(allAlpineSecDb map[string][]VersionAndPkg, allCVEs map[mo } for _, verPkg := range verPkgs { + introduced := findIntroducedVersion(cve.CVE, verPkg.Pkg) pkgInfo := vulns.PackageInfo{ PkgName: verPkg.Pkg, VersionInfo: models.VersionInfo{ - AffectedVersions: []models.AffectedVersion{{Fixed: verPkg.Ver}}, + AffectedVersions: []models.AffectedVersion{{Introduced: introduced, Fixed: verPkg.Ver}}, }, Ecosystem: "Alpine:" + verPkg.AlpineVer, PURL: "pkg:apk/alpine/" + verPkg.Pkg + "?arch=source", @@ -220,6 +222,60 @@ func generateAlpineOSV(allAlpineSecDb map[string][]VersionAndPkg, allCVEs map[mo return osvVulnerabilities } +// cpeMatchesAlpinePackage checks if a parsed CPE matches an Alpine package name +// using the same naming convention rules as the Alpine secfixes-tracker: +// language-specific prefixes (py3-, ruby-, perl-, lua-, vscode-) derived from +// the CPE target_sw field, plus direct name match for native packages. +func cpeMatchesAlpinePackage(parsed *models.CPEString, alpinePkg string) bool { + product := strings.ToLower(parsed.Product) + if product == alpinePkg { + return true + } + product = strings.ReplaceAll(product, "_", "-") + targetSW := strings.ToLower(parsed.TargetSW) + var rewritten string + switch { + case strings.Contains(targetSW, "python"): + rewritten = "py3-" + product + case strings.Contains(targetSW, "ruby"): + rewritten = "ruby-" + product + case strings.Contains(targetSW, "perl"): + rewritten = "perl-" + strings.ReplaceAll(product, "::", "-") + case strings.Contains(targetSW, "lua"): + rewritten = "lua-" + product + case strings.Contains(targetSW, "visual_studio_code"): + rewritten = "vscode-" + product + default: + return false + } + + return rewritten == alpinePkg +} + +// findIntroducedVersion searches NVD CPE configurations for the introduced +// version corresponding to the given Alpine package. Returns "0" if no +// VersionStartIncluding match is found. +func findIntroducedVersion(cve models.NVDCVE, alpinePkg string) string { + for _, config := range cve.Configurations { + for _, node := range config.Nodes { + for _, match := range node.CPEMatch { + if !match.Vulnerable || match.VersionStartIncluding == nil { + continue + } + parsed, err := conversion.ParseCPE(match.Criteria) + if err != nil { + continue + } + if cpeMatchesAlpinePackage(parsed, alpinePkg) { + return *match.VersionStartIncluding + } + } + } + } + + return "0" +} + // downloadAlpine downloads Alpine SecDB data from their API func downloadAlpine(version string) AlpineSecDB { res, err := http.Get(fmt.Sprintf(alpineURLBase, version)) diff --git a/vulnfeeds/cmd/converters/alpine/main_test.go b/vulnfeeds/cmd/converters/alpine/main_test.go new file mode 100644 index 00000000000..86ff0a3193b --- /dev/null +++ b/vulnfeeds/cmd/converters/alpine/main_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "testing" + + "github.com/google/osv/vulnfeeds/models" +) + +func TestCpeMatchesAlpinePackage(t *testing.T) { + tests := []struct { + name string + product string + targetSW string + alpinePkg string + want bool + }{ + {"direct match", "xz", "", "xz", true}, + {"direct match case-insensitive", "OpenSSL", "", "openssl", true}, + // Direct match on the full prefixed name takes priority over prefix rewriting. + {"direct match beats prefix logic", "py3-foo", "python", "py3-foo", true}, + {"direct mismatch no targetSW", "openssl", "", "xz", false}, + + // Python + {"python prefix exact", "pillow", "python", "py3-pillow", true}, + {"python prefix uppercase product", "Pillow", "python", "py3-pillow", true}, + {"python prefix underscore product", "python_pillow", "python", "py3-python-pillow", true}, + {"python prefix different package name", "pillow", "python", "py3-imaging", false}, + {"python prefix case-insensitive targetSW", "certifi", "Python", "py3-certifi", true}, + {"python prefix cpython in targetSW", "certifi", "cpython", "py3-certifi", true}, + + // Ruby + {"ruby prefix", "bigdecimal", "ruby", "ruby-bigdecimal", true}, + {"ruby prefix underscore", "some_gem", "ruby", "ruby-some-gem", true}, + + // Perl + {"perl prefix simple", "json", "perl", "perl-json", true}, + {"perl prefix double-colon namespace", "CGI::Session", "perl", "perl-cgi-session", true}, + + // Lua + {"lua prefix", "luaossl", "lua", "lua-luaossl", true}, + + // VSCode + {"vscode prefix", "python", "visual_studio_code", "vscode-python", true}, + + // Unknown / unhandled targetSW + {"java targetSW product mismatch", "log4j", "java", "log4j-core", false}, + {"unknown targetSW product mismatch", "foo", "java", "bar", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + parsed := &models.CPEString{Product: tc.product, TargetSW: tc.targetSW} + got := cpeMatchesAlpinePackage(parsed, tc.alpinePkg) + if got != tc.want { + t.Errorf("cpeMatchesAlpinePackage({Product:%q, TargetSW:%q}, %q) = %v, want %v", + tc.product, tc.targetSW, tc.alpinePkg, got, tc.want) + } + }) + } +} + +func TestFindIntroducedVersion(t *testing.T) { + // A valid CPE 2.3 string whose product is "xz" with no target_sw. + const xzCPE = "cpe:2.3:a:tukaani:xz:*:*:*:*:*:*:*:*" + // A valid CPE 2.3 string whose product is "foo". + const fooCPE = "cpe:2.3:a:example:foo:*:*:*:*:*:*:*:*" + // A valid CPE 2.3 string for a python package. + const pillowCPE = "cpe:2.3:a:python-pillow:pillow:*:*:*:*:*:python:*:*" + + tests := []struct { + name string + cve models.NVDCVE + alpinePkg string + want string + }{ + { + name: "no configurations", + cve: models.NVDCVE{}, + alpinePkg: "xz", + want: "0", + }, + { + name: "match with VersionStartIncluding", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: xzCPE, + Vulnerable: true, + VersionStartIncluding: new("5.6.0"), + }}, + }}, + }}, + }, + alpinePkg: "xz", + want: "5.6.0", + }, + { + name: "non-vulnerable CPE skipped", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: xzCPE, + Vulnerable: false, + VersionStartIncluding: new("5.6.0"), + }}, + }}, + }}, + }, + alpinePkg: "xz", + want: "0", + }, + { + name: "VersionStartIncluding nil returns 0", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: xzCPE, + Vulnerable: true, + }}, + }}, + }}, + }, + alpinePkg: "xz", + want: "0", + }, + { + // Known gap: VersionStartExcluding is not handled; falls back to "0". + name: "VersionStartExcluding only falls back to 0", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: xzCPE, + Vulnerable: true, + VersionStartExcluding: new("5.5.0"), + }}, + }}, + }}, + }, + alpinePkg: "xz", + want: "0", + }, + { + name: "invalid CPE criteria skipped", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: "not-a-cpe", + Vulnerable: true, + VersionStartIncluding: new("1.0.0"), + }}, + }}, + }}, + }, + alpinePkg: "xz", + want: "0", + }, + { + name: "match found in second node", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{ + { + CPEMatch: []models.CPEMatch{{ + Criteria: xzCPE, + Vulnerable: true, + VersionStartIncluding: new("0.9.0"), + }}, + }, + { + CPEMatch: []models.CPEMatch{{ + Criteria: fooCPE, + Vulnerable: true, + VersionStartIncluding: new("1.0.0"), + }}, + }, + }, + }}, + }, + alpinePkg: "foo", + want: "1.0.0", + }, + { + name: "python package prefix match", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{{ + Criteria: pillowCPE, + Vulnerable: true, + VersionStartIncluding: new("9.0.0"), + }}, + }}, + }}, + }, + alpinePkg: "py3-pillow", + want: "9.0.0", + }, + { + name: "first match wins when multiple CPEs match", + cve: models.NVDCVE{ + Configurations: []models.Config{{ + Nodes: []models.Node{{ + CPEMatch: []models.CPEMatch{ + { + Criteria: xzCPE, + Vulnerable: true, + VersionStartIncluding: new("5.0.0"), + }, + { + Criteria: xzCPE, + Vulnerable: true, + VersionStartIncluding: new("5.6.0"), + }, + }, + }}, + }}, + }, + alpinePkg: "xz", + want: "5.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := findIntroducedVersion(tc.cve, tc.alpinePkg) + if got != tc.want { + t.Errorf("findIntroducedVersion(..., %q) = %q, want %q", tc.alpinePkg, got, tc.want) + } + }) + } +}