I have recently been using the CIS Benchmark Assessor tool to validate the compliance of systems with the CIS Benchmarks. What I am looking at is ensuring compliance doesn’t get worse - 100% compliance would render the systems unusable so a balance is required. As a first step to automating compliance monitoring, I have automated the running of the tool and gathering reports using Ansible.

Rationale

The CIS Benchmark Assessor tool has the capability of running against remote hosts built in, so why do it this way? In an environment where Ansible is already setup and used to manage systems, including automation to run playbooks periodically and secure routes to access remote systems (e.g. via bastion hosts, short-lived credentials and/or multi-factor authentication) it may simply be easier to do it this way. It, as you will see below, can also simplify determining which benchmark to run based on Ansible detected OS and version.

Prerequisites

The prerequisites are:

  • Ansible setup to work with the remote host
  • CIS Benchmark Assessor tool downloaded
  • Licence package for the CIS Benchmark Assessor tool downloaded

Methodology

The process my Ansible tasks follow are:

  1. Create a temporary directory
  2. Extract the Assessor tool to the temporary directory
  3. Extract the licence to the Assessor tool directory’s license folder
  4. Run the Assessor tool (tool insists on running as root)
  5. Change the new files (log/reports) ownership away from root (now no need to do any other steps as root - lease permissions required for the remaining steps means having them owned by the user Ansible is running as)
  6. Create an archive containing the reports
  7. Copy the archive back to the host Ansible is running on
  8. Delete temporary directory (tidy up)

Building the role

I started by creating a CIS Benchmark role, which I imaginatively called cis-benchmark.

Argument specifications

First I created the argument specification in meta/argument_specs.yaml:

---
argument_specs:
  main:
    short_description: >-
      Run CIS Benchmark Assessor on remote hosts and gather reports
    author: Laurence Alexander Hurst
    options:
      assessor_tool_package:
        description: Filename containing the Assessor tool
        type: str
        default: CIS-CAT-Assessor-linux-jre.zip
      assessor_tool_licence_package:
        description: Filename containing the licence for the Assessor tool
        type: str
        default: NewMember-LicenseKey-ClientConfigurationBundle.zip
      benchmark:
        description: |
          Specify a benchmark to use on all hosts (override default OS
          detection/map lookup method). Should be the basename of a file in
          the `benchmarks` directory without the `-xccdf.xml` suffix (which
          will be automatically added).

          Use `Assessor-CLI.sh -l -lv` with the Linux version to get a list of
          benchmarks and their filenames.
        type: str
        required: false
      profile:
        description: |
          Specify the profile to use with the benchmark (on all hosts if
          `benchmark` is specified, fallback with default OS detection/map
          lookup method if profile not specified in the map).

          Use `Assessor-CLI.sh -b <benchmark file> -bi` with the Linux version
          to get a list of available profiles for each benchmark.
        type: str
        default: Level 2 - Server
      benchmark_map:
        description: |
          Dictionary mapping OS (`ansible_facts.distribution`) to version
          (`ansible_facts.distribution_version`, falling back to
          `ansible_facts.distribution_major_version` to full version is not
          present) to benchmark name (as per the `benchmark` argument) and
          profile to be used. Profile is optional, if omitted the one given by
          the top-level `profile` argument will be used. A default map is
          provided based on version `4.34.0` of the Assessor package and a
          subset of Linux OS versions.

          e.g.

          ```yaml
          CentOS:
            7:
              benchmark: CIS_CentOS_Linux_7_Benchmark_v3.1.2
              profile: Level 1 - Server
            6:
              benchmark: CIS_CentOS_Linux_6_Benchmark_v3.0.0
          ```
        type: dict
        required: false
      report_directory:
        description: |
          Path to a directory, which must exist, on the host Ansible is being
          run on into which the reports will be saved. The reports will be in
          an archive named
          `CIS-Benchmark-report-bundle-{{ inventory_hostname }}.zip` in this
          directory. Existing files will be overwritten.
        type: str
        required: true
...

Default values

In defaults/main.yaml I set the defaults as described in the argument specification (note the versions need to be strings, not numbers, or they won’t match the fact values which are strings):

