Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate Hiera values #20

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ group :tests do
gem 'rubocop-rspec', '~> 2.10'
gem 'rubocop-rake', '~> 0.6.0'
end

group :development do
gem 'pry', '~> 0.14.1'
gem 'pry-byebug', '~> 3.10'
gem 'rdoc', '~> 6.4'
end
14 changes: 9 additions & 5 deletions exe/scelint
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ if count.zero?
end

lint.errors.each do |error|
warn error
warn "ERROR: #{error}"
end

lint.warnings.each do |warning|
warn warning
warn "WARNING: #{warning}"
end

lint.notes.each do |note|
warn "NOTE: #{note}"
end

message = "Checked #{count} files."
Expand All @@ -30,9 +34,9 @@ else
exit_code = 1
end

if lint.warnings.count > 0
message += " #{lint.warnings.count} warnings."
end
message += " #{lint.warnings.count} warnings." if lint.warnings.count > 0

message += " #{lint.notes.count} notes." if lint.notes.count > 0

puts message
exit exit_code
246 changes: 241 additions & 5 deletions lib/scelint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def initialize(paths = ['.'])
@data = {}
@errors = []
@warnings = []
@notes = []

merged_data = {}

Expand All @@ -32,13 +33,16 @@ def initialize(paths = ['.'])
].each do |dir|
['yaml', 'json'].each do |type|
Dir.glob("#{path}/#{dir}/**/*.#{type}").each 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!(@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 @@ -52,6 +56,8 @@ def initialize(paths = ['.'])
lint(file, data)
end

validate

@data # rubocop:disable Lint/Void
end

Expand All @@ -65,7 +71,7 @@ def parse(file)
'json'
else
@errors << "#{file}: Failed to determine file type"
nil
return nil
end
begin
return YAML.safe_load(File.read(file)) if type == 'yaml'
Expand All @@ -74,13 +80,15 @@ def parse(file)
@errors << "#{file}: Failed to parse file: #{e.message}"
end

{}
nil
end

def files
@data.keys - ['merged data']
end

attr_reader :notes

attr_reader :warnings

attr_reader :errors
Expand Down Expand Up @@ -252,7 +260,6 @@ def check_remediation(file, check, remediation_section)

if remediation_section.is_a?(Hash)
remediation_section.each do |section, value|
# require 'pry-byebug'; binding.pry if section == 'disabled'
case section
when 'scan-false-positive', 'disabled'
value.each do |reason|
Expand Down Expand Up @@ -368,6 +375,233 @@ def check_checks(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) && key != 'version'
message = "#{file} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: key #{key} redefined"
if merged_data[key] == confined_value
@notes << message
else
@warnings << "#{message} (previous value: #{merged_data[key]}, new value: #{confined_value})"
end
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} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: Not a Puppet parameter"
next
end

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

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

unless specification['settings'].key?('value')
@warnings << "check #{check_name} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: 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?
@notes << "#{profile} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: 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
@errors << [
"#{profile} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: 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)
@notes << "#{profile} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: Merging Hash values for #{setting['parameter']}"
hiera[setting['parameter']] = hiera[setting['parameter']].deep_merge!(setting['value'])
next
end

if setting['value'].is_a?(Array)
@notes << "#{profile} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: Merging Array values for #{setting['parameter']}"
hiera[setting['parameter']] = (hiera[setting['parameter']] + setting['value']).uniq
next
end

message = "#{profile} #{confine.nil? ? '(no confinement data)' : "(confined: #{confine})"}: key #{setting['parameter']} redefined"
if hiera[setting['parameter']] == setting['value']
@notes << message
else
@warnings << "#{message} (previous value: #{hiera[setting['parameter']]}, new value: #{setting['value']})"
end
end

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

def validate
if profiles.nil?
@notes << '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, data)
check_version(file, data)
check_keys(file, data)
Expand All @@ -376,6 +610,8 @@ def lint(file, data)
check_ce(file, data['ce']) if data['ce']
check_checks(file, data['checks']) if data['checks']
check_controls(file, data['controls']) if data['controls']
rescue => e
@errors << "#{file}: #{e.message} (not a hash?)"
end
end
end
5 changes: 5 additions & 0 deletions spec/unit/scelint/lint_01_enforcement_confine_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ def module_path
it 'has no warnings' do
expect(lint.warnings).to eq([])
end

it 'has a note' do
expect(lint.notes.count).to eq(1)
expect(lint.notes[0]).to match(%r{no confinement data.*No Hiera values found})
end
end
end