mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Add Windows Office bulletin generator (1/3) (#42663)
This commit is contained in:
parent
7e0d0db1b1
commit
c269cf1c10
7 changed files with 795 additions and 0 deletions
45
cmd/winoffice/generate.go
Normal file
45
cmd/winoffice/generate.go
Normal 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.")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
50
server/vulnerabilities/winoffice/README.md
Normal file
50
server/vulnerabilities/winoffice/README.md
Normal 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`.
|
||||
53
server/vulnerabilities/winoffice/bulletin.go
Normal file
53
server/vulnerabilities/winoffice/bulletin.go
Normal 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)
|
||||
}
|
||||
340
server/vulnerabilities/winoffice/scraper.go
Normal file
340
server/vulnerabilities/winoffice/scraper.go
Normal 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
|
||||
}
|
||||
292
server/vulnerabilities/winoffice/scraper_test.go
Normal file
292
server/vulnerabilities/winoffice/scraper_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue