What InSpec is: compliance as code
Executable, human-readable controls.
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.
control 'ssh-01' doimpact 1.0title '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 doits('PermitRootLogin') { should cmp 'no' }endend
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.
depends:- name: inspec-awsgit: https://github.com/inspec/inspec-aws.gittag: 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.
$ inspec exec controls/ssh.rb \-t ssh://[email protected] \--key-files ~/.ssh/audit_ed25519
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)Version: (not specified)Target: ssh://[email protected]:22Target 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 skippedTest 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.
$ echo $?
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.
control 'os-14' doimpact 0.9title 'SELinux must be installed and enforcing'desc 'Permissive SELinux confines nothing. It only writes down what it would have blocked.'describe selinux doit { should be_installed }it { should be_enforcing }endend
$ inspec exec controls/os.rb -t ssh://[email protected] \--key-files ~/.ssh/audit_ed25519
× os-14: SELinux must be installed and enforcing (1 failed)✔ SELinux is expected to be installed× SELinux is expected to be enforcingexpected `SELinux.enforcing?` to return true, got falseProfile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest 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.
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.
$ inspec init profile --platform os ssh-baseline
───────────────────────── 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.
name: ssh-baselinetitle: SSH server hardening baselinemaintainer: Platform Securitycopyright: ACME Ltdlicense: Apache-2.0summary: Verifies sshd matches the ACME SSH standard (derived from CIS)version: 0.3.0supports:- platform-family: debian- platform-family: redhatinputs:- name: ssh_porttype: numericvalue: 22description: Port sshd is expected to listen ondepends:- name: ssh-baseline-upstreamurl: 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.
$ inspec check ssh-baseline
Location: ssh-baselineProfile: ssh-baselineControls: 6Timestamp: 2026-07-22T10:09:51+00:00Valid: trueNo 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.
$ inspec exec controls/ssh.rb -t docker://a91f3c0d2b77
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 skippedTest Summary: 0 successful, 0 failures, 1 skipped
$ echo $?
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.
#!/usr/bin/env bashset -uo pipefail # deliberately no -e: we need to read InSpec's exit code ourselvesmkdir -p evidenceinspec exec ssh-baseline \-t "ssh://svc-audit@${HOST}" --key-files ~/.ssh/audit_ed25519 \--reporter cli json:"evidence/${HOST}.json"rc=$?case "$rc" in0) 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.
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.
$ 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
Profile: SSH server hardening baseline (ssh-baseline)Version: 0.3.0Target: ssh://[email protected]:22Target 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 skippedTest 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.
{"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.
ssh-02:run: falseexpiration_date: 2026-09-30justification: "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.
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.
$ export CHEF_LICENSE_KEY=free-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d$ inspec version
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.
run: false and expiration_date: 2026-09-30. What happens on 1 October 2026?↺ 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?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.