CoursesInSpecInstall & first scan

Install & first scan

inspec exec against a target.

Intermediate10 min · lesson 2 of 12

A building inspector turns up with a clipboard. Water heater strapped down? Sockets earthed? Handrail at the right height? They walk the property, tick or cross every line, and hand you a report saying exactly what is out of code. Chef InSpec is that inspector, except you can send it to a thousand buildings at once, it finishes in seconds, and the clipboard lives in Git where anyone on the team can read it and argue with it.

Getting started takes two moves. Put the inspector's toolkit on your machine, then send it out to walk a target. You will repeat that install-and-run loop against laptops, build agents, golden images, container hosts and cloud accounts for as long as you use this tool, so learning the mechanics properly the first time pays for itself. The checklist language (describe, it, should) belongs to the next lesson, so the controls here stay deliberately dull. What matters now is what actually gets installed, what the license gate does to a pipeline, what inspec exec performs under the hood, and how you tell a real green result from a meaningless one.

Three Ways To Install, One CLI

InSpec is a Ruby program, and that single fact explains why there are three install routes and why people get bitten. The route Chef recommends is omnitruck, Chef's install-script service. It looks at your platform, downloads the matching package, and drops a self-contained bundle under /opt/inspec that carries its own private copy of Ruby. Think of it as a food truck that brings its own kitchen: nothing it installs can collide with the Ruby your distribution ships or the one your application needs. The second route is RubyGems, Ruby's package system. The command gem install inspec-bin gives you the same inspec CLI (command-line interface, the program you type commands at) running on your Ruby. Ask for inspec-bin, not inspec, because the plain inspec gem is the library with no command attached. That five-minute confusion catches nearly everyone once. Install it into a user-level or version-manager gemset (rbenv, rvm, or --user-install) rather than under sudo, so a routine gem update on the system Ruby cannot quietly break your scanner. The third route is Chef Workstation, which bundles InSpec with the rest of Chef's tooling. If you already run Chef Infra, inspec may already be on your PATH.

Pin the version while you are there. Omnitruck takes -v, and a runner that grabs whatever is newest today is a runner that will one day pick up a release demanding a license key and stop dead. There is a fourth option worth knowing about. CINC Auditor is a community rebuild of the same source with Chef's trademarks and license gates stripped out: same DSL (domain-specific language, the small purpose-built language you write controls in), same resources, same reports, binary named cinc-auditor instead of inspec. Every command in this lesson works if you swap the name.

Look hard at the first line below. It is a script downloaded over the network and piped straight into a root shell. That is Chef's documented install path and most teams run it as written, but on a machine you care about, fetch the script, read it, and keep your own copy, the same way you would with any other installer you let run as root. Whichever route you pick, confirm the CLI answers before going further. A half-finished gem install is the most common reason a first scan dies with a Ruby LoadError instead of producing a result, and that error looks nothing like an install problem.

terminal
# Chef's omnitruck installer, pinned to a known version (Linux/macOS)
curl -fsSL https://omnitruck.chef.io/install.sh | sudo bash -s -- -P inspec -v 5.22.80
# Or via RubyGems if you manage Ruby yourself. inspec-bin ships the command;
# the plain `inspec` gem is only the library.
gem install inspec-bin -v 5.22.80 --user-install
# macOS: the whole Chef toolkit, inspec included
brew install --cask chef-workstation
# Or the community rebuild: same behavior, no license gate, different name
curl -fsSL https://omnitruck.cinc.sh/install.sh | sudo bash -s -- -P cinc-auditor
inspec version
output
5.22.80

The License Gate That Stalls Your First Pipeline

InSpec's source has always been Apache 2.0, but from version 4 onward the packages Chef ships stop and ask you to accept their EULA (end user license agreement, the terms you agree to before you are allowed to run the software) the very first time you run any inspec command. On your laptop you press Enter once and forget it ever happened. Inside a container build or a CI (continuous integration) runner there is no TTY, short for teletype, the interactive terminal a program expects when it wants to ask a human a question. So nothing answers. The job sits there burning minutes until a timeout kills it. The log shows a banner and then silence, which sends people hunting for network faults and profile bugs that were never there.

