From 2c47cee12204c6dd0f6089fa64feeb47293d7ab7 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Thu, 28 May 2026 16:18:12 -0400 Subject: [PATCH] Fix FileVault key escrow on ADE-enrolled Macs (#45928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After ADE enrollment with enable_disk_encryption: true, hosts reported as unencrypted with the disk-encryption policy failing and no recovery key escrowed until the user logged out/in or restarted. ## Root cause Fleet's shared macOS disk-encryption probe was: ``` SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1 ``` On the osquery disk_encryption table, filevault_status and user_uuid are populated from independent sources: filevault_status from `fdesetup status`, user_uuid from `diskutil apfs listCryptoUsers` (the UUID of a user with SecureToken authority to unlock the volume). In the post-ADE window, even with ForceEnableInSetupAssistant=true, SecureToken propagation can lag — filevault_status='on' but user_uuid='' for a brief period that resolves on a session event. When the predicate failed, the query returned 0 rows and three downstream behaviors broke in lockstep: - host_disks.encrypted flipped to false ("unencrypted") - the built-in "Full disk encryption enabled (macOS)" policy failed - mdm_disk_encryption_key_file_*_darwin returned encrypted=0, gating the PRK ingest and leaving the recovery key un-escrowed The predicate originated in groob's standard query library entry from 2021 as a strict compliance check ("is the host actually protected, with a user able to unlock it?"). When the disk-encryption status feature shipped in Nov 2022 (PR #8526, issue #3906), the same string was reused verbatim and later extracted into usesMacOSDiskEncryptionQuery — never revisited for whether the SecureToken gate made sense outside the compliance-policy context. **Related issue:** Resolves #45369 --- changes/45369-ade-filevault-query | 1 + .../standard-query-library.yml | 2 +- .../understanding-host-vitals.md | 6 +- docs/queries.yml | 13 ++-- server/datastore/mysql/apple_mdm.go | 22 +++++-- server/datastore/mysql/apple_mdm_test.go | 60 +++++++++++++++++++ server/service/osquery_utils/queries.go | 9 ++- server/service/osquery_utils/queries_test.go | 11 ++++ 8 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 changes/45369-ade-filevault-query diff --git a/changes/45369-ade-filevault-query b/changes/45369-ade-filevault-query new file mode 100644 index 0000000000..9e92733283 --- /dev/null +++ b/changes/45369-ade-filevault-query @@ -0,0 +1 @@ +- Fixed issue where ADE-enrolled macOS didn't report filevault until restarted diff --git a/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml b/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml index 348813d0ba..ca9ffb9bc0 100644 --- a/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml +++ b/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml @@ -730,7 +730,7 @@ apiVersion: v1 kind: policy spec: name: Full disk encryption enabled (macOS) - query: SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1; + query: SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1; bash: fdesetup status | grep -q "FileVault is On." && echo 1 || echo 0 description: Checks to make sure that full disk encryption (FileVault) is enabled on macOS devices. resolution: To enable full disk encryption, on the failing device, select System Preferences > Security & Privacy > FileVault > Turn On FileVault. diff --git a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md index 821883fdf7..3396b7ad62 100644 --- a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md +++ b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md @@ -102,7 +102,7 @@ SELECT subject AS device_id FROM certificates WHERE issuer LIKE 'net + windows + - Query: ```sql -SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1 +SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1 ``` ## disk_encryption_linux @@ -330,7 +330,7 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na - Query: ```sql WITH - de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted), + de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted), fv AS (SELECT base64_encrypted as filevault_key FROM filevault_prk) SELECT encrypted, filevault_key FROM de LEFT JOIN fv; ``` @@ -347,7 +347,7 @@ SELECT 1 WHERE EXISTS (SELECT 1 FROM osquery_registry WHERE active = true AND re - Query: ```sql WITH - de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted), + de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted), fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat') SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl; ``` diff --git a/docs/queries.yml b/docs/queries.yml index ab4a66b19a..c303c22504 100644 --- a/docs/queries.yml +++ b/docs/queries.yml @@ -52,11 +52,10 @@ spec: platform: darwin description: Retrieves the disk encryption status of a macOS device. query: | - SELECT - 1 - FROM disk_encryption - WHERE user_uuid IS NOT "" - AND filevault_status = 'on' LIMIT 1 + SELECT + 1 + FROM disk_encryption + WHERE filevault_status = 'on' LIMIT 1 bash: fdesetup status | grep -q "FileVault is On" && echo 1 || echo 0 purpose: Informational tags: built-in @@ -335,7 +334,7 @@ spec: description: Retrieves the encrypted FileVault recovery key for managed macOS devices. query: | WITH - de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted), + de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted), fv AS (SELECT base64_encrypted as filevault_key FROM filevault_prk) SELECT encrypted, filevault_key FROM de LEFT JOIN fv discovery: filevault_prk @@ -350,7 +349,7 @@ spec: description: Retrieves the encrypted FileVault recovery key and checks for related file data on managed macOS devices. query: | WITH - de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1), 0) as encrypted), + de AS (SELECT IFNULL((SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1), 0) as encrypted), fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat') SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl; discovery: filevault_prk # TODO: this query's discovery query also checks for file_lines. diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index a4e3650d49..fd1c3d5de5 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -3921,6 +3921,17 @@ func (ds *Datastore) GetMDMIdPAccountByUUID(ctx context.Context, uuid string) (* } func subqueryFileVaultVerifying() (string, []interface{}) { + // A host is "verifying" when the FileVault profile is verifying or verified and + // a key row exists with decryptability not yet confirmed (decryptable IS NULL), + // or when the profile is verifying and the key is decryptable. The previous + // version only matched the verified-status branch, missing the equivalent + // verifying-status case that PopulateOSSettingsAndMacOSSettings (server/fleet/ + // hosts.go) reports as Verifying. See https://github.com/fleetdm/fleet/issues/45369. + // + // Note: hdek.host_id IS NOT NULL distinguishes "row exists with NULL decryptable" + // from "no key row at all" — the latter is intentionally not matched here because + // raw_decryptable is mapped to -1 for missing rows (see server/datastore/mysql/ + // hosts.go), and the Go logic classifies that as Action Required. sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -3929,15 +3940,16 @@ func subqueryFileVaultVerifying() (string, []interface{}) { AND hmap.profile_identifier = ? AND hmap.operation_type = ? AND ( - (hmap.status = ? AND hdek.decryptable IS NULL AND hdek.host_id IS NOT NULL) + (hmap.status IN (?, ?) AND hdek.decryptable IS NULL AND hdek.host_id IS NOT NULL) OR (hmap.status = ? AND hdek.decryptable = 1) )` args := []interface{}{ - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryVerified, - fleet.MDMDeliveryVerifying, + mobileconfig.FleetFileVaultPayloadIdentifier, // profile_identifier + fleet.MDMOperationTypeInstall, // operation_type + fleet.MDMDeliveryVerifying, // branch 1: status IN + fleet.MDMDeliveryVerified, // branch 1: status IN + fleet.MDMDeliveryVerifying, // branch 2: status = } return sql, args } diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index d03d33cc56..1dd0ccb70f 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5030,6 +5030,66 @@ func testSetVerifiedMacOSProfiles(t *testing.T, ds *Datastore) { checkHostMDMProfileStatuses() } +// TestMDMAppleFileVaultSummary_NullDecryptableKey is a regression test for the +// SQL aggregate gap surfaced while investigating +// https://github.com/fleetdm/fleet/issues/45369. When a host has a key row in +// host_disk_encryption_keys with decryptable=NULL (the host reported its key but +// the decryption-verifier hasn't run yet) and the FileVault profile is verifying +// or verified, PopulateOSSettingsAndMacOSSettings (server/fleet/hosts.go) classifies +// the host as Verifying. The previous subqueryFileVaultVerifying only matched the +// Verified-status case, so dashboard aggregates miscounted a Verifying-status host +// in this state. +func TestMDMAppleFileVaultSummary_NullDecryptableKey(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := t.Context() + + fvProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP(fleetmdm.FleetFileVaultProfileName, mobileconfig.FleetFileVaultPayloadIdentifier, 0), nil) + require.NoError(t, err) + + // Two hosts: one with profile in Verifying status, one in Verified. Both have + // a key row with NULL decryptable. Both should be classified as Verifying. + verifyingHost := test.NewHost(t, ds, "fv-verifying.local", "1.1.1.1", "fv-verifying-key", "fv-verifying-uuid", time.Now()) + verifiedHost := test.NewHost(t, ds, "fv-verified.local", "1.1.1.2", "fv-verified-key", "fv-verified-uuid", time.Now()) + nanoEnrollUserDeviceAndSetHostMDMData(t, ds, verifyingHost) + nanoEnrollUserDeviceAndSetHostMDMData(t, ds, verifiedHost) + + upsertHostCPs([]*fleet.Host{verifyingHost}, []*fleet.MDMAppleConfigProfile{fvProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerifying, ctx, ds, t) + upsertHostCPs([]*fleet.Host{verifiedHost}, []*fleet.MDMAppleConfigProfile{fvProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerified, ctx, ds, t) + // Key rows exist, decryptability has not been confirmed (nil → NULL). + _, err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, verifyingHost, "key-a", "", nil) + require.NoError(t, err) + _, err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, verifiedHost, "key-b", "", nil) + require.NoError(t, err) + + summary, err := ds.GetMDMAppleFileVaultSummary(ctx, nil) + require.NoError(t, err) + require.NotNil(t, summary) + assert.Equal(t, uint(2), summary.Verifying, "both hosts must count as verifying") + assert.Equal(t, uint(0), summary.ActionRequired) + assert.Equal(t, uint(0), summary.Verified) + assert.Equal(t, uint(0), summary.Enforcing) + assert.Equal(t, uint(0), summary.Failed) + + gotVerifying, err := ds.ListHosts(ctx, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionVerifying}, + ) + require.NoError(t, err) + assert.Len(t, gotVerifying, 2) + + // Host-details classification agrees for both hosts. + for _, h := range []*fleet.Host{verifyingHost, verifiedHost} { + profs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID) + require.NoError(t, err) + mdmData := fleet.MDMHostData{} + mdmData.PopulateOSSettingsAndMacOSSettings(profs, mobileconfig.FleetFileVaultPayloadIdentifier) + require.NotNil(t, mdmData.MacOSSettings) + require.NotNil(t, mdmData.MacOSSettings.DiskEncryption) + assert.Equal(t, fleet.DiskEncryptionVerifying, *mdmData.MacOSSettings.DiskEncryption, + "host %s details must report verifying", h.Hostname) + } +} + func TestCopyDefaultMDMAppleBootstrapPackage(t *testing.T) { ds := CreateMySQLDS(t) defer ds.Close() diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index efceac92d3..300bc8dfbb 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -582,7 +582,14 @@ func ingestKubequeryInfo(ctx context.Context, logger *slog.Logger, host *fleet.H return nil } -const usesMacOSDiskEncryptionQuery = `SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT "" AND filevault_status = 'on' LIMIT 1` +// usesMacOSDiskEncryptionQuery probes whether FileVault is enabled on a macOS host. +// It deliberately does not filter on `user_uuid` — that column reports the SecureToken +// holder and can be empty for a short period after ADE setup completes, even when +// FileVault is on and the recovery key has been escrowed. Gating on user_uuid here +// previously caused the host to appear unencrypted (and the recovery key to remain +// un-escrowed) until the user logged out/in to settle SecureToken propagation. See +// https://github.com/fleetdm/fleet/issues/45369. +const usesMacOSDiskEncryptionQuery = `SELECT 1 FROM disk_encryption WHERE filevault_status = 'on' LIMIT 1` // extraDetailQueries defines extra detail queries that should be run on the host, as // well as how the results of those queries should be ingested into the hosts related tables diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index ebb60bf992..047bc56537 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -2131,6 +2131,17 @@ func TestDirectDiskEncryption(t *testing.T) { ds.SetOrUpdateHostDisksEncryptionFuncInvoked = false } +// TestUsesMacOSDiskEncryptionQueryDoesNotGateOnSecureToken guards against reintroducing +// the user_uuid predicate that caused https://github.com/fleetdm/fleet/issues/45369. +// In the post-ADE window the disk_encryption.user_uuid column can be empty while +// FileVault is on; filtering on it makes the host appear unencrypted and blocks +// recovery-key escrow until the user logs out/in. +func TestUsesMacOSDiskEncryptionQueryDoesNotGateOnSecureToken(t *testing.T) { + require.NotContains(t, usesMacOSDiskEncryptionQuery, "user_uuid", + "usesMacOSDiskEncryptionQuery must not filter on user_uuid; see issue #45369") + require.Contains(t, usesMacOSDiskEncryptionQuery, "filevault_status = 'on'") +} + func TestDirectIngestDiskEncryptionWindows(t *testing.T) { ds := new(mock.Store) var gotEncrypted bool