Running CIS Benchmark Assessor on servers with Ansible
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:
- Create a temporary directory
- Extract the Assessor tool to the temporary directory
- Extract the licence to the Assessor tool directory’s
license
folder - Run the Assessor tool (tool insists on running as root)
- 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)
- Create an archive containing the reports
- Copy the archive back to the host Ansible is running on
- 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: .
...