mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Add Windows Office vulnerability detection runtime (3/3) (#42872)
This commit is contained in:
parent
854fa2af62
commit
3c6042b623
13 changed files with 1041 additions and 1 deletions
1
changes/39316-winoffice-vulnerability-detection
Normal file
1
changes/39316-winoffice-vulnerability-detection
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added vulnerability detection for Microsoft 365 Apps and Office products on Windows.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ const (
|
|||
MacOfficeReleaseNotesSource
|
||||
CustomSource
|
||||
GovalDictionarySource
|
||||
WinOfficeSource
|
||||
)
|
||||
|
||||
type VulnerabilityWithMetadata struct {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, "-")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
267
server/vulnerabilities/winoffice/analyzer.go
Normal file
267
server/vulnerabilities/winoffice/analyzer.go
Normal 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
|
||||
}
|
||||
238
server/vulnerabilities/winoffice/analyzer_test.go
Normal file
238
server/vulnerabilities/winoffice/analyzer_test.go
Normal 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)
|
||||
}
|
||||
153
server/vulnerabilities/winoffice/integration_test.go
Normal file
153
server/vulnerabilities/winoffice/integration_test.go
Normal 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)
|
||||
}
|
||||
74
server/vulnerabilities/winoffice/sync.go
Normal file
74
server/vulnerabilities/winoffice/sync.go
Normal 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
|
||||
}
|
||||
205
server/vulnerabilities/winoffice/sync_test.go
Normal file
205
server/vulnerabilities/winoffice/sync_test.go
Normal 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]})
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue