diff options
26 files changed, 2437 insertions, 40 deletions
diff --git a/kitchen-tests/cookbooks/end_to_end/attributes/default.rb b/kitchen-tests/cookbooks/end_to_end/attributes/default.rb index 72430e9367..8350cb2a80 100644 --- a/kitchen-tests/cookbooks/end_to_end/attributes/default.rb +++ b/kitchen-tests/cookbooks/end_to_end/attributes/default.rb @@ -71,3 +71,6 @@ default["chef_client"]["chef_license"] = "accept-no-persist" # default["nscd"]["server_user"] = "nobody" unless platform_family?("suse") # this breaks SLES 15 + +# enable CLI output for the compliance phase +default["audit"]["reporter"] = "cli" diff --git a/lib/chef/client.rb b/lib/chef/client.rb index efe6409fcd..7f184d7db4 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -241,8 +241,7 @@ class Chef run_status.run_id = request_id = Chef::RequestID.instance.request_id - @run_context = Chef::RunContext.new - run_context.events = events + @run_context = Chef::RunContext.new(nil, nil, events) run_status.run_context = run_context events.run_start(Chef::VERSION, run_status) diff --git a/lib/chef/compliance/input.rb b/lib/chef/compliance/input.rb new file mode 100644 index 0000000000..686b516b2e --- /dev/null +++ b/lib/chef/compliance/input.rb @@ -0,0 +1,115 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "yaml" + +class Chef + module Compliance + # + # Chef object that represents a single input file in the compliance segment + # of a cookbook. + # + class Input + # @return [Boolean] if the input has been enabled + attr_reader :enabled + + # @return [String] The name of the cookbook that the input is in + attr_reader :cookbook_name + + # @return [String] The full path on the host to the input yml file + attr_reader :path + + # @return [String] the pathname in the cookbook + attr_reader :pathname + + # Event dispatcher for this run. + # + # @return [Chef::EventDispatch::Dispatcher] + # + attr_reader :events + + # @api private + attr_reader :data + + def initialize(events, data, path, cookbook_name) + @events = events + @data = data + @cookbook_name = cookbook_name + @path = path + @pathname = File.basename(path, File.extname(path)) unless path.nil? + disable! + end + + # @return [Boolean] if the input has been enabled + # + def enabled? + !!@enabled + end + + # Set the input to being enabled + # + def enable! + events.compliance_input_enabled(self) + @enabled = true + end + + # Set the input as being disabled + # + def disable! + @enabled = false + end + + # Render the input in a way that it can be consumed by inspec + # + def inspec_data + data + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + + # Helper to construct a input object from a hash. Since the path and + # cookbook_name are required this is probably not externally useful. + # + def self.from_hash(events, hash, path = nil, cookbook_name = nil) + new(events, hash, path, cookbook_name) + end + + # Helper to construct a input object from a yaml string. Since the path + # and cookbook_name are required this is probably not externally useful. + # + def self.from_yaml(events, string, path = nil, cookbook_name = nil) + from_hash(events, YAML.load(string), path, cookbook_name) + end + + # @param filename [String] full path to the yml file in the cookbook + # @param cookbook_name [String] cookbook that the input is in + # + def self.from_file(events, filename, cookbook_name = nil) + from_yaml(events, IO.read(filename), filename, cookbook_name) + end + end + end +end diff --git a/lib/chef/compliance/input_collection.rb b/lib/chef/compliance/input_collection.rb new file mode 100644 index 0000000000..a8a9d6328f --- /dev/null +++ b/lib/chef/compliance/input_collection.rb @@ -0,0 +1,139 @@ +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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_relative "input" + +class Chef + module Compliance + class InputCollection < Array + + # Event dispatcher for this run. + # + # @return [Chef::EventDispatch::Dispatcher] + # + attr_reader :events + + def initialize(events) + @events = events + end + + # Add a input to the input collection. The cookbook_name needs to be determined by the + # caller and is used in the `include_input` API to match on. The path should be the complete + # path on the host of the yml file, including the filename. + # + # @param path [String] + # @param cookbook_name [String] + # + def from_file(filename, cookbook_name) + new_input = Input.from_file(events, filename, cookbook_name) + self << new_input + events.compliance_input_loaded(new_input) + end + + # Add a input from a raw hash. This input will be enabled by default. + # + # @param path [String] + # @param cookbook_name [String] + # + def from_hash(hash) + new_input = Input.from_hash(events, hash) + new_input.enable! + self << new_input + end + + # @return [Array<Input>] inspec inputs which are enabled in a form suitable to pass to inspec + # + def inspec_data + select(&:enabled?).each_with_object({}) { |input, hash| hash.merge(input.inspec_data) } + end + + # DSL method to enable input files. This matches on the filename of the input file. + # If the specific input is omitted then it uses the default input. The string + # supports regular expression matching. + # + # @example Specific input file in a cookbook + # + # include_input "acme_cookbook::ssh-001" + # + # @example The compliance/inputs/default.yml input in a cookbook + # + # include_input "acme_cookbook" + # + # @example Every input file in a cookbook + # + # include_input "acme_cookbook::.*" + # + # @example Matching inputs by regexp in a cookbook + # + # include_input "acme_cookbook::ssh.*" + # + # @example Matching inputs by regexp in any cookbook in the cookbook collection + # + # include_input ".*::ssh.*" + # + # @example Adding an arbitrary hash of data (not from any file in a cookbook) + # + # include_input({ "ssh_custom_path": "/usr/local/bin" }) + # + def include_input(arg) + raise "include_input was given a nil value" if arg.nil? + + # if we're given a hash argument just shove it in the raw_hash + if arg.is_a?(Hash) + from_hash(arg) + return + end + + matching_inputs(arg).each(&:enable!) + end + + def valid?(arg) + !matching_inputs(arg).empty? + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + + private + + def matching_inputs(arg, should_raise: false) + (cookbook_name, input_name) = arg.split("::") + + input_name = "default" if input_name.nil? + + inputs = select { |input| /^#{cookbook_name}$/.match?(input.cookbook_name) && /^#{input_name}$/.match?(input.pathname) } + + if inputs.empty? && should_raise + raise "No inspec inputs matching '#{input_name}' found in cookbooks matching '#{cookbook_name}'" + end + + inputs + end + + def matching_inputs!(arg) + matching_inputs(arg, should_raise: true) + end + end + end +end diff --git a/lib/chef/compliance/profile.rb b/lib/chef/compliance/profile.rb new file mode 100644 index 0000000000..ec9d61895c --- /dev/null +++ b/lib/chef/compliance/profile.rb @@ -0,0 +1,122 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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. +# + +class Chef + module Compliance + class Profile + # @return [Boolean] if the profile has been enabled + attr_accessor :enabled + + # @return [String] The full path on the host to the profile inspec.yml + attr_reader :path + + # @return [String] The name of the cookbook that the profile is in + attr_reader :cookbook_name + + # @return [String] the pathname in the cookbook + attr_accessor :pathname + + # @return [Chef::EventDispatch::Dispatcher] Event dispatcher for this run. + attr_reader :events + + # @api private + attr_reader :data + + def initialize(events, data, path, cookbook_name) + @events = events + @data = data + @path = path + @cookbook_name = cookbook_name + @pathname = File.basename(File.dirname(path)) + disable! + validate! + end + + # @return [String] name of the inspec profile from parsing the inspec.yml + def name + @data["name"] + end + + # @return [String] version of the inspec profile from parsing the inspec.yml + def version + @data["version"] + end + + # Raises if the inspec profile is not valid. + # + def validate! + raise "Inspec profile at #{path} has no name" unless name + end + + # @return [Boolean] if the profile has been enabled + def enabled? + !!@enabled + end + + # Set the profile to being enabled + # + def enable! + events.compliance_profile_enabled(self) + @enabled = true + end + + # Set the profile as being disabled + # + def disable! + @enabled = false + end + + # Render the profile in a way that it can be consumed by inspec + # + def inspec_data + { name: name, path: File.dirname(path) } + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + + # Helper to construct a profile object from a hash. Since the path and + # cookbook_name are required this is probably not externally useful. + # + def self.from_hash(events, hash, path, cookbook_name) + new(events, hash, path, cookbook_name) + end + + # Helper to construct a profile object from a yaml string. Since the path + # and cookbook_name are required this is probably not externally useful. + # + def self.from_yaml(events, string, path, cookbook_name) + from_hash(events, YAML.load(string), path, cookbook_name) + end + + # @param filename [String] full path to the inspec.yml file in the cookbook + # @param cookbook_name [String] cookbook that the profile is in + # + def self.from_file(events, filename, cookbook_name) + from_yaml(events, IO.read(filename), filename, cookbook_name) + end + end + end +end diff --git a/lib/chef/compliance/profile_collection.rb b/lib/chef/compliance/profile_collection.rb new file mode 100644 index 0000000000..d85d04e825 --- /dev/null +++ b/lib/chef/compliance/profile_collection.rb @@ -0,0 +1,109 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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_relative "profile" + +class Chef + module Compliance + class ProfileCollection < Array + + # Event dispatcher for this run. + # + # @return [Chef::EventDispatch::Dispatcher] + # + attr_reader :events + + def initialize(events) + @events = events + end + + # Add a profile to the profile collection. The cookbook_name needs to be determined by the + # caller and is used in the `include_profile` API to match on. The path should be the complete + # path on the host of the inspec.yml file, including the filename. + # + # @param path [String] + # @param cookbook_name [String] + # + def from_file(path, cookbook_name) + new_profile = Profile.from_file(events, path, cookbook_name) + self << new_profile + events.compliance_profile_loaded(new_profile) + end + + # @return [Boolean] if any of the profiles are enabled + # + def using_profiles? + any?(&:enabled?) + end + + # @return [Array<Profile>] inspec profiles which are enabled in a form suitable to pass to inspec + # + def inspec_data + select(&:enabled?).each_with_object([]) { |profile, arry| arry << profile.inspec_data } + end + + # DSL method to enable profile files. This matches on the name of the profile being included it + # does not match on the filename of the input file. If the specific profile is omitted then + # it uses the default profile. The string supports regular expression matching. + # + # @example Specific profile in a cookbook + # + # include_profile "acme_cookbook::ssh-001" + # + # @example The profile named "default" in a cookbook + # + # include_profile "acme_cookbook" + # + # @example Every profile in a cookbook + # + # include_profile "acme_cookbook::.*" + # + # @example Matching profiles by regexp in a cookbook + # + # include_profile "acme_cookbook::ssh.*" + # + # @example Matching profiles by regexp in any cookbook in the cookbook collection + # + # include_profile ".*::ssh.*" + # + def include_profile(arg) + (cookbook_name, profile_name) = arg.split("::") + + profile_name = "default" if profile_name.nil? + + profiles = select { |profile| /^#{cookbook_name}$/.match?(profile.cookbook_name) && /^#{profile_name}$/.match?(profile.pathname) } + + if profiles.empty? + raise "No inspec profiles matching '#{profile_name}' found in cookbooks matching '#{cookbook_name}'" + end + + profiles.each(&:enable!) + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + end + end +end diff --git a/lib/chef/compliance/runner.rb b/lib/chef/compliance/runner.rb index b71f200941..b00008fa51 100644 --- a/lib/chef/compliance/runner.rb +++ b/lib/chef/compliance/runner.rb @@ -12,6 +12,8 @@ class Chef attr_accessor :run_id attr_reader :node + attr_reader :run_context + def_delegators :node, :logger def enabled? @@ -25,7 +27,9 @@ class Chef logger.debug("#{self.class}##{__method__}: audit cookbook? #{audit_cookbook_present}") logger.debug("#{self.class}##{__method__}: compliance phase attr? #{node["audit"]["compliance_phase"]}") - if node["audit"]["compliance_phase"].nil? + if safe_profile_collection&.using_profiles? + true + elsif node["audit"]["compliance_phase"].nil? inspec_profiles.any? && !audit_cookbook_present else node["audit"]["compliance_phase"] @@ -41,6 +45,14 @@ class Chef self.node = node end + # This hook gives us the run_context immediately after it is created so that we can wire up this object to it. + # + # (see EventDispatch::Base#) + # + def cookbook_compilation_start(run_context) + @run_context = run_context + end + def run_started(run_status) self.run_id = run_status.run_id end @@ -121,8 +133,16 @@ class Chef end end + def inputs_from_collection + safe_input_collection&.inspec_data || {} + end + + def waivers_from_collection + safe_waiver_collection&.inspec_data || {} + end + def inspec_opts - inputs = inputs_from_attributes + inputs = inputs_from_attributes.merge(inputs_from_collection).merge(waivers_from_collection) if node["audit"]["chef_node_attribute_enabled"] inputs["chef_node"] = node.to_h @@ -133,24 +153,34 @@ class Chef backend_cache: node["audit"]["inspec_backend_cache"], inputs: inputs, logger: logger, + # output: STDOUT, output: node["audit"]["quiet"] ? ::File::NULL : STDOUT, report: true, reporter: ["json-automate"], + # reporter: ["cli"], reporter_backtrace_inclusion: node["audit"]["result_include_backtrace"], reporter_message_truncation: node["audit"]["result_message_limit"], - waiver_file: Array(node["audit"]["waiver_file"]), + waiver_file: waiver_files, } end + def waiver_files + Array(node["audit"]["waiver_file"]) + end + def inspec_profiles profiles = node["audit"]["profiles"] unless profiles.respond_to?(:map) && profiles.all? { |_, p| p.respond_to?(:transform_keys) && p.respond_to?(:update) } raise "CMPL010: #{Inspec::Dist::PRODUCT_NAME} profiles specified in an unrecognized format, expected a hash of hashes." end - profiles.map do |name, profile| + from_attributes = profiles.map do |name, profile| profile.transform_keys(&:to_sym).update(name: name) - end + end || [] + + from_cookbooks = safe_profile_collection&.inspec_data || [] + + from_attributes + from_cookbooks end def load_fetchers! @@ -316,6 +346,18 @@ class Chef @validation_passed = true end + + def safe_profile_collection + run_context&.profile_collection + end + + def safe_waiver_collection + run_context&.waiver_collection + end + + def safe_input_collection + run_context&.input_collection + end end end end diff --git a/lib/chef/compliance/waiver.rb b/lib/chef/compliance/waiver.rb new file mode 100644 index 0000000000..0062a7d5d9 --- /dev/null +++ b/lib/chef/compliance/waiver.rb @@ -0,0 +1,115 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "yaml" + +class Chef + module Compliance + # + # Chef object that represents a single waiver file in the compliance + # segment of a cookbook + # + class Waiver + # @return [Boolean] if the waiver has been enabled + attr_reader :enabled + + # @return [String] The name of the cookbook that the waiver is in + attr_reader :cookbook_name + + # @return [String] The full path on the host to the waiver yml file + attr_reader :path + + # @return [String] the pathname in the cookbook + attr_reader :pathname + + # @api private + attr_reader :data + + # Event dispatcher for this run. + # + # @return [Chef::EventDispatch::Dispatcher] + # + attr_accessor :events + + def initialize(events, data, path, cookbook_name) + @events = events + @data = data + @cookbook_name = cookbook_name + @path = path + @pathname = File.basename(path, File.extname(path)) unless path.nil? + disable! + end + + # @return [Boolean] if the waiver has been enabled + # + def enabled? + !!@enabled + end + + # Set the waiver to being enabled + # + def enable! + events.compliance_waiver_enabled(self) + @enabled = true + end + + # Set the waiver as being disabled + # + def disable! + @enabled = false + end + + # Render the waiver in a way that it can be consumed by inspec + # + def inspec_data + data + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + + # Helper to construct a waiver object from a hash. Since the path and + # cookbook_name are required this is probably not externally useful. + # + def self.from_hash(events, hash, path = nil, cookbook_name = nil) + new(events, hash, path, cookbook_name) + end + + # Helper to construct a waiver object from a yaml string. Since the path + # and cookbook_name are required this is probably not externally useful. + # + def self.from_yaml(events, string, path = nil, cookbook_name = nil) + from_hash(events, YAML.load(string), path, cookbook_name) + end + + # @param filename [String] full path to the yml file in the cookbook + # @param cookbook_name [String] cookbook that the waiver is in + # + def self.from_file(events, filename, cookbook_name = nil) + from_yaml(events, IO.read(filename), filename, cookbook_name) + end + end + end +end diff --git a/lib/chef/compliance/waiver_collection.rb b/lib/chef/compliance/waiver_collection.rb new file mode 100644 index 0000000000..4fe659554b --- /dev/null +++ b/lib/chef/compliance/waiver_collection.rb @@ -0,0 +1,143 @@ +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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_relative "waiver" + +class Chef + module Compliance + class WaiverCollection < Array + + # Event dispatcher for this run. + # + # @return [Chef::EventDispatch::Dispatcher] + # + attr_reader :events + + def initialize(events) + @events = events + end + + # Add a waiver to the waiver collection. The cookbook_name needs to be determined by the + # caller and is used in the `include_waiver` API to match on. The path should be the complete + # path on the host of the yml file, including the filename. + # + # @param path [String] + # @param cookbook_name [String] + # + def from_file(filename, cookbook_name) + new_waiver = Waiver.from_file(events, filename, cookbook_name) + self << new_waiver + events.compliance_waiver_loaded(new_waiver) + end + + # Add a waiver from a raw hash. This waiver will be enabled by default. + # + # @param path [String] + # @param cookbook_name [String] + # + def from_hash(hash) + new_waiver = Waiver.from_hash(events, hash) + new_waiver.enable! + self << new_waiver + end + + # @return [Array<Waiver>] inspec waivers which are enabled in a form suitable to pass to inspec + # + def inspec_data + select(&:enabled?).each_with_object({}) { |waiver, hash| hash.merge(waiver.inspec_data) } + end + + # DSL method to enable waiver files. This matches on the filename of the waiver file. + # If the specific waiver is omitted then it uses the default waiver. The string + # supports regular expression matching. + # + # @example Specific waiver file in a cookbook + # + # include_waiver "acme_cookbook::ssh-001" + # + # @example The compliance/waiver/default.rb waiver file in a cookbook + # + # include_waiver "acme_cookbook" + # + # @example Every waiver file in a cookbook + # + # include_waiver "acme_cookbook::.*" + # + # @example Matching waivers by regexp in a cookbook + # + # include_waiver "acme_cookbook::ssh.*" + # + # @example Matching waivers by regexp in any cookbook in the cookbook collection + # + # include_waiver ".*::ssh.*" + # + # @example Adding an arbitrary hash of data (not from any file in a cookbook) + # + # include_waiver({ "ssh-01" => { + # "expiration_date" => "2033-07-31", + # "run" => false, + # "justification" => "the reason it is waived", + # } }) + # + def include_waiver(arg) + raise "include_waiver was given a nil value" if arg.nil? + + # if we're given a hash argument just shove it in the collection + if arg.is_a?(Hash) + from_hash(arg) + return + end + + matching_waivers!(arg).each(&:enable!) + end + + def valid?(arg) + !matching_waivers(arg).empty? + end + + HIDDEN_IVARS = [ :@events ].freeze + + # Omit the event object from error output + # + def inspect + ivar_string = (instance_variables.map(&:to_sym) - HIDDEN_IVARS).map do |ivar| + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end.join(", ") + "#<#{self.class}:#{object_id} #{ivar_string}>" + end + + private + + def matching_waivers(arg, should_raise: false) + (cookbook_name, waiver_name) = arg.split("::") + + waiver_name = "default" if waiver_name.nil? + + waivers = select { |waiver| /^#{cookbook_name}$/.match?(waiver.cookbook_name) && /^#{waiver_name}$/.match?(waiver.pathname) } + + if waivers.empty? && should_raise + raise "No inspec waivers matching '#{waiver_name}' found in cookbooks matching '#{cookbook_name}'" + end + + waivers + end + + def matching_waivers!(arg) + matching_waivers(arg, should_raise: true) + end + end + end +end diff --git a/lib/chef/dsl/compliance.rb b/lib/chef/dsl/compliance.rb new file mode 100644 index 0000000000..0375c4835a --- /dev/null +++ b/lib/chef/dsl/compliance.rb @@ -0,0 +1,38 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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. +# + +class Chef + module DSL + module Compliance + + # @see Chef::Compliance::ProfileCollection#include_profile + def include_profile(*args) + run_context.profile_collection.include_profile(*args) + end + + # @see Chef::Compliance::WaiverCollection#include_waiver + def include_waiver(*args) + run_context.waiver_collection.include_waiver(*args) + end + + # @see Chef::Compliance::inputCollection#include_input + def include_input(*args) + run_context.input_collection.include_input(*args) + end + end + end +end diff --git a/lib/chef/dsl/reader_helpers.rb b/lib/chef/dsl/reader_helpers.rb new file mode 100644 index 0000000000..6a9b021d89 --- /dev/null +++ b/lib/chef/dsl/reader_helpers.rb @@ -0,0 +1,51 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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. + +autoload :TOML, "tomlrb" +require_relative "../json_compat" +autoload :YAML, "yaml" + +class Chef + module DSL + module ReaderHelpers + + def parse_file(filename) + case File.extname(filename) + when ".toml" + parse_toml(filename) + when ".yaml", ".yml" + parse_yaml(filename) + when ".json" + parse_json(filename) + end + end + + def parse_json(filename) + JSONCompat.parse(IO.read(filename)) + end + + def parse_toml(filename) + Tomlrb.load_file(filename) + end + + def parse_yaml(filename) + YAML.load(IO.read(filename)) + end + + extend self + end + end +end diff --git a/lib/chef/dsl/recipe.rb b/lib/chef/dsl/recipe.rb index ad85a9dd91..7b08f841e6 100644 --- a/lib/chef/dsl/recipe.rb +++ b/lib/chef/dsl/recipe.rb @@ -18,12 +18,13 @@ # require_relative "../exceptions" -require_relative "resources" +require_relative "compliance" +require_relative "declare_resource" require_relative "definitions" require_relative "include_recipe" require_relative "reboot_pending" +require_relative "resources" require_relative "universal" -require_relative "declare_resource" require_relative "../mixin/notifying_block" require_relative "../mixin/lazy_module_include" @@ -42,6 +43,7 @@ class Chef # - it also pollutes the namespace of nearly every context, watch out. # module Recipe + include Chef::DSL::Compliance include Chef::DSL::Universal include Chef::DSL::DeclareResource include Chef::Mixin::NotifyingBlock diff --git a/lib/chef/dsl/universal.rb b/lib/chef/dsl/universal.rb index 84566c6da7..972ebacc7e 100644 --- a/lib/chef/dsl/universal.rb +++ b/lib/chef/dsl/universal.rb @@ -23,6 +23,7 @@ require_relative "chef_vault" require_relative "registry_helper" require_relative "powershell" require_relative "secret" +require_relative "reader_helpers" require_relative "render_helpers" require_relative "toml" require_relative "../mixin/powershell_exec" @@ -50,6 +51,7 @@ class Chef include Chef::DSL::ChefVault include Chef::DSL::RegistryHelper include Chef::DSL::Powershell + include Chef::DSL::ReaderHelpers include Chef::DSL::RenderHelpers include Chef::DSL::Secret include Chef::Mixin::PowershellExec diff --git a/lib/chef/event_dispatch/base.rb b/lib/chef/event_dispatch/base.rb index f9504967a9..a973c31612 100644 --- a/lib/chef/event_dispatch/base.rb +++ b/lib/chef/event_dispatch/base.rb @@ -164,7 +164,7 @@ class Chef # Called when LWRPs are finished loading def lwrp_load_complete; end - # Called when an ohai plugin file loading starts + # Called when ohai plugin file loading starts def ohai_plugin_load_start(file_count); end # Called when an ohai plugin file has been loaded @@ -173,9 +173,51 @@ class Chef # Called when an ohai plugin file has an error on load. def ohai_plugin_file_load_failed(path, exception); end - # Called when an ohai plugin file loading has finished + # Called when ohai plugin file loading has finished def ohai_plugin_load_complete; end + # Called when compliance file loading starts + def compliance_load_start; end + + # Called when compliance file loading ends + def compliance_load_complete; end + + # Called when compliance profile loading starts + def profiles_load_start; end + + # Called when compliance profile loading end + def profiles_load_complete; end + + # Called when compliance input loading starts + def inputs_load_start; end + + # Called when compliance input loading end + def inputs_load_complete; end + + # Called when compliance waiver loading starts + def waivers_load_start; end + + # Called when compliance waiver loading end + def waivers_load_complete; end + + # Called when a compliance profile is found in a cookbook by the cookbook_compiler + def compliance_profile_loaded(profile); end + + # Called when a compliance waiver is found in a cookbook by the cookbook_compiler + def compliance_waiver_loaded(waiver); end + + # Called when a compliance waiver is found in a cookbook by the cookbook_compiler + def compliance_input_loaded(input); end + + # Called when a compliance profile is enabled (by include_profile) + def compliance_profile_enabled(profile); end + + # Called when a compliance waiver is enabled (by include_waiver) + def compliance_waiver_enabled(waiver); end + + # Called when a compliance input is enabled (by include_input) + def compliance_input_enabled(input); end + # Called before attribute files are loaded def attribute_load_start(attribute_file_count); end diff --git a/lib/chef/formatters/doc.rb b/lib/chef/formatters/doc.rb index 1b752a2924..f40af7b365 100644 --- a/lib/chef/formatters/doc.rb +++ b/lib/chef/formatters/doc.rb @@ -363,6 +363,52 @@ class Chef end end + # Called when compliance profile loading starts + def profiles_load_start + puts_line("Loading #{Inspec::Dist::PRODUCT_NAME} profile files:") + end + + # Called when compliance input loading starts + def inputs_load_start + puts_line("Loading #{Inspec::Dist::PRODUCT_NAME} input files:") + end + + # Called when compliance waiver loading starts + def waivers_load_start + puts_line("Loading #{Inspec::Dist::PRODUCT_NAME} waiver files:") + end + + # Called when a compliance profile is found in a cookbook by the cookbook_compiler + def compliance_profile_loaded(profile) + start_line(" - #{profile.cookbook_name}::#{profile.pathname}", :cyan) + puts " (#{profile.version})", :cyan if profile.version + end + + # Called when a compliance waiver is found in a cookbook by the cookbook_compiler + def compliance_input_loaded(input) + puts_line(" - #{input.cookbook_name}::#{input.pathname}", :cyan) + end + + # Called when a compliance waiver is found in a cookbook by the cookbook_compiler + def compliance_waiver_loaded(waiver) + puts_line(" - #{waiver.cookbook_name}::#{waiver.pathname}", :cyan) + end + + # Called when a compliance profile is enabled (by include_profile) + def compliance_profile_enabled(profile) + # puts_line(" * FIXME", :cyan) + end + + # Called when a compliance waiver is enabled (by include_waiver) + def compliance_waiver_enabled(waiver) + # puts_line(" * FIXME", :cyan) + end + + # Called when a compliance input is enabled (by include_input) + def compliance_input_enabled(input) + # puts_line(" * FIXME", :cyan) + end + # (see Base#deprecation) def deprecation(deprecation, _location = nil) if Chef::Config[:treat_deprecation_warnings_as_errors] diff --git a/lib/chef/resource/inspec_input.rb b/lib/chef/resource/inspec_input.rb new file mode 100644 index 0000000000..8eac12d92a --- /dev/null +++ b/lib/chef/resource/inspec_input.rb @@ -0,0 +1,128 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# +# 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_relative "../resource" + +class Chef + class Resource + class InspecInput < Chef::Resource + provides :inspec_input + unified_mode true + + description "Use the **inspec_input** resource to add an input to the Compliance Phase." + introduced "17.5" + examples <<~DOC + + **Activate the default input in the openssh cookbook's compliance segment**: + + ```ruby + inspec_input 'openssh' do + action :add + end + ``` + + **Activate all inputs in the openssh cookbook's compliance segment**: + + ```ruby + inspec_input 'openssh::.*' do + action :add + end + ``` + + **Add an InSpec input to the Compliance Phase from a hash**: + + ```ruby + inspec_input { ssh_custom_path: '/whatever2' } + ``` + + **Add an InSpec input to the Compliance Phase using the 'name' property to identify the input**: + + ```ruby + inspec_input "setting my input" do + source( { ssh_custom_path: '/whatever2' }) + end + ``` + + **Add an InSpec input to the Compliance Phase using a TOML, JSON or YAML file**: + + ```ruby + inspec_input "/path/to/my/input.yml" + ``` + + **Add an InSpec input to the Compliance Phase using a TOML, JSON or YAML file, using the 'name' property**: + + ```ruby + inspec_input "setting my input" do + source "/path/to/my/input.yml" + end + ``` + + Note that the inspec_input resource does not update and will not fire notifications (similar to the log resource). This is done to preserve the ability to use + the resource while not causing the updated resource count to be larger than zero. Since the resource does not update the state of the node being managed this + behavior is still consistent with the configuration management model. Events should be used to observe configuration changes for the compliance phase. It is + possible to use the `notify_group` resource to chain notifications of the two resources, but notifications are the wrong model to use and pure ruby conditionals + should be used instead. Compliance configuration should be independent of other resources and should only be made conditional based on state/attributes not + on other resources. + DOC + + property :name, [ Hash, String ] + + property :input, [ Hash, String ], + name_property: true + + property :source, [ Hash, String ], + name_property: true + + action :add, description: "Add an input to the compliance phase" do + if run_context.input_collection.valid?(new_resource.input) + include_input(new_resource.input) + else + include_input(input_hash) + end + end + + action_class do + # If the source is nil and the input / name_property contains a file separator and is a string of a + # file that exists, then use that as the file (similar to the package provider automatic source property). Otherwise + # just return the source. + # + # @api private + def source + @source ||= build_source + end + + def build_source + return new_resource.source unless new_resource.source.nil? + return nil unless new_resource.input.count(::File::SEPARATOR) > 0 || (::File::ALT_SEPARATOR && new_resource.input.count(::File::ALT_SEPARATOR) > 0 ) + return nil unless ::File.exist?(new_resource.input) + + new_resource.input + end + + def input_hash + case source + when Hash + source + when String + parse_file(source) + when nil + raise Chef::Exceptions::ValidationFailed, "Could not find the input #{new_resource.input} in any cookbook segment." + end + end + end + end + end +end diff --git a/lib/chef/resource/inspec_waiver.rb b/lib/chef/resource/inspec_waiver.rb new file mode 100644 index 0000000000..1643375755 --- /dev/null +++ b/lib/chef/resource/inspec_waiver.rb @@ -0,0 +1,185 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# +# 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_relative "../resource" + +class Chef + class Resource + class InspecWaiver < Chef::Resource + provides :inspec_waiver + unified_mode true + + description "Use the **inspec_waiver** resource to add a waiver to the Compliance Phase." + introduced "17.5" + examples <<~DOC + **Activate the default waiver in the openssh cookbook's compliance segment**: + + ```ruby + inspec_waiver 'openssh' do + action :add + end + ``` + + **Activate all waivers in the openssh cookbook's compliance segment**: + + ```ruby + inspec_waiver 'openssh::.*' do + action :add + end + ``` + + **Add an InSpec waiver to the Compliance Phase**: + + ```ruby + inspec_waiver 'Add waiver entry for control' do + control 'my_inspec_control_01' + run_test false + justification "The subject of this control is not managed by #{ChefUtils::Dist::Infra::PRODUCT} on the systems in policy group \#{node['policy_group']}" + expiration '2022-01-01' + action :add + end + ``` + + **Add an InSpec waiver to the Compliance Phase using the 'name' property to identify the control**: + + ```ruby + inspec_waiver 'my_inspec_control_01' do + justification "The subject of this control is not managed by #{ChefUtils::Dist::Infra::PRODUCT} on the systems in policy group \#{node['policy_group']}" + action :add + end + ``` + + **Add an InSpec waiver to the Compliance Phase using an arbitrary YAML, JSON or TOML file**: + + ```ruby + # files ending in .yml or .yaml that exist are parsed as YAML + inspec_waiver "/path/to/my/waiver.yml" + + inspec_waiver "my-waiver-name" do + source "/path/to/my/waiver.yml" + end + + # files ending in .json that exist are parsed as JSON + inspec_waiver "/path/to/my/waiver.json" + + inspec_waiver "my-waiver-name" do + source "/path/to/my/waiver.json" + end + + # files ending in .toml that exist are parsed as TOML + inspec_waiver "/path/to/my/waiver.toml" + + inspec_waiver "my-waiver-name" do + source "/path/to/my/waiver.toml" + end + ``` + + **Add an InSpec waiver to the Compliance Phase using a hash**: + + ```ruby + my_hash = { "ssh-01" => { + "expiration_date" => "2033-07-31", + "run" => false, + "justification" => "because" + } } + + inspec_waiver "my-waiver-name" do + source my_hash + end + ``` + + Note that the inspec_waiver resource does not update and will not fire notifications (similar to the log resource). This is done to preserve the ability to use + the resource while not causing the updated resource count to be larger than zero. Since the resource does not update the state of the node being managed this + behavior is still consistent with the configuration management model. Events should be used to observe configuration changes for the compliance phase. It is + possible to use the `notify_group` resource to chain notifications of the two resources, but notifications are the wrong model to use and pure ruby conditionals + should be used instead. Compliance configuration should be independent of other resources and should only be made conditional based on state/attributes not + on other resources. + DOC + + property :control, String, + name_property: true, + description: "The name of the control being waived" + + property :expiration, String, + description: "The expiration date of the waiver - provided in YYYY-MM-DD format", + callbacks: { + "Expiration date should be a valid calendar date and match the following format: YYYY-MM-DD" => proc { |e| + re = Regexp.new('\d{4}-\d{2}-\d{2}$').freeze + if re.match?(e) + Date.valid_date?(*e.split("-").map(&:to_i)) + else + e.nil? + end + }, + } + + property :run_test, [true, false], + description: "If present and true, the control will run and be reported, but failures in it won’t make the overall run fail. If absent or false, the control will not be run." + + property :justification, String, + description: "Can be any text you want and might include a reason for the waiver as well as who signed off on the waiver." + + property :source, [ Hash, String ] + + action :add, description: "Add a waiver to the compliance phase" do + if run_context.waiver_collection.valid?(new_resource.control) + include_waiver(new_resource.control) + else + include_waiver(waiver_hash) + end + end + + action_class do + # If the source is nil and the control / name_property contains a file separator and is a string of a + # file that exists, then use that as the file (similar to the package provider automatic source property). Otherwise + # just return the source. + # + # @api private + def source + @source ||= build_source + end + + def build_source + return new_resource.source unless new_resource.source.nil? + return nil unless new_resource.control.count(::File::SEPARATOR) > 0 || (::File::ALT_SEPARATOR && new_resource.control.count(::File::ALT_SEPARATOR) > 0 ) + return nil unless ::File.exist?(new_resource.control) + + new_resource.control + end + + def waiver_hash + case source + when Hash + source + when String + parse_file(source) + when nil + if new_resource.justification.nil? || new_resource.justification == "" + raise Chef::Exceptions::ValidationFailed, "Entries for an InSpec waiver must have a justification given, this parameter must have a value." + end + + control_hash = {} + control_hash["expiration_date"] = new_resource.expiration.to_s unless new_resource.expiration.nil? + control_hash["run"] = new_resource.run_test unless new_resource.run_test.nil? + control_hash["justification"] = new_resource.justification.to_s + + { new_resource.control => control_hash } + end + end + end + end + end +end diff --git a/lib/chef/resources.rb b/lib/chef/resources.rb index 6b30a58aff..ac5ec5d8e0 100644 --- a/lib/chef/resources.rb +++ b/lib/chef/resources.rb @@ -73,6 +73,8 @@ require_relative "resource/homebrew_package" require_relative "resource/homebrew_tap" require_relative "resource/homebrew_update" require_relative "resource/ifconfig" +require_relative "resource/inspec_input" +require_relative "resource/inspec_waiver" require_relative "resource/inspec_waiver_file_entry" require_relative "resource/kernel_module" require_relative "resource/ksh" diff --git a/lib/chef/run_context.rb b/lib/chef/run_context.rb index 75c18f2fcf..94f8a316e0 100644 --- a/lib/chef/run_context.rb +++ b/lib/chef/run_context.rb @@ -25,6 +25,9 @@ require_relative "log" require_relative "recipe" require_relative "run_context/cookbook_compiler" require_relative "event_dispatch/events_output_stream" +require_relative "compliance/input_collection" +require_relative "compliance/waiver_collection" +require_relative "compliance/profile_collection" require_relative "train_transport" require_relative "exceptions" require "forwardable" unless defined?(Forwardable) @@ -120,10 +123,28 @@ class Chef # Handle to the global action_collection of executed actions for reporting / data_collector /etc # - # @return [Chef::ActionCollection + # @return [Chef::ActionCollection] # attr_accessor :action_collection + # Handle to the global profile_collection of inspec profiles for the compliance phase + # + # @return [Chef::Compliance::ProfileCollection] + # + attr_accessor :profile_collection + + # Handle to the global waiver_collection of inspec waiver files for the compliance phase + # + # @return [Chef::Compliance::WaiverCollection] + # + attr_accessor :waiver_collection + + # Handle to the global input_collection of inspec input files for the compliance phase + # + # @return [Chef::Compliance::inputCollection] + # + attr_accessor :input_collection + # Pointer back to the Chef::Runner that created this # attr_accessor :runner @@ -198,6 +219,9 @@ class Chef @loaded_attributes_hash = {} @reboot_info = {} @cookbook_compiler = nil + @input_collection = Chef::Compliance::InputCollection.new(events) + @waiver_collection = Chef::Compliance::WaiverCollection.new(events) + @profile_collection = Chef::Compliance::ProfileCollection.new(events) initialize_child_state end @@ -674,6 +698,8 @@ class Chef events= has_cookbook_file_in_cookbook? has_template_in_cookbook? + input_collection + input_collection= load loaded_attribute loaded_attributes @@ -688,6 +714,8 @@ class Chef node node= open_stream + profile_collection + profile_collection= reboot_info reboot_info= reboot_requested? @@ -700,6 +728,8 @@ class Chef transport transport_connection unreachable_cookbook? + waiver_collection + waiver_collection= } def initialize(parent_run_context) diff --git a/lib/chef/run_context/cookbook_compiler.rb b/lib/chef/run_context/cookbook_compiler.rb index 27461fea9a..57c5fb680d 100644 --- a/lib/chef/run_context/cookbook_compiler.rb +++ b/lib/chef/run_context/cookbook_compiler.rb @@ -32,6 +32,7 @@ class Chef attr_reader :events attr_reader :run_list_expansion attr_reader :logger + attr_reader :run_context def initialize(run_context, run_list_expansion, events) @run_context = run_context @@ -43,23 +44,51 @@ class Chef # Chef::Node object for the current run. def node - @run_context.node + run_context.node end # Chef::CookbookCollection object for the current run def cookbook_collection - @run_context.cookbook_collection + run_context.cookbook_collection end # Resource Definitions from the compiled cookbooks. This is populated by # calling #compile_resource_definitions (which is called by #compile) def definitions - @run_context.definitions + run_context.definitions + end + + # The global waiver_collection hanging off of the run_context, used by + # compile_compliance and the compliance phase that runs inspec + # + # @returns [Chef::Compliance::WaiverCollection] + # + def waiver_collection + run_context.waiver_collection + end + + # The global input_collection hanging off of the run_context, used by + # compile_compliance and the compliance phase that runs inspec + # + # @returns [Chef::Compliance::inputCollection] + # + def input_collection + run_context.input_collection + end + + # The global profile_collection hanging off of the run_context, used by + # compile_compliance and the compliance phase that runs inspec + # + # @returns [Chef::Compliance::ProfileCollection] + # + def profile_collection + run_context.profile_collection end # Run the compile phase of the chef run. Loads files in the following order: # * Libraries # * Ohai + # * Compliance Profiles/Waivers # * Attributes # * LWRPs # * Resource Definitions @@ -73,6 +102,7 @@ class Chef def compile compile_libraries compile_ohai_plugins + compile_compliance compile_attributes compile_lwrps compile_resource_definitions @@ -98,7 +128,7 @@ class Chef # Loads library files from cookbooks according to #cookbook_order. def compile_libraries - @events.library_load_start(count_files_by_segment(:libraries)) + events.library_load_start(count_files_by_segment(:libraries)) cookbook_order.each do |cookbook| eager_load_libraries = cookbook_collection[cookbook].metadata.eager_load_libraries if eager_load_libraries == true # actually true, not truthy @@ -110,14 +140,14 @@ class Chef end end end - @events.library_load_complete + events.library_load_complete end # Loads Ohai Plugins from cookbooks, and ensure any old ones are # properly cleaned out def compile_ohai_plugins ohai_plugin_count = count_files_by_segment(:ohai) - @events.ohai_plugin_load_start(ohai_plugin_count) + events.ohai_plugin_load_start(ohai_plugin_count) FileUtils.rm_rf(Chef::Config[:ohai_segment_plugin_path]) cookbook_order.each do |cookbook| @@ -131,57 +161,81 @@ class Chef node.consume_ohai_data(ohai) end - @events.ohai_plugin_load_complete + events.ohai_plugin_load_complete + end + + # Loads the compliance segment files from the cookbook into the collections + # hanging off of the run_context, for later use in the compliance phase + # inspec run. + # + def compile_compliance + events.compliance_load_start + events.profiles_load_start + cookbook_order.each do |cookbook| + load_profiles_from_cookbook(cookbook) + end + events.profiles_load_complete + events.inputs_load_start + cookbook_order.each do |cookbook| + load_inputs_from_cookbook(cookbook) + end + events.inputs_load_complete + events.waivers_load_start + cookbook_order.each do |cookbook| + load_waivers_from_cookbook(cookbook) + end + events.waivers_load_complete + events.compliance_load_complete end # Loads attributes files from cookbooks. Attributes files are loaded # according to #cookbook_order; within a cookbook, +default.rb+ is loaded # first, then the remaining attributes files in lexical sort order. def compile_attributes - @events.attribute_load_start(count_files_by_segment(:attributes, "attributes.rb")) + events.attribute_load_start(count_files_by_segment(:attributes, "attributes.rb")) cookbook_order.each do |cookbook| load_attributes_from_cookbook(cookbook) end - @events.attribute_load_complete + events.attribute_load_complete end # Loads LWRPs according to #cookbook_order. Providers are loaded before # resources on a cookbook-wise basis. def compile_lwrps lwrp_file_count = count_files_by_segment(:providers) + count_files_by_segment(:resources) - @events.lwrp_load_start(lwrp_file_count) + events.lwrp_load_start(lwrp_file_count) cookbook_order.each do |cookbook| load_lwrps_from_cookbook(cookbook) end - @events.lwrp_load_complete + events.lwrp_load_complete end # Loads resource definitions according to #cookbook_order def compile_resource_definitions - @events.definition_load_start(count_files_by_segment(:definitions)) + events.definition_load_start(count_files_by_segment(:definitions)) cookbook_order.each do |cookbook| load_resource_definitions_from_cookbook(cookbook) end - @events.definition_load_complete + events.definition_load_complete end # Iterates over the expanded run_list, loading each recipe in turn. def compile_recipes - @events.recipe_load_start(run_list_expansion.recipes.size) + events.recipe_load_start(run_list_expansion.recipes.size) run_list_expansion.recipes.each do |recipe| path = resolve_recipe(recipe) - @run_context.load_recipe(recipe) - @events.recipe_file_loaded(path, recipe) + run_context.load_recipe(recipe) + events.recipe_file_loaded(path, recipe) rescue Chef::Exceptions::RecipeNotFound => e - @events.recipe_not_found(e) + events.recipe_not_found(e) raise rescue Exception => e - @events.recipe_file_load_failed(path, e, recipe) + events.recipe_file_load_failed(path, e, recipe) raise end - @events.recipe_load_complete + events.recipe_load_complete end # Whether or not a cookbook is reachable from the set of cookbook given @@ -225,7 +279,7 @@ class Chef attr_file_basename = ::File.basename(filename, ".rb") node.include_attribute("#{cookbook_name}::#{attr_file_basename}") rescue Exception => e - @events.attribute_file_load_failed(filename, e) + events.attribute_file_load_failed(filename, e) raise end @@ -234,9 +288,9 @@ class Chef logger.trace("Loading cookbook #{cookbook_name}'s library file: #{filename}") Kernel.require(filename) - @events.library_file_loaded(filename) + events.library_file_loaded(filename) rescue Exception => e - @events.library_file_load_failed(filename, e) + events.library_file_load_failed(filename, e) raise end @@ -260,18 +314,18 @@ class Chef def load_lwrp_provider(cookbook_name, filename) logger.trace("Loading cookbook #{cookbook_name}'s providers from #{filename}") Chef::Provider::LWRPBase.build_from_file(cookbook_name, filename, self) - @events.lwrp_file_loaded(filename) + events.lwrp_file_loaded(filename) rescue Exception => e - @events.lwrp_file_load_failed(filename, e) + events.lwrp_file_load_failed(filename, e) raise end def load_lwrp_resource(cookbook_name, filename) logger.trace("Loading cookbook #{cookbook_name}'s resources from #{filename}") Chef::Resource::LWRPBase.build_from_file(cookbook_name, filename, self) - @events.lwrp_file_loaded(filename) + events.lwrp_file_loaded(filename) rescue Exception => e - @events.lwrp_file_load_failed(filename, e) + events.lwrp_file_load_failed(filename, e) raise end @@ -288,6 +342,36 @@ class Chef end end + # Load the compliance segment files from a single cookbook + # + def load_profiles_from_cookbook(cookbook_name) + # This identifies profiles by their inspec.yml file, we recurse into subdirs so the profiles may be deeply + # nested in a subdir structure for organization. You could have profiles inside of profiles but + # since that is not coherently defined, you should not. + # + each_file_in_cookbook_by_segment(cookbook_name, :compliance, [ "profiles/**/inspec.{yml,yaml}" ]) do |filename| + profile_collection.from_file(filename, cookbook_name) + end + end + + def load_waivers_from_cookbook(cookbook_name) + # This identifies waiver files as any yaml files under the waivers subdir. We recurse into subdirs as well + # so that waivers may be nested in subdirs for organization. Any other files are ignored. + # + each_file_in_cookbook_by_segment(cookbook_name, :compliance, [ "waivers/**/*.{yml,yaml}" ]) do |filename| + waiver_collection.from_file(filename, cookbook_name) + end + end + + def load_inputs_from_cookbook(cookbook_name) + # This identifies input files as any yaml files under the inputs subdir. We recurse into subdirs as well + # so that inputs may be nested in subdirs for organization. Any other files are ignored. + # + each_file_in_cookbook_by_segment(cookbook_name, :compliance, [ "inputs/**/*.{yml,yaml}" ]) do |filename| + input_collection.from_file(filename, cookbook_name) + end + end + def load_resource_definitions_from_cookbook(cookbook_name) files_in_cookbook_by_segment(cookbook_name, :definitions).each do |filename| next unless File.extname(filename) == ".rb" @@ -300,9 +384,9 @@ class Chef logger.info("Overriding duplicate definition #{key}, new definition found in #{filename}") newval end - @events.definition_file_loaded(filename) + events.definition_file_loaded(filename) rescue Exception => e - @events.definition_file_load_failed(filename, e) + events.definition_file_load_failed(filename, e) raise end end diff --git a/spec/integration/compliance/compliance_spec.rb b/spec/integration/compliance/compliance_spec.rb index 553e947ee3..7524f7d56d 100644 --- a/spec/integration/compliance/compliance_spec.rb +++ b/spec/integration/compliance/compliance_spec.rb @@ -80,4 +80,64 @@ describe "chef-client with compliance phase" do expect(result["status"]).to eq("passed") end end + + when_the_repository "has a compliance segment" do + let(:report_file) { path_to("report_file.json") } + + before do + directory "cookbooks/x" do + directory "compliance" 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 + end + file "attributes/default.rb", <<~FILE + default['audit']['reporter'] = "json-file" + default['audit']['json_file'] = { + "location" => "#{report_file}" + } + FILE + file "recipes/default.rb", <<~FILE + include_profile ".*::.*" + FILE + end + file "config/client.rb", <<~EOM + local_mode true + cookbook_path "#{path_to("cookbooks")}" + log_level :warn + EOM + end + + it "should complete with success" do + result = shell_out!("#{chef_client} -c \"#{path_to("config/client.rb")}\" -r 'recipe[x]'", 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/compliance/input_spec.rb b/spec/unit/compliance/input_spec.rb new file mode 100644 index 0000000000..a65adfb284 --- /dev/null +++ b/spec/unit/compliance/input_spec.rb @@ -0,0 +1,104 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "spec_helper" +require "tempfile" + +describe Chef::Compliance::Input do + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:data) { { "ssh-01" => { "expiration_date" => Date.jd(2463810), "justification" => "waived, yo", "run" => false } } } + let(:path) { "/var/chef/cache/cookbooks/acme_compliance/compliance/inputs/default.yml" } + let(:cookbook_name) { "acme_compliance" } + let(:input) { Chef::Compliance::Input.new(events, data, path, cookbook_name) } + + it "has a cookbook_name" do + expect(input.cookbook_name).to eql(cookbook_name) + end + + it "has a path" do + expect(input.path).to eql(path) + end + + it "has a pathname based on the path" do + expect(input.pathname).to eql("default") + end + + it "is disabled" do + expect(input.enabled).to eql(false) + expect(input.enabled?).to eql(false) + end + + it "has an event handler" do + expect(input.events).to eql(events) + end + + it "can be enabled by enable!" do + input.enable! + expect(input.enabled).to eql(true) + expect(input.enabled?).to eql(true) + end + + it "enabling sends an event" do + expect(events).to receive(:compliance_input_enabled).with(input) + input.enable! + end + + it "can be disabled by disable!" do + input.enable! + input.disable! + expect(input.enabled).to eql(false) + expect(input.enabled?).to eql(false) + end + + it "has a #inspec_data method that renders the data" do + expect(input.inspec_data).to eql(data) + end + + it "doesn't render the events in the inspect output" do + expect(input.inspect).not_to include("events") + end + + it "inflates objects from YAML" do + string = <<~EOH +ssh-01: + expiration_date: 2033-07-31 + run: false + justification: "waived, yo" + EOH + newinput = Chef::Compliance::Input.from_yaml(events, string, path, cookbook_name) + expect(newinput.data).to eql(data) + end + + it "inflates objects from files" do + string = <<~EOH +ssh-01: + expiration_date: 2033-07-31 + run: false + justification: "waived, yo" + EOH + tempfile = Tempfile.new("chef-compliance-test") + tempfile.write string + tempfile.close + newinput = Chef::Compliance::Input.from_file(events, tempfile.path, cookbook_name) + expect(newinput.data).to eql(data) + end + + it "inflates objects from hashes" do + newinput = Chef::Compliance::Input.from_hash(events, data, path, cookbook_name) + expect(newinput.data).to eql(data) + end +end diff --git a/spec/unit/compliance/profile_spec.rb b/spec/unit/compliance/profile_spec.rb new file mode 100644 index 0000000000..8487b26691 --- /dev/null +++ b/spec/unit/compliance/profile_spec.rb @@ -0,0 +1,120 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "spec_helper" +require "tempfile" + +describe Chef::Compliance::Profile do + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:data) { { "copyright" => "DevSec Hardening Framework Team", "copyright_email" => "hello@dev-sec.io", "license" => "Apache-2.0", "maintainer" => "DevSec Hardening Framework Team", "name" => "ssh-baseline", "summary" => "Test-suite for best-practice SSH hardening", "supports" => [{ "os-family" => "unix" }], "title" => "DevSec SSH Baseline", "version" => "2.6.4" } } + let(:path) { "/var/chef/cache/cookbooks/acme_compliance/compliance/profiles/thisdirectoryisnotthename/inspec.yml" } + let(:cookbook_name) { "acme_compliance" } + let(:profile) { Chef::Compliance::Profile.new(events, data, path, cookbook_name) } + + it "has a cookbook_name" do + expect(profile.cookbook_name).to eql(cookbook_name) + end + + it "has a path" do + expect(profile.path).to eql(path) + end + + it "has a name based on the yml" do + expect(profile.name).to eql("ssh-baseline") + end + + it "has a pathname based on the path" do + expect(profile.pathname).to eql("thisdirectoryisnotthename") + end + + it "is disabled" do + expect(profile.enabled).to eql(false) + expect(profile.enabled?).to eql(false) + end + + it "has an event handler" do + expect(profile.events).to eql(events) + end + + it "can be enabled by enable!" do + profile.enable! + expect(profile.enabled).to eql(true) + expect(profile.enabled?).to eql(true) + end + + it "enabling sends an event" do + expect(events).to receive(:compliance_profile_enabled).with(profile) + profile.enable! + end + + it "can be disabled by disable!" do + profile.enable! + profile.disable! + expect(profile.enabled).to eql(false) + expect(profile.enabled?).to eql(false) + end + + it "has a #inspec_data method that renders the path" do + expect(profile.inspec_data).to eql( { name: "ssh-baseline", path: "/var/chef/cache/cookbooks/acme_compliance/compliance/profiles/thisdirectoryisnotthename" } ) + end + + it "doesn't render the events in the inspect output" do + expect(profile.inspect).not_to include("events") + end + + it "inflates objects from YAML" do + string = <<~EOH +name: ssh-baseline#{" "} +title: DevSec SSH Baseline#{" "} +maintainer: DevSec Hardening Framework Team#{" "} +copyright: DevSec Hardening Framework Team#{" "} +copyright_email: hello@dev-sec.io#{" "} +license: Apache-2.0#{" "} +summary: Test-suite for best-practice SSH hardening#{" "} +version: 2.6.4#{" "} +supports:#{" "} + - os-family: unix + EOH + newprofile = Chef::Compliance::Profile.from_yaml(events, string, path, cookbook_name) + expect(newprofile.data).to eql(data) + end + + it "inflates objects from files" do + string = <<~EOH +name: ssh-baseline#{" "} +title: DevSec SSH Baseline#{" "} +maintainer: DevSec Hardening Framework Team#{" "} +copyright: DevSec Hardening Framework Team#{" "} +copyright_email: hello@dev-sec.io#{" "} +license: Apache-2.0#{" "} +summary: Test-suite for best-practice SSH hardening#{" "} +version: 2.6.4#{" "} +supports:#{" "} + - os-family: unix + EOH + tempfile = Tempfile.new("chef-compliance-test") + tempfile.write string + tempfile.close + newprofile = Chef::Compliance::Profile.from_file(events, tempfile.path, cookbook_name) + expect(newprofile.data).to eql(data) + end + + it "inflates objects from hashes" do + newprofile = Chef::Compliance::Profile.from_hash(events, data, path, cookbook_name) + expect(newprofile.data).to eql(data) + end +end diff --git a/spec/unit/compliance/waiver_spec.rb b/spec/unit/compliance/waiver_spec.rb new file mode 100644 index 0000000000..e001e3a97d --- /dev/null +++ b/spec/unit/compliance/waiver_spec.rb @@ -0,0 +1,104 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "spec_helper" +require "tempfile" + +describe Chef::Compliance::Waiver do + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:data) { { "ssh-01" => { "expiration_date" => Date.jd(2463810), "justification" => "waived, yo", "run" => false } } } + let(:path) { "/var/chef/cache/cookbooks/acme_compliance/compliance/waivers/default.yml" } + let(:cookbook_name) { "acme_compliance" } + let(:waiver) { Chef::Compliance::Waiver.new(events, data, path, cookbook_name) } + + it "has a cookbook_name" do + expect(waiver.cookbook_name).to eql(cookbook_name) + end + + it "has a path" do + expect(waiver.path).to eql(path) + end + + it "has a pathname based on the path" do + expect(waiver.pathname).to eql("default") + end + + it "is disabled" do + expect(waiver.enabled).to eql(false) + expect(waiver.enabled?).to eql(false) + end + + it "has an event handler" do + expect(waiver.events).to eql(events) + end + + it "can be enabled by enable!" do + waiver.enable! + expect(waiver.enabled).to eql(true) + expect(waiver.enabled?).to eql(true) + end + + it "enabling sends an event" do + expect(events).to receive(:compliance_waiver_enabled).with(waiver) + waiver.enable! + end + + it "can be disabled by disable!" do + waiver.enable! + waiver.disable! + expect(waiver.enabled).to eql(false) + expect(waiver.enabled?).to eql(false) + end + + it "has a #inspec_data method that renders the data" do + expect(waiver.inspec_data).to eql(data) + end + + it "doesn't render the events in the inspect output" do + expect(waiver.inspect).not_to include("events") + end + + it "inflates objects from YAML" do + string = <<~EOH +ssh-01: + expiration_date: 2033-07-31 + run: false + justification: "waived, yo" + EOH + newwaiver = Chef::Compliance::Waiver.from_yaml(events, string, path, cookbook_name) + expect(newwaiver.data).to eql(data) + end + + it "inflates objects from files" do + string = <<~EOH +ssh-01: + expiration_date: 2033-07-31 + run: false + justification: "waived, yo" + EOH + tempfile = Tempfile.new("chef-compliance-test") + tempfile.write string + tempfile.close + newwaiver = Chef::Compliance::Waiver.from_file(events, tempfile.path, cookbook_name) + expect(newwaiver.data).to eql(data) + end + + it "inflates objects from hashes" do + newwaiver = Chef::Compliance::Waiver.from_hash(events, data, path, cookbook_name) + expect(newwaiver.data).to eql(data) + end +end diff --git a/spec/unit/resource/inspec_input_spec.rb b/spec/unit/resource/inspec_input_spec.rb new file mode 100644 index 0000000000..4acdfd60a9 --- /dev/null +++ b/spec/unit/resource/inspec_input_spec.rb @@ -0,0 +1,300 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "spec_helper" + +describe Chef::Resource::InspecInput do + def load_input(filename) + path = "/var/chef/cache/cookbooks/acme_compliance/compliance/inputs/#{filename}" + run_context.input_collection << Chef::Compliance::Input.from_yaml(events, input_yaml, path, "acme_compliance") + end + + let(:node) { Chef::Node.new } + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:run_context) do + Chef::RunContext.new(node, {}, events).tap do |rc| + end + end + let(:collection) { double("resource collection") } + let(:input_yaml) do + <<~EOH +ssh_custom_path: "/whatever2" + EOH + end + let(:input_json) do + <<~EOH + { "ssh_custom_path": "/whatever2" } + EOH + end + let(:input_toml) do + <<~EOH +ssh_custom_path = "/whatever2" + EOH + end + let(:input_hash) do + { ssh_custom_path: "/whatever2" } + end + let(:resource) do + Chef::Resource::InspecInput.new("ssh-01", run_context) + end + let(:provider) { resource.provider_for_action(:add) } + + before do + allow(run_context).to receive(:resource_collection).and_return(collection) + end + + it "sets the default action as :add" do + expect(resource.action).to eql([:add]) + end + + context "with a input in a cookbook" do + it "enables the input by the name of the cookbook" do + load_input("default.yml") + resource.name "acme_compliance" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "enables the input with a regular expression for the cookbook" do + load_input("default.yml") + resource.name "acme_comp.*" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "enables the input with an explicit name" do + load_input("default.yml") + resource.name "acme_compliance::default" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails when the cookbook name is wrong" do + load_input("default.yml") + resource.name "evil_compliance" + expect { resource.run_action(:add) }.to raise_error(StandardError) + expect(resource).not_to be_updated_by_last_action + end + + it "enables the input when its not named default" do + load_input("ssh01.yml") + resource.name "acme_compliance::ssh01" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails when it is not named default and you attempt to enable the default" do + load_input("ssh01.yml") + resource.name "acme_compliance" + expect { resource.run_action(:add) }.to raise_error(StandardError) + expect(resource).not_to be_updated_by_last_action + end + + it "succeeds with a regexp that matches the cookbook name" do + load_input("ssh01.yml") + resource.name "acme_comp.*::ssh01" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "succeeds with a regexp that matches the file name" do + load_input("ssh01.yml") + resource.name "acme_compliance::ssh.*" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "succeeds with a regexps for both the file name and cookbook name" do + load_input("ssh01.yml") + resource.name "acme_comp.*::ssh.*" + resource.run_action(:add) + expect(run_context.input_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails with regexps that do not match" do + load_input("ssh01.yml") + resource.name "evil_comp.*::etcd.*" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "substring matches without regexps should fail when they are at the end" do + load_input("ssh01.yml") + resource.name "acme_complianc::ssh0" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "substring matches without regexps should fail when they are at the start" do + load_input("ssh01.yml") + resource.name "cme_compliance::sh01" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + end + + context "with a input in a file" do + it "loads a YAML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".yaml"]) + tempfile.write input_yaml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YAML file in a source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".yaml"]) + tempfile.write input_yaml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".yml"]) + tempfile.write input_yaml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YML file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".yml"]) + tempfile.write input_yaml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a JSON file" do + tempfile = Tempfile.new(["spec-compliance-test", ".json"]) + tempfile.write input_json + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a JSON file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".json"]) + tempfile.write input_json + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a TOML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".toml"]) + tempfile.write input_toml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a TOML file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".toml"]) + tempfile.write input_toml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a Hash" do + resource.source input_hash + + resource.run_action(:add) + + expect(run_context.input_collection.first).to be_enabled + expect(run_context.input_collection.size).to be 1 + expect(run_context.input_collection.first.cookbook_name).to be nil + expect(run_context.input_collection.first.path).to be nil + expect(run_context.input_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + end +end diff --git a/spec/unit/resource/inspec_waiver_spec.rb b/spec/unit/resource/inspec_waiver_spec.rb new file mode 100644 index 0000000000..3154bcc9fa --- /dev/null +++ b/spec/unit/resource/inspec_waiver_spec.rb @@ -0,0 +1,312 @@ +# +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# 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 "spec_helper" + +describe Chef::Resource::InspecWaiver do + def load_waiver(filename) + path = "/var/chef/cache/cookbooks/acme_compliance/compliance/waivers/#{filename}" + run_context.waiver_collection << Chef::Compliance::Waiver.from_yaml(events, waiver_yaml, path, "acme_compliance") + end + + let(:node) { Chef::Node.new } + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:run_context) do + Chef::RunContext.new(node, {}, events).tap do |rc| + end + end + let(:collection) { double("resource collection") } + let(:waiver_yaml) do + <<~EOH +ssh-01: + expiration_date: 2033-07-31 + run: false + justification: "waived, yo" + EOH + end + let(:waiver_json) do + <<~EOH +{ "ssh-01": { + "expiration_date": "2033-07-31", + "run": false, + "justification": "waived, yo" + } } + EOH + end + let(:waiver_toml) do + <<~EOH +[ssh-01] +expiration_date = 2033-07-31T00:00:00.000Z +run = false +justification = "waived, yo" + EOH + end + let(:waiver_hash) do + { "ssh-01" => { + "expiration_date" => "2033-07-31", + "run" => false, + "justification" => "waived, yo", + } } + end + let(:resource) do + Chef::Resource::InspecWaiver.new("ssh-01", run_context) + end + let(:provider) { resource.provider_for_action(:add) } + + before do + allow(run_context).to receive(:resource_collection).and_return(collection) + end + + it "sets the default action as :add" do + expect(resource.action).to eql([:add]) + end + + context "with a waiver in a cookbook" do + it "enables the waiver by the name of the cookbook" do + load_waiver("default.yml") + resource.name "acme_compliance" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "enables the waiver with a regular expression for the cookbook" do + load_waiver("default.yml") + resource.name "acme_comp.*" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "enables the waiver with an explicit name" do + load_waiver("default.yml") + resource.name "acme_compliance::default" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails when the cookbook name is wrong" do + load_waiver("default.yml") + resource.name "evil_compliance" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "enables the waiver when its not named default" do + load_waiver("ssh01.yml") + resource.name "acme_compliance::ssh01" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails when it is not named default and you attempt to enable the default" do + load_waiver("ssh01.yml") + resource.name "acme_compliance" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "succeeds with a regexp that matches the cookbook name" do + load_waiver("ssh01.yml") + resource.name "acme_comp.*::ssh01" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "succeeds with a regexp that matches the file name" do + load_waiver("ssh01.yml") + resource.name "acme_compliance::ssh.*" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "succeeds with a regexps for both the file name and cookbook name" do + load_waiver("ssh01.yml") + resource.name "acme_comp.*::ssh.*" + resource.run_action(:add) + expect(run_context.waiver_collection.first).to be_enabled + expect(resource).not_to be_updated_by_last_action + end + + it "fails with regexps that do not match" do + load_waiver("ssh01.yml") + resource.name "evil_comp.*::etcd.*" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "substring matches without regexps should fail when they are at the end" do + load_waiver("ssh01.yml") + resource.name "acme_complianc::ssh0" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + + it "substring matches without regexps should fail when they are at the start" do + load_waiver("ssh01.yml") + resource.name "cme_compliance::sh01" + expect { resource.run_action(:add) }.to raise_error(StandardError) + end + end + + context "with a waiver in a file" do + it "loads a YAML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".yaml"]) + tempfile.write waiver_yaml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YAML file in a source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".yaml"]) + tempfile.write waiver_yaml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".yml"]) + tempfile.write waiver_yaml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a YML file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".yml"]) + tempfile.write waiver_yaml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a JSON file" do + tempfile = Tempfile.new(["spec-compliance-test", ".json"]) + tempfile.write waiver_json + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a JSON file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".json"]) + tempfile.write waiver_json + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a TOML file" do + tempfile = Tempfile.new(["spec-compliance-test", ".toml"]) + tempfile.write waiver_toml + tempfile.close + resource.name tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a TOML file using the source attribute" do + tempfile = Tempfile.new(["spec-compliance-test", ".toml"]) + tempfile.write waiver_toml + tempfile.close + resource.name "my-resource-name" + resource.source tempfile.path + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + + it "loads a Hash" do + resource.source waiver_hash + + resource.run_action(:add) + + expect(run_context.waiver_collection.first).to be_enabled + expect(run_context.waiver_collection.size).to be 1 + expect(run_context.waiver_collection.first.cookbook_name).to be nil + expect(run_context.waiver_collection.first.path).to be nil + expect(run_context.waiver_collection.first.pathname).to be nil + expect(resource).not_to be_updated_by_last_action + end + end +end |