The fix is one environment variable with three useful values. CHEF_LICENSE=accept records your acceptance on disk and prints a short note. The value accept-silent does the same without the note. The value accept-no-persist accepts for that single run and writes nothing, which is what you want in a read-only or throwaway container where a marker file has no future. The same values work as a flag, --chef-license accept-silent, when you would rather be explicit at the call site. Where the marker lands matters more than it looks. Root writes to /etc/chef/accepted_licenses/, and every other user writes to ~/.chef/accepted_licenses/. Accept it as yourself and root has still accepted nothing, which is why a scan that runs fine for you can hang the moment you put sudo in front of it. If a run ever exits 172, that is the dedicated exit code for a license that was never accepted, and it is a far friendlier failure than a hang.

InSpec 6 raised the stakes. Progress moved the product to commercial licensing, so agreeing to terms is no longer enough. The CLI wants a real license key (free, trial, or paid) supplied through CHEF_LICENSE_KEY or --chef-license-key. You can watch the change happen in the packaging itself: the 5.x gem still declares Apache-2.0, while the current release declares the Chef EULA in its place. The free tier carries a cap on how many targets you may scan and an expiry date, so check today's terms before you build a fleet-wide scanner on top of it. That shift is exactly why CINC Auditor exists, and why plenty of teams either pin to InSpec 5 or move across.

