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,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'abide_dev_utils/xccdf/diff/benchmark/property_existence'
4
+
5
+ module AbideDevUtils
6
+ module XCCDF
7
+ module Diff
8
+ # Diffs two sets of XCCDF profiles.
9
+ class ProfileDiff
10
+ def initialize(profiles, other_profiles)
11
+ new_profile_rule_objs(profiles, other_profiles)
12
+ end
13
+
14
+ def diff_hash(diff_type, profile1, prof1_rules, profile2, prof2_rules)
15
+ {
16
+
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def new_profile_rule_objs(profiles, other_profiles)
23
+ profile_objs = containers_from_profile_list(profiles)
24
+ other_profile_objs = containers_from_profile_list(other_profiles)
25
+ @self_prop_checker = PropertyExistenceChecker.new(profile_objs, other_profile_objs)
26
+ @other_prop_checker = PropertyExistenceChecker.new(other_profile_objs, profile_objs)
27
+ profile_objs.map { |p| p.prop_checker = @self_prop_checker }
28
+ other_profile_objs.map { |p| p.prop_checker = @other_prop_checker }
29
+ @profile_rule_objs = profile_objs
30
+ @other_profile_rule_objs = other_profile_objs
31
+ end
32
+
33
+ def containers_from_profile_list(profile_list)
34
+ profile_list.each_with_object([]) do |profile, ary|
35
+ ary << ProfileRuleContainer.new(profile)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Checks property existence in both profiles.
41
+ class PropChecker < AbideDevUtils::XCCDF::Diff::Benchmark::PropertyExistence
42
+ def initialize(profile_rule_objs, other_profile_rule_objs)
43
+ super
44
+ @profile_rule_objs = profile_rule_objs
45
+ @other_profile_rule_objs = other_profile_rule_objs
46
+ @profiles = profile_rule_objs.map(&:profile)
47
+ @other_profiles = other_profile_rule_objs.map(&:profile)
48
+ end
49
+
50
+ def profile(profile)
51
+ profile_key = profile.respond_to?(:id) ? profile.id : profile
52
+ property_existence(profile_key, @profiles, @other_profiles)
53
+ end
54
+
55
+ def rule_in_profile(rule, profile, rule_key: :title)
56
+ rk = rule.respond_to?(rule_key) ? rule.send(rule_key) : rule
57
+ rules = @profiles.find { |p| p.id == profile }.linked_rule.map(&rk)
58
+ other_rules = @other_profiles.find { |p| p.id == profile }.linked_rule.map(&rk)
59
+ property_existence(rk, rules, other_rules)
60
+ end
61
+
62
+ def added_profiles
63
+ added(@other_profiles.map(&:id), @profiles.map(&:id))
64
+ end
65
+
66
+ def removed_profiles
67
+ removed(@profiles.map(&:id), @other_profiles.map(&:id))
68
+ end
69
+
70
+ def added_rules_by_profile
71
+ @rules_by_profile.each_with_object({}) do |(profile, rules), hsh|
72
+ next unless @other_rules_by_profile.key?(profile)
73
+
74
+ hsh[profile] = added(rules, @other_rules_by_profile[profile])
75
+ end
76
+ end
77
+
78
+ def removed_rules_by_profile
79
+ @rules_by_profile.each_with_object({}) do |(profile, rules), hsh|
80
+ next unless @other_rules_by_profile.key?(profile)
81
+
82
+ hsh[profile] = removed(rules, @other_rules_by_profile[profile])
83
+ end
84
+ end
85
+ end
86
+
87
+ class ProfileRuleContainer
88
+ include ::Comparable
89
+ attr_accessor :prop_checker
90
+ attr_reader :profile, :rules
91
+
92
+ def initialize(profile, prop_checker = nil)
93
+ @profile = profile
94
+ @rules = profile.linked_rule
95
+ @prop_checker = prop_checker
96
+ end
97
+
98
+ def <=>(other)
99
+ @profile.id <=> other.profile.id
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'amatch'
4
+
5
+ module AbideDevUtils
6
+ module XCCDF
7
+ module Diff
8
+ # Diffs benchmark properties.
9
+ module BenchmarkPropertyDiff
10
+ DEFAULT_PROPERTY_DIFF_OPTS = {
11
+ rule_properties_for_similarity: %i[title description rationale fixtext],
12
+ rule_properties_for_confidence: %i[description rationale fixtext],
13
+ rule_confidence_property_threshold: 0.7,
14
+ rule_confidence_total_threshold: 0.5,
15
+ digest_similarity_threshold: 0.75,
16
+ digest_similarity_label_weights: {
17
+ 'title' => 4.0,
18
+ },
19
+ digest_similarity_only_labels: %w[title description fixtext rationale],
20
+ digest_top_x_similarities: 10,
21
+ }.freeze
22
+
23
+ def safe_rule_prop(rule, prop)
24
+ rule.respond_to?(prop) ? rule.send(prop).to_s : :none
25
+ end
26
+
27
+ def self_rule_vals
28
+ @self_rule_vals ||= {}
29
+ end
30
+
31
+ def other_rule_vals
32
+ @other_rule_vals ||= {}
33
+ end
34
+
35
+ def add_rule_val(rule, prop, val, container: nil)
36
+ raise ArgumentError, 'container must not be nil' if container.nil?
37
+
38
+ return unless container.dig(rule, prop).nil?
39
+
40
+ container[rule] ||= {}
41
+ container[rule][prop] = val
42
+ end
43
+
44
+ def add_self_rule_val(rule, prop, val)
45
+ add_rule_val(rule, prop, val, container: self_rule_vals)
46
+ end
47
+
48
+ def add_other_rule_val(rule, prop, val)
49
+ add_rule_val(rule, prop, val, container: other_rule_vals)
50
+ end
51
+
52
+ def same_rule?(prop_similarities)
53
+ confidence_indicator = 0.0
54
+ opts[:rule_properties_for_confidence].each do |prop|
55
+ confidence_indicator += 1.0 if prop_similarities[prop] >= opts[:rule_confidence_property_threshold]
56
+ end
57
+ (confidence_indicator / opts[:rule_properties_for_confidence].length) >= opts[:rule_confidence_total_threshold]
58
+ end
59
+
60
+ def maxed_digest_similarities(child, other_children)
61
+ similarities = other_children.each_with_object([]) do |other_child, ary|
62
+ if other_child.digest_equal? child
63
+ ary << [1.0, other_child]
64
+ next
65
+ end
66
+
67
+ d_sim = child.digest_similarity(other_child,
68
+ only_labels: opts[:digest_similarity_only_labels],
69
+ label_weights: opts[:digest_similarity_label_weights])
70
+ ary << [d_sim, other_child]
71
+ end
72
+ max_digest_similarities(similarities)
73
+ end
74
+
75
+ def max_digest_similarities(digest_similarities)
76
+ digest_similarities.reject! { |s| s[0] < opts[:digest_similarity_threshold] }
77
+ return digest_similarities if digest_similarities.empty?
78
+
79
+ digest_similarities.max_by(opts[:digest_top_x_similarities]) { |s| s[0] }
80
+ end
81
+
82
+ def rule_property_similarity(rule1, rule2)
83
+ prop_similarities = {}
84
+ prop_diff = {}
85
+ opts[:rule_properties_for_similarity].each do |prop|
86
+ add_self_rule_val(rule1, prop, safe_rule_prop(rule1, prop).to_s)
87
+ add_other_rule_val(rule2, prop, safe_rule_prop(rule2, prop).to_s)
88
+ prop_similarities[prop] = self_rule_vals[rule1][prop].levenshtein_similar(other_rule_vals[rule2][prop])
89
+ if prop_similarities[prop] < 1.0
90
+ prop_diff[prop] = { self: self_rule_vals[rule1][prop], other: other_rule_vals[rule2][prop] }
91
+ end
92
+ end
93
+ total = prop_similarities.values.sum / opts[:rule_properties_for_similarity].length
94
+ {
95
+ total: total,
96
+ prop_similarities: prop_similarities,
97
+ prop_diff: prop_diff,
98
+ confident_same: same_rule?(prop_similarities),
99
+ }
100
+ end
101
+
102
+ def most_similar(child, maxed_digest_similarities)
103
+ most_similar_map = maxed_digest_similarities.each_with_object({}) do |similarity, h|
104
+ prop_similarities = rule_property_similarity(child, similarity[1])
105
+ if child.title.to_s == similarity[1].title.to_s
106
+ prop_similarities[:total] = 99.0 # magic number denoting a title match
107
+ end
108
+ h[prop_similarities[:total]] = { self: child, other: similarity[1] }.merge(prop_similarities)
109
+ end
110
+ most_similar_map[most_similar_map.keys.max]
111
+ end
112
+
113
+ def find_most_similar(children, other_children)
114
+ children.each_with_object({}) do |benchmark_child, h|
115
+ maxed_similarities = maxed_digest_similarities(benchmark_child, other_children)
116
+ next if maxed_similarities.empty?
117
+
118
+ best = most_similar(benchmark_child, maxed_similarities)
119
+ next if best.nil? || best.empty?
120
+
121
+ h[benchmark_child] = best
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbideDevUtils
4
+ module XCCDF
5
+ module Diff
6
+ # PropertyExistenceChecker provides methods to check existence state of various properties
7
+ class PropertyExistenceChecker
8
+ def initialize(*_args); end
9
+
10
+ # Compares two arrays (or other iterables implementing `#to_a`)
11
+ # containing properies and returns an array of the properties
12
+ # that are added by other_props but not in self_props.
13
+ def added(self_props, other_props)
14
+ other_props.to_a - self_props.to_a
15
+ end
16
+
17
+ # Compares two arrays (or other iterables implementing `#to_a`)
18
+ # containing properies and returns an array of the properties
19
+ # that are removed by other_props but exist in self_props.
20
+ def removed(this, other)
21
+ this.to_a - other.to_a
22
+ end
23
+
24
+ # Returns a hash of existence states and their inverse.
25
+ def self.inverse_existence_state
26
+ {
27
+ removed: :added,
28
+ added: :removed,
29
+ exists: :exists,
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def property_existence(property, self_props, other_props)
36
+ if self_props.include?(property) && !other_props.include?(property)
37
+ :removed
38
+ elsif !self_props.include?(property) && other_props.include?(property)
39
+ :added
40
+ else
41
+ :exists
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -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