Solving specific use cases with CFEngine policy and providing reusable modules

Posted by Nick Anderson
November 25, 2021

With the release of build.cfengine.com, I have been working to migrate some of our own security related policy into modules of their own. CFEngine Build and the cfbs tooling allows us to organize policy into modules, which are easy to update independently and share with other users. Let’s take the scenic route and look at what life is like with cfbs.

One of our security policies requires that the password hashing algorithm in /etc/login.defs is set to SHA512.

Compliance check showing /etc/login.defs ENCRYPT_METHOD=SHA512 not passing.

Traditionally, with CFEngine we would simply promise the desired state:

bundle agent _etc_login_defs_ENCRYPT_METHOD
{
  meta:
      "tags" slist =>  { "autorun" };

  vars:
      "_config[ENCRYPT_METHOD]" string => "SHA512";

      "_etc_login_defs"
        string => "/etc/login.defs";

  files:
      "$(_etc_login_defs)"
        edit_line => set_line_based(
          "$(this.namespace):$(this.bundle)._config",
          " ",
          "\s+",
          ".*",
          "^\s*(#.*|$)");
}

When the policy runs, it simply fixes the configuration issue. For example:

[root@hub]# grep ENCRYPT_METHOD /etc/login.defs
ENCRYPT_METHOD MD5
[root@hub]# cf-agent -KI
    info: Replaced pattern '^\s*(ENCRYPT_METHOD\s+(?!SHA512$).*|ENCRYPT_METHOD)$' in '/etc/login.defs'
    info: replace_patterns promise '^\s*(ENCRYPT_METHOD\s+(?!SHA512$).*|ENCRYPT_METHOD)$' repaired
    info: Edited file '/etc/login.defs'

But, I don’t want to blindly apply this policy to the infrastructure. I could warn instead of fixing the state, but that will only tell me where it’s not correct and it won’t give me any information about what it’s currently set to. In order to get an overview of the current configuration before making promises about the state I want, I need to inventory the configuration.

In CFEngine Enterprise reporting, inventory is achieved by tagging variables and/or classes with metadata. Specifically, tagging a variable or class with inventory and attribute_name=My Name will result in a new inventory attribute for My Name. So, we need to parse the file into one or more variables that we can tag for inventory so that we can report about the currently configured state.

Inventory report showing /etc/login.defs current state.

The following policy generates the necessary data, we won’t dive into the details here, but the result is a new inventory attribute, /etc/login/.defs holding the KEY=VALUE pairs parsed from the configuration file as seen in the preceding image.

bundle agent inventory_etc_login_defs
{
  vars:
      "_etc_login_defs_path" string => "/etc/login.defs";


      # A KEY <space> VALUE formatted file
      "_etc_login_defs"
        data => data_readstringarrayidx(
          "$(_etc_login_defs_path)", # File
          "#[^\n]*",                 # Comment
          "\s+",                     # Split
          inf,                       # Max entries
          inf),                      # Max bytes
        if => fileexists( "$(_etc_login_defs_path)" );

      # We need to iterate over the data structure created by
      # data_readstringarrayidx() so that we can build tag each item.
      "_etc_login_defs_idx" slist => getindices( _etc_login_defs );

      # Here we use the index to iterate and build up an associative array of
      # KEY=Values We use an associative array so that we can stay under the
      # data limit reporting a single variable

      "etc_login_defs[$(_etc_login_defs_idx)]"
        string => "$(_etc_login_defs[$(_etc_login_defs_idx)][0])=$(_etc_login_defs[$(_etc_login_defs_idx)][1])",
        meta => { "inventory", "attribute_name=$(_etc_login_defs_path)" };
}

bundle agent __main__
{
  methods:
      "inventory_etc_login_defs";
}

Using cfbs you no longer have to copy and paste this policy to integrate it into your policy set. Let’s use cfbs to build a policy set that includes this inventory leveraging the inventory-etc-login-defs module.

First, let’s bring up an environment to play with. Here I use the CFEngine Enterprise Vagrant Environment. Note: Alternatively you can also use cf-remote to provision hosts on AWS or GCP.

Once your hub is up, log in as root and get cfbs installed.

First, install pip and get it upgraded, yum -y install python3-pip; pip3 install pip --upgrade.

Next, install cfbs, and put it in PATH for ease of use pip3 install cfbs; export PATH=$PATH:/usr/local/bin.

Now that we have cfbs let’s make a project directory, switch into it, and initialize it, mkdir -p /root/cfbs-project; cd /root/cfbs-project; cfbs init.

[root@hub ~]# mkdir -p /root/cfbs-project; cd /root/cfbs-project; cfbs init
Initialized - edit name and description cfbs.json
To add your first module, type: cfbs add masterfiles

Let’s do as instructed and run cfbs add masterfiles.

