CoursesInSpecInSpec in CI/CD & pipelines

InSpec in CI/CD & pipelines

Gate images and infra on compliance.

Advanced12 min · lesson 11 of 12

Two health inspectors walk into the same kitchen with the same clipboard. One writes his report, hands it to the manager, and drives off while lunch service carries on. The other can hang a CLOSED sign on the door before another plate leaves the pass. Same checklist. Very different power. Running inspec exec by hand after a release makes you the first inspector. Running it inside CI/CD (continuous integration and continuous delivery, the automation that builds, tests and ships your code every time somebody pushes) can make you the second, but only if the pipeline listens to the one signal InSpec uses to say no.

That signal is the exit code: the small number every command hands back to whatever started it. Zero means fine. Anything else means trouble. Your runner already watches it. GitLab, GitHub Actions and Jenkins all mark a step failed the moment the process they launched returns something other than zero, and a failed step stops everything queued behind it. A compliance gate lives or dies on that number, so learn all of them.

The Exit Code Is The Whole Gate

inspec exec has six answers you will actually meet. 0 means every control passed. 100 means at least one control failed. 101 means nothing failed but at least one control was skipped. 102 means a profile would not load. Usually that is a control file that does not parse, so part of your baseline never ran while the rest reported normally. 1 is the usage error, and it fires before any of that: a profile path InSpec cannot resolve, a target it cannot reach, a waiver file that is not where you said it was. 172 means the Chef license was never accepted, which is how a brand new pipeline produces a red build and zero findings. Smaller ones exist too: 2 for a broken plugin, 3 for a fatal deprecation, 4 for a gem dependency InSpec cannot load, 5 for a profile whose signature does not verify. InSpec 6 adds two more license codes next to 172. That spread is the argument for writing your gate as an allow-list of 0, 100 and 101 rather than a block-list of the failures you happen to know about.

Two of those ambush people. The first is 101. A skipped control is one InSpec would not or could not evaluate: a Windows rule aimed at a Linux host, a control sitting behind an only_if guard, or the big one, a control you deliberately waived. Nothing failed. The job still goes red, because 101 is not zero. The second is 172, and on a fresh runner it looks like this.

terminal
# A fresh CI container. Nobody set the license variable.
inspec exec image-baseline -t docker://gate-scan
echo "exit=$?"
output
[2026-07-22T09:14:07+00:00] ERROR: Chef InSpec cannot execute without accepting the license
exit=172

Set CHEF_LICENSE=accept-silent in the job environment and InSpec 5 runs without prompting. Everything on this page runs on Chef InSpec 5.22.80, pinned in the runner image, which is where a lot of teams have parked. InSpec 6 tightened the rules, because Progress moved Chef InSpec onto a commercial license: it wants a key in CHEF_LICENSE_KEY as well (free, trial or commercial tier), and an air-gapped site can point CHEF_LICENSE_SERVER at a license service on its own network. Get that part wrong and the message changes to Chef InSpec cannot execute without valid licenses. If none of it suits you, CINC Auditor is the open-source rebuild of the same code. The binary is called cinc-auditor, it reads the same profiles, and it returns the same exit codes.

What the pipeline should do with each exit code
inspec exec has finished. What did it return?
0
Everything passed
Publish the artifact and keep the JSON report as evidence.
100
At least one control failed
A real finding. Block it, or waive it on purpose with a justification and an expiry date.
101
Nothing failed, something was skipped
Waivers and unsupported controls land here. Decide what it means; never ignore it silently.
anything else
The scan did not complete
A profile that would not load, an unreachable target, or no license. Fail closed: the report is missing or half-written.

Standing The Image Up So InSpec Can Walk In

InSpec's Docker transport does not open sealed boxes. It walks into rooms. Pointing at docker://<name-or-id> runs your controls through docker exec, the same mechanism you use to get a shell inside a running container, so the container has to be up and has to stay up for the length of the scan. That is awkward in a build pipeline, where the thing you want to inspect is an image, and an image does not run on its own.

