Class: Scelint::Lint

Inherits:
Object
  • Object
show all
Defined in:
lib/scelint.rb

Overview

Check SCE data in the specified directories

Examples:

Look for data in the current directory (the default)

lint = Scelint::Lint.new()

Look for data in ‘/path/to/module`

lint = Scelint::Lint.new('/path/to/module')

Look for data in all modules in the current directory

lint = Scelint::Lint.new(Dir.glob('*'))

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(paths = ['.'], logger: Logger.new(STDOUT, level: Logger::INFO)) ⇒ Lint

Create a new Lint object

Parameters:

  • paths (Array<String>) (defaults to: ['.'])

    Paths to look for SCE data in. Defaults to [‘.’]

  • logger (Logger) (defaults to: Logger.new(STDOUT, level: Logger::INFO))

    A logger to send messages to. Defaults to an instance of Logger with the log level set to INFO.



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/scelint.rb', line 137

def initialize(paths = ['.'], logger: Logger.new(STDOUT, level: Logger::INFO))
  @log = logger
  @errors = []
  @warnings = []
  @notes = []

  @data = ComplianceEngine::Data.new(*Array(paths))

  @data.files.each do |file|
    lint(file, @data.get(file))
  end

  merged_data_lint

  validate
end

Instance Attribute Details

#dataObject

Returns the value of attribute data.



131
132
133
# File 'lib/scelint.rb', line 131

def data
  @data
end

#errorsObject

Returns the value of attribute errors.



131
132
133
# File 'lib/scelint.rb', line 131

def errors
  @errors
end

#logObject

Returns the value of attribute log.



131
132
133
# File 'lib/scelint.rb', line 131

def log
  @log
end

#notesObject

Returns the value of attribute notes.



131
132
133
# File 'lib/scelint.rb', line 131

def notes
  @notes
end

#warningsObject

Returns the value of attribute warnings.



131
132
133
# File 'lib/scelint.rb', line 131

def warnings
  @warnings
end

Instance Method Details

#check_ce(file, file_data) ⇒ Object

Check a CE

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/scelint.rb', line 357

def check_ce(file, file_data)
  ok = [
    'title',
    'description',
    'controls',
    'identifiers',
    'oval-ids',
    'confine',
    'imported_data',
    'notes',
  ]

  file_data.each do |ce, value|
    value.each_key do |key|
      warnings << "#{file} (CE '#{ce}'): unexpected key '#{key}'" unless ok.include?(key)
    end

    check_title(file, value['title']) unless value['title'].nil?
    check_description(file, value['description']) unless value['description'].nil?
    check_controls(file, value['controls']) unless value['controls'].nil?
    check_identifiers(file, value['identifiers']) unless value['identifiers'].nil?
    check_oval_ids(file, value['oval-ids']) unless value['oval-ids'].nil?
    check_confine(file, value['confine']) unless value['confine'].nil?
    check_imported_data(file, value['imported_data']) unless value['imported_data'].nil?
  end
end

#check_check_ces(file, file_data) ⇒ Object

Check CEs in a check

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Array)

    The data to validate



517
518
519
520
521
522
523
# File 'lib/scelint.rb', line 517

def check_check_ces(file, file_data)
  warnings << "#{file}: bad ces '#{file_data}'" unless file_data.is_a?(Array)

  file_data.each do |key|
    warnings << "#{file}: bad ce '#{key}'" unless key.is_a?(String)
  end
end

#check_checks(file, file_data) ⇒ Object

Check checks

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'lib/scelint.rb', line 529

def check_checks(file, file_data)
  ok = [
    'type',
    'settings',
    'controls',
    'identifiers',
    'oval-ids',
    'ces',
    'confine',
    'remediation',
  ]

  file_data.each do |check, value|
    if value.nil?
      warnings << "#{file} (check '#{check}'): empty value"
      next
    end

    if value.is_a?(Hash)
      value.each_key do |key|
        warnings << "#{file} (check '#{check}'): unexpected key '#{key}'" unless ok.include?(key)
      end
    else
      errors << "#{file} (check '#{check}'): contains something other than a hash, this is most likely caused by a missing note or ce element under the check"
    end

    check_type(file, check, value['type']) if value['type'] || file == 'merged data'
    check_settings(file, check, value['settings']) if value['settings'] || file == 'merged data'
    unless value['remediation'].nil?
      check_remediation(file, check, value['remediation']) if value['remediation']
    end
    check_controls(file, value['controls']) unless value['controls'].nil?
    check_identifiers(file, value['identifiers']) unless value['identifiers'].nil?
    check_oval_ids(file, value['oval-ids']) unless value['oval-ids'].nil?
    check_check_ces(file, value['ces']) unless value['ces'].nil?
    check_confine(file, value['confine']) unless value['confine'].nil?
  end
end

#check_confine(file, file_data) ⇒ Object

Check the confine data structure for any unexpected keys and legacy facts

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/scelint.rb', line 249

def check_confine(file, file_data)
  not_ok = [
    'type',
    'settings',
    'parameter',
    'value',
    'remediation',
    'risk',
    'level',
    'reason',
  ]

  unless file_data.is_a?(Hash)
    warnings << "#{file}: bad confine '#{file_data}'"
    return
  end

  file_data.each_key do |key|
    warnings << "#{file}: unexpected key '#{key}' in confine '#{file_data}'" if not_ok.include?(key)
    if Scelint::LEGACY_FACTS.any? { |legacy_fact| legacy_fact.is_a?(Regexp) ? legacy_fact.match?(key) : (legacy_fact == key) }
      warning = "#{file}: legacy fact '#{key}' in confine '#{file_data}'"
      warnings << warning unless warnings.include?(warning)
    end
  end
end

#check_controls(file, file_data) ⇒ Object

Check the controls in the given data

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



207
208
209
210
211
212
213
214
215
# File 'lib/scelint.rb', line 207

def check_controls(file, file_data)
  if file_data.is_a?(Hash)
    file_data.each do |key, value|
      warnings << "#{file}: bad control '#{key}'" unless key.is_a?(String) && value # Should be truthy
    end
  else
    warnings << "#{file}: bad controls '#{file_data}'"
  end
end

#check_description(file, file_data) ⇒ Object

Check the description of the given data

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (String)

    The data to validate



199
200
201
# File 'lib/scelint.rb', line 199

def check_description(file, file_data)
  warnings << "#{file}: bad description '#{file_data}'" unless file_data.is_a?(String)
end

#check_identifiers(file, file_data) ⇒ Object

Check identifiers

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/scelint.rb', line 279

def check_identifiers(file, file_data)
  if file_data.is_a?(Hash)
    file_data.each do |key, value|
      if key.is_a?(String) && value.is_a?(Array)
        value.each do |identifier|
          warnings << "#{file}: bad identifier '#{identifier}'" unless identifier.is_a?(String)
        end
      else
        warnings << "#{file}: bad identifier '#{key}'"
      end
    end
  else
    warnings << "#{file}: bad identifiers '#{file_data}'"
  end
end

#check_imported_data(file, file_data) ⇒ Object

Check imported_data

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



313
314
315
316
317
318
319
320
321
# File 'lib/scelint.rb', line 313

def check_imported_data(file, file_data)
  ok = ['checktext', 'fixtext']

  file_data.each do |key, value|
    warnings << "#{file}: unexpected key '#{key}'" unless ok.include?(key)

    warnings << "#{file} (key '#{key}'): bad data '#{value}'" unless value.is_a?(String)
  end
end

#check_keys(file, file_data) ⇒ Object

Check that all the top-level keys in the data are recognized

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/scelint.rb', line 173

def check_keys(file, file_data)
  ok = [
    'version',
    'profiles',
    'ce',
    'checks',
    'controls',
  ]

  file_data.each_key do |key|
    warnings << "#{file}: unexpected key '#{key}'" unless ok.include?(key)
  end
end

#check_oval_ids(file, file_data) ⇒ Object

Check oval-ids

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Array, Object)

    The data to validate



299
300
301
302
303
304
305
306
307
# File 'lib/scelint.rb', line 299

def check_oval_ids(file, file_data)
  if file_data.is_a?(Array)
    file_data.each do |key|
      warnings << "#{file}: bad oval-id '#{key}'" unless key.is_a?(String)
    end
  else
    warnings << "#{file}: bad oval-ids '#{file_data}'"
  end
end

#check_parameter(file, check, parameter) ⇒ Object

Check parameter

Parameters:

  • file (String)

    The path to the file being checked

  • check (String)

    The name of the check

  • parameter (String)

    The parameter to validate



398
399
400
# File 'lib/scelint.rb', line 398

def check_parameter(file, check, parameter)
  errors << "#{file} (check '#{check}'): invalid parameter '#{parameter}'" unless parameter.is_a?(String) && !parameter.empty?
end

#check_profile_ces(file, file_data) ⇒ Object

Check the CEs in a profile

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



221
222
223
224
225
226
227
228
229
# File 'lib/scelint.rb', line 221

def check_profile_ces(file, file_data)
  if file_data.is_a?(Hash)
    file_data.each do |key, value|
      warnings << "#{file}: bad ce '#{key}'" unless key.is_a?(String) && value.is_a?(TrueClass)
    end
  else
    warnings << "#{file}: bad ces '#{file_data}'"
  end
end

#check_profile_checks(file, file_data) ⇒ Object

Check the checks in a profile

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



235
236
237
238
239
240
241
242
243
# File 'lib/scelint.rb', line 235

def check_profile_checks(file, file_data)
  if file_data.is_a?(Hash)
    file_data.each do |key, value|
      warnings << "#{file}: bad check '#{key}'" unless key.is_a?(String) && value.is_a?(TrueClass)
    end
  else
    warnings << "#{file}: bad checks '#{file_data}'"
  end
end

#check_profiles(file, file_data) ⇒ Object

Check profiles

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/scelint.rb', line 327

def check_profiles(file, file_data)
  ok = [
    'title',
    'description',
    'controls',
    'ces',
    'checks',
    'confine',
    'id',
    'benchmark_version',
  ]

  file_data.each do |profile, value|
    value.each_key do |key|
      warnings << "#{file} (profile '#{profile}'): unexpected key '#{key}'" unless ok.include?(key)
    end

    check_title(file, value['title']) unless value['title'].nil?
    check_description(file, value['description']) unless value['description'].nil?
    check_controls(file, value['controls']) unless value['controls'].nil?
    check_profile_ces(file, value['ces']) unless value['ces'].nil?
    check_profile_checks(file, value['checks']) unless value['checks'].nil?
    check_confine(file, value['confine']) unless value['confine'].nil?
  end
end

#check_remediation(file, check, remediation_section) ⇒ Object

Check remediation

Parameters:

  • file (String)

    The path to the file being checked

  • check (String)

    The name of the check

  • remediation_section (Hash)

    The remediation section to validate



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/scelint.rb', line 407

def check_remediation(file, check, remediation_section)
  reason_ok = [
    'reason',
  ]

  risk_ok = [
    'level',
    'reason',
  ]

  if remediation_section.is_a?(Hash)
    remediation_section.each do |section, value|
      case section
      when 'scan-false-positive', 'disabled'
        value.each do |reason|
          # If the element in the remediation section isn't a hash, it is incorrect.
          if reason.is_a?(Hash)
            # Check for unknown elements and warn the user rather than failing
            (reason.keys - reason_ok).each do |unknown_element|
              warnings << "#{file} (check '#{check}'): Unknown element #{unknown_element} in remediation section #{section}"
            end
            errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of reason hashes." unless reason['reason'].is_a?(String)
          else
            errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of reason hashes."
          end
        end
      when 'risk'
        value.each do |risk|
          # If the element in the remediation section isn't a hash, it is incorrect.
          if risk.is_a?(Hash)
            # Check for unknown elements and warn the user rather than failing
            (risk.keys - risk_ok).each do |unknown_element|
              warnings << "#{file} (check '#{check}'): Unknown element #{unknown_element} in remediation section #{section}"
            end
            # Since reasons are optional here, we won't be checking for those

            errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of hashes containing levels and reasons." unless risk['level'].is_a?(Integer)
          else
            errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of hashes containing levels and reasons."
          end
        end
      else
        warnings << "#{file} (check '#{check}'): #{section} is not a recognized section within the remediation section"
      end
    end
  else
    errors << "#{file} (check '#{check}'): malformed remediation section, expecting a hash."
  end
end

#check_settings(file, check, file_data) ⇒ Object

Check settings

Parameters:

  • file (String)

    The path to the file being checked

  • check (String)

    The name of the check

  • file_data (Hash)

    The data to validate



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/scelint.rb', line 473

def check_settings(file, check, file_data)
  ok = ['parameter', 'value']

  if file_data.nil?
    msg = "#{file} (check '#{check}'): missing settings"
    if file == 'merged data'
      errors << msg
    else
      warnings << msg
    end
    return false
  end

  if file_data.key?('parameter')
    check_parameter(file, check, file_data['parameter'])
  else
    msg = "#{file} (check '#{check}'): missing key 'parameter'"
    if file == 'merged data'
      errors << msg
    else
      warnings << msg
    end
  end

  if file_data.key?('value')
    check_value(file, check, file_data['value'])
  else
    msg = "#{file} (check '#{check}'): missing key 'value'"
    if file == 'merged data'
      errors << msg
    else
      warnings << msg
    end
  end

  file_data.each_key do |key|
    warnings << "#{file} (check '#{check}'): unexpected key '#{key}'" unless ok.include?(key)
  end
end

#check_title(file, file_data) ⇒ Object

Check the title of the given data

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (String)

    The data to validate



191
192
193
# File 'lib/scelint.rb', line 191

def check_title(file, file_data)
  warnings << "#{file}: bad title '#{file_data}'" unless file_data.is_a?(String)
end

#check_type(file, check, file_data) ⇒ Object

Check type

Parameters:

  • file (String)

    The path to the file being checked

  • check (String)

    The name of the check

  • file_data (String)

    The data to validate



389
390
391
# File 'lib/scelint.rb', line 389

def check_type(file, check, file_data)
  errors << "#{file} (check '#{check}'): unknown type '#{file_data}'" unless file_data == 'puppet-class-parameter'
end

#check_value(_file, _check, _value) ⇒ Boolean

Check a value

Parameters:

  • _file (String)

    The path to the file being checked (currently unused)

  • _check (String)

    The name of the check (currently unused)

  • _value (Object)

    The value to be validated (currently unused)

Returns:

  • (Boolean)

    Always returns true (currently)



463
464
465
466
# File 'lib/scelint.rb', line 463

def check_value(_file, _check, _value) # rubocop:disable Naming/PredicateMethod
  # value could be anything
  true
end

#check_version(file, file_data) ⇒ Object

Note:

The version is currently hardcoded to ‘2.0.0’

Check that the value of the version key in the data is correct

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (String)

    The data to validate



165
166
167
# File 'lib/scelint.rb', line 165

def check_version(file, file_data)
  errors << "#{file}: version check failed" unless file_data == '2.0.0'
end

#confinesArray

Retrieve confines from the loaded data

Returns:

  • (Array)

    An array of confinement data



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/scelint.rb', line 612

def confines
  return @confines unless @confines.nil?

  @confines = []

  [:profiles, :ces, :checks, :controls].each do |type|
    data.public_send(type).each_value do |value|
      # FIXME: This is calling a private method
      value.send(:fragments).each_value do |v|
        next unless v.is_a?(Hash)
        next unless v.key?('confine')
        normalize_confinement(v['confine']).each do |confine|
          @confines << confine unless @confines.include?(confine)
        end
      end
    end
  end

  @confines
end

#filesObject

Return an array of all the files found in the loaded data



155
156
157
# File 'lib/scelint.rb', line 155

def files
  data.files
end

#lint(file, file_data) ⇒ Object

Lint the given file

Parameters:

  • file (String)

    The path to the file being checked

  • file_data (Hash)

    The data to validate



680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
# File 'lib/scelint.rb', line 680

def lint(file, file_data)
  unless file_data.is_a?(Hash)
    errors << "#{file}: Expected a Hash, got a #{file_data.class}"
    return
  end

  check_version(file, file_data['version'])

  check_keys(file, file_data)

  check_profiles(file, file_data['profiles']) if file_data['profiles']
  check_ce(file, file_data['ce']) if file_data['ce']
  check_checks(file, file_data['checks']) if file_data['checks']
  check_controls(file, file_data['controls']) if file_data['controls']
rescue => e
  errors << "#{file}: #{e.message} (not a hash?)"
end

#normalize_confinement(confine) ⇒ Array<Hash>

Normalize the given confinement hash by:

  • Expanding all possible combinations of Array values

  • Converting dotted fact names into a nested facts hash

Parameters:

  • confine (Hash)

    The confinement hash to normalize

Returns:

  • (Array<Hash>)

    An array of normalized confinement hashes



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# File 'lib/scelint.rb', line 574

def normalize_confinement(confine)
  normalized = []

  # Step 1, sort the hash keys
  sorted = confine.sort.to_h

  # Step 2, expand all possible combinations of Array values
  index = 0
  max_count = 1
  sorted.each_value { |value| max_count *= Array(value).size }

  sorted.each do |key, value|
    (index..(max_count - 1)).each do |i|
      normalized[i] ||= {}
      normalized[i][key] = Array(value)[i % Array(value).size]
    end
  end

  # Step 3, convert dotted fact names into a facts hash
  normalized.map do |c|
    c.each_with_object({}) do |(key, value), result|
      current = result
      parts = key.split('.')
      parts.each_with_index do |part, i|
        if i == parts.length - 1
          current[part] = value
        else
          current[part] ||= {}
          current = current[part]
        end
      end
    end
  end
end

#validateObject

Validate the Hiera data for each available profiles

This method performs validation in two stages:

  1. Unconfined: Checks if Hiera data exists for each profile.

  2. Confined: Checks if Hiera data exists for each profile with specific facts.



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# File 'lib/scelint.rb', line 638

def validate
  if data.profiles.keys.empty?
    notes << 'No profiles found, unable to validate Hiera data'
    return nil
  end

  # Unconfined, verify that hiera data exists
  data.profiles.each_key do |profile|
    hiera = data.hiera([profile])
    if hiera.nil?
      errors << "Profile '#{profile}': Invalid Hiera data (returned nil)"
      next
    end
    if hiera.empty?
      warnings << "Profile '#{profile}': No Hiera data found"
      next
    end
    log.debug "Profile '#{profile}': Hiera data found (#{hiera.keys.count} keys)"
  end

  # Again, this time confined
  confines.each do |confine|
    data.facts = confine
    data.profiles.select { |_, value| value.ces&.count&.positive? || value.controls&.count&.positive? }.each_key do |profile|
      hiera = data.hiera([profile])
      if hiera.nil?
        errors << "Profile '#{profile}': Invalid Hiera data (returned nil) with facts #{confine}"
        next
      end
      if hiera.empty?
        warnings << "Profile '#{profile}': No Hiera data found with facts #{confine}"
        next
      end
      log.debug "Profile '#{profile}': Hiera data found (#{hiera.keys.count} keys) with facts #{confine}"
    end
  end
end