Inheritance & overlays
Reuse and tailor baselines.
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.
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.
name: acme-linux-overlaytitle: Acme Linux Baseline (CIS overlay)maintainer: Platform Securityversion: 1.4.0supports:- platform-family: linuxdepends:- name: cis-dil-benchmarkgit: https://github.com/dev-sec/cis-dil-benchmark.gittag: 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.
$ inspec vendor --overwrite
Dependencies for profile /home/ops/acme-linux-overlay successfully vendored to /home/ops/acme-linux-overlay/vendor
---lockfile_version: 1depends:- name: cis-dil-benchmarkresolved_source:git: https://github.com/dev-sec/cis-dil-benchmark.gitref: 18fd9203d64e67f04d64fff9c5b60cd2b4065953version_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.
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.
# 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' doimpact 0.7describe sshd_config doits('ClientAliveInterval') { should cmp <= 900 }its('ClientAliveCountMax') { should cmp <= 0 }endendend
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.
control 'acme-fim-01' doimpact 1.0title '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') doit { should be_installed }enddescribe service('wazuh-agent') doit { should be_running }it { should be_enabled }enddescribe file('/var/ossec/etc/ossec.conf') doits('content') { should match(/<syscheck>/) }endend
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.
# 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' docontrol 'cis-dil-benchmark-5.2.10' # root login disabledcontrol 'cis-dil-benchmark-5.2.11' # empty passwords refusedcontrol 'cis-dil-benchmark-5.2.16' do # metadata only, see belowimpact 0.7endend
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.
$ inspec exec . -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \--controls cis-dil-benchmark-5.2.16
Profile: Acme Linux Baseline (CIS overlay) (acme-linux-overlay)Version: 1.4.0Target: ssh://ops@web1:22Target ID: 9c4f1a02-6b1e-4f77-9a5d-2f0b7c3e8d14No tests executed.Profile: CIS Distribution Independent Linux Benchmark Profile (cis-dil-benchmark)Version: 0.4.12Target: ssh://ops@web1:22Target 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 <= 0Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 2 successful, 0 failures, 0 skipped
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.
inputs:- name: cis_levelvalue: 1 # audit this fleet at CIS level 1profile: 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.
$ inspec exec . -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \--reporter cli json:run.json$ echo $?
Profile: Acme Linux Baseline (CIS overlay) (acme-linux-overlay)Version: 1.4.0Target: ssh://ops@web1:22Target 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.12Target: ssh://ops@web1:22Target 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 <= 4expected it to be <= 4got: 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 skippedTest Summary: 397 successful, 11 failures, 48 skipped100
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.
$ 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
acme-linux-overlay: 1 controlscis-dil-benchmark: 235 controls00.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.
$ inspec check /home/ops/acme-linux-overlay
Location : /home/ops/acme-linux-overlayProfile : acme-linux-overlayControls : 2Timestamp : 2026-07-22T09:41:07+00:00Valid : trueNo 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.
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.