Resources & matchers
The building blocks of controls.
A home inspector walks a house with a clipboard. Every line names one thing to look at (the water heater, the breaker panel, the roof flashing) and next to it sits a verdict: present, correct rating, up to code. InSpec is built exactly that way. A resource is the noun, the slice of the system you want to examine: a file, a package, a listening port, a user account. A matcher is the verdict, the rule you use to judge what came back. The control language from the last lesson wraps the pair in an identifier and some metadata, but resources and matchers do the actual work. Get fluent with these two and you can express almost any compliance check without dropping to a shell command.
Getting them wrong is worse than writing nothing at all. A mismatched matcher produces a control that passes on every machine you own, including the wide-open ones, and a green dashboard is more dangerous than a blank one because nobody re-checks a control that says it is fine. Nearly every broken check I have seen comes from one of two mistakes: picking a matcher whose type does not line up with what the resource hands back, or reaching for a raw shell command when a purpose-built resource already knows the answer. Both take about thirty seconds to avoid, once you know where to look.
The nouns: resources
A resource is a small Ruby class that wraps one slice of a system so you never have to care how that slice gets queried underneath. It works like a universal travel adapter: same socket on your side, whatever the wall looks like. file('/etc/ssh/sshd_config') reads identically whether the target runs Ubuntu or Red Hat. package('openssh-server') hides whether the box answers with apt, yum, or zypper. Core InSpec ships well over a hundred of these, covering operating systems, config file formats, and common services: file, user, group, service, port, sshd_config, kernel_parameter, json, ini, mysql_session. Resource packs (inspec-aws, inspec-azure, inspec-gcp) are separate bundles you pull in as profile dependencies, and they add hundreds more for cloud objects such as aws_iam_user, azure_virtual_machine, and google_compute_instance.
Before you write a single assertion, find out what the system actually hands back. inspec shell is a REPL (read-eval-print loop: an interactive prompt where you type one line and see the answer immediately) wired to the same resources your controls use. Type help resources there and it lists everything available on the current target. This one habit is the difference between people who write working controls and people who write controls that quietly always pass.
# Probe the target interactively before committing to an assertion.inspec shell
Welcome to the interactive InSpec ShellTo find out how to use it, type: helpYou are currently running on:Name: ubuntuFamilies: debian, linux, unix, osRelease: 22.04Arch: x86_64inspec> file('/etc/ssh/sshd_config').mode=> 384inspec> file('/etc/ssh/sshd_config').mode.class=> Integerinspec> file('/etc/ssh/sshd_config').mode.to_s(8)=> "600"inspec> file('/etc/ssh/sshd_config').owner=> "root"inspec> package('openssh-server').installed?=> trueinspec> port(22).protocols=> ["tcp"]inspec> os.family=> "debian"
384 is not a typo. It is 0600 written in decimal, because mode hands back a Ruby Integer, not the string you type into chmod. Octal (base 8, the numbering chmod uses, where each digit packs read, write, and execute for one class of user) is a display convention, and the resource dropped it on the way out. A file at 0644 comes back as 420. Hold that thought for two minutes. Two other details from that session are worth pocketing: protocols returned a list rather than a single value, and os.family returned debian, not ubuntu. Those are the kinds of things you want to learn at a prompt, not from a failing pipeline at one in the morning.
Inside a describe block there are two shapes. it { should ... } asks a yes-or-no question about the resource as a whole. its('property') { should ... } pulls one named value out of the resource first, then judges that value. So it { should exist } calls the file's exist? method and expects true, while its('owner') { should eq 'root' } calls owner, gets a string back, and compares it.
The verbs: matchers
If a resource is the noun, a matcher is the verdict written beside it. InSpec ships a short list of universal matchers, meaning they work against any property of any resource. eq is Ruby's ==: exact, and strict about types. cmp is its forgiving cousin. match takes a regular expression (a compact pattern language for text) and asks whether the value fits the pattern. include asks whether a list contains an item. be handles plain comparisons like should be > 3. That is the global set, and it is deliberately tiny. InSpec is built on RSpec, a Ruby testing library, so RSpec's own matchers ride along underneath, which is where be_empty, be_nil, and be_in come from.
Every other matcher you meet is generated on the fly from the resource's own methods, which is why you will not find be_installed in any list. The rule is mechanical. Any matcher spelled be_<something> strips the prefix and calls the Ruby method <something>?. be_installed calls installed?. be_running calls running?. be_listening calls listening?. Any matcher spelled have_<something> calls has_<something>?, and a bare should exist calls exist?. Invent one the resource does not implement, like should be_purple, and the failure tells you the object does not respond to purple?. Put should_not in front of any matcher to invert it.
cmp is the one to memorise, because it exists specifically to soak up the type mismatches that wreck config checks. It compares a string to a number without complaint, so '2' and 2 are the same to it. It ignores case, so 'RAW' matches 'raw'. It unwraps a single-element array to compare against a plain value, so ['root'] equals 'root'. It reads octal file modes, so cmp '0600' matches whether the resource returned 384, '0600', or '600'. And it orders version strings as versions rather than as text, which matters because a plain text sort puts 1.9.0 after 1.14.0 and would wave an outdated package straight through.
That last one has a sharp edge on it. cmp only gets version-aware behaviour when both sides parse cleanly as versions, using Ruby's Gem::Version. Package strings from a Linux distribution frequently do not. Look at what Ubuntu hands back for OpenSSH.
inspec shell
inspec> package('openssh-server').version=> "1:8.9p1-3ubuntu0.13"inspec> package('openssh-server').version >= '8.9'=> false
The leading 1: is a Debian epoch, a manual override maintainers bolt on when upstream version numbers go backwards, and the trailing -3ubuntu0.13 is the packaging revision. Neither parses as a version number. When cmp cannot read both sides as versions it quietly falls back to comparing them as ordinary text, and text says 1 sorts before 8. So its('version') { should cmp >= '8.9' } reports a failure on a box running exactly the version you asked for, and you burn an afternoon on it. Assert it { should be_installed }, then check the setting you actually care about somewhere else. Save version range comparisons for things that publish clean semantic version numbers, such as an application's own --version output.
control 'ssh-01' doimpact 1.0title 'SSH daemon config is root-owned and not world-readable'desc 'CIS 5.2.1: /etc/ssh/sshd_config must be mode 0600, owner root.'describe file('/etc/ssh/sshd_config') doit { should exist } # calls exist?its('owner') { should eq 'root' } # string vs string, safeits('mode') { should eq '0600' } # <-- wrong on purposeenddescribe package('openssh-server') doit { should be_installed } # calls installed?enddescribe port(22) doit { should be_listening } # calls listening?its('protocols') { should include 'tcp' }endend
# The file really is 0600 root:root. Watch it fail anyway.inspec exec controls/ssh.rb
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)Version: (not specified)Target: local://Target ID: 9f2c1d4e-6b0a-5c31-8e77-2b41d0a9c5f2× ssh-01: SSH daemon config is root-owned and not world-readable (1 failed)✔ File /etc/ssh/sshd_config is expected to exist✔ File /etc/ssh/sshd_config owner is expected to eq "root"× File /etc/ssh/sshd_config mode is expected to eq "0600"expected: "0600"got: 384(compared using ==)✔ System Package openssh-server is expected to be installed✔ Port 22 is expected to be listening✔ Port 22 protocols is expected to include "tcp"Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 5 successful, 1 failure, 0 skipped
its('mode') { should eq '0600' } fails on a perfectly hardened file, because mode returns the Integer 384 and eq is Ruby's == with no type coercion. The tell is a failure with a quoted string on one side and a bare number on the other: expected: "0600" sitting above got: 384. Use cmp for permissions, versions, and any value whose type you have not personally confirmed in inspec shell. A cmp failure on a mode prints the value it found back in octal (got: 0644) and signs off with (compared using cmp matcher), so the message itself tells you which matcher ran. The inverse is the dangerous one: its('mode') { should_not eq '0644' } passes on a world-readable file for exactly the same reason. That is a control that can never fire, sitting green in your baseline forever.# Swap the type-strict matcher for the forgiving one and rerun.sed -i "s/should eq '0600'/should cmp '0600'/" controls/ssh.rbinspec exec controls/ssh.rbecho "exit code: $?"
Profile: tests from controls/ssh.rb (tests from controls/ssh.rb)Version: (not specified)Target: local://Target ID: 9f2c1d4e-6b0a-5c31-8e77-2b41d0a9c5f2✔ ssh-01: SSH daemon config is root-owned and not world-readable✔ File /etc/ssh/sshd_config is expected to exist✔ File /etc/ssh/sshd_config owner is expected to eq "root"✔ File /etc/ssh/sshd_config mode is expected to cmp == "0600"✔ System Package openssh-server is expected to be installed✔ Port 22 is expected to be listening✔ Port 22 protocols is expected to include "tcp"Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 6 successful, 0 failures, 0 skippedexit code: 0
Plural resources and filters
Some resources describe one thing. Others describe a whole table of things and hand you a filter to point at the rows you care about, the way a spreadsheet gives you a filter row sitting above the columns. passwd is the table of local accounts. port is the table of listening sockets. users, processes, and etc_group behave the same way. The pattern never changes: filter first, then assert on whatever survived the filter. The assertion that matters most is usually count.
This is where InSpec starts catching things a grep walks straight past. A classic persistence trick is adding a second account with UID (user ID, the number the kernel actually checks when deciding what you are allowed to do) 0, which is root wearing a different name. It survives password rotations. It appears in no sudoers file. A check that root exists will never notice it. Asserting that exactly one account holds UID 0 does notice it.
control 'accounts-01' doimpact 1.0title 'root must be the only UID 0 account'desc 'A second UID 0 account is root access under a name nobody audits.'describe passwd.uids(0) doits('users') { should cmp 'root' } # cmp unwraps ['root'] to 'root'its('count') { should eq 1 }endendcontrol 'net-01' doimpact 0.7title 'Only SSH and HTTPS may listen on privileged TCP ports'desc 'Anything else below port 1024 is a mistake or somebody else.'# A local variable, not a constant: Ruby rejects constant assignment# inside a block with "dynamic constant assignment".sanctioned = [22, 443]describe port.where { protocol =~ /tcp/ && port < 1024 && !sanctioned.include?(port) } doits('ports') { should be_empty }endend
# --controls narrows the run to one control while you are still iterating.inspec exec controls/accounts.rb -t ssh://ops@web1 --sudo --controls accounts-01echo "exit code: $?"
Profile: tests from controls/accounts.rb (tests from controls/accounts.rb)Version: (not specified)Target: ssh://ops@web1:22Target ID: 2c0b7a55-9f31-5d4e-b6a2-77c0e1d38b40× accounts-01: root must be the only UID 0 account (2 failed)× Passwd uids == 0 users is expected to cmp == "root"expected: "root"got: ["root", "svc-metrics"](compared using `cmp` matcher)× Passwd uids == 0 count is expected to eq 1expected: 1got: 2(compared using ==)Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 0 successful, 2 failures, 0 skippedexit code: 100
svc-metrics with UID 0 is full root access with a boring name painted on it. The count assertion is what caught it, and that is the general shape of a good filter control: narrow down to the rows that must not exist, then assert the count is zero, or exactly the number you have sanctioned in writing. The exit code carries the same verdict for machines. 0 means everything passed. 100 means at least one test failed. 101 means something was skipped and nothing failed. Those three numbers are what a CI (continuous integration, the automated build-and-test pipeline that runs on every change) job actually gates on.
One control, several platforms
Because resources hide the operating system, one control often runs everywhere unchanged. When it genuinely cannot, mark it not applicable rather than letting it fail, the same way an inspector writes N/A (not applicable) on the fireplace line in a house with no chimney. The os resource gives you os.family (debian, redhat, windows), os.name, os.release, and shorthand predicates such as os.linux? and os.windows?. only_if takes a block; when that block is false the control is skipped rather than failed, and no resource inside it is ever queried. Pass a message string and the report tells you why it skipped. Pass impact: 0 and InSpec rewrites the control's impact to zero, which records "does not apply to this host" instead of "we did not check".
For the opposite situation, when several different configurations are all acceptable, describe.one passes if any one of its nested describe blocks passes in full. A bouncer on a door will take a passport, a driving licence, or a national ID card, and any one of the three gets you in. Every nested block still runs, so the report shows you all the detail. Only the verdict is combined.
control 'audit-01' doimpact 0.7title 'auditd must be enabled and running'only_if('auditd is a Linux-only concern', impact: 0) { os.linux? }describe service('auditd') doit { should be_enabled }it { should be_running }endendcontrol 'fw-01' doimpact 1.0title 'A host firewall must be active'# Any one of these three is an acceptable answer.describe.one dodescribe service('firewalld') doit { should be_running }enddescribe service('nftables') doit { should be_running }enddescribe service('ufw') doit { should be_running }endendend
# Run the Linux-gated control against a Windows target.# WINRM_PASSWORD comes from your secret store, not from your shell history.inspec exec controls/platform.rb \-t winrm://Administrator@win-dc01 --password "$WINRM_PASSWORD" \--controls audit-01echo "exit code: $?"
Profile: tests from controls/platform.rb (tests from controls/platform.rb)Version: (not specified)Target: winrm://Administrator@win-dc01:5985Target ID: 6d3f81ba-42c7-5a19-9e4d-1b8a0c77f3e5↺ audit-01: auditd must be enabled and running↺ Skipped control due to only_if condition: auditd is a Linux-only concernProfile Summary: 0 successful controls, 0 control failures, 1 control skippedTest Summary: 0 successful, 0 failures, 1 skippedexit code: 101
Exit 101 is the number that trips people up. A pipeline written as "non-zero means broken" turns red on a run where nothing is wrong, so teams reach for --no-distinct-exit, which collapses skips back to 0. Do that with your eyes open. A skip is not a pass, and a profile reporting mostly skips against a target is checking almost nothing while looking perfectly calm. Read the skip lines the way you read the failures. An only_if skip is expected and fine. A control that skipped because a resource was unreachable or a path was mistyped is a hole in your coverage wearing a friendly colour.
Pick the resource that answers the question you meant
Two resources can look like they answer the same question and quietly disagree. sshd_config parses the file at /etc/ssh/sshd_config, and that is all it does. On Ubuntu 22.04, RHEL 9, and most cloud images, that file now ends with an Include /etc/ssh/sshd_config.d/*.conf line, and the settings that actually govern the daemon can live in those drop-in files. InSpec's sshd_config resource does not follow the include. Asking the running daemon what it is doing is a different question with a different answer, and the gap between the two is a real place for a sloppy image build, or somebody with a shell, to hide.
# --sudo matters here: sshd -T reads the host keys, so it needs root.inspec shell -t ssh://ops@web1 --sudo
inspec> sshd_config.params['PasswordAuthentication']=> nilinspec> command('sshd -T').stdout.lines.grep(/passwordauthentication/).first=> "passwordauthentication yes\n"inspec> file('/etc/ssh/sshd_config.d/50-cloud-init.conf').content=> "PasswordAuthentication yes\n"
The main file says nothing about PasswordAuthentication, so sshd_config returns nil, while the daemon is cheerfully accepting passwords because a drop-in file switched it back on. Notice that sshd -T prints directive names in lower case, which by itself catches people who write case-sensitive patterns. Now the direction of your assertion decides which flavour of wrong you get. its('PasswordAuthentication') { should cmp 'no' } against nil fails, so you chase a false alarm on a host that might be fine. should_not cmp 'yes' against nil passes, so you get a false all-clear on a host that is not. Either way you were reading the wrong file.
There is also an sshd_active_config resource, which works out which configuration file the running daemon was actually started with and helps on hosts using a non-default path. Check whether the build on your fleet has it before you write controls that depend on it, because InSpec 6 moved to a Progress commercial licence and plenty of teams stayed on 5.x or switched to CINC Auditor, the community distribution of the same open-source code, where every command in this lesson works unchanged as cinc-auditor exec.
The same instinct applies further down the stack. When no resource fits, command is the escape hatch: run anything, then assert on stdout, stderr, or exit_status. It is honest, and sometimes it is the only option you have. It also bakes one platform's syntax and one release's output wording into your control, so a distribution upgrade that changes a word from enabled to active breaks a check that has nothing to do with security. Look for the purpose-built resource first. Kernel tunables (sysctl settings, the runtime knobs that change how the Linux kernel behaves) do not need a shell at all.
# Preferred: portable, readable, and it parses the value for you.# Note the current resource name is kernel_parameter; the old# linux_kernel_parameter name is deprecated.describe kernel_parameter('kernel.randomize_va_space') doits('value') { should eq 2 }enddescribe kernel_parameter('net.ipv4.conf.all.forwarding') doits('value') { should eq 0 }end# Escape hatch: only when nothing else answers the question.describe command('sysctl -n kernel.randomize_va_space') doits('exit_status') { should eq 0 }its('stdout') { should match /^2$/ }its('stderr') { should be_empty }end
inspec exec loads Ruby and runs it on your workstation, and the command resource runs shell on the target as whichever user you connected with, which becomes root the moment you add --sudo. Running a profile you found on the internet means running a stranger's code against production with elevated rights. Read the control files before the first run, pin third-party profiles to a tag or a commit rather than a branch, and vendor them into your own repository so an upstream force-push cannot change what executes tonight. inspec check ./my-profile will confirm the profile loads and that its metadata is sane. It will not tell you the profile is safe.Prove the control can fail
A control that has never gone red is a control you have no reason to trust. A smoke alarm you have never tested is a plastic disc on the ceiling. Before you commit a control, break the thing it watches on a scratch host and confirm the failure looks the way you expect. Two runs, five minutes, and you find out whether you wrote a check or a decoration.
# 1. Green on a host you believe is compliant.inspec exec controls/ssh.rb -t ssh://ops@web1 --sudoecho "compliant host: $?"# 2. Break it on a scratch box and confirm it goes red.ssh ops@lab1 'sudo chmod 0644 /etc/ssh/sshd_config'inspec exec controls/ssh.rb -t ssh://ops@lab1 --sudoecho "broken host: $?"
Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 6 successful, 0 failures, 0 skippedcompliant host: 0× ssh-01: SSH daemon config is root-owned and not world-readable (1 failed)✔ File /etc/ssh/sshd_config is expected to exist✔ File /etc/ssh/sshd_config owner is expected to eq "root"× File /etc/ssh/sshd_config mode is expected to cmp == "0600"expected: "0600"got: 0644(compared using `cmp` matcher)Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 5 successful, 1 failure, 0 skippedbroken host: 100
That expected: "0600" and got: 0644 pair is what you are buying. It names the file, the property, the value your policy demands, and the value the machine actually holds, printed back in the same notation you would type into chmod. An auditor can read it. An on-call engineer can act on it. A pipeline can gate on the 100 sitting beside it. If both runs come back green, you have not proven the host is compliant. You have proven the control cannot tell the difference, and pointing it at something you already know is broken is the only way to find that out.
describe port(22) do it { should be_listening } end. What does InSpec do with be_listening?be, cmp, eq, include, and match, and be_listening is not one of them.be_installed, be_running, and every other predicate matcher.be_<x> calls <x>? on the resource, and have_<x> calls has_<x>?.only_if('Linux only') { os.linux? } runs against a Windows host. Nothing else fails. What does inspec exec return, and why does it matter?--no-distinct-exit exists as a deliberate choice.only_if block returning false is normal behaviour, not an error.its('mode') { should_not eq '0644' } on a private key file. It has been green across 300 hosts for a year. You run ls -l on one of them and see -rw-r--r--. What is happening?should_not eq is perfectly valid and does run; it runs and passes, which is the whole problem.Try this
Run inspec shell 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: eq on a file mode fails even when the mode is correct. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.