/
rule.rb
434 lines (371 loc) · 13.9 KB
/
rule.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
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
352
353
354
355
356
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
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
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
# copyright: 2015, Dominik Richter
require "method_source"
require "date"
require "inspec/describe_base"
require "inspec/expect"
require "inspec/impact"
require "inspec/resource"
require "inspec/resources/os"
require "inspec/input_registry"
module Inspec
class Rule
include ::RSpec::Matchers
attr_reader :__waiver_data
attr_accessor :resource_dsl
attr_reader :__profile_id
def initialize(id, profile_id, resource_dsl, opts, &block)
@impact = nil
@title = nil
@descriptions = {}
@refs = []
@tags = {}
@resource_dsl = resource_dsl
extend resource_dsl # TODO: remove! do it via method_missing
# not changeable by the user:
@__code = nil
@__block = block
@__source_location = __get_block_source_location(&block)
@__rule_id = id
@__profile_id = profile_id
@__checks = []
@__skip_rule = {} # { result: true, message: "Why", type: [:only_if, :waiver] }
@__merge_count = 0
@__merge_changes = []
@__skip_only_if_eval = opts[:skip_only_if_eval]
# evaluate the given definition
return unless block_given?
begin
instance_eval(&block)
# By applying waivers *after* the instance eval, we assure that
# waivers have higher precedence than only_if.
__apply_waivers
rescue SystemStackError, StandardError => e
# We've encountered an exception while trying to eval the code inside the
# control block. We need to prevent the exception from bubbling up, and
# fail the control. Controls are failed by having a failed resource within
# them; but since our control block is unsafe (and opaque) to us, let's
# make a dummy and fail that.
location = block.source_location.compact.join(":")
describe "Control Source Code Error" do
# Rubocop thinks we are raising an exception - we're actually calling RSpec's fail()
its(location) { fail e.message } # rubocop: disable Style/SignalException
end
end
end
def to_s
Inspec::Rule.rule_id(self)
end
def id(*_)
# never overwrite the ID
@id
end
def impact(v = nil)
if v.is_a?(String)
@impact = Inspec::Impact.impact_from_string(v)
elsif !v.nil?
@impact = v
end
@impact
end
def title(v = nil)
@title = v unless v.nil?
@title
end
def desc(v = nil, data = nil)
return @descriptions[:default] if v.nil?
if data.nil?
@descriptions[:default] = unindent(v)
else
@descriptions[v.to_sym] = unindent(data)
end
end
def descriptions(description_hash = nil)
return @descriptions if description_hash.nil?
@descriptions.merge!(description_hash)
end
def ref(ref = nil, opts = {})
return @refs if ref.nil? && opts.empty?
if opts.empty? && ref.is_a?(Hash)
opts = ref
else
opts[:ref] = ref
end
@refs.push(opts)
end
def tag(*args)
args.each do |arg|
if arg.is_a?(Hash)
@tags.merge!(arg)
else
@tags[arg] ||= nil
end
end
@tags
end
def source_file
@__file
end
# Skip all checks if only_if is false
#
# @param [Type] &block returns true if tests are added, false otherwise
# @return [nil]
def only_if(message = nil)
return unless block_given?
return if @__skip_only_if_eval == true
@__skip_rule[:result] ||= !yield
@__skip_rule[:type] = :only_if
@__skip_rule[:message] = message
end
# Describe will add one or more tests to this control. There is 2 ways
# of calling it:
#
# describe resource do ... end
#
# or
#
# describe.one do ... end
#
# @param [any] Resource to be describe, string, or nil
# @param [Proc] An optional block containing tests for the described resource
# @return [nil|DescribeBase] if called without arguments, returns DescribeBase
def describe(*values, &block)
if values.empty? && !block_given?
dsl = resource_dsl
Class.new(DescribeBase) do
include dsl
end.new(method(:__add_check))
else
__add_check("describe", values, with_dsl(block))
end
end
def expect(value, &block)
target = Inspec::Expect.new(value, &with_dsl(block))
__add_check("expect", [value], target)
target
end
# allow attributes to be accessed within control blocks
def input(input_name, options = {})
if options.empty?
# Simply an access, no event here
Inspec::InputRegistry.find_or_register_input(input_name, __profile_id).value
else
options[:priority] ||= 20
options[:provider] = :inline_control_code
evt = Inspec::Input.infer_event(options)
Inspec::InputRegistry.find_or_register_input(input_name, __profile_id, event: evt).value
end
end
# Find the Input object, but don't collapse to a value.
# Will return nil on a miss.
def input_object(input_name)
Inspec::InputRegistry.find_or_register_input(input_name, __profile_id)
end
def attribute(name, options = {})
Inspec.deprecate(:attrs_dsl, "Input name: #{name}, Profile: #{__profile_id}")
input(name, options)
end
# Support for Control DSL plugins.
# This is called when an unknown method is encountered
# within a control block.
def method_missing(method_name, *arguments, &block)
# Check to see if there is a control_dsl plugin activator hook with the method name
registry = Inspec::Plugin::V2::Registry.instance
hook = registry.find_activators(plugin_type: :control_dsl, activator_name: method_name).first
if hook
# OK, load the hook if it hasn't been already. We'll then know a module,
# which we can then inject into the context
hook.activate
# Inject the module's methods into the context.
# implementation_class is the field name, but this is actually a module.
self.class.include(hook.implementation_class)
# Now that the module is loaded, it defined one or more methods
# (presumably the one we were looking for.)
# We still haven't called it, so do so now.
send(method_name, *arguments, &block)
else
begin
Inspec::DSL.method_missing_resource(inspec, method_name, *arguments)
rescue LoadError
super
end
end
end
# TODO: figure out why these violations exist and nuke them.
def self.rule_id(rule)
rule.instance_variable_get(:@__rule_id)
end
def self.set_rule_id(rule, value)
rule.instance_variable_set(:@__rule_id, value)
end
def self.profile_id(rule)
rule.instance_variable_get(:@__profile_id)
end
def self.checks(rule)
rule.instance_variable_get(:@__checks)
end
def self.skip_status(rule)
rule.instance_variable_get(:@__skip_rule)
end
def self.set_skip_rule(rule, value, message = nil, type = :only_if)
rule.instance_variable_set(:@__skip_rule,
{
result: value,
message: message,
type: type,
})
end
def self.merge_count(rule)
rule.instance_variable_get(:@__merge_count)
end
def self.merge_changes(rule)
rule.instance_variable_get(:@__merge_changes)
end
# If a rule is marked to be skipped, this
# creates a dummay array of "checks" with a skip outcome
def self.prepare_checks(rule)
skip_check = skip_status(rule)
return checks(rule) unless skip_check[:result].eql?(true)
if skip_check[:message]
msg = "Skipped control due to #{skip_check[:type]} condition: #{skip_check[:message]}"
else
msg = "Skipped control due to #{skip_check[:type]} condition."
end
resource = rule.noop
resource.skip_resource(msg)
[["describe", [resource], nil]]
end
def self.merge(dst, src) # rubocop:disable Metrics/AbcSize
if src.id != dst.id
# TODO: register an error, this case should not happen
return
end
sp = rule_id(src)
dp = rule_id(dst)
if sp != dp
# TODO: register an error, this case should not happen
return
end
# merge all fields
dst.impact(src.impact) unless src.impact.nil?
dst.title(src.title) unless src.title.nil?
dst.descriptions(src.descriptions) unless src.descriptions.nil?
dst.tag(src.tag) unless src.tag.nil?
dst.ref(src.ref) unless src.ref.nil?
# merge indirect fields
# checks defined in the source will completely eliminate
# all checks that were defined in the destination
sc = checks(src)
dst.instance_variable_set(:@__checks, sc) unless sc.empty?
skip_check = skip_status(src)
sr = skip_check[:result]
msg = skip_check[:message]
skip_type = skip_check[:type]
set_skip_rule(dst, sr, msg, skip_type) unless sr.nil?
# Save merge history
dst.instance_variable_set(:@__merge_count, merge_count(dst) + 1)
dst.instance_variable_set(
:@__merge_changes,
merge_changes(dst) << src.instance_variable_get(:@__source_location)
)
end
private
def __add_check(describe_or_expect, values, block)
@__checks.push([describe_or_expect, values, block])
end
# Look for an input with a matching ID, and if found, apply waiver
# skipping logic. Basically, if we have a current waiver, and it says
# to skip, we'll replace all the checks with a dummy check (same as
# only_if mechanism)
# Double underscore: not intended to be called as part of the DSL
def __apply_waivers
input_name = @__rule_id # TODO: control ID slugging
registry = Inspec::InputRegistry.instance
input = registry.inputs_by_profile.dig(__profile_id, input_name)
return unless input && input.has_value? && input.value.is_a?(Hash)
# An InSpec Input is a datastructure that tracks a profile parameter
# over time. Its value can be set by many sources, and it keeps a
# log of each "set" event so that when it is collapsed to a value,
# it can determine the correct (highest priority) value.
# Store in an instance variable for.. later reading???
@__waiver_data = input.value
__waiver_data["skipped_due_to_waiver"] = false
__waiver_data["message"] = ""
# Does it have an expiration date, and if so, is it in the future?
# This sets a waiver message before checking `run: true`
expiry = __waiver_data["expiration_date"]
if expiry
# YAML will automagically give us a Date or a Time.
# If transcoding YAML between languages (e.g. Go) the date might have also ended up as a String.
# A string that does not represent a valid time results in the date 0000-01-01.
if [Date, Time].include?(expiry.class) || (expiry.is_a?(String) && Time.new(expiry).year != 0)
expiry = expiry.to_time if expiry.is_a? Date
expiry = Time.new(expiry) if expiry.is_a? String
if expiry < Time.now # If the waiver expired, return - no skip applied
__waiver_data["message"] = "Waiver expired on #{expiry}, evaluating control normally"
return
end
else
ui = Inspec::UI.new
ui.error("Unable to parse waiver expiration date '#{expiry}' for control #{@__rule_id}")
ui.exit(:usage_error)
end
end
# Waivers should have a hash value with keys possibly including "run" and
# expiration_date. We only care here if it has a "run" key and it
# is false-like, since all non-skipped waiver operations are handled
# during reporting phase.
return unless __waiver_data.key?("run") && !__waiver_data["run"]
# OK, apply a skip.
@__skip_rule[:result] = true
@__skip_rule[:type] = :waiver
@__skip_rule[:message] = __waiver_data["justification"]
__waiver_data["skipped_due_to_waiver"] = true
end
#
# Takes a block and returns a block that will run the given block
# with access to the resource_dsl of the current class. This is to
# ensure that inside the constructed Rspec::ExampleGroup users
# have access to DSL methods. Previous this was done in
# Inspec::Runner before sending the example groups to rspec. It
# was moved here to ensure that code inside `its` blocks hae the
# same visibility into resources as code outside its blocks.
#
# @param [Proc] block
# @return [Proc]
#
def with_dsl(block)
return nil if block.nil?
dsl = resource_dsl
return block unless dsl
proc do |*args|
include dsl
instance_exec(*args, &block)
end
end
# Idio(ma)tic unindent, behaves similar to Ruby2.3 curly heredocs.
# Find the shortest indentation of non-empty lines and strip that from every line
# See: https://bugs.ruby-lang.org/issues/9098
#
# It is implemented here to support pre-Ruby2.3 with this feature and
# to not force non-programmers to understand heredocs.
#
# Please note: tabs are not supported! (they will be removed but they are not
# treated the same as in Ruby2.3 heredocs)
#
# @param [String] text string which needs to be unindented
# @return [String] input with indentation removed; '' if input is nil
def unindent(text)
return "" if text.nil?
len = text.split("\n").reject { |l| l.strip.empty? }.map { |x| x.index(/[^\s]/) }.compact.min
text.gsub(/^[[:blank:]]{#{len}}/, "").strip
end
# get the source location of the block
def __get_block_source_location(&block)
return {} unless block_given?
r, l = block.source_location
{ ref: r, line: l }
rescue MethodSource::SourceNotFoundError
{}
end
end
end