diff --git a/changelog/fragments/1718833116-Check-for-tamper-protection-when-uninstalling.yaml b/changelog/fragments/1718833116-Check-for-tamper-protection-when-uninstalling.yaml new file mode 100644 index 00000000000..1495198ae0d --- /dev/null +++ b/changelog/fragments/1718833116-Check-for-tamper-protection-when-uninstalling.yaml @@ -0,0 +1,35 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: bug + +# Change summary; a 80ish characters long description of the change. +summary: Use installed agent to uninstall itself when install -f is used. + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment. +description: | + When using "elastic-agent install -f", the agent will exec "elastic-agent uninstall -f" + using the agent found in the system's path. This ensures all path references are correctly + loaded and tamper protection errors will cause the install attempt to fail. + +# Affected component; a word indicating the component this changeset affects. +component: + +# PR URL; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +#pr: https://github.com/owner/repo/1234 + +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +issue: https://github.com/elastic/elastic-agent/issues/4506 diff --git a/internal/pkg/agent/cmd/install.go b/internal/pkg/agent/cmd/install.go index cb58f2a8f7c..9d518ef1bbb 100644 --- a/internal/pkg/agent/cmd/install.go +++ b/internal/pkg/agent/cmd/install.go @@ -23,8 +23,9 @@ import ( ) const ( - flagInstallBasePath = "base-path" - flagInstallUnprivileged = "unprivileged" + flagInstallBasePath = "base-path" + flagInstallUnprivileged = "unprivileged" + flagInstallRunUninstallFromBinary = "run-uninstall-from-binary" ) func newInstallCommandWithArgs(_ []string, streams *cli.IOStreams) *cobra.Command { @@ -49,6 +50,9 @@ would like the Agent to operate. cmd.Flags().String(flagInstallBasePath, paths.DefaultBasePath, "The path where the Elastic Agent will be installed. It must be an absolute path.") cmd.Flags().Bool(flagInstallUnprivileged, false, "Installed Elastic Agent will create an 'elastic-agent' user and run as that user. (experimental)") _ = cmd.Flags().MarkHidden(flagInstallUnprivileged) // Hidden until fully supported + + cmd.Flags().Bool(flagInstallRunUninstallFromBinary, false, "Run the uninstall command from this binary instead of using the binary found in the system's path.") + _ = cmd.Flags().MarkHidden(flagInstallRunUninstallFromBinary) // For internal use only. addEnrollFlags(cmd) return cmd @@ -88,6 +92,11 @@ func installCmd(streams *cli.IOStreams, cmd *cobra.Command) error { return fmt.Errorf("already installed at: %s", topPath) } + runUninstallBinary, _ := cmd.Flags().GetBool(flagInstallRunUninstallFromBinary) + if status == install.Installed && force && runUninstallBinary { + fmt.Fprintln(streams.Out, "Uninstall will not be ran from the agent installed in system path, components may persist.") + } + nonInteractive, _ := cmd.Flags().GetBool("non-interactive") if nonInteractive { fmt.Fprintln(streams.Out, "Installing in non-interactive mode.") @@ -199,6 +208,24 @@ func installCmd(streams *cli.IOStreams, cmd *cobra.Command) error { var ownership utils.FileOwner cfgFile := paths.ConfigFile() + if status == install.Installed { + // Uninstall the agent + progBar.Describe("Uninstalling current Elastic Agent") + if !runUninstallBinary { + err := execUninstall(streams) + if err != nil { + progBar.Describe("Uninstall failed") + return err + } + } else { + err := install.Uninstall(cfgFile, topPath, "", log, progBar) + if err != nil { + progBar.Describe("Uninstall from binary failed") + return err + } + } + progBar.Describe("Successfully uninstalled Elastic Agent") + } if status != install.PackageInstall { ownership, err = install.Install(cfgFile, topPath, unprivileged, log, progBar, streams) if err != nil { @@ -278,3 +305,25 @@ func installCmd(streams *cli.IOStreams, cmd *cobra.Command) error { fmt.Fprint(streams.Out, "\nElastic Agent has been successfully installed.\n") return nil } + +// execUninstall execs "elastic-agent uninstall --force" from the elastic agent installed on the system (found in PATH) +func execUninstall(streams *cli.IOStreams) error { + args := []string{ + "uninstall", + "--force", + } + execPath, err := exec.LookPath(paths.BinaryName) + if err != nil { + return fmt.Errorf("unable to find %s on path: %w", paths.BinaryName, err) + } + uninstall := exec.Command(execPath, args...) + uninstall.Stdout = streams.Out + uninstall.Stderr = streams.Err + if err := uninstall.Start(); err != nil { + return fmt.Errorf("unable to start elastic-agent uninstall: %w", err) + } + if err := uninstall.Wait(); err != nil { + return fmt.Errorf("failed to uninstall elastic-agent: %w", err) + } + return nil +} diff --git a/internal/pkg/agent/install/install.go b/internal/pkg/agent/install/install.go index 922bb85d8b6..7329f04fd03 100644 --- a/internal/pkg/agent/install/install.go +++ b/internal/pkg/agent/install/install.go @@ -40,26 +40,6 @@ func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *p return utils.FileOwner{}, errors.New(err, "failed to discover the source directory for installation", errors.TypeFilesystem) } - // We only uninstall Agent if it is currently installed. - status, _ := Status(topPath) - if status == Installed { - // Uninstall current installation - // - // There is no uninstall token for "install" command. - // Uninstall will fail on protected agent. - // The protected Agent will need to be uninstalled first before it can be installed. - pt.Describe("Uninstalling current Elastic Agent") - err = Uninstall(cfgFile, topPath, "", log, pt) - if err != nil { - pt.Describe("Failed to uninstall current Elastic Agent") - return utils.FileOwner{}, errors.New( - err, - fmt.Sprintf("failed to uninstall Agent at (%s)", filepath.Dir(topPath)), - errors.M("directory", filepath.Dir(topPath))) - } - pt.Describe("Successfully uninstalled current Elastic Agent") - } - var ownership utils.FileOwner username := "" groupName := "" diff --git a/testing/integration/endpoint_security_test.go b/testing/integration/endpoint_security_test.go index 04adf8f6e87..d97a2a44b36 100644 --- a/testing/integration/endpoint_security_test.go +++ b/testing/integration/endpoint_security_test.go @@ -148,22 +148,10 @@ func TestInstallWithEndpointSecurityAndRemoveEndpointIntegration(t *testing.T) { } } -// buildPolicyWithTamperProtection helper function to build the policy request with or without tamper protection -func buildPolicyWithTamperProtection(policy kibana.AgentPolicy, protected bool) kibana.AgentPolicy { - if protected { - policy.AgentFeatures = append(policy.AgentFeatures, map[string]interface{}{ - "name": "tamper_protection", - "enabled": true, - }) - } - policy.IsProtected = protected - return policy -} - -func testInstallAndCLIUninstallWithEndpointSecurity(t *testing.T, info *define.Info, protected bool) { - deadline := time.Now().Add(10 * time.Minute) - ctx, cancel := testcontext.WithDeadline(t, context.Background(), deadline) - defer cancel() +// installSecurityAgent is a helper function to install an elastic-agent in priviliged mode with the force+non-interactve flags. +// the policy the agent is enrolled with can have protection enabled if passed +func installSecurityAgent(ctx context.Context, t *testing.T, info *define.Info, protected bool) (*atesting.Fixture, kibana.PolicyResponse) { + t.Helper() // Get path to agent executable. fixture, err := define.NewFixture(t, define.Version()) @@ -194,6 +182,27 @@ func testInstallAndCLIUninstallWithEndpointSecurity(t *testing.T, info *define.I policy, err := tools.InstallAgentWithPolicy(ctx, t, installOpts, fixture, info.KibanaClient, createPolicyReq) require.NoError(t, err, "failed to install agent with policy") + return fixture, policy +} + +// buildPolicyWithTamperProtection helper function to build the policy request with or without tamper protection +func buildPolicyWithTamperProtection(policy kibana.AgentPolicy, protected bool) kibana.AgentPolicy { + if protected { + policy.AgentFeatures = append(policy.AgentFeatures, map[string]interface{}{ + "name": "tamper_protection", + "enabled": true, + }) + } + policy.IsProtected = protected + return policy +} + +func testInstallAndCLIUninstallWithEndpointSecurity(t *testing.T, info *define.Info, protected bool) { + deadline := time.Now().Add(10 * time.Minute) + ctx, cancel := testcontext.WithDeadline(t, context.Background(), deadline) + defer cancel() + + fixture, policy := installSecurityAgent(ctx, t, info, protected) t.Cleanup(func() { t.Log("Un-enrolling Elastic Agent...") @@ -225,39 +234,13 @@ func testInstallAndCLIUninstallWithEndpointSecurity(t *testing.T, info *define.I } func testInstallAndUnenrollWithEndpointSecurity(t *testing.T, info *define.Info, protected bool) { - // Get path to agent executable. - fixture, err := define.NewFixture(t, define.Version()) - require.NoError(t, err) - - t.Log("Enrolling the agent in Fleet") - policyUUID := uuid.New().String() - createPolicyReq := buildPolicyWithTamperProtection( - kibana.AgentPolicy{ - Name: "test-policy-" + policyUUID, - Namespace: "default", - Description: "Test policy " + policyUUID, - MonitoringEnabled: []kibana.MonitoringEnabledOption{ - kibana.MonitoringEnabledLogs, - kibana.MonitoringEnabledMetrics, - }, - }, - protected, - ) - - installOpts := atesting.InstallOpts{ - NonInteractive: true, - Force: true, - Privileged: true, - } - ctx, cn := testcontext.WithDeadline(t, context.Background(), time.Now().Add(10*time.Minute)) defer cn() - policy, err := tools.InstallAgentWithPolicy(ctx, t, installOpts, fixture, info.KibanaClient, createPolicyReq) - require.NoError(t, err) + fixture, policy := installSecurityAgent(ctx, t, info, protected) t.Log("Installing Elastic Defend") - _, err = installElasticDefendPackage(t, info, policy.ID) + _, err := installElasticDefendPackage(t, info, policy.ID) require.NoError(t, err) t.Log("Polling for endpoint-security to become Healthy") @@ -338,36 +321,10 @@ func testInstallAndUnenrollWithEndpointSecurity(t *testing.T, info *define.Info, } func testInstallWithEndpointSecurityAndRemoveEndpointIntegration(t *testing.T, info *define.Info, protected bool) { - // Get path to agent executable. - fixture, err := define.NewFixture(t, define.Version()) - require.NoError(t, err) - - t.Log("Enrolling the agent in Fleet") - policyUUID := uuid.New().String() - createPolicyReq := buildPolicyWithTamperProtection( - kibana.AgentPolicy{ - Name: "test-policy-" + policyUUID, - Namespace: "default", - Description: "Test policy " + policyUUID, - MonitoringEnabled: []kibana.MonitoringEnabledOption{ - kibana.MonitoringEnabledLogs, - kibana.MonitoringEnabledMetrics, - }, - }, - protected, - ) - - installOpts := atesting.InstallOpts{ - NonInteractive: true, - Force: true, - Privileged: true, - } - ctx, cn := testcontext.WithDeadline(t, context.Background(), time.Now().Add(10*time.Minute)) defer cn() - policy, err := tools.InstallAgentWithPolicy(ctx, t, installOpts, fixture, info.KibanaClient, createPolicyReq) - require.NoError(t, err) + fixture, policy := installSecurityAgent(ctx, t, info, protected) t.Log("Installing Elastic Defend") pkgPolicyResp, err := installElasticDefendPackage(t, info, policy.ID) @@ -914,3 +871,71 @@ func agentIsHealthyNoEndpoint(t *testing.T, ctx context.Context, agentClient cli return true } + +// TestForceInstallOverProtectedPolicy tests that running `elastic-agent install -f` +// when an installed agent is running a policy with tamper protection enabled fails. +func TestForceInstallOverProtectedPolicy(t *testing.T) { + info := define.Require(t, define.Requirements{ + Group: Fleet, + Stack: &define.Stack{}, + Local: false, // requires Agent installation + Sudo: true, // requires Agent installation + OS: []define.OS{ + {Type: define.Linux}, + }, + }) + + deadline := time.Now().Add(10 * time.Minute) + ctx, cancel := testcontext.WithDeadline(t, context.Background(), deadline) + defer cancel() + + fixture, policy := installSecurityAgent(ctx, t, info, true) + + t.Cleanup(func() { + t.Log("Un-enrolling Elastic Agent...") + // Use a separate context as the one in the test body will have been cancelled at this point. + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + assert.NoError(t, fleettools.UnEnrollAgent(cleanupCtx, info.KibanaClient, policy.ID)) + }) + + t.Log("Installing Elastic Defend") + pkgPolicyResp, err := installElasticDefendPackage(t, info, policy.ID) + require.NoErrorf(t, err, "Policy Response was: %v", pkgPolicyResp) + + t.Log("Polling for endpoint-security to become Healthy") + ctx, cancel = context.WithTimeout(ctx, endpointHealthPollingTimeout) + defer cancel() + + agentClient := fixture.Client() + err = agentClient.Connect(ctx) + require.NoError(t, err, "could not connect to local agent") + + require.Eventually(t, + func() bool { return agentAndEndpointAreHealthy(t, ctx, agentClient) }, + endpointHealthPollingTimeout, + time.Second, + "Endpoint component or units are not healthy.", + ) + t.Log("Verified endpoint component and units are healthy") + + t.Log("Run elastic-agent install -f...") + // We use the same policy with tamper protection enabled for this test and expect it to fail. + token, err := info.KibanaClient.CreateEnrollmentAPIKey(ctx, kibana.CreateEnrollmentAPIKeyRequest{ + PolicyID: policy.ID, + }) + require.NoError(t, err) + url, err := fleettools.DefaultURL(ctx, info.KibanaClient) + require.NoError(t, err) + + args := []string{ + "install", + "--force", + "--url", + url, + "--enrollment-token", + token.APIKey, + } + out, err := fixture.Exec(ctx, args) + require.Errorf(t, err, "No error detected, command output: %s", out) +}