From c2bc9d586b1efbc2ee97795575cddea5425eb13f Mon Sep 17 00:00:00 2001 From: Jef Spaleta Date: Sat, 27 Jun 2020 22:43:57 -0800 Subject: [PATCH] Feature/pure ruby ssl implementation for root certificate issuer check (#71) * Add option to treat anchor as a regexp. Fix parsing of openssl client output to work with both openssl 1.0 and openssl 1.1 formatting * updates to make travis and rubocop happy * Add pure ruby implementation of check-ssl-root-issuer.rb as alternative to check-ssl-anchor.rb * make rubocop happy * add test for check-ssl-root-issuer * update changelog and README with new plugin information * remove files changed in PR #70, unrelated to this new feature * Update logic for validating issuer name format options. Using mixin libraries internal validation for allowed values. --- CHANGELOG.md | 1 + README.md | 10 ++- bin/check-ssl-root-issuer.rb | 126 ++++++++++++++++++++++++++++++++++ test/check-ssl-root-issuer.rb | 24 +++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100755 bin/check-ssl-root-issuer.rb create mode 100644 test/check-ssl-root-issuer.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ef223..2592f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This CHANGELOG follows the format listed [here](https://github.com/sensu-plugins - Travis build automation to generate Sensu Asset tarballs that can be used n conjunction with Sensu provided ruby runtime assets and the Bonsai Asset Index - Require latest sensu-plugin for [Sensu Go support](https://github.com/sensu-plugins/sensu-plugin#sensu-go-enablement) - New option to treat anchor argument as a regexp +- New Check plugin `check-ssl-root-issuer.rb` with alternative logic for trust anchor verification. ### Changed - `check-ssl-anchor.rb` uses regexp to test for present of certificates in cert chain that works with both openssl 1.0 and 1.1 formatting diff --git a/README.md b/README.md index ca5019f..13655a4 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ The Sensu assets packaged from this repository are built against the Sensu Ruby * bin/check-ssl-hsts-preload.rb * bin/check-ssl-hsts-preloadable.rb * bin/check-ssl-qualys.rb + * bin/check-ssl-root-issuer.rb ## Usage ### `bin/check-ssl-anchor.rb` -Check that a specific website is chained to a specific root certificate (Let's Encrypt for instance). +Check that a specific website is chained to a specific root certificate (Let's Encrypt for instance). Requires the `openssl` commandline tool to be available on the system. ``` ./bin/check-ssl-anchor.rb -u example.com -a "i:/O=Digital Signature Trust Co./CN=DST Root CA X3" @@ -56,6 +57,13 @@ Checks the ssllabs qualysis api for grade of your server, this check can be quit ./bin/check-ssl-qualys.rb -d google.com ``` +### `bin/check-ssl-root-issuer.rb` + +Check that a specific website is chained to a specific root certificate issuer. This is a pure Ruby implementation, does not require the openssl cmdline client tool to be installed. + +``` +./bin/check-ssl-root-issuer.rb -u example.com -a "CN=DST Root CA X3,O=Digital Signature Trust Co." +``` ## Installation diff --git a/bin/check-ssl-root-issuer.rb b/bin/check-ssl-root-issuer.rb new file mode 100755 index 0000000..ebd1c1a --- /dev/null +++ b/bin/check-ssl-root-issuer.rb @@ -0,0 +1,126 @@ +#! /usr/bin/env ruby +# +# check-ssl-root-issuer +# +# DESCRIPTION: +# Check that a certificate is chained to a specific root certificate issuer +# +# OUTPUT: +# plain text +# +# PLATFORMS: +# Linux +# +# DEPENDENCIES: +# gem: sensu-plugin +# +# USAGE: +# +# Check that a specific website is chained to a specific root certificate +# ./check-ssl-root-issuer.rb \ +# -u https://example.com \ +# -i "CN=DST Root CA X3,O=Digital Signature Trust Co." +# +# LICENSE: +# Copyright Jef Spaleta (jspaleta@gmail.com) 2020 +# Released under the same terms as Sensu (the MIT license); see LICENSE +# for details. +# + +require 'sensu-plugin/check/cli' +require 'openssl' +require 'uri' +require 'net/http' +require 'net/https' + +# +# Check root certificate has specified issuer name +# +class CheckSSLRootIssuer < Sensu::Plugin::Check::CLI + option :url, + description: 'Url to check: Ex "https://google.com"', + short: '-u', + long: '--url URL', + required: true + + option :issuer, + description: 'An X509 certificate issuer name, RFC2253 format Ex: "CN=DST Root CA X3,O=Digital Signature Trust Co."', + short: '-i', + long: '--issuer ISSUER_NAME', + required: true + + option :regexp, + description: 'Treat the issuer name as a regexp', + short: '-r', + long: '--regexp', + default: false, + boolean: true, + required: false + + option :format, + description: 'optional issuer name format.', + short: '-f', + long: '--format FORMAT_VAL', + default: 'RFC2253', + in: %w('RFC2253', 'ONELINE', 'COMPAT'), + required: false + + def cert_name_format + # Note: because format argument is pre-validated by mixin 'in' logic eval is safe to use + eval "OpenSSL::X509::Name::#{config[:format]}" # rubocop:disable Lint/Eval + end + + def validate_issuer(cert) + issuer = cert.issuer.to_s(cert_name_format) + if config[:regexp] + issuer_regexp = Regexp.new(config[:issuer].to_s) + issuer =~ issuer_regexp + else + issuer == config[:issuer].to_s + end + end + + def find_root_cert(uri) + root_cert = nil + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 10 + http.read_timeout = 10 + http.use_ssl = true + http.cert_store = OpenSSL::X509::Store.new + http.cert_store.set_default_paths + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + + http.verify_callback = lambda { |verify_ok, store_context| + root_cert = store_context.current_cert unless root_cert + unless verify_ok + @failed_cert = store_context.current_cert + @failed_cert_reason = [store_context.error, store_context.error_string] if store_context.error != 0 + end + verify_ok + } + http.start {} + root_cert + end + + # Do the actual work and massage some data + + def run + @fail_cert = nil + @failed_cert_reason = 'Unknown' + uri = URI.parse(config[:url]) + critical "url protocol must be https, you specified #{url}" if uri.scheme != 'https' + root_cert = find_root_cert(uri) + if @failed_cert + msg = "Certificate verification failed.\n Reason: #{@failed_cert_reason}" + critical msg + end + + if validate_issuer(root_cert) + msg = 'Root certificate in chain has expected issuer name' + ok msg + else + msg = "Root certificate issuer did not match expected name.\nFound: \"#{root_cert.issuer.to_s(config[:issuer_format])}\"" + critical msg + end + end +end diff --git a/test/check-ssl-root-issuer.rb b/test/check-ssl-root-issuer.rb new file mode 100644 index 0000000..6b27891 --- /dev/null +++ b/test/check-ssl-root-issuer.rb @@ -0,0 +1,24 @@ +require_relative '../bin/check-ssl-anchor.rb' + +describe CheckSSLRootIssuer do + before(:all) do + # Ensure the check isn't run when exiting (which is the default) + CheckSSLRootIssuer.class_variable_set(:@@autorun, nil) + end + + let(:check) do + CheckSSLRootIssuer.new ['-u', 'https://philporada.com', '-i', '"CN=DST Root CA X3,O=Digital Signature Trust Co."'] + end + + it 'should pass check if the root issuer matches what the users -i flag' do + expect(check).to receive(:ok).and_raise SystemExit + expect { check.run }.to raise_error SystemExit + end + + it 'should pass check if the root issuer matches what the users -i flag' do + check.config[:anchor] = 'testdata' + check.config[:regexp] = false + expect(check).to receive(:critical).and_raise SystemExit + expect { check.run }.to raise_error SystemExit + end +end