7. InSpec Control Enhancements
InSpec Control Enhancements
You should have the basic idea of how an InSpec test is written and how to leverage InSpec's built-in features to help you write tests quickly and easily. Now let's discuss some of the more powerful features available to you when writing tests to control what output is displayed to the user.
RSpec
The InSpec testing framework is itself built on RSpec, a unit testing module for Ruby. InSpec extended rspec by including features like the resources library and more user-friendly syntax for writing tests, but we can still write our tests using RSpec syntax and they will work just fine.
Most of the time you won't need to use RSpec syntax to write a good test. But we want to show you a few neat tricks you can accomplish with RSpec.
RSpec Explicit Subject
We will write a few tests in this section to demonstrate the difference between InSpec's default syntax and RSpec syntax.
Let's pretend we have a new requirement for NGINX:
6. NGINX's /etc/nginx directory should not be empty.
(It's a bit of an odd requirement, but bear with us for the sake of this example.)
First, we'll try a test that does not use RSpec syntax to illustrate the problem we want to solve:
control 'Requirement 6' do
impact 1.0
title 'Checking that /etc/nginx does not return empty'
desc 'Let\'s do this the ugly way.'
describe command('ls -al').stdout.strip do
it { should_not be_empty }
end
end
✔ Requirement 6: Checking that /etc/nginx does not return empty
✔ total 76
drwxr-xr-x 1 root root 4096 Nov 8 20:21 .
drwxr-xr-x 1 root root 4096 Nov 8 20:21 ..
-rwxr-xr-x 1 root root 0 Nov 8 20:21 .dockerenv
lrwxrwxrwx 1 root root 7 Oct 30 00:00 bin -> usr/bin
drwxr-xr-x 2 root root 4096 Sep 29 20:04 boot
drwxr-xr-x 5 root root 360 Nov 8 20:21 dev
drwxr-xr-x 1 root root 4096 Nov 1 05:12 docker-entrypoint.d
-rwxrwxr-x 1 root root 1620 Nov 1 05:11 docker-entrypoint.sh
drwxr-xr-x 1 root root 4096 Nov 8 20:21 etc
drwxr-xr-x 2 root root 4096 Sep 29 20:04 home
lrwxrwxrwx 1 root root 7 Oct 30 00:00 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Oct 30 00:00 lib32 -> usr/lib32
lrwxrwxrwx 1 root root 9 Oct 30 00:00 lib64 -> usr/lib64
lrwxrwxrwx 1 root root 10 Oct 30 00:00 libx32 -> usr/libx32
drwxr-xr-x 2 root root 4096 Oct 30 00:00 media
drwxr-xr-x 2 root root 4096 Oct 30 00:00 mnt
drwxr-xr-x 2 root root 4096 Oct 30 00:00 opt
dr-xr-xr-x 228 root root 0 Nov 8 20:21 proc
drwx------ 2 root root 4096 Oct 30 00:00 root
drwxr-xr-x 1 root root 4096 Nov 8 20:21 run
lrwxrwxrwx 1 root root 8 Oct 30 00:00 sbin -> usr/sbin
drwxr-xr-x 2 root root 4096 Oct 30 00:00 srv
dr-xr-xr-x 12 root root 0 Nov 8 20:21 sys
drwxrwxrwt 1 root root 4096 Nov 1 05:12 tmp
drwxr-xr-x 1 root root 4096 Oct 30 00:00 usr
drwxr-xr-x 1 root root 4096 Oct 30 00:00 var is expected not to be empty
Well. . . it sort of works.
Notice how much output InSpec printed here to answer the simple question of "did this command return empty?" Imagine if we had done this on a directory with many files in it. We'd just be cluttering up the screen (and our report files) with output that is only tangential to what we really want to know, which is that the directory is not empty; we don't care what's in it.
Wait, couldn't we have just used the directory resource for this?
Correct. That would have been a much better way of doing this, and illustrates the general principle of "don't use raw shell commands with the command
resource unless you have to."
We're just doing it this way for the example.
If we would like to have a more detailed and cleaner feedback to our user, we can override the standard title of our describe block with a specific message that describes the intent of the test and use the subject
keyword to tell InSpec what the "subject" is for the test. This allows us to refactor the code like this:
control 'Requirement 6' do
impact 1.0
title 'Checking that /etc/nginx does not return empty'
desc 'Let\'s do this the concise way.'
describe "The /etc/nginx directory" do
subject { command('ls -al').stdout.strip }
it { should_not be_empty }
end
end
✔ Requirement 6: Checking that /etc/nginx does not return empty
✔ The /etc/nginx directory is expected not to be empty
Much better, right? We can override InSpec's default output to print a message that is actually useful.
Info
Another benefit to using subject
is preventing command output from being stored in the report.
The should
vs. expect
syntax
Users familiar with the RSpec testing framework may know that there are two ways to write test statements: should
and expect
. The RSpec community decided that expect
is the preferred syntax.
InSpec recommends the should
syntax as it tends to read more easily. However, there are times when the expect
syntax will communicate much more clearly to the end-user. InSpec will continue to support both methods of writing tests.
Let's copy the describe
shown below directly into our example.rb
file (we don't need to wrap them in a control
block for this section). Consider this describe
block from your my_nginx
profile:
describe file('/etc/nginx/nginx.conf') do
it { should be_file }
end
File /etc/nginx/nginx.conf
✔ should be a file
This can be re-written with expect syntax.
describe file('/etc/nginx/nginx.conf') do
it 'should be a file' do
expect(subject).to(be_file)
end
end
File /etc/nginx/nginx.conf
✔ should be a file
Notice that the output is the same for both the examples above.
In addition, you can make use of the subject
keyword to further control your output if you choose:
describe 'I can make this any string I want!' do
subject { file('/etc/nginx/nginx.conf') }
it 'should be a file' do
expect(subject).to(be_file)
end
end
I can make this any string I want!
✔ should be a file
Info
Note that all three of the above code examples are running the same test that will pass or fail in the same circumstances — the difference is that the second two examples give more control over the message output that appears to the test performer. If you write tests that will later be executed by other people, you should be sure to write descriptive test output!
Reference: https://docs.chef.io/inspec/profiles/
expect
syntax with a failure message
In addition to using an expect
statement, a failure message can be added to provide a meaningful output to the end user.
Consider this shell access test from your nginx profile:
describe users.shells(/bash/).usernames do
it { should be_in input('admin_users')}
end
["root"]
× is expected to be in "admin"
expected `["root"]` to be in the list: `["admin"]`
Diff:
["root"]
Again, this is a valid test, and will return the right pass/fail answer when run, but it is difficult for a person to parse if they were not the author ("The root user is supposed to be 'in' the admin user? What does that mean?!").
The output of a test like this can be refined to provide a cleaner output to the user. This can be done using a custom failure message:
non_admin_users = users.shells(/bash/).usernames
describe "Shell access for non-admin users" do
it "should be removed." do
failure_message = "These non-admin should not have shell access: #{non_admin_users.join(", ")}"
expect(non_admin_users).to eq(input('admin_users')), failure_message
end
end
Shell access for non-admin users
× should be removed.
These non-admin should not have shell access: root
The failure_message
variable in the above describe
block is assigned a value by pure Ruby assignment. Remember how we said that, since InSpec is built on Ruby, any Ruby syntax will work inside an InSpec test? Ruby's string formatting syntax (the #{non_admin_users.join(", ")}
) can create a string that lists the users who fail the test by having shell access when they shouldn't.
Writing good failure messages
The trick to writing useful failure messages is to use Ruby to find the subset of all elements we are testing (here, the users) that actually fail the test. We don't need to print a statement for every array element we tested; we only need to print a statement that shows the elements that failed.
Expect
syntax and Password Hashes
Here's another example -- we have an InSpec test that checks if passwords are SHA512 hashes.
As we said, when possible, and when there is a high change of a large set only having a few offending items, attempt to find only those items that could be outside our requirements. If there are none -- wonderful! We met our requirement.
bad_users = inspec.shadow.where { password != "*" && password != "!" && password !~ /\$6\$/ }.users # note that SHA512-encrypted passwords are marked by starting with '$6$' in /etc/shadow
describe 'Password hashes in /etc/shadow' do
it 'should only contain SHA512 hashes' do
failure_message = "Users without SHA512 hashes: #{bad_users.join(', ')}"
expect(bad_users).to be_empty, failure_message
end
end
Using Multiple Resources For One Test
The file
resource is perfect for looking at single files and their properties. However, it does not look at groups of files. To do that, we need to use multiple resources in concert.
Take a look at this example from a profile for use in AWS virtual machines. We use the command
resource to run the find
command and then use the file
resource to investigate each result. Using multiple resources together is one of the key values InSpec provides, allowing you to get at just the data you need when you need it.
command('find ~/* -type f -maxdepth 0 -xdev').stdout.split.each do |fname| # we need to be careful about using 'find' --
# there could be a LOT of output if we are not specific enough with the search!
describe file(fname) do
its('owner') { should cmp 'ec2-user' }
end
end
Avoid Large Sets or 'Check Everyone at the Door' Approaches
For IO intensive (full filesystem, or global scans) or large scale processes, try to be as specific as possible with your searches. Think about using 'negative logic' vs 'positive logic' - "Find me all the items outside my target set" vs "Look at each item in the set and ensure it has these properties".
This 'find the outsiders' vs 'check everyone at the door' approach can really speed things along. Again, keep your data set as small as possible, don't inspect more than the requirements require!