CoursesInSpecInheritance & overlays

Inheritance & overlays

Reuse and tailor baselines.

Intermediate12 min · lesson 7 of 12

Your landlord keeps the building's fire code. You do not rewrite it. You add a smoke alarm in the hallway, tape a note on the fuse box saying the kitchen runs on its own circuit, and leave everything else exactly as written. Profile inheritance works the same way in InSpec, which is Chef's tool for turning written security rules into tests you can run against a real machine. A profile is one bundle of those tests plus a metadata file describing it. Somebody else maintains the big bundle: two hundred-odd checks, updated whenever the standard is updated. Your job is the thin layer on top. Which of their rules genuinely do not apply here. Which thresholds your environment needs bent. Which checks they never wrote, because they have never seen your fleet. That thin layer is the overlay.

The alternative is a fork, and forks rot. You clone the CIS (Center for Internet Security) benchmark profile, edit six controls, and now you own all 237 of them. Every upstream release arrives as a merge conflict. Six months later nobody can answer the one question an auditor actually asks: what is the difference between what the standard demands and what you check? With an overlay, that difference is the whole artifact. Forty lines, maybe fifty. It goes through code review like anything else, and it reads as a list of decisions with names and ticket numbers attached.

Where each piece lives in a base plus overlay setup
Upstream base (never edited)
cis-dil-benchmark 0.4.12
237 controls, Apache-2.0, maintained by dev-sec
Pinned by git tag
resolves to commit 18fd9203 in inspec.lock
vendor/ copy
fetched once, then repeatable and offline
Your overlay (only the deltas)
depends + include_controls
pull the base in without forking it
skip_control + overrides
drop or retune named control IDs
inputs + new controls
retune thresholds, add what CIS lacks
What actually runs
One report, two profiles
1 control of yours, 235 of theirs
One exit code
0 clean, 100 failed, 101 skipped only
One JSON file
the evidence you diff and archive
The base column is read-only. Every decision you make fits in the middle column, and the right column is what your pipeline and your auditor actually see.

Point at a Base and Pin It

Dependencies go in inspec.yml, the profile's metadata file, under a key called depends. Treat it like the ingredients list on a recipe card, with the exact edition of the cookbook written beside each entry. InSpec resolves five kinds of source. path points at a profile sitting on local disk, which is handy while you are still writing it. url fetches a tarball over HTTPS. git clones a repository and accepts branch, tag, commit, version and relative_path keys. supermarket pulls from Chef Supermarket, the community profile site you can browse with inspec supermarket profiles. compliance pulls from a Chef Automate server. For a security baseline, use git with a tag or a commit. A branch name is a moving target, and this particular target decides what the word compliant means for your fleet.

inspec.yml
name: acme-linux-overlay
title: Acme Linux Baseline (CIS overlay)
maintainer: Platform Security
version: 1.4.0
supports:
- platform-family: linux
depends:
- name: cis-dil-benchmark
git: https://github.com/dev-sec/cis-dil-benchmark.git
tag: 0.4.12 # a tag or a commit, never a branch

One piece of housekeeping before you run anything. InSpec 6 moved to a Progress commercial licence, so it asks you to accept a licence on first run and exits with code 172 if you decline. CINC Auditor (CINC Is Not Chef) is the free, open-source rebuild of the same codebase. The commands, the control language and the profile format are identical, so every example below works if you type cinc-auditor where it says inspec.

terminal
$ inspec vendor --overwrite
output
Dependencies for profile /home/ops/acme-linux-overlay successfully vendored to /home/ops/acme-linux-overlay/vendor
inspec.lock
---
lockfile_version: 1
depends:
- name: cis-dil-benchmark
resolved_source:
git: https://github.com/dev-sec/cis-dil-benchmark.git
ref: 18fd9203d64e67f04d64fff9c5b60cd2b4065953
version_constraints: []

