Skip to content

Commit 21d17c3

Browse files
authored
feat(osv): add CVSSv4 support in OSV parser (#635)
1 parent 1c22d4e commit 21d17c3

File tree

3 files changed

+184
-45
lines changed

3 files changed

+184
-45
lines changed

pkg/vulnsrc/osv/osv.go

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
gocvss30 "github.com/pandatix/go-cvss/30"
1212
gocvss31 "github.com/pandatix/go-cvss/31"
13+
gocvss40 "github.com/pandatix/go-cvss/40"
1314
"github.com/samber/lo"
1415
"github.com/samber/oops"
1516
bolt "go.etcd.io/bbolt"
@@ -36,14 +37,16 @@ type Advisory struct {
3637
Arches []string
3738

3839
// Vulnerability detail
39-
Severity types.Severity
40-
Title string
41-
Description string
42-
References []string
43-
CVSSScoreV3 float64
44-
CVSSVectorV3 string
45-
Modified time.Time
46-
Published time.Time
40+
Severity types.Severity
41+
Title string
42+
Description string
43+
References []string
44+
CVSSScoreV3 float64
45+
CVSSVectorV3 string
46+
CVSSScoreV40 float64
47+
CVSSVectorV40 string
48+
Modified time.Time
49+
Published time.Time
4750
}
4851

4952
// BucketResolver resolves an ecosystem string to a bucket
@@ -214,6 +217,8 @@ func (o OSV) commit(tx *bolt.Tx, entry Entry) error {
214217
Description: adv.Description,
215218
CvssScoreV3: adv.CVSSScoreV3,
216219
CvssVectorV3: adv.CVSSVectorV3,
220+
CvssScoreV40: adv.CVSSScoreV40,
221+
CvssVectorV40: adv.CVSSVectorV40,
217222
PublishedDate: lo.Ternary(!adv.Published.IsZero(), &adv.Published, nil),
218223
LastModifiedDate: lo.Ternary(!adv.Modified.IsZero(), &adv.Modified, nil),
219224
}
@@ -235,7 +240,7 @@ func (o OSV) parseAffected(entry Entry, vulnIDs, aliases, references []string) (
235240
eb := oops.With("entry_id", entry.ID).With("vuln_ids", vulnIDs).With("aliases", aliases)
236241

237242
// Severities can be found both in severity and affected[].severity fields.
238-
cvssVectorV3, cvssScoreV3, err := parseSeverity(entry.Severities)
243+
sev, err := parseSeverities(entry.Severities)
239244
if err != nil {
240245
return nil, eb.Wrapf(err, "failed to decode CVSS vector")
241246
}
@@ -256,11 +261,15 @@ func (o OSV) parseAffected(entry Entry, vulnIDs, aliases, references []string) (
256261
}
257262

258263
// Parse affected[].severity
259-
if vecV3, scoreV3, err := parseSeverity(affected.Severities); err != nil {
264+
affSev, err := parseSeverities(affected.Severities)
265+
if err != nil {
260266
return nil, eb.Wrapf(err, "failed to decode CVSS vector")
261-
} else if vecV3 != "" {
262-
// Overwrite the CVSS vector and score if affected[].severity is set
263-
cvssVectorV3, cvssScoreV3 = vecV3, scoreV3
267+
}
268+
if affSev.VectorV3 != "" {
269+
sev.VectorV3, sev.ScoreV3 = affSev.VectorV3, affSev.ScoreV3
270+
}
271+
if affSev.VectorV40 != "" {
272+
sev.VectorV40, sev.ScoreV40 = affSev.VectorV40, affSev.ScoreV40
264273
}
265274

266275
key := fmt.Sprintf("%s/%s", bkt.Ecosystem(), pkgName)
@@ -282,8 +291,10 @@ func (o OSV) parseAffected(entry Entry, vulnIDs, aliases, references []string) (
282291
Title: entry.Summary,
283292
Description: entry.Details,
284293
References: references,
285-
CVSSVectorV3: cvssVectorV3,
286-
CVSSScoreV3: cvssScoreV3,
294+
CVSSVectorV3: sev.VectorV3,
295+
CVSSScoreV3: sev.ScoreV3,
296+
CVSSVectorV40: sev.VectorV40,
297+
CVSSScoreV40: sev.ScoreV40,
287298
Modified: entry.Modified,
288299
Published: entry.Published,
289300
}
@@ -370,43 +381,76 @@ func parseAffectedVersions(affected Affected) ([]string, []string, error) {
370381
return vulnerableVersions, patchedVersions, nil
371382
}
372383

373-
// parseSeverity parses the severity field and returns CVSSv3 vector and score
384+
type severityResult struct {
385+
VectorV3 string
386+
ScoreV3 float64
387+
VectorV40 string
388+
ScoreV40 float64
389+
}
390+
391+
// parseSeverities parses the severity field and returns CVSSv3 and CVSSv4 vectors and scores
374392
// cf.
375393
// - https://ossf.github.io/osv-schema/#severity-field
376394
// - https://ossf.github.io/osv-schema/#affectedseverity-field
377-
func parseSeverity(severities []Severity) (string, float64, error) {
395+
func parseSeverities(severities []Severity) (severityResult, error) {
396+
var result severityResult
378397
for _, s := range severities {
379-
if s.Type == "CVSS_V3" && s.Score != "" {
380-
// CVSS vectors possibly have `/` suffix
381-
// e.g. https://github.com/github/advisory-database/blob/2d3bc73d2117893b217233aeb95b9236c7b93761/advisories/github-reviewed/2019/05/GHSA-j59f-6m4q-62h6/GHSA-j59f-6m4q-62h6.json#L14
382-
// Trim the suffix to avoid errors
383-
cvssVectorV3 := strings.TrimSuffix(s.Score, "/")
384-
eb := oops.With("cvss_vector_v3", cvssVectorV3)
385-
switch {
386-
case strings.HasPrefix(cvssVectorV3, "CVSS:3.0"):
387-
cvss, err := gocvss30.ParseVector(cvssVectorV3)
388-
if err != nil {
389-
return "", 0, eb.Wrapf(err, "failed to parse CVSSv3.0 vector")
390-
}
391-
// cvss.EnvironmentalScore() returns the optimal score required from Vector.
392-
// If the Environmental Metrics is not set, it will be the same value as TemporalScore(),
393-
// and if Temporal Metrics is not set, it will be the same value as Basescore().
394-
return cvssVectorV3, cvss.EnvironmentalScore(), nil
395-
case strings.HasPrefix(s.Score, "CVSS:3.1"):
396-
cvss, err := gocvss31.ParseVector(cvssVectorV3)
397-
if err != nil {
398-
return "", 0, oops.Wrapf(err, "failed to parse CVSSv3.1 vector")
399-
}
400-
// cvss.EnvironmentalScore() returns the optimal score required from Vector.
401-
// If the Environmental Metrics is not set, it will be the same value as TemporalScore(),
402-
// and if Temporal Metrics is not set, it will be the same value as Basescore().
403-
return cvssVectorV3, cvss.EnvironmentalScore(), nil
404-
default:
405-
return "", 0, eb.Errorf("vector does not have CVSS v3 prefix: \"CVSS:3.0\" or \"CVSS:3.1\"")
398+
if s.Score == "" {
399+
continue
400+
}
401+
switch s.Type {
402+
case "CVSS_V3":
403+
vec, score, err := parseSeverityV3(s.Score)
404+
if err != nil {
405+
return severityResult{}, err
406+
}
407+
result.VectorV3 = vec
408+
result.ScoreV3 = score
409+
case "CVSS_V4":
410+
vec, score, err := parseSeverityV40(s.Score)
411+
if err != nil {
412+
return severityResult{}, err
406413
}
414+
result.VectorV40 = vec
415+
result.ScoreV40 = score
407416
}
408417
}
409-
return "", 0, nil
418+
return result, nil
419+
}
420+
421+
// parseSeverityV3 parses a CVSSv3 vector string and returns the vector and score
422+
func parseSeverityV3(score string) (string, float64, error) {
423+
// CVSS vectors possibly have `/` suffix
424+
// e.g. https://github.com/github/advisory-database/blob/2d3bc73d2117893b217233aeb95b9236c7b93761/advisories/github-reviewed/2019/05/GHSA-j59f-6m4q-62h6/GHSA-j59f-6m4q-62h6.json#L14
425+
// Trim the suffix to avoid errors
426+
vector := strings.TrimSuffix(score, "/")
427+
eb := oops.With("cvss_vector_v3", vector)
428+
switch {
429+
case strings.HasPrefix(vector, "CVSS:3.0"):
430+
cvss, err := gocvss30.ParseVector(vector)
431+
if err != nil {
432+
return "", 0, eb.Wrapf(err, "failed to parse CVSSv3.0 vector")
433+
}
434+
return vector, cvss.EnvironmentalScore(), nil
435+
case strings.HasPrefix(vector, "CVSS:3.1"):
436+
cvss, err := gocvss31.ParseVector(vector)
437+
if err != nil {
438+
return "", 0, eb.Wrapf(err, "failed to parse CVSSv3.1 vector")
439+
}
440+
return vector, cvss.EnvironmentalScore(), nil
441+
default:
442+
return "", 0, eb.Errorf("vector does not have CVSS v3 prefix: \"CVSS:3.0\" or \"CVSS:3.1\"")
443+
}
444+
}
445+
446+
// parseSeverityV40 parses a CVSSv4.0 vector string and returns the vector and score
447+
func parseSeverityV40(score string) (string, float64, error) {
448+
vector := strings.TrimSuffix(score, "/")
449+
cvss, err := gocvss40.ParseVector(vector)
450+
if err != nil {
451+
return "", 0, oops.With("cvss_vector_v40", vector).Wrapf(err, "failed to parse CVSSv4.0 vector")
452+
}
453+
return cvss.Vector(), cvss.Score(), nil
410454
}
411455

412456
func (o OSV) resolveBucket(raw string) (bucket.Bucket, error) {

pkg/vulnsrc/osv/osv_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,50 @@ func TestVulnSrc_Update(t *testing.T) {
101101
},
102102
},
103103
},
104+
// CVSSv4: advisory with both V3 and V4
105+
{
106+
Key: []string{
107+
"advisory-detail",
108+
"CVE-2026-21860",
109+
"pip::Python Packaging Advisory Database",
110+
"werkzeug",
111+
},
112+
Value: types.Advisory{
113+
VendorIDs: []string{
114+
"PYSEC-2026-1",
115+
},
116+
PatchedVersions: []string{"3.1.5"},
117+
VulnerableVersions: []string{"<3.1.5"},
118+
},
119+
},
120+
{
121+
Key: []string{
122+
"vulnerability-detail",
123+
"CVE-2026-21860",
124+
string(vulnerability.OSV),
125+
},
126+
Value: types.VulnerabilityDetail{
127+
Title: "Werkzeug safe_join() allows Windows special device names with compound extensions",
128+
Description: "Werkzeug's `safe_join` function allows path segments with Windows device names that have file extensions or trailing spaces.",
129+
CvssVectorV3: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
130+
CvssScoreV3: 5.3,
131+
CvssVectorV40: "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
132+
CvssScoreV40: 6.3,
133+
References: []string{
134+
"https://github.com/pallets/werkzeug/security/advisories/GHSA-87hc-h4r5-73f7",
135+
"https://nvd.nist.gov/vuln/detail/CVE-2026-21860",
136+
},
137+
LastModifiedDate: utils.MustTimeParse("2026-02-02T19:57:31Z"),
138+
PublishedDate: utils.MustTimeParse("2026-01-08T19:51:21Z"),
139+
},
140+
},
141+
{
142+
Key: []string{
143+
"vulnerability-id",
144+
"CVE-2026-21860",
145+
},
146+
Value: map[string]any{},
147+
},
104148
},
105149
noBuckets: [][]string{
106150
// skip withdrawn
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"id": "PYSEC-2026-1",
3+
"modified": "2026-02-02T19:57:31Z",
4+
"published": "2026-01-08T19:51:21Z",
5+
"aliases": [
6+
"CVE-2026-21860"
7+
],
8+
"summary": "Werkzeug safe_join() allows Windows special device names with compound extensions",
9+
"details": "Werkzeug's `safe_join` function allows path segments with Windows device names that have file extensions or trailing spaces.",
10+
"severity": [
11+
{
12+
"type": "CVSS_V3",
13+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
14+
},
15+
{
16+
"type": "CVSS_V4",
17+
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N"
18+
}
19+
],
20+
"affected": [
21+
{
22+
"package": {
23+
"ecosystem": "PyPI",
24+
"name": "Werkzeug"
25+
},
26+
"ranges": [
27+
{
28+
"type": "ECOSYSTEM",
29+
"events": [
30+
{
31+
"introduced": "0"
32+
},
33+
{
34+
"fixed": "3.1.5"
35+
}
36+
]
37+
}
38+
]
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/pallets/werkzeug/security/advisories/GHSA-87hc-h4r5-73f7"
45+
},
46+
{
47+
"type": "ADVISORY",
48+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-21860"
49+
}
50+
]
51+
}

0 commit comments

Comments
 (0)