CoursesInSpecWhat InSpec is: compliance as code

What InSpec is: compliance as code

Executable, human-readable controls.

Intermediate12 min · lesson 1 of 12

A written security policy is a sign on a fire door. It says the door opens from the inside. The sign stays true right up until the evening somebody chains the push bar shut, and the sign will keep saying the same thing anyway. The only way to know is to walk over and push. InSpec is the pushing.

InSpec is a testing framework for the state of real systems. You take a requirement that currently lives in a policy document ("the SSH server must not allow direct root login", "the audit log bucket must be encrypted") and write it as a control: a short block of code that goes and looks at the actual machine, or the actual cloud account, and reports pass or fail. SSH is Secure Shell, the encrypted remote-login protocol nearly every Linux server answers on, and sshd is the background program on the server side that answers it. The code reads closely enough to English that an auditor can follow it without knowing Ruby, and precisely enough that a machine runs it the same way every time. That is what people mean by compliance as code. InSpec grew out of the Chef ecosystem but it is a standalone tool. It does not care whether Chef, Ansible, Terraform, or a person with a keyboard built the thing it inspects.

One requirement, one runnable sentence

Here is a whole benchmark item expressed as something you can run. CIS is the Center for Internet Security, a non-profit that publishes free hardening benchmarks: long numbered checklists of settings that most compliance programs borrow from wholesale.

controls/ssh.rb
control 'ssh-01' do
impact 1.0
title 'Root must not be able to log in directly over SSH'
desc 'Direct root login destroys attribution. Every action lands in the log as "root" with no way to tell which human did it. Admins log in as themselves, then escalate.'
tag cis: '5.2.10'
ref 'CIS Ubuntu Linux 20.04 LTS Benchmark v1.1.0'
describe sshd_config do
its('PermitRootLogin') { should cmp 'no' }
end
end

Every part of that block earns its place. control 'ssh-01' gives the check a stable identifier that follows it into every report, so a failure in June traces to the same rule as a failure in December. impact is a number from 0.0 to 1.0 that InSpec bands into words: below 0.01 is none, up to 0.4 is low, up to 0.7 is medium, up to 0.9 is high, and 0.9 and above is critical. Reporters use it to sort results and to decide what deserves waking someone up. title and desc are the human sentences, and they are what a non-engineer reads. Put the reason the rule exists in desc, not a restatement of the code.

tag and ref link the control back to the paperwork it came from, so a failing test points at a clause rather than a mystery. Notice that ref names an exact edition of the benchmark. That is not fussiness. CIS renumbers items between revisions, so "5.2.10" on its own is a number with no home, and an auditor holding a different edition will read it as a completely different rule.

The describe block is the test itself. sshd_config is a resource, and a resource is the clerk who already knows the filing cabinet. Instead of you opening /etc/ssh/sshd_config, stripping comments and splitting lines on whitespace, you ask for a setting by name and get its value back. its('PermitRootLogin') pulls out one setting. should cmp 'no' is the assertion, and cmp is deliberately relaxed about type and letter case, so no, No and NO all pass where a strict matcher trips over a capital letter. Several hundred resources ship in the box, covering files, packages, services, ports, users, kernel parameters and arbitrary commands.

The cloud resources are the exception that catches people out. aws_s3_bucket, azure_virtual_machine and google_compute_instance are all real, and not one of them is inside InSpec. They live in resource packs: profiles that carry resources and no controls of their own. You pull one in the same way you pull in anything else, and until you do, a control mentioning aws_s3_bucket blows up with an undefined method error rather than producing a compliance finding.

cloud-baseline/inspec.yml
depends:
- name: inspec-aws
git: https://github.com/inspec/inspec-aws.git
tag: v1.62.0 # pin to a tag you have actually read

Point it at a host and run it. The -t flag names the target, which here is an SSH connection using a dedicated audit key. Nothing gets installed on web-03. InSpec opens a session, runs read-only commands, collects the answers and hangs up, which is why it works the same on a host you own and on one you have merely been handed a login to.

terminal
$ inspec exec controls/ssh.rb \
-t ssh://[email protected] \
--key-files ~/.ssh/audit_ed25519
output
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)
Version: (not specified)
Target: ssh://[email protected]:22
Target ID: 3f6c1b0a-9d21-5a44-8e17-2b1f6f3f2a10
× ssh-01: Root must not be able to log in directly over SSH (1 failed)
× SSHD Configuration PermitRootLogin is expected to cmp == "no"
expected: "no"
got: "prohibit-password"
(compared using `cmp` matcher)
Profile Summary: 0 successful controls, 1 control failure, 0 controls skipped
Test Summary: 0 successful, 1 failure, 0 skipped