inspec vendor copies every dependency into vendor/ inside your profile and writes inspec.lock, a small file recording the exact commit each dependency name resolved to. Run it a second time without --overwrite and it declines with "Profile is already vendored. Use --overwrite.", so re-vendoring after you edit depends is always a deliberate act. inspec exec will also fetch dependencies on its own, cache them under ~/.inspec/cache, and write a lockfile if none exists, which makes it easy to never think about any of this. Think about it. The lockfile is what turns "tag 0.4.12" into the 40-character hash 18fd9203d64e67f04d64fff9c5b60cd2b4065953, and the hash is what actually pins the content, because a git tag is a movable label that anyone with write access to that repository can point somewhere else.

A profile you depend on is code that runs
An InSpec profile is Ruby, not a data file, and it runs in two places. The profile's Ruby is evaluated on the box where you type inspec exec, under your own user account, with your own filesystem and your own network access. The checks it builds then reach out to the machine you are auditing: a command resource executes there, through your SSH (Secure Shell, the encrypted remote login protocol) session, with whatever that login is allowed to do. Pointing depends at a branch hands both of those to anyone who can push to that repository. Pin to a tag or a commit, vendor it, and read the diff when you bump it. Where a publisher ships a signed, packaged profile (an .iaf, or InSpec artifact file), inspec sign verify checks it against the public key before you trust it: exit code 5 means the signature is invalid, and exit code 6 means signing was required and none was found.

Include All of It, or Allow-List a Slice

There are two verbs for pulling the base's controls in. include_controls 'cis-dil-benchmark' runs every control the dependency has, next to your own, in one report. require_controls names the ones you want and runs nothing else. The allow-list looks tidier, and for security work it is usually the wrong default, because it fails quietly. When the baseline adds control 5.2.24 in its next release, your run does not fail, does not warn, and does not test the new thing. include_controls with explicit, commented skips makes the same decisions loudly. Every exception sits in one reviewable file, and new upstream controls show up as a red pipeline that somebody has to look at.

controls/overlay.rb
# Every control from the pinned baseline runs, then these deltas apply.
include_controls 'cis-dil-benchmark' do
# 1.3.1 and 1.3.2 expect AIDE. This fleet runs a different file integrity
# agent instead; the replacement check is acme-fim-01.
# Risk accepted in RISK-2291, review by 2026-12-31.
skip_control 'cis-dil-benchmark-1.3.1'
skip_control 'cis-dil-benchmark-1.3.2'
# Idle SSH timeout: the baseline's 300s kills long deploys on the jump
# hosts. CHG-4471 accepts 900s. Both its() lines are repeated on purpose,
# because a redefinition replaces a control's checks outright.
control 'cis-dil-benchmark-5.2.16' do
impact 0.7
describe sshd_config do
its('ClientAliveInterval') { should cmp <= 900 }
its('ClientAliveCountMax') { should cmp <= 0 }
end
end
end

Two things in that file need spelling out. impact is InSpec's severity dial, a number from 0.0 to 1.0 that the reporters translate into none, low, medium, high and critical, where 0.0 is none and means informational. And skip_control is more aggressive than its name suggests. Under the hood it calls unregister_rule, which deletes the control from the registry. The control does not run, does not appear in the terminal output, and does not appear in the JSON (JavaScript Object Notation) report. There is no skipped row and no reason recorded. The identifier is gone, and a reader who does not know the baseline by heart has no way to tell that anything was dropped. That is why the comment above those two lines carries a ticket number, and why the thing you do instead gets its own control with its own identifier. AIDE (Advanced Intrusion Detection Environment) is the file integrity checker CIS assumes: it fingerprints every file on disk and shouts when a fingerprint changes. If you run something else, prove it.

controls/acme_fim.rb
control 'acme-fim-01' do
impact 1.0
title 'File integrity monitoring agent is installed and running'
desc 'Compensating control for cis-dil-benchmark-1.3.1 and 1.3.2 (AIDE). RISK-2291.'
tag compensates: %w{cis-dil-benchmark-1.3.1 cis-dil-benchmark-1.3.2}
ref 'RISK-2291', url: 'https://grc.acme.example/RISK-2291'
describe package('wazuh-agent') do
it { should be_installed }
end
describe service('wazuh-agent') do
it { should be_running }
it { should be_enabled }
end
describe file('/var/ossec/etc/ossec.conf') do
its('content') { should match(/<syscheck>/) }
end
end

