From b946f5454d59248f7c6a6492bf288212a218e34e Mon Sep 17 00:00:00 2001 From: Vasundhara Jagdale Date: Fri, 19 Jan 2024 16:26:18 +0000 Subject: [PATCH] CHEF-3962: Adds ability to verify ssh keys (#6656) * ssh_key resource file Signed-off-by: Vasu1105 * Initial commit for unit test for ssh_key resource Signed-off-by: Vasu1105 * Fix linting and added resource in resources file Signed-off-by: Vasu1105 * extend the ssh key resource to use file resource properties Signed-off-by: Vasu1105 * Updates the ssh_key resource to get length and type of key. Signed-off-by: Vasu1105 * Updates unit test for ssh_key resource. Signed-off-by: Vasu1105 * ADDS Docs for ssh_key resource Signed-off-by: Vasu1105 * Fix Review: Empty file handling Signed-off-by: Vasu1105 * Fixed review comments and few code refactoring for ssh_key resource Signed-off-by: Vasu1105 * Doc edits Signed-off-by: Ian Maddaus * Updates test Signed-off-by: Vasu1105 --------- Signed-off-by: Vasu1105 Signed-off-by: Ian Maddaus Co-authored-by: Ian Maddaus --- .../content/inspec/resources/ssh_key.md | 114 ++++++++++++++++ lib/inspec/resources.rb | 1 + lib/inspec/resources/ssh_key.rb | 124 ++++++++++++++++++ test/helpers/mock_loader.rb | 1 + test/unit/resources/ssh_key_test.rb | 37 ++++++ 5 files changed, 277 insertions(+) create mode 100644 docs-chef-io/content/inspec/resources/ssh_key.md create mode 100644 lib/inspec/resources/ssh_key.rb create mode 100644 test/unit/resources/ssh_key_test.rb diff --git a/docs-chef-io/content/inspec/resources/ssh_key.md b/docs-chef-io/content/inspec/resources/ssh_key.md new file mode 100644 index 000000000..c0cb3c1ba --- /dev/null +++ b/docs-chef-io/content/inspec/resources/ssh_key.md @@ -0,0 +1,114 @@ ++++ +title = "ssh_key resource" +draft = false +gh_repo = "inspec" +platform = "os" + +[menu] + [menu.inspec] + title = "ssh_key" + identifier = "inspec/resources/os/ssh_key.md ssh_key resource" + parent = "inspec/resources/os" ++++ + +Use the `ssh_key` Chef InSpec audit resource to test ssh keys. Supported key types RSA, DSA(Limited support. Not verifies private key) , ECDSA, Ed25519 + +## Availability + +### Install + +{{% inspec/inspec_installation %}} + +### Version + +This resource first became available in v1.18.0 of Chef InSpec. + +## Syntax + +The `ssh_key` resource tests the properties of an SSH key file. Provide the path to a key file or a key filename. If you pass in a filename, this resource checks for keys on default path `~/.ssh/`. + +```rb +describe ssh_key('~/.ssh/id_rsa') do + it { should be_private } + it { should be_public } + its('type') { should cmp /rsa/ } + its('key_length') { should eq 2048 } + its('mode') { should cmp '0400' } +end +``` + +You can use an optional passphrase with `ssh_key`: + +```rb +describe ssh_key('~/.ssh/id_rsa', '') do + it { should be_private } +end +``` + +Replace `` with the private key passphrase. + +## Properties + +### key_length + +The `key_length` property tests the number of bits in the key pair. This only works with RSA keys. + +```rb +describe ssh_key('~/.ssh/id_rsa') do + its('key_length') { should eq 4096 } +end +``` + +### type + +The `type` property verifies the key type. + +```rb +describe ssh_key('~/.ssh/id_ecdsa') do + its('type') { should cmp /ecdsa/ } +end +``` + +Allowed values: + +- `rsa` +- `ecdsa` +- `ed25519` +- `dsa` + +### file properties + +The ssh_key resource also tests the same properties that the [file resource](/inspec/resources/file#properties) tests. +For example, you can use the `mode` property to test if the mode assigned to the SSH key matches the specified value. + +```rb +describe ssh_key('~/.ssh/id_rsa') do + its('mode') { should cmp '0400' } +end +``` + +## Matchers + +For a full list of available matchers, see the [matchers page](/inspec/matchers/). + +### be_public + +Use `be_public` to verify that a key is public key: + +```rb +describe ssh_key('~/.ssh/id_ed25519.pub') do + it { should be_public } +end +``` + +### be_private + +Use `be_private` to verify that a key is a private key: + +```rb +describe ssh_key('~/.ssh/id_ecdsa', '') do + it { should be_private } +end +``` + +Replace `` with the private key passphrase. diff --git a/lib/inspec/resources.rb b/lib/inspec/resources.rb index bb6ff83c4..51f5b6a40 100644 --- a/lib/inspec/resources.rb +++ b/lib/inspec/resources.rb @@ -110,6 +110,7 @@ require "inspec/resources/selinux" require "inspec/resources/service" require "inspec/resources/shadow" require "inspec/resources/ssh_config" +require "inspec/resources/ssh_key" require "inspec/resources/ssl" require "inspec/resources/sys_info" require "inspec/resources/toml" diff --git a/lib/inspec/resources/ssh_key.rb b/lib/inspec/resources/ssh_key.rb new file mode 100644 index 000000000..9984cd292 --- /dev/null +++ b/lib/inspec/resources/ssh_key.rb @@ -0,0 +1,124 @@ +require "inspec/utils/file_reader" +require "net/ssh" unless defined?(Net::SSH) + +# Change module if required +module Inspec::Resources + class SshKey < FileResource + # Every resource requires an internal name. + name "ssh_key" + + # Restrict to only run on the below platforms (if none were given, + # all OS's and cloud API's supported) + supports platform: "unix" + supports platform: "windows" + + desc "public/private SSH key pair test" + + example <<~EXAMPLE + describe ssh_key('path: ~/.ssh/id_rsa') do + its('key_length') { should eq 4096 } + its('type') { should cmp /rsa/ } + it { should be_private } + end + EXAMPLE + + include FileReader + + def initialize(keypath, passphrase = nil) + skip_resource "The `ssh_key` resource is not yet available on your OS." unless inspec.os.unix? || inspec.os.windows? + @key_path = set_ssh_key_path(keypath) + @passphrase = passphrase + @key = read_ssh_key + super(@key_path) + end + + def public? + return if @key.nil? + + @key[:public] + end + + def private? + return if @key.nil? + + @key[:private] + end + + def key_length + return if @key.nil? + + @key[:key_length] + end + + def type + return if @key.nil? + + @key[:type] + end + + # Define a resource ID. This is used in reporting engines to uniquely identify the individual resource. + # This might be a file path, or a process ID, or a cloud instance ID. Only meaningful to the implementation. + # Must be a string. Defaults to the empty string if not implemented. + def resource_id + @key_path || "SSH key" + end + + def to_s + "ssh_key #{@key_path}" + end + + private + + def set_ssh_key_path(keypath) + if File.exist?(keypath) + @key_path = keypath + elsif File.exist?(File.join("#{Dir.home}/.ssh/", keypath)) + @key_path = File.join("#{Dir.home}/.ssh/", keypath) + else + raise Inspec::Exceptions::ResourceSkipped, "Can't find file: #{keypath}" + end + end + + def read_ssh_key + key_data = {} + key = nil + filecontent = read_file_content((@key_path), @passphrase) + raise Inspec::Exceptions::ResourceSkipped, "File is empty: #{@key_path}" if filecontent.split("\n").empty? + + if filecontent.split("\n")[0].include?("PRIVATE") + # Net::SSH::KeyFactory does not have support to load private key for DSA + key = Net::SSH::KeyFactory.load_private_key(@key_path, @passphrase, false) + unless key.nil? + key_data[:private] = true + key_data[:public] = false + # The data send for ssh type is not in same format so it's good to match on the string + key_data[:type] = key.ssh_type + key_data[:key_length] = key_lengh(key) + end + else + key = Net::SSH::KeyFactory.load_public_key(@key_path) + unless key.nil? + key_data[:private] = false + key_data[:public] = true + # The data send for ssh type is not in same format so it's good to match on the string + key_data[:type] = key.ssh_type + key_data[:key_length] = key_lengh(key) + end + end + + key_data + rescue OpenSSL::PKey::PKeyError => e + raise Inspec::Exceptions::ResourceFailed, "#{e.message}" + end + + def key_lengh(key) + if key.class.to_s == "OpenSSL::PKey::RSA" + key.public_key.n.num_bits + else + # Unable to get the key lenght data for other types of keys + # TODO: Need to check if there is any method that will get this info. + nil + end + end + end +end diff --git a/test/helpers/mock_loader.rb b/test/helpers/mock_loader.rb index 6cbe6e405..0722b7fa6 100644 --- a/test/helpers/mock_loader.rb +++ b/test/helpers/mock_loader.rb @@ -151,6 +151,7 @@ class MockLoader "test_certificate.rsa.crt.pem" => mockfile.call("test_certificate.rsa.crt.pem"), "test_certificate.rsa.key.pem" => mockfile.call("test_certificate.rsa.key.pem"), "test_ca_public.key.pem" => mockfile.call("test_ca_public.key.pem"), + "test/fixtures/files/test_rsa_key" => mockfile.call("test_rsa_key"), # Test DH parameters, 2048 bit long safe prime, generator 2 for dh_params in PEM format "dh_params.dh_pem" => mockfile.call("dh_params.dh_pem"), "default.toml" => mockfile.call("default.toml"), diff --git a/test/unit/resources/ssh_key_test.rb b/test/unit/resources/ssh_key_test.rb new file mode 100644 index 000000000..60a68d20f --- /dev/null +++ b/test/unit/resources/ssh_key_test.rb @@ -0,0 +1,37 @@ +require "inspec/globals" +require "#{Inspec.src_root}/test/helper" +require_relative "../../../lib/inspec/resources/ssh_key" +require "mixlib/shellout" + +class TestSshKeyResource < Minitest::Test + def setup + # Generate an SSH RSA key for testing + @private_key_path = generate_ssh_key + end + + def teardown + # Clean up: remove the generated ssh keys + FileUtils.rm_rf(@private_key_path) + FileUtils.rm_rf("#{@private_key_path}.pub") + end + + def test_ssh_key_resoure + @ssh_key = MockLoader.new("ubuntu".to_sym).load_resource("ssh_key", @private_key_path) + assert_match("rsa", @ssh_key.type) + assert_equal(4096, @ssh_key.key_length) + assert_equal(true, @ssh_key.private?) + assert_equal(false, @ssh_key.public?) + end + + private + + def generate_ssh_key + file_path = "test/fixtures/files/test_rsa_key" + cmd = Mixlib::ShellOut.new("yes | ssh-keygen -t rsa -b 4096 -N '' -f #{file_path}") + cmd.run_command + cmd.error! + sleep 3 + + file_path + end +end \ No newline at end of file