InSpec
InSpec is Chef's open-source framework for writing infrastructure and compliance checks as code: a small Ruby DSL modeled on RSpec, in which a single testable unit — a control — states what a target system should look like, and InSpec goes and checks it for real, against a live host reached over SSH or WinRM, inside a running container, or against the machine you're standing on. It's one of the tools the CDP toolchain names directly, and it earns that spot for a specific reason: it answers the same question OpenSCAP answers — can you prove, on demand, that a system matches a written control? — with a completely different authoring experience. A developer who already knows Ruby or RSpec can read, write, and review an InSpec control in an afternoon; almost nobody sits down and hand-writes a new OVAL definition. By the end of this page you should be able to write a control against a real resource, point the same profile at a host or a container from one command, and explain precisely what you're trading away by picking InSpec's DSL over OpenSCAP's XML content for a given job.
Imagine two ways to write down the same school rule: "the fire exit must stay unlocked during class hours." One way is a plain sentence a new hall monitor can read once and immediately go check. The other way is an official form that says "see Rule 4.2.1(b), cross-reference Appendix C, Test Procedure 19" — thorough, standardized, exactly what a particular kind of inspector expects to see, but nobody's first day involves drafting one from scratch. InSpec is the plain sentence, written precisely enough that a computer can check it too. OpenSCAP is the official form.
What InSpec is, and the problem it solves
☺ Like you're 10: Instead of a document that says "root login should be off" and hopes someone re-reads it, you write a tiny program that actually goes and checks — the same way every time, on any machine you point it at.
Chef InSpec was built by Chef Software — the company behind the Chef Infra configuration-management tool — and first released in 2015, deliberately borrowed from RSpec and Serverspec so that a compliance check would read like a test a developer already knows how to write. In 2019, Chef made its core tooling, InSpec included, fully open source under the Apache-2.0 license; Progress Software acquired Chef Software in 2020 and folded it into its own portfolio. Governance and release cadence have shifted since — MITRE's Security Automation Framework (SAF) team in particular has become a heavy contributor and consumer, especially for the DISA STIG content covered later on this page — so treat the exact maintainer landscape as something worth checking directly on the project's GitHub rather than assuming it hasn't moved since a given training video was recorded.
The problem InSpec solves is turning a written rule into something that runs. "Disable SSH root login," "the checkout container must not run as root," "OpenSSL must be patched past a known CVE" — these start life as sentences in a hardening guide or a compliance framework, and a sentence doesn't verify itself. InSpec gives that sentence three pieces of vocabulary: a control is one testable rule; a profile is a versioned package of controls, with its own metadata file, that you can distribute and depend on the same way a Terraform module depends on another module; and a resource is a built-in Ruby object InSpec already knows how to interrogate on a target — a file, a package, an open port, a running container — exposing properties you assert against with an RSpec-style matcher. Get those four words solid and the rest of this page is mostly examples.
A control only ever checks — it has no built-in mechanism to fix anything it finds wrong. That's a deliberate split, not a missing feature: infrastructure as code hardening is where an Ansible role does the fixing; this page is entirely about proving, afterward and repeatedly, that the fix actually held.
Architecture: profiles, controls, resources, and the Train transport
☺ Like you're 10: The rulebook (profile) and the checking logic (controls) never change based on where the thing you're checking lives — a separate piece called Train figures out how to actually reach it, whether that's a server, a laptop, or a container.
A profile is a plain directory: inspec.yml for metadata, a controls/ folder of .rb files, and an optional libraries/ folder for custom resources. None of that says anything about where the controls run. That's the job of Train — InSpec's pluggable transport layer, a separate library it shares with the wider Chef ecosystem — and it's the single most important architectural fact on this page. Train supports a target-independent set of backends (local, ssh://, winrm://, docker://, and cloud-API backends like aws:// via plugins), and every InSpec resource is written against Train's abstraction, not against any one backend directly. The practical consequence: the exact same profile, unmodified, checks a bare-metal host over SSH, a Windows box over WinRM, or a running container — you change one command-line flag, not one line of Ruby.
Nothing about InSpec's ssh://, winrm://, or docker:// transports installs a persistent agent, and for those three backends it never even copies the inspec binary onto the target — it reads remote state by issuing discrete commands and file reads over an existing protocol, then evaluates every matcher back on the machine that ran inspec exec. That's a genuinely different model from OpenSCAP's, which is covered precisely at the end of this page.
Writing a control: resources, matchers, and inputs
☺ Like you're 10: A control is three ingredients stacked together: pick a thing to look at (a resource), say what it should look like (a matcher), and write down why it matters (impact, title, a tag).
A profile's metadata lives in inspec.yml, and — new in InSpec 4 and current best practice since — a profile can declare inputs: named, typed values a control reads instead of hardcoding, so the same profile can carry a different threshold in staging versus production without editing Ruby:
# inspec.yml
name: acme-checkout-baseline
title: Acme Checkout Service Baseline
maintainer: Acme Platform Security
license: Apache-2.0
summary: Container and host controls for the checkout service.
version: 1.4.0
inputs:
- name: min_openssl_version
description: Minimum acceptable OpenSSL version across the fleet
type: string
value: "3.0.2"
supports:
- platform: linux
depends:
- name: linux-baseline
git: https://github.com/dev-sec/linux-baseline
tag: v2.11.0A control itself wraps one or more describe blocks. Here's a minimal one, checking a resource this page comes back to shortly:
# controls/tls.rb
control 'tls-01' do
impact 1.0
title 'Disable legacy TLS protocol versions'
desc 'TLSv1 and TLSv1.1 are deprecated by RFC 8996 and must not be accepted.'
tag pci_dss: '4.2.1'
describe tls_listener(443) do
its('protocols') { should_not include 'TLSv1' }
its('protocols') { should_not include 'TLSv1.1' }
end
endimpact is a float from 0.0 to 1.0 that InSpec's own reporters bucket into severity; tag attaches arbitrary metadata — a framework section, a ticket ID — that downstream tooling can filter on. On a live host, built-in resources cover the everyday cases without any custom code at all:
# controls/host_baseline.rb
control 'pkg-01' do
impact 0.5
title 'OpenSSL must be at a patched version'
desc 'CVE tracking requires OpenSSL at or above the fleet minimum on every host.'
describe package('openssl') do
it { should be_installed }
its('version') { should cmp >= input('min_openssl_version') }
end
end
control 'net-01' do
impact 0.9
title 'The Docker daemon API must not be exposed without TLS'
desc 'An unauthenticated Docker socket on 2375/tcp is a full remote-root exploit.'
describe port(2375) do
it { should_not be_listening }
end
endAgainst a container target, the same profile leans on docker_container for what it exposes directly, and falls back to the generic command resource — shelling out to docker inspect — for anything the typed resource doesn't surface:
# controls/container_baseline.rb
control 'container-01' do
impact 0.7
title 'Checkout container must not run as root'
desc 'The process inside the container must run as a named, non-root user.'
describe command("docker inspect --format '{{.Config.User}}' checkout") do
its('stdout.strip') { should_not be_empty }
its('stdout.strip') { should_not eq 'root' }
end
end
control 'container-02' do
impact 0.5
title 'Checkout container must be running the expected image'
describe docker_container('checkout') do
it { should be_running }
its('image') { should match %r{ghcr\.io/acme/checkout} }
end
endAnd when a check has no matching built-in resource at all — the tls_listener(443) used in tls-01 above doesn't ship with InSpec — you write one, as plain Ruby, dropped in libraries/:
# libraries/tls_listener.rb
class TlsListener < Inspec.resource(1)
name 'tls_listener'
desc 'Reports which TLS protocol versions a local port actually accepts'
example "
describe tls_listener(443) do
its('protocols') { should_not include 'TLSv1' }
end
"
attr_reader :protocols
def initialize(port)
@port = port
@protocols = %w(TLSv1 TLSv1.1 TLSv1.2 TLSv1.3).select do |proto|
flag = "-#{proto.downcase.tr('.', '_')}"
cmd = inspec.command("echo | openssl s_client -connect localhost:#{@port} #{flag} 2>&1")
cmd.stdout.include?('Cipher is')
end
end
def to_s
"TLS listener on port #{@port}"
end
endThat's the whole custom-resource contract: subclass Inspec.resource(1), declare name and desc, do whatever work you need in initialize using the inspec helper to reach other built-in resources, and expose the properties a control will assert against. Anywhere a built-in resource almost fits but not quite, this is the escape hatch — and it's the same Ruby your application code is already written in, not a second language.
"I don't care that a control is only six lines of Ruby — I care that it's checked into the same repository as the thing it's checking, reviewed the same way, and produces a JSON file I can file away with a date on it. A control that only exists as a green checkmark in someone's terminal isn't evidence. It's a rumor with good intentions."
Running a profile: a live host, a container, or locally
☺ Like you're 10: One command, one flag that changes — that's the whole difference between checking your laptop, a server on the other side of the world, and a container that's only existed for ten seconds.
The Train architecture from earlier turns into exactly one flag at the command line: --target (or -t), set to a URL whose scheme picks the backend. Omit it entirely and InSpec checks the machine it's running on.
# a real host, over SSH, with a specific identity file
$ inspec exec ./checkout-baseline \
--target ssh://ops@10.0.4.21 \
-i ~/.ssh/fleet_ed25519
# a Windows host, over WinRM
$ inspec exec ./checkout-baseline --target winrm://Administrator@10.0.4.30
# a running container, by name or ID — docker exec under the hood
$ inspec exec ./checkout-baseline --target docker://checkout
# the machine you're standing on — no --target flag at all
$ inspec exec ./checkout-baselineBefore a control is finished, inspec shell is the fastest way to find out whether a resource does what you think it does — an interactive REPL that can attach to any of the same targets, so you try docker_container('checkout').running? live before committing it to a file:
$ inspec shell -t docker://checkout
> docker_container('checkout').running?
=> true
> command("docker inspect --format '{{.Config.User}}' checkout").stdout
=> "appuser\n"Cloud-resource targets (aws_security_group, azure_generic_resource, and similar) work the same way in principle but route through a Train cloud-API plugin rather than SSH or WinRM — authenticated the same way any AWS or Azure CLI call is, via environment credentials, not a --target URL pointing at a specific machine. Those resources exist and are real, but they're a narrower slice of InSpec's built-in library than the host and container resources this page focuses on.
Day-to-day commands: init, check, vendor, waivers, and reporters
☺ Like you're 10: Scaffold a new rulebook, make sure it's not broken before you run it anywhere, download anything it borrows from someone else, and decide what to do with a result you don't want to act on today.
# scaffold a new profile with the standard directory layout
$ inspec init profile checkout-baseline
# validate profile structure and inspec.yml metadata — no target contacted
$ inspec check ./checkout-baseline
# resolve the `depends` block into vendor/, and pin exact versions in inspec.lock
# — the same job a language package manager's lockfile does
$ inspec vendor ./checkout-baseline
# a full real run: env-specific inputs, a waiver file, and two report formats at once
$ inspec exec ./checkout-baseline \
--target ssh://ops@10.0.4.21 -i ~/.ssh/fleet_ed25519 \
--input-file inputs-prod.yml \
--waiver-file waivers.yml \
--reporter cli json:results/checkout.json junit:results/checkout.xmlA waiver file is InSpec's answer to "we know about this finding and we're accepting it for now" — a small YAML document, not a comment buried in a control:
# waivers.yml sshd-02: justification: "Password auth required for the legacy jump host during a migration; ticket OPS-4821" expiration_date: 2026-03-01 run: false tls-01: justification: "Load balancer terminates TLS upstream; this host never speaks TLS directly" run: false
run: false skips the control entirely rather than executing it and overriding the result — useful when the control genuinely can't apply, like tls-01 on a host with no TLS listener of its own. expiration_date is enforced by InSpec itself: once that date passes, the waiver stops applying and the control runs for real again, which is a meaningfully sharper default than a suppression comment with no expiry at all — see how Checkov's inline #checkov:skip comments handle the same problem, on the Checkov tool page, for the contrast.
None of that enforcement happens unless the run actually includes --waiver-file waivers.yml. Forget the flag in a pipeline's InSpec invocation and the file sits in the repo doing precisely nothing — every waived control runs unwaived, fails loudly, and the first sign anything's wrong is a build that was supposedly already accounted for. A waiver file with no flag pointing at it isn't a soft failure; it's silent.
Gotchas and failure modes
☺ Like you're 10: Most surprises come from the target not giving InSpec what it expected — no permission, no shell, no network path to fetch something it depends on.
- Many resources need elevated access on the target, and the failure isn't always a clear message. A file-permission check or a package query run as an unprivileged SSH user can come back as a generic error rather than an obvious "run me with sudo." Either the SSH user needs passwordless sudo configured, or run with
--sudoso InSpec escalates for the commands that need it — test this against a real target before trusting a "clean" run from an under-privileged one. - A control with no guard runs everywhere the profile runs, including where it makes no sense. A control written assuming
systemdwill hard-fail on a host that doesn't have it, reading as a real finding instead of "not applicable." Wrap it in anonly_ifblock —only_if('systemd only') { file('/run/systemd/system').exist? }— so it skips cleanly instead of failing loudly, the same distinction OpenSCAP draws natively betweenfailandnotapplicable, covered below. - A container target has no guarantee of a shell, or of the utilities a resource assumes exist. Distroless and scratch-based images — exactly the images container runtime security recommends — often have no
ps, no package manager, sometimes no shell at all. A control that leans oncommand(...)for anything beyond whatdocker_containerexposes directly can error out against a hardened image for a reason that has nothing to do with compliance. Know which of your images are minimal before writing a control against one. - Community profile dependencies are pinned by git tag, and resolving them needs network access. The
dependsblock'sgit:/tag:pair — like the DevSec Hardening Framework baselines used throughout compliance as code at scale — has to be fetched, and a run in an air-gapped or firewalled CI environment will fail at that fetch rather than at any control. Runinspec vendorahead of time in an environment with network access and commit or cache the resultingvendor/directory, rather than resolving dependencies fresh on every pipeline run. cmp's type coercion is a feature, but it means you shouldn't reach for it where exactness matters.cmpdeliberately treats"no",:no, andfalseas equivalent so a control isn't brittle to how a config file happens to serialize a value — genuinely useful for something likesshd_config. For a value where the literal representation is the point of the audit (a specific string an auditor will read verbatim), use the strictereqmatcher instead, so a coincidentally-coerced pass can't hide a real mismatch.
InSpec vs OpenSCAP: a developer-friendly DSL versus XML-based content
☺ Like you're 10: Both tools can check the exact same rule — the real difference is who's comfortable writing a new one from scratch, and whether a government auditor cares that the tool itself is on an approved list.
InSpec and OpenSCAP solve the identical problem — turn a written control into an automated, repeatable check — and the compliance-as-code-at-scale chapter covers how their outputs get normalized into one evidence stream across a fleet. This page's comparison is narrower and more concrete: the authoring experience itself, because that's what actually decides which tool a given team reaches for.
| Aspect | Chef InSpec | OpenSCAP |
|---|---|---|
| Content format | Ruby DSL — controls/*.rb, plain text, diffable in a normal pull request | XCCDF (profiles, rules) + OVAL (the test logic itself), both XML |
| Who typically authors new content | App and platform teams routinely write org-specific controls in-house | Almost nobody hand-writes new OVAL; teams select and tailor existing SCAP Security Guide content instead |
| Runtime model | Agentless via Train (SSH, WinRM, Docker, cloud APIs) — evaluation happens on the controller, nothing placed on the target | The oscap binary runs on the target (or is copied there temporarily by oscap-ssh); oscap-vm/oscap-podman scan an image offline instead |
| Best fit | Org-specific and cross-platform controls — containers, cloud resources, custom app config — versioned beside the code it checks | US federal / DISA STIG / FedRAMP contexts, where SCAP-validated tooling and NIST-aligned published content are the expectation |
| Ecosystem | The DevSec Hardening Framework baselines, plus MITRE SAF's growing library of STIG baselines built with its Vulcan tool | The SCAP Security Guide (ComplianceAsCode/content) — the larger, longer-established content library, maintained heavily by Red Hat and the wider community |
| What it costs you | A second in-house DSL to teach, if the team doesn't already write Ruby or RSpec | Raw XML/OVAL authoring is a niche skill few teams touch directly — most only ever tailor an existing profile through scap-workbench's GUI or a tailoring file |
That last row is worth sitting with, because it's the practical answer to "which one do we write new content in." Teams that write InSpec controls are usually writing genuinely new logic — a rule specific to their own application, their own container images, their own internal standard. Teams that touch OpenSCAP are, in the overwhelming majority of cases, not writing new OVAL at all; they're running oscap xccdf eval --tailoring-file against a profile someone else already published, selecting which existing rules apply and adjusting a handful of variables, because SCAP's whole value proposition is a shared, government-aligned content library nobody wants to reinvent. Pick InSpec when the rule is yours and the readers reviewing it are developers. Pick OpenSCAP when the rule already exists in a NIST-aligned benchmark and the reader who ultimately has to accept the evidence is an auditor checking it against that exact benchmark.
Nutty the Squirrel: New control's in — checked the checkout container isn't running as root. Filed the result.
Benny the Beaver: Filed already? I'm still finding the right OVAL object for the equivalent rule on the RHEL host.
Nutty: That's sort of the point. I wrote the InSpec version in about eight lines of Ruby while you were still locating the right XML element.
Timmy the Turtle: Eight lines is fine by me as long as it's actually gating the build, not just sitting in a report nobody reads.
Nutty: It's wired in — control fails, build fails. Same as anything else you gate.
Rocky the Raccoon: If I stop that container, rename it, and start a new one under a different name — does your control still find it?
Nutty: ...that's a good question. Let me go pin the container name in the profile's inputs instead of hardcoding it into the control.
Professor Owl: Which is exactly why a control gets reviewed the same way we review code. Readable Ruby doesn't mean the logic underneath it is automatically right.
1. Define profile, control, and resource in one sentence each, and say how they relate. 2. What is Train, and what's the biggest architectural consequence of InSpec using it — compared to how OpenSCAP normally has to execute? 3. In the container controls on this page, why does one use docker_container and the other fall back to the generic command resource? 4. What do a waiver file's run: false and expiration_date fields each control, and what happens in a pipeline if --waiver-file is never actually passed? 5. Name two real differences between authoring new InSpec content versus new OpenSCAP content, and give one situation where OpenSCAP's XML-based content is still the better-fit choice despite that extra authoring cost.
Check your answers
- A profile is a versioned, distributable package of controls with its own metadata file. A control is one testable rule inside a profile. A resource is a built-in (or custom) Ruby object a control's
describeblock asserts against via a matcher. A profile contains controls; each control checks one or more resources. - Train is InSpec's pluggable transport layer — it decides how to reach a target (SSH, WinRM, Docker, cloud API, or local) independently of the controls and resources, which never change based on the backend. The biggest consequence: for SSH, WinRM, and Docker targets, InSpec never places a binary on the target at all — it evaluates entirely on the controller by issuing remote commands and file reads. OpenSCAP's
oscapbinary, by contrast, has to actually execute on the target (or be copied there temporarily byoscap-ssh). docker_containeris used where it directly exposes what's needed (whether the container is running, its image name). The genericcommandresource, shelling out todocker inspect, is used where the typed resource doesn't surface a needed field — here, the container's configured user — which is the general pattern: reach for the typed resource first, fall back tocommandfor anything it doesn't cover.run: falseskips a waived control entirely rather than executing it and overriding the result.expiration_dateis enforced by InSpec itself — once that date passes, the waiver stops applying and the control runs for real again. If--waiver-fileis never passed on the actual run, none of that enforcement happens at all: the waiver file sits in the repo doing nothing, and every "waived" control fails as if it had never been addressed.- InSpec content is Ruby, written in-house by app/platform teams as a matter of course; OpenSCAP content is XML (XCCDF + OVAL), and in practice almost nobody hand-authors new OVAL — teams tailor existing SCAP Security Guide content instead. OpenSCAP remains the better fit in US federal / DISA STIG / FedRAMP contexts, where SCAP-validated tooling and NIST-aligned published content are the actual expectation an auditor is checking against, regardless of how much faster an equivalent InSpec control would have been to write.