[root@hub cfbs-project]# cfbs add masterfiles
Added module: masterfiles

Currently, you need autotools to build masterfiles from source, so let’s install that as well:

[root@hub ~]# yum -y install automake

Now, let’s add the inventory-etc-login-defs module.

[root@hub cfbs-project]# cfbs add inventory-etc-login-defs
Added module: inventory-etc-login-defs

We can see that cfbs populated cfbs.json with the information needed to build the project.

[root@hub cfbs-project]# cat cfbs.json
{
  "name": "Example",
  "type": "policy-set",
  "description": "Example description",
  "build": [
    {
      "name": "masterfiles",
      "description": "Official CFEngine Masterfiles Policy Framework (MPF)",
      "tags": ["supported", "base"],
      "repo": "https://github.com/cfengine/masterfiles",
      "by": "https://github.com/cfengine",
      "version": "0.1.1",
      "commit": "5c7dc5b43088e259a94de4e5a9f17c0ce9781a0f",
      "steps": [
        "run ./autogen.sh",
        "delete ./autogen.sh",
        "run ./cfbs/cleanup.sh",
        "delete ./cfbs/cleanup.sh",
        "copy ./ ./"
      ],
      "added_by": "cfbs add"
    },
    {
      "name": "inventory-etc-login-defs",
      "description": "Inventory useful bits from /etc/login.defs",
      "tags": ["supported", "inventory"],
      "repo": "https://github.com/nickanderson/cfengine-inventory-etc-login-defs",
      "by": "https://github.com/nickanderson",
      "version": "0.0.1",
      "commit": "52f659510ddd3bc65e804306a1bff17d6c6f4299",
      "steps": [
        "copy ./inventory-etc-login-defs.cf services/inventory-etc-login-defs/inventory-etc-login-defs.cf",
        "json cfbs/def.json def.json"
      ],
      "added_by": "cfbs add"
    }
  ]
}

Now, let’s build and install!

[root@hub cfbs-project]# cfbs build

Modules:
001 masterfiles              @ 5c7dc5b43088e259a94de4e5a9f17c0ce9781a0f (Downloaded)
002 inventory-etc-login-defs @ 52f659510ddd3bc65e804306a1bff17d6c6f4299 (Downloaded)

Steps:
001 masterfiles              : run './autogen.sh'
001 masterfiles              : delete './autogen.sh'
001 masterfiles              : run './cfbs/cleanup.sh'
001 masterfiles              : delete './cfbs/cleanup.sh'
001 masterfiles              : copy './' 'masterfiles/'
002 inventory-etc-login-defs : copy './inventory-etc-login-defs.cf' 'masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf'
002 inventory-etc-login-defs : json 'cfbs/def.json' 'masterfiles/def.json'

Generating tarball...

Build complete, ready to deploy 🐿
 -> Directory: out/masterfiles
 -> Tarball:   out/masterfiles.tgz

To install on this machine: cfbs install
To deploy on remote hub(s): cf-remote deploy --hub hub out/masterfiles.tgz
[root@hub cfbs-project]# cfbs install
Installed to /var/cfengine/masterfiles

Within a few minutes we should be able to find /etc/login.defs reported in inventory.

By querying the API:

[root@hub cfbs-project]# curl --silent --insecure --user $AUTHUSER:$PASSWORD \
  --request POST \
  https://$HUB/api/inventory \
  -H 'content-type: application/json' \
  -d '{
        "sort":"Host name",
        "select":[
           "Host name",
           "/etc/login.defs"
        ]}'
{
  "data": [
    {
      "header": [
        {
          "columnName": "Host name",
          "columnType": "STRING"
        },
        {
          "columnName": "\/etc\/login.defs",
          "columnType": "STRING"
        }
      ],
      "queryTimeMs": 17,
      "rowCount": 1,
      "rows": [
        [
          "hub.example.com",
          "UID_MIN=1000, UID_MAX=60000, UMASK=077, USERGROUPS_ENAB=yes, SYS_GID_MAX=999, CREATE_HOME=yes, ENCRYPT_METHOD=MD5, MD5_CRYPT_ENAB=yes, MAIL_DIR=\/var\/spool\/mail, GID_MAX=60000, SYS_GID_MIN=201, SYS_UID_MAX=999, GID_MIN=1000, PASS_MIN_LEN=5, PASS_MAX_DAYS=99999, PASS_MIN_DAYS=0, PASS_WARN_AGE=7, SYS_UID_MIN=201"
        ]
      ]
    }
  ],
  "meta": {
    "count": 1,
    "page": 1,
    "timestamp": 1636491107,
    "total": 1
  }
}

Or by looking in Mission Portal:

Mission Portal Inventory Report shows 2 hosts bootstrapped and reporting.

From here we could continue to build a compliance report checking that /etc/login.defs matches ENCRYPT_METHOD=SHA512, but this post is about cfbs.

