Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cfbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@
"tags": ["inventory", "security"],
"subdirectory": "inventory/inventory-fde",
"steps": [
"copy inventory-fde.cf services/cfbs/inventory-fde/",
"policy_files services/cfbs/inventory-fde/",
"copy inventory-fde.cf services/cfbs/modules/inventory-fde/inventory-fde.cf",
"policy_files services/cfbs/modules/inventory-fde/inventory-fde.cf",
"bundles inventory_fde:main"
]
},
Expand Down
32 changes: 26 additions & 6 deletions inventory/inventory-fde/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
Full disk encryption (FDE) protects data at rest by encrypting entire block devices.
This module detects mounted volumes backed by dm-crypt (LUKS1, LUKS2, or plain dm-crypt) on Linux systems and reports whether all, some, or none of the non-virtual block device filesystems are encrypted.

Detection is performed entirely through virtual filesystem reads (`/sys/block/` and `/proc/mounts`), with no dependency on external commands like `dmsetup` or `findmnt`.
Basic detection (encryption status, method, volume lists) is performed entirely through virtual filesystem reads (`/sys/block/` and `/proc/mounts`).
When `dmsetup` and `cryptsetup` are available, the module additionally reports the active cipher and LUKS keyslot details (per-keyslot cipher and PBKDF algorithm).

## How it works

