diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 98ce378..dbfbc8e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -43,10 +43,12 @@ USER vscode RUN git clone https://github.com/magicmonty/bash-git-prompt.git /home/vscode/.bash-git-prompt --depth 1 \ && cat >> /home/vscode/.bashrc < 13.3.0' group :tests do - gem 'rspec', '~> 3.12' - gem 'rubocop', '~> 1.81.0' - gem 'rubocop-performance', '~> 1.26.0' - gem 'rubocop-rake', '~> 0.7.0' - gem 'rubocop-rspec', '~> 3.9.0' + gem 'openvox', ENV.fetch('OPENVOX_VERSION', ENV.fetch('PUPPET_VERSION', '~> 8.0')) + gem 'syslog', require: false + gem 'voxpupuli-test', '~> 13.0' end group :development do diff --git a/README.md b/README.md index b1931f1..f79dcb7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,34 @@ Options: See the [`ComplianceEngine::Data`](https://rubydoc.info/gems/compliance_engine/ComplianceEngine/Data) class for details. +## Using as a Puppet Module + +The Compliance Engine can be used as a Puppet module to provide a Hiera backend for compliance data. This allows you to enforce compliance profiles through Hiera lookups within your Puppet manifests. + +### Hiera Backend + +To use the Compliance Engine Hiera backend, configure it in your `hiera.yaml`: + +```yaml +--- +version: 5 +hierarchy: + - name: "Compliance Engine" + lookup_key: compliance_engine::enforcement +``` + +Specify the profile used by setting the `compliance_engine::enforcement` key in your Hiera data. + +```yaml +--- +compliance_engine::enforcement: + - your_profile +``` + +The `compliance_engine::enforcement` function serves as the Hiera entry point and allows you to look up compliance data based on configured profiles. + +For detailed information about available functions, parameters, and configuration options, see [REFERENCE.md](REFERENCE.md). + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/simp/rubygem-simp-compliance_engine. diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..27c3aaa --- /dev/null +++ b/REFERENCE.md @@ -0,0 +1,42 @@ +# Reference + + + +## Table of Contents + +### Functions + +* [`compliance_engine::enforcement`](#compliance_engine--enforcement): Hiera entry point for Compliance Engine + +## Functions + +### `compliance_engine::enforcement` + +Type: Ruby 4.x API + +Hiera entry point for Compliance Engine + +#### `compliance_engine::enforcement(String[1] $key, Hash[String[1], Any] $options, Puppet::LookupContext $context)` + +The compliance_engine::enforcement function. + +Returns: `String` The value of the key in the Hiera data + +##### `key` + +Data type: `String[1]` + +String The key to lookup in the Hiera data + +##### `options` + +Data type: `Hash[String[1], Any]` + + + +##### `context` + +Data type: `Puppet::LookupContext` + + + diff --git a/Rakefile b/Rakefile index 20d8a80..a8c69e8 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,26 @@ # frozen_string_literal: true require 'bundler/gem_tasks' -require 'rspec/core/rake_task' +require 'voxpupuli/test/rake' -RSpec::Core::RakeTask.new(:spec) +# Override the default spec task from voxpupuli-test to exclude spec/data and spec/fixtures +Rake::Task[:spec].clear +Rake::Task['spec:standalone'].clear -require 'rubocop/rake_task' +RSpec::Core::RakeTask.new('spec:standalone') do |t| + t.pattern = 'spec/{classes,functions}/**/*_spec.rb' +end -RuboCop::RakeTask.new +desc 'Prepare fixtures for testing' +task spec_prep: :'fixtures:prep' + +desc 'Clean up fixtures after testing' +task spec_clean: :'fixtures:clean' + +desc 'Run spec tests and clean the fixtures directory if successful' +task spec: :'fixtures:prep' do |_t, args| + Rake::Task['spec:standalone'].invoke(*args.extras) + Rake::Task['fixtures:clean'].invoke +end task default: [:spec, :rubocop] diff --git a/compliance_engine.gemspec b/compliance_engine.gemspec index edaa79e..e4f27c6 100644 --- a/compliance_engine.gemspec +++ b/compliance_engine.gemspec @@ -19,14 +19,14 @@ Gem::Specification.new do |spec| spec.metadata['bug_tracker_uri'] = 'https://github.com/simp/rubygem-simp-compliance_engine/issues' # Specify which files should be added to the gem when it is released. - spec.files = Dir.glob(['*.gemspec', '*.md', 'LICENSE', 'exe/*', 'lib/**/*.rb']) + spec.files = Dir.glob(['*.gemspec', '*.md', 'LICENSE', 'exe/*', 'lib/**/*.rb']).reject { |f| f.start_with?('lib/puppet/') } spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] spec.add_dependency 'deep_merge', '~> 1.2' spec.add_dependency 'irb', '~> 1.14' - spec.add_dependency 'logger', '>= 1.4', '< 2.0' + spec.add_dependency 'logger', '~> 1.4' spec.add_dependency 'observer', '~> 0.1' spec.add_dependency 'rubyzip', '>= 2.3', '< 4' spec.add_dependency 'semantic_puppet', '~> 1.1' diff --git a/lib/compliance_engine.rb b/lib/compliance_engine.rb index d7fd01d..7738992 100644 --- a/lib/compliance_engine.rb +++ b/lib/compliance_engine.rb @@ -30,7 +30,7 @@ def self.new(*paths) def self.log return @log unless @log.nil? - @log = Logger.new(STDERR) + @log = Logger.new($stderr) @log.level = Logger::WARN @log end diff --git a/lib/compliance_engine/component.rb b/lib/compliance_engine/component.rb index d925659..126fa67 100644 --- a/lib/compliance_engine/component.rb +++ b/lib/compliance_engine/component.rb @@ -134,7 +134,7 @@ def fact_match?(fact, confine, depth = 0) fact == confine elsif confine.is_a?(Array) - if depth == 0 + if depth.zero? confine.any? { |value| fact_match?(fact, value, depth + 1) } else fact == confine @@ -155,12 +155,13 @@ def confine_away?(fragment) if k == 'module_name' unless environment_data.nil? return true unless environment_data.key?(v) + module_version = fragment['confine']['module_version'] unless module_version.nil? require 'semantic_puppet' begin return true unless SemanticPuppet::VersionRange.parse(module_version).include?(SemanticPuppet::Version.parse(environment_data[v])) - rescue => e + rescue StandardError => e ComplianceEngine.log.error "Failed to compare #{v} #{environment_data[v]} with version confinement #{module_version}: #{e.message}" return true end @@ -172,12 +173,8 @@ def confine_away?(fragment) # Confinement based on Puppet facts unless facts.nil? fact = facts.dig(*k.split('.')) - if fact.nil? - return true - end - unless fact_match?(fact, v) - return true - end + return true if fact.nil? + return true unless fact_match?(fact, v) end end end @@ -185,6 +182,34 @@ def confine_away?(fragment) false end + # Check if a fragment is has remediation risk too high or if remediation is disabled + # + # @param fragment [Hash] The fragment to check + # @return [TrueClass, FalseClass] true if the fragment should be dropped + def risk_too_high?(fragment) + return false unless is_a?(ComplianceEngine::Check) + return false unless fragment.key?('remediation') + return false unless enforcement_tolerance.is_a?(Integer) && enforcement_tolerance.positive? + + if fragment['remediation'].key?('disabled') + message = "Remediation disabled for #{fragment}" + reason = fragment['remediation']['disabled']&.map { |value| value['reason'] }&.reject(&:nil?)&.join("\n") + message += "\n#{reason}" unless reason.nil? + ComplianceEngine.log.info message + return true + end + + if fragment['remediation'].key?('risk') + risk_level = fragment['remediation']['risk']&.map { |value| value['level'] }&.select { |value| value.is_a?(Integer) }&.max + if risk_level.is_a?(Integer) && risk_level >= enforcement_tolerance + ComplianceEngine.log.info "Remediation risk #{risk_level} exceeds enforcement enforcement_tolerance #{enforcement_tolerance} for #{fragment}" + return true + end + end + + false + end + # Returns the fragments of the component after confinement # # @return [Hash] the fragments of the component @@ -208,25 +233,7 @@ def fragments end next if confine_away?(fragment) - - # Confinement based on remediation risk - if enforcement_tolerance.is_a?(Integer) && is_a?(ComplianceEngine::Check) && fragment.key?('remediation') - if fragment['remediation'].key?('disabled') - message = "Remediation disabled for #{fragment}" - reason = fragment['remediation']['disabled']&.map { |value| value['reason'] }&.reject { |value| value.nil? }&.join("\n") - message += "\n#{reason}" unless reason.nil? - ComplianceEngine.log.info message - next - end - - if fragment['remediation'].key?('risk') - risk_level = fragment['remediation']['risk']&.map { |value| value['level'] }&.select { |value| value.is_a?(Integer) }&.max - if risk_level.is_a?(Integer) && risk_level >= enforcement_tolerance - ComplianceEngine.log.info "Remediation risk #{risk_level} exceeds enforcement tolerance #{enforcement_tolerance} for #{fragment}" - next - end - end - end + next if risk_too_high?(fragment) @fragments[filename] = fragment end diff --git a/lib/compliance_engine/data.rb b/lib/compliance_engine/data.rb index 5733d0d..f0eb1be 100644 --- a/lib/compliance_engine/data.rb +++ b/lib/compliance_engine/data.rb @@ -28,7 +28,7 @@ class ComplianceEngine::Data # @param facts [Hash] The facts to use while evaluating the data # @param enforcement_tolerance [Integer] The tolerance to use while evaluating the data def initialize(*paths, facts: nil, enforcement_tolerance: nil) - @data ||= {} + @data = {} @facts = facts @enforcement_tolerance = enforcement_tolerance open(*paths) unless paths.nil? || paths.empty? @@ -201,7 +201,7 @@ def update( end reset_collection - rescue => e + rescue StandardError => e ComplianceEngine.log.error e.message end @@ -210,6 +210,7 @@ def update( # @return [Array] def files return @files unless @files.nil? + @files = data.select { |_, file| file.key?(:content) }.keys end @@ -219,7 +220,7 @@ def files # @return [Hash] def get(file) data[file][:content] - rescue + rescue StandardError nil end @@ -263,6 +264,7 @@ def confines collection.each_value do |v| v.to_a.each do |component| next unless component.key?('confine') + @confines = DeepMerge.deep_merge!(component['confine'], @confines) end end @@ -404,9 +406,7 @@ def mapping?(check, profile_or_ce) # @return [TrueClass, FalseClass] def correlate(a, b) return false if a.nil? || b.nil? - unless a.is_a?(Array) && b.is_a?(Hash) - raise ComplianceEngine::Error, "Expected array and hash, got #{a.class} and #{b.class}" - end + raise ComplianceEngine::Error, "Expected array and hash, got #{a.class} and #{b.class}" unless a.is_a?(Array) && b.is_a?(Hash) return false if a.empty? || b.empty? a.any? { |item| b[item] } diff --git a/lib/compliance_engine/data_loader.rb b/lib/compliance_engine/data_loader.rb index c7f81b8..034fe29 100644 --- a/lib/compliance_engine/data_loader.rb +++ b/lib/compliance_engine/data_loader.rb @@ -25,6 +25,7 @@ def initialize(value = {}, key: nil) # @raise [ComplianceEngine::Error] If the value is not a Hash def data=(value) raise ComplianceEngine::Error, 'Data must be a hash' unless value.is_a?(Hash) + @data = value changed notify_observers(self) diff --git a/lib/compliance_engine/environment_loader.rb b/lib/compliance_engine/environment_loader.rb index 36c8c64..f82e51b 100644 --- a/lib/compliance_engine/environment_loader.rb +++ b/lib/compliance_engine/environment_loader.rb @@ -13,6 +13,7 @@ class ComplianceEngine::EnvironmentLoader # @param zipfile_path [String, nil] the path to the zip file if loading from a zip archive def initialize(*paths, fileclass: File, dirclass: Dir, zipfile_path: nil) raise ArgumentError, 'No paths specified' if paths.empty? + @modulepath ||= paths @zipfile_path = zipfile_path modules = paths.map do |path| @@ -21,7 +22,7 @@ def initialize(*paths, fileclass: File, dirclass: Dir, zipfile_path: nil) .select { |child| fileclass.directory?(File.join(path, child)) } .map { |child| File.join(path, child) } .sort - rescue + rescue StandardError [] end modules.flatten! diff --git a/lib/compliance_engine/module_loader.rb b/lib/compliance_engine/module_loader.rb index cf78a21..74adc69 100644 --- a/lib/compliance_engine/module_loader.rb +++ b/lib/compliance_engine/module_loader.rb @@ -27,7 +27,7 @@ def initialize(path, fileclass: File, dirclass: Dir, zipfile_path: nil) metadata = ComplianceEngine::DataLoader::Json.new(metadata_json, fileclass: fileclass) @name = metadata.data['name'] @version = metadata.data['version'] - rescue => e + rescue StandardError => e ComplianceEngine.log.warn "Could not parse #{metadata_json}: #{e.message}" end end @@ -53,7 +53,7 @@ def initialize(path, fileclass: File, dirclass: Dir, zipfile_path: nil) ComplianceEngine::DataLoader::Yaml.new(file.to_s, fileclass: fileclass, key: key) end @files << loader - rescue => e + rescue StandardError => e ComplianceEngine.log.warn "Could not load #{file}: #{e.message}" end end diff --git a/lib/compliance_engine/version.rb b/lib/compliance_engine/version.rb index f5f030a..b5cd864 100644 --- a/lib/compliance_engine/version.rb +++ b/lib/compliance_engine/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ComplianceEngine - VERSION = '0.1.6' + VERSION = '0.2.0' # Handle supported compliance data versions class Version @@ -11,6 +11,7 @@ class Version def initialize(version) raise 'Missing version' if version.nil? raise "Unsupported version '#{version}'" unless version == '2.0.0' + @version = version end diff --git a/lib/puppet/functions/compliance_engine/enforcement.rb b/lib/puppet/functions/compliance_engine/enforcement.rb new file mode 100644 index 0000000..505cf72 --- /dev/null +++ b/lib/puppet/functions/compliance_engine/enforcement.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# @summary Hiera entry point for Compliance Engine +Puppet::Functions.create_function(:'compliance_engine::enforcement') do + # @param key String The key to lookup in the Hiera data + # @return [String] The value of the key in the Hiera data + dispatch :enforcement do + param 'String[1]', :key + param 'Hash[String[1], Any]', :options + param 'Puppet::LookupContext', :context + end + + require 'compliance_engine' + + def enforcement(key, options, context) + ComplianceEngine.log.level = Logger::DEBUG + + @compat = options['compliance_markup_compatibility'] + + case key + when 'lookup_options' + return context.not_found + when %r{^compliance_(?:engine|markup)::} + return context.not_found + else + return context.interpolate(context.cached_value(key)) if context.cache_has_key(key) + end + + # If we have no profiles to work with, we can't do anything. + return context.not_found if profiles.empty? + + if context.cache_has_key(:compliance_engine) + ComplianceEngine.log.debug('Using cached ComplianceEngine::Data object') + data = context.cached_value(:compliance_engine) + else + data = ComplianceEngine::Data.new + data.facts = closure_scope.lookupvar('facts') + data.enforcement_tolerance = enforcement_tolerance || options['enforcement_tolerance'] + data.open(ComplianceEngine::EnvironmentLoader.new(*closure_scope.environment.full_modulepath.select { |path| File.directory?(path) })) + + data.open(ComplianceEngine::DataLoader.new(compliance_map)) unless compliance_map.empty? + context.cache(:compliance_engine, data) + end + + context.cache_all(data.hiera(profiles)) + + return context.interpolate(context.cached_value(key)) if context.cache_has_key(key) + + # if data.hiera(profiles).key?(key) + # context.cache(key, data.hiera(profiles)[key]) + # return context.interpolate(data.hiera(profiles)[key]) + # end + + context.not_found + rescue StandardError => e + # Log any exceptions that occur + ComplianceEngine.log.error("Error in compliance_engine::enforcement: #{e.message}") + ComplianceEngine.log.error(e.backtrace.join("\n")) + raise + end + + def profiles + profile_list = Array(call_function('lookup', 'compliance_engine::enforcement', { 'default_value' => [] })) + + # For backwards compatibility with compliance_markup. + profile_list += Array(call_function('lookup', 'compliance_markup::enforcement', { 'default_value' => [] })) if @compat + + profile_list.uniq + end + + def compliance_map + hiera_compliance_map = call_function('lookup', 'compliance_engine::compliance_map', { 'default_value' => {} }) + + # For backwards compatibility with compliance_markup. + hiera_compliance_map = DeepMerge.deep_merge!(call_function('lookup', 'compliance_markup::compliance_map', { 'default_value' => {} }), hiera_compliance_map) if @compat + + hiera_compliance_map + end + + def enforcement_tolerance + tolerance = call_function('lookup', 'compliance_engine::enforcement_tolerance', { 'default_value' => nil }) + + # For backwards compatibility with compliance_markup. + tolerance = call_function('lookup', 'compliance_markup::enforcement_tolerance_level', { 'default_value' => nil }) if @compat && tolerance.nil? + + tolerance = tolerance.to_i if tolerance.is_a?(String) + + tolerance + end +end diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..562e362 --- /dev/null +++ b/metadata.json @@ -0,0 +1,61 @@ +{ + "name": "simp-compliance_engine", + "version": "0.2.0", + "author": "Sicura", + "summary": "Hiera backend for Sicura Compliance Engine data", + "license": "Apache-2.0", + "source": "https://github.com/simp/rubygem-simp-compliance_engine", + "dependencies": [ + ], + "operatingsystem_support": [ + { + "operatingsystem": "AlmaLinux", + "operatingsystemrelease": [ + "8", + "9", + "10" + ] + }, + { + "operatingsystem": "CentOS", + "operatingsystemrelease": [ + "9", + "10" + ] + }, + { + "operatingsystem": "OracleLinux", + "operatingsystemrelease": [ + "8", + "9", + "10" + ] + }, + { + "operatingsystem": "RedHat", + "operatingsystemrelease": [ + "8", + "9", + "10" + ] + }, + { + "operatingsystem": "Rocky", + "operatingsystemrelease": [ + "8", + "9", + "10" + ] + } + ], + "requirements": [ + { + "name": "puppet", + "version_requirement": ">= 8.0.0 < 9.0.0" + }, + { + "name": "openvox", + "version_requirement": ">= 8.0.0 < 9.0.0" + } + ] +} diff --git a/spec/classes/compliance_engine/check_spec.rb b/spec/classes/compliance_engine/check_spec.rb index 330e91d..807de88 100644 --- a/spec/classes/compliance_engine/check_spec.rb +++ b/spec/classes/compliance_engine/check_spec.rb @@ -19,9 +19,9 @@ 'file2' => { 'merge_key' => ['value2'], 'confine' => { 'kernel' => ['windows'] } }, 'file3' => { 'merge_key' => ['value3'], 'confine' => { 'module_name' => 'author-module' } }, 'file4' => { 'merge_key' => ['value4'], 'confine' => { 'module_name' => 'author-module', 'module_version' => '>= 1.0.0 < 2.0.0' } }, - 'file5' => { 'merge_key' => ['value5'], 'remediation' => { 'disabled' => [ { 'reason' => 'anything' } ], 'risk' => [ { 'level' => 1 } ] } }, - 'file6' => { 'merge_key' => ['value6'], 'remediation' => { 'risk' => [ { 'level' => 21 } ] } }, - 'file7' => { 'merge_key' => ['value7'], 'remediation' => { 'risk' => [ { 'level' => 41 } ] } }, + 'file5' => { 'merge_key' => ['value5'], 'remediation' => { 'disabled' => [{ 'reason' => 'anything' }], 'risk' => [{ 'level' => 1 }] } }, + 'file6' => { 'merge_key' => ['value6'], 'remediation' => { 'risk' => [{ 'level' => 21 }] } }, + 'file7' => { 'merge_key' => ['value7'], 'remediation' => { 'risk' => [{ 'level' => 41 }] } }, } end diff --git a/spec/classes/compliance_engine/data_loader_spec.rb b/spec/classes/compliance_engine/data_loader_spec.rb index f967bd3..e407edf 100644 --- a/spec/classes/compliance_engine/data_loader_spec.rb +++ b/spec/classes/compliance_engine/data_loader_spec.rb @@ -19,7 +19,7 @@ shared_examples 'an observable' do it 'updates the data' do - expect { data_loader.data = updated_data }.to change { data_loader.data }.from(initial_data).to(updated_data) + expect { data_loader.data = updated_data }.to change(data_loader, :data).from(initial_data).to(updated_data) end it 'notifies observers' do diff --git a/spec/classes/compliance_engine/data_spec.rb b/spec/classes/compliance_engine/data_spec.rb index 1f296d8..7537015 100644 --- a/spec/classes/compliance_engine/data_spec.rb +++ b/spec/classes/compliance_engine/data_spec.rb @@ -205,7 +205,7 @@ def test_data ce: ce_00: {} ce_01: {} - A_YAML + A_YAML 'b/file.yaml' => <<~B_YAML, --- version: '2.0.0' @@ -215,7 +215,7 @@ def test_data ce_02: true ce: ce_02: {} - B_YAML + B_YAML }, 'test_module_01' => { 'c/file.yaml' => <<~C_YAML, @@ -227,7 +227,7 @@ def test_data ce_03: true ce: ce_03: {} - C_YAML + C_YAML }, } end @@ -319,7 +319,7 @@ def test_data value: true ces: - enable_widget_spinner_audit_logging - A_YAML + A_YAML }, } end @@ -449,7 +449,7 @@ def test_data value: ['no'] ces: - enable_widget_spinner_audit_logging.el7 - A_YAML + A_YAML 'b/file.yaml' => <<~B_YAML, --- version: 2.0.0 @@ -485,7 +485,7 @@ def test_data value: ['yes'] ces: - enable_widget_spinner_audit_logging.el8 - B_YAML + B_YAML 'c/file.yaml' => <<~C_YAML, --- version: 2.0.0 @@ -521,7 +521,7 @@ def test_data value: ['maybe'] ces: - enable_widget_spinner_audit_logging.el9 - C_YAML + C_YAML }, } end @@ -675,7 +675,7 @@ def test_data value: true ces: - enable_widget_spinner_audit_logging - A_YAML + A_YAML }, } end @@ -741,7 +741,7 @@ def test_data it 'returns checks for a profile' do checks = compliance_engine.check_mapping(compliance_engine.profiles['custom_profile_1']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['widget_spinner_audit_logging']) end @@ -749,7 +749,7 @@ def test_data it 'returns checks for a ce' do checks = compliance_engine.check_mapping(compliance_engine.ces['enable_widget_spinner_audit_logging']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['widget_spinner_audit_logging']) end @@ -774,7 +774,7 @@ def test_data value: true controls: nist_800_53:rev4:AU-2: true - A_YAML + A_YAML }, } end @@ -855,7 +855,7 @@ def test_data settings: parameter: test_module_00::test_param value: a string - A_YAML + A_YAML }, } end @@ -942,7 +942,7 @@ def test_data value: true controls: nist_800_53:rev4:AU-2: true - A_YAML + A_YAML 'b/file.yaml' => <<~B_YAML, --- version: 2.0.0 @@ -962,7 +962,7 @@ def test_data value: 'a string' ces: - 00_ce1 - B_YAML + B_YAML }, } end @@ -1040,7 +1040,7 @@ def test_data it 'returns checks for custom_profile_1' do checks = compliance_engine.check_mapping(compliance_engine.profiles['custom_profile_1']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['widget_spinner_audit_logging']) end @@ -1048,7 +1048,7 @@ def test_data it 'returns checks for 00_profile_test' do checks = compliance_engine.check_mapping(compliance_engine.profiles['00_profile_test']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['00_check1']) end @@ -1056,7 +1056,7 @@ def test_data it 'returns checks for enable_widget_spinner_audit_logging' do checks = compliance_engine.check_mapping(compliance_engine.ces['enable_widget_spinner_audit_logging']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['widget_spinner_audit_logging']) end @@ -1064,7 +1064,7 @@ def test_data it 'returns checks for 00_ce1' do checks = compliance_engine.check_mapping(compliance_engine.ces['00_ce1']) checks.each_value { |check| expect(check).to be_instance_of(ComplianceEngine::Check) } - keys = checks.values.map { |check| check.key } + keys = checks.values.map(&:key) expect(keys).to be_instance_of(Array) expect(keys).to eq(['00_check1']) end diff --git a/spec/classes/compliance_engine/module_loader_spec.rb b/spec/classes/compliance_engine/module_loader_spec.rb index bb40a5a..c0dbc26 100644 --- a/spec/classes/compliance_engine/module_loader_spec.rb +++ b/spec/classes/compliance_engine/module_loader_spec.rb @@ -71,7 +71,7 @@ ce: ce_00: {} ce_01: {} - A_YAML + A_YAML 'b/file.yaml' => <<~B_YAML, --- version: '2.0.0' @@ -81,7 +81,7 @@ ce_02: true ce: ce_02: {} - B_YAML + B_YAML 'c/file.yaml' => <<~C_YAML, --- version: '2.0.0' @@ -91,7 +91,7 @@ ce_03: true ce: ce_03: {} - C_YAML + C_YAML }, } end @@ -135,7 +135,7 @@ end it 'returns a list of file loader objects' do - expect(module_loader.files.map { |loader| loader.key }).to eq(test_data.map { |module_path, files| files.map { |name, _| "#{module_path}/SIMP/compliance_profiles/#{name}" } }.flatten) + expect(module_loader.files.map(&:key)).to eq(test_data.map { |module_path, files| files.map { |name, _| "#{module_path}/SIMP/compliance_profiles/#{name}" } }.flatten) end end @@ -161,7 +161,7 @@ end it 'returns a list of file loader objects' do - expect(module_loader.files.map { |loader| loader.key }).to eq(test_data.map { |module_path, files| files.map { |name, _| "#{module_path}/SIMP/compliance_profiles/#{name}" } }.flatten) + expect(module_loader.files.map(&:key)).to eq(test_data.map { |module_path, files| files.map { |name, _| "#{module_path}/SIMP/compliance_profiles/#{name}" } }.flatten) end end end diff --git a/spec/data/common.yaml b/spec/data/common.yaml new file mode 100644 index 0000000..b77128c --- /dev/null +++ b/spec/data/common.yaml @@ -0,0 +1,4 @@ +--- +compliance_engine::enforcement: + # - test_profile + - "%{facts.target_compliance_profile}" diff --git a/spec/data/compliance_engine-tolerance-100.yaml b/spec/data/compliance_engine-tolerance-100.yaml new file mode 100644 index 0000000..e21127c --- /dev/null +++ b/spec/data/compliance_engine-tolerance-100.yaml @@ -0,0 +1,6 @@ +--- +compliance_engine::enforcement: + - "%{facts.target_compliance_profile}" + +# Hardcoded integer value for tolerance - Hiera interpolation would produce a string +compliance_engine::enforcement_tolerance: 100 diff --git a/spec/data/compliance_engine-tolerance-25.yaml b/spec/data/compliance_engine-tolerance-25.yaml new file mode 100644 index 0000000..293ed00 --- /dev/null +++ b/spec/data/compliance_engine-tolerance-25.yaml @@ -0,0 +1,6 @@ +--- +compliance_engine::enforcement: + - "%{facts.target_compliance_profile}" + +# Hardcoded integer value for tolerance - Hiera interpolation would produce a string +compliance_engine::enforcement_tolerance: 25 diff --git a/spec/data/compliance_engine-tolerance-60.yaml b/spec/data/compliance_engine-tolerance-60.yaml new file mode 100644 index 0000000..4323da9 --- /dev/null +++ b/spec/data/compliance_engine-tolerance-60.yaml @@ -0,0 +1,6 @@ +--- +compliance_engine::enforcement: + - "%{facts.target_compliance_profile}" + +# Hardcoded integer value for tolerance - Hiera interpolation would produce a string +compliance_engine::enforcement_tolerance: 60 diff --git a/spec/data/compliance_engine-tolerance.yaml b/spec/data/compliance_engine-tolerance.yaml new file mode 100644 index 0000000..ed732a9 --- /dev/null +++ b/spec/data/compliance_engine-tolerance.yaml @@ -0,0 +1,9 @@ +--- +compliance_engine::enforcement: + - "%{facts.target_compliance_profile}" + +# Note: Hiera interpolation always produces strings, so for testing enforcement +# tolerance (which requires Integer), use the dedicated hieradata files: +# - compliance-engine-tolerance-25.yaml +# - compliance-engine-tolerance-60.yaml +# - compliance-engine-tolerance-100.yaml diff --git a/spec/data/compliance_engine.yaml b/spec/data/compliance_engine.yaml new file mode 100644 index 0000000..c2c562c --- /dev/null +++ b/spec/data/compliance_engine.yaml @@ -0,0 +1,19 @@ +--- +compliance_engine::validate_profiles: + - "%{facts.target_compliance_profile}" + +# Needed for catalog inspection to ensure valid data +compliance_engine::report_on_client: true +compliance_engine::report_on_server: false +compliance_engine::report_types: + - 'compliant' + - 'non_compliant' + - 'unknown_parameters' + - 'unknown_resources' + +# Ideally, this would be the same as the validation array but you may want to +# do something different based on your test requirements +compliance_engine::enforcement: + - "%{facts.target_compliance_profile}" + +compliance_engine::enforcement_tolerance: "%{facts.target_enforcement_tolerance}" \ No newline at end of file diff --git a/spec/data/escaped_knockout/SIMP/compliance_profiles/escaped_knockout.yaml b/spec/data/escaped_knockout/SIMP/compliance_profiles/escaped_knockout.yaml new file mode 100644 index 0000000..82580a0 --- /dev/null +++ b/spec/data/escaped_knockout/SIMP/compliance_profiles/escaped_knockout.yaml @@ -0,0 +1,31 @@ +--- +# Some very basic compliance checks designed for the tests +# +# These should all pass + +version: 2.0.0 + +compliance_markup::enforcement: + - test_profile + +compliance_markup::enforcement_tolerance_level: 40 + +profiles: + test_profile: + controls: + test_control: true + +controls: + test_control: {} + +checks: + oval:test4: + type: puppet-class-parameter + settings: + parameter: test4::list1 + value: + - '\\-- not_a_knockout' + controls: + test_control: true + identifiers: + - 'ESC_KNOCKOUT' diff --git a/spec/data/hiera.yaml b/spec/data/hiera.yaml new file mode 100644 index 0000000..c5fd199 --- /dev/null +++ b/spec/data/hiera.yaml @@ -0,0 +1,14 @@ +--- +version: 5 +hierarchy: +- name: SIMP Compliance Engine + lookup_key: compliance_markup::enforcement +- name: Custom Test Hiera + path: "%{custom_hiera}.yaml" +- name: "%{module_name}" + path: "%{module_name}.yaml" +- name: Common + path: default.yaml +defaults: + data_hash: yaml_data + datadir: "/home/steve/src/simp/pupmod-simp-compliance_markup/spec/fixtures/hieradata" diff --git a/spec/data/passing_checks/SIMP/compliance_profiles/passing_checks.yaml b/spec/data/passing_checks/SIMP/compliance_profiles/passing_checks.yaml new file mode 100644 index 0000000..59a3f8b --- /dev/null +++ b/spec/data/passing_checks/SIMP/compliance_profiles/passing_checks.yaml @@ -0,0 +1,95 @@ +--- +# Some very basic compliance checks designed for the tests +# +# These should all pass + +version: 2.0.0 + +profiles: + test_profile: + controls: + test_control: true + +controls: + test_control: {} + +checks: + oval:test1: + type: puppet-class-parameter + settings: + parameter: test1::arg1_1 + value: foo1_1 + controls: + test_control: true + identifiers: + - 'ID1.1' + + oval:test2: + type: puppet-class-parameter + settings: + parameter: test2::test3::arg3_1 + value: 're:foo3_.*' + controls: + test_control: true + identifiers: + - 'ID1.2' + + oval:test3: + type: puppet-class-parameter + settings: + parameter: test2::test3::ref_miss1 + value: missing1 + controls: + test_control: true + identifiers: + - 'MISS1' + + oval:test4: + type: puppet-class-parameter + settings: + parameter: testdef1::defarg1_1 + value: deffoo1_1 + controls: + test_control: true + identifiers: + - 'DEF1.1' + + oval:test5: + type: puppet-class-parameter + settings: + parameter: testdef2::defarg1_2 + value: deffoo1_2 + controls: + test_control: true + identifiers: + - 'DEF1.2' + + oval:test6: + type: puppet-class-parameter + settings: + parameter: unmapped1::arg1_1 + value: um1_1 + controls: + test_control: true + identifiers: + - 'UNK001' + + oval:test7: + type: puppet-class-parameter + settings: + parameter: unmapped1::arg1_2 + value: um1_2 + controls: + test_control: true + identifiers: + - 'UNK002' + + oval:test8: + type: puppet-class-parameter + settings: + parameter: unmapped1::subclass::arg1_2 + value: um1_3 + controls: + test_control: true + identifiers: + - 'UNK003' diff --git a/spec/data/test1_deviation/SIMP/compliance_profiles/test1_deviation.yaml b/spec/data/test1_deviation/SIMP/compliance_profiles/test1_deviation.yaml new file mode 100644 index 0000000..2558a67 --- /dev/null +++ b/spec/data/test1_deviation/SIMP/compliance_profiles/test1_deviation.yaml @@ -0,0 +1,45 @@ +--- +# Some very basic compliance checks designed for the tests +# +# These should all pass + +version: 2.0.0 + +profiles: + test_profile: + controls: + test_control: true + +controls: + test_control: {} + +checks: + oval:test1: + type: puppet-class-parameter + settings: + parameter: test1::arg1_1 + value: bar1_1 + controls: + test_control: true + identifiers: + - 'ID1.1' + + oval:test2: + type: puppet-class-parameter + settings: + parameter: test1::arg1_2 + value: foo1_2 + controls: + test_control: true + identifiers: + - 'ID1.2' + + oval:test3: + type: puppet-class-parameter + settings: + parameter: test2::test3::arg3_1 + value: foo3_1 + controls: + test_control: true + identifiers: + - 'ID1.2' diff --git a/spec/data/test2_3_deviation/SIMP/compliance_profiles/test2_3_deviation.yaml b/spec/data/test2_3_deviation/SIMP/compliance_profiles/test2_3_deviation.yaml new file mode 100644 index 0000000..71d5ae1 --- /dev/null +++ b/spec/data/test2_3_deviation/SIMP/compliance_profiles/test2_3_deviation.yaml @@ -0,0 +1,35 @@ +--- +# Some very basic compliance checks designed for the tests +# +# These should all pass + +version: 2.0.0 + +profiles: + test_profile: + controls: + test_control: true + +controls: + test_control: {} + +checks: + oval:test1: + type: puppet-class-parameter + settings: + parameter: test1::arg1_1 + value: foo1_1 + controls: + test_control: true + identifiers: + - 'ID1.1' + + oval:test2: + type: puppet-class-parameter + settings: + parameter: test2::test3::arg3_1 + value: bar3_1 + controls: + test_control: true + identifiers: + - 'ID1.2' diff --git a/spec/data/undefined_values/SIMP/compliance_profiles/undefined_values.yaml b/spec/data/undefined_values/SIMP/compliance_profiles/undefined_values.yaml new file mode 100644 index 0000000..31a8ef6 --- /dev/null +++ b/spec/data/undefined_values/SIMP/compliance_profiles/undefined_values.yaml @@ -0,0 +1,85 @@ +--- +# Some very basic compliance checks designed for the tests +# +# These should all pass + +version: 2.0.0 + +profiles: + test_profile: + controls: + test_control: true + +controls: + test_control: {} + +checks: + oval:test1: + type: puppet-class-parameter + settings: + parameter: test1::arg1_1 + value: null + controls: + test_control: true + identifiers : + - 'ID1.1' + + oval:test2: + type: puppet-class-parameter + settings: + parameter: test3::arg3_1 + value: 're:foo3_.*' + controls: + test_control: true + identifiers : + - 'ID1.2' + + oval:test3: + type: puppet-class-parameter + settings: + parameter: test3::ref_miss1 + value: missing1 + controls: + test_control: true + identifiers : + - 'MISS1' + + oval:test4: + type: puppet-class-parameter + settings: + parameter: testdef1::defarg1_1 + value: deffoo1_1 + controls: + test_control: true + identifiers : + - 'DEF1.1' + + oval:test5: + type: puppet-class-parameter + settings: + parameter: unmapped1::arg1_1 + value: um1_1 + controls: + test_control: true + identifiers : + - 'UNK001' + + oval:test6: + type: puppet-class-parameter + settings: + parameter: unmapped1::arg1_2 + value: um1_2 + controls: + test_control: true + identifiers : + - 'UNK002' + + oval:test7: + type: puppet-class-parameter + settings: + parameter: unmapped1::subclass::arg1_2 + value: um1_3 + controls: + test_control: true + identifiers : + - 'UNK003' diff --git a/spec/fixtures/hieradata/10_enforce_spec.yaml b/spec/fixtures/hieradata/10_enforce_spec.yaml new file mode 100644 index 0000000..bd004e3 --- /dev/null +++ b/spec/fixtures/hieradata/10_enforce_spec.yaml @@ -0,0 +1,32 @@ +--- +compliance_engine::enforcement: +- disa_stig +compliance_engine::compliance_map: + version: 2.0.0 + checks: + oval:com.puppet.test.disa.useradd_shells: + type: puppet-class-parameter + controls: + disa_stig: true + identifiers: + FOO2: + - FOO2 + BAR2: + - BAR2 + settings: + parameter: useradd::shells + value: + - "/bin/disa" + oval:com.puppet.test.nist.useradd_shells: + type: puppet-class-parameter + controls: + nist_800_53:rev4: true + identifiers: + FOO2: + - FOO2 + BAR2: + - BAR2 + settings: + parameter: useradd::shells + value: + - "/bin/nist" diff --git a/spec/fixtures/hieradata/profile-merging.yaml b/spec/fixtures/hieradata/profile-merging.yaml new file mode 100644 index 0000000..3615608 --- /dev/null +++ b/spec/fixtures/hieradata/profile-merging.yaml @@ -0,0 +1,4 @@ +--- +compliance_engine::enforcement: +- profile_test1 +- profile_test2 diff --git a/spec/functions/compliance_engine/enforcement_spec.rb b/spec/functions/compliance_engine/enforcement_spec.rb new file mode 100644 index 0000000..6d06f22 --- /dev/null +++ b/spec/functions/compliance_engine/enforcement_spec.rb @@ -0,0 +1,755 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +# Tests for the compliance_engine::enforcement Hiera backend. +# Since this is a lookup_key backend, we test it through the `lookup` function. +# +# These tests create temporary fixture files to test the enforcement backend +# without relying on complex mocking of the file system. +RSpec.describe 'lookup' do + # Create a temporary directory structure for test modules + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_enforcement_module') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + # Default test data - can be overridden in specific contexts + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'enforcement_spec_profile' => { + 'controls' => { + 'enforcement_spec_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'enforcement_spec_ce' => { + 'controls' => { + 'enforcement_spec_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'enforcement_spec_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'enforcement_spec::test_param', + 'value' => 'test_enforced_value', + }, + 'ces' => [ + 'enforcement_spec_ce', + ], + }, + 'enforcement_spec_check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'enforcement_spec::another_param', + 'value' => 42, + }, + 'ces' => [ + 'enforcement_spec_ce', + ], + }, + }, + } + end + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_data.to_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_data.to_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_data.to_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + context 'with compliance_engine::enforcement backend and no profile configured' do + let(:hieradata) { 'common' } + let(:facts) { {} } + + it 'returns not_found for any key when no profiles are set' do + is_expected.to run.with_params('enforcement_spec::test_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with compliance_engine::enforcement backend and a valid profile' do + let(:facts) do + { 'target_compliance_profile' => 'enforcement_spec_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns the enforced string value for a matching parameter' do + is_expected.to run.with_params('enforcement_spec::test_param').and_return('test_enforced_value') + end + + it 'returns the enforced integer value for a matching parameter' do + is_expected.to run.with_params('enforcement_spec::another_param').and_return(42) + end + + it 'returns not_found for keys not in compliance data' do + is_expected.to run.with_params('unknown::param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns not_found for lookup_options key' do + is_expected.to run.with_params('lookup_options') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns not_found for compliance_engine:: prefixed keys' do + is_expected.to run.with_params('compliance_engine::some_internal_key') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns not_found for compliance_markup:: prefixed keys' do + is_expected.to run.with_params('compliance_markup::some_internal_key') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with compliance_engine::enforcement backend and a non-existent profile' do + let(:facts) do + { 'target_compliance_profile' => 'nonexistent_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns not_found when the profile does not exist' do + is_expected.to run.with_params('enforcement_spec::test_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with multiple profiles configured' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'profile_one' => { + 'controls' => { + 'control_one' => true, + }, + }, + 'profile_two' => { + 'controls' => { + 'control_two' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'ce_one' => { + 'controls' => { + 'control_one' => true, + }, + }, + 'ce_two' => { + 'controls' => { + 'control_two' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'check_from_profile_one' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'multi_profile::param_one', + 'value' => 'value_from_profile_one', + }, + 'ces' => ['ce_one'], + }, + 'check_from_profile_two' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'multi_profile::param_two', + 'value' => 'value_from_profile_two', + }, + 'ces' => ['ce_two'], + }, + }, + } + end + + let(:facts) do + { 'target_compliance_profile' => 'profile_one' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns value from the first profile' do + is_expected.to run.with_params('multi_profile::param_one').and_return('value_from_profile_one') + end + + it 'returns not_found for value only in second profile when first profile is selected' do + is_expected.to run.with_params('multi_profile::param_two') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with fact-based confine on checks' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'confine_test_profile' => { + 'controls' => { + 'confine_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'confine_ce' => { + 'controls' => { + 'confine_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'redhat_only_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'confine_test::redhat_param', + 'value' => 'redhat_value', + }, + 'ces' => ['confine_ce'], + 'confine' => { + 'os.family' => 'RedHat', + }, + }, + 'debian_only_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'confine_test::debian_param', + 'value' => 'debian_value', + }, + 'ces' => ['confine_ce'], + 'confine' => { + 'os.family' => 'Debian', + }, + }, + 'any_os_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'confine_test::any_os_param', + 'value' => 'any_os_value', + }, + 'ces' => ['confine_ce'], + }, + }, + } + end + + let(:hieradata) { 'compliance-engine' } + + context 'on RedHat family' do + let(:facts) do + { + 'target_compliance_profile' => 'confine_test_profile', + 'os' => { 'family' => 'RedHat' }, + } + end + + it 'returns value for RedHat-confined check' do + is_expected.to run.with_params('confine_test::redhat_param').and_return('redhat_value') + end + + it 'returns not_found for Debian-confined check' do + is_expected.to run.with_params('confine_test::debian_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns value for non-confined check' do + is_expected.to run.with_params('confine_test::any_os_param').and_return('any_os_value') + end + end + + context 'on Debian family' do + let(:facts) do + { + 'target_compliance_profile' => 'confine_test_profile', + 'os' => { 'family' => 'Debian' }, + } + end + + it 'returns not_found for RedHat-confined check' do + is_expected.to run.with_params('confine_test::redhat_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns value for Debian-confined check' do + is_expected.to run.with_params('confine_test::debian_param').and_return('debian_value') + end + + it 'returns value for non-confined check' do + is_expected.to run.with_params('confine_test::any_os_param').and_return('any_os_value') + end + end + end + + context 'with disabled remediation on checks' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'remediation_test_profile' => { + 'controls' => { + 'remediation_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'remediation_ce' => { + 'controls' => { + 'remediation_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'enabled_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'remediation_test::enabled_param', + 'value' => 'enabled_value', + }, + 'ces' => ['remediation_ce'], + }, + 'disabled_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'remediation_test::disabled_param', + 'value' => 'disabled_value', + }, + 'ces' => ['remediation_ce'], + 'remediation' => { + 'disabled' => [ + { 'reason' => 'This check is disabled for testing.' }, + ], + }, + }, + }, + } + end + + let(:facts) do + { 'target_compliance_profile' => 'remediation_test_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns value for enabled check' do + is_expected.to run.with_params('remediation_test::enabled_param').and_return('enabled_value') + end + + # NOTE: Disabled checks are still returned by default - they are informational + # and filtering them requires explicit configuration + it 'still returns value for disabled check (disabled is informational only)' do + is_expected.to run.with_params('remediation_test::disabled_param').and_return('disabled_value') + end + end + + context 'with risk-level remediation and enforcement tolerance' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'tolerance_test_profile' => { + 'controls' => { + 'tolerance_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'tolerance_ce' => { + 'controls' => { + 'tolerance_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'no_risk_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'tolerance_test::no_risk_param', + 'value' => 'no_risk_value', + }, + 'ces' => ['tolerance_ce'], + }, + 'low_risk_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'tolerance_test::low_risk_param', + 'value' => 'low_risk_value', + }, + 'ces' => ['tolerance_ce'], + 'remediation' => { + 'risk' => [ + { 'level' => 20, 'reason' => 'Low risk check' }, + ], + }, + }, + 'medium_risk_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'tolerance_test::medium_risk_param', + 'value' => 'medium_risk_value', + }, + 'ces' => ['tolerance_ce'], + 'remediation' => { + 'risk' => [ + { 'level' => 50, 'reason' => 'Medium risk check' }, + ], + }, + }, + 'high_risk_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'tolerance_test::high_risk_param', + 'value' => 'high_risk_value', + }, + 'ces' => ['tolerance_ce'], + 'remediation' => { + 'risk' => [ + { 'level' => 80, 'reason' => 'High risk check' }, + ], + }, + }, + }, + } + end + + # NOTE: When no enforcement tolerance is set (nil), all risk levels are accepted + context 'with no enforcement tolerance set (default accepts all)' do + let(:facts) do + { 'target_compliance_profile' => 'tolerance_test_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns value for no-risk check' do + is_expected.to run.with_params('tolerance_test::no_risk_param').and_return('no_risk_value') + end + + it 'returns value for low-risk check (no filtering when tolerance is nil)' do + is_expected.to run.with_params('tolerance_test::low_risk_param').and_return('low_risk_value') + end + + it 'returns value for medium-risk check (no filtering when tolerance is nil)' do + is_expected.to run.with_params('tolerance_test::medium_risk_param').and_return('medium_risk_value') + end + + it 'returns value for high-risk check (no filtering when tolerance is nil)' do + is_expected.to run.with_params('tolerance_test::high_risk_param').and_return('high_risk_value') + end + end + + # Use dedicated hieradata file with hardcoded integer tolerance + # (Hiera interpolation from facts would produce a string, but the code requires Integer) + context 'with enforcement tolerance set to 25' do + let(:facts) do + { + 'target_compliance_profile' => 'tolerance_test_profile', + 'custom_hiera' => 'compliance_engine-tolerance-25', + } + end + let(:hieradata) { 'compliance_engine-tolerance-25' } + + it 'returns value for no-risk check' do + is_expected.to run.with_params('tolerance_test::no_risk_param').and_return('no_risk_value') + end + + it 'returns value for low-risk check (level 20 < tolerance 25)' do + is_expected.to run.with_params('tolerance_test::low_risk_param').and_return('low_risk_value') + end + + it 'returns not_found for medium-risk check (level 50 >= tolerance 25)' do + is_expected.to run.with_params('tolerance_test::medium_risk_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + + it 'returns not_found for high-risk check (level 80 >= tolerance 25)' do + is_expected.to run.with_params('tolerance_test::high_risk_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with enforcement tolerance set to 60' do + let(:facts) do + { + 'target_compliance_profile' => 'tolerance_test_profile', + 'custom_hiera' => 'compliance_engine-tolerance-60', + } + end + let(:hieradata) { 'compliance_engine-tolerance-60' } + + it 'returns value for no-risk check' do + is_expected.to run.with_params('tolerance_test::no_risk_param').and_return('no_risk_value') + end + + it 'returns value for low-risk check' do + is_expected.to run.with_params('tolerance_test::low_risk_param').and_return('low_risk_value') + end + + it 'returns value for medium-risk check (level 50 < tolerance 60)' do + is_expected.to run.with_params('tolerance_test::medium_risk_param').and_return('medium_risk_value') + end + + it 'returns not_found for high-risk check (level 80 >= tolerance 60)' do + is_expected.to run.with_params('tolerance_test::high_risk_param') + .and_raise_error(Puppet::DataBinding::LookupError, %r{did not find a value}) + end + end + + context 'with enforcement tolerance set to 100' do + let(:facts) do + { + 'target_compliance_profile' => 'tolerance_test_profile', + 'custom_hiera' => 'compliance_engine-tolerance-100', + } + end + let(:hieradata) { 'compliance_engine-tolerance-100' } + + it 'returns value for all checks including high-risk' do + is_expected.to run.with_params('tolerance_test::high_risk_param').and_return('high_risk_value') + end + end + end + + context 'with different value types' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'types_test_profile' => { + 'controls' => { + 'types_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'types_ce' => { + 'controls' => { + 'types_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'string_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::string_param', + 'value' => 'a string value', + }, + 'ces' => ['types_ce'], + }, + 'integer_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::integer_param', + 'value' => 42, + }, + 'ces' => ['types_ce'], + }, + 'boolean_true_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::bool_true_param', + 'value' => true, + }, + 'ces' => ['types_ce'], + }, + 'boolean_false_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::bool_false_param', + 'value' => false, + }, + 'ces' => ['types_ce'], + }, + 'array_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::array_param', + 'value' => ['item1', 'item2', 'item3'], + }, + 'ces' => ['types_ce'], + }, + 'hash_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'types_test::hash_param', + 'value' => { 'key1' => 'value1', 'key2' => 'value2' }, + }, + 'ces' => ['types_ce'], + }, + }, + } + end + + let(:facts) do + { 'target_compliance_profile' => 'types_test_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns string value' do + is_expected.to run.with_params('types_test::string_param').and_return('a string value') + end + + it 'returns integer value' do + is_expected.to run.with_params('types_test::integer_param').and_return(42) + end + + it 'returns boolean true value' do + is_expected.to run.with_params('types_test::bool_true_param').and_return(true) + end + + it 'returns boolean false value' do + is_expected.to run.with_params('types_test::bool_false_param').and_return(false) + end + + it 'returns array value' do + is_expected.to run.with_params('types_test::array_param').and_return(['item1', 'item2', 'item3']) + end + + it 'returns hash value' do + is_expected.to run.with_params('types_test::hash_param').and_return({ 'key1' => 'value1', 'key2' => 'value2' }) + end + end + + context 'with controls referenced directly in profile' do + let(:profile_data) do + { + 'version' => '2.0.0', + 'profiles' => { + 'direct_control_profile' => { + 'controls' => { + 'direct_control' => true, + }, + }, + }, + } + end + + let(:ces_data) do + { + 'version' => '2.0.0', + 'ce' => { + 'direct_ce' => { + 'controls' => { + 'direct_control' => true, + }, + }, + }, + } + end + + let(:checks_data) do + { + 'version' => '2.0.0', + 'checks' => { + 'direct_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'direct_test::param', + 'value' => 'direct_value', + }, + 'ces' => ['direct_ce'], + }, + }, + } + end + + let(:facts) do + { 'target_compliance_profile' => 'direct_control_profile' } + end + let(:hieradata) { 'compliance-engine' } + + it 'returns value for check linked through control -> ce -> check' do + is_expected.to run.with_params('direct_test::param').and_return('direct_value') + end + end +end diff --git a/spec/functions/lookup/00_enforcement_spec.rb b/spec/functions/lookup/00_enforcement_spec.rb new file mode 100755 index 0000000..127f789 --- /dev/null +++ b/spec/functions/lookup/00_enforcement_spec.rb @@ -0,0 +1,137 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + '00_profile_test' => { + 'controls' => { + '00_control1' => true, + }, + }, + '00_profile_with_check_reference' => { + 'checks' => { + '00_check2' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '00_ce1' => { + 'controls' => { + '00_control1' => true, + }, + }, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '00_check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_00::test_param', + 'value' => 'a string', + }, + 'ces' => [ + '00_ce1', + ], + }, + '00_check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_00::test_param2', + 'value' => 'another string', + }, + 'ces' => [ + '00_ce1', + ], + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_00') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement and a non-existent profile" do + let(:facts) do + os_facts.merge('target_compliance_profile' => 'not_a_profile') + end + + let(:hieradata) { 'compliance-engine' } + + it { + is_expected.to run.with_params('test_module_00::test_param') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_00::test_param'") + } + end + + context "on #{os} with compliance_engine::enforcement and an existing profile" do + let(:facts) do + os_facts.merge('target_compliance_profile' => '00_profile_test') + end + + let(:hieradata) { 'compliance-engine' } + + # Test unconfined data. + it { is_expected.to run.with_params('test_module_00::test_param').and_return('a string') } + it { is_expected.to run.with_params('test_module_00::test_param2').and_return('another string') } + end + + context "on #{os} with compliance_engine::enforcement and a profile directly referencing a check" do + let(:facts) do + os_facts.merge('target_compliance_profile' => '00_profile_with_check_reference') + end + + let(:hieradata) { 'compliance-engine' } + + # Test unconfined data. + it { + is_expected.to run.with_params('test_module_00::test_param') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_00::test_param'") + } + + it { is_expected.to run.with_params('test_module_00::test_param2').and_return('another string') } + end + end +end diff --git a/spec/functions/lookup/01_enforcement_confine_spec.rb b/spec/functions/lookup/01_enforcement_confine_spec.rb new file mode 100755 index 0000000..e9be5c7 --- /dev/null +++ b/spec/functions/lookup/01_enforcement_confine_spec.rb @@ -0,0 +1,240 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + '01_profile_test' => { + 'controls' => { + '01_control1' => true, + '01_os_control' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '01_ce1' => { + 'controls' => { + '01_control1' => true, + }, + }, + '01_ce2' => { + 'controls' => { + '01_os_control' => true, + }, + }, + '01_ce3' => { + 'controls' => { + '01_control1' => true, + }, + 'confine' => { + 'module_name' => 'simp-compliance_engine', + 'module_version' => '< 3.1.0', + }, + }, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '01_el_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::is_el', + 'value' => true, + }, + 'ces' => [ + '01_ce2', + ], + 'confine' => { + 'os.family' => 'RedHat', + }, + }, + '01_el_negative_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::is_not_el', + 'value' => true, + }, + 'ces' => [ + '01_ce2', + ], + 'confine' => { + 'os.family' => '!RedHat', + }, + }, + '01_el7_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::el_version', + 'value' => '7', + }, + 'ces' => [ + '01_ce2', + ], + 'confine' => { + 'os.name' => [ + 'RedHat', + 'CentOS' + ], + 'os.release.major' => '7', + }, + }, + '01_el7_negative_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::not_el_version', + 'value' => '7', + }, + 'ces' => [ + '01_ce2', + ], + 'confine' => { + 'os.name' => [ + '!RedHat', + ], + 'os.release.major' => '7', + }, + }, + '01_el7_negative_mixed_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::not_el_centos_version', + 'value' => '7', + }, + 'ces' => [ + '01_ce2', + ], + 'confine' => { + 'os.name' => [ + '!RedHat', + 'CentOS', + ], + 'os.release.major' => '7', + }, + }, + '01_confine_in_ces' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_01::fixed_confines', + 'value' => false, + }, + 'ces' => [ + '01_ce3', + ], + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_01') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement and an existing profile" do + let(:facts) do + os_facts.merge('target_compliance_profile' => '01_profile_test') + end + + let(:hieradata) { 'compliance_engine' } + + # Test for confine on a single fact in checks. + if os_facts[:os]['family'] == 'RedHat' + it { is_expected.to run.with_params('test_module_01::is_el').and_return(true) } + else + it { is_expected.to run.with_params('test_module_01::is_el').and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::is_el'") } + end + + # Test for confine on a single fact in checks. + if os_facts[:os]['family'] == 'RedHat' + it do + is_expected.to run.with_params('test_module_01::is_not_el') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::is_not_el'") + end + else + it { is_expected.to run.with_params('test_module_01::is_not_el').and_return(true) } + end + + # Test for confine on multiple facts and an array of facts in checks. + if ['RedHat', 'CentOS'].include?(os_facts[:os]['name']) && os_facts[:os]['release']['major'] == '7' + it { is_expected.to run.with_params('test_module_01::el_version').and_return('7') } + else + it do + is_expected.to run.with_params('test_module_01::el_version') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::el_version'") + end + end + + # Test for confine on multiple facts and a negative fact match. + if (os_facts[:os]['name'] != 'RedHat') && os_facts[:os]['release']['major'] == '7' + it { is_expected.to run.with_params('test_module_01::not_el_version').and_return('7') } + else + it do + is_expected.to run.with_params('test_module_01::not_el_version') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::not_el_version'") + end + end + + # Test for confine on multiple facts and a negative fact match mixed with a positive one. + # TODO: This does not currently work as one might expect. This will still positively match OracleLinux even though + # we ask for OS names that aren't RedHat but are CentOS. The array we're confining can only do an OR operation rather + # than an AND with a negative lookup. + # rubocop:disable RSpec/RepeatedExample + if (os_facts[:os]['name'] != 'RedHat') && (os_facts[:os]['name'] == 'CentOS') && os_facts[:os]['release']['major'] == '7' + it { is_expected.to run.with_params('test_module_01::not_el_centos_version').and_return('7') } + elsif (os_facts[:os]['name'] != 'RedHat') && (os_facts[:os]['name'] != 'CentOS') && os_facts[:os]['release']['major'] == '7' + it { is_expected.to run.with_params('test_module_01::not_el_centos_version').and_return('7') } + else + it do + is_expected.to run.with_params('test_module_01::not_el_centos_version') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::not_el_centos_version'") + end + end + # rubocop:enable RSpec/RepeatedExample + + # Test for confine on module name & module version in ce. + it do + is_expected.to run.with_params('test_module_01::fixed_confines') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_01::fixed_confines'") + end + end + end +end diff --git a/spec/functions/lookup/02_enforcement_merge_spec.rb b/spec/functions/lookup/02_enforcement_merge_spec.rb new file mode 100755 index 0000000..d025dec --- /dev/null +++ b/spec/functions/lookup/02_enforcement_merge_spec.rb @@ -0,0 +1,164 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + '02_profile_test' => { + 'controls' => { + '02_control1' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '02_ce1' => { + 'controls' => { + '02_control1' => true, + }, + }, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '02_array check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::array_param', + 'value' => [ + 'array value 1', + ], + }, + 'ces' => [ + '02_ce1', + ], + }, + '02_array check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::array_param', + 'value' => [ + 'array value 2', + ], + }, + 'ces' => [ + '02_ce1', + ], + }, + '02_hash check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::hash_param', + 'value' => { + 'hash key 1' => 'hash value 1', + }, + }, + 'ces' => [ + '02_ce1', + ], + }, + '02_hash check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::hash_param', + 'value' => { + 'hash key 2' => 'hash value 2', + }, + }, + 'ces' => [ + '02_ce1', + ], + }, + '02_nested hash1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::nested_hash', + 'value' => { + 'key' => { + 'key1' => 'value1', + }, + }, + }, + 'ces' => [ + '02_ce1', + ], + }, + '02_nested hash2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_02::nested_hash', + 'value' => { + 'key' => { + 'key2' => 'value2', + }, + }, + }, + 'ces' => [ + '02_ce1', + ], + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_02') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement and an existing profile" do + let(:facts) do + os_facts.merge('target_compliance_profile' => '02_profile_test') + end + + let(:hieradata) { 'compliance-engine' } + + # Test a simple array. + it { is_expected.to run.with_params('test_module_02::array_param').and_return(['array value 1', 'array value 2']) } + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_02::hash_param').and_return({ 'hash key 1' => 'hash value 1', 'hash key 2' => 'hash value 2' }) } + + # Test a nested hash. + it { is_expected.to run.with_params('test_module_02::nested_hash').and_return({ 'key' => { 'key1' => 'value1', 'key2' => 'value2' } }) } + end + end +end diff --git a/spec/functions/lookup/03_enforcement_profile_merge_spec.rb b/spec/functions/lookup/03_enforcement_profile_merge_spec.rb new file mode 100755 index 0000000..925d21b --- /dev/null +++ b/spec/functions/lookup/03_enforcement_profile_merge_spec.rb @@ -0,0 +1,228 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + 'profile_test1' => { + 'ces' => { + '03_profile_test1' => true, + }, + }, + 'profile_test2' => { + 'ces' => { + '03_profile_test2' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '03_profile_test1' => {}, + '03_profile_test2' => {}, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '03_string check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::string_param', + 'value' => 'string value 1', + }, + 'ces' => [ + '03_profile_test1', + ], + }, + '03_string check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::string_param', + 'value' => 'string value 2', + }, + 'ces' => [ + '03_profile_test2', + ], + }, + '03_array check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::array_param', + 'value' => [ + 'array value 1', + ], + }, + 'ces' => [ + '03_profile_test1', + ], + }, + '03_array check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::array_param', + 'value' => [ + 'array value 2', + ], + }, + 'ces' => [ + '03_profile_test2', + ], + }, + '03_hash check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::hash_param', + 'value' => { + 'hash key 1' => 'hash value 1', + }, + }, + 'ces' => [ + '03_profile_test1', + ], + }, + '03_hash check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::hash_param', + 'value' => { + 'hash key 2' => '\-- hash value 2', + }, + }, + 'ces' => [ + '03_profile_test2', + ], + }, + '03_nested hash1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::nested_hash', + 'value' => { + 'key' => { + 'key1' => 'value1', + }, + }, + }, + 'ces' => [ + '03_profile_test1', + ], + }, + '03_nested hash2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_03::nested_hash', + 'value' => { + 'key' => { + 'key1' => 'value2', + 'key2' => 'value2', + }, + }, + }, + 'ces' => [ + '03_profile_test2', + ], + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_03') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + let(:hieradata_dir) { File.expand_path('../../data', __dir__) } + let(:hieradata_file) { "profile-merging-#{Process.pid}" } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement merging profiles" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { 'compliance_engine::enforcement' => ['profile_test1', 'profile_test2'] }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a string. + it { is_expected.to run.with_params('test_module_03::string_param').and_return('string value 1') } + + # Test a simple array. + it { is_expected.to run.with_params('test_module_03::array_param').and_return(['array value 2', 'array value 1']) } + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_03::hash_param').and_return({ 'hash key 1' => 'hash value 1', 'hash key 2' => '\-- hash value 2' }) } + + # Test a nested hash. + it { is_expected.to run.with_params('test_module_03::nested_hash').and_return({ 'key' => { 'key1' => 'value1', 'key2' => 'value2' } }) } + end + + context "on #{os} with compliance_engine::enforcement merging profiles in reverse order" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { 'compliance_engine::enforcement' => ['profile_test2', 'profile_test1'] }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a string. + it { is_expected.to run.with_params('test_module_03::string_param').and_return('string value 2') } + + # Test a simple array. + it { is_expected.to run.with_params('test_module_03::array_param').and_return(['array value 1', 'array value 2']) } + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_03::hash_param').and_return({ 'hash key 2' => '\-- hash value 2', 'hash key 1' => 'hash value 1' }) } + + # Test a nested hash. + it { is_expected.to run.with_params('test_module_03::nested_hash').and_return({ 'key' => { 'key1' => 'value2', 'key2' => 'value2' } }) } + end + end +end diff --git a/spec/functions/lookup/04_enforcement_profile_merge_spec.rb b/spec/functions/lookup/04_enforcement_profile_merge_spec.rb new file mode 100755 index 0000000..c5ba2be --- /dev/null +++ b/spec/functions/lookup/04_enforcement_profile_merge_spec.rb @@ -0,0 +1,496 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + # Break it up between multiple files to validate merging. + let(:files) do + { + 'profile_00' => { + 'version' => '2.0.0', + 'profiles' => { + 'profile_test1' => { + 'ces' => { + '04_profile_test1' => true, + }, + }, + }, + }, + 'profile_01' => { + 'version' => '2.0.0', + 'profiles' => { + 'profile_test2' => { + 'ces' => { + '04_profile_test2' => true, + }, + }, + }, + }, + 'ces_00' => { + 'version' => '2.0.0', + 'ce' => { + '04_profile_test1' => { + 'controls' => { + '04_control1' => true, + }, + 'oval-ids' => [ + - '04_oval_id1', + ], + }, + }, + }, + 'ces_01' => { + 'version' => '2.0.0', + 'ce' => { + '04_profile_test2' => { + 'identifiers' => { + '04_identifier1' => [], + }, + }, + }, + }, + 'checks_00' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check1' => { + 'ces' => [ + '04_profile_test1', + ], + 'controls' => { + '04_control2' => true, + }, + }, + }, + }, + 'checks_01' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check1' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_02' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check1' => { + 'settings' => { + 'value' => 'string value 1', + }, + }, + }, + }, + 'checks_03' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check1' => { + 'settings' => { + 'parameter' => 'test_module_04::string_param', + }, + }, + }, + }, + 'checks_10' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check2' => { + 'settings' => { + 'parameter' => 'test_module_04::string_param', + }, + }, + }, + }, + 'checks_11' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check2' => { + 'settings' => { + 'value' => 'string value 2', + }, + 'identifiers' => { + '04_identifier2' => [], + }, + }, + }, + }, + 'checks_12' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check2' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_13' => { + 'version' => '2.0.0', + 'checks' => { + '04_string check2' => { + 'ces' => [ + '04_profile_test2', + ], + }, + }, + }, + 'checks_20' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check1' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_21' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check1' => { + 'ces' => [ + '04_profile_test1', + ], + }, + }, + }, + 'checks_22' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check1' => { + 'settings' => { + 'value' => [ + 'array value 1', + ], + }, + 'oval-ids' => [ + - '04_oval_id2', + ], + }, + }, + }, + 'checks_23' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check1' => { + 'settings' => { + 'parameter' => 'test_module_04::array_param', + }, + }, + }, + }, + 'checks_30' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check2' => { + 'settings' => { + 'value' => [ + 'array value 2', + ], + }, + }, + }, + }, + 'checks_31' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check2' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_32' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check2' => { + 'ces' => [ + '04_profile_test2', + ], + }, + }, + }, + 'checks_33' => { + 'version' => '2.0.0', + 'checks' => { + '04_array check2' => { + 'settings' => { + 'parameter' => 'test_module_04::array_param', + }, + 'controls' => { + '04_control3' => true, + }, + }, + }, + }, + 'checks_40' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check1' => { + 'settings' => { + 'value' => { + 'hash key 1' => 'hash value 1', + }, + }, + 'identifiers' => { + '04_identifier3' => [], + }, + }, + }, + }, + 'checks_41' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check1' => { + 'settings' => { + 'parameter' => 'test_module_04::hash_param', + }, + }, + }, + }, + 'checks_42' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check1' => { + 'ces' => [ + '04_profile_test1', + ], + }, + }, + }, + 'checks_43' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check1' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_50' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check2' => { + 'ces' => [ + '04_profile_test2', + ], + }, + }, + }, + 'checks_51' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check2' => { + 'type' => 'puppet-class-parameter', + 'oval-ids' => [ + - '04_oval_id3', + ], + }, + }, + }, + 'checks_52' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check2' => { + 'settings' => { + 'parameter' => 'test_module_04::hash_param', + }, + }, + }, + }, + 'checks_53' => { + 'version' => '2.0.0', + 'checks' => { + '04_hash check2' => { + 'settings' => { + 'value' => { + 'hash key 2' => 'hash value 2', + }, + }, + }, + }, + }, + 'checks_60' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash1' => { + 'settings' => { + 'parameter' => 'test_module_04::nested_hash', + }, + }, + }, + }, + 'checks_61' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash1' => { + 'ces' => [ + '04_profile_test1', + ], + }, + }, + }, + 'checks_62' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash1' => { + 'settings' => { + 'value' => { + 'key' => { + 'key1' => 'value1', + }, + }, + }, + }, + }, + }, + 'checks_63' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash1' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_70' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash2' => { + 'type' => 'puppet-class-parameter', + }, + }, + }, + 'checks_71' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash2' => { + 'settings' => { + 'parameter' => 'test_module_04::nested_hash', + }, + }, + }, + }, + 'checks_72' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash2' => { + 'settings' => { + 'value' => { + 'key' => { + 'key1' => 'value2', + }, + }, + }, + }, + }, + }, + 'checks_73' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash2' => { + 'settings' => { + 'value' => { + 'key' => { + 'key2' => 'value2', + }, + }, + }, + }, + }, + }, + 'checks_74' => { + 'version' => '2.0.0', + 'checks' => { + '04_nested hash2' => { + 'ces' => [ + '04_profile_test2', + ], + }, + }, + }, + } + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_04') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + let(:hieradata_dir) { File.expand_path('../../data', __dir__) } + let(:hieradata_file) { "profile-merging-#{Process.pid}" } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + files.each do |file, data| + File.write(File.join(compliance_dir, "#{file}.yaml"), data.to_yaml) + end + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement merging profiles" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { 'compliance_engine::enforcement' => ['profile_test1', 'profile_test2'] }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a string. + it { is_expected.to run.with_params('test_module_04::string_param').and_return('string value 1') } + + # Test a simple array. + it { is_expected.to run.with_params('test_module_04::array_param').and_return(['array value 2', 'array value 1']) } + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_04::hash_param').and_return({ 'hash key 1' => 'hash value 1', 'hash key 2' => 'hash value 2' }) } + + # Test a nested hash. + it { is_expected.to run.with_params('test_module_04::nested_hash').and_return({ 'key' => { 'key1' => 'value1', 'key2' => 'value2' } }) } + end + + context "on #{os} with compliance_engine::enforcement merging profiles in reverse order" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { 'compliance_engine::enforcement' => ['profile_test2', 'profile_test1'] }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a string. + it { is_expected.to run.with_params('test_module_04::string_param').and_return('string value 2') } + + # Test a simple array. + it { is_expected.to run.with_params('test_module_04::array_param').and_return(['array value 1', 'array value 2']) } + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_04::hash_param').and_return({ 'hash key 2' => 'hash value 2', 'hash key 1' => 'hash value 1' }) } + + # Test a nested hash. + it { is_expected.to run.with_params('test_module_04::nested_hash').and_return({ 'key' => { 'key1' => 'value2', 'key2' => 'value2' } }) } + end + end +end diff --git a/spec/functions/lookup/05_enforcement_override_spec.rb b/spec/functions/lookup/05_enforcement_override_spec.rb new file mode 100755 index 0000000..adcea52 --- /dev/null +++ b/spec/functions/lookup/05_enforcement_override_spec.rb @@ -0,0 +1,145 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + 'profile_test1' => { + 'ces' => { + '05_profile_test1' => true, + '05_profile_test2' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '05_profile_test1' => {}, + '05_profile_test2' => {}, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '05_hash check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_05::hash_param', + 'value' => { + 'hash key 1' => 'hash value 1', + }, + }, + 'ces' => [ + '05_profile_test1', + ], + }, + '05_hash check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_05::hash_param', + 'value' => { + 'hash key 2' => 'hash value 2', + }, + }, + 'ces' => [ + '05_profile_test2', + ], + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_05') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + let(:hieradata_dir) { File.expand_path('../../data', __dir__) } + let(:hieradata_file) { "profile-merging-#{Process.pid}" } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance data in modules" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { 'compliance_engine::enforcement' => ['profile_test1'] }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_05::hash_param').and_return({ 'hash key 1' => 'hash value 1', 'hash key 2' => 'hash value 2' }) } + end + + context "on #{os} with compliance_engine::compliance_map override" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + let(:hieradata) { hieradata_file } + + before(:each) do + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + test_hiera = { + 'compliance_engine::enforcement' => ['profile_test1'], + 'compliance_engine::compliance_map' => { + 'version' => '2.0.0', + 'profiles' => { + 'profile_test1' => { + 'ces' => { + '05_profile_test2' => false, + }, + }, + }, + }, + }.to_yaml + fh.puts test_hiera + end + end + + after(:each) do + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + # Test a simple hash. + it { is_expected.to run.with_params('test_module_05::hash_param').and_return({ 'hash key 1' => 'hash value 1' }) } + end + end +end diff --git a/spec/functions/lookup/06_enforcement_debug_spec.rb b/spec/functions/lookup/06_enforcement_debug_spec.rb new file mode 100755 index 0000000..641342a --- /dev/null +++ b/spec/functions/lookup/06_enforcement_debug_spec.rb @@ -0,0 +1,133 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup', skip: 'Debug features not yet implemented in compliance_engine' do + # Generate a fake module with dummy data for lookup(). + let(:profile) do + { + 'version' => '2.0.0', + 'profiles' => { + '06_profile_test' => { + 'controls' => { + '06_control1' => true, + }, + }, + }, + } + end + + let(:ces) do + { + 'version' => '2.0.0', + 'ce' => { + '06_ce1' => { + 'controls' => { + '06_control1' => true, + }, + }, + }, + } + end + + let(:checks) do + { + 'version' => '2.0.0', + 'checks' => { + '06_check1' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_06::test_param', + 'value' => 'a string', + }, + 'ces' => [ + '06_ce1', + ], + }, + '06_check2' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_06::test_param2', + 'value' => 'another string', + }, + 'ces' => [ + '06_ce1', + ], + }, + }, + } + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:test_module_path) { File.join(tmpdir, 'test_module_06') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile.to_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces.to_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks.to_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} compliance_engine::debug values" do + let(:lookup) { subject } + let(:facts) do + os_facts.merge('target_compliance_profile' => '06_profile_test') + end + + let(:hieradata) { 'compliance-engine' } + + it do + result = lookup.execute('compliance_engine::debug::hiera_backend_compile_time') + expect(result).to be_a(Float) + expect(result).to be > 0 + end + + it do + result = lookup.execute('compliance_engine::debug::dump') + expect(result).to be_a(Hash) + expect(result['test_module_06::test_param']).to eq('a string') + expect(result['test_module_06::test_param2']).to eq('another string') + expect(result.keys).to eq([ + 'test_module_06::test_param', + 'test_module_06::test_param2', + 'compliance_engine::debug::hiera_backend_compile_time', + ]) + end + + it do + result = lookup.execute('compliance_engine::debug::profiles') + expect(result).to be_a(Array) + expect(result).to include('06_profile_test') + end + + it do + result = lookup.execute('compliance_engine::debug::compliance_data') + expect(result).to be_a(Hash) + expect(result.keys).to eq(['version', 'profiles', 'ce', 'checks']) + expect(result['profiles']).to include(profile['profiles']) + expect(result['ce']).to include(ces['ce']) + expect(result['checks']).to include(checks['checks']) + end + end + end +end diff --git a/spec/functions/lookup/07_enforcement_tolerance_spec.rb b/spec/functions/lookup/07_enforcement_tolerance_spec.rb new file mode 100755 index 0000000..bf08323 --- /dev/null +++ b/spec/functions/lookup/07_enforcement_tolerance_spec.rb @@ -0,0 +1,248 @@ +#!/usr/bin/env ruby -S rspec +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'yaml' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + # Generate a fake module with dummy data for lookup(). + let(:profile_yaml) do + { + 'version' => '2.0.0', + 'profiles' => { + '07_profile_test' => { + 'controls' => { + '07_control1' => true, + '07_os_control' => true, + }, + }, + }, + }.to_yaml + end + + let(:ces_yaml) do + { + 'version' => '2.0.0', + 'ce' => { + '07_ce1' => { + 'controls' => { + '07_control1' => true, + }, + }, + '07_ce2' => { + 'controls' => { + '07_os_control' => true, + }, + }, + }, + }.to_yaml + end + + let(:checks_yaml) do + { + 'version' => '2.0.0', + 'checks' => { + '07_disabled_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_07::is_disabled', + 'value' => true, + }, + 'ces' => [ + '07_ce2', + ], + 'remediation' => { + 'disabled' => [ + { 'reason' => 'This is the reason this check is disabled.' }, + ] + }, + }, + '07_level_21_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_07::is_level_21', + 'value' => true, + }, + 'ces' => [ + '07_ce2', + ], + 'remediation' => { + 'risk' => [ + { 'level' => 21 }, + ] + }, + }, + '07_level_41_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_07::is_level_41', + 'value' => true, + }, + 'ces' => [ + '07_ce2', + ], + 'remediation' => { + 'risk' => [ + { 'level' => 41, 'reason' => 'this is the reason for level 41' }, + ] + }, + }, + '07_level_61_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_07::is_level_61', + 'value' => true, + }, + 'ces' => [ + '07_ce2', + ], + 'remediation' => { + 'risk' => [ + { 'level' => 61, 'reason' => 'this is the reason for level 61' }, + ] + }, + }, + '07_level_81_check' => { + 'type' => 'puppet-class-parameter', + 'settings' => { + 'parameter' => 'test_module_07::is_level_81', + 'value' => true, + }, + 'ces' => [ + '07_ce2', + ], + 'remediation' => { + 'risk' => [ + { 'level' => 81, 'reason' => 'this is the reason for level 81' }, + ] + }, + }, + }, + }.to_yaml + end + + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test_07') } + let(:test_module_path) { File.join(tmpdir, 'test_module_07') } + let(:compliance_dir) { File.join(test_module_path, 'SIMP', 'compliance_profiles') } + + before(:each) do + # Create the directory structure + FileUtils.mkdir_p(compliance_dir) + + # Write the test data files + File.write(File.join(compliance_dir, 'profiles.yaml'), profile_yaml) + File.write(File.join(compliance_dir, 'ces.yaml'), ces_yaml) + File.write(File.join(compliance_dir, 'checks.yaml'), checks_yaml) + + # Mock the Puppet environment's modulepath to include our temp directory + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Puppet::Node::Environment).to receive(:full_modulepath).and_return([tmpdir]) + # rubocop:enable RSpec/AnyInstance + end + + after(:each) do + # Clean up temporary directory + FileUtils.rm_rf(tmpdir) if tmpdir && File.exist?(tmpdir) + end + + on_supported_os.each do |os, os_facts| + context "on #{os} with compliance_engine::enforcement and an existing profile using tolerance above level 21" do + let(:facts) do + os_facts.merge( + 'custom_hiera' => 'compliance_engine', + 'target_compliance_profile' => '07_profile_test', + 'target_enforcement_tolerance' => '22', + ) + end + let(:hieradata) { 'compliance_engine' } + + it do + is_expected.to run.with_params('test_module_07::is_disabled') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_disabled'") + end + + it { is_expected.to run.with_params('test_module_07::is_level_21').and_return(true) } + + it do + is_expected.to run.with_params('test_module_07::is_level_41') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_41'") + end + + it do + is_expected.to run.with_params('test_module_07::is_level_61') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_61'") + end + + it do + is_expected.to run.with_params('test_module_07::is_level_81') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_81'") + end + end + + context "on #{os} with compliance_engine::enforcement and an existing profile using tolerance above level 41" do + let(:facts) do + os_facts.merge('custom_hiera' => 'compliance_engine', 'target_compliance_profile' => '07_profile_test', 'target_enforcement_tolerance' => '42') + end + let(:hieradata) { 'compliance_engine' } + + it do + is_expected.to run.with_params('test_module_07::is_disabled') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_disabled'") + end + + it { is_expected.to run.with_params('test_module_07::is_level_21').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_41').and_return(true) } + + it do + is_expected.to run.with_params('test_module_07::is_level_61') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_61'") + end + + it do + is_expected.to run.with_params('test_module_07::is_level_81') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_81'") + end + end + + context "on #{os} with compliance_engine::enforcement and an existing profile using tolerance above level 61" do + let(:facts) do + os_facts.merge('custom_hiera' => 'compliance_engine', 'target_compliance_profile' => '07_profile_test', 'target_enforcement_tolerance' => '62') + end + let(:hieradata) { 'compliance_engine' } + + it do + is_expected.to run.with_params('test_module_07::is_disabled') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_disabled'") + end + + it { is_expected.to run.with_params('test_module_07::is_level_21').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_41').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_61').and_return(true) } + + it do + is_expected.to run.with_params('test_module_07::is_level_81') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_level_81'") + end + end + + context "on #{os} with compliance_engine::enforcement and an existing profile using tolerance above level 81" do + let(:facts) do + os_facts.merge('custom_hiera' => 'compliance_engine', 'target_compliance_profile' => '07_profile_test', 'target_enforcement_tolerance' => '82') + end + let(:hieradata) { 'compliance_engine' } + + it do + is_expected.to run.with_params('test_module_07::is_disabled') + .and_raise_error(Puppet::DataBinding::LookupError, "Function lookup() did not find a value for the name 'test_module_07::is_disabled'") + end + + it { is_expected.to run.with_params('test_module_07::is_level_21').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_41').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_61').and_return(true) } + it { is_expected.to run.with_params('test_module_07::is_level_81').and_return(true) } + end + end +end diff --git a/spec/functions/lookup/10_enforce_spec.rb b/spec/functions/lookup/10_enforce_spec.rb new file mode 100644 index 0000000..dab2124 --- /dev/null +++ b/spec/functions/lookup/10_enforce_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'spec_helper_puppet' +require 'fileutils' +require 'tmpdir' + +RSpec.describe 'lookup' do + let(:tmpdir) { Dir.mktmpdir('compliance_engine_test') } + let(:hieradata_dir) { File.expand_path('../../data', __dir__) } + let(:hieradata_file) { "10_enforce_spec-#{Process.pid}" } + + def write_hieradata(hieradata_dir, hieradata_file, policy_order) + data = { + 'compliance_engine::enforcement' => policy_order, + 'compliance_engine::compliance_map' => { + 'version' => '2.0.0', + 'profiles' => { + 'disa_stig' => { + 'controls' => { + 'disa_stig' => true, + }, + }, + 'nist_800_53:rev4' => { + 'controls' => { + 'nist_800_53:rev4' => true, + }, + }, + }, + 'controls' => { + 'disa_stig' => {}, + 'nist_800_53:rev4' => {}, + }, + 'checks' => { + 'oval:com.puppet.test.disa.useradd_shells' => { + 'type' => 'puppet-class-parameter', + 'controls' => { + 'disa_stig' => true, + }, + 'identifiers' => { + 'FOO2' => ['FOO2'], + 'BAR2' => ['BAR2'] + }, + 'settings' => { + 'parameter' => 'useradd::shells', + 'value' => ['/bin/disa'] + } + }, + 'oval:com.puppet.test.nist.useradd_shells' => { + 'type' => 'puppet-class-parameter', + 'controls' => { + 'nist_800_53:rev4' => true + }, + 'identifiers' => { + 'FOO2' => ['FOO2'], + 'BAR2' => ['BAR2'] + }, + 'settings' => { + 'parameter' => 'useradd::shells', + 'value' => ['/bin/nist'] + } + } + } + } + } + + File.open(File.join(hieradata_dir, "#{hieradata_file}.yaml"), 'w') do |fh| + fh.puts data.to_yaml + end + end + + after(:each) do + # Clean up temporary directory and hieradata file + FileUtils.rm_rf(tmpdir) + FileUtils.rm_f(File.join(hieradata_dir, "#{hieradata_file}.yaml")) + end + + on_supported_os.each do |os, os_facts| + context "on #{os}" do + let(:facts) { os_facts.merge('custom_hiera' => hieradata_file) } + + context 'with a single compliance map' do + let(:lookup) { subject } + let(:hieradata) { hieradata_file } + let(:policy_order) { ['disa_stig'] } + + before(:each) do + write_hieradata(hieradata_dir, hieradata_file, policy_order) + end + + it 'returns /bin/disa' do + result = lookup.execute('useradd::shells') + expect(result).to be_instance_of(Array) + expect(result).to include('/bin/disa') + end + + context 'with a String compliance map' do + let(:policy_order) { 'disa_stig' } + + it 'returns /bin/disa' do + result = lookup.execute('useradd::shells') + expect(result).to be_instance_of(Array) + expect(result).to include('/bin/disa') + end + end + end + + context 'when disa is higher priority' do + let(:lookup) { subject } + let(:hieradata) { hieradata_file } + let(:policy_order) { ['disa_stig', 'nist_800_53:rev4'] } + + before(:each) do + write_hieradata(hieradata_dir, hieradata_file, policy_order) + end + + it 'returns /bin/disa and /bin/nist' do + result = lookup.execute('useradd::shells') + expect(result).to be_instance_of(Array) + expect(result).to include('/bin/disa') + expect(result).to include('/bin/nist') + end + end + end + end +end diff --git a/spec/hiera.yaml b/spec/hiera.yaml new file mode 100644 index 0000000..a5d02fd --- /dev/null +++ b/spec/hiera.yaml @@ -0,0 +1,16 @@ +--- +version: 5 +defaults: + datadir: data + data_hash: yaml_data +hierarchy: + - name: Custom Test Hiera + path: "%{custom_hiera}.yaml" + - name: "%{module_name}" + path: "%{module_name}.yaml" + - name: Common + paths: + - default.yaml + - common.yaml + - name: "Compliance Engine" + lookup_key: "compliance_engine::enforcement" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b26d6f8..7f349d3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,4 +12,6 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + config.mock_with :rspec end diff --git a/spec/spec_helper_puppet.rb b/spec/spec_helper_puppet.rb new file mode 100644 index 0000000..963db03 --- /dev/null +++ b/spec/spec_helper_puppet.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.configure do |c| + c.mock_with :rspec +end + +require 'voxpupuli/test/spec_helper' +require 'rspec-puppet-facts' + +require 'spec_helper_local' if File.file?(File.join(File.dirname(__FILE__), 'spec_helper_local.rb')) + +# Load support files +Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f } + +include RspecPuppetFacts + +default_facts = { + puppetversion: Puppet.version, + facterversion: Facter.version, +} + +default_fact_files = [ + File.expand_path(File.join(File.dirname(__FILE__), 'default_facts.yml')), + File.expand_path(File.join(File.dirname(__FILE__), 'default_module_facts.yml')), +] + +default_fact_files.each do |f| + next unless File.exist?(f) && File.readable?(f) && File.size?(f) + + begin + require 'deep_merge' + default_facts.deep_merge!(YAML.safe_load(File.read(f), permitted_classes: [], permitted_symbols: [], aliases: true)) + rescue StandardError => e + RSpec.configuration.reporter.message "WARNING: Unable to load #{f}: #{e}" + end +end + +# read default_facts and merge them over what is provided by facterdb +default_facts.each do |fact, value| + add_custom_fact fact, value, merge_facts: true +end + +RSpec.configure do |c| + c.default_facts = default_facts + c.hiera_config = 'spec/hiera.yaml' + c.before :each do + # set to strictest setting for testing + # by default Puppet runs at warning level + Puppet.settings[:strict] = :warning + Puppet.settings[:strict_variables] = true + end + c.filter_run_excluding(bolt: true) unless ENV['GEM_BOLT'] + c.after(:suite) do + RSpec::Puppet::Coverage.report!(0) + end + + # Filter backtrace noise + backtrace_exclusion_patterns = [ + %r{spec_helper}, + %r{gems}, + ] + + if c.respond_to?(:backtrace_exclusion_patterns) + c.backtrace_exclusion_patterns = backtrace_exclusion_patterns + elsif c.respond_to?(:backtrace_clean_patterns) + c.backtrace_clean_patterns = backtrace_exclusion_patterns + end +end + +# Ensures that a module is defined +# @param module_name Name of the module +def ensure_module_defined(module_name) + module_name.split('::').reduce(Object) do |last_module, next_module| + last_module.const_set(next_module, Module.new) unless last_module.const_defined?(next_module, false) + last_module.const_get(next_module, false) + end +end + +# 'spec_overrides' from sync.yml will appear below this line diff --git a/spec/support/compliance_module_mocks.rb b/spec/support/compliance_module_mocks.rb new file mode 100644 index 0000000..64d9339 --- /dev/null +++ b/spec/support/compliance_module_mocks.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Shared context for mocking compliance module file system operations +RSpec.shared_context 'compliance module mocks' do |module_name| + let(:fixtures) { File.expand_path('../../fixtures', __dir__) } + let(:module_path) { File.join(fixtures, 'modules', module_name) } + let(:compliance_dir) { File.join(module_path, 'SIMP', 'compliance_profiles') } + let(:compliance_files) { ['profile.yaml', 'ces.yaml', 'checks.yaml'].map { |f| File.join(compliance_dir, f) } } + + before(:each) do + allow(Dir).to receive(:glob).and_call_original + allow(Dir).to receive(:entries).and_call_original + allow(File).to receive(:directory?).and_call_original + allow(File).to receive(:exist?).and_call_original + + # Mock the modulepath directory entries to include the test module + allow(Dir).to receive(:entries).with(File.join(fixtures, 'modules')).and_return(['.', '..', 'compliance_engine', module_name]) + + allow(File).to receive(:directory?).with(Pathname.new(File.join(fixtures, 'modules'))).and_return(true) + allow(File).to receive(:directory?).with(File.join(fixtures, 'modules', module_name)).and_return(true) + allow(File).to receive(:directory?).with(module_path).and_return(true) + allow(File).to receive(:directory?).with("#{module_path}/SIMP/compliance_profiles").and_return(true) + allow(File).to receive(:directory?).with("#{module_path}/simp/compliance_profiles").and_return(false) + + # Mock metadata.json existence check (default to not existing unless overridden) + metadata_path = File.join(module_path, 'metadata.json') + allow(File).to receive(:exist?).with(metadata_path).and_return(defined?(metadata_json) && !metadata_json.nil?) + if defined?(metadata_json) && metadata_json + allow(File).to receive(:read).with(metadata_path).and_return(metadata_json) + end + + allow(Dir).to receive(:glob) + .with("#{module_path}/SIMP/compliance_profiles/**/*.yaml") + .and_return(compliance_files) + allow(Dir).to receive(:glob) + .with("#{module_path}/SIMP/compliance_profiles/**/*.json") + .and_return([]) + + allow(File).to receive(:size).and_call_original + allow(File).to receive(:mtime).and_call_original + allow(File).to receive(:read).and_call_original + + # Mock compliance data files + allow(File).to receive(:size).with(File.join(compliance_dir, 'profile.yaml')).and_return(profile_yaml.length) + allow(File).to receive(:mtime).with(File.join(compliance_dir, 'profile.yaml')).and_return(Time.now) + allow(File).to receive(:read).with(File.join(compliance_dir, 'profile.yaml')).and_return(profile_yaml) + + allow(File).to receive(:size).with(File.join(compliance_dir, 'ces.yaml')).and_return(ces_yaml.length) + allow(File).to receive(:mtime).with(File.join(compliance_dir, 'ces.yaml')).and_return(Time.now) + allow(File).to receive(:read).with(File.join(compliance_dir, 'ces.yaml')).and_return(ces_yaml) + + allow(File).to receive(:size).with(File.join(compliance_dir, 'checks.yaml')).and_return(checks_yaml.length) + allow(File).to receive(:mtime).with(File.join(compliance_dir, 'checks.yaml')).and_return(Time.now) + allow(File).to receive(:read).with(File.join(compliance_dir, 'checks.yaml')).and_return(checks_yaml) + end +end