abide_dev_utils 0.6.0 → 0.9.3

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.
@@ -1,29 +1,645 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'abide_dev_utils/output'
3
+ require 'yaml'
4
+ require 'hashdiff'
5
+ require 'nokogiri'
4
6
  require 'abide_dev_utils/validate'
5
- require 'abide_dev_utils/xccdf/cis'
7
+ require 'abide_dev_utils/errors/xccdf'
8
+ require 'abide_dev_utils/output'
6
9
 
7
10
  module AbideDevUtils
11
+ # Contains modules and classes for working with XCCDF files
8
12
  module XCCDF
9
- def self.parse(xccdf_file)
10
- AbideDevUtils::Validate.file(xccdf_file)
11
- Nokogiri.XML(File.open(xccdf_file))
13
+ # Generate map for CEM
14
+ def self.gen_map(xccdf_file, **opts)
15
+ type = opts.fetch(:type, 'cis')
16
+ case type.downcase
17
+ when 'cis'
18
+ Benchmark.new(xccdf_file).gen_map(**opts)
19
+ else
20
+ raise AbideDevUtils::Errors::UnsupportedXCCDFError, "XCCDF type #{type} is unsupported!"
21
+ end
12
22
  end
13
23
 
14
- def self.to_hiera(xccdf_file, opts = {})
24
+ # Converts and xccdf file to a Hiera representation
25
+ def self.to_hiera(xccdf_file, opts)
15
26
  type = opts.fetch(:type, 'cis')
16
27
  case type.downcase
17
28
  when 'cis'
18
- AbideDevUtils::XCCDF::CIS::Hiera.new(xccdf_file, parent_key_prefix: opts[:parent_key_prefix], num: opts[:num])
29
+ Benchmark.new(xccdf_file).to_hiera(**opts)
19
30
  else
