abide_dev_utils 0.9.7 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +7 -1
  4. data/Gemfile.lock +82 -64
  5. data/Rakefile +28 -0
  6. data/abide_dev_utils.gemspec +3 -1
  7. data/lib/abide_dev_utils/cem/benchmark.rb +291 -0
  8. data/lib/abide_dev_utils/cem/coverage_report.rb +348 -0
  9. data/lib/abide_dev_utils/cem/generate/reference.rb +116 -0
  10. data/lib/abide_dev_utils/cem/generate.rb +10 -0
  11. data/lib/abide_dev_utils/cem/mapping/mapper.rb +155 -0
  12. data/lib/abide_dev_utils/cem.rb +74 -0
  13. data/lib/abide_dev_utils/cli/cem.rb +153 -0
  14. data/lib/abide_dev_utils/cli/jira.rb +1 -1
  15. data/lib/abide_dev_utils/cli/xccdf.rb +15 -1
  16. data/lib/abide_dev_utils/cli.rb +2 -0
  17. data/lib/abide_dev_utils/errors/cem.rb +22 -0
  18. data/lib/abide_dev_utils/errors/general.rb +8 -2
  19. data/lib/abide_dev_utils/errors/ppt.rb +4 -0
  20. data/lib/abide_dev_utils/errors.rb +6 -0
  21. data/lib/abide_dev_utils/files.rb +34 -0
  22. data/lib/abide_dev_utils/markdown.rb +104 -0
  23. data/lib/abide_dev_utils/ppt/facter_utils.rb +140 -0
  24. data/lib/abide_dev_utils/ppt/hiera.rb +297 -0
  25. data/lib/abide_dev_utils/ppt/puppet_module.rb +74 -0
  26. data/lib/abide_dev_utils/ppt.rb +3 -5
  27. data/lib/abide_dev_utils/validate.rb +14 -0
  28. data/lib/abide_dev_utils/version.rb +1 -1
  29. data/lib/abide_dev_utils/xccdf/diff/benchmark/number_title.rb +270 -0
  30. data/lib/abide_dev_utils/xccdf/diff/benchmark/profile.rb +104 -0
  31. data/lib/abide_dev_utils/xccdf/diff/benchmark/property.rb +127 -0
  32. data/lib/abide_dev_utils/xccdf/diff/benchmark/property_existence.rb +47 -0
  33. data/lib/abide_dev_utils/xccdf/diff/benchmark.rb +267 -0
  34. data/lib/abide_dev_utils/xccdf/diff/utils.rb +30 -0
  35. data/lib/abide_dev_utils/xccdf/diff.rb +233 -0
  36. data/lib/abide_dev_utils/xccdf/parser/objects/digest_object.rb +118 -0
  37. data/lib/abide_dev_utils/xccdf/parser/objects/numbered_object.rb +104 -0
  38. data/lib/abide_dev_utils/xccdf/parser/objects.rb +741 -0
  39. data/lib/abide_dev_utils/xccdf/parser.rb +52 -0
  40. data/lib/abide_dev_utils/xccdf.rb +14 -124
  41. data/new_diff.rb +48 -0
  42. metadata +60 -9
  43. data/lib/abide_dev_utils/ppt/coverage.rb +0 -86
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'abide_dev_utils/xccdf/diff/benchmark/number_title'
4
+ require 'abide_dev_utils/xccdf/diff/benchmark/property'
5
+ require 'abide_dev_utils/xccdf/diff/utils'
6
+ require 'abide_dev_utils/xccdf/parser'
7
+
8
+ module AbideDevUtils
9
+ module XCCDF
10
+ # Holds methods and classes used to diff XCCDF-derived objects.
11
+ module Diff
12
+ # Class for benchmark diffs
13
+ class BenchmarkDiff
14
+ include AbideDevUtils::XCCDF::Diff::BenchmarkPropertyDiff
15
+ attr_reader :self, :other, :opts
16
+
17
+ DEFAULT_OPTS = {
18
+ only_classes: %w[rule],
19
+ }.freeze
20
+
21
+ # Used for filtering by level and profile
22
+ LVL_PROF_DEFAULT = [nil, nil].freeze
23
+
24
+ # @param xml1 [String] path to the first benchmark XCCDF xml file
25
+ # @param xml2 [String] path to the second benchmark XCCDF xml file
26
+ # @param opts [Hash] options hash
27
+ def initialize(xml1, xml2, opts = {})
28
+ @self = new_benchmark(xml1)
29
+ @other = new_benchmark(xml2)
30
+ @opts = DEFAULT_OPTS.merge(DEFAULT_PROPERTY_DIFF_OPTS).merge(opts)
31
+ @levels = []
32
+ @profiles = []
33
+ end
34
+
35
+ def method_missing(method_name, *args, &block)
36
+ if opts.key?(method_name)
37
+ opts[method_name]
38
+ elsif @diff&.key?(method_name)
39
+ @diff[method_name]
40
+ else
41
+ super
42
+ end
43
+ end
44
+
45
+ def respond_to_missing?(method_name, include_private = false)
46
+ opts.key?(method_name) || @diff&.key?(method_name) || super
47
+ end
48
+
49
+ # Memoized getter for all "numbered" children for the "self" benchmark based on optional filters in opts
50
+ def self_numbered_children
51
+ @self_numbered_children ||= find_all_numbered_children(@self,
52
+ only_classes: opts[:only_classes],
53
+ level: opts[:level],
54
+ profile: opts[:profile])
55
+ end
56
+
57
+ # Memoized getter for all "numbered" children for the "other" benchmark based on optional filters in opts
58
+ def other_numbered_children
59
+ @other_numbered_children ||= find_all_numbered_children(@other,
60
+ only_classes: opts[:only_classes],
61
+ level: opts[:level],
62
+ profile: opts[:profile])
63
+ end
64
+
65
+ # Basic title diff
66
+ def numbered_children_title_diff
67
+ {
68
+ self: self_numbered_children.map { |c| c.title.to_s } - other_numbered_children.map { |c| c.title.to_s },
69
+ other: other_numbered_children.map { |c| c.title.to_s } - self_numbered_children.map { |c| c.title.to_s },
70
+ }
71
+ end
72
+
73
+ # Returns the output of a NumberTitleDiff object's diff function based on self_numbered_children and other_numbered_children
74
+ def number_title_diff
75
+ NumberTitleDiff.new(self_numbered_children, other_numbered_children).diff
76
+ end
77
+
78
+ # Hash of data about the "self" benchmark and the diff parameters
79
+ def from_benchmark
80
+ @from_benchmark ||= from_to_hash(@self)
81
+ end
82
+
83
+ # Hash of data about the "other" benchmark and the diff parameters
84
+ def to_benchmark
85
+ @to_benchmark ||= from_to_hash(@other)
86
+ end
87
+
88
+ # All levels that numbered children have been filtered on
89
+ def levels
90
+ @levels.flatten.uniq.empty? ? [:all] : @levels.flatten.uniq
91
+ end
92
+
93
+ # All profiles that numbered children have been filtered on
94
+ def profiles
95
+ @profiles.flatten.uniq.empty? ? [:all] : @profiles.flatten.uniq
96
+ end
97
+
98
+ # Returns a diff of the changes from the "self" xml (xml1) to the "other" xml (xml2)
99
+ # This function is memoized because the diff operation is expensive. To run the diff
100
+ # operation again, set the `new` parameter to `true`
101
+ # @param new [Boolean] Set to `true` to force a new diff operation
102
+ # return [Hash] the diff in hash format
103
+ def diff(new: false)
104
+ return @diff if @diff && !new
105
+
106
+ @diff = {}
107
+ @diff[:number_title] = number_title_diff
108
+ { from: from_benchmark, to: to_benchmark, diff: @diff }
109
+ end
110
+
111
+ private
112
+
113
+ # Returns a Benchmark object from a XCCDF xml file path
114
+ # @param xml [String] path to a XCCDF xml file
115
+ def new_benchmark(xml)
116
+ AbideDevUtils::XCCDF::Parser.parse(xml)
117
+ end
118
+
119
+ # Returns a hash of benchmark data
120
+ # @param obj [AbideDevUtils::XCCDF::Parser::Objects::Benchmark]
121
+ # @return [Hash] diff-relevant benchmark information
122
+ def from_to_hash(obj)
123
+ {
124
+ title: obj.title.to_s,
125
+ version: obj.version.to_s,
126
+ compared: {
127
+ levels: levels,
128
+ profiles: profiles,
129
+ }
130
+ }
131
+ end
132
+
133
+ # Function to check if a numbered child meets inclusion criteria based on filtering
134
+ # options.
135
+ # @param child [Object] XCCDF parser object that includes AbideDevUtils::XCCDF::Parser::Objects::NumberedObject.
136
+ # @param only_classes [Array] class names as strings. When this is specified, only objects with those class names will be considered.
137
+ # @param level [String] Specifies the benchmark profile level to filter children on. Only applies to Rules linked to Profiles that have levels.
138
+ # @param profile [String] Specifies the benchmark profile to filter children on. Only applies to Rules that have linked Profiles.
139
+ # @return [TrueClass] if child meets all filtering criteria and should be included in the set.
140
+ # @return [FalseClass] if child does not meet all criteria and should be excluded from the set.
141
+ def include_numbered_child?(child, only_classes: [], level: nil, profile: nil)
142
+ return false unless valid_class?(child, only_classes)
143
+ return true if level.nil? && profile.nil?
144
+
145
+ validated_props = valid_profile_and_level(child, level, profile)
146
+ should_include = validated_props.none?(&:nil?)
147
+ new_validated_props_vars(validated_props) if should_include
148
+ should_include
149
+ end
150
+
151
+ # Adds level and profile to respective instance vars
152
+ # @param validated_props [Array] two item array: first item - profile level, second item - profile title
153
+ def new_validated_props_vars(validated_props)
154
+ @levels << validated_props[0]
155
+ @profiles << validated_props[1]
156
+ end
157
+
158
+ # Checks if the child's class is in the only_classes list, if applicable
159
+ # @param child [Object] the child whose class will be checked
160
+ # @param only_classes [Array] an array of class names as strings
161
+ # @return [TrueClass] if only_classes is empty or if child's class is in only_classes
162
+ # @return [FalseClass] if only_classes is not empty and child's class is not in only_classes
163
+ def valid_class?(child, only_classes = [])
164
+ only_classes.empty? || only_classes.include?(child.label)
165
+ end
166
+
167
+ # Returns a two-item array of a valid level and a valid profile
168
+ # @param child [Object] XCCDF parser object or array of XCCDF parser objects
169
+ # @param level [String] a profile level
170
+ # @param profile [String] a partial / full profile title
171
+ # @return [Array] two-item array: first item - Profile level or nil, second item - Profile title or nil
172
+ def valid_profile_and_level(child, level, profile)
173
+ return LVL_PROF_DEFAULT unless child.respond_to?(:linked_profile)
174
+
175
+ validate_profile_obj(child.linked_profile, level, profile)
176
+ end
177
+
178
+ # Returns array (or array of arrays) of valid level and valid profile based on child's linked profiles
179
+ # @param obj [Object] AbideDevUtils::XCCDF::Parser::Objects::Profile objects or array of them
180
+ # @param level [String] a profile level
181
+ # @param profile [String] a partial / full profile title
182
+ # @return [Array] two-item array: first item - Profile level or nil, second item - Profile title or nil
183
+ # @return [Array] Array of two item arrays if `obj` is an array of profiles
184
+ def validate_profile_obj(obj, level, profile)
185
+ return LVL_PROF_DEFAULT if obj.nil?
186
+ return validate_profile_objs(obj, level, profile) if obj.respond_to?(:each)
187
+
188
+ validated_level = valid_level?(obj, level) ? obj.level : nil
189
+ validated_profile = valid_profile?(obj, profile) ? obj.title.to_s : nil
190
+ [validated_level, validated_profile]
191
+ end
192
+
193
+ # Returns array of arrays of valid levels and valid profiles based on all children's linked profiles
194
+ # @param objs [Array] Array of AbideDevUtils::XCCDF::Parser::Objects::Profile objects
195
+ # @param level [String] a profile level
196
+ # @param profile [String] a partial / full profile title
197
+ # @return [Array] Array of two-item arrays
198
+ def validate_profile_objs(objs, level, profile)
199
+ found = [LVL_PROF_DEFAULT]
200
+ objs.each do |obj|
201
+ validated = validate_profile_obj(obj, level, profile)
202
+ next if validated.any?(&:nil?)
203
+
204
+ found << validated
205
+ end
206
+ found
207
+ end
208
+
209
+ # Checks if a given object has a matching level. This is done
210
+ # via a basic regex match on the object's #level method
211
+ # @param obj [AbideDevUtils::XCCDF::Parser::Objects::Profile] the profile object
212
+ # @param level [String] a level to check
213
+ # @return [TrueClass] if level matches
214
+ # @return [FalseClass] if level does not match
215
+ def valid_level?(obj, level)
216
+ return true if level.nil? || obj.level.nil?
217
+
218
+ obj.level.match?(/#{Regexp.escape level}/i)
219
+ end
220
+
221
+ # Checks if a given profile object has a matching title or id. This
222
+ # is done with a regex match against the profile's title as a string
223
+ # or against it's object string representation (the ID).
224
+ # @param obj [AbideDevUtils::XCCDF::Parser::Objects::Profile] the profile object
225
+ # @param profile [String] a profile string to check
226
+ # @return [TrueClass] if `profile` matches either the profile's title or ID
227
+ # @return [FalseClass] if `profile` matches neither
228
+ def valid_profile?(obj, profile)
229
+ return true if profile.nil?
230
+
231
+ obj.title.to_s.match?(/#{Regexp.escape profile}/i) ||
232
+ obj.to_s.match?(/#{Regexp.escape profile}/i)
233
+ end
234
+
235
+ # Finds all children of the benchmark that implement AbideDevUtils::XCCDF::Parser::Objects::NumberedObject
236
+ # that are not filtered out based on optional parameters. This method recursively walks down the hierarchy
237
+ # of the benchmark to ensure that all deeply nested objects are accounted for.
238
+ # @param benchmark [AbideDevUtils::XCCDF::Parser::Objects::Benchmark] the benchmark to check
239
+ # @param only_classes [Array] An array of class names. Only children with the specified class names will be returned
240
+ # @param level [String] A profile level. Only children that have linked profiles that match this level will be returned
241
+ # @param profile [String] A profile title / id. Only children that have linked profiles that match this title / id will be returned
242
+ # @param numbered_children [Array] An array of numbered children to check. If this is empty, we start checking at the top-level of
243
+ # the benchmark. To get all of the benchmark's numbered children, this should be an empty array when calling this method.
244
+ # @return [Array] A sorted array of numbered children.
245
+ def find_all_numbered_children(benchmark, only_classes: [], level: nil, profile: nil, numbered_children: [])
246
+ benchmark.find_children_that_respond_to(:number).each do |child|
247
+ numbered_children << child if include_numbered_child?(child,
248
+ only_classes: only_classes,
249
+ level: level,
250
+ profile: profile)
251
+ find_all_numbered_children(child,
252
+ only_classes: only_classes,
253
+ level: level,
254
+ profile: profile,
255
+ numbered_children: numbered_children)
256
+ end
257
+ numbered_children.sort
258
+ end
259
+
260
+ # Returns a subset of benchmark children based on a property of that child and search values
261
+ def find_subset_of_children(children, property, search_values)
262
+ children.select { |c| search_values.include?(c.send(property)) }
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbideDevUtils
4
+ module XCCDF
5
+ module Diff
6
+ # Holds the result of a diff on a per-item basis.
7
+ class DiffChangeResult
8
+ attr_reader :type, :old_value, :new_value
9
+
10
+ def initialize(type, old_value, new_value)
11
+ @type = type
12
+ @old_value = old_value
13
+ @new_value = new_value
14
+ end
15
+
16
+ def to_h
17
+ { type: type, old_value: old_value, new_value: new_value }
18
+ end
19
+
20
+ def to_a
21
+ [type, old_value, new_value]
22
+ end
23
+
24
+ def to_s
25
+ "#{type}: #{old_value} -> #{new_value}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hashdiff'
4
+
5
+ module AbideDevUtils
6
+ module XCCDF
7
+ # Contains methods and classes used to diff XCCDF-derived objects.
8
+ module Diff
9
+ DEFAULT_DIFF_OPTS = {
10
+ similarity: 1,
11
+ strict: true,
12
+ strip: true,
13
+ array_path: true,
14
+ delimiter: '//',
15
+ use_lcs: true,
16
+ }.freeze
17
+
18
+ # Represents a change in a diff.
19
+ class ChangeSet
20
+ attr_reader :change, :key, :value, :value_to
21
+
22
+ def initialize(change:, key:, value:, value_to: nil)
23
+ validate_change(change)
24
+ @change = change
25
+ @key = key
26
+ @value = value
27
+ @value_to = value_to
28
+ end
29
+
30
+ def to_s
31
+ value_change_string(value, value_to)
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ change: change,
37
+ key: key,
38
+ value: value,
39
+ value_to: value_to,
40
+ }
41
+ end
42
+
43
+ def can_merge?(other)
44
+ return false unless (change == '-' && other.change == '+') || (change == '+' && other.change == '-')
45
+ return false unless key == other.key || value_hash_equality(other)
46
+
47
+ true
48
+ end
49
+
50
+ def merge(other)
51
+ unless can_merge?(other)
52
+ raise ArgumentError, 'Cannot merge. Possible causes: change is identical; key or value do not match'
53
+ end
54
+
55
+ new_to_value = value == other.value ? nil : other.value
56
+ ChangeSet.new(
57
+ change: '~',
58
+ key: key,
59
+ value: value,
60
+ value_to: new_to_value
61
+ )
62
+ end
63
+
64
+ def merge!(other)
65
+ new_props = merge(other)
66
+ @change = new_props.change
67
+ @key = new_props.key
68
+ @value = new_props.value
69
+ @value_to = new_props.value_to
70
+ end
71
+
72
+ private
73
+
74
+ def value_hash_equality(other)
75
+ equality = false
76
+ value.each do |k, v|
77
+ equality = true if v == other.value[k]
78
+ end
79
+ equality
80
+ end
81
+
82
+ def validate_change(chng)
83
+ raise ArgumentError, "Change type #{chng} in invalid" unless ['+', '-', '~'].include?(chng)
84
+ end
85
+
86
+ def change_string(chng)
87
+ case chng
88
+ when '-'
89
+ 'Remove'
90
+ when '+'
91
+ 'Add'
92
+ else
93
+ 'Change'
94
+ end
95
+ end
96
+
97
+ def value_change_string(value, value_to)
98
+ change_str = []
99
+ change_diff = Hashdiff.diff(value, value_to || {}, AbideDevUtils::XCCDF::Diff::DEFAULT_DIFF_OPTS)
100
+ return if change_diff.empty?
101
+ return value_change_string_single_type(change_diff, value) if all_single_change_type?(change_diff)
102
+
103
+ change_diff.each do |chng|
104
+ change_str << if chng.length == 4
105
+ "#{change_string(chng[0])} #{chng[1][0]} \"#{chng[2]}\" to \"#{chng[3]}\""
106
+ else
107
+ "#{change_string(chng[0])} #{chng[1][0]} with value #{chng[2]}"
108
+ end
109
+ end
110
+ change_str.join(', ')
111
+ end
112
+
113
+ def value_change_string_single_type(change_diff, value)
114
+ "#{change_string(change_diff[0][0])} #{value[:number]} - #{value[:level]} - #{value[:title]}"
115
+ end
116
+
117
+ def all_single_change_type?(change_diff)
118
+ change_diff.length > 1 && change_diff.map { |x| x[0] }.uniq.length == 1
119
+ end
120
+ end
121
+
122
+ # Class used to diff two Benchmark profiles.
123
+ class ProfileDiff
124
+ def initialize(profile_a, profile_b, opts = {})
125
+ @profile_a = profile_a
126
+ @profile_b = profile_b
127
+ @opts = opts
128
+ end
129
+
130
+ def diff
131
+ @diff ||= new_diff
132
+ end
133
+
134
+ private
135
+
136
+ def new_diff
137
+ Hashdiff.diff(@profile_a, @profile_b, AbideDevUtils::XCCDF::Diff::DEFAULT_DIFF_OPTS).each_with_object({}) do |change, diff|
138
+ val_to = change.length == 4 ? change[3] : nil
139
+ change_key = change[2].is_a?(Hash) ? change[2][:title] : change[2]
140
+ if diff.key?(change_key)
141
+ diff[change_key] = merge_changes(
142
+ [
143
+ diff[change_key][0],
144
+ ChangeSet.new(change: change[0], key: change[1], value: change[2], value_to: val_to),
145
+ ]
146
+ )
147
+ else
148
+ diff[change_key] = [ChangeSet.new(change: change[0], key: change[1], value: change[2], value_to: val_to)]
149
+ end
150
+ end
151
+ end
152
+
153
+ def merge_changes(changes)
154
+ return changes if changes.length < 2
155
+
156
+ if changes[0].can_merge?(changes[1])
157
+ [changes[0].merge(changes[1])]
158
+ else
159
+ changes
160
+ end
161
+ end
162
+ end
163
+
164
+ # Class used to diff two AbideDevUtils::XCCDF::Benchmark objects.
165
+ class BenchmarkDiff
166
+ def initialize(benchmark_a, benchmark_b, opts = {})
167
+ @benchmark_a = benchmark_a
168
+ @benchmark_b = benchmark_b
169
+ @opts = opts
170
+ end
171
+
172
+ def properties_to_diff
173
+ @properties_to_diff ||= %i[title version profiles]
174
+ end
175
+
176
+ def title_version
177
+ @title_version ||= diff_title_version
178
+ end
179
+
180
+ def profiles(profile: nil)
181
+ @profiles ||= diff_profiles(profile: profile)
182
+ end
183
+
184
+ private
185
+
186
+ def diff_title_version
187
+ diff = Hashdiff.diff(
188
+ @benchmark_a.to_h.reject { |k, _| k.to_s == 'profiles' },
189
+ @benchmark_b.to_h.reject { |k, _| k.to_s == 'profiles' },
190
+ AbideDevUtils::XCCDF::Diff::DEFAULT_DIFF_OPTS
191
+ )
192
+ diff.each_with_object({}) do |change, tdiff|
193
+ val_to = change.length == 4 ? change[3] : nil
194
+ change_key = change[2].is_a?(Hash) ? change[2][:title] : change[2]
195
+ tdiff[change_key] = [] unless tdiff.key?(change_key)
196
+ tdiff[change_key] << ChangeSet.new(change: change[0], key: change[1], value: change[2], value_to: val_to)
197
+ end
198
+ end
199
+
200
+ def diff_profiles(profile: nil)
201
+ diff = {}
202
+ other_hash = @benchmark_b.to_h[:profiles]
203
+ @benchmark_a.to_h[:profiles].each do |name, data|
204
+ next if profile && profile != name
205
+
206
+ diff[name] = ProfileDiff.new(data, other_hash[name], @opts).diff
207
+ end
208
+ diff
209
+ end
210
+ end
211
+
212
+ def self.diff_benchmarks(benchmark_a, benchmark_b, opts = {})
213
+ profile = opts.fetch(:profile, nil)
214
+ profile_key = profile.nil? ? 'all_profiles' : profile
215
+ benchmark_diff = BenchmarkDiff.new(benchmark_a, benchmark_b, opts)
216
+ transform_method_sym = opts.fetch(:raw, false) ? :to_h : :to_s
217
+ diff = if profile.nil?
218
+ benchmark_diff.profiles.each do |_, v|
219
+ v.transform_values! { |x| x.map!(&transform_method_sym) }
220
+ end
221
+ else
222
+ benchmark_diff.profiles(profile: profile)[profile].transform_values! { |x| x.map!(&transform_method_sym) }
223
+ end
224
+ return diff.values.flatten if opts.fetch(:raw, false)
225
+
226
+ {
227
+ 'benchmark' => benchmark_diff.title_version.transform_values { |x| x.map!(&:to_s) },
228
+ profile_key => diff.values.flatten,
229
+ }
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbideDevUtils
4
+ module XCCDF
5
+ module Parser
6
+ module Objects
7
+ # Methods for providing and comparing hash digests of objects
8
+ module DigestObject
9
+ # Excludes instance variables that are not used in the digest
10
+ def exclude_from_digest(exclude)
11
+ unless exclude.is_a?(Array) || exclude.is_a?(Symbol)
12
+ raise ArgumentError, 'exclude must be an Array or Symbol'
13
+ end
14
+
15
+ @exclude_from_digest ||= []
16
+ if exclude.is_a?(Array)
17
+ exclude.map! do |e|
18
+ normalize_exclusion(e)
19
+ end
20
+ @exclude_from_digest += exclude
21
+ else
22
+ @exclude_from_digest << normalize_exclusion(exclude)
23
+ end
24
+ @exclude_from_digest.uniq!
25
+ end
26
+
27
+ # Exclusions are instance variable symbols and must be prefixed with "@"
28
+ def normalize_exclusion(exclude)
29
+ exclude = "@#{exclude}" unless exclude.to_s.start_with?('@')
30
+ exclude.to_sym
31
+ end
32
+
33
+ # Checks SHA256 digest equality
34
+ def digest_equal?(other)
35
+ digest == other.digest
36
+ end
37
+
38
+ # Returns a SHA256 digest of the object, including the digests of all
39
+ # children
40
+ def digest
41
+ return @digest if defined?(@digest)
42
+
43
+ parts = [labeled_self_digest]
44
+ children.each { |child| parts << child.digest } unless children.empty?
45
+ @digest = parts.join('|')
46
+ @digest
47
+ end
48
+
49
+ # Returns a labeled digest of the current object
50
+ def labeled_self_digest
51
+ return "#{label}:#{Digest::SHA256.hexdigest(digestable_instance_variables)}" if respond_to?(:label)
52
+
53
+ "none:#{Digest::SHA256.hexdigest(digestable_instance_variables)}"
54
+ end
55
+
56
+ # Returns a string of all instance variable values that are not nil, empty, or excluded
57
+ def digestable_instance_variables
58
+ instance_vars = instance_variables.reject { |iv| @exclude_from_digest.include?(iv) }.sort_by!(&:to_s)
59
+ return 'empty' if instance_vars.empty?
60
+
61
+ var_vals = instance_vars.map { |iv| instance_variable_get(iv) }
62
+ var_vals.reject! { |v| v.nil? || v.empty? }
63
+ return 'empty' if var_vals.empty?
64
+
65
+ var_vals.join
66
+ end
67
+
68
+ # Compares two objects by their SHA256 digests
69
+ # and returns the degree to which they are similar
70
+ # as a percentage.
71
+ def digest_similarity(other, only_labels: [], label_weights: {})
72
+ digest_parts = sorted_digest_parts(digest)
73
+ number_compared = 0
74
+ cumulative_similarity = 0.0
75
+ digest_parts.each do |digest_part|
76
+ label, self_digest = split_labeled_digest(digest_part)
77
+ next unless only_labels.empty? || only_labels.include?(label)
78
+
79
+ label_weight = label_weights.key?(label) ? label_weights[label] : 1.0
80
+ sorted_digest_parts(other.digest).each do |other_digest_part|
81
+ other_label, other_digest = split_labeled_digest(other_digest_part)
82
+ next unless (label == other_label) && (self_digest == other_digest)
83
+
84
+ number_compared += 1
85
+ cumulative_similarity += 1.0 * label_weight
86
+ break # break when found
87
+ end
88
+ end
89
+ cumulative_similarity / (number_compared.zero? ? 1.0 : number_compared)
90
+ end
91
+
92
+ def sorted_digest_parts(dgst)
93
+ @sorted_digest_parts_cache = {} unless defined?(@sorted_digest_parts_cache)
94
+ return @sorted_digest_parts_cache[dgst] if @sorted_digest_parts_cache.key?(dgst)
95
+
96
+ @sorted_digest_parts_cache ||= {}
97
+ @sorted_digest_parts_cache[dgst] = dgst.split('|').sort_by { |part| split_labeled_digest(part).first }
98
+ @sorted_digest_parts_cache[dgst]
99
+ end
100
+
101
+ # If one of the digest parts is nil and the other is not, we can't compare
102
+ def non_compatible?(digest_part, other_digest_part)
103
+ (digest_part.nil? || other_digest_part.nil?) && digest_part != other_digest_part
104
+ end
105
+
106
+ # Splits a digest into a label and digest
107
+ def split_labeled_digest(digest_part)
108
+ @labeled_digest_part_cache = {} unless defined?(@labeled_digest_part_cache)
109
+ return @labeled_digest_part_cache[digest_part] if @labeled_digest_part_cache.key?(digest_part)
110
+
111
+ @labeled_digest_part_cache[digest_part] = digest_part.split(':')
112
+ @labeled_digest_part_cache[digest_part]
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end