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,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