Chef InSpec Compliance Testing: Writing Custom Profiles for CIS Benchmarks
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 \
--<span class="hljs-built_in">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 <span class="hljs-built_in">exec cis-linux-hardening \
-t ssh://host \
--reporter automate \
--automate-url https://automate.example.com \
--automate-token <span class="hljs-variable">$AUTOMATE_API_TOKEN \
--automate-insecure <span class="hljs-comment"># 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.