That is the interesting kind of failure. prohibit-password is Ubuntu's shipped default and it is genuinely safer than yes, because it blocks password logins for root while still allowing a key. Plenty of engineers read it as "root login is off". It is not. Anyone holding a root key, including one an image build dropped into /root/.ssh/authorized_keys three years ago, logs straight in as root, and every command they run lands in the log as "root". The control found the gap between what people believed and what the box was doing. That gap is the whole product.

terminal
$ echo $?
output
100

One hundred, not one. That number is a contract with your automation, and we come back to it shortly.

The blueprint and the building

You may already run a static scanner over your infrastructure as code (IaC, the practice of describing servers and networks in files instead of clicking around a console). Checkov, Trivy (which absorbed tfsec) and friends read your Terraform or CloudFormation before anything exists and tell you the declaration is wrong. Good habit, catches plenty. It also has a hard ceiling: it can only ever see what the code says.

Say your Terraform builds hosts from a hardened image baked in March. In May, an on-call engineer chasing an outage runs setenforce 0, flipping Security-Enhanced Linux (SELinux, the kernel feature that boxes each program into a policy of what it is allowed to touch) from enforcing to permissive so the app will start. Nothing in Terraform mentions SELinux. No file in the repository changed. Checkov stays green forever. Meanwhile the host runs with its strongest containment layer switched off, so an attacker who lands a web shell there wanders freely instead of being pinned inside the web server's policy. InSpec is the tool that logs in and notices.

controls/os.rb
control 'os-14' do
impact 0.9
title 'SELinux must be installed and enforcing'
desc 'Permissive SELinux confines nothing. It only writes down what it would have blocked.'
describe selinux do
it { should be_installed }
it { should be_enforcing }
end
end
terminal
$ inspec exec controls/os.rb -t ssh://[email protected] \
--key-files ~/.ssh/audit_ed25519
output
× os-14: SELinux must be installed and enforcing (1 failed)
✔ SELinux is expected to be installed
× SELinux is expected to be enforcing
expected `SELinux.enforcing?` to return true, got false
Profile Summary: 0 successful controls, 1 control failure, 0 controls skipped
Test Summary: 1 successful, 1 failure, 0 skipped

The same argument holds for cloud accounts. Run inspec exec with -t aws://eu-west-1 and it talks to the live application programming interface (API, the machine-to-machine way of asking a service questions) and checks what actually exists right now, including the public bucket somebody created by hand outside Terraform last Thursday. A scanner reading your repository cannot see that bucket, because it is not in your repository.

From a clause in a document to an artifact you can hand an auditor
1written clause
CIS 5.2.10, a line in a PDF
2control
impact, title, desc, describe block
3target
live host, container or cloud API
4verdict
pass / fail / skip, plus an exit code
5evidence
JSON and JUnit files you keep
The requirement and the test are the same file. A human can read it, a machine can run it, and nothing is transcribed by hand, so the two cannot drift apart.

A profile is the unit you ship

A lone .rb file is fine for a demo. Real work is packaged as a profile: a directory with a manifest, a controls/ folder and a version number. It is the difference between a recipe card that names its author, lists its ingredients and says how many it serves, and a note scribbled on the back of an envelope. inspec init profile builds the skeleton for you.

terminal
$ inspec init profile --platform os ssh-baseline
output
───────────────────────── InSpec Code Generator ─────────────────────────
Creating new profile at /home/you/ssh-baseline
• Creating directory /home/you/ssh-baseline
• Creating file inspec.yml
• Creating directory controls
• Creating file controls/example.rb
• Creating file README.md

Pass --platform os and you get an operating system profile. Leave it off and the generator asks you, which is fine at a keyboard and unhelpful in a pipeline. The manifest it writes, inspec.yml, is where a pile of tests becomes something you can share and pin.

ssh-baseline/inspec.yml
name: ssh-baseline
title: SSH server hardening baseline
maintainer: Platform Security
copyright: ACME Ltd
license: Apache-2.0
summary: Verifies sshd matches the ACME SSH standard (derived from CIS)
version: 0.3.0
supports:
- platform-family: debian
- platform-family: redhat
inputs:
- name: ssh_port
type: numeric
value: 22
description: Port sshd is expected to listen on
depends:
- name: ssh-baseline-upstream
url: https://github.com/dev-sec/ssh-baseline/archive/2.6.4.tar.gz

