Reporting & attestations
Evidence auditors accept.
A pathology lab does not hand your doctor a story about the centrifuge. It hands over a printed report: sample ID, collection date, every measurement next to its reference range, the analyser's serial number, a signature. The story is unverifiable. The report can be filed. An InSpec run works the same way. Finding the failures is the easy half. The half that survives is the artefact you hand over, and whether a stranger opening it nine months from now can tell what was checked, on which machine, using which version of which profile, and who signed off on the parts no script could reach.
This lesson is about producing that artefact. Reporters shape one run into every format your readers need. Waivers carry the human sign-off for controls a program cannot test. The details you get wrong here bite at the worst possible moment: an evidence file that quietly contains the contents of /etc/shadow, a green pipeline that is green because somebody waived a failure two years ago and never came back, an exit code your CI system (continuous integration, the robot that runs your build and test jobs on every change) reads as success when it means the opposite.
One Run, Many Readers
One building inspection, several pieces of paper. The city wants a stamped form, the buyer wants plain English, the bank wants a number it can drop into a spreadsheet. Nobody re-inspects the building for each one. A reporter is that paperwork step. It takes the results InSpec already holds in memory and writes them out in one particular shape.
cli is the coloured text you read at your desk. json (JavaScript Object Notation, a plain-text format built for machines to read) is the complete record: every control, every test, every timestamp. It is the only format that holds all of it. json-min is a stripped-down list of control IDs and statuses, cheap to diff between two runs. junit2 is XML (Extensible Markup Language, text wrapped in angle-bracket tags) in the exact shape your pipeline's test tab expects, and it fixed the quirks of the older junit. html2 is a self-contained web page you can attach to a ticket. yaml, documentation and progress cover odd cases, and automate streams the run into Chef Automate. Pass --reporter a space-separated list, and append :path to any name to write a file instead of stdout (standard output, the stream that lands in your terminal).
$ mkdir -p evidence$ inspec exec prod-web-baseline -t ssh://ops@web1 \--reporter cli \json:evidence/web1-2026-07-21.json \junit2:evidence/web1-2026-07-21.xml \html2:evidence/web1-2026-07-21.html
Profile: Production web baseline (prod-web-baseline)Version: 1.4.0Target: ssh://ops@web1:22Target ID: 8f0c7a4e-1d3b-5c2a-9e77-4b1f0a2c6d55✔ sshd-01: Server: Configure the service port✔ SSHD Configuration Port is expected to cmp == "22"× sshd-08: Server: Set the allowed authentication mechanism (1 failed)× SSHD Configuration PermitRootLogin is expected to cmp == "no"expected: "no"got: "prohibit-password"(compared using `cmp` matcher)↺ prod-web-access-review: Quarterly access review for prod-web is performed↺ Manual control: the signed attestation lives in waivers/prod-web.yamlProfile Summary: 40 successful controls, 1 control failure, 2 controls skippedTest Summary: 86 successful, 1 failure, 2 skipped
One connection to the host, one evaluation, four files that agree with each other by construction. That last part matters more than it sounds. If you scan three times to produce three formats, you have three different moments in time, and a host can change between them. Then the JSON says one thing, the HTML page says another, and you get to explain the gap to somebody whose entire job is finding gaps.
Only one reporter may write to stdout. Two of them interleaved would produce a stream that is neither valid JSON nor readable text, so InSpec checks the flag before it connects to anything and refuses the run outright.
$ inspec exec prod-web-baseline -t ssh://ops@web1 --reporter cli json ; echo "exit=$?"
The option --reporter can only have a single stdout reporter.exit=1
There is no inspec report command. InSpec cannot re-render a stored result into a different format later, so decide up front which shapes you will ever want and emit them all in the one run. If you need a format InSpec does not produce, the SAF CLI (Security Automation Framework, a free command-line tool from MITRE) reads InSpec's JSON directly. saf convert hdf2csv -i results.json -o results.csv gives you a spreadsheet. saf convert hdf2ckl -i results.json -o results.ckl gives you a checklist for DISA STIG Viewer (Defense Information Systems Agency Security Technical Implementation Guide, the US military hardening standard). saf view heimdall -f results.json opens a browsable view. Those conversions read your archived file. They never touch the host again, which is exactly the property you want once the machine has been rebuilt.
Keep The Token Out Of Your Shell History
Anything you type on a command line ends up in three places you did not choose: your shell history file, the process table that any other user on the box can read with ps, and the CI job log that half the company can open. That is fine for a profile name. It is not fine for an API token (Application Programming Interface token, a long secret string that proves to a server you are allowed to write to it). A long --reporter list is also unreadable on one line. Move the whole thing into a config file and pass --config. The file takes the same reporter names as keys, plus the settings the command line cannot express, like the Automate endpoint and its token.
{"reporter": {"cli": { "stdout": true },"json": { "file": "evidence/web1-2026-07-21.json" },"automate": {"stdout": false,"url": "https://automate.example.com/data-collector/v0/","token": "REPLACED_AT_RUNTIME","node_name": "web1.prod","environment": "production","insecure": false}}}
# jq is a small command-line tool for editing JSON. "--config -" tells InSpec to# read its config from standard input, so the real token never lands on disk.$ jq --arg t "$AUTOMATE_TOKEN" '.reporter.automate.token = $t' reporter.json \| inspec exec prod-web-baseline -t ssh://ops@web1 --config - \| tail -n 2
Profile Summary: 40 successful controls, 1 control failure, 2 controls skippedTest Summary: 86 successful, 1 failure, 2 skipped
Treat that Automate token as a write credential, because that is what it is. Whoever holds it can post results into your compliance datastore under any node name they like. An attacker who lifts it does not need to fix your servers to make your dashboard green. They can post a clean run for a host they already own, and your quarterly report will happily quote it. Keep insecure at false too. Flipping it to true switches off TLS certificate checking (Transport Layer Security, the padlock in your browser: it proves the server is who it claims to be and scrambles what you send). Anyone able to sit between you and Automate then becomes a reader of your full compliance posture and a writer of whatever they fancy.
What Makes The JSON Admissible
A report that says forty controls passed is worth about as much as a receipt that says "goods: paid". A report that says which forty, taken from which exact profile content, against which machine, at which second, is evidence. All of that is already sitting in the JSON. Learn the field names so you can prove it on demand.
$ jq '{profile: .profiles[0].name,version: .profiles[0].version,sha256: .profiles[0].sha256,platform: .platform,inspec: .version,started: .profiles[0].controls[0].results[0].start_time}' \evidence/web1-2026-07-21.json
{"profile": "prod-web-baseline","version": "1.4.0","sha256": "6f2c1a9d4e0b7c33a58f1d6e9b0c4a72d3e85f16b9c0d7a2e4f8b1c6d3a05e97","platform": {"name": "ubuntu","release": "22.04","target": "ssh://ops@web1:22","target_id": "8f0c7a4e-1d3b-5c2a-9e77-4b1f0a2c6d55"},"inspec": "5.22.80","started": "2026-07-21T04:11:38+00:00"}
That sha256 is a fingerprint of the profile's contents, the way a wax seal on an envelope tells you nobody opened it. Change one character in one control file and the fingerprint changes completely. It settles the oldest argument in compliance work: that is not the version we ran. Pin your dependencies by version in inspec.yml, then check the sha256 in the report against the tag you published. If you need a stronger claim than "we think this is the same code", make a key pair with inspec sign generate-keys --keyname ci-signing, then package the profile with inspec sign profile --profile prod-web-baseline --signing-key ci-signing. You get a signed .iaf file (InSpec Artifact File) that inspec exec will run and will refuse if anyone has edited a byte of it.
describe file('/etc/shadow') do its('content') { should_not match /^root:!/ } end fails by pasting the shadow file into your JSON, your HTML page and your JUnit XML, all of which then travel to a shared bucket, a ticket queue, and an auditor's inbox. Two habits fix it. Never assert on raw secret content: check the permissions, the line count, or the output of sha256sum instead of the bytes themselves. Then cap the blast radius with --reporter-message-truncation 200, which trims every failure message and test description to 200 characters (InSpec truncates nothing at all by default). And store evidence files like the sensitive documents they are, in a private bucket with tight permissions, never on a wiki page.Attesting To What No Script Can Check
Your car's service book has boxes the mechanic ticks after measuring something, and boxes the mechanic ticks after asking you a question. Both kinds of box sit on the same page, and a missing tick is obvious either way. Every real baseline works like that. A security officer reviews access quarterly. The disaster recovery runbook was rehearsed. A named person approved the firewall exception. There is no InSpec resource for a meeting. The mistake is leaving those requirements out of the profile, because then the report has a hole in it that nobody can see. Write the control anyway, with its ID, title, description and impact, and let it report as skipped. The report now carries a line item for the thing, which is what an auditor is actually hunting for.
control "prod-web-access-review" doimpact 0.7title "Quarterly access review for prod-web is performed"desc "Every human account with SSH access to prod-web is re-approved by the " \"platform lead each quarter. No InSpec resource can observe a meeting."tag manual: trueref "NIST SP 800-53 Rev. 5, AC-2(j)"describe "quarterly access review of prod-web SSH accounts" doskip "Manual control: the signed attestation lives in waivers/prod-web.yaml"endend
InSpec has no separate attestation command. The tool you attest with is the waiver file, and it works like the exception slip taped inside a fuse box: a short written note saying who accepted this, why, and until when. It is a YAML, JSON, CSV or XLSX file (YAML is the indented plain-text format you already use for inspec.yml; CSV and XLSX are spreadsheet formats, for teams whose risk register lives in Excel) whose top-level keys are control IDs. Pass it with --waiver-file. You can pass several files, and if the same control appears in more than one, the last file listed wins. Each entry carries a justification, an expiration_date and a run flag. Put the signer's name and the ticket number inside the justification text, because the format has no author field and that string is the only part of your sign-off that reaches the report.
# Reviewed in the same pull request as the profile. Key = control ID as reported.sshd-08:justification: >-PermitRootLogin must stay at prohibit-password until the backup agent stopsusing root over SSH (CHG-8842). Risk accepted by s.chaurasiya, platformlead, 2026-07-01.expiration_date: 2026-09-30run: true # execute and report in full; the failure still fails the runprod-web-access-review:justification: >-Manual control. Quarterly access review for prod-web completed 2026-07-06,signed record filed as GRC-1194. Attested by m.okonkwo, security.expiration_date: 2026-10-06run: false # nothing to execute; the attestation itself is the evidence
The run flag decides whether the control executes at all, and the two settings say very different things to a reader. run: true executes the control normally and prints the failure in full; the failure still counts, so the run still exits 100, and what the waiver buys you is the justification recorded beside the finding. Anyone reading the report sees an unticked box and sees exactly why. run: false skips execution completely, which is the right call for the manual control because there is nothing to execute; the control still appears in the report, marked as skipped because of the waiver. Leave the run key out and you get the true behaviour: InSpec only skips a waived control when the key is present and set to false, so a missing run means the control still executes. That catches people out, which is why every entry here spells the flag out. The expiration_date is the load-bearing part of both. A date in the future means the waiver applies. A date in the past means InSpec ignores the entry, evaluates the control as though you had never written it, and says so in the report.
$ inspec exec prod-web-baseline -t ssh://ops@web1 \--waiver-file waivers/prod-web.yaml \--enhanced-outcomes \--reporter json:evidence/web1-2026-07-21.json ; echo "exit=$?"$ jq -r '.profiles[0].controls[]| select((.waiver_data // {}) != {})| [.id, .waiver_data.expiration_date,(.waiver_data.run | tostring),(.waiver_data.skipped_due_to_waiver | tostring)] | @tsv' \evidence/web1-2026-07-21.json
exit=100sshd-08 2026-09-30 true falseprod-web-access-review 2026-10-06 false true
The justification and the expiry now live inside the same artefact as the automated results, attached to the control they excuse. Nobody has to cross-reference a spreadsheet. That run also used --enhanced-outcomes, which breaks the blunt skipped pile into statuses that mean something. A control with impact 0, or one an only_if guard ruled out, becomes Not Applicable: it genuinely does not apply to this platform. A control with real impact that did not run becomes Not Reviewed, and that is the flag an auditor cares about, because a blank line is not a pass. A control that blew up inside the profile becomes Error rather than hiding among the skips.
$ inspec exec prod-web-baseline -t ssh://ops@web1 --enhanced-outcomes \--reporter cli | tail -n 2
Profile Summary: 40 successful controls, 1 control failure, 1 control not applicable, 1 control not reviewed, 0 controls have errorTest Summary: 86 successful, 1 failure, 2 skipped
expiration_date as forever, so a one-time exemption scribbled during an incident quietly becomes permanent policy, and a real, ongoing failure sits behind it for years. Set a date on every entry. Reserve an unexpiring waiver for facts that can never change, and even then write down why. When a waiver does lapse, the control snaps back to normal evaluation and a gate that has been green for months can turn red overnight. That is the system working correctly, though it will not feel that way at 2am. Finally, stop gating only on failures. Alert when the count of waived or Not Reviewed controls grows, because otherwise the fastest route to a green pipeline is to keep adding waivers. If you want waived controls dropped from execution altogether, --filter-waived-controls does that, and pairing it with the experimental --retain-waiver-data keeps them visible in the report instead of vanishing without trace.Gating On The Truth
Most tools report the way a smoke alarm does: smoke, or no smoke. InSpec's exit code carries more than that, and it is part of the report. Zero means everything ran and everything passed. 100 means at least one control failed. 101 means nothing failed but something was skipped or waived, so the run is incomplete rather than clean. Anything else, usually 1, means InSpec itself could not do its job: a profile that would not load, a target it could not reach, a flag it did not understand. Collapsing all of those into "non-zero equals broken" throws away the distinction that matters most, which is the difference between a machine that failed a check and a check that never happened. --no-distinct-exit flattens 100 and 101 back into plain pass-or-fail if some ancient runner needs that, but reach for it last.
#!/usr/bin/env bash# InSpec 5 wants the Chef EULA accepted before it will run.# InSpec 6 wants a Progress licence key instead: export CHEF_LICENSE_KEY=...# CINC Auditor is the community rebuild: same CLI, same flags, no key at all.export CHEF_LICENSE="accept-no-persist"set -uo pipefail # deliberately NOT -e: we read InSpec's exit code ourselves: "${TARGET_HOST:?set the host this job is auditing}"stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"out="evidence/${TARGET_HOST}-${stamp}"mkdir -p evidenceinspec exec prod-web-baseline \-t "ssh://ops@${TARGET_HOST}" \--waiver-file waivers/prod-web.yaml \--enhanced-outcomes \--reporter-message-truncation 200 \--reporter "json:${out}.json" "junit2:${out}.xml" "html2:${out}.html"rc=$?case "$rc" in0) echo "clean run" ;;101) echo "no failures, but controls were skipped or waived" ;;100) echo "control failures present"; exit 1 ;;*) echo "inspec itself failed (rc=$rc)"; exit 1 ;;esac# Anything expiring inside 30 days is somebody's homework this sprint.jq -r --arg cut "$(date -u -d '+30 days' +%F)" '.profiles[].controls[]| select(.waiver_data.expiration_date != nulland .waiver_data.expiration_date <= $cut)| "waiver expiring \(.waiver_data.expiration_date) \(.id)"' "${out}.json"
Three things in that script are easy to miss. No reporter writes to stdout, which is what you want in a pipeline: the job log stays readable and no stray warning can corrupt a JSON stream. The expiry sweep runs against the artefact you already produced, so the renewal reminder comes out of the evidence itself rather than a calendar entry someone will eventually delete. And the licence is settled in the environment before InSpec starts. A first-run licence prompt in a non-interactive job is a hang, not an error, and it will sit there burning a build slot until the timeout kills it. If you would rather not manage keys, CINC Auditor is the community rebuild of the same code: cinc-auditor exec takes every flag in this lesson and asks for nothing.
The Package Someone Opens In Six Months
Evidence rots quietly. The profile moves on, the host is rebuilt, the person who wrote the justification changes jobs. What you can control is that each artefact carries its own context. A layout that holds up: evidence/<host>-<utc-timestamp>.json for the full record (UTC being Coordinated Universal Time, so nobody has to guess whose timezone the filename means), the same stem for the .xml and .html views so the three obviously belong together, the input file archived beside them if the run used one, and the profile itself in git under a tag matching the version the report names. Keep the JSON as the canonical copy. It is the only format holding the complete result, and every other view was derived from it in the same run.
Waivers belong in version control for the same reason. The file format has no author field, so the sign-off lives in the justification text and the authorship lives in the commit. A reviewed pull request that adds an expiry date and a ticket number beats any signature block, because it has a diff, a timestamp, and a second pair of eyes that had to agree before it merged.
Once a quarter, open one archived report at random and check three things. The sha256 in it resolves to a tagged commit you can still check out. Every waiver in it has an expiration_date. None of those dates has passed without a matching commit that renewed or removed the entry. If any of the three fails, that evidence was already stale on the day you filed it, and fixing it now costs you an afternoon instead of a finding.
inspec report command and InSpec cannot re-render a stored result, though the MITRE SAF CLI can convert one afterwards.run: true with an expiration_date three months away, and the control fails on the target. What lands in the report, and what does the gate see?run: false still leaves the control in the report marked skipped because of the waiver; only --filter-waived-controls drops it from the results.jq shows that control carries waiver_data.expiration_date of 2026-07-20, and today is 2026-07-22. What happened, and what do you do first?Try this
Run inspec exec prod-web-baseline -t ssh://ops@web1 --reporter cli json ; echo "exit=$?" 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: your evidence file can leak the thing you were testing. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.