summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLamont Granquist <lamont@scriptkiddie.org>2021-09-17 18:29:09 -0700
committerLamont Granquist <lamont@scriptkiddie.org>2021-09-17 18:31:33 -0700
commitbc5469bfe0e84b3c8195f6265686738ee5059d71 (patch)
tree19fdae32342c2db2ce906d3a1d12cf3f8ff488ee
parent021107a8bfe057b8c4cff9c25c02187f55dbe60b (diff)
downloadchef-bc5469bfe0e84b3c8195f6265686738ee5059d71.tar.gz
Native compliance phase
compliance/{profiles,waivers,inputs} directories supported in cookbooks. include_profile, include_waiver, include_input as DSL methods to dynamically add compliance phase items to the run. inspec_waiver, inspect_input resources for additional sugar and a resource-like API. Signed-off-by: Lamont Granquist <lamont@scriptkiddie.org>
-rw-r--r--kitchen-tests/cookbooks/end_to_end/attributes/default.rb3
-rw-r--r--lib/chef/client.rb3
-rw-r--r--lib/chef/compliance/input.rb115
-rw-r--r--lib/chef/compliance/input_collection.rb135
-rw-r--r--lib/chef/compliance/profile.rb122
-rw-r--r--lib/chef/compliance/profile_collection.rb109
-rw-r--r--lib/chef/compliance/runner.rb52
-rw-r--r--lib/chef/compliance/waiver.rb115
-rw-r--r--lib/chef/compliance/waiver_collection.rb135
-rw-r--r--lib/chef/dsl/compliance.rb38
-rw-r--r--lib/chef/dsl/reader_helpers.rb51
-rw-r--r--lib/chef/dsl/recipe.rb6
-rw-r--r--lib/chef/dsl/universal.rb2
-rw-r--r--lib/chef/event_dispatch/base.rb46
-rw-r--r--lib/chef/formatters/doc.rb46
-rw-r--r--lib/chef/resource/inspec_input.rb128
-rw-r--r--lib/chef/resource/inspec_waiver.rb185
-rw-r--r--lib/chef/resources.rb2
-rw-r--r--lib/chef/run_context.rb32
-rw-r--r--lib/chef/run_context/cookbook_compiler.rb92
-rw-r--r--spec/integration/compliance/compliance_spec.rb60
-rw-r--r--spec/unit/compliance/input_spec.rb104
-rw-r--r--spec/unit/compliance/profile_spec.rb120
-rw-r--r--spec/unit/compliance/waiver_spec.rb104
-rw-r--r--spec/unit/resource/inspec_input_spec.rb300
-rw-r--r--spec/unit/resource/inspec_waiver_spec.rb312
26 files changed, 2401 insertions, 16 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..bf0176dfe6
--- /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 for_inspec
+ 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..66cb78c643
--- /dev/null
+++ b/lib/chef/compliance/input_collection.rb
@@ -0,0 +1,135 @@
+# 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 for_inspec
+ select(&:enabled?).each_with_object({}) { |input, hash| hash.merge(input.for_inspec) }
+ 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.*"
+ #
+ 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..f818f244df
--- /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 for_inspec
+ { 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..302bae07ec
--- /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 for_inspec
+ select(&:enabled?).each_with_object([]) { |profile, arry| arry << profile.for_inspec }
+ 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..0d22133daf 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&.for_inspec || {}
+ end
+
+ def waivers_from_collection
+ safe_waiver_collection&.for_inspec || {}
+ 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&.for_inspec || []
+
+ 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..d01d4fc7d6
--- /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 for_inspec
+ 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..0fed87fd93
--- /dev/null
+++ b/lib/chef/compliance/waiver_collection.rb
@@ -0,0 +1,135 @@
+# 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 for_inspec
+ select(&:enabled?).each_with_object({}) { |waiver, hash| hash.merge(waiver.for_inspec) }
+ 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.*"
+ #
+ 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..b31323b684 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
@@ -134,6 +164,30 @@ class Chef
@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.
@@ -171,7 +225,7 @@ class Chef
run_list_expansion.recipes.each do |recipe|
path = resolve_recipe(recipe)
- @run_context.load_recipe(recipe)
+ run_context.load_recipe(recipe)
@events.recipe_file_loaded(path, recipe)
rescue Chef::Exceptions::RecipeNotFound => e
@events.recipe_not_found(e)
@@ -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"
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..1826a7501c
--- /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 #for_inspec method that renders the path" do
+ expect(input.for_inspec).to eql(path)
+ 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..53b7168279
--- /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 #for_inspec method that renders the path" do
+ expect(profile.for_inspec).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..b079f7e231
--- /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 #for_inspec method that renders the path" do
+ expect(waiver.for_inspec).to eql(path)
+ 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