Install & first scan
inspec exec against a target.
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.
# 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 includedbrew install --cask chef-workstation# Or the community rebuild: same behavior, no license gate, different namecurl -fsSL https://omnitruck.cinc.sh/install.sh | sudo bash -s -- -P cinc-auditorinspec version
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.
FROM ubuntu:24.04RUN 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"]
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.
inspec detect
== Platform DetailsName: ubuntuFamilies: debian, linux, unix, osRelease: 24.04Arch: 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.
# 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') doit { should be_directory }enddescribe port(22) doit { should be_listening }end
inspec exec smoke.rbecho "exit=$?"
Profile: tests from smoke.rb (tests from.smoke.rb)Version: (not specified)Target: local://Target ID: 8f4a0e2c-3b17-5d90-a4e1-6c0b9f2d7a15File /etc✔ is expected to be directoryPort 22✔ is expected to be listeningTest Summary: 2 successful, 0 failures, 0 skippedexit=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.
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.
# Fetch and run a published baseline against this machineinspec exec https://github.com/dev-sec/linux-baselineecho "exit=$?"# The identical profile from Chef Supermarket:# inspec exec supermarket://dev-sec/linux-baseline
Profile: DevSec Linux Security Baseline (linux-baseline)Version: 2.10.0Target: 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 skippedTest Summary: 168 successful, 27 failures, 9 skippedexit=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.
# Pin the fetch to a released tag rather than to HEADinspec 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 itgit clone --branch 2.10.0 --depth 1 https://github.com/dev-sec/linux-baseline.gitinspec check linux-baseline
Location : linux-baselineProfile : linux-baselineControls : 59Timestamp : 2026-07-22T09:41:22+00:00Valid : trueNo 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.
name: acme-linux-baselinetitle: ACME Linux Baselinemaintainer: platform-securitylicense: Apache-2.0version: 1.0.0supports:- platform-family: debiandepends:- name: linux-baselinegit: https://github.com/dev-sec/linux-baseline.gittag: "2.10.0"
inspec vendor --overwrite
Dependencies for profile /home/you/acme-linux-baseline successfully vendored to /home/you/acme-linux-baseline/vendor
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.
# Plain `sudo` wipes CHEF_LICENSE and the run stalls on the prompt again. Use -E.sudo -E inspec exec linux-baseline --controls=os-05echo "exit=$?"
Profile: DevSec Linux Security Baseline (linux-baseline)Version: 2.10.0Target: 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 skippedTest Summary: 18 successful, 0 failures, 0 skippedexit=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.
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.