abide_dev_utils 0.5.2 → 0.9.0

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