Remote & cloud targets
SSH, WinRM, and cloud APIs.
A safety inspector carries the same clipboard into every building. Fire exits clear, extinguisher in date, panel labelled. The checklist never changes. What changes is how the inspector gets in: a key here, a keypad there, a phone call to the facilities manager somewhere else. InSpec is built on exactly that split. Your controls stay as written, and one flag decides which door they walk through.
That flag is -t (long form --target), and it takes a URI (uniform resource identifier, the scheme://thing shape you already know from web addresses). The scheme picks the transport: the plumbing InSpec uses to reach the target, run read-only commands or API calls (application programming interface, the machine-to-machine way one program asks a service a question), and carry the answers home. Transports live in a library called Train that ships inside InSpec. One binary, one control language, many kinds of door.
The Doors InSpec Knows
Leave -t off entirely and InSpec inspects the machine you typed the command on. Handy for a quick sanity check. It is also the most common way people fool themselves, because a profile that quietly audited your laptop produces a report that looks identical to one that passed on production.
# no -t: the machine you are sitting oninspec exec linux-baseline# a Linux host over SSH (secure shell), authenticating with a keyinspec exec linux-baseline -t ssh://ops@web1 -i ~/.ssh/inspec_ed25519# a Windows host over WinRM, moved onto the TLS listenerinspec exec win-baseline -t winrm://[email protected] --ssl# a running container, through the container runtime on this hostinspec exec cnt-baseline -t docker://payments-apiinspec exec cnt-baseline -t podman://payments-api# a whole cloud account, subscription or project, over the provider's APIinspec exec aws-baseline -t aws://eu-west-1inspec exec az-baseline -t azure://inspec exec gcp-baseline -t gcp://
The last three are the interesting jump. ssh:// and winrm:// hand you a shell on a machine. aws:// hands you no shell at all. The target is an account, the resources are buckets and security groups instead of files and services, and InSpec sees exactly as much as your credential is permitted to read. Nothing more.
Knock Before You Scan
Before you fire a 300-control baseline at something, ask it one small question: are you there, and what are you? inspec detect opens the transport, works out the platform, prints it, and exits. It costs about a second, and it separates "my credentials are wrong" from "300 controls genuinely failed". Those are two very different Monday mornings.
inspec detect -t ssh://[email protected] -i ~/.ssh/inspec_ed25519
== Platform DetailsName: ubuntuFamilies: debian, linux, unix, osRelease: 22.04Arch: x86_64
Families is the line to read. Those tags are what your profile matches on. You declare them in inspec.yml so the profile refuses to run somewhere it was never written for, and you can guard a single control with only_if { os.debian? } when one check belongs to half the fleet. Detect the platform once and you know which half of a mixed-fleet profile will really execute before you execute it.
name: acme-linux-baselineversion: 2.1.0supports:- platform-family: debian- platform-family: redhat
SSH Shows You Exactly What the Login User Can See
Over ssh://, InSpec logs in as the user you named and runs ordinary read commands: stat, cat, ss, rpm or dpkg. Nothing is installed on the target, which is what people mean by agentless scanning, and it is genuinely useful because you can audit machines whose provisioning you do not own. The catch is that the scan inherits that user's blindness. A visitor badge walks you round the lobby and nowhere near the plant room.
Here is a control that looks perfectly reasonable and lies to you.
control 'fw-01' doimpact 1.0title 'The inbound chain must not blanket-accept SSH'desc 'nftables (the packet filter built into the Linux kernel) must hold no unrestricted accept for tcp/22.'describe command('nft list ruleset') doits('stdout') { should_not match(/tcp dport 22 accept/) }endend
inspec exec fw-baseline -t ssh://ops@web1 -i ~/.ssh/inspec_ed25519
Profile: Acme firewall baseline (acme-fw-baseline)Version: 1.0.0Target: ssh://ops@web1:22Target ID: 8d9f0f0e-2b1a-4f5b-9a54-6b2c1d3e4f50✔ fw-01: The inbound chain must not blanket-accept SSH✔ Command: `nft list ruleset` stdout is expected not to match /tcp dport 22 accept/Profile Summary: 1 successful control, 0 control failures, 0 controls skippedTest Summary: 1 successful, 0 failures, 0 skipped
Green on every host in the fleet. Wrong on every host in the fleet. The ops account is not root, so nft list ruleset printed a permission error to stderr and absolutely nothing to stdout, and an empty string matches no pattern. Any should_not assertion passes against silence. Run the identical profile through sudo (the Unix helper that runs one command as another user, usually root) and the truth turns up.
inspec exec fw-baseline -t ssh://ops@web1 -i ~/.ssh/inspec_ed25519 --sudo
Profile: Acme firewall baseline (acme-fw-baseline)Version: 1.0.0Target: ssh://ops@web1:22Target ID: 8d9f0f0e-2b1a-4f5b-9a54-6b2c1d3e4f50× fw-01: The inbound chain must not blanket-accept SSH (1 failed)× Command: `nft list ruleset` stdout is expected not to match /tcp dport 22 accept/expected "table inet filter {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n..." not to match /tcp dport 22 accept/Profile Summary: 0 successful controls, 1 control failure, 0 controls skippedTest Summary: 0 successful, 1 failure, 0 skipped
--sudo wraps every command InSpec issues on that target. --sudo-password supplies a password when the account needs one, --sudo-command swaps in a different wrapper such as doas, and --sudo-options passes extra flags through. It applies to the SSH and local transports; WinRM and the container transports ignore it and have their own answers. Two habits kill this entire class of bug. Assert on exit_status alongside any stdout check, so silence fails loudly instead of passing quietly. And reach for a purpose-built resource rather than command wherever one exists, because command hands you a raw string and leaves every bit of the interpretation to you.
describe command('nft list ruleset') doits('exit_status') { should eq 0 } # silence now fails instead of passingits('stdout') { should_not match(/tcp dport 22 accept/) }end
should_not match, should_not include, should_not contain) passes when the thing it inspected came back empty. A permission-denied read comes back empty. So does a missing binary, which exits 127 with nothing on stdout. The failure is silent, uniform across the fleet, and looks exactly like a well-run estate. When a control flips from red to green, prove the target changed before you close the ticket: re-run with --sudo, add an exit_status assertion, or point the same control at a host you know is broken and confirm it still goes red.Windows Over WinRM
WinRM (Windows Remote Management, the remote command channel already built into Windows Server) is the Windows door. Same inspector, different lock. The listener on TCP port 5985 is plain HTTP and the one on 5986 is wrapped in TLS (transport layer security, the same encryption your browser uses), and --ssl moves you to the second. Be precise about what 5985 costs you, because people get this wrong in both directions. With the default negotiate authentication, Windows encrypts the message payload itself even over HTTP, so it is not a plain-text wire. What 5985 gives you is no certificate to check, which means no way to tell the real server from something answering in its place. And --winrm-transport plaintext really does put the credential on the wire in the open. That flag also accepts ssl and kerberos.
# NOTE: the shell expands $WIN_PW before InSpec starts, so the password still# lands in the process list. The config file further down is the actual fix;# this is here so you can see the flags on one line.WIN_PW="$(pass show acme/svc-inspec)"inspec detect -t winrm://[email protected] --password "$WIN_PW" --ssl --self-signed
== Platform DetailsName: windows_server_2022_datacenterFamilies: windows, osRelease: 10.0.20348Arch: x86_64
Windows has its own version of the sudo problem, and it hides better. A WinRM session is a network logon. For a local account in the Administrators group, Windows hands back a filtered token on that kind of logon unless LocalAccountTokenFilterPolicy is set to allow the full one, so checks that export local security policy or read certain registry hives come back empty or access-denied even though the account genuinely is an administrator. Domain accounts behave differently again. --winrm-shell-type elevated runs each command through a scheduled task under a full token and clears the whole class. It is slower per command. It is also the difference between a password-policy control that means something and one that reports nothing at all.
control 'win-pw-01' doimpact 1.0title 'Local password policy must meet the Acme baseline'describe security_policy do # runs secedit /export: wants a full admin tokenits('MinimumPasswordLength') { should cmp >= 14 }its('PasswordComplexity') { should cmp 1 }endendcontrol 'win-lsa-01' doimpact 1.0title 'Blank passwords must not be usable over the network'describe registry_key('HKLM\SYSTEM\CurrentControlSet\Control\Lsa') doits('LimitBlankPasswordUse') { should cmp 1 }endend
--self-signed tells InSpec to accept whatever certificate the far end presents, so it can no longer tell the real server from a machine sitting in the middle of the path. InSpec's SSH transport does not verify host keys by default either. On a locked-down management network that is a shrug. On anything shared it is not, because the credential you are handing over is one that opens every box you own. Put a real certificate on the WinRM listener before you scan across a network you do not control end to end.A Container Is Not a Small Server
docker:// does not go over the network at all. InSpec asks the container runtime on this host to run your check commands inside the container's namespaces, the way docker exec does. So file, directory, package and user behave normally, and two very ordinary resources quietly stop working. service wants an init system, and most images have none. port shells out to ss or netstat, and slim images ship neither.
control 'cnt-01' doimpact 1.0title 'The image must not run as uid 0'describe command('id -u') doits('stdout') { should_not cmp 0 }endendcontrol 'cnt-02' doimpact 0.7title 'No package manager left in the runtime image'describe package('apt') doit { should_not be_installed }endendcontrol 'cnt-03' doimpact 1.0title 'nginx must be running'describe service('nginx') do # this one will lie to you inside a containerit { should be_running }endend
inspec exec container-baseline -t docker://payments-api
Profile: Acme container baseline (acme-container)Version: 0.3.0Target: docker://3f8c9a1b2d4eTarget ID: c41d7e92-58a0-4c6b-b3f7-91d0a2e6c845✔ cnt-01: The image must not run as uid 0✔ Command: `id -u` stdout is expected not to cmp == 0✔ cnt-02: No package manager left in the runtime image✔ System Package apt is expected not to be installed× cnt-03: nginx must be running (1 failed)× Service nginx is expected to be runningexpected that `Service nginx` is runningProfile Summary: 2 successful controls, 1 control failure, 0 controls skippedTest Summary: 2 successful, 1 failure, 0 skipped
That red cnt-03 is a lie in the opposite direction. nginx is PID 1 (process ID 1, the first and in this case only process in the container) and it is serving traffic right now. The service resource asked systemd, found no systemd, and reported not running. The fix is not --sudo. It is writing container controls against what a container really is: files, packages, the user baked into the image, the process table. Check listening ports from outside with the host resource, or ask the orchestrator, rather than interrogating a namespace with no networking tools in it.
One caveat has nothing to do with InSpec and everything to do with you. Reaching /var/run/docker.sock is equivalent to root on the host, because whoever can talk to it can start a container that mounts / and read or write anything on the machine. Running -t docker:// from a shared CI runner (continuous integration, the automation that builds and ships your code) hands that runner the same power. Scan the image in a throwaway sandbox you own, or run the profile at build time inside the image. podman:// is the rootless alternative when you truly must inspect a live container.
Pointing InSpec at a Whole Cloud Account
With ssh:// you walk the building. With aws:// you read the county's record of the building. There is no shell and no filesystem. The transport signs API calls with your cloud credentials, and the resources turn into aws_s3_bucket, aws_security_group, aws_iam_users. The DSL (domain specific language, the describe ... it { should ... } syntax you already write) is byte for byte the same. The vocabulary is completely different.
Those cloud resources do not live inside the InSpec binary. They ship as resource packs (inspec-aws, inspec-azure, inspec-gcp), which are ordinary InSpec profiles carrying custom resources instead of controls, and you pull one in with depends, exactly the way you inherit a baseline. Pin it to a release tag. An unpinned pack means the meaning of a passing scan can change under you on a Tuesday, without a single line of your code moving.
name: acme-aws-baselinetitle: Acme AWS Account Baselineversion: 1.2.0supports:- platform: awsdepends:- name: inspec-awsgit: https://github.com/inspec/inspec-aws.gittag: v1.62.0 # pin a tag you have actually read; never track maininputs:- name: log_buckettype: stringrequired: true
control 'aws-s3-01' doimpact 1.0title 'The audit log bucket must be private, encrypted and versioned'desc 'Evidence is worthless if an attacker can read it or silently overwrite it.'describe aws_s3_bucket(bucket_name: input('log_bucket')) doit { should exist }it { should_not be_public }it { should have_default_encryption_enabled }it { should have_versioning_enabled }endendcontrol 'aws-sg-01' doimpact 1.0title 'No security group anywhere exposes SSH to the internet'# plural resource lists the whole account; singular resource asserts on each oneaws_security_groups.group_ids.each do |sg|describe aws_security_group(group_id: sg) doit { should_not allow_in(port: 22, ipv4_range: '0.0.0.0/0') }endendend
export AWS_PROFILE=acme-audit-readonlyexport AWS_REGION=eu-west-1mkdir -p evidenceinspec exec aws-baseline -t aws:// \--input log_bucket=acme-cloudtrail-prod \--reporter cli json:evidence/aws-2026-07-22.json
Profile: Acme AWS Account Baseline (acme-aws-baseline)Version: 1.2.0Target: aws://eu-west-1Target ID: 6f3a1c88-7b25-4f0e-9d31-2a4c8e5b7f10✔ aws-s3-01: The audit log bucket must be private, encrypted and versioned✔ S3 Bucket acme-cloudtrail-prod is expected to exist✔ S3 Bucket acme-cloudtrail-prod is expected not to be public✔ S3 Bucket acme-cloudtrail-prod is expected to have default encryption enabled✔ S3 Bucket acme-cloudtrail-prod is expected to have versioning enabled× aws-sg-01: No security group anywhere exposes SSH to the internet (1 failed)✔ EC2 Security Group sg-0a1b2c3d4e5f60718 is expected not to allow in {:port=>22, :ipv4_range=>"0.0.0.0/0"}✔ EC2 Security Group sg-09f8e7d6c5b4a3210 is expected not to allow in {:port=>22, :ipv4_range=>"0.0.0.0/0"}× EC2 Security Group sg-0d41f9a7c2b8e5310 is expected not to allow in {:port=>22, :ipv4_range=>"0.0.0.0/0"}expected EC2 Security Group sg-0d41f9a7c2b8e5310 not to allow in {:port=>22, :ipv4_range=>"0.0.0.0/0"}Profile Summary: 1 successful control, 1 control failure, 0 controls skippedTest Summary: 6 successful, 1 failure, 0 skipped
That second control is where scanning a live account earns its keep. aws_s3_bucket names one bucket you already knew about. aws_security_groups (plural) returns every group in the account, and looping its group_ids into the singular resource turns "this one group I remembered to write down" into "nowhere in this account". Scanning your Terraform files cannot reach that, because a file only knows about resources somebody committed. The group an engineer opened by hand in the console at 2am is precisely the one the API will hand you. The trade-off of the loop is that all those checks live under one control id, so a single bad group fails the whole thing, and you read the failing group out of the test lines.
Credentials come from each provider's normal chain: AWS_PROFILE and AWS_REGION, or an instance role, for AWS; the four AZURE_* variables (subscription, tenant, client id, client secret) for a service principal, which is Azure's name for a non-human identity; and GOOGLE_APPLICATION_CREDENTIALS pointing at a service-account key file for GCP. Scope every one of them to reading only. SecurityAudit plus ViewOnlyAccess on AWS, the built-in Reader role on an Azure subscription, viewer-grade roles on a GCP project. A scanning identity that can also write is a fleet-wide foothold wearing a compliance badge.
Skips Are the Quiet Failure, and the Exit Code Says So
Aim a profile full of file and service controls at aws:// and they do not fail. They skip, because those resources do not exist on that platform. Skips are not red. A pipeline that greps the output for the word "failure" will cheerfully wave through a run in which almost nothing executed.
inspec exec mixed-baseline -t aws://echo "exit code: $?"
Profile: Acme mixed baseline (acme-mixed)Version: 0.9.0Target: aws://eu-west-1Target ID: 6f3a1c88-7b25-4f0e-9d31-2a4c8e5b7f10✔ aws-s3-01: The audit log bucket must be private, encrypted and versioned✔ S3 Bucket acme-cloudtrail-prod is expected to exist✔ S3 Bucket acme-cloudtrail-prod is expected not to be public✔ S3 Bucket acme-cloudtrail-prod is expected to have default encryption enabled✔ S3 Bucket acme-cloudtrail-prod is expected to have versioning enabled↺ os-ssh-01: SSH must not permit root login↺ Resource sshd_config is not supported on platform aws.↺ os-pkg-01: Telnet must not be installed↺ Resource package is not supported on platform aws.Profile Summary: 1 successful control, 0 control failures, 2 controls skippedTest Summary: 4 successful, 0 failures, 2 skippedexit code: 101
The exit codes are the contract between InSpec and your pipeline. 0 means everything passed. 100 means at least one control failed. 101 means at least one was skipped and none failed. 1 is a usage or general error, 2 is a plugin error, and 172 means the licence was never accepted. --no-distinct-exit collapses that spread into 0 on skips and 1 on failures, which helps when an old gate cannot cope with 100 and 101, and which is exactly what you do not want if coverage is the thing you are trying to prove. Gate the build on 100. Alert a human on 101.
Keep the Credential Off the Command Line
ps shows full command lines to every user on the box. Shell history keeps them. CI transcripts keep them forever and mail them to people. And a shell variable does not save you, because the shell expands "$WIN_PW" into the argument list before InSpec ever starts. So --password is a published credential however you dress it up. InSpec's config file exists to close that hole: describe a credential set once, then target it by name.
{"version": "1.1","cli_options": {"reporter": "cli"},"credentials": {"ssh": {"web1-prod": {"host": "web1.prod.acme.internal","user": "svc-inspec","key_files": ["/var/lib/inspec/.ssh/inspec_ed25519"],"sudo": true}},"winrm": {"dc1-prod": {"host": "10.20.1.15","user": "svc-inspec","password": "rendered-at-run-time-from-the-secret-store","ssl": true}}}}
# the credential set name goes where the hostname used to goinspec exec linux-baseline -t ssh://web1-prod# or keep the file somewhere transient and point at itinspec exec win-baseline -t winrm://dc1-prod --config /run/secrets/inspec.json# or hand it over on stdin so it never becomes a file at allvault-render inspec-creds | inspec exec win-baseline -t winrm://dc1-prod --config -
Profile: Acme Linux baseline (acme-linux-baseline)Version: 2.1.0Target: ssh://[email protected]:22Target ID: 8d9f0f0e-2b1a-4f5b-9a54-6b2c1d3e4f50✔ fw-01: The inbound chain must not blanket-accept SSH✔ Command: `nft list ruleset` exit_status is expected to eq 0✔ Command: `nft list ruleset` stdout is expected not to match /tcp dport 22 accept/✔ ssh-01: SSH must not permit root login✔ SSHD Configuration PermitRootLogin is expected to cmp == "no"× pkg-03: telnet must not be installed (1 failed)× System Package telnet is expected not to be installedexpected System Package telnet not to be installedProfile Summary: 2 successful controls, 1 control failure, 0 controls skippedTest Summary: 3 successful, 1 failure, 0 skipped
The Target line is your receipt. It names the transport, the user, and the host InSpec actually reached, which is how you catch the classic blunder of scanning your own laptop and filing the result as production evidence. The config file still holds a secret, so render it at run time from your secrets manager into a file only the scanning user can read, or onto a tmpfs (a filesystem that lives in memory and never touches disk) under /run/secrets, or pipe it straight in with --config -. What you have removed is its appearance in ps, in history, and in the build log.
Be honest with yourself about what that SSH credential is worth. --sudo runs everything through sudo, so a tightly whitelisted sudoers entry breaks most of a real baseline, and most teams end up granting the scanner broad sudo across the whole fleet. That single key then opens root on every machine you own. Give it a dedicated account, key-only authentication, no interactive shell for humans, a from= source restriction in authorized_keys so it works only from the scanner's address, and a rotation schedule somebody actually runs.
Scanning Four Hundred Hosts
One inspec exec handles one target, so people write a bash loop and go for lunch. InSpec 6 added inspec parallel exec, which reads an options file with one invocation per line and runs several at a time. The profile is named once on the command line. Everything that differs between targets goes in the file, and every line has to carry its own --reporter, otherwise the results land on top of each other.
-t ssh://web1-prod --reporter json:evidence/web1.json-t ssh://web2-prod --reporter json:evidence/web2.json-t winrm://dc1-prod --reporter json:evidence/dc1.json
# -j 8 runs eight targets at a time; the run prints a live status tableinspec parallel exec linux-baseline -o targets.txt -j 8# then turn one host's evidence into a list of failing control ids# (jq is a command-line JSON processor; any() keeps each id to one line)jq -r '.profiles[0].controls[] | select(any(.results[]; .status == "failed")) | .id' evidence/web1.json
fw-01pkg-03
--dry-run parses the options file and runs nothing, which is how you find the typo before you launch four hundred jobs. -j sets how many run at once and --bg detaches the run so it survives your terminal. Two things to know before you build a pipeline on this. inspec parallel arrived in InSpec 6, the release where the licence became a Progress commercial one, and CINC Auditor, the free rebuild with the trademarks stripped, tracks the last openly licensed 5.x source, so cinc-auditor takes every transport and flag in this lesson except parallel. And in any pipeline, accept the licence non-interactively with CHEF_LICENSE=accept-silent, plus a licence key on InSpec 6, or the run stops at a prompt and exits 172. Whichever binary you settle on, do one thing before you trust a fleet report: take a host you know is misconfigured, point the profile at it, and watch the control go red. A scanner that has never failed on purpose has not been tested.
-t flag actually change about a run?--reporter, which chooses between cli, json, junit2 and html2 output.--controls, which filters by control id; the transport decides the target, not the control list.--sudo on Linux or --winrm-shell-type elevated on Windows, a separate decision from which door you walk through.ssh:// gives you file and service, aws:// gives you buckets and security groups, and the same control code sits on top of both.--no-distinct-exit do to it?command('nft list ruleset') and asserts its('stdout') { should_not match(/dport 22 accept/) }. Run as an unprivileged user over ssh://, it comes back green on all 300 hosts. What is the most likely explanation, and the fix?Try this
Run inspec detect -t ssh://[email protected] -i ~/.ssh/inspec_ed25519 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 green control can mean the scanner was blind. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.