supports declares which platforms these controls are meaningful on, so a Debian-only profile is never asked to reason about a Windows box. inputs are the dials on the outside of the box (they were called attributes before InSpec 4, a name you will still meet in older profiles), and they let one profile serve production and a lab by changing a value instead of forking the code. version is what continuous integration pins, so today's scan is reproducible next year. depends pulls in somebody else's baseline at an exact version, and a later lesson covers include_controls and require_controls, the way you inherit an upstream benchmark wholesale and then override the three rules your organization does differently.

Before running anything, inspec check loads the profile and tells you whether it is even valid: required metadata present, Ruby that parses, dependencies that resolve, controls it can actually see. Run it on every change to the profile itself. It is the difference between finding a broken profile at your desk and finding it after a scheduled scan has reported nothing useful about five hundred hosts.

terminal
$ inspec check ssh-baseline
output
Location: ssh-baseline
Profile: ssh-baseline
Controls: 6
Timestamp: 2026-07-22T10:09:51+00:00
Valid: true
No errors or warnings

Exit codes are the contract with your pipeline

The exit code is the one thing your automation actually reads, and InSpec's is more expressive than pass or fail. Zero means every control passed. 100 means InSpec ran fine and at least one control failed. 101 means InSpec ran fine, nothing failed, and at least one control was skipped. Below those sit the codes that mean InSpec itself had a bad day: 1 for a usage or general error, 2 for a plugin error, 3 for a fatal deprecation, and 172 for a Chef license that was never accepted.

The 101 case is the one that quietly ruins compliance programs. A control is skipped when the resource behind it cannot load. Aim the SSH control at a container image with no SSH server in it and you get this.

terminal
$ inspec exec controls/ssh.rb -t docker://a91f3c0d2b77
output
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)
Version: (not specified)
Target: docker://a91f3c0d2b7741e8f0b5c9d2a63e8f014c7b2d9e6a3f5081bd4c7e2a9f610b35
↺ ssh-01: Root must not be able to log in directly over SSH
↺ Can't find file "/etc/ssh/sshd_config"
Profile Summary: 0 successful controls, 0 control failures, 1 control skipped
Test Summary: 0 successful, 0 failures, 1 skipped
terminal
$ echo $?
output
101

Nothing failed. On a dashboard that counts failures, zero failures reads as a pass. What really happened is that the check never ran, so you learned nothing at all about that target. A skipped control is an untested control, and untested sits far closer to non-compliant than to compliant. Say so in your pipeline.

ci/scan.sh
#!/usr/bin/env bash
set -uo pipefail # deliberately no -e: we need to read InSpec's exit code ourselves
mkdir -p evidence
inspec exec ssh-baseline \
-t "ssh://svc-audit@${HOST}" --key-files ~/.ssh/audit_ed25519 \
--reporter cli json:"evidence/${HOST}.json"
rc=$?
case "$rc" in
0) echo "PASS ${HOST}" ;;
100) echo "FAIL ${HOST}: control failures"; exit 1 ;;
101) echo "FAIL ${HOST}: controls skipped, nothing verified"; exit 1 ;;
172) echo "ERROR ${HOST}: Chef license not accepted"; exit 1 ;;
*) echo "ERROR ${HOST}: inspec itself exited ${rc}"; exit 1 ;;
esac

Notice the missing -e. With set -e the script dies the instant InSpec returns anything non-zero, so you never reach the case statement and never learn which kind of problem you had. There is also a --no-distinct-exit flag that throws the distinction away and reverts to the old two-value behavior: failures exit 1, and skips exit 0. Read that second half again. Under that flag, a run that skipped every control on the box returns 0, and a wrapper checking for zero waves it through as compliant. Reach for it only when some other tool genuinely cannot cope with unusual exit codes, because the distinction is the useful part.

A skipped control is not a passing control
InSpec skips a control when the resource behind it cannot load: the file is missing, the resource is not supported on the target platform, the audit user lacks permission to read the path, or a waiver told it to stand down. All four look identical on a summary line that only counts failures, and a profile where every single control skips still exits 101 with a cheerful "0 failures". A whole-profile supports mismatch is worse still, because nothing runs at all and the exit code is 0 (see the profiles lesson). Review the skip count on every run, and treat a sudden jump in skips exactly like a jump in failures, because both mean your coverage changed without anyone deciding it should.

The report is the deliverable

Terminal output is for you, the way a till receipt is for the person carrying the shopping home. Everything downstream wants a filed copy in its own format. --reporter takes a list, and each entry writes to its own destination in the same run, off the same set of results.

