Skip to content

Feature/pure ruby ssl implementation for root certificate issuer check #71

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 28, 2020
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This CHANGELOG follows the format listed [here](https://github.com/sensu-plugins
### Added
- 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 Check plugin `check-ssl-root-issuer.rb` with alternative logic for trust anchor verification.

## [2.0.1] - 2018-05-30
### Fixed
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
127 changes: 127 additions & 0 deletions bin/check-ssl-root-issuer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#! /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. Defaults to RFC2253. Allowed values: RFC2253, ONELINE, COMPAT',
short: '-f',
long: '--format FORMAT_VAL',
default: 'RFC2253',
required: false

def validate_opts
Copy link
Member

@majormoses majormoses Jun 26, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple thoughts:

  • I think we should we still add validation to the options passed in themselves. if I pass in an invalid value I see no reason to run this code here
  • I think we should add conditional logic, if I specify something other than those two options the way the code is written it will attempt to validate with RFC2253 regardless of what is passed in
  • should we add a qualifier here that this is specific to format or do you intend that we would keep adding more validation to this?

Maybe that should be something we pass in and can try something like this? (not sure if this is valid)

def validate_format_opts(config[:format])
  OpenSSL::X509::Name::config[:format]
end


# I am pretty sure this will work but is dangerous and could lead easily to code injection
# that being said if we validate the options beforehand I think that is sufficient mitigation
# (which I would call out in a comment) and will make sense right before the rubocop
# disable as I am sure it will (rightly so) yell at us as its incredibly dangerous but powerful
# if wielded properly
def validate_format_opts(config[:format])
  eval "OpenSSL::X509::Name::#{config[:format]}"
end

if we cant specify it like that then we could always use if, elif, else or case statements to handle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I've updated the logic to use mixin's cli option value validation method.

config[:issuer_format] = OpenSSL::X509::Name::RFC2253
config[:issuer_format] = OpenSSL::X509::Name::ONELINE if config[:format] == 'ONELINE'
config[:issuer_format] = OpenSSL::X509::Name::COMPAT if config[:format] == 'COMPAT'
end

def validate_issuer(cert)
issuer = cert.issuer.to_s(config[:issuer_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'
validate_opts
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
2 changes: 1 addition & 1 deletion sensu-plugins-ssl.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Gem::Specification.new do |s|

s.add_runtime_dependency 'sensu-plugin', '~> 4.0'

s.add_development_dependency 'bundler', '~> 1.7'
s.add_development_dependency 'bundler', '~> 2.1'
s.add_development_dependency 'codeclimate-test-reporter', '~> 0.4'
s.add_development_dependency 'github-markup', '~> 3.0'
s.add_development_dependency 'pry', '~> 0.10'
Expand Down
17 changes: 12 additions & 5 deletions test/check-ssl-hsts-preloadable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@
expect { check.run }.to raise_error SystemExit
end

it 'should pass check if the domain is preloadedable but has warnings' do
check.config[:domain] = 'oskuro.net'
expect(check).to receive(:warning).and_raise SystemExit
expect { check.run }.to raise_error SystemExit
end
##
# Disabled 2020/06/24 JDS
# Reason: the hsts-preloadable check depends on a domain lookup from https://hstspreload.org/
# There's no way to assure that an indexed domain at hstspreload.org will have a warning
# The previously tested domain 'oskuro.net' no longer issues a warning
# as its now incompliance with the hsts preload requirements.
##
# it 'should pass check if the domain is preloadedable but has warnings' do
# check.config[:domain] = 'oskuro.net'
# expect(check).to receive(:warning).and_raise SystemExit
# expect { check.run }.to raise_error SystemExit
# end

it 'should pass check if not preloadedable' do
check.config[:domain] = 'example.com'
Expand Down
24 changes: 24 additions & 0 deletions test/check-ssl-root-issuer.rb
Original file line number Diff line number Diff line change
@@ -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