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.
- checksums.yaml +4 -4
- data/Gemfile.lock +24 -8
- data/abide_dev_utils.gemspec +1 -0
- data/lib/abide_dev_utils/cem.rb +72 -0
- data/lib/abide_dev_utils/cli/cem.rb +73 -0
- data/lib/abide_dev_utils/cli/xccdf.rb +12 -1
- data/lib/abide_dev_utils/cli.rb +2 -0
- data/lib/abide_dev_utils/files.rb +34 -0
- data/lib/abide_dev_utils/version.rb +1 -1
- data/lib/abide_dev_utils/xccdf/diff/benchmark/number_title.rb +270 -0
- data/lib/abide_dev_utils/xccdf/diff/benchmark/profile.rb +104 -0
- data/lib/abide_dev_utils/xccdf/diff/benchmark/property.rb +127 -0
- data/lib/abide_dev_utils/xccdf/diff/benchmark/property_existence.rb +47 -0
- data/lib/abide_dev_utils/xccdf/diff/benchmark.rb +267 -0
- data/lib/abide_dev_utils/xccdf/diff/utils.rb +30 -0
- data/lib/abide_dev_utils/xccdf/diff.rb +233 -0
- data/lib/abide_dev_utils/xccdf/parser/objects/digest_object.rb +118 -0
- data/lib/abide_dev_utils/xccdf/parser/objects/numbered_object.rb +104 -0
- data/lib/abide_dev_utils/xccdf/parser/objects.rb +741 -0
- data/lib/abide_dev_utils/xccdf/parser.rb +52 -0
- data/lib/abide_dev_utils/xccdf.rb +9 -122
- data/new_diff.rb +48 -0
- metadata +34 -6
@@ -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
|