The control language
describe, it, expect, resources.
A restaurant inspector walks in with a clipboard. Every line on it names one thing to look at, says what good looks like, and gets a tick or a cross. The wording was agreed before the visit, so nobody argues about it afterwards. That clipboard is the right mental model for an InSpec control, with one difference. This clipboard runs itself.
Underneath, a control is Ruby. You will write almost none of it. InSpec gives you a small vocabulary called a DSL (a domain-specific language, meaning a mini-language built for exactly one job), and the part you actually need is five words long: control, describe, it, its, and should. Learn how those five fit together and you can write most of a hardening baseline (the set of settings you have decided every machine must have) before you understand how any single resource works on the inside. What you get out of it is one file that an auditor who has never written code can read, and that a machine which has never read your policy can execute.
The Shape Of One Check
Every assertion has the same shape. Name a thing, then say what must be true about it. The thing is called a resource, and a resource is like the specific tool the inspector reaches for: a thermometer for the fridge, a torch for the drain, a swab for the worktop. Each one already knows how to inspect one kind of object and nothing else. file understands permissions and ownership. package knows how to ask dpkg or rpm (the package managers used by the Debian and Red Hat families of Linux) whether something is installed. service, port, user, sshd_config, and the cloud families (aws_s3_bucket, azure_virtual_machine, google_compute_instance) each talk to their own world. You pick the tool. InSpec does the digging.
# A describe block on its own is a complete, runnable test file.describe file('/etc/ssh/sshd_config') doit { should exist }it { should be_owned_by 'root' }its('mode') { should cmp '0600' }its('group') { should eq 'root' }end
Read that out loud and it lands close to English. Describe the file /etc/ssh/sshd_config, which is where the SSH (Secure Shell, the encrypted remote-login service) server reads its settings. It should exist. Its mode, meaning the Unix permission bits, should compare equal to 0600. The four-line layout is deliberate. Each line becomes its own independent test result, which is exactly why you want four short assertions rather than one clever compound one. When the mode drifts to 0644 you get a single red line that names the mode. Roll all four into one condition and you get one red line that names nothing, and you are back to re-reading the block to work out which part broke.
$ inspec exec controls/sshd_file.rb
Profile: tests from controls/sshd_file.rb (tests from controls/sshd_file.rb)Version: (not specified)Target: local://Target ID: 8f3c1d02-6b4a-5f77-9a10-2c5e7b41d9aaFile /etc/ssh/sshd_config✔ is expected to exist✔ is expected to be owned by "root"✔ mode is expected to cmp == "0600"✔ group is expected to eq "root"Test Summary: 4 successful, 0 failures, 0 skipped
One piece of housekeeping before you run any of this yourself. InSpec 6 is commercial software: Progress owns it, and the binary wants a licence key before it will do anything, entered once on first run. There is a free tier with limits on how much you can scan, and paid tiers above that. CINC Auditor is the community's build of the open-source source line, given away for nothing. Same DSL, same resources, same flags, and the command is cinc-auditor instead of inspec. Every example here runs unchanged on either, so pick whichever your budget and your lawyers allow.
Wrapping Intent Around The Check
A bare describe block tests fine and tells an auditor nothing. It is a tick on a clipboard with no line of text beside it. Wrap it in a control and you attach the wording that makes the result useful six months later: a stable id, an impact score, a human title, a longer desc, and tag plus ref to link the requirement back to the standard it came from, a recommendation from a CIS (Center for Internet Security) benchmark or a NIST (National Institute of Standards and Technology) control id. Spell that ref out in full, benchmark version included, because CIS renumbers recommendations between revisions and a bare section number quietly starts pointing at a different rule. Then treat the id itself as an API (application programming interface, a contract other things depend on). Reports key on it. Waiver files, which are the signed exemption notes that let a known failure through the gate, key on it. So do overlays, where a profile is a folder of control files plus an inspec.yml that names and versions them, and an overlay is one profile that pulls in another and then skips or replaces individual controls by id. Renumber ids casually and you silently detach every waiver that pointed at the old name, which turns an accepted risk back into a failing gate at the worst possible moment.
control 'sshd-02' doimpact 0.9 # 0.9 to 1.0 reports as "critical"title 'Disable direct SSH root login'desc 'Operators log in as a named user and escalate with sudo (the command that grants one elevated action, logged against their name), so every privileged action keeps a name attached to it.'tag category: 'ssh', nist: ['AC-6(2)']ref 'CIS Ubuntu Linux 22.04 LTS Benchmark v1.0.0: Ensure SSH root login is disabled',url: 'https://www.cisecurity.org/benchmark/ubuntu_linux'describe sshd_config doits('PermitRootLogin') { should cmp 'no' }endend
impact is a float from 0.0 to 1.0, and InSpec maps bands of that scale onto the severity names reports and dashboards display. Below 0.01 is none, which is how you mark a control that only gathers information. From 0.01 up to 0.4 is low. From 0.4 up to 0.7 is medium. From 0.7 up to 0.9 is high. From 0.9 through 1.0 is critical. Each band starts at its own number and stops short of the next one. You can write the name instead of the number, impact 'critical', and InSpec turns it into the floor of that band, 0.9. Match the value to the severity in the source standard, because both human attention and CI (continuous integration, the automated pipeline that builds and tests every change) gates get sorted by it.
$ inspec exec controls/ssh.rb --reporter cli; echo "exit=$?"
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)Version: (not specified)Target: local://Target ID: 8f3c1d02-6b4a-5f77-9a10-2c5e7b41d9aa× sshd-02: Disable direct SSH root login (1 failed)× SSHD Configuration PermitRootLogin is expected to cmp == "no"expected: "no"got: "yes"(compared using `cmp` matcher)Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 0 successful, 1 failure, 0 skippedexit=100
The exit code is why InSpec drops cleanly into a pipeline. 0 means every control passed. 100 means at least one control failed. 101 means nothing failed but something was skipped, which is a different problem and deserves a different reaction. A plain 1 means InSpec itself could not run: a broken profile, an unreachable target, credentials that do not work. If you decide skips should not break a build, --no-distinct-exit gives you exit 0 on skips. Read the whole flag before you reach for it, though, because it also collapses failures from 100 down to 1. Now a genuine finding is indistinguishable from InSpec falling over on a typo, and you have traded one annoyance for a much worse one. A skip is coverage you did not get, sitting quietly in a column nobody sorts by.
it, its, And One Line Per Assertion
it and its look nearly identical and put completely different things under test. it { should be_running } hands the resource itself to the matcher, which is the bit that does the comparing, so the matchers available are the ones that resource defines: exist, be_installed, be_running, be_enabled, be_listening, be_owned_by. its('mode') calls the mode property, which is a reading the resource knows how to take, grabs whatever value comes back, and puts that value under test. That gives you the ordinary comparison matchers instead: eq, match, include, be > 0, cmp. Back to the inspector with the clipboard. it asks the tool a yes-or-no question. its asks the tool for a reading, then checks the reading.
A control is green only when every test inside it is green. That makes the control the unit of policy and the it or its line the unit of diagnosis. One control can hold several describe blocks against several resources, which is the right way to write down a requirement that has more than one moving part.
control 'web-01' doimpact 0.7title 'nginx serves on 443 and nothing answers on plaintext 80'describe package('nginx') doit { should be_installed }enddescribe service('nginx') doit { should be_enabled }it { should be_running }enddescribe port(443) doit { should be_listening }its('protocols') { should include 'tcp' }enddescribe port(80) doit { should_not be_listening }endend
$ inspec exec controls/nginx.rb -t ssh://ops@web1 --sudo
Profile: tests from controls/nginx.rb (tests from controls/nginx.rb)Version: (not specified)Target: ssh://ops@web1:22Target ID: 0c9a4f31-2d77-5b1e-8f60-af41c2b7e903× web-01: nginx serves on 443 and nothing answers on plaintext 80 (1 failed)✔ System Package nginx is expected to be installed✔ Service nginx is expected to be enabled✔ Service nginx is expected to be running✔ Port 443 is expected to be listening✔ Port 443 protocols is expected to include "tcp"× Port 80 is expected not to be listeningexpected `Port 80.listening?` to return false, got trueProfile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 5 successful, 1 failure, 0 skipped
Six tests, one control, one red line, and it names port 80 by itself. That is the whole argument for granular assertions. It is also the finding that matters most here. Something is answering on plaintext HTTP (HyperText Transfer Protocol carried with no encryption layer over it), so anyone who types the hostname without https gets served in the clear, and anyone sitting on the network path between them can read the session cookie or rewrite the response on its way back. The other five results being green is what tells you the service is healthy and the problem is a stray listener rather than a dead process.
should, expect, And The cmp Escape Hatch
InSpec inherits two ways of writing an assertion from RSpec, the Ruby testing library it is built on. The short one, it { should exist }, is what almost every published profile uses. The long one, it { is_expected.to eq '0' } or a full expect(subject).to eq '0', says the same thing with more words and buys you one capability the short form does not have. Set subject yourself and hand describe a plain string as a label, and the thing under test becomes any value you can compute: a number you parsed, a field you pulled out of a config file, the trimmed output of a command that no resource covers.
# Short grammar: use it whenever a resource already covers the thing.describe kernel_parameter('kernel.randomize_va_space') doits('value') { should eq 2 }end# Long grammar: a subject you compute, labelled with a plain string.control 'kernel-02' doimpact 0.4title 'Setuid programs do not write core dumps'desc 'A core dump is a snapshot of a crashed process memory, written to disk. Setuid programs run with more privilege than whoever started them, so that memory can hold secrets the user should never get to read.'describe 'fs.suid_dumpable' do# sysctl reads the kernel's live tuning knobs.subject { command('sysctl -n fs.suid_dumpable').stdout.strip }it { is_expected.to eq '0' }endend
$ inspec exec controls/kernel.rb
Profile: tests from controls/kernel.rb (tests from controls/kernel.rb)Version: (not specified)Target: local://Target ID: 8f3c1d02-6b4a-5f77-9a10-2c5e7b41d9aa✔ kernel-02: Setuid programs do not write core dumps✔ fs.suid_dumpable is expected to eq "0"Kernel Parameter kernel.randomize_va_space✔ value is expected to eq 2Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 2 successful, 0 failures, 0 skipped
Notice the ordering. InSpec prints every control with a real id first, then the bare describe blocks underneath, whatever order they sat in your file. Reach for a computed subject only when nothing else fits, too. It runs your command on the target exactly as written, so it inherits every problem a shell one-liner has. It breaks when the binary lives somewhere else, when the output format shifts by one version, when the platform is not the one you had in mind. A purpose-built resource carries that knowledge for you and fails with a message naming the property that drifted. The command form fails with a message about a string. Every time you write one, you are trading a check that works today against a check that keeps working next year.
cmp is the matcher that saves you from the dumbest class of false failure. Think of eq as a pedant who compares two things character by character and type by type, and cmp as a colleague who reads both and tells you whether they mean the same thing. Config files hand back strings. Your policy gets written in whatever type felt natural at the time. eq will tell you with a straight face that "0600" is not 384, because 0600 is octal (the base-8 notation Unix uses for permission bits) and 384 is the plain decimal number the file resource actually returns. cmp matches a string against a number, a mode written as '0600' against the integer the kernel stores, 'No' against 'no' whatever the case, and a single-element array against the bare value inside it. It understands version ordering as well, so its('version') { should cmp >= '1.2.3' } treats 1.10.0 as newer than 1.2.3 instead of sorting it as text and calling it older. Use cmp for anything read out of a config file. Keep eq for values whose type you already control.
Proving A Control Actually Ran
A control with full metadata and no describe block inside it is legal InSpec. It loads. It runs. It produces zero test results. It lands in the report as neither a pass nor a failure, nothing turns red, and the exit code stays 0. Anyone counting green ticks never sees the hole. The way it gets there is completely ordinary: somebody commented the describe out during an incident to unblock a deploy, and never put it back. The defence is to count results rather than colours. The JSON reporter (JavaScript Object Notation, the machine-readable version of the same report) hands you that count, and jq, a small command-line tool for pulling fields out of JSON, turns it into three lines you can eyeball.
control 'sshd-03' doimpact 0.7title 'Password authentication is disabled'# Commented out at 02:40 during the deploy freeze. Never restored.# describe sshd_config do# its('PasswordAuthentication') { should cmp 'no' }# endend
$ inspec exec ssh-baseline --reporter json \| jq -r '.profiles[0].controls[] | "\(.id) \(.results | length) result(s)"'
sshd-01 4 result(s)sshd-02 1 result(s)sshd-03 0 result(s)
sshd-01 is that four-line file check from the top of the lesson, now wrapped in a control. sshd-03 is the hole. Run this count before and after any edit to a profile and you know whether your change landed, because a control whose result count went from 1 to 0 is a control you disabled by accident. While you are still fiddling with the wording of a single check, inspec exec ssh-baseline --controls sshd-02 runs that one control and nothing else, which turns a thirty-second profile run into a one-second feedback loop. Pair it with inspec check, which parses the profile, validates the metadata in inspec.yml, and counts the controls it managed to find.
$ inspec check ssh-baseline
Location: ssh-baselineProfile: ssh-baselineControls: 3Timestamp: 2026-07-22T09:41:18+00:00Valid: trueNo errors or warnings
Valid: true is honest about what it checked and completely silent about what it did not. It confirms the Ruby parses, the profile structure is sane, and the required metadata is present. It counts three controls, which is true. It says nothing about sshd-03 having no tests left inside it, nothing about whether your assertions match the standard you copied them from, and nothing about whether the target you scanned was the one you meant to scan. Ruby that parses is not policy that is enforced, and the gap between those two is exactly where compliance theatre sets up shop. Count the results.
Try this
Run inspec exec controls/sshd_file.rb 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: impact never decides whether a check runs. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.