Modify Windows replacement code to allow Custom SCEP variables (#34633)

and refactor to share with apple mdm

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34246 

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

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
This commit is contained in:
Magnus Jensen
2025-10-22 15:46:48 -03:00
committed by GitHub
parent b3fa01a144
commit d6a23a79ee
16 changed files with 711 additions and 174 deletions
+33
View File
@@ -2337,3 +2337,36 @@ func (ds *Datastore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host
return nil
})
}
func (ds *Datastore) UpdateOrDeleteHostMDMWindowsProfile(ctx context.Context, profile *fleet.HostMDMWindowsProfile) error {
// Delete the host profile if it's remove and verified/verifying.
if profile.OperationType == fleet.MDMOperationTypeRemove && profile.Status != nil &&
(*profile.Status == fleet.MDMDeliveryVerifying || *profile.Status == fleet.MDMDeliveryVerified) {
_, err := ds.writer(ctx).ExecContext(ctx, `
DELETE FROM host_mdm_windows_profiles
WHERE host_uuid = ? AND command_uuid = ?
`, profile.HostUUID, profile.CommandUUID)
return err
}
detail := profile.Detail
if profile.OperationType == fleet.MDMOperationTypeRemove && profile.Status != nil && *profile.Status == fleet.MDMDeliveryFailed {
detail = fmt.Sprintf("Failed to remove: %s", detail)
}
status := profile.Status
// We need to run with retry due to potential deadlocks with BulkSetPendingMDMHostProfiles.
// Deadlock seen in 2024/12/12 loadtest: https://docs.google.com/document/d/1-Q6qFTd7CDm-lh7MVRgpNlNNJijk6JZ4KO49R1fp80U
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, `
UPDATE host_mdm_windows_profiles
SET status = ?, operation_type = ?, detail = ?
WHERE host_uuid = ? AND command_uuid = ?
`, status, profile.OperationType, detail, profile.HostUUID, profile.CommandUUID)
return err
})
return err
}
@@ -0,0 +1,37 @@
package tables
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
func init() {
MigrationClient.AddMigration(Up_20251022123456, Down_20251022123456)
}
func Up_20251022123456(tx *sql.Tx) error {
insStmt := `
INSERT INTO fleet_variables (
name, is_prefix, created_at
) VALUES
('FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID', 0, :created_at)
`
// use a constant time so that the generated schema is deterministic
createdAt := time.Date(2025, 10, 22, 0, 0, 0, 0, time.UTC)
stmt, args, err := sqlx.Named(insStmt, map[string]any{"created_at": createdAt})
if err != nil {
return fmt.Errorf("Failed to prepare insert for FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID: %s", err)
}
_, err = tx.Exec(stmt, args...)
if err != nil {
return fmt.Errorf("failed to insert FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID into fleet_variables: %s", err)
}
return nil
}
func Down_20251022123456(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,25 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20251022123456(t *testing.T) {
db := applyUpToPrev(t)
// look up table, and see it does not contain FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID
var count int
err := db.Get(&count, `SELECT COUNT(*) FROM fleet_variables WHERE name = 'FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID'`)
require.NoError(t, err)
require.Equal(t, 0, count)
// Apply current migration.
applyNext(t, db)
// look up table, and see it now contains FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID
err = db.Get(&count, `SELECT COUNT(*) FROM fleet_variables WHERE name = 'FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID'`)
require.NoError(t, err)
require.Equal(t, 1, count)
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -2424,6 +2424,8 @@ type Datastore interface {
// GetCurrentTime gets the current time from the database
GetCurrentTime(ctx context.Context) (time.Time, error)
UpdateOrDeleteHostMDMWindowsProfile(ctx context.Context, profile *HostMDMWindowsProfile) error
}
type AndroidDatastore interface {
+37
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"time"
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
@@ -64,11 +65,33 @@ const (
FleetVarCustomSCEPProxyURLPrefix FleetVarName = "CUSTOM_SCEP_PROXY_URL_"
FleetVarSmallstepSCEPChallengePrefix FleetVarName = "SMALLSTEP_SCEP_CHALLENGE_"
FleetVarSmallstepSCEPProxyURLPrefix FleetVarName = "SMALLSTEP_SCEP_PROXY_URL_"
FleetVarSCEPWindowsCertificateID FleetVarName = "SCEP_WINDOWS_CERTIFICATE_ID" // nolint:gosec // G101: Potential hardcoded credentials
// OneTimeChallengeTTL is the time to live for one-time challenges.
OneTimeChallengeTTL = 1 * time.Hour
)
var (
// Fleet variable regexp patterns
FleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserEmailIDP))
FleetVarHostHardwareSerialRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostHardwareSerial))
FleetVarHostEndUserIDPUsernameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPUsername))
FleetVarHostEndUserIDPDepartmentRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPDepartment))
FleetVarHostEndUserIDPUsernameLocalPartRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPUsernameLocalPart))
FleetVarHostEndUserIDPGroupsRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPGroups))
FleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPChallenge))
FleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPProxyURL))
FleetVarHostEndUserIDPFullnameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPFullname))
FleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPRenewalID))
FleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostUUID))
FleetVarSCEPWindowsCertificateIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPWindowsCertificateID))
// Fleet variable replacement failed errors
HostEndUserEmailIDPVariableReplacementFailedError = fmt.Sprintf("There is no IdP email for this host. "+
"Fleet couldn't populate $FLEET_VAR_%s. "+
"[Learn more](https://fleetdm.com/learn-more-about/idp-email)", FleetVarHostEndUserEmailIDP)
)
type AppleMDM struct {
CommonName string `json:"common_name"`
SerialNumber string `json:"serial_number"`
@@ -1100,3 +1123,17 @@ type MDMCommandResults interface {
}
type MDMCommandResultsHandler func(ctx context.Context, results MDMCommandResults) error
// Helper function for variable replacement in MDM profiles
func GetFirstIDPEmail(ctx context.Context, ds Datastore, hostUUID string) (email *string, err error) {
// TODO: Should we check on another type of device mapping? Instead of mdm_idp_accounts source.
emails, err := ds.GetHostEmails(ctx, hostUUID, DeviceMappingMDMIdpAccounts)
if err != nil {
// This is a server error, so we exit.
return nil, err
}
if len(emails) == 0 {
return nil, nil
}
return &emails[0], nil
}
+110 -19
View File
@@ -16,8 +16,9 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/admx"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/wlanxml"
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
"github.com/fleetdm/fleet/v4/server/variables"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
)
@@ -30,7 +31,8 @@ import (
// - The data (if any) of the first <Item> element of the current LocURI
func LoopOverExpectedHostProfiles(
ctx context.Context,
ds fleet.ProfileVerificationStore,
logger kitlog.Logger,
ds fleet.Datastore,
host *fleet.Host,
fn func(profile *fleet.ExpectedMDMProfile, hash, locURI, data string),
) error {
@@ -46,7 +48,7 @@ func LoopOverExpectedHostProfiles(
// Process Fleet variables if present (similar to how it's done during profile deployment)
// This ensures we compare what was actually sent to the device
processedContent := PreprocessWindowsProfileContents(host.UUID, expanded)
processedContent := PreprocessWindowsProfileContentsForVerification(ctx, logger, ds, host.UUID, expectedProf.ProfileUUID, expanded)
expectedProf.RawProfile = []byte(processedContent)
var prof fleet.SyncMLCmd
@@ -85,7 +87,7 @@ func HashLocURI(profileName, locURI string) string {
// VerifyHostMDMProfiles performs the verification of the MDM profiles installed on a host and
// updates the verification status in the datastore. It is intended to be called by Fleet osquery
// service when the Fleet server ingests host details.
func VerifyHostMDMProfiles(ctx context.Context, logger log.Logger, ds fleet.ProfileVerificationStore, host *fleet.Host,
func VerifyHostMDMProfiles(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, host *fleet.Host,
rawProfileResultsSyncML []byte,
) error {
profileResults, err := transformProfileResults(rawProfileResultsSyncML)
@@ -150,7 +152,7 @@ func splitMissingProfilesIntoFailAndRetryBuckets(ctx context.Context, ds fleet.P
return toFail, toRetry, nil
}
func compareResultsToExpectedProfiles(ctx context.Context, logger log.Logger, ds fleet.ProfileVerificationStore, host *fleet.Host,
func compareResultsToExpectedProfiles(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, host *fleet.Host,
profileResults profileResultsTransform, existingProfiles []fleet.HostMDMWindowsProfile,
) (verified map[string]struct{}, missing map[string]struct{}, err error) {
missing = map[string]struct{}{}
@@ -162,7 +164,7 @@ func compareResultsToExpectedProfiles(ctx context.Context, logger log.Logger, ds
windowsProfilesByID[existingProfile.ProfileUUID] = existingProfile
}
err = LoopOverExpectedHostProfiles(ctx, ds, host, func(profile *fleet.ExpectedMDMProfile, ref, locURI, wantData string) {
err = LoopOverExpectedHostProfiles(ctx, logger, ds, host, func(profile *fleet.ExpectedMDMProfile, ref, locURI, wantData string) {
// if we didn't get a status for a LocURI, mark the profile as missing.
gotStatus, ok := profileResults.cmdRefToStatus[ref]
if !ok {
@@ -278,7 +280,41 @@ func IsWin32OrDesktopBridgeADMXCSP(locURI string) bool {
return false
}
// PreprocessWindowsProfileContents processes Windows configuration profiles to replace Fleet variables
// PreprocessWindowsProfileContentsForVerification processes Windows configuration profiles to replace Fleet variables
// with the given host UUID for verification purposes.
//
// This function is similar to PreprocessWindowsProfileContentsForDeployment, but it does not require
// a datastore or logger since it only replaces certain fleet variables to avoid datastore unnecessary work.
func PreprocessWindowsProfileContentsForVerification(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, hostUUID string, profileUUID string, profileContents string) string {
replacedContents, _ := preprocessWindowsProfileContents(ctx, logger, ds, nil, true, hostUUID, "", profileUUID, nil, profileContents)
// ^ We ignore the error here, and rely on the fact that the function will return the original contents if no replacements were made.
// So verification fails on individual profile level, instead of entire verification failing.
return replacedContents
}
// PreprocessWindowsProfileContentsForDeployment processes Windows configuration profiles to replace Fleet variables
// with their actual values for each host during profile deployment.
func PreprocessWindowsProfileContentsForDeployment(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig, hostUUID string, hostCmdUUID string, profileUUID string, groupedCAs *fleet.GroupedCertificateAuthorities, profileContents string) (string, error) {
// TODO: Should we avoid iterating this list for every profile?
customSCEPCAs := make(map[string]*fleet.CustomSCEPProxyCA, len(groupedCAs.CustomScepProxy))
for _, ca := range groupedCAs.CustomScepProxy {
customSCEPCAs[ca.Name] = &ca
}
return preprocessWindowsProfileContents(ctx, logger, ds, appConfig, false, hostUUID, hostCmdUUID, profileUUID, customSCEPCAs, profileContents)
}
// This error type is used to indicate errors during Microsoft profile processing, such as variable replacement failures.
// It should not break the entire deployment flow, but rather be handled gracefully at the profile level, setting it to failed and detail = Error()
type MicrosoftProfileProcessingError struct {
message string
}
func (e *MicrosoftProfileProcessingError) Error() string {
return e.message
}
// preprocessWindowsProfileContents processes Windows configuration profiles to replace Fleet variables
// with their actual values for each host. This function is used both during profile deployment
// and during profile verification to ensure consistency.
//
@@ -286,6 +322,10 @@ func IsWin32OrDesktopBridgeADMXCSP(locURI string) bool {
//
// Currently supported variables:
// - $FLEET_VAR_HOST_UUID or ${FLEET_VAR_HOST_UUID}: Replaced with the host's UUID
// - $FLEET_VAR_HOST_END_USER_EMAIL_IDP or ${FLEET_VAR_HOST_END_USER_EMAIL_IDP}: Replaced with the host's end user email from the IDP
// - $FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID or ${FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID}: Replaced with the host command UUID for SCEP certificate
// - $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME> or ${FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>}: Replaced with the challenge for the specified custom SCEP CA
// - $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME> or ${FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>}: Replaced with the proxy URL for the specified custom SCEP CA
//
// Why we don't use Go templates here:
// 1. Error handling: Go templates don't provide fine-grained error handling for individual variable
@@ -296,29 +336,80 @@ func IsWin32OrDesktopBridgeADMXCSP(locURI string) bool {
// thousands of host profiles. Direct string replacement is more efficient for our use case.
// 4. XML escaping: We need XML-specific escaping for values, which is simpler to control with direct
// string replacement rather than template functions.
func PreprocessWindowsProfileContents(hostUUID string, profileContents string) string {
func preprocessWindowsProfileContents(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig,
isVerifying bool, hostUUID string, hostCmdUUID string, profileUUID string,
customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profileContents string,
) (string, error) {
// Check if Fleet variables are present
fleetVars := variables.Find(profileContents)
if len(fleetVars) == 0 {
// No variables to replace, return original content
return profileContents
return profileContents, nil
}
// Process each Fleet variable
result := profileContents
for fleetVar := range fleetVars {
if fleetVar == string(fleet.FleetVarHostUUID) {
// Replace HOST_UUID with the actual host UUID
// Use XML escaping for the replacement value to be safe and prevent XML injection
b := make([]byte, 0, len(hostUUID))
buf := bytes.NewBuffer(b)
_ = xml.EscapeText(buf, []byte(hostUUID))
escapedUUID := buf.String()
result = variables.Replace(result, fleetVar, escapedUUID)
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostUUIDRegexp, result, hostUUID)
} else if fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) {
replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserEmailIDPVariable(ctx, ds, profileContents, hostUUID)
if err != nil {
return profileContents, ctxerr.Wrap(ctx, err, "replacing host end user email IDP variable")
}
if !replacedVariable {
return profileContents, &MicrosoftProfileProcessingError{message: fleet.HostEndUserEmailIDPVariableReplacementFailedError}
}
result = replacedContents
}
// Add other Fleet variables here as they are implemented
// We skip some variables during verification, to avoid unnecessary datastore calls
// or processing that is not needed for verification.
if isVerifying {
continue
}
switch {
case fleetVar == string(fleet.FleetVarSCEPWindowsCertificateID):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, hostCmdUUID)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error {
return &MicrosoftProfileProcessingError{message: errMsg}
})
if err != nil {
return profileContents, err
}
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, result)
if err != nil {
return profileContents, ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable")
}
if !replacedVariable {
return profileContents, &MicrosoftProfileProcessingError{message: fmt.Sprintf("Custom SCEP challenge variable replacement failed for variable %s", fleetVar)}
}
result = replacedContents
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix))
err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error {
return &MicrosoftProfileProcessingError{message: errMsg}
})
if err != nil {
return profileContents, err
}
replacedContents, _, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, result, hostUUID, profileUUID)
if err != nil {
return profileContents, ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable")
}
if !replacedVariable {
return profileContents, &MicrosoftProfileProcessingError{message: fmt.Sprintf("Custom SCEP challenge variable replacement failed for variable %s", fleetVar)}
}
result = replacedContents
// TODO: Add managed certificate here.
}
// Add other Fleet variables here as they are implemented, identify if it can be skipped for verification.
}
return result
return result, nil
}
+228 -3
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/wlanxml"
@@ -65,7 +66,7 @@ func TestLoopHostMDMLocURIs(t *testing.T) {
uniqueHash string
}
got := []wantStruct{}
err := LoopOverExpectedHostProfiles(ctx, ds, &fleet.Host{}, func(profile *fleet.ExpectedMDMProfile, hash, locURI, data string) {
err := LoopOverExpectedHostProfiles(ctx, log.NewNopLogger(), ds, &fleet.Host{}, func(profile *fleet.ExpectedMDMProfile, hash, locURI, data string) {
got = append(got, wantStruct{
locURI: locURI,
data: data,
@@ -821,7 +822,16 @@ type hostProfile struct {
RetryCount uint
}
func TestPreprocessWindowsProfileContents(t *testing.T) {
func TestPreprocessWindowsProfileContentsForVerification(t *testing.T) {
ds := new(mock.Store)
ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID, source string) ([]string, error) {
if source == fleet.DeviceMappingMDMIdpAccounts && strings.Contains(hostUUID, "end-user-email") {
return []string{"test@idp.com"}, nil
}
return nil, nil
}
tests := []struct {
name string
hostUUID string
@@ -882,12 +892,227 @@ func TestPreprocessWindowsProfileContents(t *testing.T) {
profileContents: `<Replace><Data>ID1: $FLEET_VAR_HOST_UUID, ID2: ${FLEET_VAR_HOST_UUID}</Data></Replace>`,
expectedContents: `<Replace><Data>ID1: test-host-1234-uuid, ID2: test-host-1234-uuid</Data></Replace>`,
},
{
name: "fleet variable with db access",
hostUUID: "test-host-end-user-email",
profileContents: `<Replace><Data>ID: $FLEET_VAR_HOST_UUID, Other: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
expectedContents: `<Replace><Data>ID: test-host-end-user-email, Other: test@idp.com</Data></Replace>`,
},
{
name: "skips scep windows id var",
hostUUID: "test-host-1234-uuid",
profileContents: `<Replace><Data>ID: $FLEET_VAR_HOST_UUID, SCEP: $FLEET_VAR_HOST_SCEP_WINDOWS_ID</Data></Replace>`,
expectedContents: `<Replace><Data>ID: test-host-1234-uuid, SCEP: $FLEET_VAR_HOST_SCEP_WINDOWS_ID</Data></Replace>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := PreprocessWindowsProfileContents(tt.hostUUID, tt.profileContents)
result := PreprocessWindowsProfileContentsForVerification(t.Context(), log.NewNopLogger(), ds, tt.hostUUID, uuid.NewString(), tt.profileContents)
require.Equal(t, tt.expectedContents, result)
})
}
}
func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
ds := new(mock.Store)
baseSetup := func() {
ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID, source string) ([]string, error) {
if source == fleet.DeviceMappingMDMIdpAccounts && strings.Contains(hostUUID, "end-user-email") {
return []string{"test@idp.com"}, nil
}
return nil, nil
}
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
if ds.GetAllCertificateAuthoritiesFunc == nil {
return &fleet.GroupedCertificateAuthorities{
CustomScepProxy: []fleet.CustomSCEPProxyCA{},
}, nil
}
cas, err := ds.GetAllCertificateAuthoritiesFunc(ctx, includeSecrets)
if err != nil {
return nil, err
}
return fleet.GroupCertificateAuthoritiesByType(cas)
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://test-fleet.com",
},
}, nil
}
}
// use the same uuid for all profile UUID actions
profileUUID := uuid.NewString()
tests := []struct {
name string
hostUUID string
hostCmdUUID string
profileContents string
expectedContents string
expectError bool
processingError string // if set then we expect the error to be of type MicrosoftProfileProcessingError with this message
setup func() // Used for setting up datastore mocks.
freeTier bool
}{
{
name: "no fleet variables",
hostUUID: "test-uuid-123",
profileContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Simple Value</Data></Item></Replace>`,
expectedContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Simple Value</Data></Item></Replace>`,
},
{
name: "host uuid fleet variable",
hostUUID: "test-uuid-456",
profileContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Device ID: $FLEET_VAR_HOST_UUID</Data></Item></Replace>`,
expectedContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Device ID: test-uuid-456</Data></Item></Replace>`,
},
{
name: "host end user email idp",
hostUUID: "test-uuid-end-user-email",
profileContents: `<Replace><Data>Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
expectedContents: `<Replace><Data>Email: test@idp.com</Data></Replace>`,
},
{
name: "no host end user email idp found",
hostUUID: "test-uuid-no-end-user",
profileContents: `<Replace><Data>Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
expectError: true,
processingError: fleet.HostEndUserEmailIDPVariableReplacementFailedError,
},
{
name: "scep windows certificate id",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data>SCEP: $FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID</Data></Replace>`,
expectedContents: `<Replace><Data>SCEP: cmd-uuid-5678</Data></Replace>`,
},
{
name: "custom scep proxy url not usable in free tier",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data>CA: $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE</Data></Replace>`,
expectError: true,
processingError: "Custom SCEP integration requires a Fleet Premium license.",
freeTier: true,
},
{
name: "custom scep proxy url ca not found",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data>CA: $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE</Data></Replace>`,
expectError: true,
processingError: "Fleet couldn't populate $CUSTOM_SCEP_PROXY_URL_CERTIFICATE because CERTIFICATE certificate authority doesn't exist.",
},
{
name: "custom scep proxy url ca found and replaced",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data> $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE</Data></Replace>`,
expectedContents: `<Replace><Data>https://test-fleet.com/mdm/scep/proxy/test-host-1234-uuid%2C` + profileUUID + `%2CCERTIFICATE%2Csupersecret</Data></Replace>`,
setup: func() {
ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) {
return []*fleet.CertificateAuthority{
{
ID: 1,
Name: ptr.String("CERTIFICATE"),
Type: string(fleet.CATypeCustomSCEPProxy),
URL: ptr.String("https://scep.proxy.url/scep"),
Challenge: ptr.String("supersecret"),
},
}, nil
}
ds.NewChallengeFunc = func(ctx context.Context) (string, error) {
return "supersecret", nil
}
},
},
{
name: "custom scep challenge not usable in free tier",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data>CA: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE</Data></Replace>`,
expectError: true,
processingError: "Custom SCEP integration requires a Fleet Premium license.",
freeTier: true,
},
{
name: "custom scep proxy challenge ca not found",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data>CA: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE</Data></Replace>`,
expectError: true,
processingError: "Fleet couldn't populate $CUSTOM_SCEP_CHALLENGE_CERTIFICATE because CERTIFICATE certificate authority doesn't exist.",
},
{
name: "custom scep proxy challenge ca found and replaced",
hostUUID: "test-host-1234-uuid",
hostCmdUUID: "cmd-uuid-5678",
profileContents: `<Replace><Data> $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE</Data></Replace>`,
expectedContents: `<Replace><Data>supersecret</Data></Replace>`,
setup: func() {
ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) {
return []*fleet.CertificateAuthority{
{
ID: 1,
Name: ptr.String("CERTIFICATE"),
Type: string(fleet.CATypeCustomSCEPProxy),
URL: ptr.String("https://scep.proxy.url/scep"),
Challenge: ptr.String("supersecret"),
},
}, nil
}
ds.NewChallengeFunc = func(ctx context.Context) (string, error) {
return "supersecret", nil
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseSetup()
if tt.setup != nil {
tt.setup()
}
t.Cleanup(func() {
ds = new(mock.Store) // Reset the mock datastore after each test, to avoid overlapping setups.
})
licenseInfo := &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
if tt.freeTier {
licenseInfo.Tier = fleet.TierFree
}
ctx := license.NewContext(t.Context(), licenseInfo)
appConfig, err := ds.AppConfig(ctx)
require.NoError(t, err)
// Populate this one, in setup by mocking ds.GetAllCertificateAuthoritiesFunc if needed.
groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true)
require.NoError(t, err)
result, err := PreprocessWindowsProfileContentsForDeployment(ctx, log.NewNopLogger(), ds, appConfig, tt.hostUUID, tt.hostCmdUUID, profileUUID, groupedCAs, tt.profileContents)
if tt.expectError {
require.Error(t, err)
if tt.processingError != "" {
var processingErr *MicrosoftProfileProcessingError
require.ErrorAs(t, err, &processingErr, "expected ProfileProcessingError")
require.Equal(t, tt.processingError, processingErr.Error())
}
return // do not verify profile contents if an error is expected
}
require.Equal(t, tt.expectedContents, result)
require.NoError(t, err)
})
}
}
+135
View File
@@ -0,0 +1,135 @@
package profiles
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
)
/*
This file contains functions to replace profile variables in MDM profiles, that are supported
on multiple platforms, so it can be shared.
Fleet variables supported across systems:
- $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>
- $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>
- $FLEET_VAR_HOST_END_USER_EMAIL_IDP
Once more is needed it should be placed here, and the main replacement logic can be taken from the apple_mdm.go
under server/service folder. Inside the `preprocessProfileContents` under the `fleetVarLoop` loop.
*/
func ReplaceCustomSCEPChallengeVariable(ctx context.Context, logger kitlog.Logger, fleetVariable string, customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profileContents string) (contents string, replacedVariable bool, err error) {
caName := strings.TrimPrefix(fleetVariable, string(fleet.FleetVarCustomSCEPChallengePrefix))
ca, ok := customSCEPCAs[caName]
if !ok {
level.Error(logger).Log("msg", "Custom SCEP CA not found. "+
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
return "", false, nil
}
contents, err = ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarCustomSCEPChallengePrefix), ca.Name, profileContents, ca.Challenge)
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "replacing Fleet variable for SCEP challenge")
}
return contents, true, nil
}
func ReplaceCustomSCEPProxyURLVariable(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig,
fleetVar string, customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profileContents string,
hostUUID string, profUUID string,
) (contents string, managedCertificate *fleet.MDMManagedCertificate, replacedVariable bool, err error) {
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix))
ca, ok := customSCEPCAs[caName]
if !ok {
level.Error(logger).Log("msg", "Custom SCEP CA not found. "+
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
return "", nil, false, nil
}
// Generate a new SCEP challenge for the profile
challenge, err := ds.NewChallenge(ctx)
if err != nil {
return "", nil, false, ctxerr.Wrap(ctx, err, "generating SCEP challenge")
}
// Insert the SCEP URL into the profile contents
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
url.PathEscape(fmt.Sprintf("%s,%s,%s,%s", hostUUID, profUUID, caName, challenge)))
contents, err = ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarCustomSCEPProxyURLPrefix), ca.Name, profileContents, proxyURL)
if err != nil {
return "", nil, false, ctxerr.Wrap(ctx, err, "replacing Fleet variable for SCEP proxy URL")
}
managedCertificate = &fleet.MDMManagedCertificate{
HostUUID: hostUUID,
ProfileUUID: profUUID,
Type: fleet.CAConfigCustomSCEPProxy,
CAName: caName,
}
return contents, managedCertificate, true, nil
}
// ! Important if we add new replacedVariable=false cases, that we verify the caller functions still behave correctly, as some run actions based on whether a variable was replaced or not.
func ReplaceHostEndUserEmailIDPVariable(ctx context.Context, ds fleet.Datastore, profileContents string, hostUUID string) (contents string, replacedVariable bool, err error) {
email, err := fleet.GetFirstIDPEmail(ctx, ds, hostUUID)
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "getting IDP email")
}
if email == nil {
return "", false, nil
}
contents = ReplaceFleetVariableInXML(fleet.FleetVarHostEndUserEmailIDPRegexp, profileContents, *email)
return contents, true, nil
}
func ReplaceExactFleetPrefixVariableInXML(prefix string, suffix string, contents string, replacement string) (string, error) {
// Escape XML characters since this replacement is intended for XML profile.
b := make([]byte, 0, len(replacement))
buf := bytes.NewBuffer(b)
// error is always nil for Buffer.Write method, so we ignore it
_ = xml.EscapeText(buf, []byte(replacement))
// We are replacing an exact variable, which should be present in XML like: <something>$FLEET_VAR_OUR_VAR</something>
// We strip the leading/trailing whitespace since we don't want them to remain in XML
// Our plist parser ignores spaces in <data> type. We don't catch this issue at profile validation, so we handle it here.
fleetVar := "FLEET_VAR_" + prefix + suffix
re, err := regexp.Compile(fmt.Sprintf(`>\s*((\$%s)|(\${%s}))\s*<`, fleetVar, fleetVar))
if err != nil {
return "", err
}
return re.ReplaceAllLiteralString(contents, fmt.Sprintf(`>%s<`, buf.String())), nil
}
func ReplaceFleetVariableInXML(regExp *regexp.Regexp, contents string, replacement string) string {
// Escape XML characters since this replacement is intended for XML profile.
b := make([]byte, 0, len(replacement))
buf := bytes.NewBuffer(b)
// error is always nil for Buffer.Write method, so we ignore it
_ = xml.EscapeText(buf, []byte(replacement))
return regExp.ReplaceAllLiteralString(contents, buf.String())
}
func IsCustomSCEPConfigured(ctx context.Context,
customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, caName string, fleetVar string,
onError func(string) error, // A function that allows the caller to run some code on errors, if an error is returned it will be returned by IsCustomSCEPConfigured
) error {
if !license.IsPremium(ctx) {
return onError("Custom SCEP integration requires a Fleet Premium license.")
}
if _, ok := customSCEPCAs[caName]; !ok {
return onError(fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName))
}
return nil
}
+12
View File
@@ -1563,6 +1563,8 @@ type BatchApplyCertificateAuthoritiesFunc func(ctx context.Context, ops fleet.Ce
type GetCurrentTimeFunc func(ctx context.Context) (time.Time, error)
type UpdateOrDeleteHostMDMWindowsProfileFunc func(ctx context.Context, profile *fleet.HostMDMWindowsProfile) error
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@@ -3874,6 +3876,9 @@ type DataStore struct {
GetCurrentTimeFunc GetCurrentTimeFunc
GetCurrentTimeFuncInvoked bool
UpdateOrDeleteHostMDMWindowsProfileFunc UpdateOrDeleteHostMDMWindowsProfileFunc
UpdateOrDeleteHostMDMWindowsProfileFuncInvoked bool
mu sync.Mutex
}
@@ -9266,3 +9271,10 @@ func (s *DataStore) GetCurrentTime(ctx context.Context) (time.Time, error) {
s.mu.Unlock()
return s.GetCurrentTimeFunc(ctx)
}
func (s *DataStore) UpdateOrDeleteHostMDMWindowsProfile(ctx context.Context, profile *fleet.HostMDMWindowsProfile) error {
s.mu.Lock()
s.UpdateOrDeleteHostMDMWindowsProfileFuncInvoked = true
s.mu.Unlock()
return s.UpdateOrDeleteHostMDMWindowsProfileFunc(ctx, profile)
}
+2 -2
View File
@@ -233,7 +233,7 @@ type SearchHostsFunc func(ctx context.Context, matchQuery string, queryID *uint,
type ListHostDeviceMappingFunc func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error)
type SetHostDeviceMappingFunc func(ctx context.Context, id uint, email, source string) ([]*fleet.HostDeviceMapping, error)
type SetHostDeviceMappingFunc func(ctx context.Context, id uint, email string, source string) ([]*fleet.HostDeviceMapping, error)
type HostLiteByIdentifierFunc func(ctx context.Context, identifier string) (*fleet.HostLite, error)
@@ -2822,7 +2822,7 @@ func (s *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.
return s.ListHostDeviceMappingFunc(ctx, id)
}
func (s *Service) SetHostDeviceMapping(ctx context.Context, id uint, email, source string) ([]*fleet.HostDeviceMapping, error) {
func (s *Service) SetHostDeviceMapping(ctx context.Context, id uint, email string, source string) ([]*fleet.HostDeviceMapping, error) {
s.mu.Lock()
s.SetHostDeviceMappingFuncInvoked = true
s.mu.Unlock()
+62 -142
View File
@@ -9,7 +9,6 @@ import (
"encoding/hex"
"encoding/json"
"encoding/pem"
"encoding/xml"
"errors"
"fmt"
"io"
@@ -52,6 +51,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/fleetdm/fleet/v4/server/variables"
@@ -4142,7 +4142,6 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx cont
// We run this check here as we only want to run it on re-check ins for deleted hosts.
if (platform == "ios" || platform == "ipados") && isLostModeEnabled {
fmt.Println("===lost mode enabled on iPhone/iPad, checking for lock command record", host.UUID)
cmd, err := svc.ds.GetLatestAppleMDMCommandOfType(ctx, host.UUID, "EnableLostMode")
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "check for existing EnableLostMode command")
@@ -5153,13 +5152,15 @@ func preprocessProfileContents(
}
if customSCEPCAs == nil {
customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA)
for _, ca := range groupedCAs.CustomScepProxy {
customSCEPCAs[ca.Name] = &ca
}
}
configured, err := isCustomSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, customSCEPCAs, profUUID, target, caName,
fleetVar)
err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error {
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC()))
return err
})
if err != nil {
return ctxerr.Wrap(ctx, err, "checking custom SCEP configuration")
}
if !configured {
valid = false
break initialFleetVarLoop
}
@@ -5281,58 +5282,39 @@ func preprocessProfileContents(
}
managedCertificatePayloads = append(managedCertificatePayloads, payload)
hostContents = replaceFleetVariableInXML(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge)
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge)
case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL):
// Insert the SCEP URL into the profile contents
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
url.PathEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, profUUID)))
hostContents = replaceFleetVariableInXML(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL)
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL)
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
// Insert the SCEP renewal ID into the SCEP Payload CN or OU
fleetRenewalID := "fleet-" + profUUID
hostContents = replaceFleetVariableInXML(fleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID)
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
ca, ok := customSCEPCAs[caName]
if !ok {
level.Error(logger).Log("msg", "Custom SCEP CA not found. "+
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable")
}
if !replacedVariable {
continue
}
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarCustomSCEPChallengePrefix), ca.Name, hostContents, ca.Challenge)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for SCEP challenge")
}
hostContents = replacedContents
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix))
ca, ok := customSCEPCAs[caName]
if !ok {
level.Error(logger).Log("msg", "Custom SCEP CA not found. "+
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable")
}
if !replacedVariable {
continue
}
// Generate a new SCEP challenge for the profile
challenge, err := ds.NewChallenge(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating SCEP challenge")
}
// Insert the SCEP URL into the profile contents
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
url.PathEscape(fmt.Sprintf("%s,%s,%s,%s", hostUUID, profUUID, caName, challenge)))
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarCustomSCEPProxyURLPrefix), ca.Name, hostContents, proxyURL)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for SCEP proxy URL")
}
managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{
HostUUID: hostUUID,
ProfileUUID: profUUID,
Type: fleet.CAConfigCustomSCEPProxy,
CAName: caName,
})
hostContents = replacedContents
managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix))
@@ -5369,7 +5351,7 @@ func preprocessProfileContents(
CAName: caName,
}
managedCertificatePayloads = append(managedCertificatePayloads, payload)
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge)
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable")
}
@@ -5379,21 +5361,32 @@ func preprocessProfileContents(
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix))
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName)))
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL)
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable")
}
case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP):
email, ok, err := getIDPEmail(ctx, ds, target, hostUUID)
replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserEmailIDPVariable(ctx, ds, hostContents, hostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting IDP email")
return ctxerr.Wrap(ctx, err, "replacing host end user email IDP variable")
}
if !ok {
if !replacedVariable {
// We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail.
err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
CommandUUID: target.cmdUUID,
HostUUID: hostUUID,
Status: &fleet.MDMDeliveryFailed,
Detail: fleet.HostEndUserEmailIDPVariableReplacementFailedError,
OperationType: fleet.MDMOperationTypeInstall,
})
if err != nil {
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for host end user email IDP")
}
failed = true
break fleetVarLoop
}
hostContents = replaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email)
hostContents = replacedContents
case fleetVar == string(fleet.FleetVarHostHardwareSerial):
hardwareSerial, ok, err := getHostHardwareSerial(ctx, ds, target, hostUUID)
@@ -5404,7 +5397,7 @@ func preprocessProfileContents(
failed = true
break fleetVarLoop
}
hostContents = replaceFleetVariableInXML(fleetVarHostHardwareSerialRegexp, hostContents, hardwareSerial)
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostHardwareSerialRegexp, hostContents, hardwareSerial)
case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) ||
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) ||
@@ -5437,7 +5430,7 @@ func preprocessProfileContents(
rx = fleetVarHostEndUserIDPFullnameRegexp
value = strings.TrimSpace(user.IdpFullName)
}
hostContents = replaceFleetVariableInXML(rx, hostContents, value)
hostContents = profiles.ReplaceFleetVariableInXML(rx, hostContents, value)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)):
// We will replace the password when we populate the certificate data
@@ -5500,12 +5493,12 @@ func preprocessProfileContents(
failed = true
break fleetVarLoop
}
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents,
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents,
base64.StdEncoding.EncodeToString(cert.PfxData))
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data")
}
hostContents, err = replaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password)
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password)
if err != nil {
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password")
}
@@ -5566,16 +5559,28 @@ func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdT
email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)]
if !ok {
var err error
email, ok, err = getIDPEmail(ctx, ds, target, hostUUID)
foundEmail, err := fleet.GetFirstIDPEmail(ctx, ds, hostUUID)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "getting IDP email")
}
if !ok {
if foundEmail == nil {
// We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail.
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
CommandUUID: target.cmdUUID,
HostUUID: hostUUID,
Status: &fleet.MDMDeliveryFailed,
Detail: fleet.HostEndUserEmailIDPVariableReplacementFailedError,
OperationType: fleet.MDMOperationTypeInstall,
})
if err != nil {
return false, err
}
return false, nil
}
caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email
caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = *foundEmail
email = *foundEmail
}
*item = replaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email)
*item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email)
case string(fleet.FleetVarHostHardwareSerial):
hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)]
if !ok {
@@ -5589,7 +5594,7 @@ func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdT
}
caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hardwareSerial
}
*item = replaceFleetVariableInXML(fleetVarHostHardwareSerialRegexp, *item, hardwareSerial)
*item = profiles.ReplaceFleetVariableInXML(fleetVarHostHardwareSerialRegexp, *item, hardwareSerial)
default:
// We should not reach this since we validated the variables when saving app config
}
@@ -5715,33 +5720,6 @@ func getEmailLocalPart(email string) string {
return local
}
func getIDPEmail(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) {
// Insert the end user email IDP into the profile contents
emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts)
if err != nil {
// This is a server error, so we exit.
return "", false, ctxerr.Wrap(ctx, err, "getting host emails")
}
if len(emails) == 0 {
// We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail.
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
CommandUUID: target.cmdUUID,
HostUUID: hostUUID,
Status: &fleet.MDMDeliveryFailed,
Detail: fmt.Sprintf("There is no IdP email for this host. "+
"Fleet couldn't populate $FLEET_VAR_%s. "+
"[Learn more](https://fleetdm.com/learn-more-about/idp-email)",
fleet.FleetVarHostEndUserEmailIDP),
OperationType: fleet.MDMOperationTypeInstall,
})
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP")
}
return "", false, nil
}
return emails[0], true, nil
}
func getHostHardwareSerial(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) {
hosts, err := ds.ListHostsLiteByUUIDs(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, []string{hostUUID})
if err != nil {
@@ -6040,37 +6018,6 @@ func (cs *customSCEPVarsFound) SetRenewalID() (*customSCEPVarsFound, bool) {
return cs, !alreadyPresent
}
func isCustomSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore,
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
userEnrollmentsToHostUUIDsMap map[string]string,
existingCustomSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profUUID string, target *cmdTarget, caName string, fleetVar string,
) (bool, error) {
if !license.IsPremium(ctx) {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Custom SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC()))
}
if _, ok := existingCustomSCEPCAs[caName]; ok {
return true, nil
}
configured := false
var scepCA *fleet.CustomSCEPProxyCA
if len(groupedCAs.CustomScepProxy) > 0 {
for _, ca := range groupedCAs.CustomScepProxy {
if ca.Name == caName {
scepCA = &ca
configured = true
break
}
}
}
if !configured || scepCA == nil {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID,
fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC()))
}
existingCustomSCEPCAs[caName] = scepCA
return true, nil
}
type smallstepVarsFound struct {
urlCA map[string]struct{}
challengeCA map[string]struct{}
@@ -6254,33 +6201,6 @@ func markProfilesFailed(
return false, nil
}
func replaceFleetVariableInXML(regExp *regexp.Regexp, contents string, replacement string) string {
// Escape XML characters since this replacement is intended for XML profile.
b := make([]byte, 0, len(replacement))
buf := bytes.NewBuffer(b)
// error is always nil for Buffer.Write method, so we ignore it
_ = xml.EscapeText(buf, []byte(replacement))
return regExp.ReplaceAllLiteralString(contents, buf.String())
}
func replaceExactFleetPrefixVariableInXML(prefix string, suffix string, contents string, replacement string) (string, error) {
// Escape XML characters since this replacement is intended for XML profile.
b := make([]byte, 0, len(replacement))
buf := bytes.NewBuffer(b)
// error is always nil for Buffer.Write method, so we ignore it
_ = xml.EscapeText(buf, []byte(replacement))
// We are replacing an exact variable, which should be present in XML like: <something>$FLEET_VAR_OUR_VAR</something>
// We strip the leading/trailing whitespace since we don't want them to remain in XML
// Our plist parser ignores spaces in <data> type. We don't catch this issue at profile validation, so we handle it here.
fleetVar := "FLEET_VAR_" + prefix + suffix
re, err := regexp.Compile(fmt.Sprintf(`>\s*((\$%s)|(\${%s}))\s*<`, fleetVar, fleetVar))
if err != nil {
return "", err
}
return re.ReplaceAllLiteralString(contents, fmt.Sprintf(`>%s<`, buf.String())), nil
}
// scepCertRenewalThresholdDays defines the number of days before a SCEP
// certificate must be renewed.
const scepCertRenewalThresholdDays = 180
+16 -3
View File
@@ -2310,6 +2310,11 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki
return ctxerr.Wrap(ctx, err, "get profile contents")
}
groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities")
}
for profUUID, target := range installTargets {
p, ok := profileContents[profUUID]
if !ok {
@@ -2338,12 +2343,20 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki
continue
}
// Preprocess the profile content for this specific host
processedContent := microsoft_mdm.PreprocessWindowsProfileContents(hostUUID, string(p.SyncML))
// Create a unique command UUID for this host since the content is unique
hostCmdUUID := uuid.New().String()
// Preprocess the profile content for this specific host
processedContent, err := microsoft_mdm.PreprocessWindowsProfileContentsForDeployment(ctx, logger, ds, appConfig, hostUUID, hostCmdUUID, profUUID, groupedCAs, string(p.SyncML))
var profileProcessingError *microsoft_mdm.MicrosoftProfileProcessingError
if err != nil && !errors.As(err, &profileProcessingError) {
return ctxerr.Wrapf(ctx, err, "preprocessing profile contents for host %s and profile %s", hostUUID, profUUID)
} else if err != nil && errors.As(err, &profileProcessingError) {
hp.Status = &fleet.MDMDeliveryFailed
hp.Detail = profileProcessingError.Error()
continue
}
// Build the command with the processed content
command, err := buildCommandFromProfileBytes([]byte(processedContent), hostCmdUUID)
if err != nil {
+6
View File
@@ -505,6 +505,12 @@ func TestReconcileWindowsProfilesWithFleetVariableError(t *testing.T) {
return nil
}
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*mdm_types.GroupedCertificateAuthorities, error) {
return &fleet.GroupedCertificateAuthorities{
CustomScepProxy: []fleet.CustomSCEPProxyCA{},
}, nil
}
// Run ReconcileWindowsProfiles
err := ReconcileWindowsProfiles(ctx, ds, logger)
require.NoError(t, err) // The function should not return an error even if insert fails
+1 -1
View File
@@ -3031,7 +3031,7 @@ func buildConfigProfilesWindowsQuery(
var sb strings.Builder
sb.WriteString("<SyncBody>")
gotProfiles := false
err := microsoft_mdm.LoopOverExpectedHostProfiles(ctx, ds, host, func(profile *fleet.ExpectedMDMProfile, hash, locURI, data string) {
err := microsoft_mdm.LoopOverExpectedHostProfiles(ctx, logger, ds, host, func(profile *fleet.ExpectedMDMProfile, hash, locURI, data string) {
// Per the [docs][1], to `<Get>` configurations you must
// replace `/Policy/Config/` with `Policy/Result/`
// [1]: https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-configuration-service-provider