6. Create a Custom Resource - The Docker Example
Custom Resource - Docker
Let's try a more complicated example by creating a resource that can parse a Docker Compose file.
If you've ever deployed containerized applications before, you might be familiar with Docker Compose, which is a container orchestration feature of the Docker container runtime. Docker Compose works by reading a YAML specfile called the Compose file that defines attributes about a set of containers we want to deploy and how they connect together. We don't need to know too much about how to run Docker Compose for this class, but let's say that we want to write an InSpec resource for testing that our Compose files match the configuration we expect.
Create a new profile and set up Docker files
First, we need a test target. Luckily, we already have a great one sitting in our Codespaces environment. It's the same Docker Compose file we use in the build-lab.sh
script to launch the containers we use for this class! It looks like this:
# https://docs.docker.com/compose/compose-file/compose-file-v3/
version: '3'
services:
operatingsystem:
image: redhat/ubi9
container_name: redhat9
tty: true
stdin_open: true
restart: always
nginx:
image: nginx:latest
container_name: nginx
tty: true
stdin_open: true
restart: always
We will continue writing our controls to check against this Compose file.
inspec init profile docker-workstations
Redirecting to cinc-auditor...
βββββββββββββββββββββββββββ InSpec Code Generator βββββββββββββββββββββββββββ
Creating new profile at /workspaces/saf-training-lab-environment/docker-workstations
β’ Creating file inspec.yml
β’ Creating directory /workspaces/saf-training-lab-environment/docker-workstations/controls
β’ Creating file controls/example.rb
β’ Creating file README.md
Develop controls to test/run profile
Conceptually, we want to write tests with this profile that will check different settings in a docker-compose file. If you are not sure what the InSpec code looks like for a particular test, start by writing what conceptually you want to achieve, then modify it to be correct syntax. We can do that with the idea of checking a setting in a docker-compose file, which we know is a YAML file, as such:
In the docker-workstations/controls/example.rb
file, write the control:
describe yaml('file_name') do
its('setting') { should_not eq 'value' }
end
We test early and often as according to the test-driven development paradigm. We know that the test we wrote is not complete, but we can see if we are on the right track. Remember that the command line output can help guide your development!
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: 6dcb9e6f-5ede-5474-9521-595fadf5c7ce
YAML file_name
βΊ Can't find file: file_name
Test Summary: 0 successful, 0 failures, 1 skipped
We need to replace the file_name
above with the location of the docker-compose.yml
file. We also need to change the setting
to grab the tag we want to retrieve. Finally we need to change value
with the actual value as shown in the docker compose file. You can write multiple expectation statements in the describe block.
describe yaml('docker-compose.yml') do
its(['services', 'operatingsystem', 'image']) { should eq 'redhat/ubi9' }
its(['services', 'nginx', 'image']) { should cmp 'nginx:latest' }
end
Now if we test this control using the following command we should see all the tests pass.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
Test Summary: 2 successful, 0 failures, 0 skipped
Much like our git
example, this series of tests works, but it could be made better. We essentially parsed the Compose file with a simple YAML file parser which is fine for a one-off. However, if anybody else reads this code, it might not be clear what specific system component we are testing. Recall that we want InSpec tests to be extremely intuitive to read, even by people who did not write the code (and even by people who are not InSpec developers!). Furthermore, Compose files are very common! There's a high likelihood that you'd need to assess the contents of one again.
Instead of writing a lot of repetitive code, we could create a resource specific to Compose files that exposes relevant attributes in an easy to access manner for reuse by our other controls - or even the broader security community if we choose to publish it publicly and/or get it merged into InSpec proper.
Rewrite test to utilize a new resource
Going back to the control, we will write it using a resource that doesn't exist called docker-compose-config that is going to take a path as a parameter.
Test Driven Development
Remember the idea of Test Driven Development (TDD), the "red-green-clean" cycle. This way of development is driven by the tests. In this way, you know when you have succeeded while developing something new! In other words, before writing a solution, first write the test (which will fail - red), so that you know exactly what the expectation should be and when you have achieved it. Then you can write the solution to make the test pass (green). Finally, clean up the solution to make it easy to read and efficient!
describe yaml('docker-compose.yml') do
its(['services', 'operatingsystem', 'image']) { should eq 'redhat/ubi9' }
its(['services', 'nginx', 'image']) { should cmp 'nginx:latest' }
end
describe docker_compose_config('docker-compose.yml') do
its('services.operatingsystem.image') { should eq 'redhat/ubi9' }
its('services.nginx.image') { should cmp 'nginx:latest' }
end
Now we should see an error if we go back to terminal and run the same command to execute a scan. We should get an error saying the docker_compose_config
method does not yet exist. That's because we have not yet defined this resource.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
Test Summary: 2 successful, 0 failures, 0 skipped
@wdower β /workspaces/saf-training-lab-environment (answer_key) $ inspec exec docker-workstations/
Redirecting to cinc-auditor...
< lots of error text >
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Failure Message: Failed to load source for controls/example.rb: undefined method `docker_compose_config' for
< lots more error text >
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
No tests executed.
Test Summary: 0 successful, 0 failures, 0 skipped
Develop the Docker resource
In the libraries
directory of the profile we will make a docker_compose_config.rb
file, , the content of the file should look like this:
# encoding: utf-8
# copyright: 2019, The Authors
class DockerComposeConfig < Inspec.resource(1)
name 'docker_compose_config'
end
Using InSpec Init to Create the Resource
Alternatively, you can use inspec init resource <your-resource-name>
to create the template for your custom resource. You may have a "lib" folder or a "libraries" folder. Make sure that InSpec recognizes the location of your custom resource.
inspec init resource docker_compose_config --overwrite
Enter Subdirectory under which to create files: ./docker-workstations
Choose File layout, either 'resource-pack' or 'core': Resource Pack
Choose Which type of resource template to use: Basic
Enter the description of this resource: A resource for testing docker-compose files
Enter Class Name for your resource.: DockerComposeConfig
βββββββββββββββββββββββββββ InSpec Code Generator βββββββββββββββββββββββββββ
Creating new resource at /workspaces/saf-training-lab-environment/docker-workstations
β’ Creating directory /workspaces/saf-training-lab-environment/docker-workstations/docs
β’ Creating file docs/docker_compose_config.md
β’ Creating directory /workspaces/saf-training-lab-environment/docker-workstations/libraries
β’ Creating file libraries/docker_compose_config.rb
Now when we save and run the profile again using:
inspec exec docker-workstations
Redirecting to cinc-auditor...
[2024-12-09T07:03:10+00:00] ERROR: Failed to load profile docker-workstations: Failed to load source for controls/example.rb: wrong number of arguments (given 1, expected 0)
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Failure Message: Failed to load source for controls/example.rb: wrong number of arguments (given 1, expected 0)
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
No tests executed.
Test Summary: 0 successful, 0 failures, 0 skipped
We will get an error saying we gave it the wrong number of arguments: was given 1 but expected 0
. This is because every class in Ruby that has a parameter must have an initialize function to accept that parameter.
# encoding: utf-8
# copyright: 2019, The Authors
class DockerComposeConfig < Inspec.resource(1)
name 'docker_compose_config'
def initialize(path)
@path = path
end
end
Now let's run the profile once more.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
docker_compose_config
Γ services.operatingsystem.image
undefined method `services' for #<#<Class:0x000077e66a0908d0>:0x000077e66a0c2358 @resource_skipped=false, @resource_failed=false, @supports=nil, @resource_exception_message=nil, @__backend_runner__=Inspec::Backend::Class @transport=Train::Transports::Local::Connection, @__resource_name__="docker_compose_config", @resource_params=["docker-compose.yml"], @path="docker-compose.yml">
Γ services.nginx.image
undefined method `services' for #<#<Class:0x000077e66a0908d0>:0x000077e66a0c2358 @resource_skipped=false, @resource_failed=false, @supports=nil, @resource_exception_message=nil, @__backend_runner__=Inspec::Backend::Class @transport=Train::Transports::Local::Connection, @__resource_name__="docker_compose_config", @resource_params=["docker-compose.yml"], @path="docker-compose.yml">
Test Summary: 2 successful, 2 failures, 0 skipped
This time the profile runs, but we get a message that the docker_compose_config
resource does not have the services
method. So let's define that method now:
# encoding: utf-8
# copyright: 2019, The Authors
class DockerComposeConfig < Inspec.resource(1)
name 'docker_compose_config'
def initialize(path)
@path = path
end
def services
end
end
Start by just defining the services
method. Then, let's run the profile once more.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
docker_compose_config
Γ services.operatingsystem.image
undefined method `operatingsystem' for nil:NilClass
Γ services.nginx.image
undefined method `nginx' for nil:NilClass
Test Summary: 2 successful, 2 failures, 0 skipped
Now we got a different failure that tells us a nil
value was returned. So now we will go ahead and define the services method. We will use an already existing InSpec resource to parse the path file.
# encoding: utf-8
# copyright: 2019, The Authors
class DockerComposeConfig < Inspec.resource(1)
name 'docker_compose_config'
def initialize(path)
@path = path
@yaml = inspec.yaml(path)
end
def services
@yaml['services']
end
end
Now let's run the profile once more.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
docker_compose_config
Γ services.operatingsystem.image
undefined method `operatingsystem' for {"operatingsystem"=>{"image"=>"redhat/ubi9", "container_name"=>"redhat9", "tty"=>true, "stdin_open"=>true, "restart"=>"always"}, "nginx"=>{"image"=>"nginx:latest", "container_name"=>"nginx", "tty"=>true, "stdin_open"=>true, "restart"=>"always"}}:Hash
Γ services.nginx.image
undefined method `nginx' for {"operatingsystem"=>{"image"=>"redhat/ubi9", "container_name"=>"redhat9", "tty"=>true, "stdin_open"=>true, "restart"=>"always"}, "nginx"=>{"image"=>"nginx:latest", "container_name"=>"nginx", "tty"=>true, "stdin_open"=>true, "restart"=>"always"}}:Hash
Test Summary: 2 successful, 2 failures, 0 skipped
You will notice that it parses correctly, but instead of our result we end up getting a giant hash of data that doesn't seem to parse correctly. We need to convert the hash into an object that appears like other objects so that we may use our dot notation. So we will wrap our hash in a Ruby class called a Hashie::Mash
. This gives us a quick way to convert a hash into a Ruby object with a number of methods attached to it. You will have to import the Hashie library by running gem install hashie
and import it in the resource file to be used. It and is written as follows:
# encoding: utf-8
# copyright: 2019, The Authors
require "hashie/mash"
class DockerComposeConfig < Inspec.resource(1)
name 'docker_compose_config'
def initialize(path)
@path = path
@yaml = inspec.yaml(path)
end
def services
Hashie::Mash.new(@yaml['services'])
end
end
Lets run the profile again.
inspec exec docker-workstations
Redirecting to cinc-auditor...
Profile: InSpec Profile (docker-workstations)
Version: 0.1.0
Target: local://
Target ID: bdf4338d-8fb0-5ca6-a949-ced198781ad5
YAML docker-compose.yml
β ["services", "operatingsystem", "image"] is expected to eq "redhat/ubi9"
β ["services", "nginx", "image"] is expected to cmp == "nginx:latest"
docker_compose_config
β services.operatingsystem.image is expected to eq "redhat/ubi9"
β services.nginx.image is expected to cmp == "nginx:latest"
Test Summary: 4 successful, 0 failures, 0 skipped
Everything passed!