diff --git a/enricher/common.go b/enricher/common.go new file mode 100644 index 000000000..8e95d502c --- /dev/null +++ b/enricher/common.go @@ -0,0 +1,10 @@ +package enricher + +import "regexp" + +// CVERegexp is a slightly more relaxed version of the validation pattern in the NVD +// JSON schema: https://csrc.nist.gov/schema/nvd/feed/1.1/CVE_JSON_4.0_min_1.1.schema. +// +// It allows for "CVE" to be case insensitive and for dashes and underscores +// between the different segments. +var CVERegexp = regexp.MustCompile(`(?i:cve)[-_][0-9]{4}[-_][0-9]{4,}`) diff --git a/enricher/cvss/cvss.go b/enricher/cvss/cvss.go index 887a04309..ca0d31e77 100644 --- a/enricher/cvss/cvss.go +++ b/enricher/cvss/cvss.go @@ -11,7 +11,6 @@ import ( "io" "net/http" "net/url" - "regexp" "sort" "strings" "time" @@ -19,6 +18,7 @@ import ( "github.com/quay/zlog" "github.com/quay/claircore" + "github.com/quay/claircore/enricher" "github.com/quay/claircore/libvuln/driver" "github.com/quay/claircore/pkg/tmp" ) @@ -253,13 +253,6 @@ func (e *Enricher) ParseEnrichment(ctx context.Context, rc io.ReadCloser) ([]dri return ret, nil } -// This is a slightly more relaxed version of the validation pattern in the NVD -// JSON schema: https://csrc.nist.gov/schema/nvd/feed/1.1/CVE_JSON_4.0_min_1.1.schema -// -// It allows for "CVE" to be case insensitive and for dashes and underscores -// between the different segments. -var cveRegexp = regexp.MustCompile(`(?i:cve)[-_][0-9]{4}[-_][0-9]{4,}`) - // Enrich implements driver.Enricher. func (e *Enricher) Enrich(ctx context.Context, g driver.EnrichmentGetter, r *claircore.VulnerabilityReport) (string, []json.RawMessage, error) { ctx = zlog.ContextWithValues(ctx, "component", "enricher/cvss/Enricher/Enrich") @@ -278,7 +271,7 @@ func (e *Enricher) Enrich(ctx context.Context, g driver.EnrichmentGetter, r *cla v.Name, v.Links, } { - for _, m := range cveRegexp.FindAllString(elem, -1) { + for _, m := range enricher.CVERegexp.FindAllString(elem, -1) { t[m] = struct{}{} } } diff --git a/enricher/epss/epss.go b/enricher/epss/epss.go new file mode 100644 index 000000000..fcbc013ee --- /dev/null +++ b/enricher/epss/epss.go @@ -0,0 +1,382 @@ +// Package epss provides a epss enricher. +package epss + +import ( + "compress/gzip" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + "github.com/quay/zlog" + + "github.com/quay/claircore" + "github.com/quay/claircore/enricher" + "github.com/quay/claircore/internal/httputil" + "github.com/quay/claircore/libvuln/driver" + "github.com/quay/claircore/pkg/tmp" +) + +var ( + _ driver.Enricher = (*Enricher)(nil) + _ driver.EnrichmentUpdater = (*Enricher)(nil) +) + +// EPSSItem represents a single entry in the EPSS feed, containing information +// about a CVE's Exploit Prediction Scoring System (EPSS) score and percentile. +type EPSSItem struct { + ModelVersion string `json:"modelVersion"` + Date string `json:"date"` + CVE string `json:"cve"` + EPSS float64 `json:"epss"` + Percentile float64 `json:"percentile"` +} + +const ( + // Type is the type of data returned from the Enricher's Enrich method. + Type = `message/vnd.clair.map.vulnerability; enricher=clair.epss schema=none` + + // DefaultBaseURL is the default place to look for EPSS feeds. + // epss_scores-YYYY-MM-DD.csv.gz needs to be specified to get all data + DefaultBaseURL = `https://epss.cyentia.com/` + + // epssName is the name of the enricher + epssName = `clair.epss` +) + +// Enricher provides EPSS data as enrichments to a VulnerabilityReport. +// +// Configure must be called before any other methods. +type Enricher struct { + driver.NoopUpdater + c *http.Client + baseURL *url.URL + feedPath string +} + +// Config is the configuration for Enricher. +type Config struct { + URL *string `json:"url" yaml:"url"` +} + +func (e *Enricher) Configure(ctx context.Context, f driver.ConfigUnmarshaler, c *http.Client) error { + ctx = zlog.ContextWithValues(ctx, "component", "enricher/epss/Enricher/Configure") + var cfg Config + e.c = c + e.feedPath = currentFeedURL() + if f == nil { + return fmt.Errorf("configuration is nil") + } + if err := f(&cfg); err != nil { + return err + } + if cfg.URL != nil { + // validate the URL format + if _, err := url.Parse(*cfg.URL); err != nil { + return fmt.Errorf("invalid URL format for URL: %w", err) + } + + // only .gz file is supported + if strings.HasSuffix(*cfg.URL, ".gz") { + //overwrite feedPath is cfg provides another baseURL path + e.feedPath = *cfg.URL + } else { + return fmt.Errorf("invalid baseURL root: expected a '.gz' file, but got '%q'", *cfg.URL) + } + } + + return nil +} + +// FetchEnrichment implements driver.EnrichmentUpdater. +func (e *Enricher) FetchEnrichment(ctx context.Context, prevFingerprint driver.Fingerprint) (io.ReadCloser, driver.Fingerprint, error) { + ctx = zlog.ContextWithValues(ctx, "component", "enricher/epss/Enricher/FetchEnrichment") + + out, err := tmp.NewFile("", "epss.") + if err != nil { + return nil, "", err + } + var success bool + defer func() { + if !success { + if err := out.Close(); err != nil { + zlog.Warn(ctx).Err(err).Msg("unable to close spool") + } + } + }() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.feedPath, nil) + if err != nil { + return nil, "", fmt.Errorf("unable to create request for %s: %w", e.feedPath, err) + } + + resp, err := e.c.Do(req) + if err != nil { + return nil, "", fmt.Errorf("unable to fetch file from %s: %w", e.feedPath, err) + } + defer resp.Body.Close() + + if err = httputil.CheckResponse(resp, http.StatusOK); err != nil { + return nil, "", fmt.Errorf("unable to fetch file: %w", err) + } + + var newFingerprint driver.Fingerprint + if etag := resp.Header.Get("etag"); etag != "" { + newFingerprint = driver.Fingerprint(etag) + if prevFingerprint == newFingerprint { + zlog.Info(ctx).Str("fingerprint", string(newFingerprint)).Msg("file unchanged; skipping processing") + return nil, prevFingerprint, nil + } + newFingerprint = driver.Fingerprint(etag) + } + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("unable to decompress file: %w", err) + } + defer gzipReader.Close() + + csvReader := csv.NewReader(gzipReader) + csvReader.FieldsPerRecord = 2 + + // assume metadata is always in the first line + record, err := csvReader.Read() + if err != nil { + return nil, "", fmt.Errorf("unable to read metadata line: %w", err) + } + + var modelVersion, date string + for _, field := range record { + field = strings.TrimPrefix(strings.TrimSpace(field), "#") + key, value, found := strings.Cut(field, ":") + if !found { + return nil, "", fmt.Errorf("unexpected metadata field format: %q", field) + } + switch key { + case "model_version": + modelVersion = value + case "score_date": + date = value + } + } + + if modelVersion == "" || date == "" { + return nil, "", fmt.Errorf("missing metadata fields in record: %v", record) + } + csvReader.Comment = '#' + + csvReader.FieldsPerRecord = 3 // Expect exactly 3 fields per record + + // Read and validate header line + record, err = csvReader.Read() + if err != nil { + return nil, "", fmt.Errorf("unable to read header line: %w", err) + } + + expectedHeaders := []string{"cve", "epss", "percentile"} + if !slices.Equal(record, expectedHeaders) { + return nil, "", fmt.Errorf("unexpected CSV headers: %v", record) + } + + enc := json.NewEncoder(out) + totalCVEs := 0 + + for { + record, err := csvReader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, "", fmt.Errorf("unable to read line in CSV: %w", err) + } + + r, err := newItemFeed(record, modelVersion, date) + if err != nil { + zlog.Warn(ctx).Err(err).Msg("skipping invalid record") + continue + } + + if err := enc.Encode(&r); err != nil { + return nil, "", fmt.Errorf("unable to write JSON line to file: %w", err) + } + totalCVEs++ + } + + zlog.Info(ctx).Int("totalCVEs", totalCVEs).Msg("processed CVEs") + if _, err := out.Seek(0, io.SeekStart); err != nil { + return nil, newFingerprint, fmt.Errorf("unable to reset file pointer: %w", err) + } + success = true + + return out, newFingerprint, nil +} + +// ParseEnrichment implements driver.EnrichmentUpdater. +func (e *Enricher) ParseEnrichment(ctx context.Context, rc io.ReadCloser) ([]driver.EnrichmentRecord, error) { + ctx = zlog.ContextWithValues(ctx, "component", "enricher/epss/Enricher/ParseEnrichment") + + defer rc.Close() + + dec := json.NewDecoder(rc) + ret := make([]driver.EnrichmentRecord, 0, 250_000) + var err error + + for { + var record driver.EnrichmentRecord + if err = dec.Decode(&record); err != nil { + break + } + ret = append(ret, record) + } + + zlog.Debug(ctx). + Int("count", len(ret)). + Msg("decoded enrichments") + + if !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("error decoding enrichment records: %w", err) + } + + return ret, nil +} + +func (*Enricher) Name() string { + return epssName +} + +func currentFeedURL() string { + yesterday := time.Now().AddDate(0, 0, -1) // Get yesterday's date + formattedDate := yesterday.Format("2006-01-02") + filePath := fmt.Sprintf("epss_scores-%s.csv.gz", formattedDate) + + feedURL, err := url.Parse(DefaultBaseURL) + if err != nil { + panic(fmt.Errorf("invalid default baseURL URL: %w", err)) + } + + feedURL.Path = path.Join(feedURL.Path, filePath) + return feedURL.String() +} + +func (e *Enricher) Enrich(ctx context.Context, g driver.EnrichmentGetter, r *claircore.VulnerabilityReport) (string, []json.RawMessage, error) { + ctx = zlog.ContextWithValues(ctx, "component", "enricher/epss/Enricher/Enrich") + m := make(map[string][]json.RawMessage) + erCache := make(map[string][]driver.EnrichmentRecord) + + for id, v := range r.Vulnerabilities { + t := make(map[string]struct{}) + ctx := zlog.ContextWithValues(ctx, "vuln", v.Name) + + for _, elem := range []string{ + v.Description, + v.Name, + v.Links, + } { + // Check if the element is non-empty before running the regex + if elem == "" { + zlog.Debug(ctx).Str("element", elem).Msg("skipping empty element") + continue + } + + matches := enricher.CVERegexp.FindAllString(elem, -1) + if len(matches) == 0 { + zlog.Debug(ctx).Str("element", elem).Msg("no CVEs found in element") + continue + } + for _, m := range matches { + t[m] = struct{}{} + } + } + + // Skip if no CVEs were found + if len(t) == 0 { + zlog.Debug(ctx).Msg("no CVEs found in vulnerability metadata") + continue + } + + ts := make([]string, 0, len(t)) + for m := range t { + ts = append(ts, m) + } + slices.Sort(ts) + + cveKey := strings.Join(ts, "_") + + rec, ok := erCache[cveKey] + if !ok { + var err error + rec, err = g.GetEnrichment(ctx, ts) + if err != nil { + return "", nil, err + } + erCache[cveKey] = rec + } + + zlog.Debug(ctx).Int("count", len(rec)).Msg("found records") + + // Skip if no enrichment records are found + if len(rec) == 0 { + zlog.Debug(ctx).Strs("cve", ts).Msg("no enrichment records found for CVEs") + continue + } + + for _, r := range rec { + m[id] = append(m[id], r.Enrichment) + } + } + + if len(m) == 0 { + return Type, nil, nil + } + + b, err := json.Marshal(m) + if err != nil { + return Type, nil, err + } + return Type, []json.RawMessage{b}, nil +} + +func newItemFeed(record []string, modelVersion string, scoreDate string) (driver.EnrichmentRecord, error) { + // Validate the record has the expected length + if len(record) != 3 { + return driver.EnrichmentRecord{}, fmt.Errorf("unexpected record length: %d", len(record)) + } + + var item EPSSItem + item.CVE = record[0] + + if f, err := strconv.ParseFloat(record[1], 64); err == nil { + item.EPSS = f + } else { + return driver.EnrichmentRecord{}, fmt.Errorf("invalid float for epss: %w", err) + } + + if f, err := strconv.ParseFloat(record[2], 64); err == nil { + item.Percentile = f + } else { + return driver.EnrichmentRecord{}, fmt.Errorf("invalid float for percentile: %w", err) + } + + item.ModelVersion = modelVersion + item.Date = scoreDate + + enrichment, err := json.Marshal(item) + if err != nil { + return driver.EnrichmentRecord{}, fmt.Errorf("unable to encode enrichment: %w", err) + } + + r := driver.EnrichmentRecord{ + Tags: []string{item.CVE}, + Enrichment: enrichment, + } + + return r, nil +} diff --git a/enricher/epss/epss_test.go b/enricher/epss/epss_test.go new file mode 100644 index 000000000..32eeefcbf --- /dev/null +++ b/enricher/epss/epss_test.go @@ -0,0 +1,395 @@ +package epss + +import ( + "compress/gzip" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/quay/zlog" + + "github.com/quay/claircore" + "github.com/quay/claircore/libvuln/driver" +) + +func TestConfigure(t *testing.T) { + t.Parallel() + ctx := zlog.Test(context.Background(), t) + tt := []configTestcase{ + { + Name: "None", // No configuration provided, should use default + Check: func(t *testing.T, err error) { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }, + }, + { + Name: "Not OK", // URL without .gz is invalid + Config: func(i interface{}) error { + cfg := i.(*Config) + s := "http://example.com/" + cfg.URL = &s + return nil + }, + Check: func(t *testing.T, err error) { + if err == nil { + t.Errorf("expected invalid URL error, but got none: %v", err) + } + }, + }, + + { + Name: "UnmarshalError", // Expected error on unmarshaling + Config: func(_ interface{}) error { return errors.New("expected error") }, + Check: func(t *testing.T, err error) { + if err == nil { + t.Error("expected unmarshal error, but got none") + } + }, + }, + { + Name: "BadURL", // Malformed URL in URL + Config: func(i interface{}) error { + cfg := i.(*Config) + s := "http://[notaurl:/" + cfg.URL = &s + return nil + }, + Check: func(t *testing.T, err error) { + if err == nil { + t.Error("expected URL parse error, but got none") + } + }, + }, + { + Name: "ValidGZURL", // Proper .gz URL in URL + Config: func(i interface{}) error { + cfg := i.(*Config) + s := "http://example.com/epss_scores-2024-10-25.csv.gz" + cfg.URL = &s + return nil + }, + Check: func(t *testing.T, err error) { + if err != nil { + t.Errorf("unexpected error with .gz URL: %v", err) + } + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, tc.Run(ctx)) + } +} + +func (tc configTestcase) Run(ctx context.Context) func(*testing.T) { + e := &Enricher{} + return func(t *testing.T) { + ctx := zlog.Test(ctx, t) + f := tc.Config + if f == nil { + f = noopConfig + } + err := e.Configure(ctx, f, nil) + if tc.Check == nil { + if err != nil { + t.Errorf("unexpected err: %v", err) + } + return + } + tc.Check(t, err) + } +} + +func TestFetch(t *testing.T) { + t.Parallel() + ctx := zlog.Test(context.Background(), t) + srv := mockServer(t) + + tt := []fetchTestcase{ + { + Name: "Fetch OK", // Tests successful fetch and data processing + Check: func(t *testing.T, rc io.ReadCloser, fp driver.Fingerprint, err error) { + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + defer rc.Close() + if rc == nil { + t.Error("expected non-nil ReadCloser for initial fetch") + } + if fp == driver.Fingerprint("") { + t.Error("expected non-empty fingerprint") + } + + // Further check if data is correctly read and structured + data, err := io.ReadAll(rc) + if err != nil { + t.Errorf("failed to read enrichment data: %v", err) + } + t.Logf("enrichment data: %s", string(data)) + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, tc.Run(ctx, srv)) + } +} + +type fetchTestcase struct { + Check func(*testing.T, io.ReadCloser, driver.Fingerprint, error) + Name string + Hint string +} + +type configTestcase struct { + Config func(interface{}) error + Check func(*testing.T, error) + Name string +} + +func noopConfig(_ interface{}) error { return nil } + +func mockServer(t *testing.T) *httptest.Server { + const root = `testdata/` + + // Define a static ETag for testing purposes + const etagValue = `"test-etag-12345"` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch path.Ext(r.URL.Path) { + case ".gz": // only gz baseURL is supported + w.Header().Set("etag", etagValue) + + f, err := os.Open(filepath.Join(root, "data.csv")) + if err != nil { + t.Errorf("open failed: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer f.Close() + + gz := gzip.NewWriter(w) + defer gz.Close() + if _, err := io.Copy(gz, f); err != nil { + t.Errorf("write error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + default: + t.Errorf("unknown request path: %q", r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + } + })) + + t.Cleanup(srv.Close) + return srv +} + +func (tc fetchTestcase) Run(ctx context.Context, srv *httptest.Server) func(*testing.T) { + return func(t *testing.T) { + e := &Enricher{} + ctx := zlog.Test(ctx, t) + configFunc := func(i interface{}) error { + cfg, ok := i.(*Config) + if !ok { + t.Fatal("expected Config type for i, but got a different type") + } + u := srv.URL + "/data.csv.gz" + cfg.URL = &u + return nil + } + + // Configure Enricher with mock server client and custom config + if err := e.Configure(ctx, configFunc, srv.Client()); err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Run FetchEnrichment and validate the result using Check + rc, fp, err := e.FetchEnrichment(ctx, driver.Fingerprint(tc.Hint)) + if rc != nil { + defer rc.Close() + } + if tc.Check != nil { + tc.Check(t, rc, fp, err) + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + } +} + +func TestParse(t *testing.T) { + t.Parallel() + ctx := zlog.Test(context.Background(), t) + srv := mockServer(t) + tt := []parseTestcase{ + { + Name: "OK", + }, + } + for _, tc := range tt { + t.Run(tc.Name, tc.Run(ctx, srv)) + } +} + +type parseTestcase struct { + Check func(*testing.T, []driver.EnrichmentRecord, error) + Name string +} + +func (tc parseTestcase) Run(ctx context.Context, srv *httptest.Server) func(*testing.T) { + e := &Enricher{} + return func(t *testing.T) { + ctx := zlog.Test(ctx, t) + f := func(i interface{}) error { + cfg, ok := i.(*Config) + if !ok { + t.Fatal("assertion failed") + } + u := srv.URL + "/data.csv.gz" + cfg.URL = &u + return nil + } + if err := e.Configure(ctx, f, srv.Client()); err != nil { + t.Errorf("unexpected error: %v", err) + } + + hint := driver.Fingerprint("test-e-tag-54321") + rc, _, err := e.FetchEnrichment(ctx, hint) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + defer rc.Close() + rs, err := e.ParseEnrichment(ctx, rc) + if tc.Check == nil { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + tc.Check(t, rs, err) + } +} + +type fakeGetter struct { + items []driver.EnrichmentRecord +} + +func (g *fakeGetter) GetEnrichment(ctx context.Context, cves []string) ([]driver.EnrichmentRecord, error) { + var results []driver.EnrichmentRecord + for _, cve := range cves { + for _, item := range g.items { + for _, tag := range item.Tags { + if tag == cve { + results = append(results, item) + break + } + } + } + } + return results, nil +} + +func TestEnrich(t *testing.T) { + t.Parallel() + ctx := zlog.Test(context.Background(), t) + srv := mockServer(t) + e := &Enricher{} + f := func(i interface{}) error { + cfg, ok := i.(*Config) + if !ok { + t.Fatal("assertion failed") + } + u := srv.URL + "/data.csv.gz" + cfg.URL = &u + return nil + } + if err := e.Configure(ctx, f, srv.Client()); err != nil { + t.Errorf("unexpected error: %v", err) + } + rc, _, err := e.FetchEnrichment(ctx, "") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + defer rc.Close() + rs, err := e.ParseEnrichment(ctx, rc) + if err != nil { + t.Fatal(err) + } + g := &fakeGetter{items: rs} + r := &claircore.VulnerabilityReport{ + Vulnerabilities: map[string]*claircore.Vulnerability{ + "-1": { + Description: "This is a fake vulnerability that doesn't have a CVE.", + }, + "1": { + Description: "This is a fake vulnerability that looks like CVE-2022-34667.", + }, + "6004": { + Description: "CVE-2024-9972 is here", + }, + "6005": { + Description: "CVE-2024-9986 is awesome", + }, + }, + } + kind, es, err := e.Enrich(ctx, g, r) + if err != nil { + t.Error(err) + } + if got, want := kind, Type; got != want { + t.Errorf("got: %q, want: %q", got, want) + } + want := map[string][]map[string]interface{}{ + "1": { + { + "cve": "CVE-2022-34667", + "epss": float64(0.00073), + "percentile": float64(0.32799), + "modelVersion": "v2023.03.01", + "date": "2024-10-25T00:00:00+0000", + }, + }, + "6004": { + { + "cve": "CVE-2024-9972", + "epss": float64(0.00091), + "percentile": float64(0.39923), + "modelVersion": "v2023.03.01", + "date": "2024-10-25T00:00:00+0000", + }, + }, + "6005": { + { + "cve": "CVE-2024-9986", + "epss": float64(0.00165), + "percentile": float64(0.53867), + "modelVersion": "v2023.03.01", + "date": "2024-10-25T00:00:00+0000", + }, + }, + } + + got := map[string][]map[string]interface{}{} + if err := json.Unmarshal(es[0], &got); err != nil { + t.Error(err) + } else { + log.Printf("Got: %+v\n", got) + + if !cmp.Equal(got, want) { + t.Error(cmp.Diff(got, want)) + } + } +} diff --git a/enricher/epss/testdata/data.csv b/enricher/epss/testdata/data.csv new file mode 100644 index 000000000..306044130 --- /dev/null +++ b/enricher/epss/testdata/data.csv @@ -0,0 +1,31 @@ +#model_version:v2023.03.01,score_date:2024-10-25T00:00:00+0000 +cve,epss,percentile +CVE-1999-0005,0.91963,0.99030 +CVE-1999-0006,0.03341,0.91563 +CVE-1999-0007,0.00073,0.32734 +CVE-1999-0008,0.13967,0.95792 +CVE-1999-0009,0.09014,0.94772 +CVE-1999-0010,0.00292,0.69634 +CVE-2022-34665,0.00042,0.05099 +CVE-2022-34666,0.00042,0.05099 +CVE-2022-34667,0.00073,0.32799 +CVE-2022-34668,0.00311,0.70519 +CVE-2022-34669,0.00044,0.13516 +CVE-2022-34670,0.00044,0.13516 +CVE-2022-34671,0.00142,0.50809 +CVE-2022-34672,0.00044,0.13516 +CVE-2022-34673,0.00044,0.13516 +CVE-2022-34674,0.00047,0.18133 +CVE-2022-34675,0.00044,0.13516 +CVE-2024-9972,0.00091,0.39923 +CVE-2024-9973,0.00063,0.28042 +CVE-2024-9974,0.00063,0.28042 +CVE-2024-9975,0.00063,0.28515 +CVE-2024-9980,0.00050,0.20281 +CVE-2024-9981,0.00050,0.20281 +CVE-2024-9982,0.00091,0.39923 +CVE-2024-9983,0.00090,0.39372 +CVE-2024-9984,0.00091,0.39923 +CVE-2024-9985,0.00091,0.39923 +CVE-2024-9986,0.00165,0.53867 +CVE-2024-9987,0.00043,0.09778 \ No newline at end of file