diff --git a/server/fleet/microsoft_mdm.go b/server/fleet/microsoft_mdm.go
index 5d7c3dc5ddc..6876faf4fbc 100644
--- a/server/fleet/microsoft_mdm.go
+++ b/server/fleet/microsoft_mdm.go
@@ -1590,6 +1590,14 @@ func BuildMDMWindowsProfilePayloadFromMDMResponse(
}
}
}
+
+ if commandStatus == MDMDeliveryVerifying {
+ // Check a single LocURI for SCEP path, and move straight to verified.
+ if strings.Contains(string(cmdWithSecret.RawCommand), "/Vendor/MSFT/ClientCertificateInstall/SCEP") {
+ commandStatus = MDMDeliveryVerified
+ }
+ }
+
detail := strings.Join(details, ", ")
return &MDMWindowsProfilePayload{
HostUUID: hostUUID,
diff --git a/server/fleet/microsoft_mdm_test.go b/server/fleet/microsoft_mdm_test.go
index d61162d8146..58119413a0e 100644
--- a/server/fleet/microsoft_mdm_test.go
+++ b/server/fleet/microsoft_mdm_test.go
@@ -158,6 +158,30 @@ func TestBuildMDMWindowsProfilePayloadFromMDMResponse(t *testing.T) {
CommandUUID: "foo",
},
},
+ {
+ name: "scep profile gets verified",
+ cmd: MDMWindowsCommand{
+ CommandUUID: "foo",
+ RawCommand: []byte(`
+
+ foo
+ bar./Device/Vendor/MSFT/ClientCertificateInstall/SCEP
+ baz./Device/Vendor/MSFT/ClientCertificateInstall/SCEP
+ `),
+ },
+ statuses: map[string]SyncMLCmd{
+ "foo": {CmdID: CmdID{Value: "foo"}, Data: ptr.String("200")},
+ "bar": {CmdID: CmdID{Value: "bar"}, Data: ptr.String("200")},
+ "baz": {CmdID: CmdID{Value: "baz"}, Data: ptr.String("200")},
+ },
+ hostUUID: "host-uuid",
+ expectedPayload: &MDMWindowsProfilePayload{
+ HostUUID: "host-uuid",
+ Status: &MDMDeliveryVerified,
+ Detail: "",
+ CommandUUID: "foo",
+ },
+ },
}
for _, tt := range tests {
diff --git a/server/mdm/microsoft/profile_verifier.go b/server/mdm/microsoft/profile_verifier.go
index bc849afa656..f1452b7c4b3 100644
--- a/server/mdm/microsoft/profile_verifier.go
+++ b/server/mdm/microsoft/profile_verifier.go
@@ -305,7 +305,7 @@ type PreprocessingParameters struct {
// 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, params PreprocessingParameters) string {
- replacedContents, _ := preprocessWindowsProfileContents(ctx, logger, ds, nil, true, hostUUID, "", profileUUID, nil, profileContents, nil, params)
+ replacedContents, _ := preprocessWindowsProfileContents(ctx, logger, ds, nil, true, hostUUID, profileUUID, nil, profileContents, nil, params)
// ^ 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
@@ -314,7 +314,7 @@ func PreprocessWindowsProfileContentsForVerification(ctx context.Context, logger
// 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,
+ appConfig *fleet.AppConfig, hostCmdUUID string, profileUUID string,
groupedCAs *fleet.GroupedCertificateAuthorities, profileContents string,
managedCertificatePayloads *[]*fleet.MDMManagedCertificate,
params PreprocessingParameters,
@@ -325,7 +325,7 @@ func PreprocessWindowsProfileContentsForDeployment(ctx context.Context, logger k
customSCEPCAs[ca.Name] = &ca
}
- return preprocessWindowsProfileContents(ctx, logger, ds, appConfig, false, hostUUID, hostCmdUUID, profileUUID, customSCEPCAs, profileContents, managedCertificatePayloads, params)
+ return preprocessWindowsProfileContents(ctx, logger, ds, appConfig, false, hostCmdUUID, profileUUID, customSCEPCAs, profileContents, managedCertificatePayloads, params)
}
// This error type is used to indicate errors during Microsoft profile processing, such as variable replacement failures.
@@ -347,7 +347,7 @@ func (e *MicrosoftProfileProcessingError) Error() string {
// 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_SCEP_WINDOWS_CERTIFICATE_ID or ${FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID}: Replaced with the profile UUID for SCEP certificate
// - $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_ or ${FLEET_VAR_CUSTOM_SCEP_CHALLENGE_}: Replaced with the challenge for the specified custom SCEP CA
// - $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_ or ${FLEET_VAR_CUSTOM_SCEP_PROXY_URL_}: Replaced with the proxy URL for the specified custom SCEP CA
//
@@ -361,7 +361,7 @@ func (e *MicrosoftProfileProcessingError) Error() string {
// 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(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig,
- isVerifying bool, hostUUID string, hostCmdUUID string, profileUUID string,
+ isVerifying bool, hostUUID string, profileUUID string,
customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profileContents string,
managedCertificatePayloads *[]*fleet.MDMManagedCertificate,
params PreprocessingParameters,
@@ -399,7 +399,7 @@ func preprocessWindowsProfileContents(ctx context.Context, logger kitlog.Logger,
switch {
case fleetVar == string(fleet.FleetVarSCEPWindowsCertificateID):
- result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, hostCmdUUID)
+ result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, profileUUID)
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 {
diff --git a/server/mdm/microsoft/profile_verifier_test.go b/server/mdm/microsoft/profile_verifier_test.go
index 23922eac7a4..c5238b08dc4 100644
--- a/server/mdm/microsoft/profile_verifier_test.go
+++ b/server/mdm/microsoft/profile_verifier_test.go
@@ -991,7 +991,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
tests := []struct {
name string
hostUUID string
- hostCmdUUID string
profileContents string
expectedContents string
expectError bool
@@ -1015,14 +1014,12 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "scep windows certificate id",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `SCEP: $FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID`,
- expectedContents: `SCEP: cmd-uuid-5678`,
+ expectedContents: fmt.Sprintf(`SCEP: %s`, profileUUID),
},
{
name: "custom scep proxy url not usable in free tier",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `CA: $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE`,
expectError: true,
processingError: "Custom SCEP integration requires a Fleet Premium license.",
@@ -1031,7 +1028,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "custom scep proxy url ca not found",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `CA: $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE`,
expectError: true,
processingError: "Fleet couldn't populate $CUSTOM_SCEP_PROXY_URL_CERTIFICATE because CERTIFICATE certificate authority doesn't exist.",
@@ -1039,7 +1035,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "custom scep proxy url ca found and replaced",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: ` $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_CERTIFICATE`,
expectedContents: `https://test-fleet.com/mdm/scep/proxy/test-host-1234-uuid%2C` + profileUUID + `%2CCERTIFICATE%2Csupersecret`,
setup: func() {
@@ -1067,7 +1062,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "custom scep challenge not usable in free tier",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `CA: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`,
expectError: true,
processingError: "Custom SCEP integration requires a Fleet Premium license.",
@@ -1076,7 +1070,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "custom scep proxy challenge ca not found",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `CA: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`,
expectError: true,
processingError: "Fleet couldn't populate $CUSTOM_SCEP_CHALLENGE_CERTIFICATE because CERTIFICATE certificate authority doesn't exist.",
@@ -1084,7 +1077,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "custom scep proxy challenge ca found and replaced",
hostUUID: "test-host-1234-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: ` $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`,
expectedContents: `supersecret`,
setup: func() {
@@ -1107,7 +1099,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
{
name: "all idp variables",
hostUUID: "idp-host-uuid",
- hostCmdUUID: "cmd-uuid-5678",
profileContents: `- ./Device/TestUser: $FLEET_VAR_HOST_END_USER_IDP_USERNAME - $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART - $FLEET_VAR_HOST_END_USER_IDP_GROUPS - $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT - $FLEET_VAR_HOST_END_USER_IDP_FULL_NAME
`,
expectedContents: `- ./Device/TestUser: test@idp.com - test - Group One,Group Two - Department - First Last
`,
},
@@ -1170,7 +1161,7 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) {
managedCertificates := &[]*fleet.MDMManagedCertificate{}
- result, err := PreprocessWindowsProfileContentsForDeployment(ctx, log.NewNopLogger(), ds, appConfig, tt.hostUUID, tt.hostCmdUUID, profileUUID, groupedCAs, tt.profileContents, managedCertificates, params)
+ result, err := PreprocessWindowsProfileContentsForDeployment(ctx, log.NewNopLogger(), ds, appConfig, tt.hostUUID, profileUUID, groupedCAs, tt.profileContents, managedCertificates, params)
if tt.expectError {
require.Error(t, err)
if tt.processingError != "" {
diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go
index e96418336b7..1e024e8fa65 100644
--- a/server/service/integration_mdm_profiles_test.go
+++ b/server/service/integration_mdm_profiles_test.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/md5" // nolint:gosec // used only for tests
"crypto/x509"
+ _ "embed"
"encoding/json"
"encoding/xml"
"fmt"
@@ -28,8 +29,10 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/contract"
+ "github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
@@ -889,6 +892,47 @@ func (s *integrationMDMTestSuite) TestAppleProfileRetries() {
})
}
+type profileData struct {
+ Status string
+ LocURI string
+ Data string
+}
+
+// reportWindowsOSQueryProfiles simulates a Windows host reporting the status of MDM profiles from OSQuery results.
+func (s *integrationMDMTestSuite) reportWindowsOSQueryProfiles(ctx context.Context, t *testing.T, host *fleet.Host, hostProfileReports map[string][]profileData) {
+ var responseOps []*fleet.SyncMLCmd
+ for profileName, report := range hostProfileReports {
+ for _, p := range report {
+ ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
+ responseOps = append(responseOps, &fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdStatus},
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ CmdRef: &ref,
+ Data: ptr.String(p.Status),
+ })
+
+ // the protocol can respond with only a `Status`
+ // command if the status failed
+ if p.Status != "200" || p.Data != "" {
+ responseOps = append(responseOps, &fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdResults},
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ CmdRef: &ref,
+ Items: []fleet.CmdItem{
+ {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
+ },
+ })
+ }
+ }
+ }
+
+ msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
+ require.NoError(t, err)
+ out, err := xml.Marshal(msg)
+ require.NoError(t, err)
+ require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.logger, s.ds, host, out))
+}
+
func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
t := s.T()
ctx := context.Background()
@@ -930,50 +974,18 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
}
}
- type profileData struct {
- Status string
- LocURI string
- Data string
- }
hostProfileReports := map[string][]profileData{
"N1": {{"200", "L1", "D1"}},
"N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
}
- reportHostProfs := func(t *testing.T, profileNames ...string) {
- var responseOps []*fleet.SyncMLCmd
- for _, profileName := range profileNames {
- report, ok := hostProfileReports[profileName]
- require.True(t, ok)
-
- for _, p := range report {
- ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdStatus},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Data: ptr.String(p.Status),
- })
-
- // the protocol can respond with only a `Status`
- // command if the status failed
- if p.Status != "200" || p.Data != "" {
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdResults},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Items: []fleet.CmdItem{
- {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
- },
- })
- }
+ reportHostProfs := func(profileNames ...string) {
+ selectedReports := make(map[string][]profileData)
+ for _, name := range profileNames {
+ if reports, exists := hostProfileReports[name]; exists {
+ selectedReports[name] = reports
}
}
-
- msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
- require.NoError(t, err)
- out, err := xml.Marshal(msg)
- require.NoError(t, err)
- require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.logger, s.ds, h, out))
+ s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports)
}
verifyCommands := func(wantProfileInstalls int, status string) {
@@ -1016,7 +1028,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
// report osquery results with N2 missing and confirm N2 marked
// as verifying and other profiles are marked as verified
- reportHostProfs(t, "N1")
+ reportHostProfs("N1")
expectedProfileStatuses["N2"] = fleet.MDMDeliveryPending
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
@@ -1025,7 +1037,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
// report osquery results with N2 present and confirm that all profiles are verified
verifyCommands(1, syncml.CmdStatusOK)
- reportHostProfs(t, "N1", "N2")
+ reportHostProfs("N1", "N2")
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
@@ -1036,7 +1048,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
t.Run("retry after verification", func(t *testing.T) {
// report osquery results with N1 missing and confirm that the N1 marked as pending (initial retry)
- reportHostProfs(t, "N2")
+ reportHostProfs("N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["N1"] = 1
@@ -1046,7 +1058,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
verifyCommands(1, syncml.CmdStatusOK)
// report osquery results with N1 missing again and confirm that the N1 marked as failed (max retries exceeded)
- reportHostProfs(t, "N2")
+ reportHostProfs("N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
@@ -1080,7 +1092,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
// report osquery results with N3 missing and confirm that the N3 marked as failed (max
// retries exceeded)
- reportHostProfs(t, "N2")
+ reportHostProfs("N2")
expectedProfileStatuses["N3"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
@@ -1141,7 +1153,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
// report osquery results with N5 found and confirm that the N5 marked as verified
hostProfileReports["N5"] = []profileData{{"200", "L5", "D5"}}
- reportHostProfs(t, "N2", "N5")
+ reportHostProfs("N2", "N5")
expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
@@ -1151,7 +1163,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
// report osquery results again, this time N5 is missing and confirm that the N5 marked as
// failed (max retries exceeded)
- reportHostProfs(t, "N2")
+ reportHostProfs("N2")
expectedProfileStatuses["N5"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
@@ -1188,50 +1200,18 @@ func (s *integrationMDMTestSuite) TestWindowsProfileResend() {
}
}
- type profileData struct {
- Status string
- LocURI string
- Data string
- }
hostProfileReports := map[string][]profileData{
"N1": {{"200", "L1", "D1"}},
"N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
}
- reportHostProfs := func(t *testing.T, profileNames ...string) {
- var responseOps []*fleet.SyncMLCmd
- for _, profileName := range profileNames {
- report, ok := hostProfileReports[profileName]
- require.True(t, ok)
-
- for _, p := range report {
- ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdStatus},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Data: ptr.String(p.Status),
- })
-
- // the protocol can respond with only a `Status`
- // command if the status failed
- if p.Status != "200" || p.Data != "" {
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdResults},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Items: []fleet.CmdItem{
- {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
- },
- })
- }
+ reportHostProfs := func(profileNames ...string) {
+ selectedReports := make(map[string][]profileData)
+ for _, name := range profileNames {
+ if reports, exists := hostProfileReports[name]; exists {
+ selectedReports[name] = reports
}
}
-
- msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
- require.NoError(t, err)
- out, err := xml.Marshal(msg)
- require.NoError(t, err)
- require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.logger, s.ds, h, out))
+ s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports)
}
verifyCommands := func(wantProfileInstalls int, status string) {
@@ -1279,7 +1259,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileResend() {
checkProfilesStatus(t) // all profiles verifying
// report osquery results and confirm that all profiles are verified
- reportHostProfs(t, "N1", "N2")
+ reportHostProfs("N1", "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
@@ -1308,7 +1288,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileResend() {
checkProfilesStatus(t) // all profiles verifying
// report osquery results and confirm that all profiles are verified
- reportHostProfs(t, "N1", "N2")
+ reportHostProfs("N1", "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
@@ -7928,3 +7908,135 @@ func (s *integrationMDMTestSuite) TestWindowsProfilesFleetVariableSubstitution()
require.Equal(t, fleet.MDMDeliveryVerified, *(*hostRespNoVars.Host.MDM.Profiles)[0].Status,
"Profile should be verified in host details API for no-vars host")
}
+
+//go:embed testdata/profiles/windows-scep.xml
+var windowsSCEPProfileBytes []byte
+
+func (s *integrationMDMTestSuite) TestWindowsSCEPProfile() {
+ t := s.T()
+ ctx := context.Background()
+ scepServer := scep_server.StartTestSCEPServer(t)
+ scepServerURL := scepServer.URL + "/scep"
+
+ // Create windows host and enroll in MDM
+ host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
+
+ verifyCommands := func(wantProfiles int, status string) {
+ cmds, err := mdmDevice.StartManagementSession()
+ require.NoError(t, err)
+ // profile installs + 2 protocol commands acks
+ require.Len(t, cmds, wantProfiles+2)
+ msgID, err := mdmDevice.GetCurrentMsgID()
+ require.NoError(t, err)
+ atomicCmds := 0
+ for _, c := range cmds {
+ if c.Verb == "Atomic" {
+ atomicCmds++
+ }
+ mdmDevice.AppendResponse(fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdStatus},
+ MsgRef: &msgID,
+ CmdRef: ptr.String(c.Cmd.CmdID.Value),
+ Cmd: ptr.String(c.Verb),
+ Data: ptr.String(status),
+ Items: nil,
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ })
+ }
+ require.Equal(t, wantProfiles, atomicCmds)
+ cmds, err = mdmDevice.SendResponse()
+ require.NoError(t, err)
+ // the ack of the message should be the only returned command
+ require.Len(t, cmds, 1)
+ }
+
+ // Upload SCEP profile with missing CA
+ resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
+ batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "WindowsSCEPProfile", Contents: windowsSCEPProfileBytes},
+ }},
+ http.StatusBadRequest)
+ errMsg := extractServerErrorText(resp.Body)
+ require.Contains(t, errMsg, "Fleet variable $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_INTEGRATION does not exist.")
+
+ // Create Custom SCEP CA
+ ca := &fleet.CertificateAuthority{
+ Type: string(fleet.CATypeCustomSCEPProxy),
+ Name: ptr.String("INTEGRATION"),
+ Challenge: ptr.String("integration-test"),
+ URL: ptr.String(scepServerURL),
+ }
+
+ _, err := s.ds.NewCertificateAuthority(ctx, ca)
+ require.NoError(t, err)
+
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
+ batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "WindowsSCEPProfile", Contents: windowsSCEPProfileBytes},
+ }},
+ http.StatusNoContent)
+
+ // Verify host receives the profile
+ s.awaitTriggerProfileSchedule(t)
+
+ // Check that profile status is Pending
+ profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
+ require.NoError(t, err)
+ var foundProfile bool
+ for _, p := range profiles {
+ if p.Name == "WindowsSCEPProfile" {
+ foundProfile = true
+ require.NotNil(t, p.Status)
+ assert.Equal(t, fleet.MDMDeliveryPending, *p.Status)
+ }
+ }
+ require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
+
+ verifyCommands(1, syncml.CmdStatusOK)
+
+ // Verify profile status is Verified due to successful response
+ profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
+ require.NoError(t, err)
+ foundProfile = false
+ for _, p := range profiles {
+ if p.Name == "WindowsSCEPProfile" {
+ foundProfile = true
+ require.NotNil(t, p.Status)
+ assert.Equal(t, fleet.MDMDeliveryVerified, *p.Status)
+ }
+ }
+ require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
+
+ // Report Osquery results indicating SCEP profile was applied successfully
+ s.reportWindowsOSQueryProfiles(ctx, t, host, map[string][]profileData{
+ "WindowsSCEPProfile": {{"200", "L1", "Bogus"}}, // Report back with SCEP LocURI, but data that does not relate SCEP to support the case that we don't verify the success.
+ })
+
+ // Verify profile status is still Verified, and OSQuery does not change it's status.
+ profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
+ require.NoError(t, err)
+ foundProfile = false
+ profileUUID := ""
+ for _, p := range profiles {
+ if p.Name == "WindowsSCEPProfile" {
+ foundProfile = true
+ profileUUID = p.ProfileUUID
+ require.NotNil(t, p.Status)
+ assert.Equal(t, fleet.MDMDeliveryVerified, *p.Status)
+ }
+ }
+ require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
+
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ mysql.DumpTable(t, q, "host_mdm_windows_profiles")
+ mysql.DumpTable(t, q, "host_mdm_managed_certificates")
+ return nil
+ })
+
+ // Attempt simple SCEP call with GetCACaps operation to verify SCEP server is reachable
+ identifier := host.UUID + "," + profileUUID + "," + "INTEGRATION"
+ scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+"/pkiclient.exe", nil, http.StatusOK, nil, "operation", "GetCACaps")
+ body, err := io.ReadAll(scepRes.Body)
+ require.NoError(t, err)
+ assert.Equal(t, scepserver.DefaultCACaps, string(body))
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 4e65b349427..f8e6227ea8c 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -15740,8 +15740,19 @@ func (s *integrationMDMTestSuite) TestCustomSCEPIntegration() {
// /////////////////////////////////////
// Test SCEP traffic being sent by host
+
+ // Invalid profile identifier
+ scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+"invalid_identifier,p1234-uuid", nil, http.StatusBadRequest, nil, "operation", "GetCACaps")
+ scepResErr := extractServerErrorText(scepRes.Body)
+ require.Contains(t, scepResErr, "invalid profile UUID (only Apple and Windows")
+
+ // Verify Windows profiles is allowed (with dummy values that will fail lookup)
+ scepRes = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+"invalid_identifier,w1234-uuid", nil, http.StatusBadRequest, nil, "operation", "GetCACaps")
+ scepResErr = extractServerErrorText(scepRes.Body)
+ require.Contains(t, scepResErr, "unknown identifier in URL path") // Both invalid host and profile, but it allows it to lookup
+
// GetCACaps
- scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier, nil, http.StatusOK, nil, "operation", "GetCACaps")
+ scepRes = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier, nil, http.StatusOK, nil, "operation", "GetCACaps")
body, err := io.ReadAll(scepRes.Body)
require.NoError(t, err)
assert.Equal(t, scepserver.DefaultCACaps, string(body))
diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go
index 1e3d5f1488f..a7f2991bb3e 100644
--- a/server/service/microsoft_mdm.go
+++ b/server/service/microsoft_mdm.go
@@ -2349,11 +2349,8 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki
continue
}
- // 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), managedCertificatePayloads, params)
+ processedContent, err := microsoft_mdm.PreprocessWindowsProfileContentsForDeployment(ctx, logger, ds, appConfig, hostUUID, profUUID, groupedCAs, string(p.SyncML), managedCertificatePayloads, params)
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)
@@ -2363,6 +2360,9 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki
continue
}
+ // Create a unique command UUID for this host since the content is unique
+ hostCmdUUID := uuid.New().String()
+
// Build the command with the processed content
command, err := buildCommandFromProfileBytes([]byte(processedContent), hostCmdUUID)
if err != nil {
diff --git a/server/service/testdata/profiles/windows-scep.xml b/server/service/testdata/profiles/windows-scep.xml
new file mode 100644
index 00000000000..c818f03929e
--- /dev/null
+++ b/server/service/testdata/profiles/windows-scep.xml
@@ -0,0 +1,140 @@
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID
+
+
+ node
+
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/RetryCount
+
+
+ int
+
+ 3
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/RetryDelay
+
+
+ int
+
+ 10
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/KeyUsage
+
+
+ int
+
+ 160
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/KeyLength
+
+
+ int
+
+ 1024
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/HashAlgorithm
+
+
+ chr
+
+ SHA-1
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/SubjectName
+
+
+ chr
+
+ CN=$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/EKUMapping
+
+
+ chr
+
+ 1.3.6.1.5.5.7.3.2
+
+
+
+ 10
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/ServerURL
+
+
+ chr
+
+ $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_INTEGRATION
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/Challenge
+
+
+ chr
+
+ $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_INTEGRATION
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/CAThumbprint
+
+
+ chr
+
+ 2133EC6A3CFB8418837BB395188D1A62CA2B96A6
+
+
+
+ -
+
+
+ ./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/Enroll
+
+
+
\ No newline at end of file