Expand All @@ -10,13 +11,19 @@ Detection is performed entirely through virtual filesystem reads (`/sys/block/`
3. Identifies crypt devices by the `CRYPT-` prefix in the UUID
4. Parses `/proc/mounts` to find all non-virtual block device mounts (excluding loop devices)
5. Classifies each mount as encrypted or unencrypted by checking if its device matches a crypt device path
6. If `dmsetup` is available, reads the active cipher from `dmsetup table` for each crypt device
7. If `cryptsetup` is available, reads LUKS keyslot metadata (cipher and PBKDF per slot) via `cryptsetup luksDump`

## Inventory

- **Full disk encryption enabled** -- `yes` if all non-virtual block device filesystems are encrypted, `partial` if some are encrypted and some are not, `no` if none are encrypted.
- **Full disk encryption method** -- The encryption type(s) detected, e.g. `LUKS2`, `LUKS1`, `PLAIN`, or `none`. Multiple types are comma-separated if different methods are in use.
- **Full disk encryption volumes** -- List of mountpoints backed by encrypted devices.
- **Unencrypted volumes** -- List of mountpoints on non-virtual block devices that are not encrypted.
- **Full disk encryption enabled** - `yes` if all non-virtual block device filesystems are encrypted, `partial` if some are encrypted and some are not, `no` if none are encrypted.
- **Full disk encryption methods** - The encryption type(s) detected, e.g. `LUKS2`, `LUKS1`, `PLAIN`. Empty list when no encryption is found.
- **Full disk encryption volumes** - List of mountpoints backed by encrypted devices.
- **Unencrypted volumes** - List of mountpoints on non-virtual block devices that are not encrypted.
- **Full disk encryption volume ciphers** - The active dm-crypt cipher per volume, e.g. `/ : aes-xts-plain64`. Requires `dmsetup`.
- **Full disk encryption keyslot info** - LUKS keyslot cipher and PBKDF per volume, e.g. `/ : 0:aes-xts-plain64/argon2id`. Requires `cryptsetup`. Not available for plain dm-crypt (no keyslots).

[![Inventory in Mission Portal](inventory-fde-mission-portal.png)](inventory-fde-mission-portal.png)

## Example

Expand All @@ -26,11 +33,24 @@ A system with LUKS2-encrypted root but unencrypted `/boot` and `/boot/efi`:
$ sudo cf-agent -Kf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
Variable name Variable value Meta tags Comment
inventory_fde:main.fde_enabled partial source=promise,inventory,attribute_name=Full disk encryption enabled
inventory_fde:main.fde_method LUKS2 source=promise,inventory,attribute_name=Full disk encryption method
inventory_fde:main.fde_method {"LUKS2"} source=promise,inventory,attribute_name=Full disk encryption methods
inventory_fde:main.fde_volumes {"/"} source=promise,inventory,attribute_name=Full disk encryption volumes
inventory_fde:main.unencrypted_volumes {"/boot","/boot/efi"} source=promise,inventory,attribute_name=Unencrypted volumes
inventory_fde:main.fde_volume_cipher {"/ : aes-xts-plain64"} source=promise,inventory,attribute_name=Full disk encryption volume ciphers
inventory_fde:main.fde_keyslot_info {"/ : 0:aes-xts-plain64/argon2id"} source=promise,inventory,attribute_name=Full disk encryption keyslot info
```

## Testing

A helper script is included to create and tear down a LUKS2 test volume on a loopback device:

```
sudo ./test-encrypted-volume.sh setup # Create and mount test volume
sudo cf-agent -KIf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
sudo ./test-encrypted-volume.sh teardown # Clean up
```

## Platform

- Linux only (requires `/sys/block/` and `/proc/mounts`)
- Cipher and keyslot inventory requires `dmsetup` and/or `cryptsetup` (typically available on systems with dm-crypt)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
230 changes: 207 additions & 23 deletions inventory/inventory-fde/inventory-fde.cf
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,53 @@ body file control
namespace => "inventory_fde";
}

# Duplicated from the CFEngine standard library so this module can be parsed
# and tested standalone without loading the full masterfiles.
# _tidy: lib/files.cf body delete tidy
# _in_shell: lib/commands.cf body contain in_shell

body delete _tidy
{
dirlinks => "delete";
rmdirs => "true";
}

body contain _in_shell
{
useshell => "useshell";
}

bundle agent main
# @brief Inventory full disk encryption status
# @inventory Full disk encryption enabled - Whether all non-virtual mounted filesystems use dm-crypt encryption (yes, partial, or no).
# @inventory Full disk encryption method - The encryption type(s) in use, e.g. LUKS2, LUKS1, PLAIN, or none.
# @inventory Full disk encryption methods - The encryption type(s) in use, e.g. LUKS2, LUKS1, PLAIN.
# @inventory Full disk encryption volumes - List of mountpoints backed by encrypted devices, e.g. /.
# @inventory Unencrypted volumes - List of mountpoints on non-virtual block devices that are not encrypted, e.g. /boot, /boot/efi.
# @inventory Full disk encryption volume ciphers - The active dm-crypt cipher per volume, e.g. / : aes-xts-plain64.
# @inventory Full disk encryption keyslot info - LUKS keyslot cipher and PBKDF per volume, e.g. / : 0:aes-xts-plain64/argon2id.
{
vars:
linux::
"_dmsetup" string => "/sbin/dmsetup";
"_cryptsetup" string => "/sbin/cryptsetup";

classes:
linux::
"_have_dmsetup"
expression => isexecutable("${_dmsetup}");
"_have_cryptsetup"
expression => isexecutable("${_cryptsetup}");

# Flag each dm device that has a CRYPT uuid
"_dm_is_crypt_${_dm_devices}"
expression => regcmp("CRYPT-.*", "${_dm_uuid[${_dm_devices}]}");

# Classify crypt type per device
"_dm_is_luks2_${_dm_devices}"
expression => strcmp("LUKS2", "${_dm_crypt_type[${_dm_devices}]}");
"_dm_is_luks1_${_dm_devices}"
expression => strcmp("LUKS1", "${_dm_crypt_type[${_dm_devices}]}");

# Classify each mount: real block device? (starts with /dev/, not a loop device)
"_is_real_block_${_mnt_idx}"
expression => regcmp("/dev/(?!loop)\S+", "${_mnt_data[${_mnt_idx}][0]}");
Expand All @@ -25,6 +59,11 @@ bundle agent main
expression => regcmp("(${_crypt_paths_regex})", "${_mnt_data[${_mnt_idx}][0]}"),
if => canonify("_is_real_block_${_mnt_idx}");

# LUKS1: flag enabled keyslots (slots 0-7, all share global cipher, all use PBKDF2)
"_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}"
expression => regcmp("(?s).*Key Slot ${_luks1_slots}: ENABLED.*", "${_luks1_dump[${_dm_devices}]}"),
if => canonify("_dm_is_luks1_${_dm_devices}");

# Summary classes
"_has_encrypted"
expression => isgreaterthan(length(_encrypted_mountpoints), 0);
Expand Down Expand Up @@ -64,6 +103,14 @@ bundle agent main
string => regex_replace("${_dm_uuid[${_dm_devices}]}", "^CRYPT-([^-]+)-.*", "\1", ""),
if => canonify("_dm_is_crypt_${_dm_devices}");

# Underlying block device for each crypt device (for cryptsetup luksDump)
"_dm_slaves[${_dm_devices}]"
slist => lsdir("/sys/block/${_dm_devices}/slaves", "[a-z].*", false),
if => canonify("_dm_is_crypt_${_dm_devices}");
"_dm_slave_dev[${_dm_devices}]"
string => "/dev/${_dm_slaves[${_dm_devices}]}",
if => canonify("_dm_is_crypt_${_dm_devices}");

# Parse /proc/mounts into indexed array
# Columns: 0=device, 1=mountpoint, 2=fstype, 3=options, 4=dump, 5=pass
"_n_mnt_lines"
Expand All @@ -83,54 +130,191 @@ bundle agent main
canonify("_is_encrypted_${_mnt_idx}")
);

# Map dm device to its mountpoint via cross-iteration
"_dm_mountpoint[${_dm_devices}]"
string => "${_mnt_data[${_mnt_idx}][1]}",
if => and(
canonify("_dm_is_crypt_${_dm_devices}"),
regcmp("(/dev/mapper/${_dm_name[${_dm_devices}]}|/dev/${_dm_devices})",
"${_mnt_data[${_mnt_idx}][0]}"));

# Derive unencrypted mountpoints as the difference
"_all_real_mountpoints" slist => getvalues(_all_real_mountpoint);
"_encrypted_mountpoints" slist => getvalues(_encrypted_mountpoint);
"_unencrypted_mountpoints"
slist => difference(_all_real_mountpoints, _encrypted_mountpoints);

# Inventory: full encryption (encrypted volumes exist, no unencrypted ones)
_has_encrypted.!_has_unencrypted::
"fde_enabled"
string => "yes",
meta => { "inventory", "attribute_name=Full disk encryption enabled" };
# --- Active cipher via dmsetup table ---
_have_dmsetup::
# dmsetup table format: "0 <size> crypt <cipher> <key> <iv_offset> <dev> <offset>"
"_dm_active_cipher[${_dm_devices}]"
string => regex_replace(
execresult("${_dmsetup} table ${_dm_name[${_dm_devices}]}", "noshell"),
"^\d+\s+\d+\s+crypt\s+(\S+)\s+.*$", "\1", ""),
if => canonify("_dm_is_crypt_${_dm_devices}");

# Inventory: partial encryption
_has_encrypted._has_unencrypted::
"fde_enabled"
string => "partial",
meta => { "inventory", "attribute_name=Full disk encryption enabled" };
# --- LUKS2 keyslot info via cached JSON metadata ---
_have_cryptsetup::
"_luks2_cache[${_dm_devices}]"
string => "$(sys.statedir)/inventory_fde_luks2_${_dm_devices}.json",
if => canonify("_dm_is_luks2_${_dm_devices}");

"_luks2_cache_mtime[${_dm_devices}]"
string => filestat("${_luks2_cache[${_dm_devices}]}", "mtime"),
if => and(
canonify("_dm_is_luks2_${_dm_devices}"),
fileexists("${_luks2_cache[${_dm_devices}]}"));

# --- LUKS1 keyslot info via text parsing ---
_have_cryptsetup::
"_luks1_slots" slist => { "0", "1", "2", "3", "4", "5", "6", "7" };

"_luks1_dump[${_dm_devices}]"
string => execresult("${_cryptsetup} luksDump ${_dm_slave_dev[${_dm_devices}]}", "noshell"),
if => canonify("_dm_is_luks1_${_dm_devices}");

# LUKS1 global cipher: "Cipher name" + "Cipher mode"
"_luks1_cipher_name[${_dm_devices}]"
string => regex_replace("${_luks1_dump[${_dm_devices}]}", "(?s).*Cipher name:\s+(\S+).*", "\1", ""),
if => canonify("_dm_is_luks1_${_dm_devices}");
"_luks1_cipher_mode[${_dm_devices}]"
string => regex_replace("${_luks1_dump[${_dm_devices}]}", "(?s).*Cipher mode:\s+(\S+).*", "\1", ""),
if => canonify("_dm_is_luks1_${_dm_devices}");

# Build per-keyslot summary for each ENABLED slot
"_luks1_ks_entry[${_dm_devices}][${_luks1_slots}]"
string => "${_luks1_slots}:${_luks1_cipher_name[${_dm_devices}]}-${_luks1_cipher_mode[${_dm_devices}]}/pbkdf2",
if => and(
canonify("_dm_is_luks1_${_dm_devices}"),
canonify("_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}"));

"_luks1_ks_entries[${_dm_devices}]"
slist => getvalues("_luks1_ks_entry[${_dm_devices}]"),
if => canonify("_dm_is_luks1_${_dm_devices}");

"_dm_keyslot_info[${_dm_devices}]"
string => join(", ", sort("_luks1_ks_entries[${_dm_devices}]", "lex")),
if => canonify("_dm_is_luks1_${_dm_devices}");

# Inventory: no encryption
linux.!_has_encrypted::
# --- Inventory attributes ---

linux::
"fde_enabled"
string => "no",
string => ifelse("_has_encrypted.!_has_unencrypted", "yes",
"_has_encrypted._has_unencrypted", "partial",
"no"),
meta => { "inventory", "attribute_name=Full disk encryption enabled" };

# Method and volume details
_has_encrypted::
"fde_method"
string => join(", ", unique(getvalues(_dm_crypt_type))),
meta => { "inventory", "attribute_name=Full disk encryption method" };
slist => unique(getvalues(_dm_crypt_type)),
meta => { "inventory", "attribute_name=Full disk encryption methods" };

_has_encrypted::
"fde_volumes"
slist => unique(_encrypted_mountpoints),
meta => { "inventory", "attribute_name=Full disk encryption volumes" };

linux.!_has_encrypted::
"fde_method"
string => "none",
meta => { "inventory", "attribute_name=Full disk encryption method" };

_has_unencrypted::
"unencrypted_volumes"
slist => unique(_unencrypted_mountpoints),
meta => { "inventory", "attribute_name=Unencrypted volumes" };

# Build per-volume cipher and keyslot strings with mountpoint prefix
_have_dmsetup::
"_volume_cipher_entry[${_dm_devices}]"
string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_active_cipher[${_dm_devices}]}",
if => and(
canonify("_dm_is_crypt_${_dm_devices}"),
isvariable("_dm_mountpoint[${_dm_devices}]"));

_have_cryptsetup::
"_keyslot_info_entry[${_dm_devices}]"
string => "${_dm_mountpoint[${_dm_devices}]} : ${_luks2_ks_${_dm_devices}[keyslots]}",
if => and(
canonify("_dm_is_luks2_${_dm_devices}"),
isvariable("_dm_mountpoint[${_dm_devices}]"),
isvariable("_luks2_ks_${_dm_devices}[keyslots]"));

"_keyslot_info_entry[${_dm_devices}]"
string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_keyslot_info[${_dm_devices}]}",
if => and(
canonify("_dm_is_luks1_${_dm_devices}"),
isvariable("_dm_mountpoint[${_dm_devices}]"));

_has_encrypted._have_dmsetup::
"fde_volume_cipher"
slist => getvalues(_volume_cipher_entry),
meta => { "inventory", "attribute_name=Full disk encryption volume ciphers" };

_has_encrypted._have_cryptsetup::
"fde_keyslot_info"
slist => getvalues(_keyslot_info_entry),
meta => { "inventory", "attribute_name=Full disk encryption keyslot info" };

files:
_have_cryptsetup::
# Delete LUKS2 JSON cache if older than 24 hours
"${_luks2_cache[${_dm_devices}]}"
delete => _tidy,
if => and(
canonify("_dm_is_luks2_${_dm_devices}"),
fileexists("${_luks2_cache[${_dm_devices}]}"),
isgreaterthan(
format("%d", eval("$(sys.systime) - ${_luks2_cache_mtime[${_dm_devices}]}")),
"86400"));

commands:
_have_cryptsetup::
"${_cryptsetup}"
arglist => { "luksDump",
"--dump-json-metadata",
"${_dm_slave_dev[${_dm_devices}]}",
">", "${_luks2_cache[${_dm_devices}]}" },
contain => _in_shell,
if => and(
canonify("_dm_is_luks2_${_dm_devices}"),
not(fileexists("${_luks2_cache[${_dm_devices}]}")));

methods:
_have_cryptsetup::
# Parse LUKS2 JSON and return keyslot summary via bundle_return_value_index
"luks2_${_dm_devices}"
usebundle => luks2_keyslot_info("${_luks2_cache[${_dm_devices}]}"),
useresult => "_luks2_ks_${_dm_devices}",
if => and(
canonify("_dm_is_luks2_${_dm_devices}"),
fileexists("${_luks2_cache[${_dm_devices}]}"));

reports:
!linux.verbose_mode::
"$(this.promise_filename): $(this.namespace):$(this.bundle) is currently only instrumented for Linux. Please consider making a pull request or filing a ticket to request your specific platform.";
}

bundle agent luks2_keyslot_info(cache_file)
# @brief Parse LUKS2 JSON metadata and return keyslot summary
{
vars:
"_json"
data => readjson("${cache_file}");

"_ks_idx"
slist => getindices("_json[keyslots]");

# Build per-keyslot summary: "<slot>:<cipher>/<kdf>"
"_ks_entry[${_ks_idx}]"
string => "${_ks_idx}:${_json[keyslots][${_ks_idx}][area][encryption]}/${_json[keyslots][${_ks_idx}][kdf][type]}";

"_ks_entries"
slist => getvalues(_ks_entry);

"_keyslots"
string => join(", ", sort(_ks_entries, "lex"));

reports:
"${_keyslots}"
bundle_return_value_index => "keyslots";
}

body file control
{
namespace => "default";
Expand Down
Loading