Skip to content

Commit

Permalink
Validate Hiera values
Browse files Browse the repository at this point in the history
* Add methods for retrieving profile names and confines
* Compile data the way that compliance_markup does and validate the
  results
* Handle a few error cases

Fixes simp#14
  • Loading branch information
silug committed Oct 30, 2024
1 parent 63d115c commit 6f393e8
Show file tree
Hide file tree
Showing 2 changed files with 230 additions and 5 deletions.
5 changes: 3 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ group :tests do
end

group :development do
gem 'pry'
gem 'pry-byebug'
gem 'pry', '~> 0.14.1'
gem 'pry-byebug', '~> 3.10'
gem 'rdoc', '~> 6.4'
end
230 changes: 227 additions & 3 deletions lib/scelint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,16 @@ def initialize(paths = ['.'])
].each do |dir|
['yaml', 'json'].each do |type|
Dir.glob("#{path}/#{dir}/**/*.#{type}") do |file|
@data[file] = parse(file)
this_file = parse(file)
next if this_file.nil?
@data[file] = this_file
merged_data = merged_data.deep_merge!(Marshal.load(Marshal.dump(@data[file])))
end
end
end
elsif File.exist?(path)
@data[path] = parse(path)
this_file = parse(path)
@data[path] = this_file unless this_file.nil?
else
raise "Can't find path '#{path}'"
end
Expand All @@ -162,6 +165,8 @@ def initialize(paths = ['.'])
@data.each do |file, file_data|
lint(file, file_data)
end

validate
end

def parse(file)
Expand All @@ -183,7 +188,7 @@ def parse(file)
errors << "#{file}: Failed to parse file: #{e.message}"
end

{}
nil
end

def files
Expand Down Expand Up @@ -508,6 +513,223 @@ def check_checks(file, file_data)
end
end

def profiles
return @profiles unless @profiles.nil?

return nil unless @data.key?('merged data')
return nil unless @data['merged data']['profiles'].is_a?(Hash)

@profiles = @data['merged data']['profiles'].keys
end

def confines
return @confines unless @confines.nil?

confine = {}

@data.each do |key, value|
next if key == 'merged data'
next unless value.is_a?(Hash)

['profiles', 'ce', 'checks'].each do |type|
next unless value.key?(type)
next unless value[type].is_a?(Hash)

value[type].each do |_k, v|
next unless v.is_a?(Hash)
confine = confine.merge(v['confine']) if v.key?('confine')
end
end
end

@confines = []
index = 0
max_count = 1
confine.each { |_key, value| max_count *= Array(value).size }

confine.each do |key, value|
(index..(max_count - 1)).each do |i|
@confines[i] ||= {}
@confines[i][key] = Array(value)[i % Array(value).size]
end
end

@confines
end

def apply_confinement(file, data, confine)
return data unless data.is_a?(Hash)

class << self
def should_delete(file, key, specification, confine)
return false unless specification.key?('confine')

unless specification['confine'].is_a?(Hash)
@warnings << "#{file}: 'confine' is not a Hash in key #{key}"
return false
end

specification['confine'].each do |confinement_setting, confinement_value|
return true unless confine.is_a?(Hash)
return true unless confine.key?(confinement_setting)
Array(confine[confinement_setting]).each do |value|
return false if Array(confinement_value).include?(value)
end
end

true
end
end

value = Marshal.load(Marshal.dump(data))
value.delete_if { |key, specification| should_delete(file, key, specification, confine) }

value
end

def compile(profile, confine = nil)
merged_data = {}

# Pass 1: Merge everything with confined values removed.
@data.each do |file, data|
next if file == 'merged data'
data.each do |key, value|
confined_value = apply_confinement(file, value, confine)

