From 8c00024c80b5fe7e9f8d893819837824e5a3622e Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 01:53:26 +0000 Subject: [PATCH 1/9] set withdrawn on nvd converted records --- vulnfeeds/vulns/vulns.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vulnfeeds/vulns/vulns.go b/vulnfeeds/vulns/vulns.go index 16515a5ddef..29f80ee3443 100644 --- a/vulnfeeds/vulns/vulns.go +++ b/vulnfeeds/vulns/vulns.go @@ -756,6 +756,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 +770,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) From a4b2988e1d142a543d92a0ebe94b442bb78ce570 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 01:54:51 +0000 Subject: [PATCH 2/9] mark CVE5 records as withdrawn --- vulnfeeds/conversion/cve5/converter.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/vulnfeeds/conversion/cve5/converter.go b/vulnfeeds/conversion/cve5/converter.go index e1fbb0b22e2..4cbd3334384 100644 --- a/vulnfeeds/conversion/cve5/converter.go +++ b/vulnfeeds/conversion/cve5/converter.go @@ -70,7 +70,18 @@ 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 { + var withdrawnTime *timestamppb.Timestamp + if cve.Metadata.State == "REJECTED" { + withdrawn, err := models.ParseCVE5Timestamp(cve.Metadata.DateUpdated) + if err != nil { + metrics.AddNote("[%s]: Withdrawn date failed to parse, setting time to now", cve.Metadata.CVEID) + withdrawn = time.Now() + } + withdrawnTime = timestamppb.New(withdrawn) + } + aliases, related := vulns.ExtractReferencedVulns(cve.Metadata.CVEID, cve.Metadata.CVEID, refs) + v := vulns.Vulnerability{ Vulnerability: &osvschema.Vulnerability{ SchemaVersion: osvconstants.SchemaVersion, @@ -80,6 +91,7 @@ func FromCVE5(cve models.CVE5, refs []models.Reference, metrics *models.Conversi Aliases: aliases, Related: related, References: vulns.ClassifyReferences(refs), + Withdrawn: withdrawnTime, }} published, err := models.ParseCVE5Timestamp(cve.Metadata.DatePublished) @@ -170,6 +182,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 } From f7c8eb61d57c2a57d46739159c014295c7287b95 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 01:55:28 +0000 Subject: [PATCH 3/9] set record for deletion if withdrawn in combine to osv --- vulnfeeds/cmd/combine-to-osv/main.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/vulnfeeds/cmd/combine-to-osv/main.go b/vulnfeeds/cmd/combine-to-osv/main.go index 8992181c5bd..0243015f6f4 100644 --- a/vulnfeeds/cmd/combine-to-osv/main.go +++ b/vulnfeeds/cmd/combine-to-osv/main.go @@ -136,18 +136,21 @@ 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 != nil && cve5.Withdrawn != nil) || (nvd != nil && nvd.Withdrawn != nil) { return nil } - return baseOSV + if cve5 != nil && nvd != nil { + return combineTwoOSVRecords(cve5, nvd) + } + if cve5 != nil { + return cve5 + } + if nvd != nil { + return nvd + } + + return nil } func readAndCombineWorker(ctx context.Context, client *storage.Client, workChan <-chan *CVEWorkItem, vulnChan chan<- *osvschema.Vulnerability) { @@ -367,6 +370,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() } From 010622399bc5b4ae4d4af84ac10a6f068920c614 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 01:55:39 +0000 Subject: [PATCH 4/9] test combine --- vulnfeeds/cmd/combine-to-osv/main_test.go | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/vulnfeeds/cmd/combine-to-osv/main_test.go b/vulnfeeds/cmd/combine-to-osv/main_test.go index bd6bb3e95b4..d9e45470e95 100644 --- a/vulnfeeds/cmd/combine-to-osv/main_test.go +++ b/vulnfeeds/cmd/combine-to-osv/main_test.go @@ -1075,6 +1075,58 @@ func TestCombineTwoOSVRecords(t *testing.T) { } } +func TestCombineIntoOSV_Withdrawn(t *testing.T) { + cve5WithdrawnTime, _ := time.Parse(time.RFC3339, "2023-01-01T12:00:00Z") + nvdWithdrawnTime, _ := time.Parse(time.RFC3339, "2023-01-02T12:00:00Z") + + tests := []struct { + name string + cve5 *osvschema.Vulnerability + nvd *osvschema.Vulnerability + }{ + { + name: "CVE5 withdrawn, NVD not", + cve5: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(cve5WithdrawnTime), + }, + nvd: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + }, + }, + { + name: "CVE5 not withdrawn, NVD withdrawn", + cve5: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + }, + nvd: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(nvdWithdrawnTime), + }, + }, + { + name: "Both withdrawn", + cve5: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(cve5WithdrawnTime), + }, + nvd: &osvschema.Vulnerability{ + Id: "CVE-2023-1234", + Withdrawn: timestamppb.New(nvdWithdrawnTime), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := combineIntoOSV(tc.cve5, tc.nvd) + if got != nil { + t.Errorf("combineIntoOSV() expected nil for withdrawn records, got %v", got) + } + }) + } +} + func TestCombineTwoOSVRecords_ReferencesDeterminism(t *testing.T) { cve5 := &osvschema.Vulnerability{ Id: "CVE-2023-1234", From 14b88f94e5e6a97ee0b0c4721348783e10257118 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 03:04:47 +0000 Subject: [PATCH 5/9] add flag for withdrawn records to not upload if it doesnt exist upstream --- .../cve/cve5/bulk-converter/main.go | 4 +-- .../cmd/converters/cve/nvd-cve-osv/main.go | 4 +-- vulnfeeds/conversion/nvd/converter.go | 10 ++++++ vulnfeeds/conversion/writer/writer.go | 22 ++++++++++--- vulnfeeds/conversion/writer/writer_test.go | 2 +- vulnfeeds/gcs-tools/gcs.go | 31 ++++++++++++------- 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go index 365470e71ce..29fd9485558 100644 --- a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go +++ b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go @@ -131,7 +131,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 @@ -155,7 +155,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan string, gcsHelper *gcs.Helper, outDi logger.Error("Failed to queue vulnerability upload", slog.String("cve", string(cveID)), slog.Any("err", err)) } - if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, cveID, metrics); err != nil { + if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, cveID, metrics, vuln.Withdrawn != nil); err != nil { logger.Error("Failed to queue metrics upload", slog.String("cve", string(cveID)), slog.Any("err", err)) } } diff --git a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go index 6301252985e..1461eb4cb2d 100644 --- a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go +++ b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go @@ -217,7 +217,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan models.NVDCVE, gcsHelper *gcs.Helper if outcome != models.Successful { logger.Info("Failed to generate a successful OSV record", slog.String("cve", cveID), slog.String("outcome", outcome.String())) - if *rejectFailed { + if *rejectFailed && (vuln == nil || vuln.Withdrawn == nil) { continue // Skip outputting OSV file } } else { @@ -248,7 +248,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan models.NVDCVE, gcsHelper *gcs.Helper } } if *outputMetrics && metrics != nil { - if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, models.CVEID(cveID), metrics); err != nil { + if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, models.CVEID(cveID), metrics, vuln != nil && vuln.Withdrawn != nil); err != nil { logger.Error("Failed to queue metrics upload", slog.String("cve", cveID), slog.Any("err", err)) } } diff --git a/vulnfeeds/conversion/nvd/converter.go b/vulnfeeds/conversion/nvd/converter.go index 3b23ffc3c26..a50083b6bd3 100644 --- a/vulnfeeds/conversion/nvd/converter.go +++ b/vulnfeeds/conversion/nvd/converter.go @@ -29,6 +29,16 @@ 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/writer/writer.go b/vulnfeeds/conversion/writer/writer.go index a1b835a12cb..66b6be7c769 100644 --- a/vulnfeeds/conversion/writer/writer.go +++ b/vulnfeeds/conversion/writer/writer.go @@ -39,8 +39,13 @@ const ( // the vulnerability payload is unchanged. var ErrUploadSkipped = errors.New("upload skipped") +// ErrWithdrawnSkipped indicates that an upload/write was skipped because +// the vulnerability is withdrawn and does not already exist. +var ErrWithdrawnSkipped = errors.New("withdrawn 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) @@ -94,7 +99,11 @@ func uploadIfChanged(ctx context.Context, v *osvschema.Vulnerability, hexHash st if attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == hexHash { return ErrUploadSkipped } - } else if !errors.Is(err, storage.ErrObjectNotExist) { + } else if errors.Is(err, storage.ErrObjectNotExist) { + if v.Withdrawn != nil { + return ErrWithdrawnSkipped + } + } else { return fmt.Errorf("failed to get object attributes for %s: %w", vulnID, err) } @@ -173,7 +182,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.Withdrawn == nil { logger.Warn("Skipping OSV record as no affected versions found.", slog.String("id", vulnID)) continue } @@ -229,6 +238,8 @@ func VulnWorker(ctx context.Context, vulnChan <-chan *osvschema.Vulnerability, o } } else if errors.Is(writeErr, ErrUploadSkipped) { logger.Info("Skipping GCS upload, hash matches", slog.String("id", vulnID)) + } else if errors.Is(writeErr, ErrWithdrawnSkipped) { + logger.Info("Skipping withdrawn record, does not exist", slog.String("id", vulnID)) } else { if outBkt == nil && gcsHelper == nil { logger.Error("Failed to write to disk", slog.String("id", vulnID), slog.Any("err", writeErr)) @@ -396,13 +407,14 @@ func UploadVulnIfChangedAsync(gcsHelper *gcs.Helper, prefix string, vuln *osvsch } objectName := path.Join(prefix, vuln.GetId()+".json") - gcsHelper.Upload(objectName, bytes.NewReader(postModifiedBuf), hexHash, "application/json") + skipIfNotExist := vuln.Withdrawn != nil + gcsHelper.Upload(objectName, bytes.NewReader(postModifiedBuf), hexHash, "application/json", skipIfNotExist) return nil } // UploadMetricsToGCSAsync marshals ConversionMetrics to JSON and schedules it for upload via the Helper pool. -func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models.CVEID, metrics *models.ConversionMetrics) error { +func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models.CVEID, metrics *models.ConversionMetrics, skipIfNotExist bool) error { if metrics == nil || cveID == "" { return errors.New("invalid metrics or CVE ID provided") } @@ -415,7 +427,7 @@ func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models. objectName := path.Join(prefix, string(cveID)+".metrics.json") reader := bytes.NewReader(data) - gcsHelper.Upload(objectName, reader, "", "application/json") + gcsHelper.Upload(objectName, reader, "", "application/json", skipIfNotExist) return nil } diff --git a/vulnfeeds/conversion/writer/writer_test.go b/vulnfeeds/conversion/writer/writer_test.go index 2eb29d2aa4e..e6b688adec0 100644 --- a/vulnfeeds/conversion/writer/writer_test.go +++ b/vulnfeeds/conversion/writer/writer_test.go @@ -424,7 +424,7 @@ func TestUploadMetricsToGCSAsync(t *testing.T) { CNA: "nvd", } - err = UploadMetricsToGCSAsync(gcsHelper, "nvd-prefix", "CVE-2023-9999", metrics) + err = UploadMetricsToGCSAsync(gcsHelper, "nvd-prefix", "CVE-2023-9999", metrics, false) if err != nil { t.Fatalf("Expected UploadMetricsToGCSAsync to succeed, got %v", err) } diff --git a/vulnfeeds/gcs-tools/gcs.go b/vulnfeeds/gcs-tools/gcs.go index a25634a195c..d5feb0ed9fe 100644 --- a/vulnfeeds/gcs-tools/gcs.go +++ b/vulnfeeds/gcs-tools/gcs.go @@ -34,10 +34,11 @@ type Helper struct { } type uploadMsg struct { - objectName string - data io.Reader - contentType string - hash string // if hash is empty, always upload + objectName string + data io.Reader + contentType string + hash string // if hash is empty, always upload + skipIfNotExist bool } func InitUploadPool(ctx context.Context, workers int, bktName string) (*Helper, error) { @@ -67,14 +68,19 @@ func bucketWorker(ctx context.Context, gcsHelper *Helper) { if closer, ok := msg.data.(io.Closer); ok { defer closer.Close() } - if msg.hash != "" { + if msg.hash != "" || msg.skipIfNotExist { attrs, err := gcsHelper.bkt.Object(msg.objectName).Attrs(ctx) if err == nil { - if attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == msg.hash { + if msg.hash != "" && attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == msg.hash { logger.Info("Skipping GCS upload, hash matches", slog.String("id", msg.objectName)) return } - } else if !errors.Is(err, storage.ErrObjectNotExist) { + } else if errors.Is(err, storage.ErrObjectNotExist) { + if msg.skipIfNotExist { + logger.Info("Skipping GCS upload, object does not exist and skipIfNotExist is true", slog.String("id", msg.objectName)) + return + } + } else { logger.Info("Failed to get object attributes", slog.String("object", msg.objectName), slog.String("error", err.Error())) return } @@ -93,15 +99,16 @@ func bucketWorker(ctx context.Context, gcsHelper *Helper) { } } -func (g *Helper) Upload(objectName string, data io.Reader, hash string, contentType string) { +func (g *Helper) Upload(objectName string, data io.Reader, hash string, contentType string, skipIfNotExist bool) { if len(g.bus) == cap(g.bus) { g.timesBlocked.Add(1) } g.bus <- &uploadMsg{ - objectName: objectName, - data: data, - hash: hash, - contentType: contentType, + objectName: objectName, + data: data, + hash: hash, + contentType: contentType, + skipIfNotExist: skipIfNotExist, } } From 45e5fc7ee823341d922b56c69b10e5df68eb7fa8 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 3 Aug 2026 00:45:38 +0000 Subject: [PATCH 6/9] fix lint --- vulnfeeds/cmd/combine-to-osv/main.go | 2 +- vulnfeeds/conversion/nvd/converter.go | 1 + vulnfeeds/conversion/writer/writer.go | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/vulnfeeds/cmd/combine-to-osv/main.go b/vulnfeeds/cmd/combine-to-osv/main.go index 0243015f6f4..fc60b2f3a27 100644 --- a/vulnfeeds/cmd/combine-to-osv/main.go +++ b/vulnfeeds/cmd/combine-to-osv/main.go @@ -136,7 +136,7 @@ func readVulnerability(ctx context.Context, client *storage.Client, fullPath str } func combineIntoOSV(cve5 *osvschema.Vulnerability, nvd *osvschema.Vulnerability) *osvschema.Vulnerability { - if (cve5 != nil && cve5.Withdrawn != nil) || (nvd != nil && nvd.Withdrawn != nil) { + if (cve5 != nil && cve5.GetWithdrawn() != nil) || (nvd != nil && nvd.GetWithdrawn() != nil) { return nil } diff --git a/vulnfeeds/conversion/nvd/converter.go b/vulnfeeds/conversion/nvd/converter.go index a50083b6bd3..7852bb375ed 100644 --- a/vulnfeeds/conversion/nvd/converter.go +++ b/vulnfeeds/conversion/nvd/converter.go @@ -36,6 +36,7 @@ func CVEToOSV(cve models.NVDCVE, repos []string, vpRepoCache *c.VPRepoCache, cac if err == nil { v.DatabaseSpecific = databaseSpecific } + return v, metrics, models.Rejected } diff --git a/vulnfeeds/conversion/writer/writer.go b/vulnfeeds/conversion/writer/writer.go index 66b6be7c769..e04d9724701 100644 --- a/vulnfeeds/conversion/writer/writer.go +++ b/vulnfeeds/conversion/writer/writer.go @@ -100,7 +100,7 @@ func uploadIfChanged(ctx context.Context, v *osvschema.Vulnerability, hexHash st return ErrUploadSkipped } } else if errors.Is(err, storage.ErrObjectNotExist) { - if v.Withdrawn != nil { + if v.GetWithdrawn() != nil { return ErrWithdrawnSkipped } } else { @@ -182,7 +182,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 && v.Withdrawn == nil { + if len(v.GetAffected()) == 0 && v.GetWithdrawn() == nil { logger.Warn("Skipping OSV record as no affected versions found.", slog.String("id", vulnID)) continue } @@ -407,7 +407,7 @@ func UploadVulnIfChangedAsync(gcsHelper *gcs.Helper, prefix string, vuln *osvsch } objectName := path.Join(prefix, vuln.GetId()+".json") - skipIfNotExist := vuln.Withdrawn != nil + skipIfNotExist := vuln.GetWithdrawn() != nil gcsHelper.Upload(objectName, bytes.NewReader(postModifiedBuf), hexHash, "application/json", skipIfNotExist) return nil From 4ef89708bf64881ec85d1a0a5286f816762e9e01 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 3 Aug 2026 06:28:10 +0000 Subject: [PATCH 7/9] add tombstone records and address nits --- vulnfeeds/cmd/combine-to-osv/main.go | 7 +- vulnfeeds/cmd/combine-to-osv/main_test.go | 84 +++++++++++++------ .../cve/cve5/bulk-converter/main.go | 2 +- .../cmd/converters/cve/nvd-cve-osv/main.go | 4 +- vulnfeeds/conversion/writer/writer.go | 19 +---- vulnfeeds/conversion/writer/writer_test.go | 2 +- vulnfeeds/gcs-tools/gcs.go | 31 +++---- 7 files changed, 79 insertions(+), 70 deletions(-) diff --git a/vulnfeeds/cmd/combine-to-osv/main.go b/vulnfeeds/cmd/combine-to-osv/main.go index fc60b2f3a27..5f4f71eb42b 100644 --- a/vulnfeeds/cmd/combine-to-osv/main.go +++ b/vulnfeeds/cmd/combine-to-osv/main.go @@ -136,7 +136,7 @@ func readVulnerability(ctx context.Context, client *storage.Client, fullPath str } func combineIntoOSV(cve5 *osvschema.Vulnerability, nvd *osvschema.Vulnerability) *osvschema.Vulnerability { - if (cve5 != nil && cve5.GetWithdrawn() != nil) || (nvd != nil && nvd.GetWithdrawn() != nil) { + if (cve5.GetWithdrawn() != nil) || (nvd.GetWithdrawn() != nil) { return nil } @@ -146,11 +146,8 @@ func combineIntoOSV(cve5 *osvschema.Vulnerability, nvd *osvschema.Vulnerability) if cve5 != nil { return cve5 } - if nvd != nil { - return nvd - } - return nil + return nvd } func readAndCombineWorker(ctx context.Context, client *storage.Client, workChan <-chan *CVEWorkItem, vulnChan chan<- *osvschema.Vulnerability) { diff --git a/vulnfeeds/cmd/combine-to-osv/main_test.go b/vulnfeeds/cmd/combine-to-osv/main_test.go index d9e45470e95..13a1ef234cb 100644 --- a/vulnfeeds/cmd/combine-to-osv/main_test.go +++ b/vulnfeeds/cmd/combine-to-osv/main_test.go @@ -1075,53 +1075,83 @@ func TestCombineTwoOSVRecords(t *testing.T) { } } -func TestCombineIntoOSV_Withdrawn(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 not", - cve5: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - Withdrawn: timestamppb.New(cve5WithdrawnTime), - }, - nvd: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - }, + name: "CVE5 withdrawn, NVD valid", + cve5: withdrawnCVE5, + nvd: validNVD, + want: nil, }, { - name: "CVE5 not withdrawn, NVD withdrawn", - cve5: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - }, - nvd: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - Withdrawn: timestamppb.New(nvdWithdrawnTime), - }, + name: "CVE5 valid, NVD withdrawn", + cve5: validCVE5, + nvd: withdrawnNVD, + want: nil, }, { name: "Both withdrawn", - cve5: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - Withdrawn: timestamppb.New(cve5WithdrawnTime), - }, - nvd: &osvschema.Vulnerability{ - Id: "CVE-2023-1234", - Withdrawn: timestamppb.New(nvdWithdrawnTime), - }, + 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 got != nil { - t.Errorf("combineIntoOSV() expected nil for withdrawn records, got %v", got) + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("combineIntoOSV() mismatch (-want +got):\n%s", diff) } }) } diff --git a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go index 75055ea8ea2..2a38ffbe1f1 100644 --- a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go +++ b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go @@ -187,7 +187,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan string, gcsHelper *gcs.Helper, outDi logger.Error("Failed to queue vulnerability upload", slog.String("cve", string(cveID)), slog.Any("err", err)) } - if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, cveID, metrics, vuln.Withdrawn != nil); err != nil { + if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, cveID, metrics); err != nil { logger.Error("Failed to queue metrics upload", slog.String("cve", string(cveID)), slog.Any("err", err)) } } diff --git a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go index 75dc21e3135..770325721d1 100644 --- a/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go +++ b/vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go @@ -217,7 +217,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan models.NVDCVE, gcsHelper *gcs.Helper if outcome != models.Successful { logger.Info("Failed to generate a successful OSV record", slog.String("cve", cveID), slog.String("outcome", outcome.String())) - if *rejectFailed && (vuln == nil || vuln.Withdrawn == nil) { + if *rejectFailed && vuln.GetWithdrawn() == nil { continue // Skip outputting OSV file } } else { @@ -248,7 +248,7 @@ func worker(wg *sync.WaitGroup, jobs <-chan models.NVDCVE, gcsHelper *gcs.Helper } } if *outputMetrics && metrics != nil { - if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, models.CVEID(cveID), metrics, vuln != nil && vuln.Withdrawn != nil); err != nil { + if err := writer.UploadMetricsToGCSAsync(gcsHelper, *gcsPrefix, models.CVEID(cveID), metrics); err != nil { logger.Error("Failed to queue metrics upload", slog.String("cve", cveID), slog.Any("err", err)) } } diff --git a/vulnfeeds/conversion/writer/writer.go b/vulnfeeds/conversion/writer/writer.go index e04d9724701..c9e59c80f17 100644 --- a/vulnfeeds/conversion/writer/writer.go +++ b/vulnfeeds/conversion/writer/writer.go @@ -39,10 +39,6 @@ const ( // the vulnerability payload is unchanged. var ErrUploadSkipped = errors.New("upload skipped") -// ErrWithdrawnSkipped indicates that an upload/write was skipped because -// the vulnerability is withdrawn and does not already exist. -var ErrWithdrawnSkipped = errors.New("withdrawn 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. @@ -99,11 +95,7 @@ func uploadIfChanged(ctx context.Context, v *osvschema.Vulnerability, hexHash st if attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == hexHash { return ErrUploadSkipped } - } else if errors.Is(err, storage.ErrObjectNotExist) { - if v.GetWithdrawn() != nil { - return ErrWithdrawnSkipped - } - } else { + } else if !errors.Is(err, storage.ErrObjectNotExist) { return fmt.Errorf("failed to get object attributes for %s: %w", vulnID, err) } @@ -238,8 +230,6 @@ func VulnWorker(ctx context.Context, vulnChan <-chan *osvschema.Vulnerability, o } } else if errors.Is(writeErr, ErrUploadSkipped) { logger.Info("Skipping GCS upload, hash matches", slog.String("id", vulnID)) - } else if errors.Is(writeErr, ErrWithdrawnSkipped) { - logger.Info("Skipping withdrawn record, does not exist", slog.String("id", vulnID)) } else { if outBkt == nil && gcsHelper == nil { logger.Error("Failed to write to disk", slog.String("id", vulnID), slog.Any("err", writeErr)) @@ -407,14 +397,13 @@ func UploadVulnIfChangedAsync(gcsHelper *gcs.Helper, prefix string, vuln *osvsch } objectName := path.Join(prefix, vuln.GetId()+".json") - skipIfNotExist := vuln.GetWithdrawn() != nil - gcsHelper.Upload(objectName, bytes.NewReader(postModifiedBuf), hexHash, "application/json", skipIfNotExist) + gcsHelper.Upload(objectName, bytes.NewReader(postModifiedBuf), hexHash, "application/json") return nil } // UploadMetricsToGCSAsync marshals ConversionMetrics to JSON and schedules it for upload via the Helper pool. -func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models.CVEID, metrics *models.ConversionMetrics, skipIfNotExist bool) error { +func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models.CVEID, metrics *models.ConversionMetrics) error { if metrics == nil || cveID == "" { return errors.New("invalid metrics or CVE ID provided") } @@ -427,7 +416,7 @@ func UploadMetricsToGCSAsync(gcsHelper *gcs.Helper, prefix string, cveID models. objectName := path.Join(prefix, string(cveID)+".metrics.json") reader := bytes.NewReader(data) - gcsHelper.Upload(objectName, reader, "", "application/json", skipIfNotExist) + gcsHelper.Upload(objectName, reader, "", "application/json") return nil } diff --git a/vulnfeeds/conversion/writer/writer_test.go b/vulnfeeds/conversion/writer/writer_test.go index e6b688adec0..2eb29d2aa4e 100644 --- a/vulnfeeds/conversion/writer/writer_test.go +++ b/vulnfeeds/conversion/writer/writer_test.go @@ -424,7 +424,7 @@ func TestUploadMetricsToGCSAsync(t *testing.T) { CNA: "nvd", } - err = UploadMetricsToGCSAsync(gcsHelper, "nvd-prefix", "CVE-2023-9999", metrics, false) + err = UploadMetricsToGCSAsync(gcsHelper, "nvd-prefix", "CVE-2023-9999", metrics) if err != nil { t.Fatalf("Expected UploadMetricsToGCSAsync to succeed, got %v", err) } diff --git a/vulnfeeds/gcs-tools/gcs.go b/vulnfeeds/gcs-tools/gcs.go index d5feb0ed9fe..a25634a195c 100644 --- a/vulnfeeds/gcs-tools/gcs.go +++ b/vulnfeeds/gcs-tools/gcs.go @@ -34,11 +34,10 @@ type Helper struct { } type uploadMsg struct { - objectName string - data io.Reader - contentType string - hash string // if hash is empty, always upload - skipIfNotExist bool + objectName string + data io.Reader + contentType string + hash string // if hash is empty, always upload } func InitUploadPool(ctx context.Context, workers int, bktName string) (*Helper, error) { @@ -68,19 +67,14 @@ func bucketWorker(ctx context.Context, gcsHelper *Helper) { if closer, ok := msg.data.(io.Closer); ok { defer closer.Close() } - if msg.hash != "" || msg.skipIfNotExist { + if msg.hash != "" { attrs, err := gcsHelper.bkt.Object(msg.objectName).Attrs(ctx) if err == nil { - if msg.hash != "" && attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == msg.hash { + if attrs.Metadata != nil && attrs.Metadata[hashMetadataKey] == msg.hash { logger.Info("Skipping GCS upload, hash matches", slog.String("id", msg.objectName)) return } - } else if errors.Is(err, storage.ErrObjectNotExist) { - if msg.skipIfNotExist { - logger.Info("Skipping GCS upload, object does not exist and skipIfNotExist is true", slog.String("id", msg.objectName)) - return - } - } else { + } else if !errors.Is(err, storage.ErrObjectNotExist) { logger.Info("Failed to get object attributes", slog.String("object", msg.objectName), slog.String("error", err.Error())) return } @@ -99,16 +93,15 @@ func bucketWorker(ctx context.Context, gcsHelper *Helper) { } } -func (g *Helper) Upload(objectName string, data io.Reader, hash string, contentType string, skipIfNotExist bool) { +func (g *Helper) Upload(objectName string, data io.Reader, hash string, contentType string) { if len(g.bus) == cap(g.bus) { g.timesBlocked.Add(1) } g.bus <- &uploadMsg{ - objectName: objectName, - data: data, - hash: hash, - contentType: contentType, - skipIfNotExist: skipIfNotExist, + objectName: objectName, + data: data, + hash: hash, + contentType: contentType, } } From acdf82889bcb02c08328d0e3650bb3c099746d8a Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 3 Aug 2026 06:40:06 +0000 Subject: [PATCH 8/9] outcome check --- .../converters/cve/cve5/bulk-converter/main.go | 16 ++++++++++++---- vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.go | 16 ++++++++++------ vulnfeeds/models/metrics.go | 8 ++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go index 2a38ffbe1f1..5cecbbbe0d5 100644 --- a/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go +++ b/vulnfeeds/cmd/converters/cve/cve5/bulk-converter/main.go @@ -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 770325721d1..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 && vuln.GetWithdrawn() == nil { - 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/models/metrics.go b/vulnfeeds/models/metrics.go index d1334f20bcd..eee31841bcb 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -50,6 +50,14 @@ 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()) } From 0071352bdbf2af4a6cc3572a3e8a3b5f4954200d Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 3 Aug 2026 06:51:39 +0000 Subject: [PATCH 9/9] fall back to epoch if date parsing fails + tests --- vulnfeeds/conversion/cve5/converter.go | 35 +++++++++------------ vulnfeeds/conversion/cve5/converter_test.go | 26 +++++++++++++++ vulnfeeds/conversion/nvd/converter_test.go | 32 +++++++++++++++++++ vulnfeeds/models/metrics.go | 1 + vulnfeeds/vulns/vulns.go | 3 ++ 5 files changed, 77 insertions(+), 20 deletions(-) diff --git a/vulnfeeds/conversion/cve5/converter.go b/vulnfeeds/conversion/cve5/converter.go index 4cbd3334384..3998695c961 100644 --- a/vulnfeeds/conversion/cve5/converter.go +++ b/vulnfeeds/conversion/cve5/converter.go @@ -70,14 +70,21 @@ 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" { - withdrawn, err := models.ParseCVE5Timestamp(cve.Metadata.DateUpdated) - if err != nil { - metrics.AddNote("[%s]: Withdrawn date failed to parse, setting time to now", cve.Metadata.CVEID) - withdrawn = time.Now() - } - withdrawnTime = timestamppb.New(withdrawn) + withdrawnTime = timestamppb.New(modified) } aliases, related := vulns.ExtractReferencedVulns(cve.Metadata.CVEID, cve.Metadata.CVEID, refs) @@ -92,22 +99,10 @@ func FromCVE5(cve models.CVE5, refs []models.Reference, metrics *models.Conversi 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 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_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/models/metrics.go b/vulnfeeds/models/metrics.go index eee31841bcb..72c99c83e40 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -55,6 +55,7 @@ func (c ConversionOutcome) ShouldEmit(rejectFailed bool) bool { if !rejectFailed { return true } + return c == Successful || c == Rejected } diff --git a/vulnfeeds/vulns/vulns.go b/vulnfeeds/vulns/vulns.go index 29f80ee3443..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 == "" {