Add Windows Office vulnerability detection runtime (3/3) (#42872)

This commit is contained in:
Tim Lee 2026-04-03 09:44:55 -06:00 committed by GitHub
parent 854fa2af62
commit 3c6042b623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1041 additions and 1 deletions

View file

@ -0,0 +1 @@
* Added vulnerability detection for Microsoft 365 Apps and Office products on Windows.

View file

@ -43,6 +43,7 @@ import (
"github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/oval"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/utils"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/winoffice"
"github.com/fleetdm/fleet/v4/server/webhooks"
"github.com/fleetdm/fleet/v4/server/worker"
"go.opentelemetry.io/otel/attribute"
@ -196,6 +197,7 @@ func scanVulnerabilities(
ovalVulns := checkOvalVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
govalDictVulns := checkGovalDictionaryVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
macOfficeVulns := checkMacOfficeVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
winOfficeVulns := checkWinOfficeVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
customVulns := checkCustomVulnerabilities(ctx, ds, logger, vulnAutomationEnabled != "", startTime)
checkWinVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
@ -220,10 +222,11 @@ func scanVulnerabilities(
trace.WithAttributes(attribute.String("automation_type", vulnAutomationEnabled)))
defer automationSpan.End()
vulns := make([]fleet.SoftwareVulnerability, 0, len(nvdVulns)+len(ovalVulns)+len(macOfficeVulns))
vulns := make([]fleet.SoftwareVulnerability, 0, len(nvdVulns)+len(ovalVulns)+len(macOfficeVulns)+len(winOfficeVulns))
vulns = append(vulns, nvdVulns...)
vulns = append(vulns, ovalVulns...)
vulns = append(vulns, macOfficeVulns...)
vulns = append(vulns, winOfficeVulns...)
vulns = append(vulns, govalDictVulns...)
vulns = append(vulns, customVulns...)
@ -595,6 +598,45 @@ func checkMacOfficeVulnerabilities(
return r
}
func checkWinOfficeVulnerabilities(
ctx context.Context,
ds fleet.Datastore,
logger *slog.Logger,
vulnPath string,
config *config.VulnerabilitiesConfig,
collectVulns bool,
) []fleet.SoftwareVulnerability {
ctx, span := tracer.Start(ctx, "vuln.check_winoffice")
defer span.End()
if !config.DisableDataSync {
syncCtx, syncSpan := tracer.Start(ctx, "vuln.winoffice.sync")
err := winoffice.SyncFromGithub(syncCtx, vulnPath)
if err != nil {
errHandler(syncCtx, logger, "updating windows office bulletin", err)
}
syncSpan.End()
logger.DebugContext(ctx, "finished sync windows office bulletin")
}
analyzeCtx, analyzeSpan := tracer.Start(ctx, "vuln.winoffice.analyze")
start := time.Now()
r, err := winoffice.Analyze(analyzeCtx, ds, vulnPath, collectVulns)
elapsed := time.Since(start)
logger.DebugContext(analyzeCtx, "win-office-analysis-done",
"elapsed", elapsed,
"found new", len(r))
if err != nil {
errHandler(analyzeCtx, logger, "analyzing windows office products for vulnerabilities", err)
}
analyzeSpan.End()
return r
}
func newAutomationsSchedule(
ctx context.Context,
instanceID string,

View file

@ -128,6 +128,7 @@ const (
MacOfficeReleaseNotesSource
CustomSource
GovalDictionarySource
WinOfficeSource
)
type VulnerabilityWithMetadata struct {

View file

@ -8,6 +8,7 @@ import (
type FSAPI interface {
MSRCBulletins() ([]MetadataFileName, error)
WinOfficeBulletin() ([]MetadataFileName, error)
MacOfficeReleaseNotes() ([]MetadataFileName, error)
Delete(MetadataFileName) error
}
@ -33,6 +34,11 @@ func (fs FSClient) MSRCBulletins() ([]MetadataFileName, error) {
return fs.list(mSRCFilePrefix, NewMSRCMetadata)
}
// WinOfficeBulletin walks 'dir' returning all Windows Office bulletin files.
func (fs FSClient) WinOfficeBulletin() ([]MetadataFileName, error) {
return fs.list(winOfficePrefix, NewWinOfficeMetadata)
}
// MacOfficeReleaseNotes walks 'dir' returning all mac office release notes
func (fs FSClient) MacOfficeReleaseNotes() ([]MetadataFileName, error) {
return fs.list(macOfficeReleaseNotesPrefix, NewMacOfficeRelNotesMetadata)

View file

@ -29,6 +29,7 @@ type ReleaseLister interface {
type GitHubAPI interface {
Download(string) (string, error)
MSRCBulletins(context.Context) (map[MetadataFileName]string, error)
WinOfficeBulletin(context.Context) (MetadataFileName, string, error)
MacOfficeReleaseNotes(context.Context) (MetadataFileName, string, error)
}
@ -70,6 +71,27 @@ func (gh GitHubClient) MSRCBulletins(ctx context.Context) (map[MetadataFileName]
return gh.list(ctx, mSRCFilePrefix, NewMSRCMetadata)
}
// WinOfficeBulletin returns the 'MetadataFilename' and 'download URL' of the latest Windows Office bulletin
// stored in our Github NVD repo (https://github.com/fleetdm/nvd/releases)
func (gh GitHubClient) WinOfficeBulletin(ctx context.Context) (MetadataFileName, string, error) {
resultMap, err := gh.list(ctx, winOfficePrefix, NewWinOfficeMetadata)
if err != nil {
return MetadataFileName{}, "", err
}
// Find the most recent bulletin
var latest MetadataFileName
var latestURL string
for k, v := range resultMap {
if latest.filename == "" || latest.Before(k) {
latest = k
latestURL = v
}
}
return latest, latestURL, nil
}
// MacOfficeReleaseNotes returns the 'MetadataFilename' and the 'download URL' of the latest Mac Office Release
// Note asset stored in our Github NVD repo (https://github.com/fleetdm/nvd/releases)
func (gh GitHubClient) MacOfficeReleaseNotes(ctx context.Context) (MetadataFileName, string, error) {

View file

@ -41,6 +41,15 @@ func NewMacOfficeRelNotesMetadata(filename string) (MetadataFileName, error) {
return mfn, err
}
func NewWinOfficeMetadata(filename string) (MetadataFileName, error) {
mfn := MetadataFileName{prefix: winOfficePrefix, filename: filename}
// Check that the filename contains a valid timestamp
_, err := mfn.date()
return mfn, err
}
func (mfn MetadataFileName) date() (time.Time, error) {
parts := strings.Split(mfn.filename, "-")

View file

@ -32,6 +32,13 @@ func (gh ghMock) MSRCBulletins(ctx context.Context) (map[io.MetadataFileName]str
return gh.TestData.RemoteList, gh.TestData.RemoteListError
}
func (gh ghMock) WinOfficeBulletin(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, gh.TestData.RemoteListError
}
return io.MetadataFileName{}, "", gh.TestData.RemoteListError
}
func (gh ghMock) MacOfficeReleaseNotes(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, gh.TestData.RemoteListError
@ -50,6 +57,10 @@ func (fs fsMock) MSRCBulletins() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}
func (fs fsMock) WinOfficeBulletin() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}
func (fs fsMock) MacOfficeReleaseNotes() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}

View file

@ -28,6 +28,13 @@ func (gh ghMock) MSRCBulletins(ctx context.Context) (map[io.MetadataFileName]str
return gh.TestData.RemoteList, nil
}
func (gh ghMock) WinOfficeBulletin(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, nil
}
return io.MetadataFileName{}, "", nil
}
func (gh ghMock) MacOfficeReleaseNotes(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, nil
@ -46,6 +53,10 @@ func (fs fsMock) MSRCBulletins() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, nil
}
func (fs fsMock) WinOfficeBulletin() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, nil
}
func (fs fsMock) MacOfficeReleaseNotes() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, nil
}

View file

@ -0,0 +1,267 @@
package winoffice
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/io"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/utils"
)
// getLatestBulletin returns the most recent Windows Office bulletin (based on the date in the
// filename) contained in 'vulnPath'.
func getLatestBulletin(vulnPath string) (*BulletinFile, error) {
fs := io.NewFSClient(vulnPath)
files, err := fs.WinOfficeBulletin()
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, nil
}
sort.Slice(files, func(i, j int) bool { return files[j].Before(files[i]) })
filePath := filepath.Join(vulnPath, files[0].String())
payload, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var bulletin BulletinFile
if err := json.Unmarshal(payload, &bulletin); err != nil {
return nil, err
}
return &bulletin, nil
}
// parseOfficeVersion parses a Windows Office version string like "16.0.19725.20204"
// and returns the build prefix and build suffix.
func parseOfficeVersion(version string) (buildPrefix, buildSuffix string, err error) {
if !strings.HasPrefix(version, OfficeVersionPrefix) {
return "", "", fmt.Errorf("invalid Office version prefix: %s", version)
}
parts := strings.Split(version, ".")
if len(parts) < 4 {
return "", "", fmt.Errorf("invalid Office version format: %s", version)
}
return parts[2], parts[3], nil
}
// compareBuildSuffix compares two build suffixes numerically.
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareBuildSuffix(a, b string) int {
// Pad to same length for proper string comparison
maxLen := max(len(a), len(b))
paddedA := fmt.Sprintf("%0*s", maxLen, a)
paddedB := fmt.Sprintf("%0*s", maxLen, b)
if paddedA < paddedB {
return -1
}
if paddedA > paddedB {
return 1
}
return 0
}
// CheckVersion returns all CVEs affecting the given Office version.
// This is the main entry point for vulnerability matching.
func CheckVersion(version string, bulletin *BulletinFile) []fleet.SoftwareVulnerability {
if bulletin == nil {
return nil
}
software := &fleet.Software{Version: version}
return collectVulnerabilities(software, bulletin)
}
// collectVulnerabilities finds all CVEs that affect the given software based on the bulletin.
func collectVulnerabilities(
software *fleet.Software,
bulletin *BulletinFile,
) []fleet.SoftwareVulnerability {
buildPrefix, buildSuffix, err := parseOfficeVersion(software.Version)
if err != nil {
// Invalid version format, skip
return nil
}
// Look up version branch from build prefix
versionBranch, ok := bulletin.BuildPrefixes[buildPrefix]
if !ok {
// Unknown build prefix, might be very old or very new
return nil
}
// Get security updates for this version
versionBulletin, ok := bulletin.Versions[versionBranch]
if !ok {
// No data for this version
return nil
}
var vulns []fleet.SoftwareVulnerability
for _, update := range versionBulletin.SecurityUpdates {
// Parse the fixed build
fixedPrefix, fixedSuffix, err := parseOfficeVersion(update.ResolvedInVersion)
if err != nil {
continue
}
// Check if fix is for a different build prefix.
// This happens when a version branch has multiple build prefixes over time
// (e.g., LTSC 2024 with prefixes 17928 and 17932).
// Only vulnerable if the fix's prefix is NEWER than the host's prefix.
if fixedPrefix != buildPrefix {
// Compare prefixes numerically - host is only vulnerable if fix prefix > host prefix
fixedPrefixNum, err := strconv.Atoi(fixedPrefix)
if err != nil {
continue
}
buildPrefixNum, err := strconv.Atoi(buildPrefix)
if err != nil {
continue
}
if fixedPrefixNum > buildPrefixNum {
resolvedVersion := update.ResolvedInVersion
vulns = append(vulns, fleet.SoftwareVulnerability{
SoftwareID: software.ID,
CVE: update.CVE,
ResolvedInVersion: &resolvedVersion,
})
}
continue
}
// Same version branch - compare build suffixes
// If host build suffix < fixed build suffix, the host is vulnerable
if compareBuildSuffix(buildSuffix, fixedSuffix) < 0 {
resolvedVersion := update.ResolvedInVersion
vulns = append(vulns, fleet.SoftwareVulnerability{
SoftwareID: software.ID,
CVE: update.CVE,
ResolvedInVersion: &resolvedVersion,
})
}
}
return vulns
}
// getStoredVulnerabilities returns all stored vulnerabilities for 'softwareID'.
func getStoredVulnerabilities(
ctx context.Context,
ds fleet.Datastore,
softwareID uint,
) ([]fleet.SoftwareVulnerability, error) {
storedSoftware, err := ds.SoftwareByID(ctx, softwareID, nil, false, nil)
if err != nil {
return nil, err
}
var result []fleet.SoftwareVulnerability
for _, v := range storedSoftware.Vulnerabilities {
result = append(result, fleet.SoftwareVulnerability{
SoftwareID: storedSoftware.ID,
CVE: v.CVE,
})
}
return result, nil
}
func updateVulnsInDB(
ctx context.Context,
ds fleet.Datastore,
detected []fleet.SoftwareVulnerability,
existing []fleet.SoftwareVulnerability,
) ([]fleet.SoftwareVulnerability, error) {
toInsert, toDelete := utils.VulnsDelta(detected, existing)
// Remove any possible dups
toInsertSet := make(map[string]fleet.SoftwareVulnerability, len(toInsert))
for _, i := range toInsert {
toInsertSet[i.Key()] = i
}
if err := ds.DeleteSoftwareVulnerabilities(ctx, toDelete); err != nil {
return nil, err
}
allVulns := make([]fleet.SoftwareVulnerability, 0, len(toInsertSet))
for _, v := range toInsertSet {
allVulns = append(allVulns, v)
}
return ds.InsertSoftwareVulnerabilities(ctx, allVulns, fleet.WinOfficeSource)
}
// Analyze uses the most recent Windows Office bulletin in 'vulnPath' for detecting
// vulnerabilities on Windows Office apps.
func Analyze(
ctx context.Context,
ds fleet.Datastore,
vulnPath string,
collectVulns bool,
) ([]fleet.SoftwareVulnerability, error) {
bulletin, err := getLatestBulletin(vulnPath)
if err != nil {
return nil, err
}
if bulletin == nil {
return nil, nil
}
// Query for Windows Office software from "programs" source.
// Use NameMatch/NameExclude to filter at the database level.
queryParams := fleet.SoftwareIterQueryOptions{
IncludedSources: []string{"programs"},
NameMatch: "Microsoft (365|Office)",
NameExclude: "[Cc]ompanion",
}
iter, err := ds.AllSoftwareIterator(ctx, queryParams)
if err != nil {
return nil, err
}
defer iter.Close()
var vulnerabilities []fleet.SoftwareVulnerability
for iter.Next() {
software, err := iter.Value()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software from iterator")
}
detected := collectVulnerabilities(software, bulletin)
existing, err := getStoredVulnerabilities(ctx, ds, software.ID)
if err != nil {
return nil, err
}
inserted, err := updateVulnsInDB(ctx, ds, detected, existing)
if err != nil {
return nil, err
}
if collectVulns {
vulnerabilities = append(vulnerabilities, inserted...)
}
}
if err := iter.Err(); err != nil {
return nil, fmt.Errorf("iter: %w", err)
}
return vulnerabilities, nil
}

View file

@ -0,0 +1,238 @@
package winoffice
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOfficeVersion(t *testing.T) {
tests := []struct {
name string
version string
wantPrefix string
wantSuffix string
wantErr bool
}{
{
name: "valid version",
version: "16.0.19725.20204",
wantPrefix: "19725",
wantSuffix: "20204",
},
{
name: "invalid version - too few parts",
version: "16.0.19725",
wantErr: true,
},
{
name: "invalid version - wrong prefix",
version: "17.0.19725.20204",
wantErr: true,
},
{
name: "invalid version - no prefix",
version: "19725.20204",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix, suffix, err := parseOfficeVersion(tt.version)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.wantPrefix, prefix)
assert.Equal(t, tt.wantSuffix, suffix)
}
})
}
}
func TestCompareBuildSuffix(t *testing.T) {
tests := []struct {
name string
a string
b string
expected int
}{
{"a less than b", "20100", "20200", -1},
{"a greater than b", "20300", "20200", 1},
{"equal", "20200", "20200", 0},
{"different lengths - shorter is less", "200", "20200", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := compareBuildSuffix(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
func testBulletin() *BulletinFile {
return &BulletinFile{
Version: 1,
BuildPrefixes: map[string]string{
"17928": "ltsc2024",
"17932": "ltsc2024",
"19725": "2602",
},
Versions: map[string]*VersionBulletin{
"ltsc2024": {
SecurityUpdates: []SecurityUpdate{
{CVE: "CVE-2024-0001", ResolvedInVersion: "16.0.17928.20500"},
{CVE: "CVE-2024-0002", ResolvedInVersion: "16.0.17932.20100"},
},
},
"2602": {
SecurityUpdates: []SecurityUpdate{
{CVE: "CVE-2024-0003", ResolvedInVersion: "16.0.19725.20200"},
},
},
},
}
}
func TestCheckVersion(t *testing.T) {
bulletin := testBulletin()
tests := []struct {
name string
version string
wantCVEs []string
}{
{
name: "nil bulletin returns nil",
version: "16.0.19725.20100",
wantCVEs: nil,
},
{
name: "invalid version format",
version: "not-a-version",
wantCVEs: nil,
},
{
name: "unknown build prefix",
version: "16.0.99999.20100",
wantCVEs: nil,
},
{
name: "same prefix - host older than fix",
version: "16.0.19725.20100",
wantCVEs: []string{"CVE-2024-0003"},
},
{
name: "same prefix - host equal to fix",
version: "16.0.19725.20200",
wantCVEs: nil,
},
{
name: "same prefix - host newer than fix",
version: "16.0.19725.20300",
wantCVEs: nil,
},
{
name: "cross-prefix - fix has newer prefix, host vulnerable",
version: "16.0.17928.20100",
// CVE-2024-0001 has same prefix 17928, host 20100 < fix 20500 → vulnerable
// CVE-2024-0002 has prefix 17932 > 17928 → vulnerable (newer prefix)
wantCVEs: []string{"CVE-2024-0001", "CVE-2024-0002"},
},
{
name: "cross-prefix - fix has older prefix, host not vulnerable",
version: "16.0.17932.20050",
// CVE-2024-0001 has prefix 17928 < 17932 → not vulnerable (older prefix)
// CVE-2024-0002 has same prefix 17932, host 20050 < fix 20100 → vulnerable
wantCVEs: []string{"CVE-2024-0002"},
},
{
name: "cross-prefix - host on newer prefix, fully patched suffix",
version: "16.0.17932.20200",
// CVE-2024-0001 has prefix 17928 < 17932 → not vulnerable
// CVE-2024-0002 has same prefix 17932, host 20200 > fix 20100 → not vulnerable
wantCVEs: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := bulletin
if tt.name == "nil bulletin returns nil" {
b = nil
}
vulns := CheckVersion(tt.version, b)
if tt.wantCVEs == nil {
assert.Empty(t, vulns)
} else {
require.Len(t, vulns, len(tt.wantCVEs))
var gotCVEs []string
for _, v := range vulns {
gotCVEs = append(gotCVEs, v.CVE)
}
assert.Equal(t, tt.wantCVEs, gotCVEs)
}
})
}
}
func TestCheckVersionNumericPrefixComparison(t *testing.T) {
// Regression test: prefixes of different lengths must compare numerically, not lexicographically.
// "9999" < "10000" numerically, but "9999" > "10000" lexicographically.
bulletin := &BulletinFile{
Version: 1,
BuildPrefixes: map[string]string{
"9999": "branch1",
"10000": "branch1",
},
Versions: map[string]*VersionBulletin{
"branch1": {
SecurityUpdates: []SecurityUpdate{
{CVE: "CVE-2024-9999", ResolvedInVersion: "16.0.10000.20100"},
},
},
},
}
// Host on prefix 9999 (numerically smaller) with fix on prefix 10000 (numerically larger).
// Host should be vulnerable because 10000 > 9999.
vulns := CheckVersion("16.0.9999.20050", bulletin)
require.Len(t, vulns, 1)
assert.Equal(t, "CVE-2024-9999", vulns[0].CVE)
// Host on prefix 10000 with fix on prefix 9999 would mean fix is for older prefix.
// But we need a bulletin where the fix has prefix 9999.
bulletin2 := &BulletinFile{
Version: 1,
BuildPrefixes: map[string]string{
"9999": "branch1",
"10000": "branch1",
},
Versions: map[string]*VersionBulletin{
"branch1": {
SecurityUpdates: []SecurityUpdate{
{CVE: "CVE-2024-8888", ResolvedInVersion: "16.0.9999.20100"},
},
},
},
}
// Host on prefix 10000 (numerically larger), fix on prefix 9999 (numerically smaller).
// Host should NOT be vulnerable.
vulns = CheckVersion("16.0.10000.20050", bulletin2)
assert.Empty(t, vulns)
}
func TestCheckVersionResolvedVersionPointer(t *testing.T) {
bulletin := testBulletin()
vulns := CheckVersion("16.0.19725.20100", bulletin)
require.Len(t, vulns, 1)
assert.Equal(t, "CVE-2024-0003", vulns[0].CVE)
require.NotNil(t, vulns[0].ResolvedInVersion)
assert.Equal(t, "16.0.19725.20200", *vulns[0].ResolvedInVersion)
assert.Equal(t, uint(0), vulns[0].SoftwareID)
}

View file

@ -0,0 +1,153 @@
package winoffice_test
import (
"context"
"slices"
"strconv"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/nettest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/winoffice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIntegrationCheckVersion(t *testing.T) {
nettest.Run(t)
client := fleethttp.NewClient(fleethttp.WithTimeout(60 * time.Second))
bulletin, err := winoffice.FetchBulletin(context.Background(), client)
require.NoError(t, err)
require.NotEmpty(t, bulletin.Versions)
require.NotEmpty(t, bulletin.BuildPrefixes)
// Get the newest build prefix (compare numerically, not lexically)
var testPrefix string
var maxPrefixNum int
for prefix := range bulletin.BuildPrefixes {
num, err := strconv.Atoi(prefix)
if err != nil {
continue
}
if num > maxPrefixNum {
maxPrefixNum = num
testPrefix = prefix
}
}
t.Logf("Bulletin: %d versions", len(bulletin.Versions))
t.Run("old version is vulnerable", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0."+testPrefix+".00001", bulletin)
assert.NotEmpty(t, vulns, "old build should have vulnerabilities")
t.Logf("Version 16.0.%s.00001: %d CVEs", testPrefix, len(vulns))
})
t.Run("latest version is not vulnerable", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0."+testPrefix+".99999", bulletin)
assert.Empty(t, vulns, "latest build should have no vulnerabilities")
})
t.Run("unknown version returns no vulnerabilities", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.99999.99999", bulletin)
assert.Empty(t, vulns, "unknown version should return empty")
})
t.Run("invalid version returns no vulnerabilities", func(t *testing.T) {
vulns := winoffice.CheckVersion("invalid", bulletin)
assert.Empty(t, vulns, "invalid version should return empty")
})
// Test versions without data return empty (not false positives)
t.Run("version not in bulletin returns empty", func(t *testing.T) {
// Use a made-up build prefix that won't be in the bulletin
vulns := winoffice.CheckVersion("16.0.12345.20000", bulletin)
assert.Empty(t, vulns, "unknown version should return empty, not false positives")
})
// These tests verify specific CVEs and resolved versions.
// Counts use GreaterOrEqual since new security updates increase CVE counts over time.
t.Run("known suffix version includes March CVEs", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.19628.20204", bulletin)
assert.GreaterOrEqual(t, len(vulns), 7, "expected at least 7 CVEs for 16.0.19628.20204")
// Version 2601 (19628) is deprecated, so fix points to newer version 2602 (19725)
assertCVEWithResolution(t, vulns, "CVE-2026-26107", "16.0.19725.20170")
})
t.Run("unknown suffix version includes March CVEs", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.19628.20205", bulletin)
assert.GreaterOrEqual(t, len(vulns), 7, "expected at least 7 CVEs for 16.0.19628.20205")
// Version 2601 (19628) is deprecated, so fix points to newer version 2602 (19725)
assertCVEWithResolution(t, vulns, "CVE-2026-26107", "16.0.19725.20170")
assertCVENotPresent(t, vulns, "CVE-2026-21509") // February CVE should not appear
})
t.Run("Monthly channel version includes March CVEs with a lower resolved version", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.19530.20226", bulletin)
assert.GreaterOrEqual(t, len(vulns), 7, "expected at least 7 CVEs for 16.0.19530.20226")
assertCVEWithResolution(t, vulns, "CVE-2026-26107", "16.0.19530.20260")
assertCVENotPresent(t, vulns, "CVE-2026-21509") // February CVE should not appear
})
t.Run("deprecated monthly channel version includes March CVEs with a higher resolved version", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.19328.20306", bulletin)
assert.GreaterOrEqual(t, len(vulns), 7, "expected at least 7 CVEs for 16.0.19328.20306")
// Version 2510 (19328) is deprecated, so fix points to newer version 2511 (19426)
assertCVEWithResolution(t, vulns, "CVE-2026-26107", "16.0.19426.20314")
})
t.Run("January release includes March and Feb CVEs", func(t *testing.T) {
vulns := winoffice.CheckVersion("16.0.19530.20144", bulletin)
assert.GreaterOrEqual(t, len(vulns), 14, "expected at least 14 CVEs for 16.0.19530.20144")
assertCVEWithResolution(t, vulns, "CVE-2026-26107", "16.0.19530.20260") // March 2026
assertCVEWithResolution(t, vulns, "CVE-2026-21509", "16.0.19530.20226") // February 2026
})
// False positive tests - versions at their fixed build should report no vulnerabilities
t.Run("no false positives for LTSC at fixed build", func(t *testing.T) {
// LTSC 2024 version 2408 has had multiple build prefixes (17928, 17932).
// A host at 17932.20700 should NOT have CVEs with fixes at older prefix 17928.
vulns := winoffice.CheckVersion("16.0.17932.20700", bulletin)
// These CVEs have fixes at 17928.XXXXX - should not appear for host at 17932
oldPrefixCVEs := []string{"CVE-2024-38016", "CVE-2024-38226", "CVE-2024-43463"}
for _, cve := range oldPrefixCVEs {
assertCVENotPresent(t, vulns, cve)
}
})
t.Run("no false positives for version at latest build", func(t *testing.T) {
// A version with the highest possible build suffix should have no vulnerabilities
vulns := winoffice.CheckVersion("16.0."+testPrefix+".99999", bulletin)
assert.Empty(t, vulns, "version at latest build should have no vulnerabilities")
})
t.Run("no false positives for unknown build prefix", func(t *testing.T) {
// A build prefix not in the bulletin should return empty, not upgrade paths
vulns := winoffice.CheckVersion("16.0.55555.20000", bulletin)
assert.Empty(t, vulns, "unknown build prefix should return empty, not false positives")
})
}
func assertCVEWithResolution(t *testing.T, vulns []fleet.SoftwareVulnerability, cve, resolvedIn string) {
t.Helper()
idx := slices.IndexFunc(vulns, func(v fleet.SoftwareVulnerability) bool {
return v.CVE == cve
})
require.NotEqual(t, -1, idx, "CVE %s should be in the list", cve)
require.NotNil(t, vulns[idx].ResolvedInVersion, "CVE %s should have a resolved version", cve)
assert.Equal(t, resolvedIn, *vulns[idx].ResolvedInVersion, "CVE %s resolved version mismatch", cve)
}
func assertCVENotPresent(t *testing.T, vulns []fleet.SoftwareVulnerability, cve string) {
t.Helper()
idx := slices.IndexFunc(vulns, func(v fleet.SoftwareVulnerability) bool {
return v.CVE == cve
})
assert.Equal(t, -1, idx, "CVE %s should NOT be in the list (false positive)", cve)
}

