Automating Compliance Scans with Chef InSpec
Chef InSpec is a testing framework for system state. You describe how a machine, container or cloud account should be configured, in a Ruby DSL, and InSpec checks it locally, over SSH or WinRM, inside a Docker container, or through cloud APIs. It only reads. It never changes the target.
That makes it a good fit for compliance: controls from a benchmark such as CIS become code, the code is reviewed, and every scan produces a report you can hand to an auditor.
Install and licensing
Current Chef InSpec releases are distributed by Progress under the Chef EULA. Downloads need a license, and the EULA must be accepted before the first run. In automation, accept it non-interactively:
export CHEF_LICENSE=accept-silent
inspec version
If the license is not accepted, inspec exits with code 172.
If the commercial terms do not fit, CINC Auditor is a community build of the same source code. The binary is called cinc-auditor and takes the same arguments:
curl -L https://omnitruck.cinc.sh/install.sh | sudo bash -s -- -P cinc-auditor
There is also a cincproject/auditor container image for CI. The examples below use inspec.
A profile of your own
inspec init profile my-baseline
This creates inspec.yml (metadata, inputs, dependencies) and a controls/ directory. Keep values that differ between environments in inputs, not in the controls:
# my-baseline/inspec.yml
name: my-baseline
title: Baseline for my-app hosts
version: 1.0.0
supports:
- platform-family: linux
inputs:
- name: min_password_length
type: Numeric
value: 14
# my-baseline/controls/access.rb
control 'ssh-01' do
impact 0.7
title 'Root login over SSH is disabled'
desc 'Admins log in with personal accounts and use sudo, so actions are traceable.'
describe sshd_config do
its('PermitRootLogin') { should cmp 'no' }
end
end
control 'pw-01' do
impact 0.5
title 'Minimum password length is enforced'
describe parse_config_file('/etc/security/pwquality.conf') do
its('minlen') { should cmp >= input('min_password_length') }
end
end
impact goes from 0.0 to 1.0 and maps to severity in reports. cmp compares loosely, so 'no' matches no and '14' matches 14. Older profiles use attribute() for the same purpose; it was renamed to input() and the old name is deprecated.
Check syntax and metadata before running:
inspec check my-baseline
Reuse a community baseline
Writing every control from scratch is slow. The DevSec hardening baselines cover common Linux, SSH and web server settings. Depend on a pinned version and adjust it, rather than copying it:
# add to my-baseline/inspec.yml
depends:
- name: linux-baseline
git: https://github.com/dev-sec/linux-baseline.git
tag: 2.11.0
# my-baseline/controls/linux.rb
include_controls 'linux-baseline' do
skip_control 'os-10'
end
skip_control removes a control from the run. Use it only when the control does not apply to the platform at all. For controls that apply but cannot be met yet, use a waiver.
Run against real targets
# local machine
inspec exec my-baseline
# remote host over SSH, with sudo for files only root can read
inspec exec my-baseline -t ssh://admin@web-01.example.com -i ~/.ssh/id_ed25519 --sudo
# a running container
inspec exec my-baseline -t docker://my-app-test
# override an input for this environment
inspec exec my-baseline --input min_password_length=16
Exceptions with waivers
Every real environment has exceptions. Record them in a waiver file instead of editing the profile:
# waivers.yml
pw-01:
expiration_date: 2026-12-31
run: true
justification: "Legacy app hosts use LDAP auth, local passwords unused. Ticket SEC-123."
inspec exec my-baseline --waiver-file waivers.yml
With run: true the control still runs and is reported, but a failure does not fail the scan. After expiration_date the waiver stops applying, so exceptions do not become permanent by accident.
Reports and exit codes
inspec exec my-baseline -t ssh://admin@web-01.example.com \
--reporter cli json:results.json junit2:results.xml html2:report.html
junit2 and html2 replace the older junit and html reporters. Exit codes are 0 when everything passed, 100 when at least one control failed and 101 when something was skipped but nothing failed. Note that 101 is non-zero and fails a CI step. --no-distinct-exit changes this to 0 for skips and 1 for failures.
List failed controls from the JSON report:
jq -r '.profiles[].controls[]
| select(any(.results[]; .status == "failed"))
| "\(.id) \(.title)"' results.json
In CI
Test the image before it is pushed. Start a container from it and run the profile through the Docker transport:
name: compliance
on: [pull_request]
jobs:
inspec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t my-app:${{ github.sha }} .
- run: docker run -d --name my-app-test --entrypoint sleep my-app:${{ github.sha }} infinity
- run: curl -L https://omnitruck.cinc.sh/install.sh | sudo bash -s -- -P cinc-auditor
- run: cinc-auditor exec ./my-baseline -t docker://my-app-test --waiver-file waivers.yml --reporter cli junit2:inspec.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: inspec-report
path: inspec.xml
Use upload-artifact@v4. Version 3 of the artifact actions has been deprecated and no longer works on github.com.
On a schedule, for drift
CI checks what you build. Drift happens after deploy: manual changes, package updates, someone fixing an incident by hand. Run the same profile against live hosts on a schedule (a CI cron job, a systemd timer on a management host, or Chef Automate if you already use it) and keep the JSON reports. Comparing failed control IDs between runs shows exactly what changed.
Remediation
InSpec reports, it does not fix. Fix findings in the configuration management code (Chef, Ansible, Puppet) or in the image build, then rerun the scan. Avoid scripts that parse reports and change production hosts directly. That bypasses review and change management, which auditors care about as much as the setting itself.
Checklist
- Accept the license explicitly (
CHEF_LICENSE) or use CINC Auditor. - One profile per platform, environment values in inputs.
- Community baselines pinned by tag, adjusted with
skip_controlonly where a control does not apply. - Exceptions in a waiver file with justification and expiry date.
jsonandjunit2reports from every run, stored with the build or scan date.- Image scans in CI before push, scheduled scans of live hosts for drift.
- Fixes through config management, followed by a rescan.
