scelint 0.3.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.
data/lib/scelint.rb ADDED
@@ -0,0 +1,716 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'deep_merge'
6
+ require 'logger'
7
+ require 'compliance_engine'
8
+
9
+ require 'scelint/version'
10
+
11
+ module Scelint
12
+ class Error < StandardError; end
13
+
14
+ LEGACY_FACTS = [
15
+ 'architecture',
16
+ 'augeasversion',
17
+ 'blockdevices',
18
+ %r{^blockdevice_[[:alnum:]]+_model$},
19
+ %r{^blockdevice_[[:alnum:]]+_size$},
20
+ %r{^blockdevice_[[:alnum:]]+_vendor$},
21
+ 'bios_release_date',
22
+ 'bios_vendor',
23
+ 'bios_version',
24
+ 'boardassettag',
25
+ 'boardmanufacturer',
26
+ 'boardproductname',
27
+ 'boardserialnumber',
28
+ 'chassisassettag',
29
+ 'chassistype',
30
+ 'dhcp_servers',
31
+ 'domain',
32
+ 'fqdn',
33
+ 'gid',
34
+ 'hardwareisa',
35
+ 'hardwaremodel',
36
+ 'hostname',
37
+ 'id',
38
+ 'interfaces',
39
+ 'ipaddress',
40
+ 'ipaddress6',
41
+ %r{^ipaddress6_[[:alnum:]]+$},
42
+ %r{^ipaddress_[[:alnum:]]+$},
43
+ %r{^ldom_[[:alnum:]]+$},
44
+ 'lsbdistcodename',
45
+ 'lsbdistdescription',
46
+ 'lsbdistid',
47
+ 'lsbdistrelease',
48
+ 'lsbmajdistrelease',
49
+ 'lsbminordistrelease',
50
+ 'lsbrelease',
51
+ 'macaddress',
52
+ %r{^macaddress_[[:alnum:]]+$},
53
+ 'macosx_buildversion',
54
+ 'macosx_productname',
55
+ 'macosx_productversion',
56
+ 'macosx_productversion_major',
57
+ 'macosx_productversion_minor',
58
+ 'macosx_productversion_patch',
59
+ 'manufacturer',
60
+ 'memoryfree',
61
+ 'memoryfree_mb',
62
+ 'memorysize',
63
+ 'memorysize_mb',
64
+ %r{^mtu_[[:alnum:]]+$},
65
+ 'netmask',
66
+ 'netmask6',
67
+ %r{^netmask6_[[:alnum:]]+$},
68
+ %r{^netmask_[[:alnum:]]+$},
69
+ 'network',
70
+ 'network6',
71
+ %r{^network6_[[:alnum:]]+$},
72
+ %r{^network_[[:alnum:]]+$},
73
+ 'operatingsystem',
74
+ 'operatingsystemmajrelease',
75
+ 'operatingsystemrelease',
76
+ 'osfamily',
77
+ 'physicalprocessorcount',
78
+ %r{^processor[[:digit:]]+$},
79
+ 'processorcount',
80
+ 'productname',
81
+ 'rubyplatform',
82
+ 'rubysitedir',
83
+ 'rubyversion',
84
+ 'scope6',
85
+ %r{^scope6_[[:alnum:]]+$},
86
+ 'selinux',
87
+ 'selinux_config_mode',
88
+ 'selinux_config_policy',
89
+ 'selinux_current_mode',
90
+ 'selinux_enforced',
91
+ 'selinux_policyversion',
92
+ 'serialnumber',
93
+ %r{^sp_[[:alnum:]]+$},
94
+ %r{^ssh[[:alnum:]]+key$},
95
+ %r{^sshfp_[[:alnum:]]+$},
96
+ 'swapencrypted',
97
+ 'swapfree',
98
+ 'swapfree_mb',
99
+ 'swapsize',
100
+ 'swapsize_mb',
101
+ 'windows_edition_id',
102
+ 'windows_installation_type',
103
+ 'windows_product_name',
104
+ 'windows_release_id',
105
+ 'system32',
106
+ 'uptime',
107
+ 'uptime_days',
108
+ 'uptime_hours',
109
+ 'uptime_seconds',
110
+ 'uuid',
111
+ 'xendomains',
112
+ %r{^zone_[[:alnum:]]+_brand$},
113
+ %r{^zone_[[:alnum:]]+_iptype$},
114
+ %r{^zone_[[:alnum:]]+_name$},
115
+ %r{^zone_[[:alnum:]]+_uuid$},
116
+ %r{^zone_[[:alnum:]]+_id$},
117
+ %r{^zone_[[:alnum:]]+_path$},
118
+ %r{^zone_[[:alnum:]]+_status$},
119
+ 'zonename',
120
+ 'zones',
121
+ ].freeze
122
+
123
+ # Check SCE data in the specified directories
124
+ # @example Look for data in the current directory (the default)
125
+ # lint = Scelint::Lint.new()
126
+ # @example Look for data in `/path/to/module`
127
+ # lint = Scelint::Lint.new('/path/to/module')
128
+ # @example Look for data in all modules in the current directory
129
+ # lint = Scelint::Lint.new(Dir.glob('*'))
130
+ class Lint
131
+ attr_accessor :data, :errors, :warnings, :notes, :log
132
+
133
+ # Create a new Lint object
134
+ #
135
+ # @param paths [Array<String>] Paths to look for SCE data in. Defaults to ['.']
136
+ # @param logger [Logger] A logger to send messages to. Defaults to an instance of Logger with the log level set to INFO.
137
+ def initialize(paths = ['.'], logger: Logger.new(STDOUT, level: Logger::INFO))
138
+ @log = logger
139
+ @errors = []
140
+ @warnings = []
141
+ @notes = []
142
+
143
+ @data = ComplianceEngine::Data.new(*Array(paths))
144
+
145
+ @data.files.each do |file|
146
+ lint(file, @data.get(file))
147
+ end
148
+
149
+ merged_data_lint
150
+
151
+ validate
152
+ end
153
+
154
+ # Return an array of all the files found in the loaded data
155
+ def files
156
+ data.files
157
+ end
158
+
159
+ # Check that the value of the version key in the data is correct
160
+ #
161
+ # @param file [String] The path to the file being checked
162
+ # @param file_data [String] The data to validate
163
+ #
164
+ # @note The version is currently hardcoded to '2.0.0'
165
+ def check_version(file, file_data)
166
+ errors << "#{file}: version check failed" unless file_data == '2.0.0'
167
+ end
168
+
169
+ # Check that all the top-level keys in the data are recognized
170
+ #
171
+ # @param file [String] The path to the file being checked
172
+ # @param file_data [Hash] The data to validate
173
+ def check_keys(file, file_data)
174
+ ok = [
175
+ 'version',
176
+ 'profiles',
177
+ 'ce',
178
+ 'checks',
179
+ 'controls',
180
+ ]
181
+
182
+ file_data.each_key do |key|
183
+ warnings << "#{file}: unexpected key '#{key}'" unless ok.include?(key)
184
+ end
185
+ end
186
+
187
+ # Check the title of the given data
188
+ #
189
+ # @param file [String] The path to the file being checked
190
+ # @param file_data [String] The data to validate
191
+ def check_title(file, file_data)
192
+ warnings << "#{file}: bad title '#{file_data}'" unless file_data.is_a?(String)
193
+ end
194
+
195
+ # Check the description of the given data
196
+ #
197
+ # @param file [String] The path to the file being checked
198
+ # @param file_data [String] The data to validate
199
+ def check_description(file, file_data)
200
+ warnings << "#{file}: bad description '#{file_data}'" unless file_data.is_a?(String)
201
+ end
202
+
203
+ # Check the controls in the given data
204
+ #
205
+ # @param file [String] The path to the file being checked
206
+ # @param file_data [Hash] The data to validate
207
+ def check_controls(file, file_data)
208
+ if file_data.is_a?(Hash)
209
+ file_data.each do |key, value|
210
+ warnings << "#{file}: bad control '#{key}'" unless key.is_a?(String) && value # Should be truthy
211
+ end
212
+ else
213
+ warnings << "#{file}: bad controls '#{file_data}'"
214
+ end
215
+ end
216
+
217
+ # Check the CEs in a profile
218
+ #
219
+ # @param file [String] The path to the file being checked
220
+ # @param file_data [Hash] The data to validate
221
+ def check_profile_ces(file, file_data)
222
+ if file_data.is_a?(Hash)
223
+ file_data.each do |key, value|
224
+ warnings << "#{file}: bad ce '#{key}'" unless key.is_a?(String) && value.is_a?(TrueClass)
225
+ end
226
+ else
227
+ warnings << "#{file}: bad ces '#{file_data}'"
228
+ end
229
+ end
230
+
231
+ # Check the checks in a profile
232
+ #
233
+ # @param file [String] The path to the file being checked
234
+ # @param file_data [Hash] The data to validate
235
+ def check_profile_checks(file, file_data)
236
+ if file_data.is_a?(Hash)
237
+ file_data.each do |key, value|
238
+ warnings << "#{file}: bad check '#{key}'" unless key.is_a?(String) && value.is_a?(TrueClass)
239
+ end
240
+ else
241
+ warnings << "#{file}: bad checks '#{file_data}'"
242
+ end
243
+ end
244
+
245
+ # Check the confine data structure for any unexpected keys and legacy facts
246
+ #
247
+ # @param file [String] The path to the file being checked
248
+ # @param file_data [Hash] The data to validate
249
+ def check_confine(file, file_data)
250
+ not_ok = [
251
+ 'type',
252
+ 'settings',
253
+ 'parameter',
254
+ 'value',
255
+ 'remediation',
256
+ 'risk',
257
+ 'level',
258
+ 'reason',
259
+ ]
260
+
261
+ unless file_data.is_a?(Hash)
262
+ warnings << "#{file}: bad confine '#{file_data}'"
263
+ return
264
+ end
265
+
266
+ file_data.each_key do |key|
267
+ warnings << "#{file}: unexpected key '#{key}' in confine '#{file_data}'" if not_ok.include?(key)
268
+ if Scelint::LEGACY_FACTS.any? { |legacy_fact| legacy_fact.is_a?(Regexp) ? legacy_fact.match?(key) : (legacy_fact == key) }
269
+ warning = "#{file}: legacy fact '#{key}' in confine '#{file_data}'"
270
+ warnings << warning unless warnings.include?(warning)
271
+ end
272
+ end
273
+ end
274
+
275
+ # Check identifiers
276
+ #
277
+ # @param file [String] The path to the file being checked
278
+ # @param file_data [Hash] The data to validate
279
+ def check_identifiers(file, file_data)
280
+ if file_data.is_a?(Hash)
281
+ file_data.each do |key, value|
282
+ if key.is_a?(String) && value.is_a?(Array)
283
+ value.each do |identifier|
284
+ warnings << "#{file}: bad identifier '#{identifier}'" unless identifier.is_a?(String)
285
+ end
286
+ else
287
+ warnings << "#{file}: bad identifier '#{key}'"
288
+ end
289
+ end
290
+ else
291
+ warnings << "#{file}: bad identifiers '#{file_data}'"
292
+ end
293
+ end
294
+
295
+ # Check oval-ids
296
+ #
297
+ # @param file [String] The path to the file being checked
298
+ # @param file_data [Array, Object] The data to validate
299
+ def check_oval_ids(file, file_data)
300
+ if file_data.is_a?(Array)
301
+ file_data.each do |key|
302
+ warnings << "#{file}: bad oval-id '#{key}'" unless key.is_a?(String)
303
+ end
304
+ else
305
+ warnings << "#{file}: bad oval-ids '#{file_data}'"
306
+ end
307
+ end
308
+
309
+ # Check imported_data
310
+ #
311
+ # @param file [String] The path to the file being checked
312
+ # @param file_data [Hash] The data to validate
313
+ def check_imported_data(file, file_data)
314
+ ok = ['checktext', 'fixtext']
315
+
316
+ file_data.each do |key, value|
317
+ warnings << "#{file}: unexpected key '#{key}'" unless ok.include?(key)
318
+
319
+ warnings << "#{file} (key '#{key}'): bad data '#{value}'" unless value.is_a?(String)
320
+ end
321
+ end
322
+
323
+ # Check profiles
324
+ #
325
+ # @param file [String] The path to the file being checked
326
+ # @param file_data [Hash] The data to validate
327
+ def check_profiles(file, file_data)
328
+ ok = [
329
+ 'title',
330
+ 'description',
331
+ 'controls',
332
+ 'ces',
333
+ 'checks',
334
+ 'confine',
335
+ 'id',
336
+ 'benchmark_version',
337
+ ]
338
+
339
+ file_data.each do |profile, value|
340
+ value.each_key do |key|
341
+ warnings << "#{file} (profile '#{profile}'): unexpected key '#{key}'" unless ok.include?(key)
342
+ end
343
+
344
+ check_title(file, value['title']) unless value['title'].nil?
345
+ check_description(file, value['description']) unless value['description'].nil?
346
+ check_controls(file, value['controls']) unless value['controls'].nil?
347
+ check_profile_ces(file, value['ces']) unless value['ces'].nil?
348
+ check_profile_checks(file, value['checks']) unless value['checks'].nil?
349
+ check_confine(file, value['confine']) unless value['confine'].nil?
350
+ end
351
+ end
352
+
353
+ # Check a CE
354
+ #
355
+ # @param file [String] The path to the file being checked
356
+ # @param file_data [Hash] The data to validate
357
+ def check_ce(file, file_data)
358
+ ok = [
359
+ 'title',
360
+ 'description',
361
+ 'controls',
362
+ 'identifiers',
363
+ 'oval-ids',
364
+ 'confine',
365
+ 'imported_data',
366
+ 'notes',
367
+ ]
368
+
369
+ file_data.each do |ce, value|
370
+ value.each_key do |key|
371
+ warnings << "#{file} (CE '#{ce}'): unexpected key '#{key}'" unless ok.include?(key)
372
+ end
373
+
374
+ check_title(file, value['title']) unless value['title'].nil?
375
+ check_description(file, value['description']) unless value['description'].nil?
376
+ check_controls(file, value['controls']) unless value['controls'].nil?
377
+ check_identifiers(file, value['identifiers']) unless value['identifiers'].nil?
378
+ check_oval_ids(file, value['oval-ids']) unless value['oval-ids'].nil?
379
+ check_confine(file, value['confine']) unless value['confine'].nil?
380
+ check_imported_data(file, value['imported_data']) unless value['imported_data'].nil?
381
+ end
382
+ end
383
+
384
+ # Check type
385
+ #
386
+ # @param file [String] The path to the file being checked
387
+ # @param check [String] The name of the check
388
+ # @param file_data [String] The data to validate
389
+ def check_type(file, check, file_data)
390
+ errors << "#{file} (check '#{check}'): unknown type '#{file_data}'" unless file_data == 'puppet-class-parameter'
391
+ end
392
+
393
+ # Check parameter
394
+ #
395
+ # @param file [String] The path to the file being checked
396
+ # @param check [String] The name of the check
397
+ # @param parameter [String] The parameter to validate
398
+ def check_parameter(file, check, parameter)
399
+ errors << "#{file} (check '#{check}'): invalid parameter '#{parameter}'" unless parameter.is_a?(String) && !parameter.empty?
400
+ end
401
+
402
+ # Check remediation
403
+ #
404
+ # @param file [String] The path to the file being checked
405
+ # @param check [String] The name of the check
406
+ # @param remediation_section [Hash] The remediation section to validate
407
+ def check_remediation(file, check, remediation_section)
408
+ reason_ok = [
409
+ 'reason',
410
+ ]
411
+
412
+ risk_ok = [
413
+ 'level',
414
+ 'reason',
415
+ ]
416
+
417
+ if remediation_section.is_a?(Hash)
418
+ remediation_section.each do |section, value|
419
+ case section
420
+ when 'scan-false-positive', 'disabled'
421
+ value.each do |reason|
422
+ # If the element in the remediation section isn't a hash, it is incorrect.
423
+ if reason.is_a?(Hash)
424
+ # Check for unknown elements and warn the user rather than failing
425
+ (reason.keys - reason_ok).each do |unknown_element|
426
+ warnings << "#{file} (check '#{check}'): Unknown element #{unknown_element} in remediation section #{section}"
427
+ end
428
+ errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of reason hashes." unless reason['reason'].is_a?(String)
429
+ else
430
+ errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of reason hashes."
431
+ end
432
+ end
433
+ when 'risk'
434
+ value.each do |risk|
435
+ # If the element in the remediation section isn't a hash, it is incorrect.
436
+ if risk.is_a?(Hash)
437
+ # Check for unknown elements and warn the user rather than failing
438
+ (risk.keys - risk_ok).each do |unknown_element|
439
+ warnings << "#{file} (check '#{check}'): Unknown element #{unknown_element} in remediation section #{section}"
440
+ end
441
+ # Since reasons are optional here, we won't be checking for those
442
+
443
+ errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of hashes containing levels and reasons." unless risk['level'].is_a?(Integer)
444
+ else
445
+ errors << "#{file} (check '#{check}'): malformed remediation section #{section}, must be an array of hashes containing levels and reasons."
446
+ end
447
+ end
448
+ else
449
+ warnings << "#{file} (check '#{check}'): #{section} is not a recognized section within the remediation section"
450
+ end
451
+ end
452
+ else
453
+ errors << "#{file} (check '#{check}'): malformed remediation section, expecting a hash."
454
+ end
455
+ end
456
+
457
+ # Check a value
458
+ #
459
+ # @param _file [String] The path to the file being checked (currently unused)
460
+ # @param _check [String] The name of the check (currently unused)
461
+ # @param _value [Object] The value to be validated (currently unused)
462
+ # @return [Boolean] Always returns true (currently)
463
+ def check_value(_file, _check, _value)
464
+ # value could be anything
465
+ true
466
+ end
467
+
468
+ # Check settings
469
+ #
470
+ # @param file [String] The path to the file being checked
471
+ # @param check [String] The name of the check
472
+ # @param file_data [Hash] The data to validate
473
+ def check_settings(file, check, file_data)
474
+ ok = ['parameter', 'value']
475
+
476
+ if file_data.nil?
477
+ msg = "#{file} (check '#{check}'): missing settings"
478
+ if file == 'merged data'
479
+ errors << msg
480
+ else
481
+ warnings << msg
482
+ end
483
+ return false
484
+ end
485
+
486
+ if file_data.key?('parameter')
487
+ check_parameter(file, check, file_data['parameter'])
488
+ else
489
+ msg = "#{file} (check '#{check}'): missing key 'parameter'"
490
+ if file == 'merged data'
491
+ errors << msg
492
+ else
493
+ warnings << msg
494
+ end
495
+ end
496
+
497
+ if file_data.key?('value')
498
+ check_value(file, check, file_data['value'])
499
+ else
500
+ msg = "#{file} (check '#{check}'): missing key 'value'"
501
+ if file == 'merged data'
502
+ errors << msg
503
+ else
504
+ warnings << msg
505
+ end
506
+ end
507
+
508
+ file_data.each_key do |key|
509
+ warnings << "#{file} (check '#{check}'): unexpected key '#{key}'" unless ok.include?(key)
510
+ end
511
+ end
512
+
513
+ # Check CEs in a check
514
+ #
515
+ # @param file [String] The path to the file being checked
516
+ # @param file_data [Array] The data to validate
517
+ def check_check_ces(file, file_data)
518
+ warnings << "#{file}: bad ces '#{file_data}'" unless file_data.is_a?(Array)
519
+
520
+ file_data.each do |key|
521
+ warnings << "#{file}: bad ce '#{key}'" unless key.is_a?(String)
522
+ end
523
+ end
524
+
525
+ # Check checks
526
+ #
527
+ # @param file [String] The path to the file being checked
528
+ # @param file_data [Hash] The data to validate
529
+ def check_checks(file, file_data)
530
+ ok = [
531
+ 'type',
532
+ 'settings',
533
+ 'controls',
534
+ 'identifiers',
535
+ 'oval-ids',
536
+ 'ces',
537
+ 'confine',
538
+ 'remediation',
539
+ ]
540
+
541
+ file_data.each do |check, value|
542
+ if value.nil?
543
+ warnings << "#{file} (check '#{check}'): empty value"
544
+ next
545
+ end
546
+
547
+ if value.is_a?(Hash)
548
+ value.each_key do |key|
549
+ warnings << "#{file} (check '#{check}'): unexpected key '#{key}'" unless ok.include?(key)
550
+ end
551
+ else
552
+ 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"
553
+ end
554
+
555
+ check_type(file, check, value['type']) if value['type'] || file == 'merged data'
556
+ check_settings(file, check, value['settings']) if value['settings'] || file == 'merged data'
557
+ unless value['remediation'].nil?
558
+ check_remediation(file, check, value['remediation']) if value['remediation']
559
+ end
560
+ check_controls(file, value['controls']) unless value['controls'].nil?
561
+ check_identifiers(file, value['identifiers']) unless value['identifiers'].nil?
562
+ check_oval_ids(file, value['oval-ids']) unless value['oval-ids'].nil?
563
+ check_check_ces(file, value['ces']) unless value['ces'].nil?
564
+ check_confine(file, value['confine']) unless value['confine'].nil?
565
+ end
566
+ end
567
+
568
+ # Normalize the given confinement hash by:
569
+ # * Expanding all possible combinations of Array values
570
+ # * Converting dotted fact names into a nested facts hash
571
+ #
572
+ # @param confine [Hash] The confinement hash to normalize
573
+ # @return [Array<Hash>] An array of normalized confinement hashes
574
+ def normalize_confinement(confine)
575
+ normalized = []
576
+
577
+ # Step 1, sort the hash keys
578
+ sorted = confine.sort.to_h
579
+
580
+ # Step 2, expand all possible combinations of Array values
581
+ index = 0
582
+ max_count = 1
583
+ sorted.each_value { |value| max_count *= Array(value).size }
584
+
585
+ sorted.each do |key, value|
586
+ (index..(max_count - 1)).each do |i|
587
+ normalized[i] ||= {}
588
+ normalized[i][key] = Array(value)[i % Array(value).size]
589
+ end
590
+ end
591
+
592
+ # Step 3, convert dotted fact names into a facts hash
593
+ normalized.map do |c|
594
+ c.each_with_object({}) do |(key, value), result|
595
+ current = result
596
+ parts = key.split('.')
597
+ parts.each_with_index do |part, i|
598
+ if i == parts.length - 1
599
+ current[part] = value
600
+ else
601
+ current[part] ||= {}
602
+ current = current[part]
603
+ end
604
+ end
605
+ end
606
+ end
607
+ end
608
+
609
+ # Retrieve confines from the loaded data
610
+ #
611
+ # @return [Array] An array of confinement data
612
+ def confines
613
+ return @confines unless @confines.nil?
614
+
615
+ @confines = []
616
+
617
+ [:profiles, :ces, :checks, :controls].each do |type|
618
+ data.public_send(type).each_value do |value|
619
+ # FIXME: This is calling a private method
620
+ value.send(:fragments).each_value do |v|
621
+ next unless v.is_a?(Hash)
622
+ next unless v.key?('confine')
623
+ normalize_confinement(v['confine']).each do |confine|
624
+ @confines << confine unless @confines.include?(confine)
625
+ end
626
+ end
627
+ end
628
+ end
629
+
630
+ @confines
631
+ end
632
+
633
+ # Validate the Hiera data for each available profiles
634
+ #
635
+ # This method performs validation in two stages:
636
+ # 1. Unconfined: Checks if Hiera data exists for each profile.
637
+ # 2. Confined: Checks if Hiera data exists for each profile with specific facts.
638
+ def validate
639
+ if data.profiles.keys.empty?
640
+ notes << 'No profiles found, unable to validate Hiera data'
641
+ return nil
642
+ end
643
+
644
+ # Unconfined, verify that hiera data exists
645
+ data.profiles.each_key do |profile|
646
+ hiera = data.hiera([profile])
647
+ if hiera.nil?
648
+ errors << "Profile '#{profile}': Invalid Hiera data (returned nil)"
649
+ next
650
+ end
651
+ if hiera.empty?
652
+ warnings << "Profile '#{profile}': No Hiera data found"
653
+ next
654
+ end
655
+ log.debug "Profile '#{profile}': Hiera data found (#{hiera.keys.count} keys)"
656
+ end
657
+
658
+ # Again, this time confined
659
+ confines.each do |confine|
660
+ data.facts = confine
661
+ data.profiles.select { |_, value| value.ces&.count&.positive? || value.controls&.count&.positive? }.each_key do |profile|
662
+ hiera = data.hiera([profile])
663
+ if hiera.nil?
664
+ errors << "Profile '#{profile}': Invalid Hiera data (returned nil) with facts #{confine}"
665
+ next
666
+ end
667
+ if hiera.empty?
668
+ warnings << "Profile '#{profile}': No Hiera data found with facts #{confine}"
669
+ next
670
+ end
671
+ log.debug "Profile '#{profile}': Hiera data found (#{hiera.keys.count} keys) with facts #{confine}"
672
+ end
673
+ end
674
+ end
675
+
676
+ # Lint the given file
677
+ #
678
+ # @param file [String] The path to the file being checked
679
+ # @param file_data [Hash] The data to validate
680
+ def lint(file, file_data)
681
+ unless file_data.is_a?(Hash)
682
+ errors << "#{file}: Expected a Hash, got a #{file_data.class}"
683
+ return
684
+ end
685
+
686
+ check_version(file, file_data['version'])
687
+
688
+ check_keys(file, file_data)
689
+
690
+ check_profiles(file, file_data['profiles']) if file_data['profiles']
691
+ check_ce(file, file_data['ce']) if file_data['ce']
692
+ check_checks(file, file_data['checks']) if file_data['checks']
693
+ check_controls(file, file_data['controls']) if file_data['controls']
694
+ rescue => e
695
+ errors << "#{file}: #{e.message} (not a hash?)"
696
+ end
697
+
698
+ private
699
+
700
+ # Merge a ComplianceEngine::Collection object into a Hash
701
+ #
702
+ # @param collection [ComplianceEngine::Collection] A collection object
703
+ # @return [Hash] The merged data
704
+ def merge(collection)
705
+ collection.to_h.reduce({}) { |result, value| result.merge!(value[0] => value[1].to_h) }
706
+ end
707
+
708
+ # Perform lint checks on merged data
709
+ def merged_data_lint
710
+ check_profiles('merged data', merge(data.profiles))
711
+ check_ce('merged data', merge(data.ces))
712
+ check_checks('merged data', merge(data.checks))
713
+ check_controls('merged data', merge(data.controls))
714
+ end
715
+ end
716
+ end