20
- AbideDevUtils::Output.simple("XCCDF type #{type} is unsupported!")
31
+ raise AbideDevUtils::Errors::UnsupportedXCCDFError, "XCCDF type #{type} is unsupported!"
32
+ end
33
+ end
34
+
35
+ # Diffs two xccdf files
36
+ def self.diff(file1, file2, opts)
37
+ bm1 = Benchmark.new(file1)
38
+ bm2 = Benchmark.new(file2)
39
+ profile = opts.fetch(:profile, nil)
40
+ profile_diff = if profile.nil?
41
+ bm1.diff_profiles(bm2).each do |_, v|
42
+ v.transform_values! { |x| x.map!(&:to_s) }
43
+ end
44
+ else
45
+ bm1.diff_profiles(bm2)[profile].transform_values! { |x| x.map!(&:to_s) }
46
+ end
47
+ profile_key = profile.nil? ? 'all_profiles' : profile
48
+ {
49
+ 'benchmark' => bm1.diff_title_version(bm2),
50
+ profile_key => profile_diff
51
+ }
52
+ end
53
+
54
+ # Common constants and methods included by nearly everything else
55
+ module Common
56
+ XPATHS = {
57
+ benchmark: {
58
+ all: 'xccdf:Benchmark',
59
+ title: 'xccdf:Benchmark/xccdf:title',
60
+ version: 'xccdf:Benchmark/xccdf:version'
61
+ },
62
+ cis: {
63
+ profiles: {
64
+ all: 'xccdf:Benchmark/xccdf:Profile',
65
+ relative_title: './xccdf:title',
66
+ relative_select: './xccdf:select'
67
+ }
68
+ }
69
+ }.freeze
70
+ CONTROL_PREFIX = /^[\d.]+_/.freeze
71
+ UNDERSCORED = /(\s|\(|\)|-|\.)/.freeze
72
+ CIS_NEXT_GEN_WINDOWS = /[Nn]ext_[Gg]eneration_[Ww]indows_[Ss]ecurity/.freeze
73
+ CIS_CONTROL_NUMBER = /([0-9.]+[0-9]+)/.freeze
74
+ CIS_LEVEL_CODE = /(?:_|^)([Ll]evel_[0-9]|[Ll]1|[Ll]2|[NnBb][GgLl]|#{CIS_NEXT_GEN_WINDOWS})/.freeze
75
+ CIS_CONTROL_PARTS = /#{CIS_CONTROL_NUMBER}#{CIS_LEVEL_CODE}?_+([A-Za-z].*)/.freeze
76
+ CIS_PROFILE_PARTS = /#{CIS_LEVEL_CODE}[_-]+([A-Za-z].*)/.freeze
77
+
78
+ def xpath(path)
79
+ @xml.xpath(path)
80
+ end
81
+
82
+ def validate_xccdf(path)
83
+ AbideDevUtils::Validate.file(path, extension: '.xml')
84
+ end
85
+
86
+ def normalize_string(str)
87
+ nstr = str.dup.downcase
88
+ nstr.gsub!(/[^a-z0-9]$/, '')
89
+ nstr.gsub!(/^[^a-z]/, '')
90
+ nstr.gsub!(/(?:_|^)([Ll]1_|[Ll]2_|ng_)/, '')
91
+ nstr.delete!('(/|\\|\+)')
92
+ nstr.gsub!(UNDERSCORED, '_')
93
+ nstr.strip!
94
+ nstr
95
+ end
96
+
97
+ def normalize_profile_name(prof, **_opts)
98
+ prof_name = normalize_string("profile_#{control_profile_text(prof)}").dup
99
+ prof_name.gsub!(CIS_NEXT_GEN_WINDOWS, 'ngws')
100
+ prof_name.delete_suffix!('_environment_general_use')
101
+ prof_name.delete_suffix!('sensitive_data_environment_limited_functionality')
102
+ prof_name.strip!
103
+ prof_name
104
+ end
105
+
106
+ def normalize_control_name(control, number_format: false)
107
+ return number_normalize_control(control) if number_format
108
+
109
+ name_normalize_control(control)
110
+ end
111
+
112
+ def name_normalize_control(control)
113
+ normalize_string(control_profile_text(control).gsub(CONTROL_PREFIX, ''))
114
+ end
115
+
116
+ def number_normalize_control(control)
117
+ numpart = CONTROL_PREFIX.match(control_profile_text(control)).to_s.chop.gsub(UNDERSCORED, '_')
118
+ "c#{numpart}"
119
+ end
120
+
121
+ def text_normalize(control)
122
+ control_profile_text(control).tr('_', ' ')
123
+ end
124
+
125
+ def profile_parts(profile)
126
+ parts = control_profile_text(profile).match(CIS_PROFILE_PARTS)
127
+ raise AbideDevUtils::Errors::ProfilePartsError, profile if parts.nil?
128
+
129
+ parts[1].gsub!(/[Ll]evel_/, 'L')
130
+ parts[1..2]
131
+ end
132
+
133
+ def control_parts(control, parent_level: nil)
134
+ mdata = control_profile_text(control).match(CIS_CONTROL_PARTS)
135
+ raise AbideDevUtils::Errors::ControlPartsError, control if mdata.nil?
136
+
137
+ mdata[2] = parent_level unless parent_level.nil?
138
+ mdata[1..3]
139
+ end
140
+
141
+ def control_profile_text(item)
142
+ return item.raw_title if item.respond_to?(:abide_object?)
143
+
144
+ if item.respond_to?(:split)
145
+ return item.split('benchmarks_rule_')[-1] if item.include?('benchmarks_rule_')
146
+
147
+ item.split('benchmarks_profile_')[-1]
148
+ else
149
+ return item['idref'].to_s.split('benchmarks_rule_')[-1] if item.name == 'select'
150
+
151
+ item['id'].to_s.split('benchmarks_profile_')[-1]
152
+ end
153
+ end
154
+
155
+ def sorted_control_classes(raw_select_list, sort_key: :number)
156
+ raw_select_list.map { |x| Control.new(x) }.sort_by(&sort_key)
157
+ end
158
+
159
+ def sorted_profile_classes(raw_profile_list, sort_key: :title)
160
+ raw_profile_list.map { |x| Profile.new(x) }.sort_by(&sort_key)
161
+ end
162
+
163
+ def ==(other)
164
+ diff_properties.map { |x| send(x) } == other.diff_properties.map { |x| other.send(x) }
165
+ end
166
+
167
+ def default_diff_opts
168
+ {
169
+ similarity: 1,
170
+ strict: true,
171
+ strip: true,
172
+ array_path: true,
173
+ delimiter: '//',
174
+ use_lcs: false
175
+ }
176
+ end
177
+
178
+ def diff(other, **opts)
179
+ Hashdiff.diff(
180
+ to_h,
181
+ other.to_h,
182
+ default_diff_opts.merge(opts)
183
+ )
184
+ end
185
+
186
+ def abide_object?
187
+ true
21
188
  end
22
189
  end
23
190
 
24
- class UtilsObject
25
- require 'abide_dev_utils/xccdf/utils'
26
- extend AbideDevUtils::XCCDF::Utils
191
+ # Class representation of an XCCDF benchmark
192
+ class Benchmark
193
+ include AbideDevUtils::XCCDF::Common
194
+
195
+ MAP_INDICES = %w[title hiera_title hiera_title_num number].freeze
196
+
197
+ attr_reader :xml, :title, :version, :diff_properties
198
+
199
+ def initialize(path)
200
+ @xml = parse(path)
201
+ @title = xpath('xccdf:Benchmark/xccdf:title').text
202
+ @version = xpath('xccdf:Benchmark/xccdf:version').text
203
+ @diff_properties = %i[title version profiles]
204
+ end
205
+
206
+ def normalized_title
207
+ normalize_string(title)
208
+ end
209
+
210
+ def profiles
211
+ @profiles ||= Profiles.new(xpath('xccdf:Benchmark/xccdf:Profile'))
212
+ end
213
+
214
+ def profile_levels
215
+ @profiles.levels
216
+ end
217
+
218
+ def profile_titles
219
+ @profiles.titles
220
+ end
221
+
222
+ def controls
223
+ @controls ||= Controls.new(xpath('//xccdf:select'))
224
+ end
225
+
226
+ def controls_by_profile_level(level_code)
227
+ profiles.select { |x| x.level == level_code }.map(&:controls).flatten.uniq
228
+ end
229
+
230
+ def controls_by_profile_title(profile_title)
231
+ profiles.select { |x| x.title == profile_title }.controls
232
+ end
233
+
234
+ def gen_map(dir: nil, type: 'CIS', parent_key_prefix: '', **_)
235
+ os, ver = facter_platform
236
+ mapping_dir = dir ? File.expand_path(File.join(dir, type, os, ver)) : ''
237
+ parent_key_prefix = '' if parent_key_prefix.nil?
238
+ MAP_INDICES.each_with_object({}) do |idx, h|
239
+ map_file_path = "#{mapping_dir}/#{idx}.yaml"
240
+ h[map_file_path] = map_indexed(index: idx, framework: type, key_prefix: parent_key_prefix)
241
+ end
242
+ end
243
+
244
+ def find_cis_recommendation(name, number_format: false)
245
+ profiles.each do |profile|
246
+ profile.controls.each do |ctrl|
247
+ return [profile, ctrl] if normalize_control_name(ctrl, number_format: number_format) == name
248
+ end
249
+ end
250
+ end
251
+
252
+ def to_h
253
+ {
254
+ title: title,
255
+ version: version,
256
+ profiles: profiles.to_h
257
+ }
258
+ end
259
+
260
+ def diff_title_version(other)
261
+ Hashdiff.diff(
262
+ to_h.reject { |k, _| k.to_s == 'profiles' },
263
+ other.to_h.reject { |k, _| k.to_s == 'profiles' },
264
+ default_diff_opts
265
+ )
266
+ end
267
+
268
+ def diff_profiles(other)
269
+ this_diff = {}
270
+ other_hash = other.to_h[:profiles]
271
+ to_h[:profiles].each do |name, data|
272
+ diff_h = Hashdiff.diff(data, other_hash[name], default_diff_opts).each_with_object({}) do |x, a|
273
+ val_to = x.length == 4 ? x[3] : nil
274
+ a_key = x[2].is_a?(Hash) ? x[2][:title] : x[2]
275
+ a[a_key] = [] unless a.key?(a_key)
276
+ a[a_key] << ChangeSet.new(change: x[0], key: x[1], value: x[2], value_to: val_to)
277
+ end
278
+ this_diff[name] = diff_h
279
+ end
280
+ this_diff
281
+ end
282
+
283
+ def diff_controls(other)
284
+ controls.diff(other.controls)
285
+ end
286
+
287
+ def map_indexed(index: 'title', framework: 'cis', key_prefix: '')
288
+ c_map = profiles.each_with_object({}) do |profile, obj|
289
+ obj[profile.level.downcase] = {} unless obj[profile.level.downcase].is_a?(Hash)
290
+ obj[profile.level.downcase][profile.title.downcase] = map_controls_hash(profile, index).sort_by { |k, _| k }.to_h
291
+ end
292
+
293
+ c_map['benchmark'] = { 'title' => title, 'version' => version }
294
+ mappings = [framework, index, key_prefix]
295
+ mappings.unshift(key_prefix) unless key_prefix.empty?
296
+ { mappings.join('::') => c_map }.to_yaml
297
+ end
298
+
299
+ def facter_platform
300
+ cpe = xpath('xccdf:Benchmark/xccdf:platform')[0]['idref'].split(':')
301
+ [cpe[4].split('_')[0], cpe[5].split('.')[0]]
302
+ end
303
+
304
+ # Converts object to Hiera-formatted YAML
305
+ # @return [String] YAML-formatted string
306
+ def to_hiera(parent_key_prefix: nil, num: false, levels: [], titles: [], **_kwargs)
307
+ hash = { 'title' => title, 'version' => version }
308
+ key_prefix = hiera_parent_key(parent_key_prefix)
309
+ profiles.each do |profile|
310
+ next unless levels.empty? || levels.include?(profile.level)
311
+ next unless titles.empty? || titles.include?(profile.title)
312
+
313
+ hash[profile.hiera_title] = hiera_controls_for_profile(profile, num)
314
+ end
315
+ hash.transform_keys! do |k|
316
+ [key_prefix, k].join('::').strip
317
+ end
318
+ hash.to_yaml
319
+ end
320
+
321
+ def resolve_control_reference(control)
322
+ xpath("//xccdf:Rule[@id='#{control.reference}']")
323
+ end
324
+
325
+ private
326
+
327
+ def format_map_control_index(index, control)
328
+ case index
329
+ when 'hiera_title_num'
330
+ control.hiera_title(number_format: true)
331
+ when 'title'
332
+ resolve_control_reference(control).xpath('./xccdf:title').text
333
+ else
334
+ control.send(index.to_sym)
335
+ end
336
+ end
337
+
338
+ def map_controls_hash(profile, index)
339
+ profile.controls.each_with_object({}) do |ctrl, hsh|
340
+ control_array = MAP_INDICES.each_with_object([]) do |idx_sym, ary|
341
+ next if idx_sym == index
342
+
343
+ item = format_map_control_index(idx_sym, ctrl)
344
+ ary << item.to_s
345
+ end
346
+ hsh[format_map_control_index(index, ctrl)] = control_array.sort
347
+ end
348
+ end
349
+
350
+ def parse(path)
351
+ validate_xccdf(path)
352
+ Nokogiri::XML.parse(File.open(File.expand_path(path))) do |config|
353
+ config.strict.noblanks.norecover
354
+ end
355
+ end
356
+
357
+ def sorted_profile_classes(raw_profile_list, sort_key: :level)
358
+ raw_profile_list.map { |x| Profile.new(x) }.sort_by(&sort_key)
359
+ end
360
+
361
+ def find_profiles
362
+ profs = {}
363
+ xpath('xccdf:Benchmark/xccdf:Profile').each do |profile|
364
+ level_code, name = profile_parts(profile['id'])
365
+ profs[name] = {} unless profs.key?(name)
366
+ profs[name][level_code] = profile
367
+ end
368
+ profs
369
+ end
370
+
371
+ def find_profile_names
372
+ profiles.each_with_object([]) do |profile, ary|
373
+ ary << "#{profile.level} #{profile.plain_text_title}"
374
+ end
375
+ end
376
+
377
+ def hiera_controls_for_profile(profile, number_format)
378
+ profile.controls.each_with_object([]) do |ctrl, ary|
379
+ ary << ctrl.hiera_title(number_format: number_format)
380
+ end
381
+ end
382
+
383
+ def hiera_parent_key(prefix)
384
+ return normalized_title if prefix.nil?
385
+
386
+ prefix.end_with?('::') ? "#{prefix}#{normalized_title}" : "#{prefix}::#{normalized_title}"
387
+ end
388
+ end
389
+
390
+ class ChangeSet
391
+ attr_reader :change, :key, :value, :value_to
392
+
393
+ def initialize(change:, key:, value:, value_to: nil)
394
+ validate_change(change)
395
+ @change = change
396
+ @key = key
397
+ @value = value
398
+ @value_to = value_to
399
+ end
400
+
401
+ def to_s
402
+ val_to_str = value_to.nil? ? ' ' : " to #{value_to} "
403
+ "#{change_string} value #{value}#{val_to_str}at #{key}"
404
+ end
405
+
406
+ def can_merge?(other)
407
+ return false unless (change == '-' && other.change == '+') || (change == '+' && other.change == '-')
408
+ return false unless key == other.key || value_hash_equality(other)
409
+
410
+ true
411
+ end
412
+
413
+ def merge(other)
414
+ unless can_merge?(other)
415
+ raise ArgumentError, 'Cannot merge. Possible causes: change is identical; key or value do not match'
416
+ end
417
+
418
+ new_to_value = value == other.value ? nil : other.value
419
+ ChangeSet.new(
420
+ change: '~',
421
+ key: key,
422
+ value: value,
423
+ value_to: new_to_value
424
+ )
425
+ end
426
+
427
+ private
428
+
429
+ def value_hash_equality(other)
430
+ equality = false
431
+ value.each do |k, v|
432
+ equality = true if v == other.value[k]
433
+ end
434
+ equality
435
+ end
436
+
437
+ def validate_change(change)
438
+ raise ArgumentError, "Change type #{change} in invalid" unless ['+', '-', '~'].include?(change)
439
+ end
440
+
441
+ def change_string
442
+ case change
443
+ when '-'
444
+ 'remove'
445
+ when '+'
446
+ 'add'
447
+ else
448
+ 'change'
449
+ end
450
+ end
451
+ end
452
+
453
+ class ObjectContainer
454
+ include AbideDevUtils::XCCDF::Common
455
+
456
+ def initialize(list, object_creation_method, *args, **kwargs)
457
+ @object_list = send(object_creation_method.to_sym, list, *args, **kwargs)
458
+ @searchable = []
459
+ end
460
+
461
+ def method_missing(m, *args, &block)
462
+ property = m.to_s.start_with?('search_') ? m.to_s.split('_')[-1].to_sym : nil
463
+ return search(property, *args, &block) if property && @searchable.include?(property)
464
+ return @object_list.send(m, *args, &block) if @object_list.respond_to?(m)
465
+
466
+ super
467
+ end
468
+
469
+ def respond_to_missing?(m, include_private = false)
470
+ return true if m.to_s.start_with?('search_') && @searchable.include?(m.to_s.split('_')[-1].to_sym)
471
+
472
+ super
473
+ end
474
+
475
+ def to_h
476
+ @object_list.each_with_object({}) do |obj, self_hash|
477
+ key = resolve_hash_key(obj)
478
+ self_hash[key] = obj.to_h
479
+ end
480
+ end
481
+
482
+ def search(property, item)
483
+ max = @object_list.length - 1
484
+ min = 0
485
+ while min <= max
486
+ mid = (min + max) / 2
487
+ return @object_list[mid] if @object_list[mid].send(property.to_sym) == item
488
+
489
+ if @object_list[mid].send(property.to_sym) > item
490
+ max = mid - 1
491
+ else
492
+ min = mid + 1
493
+ end
494
+ end
495
+ nil
496
+ end
497
+
498
+ private
499
+
500
+ def resolve_hash_key(obj)
501
+ return obj.send(:raw_title) unless defined?(@hash_key)
502
+
503
+ @hash_key.each_with_object([]) { |x, a| a << obj.send(x) }.join('_')
504
+ end
505
+
506
+ def searchable!(*properties)
507
+ @searchable = properties
508
+ end
509
+
510
+ def index!(property)
511
+ @index = property
512
+ end
513
+
514
+ def hash_key!(*properties)
515
+ @hash_key = properties
516
+ end
517
+ end
518
+
519
+ class Profiles < ObjectContainer
520
+ def initialize(list)
521
+ super(list, :sorted_profile_classes)
522
+ searchable! :level, :title
523
+ index! :title
524
+ hash_key! :level, :title
525
+ end
526
+
527
+ def levels
528
+ @levels ||= @object_list.map(&:level).sort
529
+ end
530
+
531
+ def titles
532
+ @titles ||= @object_list.map(&:title).sort
533
+ end
534
+
535
+ def include_level?(item)
536
+ levels.include?(item)
537
+ end
538
+
539
+ def include_title?(item)
540
+ titles.include?(item)
541
+ end
542
+ end
543
+
544
+ class Controls < ObjectContainer
545
+ def initialize(list)
546
+ super(list, :sorted_control_classes)
547
+ searchable! :level, :title, :number
548
+ index! :number
549
+ hash_key! :number
550
+ end
551
+
552
+ def numbers
553
+ @numbers ||= @object_list.map(&:number).sort
554
+ end
555
+
556
+ def levels
557
+ @levels ||= @object_list.map(&:level).sort
558
+ end
559
+
560
+ def titles
561
+ @titles ||= @object_list.map(&:title).sort
562
+ end
563
+
564
+ def include_number?(item)
565
+ numbers.include?(item)
566
+ end
567
+
568
+ def include_level?(item)
569
+ levels.include?(item)
570
+ end
571
+
572
+ def include_title?(item)
573
+ titles.include?(item)
574
+ end
575
+ end
576
+
577
+ class XccdfElement
578
+ include AbideDevUtils::XCCDF::Common
579
+
580
+ def initialize(element)
581
+ @xml = element
582
+ @element_type = self.class.name.split('::').last.downcase
583
+ @raw_title = control_profile_text(element)
584
+ end
585
+
586
+ def to_h
587
+ @properties.each_with_object({}) do |pair, hash|
588
+ hash[pair[0]] = if pair[1].nil?
589
+ send(pair[0])
590
+ else
591
+ obj = send(pair[0])
592
+ obj.send(pair[1])
593
+ end
594
+ end
595
+ end
596
+
597
+ def to_s
598
+ @hash.inspect
599
+ end
600
+
601
+ def reference
602
+ @reference ||= @element_type == 'control' ? @xml['idref'] : @xml['id']
603
+ end
604
+
605
+ def hiera_title(**opts)
606
+ send("normalize_#{@element_type}_name".to_sym, @xml, **opts)
607
+ end
608
+
609
+ private
610
+
611
+ attr_reader :xml
612
+
613
+ def properties(*plain_props, **props)
614
+ plain_props.each { |x| props[x] = nil }
615
+ props.transform_keys!(&:to_sym)
616
+ self.class.class_eval do
617
+ attr_reader :raw_title, :diff_properties
618
+
619
+ plain_props.each { |x| attr_reader x.to_sym unless respond_to?(x) }
620
+ props.each_key { |k| attr_reader k.to_sym unless respond_to?(k) }
621
+ end
622
+ @diff_properties = props.keys
623
+ @properties = props
624
+ end
625
+ end
626
+
627
+ class Profile < XccdfElement
628
+ def initialize(profile)
629
+ super(profile)
630
+ @level, @title = profile_parts(control_profile_text(profile))
631
+ @plain_text_title = @xml.xpath('./xccdf:title').text
632
+ @controls = Controls.new(xpath('./xccdf:select'))
633
+ properties :title, :level, :plain_text_title, controls: :to_h
634
+ end
635
+ end
636
+
637
+ class Control < XccdfElement
638
+ def initialize(control, parent_level: nil)
639
+ super(control)
640
+ @number, @level, @title = control_parts(control_profile_text(control), parent_level: parent_level)
641
+ properties :number, :level, :title
642
+ end
27
643
  end
28
644
  end
29
645
  end