summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Smith <tsmith@chef.io>2020-12-02 23:53:16 -0800
committerGitHub <noreply@github.com>2020-12-02 23:53:16 -0800
commite44bfc1507ae9a6126b9510b57318ffbfb684e40 (patch)
treef2ed5b1d6921d0514065f5ca2e3419ce6b14e6bd
parent0a417f1ce92c5905874506cae7e8b267bf096665 (diff)
parenta2ba9037bc7e225801f29f85eac0c428aa908ac8 (diff)
downloadchef-e44bfc1507ae9a6126b9510b57318ffbfb684e40.tar.gz
Merge pull request #10547 from chef/audit-mode
Signed-off-by: Tim Smith <tsmith@chef.io>
-rw-r--r--.gitignore2
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--chef.gemspec1
-rw-r--r--cspell.json2
-rw-r--r--lib/chef/client.rb3
-rw-r--r--lib/chef/compliance/default_attributes.rb89
-rw-r--r--lib/chef/compliance/fetcher/automate.rb69
-rw-r--r--lib/chef/compliance/fetcher/chef_server.rb134
-rw-r--r--lib/chef/compliance/reporter/automate.rb202
-rw-r--r--lib/chef/compliance/reporter/chef_server_automate.rb92
-rw-r--r--lib/chef/compliance/reporter/compliance_enforcer.rb20
-rw-r--r--lib/chef/compliance/reporter/json_file.rb19
-rw-r--r--lib/chef/compliance/runner.rb250
-rw-r--r--spec/integration/compliance/compliance_spec.rb81
-rw-r--r--spec/unit/client_spec.rb1
-rw-r--r--spec/unit/compliance/fetcher/automate_spec.rb134
-rw-r--r--spec/unit/compliance/fetcher/chef_server_spec.rb93
-rw-r--r--spec/unit/compliance/reporter/automate_spec.rb427
-rw-r--r--spec/unit/compliance/reporter/chef_server_automate_spec.rb177
-rw-r--r--spec/unit/compliance/reporter/compliance_enforcer_spec.rb48
-rw-r--r--spec/unit/compliance/runner_spec.rb113
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
diff --git a/Gemfile b/Gemfile
index 0298aab7df..f92dc7be14 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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