diff --git a/cmd/osv-processor/main.go b/cmd/osv-processor/main.go new file mode 100644 index 0000000000..486657823e --- /dev/null +++ b/cmd/osv-processor/main.go @@ -0,0 +1,518 @@ +package main + +import ( + "bufio" + "compress/gzip" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +type OSVData struct { + SchemaVersion string `json:"schema_version"` + ID string `json:"id"` + Published string `json:"published"` + Modified string `json:"modified"` + Details string `json:"details"` + Affected []Affected `json:"affected"` + Upstream []string `json:"upstream,omitempty"` + Related []string `json:"related,omitempty"` +} + +type Affected struct { + Package Package `json:"package"` + Ranges []Range `json:"ranges"` + Versions []string `json:"versions,omitempty"` + EcosystemSpecific map[string]any `json:"ecosystem_specific,omitempty"` + DatabaseSpecific map[string]any `json:"database_specific,omitempty"` +} + +type Package struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` + Purl string `json:"purl,omitempty"` +} + +type Range struct { + Type string `json:"type"` + Events []Event `json:"events"` +} + +type Event struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` +} + +type ProcessedVuln struct { + CVE string `json:"cve"` + Published string `json:"published"` + Modified string `json:"modified"` + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` + Versions []string `json:"versions,omitempty"` +} + +type Config struct { + InputDir string + OutputDir string + Versions string + ExcludeVersions string + ChangedFilesToday string + ChangedFilesYesterday string + DateStr string + YesterdayStr string + GeneratedTimestamp string + RunTime time.Time +} + +type ArtifactData struct { + SchemaVersion string `json:"schema_version"` + UbuntuVersion string `json:"ubuntu_version"` + Generated string `json:"generated"` + TotalCVEs int `json:"total_cves"` + TotalPackages int `json:"total_packages"` + Vulnerabilities map[string][]ProcessedVuln `json:"vulnerabilities"` +} + +func main() { + inputDir := flag.String("input", "/tmp/ubuntu-osv", "Input directory with OSV JSON files") + outputDir := flag.String("output", "./artifacts", "Output directory for artifacts") + versions := flag.String("versions", "", "Comma-separated Ubuntu versions to process (inclusive)") + excludeVersions := flag.String("exclude-versions", "", "Comma-separated Ubuntu versions to exclude (ignored if --versions is set)") + changedFilesToday := flag.String("changed-files-today", "", "Path to file containing CVE files changed today (generates today's deltas)") + changedFilesYesterday := flag.String("changed-files-yesterday", "", "Path to file containing CVE files changed yesterday (generates yesterday's deltas)") + flag.Parse() + + runTime := time.Now().UTC() + + cfg := Config{ + InputDir: *inputDir, + OutputDir: *outputDir, + Versions: *versions, + ExcludeVersions: *excludeVersions, + ChangedFilesToday: *changedFilesToday, + ChangedFilesYesterday: *changedFilesYesterday, + DateStr: runTime.Format("2006-01-02"), + YesterdayStr: runTime.AddDate(0, 0, -1).Format("2006-01-02"), + GeneratedTimestamp: runTime.Format(time.RFC3339), + RunTime: runTime, + } + + if err := run(cfg); err != nil { + log.Fatalf("Error: %v", err) + } +} + +func run(cfg Config) error { + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Build version filter + targetVersions, excludedVersions := buildVersionFilter(cfg.Versions, cfg.ExcludeVersions) + switch { + case targetVersions != nil: + log.Printf("Processing OSV files from %s for versions: %s", cfg.InputDir, cfg.Versions) + case excludedVersions != nil: + log.Printf("Processing OSV files from %s (auto-detecting, excluding: %s)", cfg.InputDir, cfg.ExcludeVersions) + default: + log.Printf("Processing OSV files from %s (auto-detecting all versions)", cfg.InputDir) + } + + // Load changed CVE files for delta generation + var todayCVEFiles, yesterdayCVEFiles map[string]bool + generateTodayDeltas := cfg.ChangedFilesToday != "" + generateYesterdayDeltas := cfg.ChangedFilesYesterday != "" + + if generateTodayDeltas { + log.Printf("Loading today's changed CVE files from %s", cfg.ChangedFilesToday) + var err error + todayCVEFiles, err = loadChangedFiles(cfg.ChangedFilesToday) + if err != nil { + return fmt.Errorf("failed to load today's changed files: %w", err) + } + log.Printf("Found %d CVE files changed today", len(todayCVEFiles)) + } + + if generateYesterdayDeltas { + log.Printf("Loading yesterday's changed CVE files from %s", cfg.ChangedFilesYesterday) + var err error + yesterdayCVEFiles, err = loadChangedFiles(cfg.ChangedFilesYesterday) + if err != nil { + return fmt.Errorf("failed to load yesterday's changed files: %w", err) + } + log.Printf("Found %d CVE files changed yesterday", len(yesterdayCVEFiles)) + } + + artifacts := make(map[string]*ArtifactData) + todayArtifacts := make(map[string]*ArtifactData) + yesterdayArtifacts := make(map[string]*ArtifactData) + + filesProcessed := 0 + filesSkipped := 0 + + err := filepath.Walk(cfg.InputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + + osvData, err := parseOSVFile(path) + if err != nil { + log.Printf("Failed to parse %s: %v", path, err) + filesSkipped++ + return nil + } + + inToday := false + inYesterday := false + if generateTodayDeltas { + inToday = shouldIncludeInDelta(cfg.InputDir, path, todayCVEFiles) + } + if generateYesterdayDeltas { + inYesterday = shouldIncludeInDelta(cfg.InputDir, path, yesterdayCVEFiles) + } + + for _, affected := range osvData.Affected { + ecosystem := affected.Package.Ecosystem + packageName := affected.Package.Name + + ubuntuVer := extractUbuntuVersion(ecosystem) + if ubuntuVer == "" { + continue + } + + // Filter versions based on flags + if targetVersions != nil { + // Inclusive mode: only process if in target list + if !targetVersions[ubuntuVer] { + continue + } + } else if excludedVersions != nil { + // Exclusive mode: skip if in excluded list + if excludedVersions[ubuntuVer] { + continue + } + } + // Otherwise auto-detect all versions (no filtering) + + cveID := extractCVEID(osvData) + if cveID == "" { + cveID = osvData.ID + } + + introduced, fixed := extractVersionRange(affected.Ranges) + + vuln := ProcessedVuln{ + CVE: cveID, + Published: osvData.Published, + Modified: osvData.Modified, + Introduced: introduced, + Fixed: fixed, + Versions: affected.Versions, + } + + // Apply any transformations/filters to modify the package name or cve + packages, modifiedVuln := transformVuln(packageName, cveID, &vuln) + if packages == nil { + continue + } + // Use modified vulnerability if provided, otherwise use original + vulnToUse := &vuln + if modifiedVuln != nil { + vulnToUse = modifiedVuln + } + + for _, pkg := range packages { + if _, exists := artifacts[ubuntuVer]; !exists { + artifacts[ubuntuVer] = &ArtifactData{ + SchemaVersion: "1.0", + UbuntuVersion: ubuntuVer, + Vulnerabilities: make(map[string][]ProcessedVuln), + } + } + artifacts[ubuntuVer].Vulnerabilities[pkg] = append(artifacts[ubuntuVer].Vulnerabilities[pkg], *vulnToUse) + } + + // Add to today's delta artifact if this file was changed today + if inToday { + for _, pkg := range packages { + if _, exists := todayArtifacts[ubuntuVer]; !exists { + todayArtifacts[ubuntuVer] = &ArtifactData{ + SchemaVersion: "1.0", + UbuntuVersion: ubuntuVer, + Vulnerabilities: make(map[string][]ProcessedVuln), + } + } + todayArtifacts[ubuntuVer].Vulnerabilities[pkg] = append(todayArtifacts[ubuntuVer].Vulnerabilities[pkg], *vulnToUse) + } + } + + // Add to yesterday's delta artifact if this file was changed yesterday + if inYesterday { + for _, pkg := range packages { + if _, exists := yesterdayArtifacts[ubuntuVer]; !exists { + yesterdayArtifacts[ubuntuVer] = &ArtifactData{ + SchemaVersion: "1.0", + UbuntuVersion: ubuntuVer, + Vulnerabilities: make(map[string][]ProcessedVuln), + } + } + yesterdayArtifacts[ubuntuVer].Vulnerabilities[pkg] = append(yesterdayArtifacts[ubuntuVer].Vulnerabilities[pkg], *vulnToUse) + } + } + } + + filesProcessed++ + if filesProcessed%1000 == 0 { + log.Printf("Processed %d files...", filesProcessed) + } + + return nil + }) + if err != nil { + return fmt.Errorf("error walking directory: %w", err) + } + + log.Printf("Processed %d files, skipped %d files", filesProcessed, filesSkipped) + log.Printf("Discovered %d Ubuntu versions", len(artifacts)) + + // Write full artifacts + for ver, artifact := range artifacts { + artifact.Generated = cfg.GeneratedTimestamp + artifact.TotalCVEs = countTotalCVEs(artifact) + artifact.TotalPackages = len(artifact.Vulnerabilities) + + outputFile := filepath.Join(cfg.OutputDir, fmt.Sprintf("osv-ubuntu-%s-%s.json.gz", + strings.ReplaceAll(ver, ".", ""), + cfg.DateStr)) + + if err := writeArtifact(outputFile, artifact); err != nil { + return fmt.Errorf("failed to write artifact for Ubuntu %s: %w", ver, err) + } + + log.Printf("Ubuntu %s: %d packages, %d CVEs -> %s", + ver, artifact.TotalPackages, artifact.TotalCVEs, outputFile) + } + + // Write delta artifacts (if any were generated) + if generateTodayDeltas && len(todayArtifacts) > 0 { + log.Printf("\nWriting today's delta artifacts (%s)...", cfg.DateStr) + for ver, artifact := range todayArtifacts { + artifact.Generated = cfg.GeneratedTimestamp + artifact.TotalCVEs = countTotalCVEs(artifact) + artifact.TotalPackages = len(artifact.Vulnerabilities) + + outputFile := filepath.Join(cfg.OutputDir, fmt.Sprintf("osv-ubuntu-%s-delta-%s.json.gz", + strings.ReplaceAll(ver, ".", ""), cfg.DateStr)) + + if err := writeArtifact(outputFile, artifact); err != nil { + return fmt.Errorf("failed to write today's delta for Ubuntu %s: %w", ver, err) + } + + log.Printf("Ubuntu %s (today): %d packages, %d CVEs -> %s", + ver, artifact.TotalPackages, artifact.TotalCVEs, outputFile) + } + } + + if generateYesterdayDeltas && len(yesterdayArtifacts) > 0 { + log.Printf("\nWriting yesterday's delta artifacts (%s)...", cfg.YesterdayStr) + for ver, artifact := range yesterdayArtifacts { + artifact.Generated = cfg.GeneratedTimestamp + artifact.TotalCVEs = countTotalCVEs(artifact) + artifact.TotalPackages = len(artifact.Vulnerabilities) + + outputFile := filepath.Join(cfg.OutputDir, fmt.Sprintf("osv-ubuntu-%s-delta-%s.json.gz", + strings.ReplaceAll(ver, ".", ""), cfg.YesterdayStr)) + + if err := writeArtifact(outputFile, artifact); err != nil { + return fmt.Errorf("failed to write yesterday's delta for Ubuntu %s: %w", ver, err) + } + + log.Printf("Ubuntu %s (yesterday): %d packages, %d CVEs -> %s", + ver, artifact.TotalPackages, artifact.TotalCVEs, outputFile) + } + } + + return nil +} + +func buildVersionFilter(versions, excludeVersions string) (targetVersions, excludedVersions map[string]bool) { + if versions != "" { + // Inclusive mode: only process specified versions + targetVersions = make(map[string]bool) + for ver := range strings.SplitSeq(versions, ",") { + trimmed := strings.TrimSpace(ver) + if trimmed != "" { + targetVersions[trimmed] = true + } + } + // If no valid versions were parsed, fall back to auto-detect + if len(targetVersions) == 0 { + return nil, nil + } + return targetVersions, nil + } + + if excludeVersions != "" { + // Exclusive mode: process all except specified versions + excludedVersions = make(map[string]bool) + for ver := range strings.SplitSeq(excludeVersions, ",") { + trimmed := strings.TrimSpace(ver) + if trimmed != "" { + excludedVersions[trimmed] = true + } + } + // If no valid versions were parsed, fall back to auto-detect + if len(excludedVersions) == 0 { + return nil, nil + } + return nil, excludedVersions + } + + // Auto-detect all versions + return nil, nil +} + +func shouldIncludeInDelta(inputDir, filePath string, changedFiles map[string]bool) bool { + relPath, err := filepath.Rel(inputDir, filePath) + if err != nil { + return false + } + + normalizedRelPath := strings.TrimPrefix(filepath.ToSlash(relPath), "osv/cve/") + fullRelPath := "osv/cve/" + normalizedRelPath + + return changedFiles[fullRelPath] +} + +func parseOSVFile(path string) (*OSVData, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var osv OSVData + if err := json.Unmarshal(data, &osv); err != nil { + return nil, err + } + + return &osv, nil +} + +func extractUbuntuVersion(ecosystem string) string { + // Example: "Ubuntu:24.04:LTS" -> "24.04" + // Example: "Ubuntu:Pro:22.04:LTS" -> "22.04" + for part := range strings.SplitSeq(ecosystem, ":") { + // Look for version pattern like "24.04", "22.04", "20.04" + if len(part) == 5 && strings.Contains(part, ".") { + return part + } + } + return "" +} + +func extractCVEID(osv *OSVData) string { + for _, upstream := range osv.Upstream { + if strings.HasPrefix(upstream, "CVE-") { + return upstream + } + } + + if strings.HasPrefix(osv.ID, "CVE-") { + return osv.ID + } + + if strings.HasPrefix(osv.ID, "UBUNTU-CVE-") { + return strings.TrimPrefix(osv.ID, "UBUNTU-") + } + + return "" +} + +func extractVersionRange(ranges []Range) (introduced string, fixed string) { + for _, r := range ranges { + if r.Type == "ECOSYSTEM" { + for _, event := range r.Events { + if event.Introduced != "" && introduced == "" { + introduced = event.Introduced + } + if event.Fixed != "" && fixed == "" { + fixed = event.Fixed + } + } + } + } + return +} + +func countTotalCVEs(artifact *ArtifactData) int { + seen := make(map[string]bool) + for _, vulns := range artifact.Vulnerabilities { + for _, vuln := range vulns { + seen[vuln.CVE] = true + } + } + return len(seen) +} + +func writeArtifact(path string, artifact *ArtifactData) (err error) { + file, err := os.Create(path) + if err != nil { + return err + } + defer func() { + if cerr := file.Close(); err == nil && cerr != nil { + err = cerr + } + }() + + gzWriter := gzip.NewWriter(file) + defer func() { + if cerr := gzWriter.Close(); err == nil && cerr != nil { + err = cerr + } + }() + + encoder := json.NewEncoder(gzWriter) + + if err = encoder.Encode(artifact); err != nil { + return err + } + + return nil +} + +func loadChangedFiles(changedFilesPath string) (map[string]bool, error) { + file, err := os.Open(changedFilesPath) + if err != nil { + return nil, fmt.Errorf("failed to open changed files list: %w", err) + } + defer file.Close() + + changedFiles := make(map[string]bool) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + changedFiles[line] = true + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading changed files: %w", err) + } + + return changedFiles, nil +} diff --git a/cmd/osv-processor/main_test.go b/cmd/osv-processor/main_test.go new file mode 100644 index 0000000000..6d7085ce7f --- /dev/null +++ b/cmd/osv-processor/main_test.go @@ -0,0 +1,860 @@ +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestExtractUbuntuVersion(t *testing.T) { + tests := []struct { + name string + ecosystem string + expected string + }{ + { + name: "Standard Ubuntu LTS", + ecosystem: "Ubuntu:24.04:LTS", + expected: "24.04", + }, + { + name: "Ubuntu Pro", + ecosystem: "Ubuntu:Pro:22.04:LTS", + expected: "22.04", + }, + { + name: "Ubuntu 20.04", + ecosystem: "Ubuntu:20.04:LTS", + expected: "20.04", + }, + { + name: "Ubuntu 18.04", + ecosystem: "Ubuntu:18.04:LTS", + expected: "18.04", + }, + { + name: "No version pattern", + ecosystem: "Ubuntu:LTS", + expected: "", + }, + { + name: "Empty string", + ecosystem: "", + expected: "", + }, + { + name: "Not Ubuntu", + ecosystem: "Debian:12:stable", + expected: "", + }, + { + name: "Version without LTS", + ecosystem: "Ubuntu:24.04", + expected: "24.04", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractUbuntuVersion(tt.ecosystem) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractCVEID(t *testing.T) { + tests := []struct { + name string + osv *OSVData + expected string + }{ + { + name: "CVE in Upstream field", + osv: &OSVData{ + ID: "UBUNTU-CVE-2024-1234", + Upstream: []string{"CVE-2024-1234", "https://example.com"}, + }, + expected: "CVE-2024-1234", + }, + { + name: "CVE as ID", + osv: &OSVData{ + ID: "CVE-2024-5678", + Upstream: []string{}, + }, + expected: "CVE-2024-5678", + }, + { + name: "UBUNTU-CVE prefix", + osv: &OSVData{ + ID: "UBUNTU-CVE-2024-9999", + Upstream: []string{}, + }, + expected: "CVE-2024-9999", + }, + { + name: "No CVE found", + osv: &OSVData{ + ID: "SOME-OTHER-ID", + Upstream: []string{"https://example.com"}, + }, + expected: "", + }, + { + name: "Multiple upstreams with CVE first", + osv: &OSVData{ + ID: "UBUNTU-123", + Upstream: []string{"CVE-2024-1111", "CVE-2024-2222"}, + }, + expected: "CVE-2024-1111", + }, + { + name: "Empty upstream, no CVE in ID", + osv: &OSVData{ + ID: "USN-1234-1", + Upstream: []string{}, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractCVEID(tt.osv) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractVersionRange(t *testing.T) { + tests := []struct { + name string + ranges []Range + expectedIntroduced string + expectedFixed string + }{ + { + name: "Simple range with introduced and fixed", + ranges: []Range{ + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "1.0.0"}, + {Fixed: "2.0.0"}, + }, + }, + }, + expectedIntroduced: "1.0.0", + expectedFixed: "2.0.0", + }, + { + name: "Only introduced version", + ranges: []Range{ + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "1.5.0"}, + }, + }, + }, + expectedIntroduced: "1.5.0", + expectedFixed: "", + }, + { + name: "Only fixed version", + ranges: []Range{ + { + Type: "ECOSYSTEM", + Events: []Event{ + {Fixed: "3.0.0"}, + }, + }, + }, + expectedIntroduced: "", + expectedFixed: "3.0.0", + }, + { + name: "Multiple ranges, first ECOSYSTEM wins", + ranges: []Range{ + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "1.0.0"}, + {Fixed: "2.0.0"}, + }, + }, + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "3.0.0"}, + {Fixed: "4.0.0"}, + }, + }, + }, + expectedIntroduced: "1.0.0", + expectedFixed: "2.0.0", + }, + { + name: "Non-ECOSYSTEM range ignored", + ranges: []Range{ + { + Type: "GIT", + Events: []Event{ + {Introduced: "abc123"}, + {Fixed: "def456"}, + }, + }, + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "2.0.0"}, + {Fixed: "2.5.0"}, + }, + }, + }, + expectedIntroduced: "2.0.0", + expectedFixed: "2.5.0", + }, + { + name: "Empty ranges", + ranges: []Range{}, + expectedIntroduced: "", + expectedFixed: "", + }, + { + name: "Multiple events in single range", + ranges: []Range{ + { + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "0"}, + {Fixed: "1.2.3"}, + {Introduced: "2.0.0"}, // This should be ignored (first introduced wins) + }, + }, + }, + expectedIntroduced: "0", + expectedFixed: "1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + introduced, fixed := extractVersionRange(tt.ranges) + require.Equal(t, tt.expectedIntroduced, introduced) + require.Equal(t, tt.expectedFixed, fixed) + }) + } +} + +func TestCountTotalCVEs(t *testing.T) { + tests := []struct { + name string + artifact *ArtifactData + expected int + }{ + { + name: "Multiple packages with unique CVEs", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{ + "curl": { + {CVE: "CVE-2024-1234"}, + {CVE: "CVE-2024-5678"}, + }, + "openssl": { + {CVE: "CVE-2024-9999"}, + }, + }, + }, + expected: 3, + }, + { + name: "Duplicate CVEs across packages", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{ + "emacs": { + {CVE: "CVE-2024-39331"}, + }, + "emacs-common": { + {CVE: "CVE-2024-39331"}, + }, + "emacs-el": { + {CVE: "CVE-2024-39331"}, + }, + }, + }, + expected: 1, // Deduplicated + }, + { + name: "Mix of unique and duplicate CVEs", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{ + "package1": { + {CVE: "CVE-2024-1111"}, + {CVE: "CVE-2024-2222"}, + }, + "package2": { + {CVE: "CVE-2024-1111"}, // Duplicate + {CVE: "CVE-2024-3333"}, + }, + }, + }, + expected: 3, // CVE-2024-1111, CVE-2024-2222, CVE-2024-3333 + }, + { + name: "Empty artifact", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{}, + }, + expected: 0, + }, + { + name: "Package with no vulnerabilities", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{ + "safe-package": {}, + }, + }, + expected: 0, + }, + { + name: "Single package, single CVE", + artifact: &ArtifactData{ + Vulnerabilities: map[string][]ProcessedVuln{ + "apache2": { + {CVE: "CVE-2024-7777"}, + }, + }, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := countTotalCVEs(tt.artifact) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildVersionFilter(t *testing.T) { + tests := []struct { + name string + versions string + excludeVersions string + expectedTargetVersions map[string]bool + expectedExcludedVersions map[string]bool + }{ + { + name: "Inclusive mode: single version", + versions: "20.04", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: multiple versions", + versions: "20.04,22.04,24.04", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true, "24.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: with spaces", + versions: "20.04, 22.04, 24.04", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true, "24.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Exclusive mode: single version", + versions: "", + excludeVersions: "14.04", + expectedTargetVersions: nil, + expectedExcludedVersions: map[string]bool{"14.04": true}, + }, + { + name: "Exclusive mode: multiple versions", + versions: "", + excludeVersions: "14.04,16.04,24.10", + expectedTargetVersions: nil, + expectedExcludedVersions: map[string]bool{"14.04": true, "16.04": true, "24.10": true}, + }, + { + name: "Exclusive mode: with spaces", + versions: "", + excludeVersions: "14.04, 16.04, 24.10", + expectedTargetVersions: nil, + expectedExcludedVersions: map[string]bool{"14.04": true, "16.04": true, "24.10": true}, + }, + { + name: "Auto-detect mode: both empty", + versions: "", + excludeVersions: "", + expectedTargetVersions: nil, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive takes precedence: both provided", + versions: "20.04,22.04", + excludeVersions: "14.04,16.04", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: trailing comma ignored", + versions: "20.04,22.04,", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: leading comma ignored", + versions: ",20.04,22.04", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: multiple commas ignored", + versions: "20.04,,22.04", + excludeVersions: "", + expectedTargetVersions: map[string]bool{"20.04": true, "22.04": true}, + expectedExcludedVersions: nil, + }, + { + name: "Exclusive mode: trailing comma ignored", + versions: "", + excludeVersions: "14.04,16.04,", + expectedTargetVersions: nil, + expectedExcludedVersions: map[string]bool{"14.04": true, "16.04": true}, + }, + { + name: "Inclusive mode: only empty strings falls back to auto-detect", + versions: ",,", + excludeVersions: "", + expectedTargetVersions: nil, + expectedExcludedVersions: nil, + }, + { + name: "Inclusive mode: only whitespace falls back to auto-detect", + versions: " , , ", + excludeVersions: "", + expectedTargetVersions: nil, + expectedExcludedVersions: nil, + }, + { + name: "Exclusive mode: only empty strings falls back to auto-detect", + versions: "", + excludeVersions: ",,", + expectedTargetVersions: nil, + expectedExcludedVersions: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targetVersions, excludedVersions := buildVersionFilter(tt.versions, tt.excludeVersions) + require.Equal(t, tt.expectedTargetVersions, targetVersions) + require.Equal(t, tt.expectedExcludedVersions, excludedVersions) + }) + } +} + +func TestShouldIncludeInDelta(t *testing.T) { + tests := []struct { + name string + inputDir string + filePath string + changedFiles map[string]bool + expectedMatch bool + }{ + { + name: "Unix path: file in changed set", + inputDir: "/tmp/ubuntu-osv", + filePath: "/tmp/ubuntu-osv/osv/cve/CVE-2024-1234.json", + changedFiles: map[string]bool{ + "osv/cve/CVE-2024-1234.json": true, + }, + expectedMatch: true, + }, + { + name: "Unix path: file not in changed set", + inputDir: "/tmp/ubuntu-osv", + filePath: "/tmp/ubuntu-osv/osv/cve/CVE-2024-9999.json", + changedFiles: map[string]bool{ + "osv/cve/CVE-2024-1234.json": true, + }, + expectedMatch: false, + }, + { + name: "Nested directory: file in changed set", + inputDir: "/data/osv", + filePath: "/data/osv/osv/cve/2024/CVE-2024-1111.json", + changedFiles: map[string]bool{ + "osv/cve/2024/CVE-2024-1111.json": true, + }, + expectedMatch: true, + }, + { + name: "File already has osv/cve prefix in relative path", + inputDir: "/workspace", + filePath: "/workspace/osv/cve/CVE-2024-2222.json", + changedFiles: map[string]bool{ + "osv/cve/CVE-2024-2222.json": true, + }, + expectedMatch: true, + }, + { + name: "Changed files with leading slash (should not match)", + inputDir: "/tmp/ubuntu-osv", + filePath: "/tmp/ubuntu-osv/osv/cve/CVE-2024-3333.json", + changedFiles: map[string]bool{ + "/osv/cve/CVE-2024-3333.json": true, // Wrong: has leading slash + }, + expectedMatch: false, + }, + { + name: "Changed files without osv/cve prefix (should not match)", + inputDir: "/tmp/ubuntu-osv", + filePath: "/tmp/ubuntu-osv/osv/cve/CVE-2024-4444.json", + changedFiles: map[string]bool{ + "CVE-2024-4444.json": true, // Wrong: missing osv/cve/ prefix + }, + expectedMatch: false, + }, + { + name: "Empty changed files set", + inputDir: "/tmp/ubuntu-osv", + filePath: "/tmp/ubuntu-osv/osv/cve/CVE-2024-5555.json", + changedFiles: map[string]bool{}, + expectedMatch: false, + }, + { + name: "File outside input directory tree (relative path doesn't match)", + inputDir: "/tmp/ubuntu-osv/subdir", + filePath: "/tmp/other-dir/osv/cve/CVE-2024-6666.json", + changedFiles: map[string]bool{ + "osv/cve/CVE-2024-6666.json": true, + }, + expectedMatch: false, // filepath.Rel will work but path won't match + }, + { + name: "Multiple files in changed set, match one", + inputDir: "/data/osv", + filePath: "/data/osv/osv/cve/CVE-2024-7777.json", + changedFiles: map[string]bool{ + "osv/cve/CVE-2024-1111.json": true, + "osv/cve/CVE-2024-7777.json": true, + "osv/cve/CVE-2024-9999.json": true, + }, + expectedMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldIncludeInDelta(tt.inputDir, tt.filePath, tt.changedFiles) + require.Equal(t, tt.expectedMatch, result) + }) + } +} + +func TestRun(t *testing.T) { + // Create temporary directories for input and output + inputDir := t.TempDir() + outputDir := t.TempDir() + + // Create a simple test OSV file + testOSVData := `{ + "schema_version": "1.0", + "id": "USN-1234-1", + "published": "2024-01-01T00:00:00Z", + "modified": "2024-01-02T00:00:00Z", + "details": "Test vulnerability", + "affected": [{ + "package": { + "ecosystem": "Ubuntu:22.04:LTS", + "name": "test-package" + }, + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + {"introduced": "0"}, + {"fixed": "1.2.3"} + ] + }] + }], + "upstream": ["CVE-2024-1234"] + }` + + // Write test file + require.NoError(t, os.WriteFile(filepath.Join(inputDir, "CVE-2024-1234.json"), []byte(testOSVData), 0o644)) + + // Create config + cfg := Config{ + InputDir: inputDir, + OutputDir: outputDir, + Versions: "", + ExcludeVersions: "", + ChangedFilesToday: "", + ChangedFilesYesterday: "", + DateStr: "2024-01-03", + YesterdayStr: "2024-01-02", + GeneratedTimestamp: "2024-01-03T00:00:00Z", + RunTime: time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC), + } + + // Run the function + err := run(cfg) + require.NoError(t, err) + + // Verify output artifact was created + expectedFile := filepath.Join(outputDir, "osv-ubuntu-2204-2024-01-03.json.gz") + require.FileExists(t, expectedFile) + + // Verify artifact content (decompress and check) + artifact, err := readArtifact(expectedFile) + require.NoError(t, err) + require.Equal(t, "1.0", artifact.SchemaVersion) + require.Equal(t, "22.04", artifact.UbuntuVersion) + require.Equal(t, 1, artifact.TotalCVEs) + require.Equal(t, 1, artifact.TotalPackages) + require.Contains(t, artifact.Vulnerabilities, "test-package") + require.Len(t, artifact.Vulnerabilities["test-package"], 1) + require.Equal(t, "CVE-2024-1234", artifact.Vulnerabilities["test-package"][0].CVE) +} + +func TestRunWithDeltaGeneration(t *testing.T) { + // Create temporary directories + inputDir := t.TempDir() + outputDir := t.TempDir() + changedFilesDir := t.TempDir() + + // Create two test OSV files + testOSVData1 := `{ + "schema_version": "1.0", + "id": "USN-1234-1", + "published": "2024-01-01T00:00:00Z", + "modified": "2024-01-02T00:00:00Z", + "affected": [{ + "package": { + "ecosystem": "Ubuntu:22.04:LTS", + "name": "changed-today-package" + }, + "ranges": [{ + "type": "ECOSYSTEM", + "events": [{"introduced": "0"}, {"fixed": "1.0"}] + }] + }], + "upstream": ["CVE-2024-1111"] + }` + + testOSVData2 := `{ + "schema_version": "1.0", + "id": "USN-5678-1", + "published": "2024-01-01T00:00:00Z", + "modified": "2024-01-02T00:00:00Z", + "affected": [{ + "package": { + "ecosystem": "Ubuntu:22.04:LTS", + "name": "changed-yesterday-package" + }, + "ranges": [{ + "type": "ECOSYSTEM", + "events": [{"introduced": "0"}, {"fixed": "2.0"}] + }] + }], + "upstream": ["CVE-2024-2222"] + }` + + // Write test files + osvCveDir := filepath.Join(inputDir, "osv", "cve") + require.NoError(t, os.MkdirAll(osvCveDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(osvCveDir, "CVE-2024-1111.json"), []byte(testOSVData1), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(osvCveDir, "CVE-2024-2222.json"), []byte(testOSVData2), 0o644)) + + // Create changed files lists + changedTodayFile := filepath.Join(changedFilesDir, "changed_today.txt") + changedYesterdayFile := filepath.Join(changedFilesDir, "changed_yesterday.txt") + require.NoError(t, os.WriteFile(changedTodayFile, []byte("osv/cve/CVE-2024-1111.json\n"), 0o644)) + require.NoError(t, os.WriteFile(changedYesterdayFile, []byte("osv/cve/CVE-2024-2222.json\n"), 0o644)) + + // Create config with delta generation + cfg := Config{ + InputDir: inputDir, + OutputDir: outputDir, + Versions: "", + ExcludeVersions: "", + ChangedFilesToday: changedTodayFile, + ChangedFilesYesterday: changedYesterdayFile, + DateStr: "2024-01-03", + YesterdayStr: "2024-01-02", + GeneratedTimestamp: "2024-01-03T00:00:00Z", + RunTime: time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC), + } + + // Run + err := run(cfg) + require.NoError(t, err) + + // Verify full artifact + fullArtifact, err := readArtifact(filepath.Join(outputDir, "osv-ubuntu-2204-2024-01-03.json.gz")) + require.NoError(t, err) + require.Equal(t, 2, fullArtifact.TotalCVEs) + require.Equal(t, 2, fullArtifact.TotalPackages) + + // Verify today's delta artifact + todayDelta, err := readArtifact(filepath.Join(outputDir, "osv-ubuntu-2204-delta-2024-01-03.json.gz")) + require.NoError(t, err) + require.Equal(t, 1, todayDelta.TotalCVEs) + require.Equal(t, 1, todayDelta.TotalPackages) + require.NotNil(t, todayDelta.Vulnerabilities) + require.Contains(t, todayDelta.Vulnerabilities, "changed-today-package") + require.NotEmpty(t, todayDelta.Vulnerabilities["changed-today-package"]) + require.Equal(t, "CVE-2024-1111", todayDelta.Vulnerabilities["changed-today-package"][0].CVE) + + // Verify yesterday's delta artifact + yesterdayDelta, err := readArtifact(filepath.Join(outputDir, "osv-ubuntu-2204-delta-2024-01-02.json.gz")) + require.NoError(t, err) + require.Equal(t, 1, yesterdayDelta.TotalCVEs) + require.Equal(t, 1, yesterdayDelta.TotalPackages) + require.NotNil(t, yesterdayDelta.Vulnerabilities) + require.Contains(t, yesterdayDelta.Vulnerabilities, "changed-yesterday-package") + require.NotEmpty(t, yesterdayDelta.Vulnerabilities["changed-yesterday-package"]) + require.Equal(t, "CVE-2024-2222", yesterdayDelta.Vulnerabilities["changed-yesterday-package"][0].CVE) +} + +func TestRunWithVersionFiltering(t *testing.T) { + tests := []struct { + name string + versions string + excludeVersions string + expectedVersionCount int + expectedVersions []string + }{ + { + name: "Inclusive filtering - single version", + versions: "22.04", + excludeVersions: "", + expectedVersionCount: 1, + expectedVersions: []string{"22.04"}, + }, + { + name: "Inclusive filtering - multiple versions", + versions: "20.04,22.04", + excludeVersions: "", + expectedVersionCount: 2, + expectedVersions: []string{"20.04", "22.04"}, + }, + { + name: "Exclusive filtering - exclude one version", + versions: "", + excludeVersions: "24.04", + expectedVersionCount: 2, + expectedVersions: []string{"20.04", "22.04"}, + }, + { + name: "Auto-detect - no filtering", + versions: "", + excludeVersions: "", + expectedVersionCount: 3, + expectedVersions: []string{"20.04", "22.04", "24.04"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directories + inputDir := t.TempDir() + outputDir := t.TempDir() + + // Create test OSV files for different Ubuntu versions + for _, ver := range []string{"20.04", "22.04", "24.04"} { + data := fmt.Sprintf(`{ + "schema_version": "1.0", + "id": "USN-1234-1", + "published": "2024-01-01T00:00:00Z", + "modified": "2024-01-02T00:00:00Z", + "affected": [{ + "package": { + "ecosystem": "Ubuntu:%s:LTS", + "name": "test-package-%s" + }, + "ranges": [{ + "type": "ECOSYSTEM", + "events": [{"introduced": "0"}, {"fixed": "1.0"}] + }] + }], + "upstream": ["CVE-2024-%s"] + }`, ver, strings.ReplaceAll(ver, ".", ""), strings.ReplaceAll(ver, ".", "")) + + filename := fmt.Sprintf("CVE-2024-%s.json", strings.ReplaceAll(ver, ".", "")) + require.NoError(t, os.WriteFile(filepath.Join(inputDir, filename), []byte(data), 0o644)) + } + + // Create config + cfg := Config{ + InputDir: inputDir, + OutputDir: outputDir, + Versions: tt.versions, + ExcludeVersions: tt.excludeVersions, + ChangedFilesToday: "", + ChangedFilesYesterday: "", + DateStr: "2024-01-03", + YesterdayStr: "2024-01-02", + GeneratedTimestamp: "2024-01-03T00:00:00Z", + RunTime: time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC), + } + + // Run + err := run(cfg) + require.NoError(t, err) + + // Count artifacts created + files, err := filepath.Glob(filepath.Join(outputDir, "osv-ubuntu-*.json.gz")) + require.NoError(t, err) + require.Equal(t, tt.expectedVersionCount, len(files)) + + // Verify expected versions were generated + for _, expectedVer := range tt.expectedVersions { + verStr := strings.ReplaceAll(expectedVer, ".", "") + expectedFile := filepath.Join(outputDir, fmt.Sprintf("osv-ubuntu-%s-2024-01-03.json.gz", verStr)) + require.FileExists(t, expectedFile) + + artifact, err := readArtifact(expectedFile) + require.NoError(t, err) + require.Equal(t, expectedVer, artifact.UbuntuVersion) + } + }) + } +} + +func readArtifact(path string) (*ArtifactData, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer gzReader.Close() + + var artifact ArtifactData + if err := json.NewDecoder(gzReader).Decode(&artifact); err != nil { + return nil, err + } + + return &artifact, nil +} diff --git a/cmd/osv-processor/sync-and-detect-changes.sh b/cmd/osv-processor/sync-and-detect-changes.sh new file mode 100755 index 0000000000..4df24c28a9 --- /dev/null +++ b/cmd/osv-processor/sync-and-detect-changes.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Sync Canonical OSV repository using shallow clone with rolling window +# Usage: ./sync-and-detect-changes.sh +# +# Outputs: +# - Creates/updates ubuntu-security-notices directory (shallow clone) +# - changed_files_today.txt and changed_files_yesterday.txt +# +# Exit codes: +# 0: Success +# 1: Error occurred + +set -euo pipefail + +# Configuration +REPO_URL="https://github.com/canonical/ubuntu-security-notices.git" +REPO_DIR="ubuntu-security-notices" +DAYS_TO_KEEP=3 # how much git history to keep + +echo "=== OSV Repository Sync ===" +echo "" + +if [ -d "$REPO_DIR/.git" ]; then + echo "Repository exists, updating with rolling window..." + cd "$REPO_DIR" + + git config core.sparseCheckout true + echo "osv/" > .git/info/sparse-checkout + + OLD_SHA=$(git rev-parse HEAD) + OLD_COUNT=$(git log --oneline | wc -l | xargs) + + git fetch --update-shallow --shallow-since="${DAYS_TO_KEEP} days ago" origin main + + NEW_SHA=$(git rev-parse origin/main) + + echo "" + if [ "$OLD_SHA" = "$NEW_SHA" ]; then + echo "No new commits (already at $NEW_SHA)" + else + echo "Updating: $OLD_SHA -> $NEW_SHA" + git reset --hard origin/main + fi + + NEW_COUNT=$(git log --oneline | wc -l | xargs) + echo "History: $OLD_COUNT commits -> $NEW_COUNT commits" + + cd .. +else + echo "Cloning repository (shallow since ${DAYS_TO_KEEP} days ago)..." + + mkdir -p "$REPO_DIR" + cd "$REPO_DIR" + git init --initial-branch=main + git remote add origin "$REPO_URL" + + git config core.sparseCheckout true + echo "osv/" > .git/info/sparse-checkout + + git fetch --shallow-since="${DAYS_TO_KEEP} days ago" origin main + git checkout -b main --track origin/main + + COMMIT_SHA=$(git rev-parse HEAD) + COMMIT_COUNT=$(git log --oneline | wc -l | xargs) + cd .. + + echo "" + echo "Cloned at: $COMMIT_SHA" +fi + +cd "$REPO_DIR" + +# Get files changed today (since midnight UTC today) +TODAY_UTC=$(date -u +%Y-%m-%d) +git log --since="${TODAY_UTC}T00:00:00Z" --name-only --pretty="" -- osv/cve \ + | sed '/^$/d' | sort -u > "../changed_files_today.txt" + +# Get files changed yesterday (from midnight yesterday to midnight today UTC) +YESTERDAY_UTC=$(date -u -v-1d +%Y-%m-%d 2>/dev/null || date -u -d "yesterday" +%Y-%m-%d) +git log --since="${YESTERDAY_UTC}T00:00:00Z" --until="${TODAY_UTC}T00:00:00Z" --name-only --pretty="" -- osv/cve \ + | sed '/^$/d' | sort -u > "../changed_files_yesterday.txt" + +TODAY_COUNT=$(wc -l < "../changed_files_today.txt" | xargs) +YESTERDAY_COUNT=$(wc -l < "../changed_files_yesterday.txt" | xargs) +cd .. + +echo "Today: $TODAY_COUNT CVE files changed" +echo "Yesterday: $YESTERDAY_COUNT CVE files changed" + +echo "" +echo "Sync Complete" +cd "$REPO_DIR" +FINAL_SHA=$(git rev-parse HEAD) +FINAL_COUNT=$(git log --oneline | wc -l | xargs) +cd .. + +du -sh "$REPO_DIR" | awk '{print "Size: " $1}' +echo "REPO_SHA=$FINAL_SHA" +echo "REPO_COMMITS=$FINAL_COUNT" +echo "OSV_DIR=$REPO_DIR/osv/cve" +echo "CHANGED_FILES_TODAY=changed_files_today.txt" +echo "CHANGED_FILES_YESTERDAY=changed_files_yesterday.txt" +echo "TODAY_COUNT=$TODAY_COUNT" +echo "YESTERDAY_COUNT=$YESTERDAY_COUNT" + +exit 0 diff --git a/cmd/osv-processor/transforms.go b/cmd/osv-processor/transforms.go new file mode 100644 index 0000000000..bd9364a180 --- /dev/null +++ b/cmd/osv-processor/transforms.go @@ -0,0 +1,30 @@ +package main + +// transformVuln applies transformations and filters to OSV vulnerability data. +func transformVuln(packageName, cveID string, vuln *ProcessedVuln) (packages []string, modifiedVuln *ProcessedVuln) { + // To completely ignore a CVE definition return nil + // if cveID == "CVE-YYYY-XXXXX" { + // return nil, nil + // } + + // Default: include the original package + packages = []string{packageName} + + // Package expansion rules: Add related packages that should also get this CVE + + // Emacs CVEs (CVE-2024-39331, CVE-2024-53920, CVE-2025-1244, etc.) + // Emacs vulnerabilities are in the Emacs Lisp runtime/interpreter shared across all packages. + if packageName == "emacs" { + packages = append(packages, "emacs-common", "emacs-el") + } + + // CVE-specific modifications: modify vulnerability details for specific CVEs + // if cveID == "CVE-YYYY-XXXXX" { + // modified := *vuln // Copy the vulnerability + // modified.Fixed = "corrected-version" + // return packages, &modified + // } + + // If the vulnerability requires no modifications return original + return packages, nil +} diff --git a/cmd/osv-processor/transforms_test.go b/cmd/osv-processor/transforms_test.go new file mode 100644 index 0000000000..5992e933c8 --- /dev/null +++ b/cmd/osv-processor/transforms_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTransformVuln(t *testing.T) { + tests := []struct { + name string + packageName string + cveID string + inputVuln ProcessedVuln + expectedPackages []string + expectModified bool + }{ + { + name: "emacs maps to emacs, emacs-common, and emacs-el", + packageName: "emacs", + cveID: "CVE-2024-39331", + inputVuln: ProcessedVuln{ + CVE: "CVE-2024-39331", + Published: "2024-07-01T00:00:00Z", + Modified: "2024-07-15T00:00:00Z", + Fixed: "1:26.3+1-1ubuntu2.1", + Introduced: "0", + }, + expectedPackages: []string{"emacs", "emacs-common", "emacs-el"}, + expectModified: false, + }, + { + name: "curl returns only curl (no transform)", + packageName: "curl", + cveID: "CVE-2024-1234", + inputVuln: ProcessedVuln{ + CVE: "CVE-2024-1234", + Published: "2024-01-01T00:00:00Z", + Modified: "2024-01-15T00:00:00Z", + }, + expectedPackages: []string{"curl"}, + expectModified: false, + }, + { + name: "linux returns only linux (no transform)", + packageName: "linux", + cveID: "CVE-2024-5678", + inputVuln: ProcessedVuln{ + CVE: "CVE-2024-5678", + Published: "2024-03-01T00:00:00Z", + Modified: "2024-03-15T00:00:00Z", + }, + expectedPackages: []string{"linux"}, + expectModified: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packages, modifiedVuln := transformVuln(tt.packageName, tt.cveID, &tt.inputVuln) + require.ElementsMatch(t, tt.expectedPackages, packages) + + if tt.expectModified { + require.NotNil(t, modifiedVuln, "expected modified vulnerability") + } else { + require.Nil(t, modifiedVuln, "expected no modification") + } + }) + } +}