abide_dev_utils 0.6.0 → 0.9.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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