unless confined_value.is_a?(Hash)
if merged_data.key?(key)
@warnings << "#{file}#{confine.nil? ? '' : " (confined: #{confine})"}: key #{key} redefined (previous value: #{merged_data[key]}, new value: #{confined_value})" unless key == 'version'
end

merged_data[key] = confined_value
next
end

merged_data[key] ||= {}
confined_value.each do |k, v|
merged_data[key][k] ||= {}
merged_data[key][k] = merged_data[key][k].deep_merge!(v, { knockout_prefix: '--' })
end
end
end

# Pass 2: Build a mapping of all of the checks we found.
check_map = {
'checks' => {},
'controls' => {},
'ces' => {},
}

merged_data['checks']&.each do |check_name, specification|
unless specification['type'] == 'puppet-class-parameter'
@warnings << "check #{check_name}: Not a Puppet parameter"
next
end

unless specification['settings'].is_a?(Hash)
@warnings << "check #{check_name}: Missing required 'settings' Hash"
next
end

unless specification['settings'].key?('parameter') && specification['settings']['parameter'].is_a?(String)
@warnings << "check #{check_name}: Missing required key 'parameter' or wrong data type"
next
end

unless specification['settings'].key?('value')
@warnings << "check #{check_name}: Missing required key 'value' for parameter #{specification['settings']['parameter']}"
next
end

check_map['checks'][check_name] = [specification]

specification['controls']&.each do |control_name, v|
next unless v
check_map['controls'][control_name] ||= []
check_map['controls'][control_name] << specification
end

specification['ces']&.each do |ce_name|
next unless merged_data['ce']&.key?(ce_name)

check_map['ces'][ce_name] ||= []
check_map['ces'][ce_name] << specification

merged_data['ce'][ce_name]['controls']&.each do |control_name, value|
next unless value

check_map['controls'][control_name] ||= []
check_map['controls'][control_name] << specification
end
end
end

# Pass 3: Extract the relevant Hiera values.
hiera_spec = []
info = merged_data['profiles'][profile]

['checks', 'controls', 'ces'].each do |map_type|
info[map_type]&.each do |key, value|
next unless value
next unless check_map[map_type]&.key?(key)
hiera_spec += check_map[map_type][key]
end
end

if hiera_spec.empty?
@warnings << "#{profile}: No Hiera values found"
return {}
end

hiera = {}

hiera_spec.each do |spec|
setting = spec['settings']

if hiera.key?(setting['parameter'])
if setting['value'].class.to_s != hiera[setting['parameter']].class.to_s
warnings << [
"#{profile}: key #{setting['parameter']} type mismatch",
"(previous value: #{hiera[setting['parameter']]} (#{hiera[setting['parameter']].class}),",
"new value: #{setting['value']} (#{setting['value'].class})",
].join(' ')
hiera[setting['parameter']] = setting['value']
next
end

if setting['value'].is_a?(Hash)
warn "#{profile}: Merging Hash values for #{setting['parameter']}"
hiera[setting['parameter']] = hiera[setting['parameter']].deep_merge!(setting['value'])
next
end

if setting['value'].is_a?(Array)
warn "#{profile}: Merging Array values for #{setting['parameter']}"
hiera[setting['parameter']] = (hiera[setting['parameter']] + setting['value']).uniq
next
end

warnings << "#{profile}: key #{setting['parameter']} redefined (previous value: #{hiera[setting['parameter']]}, new value: #{setting['value']}"
end

hiera[setting['parameter']] = setting['value']
end
end

def validate
if profiles.nil?
@warnings << 'No profiles found, unable to validate Hiera data'
return nil
end

profiles.each do |profile|
compile(profile)
confines.each do |confine|
compile(profile, confine)
end
end
end

def lint(file, file_data)
check_version(file, file_data)
check_keys(file, file_data)
Expand All @@ -516,6 +738,8 @@ def lint(file, file_data)
check_ce(file, file_data['ce']) if file_data['ce']
check_checks(file, file_data['checks']) if file_data['checks']
check_controls(file, file_data['controls']) if file_data['controls']
rescue => e
@errors << "#{file}: #{e.message} (not a hash?)"
end
end
end

0 comments on commit 6f393e8

Please sign in to comment.