diff --git a/examples/kitchen/.kitchen.linux.yml b/examples/compliance/.kitchen.linux.yml similarity index 90% rename from examples/kitchen/.kitchen.linux.yml rename to examples/compliance/.kitchen.linux.yml index e1743f8d..5643ddfa 100644 --- a/examples/kitchen/.kitchen.linux.yml +++ b/examples/compliance/.kitchen.linux.yml @@ -34,5 +34,5 @@ suites: profiles: - name: linux compliance: base/linux - - name: brewinc/ssh-hardening - supermarket: hardening/ssh-hardening + - name: ssh + compliance: base/ssh diff --git a/examples/kitchen/.kitchen.win.yml b/examples/compliance/.kitchen.win.yml similarity index 100% rename from examples/kitchen/.kitchen.win.yml rename to examples/compliance/.kitchen.win.yml diff --git a/examples/kitchen/Berksfile b/examples/compliance/Berksfile similarity index 100% rename from examples/kitchen/Berksfile rename to examples/compliance/Berksfile diff --git a/examples/kitchen/Gemfile b/examples/compliance/Gemfile similarity index 100% rename from examples/kitchen/Gemfile rename to examples/compliance/Gemfile diff --git a/examples/kitchen/README.md b/examples/compliance/README.md similarity index 100% rename from examples/kitchen/README.md rename to examples/compliance/README.md diff --git a/examples/kitchen/cc_report.png b/examples/compliance/cc_report.png similarity index 100% rename from examples/kitchen/cc_report.png rename to examples/compliance/cc_report.png diff --git a/examples/kitchen/visib_reporting.png b/examples/compliance/visib_reporting.png similarity index 100% rename from examples/kitchen/visib_reporting.png rename to examples/compliance/visib_reporting.png diff --git a/files/default/audit_report.rb b/files/default/audit_report.rb index 51cd21a2..c6396480 100644 --- a/files/default/audit_report.rb +++ b/files/default/audit_report.rb @@ -6,9 +6,19 @@ class Handler # Creates a compliance audit report class AuditReport < ::Chef::Handler def report + reporter = node['audit']['collector'] + server = node['audit']['server'] + user = node['audit']['owner'] + token = node['audit']['token'] + refresh_token = node['audit']['refresh_token'] + load_needed_dependencies - call - send_report + + # ensure authentication for Chef Compliance is in place + login_to_compliance(server, user, token, refresh_token) if reporter == 'chef-compliance' + + call(reporter) + send_report(reporter, server, user) end def load_needed_dependencies @@ -25,35 +35,83 @@ def load_needed_dependencies require 'bundles/inspec-compliance/target' end - def set_json_format - reporter = node['audit']['collector'] - if reporter == 'chef-visibility' - format = 'json' + # TODO: temporary, we should not use this + # TODO: harmonize with CLI login_refreshtoken method + def login_to_compliance(server, user, access_token, refresh_token) + if !refresh_token.nil? + success, msg, access_token = Compliance::API.get_token_via_refresh_token(server, refresh_token, true) else - format = 'json-min' + success = true + end + + if success + config = Compliance::Configuration.new + config['user'] = user + config['server'] = server + config['token'] = access_token + config['insecure'] = true + config['version'] = Compliance::API.version(server, true) + config.store + else + Chef::Log.error msg + raise('Could not store authentication token') end - format end - def call + def call(reporter) Chef::Log.debug 'Initialize InSpec' - format = set_json_format + format = reporter == 'chef-visibility' ? 'json' : 'json-min' + Chef::Log.warn "Format is #{format}" + # TODO: for now we need to store the report to a file we expect that to + # get from the runner + Chef::Log.warn "*********** Directory is #{node['audit']['output']}" opts = { 'format' => format, 'output' => node['audit']['output'] } runner = ::Inspec::Runner.new(opts) tests = tests_for_runner tests.each { |target| runner.add_target(target, opts) } - Chef::Log.debug 'Running tests from: #{tests.inspect}' + Chef::Log.info "Running tests from: #{tests.inspect}" runner.run end - def send_report - reporter = node['audit']['collector'] - Chef::Log.debug 'Reporting to #{reporter}' + def send_report(reporter, server, user) + Chef::Log.info "Reporting to #{reporter}" + # TODO: harmonize reporter interface if reporter == 'chef-visibility' Collector::ChefVisibility.new(entity_uuid, run_id, run_context.node.name).send_report + + elsif reporter == 'chef-compliance' + raise_if_unreachable = node['audit']['raise_if_unreachable'] + url = construct_url(server, File.join('/owners', user, 'inspec')) + if server + # TODO: we should not send the profiles to the reporter, all the information + # should be available in inspec reports out-of-the-box + # TODO: Chef Compliance can only handle reports for profiles it knows + profiles = tests_for_runner.map { |profile| profile[:compliance] }.uniq + compliance_profiles = profiles.map { |profile| + owner, profile_id = profile.split('/') + { + owner: owner, + profile_id: profile_id, + } + } + Collector::ChefCompliance.new(url, run_context, raise_if_unreachable, compliance_profiles).send_report + else + Chef::Log.warn "'server' and 'token' properties required by inspec report collector #{reporter}. Skipping..." + end + + elsif reporter == 'chef-server' + chef_url = server || base_chef_server_url + if chef_url + url = construct_url(chef_url + '/compliance/', File.join('organizations', user, 'inspec')) + Collector::ChefServer.new(url).send_report + else + Chef::Log.warn "unable to determine chef-server url required by inspec report collector '#{reporter}'. Skipping..." + end + else + Chef::Log.warn "#{reporter} is not a supported InSpec report collector" end end end diff --git a/libraries/collector_classes.rb b/libraries/collector_classes.rb index 7433b4ef..91e9bd8d 100644 --- a/libraries/collector_classes.rb +++ b/libraries/collector_classes.rb @@ -7,6 +7,8 @@ class Collector # Used to send inspec reports to Chef Visibility via the data_collector service # class ChefVisibility + include ReportHelpers + @entity_uuid = nil @run_id = nil @node_name = '' @@ -24,13 +26,7 @@ def send_report return false end - # get file contents where inspec results were saved - result_path = File.expand_path('../../inspec_results.json', __FILE__) - file = File.open(result_path, 'rb') - content = file.read - file.close - - # parse that string of contents into json + content = results json_report = enriched_report(JSON.parse(content)) unless json_report @@ -181,4 +177,111 @@ def control_status(results) status end end + + # + # Used to send inspec reports to a Chef Compliance server + # + class ChefCompliance + include ReportHelpers + + @url = nil + @node_info = {} + + # TODO: do not pass run_context in here, define a proper interface + def initialize(_url, run_context, raise_if_unreachable, compliance_profiles) + @run_context = run_context + @config = Compliance::Configuration.new + Chef::Log.warn "Report to Chef Compliance: #{@config['user']}" + Chef::Log.warn "#{@config['server']}/owners/#{@config['user']}/inspec" + @url = URI("#{@config['server']}/owners/#{@config['user']}/inspec") + @token = @config['token'] + @raise_if_unreachable = raise_if_unreachable + @compliance_profiles = compliance_profiles + end + + def send_report + Chef::Log.info "Report to Chef Compliance: #{@token}" + req = Net::HTTP::Post.new(@url, { 'Authorization' => "Bearer #{@token}" }) + + content = results + json_report = enriched_report(JSON.parse(content)) + req.body = json_report + + Chef::Log.info "Report to Chef Compliance: #{@url}" + + # TODO: use insecure option + opts = { use_ssl: @url.scheme == 'https', + verify_mode: OpenSSL::SSL::VERIFY_NONE, + } + Net::HTTP.start(@url.host, @url.port, opts) do |http| + with_http_rescue do + http.request(req) + end + end + end + + # TODO: add to docs that all profiles used in Chef Compliance, need to + # be uploaded to Chef Compliance first + def enriched_report(report) + blob = node_info + + # extract profile names + profiles = report['controls'].collect { |control| control['profile_id'] }.uniq + + # build report for chef compliance, it includes node data + blob[:reports] = {} + blob[:profiles] = {} + profiles.each { |profile| + namespace = @compliance_profiles.select { |entry| entry[:profile_id] == 'ssh' } + unless namespace.nil? && namespace.empty? + Chef::Log.debug "Namespace for #{profile} is #{namespace[0][:owner]}" + blob[:profiles][profile] = namespace[0][:owner] + blob[:reports][profile] = report.dup + # filter controls by profile_id + blob[:reports][profile]['controls'] = blob[:reports][profile]['controls'].select { |control| control['profile_id'] == profile } + else + Chef::Log.warn "Could not determine compliance namespace for #{profile}" + end + } + + blob.to_json + end + + def node_info + n = @run_context.node + { + node: n.name, + os: { + # arch: n['arch'], + release: n['platform_version'], + family: n['platform'], + }, + environment: n.environment, + } + end + end + + # + # Used to send inspec reports to a Chef Compliance server via Chef Server + # + class ChefServer + include ReportHelpers + + @url = nil + + def initialize(url) + @url = url + end + + def send_report + content = results + Chef::Config[:verify_api_cert] = false + Chef::Config[:ssl_verify_mode] = :verify_none + Chef::Log.info "Report to Chef Server: #{@url}" + rest = Chef::ServerAPI.new(@url, Chef::Config) + with_http_rescue do + rest.post(@url, content) + end + end + end end diff --git a/libraries/helper.rb b/libraries/helper.rb index 6b8937e6..481c674f 100644 --- a/libraries/helper.rb +++ b/libraries/helper.rb @@ -30,4 +30,52 @@ def tests_for_runner tests_for_runner = tests.map { |test| Hash[test.map { |k, v| [k.to_sym, v] }] } tests_for_runner end + + def construct_url(server, path) + # sanitize inputs + server << '/' unless server =~ %r{/\z} + path.sub!(%r{^/}, '') + server = URI(server) + server.path = server.path + path if path + server + end + + def with_http_rescue(*) + response = yield + if response.respond_to?(:code) + # handle non 200 error codes, they are not raised as Net::HTTPServerException + handle_http_error_code(response.code) if response.code.to_i >= 300 + end + return response + rescue Net::HTTPServerException => e + Chef::Log.error e + handle_http_error_code(e.response.code) + end + + def handle_http_error_code(code) + case code + when /401|403/ + Chef::Log.error 'Auth issue: see audit cookbook TROUBLESHOOTING.md' + when /404/ + Chef::Log.error 'Object does not exist on remote server.' + end + msg = "Received HTTP error #{code}" + Chef::Log.error msg + raise msg if @raise_if_unreachable + end + + def base_chef_server_url + cs = URI(Chef::Config[:chef_server_url]) + cs.path = '' + cs.to_s + end + + # get file contents where inspec results were saved + def results + result_path = File.expand_path('../../inspec_results.json', __FILE__) + file = File.open(result_path, 'rb') + content = file.read + file.close + content + end end diff --git a/libraries/matchers.rb b/libraries/matchers.rb new file mode 100644 index 00000000..9b82a5d2 --- /dev/null +++ b/libraries/matchers.rb @@ -0,0 +1,27 @@ +# encoding: utf-8 + +# used by ChefSpec +if defined?(ChefSpec) + + ChefSpec.define_matcher :compliance_profile + + def create_compliance_token(resource_name) + ChefSpec::Matchers::ResourceMatcher.new(:compliance_token, :create, resource_name) + end + + def fetch_compliance_profile(resource_name) + ChefSpec::Matchers::ResourceMatcher.new(:compliance_profile, :fetch, resource_name) + end + + def upload_compliance_profile(resource_name) + ChefSpec::Matchers::ResourceMatcher.new(:compliance_profile, :upload, resource_name) + end + + def execute_compliance_profile(resource_name) + ChefSpec::Matchers::ResourceMatcher.new(:compliance_profile, :execute, resource_name) + end + + def execute_compliance_report(resource_name) + ChefSpec::Matchers::ResourceMatcher.new(:compliance_report, :execute, resource_name) + end +end