Use FMA names for macOS software (#42221)
This commit is contained in:
@@ -65,6 +65,12 @@ const (
|
||||
// changes, it'll linger for this amount of time. The curent
|
||||
// implementation assumes infrequent asset changes.
|
||||
defaultMDMConfigAssetExpiration = 15 * time.Minute
|
||||
|
||||
// FMA names cache stores a map of unique_identifier -> canonical name
|
||||
// for Fleet-maintained apps. Used during software ingestion to override
|
||||
// osquery-reported names with the FMA canonical name.
|
||||
fmaNamesByIdentifierKey = "FMANamesByIdentifier"
|
||||
defaultFMANamesByIdentifierExpiration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// cloneCache wraps the in memory cache with one that clones items before returning them.
|
||||
@@ -114,17 +120,18 @@ type cachedMysql struct {
|
||||
|
||||
c *cloneCache
|
||||
|
||||
appConfigExp time.Duration
|
||||
packsExp time.Duration
|
||||
scheduledQueriesExp time.Duration
|
||||
teamAgentOptionsExp time.Duration
|
||||
teamFeaturesExp time.Duration
|
||||
teamMDMConfigExp time.Duration
|
||||
defaultTeamConfigExp time.Duration
|
||||
queryByNameExp time.Duration
|
||||
queryResultsCountExp time.Duration
|
||||
yaraRuleByNameExp time.Duration
|
||||
mdmConfigAssetExp time.Duration
|
||||
appConfigExp time.Duration
|
||||
packsExp time.Duration
|
||||
scheduledQueriesExp time.Duration
|
||||
teamAgentOptionsExp time.Duration
|
||||
teamFeaturesExp time.Duration
|
||||
teamMDMConfigExp time.Duration
|
||||
defaultTeamConfigExp time.Duration
|
||||
queryByNameExp time.Duration
|
||||
queryResultsCountExp time.Duration
|
||||
yaraRuleByNameExp time.Duration
|
||||
mdmConfigAssetExp time.Duration
|
||||
fmaNamesByIdentifierExp time.Duration
|
||||
}
|
||||
|
||||
type Option func(*cachedMysql)
|
||||
@@ -195,21 +202,28 @@ func WithDefaultTeamConfigExpiration(d time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithFMANamesByIdentifierExpiration(d time.Duration) Option {
|
||||
return func(o *cachedMysql) {
|
||||
o.fmaNamesByIdentifierExp = d
|
||||
}
|
||||
}
|
||||
|
||||
func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
|
||||
c := &cachedMysql{
|
||||
Datastore: ds,
|
||||
c: &cloneCache{cache.New(5*time.Minute, 10*time.Minute)},
|
||||
appConfigExp: defaultAppConfigExpiration,
|
||||
packsExp: defaultPacksExpiration,
|
||||
scheduledQueriesExp: defaultScheduledQueriesExpiration,
|
||||
teamAgentOptionsExp: defaultTeamAgentOptionsExpiration,
|
||||
teamFeaturesExp: defaultTeamFeaturesExpiration,
|
||||
teamMDMConfigExp: defaultTeamMDMConfigExpiration,
|
||||
defaultTeamConfigExp: defaultDefaultTeamConfigExpiration,
|
||||
queryByNameExp: defaultQueryByNameExpiration,
|
||||
queryResultsCountExp: defaultQueryResultsCountExpiration,
|
||||
yaraRuleByNameExp: defaultYaraRuleByNameExpiration,
|
||||
mdmConfigAssetExp: defaultMDMConfigAssetExpiration,
|
||||
Datastore: ds,
|
||||
c: &cloneCache{cache.New(5*time.Minute, 10*time.Minute)},
|
||||
appConfigExp: defaultAppConfigExpiration,
|
||||
packsExp: defaultPacksExpiration,
|
||||
scheduledQueriesExp: defaultScheduledQueriesExpiration,
|
||||
teamAgentOptionsExp: defaultTeamAgentOptionsExpiration,
|
||||
teamFeaturesExp: defaultTeamFeaturesExpiration,
|
||||
teamMDMConfigExp: defaultTeamMDMConfigExpiration,
|
||||
defaultTeamConfigExp: defaultDefaultTeamConfigExpiration,
|
||||
queryByNameExp: defaultQueryByNameExpiration,
|
||||
queryResultsCountExp: defaultQueryResultsCountExpiration,
|
||||
yaraRuleByNameExp: defaultYaraRuleByNameExpiration,
|
||||
mdmConfigAssetExp: defaultMDMConfigAssetExpiration,
|
||||
fmaNamesByIdentifierExp: defaultFMANamesByIdentifierExpiration,
|
||||
}
|
||||
for _, fn := range opts {
|
||||
fn(c)
|
||||
@@ -541,3 +555,44 @@ func (ds *cachedMysql) ApplyYaraRules(ctx context.Context, rules []fleet.YaraRul
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *cachedMysql) GetFMANamesByIdentifier(ctx context.Context) (map[string]string, error) {
|
||||
if x, found := ds.c.Get(ctx, fmaNamesByIdentifierKey); found {
|
||||
if names, ok := x.(fmaNameMap); ok {
|
||||
return names, nil
|
||||
}
|
||||
}
|
||||
|
||||
names, err := ds.Datastore.GetFMANamesByIdentifier(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ds.c.Set(ctx, fmaNamesByIdentifierKey, fmaNameMap(names), ds.fmaNamesByIdentifierExp)
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (ds *cachedMysql) UpsertMaintainedApp(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error) {
|
||||
result, err := ds.Datastore.UpsertMaintainedApp(ctx, app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Invalidate the FMA names cache since an app was added/updated
|
||||
ds.c.Delete(fmaNamesByIdentifierKey)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ds *cachedMysql) ClearRemovedFleetMaintainedApps(ctx context.Context, slugsToKeep []string) error {
|
||||
err := ds.Datastore.ClearRemovedFleetMaintainedApps(ctx, slugsToKeep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate the FMA names cache since apps may have been removed
|
||||
ds.c.Delete(fmaNamesByIdentifierKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -981,3 +982,80 @@ func TestCachedYaraRules(t *testing.T) {
|
||||
require.Same(t, testRule1, rule1Expired)
|
||||
require.True(t, mockedDS.YaraRuleByNameFuncInvoked) // from DB after expiration
|
||||
}
|
||||
|
||||
func TestCachedFMANamesByIdentifier(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockedDS := new(mock.Store)
|
||||
ds := New(mockedDS, WithFMANamesByIdentifierExpiration(100*time.Millisecond))
|
||||
|
||||
fmaNames := map[string]string{
|
||||
"com.microsoft.VSCode": "Microsoft Visual Studio Code",
|
||||
"com.1password.1password": "1Password",
|
||||
}
|
||||
|
||||
mockedDS.GetFMANamesByIdentifierFunc = func(ctx context.Context) (map[string]string, error) {
|
||||
// Return a copy to avoid mutation
|
||||
result := make(map[string]string, len(fmaNames))
|
||||
maps.Copy(result, fmaNames)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
mockedDS.UpsertMaintainedAppFunc = func(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error) {
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// Test 1: Initial call hits the DB
|
||||
names, err := ds.GetFMANamesByIdentifier(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, names, 2)
|
||||
require.Equal(t, "Microsoft Visual Studio Code", names["com.microsoft.VSCode"])
|
||||
require.Equal(t, "1Password", names["com.1password.1password"])
|
||||
require.True(t, mockedDS.GetFMANamesByIdentifierFuncInvoked)
|
||||
mockedDS.GetFMANamesByIdentifierFuncInvoked = false
|
||||
|
||||
// Test 2: Second call uses cache
|
||||
names2, err := ds.GetFMANamesByIdentifier(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, names2, 2)
|
||||
require.False(t, mockedDS.GetFMANamesByIdentifierFuncInvoked) // from cache
|
||||
|
||||
// Test 3: Modifying returned map doesn't affect cache
|
||||
names2["com.microsoft.VSCode"] = "Modified"
|
||||
names3, err := ds.GetFMANamesByIdentifier(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Microsoft Visual Studio Code", names3["com.microsoft.VSCode"]) // still original
|
||||
|
||||
// Test 4: UpsertMaintainedApp invalidates cache
|
||||
_, err = ds.UpsertMaintainedApp(context.Background(), &fleet.MaintainedApp{
|
||||
Name: "New App",
|
||||
Slug: "new-app/darwin",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "com.new.app",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, mockedDS.UpsertMaintainedAppFuncInvoked)
|
||||
|
||||
// Update mock to return new data
|
||||
fmaNames["com.new.app"] = "New App"
|
||||
|
||||
// Next call should hit DB again since cache was invalidated
|
||||
names4, err := ds.GetFMANamesByIdentifier(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, names4, 3)
|
||||
require.Equal(t, "New App", names4["com.new.app"])
|
||||
require.True(t, mockedDS.GetFMANamesByIdentifierFuncInvoked)
|
||||
mockedDS.GetFMANamesByIdentifierFuncInvoked = false
|
||||
|
||||
// Test 5: Cache expiration
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Update mock to return different data
|
||||
fmaNames["com.microsoft.VSCode"] = "VS Code Updated"
|
||||
|
||||
// This call should get from DB again since cache expired
|
||||
names5, err := ds.GetFMANamesByIdentifier(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "VS Code Updated", names5["com.microsoft.VSCode"])
|
||||
require.True(t, mockedDS.GetFMANamesByIdentifierFuncInvoked)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cached_mysql
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
@@ -39,3 +40,16 @@ type integer int
|
||||
func (i integer) Clone() (fleet.Cloner, error) {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// fmaNameMap is a map of unique_identifier -> canonical FMA name.
|
||||
// Used during software ingestion to override osquery-reported names.
|
||||
type fmaNameMap map[string]string
|
||||
|
||||
func (m fmaNameMap) Clone() (fleet.Cloner, error) {
|
||||
if m == nil {
|
||||
return fmaNameMap(nil), nil
|
||||
}
|
||||
clone := make(fmaNameMap, len(m))
|
||||
maps.Copy(clone, m)
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,40 @@ ON DUPLICATE KEY UPDATE
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
appID = uint(id) //nolint:gosec // dismiss G115
|
||||
|
||||
// For darwin apps, update existing software_titles and software entries
|
||||
// to use the FMA canonical name. This ensures consistency when an FMA
|
||||
// is added for software that was previously ingested with osquery-reported names.
|
||||
//
|
||||
// We only run these UPDATEs when the FMA was actually inserted or modified.
|
||||
// MySQL's ON DUPLICATE KEY UPDATE returns RowsAffected:
|
||||
// 0 = duplicate key, no changes (existing FMA with same values)
|
||||
// 1 = new row inserted
|
||||
// 2 = duplicate key, values changed
|
||||
// Skip if RowsAffected == 0 since nothing changed.
|
||||
rowsAffected, _ := res.RowsAffected()
|
||||
if app.Platform == "darwin" && app.UniqueIdentifier != "" && rowsAffected > 0 {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE software_titles
|
||||
SET name = ?
|
||||
WHERE bundle_identifier = ?
|
||||
AND name != ?
|
||||
`, app.Name, app.UniqueIdentifier, app.Name)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update software_titles names for FMA")
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE software
|
||||
SET name = ?
|
||||
WHERE bundle_identifier = ?
|
||||
AND name != ?
|
||||
`, app.Name, app.UniqueIdentifier, app.Name)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update software names for FMA")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -180,6 +214,30 @@ func (ds *Datastore) ListAvailableFleetMaintainedApps(ctx context.Context, teamI
|
||||
return avail, meta, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetFMANamesByIdentifier(ctx context.Context) (map[string]string, error) {
|
||||
query := `SELECT unique_identifier, name FROM fleet_maintained_apps WHERE platform = 'darwin'`
|
||||
|
||||
rows, err := ds.reader(ctx).QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "query FMA names by identifier")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var identifier, name string
|
||||
if err := rows.Scan(&identifier, &name); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "scan FMA name row")
|
||||
}
|
||||
result[identifier] = name
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "iterate FMA name rows")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ClearRemovedFleetMaintainedApps(ctx context.Context, slugsToKeep []string) error {
|
||||
stmt := `DELETE FROM fleet_maintained_apps WHERE slug NOT IN (?)`
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ func TestMaintainedApps(t *testing.T) {
|
||||
{"SyncAndRemoveApps", testSyncAndRemoveApps},
|
||||
{"GetMaintainedAppBySlug", testGetMaintainedAppBySlug},
|
||||
{"ListAvailableAppsWindows", testListAvailableAppsWindows},
|
||||
{"GetFMANamesByIdentifier", testGetFMANamesByIdentifier},
|
||||
{"UpsertMaintainedAppUpdatesSoftware", testUpsertMaintainedAppUpdatesSoftware},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -629,3 +631,176 @@ func testListAvailableAppsWindows(t *testing.T, ds *Datastore) {
|
||||
// the darwin app should not be matched by name
|
||||
require.Nil(t, apps[1].TitleID)
|
||||
}
|
||||
|
||||
func testUpsertMaintainedAppUpdatesSoftware(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Create a host to associate software with
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
Hostname: "test-host",
|
||||
Platform: "darwin",
|
||||
OsqueryHostID: ptr.String("osquery-host-id"),
|
||||
NodeKey: ptr.String("node-key"),
|
||||
DetailUpdatedAt: ds.clock.Now(),
|
||||
LabelUpdatedAt: ds.clock.Now(),
|
||||
PolicyUpdatedAt: ds.clock.Now(),
|
||||
SeenTime: ds.clock.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create software entries with osquery-reported name ("Code" instead of "Microsoft Visual Studio Code")
|
||||
software := []fleet.Software{
|
||||
{
|
||||
Name: "Code",
|
||||
Version: "1.85.0",
|
||||
Source: "apps",
|
||||
BundleIdentifier: "com.microsoft.VSCode",
|
||||
},
|
||||
{
|
||||
Name: "Code",
|
||||
Version: "1.84.0",
|
||||
Source: "apps",
|
||||
BundleIdentifier: "com.microsoft.VSCode",
|
||||
},
|
||||
}
|
||||
|
||||
// Insert software using the normal ingestion path
|
||||
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the software and software_titles were created with the osquery name "Code"
|
||||
var softwareNames []string
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.SelectContext(ctx, q, &softwareNames,
|
||||
`SELECT name FROM software WHERE bundle_identifier = 'com.microsoft.VSCode' ORDER BY version`)
|
||||
})
|
||||
require.Len(t, softwareNames, 2)
|
||||
require.Equal(t, "Code", softwareNames[0])
|
||||
require.Equal(t, "Code", softwareNames[1])
|
||||
|
||||
var titleName string
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &titleName,
|
||||
`SELECT name FROM software_titles WHERE bundle_identifier = 'com.microsoft.VSCode'`)
|
||||
})
|
||||
require.Equal(t, "Code", titleName)
|
||||
|
||||
// Now upsert an FMA with the canonical name
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Microsoft Visual Studio Code",
|
||||
Slug: "visual-studio-code/darwin",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "com.microsoft.VSCode",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify software entries were updated to use the FMA canonical name
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.SelectContext(ctx, q, &softwareNames,
|
||||
`SELECT name FROM software WHERE bundle_identifier = 'com.microsoft.VSCode' ORDER BY version`)
|
||||
})
|
||||
require.Len(t, softwareNames, 2)
|
||||
require.Equal(t, "Microsoft Visual Studio Code", softwareNames[0])
|
||||
require.Equal(t, "Microsoft Visual Studio Code", softwareNames[1])
|
||||
|
||||
// Verify software_titles was also updated
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &titleName,
|
||||
`SELECT name FROM software_titles WHERE bundle_identifier = 'com.microsoft.VSCode'`)
|
||||
})
|
||||
require.Equal(t, "Microsoft Visual Studio Code", titleName)
|
||||
|
||||
// Verify upserting the same FMA again doesn't cause issues (idempotent)
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Microsoft Visual Studio Code",
|
||||
Slug: "visual-studio-code/darwin",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "com.microsoft.VSCode",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Names should still be the FMA canonical name
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.SelectContext(ctx, q, &softwareNames,
|
||||
`SELECT name FROM software WHERE bundle_identifier = 'com.microsoft.VSCode' ORDER BY version`)
|
||||
})
|
||||
require.Len(t, softwareNames, 2)
|
||||
require.Equal(t, "Microsoft Visual Studio Code", softwareNames[0])
|
||||
require.Equal(t, "Microsoft Visual Studio Code", softwareNames[1])
|
||||
|
||||
// Verify Windows FMA does NOT update darwin software entries
|
||||
// First create darwin software with a different bundle_id
|
||||
software2 := []fleet.Software{
|
||||
{
|
||||
Name: "Some App",
|
||||
Version: "1.0.0",
|
||||
Source: "apps",
|
||||
BundleIdentifier: "com.example.someapp",
|
||||
},
|
||||
}
|
||||
_, err = ds.UpdateHostSoftware(ctx, host.ID, append(software, software2...))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upsert a Windows FMA - should not affect darwin software
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Some App Windows",
|
||||
Slug: "some-app/windows",
|
||||
Platform: "windows",
|
||||
UniqueIdentifier: "com.example.someapp", // Same identifier but different platform
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The darwin software should NOT have been renamed
|
||||
var someAppName string
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &someAppName,
|
||||
`SELECT name FROM software WHERE bundle_identifier = 'com.example.someapp'`)
|
||||
})
|
||||
require.Equal(t, "Some App", someAppName)
|
||||
}
|
||||
|
||||
func testGetFMANamesByIdentifier(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Initially empty
|
||||
names, err := ds.GetFMANamesByIdentifier(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, names)
|
||||
|
||||
// Add some darwin FMAs
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Microsoft Visual Studio Code",
|
||||
Slug: "visual-studio-code/darwin",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "com.microsoft.VSCode",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "1Password",
|
||||
Slug: "1password/darwin",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "com.1password.1password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a Windows FMA - should NOT be returned (only darwin)
|
||||
_, err = ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Microsoft Visual Studio Code",
|
||||
Slug: "visual-studio-code/windows",
|
||||
Platform: "windows",
|
||||
UniqueIdentifier: "Microsoft Visual Studio Code",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get FMA names - should only return darwin apps
|
||||
names, err = ds.GetFMANamesByIdentifier(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, names, 2)
|
||||
require.Equal(t, "Microsoft Visual Studio Code", names["com.microsoft.VSCode"])
|
||||
require.Equal(t, "1Password", names["com.1password.1password"])
|
||||
|
||||
// Windows identifier should not be present
|
||||
_, ok := names["Microsoft Visual Studio Code"]
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package tables
|
||||
|
||||
import "database/sql"
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260326210603, Down_20260326210603)
|
||||
}
|
||||
|
||||
func Up_20260326210603(tx *sql.Tx) error {
|
||||
// Update software_titles to use FMA canonical names where there's a matching
|
||||
// bundle_identifier. This fixes existing titles that were created with
|
||||
// osquery-reported names (e.g., "Code") instead of the FMA name
|
||||
// (e.g., "Microsoft Visual Studio Code").
|
||||
//
|
||||
// Note: We intentionally do NOT add an index on software.bundle_identifier.
|
||||
// The software table is a hot table with frequent writes (software ingestion
|
||||
// runs per-host/hour). Adding an index would impose write overhead on every
|
||||
// ingestion. The cost of a full table scan here (one-time migration) and during
|
||||
// rare FMA additions is acceptable compared to continuous index maintenance.
|
||||
//
|
||||
// software_titles.bundle_identifier already has an index (idx_software_titles_bundle_identifier).
|
||||
_, err := tx.Exec(`
|
||||
UPDATE software_titles st
|
||||
JOIN fleet_maintained_apps fma
|
||||
ON st.bundle_identifier = fma.unique_identifier
|
||||
AND fma.platform = 'darwin'
|
||||
SET st.name = fma.name
|
||||
WHERE st.bundle_identifier IS NOT NULL
|
||||
AND st.bundle_identifier != ''
|
||||
AND st.name != fma.name
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also update software entries to match their software_titles names.
|
||||
// This ensures consistency when navigating from software_titles to software versions.
|
||||
_, err = tx.Exec(`
|
||||
UPDATE software s
|
||||
JOIN fleet_maintained_apps fma
|
||||
ON s.bundle_identifier = fma.unique_identifier
|
||||
AND fma.platform = 'darwin'
|
||||
SET s.name = fma.name
|
||||
WHERE s.bundle_identifier IS NOT NULL
|
||||
AND s.bundle_identifier != ''
|
||||
AND s.name != fma.name
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down_20260326210603(tx *sql.Tx) error {
|
||||
// Down migration is a no-op because we cannot reliably restore the original
|
||||
// osquery-reported names. The FMA names are the canonical/correct names anyway.
|
||||
return nil
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260326210603(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Insert FMAs with canonical names
|
||||
dataStmts := `
|
||||
INSERT INTO fleet_maintained_apps (name, slug, unique_identifier, platform) VALUES
|
||||
('Microsoft Visual Studio Code', 'visual-studio-code/darwin', 'com.microsoft.VSCode', 'darwin'),
|
||||
('1Password', '1password/darwin', 'com.1password.1password', 'darwin'),
|
||||
('Windows App', 'windows-app/windows', 'com.windows.app', 'windows');
|
||||
|
||||
INSERT INTO software_titles (id, name, source, bundle_identifier) VALUES
|
||||
(1, 'Code', 'apps', 'com.microsoft.VSCode'),
|
||||
(2, '1Password 7', 'apps', 'com.1password.1password'),
|
||||
(3, 'Other App', 'apps', 'com.example.other'),
|
||||
(4, 'No Bundle ID App', 'apps', NULL);
|
||||
|
||||
INSERT INTO software (id, checksum, name, version, source, bundle_identifier, title_id) VALUES
|
||||
(1, 'checksum_01', 'Code', '1.85.0', 'apps', 'com.microsoft.VSCode', 1),
|
||||
(2, 'checksum_02', 'Code', '1.86.0', 'apps', 'com.microsoft.VSCode', 1),
|
||||
(3, 'checksum_03', '1Password 7', '7.10.0', 'apps', 'com.1password.1password', 2),
|
||||
(4, 'checksum_04', 'Other App', '1.0.0', 'apps', 'com.example.other', 3),
|
||||
(5, 'checksum_05', 'No Bundle ID App', '1.0.0', 'apps', '', 4);
|
||||
`
|
||||
|
||||
_, err := db.Exec(dataStmts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Apply the migration
|
||||
applyNext(t, db)
|
||||
|
||||
// Verify software_titles were updated correctly
|
||||
type softwareTitle struct {
|
||||
ID uint `db:"id"`
|
||||
Name string `db:"name"`
|
||||
BundleIdentifier string `db:"bundle_identifier"`
|
||||
}
|
||||
|
||||
var titles []softwareTitle
|
||||
err = db.Select(&titles, `SELECT id, name, COALESCE(bundle_identifier, '') as bundle_identifier FROM software_titles ORDER BY id`)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []softwareTitle{
|
||||
{1, "Microsoft Visual Studio Code", "com.microsoft.VSCode"}, // Updated to FMA name
|
||||
{2, "1Password", "com.1password.1password"}, // Updated to FMA name
|
||||
{3, "Other App", "com.example.other"}, // No matching FMA, unchanged
|
||||
{4, "No Bundle ID App", ""}, // No bundle_identifier, unchanged
|
||||
}, titles)
|
||||
|
||||
// Verify software entries were updated correctly
|
||||
type softwareRow struct {
|
||||
ID uint `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Version string `db:"version"`
|
||||
BundleIdentifier string `db:"bundle_identifier"`
|
||||
TitleID *uint `db:"title_id"`
|
||||
}
|
||||
|
||||
var software []softwareRow
|
||||
err = db.Select(&software, `SELECT id, name, version, COALESCE(bundle_identifier, '') as bundle_identifier, title_id FROM software ORDER BY id`)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []softwareRow{
|
||||
{1, "Microsoft Visual Studio Code", "1.85.0", "com.microsoft.VSCode", ptr.Uint(1)}, // Updated to FMA name
|
||||
{2, "Microsoft Visual Studio Code", "1.86.0", "com.microsoft.VSCode", ptr.Uint(1)}, // Updated to FMA name
|
||||
{3, "1Password", "7.10.0", "com.1password.1password", ptr.Uint(2)}, // Updated to FMA name
|
||||
{4, "Other App", "1.0.0", "com.example.other", ptr.Uint(3)}, // No matching FMA, unchanged
|
||||
{5, "No Bundle ID App", "1.0.0", "", ptr.Uint(4)}, // No bundle_identifier, unchanged
|
||||
}, software)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -925,6 +925,22 @@ func (ds *Datastore) preInsertSoftwareInventory(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch FMA canonical names to override osquery-reported names for macOS apps.
|
||||
// This ensures software titles use consistent names (e.g., "Microsoft Visual Studio Code"
|
||||
// instead of "Code" which is what osquery reports for VS Code).
|
||||
// Note: This call is made from the base datastore so it bypasses the cached_mysql layer.
|
||||
// The query is simple (SELECT from the small fleet_maintained_apps table) so this is acceptable.
|
||||
// The cached_mysql layer still caches this method for other callers (e.g., API endpoints).
|
||||
fmaNames, fmaErr := ds.GetFMANamesByIdentifier(ctx)
|
||||
if fmaErr != nil {
|
||||
// Log but don't fail - we can still use osquery-reported names.
|
||||
// A nil map is safe here since Go's map access on nil returns the zero value.
|
||||
if ds.logger != nil {
|
||||
ds.logger.WarnContext(ctx, "failed to get FMA names by identifier", "err", fmaErr)
|
||||
}
|
||||
fmaNames = nil
|
||||
}
|
||||
|
||||
// Process in smaller batches to reduce lock time
|
||||
err := common_mysql.BatchProcessSimple(keys, softwareInventoryInsertBatchSize, func(batchKeys []string) error {
|
||||
batchSoftware := make(map[string]fleet.Software, len(batchKeys))
|
||||
@@ -941,13 +957,19 @@ func (ds *Datastore) preInsertSoftwareInventory(
|
||||
// there is not an existing software title corresponding to this incoming software version
|
||||
newTitleName := sw.Name
|
||||
if sw.BundleIdentifier != "" {
|
||||
key := titleKey{
|
||||
bundleID: sw.BundleIdentifier,
|
||||
source: sw.Source,
|
||||
extensionFor: sw.ExtensionFor,
|
||||
}
|
||||
if computedName, exists := bestTitleNames[key]; exists {
|
||||
newTitleName = computedName
|
||||
// First check if there's an FMA with this bundle identifier - use its canonical name
|
||||
if fmaName, ok := fmaNames[sw.BundleIdentifier]; ok {
|
||||
newTitleName = fmaName
|
||||
} else {
|
||||
// Fall back to computed best name from osquery reports
|
||||
key := titleKey{
|
||||
bundleID: sw.BundleIdentifier,
|
||||
source: sw.Source,
|
||||
extensionFor: sw.ExtensionFor,
|
||||
}
|
||||
if computedName, exists := bestTitleNames[key]; exists {
|
||||
newTitleName = computedName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,8 +1208,34 @@ func (ds *Datastore) preInsertSoftwareInventory(
|
||||
missingSoftwareTitles = append(missingSoftwareTitles,
|
||||
fmt.Sprintf("%s %s %s", sw.Name, sw.Version, sw.Source))
|
||||
}
|
||||
|
||||
// Use FMA canonical name if available, otherwise use osquery-reported name.
|
||||
// This ensures software.name matches software_titles.name for consistency.
|
||||
//
|
||||
// IMPORTANT: The checksum is intentionally computed from osquery data
|
||||
// (including the osquery-reported name, NOT the FMA name) for these reasons:
|
||||
//
|
||||
// 1. The checksum is used for deduplication via unique index. It serves as
|
||||
// an internal identifier, not a content integrity hash. The stored name
|
||||
// can differ from the name used in checksum computation.
|
||||
//
|
||||
// 2. Checksums are computed before FMA lookup, using raw osquery data.
|
||||
// If we regenerated checksums with FMA names:
|
||||
// - A cache miss or FMA sync delay could cause the same software to
|
||||
// generate different checksums, creating duplicate entries.
|
||||
// - Migration would require recomputing checksums for millions of rows.
|
||||
//
|
||||
// 3. The checksum is never recomputed from stored data - it's only computed
|
||||
// from incoming osquery data during ingestion and used for lookup.
|
||||
softwareName := sw.Name
|
||||
if sw.BundleIdentifier != "" {
|
||||
if fmaName, ok := fmaNames[sw.BundleIdentifier]; ok {
|
||||
softwareName = fmaName
|
||||
}
|
||||
}
|
||||
|
||||
args = append(
|
||||
args, sw.Name, sw.Version, sw.Source, sw.Release, sw.Vendor, sw.Arch,
|
||||
args, softwareName, sw.Version, sw.Source, sw.Release, sw.Vendor, sw.Arch,
|
||||
sw.BundleIdentifier, sw.ExtensionID, sw.ExtensionFor, titleID, checksum, sw.ApplicationID, sw.UpgradeCode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2447,6 +2447,11 @@ type Datastore interface {
|
||||
// metadata provided via app.
|
||||
UpsertMaintainedApp(ctx context.Context, app *MaintainedApp) (*MaintainedApp, error)
|
||||
|
||||
// GetFMANamesByIdentifier returns a map of unique_identifier -> canonical name
|
||||
// for all Fleet-maintained apps on macOS. This is used during software ingestion
|
||||
// to use the FMA name instead of the osquery-reported name.
|
||||
GetFMANamesByIdentifier(ctx context.Context) (map[string]string, error)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// Certificate management
|
||||
|
||||
|
||||
@@ -1553,6 +1553,8 @@ type GetMaintainedAppBySlugFunc func(ctx context.Context, slug string, teamID *u
|
||||
|
||||
type UpsertMaintainedAppFunc func(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error)
|
||||
|
||||
type GetFMANamesByIdentifierFunc func(ctx context.Context) (map[string]string, error)
|
||||
|
||||
type BulkUpsertMDMManagedCertificatesFunc func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error
|
||||
|
||||
type GetAppleHostMDMCertificateProfileFunc func(ctx context.Context, hostUUID string, profileUUID string, caName string) (*fleet.HostMDMCertificateProfile, error)
|
||||
@@ -4133,6 +4135,9 @@ type DataStore struct {
|
||||
UpsertMaintainedAppFunc UpsertMaintainedAppFunc
|
||||
UpsertMaintainedAppFuncInvoked bool
|
||||
|
||||
GetFMANamesByIdentifierFunc GetFMANamesByIdentifierFunc
|
||||
GetFMANamesByIdentifierFuncInvoked bool
|
||||
|
||||
BulkUpsertMDMManagedCertificatesFunc BulkUpsertMDMManagedCertificatesFunc
|
||||
BulkUpsertMDMManagedCertificatesFuncInvoked bool
|
||||
|
||||
@@ -9917,6 +9922,13 @@ func (s *DataStore) UpsertMaintainedApp(ctx context.Context, app *fleet.Maintain
|
||||
return s.UpsertMaintainedAppFunc(ctx, app)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetFMANamesByIdentifier(ctx context.Context) (map[string]string, error) {
|
||||
s.mu.Lock()
|
||||
s.GetFMANamesByIdentifierFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetFMANamesByIdentifierFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *DataStore) BulkUpsertMDMManagedCertificates(ctx context.Context, payload []*fleet.MDMManagedCertificate) error {
|
||||
s.mu.Lock()
|
||||
s.BulkUpsertMDMManagedCertificatesFuncInvoked = true
|
||||
|
||||
Reference in New Issue
Block a user