---
assessor_tool_package: CIS-CAT-Assessor-linux-jre.zip
assessor_tool_licence_package: NewMember-LicenseKey-ClientConfigurationBundle.zip
profile: Level 2 - Server
benchmark_map:
  CentOS:
    '7':
      benchmark: CIS_CentOS_Linux_7_Benchmark_v3.1.2
  Debian:
    '10':
      benchmark: CIS_Debian_Linux_10_Benchmark_v1.0.0
    '11':
      benchmark: CIS_Debian_Linux_11_Benchmark_v1.0.0
  Ubuntu:
    '18.04':
      benchmark: CIS_Ubuntu_Linux_18.04_LTS_Benchmark_v2.1.0
  Rocky:
    '8':
      benchmark: CIS_Rocky_Linux_8_Benchmark_v1.0.0
    '9':
      benchmark: CIS_Rocky_Linux_9_Benchmark_v1.0.0
...

Files

I then dropped the CIS Assessor and licence files in the role’s files, and created a symlink from CIS-CAT-Assessor-linux-jre.zip to the current version in use. At the same time, I added a .gitignore to the files folder to ensure the licence and software files are not accidentally committed:

# Ignore licensed software and licences
/CIS-CAT-Assessor-linux-jre-v*.zip
/NewMember-LicenseKey-ClientConfigurationBundle.zip

Tasks

Finally, the task file that does the work (Note that I used community.general.archive, I already had the community.general collection in my requirements.yaml file but this many need adding if not):

---
- name: Ensure temporary directory exists for Assessor tool
  ansible.builtin.tempfile:
    state: directory
  register: temporary_directory
- name: Assessor tool is extracted on remote system
  ansible.builtin.unarchive:
    src: "{{ assessor_tool_package }}"
    dest: "{{ temporary_directory.path }}"
    creates: "{{ temporary_directory.path }}/Assessor"
- name: Licence bundle is extracted
  ansible.builtin.unarchive:
    src: "{{ assessor_tool_licence_package }}"
    dest: "{{ temporary_directory.path }}/Assessor/license"
    creates: "{{ temporary_directory.path }}/Assessor/license/license.xml"
- name: Run Assessor tool
  become: true  # Tool requires running as root
  ansible.builtin.command:
    # For simplicity, change to this directory then use relative paths
    chdir: "{{ temporary_directory.path }}/Assessor"
    argv:
      - ./Assessor-CLI.sh
      - -b
      - >-
        benchmarks/{{
            benchmark
            |
            default(
                benchmark_map[ansible_facts.distribution][ansible_facts.distribution_version].benchmark
            )
            |
            default(
                benchmark_map[ansible_facts.distribution][ansible_facts.distribution_major_version].benchmark
            )
        }}-xccdf.xml
      - -p
      - >-
        {%- if benchmark is defined -%}
          {{ profile }}
        {%- else -%}
          {{
              benchmark_map[ansible_facts.distribution][ansible_facts.distribution_version].profile
              |
              default(
                  benchmark_map[ansible_facts.distribution][ansible_facts.distribution_major_version].profile
              )
              |
              default(profile)
          }}
        {%- endif -%}
      # Produce reports in these formats
      - -csv
      - -html
      - -json
- name: Assessor file ownership is ansible_user_id (no need for logs/reports to remain owned by root)
  become: true
  ansible.builtin.file:
    path: "{{ temporary_directory.path }}/Assessor"
    owner: "{{ ansible_user_id }}"
    recurse: true
- name: Report bundle is created
  community.general.archive:
    format: zip
    # `community.general.archive` can only create archives on remote system
    dest: "{{ temporary_directory.path }}/report-bundle.zip"
    path: "{{ temporary_directory.path }}/Assessor/reports"
- name: Report bundle is fetched from remote host
  ansible.builtin.fetch:
    dest: "{{ report_directory }}/CIS-Benchmark-report-bundle-{{ inventory_hostname }}.zip"
    src: "{{ temporary_directory.path }}/report-bundle.zip"
    flat: true
- name: Temporary directory is removed
  ansible.builtin.file:
    name: "{{ temporary_directory.path }}"
    state: absent
...

Using the role

Using the role is very simple:

---
- hosts: all
  roles:
    - role: cis-benchmark
      vars:
        # Save the reports to the current directory
        report_directory: .
...