diff --git a/vulnfeeds/cmd/combine-to-osv/main.go b/vulnfeeds/cmd/combine-to-osv/main.go index 8992181c5bd..5f4f71eb42b 100644 --- a/vulnfeeds/cmd/combine-to-osv/main.go +++ b/vulnfeeds/cmd/combine-to-osv/main.go @@ -136,18 +136,18 @@ func readVulnerability(ctx context.Context, client *storage.Client, fullPath str } func combineIntoOSV(cve5 *osvschema.Vulnerability, nvd *osvschema.Vulnerability) *osvschema.Vulnerability { - var baseOSV *osvschema.Vulnerability - if cve5 != nil && nvd != nil { - baseOSV = combineTwoOSVRecords(cve5, nvd) - } else if cve5 != nil { - baseOSV = cve5 - } else if nvd != nil { - baseOSV = nvd - } else { + if (cve5.GetWithdrawn() != nil) || (nvd.GetWithdrawn() != nil) { return nil } - return baseOSV + if cve5 != nil && nvd != nil { + return combineTwoOSVRecords(cve5, nvd) + } + if cve5 != nil { + return cve5 + } + + return nvd } func readAndCombineWorker(ctx context.Context, client *storage.Client, workChan <-chan *CVEWorkItem, vulnChan chan<- *osvschema.Vulnerability) { @@ -367,6 +367,7 @@ func main() { // combineTwoOSVRecords takes two osv records and combines them into one func combineTwoOSVRecords(cve5 *osvschema.Vulnerability, nvd *osvschema.Vulnerability) *osvschema.Vulnerability { baseOSV := cve5 + if baseOSV.GetDetails() == "" && nvd.GetDetails() != "" { baseOSV.Details = nvd.GetDetails() } diff --git a/vulnfeeds/cmd/combine-to-osv/main_test.go b/vulnfeeds/cmd/combine-to-osv/main_test.go index bd6bb3e95b4..13a1ef234cb 100644 --- a/vulnfeeds/cmd/combine-to-osv/main_test.go +++ b/vulnfeeds/cmd/combine-to-osv/main_test.go @@ -1075,6 +1075,88 @@ func TestCombineTwoOSVRecords(t *testing.T) { } } +func TestCombineIntoOSV(t *testing.T) { + cve5WithdrawnTime, _ := time.Parse(time.RFC3339, "2023-01-01T12:00:00Z") + nvdWithdrawnTime, _ := time.Parse(time.RFC3339, "2023-01-02T12:00:00Z") + + validCVE5 := &osvschema.Vulnerability{Id: "CVE-2023-1234", Details: "CVE5 Details"} + validNVD := &osvschema.Vulnerability{Id: "CVE-2023-1234", Details: "NVD Details"} + + withdrawnCVE5 := &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(cve5WithdrawnTime), + } + withdrawnNVD := &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(nvdWithdrawnTime), + } + + tests := []struct { + name string + cve5 *osvschema.Vulnerability + nvd *osvschema.Vulnerability + want *osvschema.Vulnerability + }{ + { + name: "CVE5 withdrawn, NVD valid", + cve5: withdrawnCVE5, + nvd: validNVD, + want: nil, + }, + { + name: "CVE5 valid, NVD withdrawn", + cve5: validCVE5, + nvd: withdrawnNVD, + want: nil, + }, + { + name: "Both withdrawn", + cve5: withdrawnCVE5, + nvd: withdrawnNVD, + want: nil, + }, + { + name: "CVE5 withdrawn, NVD nil", + cve5: withdrawnCVE5, + nvd: nil, + want: nil, + }, + { + name: "CVE5 nil, NVD withdrawn", + cve5: nil, + nvd: withdrawnNVD, + want: nil, + }, + { + name: "CVE5 valid, NVD nil", + cve5: validCVE5, + nvd: nil, + want: validCVE5, + }, + { + name: "CVE5 nil, NVD valid", + cve5: nil, + nvd: validNVD, + want: validNVD, + }, + { + name: "Both nil", + cve5: nil, + nvd: nil, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := combineIntoOSV(tc.cve5, tc.nvd) + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("combineIntoOSV() mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestCombineTwoOSVRecords_ReferencesDeterminism(t *testing.T) { cve5 := &osvschema.Vulnerability{ Id: "CVE-2023-1234", diff --git a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go index ef1ac152fb9..5cecbbbe0d5 100644 --- a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go +++ b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go @@ -159,7 +159,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan string, gcsHelper *gcs.Helper, outDi continue } - if slices.Contains(cnas, cve.Metadata.AssignerShortName) || cve.Metadata.State != "PUBLISHED" { + if slices.Contains(cnas, cve.Metadata.AssignerShortName) || (cve.Metadata.State != "PUBLISHED" && cve.Metadata.State != "REJECTED") { continue } cveID := cve.Metadata.CVEID @@ -179,10 +179,14 @@ func worker(wg *sync.WaitGroup, jobs <-chan string, gcsHelper *gcs.Helper, outDi if metrics.Outcome == models.Successful { successfulConversionsCount.Add(1) } - if rejectFailed && metrics.Outcome != models.Successful { + if !metrics.Outcome.ShouldEmit(rejectFailed) { logger.Info("Rejecting failed OSV record", slog.String("cve", string(cveID)), slog.String("outcome", metrics.Outcome.String())) } else { - logger.Info("Queueing OSV record for "+string(cveID), slog.String("cve", string(cveID))) + if metrics.Outcome == models.Rejected { + logger.Info("Queueing withdrawn OSV record for "+string(cveID), slog.String("cve", string(cveID))) + } else { + logger.Info("Queueing OSV record for "+string(cveID), slog.String("cve", string(cveID))) + } if err := writer.UploadVulnIfChangedAsync(gcsHelper, *gcsPrefix, vuln.Vulnerability); err != nil { logger.Error("Failed to queue vulnerability upload", slog.String("cve", string(cveID)), slog.Any("err", err)) } @@ -216,12 +220,16 @@ func worker(wg *sync.WaitGroup, jobs <-chan string, gcsHelper *gcs.Helper, outDi if metrics.Outcome == models.Successful { successfulConversionsCount.Add(1) } - if rejectFailed && metrics.Outcome != models.Successful { + if !metrics.Outcome.ShouldEmit(rejectFailed) { logger.Info("Rejecting failed OSV record", slog.String("cve", string(cveID)), slog.String("outcome", metrics.Outcome.String())) osvFile.Close() os.Remove(osvFile.Name()) } else { - logger.Info("Generated OSV record for "+string(cveID), slog.String("cve", string(cveID)), slog.String("cna", cve.Metadata.AssignerShortName), slog.String("outcome", metrics.Outcome.String())) + if metrics.Outcome == models.Rejected { + logger.Info("Generated withdrawn OSV record for "+string(cveID), slog.String("cve", string(cveID)), slog.String("cna", cve.Metadata.AssignerShortName)) + } else { + logger.Info("Generated OSV record for "+string(cveID), slog.String("cve", string(cveID)), slog.String("cna", cve.Metadata.AssignerShortName), slog.String("outcome", metrics.Outcome.String())) + } } } diff --git a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go index e7215244ad2..4f0f5c3886c 100644 --- a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go +++ b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go @@ -215,14 +215,18 @@ func worker(wg *sync.WaitGroup, jobs <-chan models.NVDCVE, gcsHelper *gcs.Helper continue // Don't attempt to output files if there was an error } - if outcome != models.Successful { - logger.Info("Failed to generate a successful OSV record", slog.String("cve", cveID), slog.String("outcome", outcome.String())) - if *rejectFailed { - continue // Skip outputting OSV file - } - } else { + if outcome == models.Successful { logger.Info("Generated OSV record for "+cveID, slog.String("cve", cveID)) successfulConversionsCount.Add(1) + } else { + if outcome == models.Rejected { + logger.Info("Generated withdrawn OSV record for "+cveID, slog.String("cve", cveID)) + } else { + logger.Info("Failed to generate a successful OSV record", slog.String("cve", cveID), slog.String("outcome", outcome.String())) + } + if !outcome.ShouldEmit(*rejectFailed) { + continue // Skip outputting OSV file + } } // Extract year from CVE ID to organize local outputs into subfolders diff --git a/vulnfeeds/conversion/cve5/converter.go b/vulnfeeds/conversion/cve5/converter.go index e1fbb0b22e2..3998695c961 100644 --- a/vulnfeeds/conversion/cve5/converter.go +++ b/vulnfeeds/conversion/cve5/converter.go @@ -70,7 +70,25 @@ func getCWEs(cna models.CNA, metrics *models.ConversionMetrics) []string { // It populates the main fields of the OSV record, including ID, summary, details, // references, timestamps, severity, and version information. func FromCVE5(cve models.CVE5, refs []models.Reference, metrics *models.ConversionMetrics, sourceLink string) *vulns.Vulnerability { + published, err := models.ParseCVE5Timestamp(cve.Metadata.DatePublished) + if err != nil { + metrics.AddNote("[%s]: Published date failed to parse, falling back to Epoch", cve.Metadata.CVEID) + published = time.Unix(0, 0).UTC() + } + + modified, err := models.ParseCVE5Timestamp(cve.Metadata.DateUpdated) + if err != nil { + metrics.AddNote("[%s]: Modified date failed to parse, falling back to Published time", cve.Metadata.CVEID) + modified = published + } + + var withdrawnTime *timestamppb.Timestamp + if cve.Metadata.State == "REJECTED" { + withdrawnTime = timestamppb.New(modified) + } + aliases, related := vulns.ExtractReferencedVulns(cve.Metadata.CVEID, cve.Metadata.CVEID, refs) + v := vulns.Vulnerability{ Vulnerability: &osvschema.Vulnerability{ SchemaVersion: osvconstants.SchemaVersion, @@ -80,22 +98,11 @@ func FromCVE5(cve models.CVE5, refs []models.Reference, metrics *models.Conversi Aliases: aliases, Related: related, References: vulns.ClassifyReferences(refs), + Withdrawn: withdrawnTime, + Published: timestamppb.New(published), + Modified: timestamppb.New(modified), }} - published, err := models.ParseCVE5Timestamp(cve.Metadata.DatePublished) - if err != nil { - metrics.AddNote("[%s]: Published date failed to parse, setting time to now", cve.Metadata.CVEID) - published = time.Now() - } - v.Published = timestamppb.New(published) - - modified, err := models.ParseCVE5Timestamp(cve.Metadata.DateUpdated) - if err != nil { - metrics.AddNote("[%s]: Modified date failed to parse, setting time to now", cve.Metadata.CVEID) - modified = time.Now() - } - v.Modified = timestamppb.New(modified) - // Try to extract repository URLs from references. repos := conversion.ReposFromReferencesCVEList(refs, models.RefTagDenyList, metrics) metrics.Repos = repos @@ -170,6 +177,10 @@ func CVEToOSV(cve models.CVE5, sourceLink string) (*vulns.Vulnerability, *models models.DetermineOutcome(&metrics) + if cve.Metadata.State == "REJECTED" { + metrics.Outcome = models.Rejected + } + return v, &metrics } diff --git a/vulnfeeds/conversion/cve5/converter_test.go b/vulnfeeds/conversion/cve5/converter_test.go index b1d81744217..165f7294f71 100644 --- a/vulnfeeds/conversion/cve5/converter_test.go +++ b/vulnfeeds/conversion/cve5/converter_test.go @@ -209,6 +209,32 @@ func TestFromCVE5(t *testing.T) { }, }, }, + { + name: "rejected record", + cve: models.CVE5{ + Metadata: models.CVE5Metadata{ + CVEID: "CVE-2025-8888", + State: "REJECTED", + DatePublished: "2025-05-04T07:20:46.575Z", + DateUpdated: "2025-05-04T07:20:46.575Z", + }, + }, + refs: []models.Reference{}, + expectedVuln: &vulns.Vulnerability{ + Vulnerability: &osvschema.Vulnerability{ + Id: "CVE-2025-8888", + SchemaVersion: "1.7.5", + Published: timestamppb.New(cvePlaceholder), + Modified: timestamppb.New(cvePlaceholder), + Withdrawn: timestamppb.New(cvePlaceholder), + DatabaseSpecific: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "osv_generated_from": structpb.NewStringValue("unknown"), + }, + }, + }, + }, + }, { name: "CVE-2025-1110", cve: loadTestData(t, "CVE-2025-1110"), diff --git a/vulnfeeds/conversion/nvd/converter.go b/vulnfeeds/conversion/nvd/converter.go index 3b23ffc3c26..7852bb375ed 100644 --- a/vulnfeeds/conversion/nvd/converter.go +++ b/vulnfeeds/conversion/nvd/converter.go @@ -29,6 +29,17 @@ func CVEToOSV(cve models.NVDCVE, repos []string, vpRepoCache *c.VPRepoCache, cac refs := c.DeduplicateRefs(cve.References) // The vendor name and product name are used to construct the output `vulnDir` below, so need to be set to *something* to keep the output tidy. + if cve.VulnStatus != nil && *cve.VulnStatus == "Rejected" { + metrics.SetOutcome(models.Rejected) + v := vulns.FromNVDCVE(cve.ID, cve) + databaseSpecific, err := utility.NewStructpbFromMap(make(map[string]any)) + if err == nil { + v.DatabaseSpecific = databaseSpecific + } + + return v, metrics, models.Rejected + } + if len(CPEs) > 0 { _, err := c.ParseCPE(CPEs[0]) // For naming the subdirectory used for output. if err != nil { diff --git a/vulnfeeds/conversion/nvd/converter_test.go b/vulnfeeds/conversion/nvd/converter_test.go index 82275c18ea8..643f2ea2ccd 100644 --- a/vulnfeeds/conversion/nvd/converter_test.go +++ b/vulnfeeds/conversion/nvd/converter_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "testing" + "time" "github.com/gkampitakis/go-snaps/snaps" "github.com/go-git/go-git/v5/plumbing/transport/client" @@ -97,6 +98,37 @@ func TestCVEToOSV_429(t *testing.T) { } } +func TestCVEToOSV_Rejected(t *testing.T) { + cve := models.NVDCVE{ + ID: "CVE-2025-54321", + VulnStatus: func() *string { + s := "Rejected" + return &s + }(), + LastModified: models.NVDTime{Time: func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-06-01T12:00:00Z") + return t + }()}, + } + + metrics := &models.ConversionMetrics{} + cache := &git.InMemoryRepoTagsCache{} + + vuln, _, outcome := CVEToOSV(cve, nil, nil, cache, metrics) + + if outcome != models.Rejected { + t.Errorf("Expected outcome models.Rejected, got %v", outcome) + } + + if vuln == nil { + t.Fatal("Expected non-nil vulnerability for rejected CVE") + } + + if vuln.GetWithdrawn() == nil { + t.Error("Expected Withdrawn timestamp to be set for rejected CVE") + } +} + func TestCVEToOSV_ReferencesDeterminism(t *testing.T) { cve := models.NVDCVE{ ID: "CVE-2025-12345", diff --git a/vulnfeeds/conversion/writer/writer.go b/vulnfeeds/conversion/writer/writer.go index a1b835a12cb..c9e59c80f17 100644 --- a/vulnfeeds/conversion/writer/writer.go +++ b/vulnfeeds/conversion/writer/writer.go @@ -41,6 +41,7 @@ var ErrUploadSkipped = errors.New("upload skipped") // writeToDisk writes the vulnerability to a local file. // It returns an error if the file could not be written. +// Writes out withdrawn records regardless of whether they don't already exist. func writeToDisk(v *osvschema.Vulnerability, preModifiedBuf []byte, outputPrefix string) error { filename := v.GetId() + ".json" filePath := path.Join(outputPrefix, filename) @@ -173,7 +174,7 @@ func handleOverride(ctx context.Context, v *osvschema.Vulnerability, overridesBk func VulnWorker(ctx context.Context, vulnChan <-chan *osvschema.Vulnerability, outBkt, overridesBkt *storage.BucketHandle, gcsHelper *gcs.Helper, outputPrefix string, counter *atomic.Uint64) { for v := range vulnChan { vulnID := v.GetId() - if len(v.GetAffected()) == 0 { + if len(v.GetAffected()) == 0 && v.GetWithdrawn() == nil { logger.Warn("Skipping OSV record as no affected versions found.", slog.String("id", vulnID)) continue } diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index d1334f20bcd..72c99c83e40 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -50,6 +50,15 @@ func (c ConversionOutcome) String() string { return conversionOutcomeStrings[ConversionUnknown] } +// ShouldEmit returns true if the record should be emitted based on its outcome and the rejectFailed flag. +func (c ConversionOutcome) ShouldEmit(rejectFailed bool) bool { + if !rejectFailed { + return true + } + + return c == Successful || c == Rejected +} + func (c ConversionOutcome) MarshalJSON() ([]byte, error) { return json.Marshal(c.String()) } diff --git a/vulnfeeds/vulns/vulns.go b/vulnfeeds/vulns/vulns.go index 16515a5ddef..3d096073657 100644 --- a/vulnfeeds/vulns/vulns.go +++ b/vulnfeeds/vulns/vulns.go @@ -377,6 +377,9 @@ func getBestSeverity(metricsData *models.CVEItemMetrics) (string, string) { // AddSeverity adds CVSS severity information to the OSV vulnerability object. // It uses the highest available CVSS score from the underlying CVE record. func (v *Vulnerability) AddSeverity(metricsData *models.CVEItemMetrics) { + if metricsData == nil { + return + } bestVectorString, severityType := getBestSeverity(metricsData) if bestVectorString == "" { @@ -756,6 +759,11 @@ func ClassifyReferences(refs []models.Reference) []*osvschema.Reference { // and the ExtractReferencedVulns function uses these in a check to add the other ID as an alias. func FromNVDCVE(id models.CVEID, cve models.NVDCVE) *Vulnerability { aliases, related := ExtractReferencedVulns(id, cve.ID, cve.References) + var withdrawnTime *timestamppb.Timestamp + if cve.VulnStatus != nil && *cve.VulnStatus == "Rejected" { + withdrawnTime = timestamppb.New(cve.LastModified.Time) + } + v := &Vulnerability{ Vulnerability: &osvschema.Vulnerability{ Id: string(id), @@ -765,6 +773,7 @@ func FromNVDCVE(id models.CVEID, cve models.NVDCVE) *Vulnerability { Published: timestamppb.New(cve.Published.Time), Modified: timestamppb.New(cve.LastModified.Time), References: ClassifyReferences(cve.References), + Withdrawn: withdrawnTime, }, } v.AddSeverity(cve.Metrics)