terminal
$ inspec exec ssh-baseline -t ssh://[email protected] \
--key-files ~/.ssh/audit_ed25519 \
--reporter cli \
json:evidence/web-03.json \
junit2:evidence/web-03.xml \
html2:evidence/web-03.html
output
Profile: SSH server hardening baseline (ssh-baseline)
Version: 0.3.0
Target: ssh://[email protected]:22
Target ID: 3f6c1b0a-9d21-5a44-8e17-2b1f6f3f2a10
× ssh-01: Root must not be able to log in directly over SSH (1 failed)
× SSHD Configuration PermitRootLogin is expected to cmp == "no"
expected: "no"
got: "prohibit-password"
(compared using `cmp` matcher)
✔ ssh-02: sshd must not accept password authentication
✔ SSHD Configuration PasswordAuthentication is expected to cmp == "no"
✔ ssh-03: sshd must listen only on the management interface
✔ SSHD Configuration ListenAddress is expected to cmp == "10.20.0.13"
✔ ssh-04: Only approved key exchange algorithms are offered
✔ SSHD Configuration KexAlgorithms is expected to cmp == "curve25519-sha256"
✔ ssh-05: Idle sessions must time out
✔ SSHD Configuration ClientAliveInterval is expected to cmp == 300
✔ SSHD Configuration ClientAliveCountMax is expected to cmp == 0
✔ ssh-06: sshd_config must be owned by root and closed to everyone else
✔ File /etc/ssh/sshd_config is expected to be owned by "root"
✔ File /etc/ssh/sshd_config mode is expected to cmp == "0600"
Profile Summary: 5 successful controls, 1 control failure, 0 controls skipped
Test Summary: 7 successful, 1 failure, 0 skipped

cli keeps the colored summary on your screen. json (JavaScript Object Notation, a plain-text format machines parse easily) is the record you archive, feed to a dashboard, or push into a search index. junit2 writes XML (Extensible Markup Language, the tag-based format older tools speak) in the test-result shape continuous integration systems already know how to draw, so failed controls land in the same panel as failed unit tests. html2 produces the page you send to whoever asked for "a report". The 2 in junit2 and html2 is not decoration: junit and html are the older implementations, kept around for compatibility and no longer where the work goes. Point new pipelines at the numbered ones.

The JSON is where evidence really lives, because it carries the identifiers and tags alongside the result.

evidence/web-03.json (trimmed)
{
"platform": { "name": "ubuntu", "release": "20.04" },
"profiles": [{
"name": "ssh-baseline",
"version": "0.3.0",
"controls": [{
"id": "ssh-01",
"title": "Root must not be able to log in directly over SSH",
"impact": 1.0,
"tags": { "cis": "5.2.10" },
"refs": [{ "ref": "CIS Ubuntu Linux 20.04 LTS Benchmark v1.1.0" }],
"results": [{
"status": "failed",
"code_desc": "SSHD Configuration PermitRootLogin is expected to cmp == \"no\"",
"message": "\nexpected: \"no\"\n got: \"prohibit-password\"\n",
"run_time": 0.008112,
"start_time": "2026-07-22T10:14:03+00:00"
}]
}]
}],
"statistics": { "duration": 3.9412 },
"version": "6.8.24"
}

That record is timestamped, tied to a named target, and traceable to a numbered item in a named edition of a benchmark. Keep a year of them and you have an audit trail nobody had to assemble by hand in a panic the week before an assessment. When a control genuinely cannot be met yet, resist the urge to delete it from the profile. Write a waiver instead. A waiver is a parking permit on the dashboard rather than a scratched-out line in the ledger: visible, signed and dated.

waivers.yml
ssh-02:
run: false
expiration_date: 2026-09-30
justification: "Legacy build agents still authenticate with passwords. Migration tracked in SEC-4412."

Run it with --waiver-file waivers.yml. With run: false the control does not execute, it reports as skipped, and the justification travels into the report next to the result. That means a waived control lands in the same skipped column that pushes the run to exit 101, which is a feature rather than a nuisance: waived work is unverified work, and your pipeline should keep saying so out loud. The expiry date makes the exception self-destruct. Once 30 September passes, InSpec ignores the waiver, the control runs, and it fails again if nobody did the migration. An exception with a deadline is risk management. An exception you quietly deleted from the profile is a hole nobody can see.

