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