Files
fleet/server/service/script_variables_test.go
Jordan Montgomery 89f67544b4 Add support for fleet vars in scripts(controls scripts, software scripts/script-only packages and setup experience scripts) (#49781)
<!-- 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 -->
2026-07-27 11:24:42 +02:00

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)
})
}