Skip to content

Commit 4da09af

Browse files
committed
Added cipher and keyslot inventory to inventory-fde
Reports active cipher per volume and per-keyslot cipher/PBKDF for security evaluation. Values include the mountpoint for visibility in Mission Portal inventory, e.g. "/ : aes-xts-plain64". LUKS2 metadata is cached with a 24-hour TTL. Also fixed cfbs.json install path to services/cfbs/modules/, pluralized slist attribute names, simplified fde_enabled with ifelse(), and changed fde_method to an slist. Ticket: ENT-13744 Changelog: Title
1 parent dd677fe commit 4da09af

File tree

4 files changed

+324
-31
lines changed

4 files changed

+324
-31
lines changed

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: 24 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,17 @@ 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).
2025

2126
## Example
2227

@@ -26,11 +31,24 @@ A system with LUKS2-encrypted root but unencrypted `/boot` and `/boot/efi`:
2631
$ sudo cf-agent -Kf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
2732
Variable name Variable value Meta tags Comment
2833
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
34+
inventory_fde:main.fde_method {"LUKS2"} source=promise,inventory,attribute_name=Full disk encryption methods
3035
inventory_fde:main.fde_volumes {"/"} source=promise,inventory,attribute_name=Full disk encryption volumes
3136
inventory_fde:main.unencrypted_volumes {"/boot","/boot/efi"} source=promise,inventory,attribute_name=Unencrypted volumes
37+
inventory_fde:main.fde_volume_cipher {"/ : aes-xts-plain64"} source=promise,inventory,attribute_name=Full disk encryption volume ciphers
38+
inventory_fde:main.fde_keyslot_info {"/ : 0:aes-xts-plain64/argon2id"} source=promise,inventory,attribute_name=Full disk encryption keyslot info
39+
```
40+
41+
## Testing
42+
43+
A helper script is included to create and tear down a LUKS2 test volume on a loopback device:
44+
45+
```
46+
sudo ./test-encrypted-volume.sh setup # Create and mount test volume
47+
sudo cf-agent -KIf ./inventory-fde.cf --show-evaluated-vars=inventory_fde
48+
sudo ./test-encrypted-volume.sh teardown # Clean up
3249
```
3350

3451
## Platform
3552

3653
- Linux only (requires `/sys/block/` and `/proc/mounts`)
54+
- Cipher and keyslot inventory requires `dmsetup` and/or `cryptsetup` (typically available on systems with dm-crypt)

inventory/inventory-fde/inventory-fde.cf

Lines changed: 202 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,48 @@ 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
{
1331
classes:
1432
linux::
33+
"_have_dmsetup"
34+
expression => isexecutable("/sbin/dmsetup");
35+
"_have_cryptsetup"
36+
expression => isexecutable("/sbin/cryptsetup");
37+
1538
# Flag each dm device that has a CRYPT uuid
1639
"_dm_is_crypt_${_dm_devices}"
1740
expression => regcmp("CRYPT-.*", "${_dm_uuid[${_dm_devices}]}");
1841

42+
# Classify crypt type per device
43+
"_dm_is_luks2_${_dm_devices}"
44+
expression => strcmp("LUKS2", "${_dm_crypt_type[${_dm_devices}]}");
45+
"_dm_is_luks1_${_dm_devices}"
46+
expression => strcmp("LUKS1", "${_dm_crypt_type[${_dm_devices}]}");
47+
1948
# Classify each mount: real block device? (starts with /dev/, not a loop device)
2049
"_is_real_block_${_mnt_idx}"
2150
expression => regcmp("/dev/(?!loop)\S+", "${_mnt_data[${_mnt_idx}][0]}");
@@ -25,6 +54,11 @@ bundle agent main
2554
expression => regcmp("(${_crypt_paths_regex})", "${_mnt_data[${_mnt_idx}][0]}"),
2655
if => canonify("_is_real_block_${_mnt_idx}");
2756

57+
# LUKS1: flag enabled keyslots (slots 0-7, all share global cipher, all use PBKDF2)
58+
"_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}"
59+
expression => regcmp("(?s).*Key Slot ${_luks1_slots}: ENABLED.*", "${_luks1_dump[${_dm_devices}]}"),
60+
if => canonify("_dm_is_luks1_${_dm_devices}");
61+
2862
# Summary classes
2963
"_has_encrypted"
3064
expression => isgreaterthan(length(_encrypted_mountpoints), 0);
@@ -64,6 +98,14 @@ bundle agent main
6498
string => regex_replace("${_dm_uuid[${_dm_devices}]}", "^CRYPT-([^-]+)-.*", "\1", ""),
6599
if => canonify("_dm_is_crypt_${_dm_devices}");
66100

101+
# Underlying block device for each crypt device (for cryptsetup luksDump)
102+
"_dm_slaves[${_dm_devices}]"
103+
slist => lsdir("/sys/block/${_dm_devices}/slaves", "[a-z].*", false),
104+
if => canonify("_dm_is_crypt_${_dm_devices}");
105+
"_dm_slave_dev[${_dm_devices}]"
106+
string => "/dev/${_dm_slaves[${_dm_devices}]}",
107+
if => canonify("_dm_is_crypt_${_dm_devices}");
108+
67109
# Parse /proc/mounts into indexed array
68110
# Columns: 0=device, 1=mountpoint, 2=fstype, 3=options, 4=dump, 5=pass
69111
"_n_mnt_lines"
@@ -83,54 +125,191 @@ bundle agent main
83125
canonify("_is_encrypted_${_mnt_idx}")
84126
);
85127

128+
# Map dm device to its mountpoint via cross-iteration
129+
"_dm_mountpoint[${_dm_devices}]"
130+
string => "${_mnt_data[${_mnt_idx}][1]}",
131+
if => and(
132+
canonify("_dm_is_crypt_${_dm_devices}"),
133+
regcmp("(/dev/mapper/${_dm_name[${_dm_devices}]}|/dev/${_dm_devices})",
134+
"${_mnt_data[${_mnt_idx}][0]}"));
135+
86136
# Derive unencrypted mountpoints as the difference
87137
"_all_real_mountpoints" slist => getvalues(_all_real_mountpoint);
88138
"_encrypted_mountpoints" slist => getvalues(_encrypted_mountpoint);
89139
"_unencrypted_mountpoints"
90140
slist => difference(_all_real_mountpoints, _encrypted_mountpoints);
91141

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" };
142+
# --- Active cipher via dmsetup table ---
143+
_have_dmsetup::
144+
# dmsetup table format: "0 <size> crypt <cipher> <key> <iv_offset> <dev> <offset>"
145+
"_dm_active_cipher[${_dm_devices}]"
146+
string => regex_replace(
147+
execresult("/sbin/dmsetup table ${_dm_name[${_dm_devices}]}", "noshell"),
148+
"^\d+\s+\d+\s+crypt\s+(\S+)\s+.*$", "\1", ""),
149+
if => canonify("_dm_is_crypt_${_dm_devices}");
97150

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

104-
# Inventory: no encryption
105-
linux.!_has_encrypted::
196+
linux::
106197
"fde_enabled"
107-
string => "no",
198+
string => ifelse("_has_encrypted.!_has_unencrypted", "yes",
199+
"_has_encrypted._has_unencrypted", "partial",
200+
"no"),
108201
meta => { "inventory", "attribute_name=Full disk encryption enabled" };
109202

110-
# Method and volume details
111-
_has_encrypted::
112203
"fde_method"
113-
string => join(", ", unique(getvalues(_dm_crypt_type))),
114-
meta => { "inventory", "attribute_name=Full disk encryption method" };
204+
slist => unique(getvalues(_dm_crypt_type)),
205+
meta => { "inventory", "attribute_name=Full disk encryption methods" };
206+
207+
_has_encrypted::
115208
"fde_volumes"
116209
slist => unique(_encrypted_mountpoints),
117210
meta => { "inventory", "attribute_name=Full disk encryption volumes" };
118211

119-
linux.!_has_encrypted::
120-
"fde_method"
121-
string => "none",
122-
meta => { "inventory", "attribute_name=Full disk encryption method" };
123-
124212
_has_unencrypted::
125213
"unencrypted_volumes"
126214
slist => unique(_unencrypted_mountpoints),
127215
meta => { "inventory", "attribute_name=Unencrypted volumes" };
128216

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

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

0 commit comments

Comments
 (0)