abide_dev_utils 0.9.7 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbideDevUtils
4
+ module XCCDF
5
+ module Parser
6
+ module Objects
7
+ # Methods for interacting with objects that have numbers (e.g. Group, Rule, etc.)
8
+ # This module is included in the Benchmark class and Group / Rule classes
9
+ module NumberedObject
10
+ include ::Comparable
11
+
12
+ def <=>(other)
13
+ return 0 if number_eq(number, other.number)
14
+ return 1 if number_gt(number, other.number)
15
+ return -1 if number_lt(number, other.number)
16
+ end
17
+
18
+ def number_eq(this_num, other_num)
19
+ this_num == other_num
20
+ end
21
+
22
+ def number_parent_of?(this_num, other_num)
23
+ return false if number_eq(this_num, other_num)
24
+
25
+ # We split the numbers into parts and compare the resulting arrays
26
+ num1_parts = this_num.to_s.split('.')
27
+ num2_parts = other_num.to_s.split('.')
28
+ # For this_num to be a parent of other_num, the number of parts in
29
+ # this_num must be less than the number of parts in other_num.
30
+ # Additionally, each part of this_num must be equal to the parts of
31
+ # other_num at the same index.
32
+ # Example: this_num = '1.2.3' and other_num = '1.2.3.4'
33
+ # In this case, num1_parts = ['1', '2', '3'] and num2_parts = ['1', '2', '3', '4']
34
+ # So, this_num is a parent of other_num because at indexes 0, 1, and 2
35
+ # of num1_parts and num2_parts, the parts are equal.
36
+ num1_parts.length < num2_parts.length &&
37
+ num2_parts[0..(num1_parts.length - 1)] == num1_parts
38
+ end
39
+
40
+ def number_child_of?(this_num, other_num)
41
+ number_parent_of?(other_num, this_num)
42
+ end
43
+
44
+ def number_gt(this_num, other_num)
45
+ return false if number_eq(this_num, other_num)
46
+ return true if number_parent_of?(this_num, other_num)
47
+
48
+ num1_parts = this_num.to_s.split('.')
49
+ num2_parts = other_num.to_s.split('.')
50
+ num1_parts.zip(num2_parts).each do |num1_part, num2_part|
51
+ next if num1_part == num2_part # we skip past equal parts
52
+
53
+ # If num1_part is nil that means that we've had equal numbers so far.
54
+ # Therfore, this_num is greater than other num because of the
55
+ # hierarchical nature of the numbers.
56
+ # Example: this_num = '1.2' and other_num = '1.2.3'
57
+ # In this case, num1_part is nil and num2_part is '3'
58
+ # So, this_num is greater than other_num
59
+ return true if num1_part.nil?
60
+ # If num2_part is nil that means that we've had equal numbers so far.
61
+ # Therfore, this_num is less than other num because of the
62
+ # hierarchical nature of the numbers.
63
+ # Example: this_num = '1.2.3' and other_num = '1.2'
64
+ # In this case, num1_part is '3' and num2_part is nil
65
+ # So, this_num is less than other_num
66
+ return false if num2_part.nil?
67
+
68
+ return num1_part.to_i > num2_part.to_i
69
+ end
70
+ end
71
+
72
+ def number_lt(this_num, other_num)
73
+ number_gt(other_num, this_num)
74
+ end
75
+
76
+ # This method will recursively walk the tree to find the first
77
+ # child, grandchild, etc. that has a number method and returns the
78
+ # matching number.
79
+ # @param [String] number The number to find in the tree
80
+ # @return [Group] The first child, grandchild, etc. that has a matching number
81
+ # @return [Rule] The first child, grandchild, etc. that has a matching number
82
+ # @return [nil] If no child, grandchild, etc. has a matching number
83
+ def search_children_by_number(number)
84
+ find_children_that_respond_to(:number).find do |child|
85
+ if number_eq(child.number, number)
86
+ child
87
+ elsif number_parent_of?(child.number, number)
88
+ # We recursively search the child for its child with the number
89
+ # if our number is a parent of the child's number
90
+ return child.search_children_by_number(number)
91
+ end
92
+ end
93
+ end
94
+
95
+ def find_child_by_number(number)
96
+ find_children_that_respond_to(:number).find do |child|
97
+ number_eq(child.number, number)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end