View file

@ -0,0 +1,74 @@
package winoffice
import (
"context"
"fmt"
"sort"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/io"
"github.com/google/go-github/v37/github"
)
// SyncFromGithub keeps the local Windows Office bulletin in sync with the one published in Github.
func SyncFromGithub(ctx context.Context, dstDir string) error {
client := fleethttp.NewGithubClient()
rep := github.NewClient(client).Repositories
gh := io.NewGitHubClient(client, rep, dstDir)
fs := io.NewFSClient(dstDir)
if err := syncBulletin(ctx, fs, gh); err != nil {
return fmt.Errorf("winoffice bulletin sync: %w", err)
}
return nil
}
func syncBulletin(
ctx context.Context,
fsClient io.FSAPI,
ghClient io.GitHubAPI,
) error {
remote, url, err := ghClient.WinOfficeBulletin(ctx)
if err != nil {
return err
}
// Nothing published yet on remote repo, so we do nothing.
if url == "" {
return nil
}
local, err := fsClient.WinOfficeBulletin()
if err != nil {
return err
}
if len(local) == 0 {
if _, err := ghClient.Download(url); err != nil {
return err
}
return nil
}
sort.Slice(local, func(i, j int) bool {
return local[j].Before(local[i])
})
if local[0].Before(remote) {
if _, err := ghClient.Download(url); err != nil {
return err
}
}
// Clean up out of date files
for _, l := range local {
if l.Before(remote) {
if err := fsClient.Delete(l); err != nil {
return err
}
}
}
return nil
}

