Chef InSpec Compliance Testing: Writing Custom Profiles for CIS Benchmarks
Compliance frameworks like CIS Benchmarks contain hundreds of controls.
Compliance frameworks like CIS Benchmarks contain hundreds of controls. Auditing them manually—or trusting that your IaC applied them correctly—is a recipe for drift. InSpec turns benchmark requirements into executable tests that run after provisioning and on a schedule. Here's how to go beyond the sample profiles and build something production-ready.
Profile Structure
An InSpec profile is a directory with a specific layout:
cis-linux-hardening/
inspec.yml # profile metadata
controls/
os_hardening.rb # control files
network.rb
access_control.rb
libraries/
custom_resource.rb
files/
expected_sshd_config.txtThe inspec.yml declares dependencies and metadata:
name: cis-linux-hardening
title: CIS Ubuntu 22.04 Benchmark
maintainer: Security Team
license: Apache-2.0
summary: Level 1 and Level 2 CIS controls for Ubuntu 22.04
version: 1.0.0
supports:
- platform: ubuntu
release: "22.04"
depends:
- name: linux-baseline
git: https://github.com/dev-sec/linux-baseline
tag: 2.9.0Writing CIS Controls
CIS Benchmark PDF controls map directly to InSpec resources. CIS Ubuntu 22.04, Control 1.1.1.1—"Ensure mounting of cramfs filesystems is disabled":
# controls/filesystem_modules.rb
control 'cis-1.1.1.1' do
impact 1.0
title 'Ensure mounting of cramfs filesystems is disabled'
desc 'The cramfs filesystem type is a compressed read-only Linux filesystem. Removing support for unneeded filesystem types reduces the local attack surface.'
desc 'rationale', 'If this filesystem type is not needed, disable it.'
tag cis: '1.1.1.1'
tag level: 1
tag platform: 'linux'
describe kernel_module('cramfs') do
it { should_not be_loaded }
it { should be_disabled }
it { should be_blacklisted }
end
end
control 'cis-1.1.1.2' do
impact 1.0
title 'Ensure mounting of freevxfs filesystems is disabled'
tag cis: '1.1.1.2'
tag level: 1
describe kernel_module('freevxfs') do
it { should_not be_loaded }
it { should be_disabled }
end
endSSH hardening controls (CIS 5.2.x):
# controls/sshd.rb
sshd_config = '/etc/ssh/sshd_config'
control 'cis-5.2.4' do
impact 1.0
title 'Ensure SSH Protocol is set to 2'
tag cis: '5.2.4'
tag level: 1
describe sshd_config(sshd_config) do
its('Protocol') { should cmp 2 }
end
end
control 'cis-5.2.10' do
impact 1.0
title 'Ensure SSH root login is disabled'
tag cis: '5.2.10'
tag level: 1
describe sshd_config(sshd_config) do
its('PermitRootLogin') { should match(/no|prohibit-password/) }
end
end
control 'cis-5.2.12' do
impact 1.0
title 'Ensure only approved ciphers are used'
tag cis: '5.2.12'
tag level: 1
approved_ciphers = %w[
aes256-gcm@openssh.com
aes128-gcm@openssh.com
aes256-ctr
aes192-ctr
aes128-ctr
]
describe sshd_config(sshd_config) do
its('Ciphers') { should eq approved_ciphers.join(',') }
end
endCustom Resources
When InSpec's built-in resources don't cover your infrastructure, you write custom ones. Example: a custom resource for checking systemd unit hardening options.
# libraries/systemd_unit_security.rb
class SystemdUnitSecurity < Inspec.resource(1)
name 'systemd_unit_security'
desc 'Checks systemd unit file security settings'
example <<~EXAMPLE
describe systemd_unit_security('nginx') do
its('NoNewPrivileges') { should eq 'yes' }
its('PrivateTmp') { should eq 'yes' }
end
EXAMPLE
def initialize(unit_name)
@unit_name = unit_name
@params = {}
load_unit_properties
end
def method_missing(name)
@params[name.to_s]
end
private
def load_unit_properties
cmd = inspec.command("systemctl show #{@unit_name} --property=NoNewPrivileges,PrivateTmp,ProtectSystem,ProtectHome")
return unless cmd.exit_status.zero?
cmd.stdout.each_line do |line|
key, value = line.chomp.split('=', 2)
@params[key] = value
end
end
endUsing it in a control:
control 'systemd-hardening-nginx' do
impact 0.7
title 'Ensure nginx service has systemd hardening enabled'
describe systemd_unit_security('nginx') do
its('NoNewPrivileges') { should eq 'yes' }
its('PrivateTmp') { should eq 'yes' }
end
endMulti-Platform Testing
The same profile structure works for Linux, Windows, and AWS. Use only_if to gate controls on platform:
# controls/password_policy.rb
control 'password-policy-linux' do
only_if { os.linux? }
impact 1.0
title 'Ensure password hashing algorithm is SHA-512'
describe file('/etc/pam.d/common-password') do
its('content') { should match(/sha512/) }
end
end
control 'password-policy-windows' do
only_if { os.windows? }
impact 1.0
title 'Ensure password complexity is enabled'
describe security_policy do
its('PasswordComplexity') { should cmp 1 }
its('MinimumPasswordLength') { should be >= 14 }
end
endFor AWS infrastructure compliance, use the inspec-aws resource pack:
# inspec.yml
depends:
- name: inspec-aws
url: https://github.com/inspec/inspec-aws/archive/main.tar.gz# controls/aws_s3.rb
control 'aws-s3-no-public-access' do
impact 1.0
title 'Ensure S3 buckets do not allow public access'
tag cis_aws: '2.1.5'
aws_s3_buckets.bucket_names.each do |bucket_name|
describe aws_s3_bucket(bucket_name) do
it { should_not be_public }
it { should have_access_logging_enabled }
end
end
end
control 'aws-iam-root-mfa' do
impact 1.0
title 'Ensure MFA is enabled for the root account'
tag cis_aws: '1.5'
describe aws_iam_root_user do
it { should have_mfa_enabled }
it { should_not have_access_key }
end
endRunning Profiles
Against a remote target via SSH:
inspec exec cis-linux-hardening \
-t ssh://ubuntu@10.0.1.5 \
-i ~/.ssh/deploy_key \
--sudo \
--reporter cli json:/tmp/results.jsonAgainst an AWS account:
inspec exec aws-cis-profile \
-t aws:// \
--input aws_region=us-east-1 \
--reporter cli json:aws-results.jsonFilter to specific CIS levels:
inspec exec cis-linux-hardening \
-t ssh://host \
--controls /cis-.*/ \
--reporter cliCI Integration with Chef Automate
InSpec results in JSON format upload directly to Chef Automate for compliance dashboards:
# Generate token in Automate: Settings > API Tokens
inspec exec cis-linux-hardening \
-t ssh://host \
--reporter automate \
--automate-url https://automate.example.com \
--automate-token $AUTOMATE_API_TOKEN \
--automate-insecure # if self-signedIn GitHub Actions:
# .github/workflows/compliance.yml
name: Compliance Scan
on:
schedule:
- cron: '0 6 * * *'
push:
paths:
- 'terraform/**'
- 'ansible/**'
jobs:
inspec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install InSpec
run: |
curl https://omnitruck.chef.io/install.sh | \
sudo bash -s -- -P inspec -v 6
- name: Run CIS Profile
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
TARGET_HOST: ${{ secrets.STAGING_HOST }}
run: |
echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
inspec exec cis-linux-hardening \
-t ssh://ubuntu@$TARGET_HOST \
-i /tmp/key \
--sudo \
--reporter cli junit:results/inspec.xml \
--input-file inputs/staging.yml
- name: Upload Results
uses: actions/upload-artifact@v4
with:
name: compliance-results
path: results/
- name: Publish Test Results
uses: dorny/test-reporter@v1
with:
name: InSpec Compliance
path: results/inspec.xml
reporter: java-junitCreate an inputs/staging.yml for environment-specific values:
# inputs/staging.yml
ssh_port: 22
allowed_users:
- ubuntu
- deploy
ntp_server: 169.254.169.123Reference in controls:
control 'ntp-configured' do
impact 0.5
describe ntp_conf do
its('server') { should include input('ntp_server') }
end
endWaiver Files
Not all CIS controls apply to every environment. Document exceptions with waiver files instead of deleting controls:
# waivers/staging.yml
cis-1.1.1.1:
expiration_date: 2026-12-31
justification: "cramfs required by legacy backup agent, ticket #1234"
run: false
cis-5.2.4:
expiration_date: 2026-06-30
justification: "Protocol 1 disabled at network level, not OS"
run: true # still run, but don't failinspec exec cis-linux-hardening \
-t ssh://host \
--waiver-file waivers/staging.ymlExpired waivers automatically reactivate controls—you can't silently extend compliance exceptions. The combination of explicit waivers with expiration dates and Automate dashboards gives you a complete audit trail without manual spreadsheet tracking.