That is a compensating control: a different check that covers the same requirement the baseline was asking about. The tag and the ref lines matter as much as the tests do, because they are what let a reader walk from a missing CIS identifier to the thing that replaced it. Now compare it with the allow-list style, which belongs in a profile of its own with its own inspec.yml and its own copy of that depends entry.

ssh-only-overlay/controls/ssh_only.rb
# A separate, narrower profile: only the SSH section of the same baseline.
# require_controls allow-lists, and can still modify what it lets through.
require_controls 'cis-dil-benchmark' do
control 'cis-dil-benchmark-5.2.10' # root login disabled
control 'cis-dil-benchmark-5.2.11' # empty passwords refused
control 'cis-dil-benchmark-5.2.16' do # metadata only, see below
impact 0.7
end
end

Notice what that last block does not contain. The 5.2.16 entry sets an impact and stops. No describe block, so the baseline's own tests for that control survive untouched and only the severity changes. That distinction is the whole of the next section.

How an Override Actually Merges

When InSpec meets a second definition of a control identifier it already holds, it merges the two, the way you swap one page in a ring binder rather than slipping a second copy in behind it. Metadata you set (impact, title, desc, tag, ref) wins. Metadata you leave alone keeps the baseline's value. Checks behave differently. The comment in InSpec's own rule.rb says checks in the newer definition "completely eliminate" the ones already there, and the code does exactly that: if your redefinition contains any describe blocks at all, they replace the baseline's whole set for that control. Write one and you get one. In the overlay above, the ClientAliveCountMax line is repeated for that reason. Delete it and that check stops running, the report still shows 5.2.16 as passing, and nobody finds out.

terminal
$ inspec exec . -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \
--controls cis-dil-benchmark-5.2.16
output
Profile: Acme Linux Baseline (CIS overlay) (acme-linux-overlay)
Version: 1.4.0
Target: ssh://ops@web1:22
Target ID: 9c4f1a02-6b1e-4f77-9a5d-2f0b7c3e8d14
No tests executed.
Profile: CIS Distribution Independent Linux Benchmark Profile (cis-dil-benchmark)
Version: 0.4.12
Target: ssh://ops@web1:22
Target ID: 9c4f1a02-6b1e-4f77-9a5d-2f0b7c3e8d14
✔ cis-dil-benchmark-5.2.16: Ensure SSH Idle Timeout Interval is configured (Scored)
✔ SSHD Configuration ClientAliveInterval is expected to cmp <= 900
✔ SSHD Configuration ClientAliveCountMax is expected to cmp <= 0
Profile Summary: 1 successful control, 0 control failures, 0 controls skipped
Test Summary: 2 successful, 0 failures, 0 skipped
Tailoring fails silently in both directions
A bare skip_control leaves no trace at all: no skipped row, no reason, no effect on the exit code, so continuous integration cannot notice the gap even in principle. A redefined control silently drops any its() line you did not repeat, and then reports as passing. Both failure modes look identical to a green pipeline. Treat every skip as a risk decision with an owner, a ticket and an expiry date, and where you can, use a waiver file instead (loaded with --waiver-file, and the subject of the next lesson). A waiver keeps the control in the report with the justification attached, so the exception is visible rather than deleted.

Retune the Base With Inputs

Inputs are the dials the baseline's author left on the outside of the box, so you can change a number without opening anything up. They were called attributes before InSpec 4. cis-dil-benchmark declares exactly one, named cis_level, a number that defaults to 2, and it guards its level-2 controls with only_if { cis_level == 2 }, a condition InSpec evaluates at run time to decide whether a control is worth testing at all. Set that input to 1 and 41 controls stop evaluating, without you naming a single control identifier. You set it from your own inspec.yml using a profile key that names the dependency, because since InSpec 4 every input belongs to exactly one profile. Leave that key out and you have set an input on yourself, while the base carries on with its default.

