diff --git a/changes/26693-verify-linux-escrowed-userkey b/changes/26693-verify-linux-escrowed-userkey new file mode 100644 index 0000000000..9c882e94ea --- /dev/null +++ b/changes/26693-verify-linux-escrowed-userkey @@ -0,0 +1 @@ +* Added new Detail Query 'luks_verify' used to verify if the stored LUKS key is valid. \ No newline at end of file diff --git a/orbit/changes/26693-verify-linux-escrowed-userkey b/orbit/changes/26693-verify-linux-escrowed-userkey new file mode 100644 index 0000000000..8aec697084 --- /dev/null +++ b/orbit/changes/26693-verify-linux-escrowed-userkey @@ -0,0 +1,2 @@ +* Added a new Linux table 'lsblk' populated from the output of running 'lsblk -n -O' +* Added a new Linux table 'cryptsetup_luks_salt', given a device path returns all the key_slots and salts of said device. diff --git a/orbit/pkg/luks/luks.go b/orbit/pkg/luks/luks.go index ea607e4695..1b8cf78e11 100644 --- a/orbit/pkg/luks/luks.go +++ b/orbit/pkg/luks/luks.go @@ -7,6 +7,18 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" ) +type LuksDump struct { + Keyslots map[string]Keyslot `json:"keyslots"` // keyslot -> salt +} + +type Keyslot struct { + KDF KDF `json:"kdf"` +} + +type KDF struct { + Salt string `json:"salt"` +} + type KeyEscrower interface { SendLinuxKeyEscrowResponse(LuksResponse) error } diff --git a/orbit/pkg/luks/luks_linux.go b/orbit/pkg/luks/luks_linux.go index 2c23580257..0cb2d80987 100644 --- a/orbit/pkg/luks/luks_linux.go +++ b/orbit/pkg/luks/luks_linux.go @@ -216,7 +216,7 @@ func (lr *LuksRunner) passphraseIsValid(ctx context.Context, device *luksdevice. } func getNextAvailableKeySlot(ctx context.Context, devicePath string) (uint, error) { - dump, err := getLuksDump(ctx, devicePath) + dump, err := GetLuksDump(ctx, devicePath) if err != nil { return 0, fmt.Errorf("get next available key slot: %w", err) } @@ -323,19 +323,7 @@ func (lr *LuksRunner) infoPrompt(title, text string) error { return nil } -type LuksDump struct { - Keyslots map[string]Keyslot `json:"keyslots"` -} - -type Keyslot struct { - KDF KDF `json:"kdf"` -} - -type KDF struct { - Salt string `json:"salt"` -} - -func getLuksDump(ctx context.Context, devicePath string) (*LuksDump, error) { +func GetLuksDump(ctx context.Context, devicePath string) (*LuksDump, error) { var jsonFlag string var jsonNeedsExtraction bool @@ -373,7 +361,7 @@ func getLuksDump(ctx context.Context, devicePath string) (*LuksDump, error) { } func getSaltforKeySlot(ctx context.Context, devicePath string, keySlot uint) (string, error) { - dump, err := getLuksDump(ctx, devicePath) + dump, err := GetLuksDump(ctx, devicePath) if err != nil { return "", fmt.Errorf("getting salt for key slot: %w", err) } diff --git a/orbit/pkg/luks/luks_stub.go b/orbit/pkg/luks/luks_stub.go index 4358df26c7..8a8175c6e6 100644 --- a/orbit/pkg/luks/luks_stub.go +++ b/orbit/pkg/luks/luks_stub.go @@ -4,6 +4,7 @@ package luks import ( + "context" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -11,3 +12,8 @@ import ( func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error { return nil } + +// GetLuksDump is a placeholder method for non-Linux builds. +func GetLuksDump(ctx context.Context, devicePath string) (*LuksDump, error) { + return nil, nil +} diff --git a/orbit/pkg/table/cryptsetup_luks_salt/table.go b/orbit/pkg/table/cryptsetup_luks_salt/table.go new file mode 100644 index 0000000000..2447248e11 --- /dev/null +++ b/orbit/pkg/table/cryptsetup_luks_salt/table.go @@ -0,0 +1,75 @@ +package cryptsetup_luks_salt + +import ( + "context" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/orbit/pkg/luks" + "github.com/osquery/osquery-go/plugin/table" + "github.com/rs/zerolog/log" + "strings" +) + +const TblName = "cryptsetup_luks_salt" +const requiredCriteria = "device" + +type criteria struct { + device string +} + +func Columns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("device"), // required + table.TextColumn("key_slot"), + table.TextColumn("salt"), + } +} + +func getCriteria(qContext table.QueryContext) (*criteria, error) { + missingPropErr := fmt.Errorf( + "the %s table requires the following columns in the where clause: %s", + TblName, + requiredCriteria, + ) + if len(qContext.Constraints) == 0 { + return nil, missingPropErr + } + for _, c := range strings.Split(requiredCriteria, ", ") { + constraint, ok := qContext.Constraints[c] + if !ok || len(constraint.Constraints) == 0 || len(constraint.Constraints[0].Expression) == 0 { + return nil, missingPropErr + } + if constraint.Constraints[0].Operator != table.OperatorEquals { + return nil, errors.New("only the = operator is supported on the where clause") + } + } + return &criteria{ + device: qContext.Constraints["device"].Constraints[0].Expression, + }, nil +} + +func Generate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + criteria, err := getCriteria(queryContext) + if err != nil { + log.Debug().Err(err).Msg("error parsing query criteria") + return nil, err + } + + result, err := luks.GetLuksDump(ctx, criteria.device) + if err != nil { + return nil, fmt.Errorf("failed to run luksDump: %w", err) + } + + if result != nil { + rows := make([]map[string]string, 0, len(result.Keyslots)) + for keySlot, entries := range result.Keyslots { + rows = append(rows, map[string]string{ + "device": criteria.device, + "key_slot": keySlot, + "salt": entries.KDF.Salt, + }) + } + return rows, nil + } + return nil, nil +} diff --git a/orbit/pkg/table/extension_linux.go b/orbit/pkg/table/extension_linux.go index f3eeb50be3..f16c40973d 100644 --- a/orbit/pkg/table/extension_linux.go +++ b/orbit/pkg/table/extension_linux.go @@ -6,6 +6,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/table/crowdstrike/falcon_kernel_check" "github.com/fleetdm/fleet/v4/orbit/pkg/table/crowdstrike/falconctl" "github.com/fleetdm/fleet/v4/orbit/pkg/table/cryptsetup" + "github.com/fleetdm/fleet/v4/orbit/pkg/table/cryptsetup_luks_salt" "github.com/fleetdm/fleet/v4/orbit/pkg/table/dataflattentable" "github.com/fleetdm/fleet/v4/orbit/pkg/table/dconf_read" "github.com/osquery/osquery-go" @@ -20,5 +21,19 @@ func PlatformTables(_ PluginOpts) ([]osquery.OsqueryPlugin, error) { falcon_kernel_check.TablePlugin(log.Logger), // table name is "falcon_kernel_check" dataflattentable.TablePluginExec(log.Logger, "nftables", dataflattentable.JsonType, []string{"nft", "-jat", "list", "ruleset"}, dataflattentable.WithBinDirs("/usr/bin", "/usr/sbin")), // -j (json) -a (show object handles) -t (terse, omit set contents) table.NewPlugin("dconf_read", dconf_read.Columns(), dconf_read.Generate), + + dataflattentable.TablePluginExec( + log.Logger, + "lsblk", + dataflattentable.JsonType, + []string{"lsblk", "-n", "-O", "--json"}, // -n (no header) -O (all vars) --json (output in json) + dataflattentable.WithBinDirs("/usr/bin", "/usr/sbin"), + ), + + table.NewPlugin( + cryptsetup_luks_salt.TblName, + cryptsetup_luks_salt.Columns(), + cryptsetup_luks_salt.Generate, + ), }, nil } diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index eabe868704..4aeb166756 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -5438,6 +5438,37 @@ "url": "https://fleetdm.com/tables/cryptoinfo", "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cryptoinfo.yml" }, + { + "name": "cryptsetup_luks_salt", + "description": "Given an LUKS encrypted device path, returns all the LUKS2 key slots and their respective salts.", + "evented": false, + "notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).", + "platforms": [ + "linux" + ], + "columns": [ + { + "name": "device", + "description": "The device path used for querying the LUKS metadata, e.g. `/dev/vda3`", + "type": "text", + "required": true + }, + { + "name": "key_slot", + "description": "A 'key slot' that indicates where in the LUKS metadata header the user key is stored.", + "type": "text", + "required": false + }, + { + "name": "salt", + "description": "Salt used during the encryption process of the LUKS user key.", + "type": "text", + "required": false + } + ], + "url": "https://fleetdm.com/tables/cryptsetup_luks_salt", + "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cryptsetup_luks_salt.yml" + }, { "name": "cryptsetup_status", "description": "Get info about the encrypted drive on the host.", @@ -14663,6 +14694,49 @@ "osqueryRepoUrl": "https://github.com/osquery/osquery/blob/master/specs/windows/logon_sessions.table", "fleetRepoUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flogon_sessions.yml&value=name%3A%20logon_sessions%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." }, + { + "name": "lsblk", + "description": "Based on the output obtained by running `lsblk -n -O`", + "evented": false, + "notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).", + "platforms": [ + "linux" + ], + "columns": [ + { + "name": "fullkey", + "description": "Flattened full key with '/' as separator, e.g. `blockdevices/15/min-io`.", + "required": false, + "type": "text" + }, + { + "name": "parent", + "description": "Parent key when keys are nested in the document.", + "required": false, + "type": "text" + }, + { + "name": "key", + "description": "JSON key or array index.", + "required": false, + "type": "text" + }, + { + "name": "value", + "description": "JSON value", + "required": false, + "type": "text" + }, + { + "name": "query", + "description": "Specifies a query to flatten with. This is used both for re-writing arrays into maps, and for filtering.", + "required": false, + "type": "text" + } + ], + "url": "https://fleetdm.com/tables/lsblk", + "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/lsblk.yml" + }, { "name": "lxd_certificates", "description": "LXD certificates information.", diff --git a/schema/tables/cryptsetup_luks_salt.yml b/schema/tables/cryptsetup_luks_salt.yml new file mode 100644 index 0000000000..eaaf7582b6 --- /dev/null +++ b/schema/tables/cryptsetup_luks_salt.yml @@ -0,0 +1,19 @@ +name: cryptsetup_luks_salt +description: Given an LUKS encrypted device path, returns all the LUKS2 key slots and their respective salts. +evented: false +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). +platforms: + - linux +columns: + - name: device + description: The device path used for querying the LUKS metadata, e.g. `/dev/vda3` + type: text + required: true + - name: key_slot + description: A 'key slot' that indicates where in the LUKS metadata header the user key is stored. + type: text + required: false + - name: salt + description: Salt used during the encryption process of the LUKS user key. + type: text + required: false \ No newline at end of file diff --git a/schema/tables/lsblk.yml b/schema/tables/lsblk.yml new file mode 100644 index 0000000000..7f90b5907e --- /dev/null +++ b/schema/tables/lsblk.yml @@ -0,0 +1,27 @@ +name: lsblk +description: Based on the output obtained by running `lsblk -n -O` +evented: false +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). +platforms: + - linux +columns: + - name: fullkey + description: Flattened full key with '/' as separator, e.g. `blockdevices/15/min-io`. + required: false + type: text + - name: parent + description: Parent key when keys are nested in the document. + required: false + type: text + - name: key + description: JSON key or array index. + required: false + type: text + - name: value + description: JSON value + required: false + type: text + - name: query + description: Specifies a query to flatten with. This is used both for re-writing arrays into maps, and for filtering. + required: false + type: text \ No newline at end of file diff --git a/server/datastore/mysql/disk_encryption.go b/server/datastore/mysql/disk_encryption.go index 96f278a68a..0834d6fea6 100644 --- a/server/datastore/mysql/disk_encryption.go +++ b/server/datastore/mysql/disk_encryption.go @@ -108,6 +108,14 @@ VALUES (?, ?, ?, ?, ?, ?)` return nil } +func (ds *Datastore) DeleteLUKSData(ctx context.Context, hostID, keySlot uint) error { + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, ` +DELETE FROM host_disk_encryption_keys WHERE host_id = ? AND key_slot = ?`, hostID, keySlot) + return err + }) +} + func (ds *Datastore) SaveLUKSData(ctx context.Context, host *fleet.Host, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error { if encryptedBase64Passphrase == "" || encryptedBase64Salt == "" { // should have been caught at service level @@ -252,11 +260,16 @@ func (ds *Datastore) SetHostsDiskEncryptionKeyStatus( func (ds *Datastore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { var key fleet.HostDiskEncryptionKey err := sqlx.GetContext(ctx, ds.reader(ctx), &key, ` - SELECT - host_id, base64_encrypted, decryptable, updated_at, client_error - FROM - host_disk_encryption_keys - WHERE host_id = ?`, hostID) +SELECT + host_id, + base64_encrypted, + base64_encrypted_salt, + key_slot, + decryptable, + updated_at, + client_error +FROM host_disk_encryption_keys +WHERE host_id = ?`, hostID) if err != nil { if err == sql.ErrNoRows { msg := fmt.Sprintf("for host %d", hostID) diff --git a/server/datastore/mysql/disk_encryption_test.go b/server/datastore/mysql/disk_encryption_test.go index a55c2f0a0b..83c0b916cb 100644 --- a/server/datastore/mysql/disk_encryption_test.go +++ b/server/datastore/mysql/disk_encryption_test.go @@ -2,7 +2,13 @@ package mysql import ( "context" + "encoding/base64" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/uuid" + "github.com/stretchr/testify/require" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -15,6 +21,7 @@ func TestDiskEncryption(t *testing.T) { fn func(t *testing.T, ds *Datastore) }{ {"TestCleanupDiskEncryptionKeysOnTeamChange", testCleanupDiskEncryptionKeysOnTeamChange}, + {"TestDeleteLUKSData", testDeleteLUKSData}, } for _, c := range cases { @@ -33,3 +40,54 @@ func testCleanupDiskEncryptionKeysOnTeamChange(t *testing.T, ds *Datastore) { // No-op test assert.NoError(t, ds.CleanupDiskEncryptionKeysOnTeamChange(ctx, []uint{1, 2, 3}, nil)) } + +func testDeleteLUKSData(t *testing.T, ds *Datastore) { + ctx := context.Background() + + hostOne, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }) + require.NoError(t, err) + + hostTwo, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("2"), + UUID: "2", + Hostname: "foo.local-zzz", + PrimaryIP: "192.168.1.2", + PrimaryMac: "30-65-EC-6F-C4-59", + }) + require.NoError(t, err) + + // Add a LUKS user key + randomBits := base64.StdEncoding.EncodeToString([]byte(uuid.New().String())) + var keySlot uint = 1 + + err = ds.SaveLUKSData(ctx, hostOne, randomBits, randomBits, keySlot) + require.NoError(t, err) + + // Try to delete a non-existent LUKS key + err = ds.DeleteLUKSData(ctx, hostTwo.ID, keySlot) + require.NoError(t, err) + + // Try to delete the wrong key slot + err = ds.DeleteLUKSData(ctx, hostOne.ID, keySlot+1) + require.NoError(t, err) + + err = ds.DeleteLUKSData(ctx, hostOne.ID, keySlot) + require.NoError(t, err) + + _, err = ds.GetHostDiskEncryptionKey(ctx, hostOne.ID) + require.True(t, fleet.IsNotFound(err)) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 02d7f88eb5..c2376290bc 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -951,6 +951,8 @@ type Datastore interface { // SaveLUKSData sets base64'd encrypted LUKS passphrase, key slot, and salt data for a host that has successfully // escrowed LUKS data SaveLUKSData(ctx context.Context, host *Host, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error + // DeleteLUKSData deletes the LUKS encryption key associated with the provided host ID and key slot. + DeleteLUKSData(ctx context.Context, hostID, keySlot uint) error // GetUnverifiedDiskEncryptionKeys returns all the encryption keys that // are collected but their decryptable status is not known yet (ie: diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 3e3f797aa7..dbafc8d63a 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1230,12 +1230,14 @@ type HostMDMCheckinInfo struct { } type HostDiskEncryptionKey struct { - HostID uint `json:"-" db:"host_id"` - Base64Encrypted string `json:"-" db:"base64_encrypted"` - Decryptable *bool `json:"-" db:"decryptable"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - DecryptedValue string `json:"key" db:"-"` - ClientError string `json:"-" db:"client_error"` + HostID uint `json:"-" db:"host_id"` + Base64Encrypted string `json:"-" db:"base64_encrypted"` + Base64EncryptedSalt string `json:"-" db:"base64_encrypted_salt"` + KeySlot *uint `json:"-" db:"key_slot"` + Decryptable *bool `json:"-" db:"decryptable"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + DecryptedValue string `json:"key" db:"-"` + ClientError string `json:"-" db:"client_error"` } // HostSoftwareInstalledPath represents where in the file system a software on a host was installed diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 4d509536e6..98c277655f 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -42,7 +42,7 @@ type OrbitConfigNotifications struct { // PendingSoftwareInstallerIDs contains a list of software install_ids queued for installation PendingSoftwareInstallerIDs []string `json:"pending_software_installer_ids,omitempty"` - // RunSetupExperience indicates whether or not Orbit should run the Fleet setup experience + // RunSetupExperience indicates whether Orbit should run the Fleet setup experience // during macOS Setup Assistant. RunSetupExperience bool `json:"run_setup_experience,omitempty"` diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index dfb3643527..af2726e028 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -684,6 +684,8 @@ type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, host *fleet. type SaveLUKSDataFunc func(ctx context.Context, host *fleet.Host, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error +type DeleteLUKSDataFunc func(ctx context.Context, hostID uint, keySlot uint) error + type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error @@ -2352,6 +2354,9 @@ type DataStore struct { SaveLUKSDataFunc SaveLUKSDataFunc SaveLUKSDataFuncInvoked bool + DeleteLUKSDataFunc DeleteLUKSDataFunc + DeleteLUKSDataFuncInvoked bool + GetUnverifiedDiskEncryptionKeysFunc GetUnverifiedDiskEncryptionKeysFunc GetUnverifiedDiskEncryptionKeysFuncInvoked bool @@ -5683,6 +5688,13 @@ func (s *DataStore) SaveLUKSData(ctx context.Context, host *fleet.Host, encrypte return s.SaveLUKSDataFunc(ctx, host, encryptedBase64Passphrase, encryptedBase64Salt, keySlot) } +func (s *DataStore) DeleteLUKSData(ctx context.Context, hostID uint, keySlot uint) error { + s.mu.Lock() + s.DeleteLUKSDataFuncInvoked = true + s.mu.Unlock() + return s.DeleteLUKSDataFunc(ctx, hostID, keySlot) +} + func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { s.mu.Lock() s.GetUnverifiedDiskEncryptionKeysFuncInvoked = true diff --git a/server/service/orbit.go b/server/service/orbit.go index f7e9c250e9..fe50b00b5f 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -310,7 +310,9 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } notifs.RunDiskEncryptionEscrow = host.IsLUKSSupported() && - host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && svc.ds.IsHostPendingEscrow(ctx, host.ID) + host.DiskEncryptionEnabled != nil && + *host.DiskEncryptionEnabled && + svc.ds.IsHostPendingEscrow(ctx, host.ID) // load the (active, ready to execute) pending software install executions for that host pendingInstalls, err := svc.ds.ListReadyToExecuteSoftwareInstalls(ctx, host.ID) diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go index f5310ece55..c00fca3b41 100644 --- a/server/service/orbit_test.go +++ b/server/service/orbit_test.go @@ -18,6 +18,67 @@ import ( ) func TestGetOrbitConfigLinuxEscrow(t *testing.T) { + setupEscrowContext := func() (*mock.Store, fleet.Service, context.Context, *fleet.Host, fleet.Team) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + os := &fleet.OperatingSystem{ + Platform: "ubuntu", + Version: "20.04", + } + host := &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + OSVersion: "Ubuntu 20.04", + Platform: "ubuntu", + DiskEncryptionEnabled: ptr.Bool(true), + } + + team := fleet.Team{ID: 1} + teamMDM := fleet.TeamMDM{EnableDiskEncryption: true} + ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) { + require.Equal(t, team.ID, teamID) + return &teamMDM, nil + } + ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) { + return ptr.RawMessage(json.RawMessage(`{}`)), nil + } + ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) { + return nil, nil + } + ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) { + return nil, nil + } + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return true + } + ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error { + return nil + } + + appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return appCfg, nil + } + ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { + return os, nil + } + + ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) { + return false, nil + } + + ctx = test.HostContext(ctx, host) + + return ds, svc, ctx, host, team + } + t.Run("don't check for pending escrow if unsupported platform or encryption is not enabled", func(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} @@ -80,62 +141,7 @@ func TestGetOrbitConfigLinuxEscrow(t *testing.T) { }) t.Run("pending escrow sets config flag and clears in DB", func(t *testing.T) { - ds := new(mock.Store) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium} - svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) - os := &fleet.OperatingSystem{ - Platform: "ubuntu", - Version: "20.04", - } - host := &fleet.Host{ - OsqueryHostID: ptr.String("test"), - ID: 1, - OSVersion: "Ubuntu 20.04", - Platform: "ubuntu", - DiskEncryptionEnabled: ptr.Bool(true), - } - - team := fleet.Team{ID: 1} - teamMDM := fleet.TeamMDM{EnableDiskEncryption: true} - ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) { - require.Equal(t, team.ID, teamID) - return &teamMDM, nil - } - ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) { - return ptr.RawMessage(json.RawMessage(`{}`)), nil - } - ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) { - return nil, nil - } - ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) { - return nil, nil - } - ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { - return true, nil - } - ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { - return nil, nil - } - ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { - return true - } - ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error { - return nil - } - - appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}} - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return appCfg, nil - } - ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { - return os, nil - } - - ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) { - return false, nil - } - - ctx = test.HostContext(ctx, host) + ds, svc, ctx, host, team := setupEscrowContext() // no-team cfg, err := svc.GetOrbitConfig(ctx) diff --git a/server/service/osquery.go b/server/service/osquery.go index 2a815e64bb..95c7fc1013 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -705,10 +705,17 @@ func (svc *Service) detailQueriesForHost(ctx context.Context, host *fleet.Host) if query.RunsForPlatform(host.Platform) { queryName := hostDetailQueryPrefix + name - queries[queryName] = query.Query + if query.QueryFunc != nil && query.Query == "" { - queries[queryName] = query.QueryFunc(ctx, svc.logger, host, svc.ds) + query, ok := query.QueryFunc(ctx, svc.logger, host, svc.ds) + if !ok { + continue + } + queries[queryName] = query + } else { + queries[queryName] = query.Query } + discoveryQuery := query.Discovery if discoveryQuery == "" { discoveryQuery = alwaysTrueQuery diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 6a4eeff7e4..653ed71f5c 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "github.com/fleetdm/fleet/v4/server/mdm" "net" "net/url" "regexp" @@ -39,8 +40,9 @@ type DetailQuery struct { Description string // Query is the SQL query string. Query string - // QueryFunc is optionally used to dynamically build a query. - QueryFunc func(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore) string + // QueryFunc is optionally used to dynamically build a query. If false is returned, then the query should be + // ignored. + QueryFunc func(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore) (string, bool) // Discovery is the SQL query that defines whether the query will run on the host or not. // If not set, Fleet makes sure the query will always run. Discovery string @@ -2068,6 +2070,152 @@ func directIngestMDMDeviceIDWindows(ctx context.Context, logger log.Logger, host return ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, rows[0]["data"]) } +var luksVerifyQuery = DetailQuery{ + Platforms: fleet.HostLinuxOSs, + Discovery: fmt.Sprintf( + `SELECT 1 WHERE EXISTS (%s) AND EXISTS (%s);`, + discoveryTable("lsblk"), + discoveryTable("cryptsetup_luks_salt"), + ), + QueryFunc: func(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore) (string, bool) { + if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" || !host.IsLUKSSupported() { + return "", false + } + + if _, err := ds.GetHostDiskEncryptionKey(ctx, host.ID); err != nil { + if fleet.IsNotFound(err) { + return "", false + } + } + + // Returns the key_slot and salt of the LUKS block device where '/' is mounted. + query := ` + WITH RECURSIVE + devices AS ( + SELECT + MAX(CASE WHEN key = 'path' THEN value ELSE NULL END) AS path, + MAX(CASE WHEN key = 'kname' THEN value ELSE NULL END) AS kname, + MAX(CASE WHEN key = 'pkname' THEN value ELSE NULL END) AS pkname, + MAX(CASE WHEN key = 'fstype' THEN value ELSE NULL END) AS fstype, + MAX(CASE WHEN key = 'mountpoint' THEN value ELSE NULL END) as mountpoint + FROM lsblk + GROUP BY parent + HAVING path <> '' AND fstype <> '' + ), + root_mount AS ( + SELECT + path, + kname, + -- if '/' is mounted in a LUKS FS, then we don't need to transverse the tree + CASE WHEN fstype = 'crypto_LUKS' THEN NULL ELSE pkname END as pkname, + fstype + FROM devices + WHERE mountpoint = '/' + ), + luks_h(path, kname, pkname, fstype) AS ( + SELECT + rtm.path, + rtm.kname, + rtm.pkname, + rtm.fstype + FROM root_mount rtm + UNION + SELECT + dv.path, + dv.kname, + dv.pkname, + dv.fstype + FROM devices dv + JOIN luks_h ON dv.kname=luks_h.pkname + ) + SELECT salt, key_slot + FROM cryptsetup_luks_salt + WHERE device = (SELECT path FROM luks_h WHERE fstype = 'crypto_LUKS' LIMIT 1)` + return query, true + }, +} + +// We need to define the ingest function inline like this because we need access to the server private key +var luksVerifyQueryIngester = func(decrypter func(string) (string, error)) func( + ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string) error { + return func( + ctx context.Context, + logger log.Logger, + host *fleet.Host, + ds fleet.Datastore, + rows []map[string]string, + ) error { + if len(rows) == 0 || host == nil || !host.IsLUKSSupported() { + return nil + } + + dek, err := ds.GetHostDiskEncryptionKey(ctx, host.ID) + if err != nil { + if fleet.IsNotFound(err) { + level.Error(logger).Log( + "component", "service", + "method", "luksVerifyQueryIngester", + "msg", "unexpected missing LUKS2 disk encryption key", + "err", err, + ) + return nil + } + level.Error(logger).Log( + "component", "service", + "method", "luksVerifyQueryIngester", + "msg", "unexpected error", + "err", err, + ) + return err + } + if dek == nil || dek.Base64EncryptedSalt == "" || dek.KeySlot == nil { + return nil + } + + storedSalt, err := decrypter(dek.Base64EncryptedSalt) + if err != nil { + level.Debug(logger).Log( + "component", "service", + "method", "luksVerifyQueryIngester", + "host", host.ID, + "err", err, + ) + return err + } + storedKeySlot := fmt.Sprintf("%d", *dek.KeySlot) + + var entryFound bool + for _, row := range rows { + hostSalt, okSalt := row["salt"] + hostKeySlot, okKeySlot := row["key_slot"] + + if !okSalt || !okKeySlot { + level.Error(logger).Log( + "component", "service", + "method", "luksVerifyQueryIngester", + "host", host.ID, + "err", "luks_verify expected some salt and a key_slot", + ) + continue + } + if hostSalt == storedSalt && hostKeySlot == storedKeySlot { + entryFound = true + break + } + } + if !entryFound { + level.Info(logger).Log( + "component", "service", + "method", "luksVerifyQueryIngester", + "host", host.ID, + "msg", "LUKS key do not match, deleting", + ) + return ds.DeleteLUKSData(ctx, host.ID, *dek.KeySlot) + } + return nil + } +} + //go:generate go run gen_queries_doc.go "../../../docs/Contributing/product-groups/orchestration/understanding-host-vitals.md" func GetDetailQueries( @@ -2120,6 +2268,15 @@ func GetDetailQueries( } } + if appConfig != nil && appConfig.MDM.EnableDiskEncryption.Value { + luksVerifyQuery.DirectIngestFunc = luksVerifyQueryIngester(func(privateKey string) func(string) (string, error) { + return func(encrypted string) (string, error) { + return mdm.DecodeAndDecrypt(encrypted, privateKey) + } + }(fleetConfig.Server.PrivateKey)) + generatedMap["luks_verify"] = luksVerifyQuery + } + if features != nil { var unknownQueries []string @@ -2162,7 +2319,7 @@ func buildConfigProfilesWindowsQuery( logger log.Logger, host *fleet.Host, ds fleet.Datastore, -) string { +) (string, bool) { var sb strings.Builder sb.WriteString("") gotProfiles := false @@ -2187,7 +2344,7 @@ func buildConfigProfilesWindowsQuery( "method", "QueryFunc - windows config profiles", "err", err, ) - return "" + return "", false } if !gotProfiles { level.Debug(logger).Log( @@ -2196,10 +2353,10 @@ func buildConfigProfilesWindowsQuery( "msg", "host doesn't have profiles to check", "host_id", host.ID, ) - return "" + return "", false } sb.WriteString("") - return fmt.Sprintf("SELECT raw_mdm_command_output FROM mdm_bridge WHERE mdm_command_input = '%s';", sb.String()) + return fmt.Sprintf("SELECT raw_mdm_command_output FROM mdm_bridge WHERE mdm_command_input = '%s';", sb.String()), true } func directIngestWindowsProfiles( diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 9b5657be60..b942088953 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -10,6 +10,7 @@ import ( "encoding/xml" "errors" "fmt" + "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql" "regexp" "slices" "sort" @@ -1862,7 +1863,7 @@ func TestDirectIngestWindowsProfiles(t *testing.T) { return secret, nil } - gotQuery := buildConfigProfilesWindowsQuery(ctx, logger, &fleet.Host{}, ds) + gotQuery, _ := buildConfigProfilesWindowsQuery(ctx, logger, &fleet.Host{}, ds) if tc.want != "" { require.Contains(t, gotQuery, "SELECT raw_mdm_command_output FROM mdm_bridge WHERE mdm_command_input =") re := regexp.MustCompile(`'<(.*?)>'`) @@ -2076,3 +2077,162 @@ func TestGenerateSQLForAllExists(t *testing.T) { sql = generateSQLForAllExists(query1, query2) assert.Equal(t, "SELECT 1 WHERE EXISTS (SELECT 1 WHERE foo = 'ba;r') AND EXISTS (SELECT 1 WHERE baz = 'qu;x')", sql) } + +func TestLuksVerifyQueryDiscovery(t *testing.T) { + lsblkTbl := "SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = 'lsblk'" + cryptsetupLuksSaltTbl := "SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = 'cryptsetup_luks_salt'" + + require.Equal(t, + fmt.Sprintf("SELECT 1 WHERE EXISTS (%s) AND EXISTS (%s);", lsblkTbl, cryptsetupLuksSaltTbl), + luksVerifyQuery.Discovery, + ) +} + +func TestLuksVerifyQueryIngester(t *testing.T) { + decrypter := func(encrypted string) (string, error) { + return encrypted, nil + } + ctx := context.Background() + logger := log.NewNopLogger() + + nonLUKSHost := &fleet.Host{ID: 1, Platform: "skynet"} + luksHost := &fleet.Host{ID: 1, Platform: "ubuntu"} + + testCases := []struct { + name string + rows []map[string]string + err error + host *fleet.Host + setUp func(t *testing.T, ds *mock.Store) + expectations func(t *testing.T, ds *mock.Store, err error) + }{ + { + name: "No results", + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.NoError(t, err) + require.False(t, ds.GetHostDiskEncryptionKeyFuncInvoked) + require.False(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + { + name: "host is not LUKS capable", + host: nonLUKSHost, + rows: []map[string]string{ + { + "key_slot": "0", + "salt": "some salty bits", + }, + }, + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.NoError(t, err) + require.False(t, ds.GetHostDiskEncryptionKeyFuncInvoked) + require.False(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + { + name: "disk encryption entry not found on DB", + host: luksHost, + rows: []map[string]string{ + { + "key_slot": "0", + "salt": "some salty bits", + }, + }, + setUp: func(t *testing.T, ds *mock.Store) { + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + require.Equal(t, uint(1), hostID) + return nil, common_mysql.NotFound("HostDiskEncryptionKey") + } + }, + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.NoError(t, err) + require.False(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + { + name: "error is thrown while getting the host disk encryption key", + host: luksHost, + rows: []map[string]string{ + { + "key_slot": "0", + "salt": "some salty bits", + }, + }, + setUp: func(t *testing.T, ds *mock.Store) { + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + require.Equal(t, uint(1), hostID) + return nil, errors.New("some error") + } + }, + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.Error(t, err) + require.False(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + { + name: "stored key matches the one reported", + host: luksHost, + rows: []map[string]string{ + { + "key_slot": "0", + "salt": "some salty bits", + }, + }, + setUp: func(t *testing.T, ds *mock.Store) { + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + require.Equal(t, uint(1), hostID) + return &fleet.HostDiskEncryptionKey{ + KeySlot: ptr.Uint(0), + Base64EncryptedSalt: "some salty bits", + }, nil + } + }, + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.NoError(t, err) + require.False(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + { + name: "stored key does not match the one reported", + host: luksHost, + rows: []map[string]string{ + { + "key_slot": "0", + "salt": "some sour bits", + }, + { + "key_slot": "1", + "salt": "some spicy bits", + }, + }, + setUp: func(t *testing.T, ds *mock.Store) { + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + require.Equal(t, uint(1), hostID) + return &fleet.HostDiskEncryptionKey{ + KeySlot: ptr.Uint(0), + Base64EncryptedSalt: base64.StdEncoding.EncodeToString([]byte("some salty bits")), + }, nil + } + ds.DeleteLUKSDataFunc = func(ctx context.Context, hostID uint, keySlot uint) error { + require.Equal(t, uint(1), hostID) + return nil + } + }, + expectations: func(t *testing.T, ds *mock.Store, err error) { + require.NoError(t, err) + require.True(t, ds.DeleteLUKSDataFuncInvoked) + }, + }, + } + + sut := luksVerifyQueryIngester(decrypter) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ds := new(mock.Store) + if tc.setUp != nil { + tc.setUp(t, ds) + } + tc.expectations(t, ds, sut(ctx, logger, tc.host, ds, tc.rows)) + }) + } +}