diff options
author | Tim Smith <tsmith@chef.io> | 2020-12-02 23:53:16 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-12-02 23:53:16 -0800 |
commit | e44bfc1507ae9a6126b9510b57318ffbfb684e40 (patch) | |
tree | f2ed5b1d6921d0514065f5ca2e3419ce6b14e6bd | |
parent | 0a417f1ce92c5905874506cae7e8b267bf096665 (diff) | |
parent | a2ba9037bc7e225801f29f85eac0c428aa908ac8 (diff) | |
download | chef-e44bfc1507ae9a6126b9510b57318ffbfb684e40.tar.gz |
Merge pull request #10547 from chef/audit-mode
Signed-off-by: Tim Smith <tsmith@chef.io>
22 files changed, 1958 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore index 0699f6f70f..fdf31a58e9 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,4 @@ mkmf.log .byebug_history # our custom dictionary pulled from https://github.com/chef/chef_dictionary/ -chef_dictionary.txt
\ No newline at end of file +chef_dictionary.txt @@ -27,7 +27,6 @@ gem "chef-telemetry", ">=1.0.8" # 1.0.8 removes the http dep group(:omnibus_package) do gem "appbundler" gem "rb-readline" - gem "inspec-core", "~> 4.23" gem "inspec-core-bin", "~> 4.23" # need to provide the binaries for inspec gem "chef-vault" end diff --git a/Gemfile.lock b/Gemfile.lock index 59894c58e7..b5c2a2991b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,6 +36,7 @@ PATH ffi-yajl (~> 2.2) highline (>= 1.6.9, < 3) iniparse (~> 1.4) + inspec-core (~> 4.23) license-acceptance (>= 1.0.5, < 3) mixlib-archive (>= 0.4, < 2.0) mixlib-authentication (>= 2.1, < 4) @@ -72,6 +73,7 @@ PATH ffi-yajl (~> 2.2) highline (>= 1.6.9, < 3) iniparse (~> 1.4) + inspec-core (~> 4.23) iso8601 (>= 0.12.1, < 0.14) license-acceptance (>= 1.0.5, < 3) mixlib-archive (>= 0.4, < 2.0) @@ -428,7 +430,6 @@ DEPENDENCIES cheffish (>= 14) chefstyle (= 1.5.2) fauxhai-ng - inspec-core (~> 4.23) inspec-core-bin (~> 4.23) ohai! pry diff --git a/chef.gemspec b/chef.gemspec index 965981fee7..9379e74fa7 100644 --- a/chef.gemspec +++ b/chef.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |s| s.add_dependency "mixlib-shellout", ">= 3.1.1", "< 4.0" s.add_dependency "mixlib-archive", ">= 0.4", "< 2.0" s.add_dependency "ohai", "~> 16.0" + s.add_dependency "inspec-core", "~> 4.23" s.add_dependency "ffi", ">= 1.9.25" s.add_dependency "ffi-yajl", "~> 2.2" diff --git a/cspell.json b/cspell.json index b63cc0f898..2c34043b30 100644 --- a/cspell.json +++ b/cspell.json @@ -317,6 +317,7 @@ "DBCS", "dbresson", "DBUS", + "dctoken", "DDTHH", "debconf", "debianrcd", @@ -943,6 +944,7 @@ "metafile", "METAFILE", "metalink", + "metasearch", "Miah", "michaellihs", "Microarchitecture", diff --git a/lib/chef/client.rb b/lib/chef/client.rb index 099c061a96..094b59fc35 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -57,6 +57,8 @@ require "ohai" unless defined?(Ohai::System) require "rbconfig" unless defined?(RbConfig) require "forwardable" unless defined?(Forwardable) +require_relative "compliance/runner" + class Chef # == Chef::Client # The main object in a Chef run. Preps a Chef::Node and Chef::RunContext, @@ -235,6 +237,7 @@ class Chef events.register(Chef::DataCollector::Reporter.new(events)) events.register(Chef::ActionCollection.new(events)) + events.register(Chef::Compliance::Runner.new) run_status.run_id = request_id = Chef::RequestID.instance.request_id diff --git a/lib/chef/compliance/default_attributes.rb b/lib/chef/compliance/default_attributes.rb new file mode 100644 index 0000000000..eb50c3a5e9 --- /dev/null +++ b/lib/chef/compliance/default_attributes.rb @@ -0,0 +1,89 @@ +# Author:: Stephan Renatus <srenatus@chef.io> +# Copyright:: (c) 2016-2019, Chef Software Inc. <legal@chef.io> +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "chef/node/attribute_collections" # for VividMash +require "chef/util/path_helper" + +class Chef + module Compliance + DEFAULT_ATTRIBUTES = Chef::Node::VividMash.new( + # If enabled, a cache is built for all backend calls. This should only be + # disabled if you are expecting unique results from the same backend call. + # Under the covers, this controls :command and :file caching on Chef InSpec's + # Train connection. + "inspec_backend_cache" => true, + + # Controls what is done with the resulting report after the Chef InSpec run. + # Accepts a single string value or an array of multiple values. + # Accepted values: 'chef-server-automate', 'chef-automate', 'json-file', 'audit-enforcer' + "reporter" => "json-file", + + # Controls if Chef InSpec profiles should be fetched from Chef Automate or Chef Infra Server + # in addition to the default fetch locations provided by Chef Inspec. + # Accepted values: nil, 'chef-server', 'chef-automate' + "fetcher" => nil, + + # Allow for connections to HTTPS endpoints using self-signed ssl certificates. + "insecure" => nil, + + # Controls verbosity of Chef InSpec runner. + "quiet" => true, + + # Chef Inspec Compliance profiles to be used for scan of node. + # See README.md for details + "profiles" => {}, + + # Extra inputs passed to Chef InSpec to allow finer-grained control over behavior. + # These are mapped to Chef InSpec's inputs, but are named attributes here for legacy reasons. + # See Chef Inspec's documentation for more information: https://docs.chef.io/inspec/inputs/ + "attributes" => {}, + + # A string path or an array of paths to Chef InSpec waiver files. + # See Chef Inspec's documentation for more information: https://docs.chef.io/inspec/waivers/ + "waiver_file" => nil, + + "json_file" => { + # The location on disk that Chef InSpec's json reports are saved to when using the + # 'json-file' reporter. Defaults to: + # <chef_cache_path>/compliance_reports/compliance-<timestamp>.json + "location" => Chef::Util::PathHelper.join( + Chef::Config[:cache_path], + "compliance_reports", + Time.now.utc.strftime("compliance-%Y%m%d%H%M%S.json") + ), + }, + + # Control results that have a `run_time` below this limit will + # be stripped of the `start_time` and `run_time` fields to + # reduce the size of the reports being sent to Chef Automate. + "run_time_limit" => 1.0, + + # A control result message that exceeds this character limit will be truncated. + # This helps keep reports to a reasonable size. On rare occasions, we've seen messages exceeding 9 MB in size, + # causing the report to not be ingested in the backend because of the 4 MB report size rpc limitation. + # Chef InSpec will append this text at the end of any truncated messages: `[Truncated to 10000 characters]` + "result_message_limit" => 10000, + + # When a Chef InSpec resource throws an exception, results will contain a short error message and a + # detailed ruby stacktrace of the error. This attribute instructs Chef InSpec not to include the detailed stacktrace in order + # to keep the overall report to a manageable size. + "result_include_backtrace" => false, + + # The array of results per control will be truncated at this limit to avoid large reports that cannot be + # processed by Chef Automate. A summary of removed results will be sent with each impacted control. + "control_results_limit" => 50 + ) + end +end diff --git a/lib/chef/compliance/fetcher/automate.rb b/lib/chef/compliance/fetcher/automate.rb new file mode 100644 index 0000000000..c86da331c2 --- /dev/null +++ b/lib/chef/compliance/fetcher/automate.rb @@ -0,0 +1,69 @@ +require "uri" unless defined?(URI) +require "plugins/inspec-compliance/lib/inspec-compliance" + +class Chef + module Compliance + module Fetcher + class Automate < ::InspecPlugins::Compliance::Fetcher + name "chef-automate" + + # it positions itself before `compliance` fetcher + # only load it, if you want to use audit cookbook in Chef Solo with Chef Automate + priority 502 + + CONFIG = { + "insecure" => true, + "token" => nil, + "server_type" => "automate", + "automate" => { + "ent" => "default", + "token_type" => "dctoken", + }, + }.freeze + + def self.resolve(target) + uri = get_target_uri(target) + return nil if uri.nil? + + config = CONFIG.dup + + # we have detailed information available in our lockfile, no need to ask the server + if target.respond_to?(:key?) && target.key?(:url) + profile_fetch_url = target[:url] + else + # verifies that the target e.g base/ssh exists + base_path = "/compliance/profiles/#{uri.host}#{uri.path}" + + profile_path = if target.respond_to?(:key?) && target.key?(:version) + "#{base_path}/version/#{target[:version]}/tar" + else + "#{base_path}/tar" + end + + url = URI(Chef::Config[:data_collector][:server_url]) + url.path = profile_path + profile_fetch_url = url.to_s + + config["token"] = Chef::Config[:data_collector][:token] + + if config["token"].nil? + raise Inspec::FetcherFailure, + "No data-collector token set, which is required by the chef-automate fetcher. " \ + "Set the `data_collector.token` configuration parameter in your client.rb " \ + 'or use the "chef-server-automate" reporter which does not require any ' \ + "data-collector settings and uses #{ChefUtils::Dist::Server::PRODUCT} to fetch profiles." + end + end + + new(profile_fetch_url, config) + rescue URI::Error => _e + nil + end + + def to_s + "#{ChefUtils::Dist::Automate::PRODUCT} for #{ChefUtils::Dist::Solo::PRODUCT} Fetcher" + end + end + end + end +end diff --git a/lib/chef/compliance/fetcher/chef_server.rb b/lib/chef/compliance/fetcher/chef_server.rb new file mode 100644 index 0000000000..db56aa82a7 --- /dev/null +++ b/lib/chef/compliance/fetcher/chef_server.rb @@ -0,0 +1,134 @@ +require "uri" unless defined?(URI) +require "plugins/inspec-compliance/lib/inspec-compliance" + +# This class implements an InSpec fetcher for Chef Server. The implementation +# is based on the Chef Compliance fetcher and only adapts the calls to redirect +# the requests via Chef Server. +# +# This implementation depends on chef-client runtime, therefore it is only executable +# inside of a chef-client run + +class Chef + module Compliance + module Fetcher + class ChefServer < ::InspecPlugins::Compliance::Fetcher + name "chef-server" + + # it positions itself before `compliance` fetcher + # only load it, if the Chef Server is integrated with Chef Compliance + priority 501 + + CONFIG = { "insecure" => true }.freeze + + # Accepts URLs to compliance profiles in one of two forms: + # * a String URL with a compliance scheme, like "compliance://namespace/profile_name" + # * a Hash with a key of `compliance` and a value like "compliance/profile_name" and optionally a `version` key with a String value + def self.resolve(target) + profile_uri = get_target_uri(target) + return nil if profile_uri.nil? + + organization = Chef::Config[:chef_server_url].split("/").last + owner = profile_uri.user ? "#{profile_uri.user}@#{profile_uri.host}" : profile_uri.host + version = target[:version] if target.respond_to?(:key?) + + path_parts = [""] + path_parts << "compliance" if chef_server_reporter? || chef_server_fetcher? + path_parts << "organizations" + path_parts << organization + path_parts << "owners" + path_parts << owner + path_parts << "compliance" + path_parts << profile_uri.path + path_parts << "version/#{version}" if version + path_parts << "tar" + + target_url = URI(Chef::Config[:chef_server_url]) + target_url.path = File.join(path_parts) + Chef::Log.info("Fetching profile from: #{target_url}") + + new(target_url, CONFIG) + rescue URI::Error => _e + nil + end + + # + # We want to save compliance: in the lockfile rather than url: to + # make sure we go back through the ComplianceAPI handling. + # + def resolved_source + { compliance: chef_server_url } + end + + # Downloads archive to temporary file using a Chef::ServerAPI + # client so that Chef Server's header-based authentication can be + # used. + def download_archive_to_temp + return @temp_archive_path unless @temp_archive_path.nil? + + rest = Chef::ServerAPI.new(@target, Chef::Config.merge(ssl_verify_mode: :verify_none)) + archive = with_http_rescue do + rest.streaming_request(@target) + end + @archive_type = ".tar.gz" + + if archive.nil? + path = @target.respond_to?(:path) ? @target.path : path + raise Inspec::FetcherFailure, "Unable to find requested profile on path: '#{path}' on the #{ChefUtils::Dist::Automate::PRODUCT} system." + end + + Inspec::Log.debug("Archive stored at temporary location: #{archive.path}") + @temp_archive_path = archive.path + end + + def with_http_rescue + response = yield + if response.respond_to?(:code) + # handle non 200 error codes, they are not raised as Net::HTTPClientException + handle_http_error_code(response.code) if response.code.to_i >= 300 + end + response + rescue Net::HTTPClientException => 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." + when /413/ + Chef::Log.error "You most likely hit the erchef request size in #{ChefUtils::Dist::Server::PRODUCT} that defaults to ~2MB. To increase this limit see audit cookbook TROUBLESHOOTING.md OR https://docs.chef.io/config_rb_server.html" + when /429/ + Chef::Log.error "This error typically means the data sent was larger than #{ChefUtils::Dist::Automate::PRODUCT}'s limit (4 MB). Run InSpec locally to identify any controls producing large diffs." + end + msg = "Received HTTP error #{code}" + Chef::Log.error msg + raise Inspec::FetcherFailure, msg + end + + def to_s + "#{ChefUtils::Dist::Server::PRODUCT}/Compliance Profile Loader" + end + + CHEF_SERVER_REPORTERS = %w{chef-server chef-server-compliance chef-server-visibility chef-server-automate}.freeze + def self.chef_server_reporter? + (Array(Chef.node.attributes["audit"]["reporter"]) & CHEF_SERVER_REPORTERS).any? + end + + CHEF_SERVER_FETCHERS = %w{chef-server chef-server-compliance chef-server-visibility chef-server-automate}.freeze + def self.chef_server_fetcher? + CHEF_SERVER_FETCHERS.include?(Chef.node.attributes["audit"]["fetcher"]) + end + + private + + def chef_server_url + m = %r{^#{@config['server']}/owners/(?<owner>[^/]+)/compliance/(?<id>[^/]+)/tar$}.match(@target) + "#{m[:owner]}/#{m[:id]}" + end + end + end + end +end diff --git a/lib/chef/compliance/reporter/automate.rb b/lib/chef/compliance/reporter/automate.rb new file mode 100644 index 0000000000..ed5c4837d0 --- /dev/null +++ b/lib/chef/compliance/reporter/automate.rb @@ -0,0 +1,202 @@ +class Chef + module Compliance + module Reporter + # + # Used to send inspec reports to Chef Automate via the data_collector service + # + class Automate + def initialize(opts) + @entity_uuid = opts[:entity_uuid] + @run_id = opts[:run_id] + @node_name = opts[:node_info][:node] + @environment = opts[:node_info][:environment] + @roles = opts[:node_info][:roles] + @recipes = opts[:node_info][:recipes] + @insecure = opts[:insecure] + @chef_tags = opts[:node_info][:chef_tags] + @policy_group = opts[:node_info][:policy_group] + @policy_name = opts[:node_info][:policy_name] + @source_fqdn = opts[:node_info][:source_fqdn] + @organization_name = opts[:node_info][:organization_name] + @ipaddress = opts[:node_info][:ipaddress] + @fqdn = opts[:node_info][:fqdn] + @run_time_limit = opts[:run_time_limit] + @control_results_limit = opts[:control_results_limit] + @timestamp = opts.fetch(:timestamp) { Time.now } + + @url = Chef::Config[:data_collector][:server_url] + @token = Chef::Config[:data_collector][:token] + end + + # Method used in order to send the inspec report to the data_collector server + def send_report(report) + unless @entity_uuid && @run_id + Chef::Log.error "entity_uuid(#{@entity_uuid}) or run_id(#{@run_id}) can't be nil, not sending report to #{ChefUtils::Dist::Automate::PRODUCT}" + return false + end + + unless @url && @token + Chef::Log.warn "data_collector.token and data_collector.server_url must be defined in client.rb!" + Chef::Log.warn "Further information: https://github.com/chef-cookbooks/audit#direct-reporting-to-chef-automate" + return false + end + + headers = { + "Content-Type" => "application/json", + "x-data-collector-auth" => "version=1.0", + "x-data-collector-token" => @token, + } + + all_report_shas = report[:profiles].map { |p| p[:sha256] } + missing_report_shas = missing_automate_profiles(headers, all_report_shas) + + full_report = truncate_controls_results(enriched_report(report), @control_results_limit) + full_report = strip_profiles_meta(full_report, missing_report_shas, @run_time_limit) + json_report = Chef::JSONCompat.to_json(full_report, validate_utf8: false) + + # Automate GRPC currently has a message limit of ~4MB + # https://github.com/chef/automate/issues/1417#issuecomment-541908157 + if json_report.bytesize > 4 * 1024 * 1024 + Chef::Log.warn "Generated report size is #{(json_report.bytesize / (1024 * 1024.0)).round(2)} MB. #{ChefUtils::Dist::Automate::PRODUCT} has an internal 4MB limit that is not currently configurable." + end + + unless json_report + Chef::Log.warn "Something went wrong, report can't be nil" + return false + end + + begin + Chef::Log.info "Report to #{ChefUtils::Dist::Automate::PRODUCT}: #{@url}" + Chef::Log.debug "Compliance Report: #{json_report}" + http_client.post(nil, json_report, headers) + true + rescue => e + Chef::Log.error "send_report: POST to #{@url} returned: #{e.message}" + false + end + end + + def http_client(url = @url) + if @insecure + Chef::HTTP.new(url, ssl_verify_mode: :verify_none) + else + Chef::HTTP.new(url) + end + end + + def enriched_report(final_report) + final_report[:profiles].compact! + + # Label this content as an inspec_report + final_report[:type] = "inspec_report" + + final_report[:node_name] = @node_name + final_report[:end_time] = @timestamp.utc.strftime("%FT%TZ") + final_report[:node_uuid] = @entity_uuid + final_report[:environment] = @environment + final_report[:roles] = @roles + final_report[:recipes] = @recipes + final_report[:report_uuid] = @run_id + final_report[:source_fqdn] = @source_fqdn + final_report[:organization_name] = @organization_name + final_report[:policy_group] = @policy_group + final_report[:policy_name] = @policy_name + final_report[:chef_tags] = @chef_tags + final_report[:ipaddress] = @ipaddress + final_report[:fqdn] = @fqdn + + final_report + end + + CONTROL_RESULT_SORT_ORDER = %w{ failed skipped passed }.freeze + + # Truncates the number of results per control in the report when they exceed max_results. + # The truncation prioritizes failed and skipped results over passed ones. + # Controls where results have been truncated will get a new object 'removed_results_counts' + # with the status counts of the truncated results + def truncate_controls_results(report, max_results) + return report unless max_results.is_a?(Integer) && max_results > 0 + + report.fetch(:profiles, []).each do |profile| + profile.fetch(:controls, []).each do |control| + # Only bother with truncation if the number of results exceed max_results + next unless control[:results].length > max_results + + res = control[:results] + res.sort_by! { |r| CONTROL_RESULT_SORT_ORDER.index(r[:status]) } + + # Count the results that will be truncated + truncated = { failed: 0, skipped: 0, passed: 0 } + (max_results..res.length - 1).each do |i| + case res[i][:status] + when "failed" + truncated[:failed] += 1 + when "skipped" + truncated[:skipped] += 1 + when "passed" + truncated[:passed] += 1 + end + end + # Truncate the results array now + control[:results] = res[0..max_results - 1] + control[:removed_results_counts] = truncated + end + end + report + end + + # Contacts the metasearch Automate API to check which of the inspec profile sha256 ids + # passed in via `report_shas` are missing from the Automate profiles metadata database. + def missing_automate_profiles(headers, report_shas) + Chef::Log.debug "Checking the #{ChefUtils::Dist::Automate::PRODUCT} profiles metadata for: #{report_shas}" + meta_url = URI(@url) + meta_url.path = "/compliance/profiles/metasearch" + response_str = http_client(meta_url.to_s).post(nil, "{\"sha256\": #{report_shas}}", headers) + missing_shas = Chef::JSONCompat.parse(response_str)["missing_sha256"] + unless missing_shas.empty? + Chef::Log.info "#{ChefUtils::Dist::Automate::PRODUCT} is missing metadata for the following profile ids: #{missing_shas}" + end + missing_shas + rescue => e + Chef::Log.error "missing_automate_profiles error: #{e.message}" + # If we get an error it's safer to assume none of the profile shas exist in Automate + report_shas + end + + # Profile 'name' is a required property. + # By not sending it in the report, we make it clear to the ingestion backend that the profile metadata has been stripped from this profile in the report. + # Profile 'title' and 'version' are still kept for troubleshooting purposes in the backend. + SEEN_PROFILE_UNNECESSARY_FIELDS = %i{ copyright copyright_email groups license maintainer name summary supports}.freeze + + SEEN_PROFILE_UNNECESSARY_CONTROL_FIELDS = %i{ code desc descriptions impact refs source_location tags title }.freeze + + # TODO: This mutates the report and probably doesn't need to. + def strip_profiles_meta(report, missing_report_shas, run_time_limit) + report[:profiles].each do |p| + next if missing_report_shas.include?(p[:sha256]) + + p.delete_if { |f| SEEN_PROFILE_UNNECESSARY_FIELDS.include?(f) } + + next unless p[:controls].is_a?(Array) + + p[:controls].each do |c| + c.delete_if { |f| SEEN_PROFILE_UNNECESSARY_CONTROL_FIELDS.include?(f) } + c.delete(:waiver_data) if c[:waiver_data] == {} + + next unless c[:results].is_a?(Array) + + c[:results].each do |r| + if r[:run_time].is_a?(Float) && r[:run_time] < run_time_limit + r.delete(:start_time) + r.delete(:run_time) + end + end + end + end + report[:run_time_limit] = run_time_limit + report + end + end + end + end +end diff --git a/lib/chef/compliance/reporter/chef_server_automate.rb b/lib/chef/compliance/reporter/chef_server_automate.rb new file mode 100644 index 0000000000..be59a4cf69 --- /dev/null +++ b/lib/chef/compliance/reporter/chef_server_automate.rb @@ -0,0 +1,92 @@ +require_relative "automate" + +class Chef + module Compliance + module Reporter + # + # Used to send inspec reports to Chef Automate server via Chef Server + # + class ChefServerAutomate < Chef::Compliance::Reporter::Automate + def initialize(opts) + @entity_uuid = opts[:entity_uuid] + @run_id = opts[:run_id] + @node_name = opts[:node_info][:node] + @insecure = opts[:insecure] + @environment = opts[:node_info][:environment] + @roles = opts[:node_info][:roles] + @recipes = opts[:node_info][:recipes] + @url = opts[:url] + @chef_tags = opts[:node_info][:chef_tags] + @policy_group = opts[:node_info][:policy_group] + @policy_name = opts[:node_info][:policy_name] + @source_fqdn = opts[:node_info][:source_fqdn] + @organization_name = opts[:node_info][:organization_name] + @ipaddress = opts[:node_info][:ipaddress] + @fqdn = opts[:node_info][:fqdn] + @control_results_limit = opts[:control_results_limit] + @timestamp = opts.fetch(:timestamp) { Time.now } + end + + def send_report(report) + unless @entity_uuid && @run_id + Chef::Log.error "entity_uuid(#{@entity_uuid}) or run_id(#{@run_id}) can't be nil, not sending report to #{ChefUtils::Dist::Automate::PRODUCT}" + return false + end + + automate_report = truncate_controls_results(enriched_report(report), @control_results_limit) + + report_size = Chef::JSONCompat.to_json(automate_report, validate_utf8: false).bytesize + # this is set to slightly less than the oc_erchef limit + if report_size > 900 * 1024 + Chef::Log.warn "Generated report size is #{(report_size / (1024 * 1024.0)).round(2)} MB. #{ChefUtils::Dist::Server::PRODUCT} < 13.0 defaults to a limit of ~1MB, 13.0+ defaults to a limit of ~2MB." + end + + Chef::Log.info "Report to #{ChefUtils::Dist::Automate::PRODUCT} via #{ChefUtils::Dist::Server::PRODUCT}: #{@url}" + with_http_rescue do + http_client.post(@url, automate_report) + return true + end + false + end + + def http_client + config = if @insecure + Chef::Config.merge(ssl_verify_mode: :verify_none) + else + Chef::Config + end + + Chef::ServerAPI.new(@url, config) + end + + def with_http_rescue + response = yield + if response.respond_to?(:code) + # handle non 200 error codes, they are not raised as Net::HTTPClientException + handle_http_error_code(response.code) if response.code.to_i >= 300 + end + response + rescue Net::HTTPClientException => 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." + when /413/ + Chef::Log.error "You most likely hit the erchef request size in #{ChefUtils::Dist::Server::PRODUCT} that defaults to ~2MB. To increase this limit see audit cookbook TROUBLESHOOTING.md OR https://docs.chef.io/config_rb_server.html" + when /429/ + Chef::Log.error "This error typically means the data sent was larger than #{ChefUtils::Dist::Automate::PRODUCT}'s limit (4 MB). Run InSpec locally to identify any controls producing large diffs." + end + msg = "Received HTTP error #{code}" + Chef::Log.error msg + raise msg + end + end + end + end +end diff --git a/lib/chef/compliance/reporter/compliance_enforcer.rb b/lib/chef/compliance/reporter/compliance_enforcer.rb new file mode 100644 index 0000000000..1c63e43b28 --- /dev/null +++ b/lib/chef/compliance/reporter/compliance_enforcer.rb @@ -0,0 +1,20 @@ +class Chef + module Compliance + module Reporter + class AuditEnforcer + class ControlFailure < StandardError; end + + def send_report(report) + report.fetch(:profiles, []).each do |profile| + profile.fetch(:controls, []).each do |control| + control.fetch(:results, []).each do |result| + raise ControlFailure, "Audit #{control[:id]} has failed. Aborting #{ChefUtils::Dist::Infra::CLIENT} run." if result[:status] == "failed" + end + end + end + true + end + end + end + end +end diff --git a/lib/chef/compliance/reporter/json_file.rb b/lib/chef/compliance/reporter/json_file.rb new file mode 100644 index 0000000000..471d9f64b1 --- /dev/null +++ b/lib/chef/compliance/reporter/json_file.rb @@ -0,0 +1,19 @@ +require_relative "../../json_compat" + +class Chef + module Compliance + module Reporter + class JsonFile + def initialize(opts) + @path = opts.fetch(:file) + end + + def send_report(report) + FileUtils.mkdir_p(File.dirname(@path), mode: 0700) + + File.write(@path, Chef::JSONCompat.to_json(report)) + end + end + end + end +end diff --git a/lib/chef/compliance/runner.rb b/lib/chef/compliance/runner.rb new file mode 100644 index 0000000000..677349df3e --- /dev/null +++ b/lib/chef/compliance/runner.rb @@ -0,0 +1,250 @@ +autoload :Inspec, "inspec" + +require_relative "default_attributes" +require_relative "reporter/automate" +require_relative "reporter/chef_server_automate" +require_relative "reporter/compliance_enforcer" +require_relative "reporter/json_file" + +class Chef + module Compliance + class Runner < EventDispatch::Base + extend Forwardable + + attr_accessor :run_id, :recipes + attr_reader :node + def_delegators :node, :logger + + def enabled? + audit_cookbook_present = recipes.include?("audit::default") + + logger.info("#{self.class}##{__method__}: #{Inspec::Dist::PRODUCT_NAME} profiles? #{inspec_profiles.any?}") + logger.info("#{self.class}##{__method__}: audit cookbook? #{audit_cookbook_present}") + + inspec_profiles.any? && !audit_cookbook_present + end + + def node=(node) + @node = node + node.default["audit"] = Chef::Compliance::DEFAULT_ATTRIBUTES.merge(node.default["audit"]) + end + + def node_load_completed(node, _expanded_run_list, _config) + self.node = node + end + + def run_started(run_status) + self.run_id = run_status.run_id + end + + def run_list_expanded(run_list_expansion) + self.recipes = run_list_expansion.recipes + end + + def run_completed(_node, _run_status) + return unless enabled? + + logger.info("#{self.class}##{__method__}: enabling Compliance Phase") + + report + end + + def run_failed(_exception, _run_status) + return unless enabled? + + logger.info("#{self.class}##{__method__}: enabling Compliance Phase") + + report + end + + ### Below code adapted from audit cookbook's files/default/handler/audit_report.rb + + DEPRECATED_CONFIG_VALUES = %w{ + attributes_save + chef_node_attribute_enabled + fail_if_not_present + inspec_gem_source + inspec_version + interval + owner + raise_if_unreachable + }.freeze + + def warn_for_deprecated_config_values! + deprecated_config_values = (node["audit"].keys & DEPRECATED_CONFIG_VALUES) + + if deprecated_config_values.any? + values = deprecated_config_values.sort.map { |v| "'#{v}'" }.join(", ") + logger.warn "audit cookbook config values #{values} are not supported in #{ChefUtils::Dist::Infra::PRODUCT}'s Compliance Phase." + end + end + + def report(report = generate_report) + warn_for_deprecated_config_values! + + if report.empty? + logger.error "Compliance report was not generated properly, skipped reporting" + return + end + + Array(node["audit"]["reporter"]).each do |reporter| + send_report(reporter, report) + end + end + + def inspec_opts + { + backend_cache: node["audit"]["inspec_backend_cache"], + inputs: node["audit"]["attributes"], + logger: logger, + output: node["audit"]["quiet"] ? ::File::NULL : STDOUT, + report: true, + reporter: ["json-automate"], + reporter_backtrace_inclusion: node["audit"]["result_include_backtrace"], + reporter_message_truncation: node["audit"]["result_message_limit"], + waiver_file: Array(node["audit"]["waiver_file"]), + } + end + + def inspec_profiles + profiles = node["audit"]["profiles"] + + # TODO: Custom exception class here? + unless profiles.respond_to?(:map) && profiles.all? { |_, p| p.respond_to?(:transform_keys) && p.respond_to?(:update) } + raise "#{Inspec::Dist::PRODUCT_NAME} profiles specified in an unrecognized format, expected a hash of hashes." + end + + profiles.map do |name, profile| + profile.transform_keys(&:to_sym).update(name: name) + end + end + + def load_fetchers! + case node["audit"]["fetcher"] + when "chef-automate" + require_relative "fetcher/automate" + when "chef-server" + require_relative "fetcher/chef_server" + when nil + # intentionally blank + else + raise "Invalid value specified for Compliance Phase's fetcher: '#{node["audit"]["fetcher"]}'. Valid values are 'chef-automate', 'chef-server', or nil." + end + end + + def generate_report(opts: inspec_opts, profiles: inspec_profiles) + load_fetchers! + + logger.debug "Options are set to: #{opts}" + runner = ::Inspec::Runner.new(opts) + + if profiles.empty? + failed_report("No #{Inspec::Dist::PRODUCT_NAME} profiles are defined.") + return + end + + profiles.each { |target| runner.add_target(target) } + + logger.info "Running profiles from: #{profiles.inspect}" + runner.run + runner.report.tap do |r| + logger.debug "Compliance Report #{r}" + end + rescue Inspec::FetcherFailure => e + failed_report("Cannot fetch all profiles: #{profiles}. Please make sure you're authenticated and the server is reachable. #{e.message}") + rescue => e + failed_report(e.message) + end + + # In case InSpec raises a runtime exception without providing a valid report, + # we make one up and add two new fields to it: `status` and `status_message` + def failed_report(err) + logger.error "#{Inspec::Dist::PRODUCT_NAME} has raised a runtime exception. Generating a minimal failed report." + logger.error err + { + "platform": { + "name": "unknown", + "release": "unknown", + }, + "profiles": [], + "statistics": { + "duration": 0.0000001, + }, + "version": Inspec::VERSION, + "status": "failed", + "status_message": err, + } + end + + # extracts relevant node data + def node_info + runlist_roles = node.run_list.select { |item| item.type == :role }.map(&:name) + runlist_recipes = node.run_list.select { |item| item.type == :recipe }.map(&:name) + { + node: node.name, + os: { + release: node["platform_version"], + family: node["platform"], + }, + environment: node.environment, + roles: runlist_roles, + recipes: runlist_recipes, + policy_name: node.policy_name || "", + policy_group: node.policy_group || "", + chef_tags: node.tags, + organization_name: chef_server_uri.path.split("/").last || "", + source_fqdn: chef_server_uri.host || "", + ipaddress: node["ipaddress"], + fqdn: node["fqdn"], + } + end + + def send_report(reporter, report) + logger.info "Reporting to #{reporter}" + + insecure = node["audit"]["insecure"] + run_time_limit = node["audit"]["run_time_limit"] + control_results_limit = node["audit"]["control_results_limit"] + + case reporter + when "chef-automate" + opts = { + entity_uuid: node["chef_guid"], + run_id: run_id, + node_info: node_info, + insecure: insecure, + run_time_limit: run_time_limit, + control_results_limit: control_results_limit, + } + Chef::Compliance::Reporter::Automate.new(opts).send_report(report) + when "chef-server-automate" + chef_url = node["audit"]["server"] || base_chef_server_url + chef_org = Chef::Config[:chef_server_url].split("/").last + if chef_url + url = construct_url(chef_url, File.join("organizations", chef_org, "data-collector")) + opts = { + entity_uuid: node["chef_guid"], + run_id: run_id, + node_info: node_info, + insecure: insecure, + url: url, + run_time_limit: run_time_limit, + control_results_limit: control_results_limit, + } + Chef::Compliance::Reporter::ChefServer.new(opts).send_report(report) + else + logger.warn "Unable to determine #{ChefUtils::Dist::Server::PRODUCT} url required by #{Inspec::Dist::PRODUCT_NAME} report collector '#{reporter}'. Skipping..." + end + when "json-file" + path = node["audit"]["json_file"]["location"] + logger.info "Writing compliance report to #{path}" + Chef::Compliance::Reporter::JsonFile.new(file: path).send_report(report) + when "audit-enforcer" + Chef::Compliance::Reporter::ComplianceEnforcer.new.send_report(report) + else + logger.warn "#{reporter} is not a supported #{Inspec::Dist::PRODUCT_NAME} report collector" + end + end + end + end +end diff --git a/spec/integration/compliance/compliance_spec.rb b/spec/integration/compliance/compliance_spec.rb new file mode 100644 index 0000000000..98776308de --- /dev/null +++ b/spec/integration/compliance/compliance_spec.rb @@ -0,0 +1,81 @@ +require "spec_helper" + +require "support/shared/integration/integration_helper" +require "chef/mixin/shell_out" +require "chef-utils/dist" + +describe "chef-client with audit mode" do + + include IntegrationSupport + include Chef::Mixin::ShellOut + + let(:chef_dir) { File.join(__dir__, "..", "..", "..", "bin") } + + # Invoke `chef-client` as `ruby PATH/TO/chef-client`. This ensures the + # following constraints are satisfied: + # * Windows: windows can only run batch scripts as bare executables. Rubygems + # creates batch wrappers for installed gems, but we don't have batch wrappers + # in the source tree. + # * Other `chef-client` in PATH: A common case is running the tests on a + # machine that has omnibus chef installed. In that case we need to ensure + # we're running `chef-client` from the source tree and not the external one. + # cf. CHEF-4914 + let(:chef_client) { "bundle exec #{ChefUtils::Dist::Infra::CLIENT} --minimal-ohai" } + + when_the_repository "has a custom profile" do + let(:report_file) { path_to("report_file.json") } + + before do + directory "profiles/my-profile" do + file "inspec.yml", <<~FILE + --- + name: my-profile + FILE + + directory "controls" do + file "my_control.rb", <<~FILE + control "my control" do + describe Dir.home do + it { should be_kind_of String } + end + end + FILE + end + end + + file "attributes.json", <<~FILE + { + "audit": { + "json_file": { + "location": "#{report_file}" + }, + "profiles": { + "my-profile": { + "path": "#{path_to("profiles/my-profile")}" + } + } + } + } + FILE + end + + it "should complete with success" do + result = shell_out!("#{chef_client} --local-mode --json-attributes #{path_to("attributes.json")}", cwd: chef_dir) + result.error! + + inspec_report = JSON.parse(File.read(report_file)) + expect(inspec_report["profiles"].length).to eq(1) + + profile = inspec_report["profiles"].first + expect(profile["name"]).to eq("my-profile") + expect(profile["controls"].length).to eq(1) + + control = profile["controls"].first + expect(control["id"]).to eq("my control") + expect(control["results"].length).to eq(1) + + result = control["results"].first + expect(result["status"]).to eq("passed") + end + end +end diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index 377a0b8f05..b6a321c8e8 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -129,6 +129,7 @@ shared_context "a client run" do expect(client.events).to receive(:register).with(instance_of(Chef::DataCollector::Reporter)) expect(client.events).to receive(:register).with(instance_of(Chef::ResourceReporter)) expect(client.events).to receive(:register).with(instance_of(Chef::ActionCollection)) + expect(client.events).to receive(:register).with(instance_of(Chef::Compliance::Runner)) end def stub_for_node_load diff --git a/spec/unit/compliance/fetcher/automate_spec.rb b/spec/unit/compliance/fetcher/automate_spec.rb new file mode 100644 index 0000000000..bc2125aaa7 --- /dev/null +++ b/spec/unit/compliance/fetcher/automate_spec.rb @@ -0,0 +1,134 @@ +require "spec_helper" +require "chef/compliance/fetcher/automate" + +describe Chef::Compliance::Fetcher::Automate do + describe ".resolve" do + before do + Chef::Config[:data_collector] = { + server_url: "https://automate.test/data_collector", + token: token, + } + end + + let(:token) { "fake_token" } + + context "when target is a string" do + it "should resolve a compliance URL" do + res = Chef::Compliance::Fetcher::Automate.resolve("compliance://namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://automate.test/compliance/profiles/namespace/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "raises an exception with no data collector token" do + Chef::Config[:data_collector].delete(:token) + + expect { + Chef::Compliance::Fetcher::Automate.resolve("compliance://namespace/profile_name") + }.to raise_error(/No data-collector token set/) + end + + it "includes the data collector token" do + expect(Chef::Compliance::Fetcher::Automate).to receive(:new).with( + "https://automate.test/compliance/profiles/namespace/profile_name/tar", + hash_including("token" => token) + ).and_call_original + + res = Chef::Compliance::Fetcher::Automate.resolve("compliance://namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://automate.test/compliance/profiles/namespace/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "returns nil with a non-compliance URL" do + res = Chef::Compliance::Fetcher::Automate.resolve("http://github.com/chef-cookbooks/audit") + + expect(res).to eq(nil) + end + end + + context "when target is a hash" do + it "should resolve a target with a version" do + res = Chef::Compliance::Fetcher::Automate.resolve( + compliance: "namespace/profile_name", + version: "1.2.3" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://automate.test/compliance/profiles/namespace/profile_name/version/1.2.3/tar" + expect(res.target).to eq(expected) + end + + it "should resolve a target without a version" do + res = Chef::Compliance::Fetcher::Automate.resolve( + compliance: "namespace/profile_name" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://automate.test/compliance/profiles/namespace/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "uses url key when present" do + res = Chef::Compliance::Fetcher::Automate.resolve( + compliance: "namespace/profile_name", + version: "1.2.3", + url: "https://profile.server.test/profiles/profile_name/1.2.3" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://profile.server.test/profiles/profile_name/1.2.3" + expect(res.target).to eq(expected) + end + + it "does not include token in the config when url key is present" do + expect(Chef::Compliance::Fetcher::Automate).to receive(:new).with( + "https://profile.server.test/profiles/profile_name/1.2.3", + hash_including("token" => nil) + ).and_call_original + + res = Chef::Compliance::Fetcher::Automate.resolve( + compliance: "namespace/profile_name", + version: "1.2.3", + url: "https://profile.server.test/profiles/profile_name/1.2.3" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://profile.server.test/profiles/profile_name/1.2.3" + expect(res.target).to eq(expected) + end + + it "raises an exception with no data collector token" do + Chef::Config[:data_collector].delete(:token) + + expect { + Chef::Compliance::Fetcher::Automate.resolve(compliance: "namespace/profile_name") + }.to raise_error(Inspec::FetcherFailure, /No data-collector token set/) + end + + it "includes the data collector token" do + expect(Chef::Compliance::Fetcher::Automate).to receive(:new).with( + "https://automate.test/compliance/profiles/namespace/profile_name/tar", + hash_including("token" => token) + ).and_call_original + + res = Chef::Compliance::Fetcher::Automate.resolve(compliance: "namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::Automate) + expected = "https://automate.test/compliance/profiles/namespace/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "returns nil with a non-profile Hash" do + res = Chef::Compliance::Fetcher::Automate.resolve( + profile: "namespace/profile_name", + version: "1.2.3" + ) + + expect(res).to eq(nil) + end + end + end +end diff --git a/spec/unit/compliance/fetcher/chef_server_spec.rb b/spec/unit/compliance/fetcher/chef_server_spec.rb new file mode 100644 index 0000000000..fc1c229989 --- /dev/null +++ b/spec/unit/compliance/fetcher/chef_server_spec.rb @@ -0,0 +1,93 @@ +require "spec_helper" +require "chef/compliance/fetcher/chef_server" + +describe Chef::Compliance::Fetcher::ChefServer do + let(:node) do + Chef::Node.new.tap do |n| + n.default["audit"] = {} + end + end + + before :each do + allow(Chef).to receive(:node).and_return(node) + + Chef::Config[:chef_server_url] = "http://127.0.0.1:8889/organizations/my_org" + end + + describe ".resolve" do + context "when target is a string" do + it "should resolve a compliance URL" do + res = Chef::Compliance::Fetcher::ChefServer.resolve("compliance://namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/organizations/my_org/owners/namespace/compliance/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "should add /compliance URL prefix if needed" do + node.default["audit"]["fetcher"] = "chef-server" + res = Chef::Compliance::Fetcher::ChefServer.resolve("compliance://namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/compliance/organizations/my_org/owners/namespace/compliance/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "includes user in the URL if present" do + res = Chef::Compliance::Fetcher::ChefServer.resolve("compliance://username@namespace/profile_name") + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/organizations/my_org/owners/username@namespace/compliance/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "returns nil with a non-compliance URL" do + res = Chef::Compliance::Fetcher::ChefServer.resolve("http://github.com/chef-cookbooks/audit") + + expect(res).to eq(nil) + end + end + + context "when target is a hash" do + it "should resolve a target with a version" do + res = Chef::Compliance::Fetcher::ChefServer.resolve( + compliance: "namespace/profile_name", + version: "1.2.3" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/organizations/my_org/owners/namespace/compliance/profile_name/version/1.2.3/tar" + expect(res.target).to eq(expected) + end + + it "should resolve a target without a version" do + res = Chef::Compliance::Fetcher::ChefServer.resolve( + compliance: "namespace/profile_name" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/organizations/my_org/owners/namespace/compliance/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "includes user in the URL if present" do + res = Chef::Compliance::Fetcher::ChefServer.resolve( + compliance: "username@namespace/profile_name" + ) + + expect(res).to be_kind_of(Chef::Compliance::Fetcher::ChefServer) + expected = "http://127.0.0.1:8889/organizations/my_org/owners/username@namespace/compliance/profile_name/tar" + expect(res.target).to eq(expected) + end + + it "returns nil with a non-profile Hash" do + res = Chef::Compliance::Fetcher::ChefServer.resolve( + profile: "namespace/profile_name", + version: "1.2.3" + ) + + expect(res).to eq(nil) + end + end + end +end diff --git a/spec/unit/compliance/reporter/automate_spec.rb b/spec/unit/compliance/reporter/automate_spec.rb new file mode 100644 index 0000000000..e0a33892b0 --- /dev/null +++ b/spec/unit/compliance/reporter/automate_spec.rb @@ -0,0 +1,427 @@ +require "spec_helper" +require "json" # For .to_json + +describe Chef::Compliance::Reporter::Automate do + let(:reporter) { Chef::Compliance::Reporter::Automate.new(opts) } + + let(:opts) do + { + entity_uuid: "aaaaaaaa-709a-475d-bef5-zzzzzzzzzzzz", + run_id: "3f0536f7-3361-4bca-ae53-b45118dceb5d", + node_info: { + node: "chef-client.solo", + environment: "My Prod Env", + roles: %w{base_linux apache_linux}, + recipes: ["some_cookbook::some_recipe", "some_cookbook"], + policy_name: "test_policy_name", + policy_group: "test_policy_group", + chef_tags: ["mylinux", "my.tag", "some=tag"], + organization_name: "test_org", + source_fqdn: "api.chef.io", + ipaddress: "192.168.56.33", + fqdn: "lb1.prod.example.com", + }, + run_time_limit: 1.1, + control_results_limit: 2, + timestamp: Time.parse("2016-07-19T19:19:19+01:00"), + } + end + + let(:inspec_report) do + { + "version": "1.2.1", + "profiles": + [{ "name": "tmp_compliance_profile", + "title": "/tmp Compliance Profile", + "summary": "An Example Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + "version": "0.1.1", + "maintainer": "Nathen Harvey <nharvey@chef.io>", + "license": "Apache 2.0 License", + "copyright": "Nathen Harvey <nharvey@chef.io>", + "supports": [], + "controls": + [{ "title": "A /tmp directory must exist", + "desc": "A /tmp directory must exist", + "impact": 0.3, + "refs": [], + "tags": {}, + "code": "control 'tmp-1.0' do\n impact 0.3\n title 'A /tmp directory must exist'\n desc 'A /tmp directory must exist'\n describe file '/tmp' do\n it { should be_directory }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 3 }, + "id": "tmp-1.0", + "results": [ + { "status": "passed", "code_desc": "File /tmp should be directory", "run_time": 0.002312, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + }, + { "title": "/tmp directory is owned by the root user", + "desc": "The /tmp directory must be owned by the root user", + "impact": 0.3, + "refs": [{ "url": "https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf", "ref": "Compliance Whitepaper" }], + "tags": { "production": nil, "development": nil, "identifier": "value", "remediation": "https://github.com/chef-cookbooks/audit" }, + "code": "control 'tmp-1.1' do\n impact 0.3\n title '/tmp directory is owned by the root user'\n desc 'The /tmp directory must be owned by the root user'\n tag 'production','development'\n tag identifier: 'value'\n tag remediation: 'https://github.com/chef-cookbooks/audit'\n ref 'Compliance Whitepaper', url: 'https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf'\n describe file '/tmp' do\n it { should be_owned_by 'root' }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 12 }, + "id": "tmp-1.1", + "results": [ + { "status": "passed", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "skipped", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "failed", "code_desc": "File /etc/hosts is expected to be directory", "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400", "message": "expected `File /etc/hosts.directory?` to return true, got false" }, + ], + }, + ], + "groups": [{ "title": "/tmp Compliance Profile", "controls": ["tmp-1.0", "tmp-1.1"], "id": "controls/tmp.rb" }], + "attributes": [{ "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }] }], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + } + end + + describe "#send_report" do + before :each do + WebMock.disable_net_connect! + + Chef::Config[:data_collector] = { token: token, server_url: "https://automate.test/data_collector" } + end + + let(:token) { "fake_token" } + + it "sends report successfully to ChefAutomate with missing profiles" do + metasearch_stub = stub_request(:post, "https://automate.test/compliance/profiles/metasearch") + .with( + body: '{"sha256": ["7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd"]}', + headers: { + "Accept-Encoding" => "identity", + "X-Chef-Version" => Chef::VERSION, + "X-Data-Collector-Auth" => "version=1.0", + "X-Data-Collector-Token" => token, + } + ).to_return( + status: 200, + body: '{"missing_sha256": ["7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd"]}' + ) + + report_stub = stub_request(:post, "https://automate.test/data_collector") + .with( + body: { + "version": "1.2.1", + "profiles": [ + { + "name": "tmp_compliance_profile", + "title": "/tmp Compliance Profile", + "summary": "An Example Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + "version": "0.1.1", + "maintainer": "Nathen Harvey <nharvey@chef.io>", + "license": "Apache 2.0 License", + "copyright": "Nathen Harvey <nharvey@chef.io>", + "supports": [], + "controls": [ + { + "title": "A /tmp directory must exist", + "desc": "A /tmp directory must exist", + "impact": 0.3, + "refs": [], + "tags": {}, + "code": "control 'tmp-1.0' do\n impact 0.3\n title 'A /tmp directory must exist'\n desc 'A /tmp directory must exist'\n describe file '/tmp' do\n it { should be_directory }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 3 }, + "id": "tmp-1.0", + "results": [ + { "status": "passed", "code_desc": "File /tmp should be directory", "run_time": 0.002312, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + }, + { + "title": "/tmp directory is owned by the root user", + "desc": "The /tmp directory must be owned by the root user", + "impact": 0.3, + "refs": [ + { "url": "https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf", "ref": "Compliance Whitepaper" }, + ], + "tags": { "production": nil, "development": nil, "identifier": "value", "remediation": "https://github.com/chef-cookbooks/audit" }, + "code": "control 'tmp-1.1' do\n impact 0.3\n title '/tmp directory is owned by the root user'\n desc 'The /tmp directory must be owned by the root user'\n tag 'production','development'\n tag identifier: 'value'\n tag remediation: 'https://github.com/chef-cookbooks/audit'\n ref 'Compliance Whitepaper', url: 'https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf'\n describe file '/tmp' do\n it { should be_owned_by 'root' }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 12 }, + "id": "tmp-1.1", + "results": [ + { "status": "failed", "code_desc": "File /etc/hosts is expected to be directory", "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400", "message": "expected `File /etc/hosts.directory?` to return true, got false" }, + { "status": "skipped", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + "removed_results_counts": { "failed": 0, "skipped": 0, "passed": 1 }, + }, + ], + "groups": [ + { "title": "/tmp Compliance Profile", "controls": ["tmp-1.0", "tmp-1.1"], "id": "controls/tmp.rb" }, + ], + "attributes": [ + { "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }, + ], + }, + ], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + "type": "inspec_report", + "node_name": "chef-client.solo", + "end_time": "2016-07-19T18:19:19Z", + "node_uuid": "aaaaaaaa-709a-475d-bef5-zzzzzzzzzzzz", + "environment": "My Prod Env", + "roles": %w{base_linux apache_linux}, + "recipes": ["some_cookbook::some_recipe", "some_cookbook"], + "report_uuid": "3f0536f7-3361-4bca-ae53-b45118dceb5d", + "source_fqdn": "api.chef.io", + "organization_name": "test_org", + "policy_group": "test_policy_group", + "policy_name": "test_policy_name", + "chef_tags": ["mylinux", "my.tag", "some=tag"], + "ipaddress": "192.168.56.33", + "fqdn": "lb1.prod.example.com", + "run_time_limit": 1.1, + }, + headers: { + "Accept-Encoding" => "identity", + "X-Chef-Version" => Chef::VERSION, + "X-Data-Collector-Auth" => "version=1.0", + "X-Data-Collector-Token" => token, + } + ).to_return(status: 200) + + expect(reporter.send_report(inspec_report)).to eq(true) + + expect(metasearch_stub).to have_been_requested + expect(report_stub).to have_been_requested + end + + it "sends report successfully to ChefAutomate with seen profiles" do + metasearch_stub = stub_request(:post, "https://automate.test/compliance/profiles/metasearch") + .with( + body: '{"sha256": ["7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd"]}', + headers: { + "Accept-Encoding" => "identity", + "X-Chef-Version" => Chef::VERSION, + "X-Data-Collector-Auth" => "version=1.0", + "X-Data-Collector-Token" => token, + } + ).to_return( + status: 200, + body: '{"missing_sha256": []}' + ) + + report_stub = stub_request(:post, "https://automate.test/data_collector") + .with( + body: { + "version": "1.2.1", + "profiles": [ + { + "title": "/tmp Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + "version": "0.1.1", + "controls": [ + { + "id": "tmp-1.0", + "results": [ + { "status": "passed", "code_desc": "File /tmp should be directory" }, + ], + }, + { + "id": "tmp-1.1", + "results": [ + { "status": "failed", "code_desc": "File /etc/hosts is expected to be directory", "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400", "message": "expected `File /etc/hosts.directory?` to return true, got false" }, + { "status": "skipped", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + "removed_results_counts": { "failed": 0, "skipped": 0, "passed": 1 }, + }, + ], + "attributes": [ + { "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }, + ], + }, + ], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + "type": "inspec_report", + "node_name": "chef-client.solo", + "end_time": "2016-07-19T18:19:19Z", + "node_uuid": "aaaaaaaa-709a-475d-bef5-zzzzzzzzzzzz", + "environment": "My Prod Env", + "roles": %w{base_linux apache_linux}, + "recipes": ["some_cookbook::some_recipe", "some_cookbook"], + "report_uuid": "3f0536f7-3361-4bca-ae53-b45118dceb5d", + "source_fqdn": "api.chef.io", + "organization_name": "test_org", + "policy_group": "test_policy_group", + "policy_name": "test_policy_name", + "chef_tags": ["mylinux", "my.tag", "some=tag"], + "ipaddress": "192.168.56.33", + "fqdn": "lb1.prod.example.com", + "run_time_limit": 1.1, + }, + headers: { + "Accept-Encoding" => "identity", + "X-Chef-Version" => Chef::VERSION, + "X-Data-Collector-Auth" => "version=1.0", + "X-Data-Collector-Token" => token, + } + ).to_return(status: 200) + + expect(reporter.send_report(inspec_report)).to eq(true) + + expect(metasearch_stub).to have_been_requested + expect(report_stub).to have_been_requested + end + + it "does not send report when entity_uuid is missing" do + opts.delete(:entity_uuid) + reporter = Chef::Compliance::Reporter::Automate.new(opts) + expect(reporter.send_report(inspec_report)).to eq(false) + end + end + + describe "#truncate_controls_results" do + let(:report) do + { + "version": "1.2.1", + "profiles": + [{ "name": "tmp_compliance_profile", + "title": "/tmp Compliance Profile", + "summary": "An Example Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215ff", + "version": "0.1.1", + "maintainer": "Nathen Harvey <nharvey@chef.io>", + "license": "Apache 2.0 License", + "copyright": "Nathen Harvey <nharvey@chef.io>", + "supports": [], + "controls": + [{ "id": "tmp-2.0", + "title": "A bunch of directories must exist", + "desc": "A bunch of directories must exist for testing", + "impact": 0.3, + "refs": [], + "tags": {}, + "code": "control 'tmp-2.0' do\n impact 0.3\n title 'A bunch of directories must exist'\n desc 'A bunch of directories must exist for testing'\n describe file '/tmp' do\n it { should be_directory }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 3 }, + "results": [ + { "status": "passed", "code_desc": "File /tmp should be directory", "run_time": 0.002312, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "passed", "code_desc": "File /etc should be directory", "run_time": 0.002314, "start_time": "2016-10-19 11:09:45 -0400" }, + { "status": "passed", "code_desc": "File /opt should be directory", "run_time": 0.002315, "start_time": "2016-10-19 11:09:46 -0400" }, + { "status": "skipped", "code_desc": "No-op", "run_time": 0.002316, "start_time": "2016-10-19 11:09:44 -0400", "skip_message": "4 testing" }, + { "status": "skipped", "code_desc": "No-op", "run_time": 0.002317, "start_time": "2016-10-19 11:09:44 -0400", "skip_message": "4 testing" }, + { "status": "skipped", "code_desc": "No-op", "run_time": 0.002318, "start_time": "2016-10-19 11:09:44 -0400", "skip_message": "4 testing" }, + { "status": "failed", "code_desc": "File /etc/passwd should be directory", "run_time": 0.002313, "start_time": "2016-10-19 11:09:44 -0400" }, + { "status": "failed", "code_desc": "File /etc/passwd should be directory", "run_time": 0.002313, "start_time": "2016-10-19 11:09:44 -0400" }, + { "status": "failed", "code_desc": "File /etc/passwd should be directory", "run_time": 0.002313, "start_time": "2016-10-19 11:09:44 -0400" }, + ], + }, + { "id": "tmp-2.1", + "title": "/tmp directory is owned by the root user", + "desc": "The /tmp directory must be owned by the root user", + "impact": 0.3, + "refs": [{ "url": "https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf", "ref": "Compliance Whitepaper" }], + "tags": { "production": nil, "development": nil, "identifier": "value", "remediation": "https://github.com/chef-cookbooks/audit" }, + "code": "control 'tmp-2.1' do\n impact 0.3\n title '/tmp directory is owned by the root user'\n desc 'The /tmp directory must be owned by the root user'\n tag 'production','development'\n tag identifier: 'value'\n tag remediation: 'https://github.com/chef-cookbooks/audit'\n ref 'Compliance Whitepaper', url: 'https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf'\n describe file '/tmp' do\n it { should be_owned_by 'root' }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 12 }, + "results": [ + { "status": "passed", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "passed", "code_desc": 'File /etc should be owned by "root"', "run_time": 1.238845, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + }, + ], + "groups": [{ "title": "/tmp Compliance Profile", "controls": ["tmp-1.0", "tmp-1.1"], "id": "controls/tmp.rb" }], + "attributes": [{ "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }] }], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + } + end + + it "truncates controls results 1" do + truncated_report = reporter.truncate_controls_results(report, 5) + expect(truncated_report[:profiles][0][:controls][0][:results].length).to eq(5) + statuses = truncated_report[:profiles][0][:controls][0][:results].map { |r| r[:status] } + expect(statuses).to eq(%w{failed failed failed skipped skipped}) + expect(truncated_report[:profiles][0][:controls][0][:removed_results_counts]).to eq(failed: 0, skipped: 1, passed: 3) + end + + it "truncates controls results 2" do + truncated_report = reporter.truncate_controls_results(report, 5) + expect(truncated_report[:profiles][0][:controls][1][:results].length).to eq(2) + statuses = truncated_report[:profiles][0][:controls][1][:results].map { |r| r[:status] } + expect(statuses).to eq(%w{passed passed}) + expect(truncated_report[:profiles][0][:controls][1][:removed_results_counts]).to eq(nil) + end + + it "truncates controls results 3" do + truncated_report = reporter.truncate_controls_results(report, 0) + expect(truncated_report[:profiles][0][:controls][0][:results].length).to eq(9) + end + + it "truncates controls results 4" do + truncated_report = reporter.truncate_controls_results(report, 1) + expect(truncated_report[:profiles][0][:controls][0][:results].length).to eq(1) + end + end + + describe "#strip_profiles_meta" do + it "removes the metadata from seen profiles" do + expected = { + other_checks: [], + profiles: [ + { + attributes: [ + { + name: "syslog_pkg", + options: { + default: "rsyslog", + description: "syslog package...", + }, + }, + ], + controls: [ + { + id: "tmp-1.0", + results: [ + { + code_desc: "File /tmp should be directory", + status: "passed", + }, + ], + }, + { + id: "tmp-1.1", + results: [ + { + code_desc: 'File /tmp should be owned by "root"', + run_time: 1.228845, + start_time: "2016-10-19 11:09:43 -0400", + status: "passed", + }, + { + code_desc: 'File /tmp should be owned by "root"', + run_time: 1.228845, + start_time: "2016-10-19 11:09:43 -0400", + status: "skipped", + }, + { + code_desc: "File /etc/hosts is expected to be directory", + message: "expected `File /etc/hosts.directory?` to return true, got false", + run_time: 1.228845, + start_time: "2016-10-19 11:09:43 -0400", + status: "failed", + }, + ], + }, + ], + sha256: "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + title: "/tmp Compliance Profile", + version: "0.1.1", + }, + ], + run_time_limit: 1.1, + statistics: { + duration: 0.032332, + }, + version: "1.2.1", + } + expect(reporter.strip_profiles_meta(inspec_report, [], 1.1)).to eq(expected) + end + + it "does not remove the metadata from missing profiles" do + expected = inspec_report.merge(run_time_limit: 1.1) + expect(reporter.strip_profiles_meta(inspec_report, ["7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd"], 1.1)).to eq(expected) + end + end +end diff --git a/spec/unit/compliance/reporter/chef_server_automate_spec.rb b/spec/unit/compliance/reporter/chef_server_automate_spec.rb new file mode 100644 index 0000000000..e45a7157ee --- /dev/null +++ b/spec/unit/compliance/reporter/chef_server_automate_spec.rb @@ -0,0 +1,177 @@ +require "spec_helper" + +describe Chef::Compliance::Reporter::ChefServerAutomate do + before do + WebMock.disable_net_connect! + + Chef::Config[:client_key] = File.expand_path("../../../data/ssl/private_key.pem", __dir__) + Chef::Config[:node_name] = "spec-node" + end + + let(:reporter) { Chef::Compliance::Reporter::ChefServerAutomate.new(opts) } + + let(:opts) do + { + entity_uuid: "aaaaaaaa-709a-475d-bef5-zzzzzzzzzzzz", + run_id: "3f0536f7-3361-4bca-ae53-b45118dceb5d", + node_info: { + node: "chef-client.solo", + environment: "My Prod Env", + roles: %w{base_linux apache_linux}, + recipes: ["some_cookbook::some_recipe", "some_cookbook"], + policy_name: "test_policy_name", + policy_group: "test_policy_group", + chef_tags: ["mylinux", "my.tag", "some=tag"], + organization_name: "test_org", + source_fqdn: "api.chef.io", + ipaddress: "192.168.56.33", + fqdn: "lb1.prod.example.com", + }, + url: "https://chef.server/data_collector", + control_results_limit: 2, + timestamp: Time.parse("2016-07-19T19:19:19+01:00"), + } + end + + let(:inspec_report) do + { + "version": "1.2.1", + "profiles": + [{ "name": "tmp_compliance_profile", + "title": "/tmp Compliance Profile", + "summary": "An Example Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + "version": "0.1.1", + "maintainer": "Nathen Harvey <nharvey@chef.io>", + "license": "Apache 2.0 License", + "copyright": "Nathen Harvey <nharvey@chef.io>", + "supports": [], + "controls": + [{ "title": "A /tmp directory must exist", + "desc": "A /tmp directory must exist", + "impact": 0.3, + "refs": [], + "tags": {}, + "code": "control 'tmp-1.0' do\n impact 0.3\n title 'A /tmp directory must exist'\n desc 'A /tmp directory must exist'\n describe file '/tmp' do\n it { should be_directory }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 3 }, + "id": "tmp-1.0", + "results": [ + { "status": "passed", "code_desc": "File /tmp should be directory", "run_time": 0.002312, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + }, + { "title": "/tmp directory is owned by the root user", + "desc": "The /tmp directory must be owned by the root user", + "impact": 0.3, + "refs": [{ "url": "https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf", "ref": "Compliance Whitepaper" }], + "tags": { "production": nil, "development": nil, "identifier": "value", "remediation": "https://github.com/chef-cookbooks/audit" }, + "code": "control 'tmp-1.1' do\n impact 0.3\n title '/tmp directory is owned by the root user'\n desc 'The /tmp directory must be owned by the root user'\n tag 'production','development'\n tag identifier: 'value'\n tag remediation: 'https://github.com/chef-cookbooks/audit'\n ref 'Compliance Whitepaper', url: 'https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf'\n describe file '/tmp' do\n it { should be_owned_by 'root' }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 12 }, + "id": "tmp-1.1", + "results": [ + { "status": "passed", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "skipped", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + { "status": "failed", "code_desc": "File /etc/hosts is expected to be directory", "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400", "message": "expected `File /etc/hosts.directory?` to return true, got false" }, + ], + }, + ], + "groups": [{ "title": "/tmp Compliance Profile", "controls": ["tmp-1.0", "tmp-1.1"], "id": "controls/tmp.rb" }], + "attributes": [{ "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }] }], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + } + end + + let(:enriched_report) do + { + "version": "1.2.1", + "profiles": [ + { + "name": "tmp_compliance_profile", + "title": "/tmp Compliance Profile", + "summary": "An Example Compliance Profile", + "sha256": "7bd598e369970002fc6f2d16d5b988027d58b044ac3fa30ae5fc1b8492e215cd", + "version": "0.1.1", + "maintainer": "Nathen Harvey <nharvey@chef.io>", + "license": "Apache 2.0 License", + "copyright": "Nathen Harvey <nharvey@chef.io>", + "supports": [], + "controls": [ + { + "title": "A /tmp directory must exist", + "desc": "A /tmp directory must exist", + "impact": 0.3, + "refs": [], + "tags": {}, + "code": + "control 'tmp-1.0' do\n impact 0.3\n title 'A /tmp directory must exist'\n desc 'A /tmp directory must exist'\n describe file '/tmp' do\n it { should be_directory }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 3 }, + "id": "tmp-1.0", + "results": [{ "status": "passed", "code_desc": "File /tmp should be directory", "run_time": 0.002312, "start_time": "2016-10-19 11:09:43 -0400" }], + }, + { + "title": "/tmp directory is owned by the root user", + "desc": "The /tmp directory must be owned by the root user", + "impact": 0.3, + "refs": [{ "url": "https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf", "ref": "Compliance Whitepaper" }], + "tags": { "production": nil, "development": nil, "identifier": "value", "remediation": "https://github.com/chef-cookbooks/audit" }, + "code": "control 'tmp-1.1' do\n impact 0.3\n title '/tmp directory is owned by the root user'\n desc 'The /tmp directory must be owned by the root user'\n tag 'production','development'\n tag identifier: 'value'\n tag remediation: 'https://github.com/chef-cookbooks/audit'\n ref 'Compliance Whitepaper', url: 'https://pages.chef.io/rs/255-VFB-268/images/compliance-at-velocity2015.pdf'\n describe file '/tmp' do\n it { should be_owned_by 'root' }\n end\nend\n", + "source_location": { "ref": "/Users/vjeffrey/code/delivery/insights/data_generator/chef-client/cache/cookbooks/test-cookbook/recipes/../files/default/compliance_profiles/tmp_compliance_profile/controls/tmp.rb", "line": 12 }, + "id": "tmp-1.1", + "results": [ + { "status": "failed", "code_desc": "File /etc/hosts is expected to be directory", "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400", "message": "expected `File /etc/hosts.directory?` to return true, got false" }, + { "status": "skipped", "code_desc": 'File /tmp should be owned by "root"', "run_time": 1.228845, "start_time": "2016-10-19 11:09:43 -0400" }, + ], + "removed_results_counts": { "failed": 0, "skipped": 0, "passed": 1 }, + }, + ], + "groups": [{ "title": "/tmp Compliance Profile", "controls": ["tmp-1.0", "tmp-1.1"], "id": "controls/tmp.rb" }], + "attributes": [{ "name": "syslog_pkg", "options": { "default": "rsyslog", "description": "syslog package..." } }], + }, + ], + "other_checks": [], + "statistics": { "duration": 0.032332 }, + "type": "inspec_report", + "node_name": "chef-client.solo", + "end_time": "2016-07-19T18:19:19Z", + "node_uuid": "aaaaaaaa-709a-475d-bef5-zzzzzzzzzzzz", + "environment": "My Prod Env", + "roles": %w{base_linux apache_linux}, + "recipes": ["some_cookbook::some_recipe", "some_cookbook"], + "report_uuid": "3f0536f7-3361-4bca-ae53-b45118dceb5d", + "source_fqdn": "api.chef.io", + "organization_name": "test_org", + "policy_group": "test_policy_group", + "policy_name": "test_policy_name", + "chef_tags": ["mylinux", "my.tag", "some=tag"], + "ipaddress": "192.168.56.33", + "fqdn": "lb1.prod.example.com", + } + end + + it "sends report successfully" do + # TODO: Had to change 'X-Ops-Server-Api-Version' from 1 to 2, is that correct? + report_stub = stub_request(:post, "https://chef.server/data_collector") + .with( + body: enriched_report, + headers: { + "X-Chef-Version" => Chef::VERSION, + "X-Ops-Authorization-1" => /.+/, + "X-Ops-Authorization-2" => /.+/, + "X-Ops-Authorization-3" => /.+/, + "X-Ops-Authorization-4" => /.+/, + "X-Ops-Authorization-5" => /.+/, + "X-Ops-Authorization-6" => /.+/, + "X-Ops-Content-Hash" => "yfck5nQDcRWta06u45Q+J463LYY=", + "X-Ops-Server-Api-Version" => "2", + "X-Ops-Sign" => "algorithm=sha1;version=1.1;", + "X-Ops-Timestamp" => /.+/, + "X-Ops-Userid" => "spec-node", + "X-Remote-Request-Id" => /.+/, + } + ).to_return(status: 200) + + expect(reporter.send_report(inspec_report)).to eq(true) + + expect(report_stub).to have_been_requested + end +end diff --git a/spec/unit/compliance/reporter/compliance_enforcer_spec.rb b/spec/unit/compliance/reporter/compliance_enforcer_spec.rb new file mode 100644 index 0000000000..ae63cf0853 --- /dev/null +++ b/spec/unit/compliance/reporter/compliance_enforcer_spec.rb @@ -0,0 +1,48 @@ +require "spec_helper" + +describe Chef::Compliance::Reporter::AuditEnforcer do + let(:reporter) { Chef::Compliance::Reporter::AuditEnforcer.new } + + it "does not raise error for a successful InSpec report" do + report = { + "profiles": [ + { + "controls": [ + { "id": "c1", "results": [{ "status": "passed" }] }, + { "id": "c2", "results": [{ "status": "passed" }] }, + ], + }, + ], + } + + expect(reporter.send_report(report)).to eq(true) + end + + it "does not raise error for an InSpec report with no controls" do + report = { "profiles": [{ "name": "empty" }] } + + expect(reporter.send_report(report)).to eq(true) + end + + it "does not raise error for an InSpec report with controls but no results" do + report = { "profiles": [{ "controls": [{ "id": "empty" }] }] } + expect(reporter.send_report(report)).to eq(true) + end + + it "raises an error for a failed InSpec report" do + report = { + "profiles": [ + { + "controls": [ + { "id": "c1", "results": [{ "status": "passed" }] }, + { "id": "c2", "results": [{ "status": "failed" }] }, + ], + }, + ], + } + + expect { + reporter.send_report(report) + }.to raise_error(Chef::Compliance::Reporter::AuditEnforcer::ControlFailure, "Audit c2 has failed. Aborting chef-client run.") + end +end diff --git a/spec/unit/compliance/runner_spec.rb b/spec/unit/compliance/runner_spec.rb new file mode 100644 index 0000000000..68c8a9553b --- /dev/null +++ b/spec/unit/compliance/runner_spec.rb @@ -0,0 +1,113 @@ +require "spec_helper" + +describe Chef::Compliance::Runner do + let(:logger) { double(:logger).as_null_object } + let(:node) { Chef::Node.new(logger: logger) } + + let(:runner) do + described_class.new.tap do |r| + r.node = node + r.run_id = "my_run_id" + r.recipes = [] + end + end + + describe "#enabled?" do + it "is true if the node attributes have audit profiles and the audit cookbook is not present" do + node.normal["audit"]["profiles"]["ssh"] = { 'compliance': "base/ssh" } + runner.recipes = %w{ fancy_cookbook::fanciness tacobell::nachos } + + expect(runner).to be_enabled + end + + it "is false if the node attributes have audit profiles and the audit cookbook is present" do + node.normal["audit"]["profiles"]["ssh"] = { 'compliance': "base/ssh" } + runner.recipes = %w{ audit::default fancy_cookbook::fanciness tacobell::nachos } + + expect(runner).not_to be_enabled + end + + it "is false if the node attributes do not have audit profiles and the audit cookbook is not present" do + node.normal["audit"]["profiles"] = {} + runner.recipes = %w{ fancy_cookbook::fanciness tacobell::nachos } + + expect(runner).not_to be_enabled + end + + it "is false if the node attributes do not have audit profiles and the audit cookbook is present" do + node.normal["audit"]["profiles"] = {} + runner.recipes = %w{ audit::default fancy_cookbook::fanciness tacobell::nachos } + + expect(runner).not_to be_enabled + end + + it "is false if the node attributes do not have audit attributes and the audit cookbook is not present" do + runner.recipes = %w{ fancy_cookbook::fanciness tacobell::nachos } + expect(runner).not_to be_enabled + end + end + + describe "#inspec_profiles" do + it "returns an empty list with no profiles defined" do + expect(runner.inspec_profiles).to eq([]) + end + + it "converts from the attribute format to the format Inspec expects" do + node.normal["audit"]["profiles"]["linux-baseline"] = { + 'compliance': "user/linux-baseline", + 'version': "2.1.0", + } + + node.normal["audit"]["profiles"]["ssh"] = { + 'supermarket': "hardening/ssh-hardening", + } + + expected = [ + { + compliance: "user/linux-baseline", + name: "linux-baseline", + version: "2.1.0", + }, + { + name: "ssh", + supermarket: "hardening/ssh-hardening", + }, + ] + + expect(runner.inspec_profiles).to eq(expected) + end + + it "raises an error when the profiles are in the old audit-cookbook format" do + node.normal["audit"]["profiles"] = [ + { + name: "Windows 2019 Baseline", + compliance: "admin/windows-2019-baseline", + }, + ] + + expect { runner.inspec_profiles }.to raise_error(/profiles specified in an unrecognized format, expected a hash of hashes./) + end + end + + describe "#warn_for_deprecated_config_values!" do + it "logs a warning when deprecated config values are present" do + node.normal["audit"]["owner"] = "my_org" + node.normal["audit"]["inspec_version"] = "90210" + + expect(logger).to receive(:warn).with(/config values 'inspec_version', 'owner' are not supported/) + + runner.warn_for_deprecated_config_values! + end + + it "does not log a warning with no deprecated config values" do + node.normal["audit"]["profiles"]["linux-baseline"] = { + 'compliance': "user/linux-baseline", + 'version': "2.1.0", + } + + expect(logger).not_to receive(:warn) + + runner.warn_for_deprecated_config_values! + end + end +end |