diff --git a/ee/server/service/maintained_apps_test.go b/ee/server/service/maintained_apps_test.go index 4b06f7258e..9f1737f274 100644 --- a/ee/server/service/maintained_apps_test.go +++ b/ee/server/service/maintained_apps_test.go @@ -2,7 +2,10 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -11,6 +14,7 @@ import ( ma "github.com/fleetdm/fleet/v4/ee/maintained-apps" "github.com/fleetdm/fleet/v4/server/authz" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -253,3 +257,83 @@ func TestGetMaintainedAppAuth(t *testing.T) { }) } } + +func TestAddFleetMaintainedApp(t *testing.T) { + installerBytes := []byte("abc") + + // this is the hash we expect to get in the DB + h := sha256.New() + _, err := h.Write(installerBytes) + require.NoError(t, err) + spoofedSHA := hex.EncodeToString(h.Sum(nil)) + + ds := new(mock.Store) + ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { + return nil + } + ds.GetMaintainedAppByIDFunc = func(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) { + return &fleet.MaintainedApp{ + ID: 1, + Name: "Internet Exploder", + Slug: "iexplode/windows", + Platform: "windows", + TitleID: nil, + UniqueIdentifier: "Internet Exploder", + }, nil + } + ds.MatchOrCreateSoftwareInstallerFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, uint, error) { + require.Equal(t, spoofedSHA, payload.StorageID) + require.Empty(t, payload.BundleIdentifier) + require.Equal(t, "Internet Exploder", payload.Title) + require.Equal(t, "programs", payload.Source) + require.Equal(t, "Hello World!", payload.InstallScript) + require.Equal(t, "Hello World!", payload.UninstallScript) + + // Can't easily inject a proper fleet.service so we bail early before NewActivity gets called and panics + return 0, 0, errors.New("forced error to short-circuit storage and activity creation") + } + + // Mock server to serve the "installer" + installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(installerBytes) + })) + defer installerServer.Close() + + // Mock server to serve the manifest + manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var versions []*ma.FMAManifestApp + versions = append(versions, &ma.FMAManifestApp{ + Version: "6.0", + Queries: ma.FMAQueries{ + Exists: "SELECT 1 FROM osquery_info;", + }, + InstallerURL: installerServer.URL + "/iexplode.exe", + InstallScriptRef: "foobaz", + UninstallScriptRef: "foobaz", + SHA256: noCheckHash, + }) + + manifest := ma.FMAManifestFile{ + Versions: versions, + Refs: map[string]string{ + "foobaz": "Hello World!", + }, + } + + err := json.NewEncoder(w).Encode(manifest) + require.NoError(t, err) + })) + t.Cleanup(manifestServer.Close) + os.Setenv("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL) + defer os.Unsetenv("FLEET_DEV_MAINTAINED_APPS_BASE_URL") + + svc := newTestService(t, ds) + + authCtx := authz_ctx.AuthorizationContext{} + ctx := authz_ctx.NewContext(context.Background(), &authCtx) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + _, err = svc.AddFleetMaintainedApp(ctx, nil, 1, "", "", "", "", false, false, nil, nil) + require.ErrorContains(t, err, "forced error to short-circuit storage and activity creation") + + require.True(t, ds.MatchOrCreateSoftwareInstallerFuncInvoked) +} diff --git a/pkg/automatic_policy/automatic_policy_test.go b/pkg/automatic_policy/automatic_policy_test.go index 69da3bb9f0..7e7250ef1c 100644 --- a/pkg/automatic_policy/automatic_policy_test.go +++ b/pkg/automatic_policy/automatic_policy_test.go @@ -63,6 +63,12 @@ func TestGenerateErrors(t *testing.T) { PackageIDs: []string{""}, }) require.ErrorIs(t, err, ErrMissingTitle) + + _, err = Generate(FMAInstallerMetadata{}) + require.ErrorIs(t, err, ErrMissingTitle) + + _, err = FMAInstallerMetadata{}.PolicyDescription() + require.ErrorIs(t, err, ErrMissingTitle) } func TestGenerate(t *testing.T) { diff --git a/server/datastore/mysql/maintained_apps_test.go b/server/datastore/mysql/maintained_apps_test.go index aea9077e16..854a835d55 100644 --- a/server/datastore/mysql/maintained_apps_test.go +++ b/server/datastore/mysql/maintained_apps_test.go @@ -99,6 +99,10 @@ func testListAndGetAvailableApps(t *testing.T, ds *Datastore) { team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 2"}) require.NoError(t, err) + // Testing search that returns no results; nothing inserted yet case + _, _, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) + require.ErrorIs(t, err, &fleet.NoMaintainedAppsInDatabaseError{}) + maintained1, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ Name: "Maintained1", Slug: "maintained1", @@ -203,6 +207,23 @@ func testListAndGetAvailableApps(t *testing.T, ds *Datastore) { require.False(t, meta.HasNextResults) require.True(t, meta.HasPreviousResults) + // Testing search + apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{MatchQuery: "Maintained4", IncludeMetadata: true}) + require.NoError(t, err) + require.Len(t, apps, 1) + require.EqualValues(t, 1, meta.TotalResults) + require.Equal(t, expectedApps[3:], apps) + require.False(t, meta.HasNextResults) + require.False(t, meta.HasPreviousResults) + + // Testing search that returns no results; non-error case + apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{MatchQuery: "Maintained5", IncludeMetadata: true}) + require.NoError(t, err) + require.Len(t, apps, 0) + require.EqualValues(t, 0, meta.TotalResults) + require.False(t, meta.HasNextResults) + require.False(t, meta.HasPreviousResults) + // // Test including software title ID for existing apps (installers) diff --git a/server/datastore/mysql/migrations/tables/20250320200000_FMAv2.go b/server/datastore/mysql/migrations/tables/20250320200000_FMAv2.go index 5d057312eb..dbe98e6d45 100644 --- a/server/datastore/mysql/migrations/tables/20250320200000_FMAv2.go +++ b/server/datastore/mysql/migrations/tables/20250320200000_FMAv2.go @@ -13,33 +13,7 @@ func init() { } func Up_20250320200000(tx *sql.Tx) error { - // Clean up Fleet Library App associated scripts before we drop the columns on the table - _, err := tx.Exec(`DELETE FROM - script_contents -WHERE - NOT EXISTS ( - SELECT 1 FROM host_script_results WHERE script_content_id = script_contents.id) - AND NOT EXISTS ( - SELECT 1 FROM scripts WHERE script_content_id = script_contents.id) - AND NOT EXISTS ( - SELECT 1 FROM software_installers si - WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id) - ) - AND NOT EXISTS ( - SELECT 1 FROM fleet_library_apps fla - WHERE script_contents.id IN (fla.install_script_content_id, fla.uninstall_script_content_id) - ) - AND NOT EXISTS ( - SELECT 1 FROM setup_experience_scripts WHERE script_content_id = script_contents.id - ) - AND NOT EXISTS ( - SELECT 1 FROM script_upcoming_activities WHERE script_content_id = script_contents.id - )`) - if err != nil { - return fmt.Errorf("failed to clean up unused scripts: %w", err) - } - - _, err = tx.Exec(` + _, err := tx.Exec(` ALTER TABLE software_installers CHANGE COLUMN fleet_library_app_id fleet_maintained_app_id INT unsigned DEFAULT NULL `) @@ -84,6 +58,28 @@ ALTER TABLE fleet_maintained_apps return fmt.Errorf("failed to alter fleet_maintained_apps: %w", err) } + // Clean up scripts that were only associated with FMAs + _, err = tx.Exec(`DELETE FROM + script_contents +WHERE + NOT EXISTS ( + SELECT 1 FROM host_script_results WHERE script_content_id = script_contents.id) + AND NOT EXISTS ( + SELECT 1 FROM scripts WHERE script_content_id = script_contents.id) + AND NOT EXISTS ( + SELECT 1 FROM software_installers si + WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id) + ) + AND NOT EXISTS ( + SELECT 1 FROM setup_experience_scripts WHERE script_content_id = script_contents.id + ) + AND NOT EXISTS ( + SELECT 1 FROM script_upcoming_activities WHERE script_content_id = script_contents.id + )`) + if err != nil { + return fmt.Errorf("failed to clean up unused scripts: %w", err) + } + _, err = tx.Exec(`UPDATE fleet_maintained_apps SET slug = concat(slug, '/', platform)`) if err != nil { return fmt.Errorf("failed to rename FMA slugs: %w", err) diff --git a/server/datastore/mysql/migrations/tables/20250320200000_FMAv2_test.go b/server/datastore/mysql/migrations/tables/20250320200000_FMAv2_test.go new file mode 100644 index 0000000000..a1f9708046 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250320200000_FMAv2_test.go @@ -0,0 +1,149 @@ +package tables + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" + "github.com/stretchr/testify/require" +) + +func TestUp_20250320200000(t *testing.T) { + db := applyUpToPrev(t) + + // Insert a scheduled and a triggered job run for maintained_apps + execNoErr(t, db, `INSERT INTO cron_stats (name, instance, stats_type, status) VALUES (?, 'foo', ?, ?)`, fleet.CronMaintainedApps, fleet.CronStatsTypeScheduled, fleet.CronStatsStatusCompleted) + execNoErr(t, db, `INSERT INTO cron_stats (name, instance, stats_type, status) VALUES (?, 'foo', ?, ?)`, fleet.CronMaintainedApps, fleet.CronStatsTypeTriggered, fleet.CronStatsStatusCompleted) + + // Add the old Zoom, Zoom for IT Admins, and Box Drive FMAs + tx, err := db.Begin() + require.NoError(t, err) + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + installScriptID, err := getOrInsertScript(txx, "echo install") + require.NoError(t, err) + uninstallScriptID, err := getOrInsertScript(txx, "echo uninstall") + require.NoError(t, err) + + installScriptID2, err := getOrInsertScript(txx, "echo install2") + require.NoError(t, err) + uninstallScriptID2, err := getOrInsertScript(txx, "echo uninstall2") + require.NoError(t, err) + + installScriptID3, err := getOrInsertScript(txx, "echo install different") + require.NoError(t, err) + + otherScriptID, err := getOrInsertScript(txx, "just a lil scripty boi") + require.NoError(t, err) + + err = tx.Commit() + require.NoError(t, err) + + execNoErr( + t, + db, + `INSERT INTO fleet_library_apps (name, token, version, platform, installer_url, sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "Zoom", + "zoom", + "6.2.11.43613", + "darwin", + "https://cdn.zoom.us/prod/6.2.11.43613/arm64/zoomusInstallerFull.pkg", + "dd6d28853eb6be7eaf7731aae1855c68cd6411ef6847158e6af18fffed5f8597", + "us.zoom.xos", + installScriptID, + uninstallScriptID, + ) + + execNoErr( + t, + db, + `INSERT INTO fleet_library_apps (name, token, version, platform, installer_url, sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "Zoom for IT Admins", + "zoom-for-it-admins", + "6.2.11.43613", + "darwin", + "https://cdn.zoom.us/prod/6.2.11.43613/arm64/zoomusInstallerFull.pkg", + "dd6d28853eb6be7eaf7731aae1855c68cd6411ef6847158e6af18fffed5f8597", + "us.zoom.xos", + installScriptID, + uninstallScriptID, + ) + + boxFMAID := execNoErrLastID( + t, + db, + `INSERT INTO fleet_library_apps (name, token, version, platform, installer_url, sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "Box Drive", + "box-drive", + "2.42.212", + "darwin", + "https://e3.boxcdn.net/desktop/releases/mac/BoxDrive-2.42.212.pkg", + "93550756150c434bc058c30b82352c294a21e978caf436ac99e0a5f431adfb6e", + "com.box.desktop", + installScriptID2, + uninstallScriptID2, + ) + + // add a software installer for Box to No team, same install scripts + noTeamBox := execNoErrLastID(t, db, ` + INSERT INTO software_installers + (filename, version, platform, install_script_content_id, storage_id, package_ids, uninstall_script_content_id, fleet_library_app_id) + VALUES + (?,?,?,?,?,?,?,?)`, "box.pkg", "2.42.212", "darwin", installScriptID2, "sha-is-not-president", "", uninstallScriptID2, boxFMAID) + + // add a software installer for Box to another team, different install script + teamID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ("Foo")`) + otherTeamBox := execNoErrLastID(t, db, ` + INSERT INTO software_installers + (team_id, global_or_team_id, filename, version, platform, install_script_content_id, storage_id, package_ids, uninstall_script_content_id) + VALUES + (?,?,?,?,?,?,?,?,?)`, teamID, teamID, "box.pkg", "2.42.212", "darwin", installScriptID3, "sha-is-not-president", "", uninstallScriptID2) + + // add a separate script to No team + execNoErr(t, db, `INSERT INTO scripts ( + team_id, global_or_team_id, name, script_content_id + ) VALUES (?, ?, ?, ?)`, nil, 0, "myscript.sh", otherScriptID) + + // Apply current migration. + applyNext(t, db) + + // install/uninstall scripts for Zoom should be gone + // Box script should remain, other script should remain + var scriptContentsIDs []int64 + err = db.Select(&scriptContentsIDs, `SELECT id FROM script_contents ORDER BY id`) + require.NoError(t, err) + require.Equal(t, []int64{installScriptID2, uninstallScriptID2, installScriptID3, otherScriptID}, scriptContentsIDs) + + // Should only have one Zoom plus Box + var fmas []fleet.MaintainedApp + err = db.Select(&fmas, `SELECT id, name, slug, unique_identifier FROM fleet_maintained_apps ORDER BY name`) + require.NoError(t, err) + require.Len(t, fmas, 2) + require.Equal(t, "Box Drive", fmas[0].Name) + require.Equal(t, "box-drive/darwin", fmas[0].Slug) + require.Equal(t, "com.box.desktop", fmas[0].UniqueIdentifier) + require.Equal(t, "Zoom", fmas[1].Name) + require.Equal(t, "zoom/darwin", fmas[1].Slug) + require.Equal(t, "us.zoom.xos", fmas[1].UniqueIdentifier) + + var linkedFMAID *int64 + + // FMA ID for Box software installer on No team should match ID of Box FMA + err = db.Get(&linkedFMAID, `SELECT fleet_maintained_app_id FROM software_installers WHERE id = ?`, noTeamBox) + require.NoError(t, err) + require.Equal(t, boxFMAID, *linkedFMAID) + + // FMA ID for Box software installer on other team should be null + err = db.Get(&linkedFMAID, `SELECT fleet_maintained_app_id FROM software_installers WHERE id = ?`, otherTeamBox) + require.NoError(t, err) + require.Nil(t, linkedFMAID) + + // Only the triggered job record should remain in the cron_stats table + var stats []fleet.CronStats + err = db.Select(&stats, `SELECT name, instance, stats_type, status FROM cron_stats`) + require.NoError(t, err) + require.Len(t, stats, 1) + require.Equal(t, string(fleet.CronMaintainedApps), stats[0].Name) + require.Equal(t, fleet.CronStatsTypeTriggered, stats[0].StatsType) + require.Equal(t, fleet.CronStatsStatusCompleted, stats[0].Status) +} diff --git a/server/mdm/maintainedapps/installers_test.go b/server/mdm/maintainedapps/installers_test.go new file mode 100644 index 0000000000..0f07e4e1d9 --- /dev/null +++ b/server/mdm/maintainedapps/installers_test.go @@ -0,0 +1,49 @@ +package maintained_apps + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/stretchr/testify/require" +) + +func TestInstallerFilenameExtraction(t *testing.T) { + // Mock server to serve the "installers" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/redirect": + w.Header().Set("Location", "/redirected%20package.exe") + w.WriteHeader(302) + _, _ = w.Write([]byte("redirecting")) + case "/redirected%20package.exe": + _, _ = w.Write([]byte("redirected fallback")) + case "/compliant": + w.Header().Set("Content-Disposition", `attachment; filename="compliant.msi"`) + _, _ = w.Write([]byte("compliant")) + case "/not_compliant": + w.Header().Set("Content-Disposition", `attachment; filename=not_compliant.pkg`) + _, _ = w.Write([]byte("not_compliant")) + } + })) + defer srv.Close() + + // follow redirect and fall back to URL, after sanitization, when we don't have a content-disposition header + client := fleethttp.NewClient(fleethttp.WithTimeout(time.Second)) + _, filename, err := DownloadInstaller(context.Background(), srv.URL+"/redirect", client) + require.NoError(t, err) + require.Equal(t, "redirected package.exe", filename) + + // handle properly formatted content-disposition header + _, filename, err = DownloadInstaller(context.Background(), srv.URL+"/compliant", client) + require.NoError(t, err) + require.Equal(t, "compliant.msi", filename) + + // handle non-compliant content-disposition header + _, filename, err = DownloadInstaller(context.Background(), srv.URL+"/not_compliant", client) + require.NoError(t, err) + require.Equal(t, "not_compliant.pkg", filename) +}