<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #49511 and #46837 as a whole # 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/guides/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), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for Fleet built-in variables in host scripts, software installer scripts, setup-experience scripts, and maintained-app installer scripts. * Variables are resolved per host at execution time; saved content remains unexpanded. * **Bug Fixes** * Requests now validate Fleet variables up-front, with clear script-specific error messages for unsupported variables. * Added improved messaging when variable resolution fails during execution. * Enforced Fleet Premium licensing for script/installer flows that use Fleet variables. * **Documentation** * Documented supported variables and Premium requirements, including usage examples. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
384 lines
15 KiB
Go
384 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestMaybeExpandScriptFleetVariables(t *testing.T) {
|
|
newSvcAndCtx := func(tier string) (*Service, context.Context, *mock.Store) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{ds: ds}
|
|
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: tier})
|
|
return svc, ctx, ds
|
|
}
|
|
|
|
host := &fleet.Host{
|
|
ID: 42,
|
|
UUID: "ABC-123",
|
|
HardwareSerial: "SERIAL-1",
|
|
Platform: "darwin",
|
|
}
|
|
|
|
scimUser := &fleet.ScimUser{
|
|
UserName: "user@example.com",
|
|
GivenName: new("Ada"),
|
|
FamilyName: new("Lovelace"),
|
|
Department: new("Engineering"),
|
|
Groups: []fleet.ScimUserGroup{{DisplayName: "g1"}, {DisplayName: "g2"}},
|
|
}
|
|
mockScimUser := func(ds *mock.Store, user *fleet.ScimUser) {
|
|
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
|
|
if user == nil {
|
|
return nil, newNotFoundError()
|
|
}
|
|
return user, nil
|
|
}
|
|
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
t.Run("no variables is byte-for-byte unchanged", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(fleet.TierPremium)
|
|
for _, contents := range []string{
|
|
"#!/bin/sh\necho hello\n",
|
|
"echo $FLEET_SECRET_FOO and $FLEET_HOST_VITAL_computer_name",
|
|
"echo $OTHER_VAR",
|
|
"",
|
|
} {
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host, contents)
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, contents, expanded)
|
|
}
|
|
})
|
|
|
|
t.Run("host variables expand", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(fleet.TierPremium)
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host,
|
|
"echo $FLEET_VAR_HOST_UUID $FLEET_VAR_HOST_HARDWARE_SERIAL ${FLEET_VAR_HOST_PLATFORM}")
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, "echo ABC-123 SERIAL-1 macos", expanded)
|
|
})
|
|
|
|
t.Run("platform passes through for linux and windows", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(fleet.TierPremium)
|
|
for platform, want := range map[string]string{"ubuntu": "ubuntu", "rhel": "rhel", "windows": "windows"} {
|
|
h := *host
|
|
h.Platform = platform
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, &h, "echo $FLEET_VAR_HOST_PLATFORM")
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, "echo "+want, expanded)
|
|
}
|
|
})
|
|
|
|
t.Run("IdP variables expand", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(fleet.TierPremium)
|
|
mockScimUser(ds, scimUser)
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host,
|
|
"user: $FLEET_VAR_HOST_END_USER_IDP_USERNAME\n"+
|
|
"email: user_${FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART}@corp.example.com\n"+
|
|
"name: $FLEET_VAR_HOST_END_USER_IDP_FULL_NAME\n"+
|
|
"groups: $FLEET_VAR_HOST_END_USER_IDP_GROUPS\n"+
|
|
"dept: $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT\n")
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, "user: user@example.com\n"+
|
|
"email: user_user@corp.example.com\n"+
|
|
"name: Ada Lovelace\n"+
|
|
"groups: g1,g2\n"+
|
|
"dept: Engineering\n", expanded)
|
|
})
|
|
|
|
t.Run("missing IdP user is a resolution failure", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(fleet.TierPremium)
|
|
mockScimUser(ds, nil)
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host,
|
|
"echo $FLEET_VAR_HOST_END_USER_IDP_USERNAME")
|
|
require.NoError(t, err)
|
|
require.Empty(t, expanded)
|
|
require.Contains(t, failMsg, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.")
|
|
})
|
|
|
|
t.Run("multiple failures accumulate", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(fleet.TierPremium)
|
|
mockScimUser(ds, nil)
|
|
h := *host
|
|
h.HardwareSerial = ""
|
|
_, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, &h,
|
|
"echo $FLEET_VAR_HOST_HARDWARE_SERIAL $FLEET_VAR_HOST_END_USER_IDP_USERNAME")
|
|
require.NoError(t, err)
|
|
require.Contains(t, failMsg, "There is no hardware serial for this host.")
|
|
require.Contains(t, failMsg, "There is no IdP username for this host.")
|
|
require.Len(t, splitLines(failMsg), 2)
|
|
})
|
|
|
|
t.Run("unsupported variable names are left untouched", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(fleet.TierPremium)
|
|
contents := "echo $FLEET_VAR_SOMETHING_ELSE and $FLEET_VAR_HOST_UUID"
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host, contents)
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, "echo $FLEET_VAR_SOMETHING_ELSE and ABC-123", expanded)
|
|
})
|
|
|
|
t.Run("variables on free license fail instead of expanding", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(fleet.TierFree)
|
|
expanded, failMsg, err := svc.maybeExpandScriptFleetVariables(ctx, host, "echo $FLEET_VAR_HOST_UUID")
|
|
require.NoError(t, err)
|
|
require.Empty(t, expanded)
|
|
require.Contains(t, failMsg, "Fleet Premium license")
|
|
|
|
// variable-free content is unaffected on free
|
|
expanded, failMsg, err = svc.maybeExpandScriptFleetVariables(ctx, host, "echo hello")
|
|
require.NoError(t, err)
|
|
require.Empty(t, failMsg)
|
|
require.Equal(t, "echo hello", expanded)
|
|
})
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func TestGetHostScriptFleetVariables(t *testing.T) {
|
|
newSvcAndCtx := func(t *testing.T, host *fleet.Host, storedContents string, storedExitCode *int64) (fleet.Service, context.Context, *mock.Store) {
|
|
ds := new(mock.Store)
|
|
lic := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: lic, SkipCreateTestUsers: true})
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
|
return &fleet.HostScriptResult{
|
|
HostID: host.ID,
|
|
ExecutionID: execID,
|
|
ScriptContents: storedContents,
|
|
ExitCode: storedExitCode,
|
|
}, nil
|
|
}
|
|
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
|
|
return document, nil
|
|
}
|
|
ds.ExpandCustomHostVitalsFunc = func(ctx context.Context, hostID uint, document string) (string, error) {
|
|
return document, nil
|
|
}
|
|
return svc, ctx, ds
|
|
}
|
|
|
|
host := &fleet.Host{
|
|
ID: 42,
|
|
UUID: "ABC-123",
|
|
HardwareSerial: "SERIAL-1",
|
|
Platform: "ubuntu",
|
|
}
|
|
|
|
t.Run("variables expand for the fetching host", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(t, host, "echo $FLEET_VAR_HOST_UUID on $FLEET_VAR_HOST_PLATFORM", nil)
|
|
|
|
// pin the ordering: secrets expansion runs before fleet variables, so
|
|
// its input must still contain the unexpanded variable references
|
|
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
|
|
require.Contains(t, document, "$FLEET_VAR_HOST_UUID")
|
|
return document, nil
|
|
}
|
|
|
|
script, err := svc.GetHostScript(ctx, "exec-1")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "echo ABC-123 on ubuntu", script.ScriptContents)
|
|
require.Nil(t, script.ExitCode)
|
|
require.True(t, ds.ExpandEmbeddedSecretsFuncInvoked)
|
|
})
|
|
|
|
t.Run("unresolvable variable records failed result and returns marked script", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(t, host, "echo $FLEET_VAR_HOST_END_USER_IDP_USERNAME", nil)
|
|
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
|
|
return nil, nil
|
|
}
|
|
var savedResult *fleet.HostScriptResultPayload
|
|
ds.SetHostScriptExecutionResultFunc = func(ctx context.Context, result *fleet.HostScriptResultPayload, attemptNumber *int) (*fleet.HostScriptResult, string, error) {
|
|
savedResult = result
|
|
exitCode := int64(result.ExitCode)
|
|
return &fleet.HostScriptResult{
|
|
HostID: result.HostID,
|
|
ExecutionID: result.ExecutionID,
|
|
Output: result.Output,
|
|
ExitCode: &exitCode,
|
|
}, "", nil
|
|
}
|
|
ds.MaybeUpdateSetupExperienceScriptStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
script, err := svc.GetHostScript(ctx, "exec-1")
|
|
require.NoError(t, err)
|
|
|
|
// the failure was recorded through the normal result-saving path
|
|
require.NotNil(t, savedResult)
|
|
require.Equal(t, fleet.ExitCodeFleetVarResolutionFailed, savedResult.ExitCode)
|
|
require.Contains(t, savedResult.Output, "There is no IdP username for this host.")
|
|
require.Equal(t, host.ID, savedResult.HostID)
|
|
|
|
// the returned script carries the exit code so fleetd skips it and
|
|
// keeps processing its queue
|
|
require.NotNil(t, script.ExitCode)
|
|
require.EqualValues(t, fleet.ExitCodeFleetVarResolutionFailed, *script.ExitCode)
|
|
})
|
|
|
|
t.Run("already-completed execution is not re-recorded", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(t, host, "echo $FLEET_VAR_HOST_END_USER_IDP_USERNAME",
|
|
new(int64(fleet.ExitCodeFleetVarResolutionFailed)))
|
|
|
|
script, err := svc.GetHostScript(ctx, "exec-1")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, fleet.ExitCodeFleetVarResolutionFailed, *script.ExitCode)
|
|
require.False(t, ds.SetHostScriptExecutionResultFuncInvoked)
|
|
})
|
|
|
|
t.Run("internal scripts without variables are unchanged", func(t *testing.T) {
|
|
const lockScript = "#!/bin/sh\npmset displaysleepnow && shutdown -h now\n"
|
|
svc, ctx, _ := newSvcAndCtx(t, host, lockScript, nil)
|
|
script, err := svc.GetHostScript(ctx, "exec-1")
|
|
require.NoError(t, err)
|
|
require.Equal(t, lockScript, script.ScriptContents)
|
|
})
|
|
}
|
|
|
|
func TestGetSoftwareInstallDetailsFleetVariables(t *testing.T) {
|
|
host := &fleet.Host{
|
|
ID: 42,
|
|
UUID: "ABC-123",
|
|
HardwareSerial: "SERIAL-1",
|
|
Platform: "ubuntu",
|
|
OsqueryHostID: new("osquery-42"),
|
|
}
|
|
|
|
newSvcAndCtx := func(t *testing.T, details *fleet.SoftwareInstallDetails) (fleet.Service, context.Context, *mock.Store) {
|
|
ds := new(mock.Store)
|
|
lic := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: lic, SkipCreateTestUsers: true})
|
|
ctx = test.HostContext(ctx, host)
|
|
ds.GetSoftwareInstallDetailsFunc = func(ctx context.Context, executionID string) (*fleet.SoftwareInstallDetails, error) {
|
|
return details, nil
|
|
}
|
|
return svc, ctx, ds
|
|
}
|
|
|
|
t.Run("variables expand in all three scripts", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(t, &fleet.SoftwareInstallDetails{
|
|
HostID: host.ID,
|
|
ExecutionID: "install-1",
|
|
InstallScript: "install $FLEET_VAR_HOST_HARDWARE_SERIAL",
|
|
PostInstallScript: "post ${FLEET_VAR_HOST_UUID}",
|
|
UninstallScript: "uninstall $FLEET_VAR_HOST_PLATFORM",
|
|
})
|
|
|
|
details, err := svc.GetSoftwareInstallDetails(ctx, "install-1")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "install SERIAL-1", details.InstallScript)
|
|
require.Equal(t, "post ABC-123", details.PostInstallScript)
|
|
require.Equal(t, "uninstall ubuntu", details.UninstallScript)
|
|
})
|
|
|
|
t.Run("scripts without variables are unchanged", func(t *testing.T) {
|
|
svc, ctx, _ := newSvcAndCtx(t, &fleet.SoftwareInstallDetails{
|
|
HostID: host.ID,
|
|
ExecutionID: "install-1",
|
|
InstallScript: "install --flag",
|
|
})
|
|
|
|
details, err := svc.GetSoftwareInstallDetails(ctx, "install-1")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "install --flag", details.InstallScript)
|
|
require.Empty(t, details.PostInstallScript)
|
|
})
|
|
|
|
t.Run("unresolvable variable records failed install and returns not found", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(t, &fleet.SoftwareInstallDetails{
|
|
HostID: host.ID,
|
|
ExecutionID: "install-1",
|
|
InstallScript: "install $FLEET_VAR_HOST_END_USER_IDP_USERNAME",
|
|
UninstallScript: "uninstall $FLEET_VAR_HOST_END_USER_IDP_USERNAME",
|
|
})
|
|
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
|
|
return nil, nil
|
|
}
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
InstallUUID: "install-1",
|
|
HostID: host.ID,
|
|
Status: fleet.SoftwareInstallPending,
|
|
}
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return hsi, nil
|
|
}
|
|
var savedResult *fleet.HostSoftwareInstallResultPayload
|
|
ds.SetHostSoftwareInstallResultFunc = func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload, attemptNumber *int) (bool, error) {
|
|
savedResult = result
|
|
return false, nil
|
|
}
|
|
ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
_, err := svc.GetSoftwareInstallDetails(ctx, "install-1")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err), "expected not-found, got: %v", err)
|
|
|
|
// the failure was recorded through the normal result-saving path, with
|
|
// the identical failure reported once even though two scripts hit it
|
|
require.NotNil(t, savedResult)
|
|
require.NotNil(t, savedResult.InstallScriptExitCode)
|
|
require.Equal(t, fleet.ExitCodeFleetVarResolutionFailed, *savedResult.InstallScriptExitCode)
|
|
require.NotNil(t, savedResult.InstallScriptOutput)
|
|
require.Equal(t, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.", *savedResult.InstallScriptOutput)
|
|
})
|
|
|
|
t.Run("already-recorded install failure is not re-recorded", func(t *testing.T) {
|
|
svc, ctx, ds := newSvcAndCtx(t, &fleet.SoftwareInstallDetails{
|
|
HostID: host.ID,
|
|
ExecutionID: "install-1",
|
|
InstallScript: "install $FLEET_VAR_HOST_END_USER_IDP_USERNAME",
|
|
})
|
|
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
|
|
return nil, nil
|
|
}
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return &fleet.HostSoftwareInstallerResult{
|
|
InstallUUID: "install-1",
|
|
HostID: host.ID,
|
|
Status: fleet.SoftwareInstallFailed,
|
|
}, nil
|
|
}
|
|
|
|
_, err := svc.GetSoftwareInstallDetails(ctx, "install-1")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err), "expected not-found, got: %v", err)
|
|
require.False(t, ds.SetHostSoftwareInstallResultFuncInvoked)
|
|
})
|
|
}
|