inspec.yml
inputs:
- name: cis_level
value: 1 # audit this fleet at CIS level 1
profile: cis-dil-benchmark # required: inputs are namespaced per profile

Values can arrive from several places at once, so InSpec scores each source and the highest number wins. An inline input() call in control code is 20. The profile's own inspec.yml is 30. A wrapping profile's inspec.yml is 35. --input-file is 40. --input on the command line is 50. That ordering is why your overlay's 35 beats the baseline's own 30. It is also why anyone can override your overlay from the shell with --input without touching a file, which is useful during an incident and worth keeping in mind when you read somebody else's evidence.

terminal
$ inspec exec . -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \
--reporter cli json:run.json
$ echo $?
output
Profile: Acme Linux Baseline (CIS overlay) (acme-linux-overlay)
Version: 1.4.0
Target: ssh://ops@web1:22
Target ID: 9c4f1a02-6b1e-4f77-9a5d-2f0b7c3e8d14
✔ acme-fim-01: File integrity monitoring agent is installed and running
✔ System Package wazuh-agent is expected to be installed
✔ Service wazuh-agent is expected to be running
✔ Service wazuh-agent is expected to be enabled
✔ File /var/ossec/etc/ossec.conf content is expected to match /<syscheck>/
Profile: CIS Distribution Independent Linux Benchmark Profile (cis-dil-benchmark)
Version: 0.4.12
Target: ssh://ops@web1:22
Target ID: 9c4f1a02-6b1e-4f77-9a5d-2f0b7c3e8d14
↺ cis-dil-benchmark-1.1.6: Ensure separate partition exists for /var
↺ Skipped control due to only_if condition.
× cis-dil-benchmark-5.2.7: Ensure SSH MaxAuthTries is set to 4 or less (Scored)
× SSHD Configuration MaxAuthTries is expected to cmp <= 4
expected it to be <= 4
got: 6
(compared using `cmp` matcher)
✔ cis-dil-benchmark-5.2.16: Ensure SSH Idle Timeout Interval is configured (Scored)
✔ SSHD Configuration ClientAliveInterval is expected to cmp <= 900
✔ SSHD Configuration ClientAliveCountMax is expected to cmp <= 0
... 232 more controls in this profile, output trimmed ...
Profile Summary: 180 successful controls, 8 control failures, 48 controls skipped
Test Summary: 397 successful, 11 failures, 48 skipped
100

Read what that exit code does and does not tell you. InSpec exits 0 when everything passed, 100 when at least one test failed, and 101 when tests were skipped and nothing failed. Those 48 skips would leave an otherwise clean run sitting at 101, which is why pipelines that treat any non-zero code as a failure either handle 101 on purpose or pass --no-distinct-exit, which folds a skip-only run back to 0 and collapses failures to a plain 1. Forty-one of the skips are the cis_level gate you just set. The other seven are controls the baseline guards with mount checks, for separate partitions this host does not have. The two controls you removed with skip_control contribute nothing to any of it. They are not skipped. They are absent, and no exit code will ever mention them.

Prove the Overlay Did What You Think

Green is not proof. Every tailoring move in this lesson can go wrong in a way that still produces a passing run, so interrogate the machine-readable report rather than the pretty one. Ask run.json three questions. Did the control count drop by exactly the number you skipped? Is the removed identifier really absent? Did your override attach itself to the base's control, or did it quietly create a lookalike inside your own profile? jq (a command-line filter for pulling fields out of JSON) answers all three in about ten seconds.

terminal
$ jq -r '.profiles[] | "\(.name): \(.controls | length) controls"' run.json
$ jq -r '.profiles[].controls[].id' run.json | grep -c '^cis-dil-benchmark-1\.3\.'
$ jq '.profiles[].controls[]? | select(.id == "cis-dil-benchmark-5.2.16") | .impact' run.json
output
acme-linux-overlay: 1 controls
cis-dil-benchmark: 235 controls
0
0.7

