Skip to content

Commit 486a05e

Browse files
committed
Added cipher and keyslot inventory to inventory-fde
Extended inventory-fde to report active dm-crypt cipher per volume and LUKS keyslot details (per-keyslot cipher and PBKDF algorithm). LUKS2 metadata is cached as JSON with a 24-hour TTL. Gracefully degrades when dmsetup or cryptsetup are absent. Tool paths are defined as variables for single-point configuration. Includes loopback test helper script and Mission Portal screenshot.
1 parent dd677fe commit 486a05e

5 files changed

Lines changed: 331 additions & 31 deletions

File tree

cfbs.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@
149149
"tags": ["inventory", "security"],
150150
"subdirectory": "inventory/inventory-fde",
151151
"steps": [
152-
"copy inventory-fde.cf services/cfbs/inventory-fde/",
153-
"policy_files services/cfbs/inventory-fde/",
152+
"copy inventory-fde.cf services/cfbs/modules/inventory-fde/inventory-fde.cf",
153+
"policy_files services/cfbs/modules/inventory-fde/inventory-fde.cf",
154154
"bundles inventory_fde:main"
155155
]
156156
},

inventory/inventory-fde/README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
Full disk encryption (FDE) protects data at rest by encrypting entire block devices.
22
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.
33

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

67
## How it works
78

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

1417
## Inventory
1518

16-
- **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.
17-
- **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.
18-
- **Full disk encryption volumes** -- List of mountpoints backed by encrypted devices.
19-
- **Unencrypted volumes** -- List of mountpoints on non-virtual block devices that are not encrypted.
19+
- **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.
20+
- **Full disk encryption methods** - The encryption type(s) detected, e.g. `LUKS2`, `LUKS1`, `PLAIN`. Empty list when no encryption is found.
21+
- **Full disk encryption volumes** - List of mountpoints backed by encrypted devices.
22+
- **Unencrypted volumes** - List of mountpoints on non-virtual block devices that are not encrypted.
23+
- **Full disk encryption volume ciphers** - The active dm-crypt cipher per volume, e.g. `/ : aes-xts-plain64`. Requires `dmsetup`.
24+
- **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).
25+
26+
[![Inventory in Mission Portal](inventory-fde-mission-portal.png)](inventory-fde-mission-portal.png)
2027

2128
## Example
2229

