Skip to content

Commit a3423d9

Browse files
feat(suse/cvrf): enrich CVE score sets from cvrf-cve feed
Use SUSE's cvrf-cve feed to populate CVSS score sets for advisory vulnerabilities when advisory CVRF entries are missing or incomplete, while preserving the existing output schema and storage layout. Made-with: Cursor
1 parent 5c08311 commit a3423d9

5 files changed

Lines changed: 147 additions & 6 deletions

File tree

suse/cvrf/cve_cvrf.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package cvrf
2+
3+
import (
4+
"encoding/xml"
5+
"log"
6+
"strings"
7+
8+
"golang.org/x/xerrors"
9+
10+
"github.com/aquasecurity/vuln-list-update/utils"
11+
)
12+
13+
// cveCvrfDoc is a minimal parse of SUSE cvrf-cve/* XML for CVSS score extraction.
14+
type cveCvrfDoc struct {
15+
XMLName xml.Name `xml:"cvrfdoc"`
16+
Vuln cveCvrfVuln `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln Vulnerability"`
17+
}
18+
19+
type cveCvrfVuln struct {
20+
CVSSScoreSets cveCvrfCVSSScoreSets `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln CVSSScoreSets"`
21+
}
22+
23+
type cveCvrfCVSSScoreSets struct {
24+
ScoreSetV2 []cveScoreSetV2 `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln ScoreSetV2"`
25+
ScoreSetV3 []cveScoreSetV3 `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln ScoreSetV3"`
26+
}
27+
28+
type cveScoreSetV2 struct {
29+
BaseScoreV2 string `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln BaseScoreV2"`
30+
VectorV2 string `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln VectorV2"`
31+
}
32+
33+
type cveScoreSetV3 struct {
34+
BaseScoreV3 string `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln BaseScoreV3"`
35+
VectorV3 string `xml:"http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln VectorV3"`
36+
}
37+
38+
func parseCVECvrfScoreSets(b []byte) ([]ScoreSet, error) {
39+
var doc cveCvrfDoc
40+
if err := xml.Unmarshal(b, &doc); err != nil {
41+
return nil, xerrors.Errorf("decode CVE CVRF: %w", err)
42+
}
43+
return scoreSetsFromCVE12(doc.Vuln.CVSSScoreSets), nil
44+
}
45+
46+
func (c Config) mergeCVEDetailsFromCVEFeed(cv *Cvrf, cache map[string][]ScoreSet) {
47+
if c.CvrfCVEURL == "" {
48+
return
49+
}
50+
base := strings.TrimSuffix(c.CvrfCVEURL, "/")
51+
for i := range cv.Vulnerabilities {
52+
cveID := strings.TrimSpace(cv.Vulnerabilities[i].CVE)
53+
if cveID == "" {
54+
continue
55+
}
56+
if sets, ok := cache[cveID]; ok {
57+
if len(sets) > 0 {
58+
cv.Vulnerabilities[i].CVSSScoreSets = sets
59+
}
60+
continue
61+
}
62+
u := base + "/cvrf-" + cveID + ".xml"
63+
b, err := utils.FetchURL(u, "", c.Retry)
64+
if err != nil {
65+
log.Printf("CVE CVRF fetch skipped for %s: %v", cveID, err)
66+
cache[cveID] = nil
67+
continue
68+
}
69+
sets, err := parseCVECvrfScoreSets(b)
70+
if err != nil {
71+
log.Printf("CVE CVRF parse failed for %s: %v", cveID, err)
72+
cache[cveID] = nil
73+
continue
74+
}
75+
cache[cveID] = sets
76+
if len(sets) > 0 {
77+
cv.Vulnerabilities[i].CVSSScoreSets = sets
78+
}
79+
}
80+
}
81+
82+
func scoreSetsFromCVE12(cvss cveCvrfCVSSScoreSets) []ScoreSet {
83+
var out []ScoreSet
84+
for _, s := range cvss.ScoreSetV2 {
85+
if strings.TrimSpace(s.BaseScoreV2) == "" && strings.TrimSpace(s.VectorV2) == "" {
86+
continue
87+
}
88+
out = append(out, ScoreSet{BaseScore: s.BaseScoreV2, Vector: s.VectorV2})
89+
}
90+
for _, s := range cvss.ScoreSetV3 {
91+
if strings.TrimSpace(s.BaseScoreV3) == "" && strings.TrimSpace(s.VectorV3) == "" {
92+
continue
93+
}
94+
out = append(out, ScoreSet{BaseScore: s.BaseScoreV3, Vector: s.VectorV3})
95+
}
96+
return out
97+
}