235 instead of 237 says both skips landed. A count of zero for the 1.3 prefix says neither AIDE control reached the report in any form. And 0.7 says your override merged into the baseline's control rather than sitting beside it. If that last number came back as 1.0, your control block was outside the include_controls do...end block, which is the classic mistake here. Outside the block you are defining a brand new control that belongs to your profile, so the same identifier appears twice in the report under two profile headings, while the base's original test carries on running untouched. Control identifiers are scoped per profile, which is what makes the duplicate legal and easy to miss. Note also that only acme-fim-01 counts as yours: the 5.2.16 override merged into the dependency, so it is reported under the baseline's name.

terminal
$ inspec check /home/ops/acme-linux-overlay
output
Location : /home/ops/acme-linux-overlay
Profile : acme-linux-overlay
Controls : 2
Timestamp : 2026-07-22T09:41:07+00:00
Valid : true
No errors, warnings, or offenses

inspec check validates a profile without running it: metadata fields, controls with no title, no description or no tests, and control files that will not parse. Read that Controls count carefully, because it catches people out. It says 2, not 236. check reads the control blocks written in your own controls/ directory and never evaluates the dependency, so it will never show you the merged picture. For an overlay, check earns its place a different way: it compares inspec.yml against inspec.lock and fails with "inspec.yml and inspec.lock are out-of-sync. Please re-vendor with inspec vendor." the moment you edit depends and forget to re-vendor. That exits 1, which makes it a cheap first stage in continuous integration, ahead of spending an SSH connection on a real host.

Give the overlay the review weight of a firewall change. Two approvers, one from the team that runs the hosts and one from the team that answers the auditor, plus a standing rule that no skip_control merges without a ticket identifier in the comment directly above it. When you bump the base's tag, run the old profile and the new one against the same host with --reporter json and diff the two files on control identifiers and impacts. The controls that appear, vanish or change severity between those two reports are the compliance change you are putting your name to. That diff is also the only honest answer to "what changed in our baseline this quarter", whether the base uses short dotted identifiers like cis-dil-benchmark-5.2.16 or the long XCCDF (Extensible Configuration Checklist Description Format) ones such as xccdf_org.cisecurity.benchmarks_rule_1.1.1.1 that machine-generated profiles carry.

Quick check
01Your overlay lists cis-dil-benchmark under depends, and controls/overlay.rb contains include_controls 'cis-dil-benchmark'. What does that give you?
Incorrect — Inheritance never copies control files in for editing; editing the baseline in place is the fork you are trying to avoid.
Correct — include_controls executes the whole dependency each time your profile runs.
Incorrect — That describes require_controls, which allow-lists instead of including everything.
Incorrect — No such flag exists; a declared and included dependency runs as part of inspec exec.
02Inside include_controls you redefine cis-dil-benchmark-5.2.16 with a single its('ClientAliveInterval') line and nothing else. What happens to that control's ClientAliveCountMax check from the baseline?
Incorrect — Redefinition is the supported way to tailor a control, so InSpec merges the two definitions rather than erroring.
Incorrect — Metadata merges, but checks do not stack; a redefinition containing any checks replaces the lot.
Correct — the merge wipes the original checks and installs yours in their place.
Incorrect — Impact is metadata and has no bearing on which describe blocks execute.
03Your overlay skips cis-dil-benchmark-1.3.1 with skip_control. The nightly run exits 0, and grepping the control identifiers in run.json for that id returns nothing. An auditor asks how the file integrity requirement is covered. What is true, and what should you do?
Incorrect — skip_control never touches impact, and the control did not execute at all.
Incorrect — A skipped row would push an otherwise clean run to 101, and no such row exists in the report.
Incorrect — Not Applicable comes from an impact 0.0 override read with --enhanced-outcomes, not from skip_control.
Correct — the rule is deleted from the registry, so only a waiver or your own compensating control leaves evidence behind.

Try this

Run inspec vendor --overwrite 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 profile you depend on is code that runs. 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