docker create gives you a container you cannot enter
The tidy-looking -t docker://$(docker create app:candidate) trick does not work. docker create leaves the container in the Created state, and the daemon answers the exec request with a 409 Conflict: {"message":"container b6c8670b9487... is not running"}, which the Ruby Docker client raises as Docker::Error::ConflictError. InSpec does not recognise that exception class, so it prints a raw stack trace and dies with exit 1 during platform detection, before a single control runs. Start the container instead, with a command that stays alive: docker run -d --entrypoint sleep app:candidate 900. Images built FROM scratch or on a distroless base have no shell for docker exec to run, so they cannot be scanned this way at all. Check those in the build stage that still has a shell, or with a scanner that reads image layers directly.
image-baseline/controls/image.rb
control 'img-01' do
impact 1.0
title 'Shipped image has no package manager'
desc 'Code execution inside the container should not come with an installer attached.'
%w{/sbin/apk /usr/bin/apt-get /usr/bin/dnf}.each do |mgr|
describe file(mgr) do
it { should_not exist }
end
end
end
control 'img-02' do
impact 1.0
title 'No SSH daemon inside the image'
desc 'A container is not a server. Nothing in it should be listening for logins.'
describe file('/usr/sbin/sshd') do
it { should_not exist }
end
end
control 'img-03' do
impact 0.7
title 'No world-writable files outside /tmp'
desc "World-writable paths let one compromised process rewrite another's code."
describe command("find / -xdev -type f -perm -0002 -not -path '/tmp/*' 2>/dev/null") do
its('stdout') { should eq '' }
end
end
control 'img-04' do
impact 0.4
title 'Image records a build provenance label'
desc 'Every published image must carry the commit it was built from.'
describe file('/etc/build-info') do
it { should exist }
end
end

Those impact numbers are not decoration. InSpec scores impact from 0.0 to 1.0 and borrows the bands from CVSS 3.0 (the Common Vulnerability Scoring System, the industry way of ranking how bad a weakness is): below 0.01 is none, 0.01 up to 0.4 is low, 0.4 up to 0.7 is medium, 0.7 up to 0.9 is high, and 0.9 and above is critical. The gate you build in a moment blocks on 0.7 and above. Putting 0.4 on img-04 says out loud that a missing provenance label is worth reporting and not worth stopping a release for.

terminal
# The candidate image is built. Give it a container that stays alive.
docker run -d --name gate-scan --entrypoint sleep app:candidate 900
inspec exec image-baseline -t docker://gate-scan \
--waiver-file waivers.yaml \
--reporter cli json:compliance.json
echo "exit=$?"
output
a8bffa7f78f018c51ff4a966bee0bcca9fc80afc9da978d33f6f53dc67bf19d0
Profile: Container image baseline (image-baseline)
Version: 1.4.0
Target: docker://gate-scan
Target ID: da39a3ee-5e6b-5b0d-b255-bfef95601890
× img-01: Shipped image has no package manager (1 failed)
× File /sbin/apk is expected not to exist
expected File /sbin/apk not to exist
✔ File /usr/bin/apt-get is expected not to exist
✔ File /usr/bin/dnf is expected not to exist
✔ img-02: No SSH daemon inside the image
✔ File /usr/sbin/sshd is expected not to exist
✔ img-03: No world-writable files outside /tmp
✔ Command: `find / -xdev -type f -perm -0002 -not -path '/tmp/*' 2>/dev/null` stdout is expected to eq ""
↺ img-04: Image records a build provenance label
↺ Skipped control due to waiver condition: Provenance label lands with the build-provenance work, SEC-4412. Owner: @platform-sec
Profile Summary: 2 successful controls, 1 control failure, 1 control skipped
Test Summary: 4 successful, 1 failure, 1 skipped
exit=100

Read that top to bottom. img-01 carries the × and a (1 failed) counter because only one of its three tests failed: apt-get and dnf are absent, apk is not. img-04 shows and the sentence from your waiver file where a result would normally sit. The two summary lines count controls first and individual tests second, which is why one control failure and one test failure can be very different numbers on a real baseline. The exit is 100, so the pipeline stops here, and rightly: an image that ships apk hands anyone with code execution inside it a working package installer, which is how a foothold turns into a toolkit.

Waivers, And The Two Ways They Bite

A waiver is a signed note in the margin of the checklist: this one is allowed to be wrong, here is who said so, here is when the permission runs out. It is what keeps a gate from being either theatre or a roadblock. The file is YAML (a plain-text format for writing structured data), keyed by control id, and you hand it to InSpec with --waiver-file.

waivers.yaml
img-04:
run: false # do not evaluate it at all
expiration_date: 2026-09-30 # after this date the control runs again
justification: "Provenance label lands with the build-provenance work, SEC-4412. Owner: @platform-sec"

