CIS/DISA baselines & waivers
Prebuilt controls; documented exceptions.
Nobody opening a restaurant writes the fire code themselves. The city hands you a printed book of rules, an inspector shows up with the same book, and where a rule genuinely cannot work in your building you apply for a variance and get it signed. Server hardening runs on the same arrangement. CIS (the Center for Internet Security) publishes Benchmarks, which are long per-operating-system hardening guides. DISA (the Defense Information Systems Agency, the US military's IT arm) publishes STIGs (Security Technical Implementation Guides), stricter, numbered rule by rule, each carrying a severity. Other people have already translated both into InSpec profiles. Your job is to fetch one, run it, fix what you can, and file a documented exception for the rest.
Somebody Already Wrote The Rules
Two sources cover most of what you need. The dev-sec project publishes free InSpec baselines: linux-baseline, ssh-baseline, mysql-baseline, and cis-dil-benchmark (the CIS Distribution Independent Linux Benchmark, the CIS rules written so they do not assume one particular distribution). MITRE's SAF (Security Automation Framework) maintains STIG-aligned profiles such as redhat-enterprise-linux-8-stig-baseline. A profile is a folder: Ruby control files plus an inspec.yml manifest that names it, says which platforms it supports, and lists the inputs it accepts. InSpec can fetch that folder from a URL, a git repository, or Chef Supermarket (Chef's public registry of profiles and cookbooks), so you can run one against a host without cloning anything first. inspec supermarket profiles lists what is published there, inspec supermarket info dev-sec/ssh-baseline shows the details of one, and inspec supermarket exec dev-sec/ssh-baseline runs it by name.
# Run DevSec's Linux baseline straight off GitHub, pinned to a released taginspec exec https://github.com/dev-sec/linux-baseline/archive/2.10.0.tar.gz \-t ssh://ops@web01 -i ~/.ssh/id_ed25519
Profile: DevSec Linux Security Baseline (linux-baseline)Version: 2.10.0Target: ssh://ops@web01:22Target ID: 5f8a1c33-6b1d-5a3e-9d80-1c4b7e2f9a10✔ os-01: Trusted hosts login✔ File /etc/hosts.equiv is expected not to exist✔ os-02: Check owner and permissions for /etc/shadow✔ File /etc/shadow is expected to exist✔ File /etc/shadow is expected to be owned by "root"...× os-05: Check login.defs (1 failed)✔ File /etc/login.defs is expected to exist× login.defs PASS_MAX_DAYS is expected to eq "60"expected: "60"got: "99999"(compared using ==)↺ os-05b: Check login.defs - RedHat specific↺ Skipped control due to only_if condition.× sysctl-01: IPv4 Forwarding (2 failed)× Kernel Parameter net.ipv4.ip_forward value is expected to eq 0expected: 0got: 1(compared using ==)× Kernel Parameter net.ipv4.conf.all.forwarding value is expected to eq 0expected: 0got: 1(compared using ==)Profile Summary: 55 successful controls, 2 control failures, 2 controls skippedTest Summary: 187 successful, 3 failures, 8 skipped
Two red controls, and they mean completely different things. PASS_MAX_DAYS of 99999 means passwords on that host expire in about 273 years, so a credential phished last spring still works today. That is a real finding and you fix the host. sysctl-01 is the awkward one. net.ipv4.ip_forward=1 tells the kernel to move packets between its own network interfaces. On a web server that is a present for whoever lands on it, because your box becomes the bridge from the exposed subnet into everything it can reach. On a Kubernetes worker node the same setting is mandatory, and switching it off stops pod traffic dead. Same failing check, opposite meaning. Hold that one, because the fix is not the one most people reach for.
Pin It, Don't Fork It
Treat a fetched baseline the way a lawyer treats a cited statute: name the exact edition, and do not scribble in the margins. That URL ends in 2.10.0.tar.gz for a reason. Point it at master.tar.gz instead and next quarter's scan quietly measures different rules than last quarter's, so a change in your score tells you nothing about a change in your fleet. Editing the control files is worse. Your next upgrade becomes an argument with a repository you do not control, and the sentence at the top of your evidence stops being "we ran DevSec 2.10.0 unmodified" and becomes "we ran our own version of it", which nobody can check against anything. Clone STIG profiles rather than streaming them, because you want to read a few hundred DoD (US Department of Defense) rules before you point them at production.
# STIG profiles: clone, pin to a released tag, and validate before you trust themgit clone https://github.com/mitre/redhat-enterprise-linux-8-stig-baselinegit -C redhat-enterprise-linux-8-stig-baseline checkout v2.2.0# inspec check loads the manifest and parses every control file, without a hostinspec check redhat-enterprise-linux-8-stig-baseline
Location : redhat-enterprise-linux-8-stig-baselineProfile : redhat-enterprise-linux-8-stig-baselineControls : 367Timestamp : 2026-07-22T09:14:02+00:00Valid : trueNo errors, warnings, or offenses
Those two commands buy you different things. inspec check never touches a target. It reads inspec.yml, parses every control file, and reports what it finds: broken metadata, a control with an empty ID, a control with no tests behind it, an impact value outside 0.0 to 1.0, dependencies you forgot to vendor. What it cannot tell you is whether these 367 rules are the right rules for your fleet. It only says the profile is well formed enough to run.
One practical wrinkle before you standardise on this across a fleet. Every supported Chef InSpec release, 5.0 and later, makes you accept the Chef licence agreement on first run, which in an unattended pipeline means exporting CHEF_LICENSE=accept-silent. Skip that and 5.22.80, the release this course pins, exits 172 without evaluating a single control. From InSpec 6 onward the packaged builds moved to a Progress commercial licence, so commercial use needs a licence key, and it adds two more licence codes, 173 and 174, for reasons that have nothing to do with your controls. The source in GitHub is still Apache-2.0; the packaged binaries are what carry the commercial terms. That gap is why CINC Auditor (CINC stands for "CINC Is Not Chef") exists: the same source, rebuilt by the community, with the trademarks and the licence gate removed. The command line is identical, so everything below works if you type cinc-auditor instead of inspec. Decide which binary your scanners run before four thousand nodes depend on the answer.
Read The Control Before You Reach For A Waiver
Look at what os-05 actually asserts: its('PASS_MAX_DAYS') { should eq login_defs_passmaxdays }, where that value arrives from input('login_defs_passmaxdays', value: '60'). dev-sec picked 60 days. Your policy says 90. That disagreement is a dial on the rule, not an exception to it, the same way a fire code lets the city set the inspection interval without anybody applying for a variance. InSpec calls those dials inputs (they were called attributes until InSpec 4). Put your values in a YAML (YAML Ain't Markup Language, the indented configuration format) file and pass --input-file. The baseline stays untouched, your report still names version 2.10.0, and the check now measures your policy instead of somebody else's.
# Values for inputs the profile already exposes. The names come from the# profile's control files and inspec.yml, not from you. Quote them: the control# compares with `eq`, which is strict about types, so 90 (integer) fails# against the string "90" that login.defs actually yields.login_defs_passmaxdays: '90'login_defs_passwarnage: '7'
# --controls narrows the run to one control (or a /regex/) while you iterateinspec exec https://github.com/dev-sec/linux-baseline/archive/2.10.0.tar.gz \-t ssh://ops@web01 -i ~/.ssh/id_ed25519 \--input-file inputs/web.yml \--controls os-05echo "exit=$?"
Profile: DevSec Linux Security Baseline (linux-baseline)Version: 2.10.0Target: ssh://ops@web01:22Target ID: 5f8a1c33-6b1d-5a3e-9d80-1c4b7e2f9a10× os-05: Check login.defs (1 failed)✔ File /etc/login.defs is expected to exist× login.defs PASS_MAX_DAYS is expected to eq "90"expected: "90"got: "99999"(compared using ==)Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 17 successful, 1 failure, 0 skippedexit=100
The expectation moved from "60" to "90", which proves the input landed. The control still fails, which proves this was never a paperwork problem. So fix the machine and re-run the same single control to confirm it. That loop, change the host then re-run with --controls, takes seconds, and it is the difference between believing you fixed something and knowing it.
ssh ops@web01 'sudo sed -i "s/^PASS_MAX_DAYS.*/PASS_MAX_DAYS\t90/" /etc/login.defs'inspec exec https://github.com/dev-sec/linux-baseline/archive/2.10.0.tar.gz \-t ssh://ops@web01 -i ~/.ssh/id_ed25519 \--input-file inputs/web.yml --controls os-05echo "exit=$?"
✔ os-05: Check login.defs✔ File /etc/login.defs is expected to exist✔ login.defs PASS_MAX_DAYS is expected to eq "90"Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 18 successful, 0 failures, 0 skippedexit=0
Green, and still incomplete. /etc/login.defs holds the defaults that useradd copies into an account at the moment it is created, like a form's pre-filled fields. Every account that already exists on that box keeps whatever expiry it was born with. The control passes and the deploy user's password is still immortal. Close that with chage -M 90 deploy per account, then check it with the InSpec shadow resource. Any baseline can go green while the risk sits untouched, which is why you read what a control asserts instead of trusting the tick.
Now go back to sysctl-01 and read its first line instead of its result. dev-sec wrote only_if { sysctl_forwarding == false && !container_execution }, and sysctl_forwarding is an input that defaults to false. The author already thought about routers. Set that input to true for your Kubernetes workers and both sysctl-01 and sysctl-19, its IPv6 twin, take themselves out of the run, with the report saying Skipped control due to only_if condition. No fork, no exception paperwork, nothing to renew in three months. Read the control source before you write a waiver, because a well-maintained baseline often ships the escape hatch you were about to invent.
# One input file per class of machine. sysctl_forwarding is a real input in# linux-baseline 2.10.0: it gates the only_if guard on sysctl-01 (IPv4) and# sysctl-19 (IPv6), so a routing host stops failing rules it can never meet.sysctl_forwarding: truelogin_defs_passmaxdays: '90'login_defs_passwarnage: '7'
A Waiver Is A Signed Variance
Some rules have no dial. package-06 says a TFTP (trivial file transfer protocol) server must not be installed, and it is right about almost every machine you own. It is wrong about netboot01, which exists to hand boot images over TFTP to machines doing a PXE (preboot execution environment) install. There is no input for that. Deleting the control from a fork makes the problem vanish from the report, which is the same thing as lying to whoever reads the report. A waiver is the honest version: the rule stays in the run, and the failure is replaced by a dated, signed exception. Write it as YAML, JSON (JavaScript Object Notation), or CSV (comma-separated values), keyed by the exact control ID. justification is required. expiration_date is optional, takes plain YYYY-MM-DD, and lapses at 00:00 local time on the system running the scan. run decides whether the check still executes, and leaving it out is not the same as switching it off.
# Top-level keys are exact control IDs from the profile being run.# InSpec does not fuzzy-match: package-6 or PACKAGE-06 matches nothing at all.package-06:justification: "netboot01 is the datacentre PXE server; serving images over tftp is its job. tftpd listens on the provisioning VLAN only and is blocked at the host firewall everywhere else. Reviewed by @platform-sec 2026-07-15."expiration_date: 2026-10-01run: falsepackage-08:justification: "auditd rollout is scheduled for Q4, tracked in OPS-812. Accepted for this quarter; keep the check running so the gap shows up in every nightly report."expiration_date: 2026-10-31run: true
run: false is the strong form. InSpec never evaluates the control, marks it skipped, and prints your justification word for word as the skip reason, which is why that field should read like something you would say to an auditor rather than "pxe thing". run: true keeps the check running and leaves the real result in the report with the waiver attached beside it as structured data. Leaving the key out gives you the same thing: InSpec skips a control only when run is explicitly false, so an entry with no run key still executes and still reports whatever it finds. Write the key anyway, so that nobody reading the file has to know that rule. Use false for "this rule cannot apply to this kind of machine" and true for "this is a genuine gap, we accepted it, and we still want to see it every night". Pass several files with --waiver-file waivers/base.yml waivers/netboot.yml; they merge into one map in the order you list them, so the later file wins on a duplicate control ID. A CSV waiver file needs a control_id column and a justification column, with run and expiration_date optional.
inspec exec https://github.com/dev-sec/linux-baseline/archive/2.10.0.tar.gz \-t ssh://ops@netboot01 -i ~/.ssh/id_ed25519 \--waiver-file waivers/netboot.yml \--reporter cli json:results.jsonecho "exit=$?"
Profile: DevSec Linux Security Baseline (linux-baseline)Version: 2.10.0Target: ssh://ops@netboot01:22Target ID: 9d7c1f04-2b55-5e19-8f31-77a0c4d8b612↺ package-06: Do not install tftp server↺ Skipped control due to waiver condition: netboot01 is the datacentre PXE server; serving images over tftp is its job. tftpd listens on the provisioning VLAN only and is blocked at the host firewall everywhere else. Reviewed by @platform-sec 2026-07-15.× package-08: Install auditd (12 failed)× System Package auditd is expected to be installedexpected that `System Package auditd` is installed× auditd_conf log_file is expected to cmp == "/var/log/audit/audit.log"expected: "/var/log/audit/audit.log"got: nil(compared using `cmp` matcher)...Profile Summary: 55 successful controls, 1 control failure, 3 controls skippedTest Summary: 175 successful, 12 failures, 11 skippedexit=100
Exit Codes Are The Only Thing Your Pipeline Reads
Read that last line again, because it surprises people. A merge gate never opens your beautiful HTML report. It reads one number, the way a doorman reads the colour of a wristband. InSpec returns 0 when everything passed, 100 when any control failed, 101 when controls were skipped and nothing failed, and 1 for everything it files under usage or general error, which is where a profile that will not load and a waiver file it cannot parse both end up. The counting is done by bucket, straight from the test results, and a waived run: true control that fails still lands in the failed bucket. One control here holds twelve tests, which is also why the two summary lines disagree. Chef's documentation describes a run: true waiver as not failing the overall run; what that actually buys you is the waiver record in the JSON evidence, which downstream tools such as Chef Automate and MITRE Heimdall use to classify the result as waived. The local exit code is unmoved at 100. Print $? on your own version before you build a gate on top of it.
The mirror-image trap is run: false. Skipped controls push a clean run from 0 up to 101, so a pipeline that treats any non-zero code as failure goes red precisely because you did the paperwork properly. only_if skips do the same thing, which means the tidy input-file fix on your Kubernetes workers has the same side effect. If you want waived controls out of the arithmetic altogether, --filter-waived-controls strips them from the run before it starts; it requires --waiver-file and it ignores the run key entirely. Its companion --retain-waiver-data puts the waiver records back into the report, though InSpec's own help still labels that one experimental. Either way, handle the codes yourself rather than leaning on set -e.
#!/usr/bin/env bash# No -e: we want to read InSpec's exit code, not be killed by it.set -uo pipefailinspec exec "$PROFILE" -t "ssh://$TARGET" -i "$SSH_KEY" \--input-file "inputs/$HOST_CLASS.yml" \--waiver-file waivers/base.yml "waivers/$HOST_CLASS.yml" \--reporter cli json:results.json html2:report.html junit2:junit.xmlrc=$?case "$rc" in0) echo "clean" ;;101) echo "skips only (waivers or only_if guards) - review results.json" ;;100) echo "control failures"; exit 1 ;;1) echo "usage error: profile would not load, or a waiver date would not parse"; exit 1 ;;172|173|174) echo "licence problem, not a security finding"; exit 1 ;;*) echo "inspec itself failed (rc=$rc)"; exit "$rc" ;;esac
The evidence side is where waivers earn their keep. The JSON reporter attaches a waiver_data block to every affected control, and that block is the machine-readable version of the signed variance: who accepted what, why, and until when. Pull it back out with jq (a command line JSON processor) and you have a review sheet for the exceptions meeting without anybody opening a spreadsheet.
jq -r '.profiles[].controls[]| select(.waiver_data.justification != null)| {id, run: .waiver_data.run,skipped: .waiver_data.skipped_due_to_waiver,expires: .waiver_data.expiration_date,why: .waiver_data.justification}' results.json
{"id": "package-06","run": false,"skipped": true,"expires": "2026-10-01","why": "netboot01 is the datacentre PXE server; serving images over tftp is its job. tftpd listens on the provisioning VLAN only and is blocked at the host firewall everywhere else. Reviewed by @platform-sec 2026-07-15."}{"id": "package-08","run": true,"skipped": false,"expires": "2026-10-31","why": "auditd rollout is scheduled for Q4, tracked in OPS-812. Accepted for this quarter; keep the check running so the gap shows up in every nightly report."}
When The Baseline Moves Under You
Waivers decay in two quiet ways. The first is time. Once expiration_date is in the past InSpec stops applying that waiver and evaluates the control normally, which is correct behaviour and also means a rule you handled in April can turn a pipeline red in July with no code change to blame. It does leave a fingerprint: the control's waiver_data.message reads Waiver expired on ..., evaluating control normally. The second is renumbering. STIG revisions add, retire, and renumber rules with every release. Moving the RHEL 8 baseline from v1.14.1 to v2.2.0 retires nine controls and adds one, dropping 375 rules to 367. A waiver keyed to an ID that no longer exists matches nothing, produces no waiver_data at all, and disappears without a single warning. Diff the control IDs every time you bump a baseline version.
# What changed between the version your waivers were written for and the new one?inspec export --format json rhel8-stig-v1.14.1 | jq -r '.controls[].id' | sort > /tmp/ids-old.txtinspec export --format json rhel8-stig-v2.2.0 | jq -r '.controls[].id' | sort > /tmp/ids-new.txtdiff /tmp/ids-old.txt /tmp/ids-new.txt | head -6# Any waiver key that is not a control ID in the new profile is dead paperwork (yq v4)comm -23 <(yq 'keys | .[]' waivers/rhel8.yml | sort) /tmp/ids-new.txt# And which waivers stopped applying because they aged out?jq -r '.profiles[].controls[] | select(.waiver_data.message // "" != "")| "\(.id) \(.waiver_data.message)"' results.json
125,127d124< SV-230348< SV-230349< SV-230350130d126< SV-230353SV-230348SV-230221 Waiver expired on 2026-06-30 00:00:00 +0000, evaluating control normally
Unable to parse waiver expiration date '...' for control <id> and a usage-level exit of 1 before a single check runs. Leave the date unquoted and use plain YYYY-MM-DD. The opposite mistake is quieter and worse: a waiver with no expiration_date never expires, so it becomes a permanent blind spot carrying a justification nobody has read since the engineer who wrote it left the company. Set dates to a review cadence you actually keep. Quarterly, not 2099.Make those three checks a pre-merge job on the waivers directory itself. Every key must exist in the pinned profile, every justification must be longer than a shrug, and any waiver expiring inside the next sprint fails the check so that somebody renews it deliberately. Ten lines of shell, and it is what separates a waiver file that documents your exceptions from one that hides them.
Try this
Run git clone https://github.com/mitre/redhat-enterprise-linux-8-stig-baseline 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: waiver files have no idea which host they are for. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.