OSV artifact generation for use in vulnerabilities repository (#42203)

**Related issue:** Resolves #41571

**Full Artifacts:**
Ubuntu 14.04: 901 KB
Ubuntu 16.04: 2.0 MB
Ubuntu 18.04: 4.3 MB
Ubuntu 20.04: 5.9 MB
Ubuntu 22.04: 5.6 MB
Ubuntu 24.04: 1.7 MB
Ubuntu 24.10: 4.4 KB
Ubuntu 25.04: 6.0 KB
Ubuntu 25.10: 207 KB

**Total Size:**
All artifacts (full + deltas): 31 MB (was 54 MB)
Full artifacts only: ~20 MB (was ~27 MB)
Delta artifacts: ~11 MB (was ~27 MB)

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a vulnerability data processor that scans OSV JSON inputs,
aggregates per-Ubuntu-version artifacts, supports inclusive/exclusive
version filters, and can emit optional “today”/“yesterday” delta
artifacts.
* Added a repository sync-and-change-detection tool that generates
de-duplicated lists of CVE-related files changed today and yesterday.
* Processor expands certain package names (e.g., emacs) into additional
package entries for broader coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Sykulev
2026-03-25 13:02:26 -05:00
committed by GitHub
parent d9f7a593d7
commit 8eaecfc9e1
5 changed files with 1584 additions and 0 deletions
+518
View File
@@ -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
}
+860
View File
@@ -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
}
+106
View File
@@ -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
+30
View File
@@ -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
}
+70
View File
@@ -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")
}
})
}
}