@@ -26,11 +33,24 @@ A system with LUKS2-encrypted root but unencrypted `/boot` and `/boot/efi`:
2633
$ sudo cf-agent -Kf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
2734
Variable name Variable value Meta tags Comment
2835
inventory_fde:main.fde_enabled partial source=promise,inventory,attribute_name=Full disk encryption enabled
29-
inventory_fde:main.fde_method LUKS2 source=promise,inventory,attribute_name=Full disk encryption method
36+
inventory_fde:main.fde_method {"LUKS2"} source=promise,inventory,attribute_name=Full disk encryption methods
3037
inventory_fde:main.fde_volumes {"/"} source=promise,inventory,attribute_name=Full disk encryption volumes
3138
inventory_fde:main.unencrypted_volumes {"/boot","/boot/efi"} source=promise,inventory,attribute_name=Unencrypted volumes
39+
inventory_fde:main.fde_volume_cipher {"/ : aes-xts-plain64"} source=promise,inventory,attribute_name=Full disk encryption volume ciphers
40+
inventory_fde:main.fde_keyslot_info {"/ : 0:aes-xts-plain64/argon2id"} source=promise,inventory,attribute_name=Full disk encryption keyslot info
41+
```
42+
43+
## Testing
44+
45+
A helper script is included to create and tear down a LUKS2 test volume on a loopback device:
46+
47+
```
48+
sudo ./test-encrypted-volume.sh setup # Create and mount test volume
49+
sudo cf-agent -KIf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
50+
sudo ./test-encrypted-volume.sh teardown # Clean up
3251
```
3352

3453
## Platform
3554

3655
- Linux only (requires `/sys/block/` and `/proc/mounts`)
56+
- Cipher and keyslot inventory requires `dmsetup` and/or `cryptsetup` (typically available on systems with dm-crypt)
46.9 KB
Loading

inventory/inventory-fde/inventory-fde.cf

Lines changed: 207 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,53 @@ body file control
33
namespace => "inventory_fde";
44
}
55

6+
# Duplicated from the CFEngine standard library so this module can be parsed
7+
# and tested standalone without loading the full masterfiles.
8+
# _tidy: lib/files.cf body delete tidy
9+
# _in_shell: lib/commands.cf body contain in_shell
10+
11+
body delete _tidy
12+
{
13+
dirlinks => "delete";
14+
rmdirs => "true";
15+
}
16+
17+
body contain _in_shell
18+
{
19+
useshell => "useshell";
20+
}
21+
622
bundle agent main
723
# @brief Inventory full disk encryption status
824
# @inventory Full disk encryption enabled - Whether all non-virtual mounted filesystems use dm-crypt encryption (yes, partial, or no).
9-
# @inventory Full disk encryption method - The encryption type(s) in use, e.g. LUKS2, LUKS1, PLAIN, or none.
25+
# @inventory Full disk encryption methods - The encryption type(s) in use, e.g. LUKS2, LUKS1, PLAIN.
1026
# @inventory Full disk encryption volumes - List of mountpoints backed by encrypted devices, e.g. /.
1127
# @inventory Unencrypted volumes - List of mountpoints on non-virtual block devices that are not encrypted, e.g. /boot, /boot/efi.
28+
# @inventory Full disk encryption volume ciphers - The active dm-crypt cipher per volume, e.g. / : aes-xts-plain64.
29+
# @inventory Full disk encryption keyslot info - LUKS keyslot cipher and PBKDF per volume, e.g. / : 0:aes-xts-plain64/argon2id.
1230
{
31+
vars:
32+
linux::
33+
"_dmsetup" string => "/sbin/dmsetup";
34+
"_cryptsetup" string => "/sbin/cryptsetup";
35+
1336
classes:
1437
linux::
38+
"_have_dmsetup"
39+
expression => isexecutable("${_dmsetup}");
40+
"_have_cryptsetup"
41+
expression => isexecutable("${_cryptsetup}");
42+
1543
# Flag each dm device that has a CRYPT uuid
1644
"_dm_is_crypt_${_dm_devices}"
1745
expression => regcmp("CRYPT-.*", "${_dm_uuid[${_dm_devices}]}");
1846

47+
# Classify crypt type per device
48+
"_dm_is_luks2_${_dm_devices}"
49+
expression => strcmp("LUKS2", "${_dm_crypt_type[${_dm_devices}]}");
50+
"_dm_is_luks1_${_dm_devices}"
51+
expression => strcmp("LUKS1", "${_dm_crypt_type[${_dm_devices}]}");
52+
1953
# Classify each mount: real block device? (starts with /dev/, not a loop device)
2054
"_is_real_block_${_mnt_idx}"
2155
expression => regcmp("/dev/(?!loop)\S+", "${_mnt_data[${_mnt_idx}][0]}");
@@ -25,6 +59,11 @@ bundle agent main
2559
expression => regcmp("(${_crypt_paths_regex})", "${_mnt_data[${_mnt_idx}][0]}"),
2660
if => canonify("_is_real_block_${_mnt_idx}");
2761

62+
# LUKS1: flag enabled keyslots (slots 0-7, all share global cipher, all use PBKDF2)
63+
"_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}"
64+
expression => regcmp("(?s).*Key Slot ${_luks1_slots}: ENABLED.*", "${_luks1_dump[${_dm_devices}]}"),
65+
if => canonify("_dm_is_luks1_${_dm_devices}");
66+
2867
# Summary classes
2968
"_has_encrypted"
3069
expression => isgreaterthan(length(_encrypted_mountpoints), 0);
@@ -64,6 +103,14 @@ bundle agent main
64103
string => regex_replace("${_dm_uuid[${_dm_devices}]}", "^CRYPT-([^-]+)-.*", "\1", ""),
65104
if => canonify("_dm_is_crypt_${_dm_devices}");
66105

106+
# Underlying block device for each crypt device (for cryptsetup luksDump)
107+
"_dm_slaves[${_dm_devices}]"
108+
slist => lsdir("/sys/block/${_dm_devices}/slaves", "[a-z].*", false),
109+
if => canonify("_dm_is_crypt_${_dm_devices}");
110+
"_dm_slave_dev[${_dm_devices}]"
111+
string => "/dev/${_dm_slaves[${_dm_devices}]}",
112+
if => canonify("_dm_is_crypt_${_dm_devices}");
113+
67114
# Parse /proc/mounts into indexed array
68115
# Columns: 0=device, 1=mountpoint, 2=fstype, 3=options, 4=dump, 5=pass
69116
"_n_mnt_lines"
@@ -83,54 +130,191 @@ bundle agent main
83130
canonify("_is_encrypted_${_mnt_idx}")
84131
);
85132

133+
# Map dm device to its mountpoint via cross-iteration
134+
"_dm_mountpoint[${_dm_devices}]"
135+
string => "${_mnt_data[${_mnt_idx}][1]}",
136+
if => and(
137+
canonify("_dm_is_crypt_${_dm_devices}"),
138+
regcmp("(/dev/mapper/${_dm_name[${_dm_devices}]}|/dev/${_dm_devices})",
139+
"${_mnt_data[${_mnt_idx}][0]}"));
140+
86141
# Derive unencrypted mountpoints as the difference
87142
"_all_real_mountpoints" slist => getvalues(_all_real_mountpoint);
88143
"_encrypted_mountpoints" slist => getvalues(_encrypted_mountpoint);
89144
"_unencrypted_mountpoints"
90145
slist => difference(_all_real_mountpoints, _encrypted_mountpoints);
91146

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

98-
# Inventory: partial encryption
99-
_has_encrypted._has_unencrypted::
100-
"fde_enabled"
101-
string => "partial",
102-
meta => { "inventory", "attribute_name=Full disk encryption enabled" };
156+
# --- LUKS2 keyslot info via cached JSON metadata ---
157+
_have_cryptsetup::
158+
"_luks2_cache[${_dm_devices}]"
159+
string => "$(sys.statedir)/inventory_fde_luks2_${_dm_devices}.json",
160+
if => canonify("_dm_is_luks2_${_dm_devices}");
161+
162+
"_luks2_cache_mtime[${_dm_devices}]"
163+
string => filestat("${_luks2_cache[${_dm_devices}]}", "mtime"),
164+
if => and(
165+
canonify("_dm_is_luks2_${_dm_devices}"),
166+
fileexists("${_luks2_cache[${_dm_devices}]}"));
167+
168+
# --- LUKS1 keyslot info via text parsing ---
169+
_have_cryptsetup::
170+
"_luks1_slots" slist => { "0", "1", "2", "3", "4", "5", "6", "7" };
171+
172+
"_luks1_dump[${_dm_devices}]"
173+
string => execresult("${_cryptsetup} luksDump ${_dm_slave_dev[${_dm_devices}]}", "noshell"),
174+
if => canonify("_dm_is_luks1_${_dm_devices}");
175+
176+
# LUKS1 global cipher: "Cipher name" + "Cipher mode"
177+
"_luks1_cipher_name[${_dm_devices}]"
178+
string => regex_replace("${_luks1_dump[${_dm_devices}]}", "(?s).*Cipher name:\s+(\S+).*", "\1", ""),
179+
if => canonify("_dm_is_luks1_${_dm_devices}");
180+
"_luks1_cipher_mode[${_dm_devices}]"
181+
string => regex_replace("${_luks1_dump[${_dm_devices}]}", "(?s).*Cipher mode:\s+(\S+).*", "\1", ""),
182+
if => canonify("_dm_is_luks1_${_dm_devices}");
183+
184+
# Build per-keyslot summary for each ENABLED slot
185+
"_luks1_ks_entry[${_dm_devices}][${_luks1_slots}]"
186+
string => "${_luks1_slots}:${_luks1_cipher_name[${_dm_devices}]}-${_luks1_cipher_mode[${_dm_devices}]}/pbkdf2",
187+
if => and(
188+
canonify("_dm_is_luks1_${_dm_devices}"),
189+
canonify("_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}"));
190+
191+
"_luks1_ks_entries[${_dm_devices}]"
192+
slist => getvalues("_luks1_ks_entry[${_dm_devices}]"),
193+
if => canonify("_dm_is_luks1_${_dm_devices}");
194+
195+
"_dm_keyslot_info[${_dm_devices}]"
196+
string => join(", ", sort("_luks1_ks_entries[${_dm_devices}]", "lex")),
197+
if => canonify("_dm_is_luks1_${_dm_devices}");
103198

104-
# Inventory: no encryption
105-
linux.!_has_encrypted::
199+
# --- Inventory attributes ---
200+
201+
linux::
106202
"fde_enabled"
107-
string => "no",
203+
string => ifelse("_has_encrypted.!_has_unencrypted", "yes",
204+
"_has_encrypted._has_unencrypted", "partial",
205+
"no"),
108206
meta => { "inventory", "attribute_name=Full disk encryption enabled" };
109207

110-
# Method and volume details
111-
_has_encrypted::
112208
"fde_method"
113-
string => join(", ", unique(getvalues(_dm_crypt_type))),
114-
meta => { "inventory", "attribute_name=Full disk encryption method" };
209+
slist => unique(getvalues(_dm_crypt_type)),
210+
meta => { "inventory", "attribute_name=Full disk encryption methods" };
211+
212+
_has_encrypted::
115213
"fde_volumes"
116214
slist => unique(_encrypted_mountpoints),
117215
meta => { "inventory", "attribute_name=Full disk encryption volumes" };
118216

119-
linux.!_has_encrypted::
120-
"fde_method"
121-
string => "none",
122-
meta => { "inventory", "attribute_name=Full disk encryption method" };
123-
124217
_has_unencrypted::
125218
"unencrypted_volumes"
126219
slist => unique(_unencrypted_mountpoints),
127220
meta => { "inventory", "attribute_name=Unencrypted volumes" };
128221

222+
# Build per-volume cipher and keyslot strings with mountpoint prefix
223+
_have_dmsetup::
224+
"_volume_cipher_entry[${_dm_devices}]"
225+
string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_active_cipher[${_dm_devices}]}",
226+
if => and(
227+
canonify("_dm_is_crypt_${_dm_devices}"),
228+
isvariable("_dm_mountpoint[${_dm_devices}]"));
229+
230+
_have_cryptsetup::
231+
"_keyslot_info_entry[${_dm_devices}]"
232+
string => "${_dm_mountpoint[${_dm_devices}]} : ${_luks2_ks_${_dm_devices}[keyslots]}",
233+
if => and(
234+
canonify("_dm_is_luks2_${_dm_devices}"),
235+
isvariable("_dm_mountpoint[${_dm_devices}]"),
236+
isvariable("_luks2_ks_${_dm_devices}[keyslots]"));
237+
238+
"_keyslot_info_entry[${_dm_devices}]"
239+
string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_keyslot_info[${_dm_devices}]}",
240+
if => and(
241+
canonify("_dm_is_luks1_${_dm_devices}"),
242+
isvariable("_dm_mountpoint[${_dm_devices}]"));
243+
244+
_has_encrypted._have_dmsetup::
245+
"fde_volume_cipher"
246+
slist => getvalues(_volume_cipher_entry),
247+
meta => { "inventory", "attribute_name=Full disk encryption volume ciphers" };
248+
249+
_has_encrypted._have_cryptsetup::
250+
"fde_keyslot_info"
251+
slist => getvalues(_keyslot_info_entry),
252+
meta => { "inventory", "attribute_name=Full disk encryption keyslot info" };
253+
254+
files:
255+
_have_cryptsetup::
256+
# Delete LUKS2 JSON cache if older than 24 hours
257+
"${_luks2_cache[${_dm_devices}]}"
258+
delete => _tidy,
259+
if => and(
260+
canonify("_dm_is_luks2_${_dm_devices}"),
261+
fileexists("${_luks2_cache[${_dm_devices}]}"),
262+
isgreaterthan(
263+
format("%d", eval("$(sys.systime) - ${_luks2_cache_mtime[${_dm_devices}]}")),
264+
"86400"));
265+
266+
commands:
267+
_have_cryptsetup::
268+
"${_cryptsetup}"
269+
arglist => { "luksDump",
270+
"--dump-json-metadata",
271+
"${_dm_slave_dev[${_dm_devices}]}",
272+
">", "${_luks2_cache[${_dm_devices}]}" },
273+
contain => _in_shell,
274+
if => and(
275+
canonify("_dm_is_luks2_${_dm_devices}"),
276+
not(fileexists("${_luks2_cache[${_dm_devices}]}")));
277+
278+
methods:
279+
_have_cryptsetup::
280+
# Parse LUKS2 JSON and return keyslot summary via bundle_return_value_index
281+
"luks2_${_dm_devices}"
282+
usebundle => luks2_keyslot_info("${_luks2_cache[${_dm_devices}]}"),
283+
useresult => "_luks2_ks_${_dm_devices}",
284+
if => and(
285+
canonify("_dm_is_luks2_${_dm_devices}"),
286+
fileexists("${_luks2_cache[${_dm_devices}]}"));
287+
129288
reports:
130289
!linux.verbose_mode::
131290
"$(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.";
132291
}
133292

293+
bundle agent luks2_keyslot_info(cache_file)
294+
# @brief Parse LUKS2 JSON metadata and return keyslot summary
295+
{
296+
vars:
297+
"_json"
298+
data => readjson("${cache_file}");
299+
300+
"_ks_idx"
301+
slist => getindices("_json[keyslots]");
302+
303+
# Build per-keyslot summary: "<slot>:<cipher>/<kdf>"
304+
"_ks_entry[${_ks_idx}]"
305+
string => "${_ks_idx}:${_json[keyslots][${_ks_idx}][area][encryption]}/${_json[keyslots][${_ks_idx}][kdf][type]}";
306+
307+
"_ks_entries"
308+
slist => getvalues(_ks_entry);
309+
310+
"_keyslots"
311+
string => join(", ", sort(_ks_entries, "lex"));
312+
313+
reports:
314+
"${_keyslots}"
315+
bundle_return_value_index => "keyslots";
316+
}
317+
134318
body file control
135319
{
136320
namespace => "default";

0 commit comments

Comments
 (0)