Add Windows Office bulletin generator (1/3) (#42663)

This commit is contained in:
Tim Lee 2026-04-01 12:08:50 -06:00 committed by GitHub
parent 7e0d0db1b1
commit c269cf1c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 795 additions and 0 deletions

45
cmd/winoffice/generate.go Normal file
View file

@ -0,0 +1,45 @@
//go:build ignore
package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/winoffice"
)
func panicif(err error) {
if err != nil {
panic(err)
}
}
func main() {
wd, err := os.Getwd()
panicif(err)
outPath := filepath.Join(wd, "winoffice_out")
err = os.MkdirAll(outPath, 0o755)
panicif(err)
now := time.Now()
client := &http.Client{Timeout: 60 * time.Second}
fmt.Println("Scraping Windows Office security updates from Microsoft Learn...")
bulletin, err := winoffice.FetchBulletin(context.Background(), client)
panicif(err)
fmt.Printf("Found %d versions\n", len(bulletin.Versions))
fmt.Println("Saving Windows Office bulletin...")
err = bulletin.Serialize(now, outPath)
panicif(err)
fmt.Println("Done.")
}

View file

@ -9,6 +9,7 @@ import (
const (
mSRCFilePrefix = "fleet_msrc_"
winOfficePrefix = "fleet_winoffice_"
macOfficeReleaseNotesPrefix = "fleet_macoffice_release_notes_"
fileExt = "json"
dateLayout = "2006_01_02"
@ -93,3 +94,7 @@ func MSRCFileName(productName string, date time.Time) string {
func MacOfficeRelNotesFileName(date time.Time) string {
return fmt.Sprintf("%s%s-%d_%02d_%02d.%s", macOfficeReleaseNotesPrefix, "macoffice", date.Year(), date.Month(), date.Day(), fileExt)
}
func WinOfficeFileName(date time.Time) string {
return fmt.Sprintf("%s%s-%d_%02d_%02d.%s", winOfficePrefix, "bulletin", date.Year(), date.Month(), date.Day(), fileExt)
}

View file

@ -27,6 +27,16 @@ func TestSecurityBulletinName(t *testing.T) {
require.Contains(t, result, strconv.Itoa(now.Day()))
})
t.Run("WinOfficeFileName", func(t *testing.T) {
now := time.Now()
result := WinOfficeFileName(now)
require.Contains(t, result, "fleet_winoffice_")
require.Contains(t, result, "bulletin")
require.Contains(t, result, strconv.Itoa(now.Year()))
require.Contains(t, result, strconv.Itoa(int(now.Month())))
require.Contains(t, result, strconv.Itoa(now.Day()))
})
t.Run("String", func(t *testing.T) {
sut, err := NewMSRCMetadata("Windows_10-2022_09_10.json")
require.NoError(t, err)

View file

@ -0,0 +1,50 @@
# Windows Office Vulnerability Detection
This package detects vulnerabilities in Microsoft 365 Apps and Office products on Windows by scraping Microsoft's security updates page.
## Overview
Windows Office uses a version format: `16.0.<build_prefix>.<build_suffix>`
- **Build prefix** identifies the version branch (e.g., `19725` → version `2602`, meaning February 2026)
- **Build suffix** identifies the specific build within that branch
The package:
1. Scrapes [Microsoft's Office security updates page](https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates)
2. Builds a bulletin mapping CVEs to fixed versions
3. Compares host software versions against the bulletin to detect vulnerabilities
## Supported Products
- Microsoft 365 Apps for enterprise
- Office LTSC 2024/2021
- Office 2019
## Key Concepts
### Version Branches
Each version branch (e.g., `2602`) has a unique build prefix. The bulletin tracks which CVEs are fixed in which builds.
### Deprecated Versions
A version is considered deprecated if it appeared in older releases but is no longer listed in the most recent release. Deprecated versions get upgrade paths pointing to the oldest newer version that has a fix.
Versions that aren't in the most recent release but also weren't in any older releases (like LTSC versions that appear sporadically) are NOT marked deprecated - they only get direct fixes.
### Vulnerability Detection
A host is vulnerable if:
1. **Supported version**: Host's build suffix < fixed build suffix for that version branch
2. **Deprecated version**: The fix points to a different version branch (host must upgrade)
## Generating Bulletins
```bash
cd cmd/winoffice
go run generate.go
```
This creates a bulletin file in `winoffice_out/` with the naming format `fleet_winoffice_bulletin-YYYY_MM_DD.json`.

View file

@ -0,0 +1,53 @@
package winoffice
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/io"
)
// OfficeVersionPrefix is the major.minor version prefix for Microsoft Office products.
// Office 2016, 2019, 2021, LTSC 2024, and Microsoft 365 all use "16.0" as their
// version prefix. This has been consistent since Office 2016 was released in 2015.
// Windows reports installed Office versions in this format via the "programs" source.
// If Microsoft changes this in a future release, validation will fail safely rather
// than silently miscomparing versions.
const OfficeVersionPrefix = "16.0."
// SecurityUpdate represents a CVE with its resolved version for a specific version branch.
type SecurityUpdate struct {
CVE string `json:"cve"`
// ResolvedInVersion is the build version that fixes this CVE (e.g., "16.0.19725.20172").
ResolvedInVersion string `json:"resolved_in_version"`
}
// VersionBulletin contains security data for a specific version branch.
type VersionBulletin struct {
SecurityUpdates []SecurityUpdate `json:"security_updates"`
}
// BulletinFile contains Windows Office vulnerability data indexed by version.
type BulletinFile struct {
// Version is the schema version for this file format.
Version int `json:"version"`
// BuildPrefixes maps build prefix to version branch (e.g., "19725" -> "2602").
BuildPrefixes map[string]string `json:"build_prefixes"`
// Versions contains security data indexed by version branch.
Versions map[string]*VersionBulletin `json:"versions"`
}
// Serialize writes the bulletin to a JSON file.
func (b *BulletinFile) Serialize(d time.Time, dir string) error {
payload, err := json.Marshal(b)
if err != nil {
return err
}
fileName := io.WinOfficeFileName(d)
filePath := filepath.Join(dir, fileName)
return os.WriteFile(filePath, payload, 0o644)
}

View file

@ -0,0 +1,340 @@
package winoffice
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strings"
)
const (
// SecurityUpdatesURL is the Microsoft Learn page with Windows Office security updates.
SecurityUpdatesURL = "https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates"
)
// VersionBranch represents a supported Windows Office version branch.
// Windows Office versions follow a YYMM pattern (e.g., 2602 = February 2026).
type VersionBranch struct {
Version string // e.g., "2602" (YYMM format)
BuildPrefix string // e.g., "19725" (first part of build number)
FullBuild string // e.g., "19725.20172" (complete build number)
}
// SecurityRelease represents a single Windows Office security update release.
type SecurityRelease struct {
Date string // e.g., "March 10, 2026"
Branches []VersionBranch // All supported version branches with their fixed builds
CVEs []string
}
// ScrapeSecurityUpdates fetches and parses the Office security updates page
func ScrapeSecurityUpdates(ctx context.Context, client *http.Client) ([]SecurityRelease, error) {
req, err := http.NewRequestWithContext(ctx, "GET", SecurityUpdatesURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request with context: %w", err)
}
// Request markdown format
req.Header.Set("Accept", "text/markdown")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return parseSecurityMarkdown(resp.Body)
}
// parseSecurityMarkdown parses the markdown content
func parseSecurityMarkdown(r io.Reader) ([]SecurityRelease, error) {
var releases []SecurityRelease
var current *SecurityRelease
// Patterns
datePattern := regexp.MustCompile(`^## ([A-Z][a-z]+ \d{1,2}, \d{4})`)
// Matches: "Current Channel: Version 2602 (Build 19725.20172)"
// Also matches: "Monthly Enterprise Channel: Version 2512 (Build 19530.20260)"
// Also matches: "Office LTSC 2024 Volume Licensed: Version 2408 (Build 17932.20700)"
versionPattern := regexp.MustCompile(`([A-Za-z0-9 ]+):\s*Version\s+(\d+)\s+\(Build\s+(\d+)\.(\d+)\)`)
cvePattern := regexp.MustCompile(`\[CVE-(\d{4}-\d+)\]`)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
// Check for new release date
if matches := datePattern.FindStringSubmatch(line); matches != nil {
releases = appendIfValid(releases, current)
current = &SecurityRelease{Date: matches[1]}
continue
}
if current == nil {
continue
}
// Check for version info (any channel/product)
for _, matches := range versionPattern.FindAllStringSubmatch(line, -1) {
if strings.Contains(matches[1], "Retail") {
continue
}
branch := VersionBranch{
Version: matches[2],
BuildPrefix: matches[3],
FullBuild: matches[3] + "." + matches[4],
}
current.Branches = addOrUpdateBranch(current.Branches, branch)
}
// Check for CVE
if matches := cvePattern.FindStringSubmatch(line); matches != nil {
current.CVEs = append(current.CVEs, "CVE-"+matches[1])
}
}
releases = appendIfValid(releases, current)
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning: %w", err)
}
return releases, nil
}
// appendIfValid appends the release to the slice if it has branches and CVEs.
func appendIfValid(releases []SecurityRelease, rel *SecurityRelease) []SecurityRelease {
if rel != nil && len(rel.Branches) > 0 && len(rel.CVEs) > 0 {
return append(releases, *rel)
}
return releases
}
// addOrUpdateBranch adds a new branch or updates an existing one with the minimum build.
func addOrUpdateBranch(branches []VersionBranch, branch VersionBranch) []VersionBranch {
for i, b := range branches {
if b.Version != branch.Version {
continue
}
// Keep the MINIMUM build since that's the lowest build containing the fix
if compareBuildVersions(branch.FullBuild, b.FullBuild) < 0 {
branches[i].BuildPrefix = branch.BuildPrefix
branches[i].FullBuild = branch.FullBuild
}
return branches
}
return append(branches, branch)
}
// BuildBulletinFile creates a BulletinFile from scraped releases.
func BuildBulletinFile(releases []SecurityRelease) *BulletinFile {
buildPrefixes := make(map[string]string)
cveToBuilds := make(map[string]map[string]string) // CVE → version → build
versions := make(map[string]*VersionBulletin)
// Identify currently supported versions from the most recent release.
// Releases are in reverse chronological order (newest first).
currentVersions := make(map[string]bool)
if len(releases) > 0 {
for _, branch := range releases[0].Branches {
currentVersions[branch.Version] = true
}
}
// Collect all data from releases
for _, rel := range releases {
for _, branch := range rel.Branches {
buildPrefixes[branch.BuildPrefix] = branch.Version
}
for _, cve := range rel.CVEs {
recordCVEFix(cveToBuilds, cve, rel.Branches)
}
}
// Build version-indexed structure with direct fixes
for cve, fixedBuilds := range cveToBuilds {
for version, build := range fixedBuilds {
vb := getOrCreateVersion(versions, version)
vb.SecurityUpdates = append(vb.SecurityUpdates, SecurityUpdate{
CVE: cve,
ResolvedInVersion: OfficeVersionPrefix + build,
})
}
}
// Add upgrade paths for deprecated versions (appeared in older releases but not in the latest).
// These versions need to upgrade to a newer version branch to get fixes.
deprecatedVersions := findDeprecatedVersions(versions, currentVersions)
if len(deprecatedVersions) > 0 {
sortedVersions := sortedVersionKeys(versions)
for _, version := range deprecatedVersions {
vb, ok := versions[version]
if !ok || vb == nil {
continue
}
existingCVEs := make(map[string]bool)
for _, su := range vb.SecurityUpdates {
existingCVEs[su.CVE] = true
}
addUpgradePaths(vb, version, sortedVersions, cveToBuilds, existingCVEs)
}
}
// Sort for deterministic output
for _, vb := range versions {
if vb == nil {
continue
}
sort.Slice(vb.SecurityUpdates, func(i, j int) bool {
return vb.SecurityUpdates[i].CVE < vb.SecurityUpdates[j].CVE
})
}
return &BulletinFile{
Version: 1,
BuildPrefixes: buildPrefixes,
Versions: versions,
}
}
// findDeprecatedVersions returns versions that appear in the bulletin but not in the current release.
func findDeprecatedVersions(versions map[string]*VersionBulletin, currentVersions map[string]bool) []string {
var deprecated []string
for version := range versions {
if !currentVersions[version] {
deprecated = append(deprecated, version)
}
}
return deprecated
}
// sortedVersionKeys returns the keys of versions sorted in ascending order.
func sortedVersionKeys(versions map[string]*VersionBulletin) []string {
keys := make([]string, 0, len(versions))
for v := range versions {
keys = append(keys, v)
}
sort.Strings(keys)
return keys
}
// addUpgradePaths adds CVE fixes pointing to newer versions for deprecated versions.
func addUpgradePaths(
vb *VersionBulletin,
version string,
sortedVersions []string,
cveToBuilds map[string]map[string]string,
existingCVEs map[string]bool,
) {
for cve, fixedBuilds := range cveToBuilds {
if existingCVEs[cve] {
continue
}
build := findMinimumUpgrade(version, sortedVersions, fixedBuilds)
if build == "" {
continue
}
vb.SecurityUpdates = append(vb.SecurityUpdates, SecurityUpdate{
CVE: cve,
ResolvedInVersion: OfficeVersionPrefix + build,
})
}
}
// findMinimumUpgrade finds the oldest version > current that has a fix for a CVE.
func findMinimumUpgrade(version string, sortedVersions []string, fixedBuilds map[string]string) string {
for _, v := range sortedVersions {
if v <= version {
continue
}
if build, ok := fixedBuilds[v]; ok {
return build
}
}
return ""
}
// recordCVEFix records a CVE fix for each branch, keeping the first seen fix per version.
// Since releases are processed newest-first, this keeps the newest build. In practice,
// each CVE appears only once per version branch (in the release that fixed it).
func recordCVEFix(cveToBuilds map[string]map[string]string, cve string, branches []VersionBranch) {
if cveToBuilds[cve] == nil {
cveToBuilds[cve] = make(map[string]string)
}
for _, branch := range branches {
if _, exists := cveToBuilds[cve][branch.Version]; !exists {
cveToBuilds[cve][branch.Version] = branch.FullBuild
}
}
}
// getOrCreateVersion returns the VersionBulletin for a version, creating it if needed.
func getOrCreateVersion(versions map[string]*VersionBulletin, version string) *VersionBulletin {
if versions[version] == nil {
versions[version] = &VersionBulletin{}
}
return versions[version]
}
// compareBuildVersions compares two build versions like "19725.20172"
// Returns: -1 if a < b, 0 if a == b, 1 if a > b
func compareBuildVersions(a, b string) int {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
// Compare build prefix (major)
if len(partsA) >= 1 && len(partsB) >= 1 {
prefixA := partsA[0]
prefixB := partsB[0]
// Pad to same length for proper numeric comparison
maxLen := max(len(prefixA), len(prefixB))
prefixA = fmt.Sprintf("%0*s", maxLen, prefixA)
prefixB = fmt.Sprintf("%0*s", maxLen, prefixB)
if prefixA < prefixB {
return -1
}
if prefixA > prefixB {
return 1
}
}
// Compare build suffix (minor)
if len(partsA) >= 2 && len(partsB) >= 2 {
suffixA := partsA[1]
suffixB := partsB[1]
// Pad to same length for proper comparison
maxLen := max(len(suffixA), len(suffixB))
suffixA = fmt.Sprintf("%0*s", maxLen, suffixA)
suffixB = fmt.Sprintf("%0*s", maxLen, suffixB)
if suffixA < suffixB {
return -1
}
if suffixA > suffixB {
return 1
}
}
return 0
}
// FetchBulletin scrapes and builds Office security bulletin
func FetchBulletin(ctx context.Context, client *http.Client) (*BulletinFile, error) {
releases, err := ScrapeSecurityUpdates(ctx, client)
if err != nil {
return nil, err
}
if len(releases) == 0 {
return nil, errors.New("no releases found")
}
return BuildBulletinFile(releases), nil
}

View file

@ -0,0 +1,292 @@
package winoffice
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSecurityMarkdown(t *testing.T) {
t.Run("parses single release with multiple versions", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172), Monthly Enterprise Channel: Version 2512 (Build 19530.20260)
- [CVE-2026-12345](https://example.com) Remote code execution
- [CVE-2026-12346](https://example.com) Elevation of privilege
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 1)
rel := releases[0]
assert.Equal(t, "March 11, 2026", rel.Date)
assert.Len(t, rel.Branches, 2)
assert.Len(t, rel.CVEs, 2)
// Check branches
assert.Equal(t, "2602", rel.Branches[0].Version)
assert.Equal(t, "19725", rel.Branches[0].BuildPrefix)
assert.Equal(t, "19725.20172", rel.Branches[0].FullBuild)
assert.Equal(t, "2512", rel.Branches[1].Version)
assert.Equal(t, "19530", rel.Branches[1].BuildPrefix)
// Check CVEs
assert.Contains(t, rel.CVEs, "CVE-2026-12345")
assert.Contains(t, rel.CVEs, "CVE-2026-12346")
})
t.Run("parses multiple releases", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172)
- [CVE-2026-12345](https://example.com) First CVE
## February 11, 2026
Current Channel: Version 2601 (Build 19628.20204)
- [CVE-2026-11111](https://example.com) Second CVE
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 2)
assert.Equal(t, "March 11, 2026", releases[0].Date)
assert.Equal(t, "February 11, 2026", releases[1].Date)
})
t.Run("skips retail versions", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172), Office 2024 Retail: Version 2602 (Build 19725.20172)
- [CVE-2026-12345](https://example.com) CVE
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 1)
// Should only have one branch (retail skipped)
assert.Len(t, releases[0].Branches, 1)
assert.Equal(t, "2602", releases[0].Branches[0].Version)
})
t.Run("keeps minimum build suffix for same version", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172), Monthly Enterprise Channel: Version 2602 (Build 19725.20170)
- [CVE-2026-12345](https://example.com) CVE
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 1)
// Should only have one branch with minimum build suffix
assert.Len(t, releases[0].Branches, 1)
assert.Equal(t, "2602", releases[0].Branches[0].Version)
assert.Equal(t, "19725.20170", releases[0].Branches[0].FullBuild)
})
t.Run("skips releases without CVEs", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172)
No security updates this month.
## February 11, 2026
Current Channel: Version 2601 (Build 19628.20204)
- [CVE-2026-11111](https://example.com) CVE
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 1)
assert.Equal(t, "February 11, 2026", releases[0].Date)
})
t.Run("parses LTSC versions", func(t *testing.T) {
markdown := `# Security Updates
## March 11, 2026
Current Channel: Version 2602 (Build 19725.20172), Office LTSC 2024 Volume Licensed: Version 2408 (Build 17932.20700)
- [CVE-2026-12345](https://example.com) CVE
`
releases, err := parseSecurityMarkdown(strings.NewReader(markdown))
require.NoError(t, err)
require.Len(t, releases, 1)
require.Len(t, releases[0].Branches, 2)
// Find LTSC version
var ltscBranch *VersionBranch
for i := range releases[0].Branches {
if releases[0].Branches[i].Version == "2408" {
ltscBranch = &releases[0].Branches[i]
break
}
}
require.NotNil(t, ltscBranch)
assert.Equal(t, "17932", ltscBranch.BuildPrefix)
assert.Equal(t, "17932.20700", ltscBranch.FullBuild)
})
}
func TestCompareBuildVersions(t *testing.T) {
tests := []struct {
name string
a string
b string
expected int
}{
{"equal", "19725.20172", "19725.20172", 0},
{"a less than b - different prefix", "19628.20204", "19725.20172", -1},
{"a greater than b - different prefix", "19725.20172", "19628.20204", 1},
{"a less than b - same prefix", "19725.20170", "19725.20172", -1},
{"a greater than b - same prefix", "19725.20172", "19725.20170", 1},
{"different suffix lengths", "19725.20170", "19725.201720", -1},
{"different prefix lengths - a shorter", "9999.1", "10000.1", -1},
{"different prefix lengths - a longer", "10000.1", "9999.1", 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := compareBuildVersions(tc.a, tc.b)
assert.Equal(t, tc.expected, result)
})
}
}
func TestBuildBulletinFile(t *testing.T) {
t.Run("builds version-indexed structure", func(t *testing.T) {
releases := []SecurityRelease{
{
Date: "March 11, 2026",
Branches: []VersionBranch{
{Version: "2602", BuildPrefix: "19725", FullBuild: "19725.20172"},
{Version: "2512", BuildPrefix: "19530", FullBuild: "19530.20260"},
},
CVEs: []string{"CVE-2026-12345", "CVE-2026-12346"},
},
{
Date: "February 11, 2026",
Branches: []VersionBranch{
{Version: "2601", BuildPrefix: "19628", FullBuild: "19628.20204"},
{Version: "2512", BuildPrefix: "19530", FullBuild: "19530.20200"},
},
CVEs: []string{"CVE-2026-11111"},
},
}
bulletin := BuildBulletinFile(releases)
// Check build prefix mappings
assert.Equal(t, "2602", bulletin.BuildPrefixes["19725"])
assert.Equal(t, "2512", bulletin.BuildPrefixes["19530"])
assert.Equal(t, "2601", bulletin.BuildPrefixes["19628"])
// Check version 2602 has its CVEs
require.NotNil(t, bulletin.Versions["2602"])
var found2602 bool
for _, su := range bulletin.Versions["2602"].SecurityUpdates {
if su.CVE == "CVE-2026-12345" {
found2602 = true
assert.Equal(t, "16.0.19725.20172", su.ResolvedInVersion)
}
}
assert.True(t, found2602)
})
t.Run("first fix wins for same CVE", func(t *testing.T) {
releases := []SecurityRelease{
{
Date: "March 11, 2026",
Branches: []VersionBranch{
{Version: "2602", BuildPrefix: "19725", FullBuild: "19725.20172"},
},
CVEs: []string{"CVE-2026-12345"},
},
{
Date: "February 11, 2026",
Branches: []VersionBranch{
{Version: "2602", BuildPrefix: "19725", FullBuild: "19725.20100"},
},
CVEs: []string{"CVE-2026-12345"}, // Same CVE, different build
},
}
bulletin := BuildBulletinFile(releases)
// Should have first (March) build, not February
for _, su := range bulletin.Versions["2602"].SecurityUpdates {
if su.CVE == "CVE-2026-12345" {
assert.Equal(t, "16.0.19725.20172", su.ResolvedInVersion)
}
}
})
t.Run("adds upgrade paths only for deprecated versions", func(t *testing.T) {
releases := []SecurityRelease{
{
// Most recent release - defines currently supported versions
Date: "March 11, 2026",
Branches: []VersionBranch{
{Version: "2602", BuildPrefix: "19725", FullBuild: "19725.20172"},
{Version: "2512", BuildPrefix: "19530", FullBuild: "19530.20260"},
},
CVEs: []string{"CVE-2026-12345"},
},
{
// Older release with a version (2400) that's no longer in the latest
Date: "January 11, 2026",
Branches: []VersionBranch{
{Version: "2512", BuildPrefix: "19530", FullBuild: "19530.20100"},
{Version: "2400", BuildPrefix: "19000", FullBuild: "19000.20100"},
},
CVEs: []string{"CVE-2026-11111"},
},
}
bulletin := BuildBulletinFile(releases)
// Version 2512 is still supported (in latest release) - should only have direct fixes
require.NotNil(t, bulletin.Versions["2512"])
cves2512 := make(map[string]bool)
for _, su := range bulletin.Versions["2512"].SecurityUpdates {
cves2512[su.CVE] = true
}
assert.True(t, cves2512["CVE-2026-12345"], "2512 should have direct fix for CVE-2026-12345")
assert.True(t, cves2512["CVE-2026-11111"], "2512 should have direct fix for CVE-2026-11111")
// Version 2400 is deprecated (not in latest release) - should have upgrade path
require.NotNil(t, bulletin.Versions["2400"])
var foundUpgradePath bool
for _, su := range bulletin.Versions["2400"].SecurityUpdates {
if su.CVE == "CVE-2026-12345" {
foundUpgradePath = true
// Should point to 2512's fix (oldest version > 2400 with a fix)
assert.Equal(t, "16.0.19530.20260", su.ResolvedInVersion)
}
}
assert.True(t, foundUpgradePath, "deprecated version 2400 should have upgrade path for CVE-2026-12345")
})
}