From effd3563c86fbf05da44ea307a2c23d29c6bb9e4 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:17:18 -0500 Subject: [PATCH] Add secrets software script support (#24912) #24899 --- .../24899-software-installer-scripts-secrets | 1 + ee/server/service/maintained_apps.go | 4 ++ ee/server/service/software_installers.go | 27 ++++++++ pkg/spec/gitops.go | 32 ++++++++++ pkg/spec/gitops_test.go | 9 ++- pkg/spec/testdata/lib/collect-fleetd-logs.sh | 1 + pkg/spec/testdata/lib/uninstall.sh | 1 + .../testdata/microsoft-teams.pkg.software.yml | 2 +- server/datastore/mysql/software_installers.go | 18 ++++++ .../mysql/software_installers_test.go | 28 +++++++-- server/fleet/secrets.go | 2 +- server/service/integration_enterprise_test.go | 63 +++++++++++++++++++ 12 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 changes/24899-software-installer-scripts-secrets create mode 100644 pkg/spec/testdata/lib/uninstall.sh diff --git a/changes/24899-software-installer-scripts-secrets b/changes/24899-software-installer-scripts-secrets new file mode 100644 index 0000000000..f5f11a77c7 --- /dev/null +++ b/changes/24899-software-installer-scripts-secrets @@ -0,0 +1 @@ +- Add support for fleet secret validation in software installer scripts diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 0171378515..fea74bf6bc 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -36,6 +36,10 @@ func (svc *Service) AddFleetMaintainedApp( return 0, fleet.ErrNoContext } + if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil { + return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) + } + app, err := svc.ds.GetMaintainedAppByID(ctx, appID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id") diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 7c86740682..82c626bda0 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -64,6 +64,10 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. // Update $PACKAGE_ID in uninstall script preProcessUninstallScript(payload) + if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) + } + installerID, titleID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload) if err != nil { return ctxerr.Wrap(ctx, err, "matching or creating software installer") @@ -144,6 +148,22 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. teamName = &t.Name } + var scripts []string + + if payload.InstallScript != nil { + scripts = append(scripts, *payload.InstallScript) + } + if payload.PostInstallScript != nil { + scripts = append(scripts, *payload.PostInstallScript) + } + if payload.UninstallScript != nil { + scripts = append(scripts, *payload.UninstallScript) + } + + if err := svc.ds.ValidateEmbeddedSecrets(ctx, scripts); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) + } + // get software by ID, fail if it does not exist or does not have an existing installer software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{ User: vc.User, @@ -1149,6 +1169,8 @@ func (svc *Service) BatchSetSoftwareInstallers( return "", ctxerr.Wrap(ctx, err, "validating authorization") } + var allScripts []string + // Verify payloads first, to prevent starting the download+upload process if the data is invalid. for _, payload := range payloads { if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength { @@ -1163,6 +1185,11 @@ func (svc *Service) BatchSetSoftwareInstallers( fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL), ) } + allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript) + } + + if err := svc.ds.ValidateEmbeddedSecrets(ctx, allScripts); err != nil { + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) } // keyExpireTime is the current maximum time supported for retrieving diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 98ed97e9a6..a4f0e0a595 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -853,6 +853,24 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin } else { softwarePackageSpec = resolveSoftwarePackagePaths(baseDir, item.SoftwarePackageSpec) } + if softwarePackageSpec.InstallScript.Path != "" { + if err := gatherFileSecrets(result, softwarePackageSpec.InstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } + if softwarePackageSpec.PostInstallScript.Path != "" { + if err := gatherFileSecrets(result, softwarePackageSpec.PostInstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } + if softwarePackageSpec.UninstallScript.Path != "" { + if err := gatherFileSecrets(result, softwarePackageSpec.UninstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } if softwarePackageSpec.URL == "" { multiError = multierror.Append(multiError, errors.New("software URL is required")) continue @@ -867,6 +885,20 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin return multiError } +func gatherFileSecrets(result *GitOps, filePath string) error { + fileBytes, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets) + if err != nil { + return fmt.Errorf("failed to lookup environment secrets for %s: %w", filePath, err) + } + + return nil +} + func resolveSoftwarePackagePaths(baseDir string, softwareSpec fleet.SoftwarePackageSpec) fleet.SoftwarePackageSpec { if softwareSpec.PreInstallQuery.Path != "" { softwareSpec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, softwareSpec.PreInstallQuery.Path) diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index dfc6703ed5..a72501409e 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -83,6 +83,7 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", + "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/global_config_no_paths.yml", }, @@ -94,6 +95,7 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", + "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/global_config.yml", }, @@ -102,6 +104,7 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", + "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/team_config_no_paths.yml", isTeam: true, @@ -115,6 +118,7 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", + "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/team_config.yml", isTeam: true, @@ -172,7 +176,7 @@ func TestValidGitOpsYaml(t *testing.T) { require.Len(t, gitops.Software.Packages, 2) for _, pkg := range gitops.Software.Packages { if strings.Contains(pkg.URL, "MicrosoftTeams") { - assert.Equal(t, "uninstall.sh", pkg.UninstallScript.Path) + assert.Equal(t, "testdata/lib/uninstall.sh", pkg.UninstallScript.Path) } else { assert.Empty(t, pkg.UninstallScript.Path) } @@ -236,10 +240,11 @@ func TestValidGitOpsYaml(t *testing.T) { assert.True(t, ok, "windows_migration_enabled not found") _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{}) assert.True(t, ok, "windows_updates not found") - require.Len(t, gitops.FleetSecrets, 3) + require.Len(t, gitops.FleetSecrets, 4) assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"]) assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"]) assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_length"]) + assert.Equal(t, "bread", gitops.FleetSecrets["FLEET_SECRET_BANANA"]) // Check agent options assert.NotNil(t, gitops.AgentOptions) diff --git a/pkg/spec/testdata/lib/collect-fleetd-logs.sh b/pkg/spec/testdata/lib/collect-fleetd-logs.sh index f81b4da0dd..cd2b5fc62f 100644 --- a/pkg/spec/testdata/lib/collect-fleetd-logs.sh +++ b/pkg/spec/testdata/lib/collect-fleetd-logs.sh @@ -1,3 +1,4 @@ # collect fleetd logs echo a${FLEET_SECRET_FLEET_SECRET_}a echo $NOT_FLEET_SECRET_X +echo $FLEET_SECRET_BANANA diff --git a/pkg/spec/testdata/lib/uninstall.sh b/pkg/spec/testdata/lib/uninstall.sh new file mode 100644 index 0000000000..9b43cff106 --- /dev/null +++ b/pkg/spec/testdata/lib/uninstall.sh @@ -0,0 +1 @@ +echo $FLEET_SECRET_BANANA diff --git a/pkg/spec/testdata/microsoft-teams.pkg.software.yml b/pkg/spec/testdata/microsoft-teams.pkg.software.yml index 9f10d148d6..55807b3a10 100644 --- a/pkg/spec/testdata/microsoft-teams.pkg.software.yml +++ b/pkg/spec/testdata/microsoft-teams.pkg.software.yml @@ -1,4 +1,4 @@ url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg self_service: false uninstall_script: - path: ../uninstall.sh + path: ./lib/uninstall.sh diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 1081f53f91..8f3d66d7f4 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -70,6 +70,24 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId } return nil, ctxerr.Wrap(ctx, err, "get software install details") } + + expandedInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.InstallScript) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "expanding secrets in install script") + } + expandedPostInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.PostInstallScript) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "expanding secrets in post-install script") + } + expandedUninstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.UninstallScript) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "expanding secrets in uninstall script") + } + + result.InstallScript = expandedInstallScript + result.PostInstallScript = expandedPostInstallScript + result.UninstallScript = expandedUninstallScript + return result, nil } diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 7af41846a4..87251f7363 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -54,13 +54,29 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + err := ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ + { + Name: "RUBBER", + Value: "DUCKY", + }, + { + Name: "BIG", + Value: "BIRD", + }, + { + Name: "COOKIE", + Value: "MONSTER", + }, + }) + require.NoError(t, err) + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) require.NoError(t, err) installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "hello", + InstallScript: "hello $FLEET_SECRET_RUBBER", PreInstallQuery: "SELECT 1", - PostInstallScript: "world", - UninstallScript: "goodbye", + PostInstallScript: "world $FLEET_SECRET_BIG", + UninstallScript: "goodbye $FLEET_SECRET_COOKIE", InstallerFile: tfr1, StorageID: "storage1", Filename: "file1", @@ -151,12 +167,12 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { require.Equal(t, host1.ID, exec1.HostID) require.Equal(t, hostInstall1, exec1.ExecutionID) - require.Equal(t, "hello", exec1.InstallScript) - require.Equal(t, "world", exec1.PostInstallScript) + require.Equal(t, "hello DUCKY", exec1.InstallScript) + require.Equal(t, "world BIRD", exec1.PostInstallScript) require.Equal(t, installerID1, exec1.InstallerID) require.Equal(t, "SELECT 1", exec1.PreInstallCondition) require.False(t, exec1.SelfService) - assert.Equal(t, "goodbye", exec1.UninstallScript) + assert.Equal(t, "goodbye MONSTER", exec1.UninstallScript) hostInstall6, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID3, true, nil) require.NoError(t, err) diff --git a/server/fleet/secrets.go b/server/fleet/secrets.go index 0688a825d0..f8ecaac4ad 100644 --- a/server/fleet/secrets.go +++ b/server/fleet/secrets.go @@ -20,5 +20,5 @@ func (e MissingSecretsError) Error() string { if len(secretVars) > 1 { plural = "s" } - return fmt.Sprintf("Couldn't add. Variable%s %s missing", plural, strings.Join(secretVars, ", ")) + return fmt.Sprintf("Couldn't add. Secret variable%s %s missing from database", plural, strings.Join(secretVars, ", ")) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 97d4e0e7d8..f11d82bd9c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8923,6 +8923,31 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { } s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "") + payloadEmacsMissingSecret := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install $FLEET_SECRET_INVALID", + Filename: "emacs.deb", + PostInstallScript: "d", + SelfService: true, + } + s.uploadSoftwareInstaller(t, payloadEmacsMissingSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID") + + payloadEmacsMissingPostSecret := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "emacs.deb", + PostInstallScript: "d $FLEET_SECRET_INVALID", + SelfService: true, + } + s.uploadSoftwareInstaller(t, payloadEmacsMissingPostSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID") + + payloadEmacsMissingUnSecret := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "emacs.deb", + PostInstallScript: "d", + UninstallScript: "delet $FLEET_SECRET_INVALID", + SelfService: true, + } + s.uploadSoftwareInstaller(t, payloadEmacsMissingUnSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID") + payloadEmacs := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "emacs.deb", @@ -11281,6 +11306,28 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { require.NotNil(t, packages[0].TeamID) require.Equal(t, tm.ID, *packages[0].TeamID) + softwareToInstallBadSecret := []fleet.SoftwareInstallerPayload{ + { + URL: rubyURL, + InstallScript: "echo $FLEET_SECRET_INVALID", + }, + } + resp := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name) + errMsg := extractServerErrorText(resp.Body) + require.Contains(t, errMsg, "$FLEET_SECRET_INVALID") + + softwareToInstallBadSecret[0].InstallScript = "" + softwareToInstallBadSecret[0].PostInstallScript = "echo $FLEET_SECRET_ALSO_INVALID" + resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name) + errMsg = extractServerErrorText(resp.Body) + require.Contains(t, errMsg, "$FLEET_SECRET_ALSO_INVALID") + + softwareToInstallBadSecret[0].PostInstallScript = "" + softwareToInstallBadSecret[0].UninstallScript = "echo $FLEET_SECRET_THIRD_INVALID" + resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name) + errMsg = extractServerErrorText(resp.Body) + require.Contains(t, errMsg, "$FLEET_SECRET_THIRD_INVALID") + // TODO(roberto): test with a variety of response codes // check the application status @@ -15411,6 +15458,22 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { getMAResp.FleetMaintainedApp.UninstallScript = "" require.Equal(t, actualApp, *getMAResp.FleetMaintainedApp) + // Try adding ingested app with invalid secret + reqInvalidSecret := &addFleetMaintainedAppRequest{ + AppID: 1, + TeamID: &team.ID, + SelfService: true, + PreInstallQuery: "SELECT 1", + InstallScript: "echo foo $FLEET_SECRET_INVALID1", + PostInstallScript: "echo done $FLEET_SECRET_INVALID2", + UninstallScript: "echo $FLEET_SECRET_INVALID3", + } + respBadSecret := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqInvalidSecret, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(respBadSecret.Body) + require.Contains(t, errMsg, "$FLEET_SECRET_INVALID1") + require.Contains(t, errMsg, "$FLEET_SECRET_INVALID2") + require.Contains(t, errMsg, "$FLEET_SECRET_INVALID3") + // Add an ingested app to the team var addMAResp addFleetMaintainedAppResponse req := &addFleetMaintainedAppRequest{