Dockerfile
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL https://omnitruck.chef.io/install.sh | bash -s -- -P inspec -v 5.22.80 \
&& rm -rf /var/lib/apt/lists/*
# Accept the license non-interactively or every run in this image hangs.
ENV CHEF_LICENSE=accept-silent
# On InSpec 6 and later, acceptance is not enough; you also need a key:
# ENV CHEF_LICENSE_KEY=<your-license-key>
ENTRYPOINT ["inspec"]
A silent hang is a license prompt until proven otherwise
The first InSpec command on a fresh machine blocks on an interactive license acceptance, and a build container has nobody to answer it. The job stalls with no error, no output and no clue, until the runner's timeout kills it. Set CHEF_LICENSE=accept-silent (or accept-no-persist in throwaway containers) in the Dockerfile and in the CI environment. Setting it in your personal shell profile fixes your laptop and nothing else, so the stall returns the moment someone runs the scan on a new agent, and it returns again the first time you run under sudo, because root looks for its own marker in /etc/chef. On InSpec 6 and later, acceptance alone will not start the run: with no key in CHEF_LICENSE_KEY it refuses outright.

Ask The Target What It Is

Before the inspector starts ticking boxes, they work out what sort of building they are standing in. Fire door rules for an office block are nonsense in a bungalow. The inspec detect command does that check: it fingerprints the target and prints the operating system name, the families it belongs to, the release, and the CPU architecture. Those family names are not decoration. They are the exact values your controls branch on later, which is why os.debian? is true on Ubuntu (debian appears in the family list) and a control written for redhat will quietly skip on this box.

Detect is also the cheapest smoke test you own. With no flags it exercises the install and the local transport in one go. Point it at a remote box with -t ssh://user@host (SSH, the secure shell protocol you already use to log into servers) and it proves your credentials, network path and host key are all fine before you start blaming a profile for what was really a firewall rule.

terminal
inspec detect
output
== Platform Details
Name: ubuntu
Families: debian, linux, unix, os
Release: 24.04
Arch: x86_64

Your First Scan

A profile is a folder of controls plus a little metadata, but InSpec will happily accept a single Ruby file, which is ideal for proving the plumbing works. Write two checks that should pass on any ordinary Linux server, run them, and read the header carefully. If the port check goes red because nothing is listening on 22, that is a genuine finding rather than a broken setup, and it makes a decent first look at what a failure prints.

smoke.rb
# Two boring checks. The syntax is the next lesson's job;
# right now you only want green text and an exit code of 0.
describe file('/etc') do
it { should be_directory }
end
describe port(22) do
it { should be_listening }
end
terminal
inspec exec smoke.rb
echo "exit=$?"
output
Profile: tests from smoke.rb (tests from.smoke.rb)
Version: (not specified)
Target: local://
Target ID: 8f4a0e2c-3b17-5d90-a4e1-6c0b9f2d7a15
File /etc
✔ is expected to be directory
Port 22
✔ is expected to be listening
Test Summary: 2 successful, 0 failures, 0 skipped
exit=0

Read that header line by line, because it is the same header you will be reading in anger later. Profile names what ran, and a single file gets an auto-generated name rather than one you chose. Target: local:// is the line that matters most. With no -t flag InSpec uses the local transport, meaning it shells out on this machine instead of opening a network connection. Target ID is a stable identifier for the scanned machine, derived on Linux from the host's machine-id, and it is what lets a reporting system stitch repeated scans of one host into a history rather than a pile of unrelated runs. There is a trap hiding in that. Clone a golden image without regenerating /etc/machine-id and every VM built from it reports the same Target ID, so a hundred servers quietly collapse into one row on your dashboard.

Exit Codes Are What CI Reads

Your pipeline never reads the pretty output. It reads one number, the way a vending machine reads a coin rather than your opinion of the coin. InSpec is deliberate about that number. 0 means everything that ran passed. 100 means at least one test failed. 101 means tests were skipped and nothing failed. Failures outrank skips, so a run with both gives you 100. Two more are worth committing to memory: 1 is a usage or general error, a bad flag or a profile that will not load being the classic cases, and 172 is the license refusal from earlier.

The gap between 100 and 101 is what catches people out, because a skips-only run is still non-zero, and inspec exec ./baseline && ./deploy.sh will stop dead on it. If you would rather skips did not block the pipeline, --no-distinct-exit collapses the scheme so skips exit 0 and failures exit 1. Reach for that consciously rather than by reflex, because turning skips into silence is how a control that never ran gets mistaken for a control that passed. One warning about that flag: its own help text has long described the numbers the other way round from the exit code table in the same documentation. Trust the table, and when you are unsure, break a control on purpose and read echo $? for yourself. When you want to accept a specific known failure, the honest tool is a waiver file (--waiver-file), which records what you excused and why. Watch the run key inside it, because that key decides what happens. Set run: false and the control never executes and is reported as skipped, so waiving your way out of a 100 can land you on a 101. Leave run out and nothing is skipped at all. Absent, true and yes all mean the same thing: the control still runs and still reports, because InSpec applies a skip only when the key is present and false. Writing a waiver and forgetting that key is how people end up excusing nothing.

What inspec exec actually does
1fetch the profile
local path, .rb file, URL, git, supermarket://
2resolve dependencies
reads inspec.yml, honours inspec.lock
3open a transport
local:// by default, ssh:// or winrm:// with -t
4run every control
each resource queries the live target
5report and exit
cli, json, junit2, html2, then 0 / 100 / 101
The same five steps run whether the target is your laptop now or a hardened image in CI later.

Run A Real Baseline

One trivial file proves the plumbing. A published profile proves the point. The DevSec Hardening Framework maintains open baselines, and inspec exec accepts a URL directly: it fetches the profile (a tarball for a GitHub URL, a clone for a git remote), resolves whatever that profile depends on, and runs the lot in one shot. Dozens of real controls against the machine you are sitting on, in seconds, without you writing a line. Chef Supermarket, the public registry of shared profiles, carries the same content behind a supermarket:// slug if you prefer that route.

terminal
# Fetch and run a published baseline against this machine
inspec exec https://github.com/dev-sec/linux-baseline
echo "exit=$?"
# The identical profile from Chef Supermarket:
# inspec exec supermarket://dev-sec/linux-baseline
output
Profile: DevSec Linux Security Baseline (linux-baseline)
Version: 2.10.0
Target: local://
Target ID: 8f4a0e2c-3b17-5d90-a4e1-6c0b9f2d7a15
✔ os-01: Trusted hosts login
✔ File /etc/hosts.equiv is expected not to exist
× os-05: Check login.defs (3 failed)
✔ File /etc/login.defs is expected to exist
✔ File /etc/login.defs is expected to be owned by "root"
× login.defs UMASK is expected to include "027"
expected "022" to include "027"
× login.defs PASS_MAX_DAYS is expected to eq "60"
expected: "60"
got: "99999"
(compared using ==)
× login.defs PASS_MIN_DAYS is expected to eq "7"
expected: "7"
got: "0"
(compared using ==)
... 57 more controls ...
Profile Summary: 34 successful controls, 16 control failures, 9 controls skipped
Test Summary: 168 successful, 27 failures, 9 skipped
exit=100

That is what an honest first look at a stock server produces, and the failures are usually boring and true. Ubuntu ships UMASK 022 and PASS_MAX_DAYS 99999 in /etc/login.defs, while the baseline wants 027 and 60. UMASK is the mask that decides default permissions on newly created files, so 027 keeps other users out where 022 lets them read. Nothing here is broken. You are looking at the distance between a vendor default and a hardening standard, and that distance is exactly the work your remediation has to cover. Learn the symbols now: ✔ passed, × failed, ↺ skipped. The run exited 100 because controls failed, and it would still have exited 100 with those nine skips in the mix, because failures win.

A Profile From The Internet Runs With Your Privileges

Here is the part most quickstarts skip. Running inspec exec against a GitHub URL downloads Ruby code and executes it on your machine. A control is executable code. It is not a passive list of settings that something else reads for you. Controls call resources such as command('...'), which runs that command on the target and reads the result back. Pointing InSpec at a URL is therefore code execution by design, working exactly as intended, and the only thing standing between that repository and your host is whoever holds push access to it. Do it under sudo on a production box and that code runs as root.

So treat a public profile like any other dependency. Pin the fetch to a released tag or an archive URL rather than to HEAD, whatever the newest commit happens to be at the moment your pipeline runs, so a careless or hostile commit cannot reach you on the next run. Read the controls once before you trust them on anything that matters. Better still, keep a reviewed copy inside your own Git. You can lint that copy with inspec check, which validates the profile and its metadata against a mock backend rather than a real machine, so no command touches a live system. Be clear about what that does and does not buy you: check still loads and evaluates the Ruby in order to find the controls, so it is a lint, not a sandbox. Reading the code is the step that actually protects you.

terminal
# Pin the fetch to a released tag rather than to HEAD
inspec exec https://github.com/dev-sec/linux-baseline/archive/refs/tags/2.10.0.tar.gz
# Or keep a copy you have actually read, and lint it before you trust it
git clone --branch 2.10.0 --depth 1 https://github.com/dev-sec/linux-baseline.git
inspec check linux-baseline
output
Location : linux-baseline
Profile : linux-baseline
Controls : 59
Timestamp : 2026-07-22T09:41:22+00:00
Valid : true
No errors or warnings

For anything long-lived, record the pin in metadata rather than in shell history. A profile's inspec.yml (YAML, the indented text format used for configuration files) declares what it depends on, and inspec vendor resolves those dependencies into a local vendor/ directory while writing inspec.lock, which captures the exact source that was resolved. Commit the lock file. It works like the docket on a batch of concrete: every runner pours the same mix, and bumping the baseline becomes a reviewable diff instead of a surprise on a Tuesday morning.

inspec.yml
name: acme-linux-baseline
title: ACME Linux Baseline
maintainer: platform-security
license: Apache-2.0
version: 1.0.0
supports:
- platform-family: debian
depends:
- name: linux-baseline
git: https://github.com/dev-sec/linux-baseline.git
tag: "2.10.0"
terminal
inspec vendor --overwrite
output
Dependencies for profile /home/you/acme-linux-baseline successfully vendored to /home/you/acme-linux-baseline/vendor
inspec exec on a URL is a trust decision, not a download
A profile is Ruby, and its controls can call command() to run anything they like on the target. Fetching one from the internet and running it under sudo hands root on that host to whoever can push to the repository, or to anyone who can tamper with the download in transit. Pin to a tag, read the controls once, mirror the profile into your own Git, and commit inspec.lock so every runner executes identical code. Do not lean on inspec check as a safety net: it uses a mock backend so nothing runs against a live system, but it still loads the profile's Ruby to find the controls. Weigh running someone else's profile URL on a production host the same way you would weigh piping a stranger's script into a root shell, because it is the same decision wearing different clothes.

Privilege Decides Whether Green Means Anything

The last trap hands you a clean report and a false sense of safety. Run a host baseline as an unprivileged user and some controls fail loudly with permission errors, which is fine, because you notice. The dangerous ones are the negative checks. A control asserting that a file's contents should not contain something will pass when InSpec could not read that file at all, because nothing contains nothing. Our building inspector could not get the locked cupboard open, so they wrote no problems found in cupboard. No error, no failure, green report, complete fiction. An attacker does not have to defeat your scanner if your scanner was never allowed to look.

So scan with the privilege the checks actually need. Locally that means sudo, and specifically sudo -E, because plain sudo wipes your environment and takes CHEF_LICENSE with it, dropping you back into the hang from earlier. Root also keeps its own acceptance marker under /etc/chef, so accepting the license as yourself does nothing for the sudo run. For remote targets, --sudo escalates on the far side. Take care with --sudo-password: it demands a value, and passing the flag on its own errors out asking for one rather than prompting you. Any value you do pass lands in your shell history and in the process list that every other user on the box can read, so the clean answer is a dedicated scanning account with a narrow NOPASSWD rule in sudoers (a line telling sudo this one account may run this one command without typing a password) rather than a secret on the command line. Then verify the way you verify any change: fix the setting, narrow the run to the single control you touched with --controls, and read the exit code instead of skimming the text.

terminal
# Plain `sudo` wipes CHEF_LICENSE and the run stalls on the prompt again. Use -E.
sudo -E inspec exec linux-baseline --controls=os-05
echo "exit=$?"
output
Profile: DevSec Linux Security Baseline (linux-baseline)
Version: 2.10.0
Target: local://
Target ID: 8f4a0e2c-3b17-5d90-a4e1-6c0b9f2d7a15
✔ os-05: Check login.defs
✔ File /etc/login.defs is expected to exist
✔ File /etc/login.defs is expected to be file
✔ File /etc/login.defs is expected to be owned by "root"
✔ File /etc/login.defs group is expected to eq "root"
✔ File /etc/login.defs is expected not to be executable
✔ File /etc/login.defs is expected to be readable by owner
✔ File /etc/login.defs is expected to be readable by group
✔ File /etc/login.defs is expected to be readable by other
✔ login.defs ENV_SUPATH is expected to include "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
✔ login.defs ENV_PATH is expected to include "/usr/local/bin:/usr/bin:/bin"
✔ login.defs UMASK is expected to include "027"
✔ login.defs PASS_MAX_DAYS is expected to eq "60"
✔ login.defs PASS_MIN_DAYS is expected to eq "7"
✔ login.defs PASS_WARN_AGE is expected to eq "7"
✔ login.defs LOGIN_RETRIES is expected to eq "5"
✔ login.defs LOGIN_TIMEOUT is expected to eq "60"
✔ login.defs UID_MIN is expected to eq "1000"
✔ login.defs GID_MIN is expected to eq "1000"
Profile Summary: 1 successful control, 0 control failures, 0 controls skipped
Test Summary: 18 successful, 0 failures, 0 skipped
exit=0

Keep that narrowed command in your shell history. Re-running the whole baseline after a fix tells you the machine is still imperfect somewhere, which you already knew. Re-running the one control tells you whether the thing you touched is now correct, and that is the difference between a scan you act on and a scan you skim.

Quick check
01You run inspec exec smoke.rb on your laptop with no -t flag, and the report header reads Target: local://. What does that line tell you?
Incorrect — InSpec keeps no such cache, and a reused SSH connection would print an ssh:// target.
Incorrect — where the profile came from and where the checks run are separate things, and the transport line describes the latter.
Correct — with no target flag InSpec uses local://, so every resource queries the machine you typed the command on.
Incorrect — that describes inspec check, which evaluates a profile against a mock backend rather than a real target.
02A Docker build hangs forever on its first inspec exec. The image has network access and the profile is copied in locally. What is happening?
Correct — the license prompt blocks with no error output, and an environment variable in the Dockerfile clears it.
Incorrect — the profile is already local, and a name-resolution failure errors out quickly instead of hanging.
Incorrect — a local run opens no SSH connection at all, so there is no host key to confirm.
Incorrect — the omnitruck package ships prebuilt with its own Ruby, and compilation would print progress rather than sit silent.
03A pipeline step is inspec exec ./baseline && ./deploy.sh. The scan prints "Profile Summary: 41 successful controls, 0 control failures, 6 controls skipped", yet deploy.sh never runs. What happened, and what are your options?
Incorrect — the report goes to stdout, and && keys off the exit code regardless of which stream carried the text.
Incorrect — waiving does not take a control out of the skipped count. With run: false the control is reported as skipped anyway, and leaving run out skips nothing, so either way you stay on 101.
Incorrect — a crash exits 1 and prints an error, and here the summary printed cleanly to the end.
Correct — 101 is the dedicated skips-only exit code, and you choose between handling it explicitly or collapsing the exit scheme.

Try this

Run curl -fsSL https://omnitruck.chef.io/install.sh | sudo bash -s -- -P inspec -v 5.22.80 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 silent hang is a license prompt until proven otherwise. 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