From 980adc0c45e5f500899df9956c3eaba562cf7b5b Mon Sep 17 00:00:00 2001 From: Ian Littman Date: Mon, 19 May 2025 10:32:36 -0500 Subject: [PATCH] Improve .pkg metadata extraction for names and bundle IDs, let custom package metadata extraction tool check an entire directory at a time (#29249) For #24083, #26597. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated automated tests - [x] Manual QA for all new/changed functionality --- changes/24083-app-metadata | 1 + pkg/file/xar.go | 63 ++++++--- pkg/file/xar_test.go | 34 ++--- server/service/integration_enterprise_test.go | 50 +++---- server/service/integration_mdm_dep_test.go | 32 ++--- server/service/integration_mdm_test.go | 6 +- tools/custom-package-parser/main.go | 125 ++++++++++++------ 7 files changed, 192 insertions(+), 119 deletions(-) create mode 100644 changes/24083-app-metadata diff --git a/changes/24083-app-metadata b/changes/24083-app-metadata new file mode 100644 index 0000000000..605945bf6f --- /dev/null +++ b/changes/24083-app-metadata @@ -0,0 +1 @@ +* Revised PKG custom package parsing to pick the correct app name and bundle ID in more instances. diff --git a/pkg/file/xar.go b/pkg/file/xar.go index 967754b1bb..af2ee37a05 100644 --- a/pkg/file/xar.go +++ b/pkg/file/xar.go @@ -29,6 +29,7 @@ import ( "fmt" "io" "path/filepath" + "slices" "strings" "github.com/fleetdm/fleet/v4/server/fleet" @@ -362,20 +363,33 @@ func getDistributionInfo(d *distributionXML) (name string, identifier string, ve packageIDs = append(packageIDs, id) } -out: // look in all the bundle versions for one that has a `path` attribute // that is not nested, this is generally the case for packages that distribute // `.app` files, which are ultimately picked up as an installed app by osquery + var potentialBundles []distributionBundle for _, pkg := range d.PkgRefs { for _, versions := range pkg.BundleVersions { - for _, bundle := range versions.Bundles { - if base, isValid := isValidAppFilePath(bundle.Path); isValid { - identifier = bundle.ID - name = base - appVersion = bundle.CFBundleShortVersionString - break out - } - } + potentialBundles = append(potentialBundles, versions.Bundles...) + } + } + + // Prefer paths that refer to Applications for name, bundle ID, etc. + slices.SortFunc(potentialBundles, func(a distributionBundle, b distributionBundle) int { + if strings.HasPrefix(a.Path, "Applications/") && !strings.HasPrefix(b.Path, "Applications/") { + return -1 + } + if strings.HasPrefix(b.Path, "Applications/") && !strings.HasPrefix(a.Path, "Applications/") { + return 1 + } + return 0 + }) + + for _, bundle := range potentialBundles { + if base, isValid := isValidAppFilePath(bundle.Path); isValid { + identifier = bundle.ID + name = strings.TrimSuffix(base, ".app") + appVersion = bundle.CFBundleShortVersionString + break } } @@ -391,6 +405,14 @@ out: } } + // if the identifier is still empty, try to use the product id, and make sure it's in the package IDs list + if identifier == "" && d.Product.ID != "" { + identifier = d.Product.ID + if !slices.Contains(packageIDs, identifier) { + packageIDs = append(packageIDs, identifier) + } + } + // Try to get the identifier based on the choices list, if we have one. Some .pkgs have multiple // sub-pkgs inside, so the choices list helps us be a bit smarter. if identifier == "" && len(d.ChoicesOutline.Lines) > 0 { @@ -435,11 +457,6 @@ out: } } - // if the identifier is still empty, try to use the product id - if identifier == "" && d.Product.ID != "" { - identifier = d.Product.ID - } - // if package IDs are still empty, use the identifier as the package ID if len(packageIDs) == 0 && identifier != "" { packageIDs = append(packageIDs, identifier) @@ -451,9 +468,11 @@ out: name = d.Title } - if _, ok := knownBadNames[name]; name == "" || ok { - name = identifier + if _, ok := knownBadNames[name]; ok { + name = "" + } + if name == "" { // Try to find a tag that matches the bundle ID for this app. It might have the app // name, so if we find it we can use that. for _, c := range d.Choices { @@ -463,6 +482,18 @@ out: } } + if name == "" { // Fall back to any bundle ID in packages for name matching vs. choices + for _, c := range d.Choices { + if slices.Contains(packageIDs, c.PkgRef.ID) && c.Title != "" { + name = c.Title + } + } + } + + if name == "" { // fall back to bundle ID + name = identifier + } + // for the version, try to use the top-level product version, if not, // fallback to any version definition alongside the name or the first // version in a pkg-ref we find. diff --git a/pkg/file/xar_test.go b/pkg/file/xar_test.go index bd31b0eb63..66093b0572 100644 --- a/pkg/file/xar_test.go +++ b/pkg/file/xar_test.go @@ -63,28 +63,28 @@ func TestParseRealDistributionFiles(t *testing.T) { }{ { file: "distribution-1password.xml", - expectedName: "1Password.app", + expectedName: "1Password", expectedVersion: "8.10.34", expectedBundleID: "com.1password.1password", expectedPackageIDs: []string{"com.1password.1password"}, }, { file: "distribution-chrome.xml", - expectedName: "Google Chrome.app", + expectedName: "Google Chrome", expectedVersion: "126.0.6478.62", expectedBundleID: "com.google.Chrome", expectedPackageIDs: []string{"com.google.Chrome"}, }, { file: "distribution-edge.xml", - expectedName: "Microsoft Edge.app", + expectedName: "Microsoft Edge", expectedVersion: "126.0.2592.56", expectedBundleID: "com.microsoft.edgemac", expectedPackageIDs: []string{"com.microsoft.edgemac"}, }, { file: "distribution-firefox.xml", - expectedName: "Firefox.app", + expectedName: "Firefox", expectedVersion: "99.0", expectedBundleID: "org.mozilla.firefox", expectedPackageIDs: []string{"org.mozilla.firefox"}, @@ -105,7 +105,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-microsoft-teams.xml", - expectedName: "Microsoft Teams.app", + expectedName: "Microsoft Teams", expectedVersion: "24124.1412.2911.3341", expectedBundleID: "com.microsoft.teams2", expectedPackageIDs: []string{ @@ -115,14 +115,14 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-zoom.xml", - expectedName: "zoom.us.app", + expectedName: "zoom.us", expectedVersion: "6.0.11.35001", expectedBundleID: "us.zoom.xos", expectedPackageIDs: []string{"us.zoom.pkg.videomeeting"}, }, { file: "distribution-acrobatreader.xml", - expectedName: "Adobe Acrobat Reader.app", + expectedName: "Adobe Acrobat Reader", expectedVersion: "24.002.20857", expectedBundleID: "com.adobe.Reader", expectedPackageIDs: []string{ @@ -132,14 +132,14 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-airtame.xml", - expectedName: "Airtame.app", + expectedName: "Airtame", expectedVersion: "4.10.1", expectedBundleID: "com.airtame.airtame-application", expectedPackageIDs: []string{"com.airtame.airtame-application"}, }, { file: "distribution-boxdrive.xml", - expectedName: "Box.app", + expectedName: "Box", expectedVersion: "2.38.173", expectedBundleID: "com.box.desktop", expectedPackageIDs: []string{ @@ -149,7 +149,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-iriunwebcam.xml", - expectedName: "IriunWebcam.app", + expectedName: "IriunWebcam", expectedVersion: "2.8.8", expectedBundleID: "com.iriun.macwebcam", // Note: "com.iriun.pkg.multicam" is part of the installer package, but it is not actually installed by default. @@ -158,7 +158,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-microsoftexcel.xml", - expectedName: "Microsoft Excel.app", + expectedName: "Microsoft Excel", expectedVersion: "16.86", expectedBundleID: "com.microsoft.Excel", expectedPackageIDs: []string{ @@ -168,7 +168,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-microsoftword.xml", - expectedName: "Microsoft Word.app", + expectedName: "Microsoft Word", expectedVersion: "16.86", expectedBundleID: "com.microsoft.Word", expectedPackageIDs: []string{ @@ -178,7 +178,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-miscrosoftpowerpoint.xml", - expectedName: "Microsoft PowerPoint.app", + expectedName: "Microsoft PowerPoint", expectedVersion: "16.86", expectedBundleID: "com.microsoft.Powerpoint", expectedPackageIDs: []string{ @@ -188,7 +188,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "distribution-ringcentral.xml", - expectedName: "RingCentral.app", + expectedName: "RingCentral", expectedVersion: "24.1.32.9774", expectedBundleID: "com.ringcentral.glip", expectedPackageIDs: []string{"com.ringcentral.glip"}, @@ -202,7 +202,7 @@ func TestParseRealDistributionFiles(t *testing.T) { }, { file: "test-zero-installkbytes.xml", - expectedName: "ZeroInstallSize.app", + expectedName: "ZeroInstallSize", expectedVersion: "1.2.3", expectedBundleID: "com.bozo.zeroinstallsize", expectedPackageIDs: []string{"com.bozo.zeroinstallsize.app"}, @@ -211,8 +211,8 @@ func TestParseRealDistributionFiles(t *testing.T) { file: "distribution-sentinelone.xml", expectedName: "SentinelOne", expectedVersion: "24.3.2.7753", - expectedBundleID: "com.sentinelone.pkg.sentinel-agent", - expectedPackageIDs: []string{"com.sentinelone.pkg.sentinel-agent"}, + expectedBundleID: "com.sentinelone.sentinel-agent", + expectedPackageIDs: []string{"com.sentinelone.pkg.sentinel-agent", "com.sentinelone.sentinel-agent"}, }, { file: "distribution-cold-turkey.xml", diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 68b844e329..9560032ca3 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12484,7 +12484,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { // Upload another package for another platform payloadDummy := &fleet.UploadSoftwareInstallerPayload{ Filename: "dummy_installer.pkg", - Title: "DummyApp.app", + Title: "DummyApp", TeamID: teamID, } s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "") @@ -13546,7 +13546,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { {Name: "foo", Version: "0.0.1", Source: "homebrew"}, {Name: "foo", Version: "0.0.3", Source: "homebrew"}, {Name: "bar", Version: "0.0.4", Source: "apps"}, - {Name: "DummyApp.app", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, + {Name: "DummyApp", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, } _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -13577,7 +13577,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { require.Len(t, resp.SoftwareTitles, 3) require.ElementsMatch( t, - []string{"foo", "bar", "DummyApp.app"}, + []string{"foo", "bar", "DummyApp"}, []string{ resp.SoftwareTitles[0].Name, resp.SoftwareTitles[1].Name, @@ -13591,8 +13591,8 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { {Name: "foo", Version: "0.0.1", Source: "homebrew"}, {Name: "foo", Version: "0.0.3", Source: "homebrew"}, {Name: "bar", Version: "0.0.4", Source: "apps"}, - {Name: "DummyApp.app", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, - {Name: "AppDummy.app", Version: "2.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, + {Name: "DummyApp", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, + {Name: "AppDummy", Version: "2.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"}, } _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -13613,7 +13613,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { require.Len(t, resp.SoftwareTitles, 3) require.ElementsMatch( t, - []string{"foo", "bar", "DummyApp.app"}, + []string{"foo", "bar", "DummyApp"}, []string{ resp.SoftwareTitles[0].Name, resp.SoftwareTitles[1].Name, @@ -13645,7 +13645,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() { "team_id", fmt.Sprintf("%d", team.ID), ) require.Len(t, resp.SoftwareTitles, 1) - require.Equal(t, "NoVersion.app", resp.SoftwareTitles[0].Name) + require.Equal(t, "NoVersion", resp.SoftwareTitles[0].Name) require.Equal(t, "", resp.SoftwareTitles[0].SoftwarePackage.Version) } @@ -13759,7 +13759,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() { {Name: "foo", Version: "0.0.3", Source: "homebrew"}, {Name: "bar", Version: "0.0.4", Source: "apps"}, // note: the source is not "apps" - {Name: "DummyApp.app", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"}, + {Name: "DummyApp", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"}, } _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -13780,7 +13780,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() { require.Len(t, resp.SoftwareTitles, 3) require.ElementsMatch( t, - []string{"foo", "bar", "DummyApp.app"}, + []string{"foo", "bar", "DummyApp"}, []string{ resp.SoftwareTitles[0].Name, resp.SoftwareTitles[1].Name, @@ -13805,7 +13805,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() { require.Len(t, resp.SoftwareTitles, 3) require.ElementsMatch( t, - []string{"foo", "bar", "DummyApp.app"}, + []string{"foo", "bar", "DummyApp"}, []string{ resp.SoftwareTitles[0].Name, resp.SoftwareTitles[1].Name, @@ -13845,7 +13845,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() { {Name: "foo", Version: "0.0.3", Source: "homebrew"}, {Name: "bar", Version: "0.0.4", Source: "apps"}, // note: the source is not "apps" - {Name: "DummyApp.app", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"}, + {Name: "DummyApp", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"}, } _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -13870,7 +13870,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() { require.Len(t, resp.SoftwareTitles, 1) require.ElementsMatch( t, - []string{"DummyApp.app"}, + []string{"DummyApp"}, []string{resp.SoftwareTitles[0].Name}, ) @@ -13888,7 +13888,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() { require.Len(t, resp.SoftwareTitles, 3) require.ElementsMatch( t, - []string{"foo", "bar", "DummyApp.app"}, + []string{"foo", "bar", "DummyApp"}, []string{ resp.SoftwareTitles[0].Name, resp.SoftwareTitles[1].Name, @@ -14787,7 +14787,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers "GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, - "query", "DummyApp.app", + "query", "DummyApp", "team_id", fmt.Sprintf("%d", team1.ID), ) require.Len(t, resp.SoftwareTitles, 1) @@ -14938,7 +14938,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers // Populate software for host1Team1 (to have a software title // that doesn't have an associated installer) software := []fleet.Software{ - {Name: "Foobar.app", Version: "0.0.1", Source: "apps"}, + {Name: "Foobar", Version: "0.0.1", Source: "apps"}, } _, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software) require.NoError(t, err) @@ -14951,7 +14951,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers "GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, - "query", "Foobar.app", + "query", "Foobar", "team_id", fmt.Sprintf("%d", team1.ID), ) require.Len(t, resp.SoftwareTitles, 1) @@ -15383,7 +15383,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers "status": "installed", "policy_id": %d, "policy_name": "%s" - }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp.app", "dummy_installer.pkg", host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0) + }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp", "dummy_installer.pkg", host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0) // host2Team1 posts the installation result for ruby.deb. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ @@ -16315,7 +16315,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIden require.NoError(t, err) software := []fleet.Software{ - {Name: "DummyApp.app", Version: "0.0.2", Source: "apps"}, + {Name: "DummyApp", Version: "0.0.2", Source: "apps"}, } // we must ingest the title with an empty bundle identifier for this // test to be valid @@ -17132,7 +17132,7 @@ func (s *integrationEnterpriseTestSuite) TestAutomaticPolicies() { require.Len(t, ts.InheritedPolicies, 0) // Delete and try again with automatic policy turned on. - pkgTitleID := getSoftwareTitleID(t, s.ds, "DummyApp.app", "apps") + pkgTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps") s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", pkgTitleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", team1.ID)) @@ -17145,20 +17145,20 @@ func (s *integrationEnterpriseTestSuite) TestAutomaticPolicies() { } s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "") - pkgTitleID = getSoftwareTitleID(t, s.ds, "DummyApp.app", "apps") + pkgTitleID = getSoftwareTitleID(t, s.ds, "DummyApp", "apps") respTitle := getSoftwareTitleResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d?team_id=%d", pkgTitleID, team1.ID), listSoftwareTitlesRequest{}, http.StatusOK, &respTitle) require.NotNil(t, respTitle.SoftwareTitle) require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) require.Len(t, respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies, 1) - require.Equal(t, "[Install software] DummyApp.app (pkg)", respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies[0].Name) + require.Equal(t, "[Install software] DummyApp (pkg)", respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies[0].Name) // Check a policy was created on team1. ts = listTeamPoliciesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts) require.Len(t, ts.Policies, 1) require.Len(t, ts.InheritedPolicies, 0) - require.Equal(t, "[Install software] DummyApp.app (pkg)", ts.Policies[0].Name) + require.Equal(t, "[Install software] DummyApp (pkg)", ts.Policies[0].Name) // Upload dummy_installer.pkg to team2 with automatic policy. pkgPayload = &fleet.UploadSoftwareInstallerPayload{ @@ -17174,7 +17174,7 @@ func (s *integrationEnterpriseTestSuite) TestAutomaticPolicies() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team2.ID), nil, http.StatusOK, &ts) require.Len(t, ts.Policies, 1) require.Len(t, ts.InheritedPolicies, 0) - require.Equal(t, "[Install software] DummyApp.app (pkg)", ts.Policies[0].Name) + require.Equal(t, "[Install software] DummyApp (pkg)", ts.Policies[0].Name) // Upload ruby.deb to team1 with automatic policy. payloadRubyDEB := &fleet.UploadSoftwareInstallerPayload{ @@ -17561,7 +17561,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSoftwareUploadWithSHAs() { require.NotNil(t, pkgTitleID) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *pkgTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team2.ID)) require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage) - require.Equal(t, "DummyApp.app", stResp.SoftwareTitle.Name) + require.Equal(t, "DummyApp", stResp.SoftwareTitle.Name) require.Equal(t, pkgURL, stResp.SoftwareTitle.SoftwarePackage.URL) require.Equal(t, softwareToInstall[2].InstallScript, stResp.SoftwareTitle.SoftwarePackage.InstallScript) require.Equal(t, softwareToInstall[2].UninstallScript, stResp.SoftwareTitle.SoftwarePackage.UninstallScript) @@ -17630,7 +17630,7 @@ done require.NotNil(t, pkgTitleID) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *pkgTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team2.ID)) require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage) - require.Equal(t, "DummyApp.app", stResp.SoftwareTitle.Name) + require.Equal(t, "DummyApp", stResp.SoftwareTitle.Name) require.Equal(t, pkgURL, stResp.SoftwareTitle.SoftwarePackage.URL) require.Equal(t, file.GetInstallScript("pkg"), stResp.SoftwareTitle.SoftwarePackage.InstallScript) require.Equal(t, expectedUninstallScript, stResp.SoftwareTitle.SoftwarePackage.UninstallScript) diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 2b7e12dec6..5c47689296 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -2050,7 +2050,7 @@ func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileS payloadDummy := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "dummy_installer.pkg", - Title: "DummyApp.app", + Title: "DummyApp", TeamID: &tm.ID, } s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "") @@ -2236,7 +2236,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -2300,7 +2300,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) // Software is now running, script is still pending - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -2328,7 +2328,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) // no MDM command got enqueued due to the /status call (device not released yet) @@ -2339,7 +2339,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu // Software is installed, now we should run the script statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -2410,7 +2410,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu // release of the device, as all setup experience steps are now complete. statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -2570,7 +2570,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3309,7 +3309,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { // Add the VPP app to setup experience vppTitleID := getSoftwareTitleID(t, s.ds, "App 5", "apps") - installerTitleID := getSoftwareTitleID(t, s.ds, "DummyApp.app", "apps") + installerTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps") var swInstallResp putSetupExperienceSoftwareResponse s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: team.ID, TitleIDs: []uint{vppTitleID, installerTitleID}}, http.StatusOK, &swInstallResp) @@ -3404,7 +3404,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 2) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3452,7 +3452,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 2) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.Equal(t, "App 5", statusResp.Results.Software[1].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status) @@ -3466,7 +3466,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 2) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) // App 5 has no licenses available, so we should get a status failed here and setup experience @@ -3477,7 +3477,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { // Software installations are done, now we should run the script statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3508,7 +3508,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { // release of the device, as all setup experience steps are now complete. statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3597,7 +3597,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3644,7 +3644,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) @@ -3688,7 +3688,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 1) - require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 9e28c2d2ce..259141f7b8 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -15934,7 +15934,7 @@ func (s *integrationMDMTestSuite) TestCancelUpcomingActivity() { payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "dummy_installer.pkg", - Title: "DummyApp.app", + Title: "DummyApp", } s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") swTitleID := getSoftwareTitleID(t, s.ds, payload.Title, "apps") @@ -16011,7 +16011,7 @@ func (s *integrationMDMTestSuite) TestCancelUpcomingActivity() { // cancel the uninstall, confirm canceled activity s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", mdmHost.ID, hostActivitiesResp.Activities[1].UUID), nil, http.StatusNoContent) lastCanceledActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeCanceledUninstallSoftware{}.ActivityName(), - fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp.app", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) // record an uninstall result post-cancelation s.DoJSON("POST", "/api/fleet/orbit/scripts/result", @@ -16064,7 +16064,7 @@ func (s *integrationMDMTestSuite) TestCancelUpcomingActivity() { // cancel the software install, confirm canceled activity s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", mdmHost.ID, hostActivitiesResp.Activities[1].UUID), nil, http.StatusNoContent) lastCanceledActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeCanceledInstallSoftware{}.ActivityName(), - fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp.app", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) // record a software install result post-cancelation s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ diff --git a/tools/custom-package-parser/main.go b/tools/custom-package-parser/main.go index 9820da7cb7..782c7a2f79 100644 --- a/tools/custom-package-parser/main.go +++ b/tools/custom-package-parser/main.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "os" + "path/filepath" + "slices" "strings" "github.com/fleetdm/fleet/v4/pkg/file" @@ -15,62 +17,101 @@ import ( func main() { url := flag.String("url", "", "URL of the custom package") - path := flag.String("path", "", "File path of the custom package") + path := flag.String("path", "", "File path of the custom package (or a directory of packages)") flag.Parse() - if *url == "" && *path == "" { - log.Fatal("missing -url or -path argument") - } - if *url != "" && *path != "" { - log.Fatal("cannot set both -url and -path") - } + switch { + case *url != "" && *path != "": + log.Fatalf("-url and -path are mutually exclusive") + case *url != "": + metadata, err := processPackageFromUrl(*url) + if err != nil { + log.Fatal(err) + } + output(metadata) + case *path != "": + pathInfo, err := os.Stat(*path) + if err != nil { + log.Fatal(err) + } + if pathInfo.IsDir() { + files, err := os.ReadDir(*path) + if err != nil { + log.Fatal(err) + } - metadata, err := processPackage(*url, *path) - if err != nil { - log.Fatal(err) - } + for _, selectedFile := range files { + if selectedFile.IsDir() || strings.HasPrefix(selectedFile.Name(), ".") { + continue + } + fmt.Printf("File: %s\n", selectedFile.Name()) + metadata, err := processPackageFromLocal(filepath.Join(*path, selectedFile.Name())) + if err != nil { + log.Fatal(err) + } + output(metadata) + } + } else { + metadata, err := processPackageFromLocal(*path) + if err != nil { + log.Fatal(err) + } + output(metadata) + } + default: + flag.Usage() + } +} + +func output(metadata *file.InstallerMetadata) { + slices.Sort(metadata.PackageIDs) fmt.Printf( "- Name: '%s'\n- Bundle Identifier: '%s'\n- Package IDs: '%s'\n- Version: %s\n\n", metadata.Name, metadata.BundleIdentifier, strings.Join(metadata.PackageIDs, ","), metadata.Version, ) } -func processPackage(url, path string) (*file.InstallerMetadata, error) { - var tfr *fleet.TempFileReader - if url != "" { - client := fleethttp.NewClient() - client.Transport = fleethttp.NewSizeLimitTransport(fleet.MaxSoftwareInstallerSize) +func processPackageFromUrl(url string) (*file.InstallerMetadata, error) { + client := fleethttp.NewClient() + client.Transport = fleethttp.NewSizeLimitTransport(fleet.MaxSoftwareInstallerSize) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("create http request: %s", err) - } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create http request: %s", err) + } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("get request: %s", err) - } - defer resp.Body.Close() + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("get request: %s", err) + } + defer resp.Body.Close() - // Allow all 2xx and 3xx status codes in this pass. - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("get request failed with status: %d", resp.StatusCode) - } + // Allow all 2xx and 3xx status codes in this pass. + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("get request failed with status: %d", resp.StatusCode) + } - tfr, err = fleet.NewTempFileReader(resp.Body, nil) - if err != nil { - return nil, fmt.Errorf("reading custom package: %d", resp.StatusCode) - } - defer tfr.Close() - } else { // -path - fp, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open file: %s", err) - } - tfr = &fleet.TempFileReader{ - File: fp, - } + tfr, err := fleet.NewTempFileReader(resp.Body, nil) + if err != nil { + return nil, fmt.Errorf("reading custom package: %d", resp.StatusCode) + } + defer tfr.Close() + + metadata, err := file.ExtractInstallerMetadata(tfr) + if err != nil { + return nil, fmt.Errorf("extract installer metadata: %s", err) + } + return metadata, nil +} + +func processPackageFromLocal(path string) (*file.InstallerMetadata, error) { + fp, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open file: %s", err) + } + tfr := &fleet.TempFileReader{ + File: fp, } metadata, err := file.ExtractInstallerMetadata(tfr)