A profile is code, and it runs against your fleet
InSpec profiles are Ruby, and the command resource shells out on the target. Running an untrusted profile from a random GitHub repository or from Chef Supermarket (inspec supermarket profiles lists what is published, inspec exec supermarket://dev-sec/ssh-baseline runs one) means executing somebody else's code against production hosts, usually with read access to things like /etc/shadow and private keys. Read the profile, pin it to an exact tag or commit, and mirror it internally rather than fetching it live at scan time. Give the scanner its own account with the narrowest access its controls actually need, and log its sessions like any other privileged login.

InSpec 6, licensing and CINC Auditor

One practical thing changed and it catches people out. InSpec 5 and earlier were open source under Apache 2.0, and the packaged builds asked you once to accept Chef's terms, which you did with --chef-license accept-silent or the CHEF_LICENSE environment variable. InSpec 6 moved to a Progress Chef commercial license and expects a license key at run time, supplied with --chef-license-key or the CHEF_LICENSE_KEY environment variable. There is a free tier with usage limits, alongside trial and commercial tiers. With no valid key the run stops with exit 172 and produces nothing, which in a nightly pipeline looks exactly like a scanner that quietly stopped existing.

terminal
$ export CHEF_LICENSE_KEY=free-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
$ inspec version
output
6.8.24

CINC Auditor is the community rebuild, put out by the CINC project (the name expands, recursively, to CINC Is Not Chef). It builds from the open source code with Chef's trademarks and the license gate taken out. Same control language, same resources, same profile format. The binary is cinc-auditor instead of inspec and the arguments are identical, so cinc-auditor exec ssh-baseline -t ssh://[email protected] does what you would expect, and everything in this course works with either. Check which upstream version your CINC build tracks before you assume feature parity, and pin whichever one you choose. A fleet-wide scanner that changes behavior underneath you is a bad surprise.

Where InSpec stops

InSpec detects and records. It does not fix. When PermitRootLogin comes back as prohibit-password, InSpec tells you and stops. Changing it is the job of a configuration management tool such as Ansible, Chef, Puppet or Salt, or of rebuilding the image properly. The pairing is the point. The configuration tool asserts the desired state, InSpec independently verifies it, and the two are written and reviewed separately so a bug in the enforcement cannot copy itself into the verification. Treating InSpec as a hardening tool is a category error. It is the thermometer, not the heating.

The second limit is quieter and costs people more. A control nobody wrote cannot fail. InSpec will happily report a hundred passes on a host running an unauthenticated admin panel on port 8080, because nothing in your profile ever asked about port 8080. Coverage is a human decision, and the honest way to track it is to map your controls back to the clauses of a real standard and count what is missing. Starting from a published baseline, a CIS benchmark profile or a DISA STIG (the United States Defense Information Systems Agency's Security Technical Implementation Guide, the hardening standard used across US government systems), gets you a long way before you write a single control of your own.

Quick check
01What question does InSpec answer that a pre-deploy scanner like Checkov cannot?
Incorrect — That is precisely what a static scanner reads; InSpec never looks at your infrastructure code at all.
Incorrect — That is git blame. InSpec reports state, not authorship.
Correct — InSpec logs into the running system or queries the live cloud API and reports actual state, including drift no file ever recorded.
Incorrect — Cost estimation is a separate class of tool entirely, and neither InSpec nor Checkov does it.
02You waive a control you cannot fix yet with run: false and expiration_date: 2026-09-30. What happens on 1 October 2026?
Incorrect — No. InSpec enforces that date itself, which is the whole reason to write one.
Correct — An expired waiver is ignored, so the exception self-destructs and the risk comes back into view without anyone remembering to look.
Incorrect — InSpec never blocks a run over an expired waiver; only the waiver itself stops applying.
Incorrect — Nothing edits your profile. Waivers live in a separate file and only change how a control is run and reported.
03Your pipeline fails a build only on exit code 100. Tonight's scan of a new host prints ↺ ssh-01 ... Can't find file "/etc/ssh/sshd_config" with 0 control failures, 1 control skipped, and the build goes green. What is the right move?
Incorrect — Dangerous guess. A skip often means the resource could not read the file, commonly because the audit user lacks permission, while sshd runs happily with root login enabled.
Incorrect — A waiver documents an accepted risk with an expiry date. Here it would paper over a check that never ran and hide a real gap behind paperwork.
Incorrect — That flag really does make skips exit 0, which deletes the only signal you had. It hides the problem instead of answering it.
Correct — A skipped control proved nothing, so your automation should be as unhappy about 101 as it is about 100.

Try this

Run inspec init profile --platform os ssh-baseline on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a skipped control is not a passing control. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related