suse/cvrf/cve_cvrf_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cvrf
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestParseCVECvrfScoreSets(t *testing.T) {
12+
b, err := os.ReadFile("testdata/cvrf-cve-CVE-2014-6271.xml")
13+
require.NoError(t, err)
14+
ss, err := parseCVECvrfScoreSets(b)
15+
require.NoError(t, err)
16+
require.Len(t, ss, 2)
17+
assert.Equal(t, "5.1", ss[0].BaseScore)
18+
assert.Equal(t, "AV:N/AC:H/Au:N/C:P/I:P/A:P", ss[0].Vector)
19+
assert.Equal(t, "9.8", ss[1].BaseScore)
20+
assert.Equal(t, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", ss[1].Vector)
21+
}

suse/cvrf/cvrf.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
var (
2222
cvrfURL = "http://ftp.suse.com/pub/projects/security/cvrf/"
23+
cvrfCVEURL = "http://ftp.suse.com/pub/projects/security/cvrf-cve/"
2324
fileRegexp = regexp.MustCompile(`<a href="(cvrf-(.*?)-.*)">.*`)
2425
retry = 5
2526
concurrency = 20
@@ -31,14 +32,17 @@ var (
3132
type Config struct {
3233
VulnListDir string
3334
URL string
34-
AppFs afero.Fs
35-
Retry int
35+
// CvrfCVEURL is the SUSE per-CVE CVRF feed (CSAF CVRF 1.2), used to fill CVE CVSS details.
36+
CvrfCVEURL string
37+
AppFs afero.Fs
38+
Retry int
3639
}
3740

3841
func NewConfig() Config {
3942
return Config{
4043
VulnListDir: utils.VulnListDir(),
4144
URL: cvrfURL,
45+
CvrfCVEURL: cvrfCVEURL,
4246
AppFs: afero.NewOsFs(),
4347
Retry: retry,
4448
}
@@ -98,9 +102,11 @@ func (c Config) update(os string, urls []string) error {
98102

99103
dir := filepath.Join(cvrfDir, suseDir, os)
100104
log.Printf("Fetching %s CVRF data...", os)
105+
cveScoreCache := make(map[string][]ScoreSet)
101106
bar := pb.StartNew(len(cvrfs))
102-
for _, cvrf := range cvrfs {
103-
if err = c.saveCvrfPerYear(dir, cvrf.Tracking.ID, cvrf); err != nil {
107+
for i := range cvrfs {
108+
c.mergeCVEDetailsFromCVEFeed(&cvrfs[i], cveScoreCache)
109+
if err = c.saveCvrfPerYear(dir, cvrfs[i].Tracking.ID, cvrfs[i]); err != nil {
104110
return xerrors.Errorf("failed to save CVRF: %w", err)
105111
}
106112
bar.Increment()

suse/cvrf/cvrf_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ func TestConfig_Update(t *testing.T) {
116116
c := cvrf.Config{
117117
VulnListDir: "/tmp",
118118
URL: url,
119-
AppFs: tc.appFs,
120-
Retry: 0,
119+
// CvrfCVEURL left empty: per-CVE CVRF merge is disabled; golden files match advisory XML only.
120+
AppFs: tc.appFs,
121+
Retry: 0,
121122
}
122123
err := c.Update()
123124
switch {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<cvrfdoc xmlns="http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/cvrf">
3+
<Vulnerability xmlns="http://docs.oasis-open.org/csaf/ns/csaf-cvrf/v1.2/vuln" Ordinal="1">
4+
<CVE>CVE-2014-6271</CVE>
5+
<CVSSScoreSets>
6+
<ScoreSetV2>
7+
<BaseScoreV2>5.1</BaseScoreV2>
8+
<VectorV2>AV:N/AC:H/Au:N/C:P/I:P/A:P</VectorV2>
9+
</ScoreSetV2>
10+
<ScoreSetV3>
11+
<BaseScoreV3>9.8</BaseScoreV3>
12+
<VectorV3>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</VectorV3>
13+
</ScoreSetV3>
14+
</CVSSScoreSets>
15+
</Vulnerability>
16+
</cvrfdoc>

0 commit comments

Comments
 (0)