run: false is what makes a control skip. InSpec does not evaluate it, and reports it with your justification attached. Leaving run out does not skip anything, which is the opposite of what most people assume. An absent run means the same thing as run: true: the control is evaluated, it fails as usual, and your waiver changes nothing you can see except a waiver_data block in the report. The documentation says it in as many words, and the code agrees, applying a skip only when the key is there and set to false. If you want a control skipped, write run: false yourself. expiration_date is the field that earns its keep. A waiver without one never expires, and an exception nobody revisits is a hole with paperwork stapled to it. Point the flag at a path that does not exist and InSpec stops with Waiver file waivers.yml does not exist. and exit 1, rather than quietly running unwaived. That is the right way round.

Now the first bite. The team decides apk stays until the multi-stage rebuild lands, so they waive img-01 as well. Every failure is now covered by a signed note. Watch what the gate does.

waivers.yaml (appended)
img-01:
run: false
expiration_date: 2026-08-15
justification: "apk needed by the entrypoint until the multi-stage rebuild, SEC-4501"
terminal
inspec exec image-baseline -t docker://gate-scan --waiver-file waivers.yaml
echo "exit=$?"
output
Profile: Container image baseline (image-baseline)
Version: 1.4.0
Target: docker://gate-scan
Target ID: da39a3ee-5e6b-5b0d-b255-bfef95601890
↺ img-01: Shipped image has no package manager
↺ Skipped control due to waiver condition: apk needed by the entrypoint until the multi-stage rebuild, SEC-4501
✔ img-02: No SSH daemon inside the image
✔ File /usr/sbin/sshd is expected not to exist
✔ img-03: No world-writable files outside /tmp
✔ Command: `find / -xdev -type f -perm -0002 -not -path '/tmp/*' 2>/dev/null` stdout is expected to eq ""
↺ img-04: Image records a build provenance label
↺ Skipped control due to waiver condition: Provenance label lands with the build-provenance work, SEC-4412. Owner: @platform-sec
Profile Summary: 2 successful controls, 0 control failures, 2 controls skipped
Test Summary: 2 successful, 0 failures, 2 skipped
exit=101

Zero failures. Exit 101. The pipeline is still red, and the engineer who wrote the waiver is now convinced waivers are broken. They are not. A waived control is a skipped control, and skips are never silent, so any gate you build has to decide what 101 means to you. There is a blunt fix, --no-distinct-exit, which collapses the scale back to the old shape: skips become 0 and failures become 1. It works, and it also throws away the difference between a clean run and a run where half the baseline never executed.

The second bite is quieter, and it is a gap between the documentation and the code. The docs say a waiver with run: true lets the control run and report while its failures do not fail the overall run. The exit code does not honour that. InSpec marks a control failed whenever any of its results failed, and the function that picks the exit code never looks at waiver_data at all, so a run: true waiver over a failing control still returns 100. If you want a finding visible in the report and out of the gate, use run: false and handle the 101, or filter the report yourself. Check it on your own build before you write policy around it, which is what echo $? is for.

Expiry has teeth too. When the date passes, InSpec does not warn and carry on. It evaluates the control normally and writes Waiver expired on 2026-08-15, evaluating control normally into that control's waiver_data.message. A pipeline that was green on Friday goes red on Saturday with no code change at all. Query your reports for waivers expiring in the next fortnight and open the ticket yourself, before the build opens it for you at two in the morning.

Gate On Impact, Not On Every Red Line

The exit code is a blunt instrument: one number for a whole run. What you want is narrower. Block the build when a high-impact control fails and nobody has signed for it. Report everything else and let people fix it in daylight. That decision lives in the JSON report (JavaScript Object Notation, the machine-readable format the json reporter writes), so run both reporters in the same pass and read the file afterwards. One ordering trap while you wire it up: the profile has to come before --reporter, or the option swallows it as another reporter name and InSpec stops with 'image-baseline' is not a valid reporter type. Put the profile first, or separate the two with a bare --.

terminal
# Which controls actually failed, and how much do they matter?
jq -r '.profiles[].controls[]
| select(any(.results[]; .status == "failed"))
| "\(.impact) \(.id) \(.title)"' compliance.json
output
1.0 img-01 Shipped image has no package manager

jq is a small command-line tool for slicing JSON. That query walks every control, keeps the ones with at least one failing result, and prints the impact next to the id. Wrap the same idea in a script and you get a gate that blocks on what matters, tolerates 101, and refuses to pass when the scan itself fell over. Note the order it checks things in: the recorded exit code first, then that the report exists and is not empty, and only then the findings.

