CoursesInSpecReporting & attestations

Reporting & attestations

Evidence auditors accept.

Advanced12 min · lesson 10 of 12

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).

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
output
Profile: Production web baseline (prod-web-baseline)
Version: 1.4.0
Target: ssh://ops@web1:22
Target 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.yaml
Profile Summary: 40 successful controls, 1 control failure, 2 controls skipped
Test 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.

terminal
$ inspec exec prod-web-baseline -t ssh://ops@web1 --reporter cli json ; echo "exit=$?"
output
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.

One exec, three audiences
People, right now
cli
coloured terminal output, the one stdout reporter
html2
self-contained page you can attach to a ticket
Machines, in the pipeline
junit2
XML the CI test tab parses into pass/fail rows
json-min
flat control id plus status, cheap to diff
automate
streams to Chef Automate over HTTPS
Auditors, months later
json
every control, timestamp, profile sha256
waivers.yaml
justification and expiry, reviewed in git
exit code
0 pass, 100 failed, 101 skipped
The json file is the only complete record. Every other format is a partial view of the same run, and InSpec will not regenerate any of them later, so emit everything you need inside the single execution.

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.json
{
"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
}
}
}
terminal
# 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
output
Profile Summary: 40 successful controls, 1 control failure, 2 controls skipped
Test 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.

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

Your evidence file can leak the thing you were testing
When a test fails, InSpec writes the actual value it found into the report so a human can see what went wrong. Helpful, right up to the moment the actual value is a password hash, a private key, or a customer record. A control like 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.

controls/manual.rb
control "prod-web-access-review" do
impact 0.7
title "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: true
ref "NIST SP 800-53 Rev. 5, AC-2(j)"
describe "quarterly access review of prod-web SSH accounts" do
skip "Manual control: the signed attestation lives in waivers/prod-web.yaml"
end
end

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.

waivers/prod-web.yaml
# 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 stops
using root over SSH (CHG-8842). Risk accepted by s.chaurasiya, platform
lead, 2026-07-01.
expiration_date: 2026-09-30
run: true # execute and report in full; the failure still fails the run
prod-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-06
run: 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.

terminal
$ 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
output
exit=100
sshd-08 2026-09-30 true false
prod-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.

terminal
$ inspec exec prod-web-baseline -t ssh://ops@web1 --enhanced-outcomes \
--reporter cli | tail -n 2
output
Profile Summary: 40 successful controls, 1 control failure, 1 control not applicable, 1 control not reviewed, 0 controls have error
Test Summary: 86 successful, 1 failure, 2 skipped
A waiver hides a failure by design, so it must expire
The whole purpose of a waiver is to hold a known failure open on purpose, which makes it the most dangerous file in the repository. InSpec treats a missing 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.

ci/run-baseline.sh
#!/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 evidence
inspec 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" in
0) 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 != null
and .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.

Quick check
01Your nightly job needs to feed the CI test panel, publish a shareable page, and archive a complete record of the same scan. What is the right move?
Incorrect — Three runs are three different moments in time, and the host can change between them, so the artefacts stop agreeing with each other.
Correct — one evaluation fans out to as many file-backed reporters as you need, so every artefact describes the identical run.
Incorrect — There is no inspec report command and InSpec cannot re-render a stored result, though the MITRE SAF CLI can convert one afterwards.
Incorrect — Only one reporter may write to stdout, so InSpec rejects that command before it even connects to the target.
02A waiver entry sets 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?
Incorrect — Even run: false still leaves the control in the report marked skipped because of the waiver; only --filter-waived-controls drops it from the results.
Incorrect — A waiver never converts a failure into a pass; the failure stays on the record for anyone reading it.
Incorrect — That is what Chef's documentation promises, but the exit code is counted from the control results and never looks at the waiver, so the failure still lands in the failed bucket.
Correct — the failing control is counted as failed like any other, so the run still exits 100; what the waiver adds is the justification stored beside the finding.
03A nightly baseline has exited 0 for months. Today it exits 100, and the failing control is one nobody has touched. 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?
Correct — an expired waiver stops applying, the control snaps back to normal evaluation, and the red you are seeing is a real failure that was previously hidden.
Incorrect — InSpec does not reject the run at all; it ignores the expired entry, notes that in the report, and evaluates the control as if no waiver existed.
Incorrect — That rubber-stamps a live failure with no fresh review, which is exactly how a one-time exemption turns into permanent policy.
Incorrect — That flag only removes controls covered by a currently valid waiver, and an expired waiver covers nothing.

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.

Related