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