ci/gate.sh
#!/usr/bin/env bash
set -euo pipefail
report="$1"
rc="$(cat "$2")"
# 0, 100 and 101 all mean InSpec ran and produced a verdict.
# Anything else means the run never started or never finished. Fail closed.
case "$rc" in
0|100|101) ;;
*) echo "inspec did not complete (exit $rc). Blocking."; exit 1 ;;
esac
# No report, or an empty one, means the same thing.
[ -s "$report" ] || { echo "no report at $report. Blocking."; exit 1; }
# High impact, really failing, and not covered by a waiver that is still in date.
blocking=$(jq -r '.profiles[].controls[]
| select(.impact >= 0.7)
| select(any(.results[]; .status == "failed"))
| select((.waiver_data.justification // "") == ""
or ((.waiver_data.message // "") | startswith("Waiver expired")))
| " BLOCKING \(.id) (impact \(.impact)) \(.title)"' "$report")
if [ -n "$blocking" ]; then
echo "$blocking"
echo "high-impact controls failing without a live waiver: $(echo "$blocking" | wc -l)"
exit 1
fi
echo "gate passed: nothing high impact is failing without a live waiver"
terminal
# The semicolon keeps this step's own status at zero, so the runner
# does not abort the job before the gate has had its say.
inspec exec image-baseline -t docker://gate-scan \
--waiver-file waivers.yaml --reporter json:compliance.json ; echo $? > inspec.rc
./ci/gate.sh compliance.json inspec.rc
echo "gate exit=$?"
output
BLOCKING img-01 (impact 1.0) Shipped image has no package manager
high-impact controls failing without a live waiver: 1
gate exit=1

That last filter is the interesting one. A control gets through when it carries a justification, and it is dragged back into the blocking list when its waiver_data.message starts with Waiver expired. The signed note stops counting the day it runs out, without anybody editing the pipeline. Here is the whole thing as a GitLab job.

.gitlab-ci.yml
image-compliance:
stage: test
image: registry.example.com/ci/inspec-docker:5.22.80 # inspec + docker CLI + jq
services:
- docker:27-dind # Docker-in-Docker: a daemon this job can talk to
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_CERT_PATH: "/certs/client"
DOCKER_TLS_VERIFY: "1"
CHEF_LICENSE: accept-silent # without this, inspec exits 172 and tests nothing
before_script:
- rm -f compliance.json inspec.rc # a cached report is a green light nobody earned
script:
- docker build -t app:candidate .
- docker run -d --name gate-scan --entrypoint sleep app:candidate 900
- inspec exec image-baseline -t docker://gate-scan --waiver-file waivers.yaml --reporter cli json:compliance.json junit2:inspec-junit.xml ; echo $? > inspec.rc
- ./ci/gate.sh compliance.json inspec.rc
after_script:
- docker rm -f gate-scan || true
artifacts:
when: always # keep the evidence even when the gate blocks
paths:
- compliance.json
reports:
junit: inspec-junit.xml # failed controls show up as failed tests on the merge request
A gate that reads the report must fail closed
Swallowing InSpec's exit code and reading the JSON instead is a sound design, and it is also how gates quietly stop working. When the target is unreachable, InSpec exits 1 and writes no report at all: there is no compliance.json to read, so a script that only counts findings counts zero and waves the image through. A report left behind by an earlier job does the same damage more convincingly, because it parses fine and answers about yesterday's build. Delete the report before the run, record the exit code, and refuse to pass when the file is missing or empty. A gate that fails open is worse than no gate, because it manufactures confidence.

Pin The Profile Or You Are Running A Stranger's Code

Handing InSpec a profile is closer to handing somebody your house keys than handing them a checklist. A profile is Ruby, and InSpec runs it in its own process on the machine doing the scanning, not on the target. Anything outside a describe block is ordinary code with ordinary powers. Three lines at the top of a control file show you where they land.

third-party-baseline/controls/00_prelude.rb
# Plain Ruby, outside any control. It runs when the profile loads.
File.write('/tmp/i-ran-here', `id`)
control 'looks-legit-01' do
describe file('/etc/passwd') do
it { should exist }
end
end
terminal
inspec exec third-party-baseline -t docker://gate-scan > /dev/null 2>&1
cat /tmp/i-ran-here # on the runner
docker exec gate-scan ls /tmp/i-ran-here # on the target that was scanned
output
uid=0(root) gid=0(root) groups=0(root)
ls: /tmp/i-ran-here: No such file or directory

The scan pointed at the container. The code ran on the runner, as root. Now think about what a compliance runner usually holds: an SSH key (secure shell, the standard way of logging into a remote machine) that reaches production, or a cloud role with read access to every account you audit. inspec supermarket exec dev-sec/linux-baseline fetches whatever that profile contains today and runs it in exactly that context, and so does a depends: entry pointing at a git branch, because a branch moves whenever its owner wants it to.

terminal
# inspec.yml declares: git: https://github.com/dev-sec/ssh-baseline.git, tag: 2.8.1
inspec vendor . --overwrite
cat inspec.lock
output
Dependencies for profile . successfully vendored to /builds/acme/compliance/vendor
---
lockfile_version: 1
depends:
- name: ssh-baseline
resolved_source:
git: https://github.com/dev-sec/ssh-baseline.git
ref: 975dff3caa1e6d912ae52be1f82318c9e42bedfa
version_constraints: []
An unpinned profile is code execution on your compliance runner
A tag is a label somebody can move. The 40-character commit ref that inspec vendor writes into inspec.lock is not, and the vendored copy under vendor/ is what actually executes, so CI stops reaching out to the internet mid-build. Commit both, and read the diff when you bump the version, exactly as you would for any other dependency that runs as root. inspec archive packs a vendored profile into a versioned tar.gz (ssh-overlay-0.1.0.tar.gz) if you would rather publish to an internal artifact store than pull from GitHub at scan time.

The Same Profile, On A Schedule

The build gate answers one question: is the thing we are about to publish compliant. It cannot answer the other one: is the thing we published six weeks ago still compliant. Somebody edited /etc/ssh/sshd_config during an incident at midnight and never put it back. So run the same profile on a schedule against the running fleet over SSH, write a timestamped JSON report each time, and feed those into a dashboard such as MITRE Heimdall or Chef Automate. Same controls, same waiver file, different cadence. Drift shows up as a trend line instead of an audit finding.

Your profile is code as well, so lint it in the same pipeline with inspec check. It reads inspec.yml, checks every control for a title, a description and an impact, and flags deprecated DSL (domain-specific language, the small vocabulary of describe and it that you write controls in). Add --with-cookstyle and it runs Ruby style checks on top. One catch: it exits 0 unless the profile is genuinely invalid, so warnings and offenses slide straight past a pipeline that only watches the exit code. Read the summary line.

terminal
cd image-baseline
inspec check .
echo "exit=$?"
output
Location : .
Profile : image-baseline
Controls : 4
Timestamp : 2026-07-22T09:22:41+00:00
Valid : true
! Missing profile copyright in inspec.yml
Summary: 0 errors, 1 warnings, 0 offenses
exit=0

Then prove the gate blocks before you trust it. Point the pipeline at an image you know is bad, an old build with apk still baked in, and watch the job stop with BLOCKING img-01 in the log and compliance.json sitting in the artifacts. A gate nobody has ever watched refuse something is a decoration.

Quick check
01What is it about inspec exec that turns a compliance scan into a gate rather than a report?
Incorrect — That produces evidence, and archiving a file has never stopped a release on its own.
Correct — GitLab, GitHub Actions and Jenkins all fail the step on a non-zero exit, and a failed step blocks what comes after it.
Incorrect — Impact ranks severity for you to act on, but InSpec never blocks anything because of it; you write that logic yourself.
Incorrect — A waiver records an accepted risk with an owner and an expiry; it grants exceptions, it does not enforce anything.
02A control is failing. You add a waiver entry for it with a justification and an expiration date, and no run key at all. You rerun the scan. What happens?
Incorrect — That is what run: false does; you have to write it, because omitting the key does not skip anything.
Incorrect — The docs promise something close to this, but InSpec's exit-code logic never inspects waiver data, so a failing control still yields 100.
Incorrect — run is optional; only the justification is required, and the thing that does stop the run is a waiver file path that does not exist.
Correct — InSpec applies a skip only when run is present and set to false, so an omitted key leaves the control evaluated normally, the failure still counts, and the waiver buys you a waiver_data block in the report and nothing else.
03The nightly image gate fails. The log's first line is a Ruby stack trace ending in Docker::Error::ConflictError with the message container 9e532d8c... is not running, the exit code is 1, and no compliance.json was produced. What happened and what do you change?
Correct — the Docker transport shells in over docker exec, which needs a running container, and the missing report is exactly why the gate must treat exit 1 as blocking.
Incorrect — No control ever ran. That error comes from the transport during platform detection, before a single test is evaluated.
Incorrect — An expired waiver produces an ordinary failing control, exit 100 and a full report, not a transport error with no report at all.
Incorrect — That flag only remaps exit codes; it cannot connect to a stopped container, and the failure here is exit 1, not 101.

Try this

Run inspec exec image-baseline -t docker://gate-scan 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: docker create gives you a container you cannot enter. 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