Compliance check showing /etc/login.defs ENCRYPT_METHOD=SHA512 not passing.

Here is where things get really great!

While authoring this post, I noticed that the comment regex in the policy could be improved. So, I updated the module, and now I can quickly get that change integrated into my cfbs project.

First, let’s initialize our cfbs project as a git repository. This will make it very easy to see the changes cfbs is making to the project.

[root@hub cfbs-project]# git init
Initialized empty Git repository in /root/cfbs-project/.git/
[root@hub cfbs-project]# git add cfbs.json
[root@hub cfbs-project]# git commit -m "Initialized cfbs project with masterfiles and inventory-etc-login-defs"

Okay, now, let’s run cfbs update to update all of our modules to the latest versions available in the index. We can use git diff to see the changes made by cfbs.

[root@hub cfbs-project]# cfbs update
[root@hub cfbs-project]# git diff
diff --git a/cfbs.json b/cfbs.json
index 1540e08..bc0a03f 100644
--- a/cfbs.json
+++ b/cfbs.json
@@ -27,8 +27,8 @@
       "tags": ["supported", "inventory"],
       "repo": "https://github.com/nickanderson/cfengine-inventory-etc-login-defs",
       "by": "https://github.com/nickanderson",
-      "version": "0.0.1",
-      "commit": "52f659510ddd3bc65e804306a1bff17d6c6f4299",
+      "version": "0.0.3",
+      "commit": "8f4747e4856737aa44fd96b9b8d38d941afec9f3",
       "steps": [
         "copy ./inventory-etc-login-defs.cf services/inventory-etc-login-defs/inventory-etc-login-defs.cf",
         "json cfbs/def.json def.json"

Using cfbs status we can see that the latest inventory-etc-login-defs has yet to be downloaded.

[root@hub cfbs-project]# cfbs status
Name:        Example
Description: Example description
File:        cfbs.json

Modules:
001 masterfiles              @ 5c7dc5b43088e259a94de4e5a9f17c0ce9781a0f (Downloaded)
002 inventory-etc-login-defs @ 8f4747e4856737aa44fd96b9b8d38d941afec9f3 (Not downloaded)

Let’s build the new policy set.

[root@hub cfbs-project]# cfbs build

Modules:
001 masterfiles              @ 5c7dc5b43088e259a94de4e5a9f17c0ce9781a0f (Downloaded)
002 inventory-etc-login-defs @ 8f4747e4856737aa44fd96b9b8d38d941afec9f3 (Downloaded)

Steps:
001 masterfiles              : run './autogen.sh'
001 masterfiles              : delete './autogen.sh'
001 masterfiles              : run './cfbs/cleanup.sh'
001 masterfiles              : delete './cfbs/cleanup.sh'
001 masterfiles              : copy './' 'masterfiles/'
002 inventory-etc-login-defs : copy './inventory-etc-login-defs.cf' 'masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf'
002 inventory-etc-login-defs : json 'cfbs/def.json' 'masterfiles/def.json'

Generating tarball...

Build complete, ready to deploy 🐿
 -> Directory: out/masterfiles
 -> Tarball:   out/masterfiles.tgz

To install on this machine: cfbs install
To deploy on remote hub(s): cf-remote deploy --hub hub out/masterfiles.tgz

We can see the diff of the policy file does indeed show the change in comment regex.

[root@hub cfbs-project]# diff -u /var/cfengine/masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf out/masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf
--- /var/cfengine/masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf  2021-11-09 20:38:52.342254892 +0000
+++ out/masterfiles/services/inventory-etc-login-defs/inventory-etc-login-defs.cf       2021-11-09 21:58:45.229784693 +0000
@@ -13,7 +13,7 @@
       "_etc_login_defs"
         data => data_readstringarrayidx(
           "$(_etc_login_defs_path)", # File
-          "#[^\n]*",                 # Comment
+          "\s*#[^\n]*",              # Comment
           "\s+",                     # Split
           inf,                       # Max entries
           inf),                      # Max bytes

Let’s go ahead and cfbs install to deploy the updated policy set.

[root@hub cfbs-project]# cfbs install
Installed to /var/cfengine/masterfiles

Tip: cfbs install requires elevated privileges to write in /var/cfengine/masterfiles, execute as root user or use sudo to elevate privileges. All the other cfbs commands only edit files inside current working directory and your home directory, and don’t need elevated privileges. Throughout this post I’ve been using the root user for convenience, but this is inside a “throw-away” virtual machine. If you are running this on your development machine or production infrastructure, you should only use the root user when necessary.

I hope this kindles the same excitement in you as it has for me. This is definitely going to make it easier to manage a policy set, especially when considering working with various policy authors, and that’s without even considering the other kinds of content already in cfbs, like custom promise types or content that is on the horizon like compliance reports.