View file

@ -0,0 +1,205 @@
package winoffice
import (
"context"
"errors"
"testing"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/io"
"github.com/stretchr/testify/require"
)
func newMetadataFile(t *testing.T, name string) io.MetadataFileName {
mfn, err := io.NewWinOfficeMetadata(name)
require.NoError(t, err)
return mfn
}
type testData struct {
RemoteList map[io.MetadataFileName]string
RemoteListError error
RemoteDownloaded []string
RemoteDownloadError error
LocalList []io.MetadataFileName
LocalListError error
LocalDeleted []io.MetadataFileName
LocalDeleteError error
}
type ghMock struct{ TestData *testData }
func (gh ghMock) MSRCBulletins(ctx context.Context) (map[io.MetadataFileName]string, error) {
return gh.TestData.RemoteList, gh.TestData.RemoteListError
}
func (gh ghMock) WinOfficeBulletin(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, gh.TestData.RemoteListError
}
return io.MetadataFileName{}, "", gh.TestData.RemoteListError
}
func (gh ghMock) MacOfficeReleaseNotes(ctx context.Context) (io.MetadataFileName, string, error) {
for k, v := range gh.TestData.RemoteList {
return k, v, gh.TestData.RemoteListError
}
return io.MetadataFileName{}, "", gh.TestData.RemoteListError
}
func (gh ghMock) Download(url string) (string, error) {
gh.TestData.RemoteDownloaded = append(gh.TestData.RemoteDownloaded, url)
return "", gh.TestData.RemoteDownloadError
}
type fsMock struct{ TestData *testData }
func (fs fsMock) MSRCBulletins() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}
func (fs fsMock) WinOfficeBulletin() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}
func (fs fsMock) MacOfficeReleaseNotes() ([]io.MetadataFileName, error) {
return fs.TestData.LocalList, fs.TestData.LocalListError
}
func (fs fsMock) Delete(d io.MetadataFileName) error {
fs.TestData.LocalDeleted = append(fs.TestData.LocalDeleted, d)
return fs.TestData.LocalDeleteError
}
func TestSyncBulletin(t *testing.T) {
ctx := t.Context()
remote := newMetadataFile(t, "fleet_winoffice_bulletin-2023_10_10.json")
t.Run("on GH error", func(t *testing.T) {
td := testData{
RemoteListError: errors.New("some error"),
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.Error(t, err)
})
t.Run("when nothing published on GH", func(t *testing.T) {
td := testData{}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
})
t.Run("on FS error", func(t *testing.T) {
td := testData{
RemoteList: map[io.MetadataFileName]string{{}: "http://someurl.com"},
LocalListError: errors.New("some error"),
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.Error(t, err)
})
t.Run("on error when downloading GH asset", func(t *testing.T) {
td := testData{
RemoteList: map[io.MetadataFileName]string{remote: "http://someurl.com"},
LocalList: []io.MetadataFileName{
newMetadataFile(t, "fleet_winoffice_bulletin-2020_10_10.json"),
},
RemoteDownloadError: errors.New("some error"),
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.Error(t, err)
})
t.Run("when there are no local files", func(t *testing.T) {
td := testData{
RemoteList: map[io.MetadataFileName]string{{}: "http://someurl.com"},
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
require.Empty(t, td.LocalDeleted)
require.Contains(t, td.RemoteDownloaded, "http://someurl.com")
})
t.Run("when local is newer than remote", func(t *testing.T) {
td := testData{
RemoteList: map[io.MetadataFileName]string{{}: "http://someurl.com"},
LocalList: []io.MetadataFileName{
newMetadataFile(t, "fleet_winoffice_bulletin-2024_09_10.json"),
},
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
require.Empty(t, td.LocalDeleted)
require.Empty(t, td.RemoteDownloaded)
})
t.Run("removes multiple out of date copies", func(t *testing.T) {
local := []io.MetadataFileName{
newMetadataFile(t, "fleet_winoffice_bulletin-2022_09_10.json"),
newMetadataFile(t, "fleet_winoffice_bulletin-2022_08_10.json"),
}
td := testData{
RemoteList: map[io.MetadataFileName]string{remote: "http://someurl.com"},
LocalList: local,
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
require.ElementsMatch(t, td.LocalDeleted, local)
require.Contains(t, td.RemoteDownloaded, "http://someurl.com")
})
t.Run("on error when deleting", func(t *testing.T) {
local := []io.MetadataFileName{
newMetadataFile(t, "fleet_winoffice_bulletin-2022_09_10.json"),
newMetadataFile(t, "fleet_winoffice_bulletin-2022_08_10.json"),
}
td := testData{
RemoteList: map[io.MetadataFileName]string{remote: "http://someurl.com"},
LocalList: local,
LocalDeleteError: errors.New("some error"),
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.Error(t, err)
})
t.Run("when local copy is out of date", func(t *testing.T) {
local := newMetadataFile(t, "fleet_winoffice_bulletin-2022_09_10.json")
td := testData{
RemoteList: map[io.MetadataFileName]string{remote: "http://someurl.com"},
LocalList: []io.MetadataFileName{local},
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
require.ElementsMatch(t, td.RemoteDownloaded, []string{"http://someurl.com"})
require.ElementsMatch(t, td.LocalDeleted, []io.MetadataFileName{local})
})
t.Run("when local copy is not out of date", func(t *testing.T) {
local := []io.MetadataFileName{
newMetadataFile(t, "fleet_winoffice_bulletin-2023_11_10.json"),
newMetadataFile(t, "fleet_winoffice_bulletin-2023_01_10.json"),
}
td := testData{
RemoteList: map[io.MetadataFileName]string{remote: "http://someurl.com"},
LocalList: local,
}
err := syncBulletin(ctx, fsMock{TestData: &td}, ghMock{TestData: &td})
require.NoError(t, err)
require.Empty(t, td.RemoteDownloaded)
require.ElementsMatch(t, td.LocalDeleted, []io.MetadataFileName{local[1]})
})
}