DigiCert backend (#26914)

For #26609 

This PR includes
- ability to get a DigiCert certificate to a macOS device
- integration test for the above
- some validation

This PR does not include the following. They will be included in
subsequent PRs:
- support for User Principal Name in certificate
- support for $FLEET_VAR_HOST_HARDWARE_SERIAL
- saving certificate expiration date
- not resending DigiCert profile after failure

# Checklist for submitter
- [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/Committing-Changes.md#changes-files)
for more information.
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky
2025-03-10 13:02:49 -05:00
committed by GitHub
parent aad8d858cc
commit b42dbf2ff7
14 changed files with 822 additions and 128 deletions
+1
View File
@@ -0,0 +1 @@
Added integration with DigiCert Trust Lifecycle Manager. Fleet admins can now deploy DigiCert certificates to their macOS devices via configuration profiles.
+154 -8
View File
@@ -2,17 +2,24 @@ package digicert
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"net/http"
"net/url"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-json-experiment/json"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"software.sslmate.com/src/go-pkcs12"
)
// REST client for https://one.digicert.com/mpki/docs/swagger-ui/index.html
@@ -36,14 +43,7 @@ func WithTimeout(t time.Duration) Opt {
func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) error {
o := integrationOpts{
timeout: defaultTimeout,
}
for _, opt := range opts {
opt(&o)
}
client := fleethttp.NewClient(fleethttp.WithTimeout(o.timeout))
client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout))
config.URL = strings.TrimRight(config.URL, "/")
req, err := http.NewRequest("GET", config.URL+"/mpki/api/v2/profile/"+url.PathEscape(config.ProfileID), nil)
@@ -73,6 +73,152 @@ func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.Dig
if err != nil {
return ctxerr.Wrap(ctx, err, "unmarshaling DigiCert response")
}
if p.Status != "Active" {
return ctxerr.Errorf(ctx, "DigiCert profile status is not Active: %s", p.Status)
}
level.Debug(logger).Log("msg", "DigiCert profile verified", "id", p.ID, "name", p.Name, "status", p.Status)
return nil
}
func populateOpts(opts []Opt) integrationOpts {
o := integrationOpts{
timeout: defaultTimeout,
}
for _, opt := range opts {
opt(&o)
}
return o
}
func GetCertificate(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) (pfxData []byte,
password string, err error) {
client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout))
// Generate a CSR (Certificate Signing Request).
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "generating RSA private key")
}
csrTemplate := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: config.CertificateCommonName,
},
}
// TODO(#26609): Add support for User Principal Name
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privateKey)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "creating CSR")
}
csr := strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
})))
reqBody := map[string]interface{}{
"profile": map[string]string{
"id": config.ProfileID,
},
"seat": map[string]string{
"seat_id": config.CertificateSeatID,
},
"delivery_format": "x509",
"attributes": map[string]interface{}{
"subject": map[string]string{
"common_name": config.CertificateCommonName,
},
},
"csr": csr,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "marshaling request body")
}
config.URL = strings.TrimRight(config.URL, "/")
req, err := http.NewRequest("POST", config.URL+"/mpki/api/v1/certificate", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "creating DigiCert POST request")
}
req.Header.Set("X-API-key", config.APIToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "sending DigiCert POST request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
// Try to see if errors are present in body
type errorResponse struct {
Errors []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
var errResp errorResponse
err = json.UnmarshalRead(resp.Body, &errResp)
if err != nil || len(errResp.Errors) == 0 {
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d", resp.StatusCode)
}
combinedErrorMessages := make([]string, len(errResp.Errors))
for i, e := range errResp.Errors {
combinedErrorMessages[i] = e.Message
}
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d, errors: %s", resp.StatusCode,
strings.Join(combinedErrorMessages, "; "))
}
type certificateResponse struct {
SerialNumber string `json:"serial_number"`
DeliveryFormat string `json:"delivery_format"`
Certificate string `json:"certificate"`
}
var certResp certificateResponse
err = json.UnmarshalRead(resp.Body, &certResp)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "unmarshaling DigiCert POST response")
}
if certResp.DeliveryFormat != "x509" {
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert delivery format: %s", certResp.DeliveryFormat)
}
if len(certResp.Certificate) == 0 {
return nil, "", ctxerr.Errorf(ctx, "did not receive DigiCert certificate")
}
level.Debug(logger).Log("msg", "DigiCert certificate created", "serial_number", certResp.SerialNumber)
// Decode the certificate from PEM format
certBlock, _ := pem.Decode([]byte(certResp.Certificate))
if certBlock == nil {
return nil, "", ctxerr.Errorf(ctx, "failed to decode certificate PEM block")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "parsing certificate from PEM")
}
// Encode the private key and certificate into PKCS12
password, err = server.GenerateRandomText(10)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "generating password for PKCS12 bundle")
}
pkcs12Data, err := pkcs12.Legacy.Encode(privateKey, cert, nil, password)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "creating PKCS12 bundle")
}
return pkcs12Data, password, nil
}
@@ -2,6 +2,8 @@ package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -73,6 +75,33 @@ func (ds *Datastore) saveCAConfigAssets(ctx context.Context, tx sqlx.ExtContext,
return nil
}
func (ds *Datastore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) {
stmt := `
SELECT
name, type, value
FROM
ca_config_assets
WHERE
name = ? AND type = ?
`
var asset fleet.CAConfigAsset
if err := sqlx.GetContext(ctx, ds.reader(ctx), &asset, stmt, name, assetType); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("CAConfigAsset").WithName(name)
}
return nil, ctxerr.Wrapf(ctx, err, "get CA config asset %s", name)
}
decryptedVal, err := decrypt(asset.Value, ds.serverPrivateKey)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "decrypting CA config asset %s", asset.Name)
}
asset.Value = decryptedVal
return &asset, nil
}
func (ds *Datastore) DeleteCAConfigAssets(ctx context.Context, names []string) error {
if len(names) == 0 {
return nil
@@ -19,6 +19,7 @@ func TestCAConfigAssets(t *testing.T) {
{"GetAllCAConfigAssets", testGetAllCAConfigAssets},
{"SaveCAConfigAssets", testSaveCAConfigAssets},
{"DeleteCAConfigAssets", testDeleteCAConfigAssets},
{"GetCAConfigAsset", testGetCAConfigAsset},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -154,3 +155,43 @@ func testDeleteCAConfigAssets(t *testing.T, ds *Datastore) {
err = ds.DeleteCAConfigAssets(ctx, []string{"non-existent-asset"})
assert.NoError(t, err)
}
func testGetCAConfigAsset(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Test with non-existent asset - should return not found error
asset, err := ds.GetCAConfigAsset(ctx, "non-existent-asset", fleet.CAConfigDigiCert)
assert.Error(t, err)
assert.Nil(t, asset)
assert.True(t, fleet.IsNotFound(err))
// Insert some test assets
testAssets := []fleet.CAConfigAsset{
{Name: "asset1", Type: fleet.CAConfigDigiCert, Value: []byte("value1")},
{Name: "asset2", Type: fleet.CAConfigCustomSCEPProxy, Value: []byte("value2")},
}
err = ds.SaveCAConfigAssets(ctx, testAssets)
require.NoError(t, err)
// Test retrieving an existing asset by name and type
asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigDigiCert)
require.NoError(t, err)
require.NotNil(t, asset)
assert.Equal(t, "asset1", asset.Name)
assert.Equal(t, fleet.CAConfigDigiCert, asset.Type)
assert.Equal(t, []byte("value1"), asset.Value)
// Test retrieving another existing asset
asset, err = ds.GetCAConfigAsset(ctx, "asset2", fleet.CAConfigCustomSCEPProxy)
require.NoError(t, err)
require.NotNil(t, asset)
assert.Equal(t, "asset2", asset.Name)
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, asset.Type)
assert.Equal(t, []byte("value2"), asset.Value)
// Test retrieving an asset with a matching name but different type
asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigCustomSCEPProxy)
assert.Error(t, err)
assert.Nil(t, asset)
assert.True(t, fleet.IsNotFound(err))
}
+1
View File
@@ -1448,6 +1448,7 @@ type Datastore interface {
// GetAllCAConfigAssets returns the config assets for DigiCert and custom SCEP CAs.
GetAllCAConfigAssets(ctx context.Context) (map[string]CAConfigAsset, error)
GetCAConfigAsset(ctx context.Context, name string, assetType CAConfigAssetType) (*CAConfigAsset, error)
SaveCAConfigAssets(ctx context.Context, assets []CAConfigAsset) error
DeleteCAConfigAssets(ctx context.Context, names []string) error
@@ -109,12 +109,17 @@ func getSignedProfileData(mc Mobileconfig) (Mobileconfig, error) {
// Adapted from https://github.com/micromdm/micromdm/blob/main/platform/profile/profile.go
func (mc Mobileconfig) ParseConfigProfile() (*Parsed, error) {
mcBytes := mc
// Remove Fleet variables expected in <data> section.
mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
if mc.isSignedProfile() {
profileData, err := getSignedProfileData(mc)
if err != nil {
return nil, err
}
mcBytes = profileData
if mdm.ProfileVariableRegex.Match(mcBytes) {
return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)")
}
}
var p Parsed
if _, err := plist.Unmarshal(mcBytes, &p); err != nil {
@@ -145,12 +150,17 @@ type payloadSummary struct {
// See also https://developer.apple.com/documentation/devicemanagement/toplevel
func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) {
mcBytes := mc
// Remove Fleet variables expected in <data> section.
mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
if mc.isSignedProfile() {
profileData, err := getSignedProfileData(mc)
if err != nil {
return nil, err
}
mcBytes = profileData
if mdm.ProfileVariableRegex.Match(mcBytes) {
return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)")
}
}
// unmarshal the values we need from the top-level object
+6
View File
@@ -10,10 +10,16 @@ import (
"encoding/base64"
"fmt"
"io"
"regexp"
"github.com/smallstep/pkcs7"
)
var ProfileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P<name1>\w+))|(\${FLEET_VAR_(?P<name2>\w+)})`)
// ProfileDataVariableRegex matches variables present in <data> section of Apple profile, which may cause validation issues.
var ProfileDataVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_DIGICERT_DATA_(?P<name1>\w+))|(\${FLEET_VAR_DIGICERT_DATA_(?P<name2>\w+)})`)
// MaxProfileRetries is the maximum times an install profile command may be
// retried, after which marked as failed and no further attempts will be made
// to install the profile.
+12
View File
@@ -958,6 +958,8 @@ type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConf
type GetAllCAConfigAssetsFunc func(ctx context.Context) (map[string]fleet.CAConfigAsset, error)
type GetCAConfigAssetFunc func(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error)
type SaveCAConfigAssetsFunc func(ctx context.Context, assets []fleet.CAConfigAsset) error
type DeleteCAConfigAssetsFunc func(ctx context.Context, names []string) error
@@ -2663,6 +2665,9 @@ type DataStore struct {
GetAllCAConfigAssetsFunc GetAllCAConfigAssetsFunc
GetAllCAConfigAssetsFuncInvoked bool
GetCAConfigAssetFunc GetCAConfigAssetFunc
GetCAConfigAssetFuncInvoked bool
SaveCAConfigAssetsFunc SaveCAConfigAssetsFunc
SaveCAConfigAssetsFuncInvoked bool
@@ -6392,6 +6397,13 @@ func (s *DataStore) GetAllCAConfigAssets(ctx context.Context) (map[string]fleet.
return s.GetAllCAConfigAssetsFunc(ctx)
}
func (s *DataStore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) {
s.mu.Lock()
s.GetCAConfigAssetFuncInvoked = true
s.mu.Unlock()
return s.GetCAConfigAssetFunc(ctx, name, assetType)
}
func (s *DataStore) SaveCAConfigAssets(ctx context.Context, assets []fleet.CAConfigAsset) error {
s.mu.Lock()
s.SaveCAConfigAssetsFuncInvoked = true
+19 -1
View File
@@ -15,6 +15,7 @@ import (
"net/url"
"os"
"regexp"
"strings"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
@@ -1044,7 +1045,8 @@ func (svc *Service) processAppConfigCAs(ctx context.Context, newAppConfig *fleet
additionalDigiCertValidationNeeded = true
for _, ca := range newAppConfig.Integrations.DigiCert.Value {
ca.Name = fleet.Preprocess(ca.Name)
if !validateCAName(ca.Name, "digicert", allCANames, invalid) {
if !validateCAName(ca.Name, "digicert", allCANames, invalid) ||
!validateCACN(ca.CertificateCommonName, invalid) || !validateSeatID(ca.CertificateSeatID, invalid) {
additionalDigiCertValidationNeeded = false
continue
}
@@ -1230,6 +1232,22 @@ func validateCAName(name string, caType string, allCANames map[string]struct{},
return true
}
func validateCACN(cn string, invalid *fleet.InvalidArgumentError) bool {
if len(strings.TrimSpace(cn)) == 0 {
invalid.Append("integrations.digicert.certificate_common_name", "CA Common Name (CN) cannot be empty")
return false
}
return true
}
func validateSeatID(seatID string, invalid *fleet.InvalidArgumentError) bool {
if len(strings.TrimSpace(seatID)) == 0 {
invalid.Append("integrations.digicert.certificate_seat_id", "CA Seat ID cannot be empty")
return false
}
return true
}
type appConfigCAStatus struct {
ndes caStatusType
digicert map[string]caStatusType
+18 -2
View File
@@ -1939,6 +1939,22 @@ func TestAppConfigCAs(t *testing.T) {
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.api_token", "DigiCert API token must be set")
})
t.Run("digicert common name not set", func(t *testing.T) {
mt := setUp()
mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateCommonName = "\n\t"
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
require.NoError(t, err)
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_common_name", "Common Name (CN) cannot be empty")
})
t.Run("digicert seat id not set", func(t *testing.T) {
mt := setUp()
mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateSeatID = "\t\n"
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
require.NoError(t, err)
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_seat_id", "Seat ID cannot be empty")
})
t.Run("digicert happy path -- add one", func(t *testing.T) {
mt := setUp()
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
@@ -2011,7 +2027,7 @@ func TestAppConfigCAs(t *testing.T) {
URL: mockDigiCertServer.URL,
APIToken: "api_token",
ProfileID: "profile_id",
CertificateCommonName: "",
CertificateCommonName: "other_cn",
CertificateUserPrincipalNames: nil,
CertificateSeatID: "seat_id",
},
@@ -2044,7 +2060,7 @@ func TestAppConfigCAs(t *testing.T) {
URL: mockDigiCertServer.URL,
APIToken: "api_token",
ProfileID: "profile_id",
CertificateCommonName: "",
CertificateCommonName: "other_cn",
CertificateUserPrincipalNames: nil,
CertificateSeatID: "seat_id",
},
+208 -72
View File
@@ -24,6 +24,7 @@ import (
"github.com/docker/go-units"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server"
@@ -59,9 +60,12 @@ const (
// FleetVarNDESSCEPChallenge and other variables are used as $FLEET_VAR_<VARIABLE_NAME>.
// For example: $FLEET_VAR_NDES_SCEP_CHALLENGE
// Currently, we assume the variables are fully unique and not substrings of each other.
FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE"
FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL"
FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP"
FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE"
FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL"
FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP"
FleetVarHostHardwareSerial = "HOST_HARDWARE_SERIAL"
FleetVarDigiCertDataPrefix = "DIGICERT_DATA_"
FleetVarDigiCertPasswordPrefix = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials
)
const (
@@ -69,14 +73,18 @@ const (
)
var (
profileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P<name1>\w+))|(\${FLEET_VAR_(?P<name2>\w+)})`)
fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPChallenge,
FleetVarNDESSCEPChallenge))
fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPProxyURL,
FleetVarNDESSCEPProxyURL))
fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarHostEndUserEmailIDP,
FleetVarHostEndUserEmailIDP))
fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP}
fleetVarDigiCertData = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertDataPrefix,
FleetVarDigiCertDataPrefix))
fleetVarDigiCertPassword = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertPasswordPrefix,
FleetVarDigiCertPasswordPrefix))
fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP,
FleetVarHostHardwareSerial}
)
type hostProfileUUID struct {
@@ -401,7 +409,11 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
if err := cp.ValidateUserProvided(); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
}
err = validateConfigProfileFleetVariables(string(cp.Mobileconfig))
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
err = validateConfigProfileFleetVariables(appConfig, string(cp.Mobileconfig))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating fleet variables")
}
@@ -465,12 +477,32 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
return newCP, nil
}
func validateConfigProfileFleetVariables(contents string) error {
func validateConfigProfileFleetVariables(appConfig *fleet.AppConfig, contents string) error {
fleetVars := findFleetVariables(contents)
for k := range fleetVars {
if !slices.Contains(fleetVarsSupportedInConfigProfiles, k) {
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles",
k)}
found := false
switch {
case strings.HasPrefix(k, FleetVarDigiCertDataPrefix):
caName := strings.TrimPrefix(k, FleetVarDigiCertDataPrefix)
for _, ca := range appConfig.Integrations.DigiCert.Value {
if ca.Name == caName {
found = true
break
}
}
case strings.HasPrefix(k, FleetVarDigiCertPasswordPrefix):
caName := strings.TrimPrefix(k, FleetVarDigiCertPasswordPrefix)
for _, ca := range appConfig.Integrations.DigiCert.Value {
if ca.Name == caName {
found = true
break
}
}
}
if !found {
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles", k)}
}
}
}
return nil
@@ -3614,7 +3646,7 @@ func ReconcileAppleProfiles(
}
// Insert variables into profile contents of install targets. Variables may be host-specific.
err = preprocessProfileContents(ctx, appConfig, ds, installTargets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appConfig, ds, logger, installTargets, profileContents, hostProfilesToInstallMap)
if err != nil {
return err
}
@@ -3733,6 +3765,7 @@ func preprocessProfileContents(
ctx context.Context,
appConfig *fleet.AppConfig,
ds fleet.Datastore,
logger kitlog.Logger,
targets map[string]*cmdTarget,
profileContents map[string]mobileconfig.Mobileconfig,
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
@@ -3740,45 +3773,9 @@ func preprocessProfileContents(
// This method replaces Fleet variables ($FLEET_VAR_<NAME>) in the profile contents, generating a unique profile for each host.
// For a 2KB profile and 30K hosts, this method may generate ~60MB of profile data in memory.
isNDESSCEPConfigured := func(profUUID string, target *cmdTarget) (bool, error) {
if !license.IsPremium(ctx) {
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
for _, hostUUID := range target.hostUUIDs {
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
if !ok { // Should never happen
continue
}
profile.Status = &fleet.MDMDeliveryFailed
profile.Detail = "NDES SCEP Proxy requires a Fleet Premium license."
profilesToUpdate = append(profilesToUpdate, profile)
}
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
return false, err
}
return false, nil
}
if !appConfig.Integrations.NDESSCEPProxy.Valid {
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
for _, hostUUID := range target.hostUUIDs {
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
if !ok { // Should never happen
continue
}
profile.Status = &fleet.MDMDeliveryFailed
profile.Detail = "NDES SCEP Proxy is not configured. " +
"Please configure in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol."
profilesToUpdate = append(profilesToUpdate, profile)
}
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
return false, err
}
return false, nil
}
return appConfig.Integrations.NDESSCEPProxy.Valid, nil
}
// Copy of NDES SCEP config which will contain unencrypted password, if needed
var ndesConfig *fleet.NDESSCEPProxyIntegration
digiCertCAs := make(map[string]*fleet.DigiCertIntegration)
var addedTargets map[string]*cmdTarget
for profUUID, target := range targets {
@@ -3797,10 +3794,11 @@ func preprocessProfileContents(
// Do common validation that applies to all hosts in the target
valid := true
var digiCertVars digiCertVarsFound
for fleetVar := range fleetVars {
switch fleetVar {
case FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL:
configured, err := isNDESSCEPConfigured(profUUID, target)
switch {
case fleetVar == FleetVarNDESSCEPChallenge || fleetVar == FleetVarNDESSCEPProxyURL:
configured, err := getIsNDESSCEPConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap)(profUUID, target)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration")
}
@@ -3808,27 +3806,44 @@ func preprocessProfileContents(
valid = false
break
}
case FleetVarHostEndUserEmailIDP:
// No extra validation needed for this variable
default:
// Error out if we find an unknown variable
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
for _, hostUUID := range target.hostUUIDs {
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
if !ok { // Should never happen
continue
}
profile.Status = &fleet.MDMDeliveryFailed
profile.Detail = fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.",
fleetVar)
profilesToUpdate = append(profilesToUpdate, profile)
case fleetVar == FleetVarHostEndUserEmailIDP || fleetVar == FleetVarHostHardwareSerial:
// No extra validation needed for these variables
case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) || strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix):
var caName string
if strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) {
digiCertVars.password = true
caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertPasswordPrefix)
} else {
digiCertVars.data = true
caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix)
}
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profiles for unknown variable")
configured, err := getIsDigiCertConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap, digiCertCAs)(profUUID, target, caName)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking DigiCert configuration")
}
if !configured {
valid = false
break
}
default:
// Otherwise, error out since this variable is unknown
detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.",
fleetVar)
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, detail)
if err != nil {
return err
}
valid = false
}
}
if !digiCertVars.Ok() {
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "For DigiCert integration, "+
"both $FLEET_VAR_DIGICERT_PASSWORD_<ca_name> and $FLEET_VAR_DIGICERT_DATA_<ca_name> must be present in the profile.")
if err != nil {
return err
}
valid = false
}
if !valid {
// We marked the profile as failed, so we will not do any additional processing on it
delete(targets, profUUID)
@@ -3860,8 +3875,8 @@ func preprocessProfileContents(
failed := false
for fleetVar := range fleetVars {
switch fleetVar {
case FleetVarNDESSCEPChallenge:
switch {
case fleetVar == FleetVarNDESSCEPChallenge:
if ndesConfig == nil {
// Retrieve the NDES admin password. This is done once per run.
configAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetNDESPassword}, nil)
@@ -3918,12 +3933,12 @@ func preprocessProfileContents(
managedCertificatePayloads = append(managedCertificatePayloads, payload)
hostContents = replaceFleetVariable(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge)
case FleetVarNDESSCEPProxyURL:
case fleetVar == 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", hostUUID, profUUID)))
hostContents = replaceFleetVariable(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL)
case FleetVarHostEndUserEmailIDP:
case fleetVar == FleetVarHostEndUserEmailIDP:
// Insert the end user email IDP into the profile contents
emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts)
if err != nil {
@@ -3949,6 +3964,37 @@ func preprocessProfileContents(
break
}
hostContents = replaceFleetVariable(fleetVarHostEndUserEmailIDPRegexp, hostContents, emails[0])
case fleetVar == FleetVarHostHardwareSerial:
// TODO(#26609): swap in host serial
case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix):
// We will replace the password when we populate the certificate data
case strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix):
caName := strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix)
ca, ok := digiCertCAs[caName]
if !ok {
continue // Should never happen since we validated/populated DigiCert CAs earlier
}
// TODO(#26609): populate Fleet vars in the CA fields
data, password, err := digicert.GetCertificate(ctx, logger, *ca)
if err != nil {
detail := fmt.Sprintf("Couldn't get certificate from DigiCert. %s", err)
err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
CommandUUID: target.cmdUUID,
HostUUID: hostUUID,
Status: &fleet.MDMDeliveryFailed,
Detail: detail,
OperationType: fleet.MDMOperationTypeInstall,
})
if err != nil {
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert")
}
failed = true
break
}
hostContents = replaceFleetVariable(fleetVarDigiCertData, hostContents, base64.StdEncoding.EncodeToString(data))
hostContents = replaceFleetVariable(fleetVarDigiCertPassword, hostContents, password)
default:
// This was handled in the above switch statement, so we should never reach this case
}
@@ -3983,6 +4029,96 @@ func preprocessProfileContents(
return nil
}
type digiCertVarsFound struct {
data bool
password bool
}
// Ok makes sure that both DATA and PASSWORD variables are present in a DigiCert profile.
func (d digiCertVarsFound) Ok() bool {
return d.data && d.password || !d.data && !d.password
}
func getIsDigiCertConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore,
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
digiCertCAs map[string]*fleet.DigiCertIntegration) func(profUUID string, target *cmdTarget, caName string) (bool, error) {
return func(profUUID string, target *cmdTarget, caName string) (bool, error) {
if !license.IsPremium(ctx) {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "DigiCert integration requires a Fleet Premium license.")
}
if _, ok := digiCertCAs[caName]; ok {
return true, nil
}
configured := false
var digiCertCA *fleet.DigiCertIntegration
if appConfig.Integrations.DigiCert.Valid {
for _, ca := range appConfig.Integrations.DigiCert.Value {
if ca.Name == caName {
digiCertCA = &ca
configured = true
break
}
}
}
if !configured || digiCertCA == nil {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
fmt.Sprintf("DigiCert CA '%s' is not configured. Please configure in Settings > Integrations > Certificates.", caName))
}
// Get the API token
asset, err := ds.GetCAConfigAsset(ctx, digiCertCA.Name, fleet.CAConfigDigiCert)
switch {
case fleet.IsNotFound(err):
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
fmt.Sprintf("DigiCert CA '%s' is missing API token. Please configure in Settings > Integrations > Certificates.", caName))
case err != nil:
return false, ctxerr.Wrap(ctx, err, "getting CA config asset")
}
digiCertCA.APIToken = string(asset.Value)
digiCertCAs[caName] = digiCertCA
return true, nil
}
}
func getIsNDESSCEPConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore,
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload) func(profUUID string, target *cmdTarget) (bool, error) {
return func(profUUID string, target *cmdTarget) (bool, error) {
if !license.IsPremium(ctx) {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.")
}
if !appConfig.Integrations.NDESSCEPProxy.Valid {
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
"NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.")
}
return appConfig.Integrations.NDESSCEPProxy.Valid, nil
}
}
func markProfilesFailed(
ctx context.Context,
ds fleet.Datastore,
target *cmdTarget,
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
profUUID string,
detail string,
) (bool, error) {
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
for _, hostUUID := range target.hostUUIDs {
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
if !ok { // Should never happen
continue
}
profile.Status = &fleet.MDMDeliveryFailed
profile.Detail = detail
profilesToUpdate = append(profilesToUpdate, profile)
}
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
return false, ctxerr.Wrap(ctx, err, "marking host profiles failed")
}
return false, nil
}
func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement string) string {
// Escape XML characters
b := make([]byte, 0, len(replacement))
@@ -3994,12 +4130,12 @@ func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement st
func findFleetVariables(contents string) map[string]interface{} {
var result map[string]interface{}
matches := profileVariableRegex.FindAllStringSubmatch(contents, -1)
matches := mdm_types.ProfileVariableRegex.FindAllStringSubmatch(contents, -1)
if len(matches) == 0 {
return nil
}
nameToIndex := make(map[string]int, 2)
for i, name := range profileVariableRegex.SubexpNames() {
for i, name := range mdm_types.ProfileVariableRegex.SubexpNames() {
if name == "" {
continue
}
+14 -13
View File
@@ -2798,6 +2798,7 @@ func TestPreprocessProfileContents(t *testing.T) {
})
ctx := context.Background()
logger := kitlog.NewNopLogger()
appCfg := &fleet.AppConfig{}
appCfg.ServerSettings.ServerURL = "https://test.example.com"
appCfg.MDM.EnabledAndConfigured = true
@@ -2805,7 +2806,7 @@ func TestPreprocessProfileContents(t *testing.T) {
ds := new(mock.Store)
// No-op
err := preprocessProfileContents(ctx, appCfg, ds, nil, nil, nil)
err := preprocessProfileContents(ctx, appCfg, ds, logger, nil, nil, nil)
require.NoError(t, err)
hostUUID := "host-1"
@@ -2845,7 +2846,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
// Can't use NDES SCEP proxy with free tier
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedPayload)
assert.Contains(t, updatedPayload.Detail, "Premium license")
@@ -2856,7 +2857,7 @@ func TestPreprocessProfileContents(t *testing.T) {
appCfg.Integrations.NDESSCEPProxy.Valid = false
updatedPayload = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedPayload)
assert.Contains(t, updatedPayload.Detail, "not configured")
@@ -2869,7 +2870,7 @@ func TestPreprocessProfileContents(t *testing.T) {
appCfg.Integrations.NDESSCEPProxy.Valid = true
updatedPayload = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedPayload)
assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO")
@@ -2914,7 +2915,7 @@ func TestPreprocessProfileContents(t *testing.T) {
assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated
return nil
}
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedProfile)
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
@@ -2928,7 +2929,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
updatedProfile = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedProfile)
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
@@ -2942,7 +2943,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
updatedProfile = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedProfile)
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
@@ -2956,7 +2957,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
updatedProfile = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedProfile)
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
@@ -2983,7 +2984,7 @@ func TestPreprocessProfileContents(t *testing.T) {
assert.NotNil(t, payload[0].ChallengeRetrievedAt)
return nil
}
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
assert.Nil(t, updatedProfile)
require.NotEmpty(t, targets)
@@ -3006,7 +3007,7 @@ func TestPreprocessProfileContents(t *testing.T) {
assert.Empty(t, payload)
return nil
}
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
assert.Nil(t, updatedProfile)
require.NotEmpty(t, targets)
@@ -3027,7 +3028,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
updatedProfile = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotNil(t, updatedProfile)
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarHostEndUserEmailIDP)
@@ -3041,7 +3042,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
updatedProfile = nil
populateTargets()
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
assert.Nil(t, updatedProfile)
require.NotEmpty(t, targets)
@@ -3109,7 +3110,7 @@ func TestPreprocessProfileContents(t *testing.T) {
}
return nil
}
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
require.NoError(t, err)
require.NotEmpty(t, targets)
assert.Len(t, targets, 3)
+306 -29
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
@@ -78,6 +79,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"software.sslmate.com/src/go-pkcs12"
)
func TestIntegrationsMDM(t *testing.T) {
@@ -13240,34 +13242,27 @@ func (s *integrationMDMTestSuite) TestSCEPProxy() {
assert.Equal(t, scep.CertRep, pkiMessage.MessageType)
}
func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
func (s *integrationMDMTestSuite) TestDigiCertConfig() {
t := s.T()
ctx := context.Background()
mockDigiCertServer := createMockDigiCertServer(t)
pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`)
mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
matches := pathRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
profileID := matches[1]
resp := map[string]string{
"id": profileID,
"name": "Test CA",
"status": "Active",
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
defer mockDigiCertServer.Close()
// Add DigiCert integration with bad URL
caBad := getDigiCertIntegration("https://httpstat.us/410", "ca")
appConfig := map[string]interface{}{
"integrations": map[string]interface{}{
"digicert": []fleet.DigiCertIntegration{caBad},
},
}
raw, err := json.Marshal(appConfig)
require.NoError(t, err)
var req modifyAppConfigRequest
req.RawMessage = raw
rawRes := s.Do("PATCH", "/api/latest/fleet/config", &req, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg := extractServerErrorText(rawRes.Body)
require.Contains(t, errMsg, "Could not verify DigiCert profile ID")
_, err = s.ds.GetAllCAConfigAssets(ctx)
assert.True(t, fleet.IsNotFound(err))
// Add 3 DigiCert integrations
ca0 := getDigiCertIntegration(mockDigiCertServer.URL, "ca0")
@@ -13276,16 +13271,16 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
ca1.APIToken = "api_token1"
ca2 := getDigiCertIntegration(mockDigiCertServer.URL, "ca2")
ca2.APIToken = "api_token2"
appConfig := map[string]interface{}{
appConfig = map[string]interface{}{
"integrations": map[string]interface{}{
"digicert": []fleet.DigiCertIntegration{ca0, ca1, ca2},
},
}
raw, err := json.Marshal(appConfig)
raw, err = json.Marshal(appConfig)
require.NoError(t, err)
var req modifyAppConfigRequest
req = modifyAppConfigRequest{}
req.RawMessage = raw
var res appConfigResponse
res := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res, "dry_run", "true")
assert.Empty(t, res.Integrations.DigiCert.Value)
_, err = s.ds.GetAllCAConfigAssets(ctx)
@@ -13429,6 +13424,288 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
assert.EqualValues(t, caNames, []string{"ca1", "ca2", "ca3"})
}
func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
t := s.T()
ctx := context.Background()
mockDigiCertServer := createMockDigiCertServer(t)
// Add DigiCert config
ca := getDigiCertIntegration(mockDigiCertServer.URL, "my_CA")
ca.APIToken = "api_token0"
appConfig := map[string]interface{}{
"integrations": map[string]interface{}{
"digicert": []fleet.DigiCertIntegration{ca},
},
}
raw, err := json.Marshal(appConfig)
require.NoError(t, err)
var req modifyAppConfigRequest
req.RawMessage = raw
var res appConfigResponse
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res)
assert.Len(t, res.Integrations.DigiCert.Value, 1)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(profiles), 0)
// Receive enrollment profiles (we are not checking/testing these here)
for {
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
if cmd == nil {
break
}
_, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
// Add a profile with a bad CA
profile := digiCertForTest("N1", "BadCA", "badName")
rawRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: profile},
}}, http.StatusBadRequest)
errMsg := extractServerErrorText(rawRes.Body)
require.Contains(t, errMsg, "_badName is not supported")
// Add good profile
profile = digiCertForTest("N2", "I2", "my_CA")
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N2", Contents: profile},
}}, http.StatusNoContent)
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(profiles), 1)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
p := s.assertConfigProfilesByIdentifier(nil, "I2", true)
require.Contains(t, string(p.Mobileconfig), "com.fleetdm.pkcs12")
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "Expecting PKCS12 certificate")
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Nil(t, cmd)
require.NotNil(t, fullCmd.Command)
require.NotNil(t, fullCmd.Command.InstallProfile)
rawProfile := fullCmd.Command.InstallProfile.Payload
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(rawProfile)
require.NoError(t, err)
require.NoError(t, p7.Verify())
rawProfile = p7.Content
}
var pkcs12Profile struct {
PayloadContent []map[string]interface{} `plist:"PayloadContent"`
}
require.NoError(t, plist.Unmarshal(rawProfile, &pkcs12Profile))
assert.Equal(t, "com.apple.security.pkcs12", pkcs12Profile.PayloadContent[0]["PayloadType"].(string))
password := pkcs12Profile.PayloadContent[0]["Password"].(string)
data := pkcs12Profile.PayloadContent[0]["PayloadContent"].([]byte)
_, certificate, err := pkcs12.Decode(data, password)
require.NoError(t, err)
assert.Equal(t, ca.CertificateCommonName, certificate.Subject.CommonName)
// Try a DigiCert CA that will fail
caFail := getDigiCertIntegration(mockDigiCertServer.URL, "fail_CA")
caFail.CertificateCommonName = "Fail"
appConfig = map[string]interface{}{
"integrations": map[string]interface{}{
"digicert": []fleet.DigiCertIntegration{ca, caFail},
},
}
raw, err = json.Marshal(appConfig)
require.NoError(t, err)
req = modifyAppConfigRequest{}
req.RawMessage = raw
res = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res)
assert.Len(t, res.Integrations.DigiCert.Value, 2)
profileFail := digiCertForTest("N3", "I3", "fail_CA")
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N2", Contents: profile}, // resend the previous profile so it doesn't get removed
{Name: "N3", Contents: profileFail},
}}, http.StatusNoContent)
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(profiles), 2)
s.awaitTriggerProfileSchedule(t)
p = s.assertConfigProfilesByIdentifier(nil, "I3", true)
require.Contains(t, string(p.Mobileconfig), "com.fleetdm.pkcs12")
getHostResp := getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
var foundGood, foundFailed bool
require.NotNil(t, getHostResp.Host.MDM.Profiles)
for _, prof := range *getHostResp.Host.MDM.Profiles {
if prof.Name == "N2" {
foundGood = true
assert.Equal(t, fleet.MDMDeliveryVerifying, *prof.Status)
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
continue
}
if prof.Name == "N3" {
foundFailed = true
assert.Equal(t, fleet.MDMDeliveryFailed, *prof.Status)
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
assert.Contains(t, prof.Detail, "Expected Fail")
continue
}
}
assert.True(t, foundGood)
assert.True(t, foundFailed)
}
func digiCertForTest(name, identifier, caName string) []byte {
return []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>Password</key>
<string>$FLEET_VAR_DIGICERT_PASSWORD_%s</string>
<key>PayloadContent</key>
<data>${FLEET_VAR_DIGICERT_DATA_%s}</data>
<key>PayloadDisplayName</key>
<string>CertificatePKCS12</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.pkcs12</string>
<key>PayloadType</key>
<string>com.apple.security.pkcs12</string>
<key>PayloadUUID</key>
<string>ee86cfcb-2409-42c2-9394-1f8113412e04</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>%s</string>
<key>PayloadIdentifier</key>
<string>%s</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>%s</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`, caName, caName, name, identifier, uuid.New().String()))
}
func createMockDigiCertServer(t *testing.T) *httptest.Server {
profileRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`)
certRegex := regexp.MustCompile(`^/mpki/api/v1/certificate`)
mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
matches := profileRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
profileID := matches[1]
resp := map[string]string{
"id": profileID,
"name": "Test CA",
"status": "Active",
}
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
case http.MethodPost:
if len(certRegex.FindStringSubmatch(r.URL.Path)) != 1 {
w.WriteHeader(http.StatusBadRequest)
return
}
var req struct {
CSR string `json:"csr"`
}
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
// Decode the PEM format CSR into DER
block, _ := pem.Decode([]byte(req.CSR))
require.NotNil(t, block, "failed to decode PEM block containing CSR")
require.Equal(t, "CERTIFICATE REQUEST", block.Type, "unexpected PEM block type")
// Parse the CSR
csr, err := x509.ParseCertificateRequest(block.Bytes)
require.NoError(t, err)
require.NoError(t, csr.CheckSignature())
// Setting CertificateCommonName to "Fail" allows us to test a failure getting a DigiCert certificate
if csr.Subject.CommonName == "Fail" {
w.WriteHeader(http.StatusBadRequest)
type errorResponse struct {
Errors interface{} `json:"errors"`
}
err = json.NewEncoder(w).Encode(errorResponse{
Errors: []struct {
Code string `json:"code"`
Message string `json:"message"`
}{
{
Message: "Expected Fail",
},
},
})
require.NoError(t, err)
return
}
// Generate a self-signed certificate based on the CSR
serialNumber := big.NewInt(time.Now().Unix())
certTemplate := &x509.Certificate{
SerialNumber: serialNumber,
Subject: csr.Subject,
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year validity
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
// Encode the certificate to PEM
certPEM := new(bytes.Buffer)
err = pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
resp := map[string]string{
"serial_number": serialNumber.String(),
"delivery_format": "x509",
"certificate": certPEM.String(),
}
err = json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
t.Cleanup(mockDigiCertServer.Close)
return mockDigiCertServer
}
type noopCertDepot struct{ depot.Depot }
func (d *noopCertDepot) Put(_ string, _ *x509.Certificate) error {
+3 -3
View File
@@ -1673,7 +1673,7 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
err = validateFleetVariables(ctx, appleProfiles, windowsProfiles, appleDecls)
err = validateFleetVariables(ctx, appCfg, appleProfiles, windowsProfiles, appleDecls)
if err != nil {
return err
}
@@ -1756,13 +1756,13 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
func validateFleetVariables(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
func validateFleetVariables(ctx context.Context, appConfig *fleet.AppConfig, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
) error {
var err error
for _, p := range appleProfiles {
err = validateConfigProfileFleetVariables(string(p.Mobileconfig))
err = validateConfigProfileFleetVariables(appConfig, string(p.Mobileconfig))
if err != nil {
return ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
}