Profiles, inputs & metadata
Package and parameterize controls.
A control on its own is one line on a checklist taped to a wall. A profile is the whole clipboard: the checklist, a cover sheet naming the standard and the team that owns it, and blank fields where the inspector writes the numbers for this particular building. InSpec means something precise by that. A profile is a directory with a fixed layout the tool knows how to load, version, publish and depend on. Inputs are the blank fields, so the same controls can check a developer laptop this morning and a hardened production fleet this afternoon without anybody editing a line of Ruby.
Skip the packaging step and you meet the failure mode every security team eventually meets. Somebody copies four useful controls into their own repository and edits the port number. Six months later, five repositories hold five slightly different baselines, three of them stale, and nobody can answer the question an auditor actually asks: which rules, at which version, ran against that host, on which day. A profile answers that in the first three lines of its own report, because the name, the version and the title travel with the code.
Let the Generator Lay Out the Box
The layout is fixed, so building the folders by hand wins you nothing except a chance to misspell one. Let the generator do it. Everything below behaves the same under CINC Auditor, the community rebuild of InSpec, where the command is spelled cinc-auditor instead of inspec. That project exists for a reason worth knowing. The source code stays Apache-2.0 (a permissive open-source licence), but the official InSpec 6 builds shipped by Progress now need a paid commercial licence, and CINC Auditor is built from that same source with nothing to buy.
$ inspec init profile ssh-baseline
───────────────────────────── InSpec Code Generator ─────────────────────────────Creating new profile at /home/ops/ssh-baseline• Creating directory /home/ops/ssh-baseline• Creating file README.md• Creating directory controls• Creating file controls/example.rb• Creating file inspec.yml
$ find ssh-baseline | sort
ssh-baselinessh-baseline/README.mdssh-baseline/controlsssh-baseline/controls/example.rbssh-baseline/inspec.yml
Every .rb file under controls/ is loaded automatically, subdirectories included. There is no manifest listing them, so splitting your checks into sshd.rb, packages.rb and users.rb is a kindness to the next human reading the repository, nothing more. Two directories the generator leaves to you, and both earn their keep. libraries/ holds custom resources, the Ruby you write when no built-in resource fits. files/ holds data the controls read rather than logic they run: an approved login banner, an allowlist of package versions, the exact policy wording counsel signed off. You reach into it with inspec.profile.file, which resolves relative to the profile no matter which directory you launched the run from, and the fixture then travels inside the packaged profile alongside the controls.
# Data lives in files/, logic stays in controls/. The fixture ships with the profile.expected_banner = inspec.profile.file('issue.net')control 'ssh-04' doimpact 0.3title 'Pre-login banner matches the approved text'desc 'Legal wording is set by counsel; see files/issue.net.'describe file('/etc/issue.net') doit { should exist }its('content') { should cmp expected_banner }endend
inspec.yml Is the Label on the Box
An unlabelled tin is still soup. Nobody serves it, because nobody can say what is inside or when it was made. inspec.yml is the label, and every field on it answers a question somebody asks later, usually under pressure.
name: ssh-baseline # the profile's real identity, not the folder nametitle: SSH Server Baselinemaintainer: Platform Securitycopyright: Acme Ltdcopyright_email: [email protected]license: Apache-2.0 # an SPDX id, or the word Proprietarysummary: Hardening checks for the OpenSSH serverversion: 1.2.0 # semver, and inspec check errors if the shape is wrongsupports:- platform-family: linuxinspec_version: ">= 5.0"inputs:- name: ssh_porttype: numericvalue: 22 # a sane default any environment may overridedescription: Port sshd is expected to be listening on- name: allowed_userstype: arrayrequired: true # no default: every run must state its own answerdescription: Accounts permitted to hold an interactive shell- name: scan_tokentype: stringsensitive: true # printed as *** by reporters that list inputsdescription: Token the profile presents to the internal scan service
The name field is the profile's identity. Call the folder whatever you like; name is what another profile writes in its depends list, and what a wrapper profile uses to push a value down into this one. The version follows semver (semantic versioning, the MAJOR.MINOR.PATCH numbering scheme), so consumers have something to pin, and getting the shape wrong is an error rather than a gentle nudge: the whole profile comes back invalid. The title does real work as well. It prints at the top of every report, so an auditor reading evidence next winter sees a human name and not a folder path. supports scopes the profile to platforms, which stops a Linux-only profile flailing at a Windows host. inspec_version guards against a CLI (command line interface, the inspec command itself) too old for the syntax you wrote. And license wants an SPDX identifier, from the Software Package Data Exchange list of standard licence names, where Apache-2.0 is valid and Apache 2.0 with a space is not.
Say you typed the version as 1.2, wrote the licence with a space, and left the root-login control as a title with nothing under it while you were still thinking. inspec check parses the whole profile and never touches a target, which makes it cheap enough to run on every commit.
$ inspec check ssh-baseline
Location : /home/ops/ssh-baselineProfile : ssh-baselineControls : 4Timestamp : 2026-07-22T09:41:12+00:00Valid : false× Version needs to be in SemVer format! License 'Apache 2.0' needs to be in SPDX format or marked as 'Proprietary'. See https://spdx.org/licenses/.! Control ssh-03 has no tests defined
The first line is an error, and it alone drops Valid to false. The other two are warnings. Pay attention to the third one, because it is the quiet killer. A control with an id, a title, an impact and no describe block inside it holds nothing that can fail, so it never goes red, while the control count in your evidence goes up by one anyway. Depending on the reporter it either disappears from the run or shows as a green line with no tests under it, and nobody reads a green line. Wire inspec check into the pipeline and fail the job on warnings as well as on a non-zero exit, because check exits 0 for a warning and only a genuinely invalid profile pushes it above zero. Do that and the stub gets caught the day it is written rather than during an incident. Fix all three now: bump the version to 1.2.0, write the licence as Apache-2.0, and give ssh-03 the describe block it was owed.
Inputs Are the Blanks on the Form
An input is the dial on a thermostat. You turn it without rewiring the boiler. Declare it once in inspec.yml with a name, a type and usually a default, then read it inside a control with input('name'). Declaring it centrally buys three things a bare Ruby variable scattered through a control file cannot. The type is checked when the value resolves. There is one place to look to see what is tunable. And callers outside the profile can override it at run time. required: true is the stronger form. An input with no default and required: true fails any control that reads it without a value, which is how you make each environment state its own answer instead of silently inheriting somebody else's.
control 'ssh-01' doimpact 0.7title 'sshd listens only on the approved port'desc 'The port comes from an input, so one profile covers 22 and 2222.'describe port(input('ssh_port')) doit { should be_listening }endendcontrol 'ssh-02' doimpact 0.7title 'Only approved accounts hold an interactive shell'desc 'The allowlist is an input, reviewed per environment.'passwd.where { shell !~ /(nologin|false)$/ }.users.each do |account|describe "interactive account: #{account}" dosubject { account }it { should be_in input('allowed_users') }endendendcontrol 'ssh-03' doimpact 1.0title 'Root login over SSH is disabled'desc 'No input here on purpose. This one is never negotiable.'describe sshd_config doits('PermitRootLogin') { should cmp 'no' }endend
# One file per environment. This is policy, so it belongs in review.ssh_port: 2222allowed_users:- root- deploy
$ inspec exec ssh-baseline -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \--input-file inputs-prod.yml; echo "exit=$?"
Profile: SSH Server Baseline (ssh-baseline)Version: 1.2.0Target: ssh://ops@web1:22Target ID: 0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0× ssh-01: sshd listens only on the approved port (1 failed)× Port 2222 is expected to be listeningexpected `Port 2222.listening?` to return true, got false✔ ssh-02: Only approved accounts hold an interactive shell✔ ssh-03: Root login over SSH is disabled✔ ssh-04: Pre-login banner matches the approved textProfile Summary: 3 successful controls, 1 control failure, 0 controls skippedTest Summary: 5 successful, 1 failure, 0 skippedexit=100
That failure is the profile earning its keep. Production policy says sshd (the Secure Shell server, the background program that accepts remote logins) listens on 2222, the input file says 2222, and web1 came back from a rebuild answering on 22. The clue is sitting in the header: the Target line shows the run reached the host over port 22. Run the same command without --input-file and the built-in default of 22 would have made that drift invisible, a comfortable green row about a host that quietly reverted. The exit code carries the verdict for whatever is scripting around the run: 0 when everything passed, 100 when at least one control failed, 101 when controls were skipped and none failed, and 1 for a usage or connection error.
Which Value Wins, and Why the Ladder Is Shaped That Way
Set the same input in five places and InSpec does not argue with you. It takes the highest priority number and moves on. Read the ladder from the bottom up and it describes how you should actually work. Sane defaults live in the profile, documented and reviewed alongside the controls. Environment facts live in an input file, checked into the repository that owns that environment. The command line stays reserved for experiments. Watch what that means in practice.
$ inspec exec ssh-baseline -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \--input-file inputs-prod.yml --input ssh_port=22; echo "exit=$?"
Profile: SSH Server Baseline (ssh-baseline)Version: 1.2.0Target: ssh://ops@web1:22Target ID: 0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0✔ ssh-01: sshd listens only on the approved port✔ ssh-02: Only approved accounts hold an interactive shell✔ ssh-03: Root login over SSH is disabled✔ ssh-04: Pre-login banner matches the approved textProfile Summary: 4 successful controls, 0 control failures, 0 controls skippedTest Summary: 6 successful, 0 failures, 0 skippedexit=0
Priority 50 beat priority 40, the control went green, and nothing whatsoever changed on the host. That is the right tool for reproducing a colleague's result on your laptop and the wrong tool for making a red build go away, which is where compliance theatre starts. If production genuinely moved to port 22, the edit belongs in inputs-prod.yml where a reviewer sees it in a diff. Two more knobs are worth knowing. A declaration can carry profile: some-other-profile, which sets an input inside a profile you depend on rather than your own, so a wrapper tunes a vendor baseline without forking it. And priority: takes a plain integer, highest number wins, on the declarations whose priority you are allowed to change (inline in the control code, and entries in inspec.yml), so you can deliberately raise a profile default above an input file when a value must never be loosened downstream. Command line values are parsed as YAML (a plain-text format for structured data), so --input allowed_users='[root,deploy]' hands over a two-item list rather than a lump of text.
Inputs Land in the Report, Including the Ones You Regret
The JSON reporter (JavaScript Object Notation, the machine-readable report format) writes an inputs list for every profile in the run. That is useful evidence, because the record then shows which rules ran and what they were told to expect. It is also a leak waiting for its moment, since pipelines upload that file as a build artifact where anyone with read access to the job can fetch it. A port number is fine there. A credential is not. Marking an input sensitive: true swaps its value for three asterisks in the reporters that list inputs.
$ inspec exec ssh-baseline -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \--input-file inputs-prod.yml --input scan_token="$SCAN_TOKEN" \--reporter json:run.json$ jq '.profiles[0].inputs // .profiles[0].attributes' run.json
[{"name": "ssh_port","options": {"description": "Port sshd is expected to be listening on","type": "Numeric","value": 2222}},{"name": "allowed_users","options": {"description": "Accounts permitted to hold an interactive shell","type": "Array","required": true,"value": ["root","deploy"]}},{"name": "scan_token","options": {"description": "Token the profile presents to the internal scan service","type": "String","value": "***","sensitive": true}}]
The fallback in that jq expression is not paranoia. Current reports call the list inputs, while older reports and several downstream tools still carry the legacy name attributes, from the days before inputs were renamed. Either way, the redaction covers the report and not the machine. A secret passed as --input scan_token=... sits in the process list where every local user can read it for as long as the run lasts, and in your shell history forever after. Write it into an input file at run time from your secret manager instead, with permissions of 0600 (the owner can read and write it, nobody else can do either), and delete the file when the run ends. While you are handling that file carefully, treat it as policy too. Adding a name to allowed_users in a YAML file changes what compliant means exactly as much as editing the control did, so input files belong in review with the same seriousness as the Ruby.
The Profile That Checks Nothing and Still Exits 0
supports is a door policy: this profile is for Linux hosts, and anything else gets waved past without a word. The price of that politeness is that a mismatch is silent by design. Suppose somebody tightened the stanza while chasing a false positive on one old box.
supports:- platform-name: ubunturelease: '20.04'
Then the fleet gets rebuilt on 22.04 and nobody thinks to revisit the metadata. Here is what the nightly scan looks like on web9 after the rebuild.
$ inspec exec ssh-baseline -t ssh://ops@web9 -i ~/.ssh/id_ed25519; echo "exit=$?"
W, [2026-07-22T09:52:44.118301 #4821] WARN -- : Skipping profile: 'ssh-baseline' on unsupported platform: 'ubuntu/22.04'.Profile: SSH Server Baseline (ssh-baseline)Version: 1.2.0Target: ssh://ops@web9:22Target ID: 7c9e6679-7425-40de-944b-e07fc1f90ae7No tests executed.Test Summary: 0 successful, 0 failures, 0 skippedexit=0
$ inspec exec ssh-baseline -t ssh://ops@web9 --reporter json:run.json$ jq '[.profiles[].controls[].results[]] | length' run.json
0
Nothing failed because nothing ran, and the exit code says success. A pipeline gating on that number alone waves the host through, and an attacker inherits a box whose SSH configuration has not been looked at since the rebuild. The report is more honest than the exit code, marking that profile skipped rather than loaded, but nobody reads a report they believe is green. Defend on two fronts. Keep supports as loose as it can honestly be, preferring platform-family: linux to a pinned release, so a routine operating system upgrade does not switch your checks off. Then gate on evidence rather than colour: pull the result count out of the JSON report and fail the build when it drops below what that profile should produce. A number you assert on is far harder to lose than a summary line nobody reads.
Ship the Box, Pinned
The depends list pulls in other profiles by local path, git address, Chef Supermarket (the public registry you can browse with inspec supermarket profiles) or a plain tarball link, and your own profile then reaches into them with include_controls, which takes everything the dependency defines, or require_controls, which takes only the controls you name. inspec vendor downloads those dependencies into vendor/ and writes an inspec.lock recording the exact resolved source of each one. Commit that lock file. Without it, a re-scan next quarter measures whatever upstream happened to publish in the meantime, and "we were compliant in March" turns into a claim nobody can check. When the profile is ready to leave your repository, inspec archive turns it into a single compressed file.
$ inspec archive ssh-baseline
I, [2026-07-22T10:02:11.442019 #5140] INFO -- : Checking profile in /home/ops/ssh-baselineI, [2026-07-22T10:02:11.512744 #5140] INFO -- : Metadata OK.I, [2026-07-22T10:02:11.884502 #5140] INFO -- : Found 4 controls.I, [2026-07-22T10:02:11.884688 #5140] INFO -- : Control definitions OK.I, [2026-07-22T10:02:11.902114 #5140] INFO -- : Generate archive /home/ops/ssh-baseline-1.2.0.tar.gz.I, [2026-07-22T10:02:11.913377 #5140] INFO -- : Finished archive generation.
The archive takes its name from the metadata rather than the folder, which is the practical reason to keep version honest. Publish 1.2.0 and the artifact is ssh-baseline-1.2.0.tar.gz, and inspec exec will run it straight from that path or from a web address without unpacking anything first. Bump the version in the same commit that changes a control, and the header of the next scan report tells anyone reading the evidence exactly which set of rules produced it.
Try this
Run inspec init profile ssh-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: a Misspelled Input Fails Quietly, Not Loudly. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.