abide_dev_utils 0.9.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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