Chef InSpec Compliance Testing: Writing Custom Profiles for CIS Benchmarks

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.txt

The 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.0

Writing 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
end

SSH 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
end

Custom 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
end

Using 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
end

Multi-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
end

For 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
end

Running 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.json

Against an AWS account:

inspec exec aws-cis-profile \
  -t aws:// \
  --input aws_region=us-east-1 \
  --reporter cli json:aws-results.json

Filter to specific CIS levels:

inspec exec cis-linux-hardening \
  -t ssh://host \
  --controls /cis-.*/ \
  --reporter cli

CI 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-signed

In 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-junit

Create an inputs/staging.yml for environment-specific values:

# inputs/staging.yml
ssh_port: 22
allowed_users:
  - ubuntu
  - deploy
ntp_server: 169.254.169.123

Reference in controls:

control 'ntp-configured' do
  impact 0.5
  
  describe ntp_conf do
    its('server') { should include input('ntp_server') }
  end
end

Waiver 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 fail
inspec exec cis-linux-hardening \
  -t ssh://host \
  --waiver-file waivers/staging.yml

Expired 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.

Read more