abide_dev_utils 0.5.2 → 0.9.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.
@@ -1,23 +1,643 @@
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
+ new_map = 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
188
+ end
189
+ end
190
+
191
+ # Class representation of an XCCDF benchmark
192
+ class Benchmark
193
+ include AbideDevUtils::XCCDF::Common
194
+
195
+ attr_reader :xml, :title, :version, :diff_properties
196
+
197
+ def initialize(path)
198
+ @xml = parse(path)
199
+ @title = xpath('xccdf:Benchmark/xccdf:title').text
200
+ @version = xpath('xccdf:Benchmark/xccdf:version').text
201
+ @diff_properties = %i[title version profiles]
202
+ end
203
+
204
+ def normalized_title
205
+ normalize_string(title)
206
+ end
207
+
208
+ def profiles
209
+ @profiles ||= Profiles.new(xpath('xccdf:Benchmark/xccdf:Profile'))
210
+ end
211
+
212
+ def profile_levels
213
+ @profiles.levels
214
+ end
215
+
216
+ def profile_titles
217
+ @profiles.titles
218
+ end
219
+
220
+ def controls
221
+ @controls ||= Controls.new(xpath('//xccdf:select'))
222
+ end
223
+
224
+ def controls_by_profile_level(level_code)
225
+ profiles.select { |x| x.level == level_code }.map(&:controls).flatten.uniq
226
+ end
227
+
228
+ def controls_by_profile_title(profile_title)
229
+ profiles.select { |x| x.title == profile_title }.controls
230
+ end
231
+
232
+ def gen_map(dir: nil, type: 'CIS', parent_key_prefix: '', **_)
233
+ os, ver = facter_platform
234
+ if dir
235
+ mapping_dir = File.expand_path(File.join(dir, type, os, ver))
236
+ else
237
+ mapping_dir = ''
238
+ end
239
+ parent_key_prefix = parent_key_prefix.nil? ? nil : ''
240
+ ['title', 'hiera_title', 'hiera_title_num', 'number'].each_with_object({}) do |idx, h|
241
+ map_file_path = "#{mapping_dir}/#{idx}.yaml"
242
+ h[map_file_path] = map_indexed(index: idx, framework: type, key_prefix: parent_key_prefix)
243
+ end
244
+ end
245
+
246
+ def find_cis_recommendation(name, number_format: false)
247
+ profiles.each do |profile|
248
+ profile.controls.each do |ctrl|
249
+ return [profile, ctrl] if normalize_control_name(ctrl, number_format: number_format) == name
250
+ end
251
+ end
252
+ end
253
+
254
+ def to_h
255
+ {
256
+ title: title,
257
+ version: version,
258
+ profiles: profiles.to_h
259
+ }
260
+ end
261
+
262
+ def diff_title_version(other)
263
+ Hashdiff.diff(
264
+ to_h.reject { |k, _| k.to_s == 'profiles' },
265
+ other.to_h.reject { |k, _| k.to_s == 'profiles' },
266
+ default_diff_opts
267
+ )
268
+ end
269
+
270
+ def diff_profiles(other)
271
+ this_diff = {}
272
+ other_hash = other.to_h[:profiles]
273
+ to_h[:profiles].each do |name, data|
274
+ diff_h = Hashdiff.diff(data, other_hash[name], default_diff_opts).each_with_object({}) do |x, a|
275
+ val_to = x.length == 4 ? x[3] : nil
276
+ a_key = x[2].is_a?(Hash) ? x[2][:title] : x[2]
277
+ a[a_key] = [] unless a.key?(a_key)
278
+ a[a_key] << ChangeSet.new(change: x[0], key: x[1], value: x[2], value_to: val_to)
279
+ end
280
+ this_diff[name] = diff_h
281
+ end
282
+ this_diff
283
+ end
284
+
285
+ def diff_controls(other)
286
+ controls.diff(other.controls)
287
+ end
288
+
289
+ def map_indexed(index: 'title', framework: 'cis', key_prefix: '')
290
+ all_indexes = ['title', 'hiera_title', 'hiera_title_num', 'number']
291
+ c_map = profiles.each_with_object({}) do |profile, obj|
292
+ controls_hash = profile.controls.each_with_object({}) do |ctrl, hsh|
293
+ real_index = if index == 'hiera_title_num'
294
+ ctrl.hiera_title(number_format: true)
295
+ elsif index == 'title'
296
+ resolve_control_reference(ctrl).xpath('./xccdf:title').text
297
+ else
298
+ ctrl.send(index.to_sym)
299
+ end
300
+ controls_array = all_indexes.each_with_object([]) do |idx_sym, ary|
301
+ next if idx_sym == index
302
+
303
+ item = if idx_sym == 'hiera_title_num'
304
+ ctrl.hiera_title(number_format: true)
305
+ elsif idx_sym == 'title'
306
+ resolve_control_reference(ctrl).xpath('./xccdf:title').text
307
+ else
308
+ ctrl.send(idx_sym.to_sym)
309
+ end
310
+ ary << "#{item}"
311
+ end
312
+ hsh["#{real_index.to_s}"] = controls_array.sort
313
+ end
314
+ obj[profile.level.downcase] = {profile.title.downcase => controls_hash.sort_by { |k, _| k }.to_h }
315
+ end
316
+ mappings = [framework, index]
317
+ mappings.unshift(key_prefix) unless key_prefix.empty?
318
+ { mappings.join('::') => c_map }.to_yaml
319
+ end
320
+
321
+ def facter_platform
322
+ cpe = xpath('xccdf:Benchmark/xccdf:platform')[0]['idref'].split(':')
323
+ [cpe[4].split('_')[0], cpe[5].split('.')[0]]
324
+ end
325
+
326
+ # Converts object to Hiera-formatted YAML
327
+ # @return [String] YAML-formatted string
328
+ def to_hiera(parent_key_prefix: nil, num: false, levels: [], titles: [], **_kwargs)
329
+ hash = { 'title' => title, 'version' => version }
330
+ key_prefix = hiera_parent_key(parent_key_prefix)
331
+ profiles.each do |profile|
332
+ next unless levels.empty? || levels.include?(profile.level)
333
+ next unless titles.empty? || titles.include?(profile.title)
334
+
335
+ hash[profile.hiera_title] = hiera_controls_for_profile(profile, num)
336
+ end
337
+ hash.transform_keys! do |k|
338
+ [key_prefix, k].join('::').strip
339
+ end
340
+ hash.to_yaml
341
+ end
342
+
343
+ def resolve_control_reference(control)
344
+ xpath("//xccdf:Rule[@id='#{control.reference}']")
345
+ end
346
+
347
+ private
348
+
349
+ def parse(path)
350
+ validate_xccdf(path)
351
+ Nokogiri::XML.parse(File.open(File.expand_path(path))) do |config|
352
+ config.strict.noblanks.norecover
353
+ end
354
+ end
355
+
356
+ def sorted_profile_classes(raw_profile_list, sort_key: :level)
357
+ raw_profile_list.map { |x| Profile.new(x) }.sort_by(&sort_key)
358
+ end
359
+
360
+ def find_profiles
361
+ profs = {}
362
+ xpath('xccdf:Benchmark/xccdf:Profile').each do |profile|
363
+ level_code, name = profile_parts(profile['id'])
364
+ profs[name] = {} unless profs.key?(name)
365
+ profs[name][level_code] = profile
366
+ end
367
+ profs
368
+ end
369
+
370
+ def find_profile_names
371
+ profiles.each_with_object([]) do |profile, ary|
372
+ ary << "#{profile.level} #{profile.plain_text_title}"
373
+ end
374
+ end
375
+
376
+ def hiera_controls_for_profile(profile, number_format)
377
+ profile.controls.each_with_object([]) do |ctrl, ary|
378
+ ary << ctrl.hiera_title(number_format: number_format)
379
+ end
380
+ end
381
+
382
+ def hiera_parent_key(prefix)
383
+ return normalized_title if prefix.nil?
384
+
385
+ prefix.end_with?('::') ? "#{prefix}#{normalized_title}" : "#{prefix}::#{normalized_title}"
386
+ end
387
+ end
388
+
389
+ class ChangeSet
390
+ attr_reader :change, :key, :value, :value_to
391
+
392
+ def initialize(change:, key:, value:, value_to: nil)
393
+ validate_change(change)
394
+ @change = change
395
+ @key = key
396
+ @value = value
397
+ @value_to = value_to
398
+ end
399
+
400
+ def to_s
401
+ val_to_str = value_to.nil? ? ' ' : " to #{value_to} "
402
+ "#{change_string} value #{value}#{val_to_str}at #{key}"
403
+ end
404
+
405
+ def can_merge?(other)
406
+ return false unless (change == '-' && other.change == '+') || (change == '+' && other.change == '-')
407
+ return false unless key == other.key || value_hash_equality(other)
408
+
409
+ true
410
+ end
411
+
412
+ def merge(other)
413
+ unless can_merge?(other)
414
+ raise ArgumentError, 'Cannot merge. Possible causes: change is identical; key or value do not match'
415
+ end
416
+
417
+ new_to_value = value == other.value ? nil : other.value
418
+ ChangeSet.new(
419
+ change: '~',
420
+ key: key,
421
+ value: value,
422
+ value_to: new_to_value
423
+ )
424
+ end
425
+
426
+ private
427
+
428
+ def value_hash_equality(other)
429
+ equality = false
430
+ value.each do |k, v|
431
+ equality = true if v == other.value[k]
432
+ end
433
+ equality
434
+ end
435
+
436
+ def validate_change(change)
437
+ raise ArgumentError, "Change type #{change} in invalid" unless ['+', '-', '~'].include?(change)
438
+ end
439
+
440
+ def change_string
441
+ case change
442
+ when '-'
443
+ 'remove'
444
+ when '+'
445
+ 'add'
446
+ else
447
+ 'change'
448
+ end
449
+ end
450
+ end
451
+
452
+ class ObjectContainer
453
+ include AbideDevUtils::XCCDF::Common
454
+
455
+ def initialize(list, object_creation_method, *args, **kwargs)
456
+ @object_list = send(object_creation_method.to_sym, list, *args, **kwargs)
457
+ @searchable = []
458
+ end
459
+
460
+ def method_missing(m, *args, &block)
461
+ property = m.to_s.start_with?('search_') ? m.to_s.split('_')[-1].to_sym : nil
462
+ return search(property, *args, &block) if property && @searchable.include?(property)
463
+ return @object_list.send(m, *args, &block) if @object_list.respond_to?(m)
464
+
465
+ super
466
+ end
467
+
468
+ def respond_to_missing?(m, include_private = false)
469
+ return true if m.to_s.start_with?('search_') && @searchable.include?(m.to_s.split('_')[-1].to_sym)
470
+
471
+ super
472
+ end
473
+
474
+ def to_h
475
+ @object_list.each_with_object({}) do |obj, self_hash|
476
+ key = resolve_hash_key(obj)
477
+ self_hash[key] = obj.to_h
478
+ end
479
+ end
480
+
481
+ def search(property, item)
482
+ max = @object_list.length - 1
483
+ min = 0
484
+ while min <= max
485
+ mid = (min + max) / 2
486
+ return @object_list[mid] if @object_list[mid].send(property.to_sym) == item
487
+
488
+ if @object_list[mid].send(property.to_sym) > item
489
+ max = mid - 1
490
+ else
491
+ min = mid + 1
492
+ end
493
+ end
494
+ nil
495
+ end
496
+
497
+ private
498
+
499
+ def resolve_hash_key(obj)
500
+ return obj.send(:raw_title) unless defined?(@hash_key)
501
+
502
+ @hash_key.each_with_object([]) { |x, a| a << obj.send(x) }.join('_')
503
+ end
504
+
505
+ def searchable!(*properties)
506
+ @searchable = properties
507
+ end
508
+
509
+ def index!(property)
510
+ @index = property
511
+ end
512
+
513
+ def hash_key!(*properties)
514
+ @hash_key = properties
515
+ end
516
+ end
517
+
518
+ class Profiles < ObjectContainer
519
+ def initialize(list)
520
+ super(list, :sorted_profile_classes)
521
+ searchable! :level, :title
522
+ index! :title
523
+ hash_key! :level, :title
524
+ end
525
+
526
+ def levels
527
+ @levels ||= @object_list.map(&:level).sort
528
+ end
529
+
530
+ def titles
531
+ @titles ||= @object_list.map(&:title).sort
532
+ end
533
+
534
+ def include_level?(item)
535
+ levels.include?(item)
536
+ end
537
+
538
+ def include_title?(item)
539
+ titles.include?(item)
540
+ end
541
+ end
542
+
543
+ class Controls < ObjectContainer
544
+ def initialize(list)
545
+ super(list, :sorted_control_classes)
546
+ searchable! :level, :title, :number
547
+ index! :number
548
+ hash_key! :number
549
+ end
550
+
551
+ def numbers
552
+ @numbers ||= @object_list.map(&:number).sort
553
+ end
554
+
555
+ def levels
556
+ @levels ||= @object_list.map(&:level).sort
557
+ end
558
+
559
+ def titles
560
+ @titles ||= @object_list.map(&:title).sort
561
+ end
562
+
563
+ def include_number?(item)
564
+ numbers.include?(item)
565
+ end
566
+
567
+ def include_level?(item)
568
+ levels.include?(item)
569
+ end
570
+
571
+ def include_title?(item)
572
+ titles.include?(item)
573
+ end
574
+ end
575
+
576
+ class XccdfElement
577
+ include AbideDevUtils::XCCDF::Common
578
+
579
+ def initialize(element)
580
+ @xml = element
581
+ @element_type = self.class.name.split('::').last.downcase
582
+ @raw_title = control_profile_text(element)
583
+ end
584
+
585
+ def to_h
586
+ @properties.each_with_object({}) do |pair, hash|
587
+ hash[pair[0]] = if pair[1].nil?
588
+ send(pair[0])
589
+ else
590
+ obj = send(pair[0])
591
+ obj.send(pair[1])
592
+ end
593
+ end
594
+ end
595
+
596
+ def to_s
597
+ @hash.inspect
598
+ end
599
+
600
+ def reference
601
+ @reference ||= @element_type == 'control' ? @xml['idref'] : @xml['id']
602
+ end
603
+
604
+ def hiera_title(**opts)
605
+ send("normalize_#{@element_type}_name".to_sym, @xml, **opts)
606
+ end
607
+
608
+ private
609
+
610
+ attr_reader :xml
611
+
612
+ def properties(*plain_props, **props)
613
+ plain_props.each { |x| props[x] = nil }
614
+ props.transform_keys!(&:to_sym)
615
+ self.class.class_eval do
616
+ attr_reader :raw_title, :diff_properties
617
+
618
+ plain_props.each { |x| attr_reader x.to_sym unless respond_to?(x) }
619
+ props.each_key { |k| attr_reader k.to_sym unless respond_to?(k) }
620
+ end
621
+ @diff_properties = props.keys
622
+ @properties = props
623
+ end
624
+ end
625
+
626
+ class Profile < XccdfElement
627
+ def initialize(profile)
628
+ super(profile)
629
+ @level, @title = profile_parts(control_profile_text(profile))
630
+ @plain_text_title = @xml.xpath('./xccdf:title').text
631
+ @controls = Controls.new(xpath('./xccdf:select'))
632
+ properties :title, :level, :plain_text_title, controls: :to_h
633
+ end
634
+ end
635
+
636
+ class Control < XccdfElement
637
+ def initialize(control, parent_level: nil)
638
+ super(control)
639
+ @number, @level, @title = control_parts(control_profile_text(control), parent_level: parent_level)
640
+ properties :number, :level, :title
21
641
  end
22
642
  end
23
643
  end
@@ -5,6 +5,7 @@ require 'abide_dev_utils/xccdf'
5
5
  require 'abide_dev_utils/ppt'
6
6
  require 'abide_dev_utils/jira'
7
7
  require 'abide_dev_utils/config'
8
+ require 'abide_dev_utils/comply'
8
9
 
9
10
  # Root namespace all modules / classes
10
11
  module AbideDevUtils; end