CoursesInSpecProfiles, inputs & metadata

Profiles, inputs & metadata

Package and parameterize controls.

Intermediate12 min · lesson 6 of 12

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.

terminal
$ inspec init profile ssh-baseline
output
───────────────────────────── 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
terminal
$ find ssh-baseline | sort
output
ssh-baseline
ssh-baseline/README.md
ssh-baseline/controls
ssh-baseline/controls/example.rb
ssh-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.

controls/banner.rb
# Data lives in files/, logic stays in controls/. The fixture ships with the profile.
expected_banner = inspec.profile.file('issue.net')
control 'ssh-04' do
impact 0.3
title 'Pre-login banner matches the approved text'
desc 'Legal wording is set by counsel; see files/issue.net.'
describe file('/etc/issue.net') do
it { should exist }
its('content') { should cmp expected_banner }
end
end

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.

ssh-baseline/inspec.yml
name: ssh-baseline # the profile's real identity, not the folder name
title: SSH Server Baseline
maintainer: Platform Security
copyright: Acme Ltd
copyright_email: [email protected]
license: Apache-2.0 # an SPDX id, or the word Proprietary
summary: Hardening checks for the OpenSSH server
version: 1.2.0 # semver, and inspec check errors if the shape is wrong
supports:
- platform-family: linux
inspec_version: ">= 5.0"
inputs:
- name: ssh_port
type: numeric
value: 22 # a sane default any environment may override
description: Port sshd is expected to be listening on
- name: allowed_users
type: array
required: true # no default: every run must state its own answer
description: Accounts permitted to hold an interactive shell
- name: scan_token
type: string
sensitive: true # printed as *** by reporters that list inputs
description: 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.

terminal
$ inspec check ssh-baseline
output
Location : /home/ops/ssh-baseline
Profile : ssh-baseline
Controls : 4
Timestamp : 2026-07-22T09:41:12+00:00
Valid : 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.

controls/sshd.rb
control 'ssh-01' do
impact 0.7
title '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')) do
it { should be_listening }
end
end
control 'ssh-02' do
impact 0.7
title '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}" do
subject { account }
it { should be_in input('allowed_users') }
end
end
end
control 'ssh-03' do
impact 1.0
title 'Root login over SSH is disabled'
desc 'No input here on purpose. This one is never negotiable.'
describe sshd_config do
its('PermitRootLogin') { should cmp 'no' }
end
end
inputs-prod.yml
# One file per environment. This is policy, so it belongs in review.
ssh_port: 2222
allowed_users:
- root
- deploy
terminal
$ inspec exec ssh-baseline -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \
--input-file inputs-prod.yml; echo "exit=$?"
output
Profile: SSH Server Baseline (ssh-baseline)
Version: 1.2.0
Target: ssh://ops@web1:22
Target ID: 0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0
× ssh-01: sshd listens only on the approved port (1 failed)
× Port 2222 is expected to be listening
expected `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 text
Profile Summary: 3 successful controls, 1 control failure, 0 controls skipped
Test Summary: 5 successful, 1 failure, 0 skipped
exit=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.

Where an input's value actually comes from
1input('ssh_port', value: 22) in the control
priority 20, last-resort fallback
2inputs: in the profile's own inspec.yml
priority 30, the documented default
3inputs: in a wrapper profile's inspec.yml
priority 35, an overlay beats the baseline
4--input-file inputs-prod.yml
priority 40, the reviewed per-environment answer
5--input ssh_port=22
priority 50, one-off, beats everything
Highest number wins, quietly. A priority: key on a declaration lets you change the order deliberately.

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.

terminal
$ inspec exec ssh-baseline -t ssh://ops@web1 -i ~/.ssh/id_ed25519 \
--input-file inputs-prod.yml --input ssh_port=22; echo "exit=$?"
output
Profile: SSH Server Baseline (ssh-baseline)
Version: 1.2.0
Target: ssh://ops@web1:22
Target 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 text
Profile Summary: 4 successful controls, 0 control failures, 0 controls skipped
Test Summary: 6 successful, 0 failures, 0 skipped
exit=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.

A Misspelled Input Fails Quietly, Not Loudly
Write input('ssh_prt') by accident and InSpec keeps going. An input that was never declared and has no value resolves to a placeholder object that answers every method call with itself and prints as "Input 'ssh_prt' does not have a value. Skipping test." That placeholder flows straight into your matcher, and what lands in the report is a skipped test or a failure that makes no sense, sitting one row below genuine passes. Two habits close the hole. Declare every input in inspec.yml with a type, so a bad value raises "Input 'ssh_port' with value 'two-two-two' does not validate to type 'Numeric'." instead of sliding through. Mark environment-specific inputs required: true, so a missing value raises "Input 'allowed_users' is required and does not have a value." and fails the control rather than quietly widening it. One last trap: attribute() is the old spelling of input(), deprecated since InSpec 4 and warned about ever since. Use input() in new profiles, and never mix the two names for the same value.

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.

terminal
$ 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
output
[
{
"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.

ssh-baseline/inspec.yml
supports:
- platform-name: ubuntu
release: '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.

terminal
$ inspec exec ssh-baseline -t ssh://ops@web9 -i ~/.ssh/id_ed25519; echo "exit=$?"
output
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.0
Target: ssh://ops@web9:22
Target ID: 7c9e6679-7425-40de-944b-e07fc1f90ae7
No tests executed.
Test Summary: 0 successful, 0 failures, 0 skipped
exit=0
terminal
$ inspec exec ssh-baseline -t ssh://ops@web9 --reporter json:run.json
$ jq '[.profiles[].controls[].results[]] | length' run.json
output
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.

terminal
$ inspec archive ssh-baseline
output
I, [2026-07-22T10:02:11.442019 #5140] INFO -- : Checking profile in /home/ops/ssh-baseline
I, [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.

Quick check
01What does declaring an input in inspec.yml give you that a plain Ruby default written inside a control does not?
Incorrect — the load order is real but buys you nothing about correctness, documentation or reuse.
Correct — those three properties are exactly why parameters belong in metadata instead of scattered through control files.
Incorrect — sensitive: true masks the value as *** in reporters that list inputs, and nothing anywhere is encrypted.
Incorrect — setting an input inside another profile needs the profile: key on the declaration, it does not happen by itself.
02Your profile's inspec.yml sets ssh_port: 22. A wrapper profile that depends on it sets ssh_port: 2222. The CI (continuous integration) job runs with --input-file prod.yml, which contains ssh_port: 2200. Which port does the control check?
Incorrect — proximity is not the rule, and a profile's own inspec.yml is priority 30, the lowest of these three.
Incorrect — wrapper metadata is priority 35, which beats the baseline's 30 but still loses to an input file.
Correct — only --input on the command line, at priority 50, would beat the input file here.
Incorrect — several sources for one input is the expected case, and the highest priority wins with no warning at all.
03A nightly scan prints "WARN -- : Skipping profile: 'ssh-baseline' on unsupported platform: 'ubuntu/22.04'.", then "Test Summary: 0 successful, 0 failures, 0 skipped", and exits 0. What happened and what do you do?
Incorrect — zero tests ran, so the report is evidence of nothing rather than evidence of compliance.
Incorrect — the connection clearly succeeded, which is the only way InSpec could have identified the target as ubuntu/22.04.
Incorrect — a waived control still appears in the run and is counted as skipped, so the summary would not read all zeros.
Correct — the platform pin switched the checks off silently, and only an assertion on the number of results turns that back into a red build.

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.

Related