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.
- checksums.yaml +7 -0
- data/.github/workflows/add_new_issue_to_triage_project.yml +40 -0
- data/.github/workflows/pr_tests.yml +42 -0
- data/.github/workflows/tag_deploy_rubygem.yml +212 -0
- data/.gitignore +16 -0
- data/.rspec +3 -0
- data/.rubocop.yml +748 -0
- data/CHANGELOG.md +23 -0
- data/Gemfile +20 -0
- data/README.md +13 -0
- data/Rakefile +12 -0
- data/exe/scelint +6 -0
- data/lib/scelint/cli.rb +80 -0
- data/lib/scelint/version.rb +5 -0
- data/lib/scelint.rb +716 -0
- data/scelint.gemspec +30 -0
- metadata +103 -0
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
|