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) + } + }) + } +}