mirror of
https://github.com/inspec/inspec
synced 2025-02-17 14:38:43 +00:00
347 lines
12 KiB
Ruby
347 lines
12 KiB
Ruby
require "forwardable"
|
|
require "singleton"
|
|
require "inspec/input"
|
|
require "inspec/secrets"
|
|
require "inspec/exceptions"
|
|
require "inspec/plugin/v2"
|
|
|
|
module Inspec
|
|
# The InputRegistry's responsibilities include:
|
|
# - maintaining a list of Input objects that are bound to profiles
|
|
# - assisting in the lookup and creation of Inputs
|
|
class InputRegistry
|
|
include Singleton
|
|
extend Forwardable
|
|
|
|
class Error < Inspec::Error; end
|
|
class ProfileLookupError < Error
|
|
attr_accessor :profile_name
|
|
end
|
|
class InputLookupError < Error
|
|
attr_accessor :profile_name
|
|
attr_accessor :input_name
|
|
end
|
|
|
|
attr_reader :inputs_by_profile, :profile_aliases, :plugins
|
|
def_delegator :inputs_by_profile, :each
|
|
def_delegator :inputs_by_profile, :[]
|
|
def_delegator :inputs_by_profile, :key?, :profile_known?
|
|
def_delegator :inputs_by_profile, :select
|
|
def_delegator :profile_aliases, :key?, :profile_alias?
|
|
|
|
def initialize
|
|
# Keyed on String profile_name => Hash of String input_name => Input object
|
|
@inputs_by_profile = {}
|
|
|
|
# this is a list of optional profile name overrides set in the inspec.yml
|
|
@profile_aliases = {}
|
|
|
|
# Upon creation, activate all input plugins
|
|
activators = Inspec::Plugin::V2::Registry.instance.find_activators(plugin_type: :input)
|
|
|
|
@plugins = activators.map do |activator|
|
|
activator.activate!
|
|
activator.implementation_class.new
|
|
end
|
|
end
|
|
|
|
#-------------------------------------------------------------#
|
|
# Support for Profiles
|
|
#-------------------------------------------------------------#
|
|
|
|
def register_profile_alias(name, alias_name)
|
|
@profile_aliases[name] = alias_name
|
|
end
|
|
|
|
# Returns an Hash, name => Input that have actually been mentioned
|
|
def list_inputs_for_profile(profile)
|
|
inputs_by_profile[profile] = {} unless profile_known?(profile)
|
|
inputs_by_profile[profile]
|
|
end
|
|
|
|
# Returns an Array of input names. This includes input names
|
|
# that plugins may be able to fetch, but have not actually been
|
|
# mentioned in the control code.
|
|
def list_potential_input_names_for_profile(profile_name)
|
|
input_names_from_dsl = inputs_by_profile[profile_name].keys
|
|
input_names_from_plugins = plugins.map { |plugin| plugin.list_inputs(profile_name) }
|
|
(input_names_from_dsl + input_names_from_plugins).flatten.uniq
|
|
end
|
|
|
|
#-------------------------------------------------------------#
|
|
# Support for Individual Inputs
|
|
#-------------------------------------------------------------#
|
|
|
|
def find_or_register_input(input_name, profile_name, options = {})
|
|
input_name = input_name.to_s
|
|
profile_name = profile_name.to_s
|
|
|
|
if profile_alias?(profile_name) && !profile_aliases[profile_name].nil?
|
|
alias_name = profile_name
|
|
profile_name = profile_aliases[profile_name]
|
|
handle_late_arriving_alias(alias_name, profile_name) if profile_known?(alias_name)
|
|
end
|
|
|
|
# Find or create the input
|
|
inputs_by_profile[profile_name] ||= {}
|
|
if inputs_by_profile[profile_name].key?(input_name)
|
|
inputs_by_profile[profile_name][input_name].update(options)
|
|
else
|
|
inputs_by_profile[profile_name][input_name] = Inspec::Input.new(input_name, options)
|
|
poll_plugins_for_update(profile_name, input_name)
|
|
end
|
|
|
|
inputs_by_profile[profile_name][input_name]
|
|
end
|
|
|
|
def poll_plugins_for_update(profile_name, input_name)
|
|
plugins.each do |plugin|
|
|
response = plugin.fetch(profile_name, input_name)
|
|
evt = Inspec::Input::Event.new(
|
|
action: :fetch,
|
|
provider: plugin.class.plugin_name,
|
|
priority: plugin.default_priority,
|
|
hit: !response.nil?
|
|
)
|
|
evt.value = response unless response.nil?
|
|
inputs_by_profile[profile_name][input_name].events << evt
|
|
end
|
|
end
|
|
|
|
# It is possible for a wrapper profile to create an input in metadata,
|
|
# referring to the child profile by an alias that has not yet been registered.
|
|
# The registry will then store the inputs under the alias, as if the alias
|
|
# were a true profile.
|
|
# If that happens and the child profile also mentions the input, we will
|
|
# need to move some things - all inputs should be stored under the true
|
|
# profile name, and no inputs should be stored under the alias.
|
|
def handle_late_arriving_alias(alias_name, profile_name)
|
|
inputs_by_profile[profile_name] ||= {}
|
|
inputs_by_profile[alias_name].each do |input_name, input_from_alias|
|
|
# Move the inpuut, or if it exists, merge events
|
|
existing = inputs_by_profile[profile_name][input_name]
|
|
if existing
|
|
existing.events.concat(input_from_alias.events)
|
|
else
|
|
inputs_by_profile[profile_name][input_name] = input_from_alias
|
|
end
|
|
end
|
|
# Finally, delete the (now copied-out) entry for the alias
|
|
inputs_by_profile.delete(alias_name)
|
|
end
|
|
#-------------------------------------------------------------#
|
|
# Support for Binding Inputs
|
|
#-------------------------------------------------------------#
|
|
|
|
# This method is called by the Profile as soon as it has
|
|
# enough context to allow binding inputs to it.
|
|
def bind_profile_inputs(profile_name, sources = {})
|
|
inputs_by_profile[profile_name] ||= {}
|
|
|
|
# In a more perfect world, we could let the core plugins choose
|
|
# self-determine what to do; but as-is, the APIs that call this
|
|
# are a bit over-constrained.
|
|
bind_inputs_from_metadata(profile_name, sources[:profile_metadata])
|
|
bind_inputs_from_input_files(profile_name, sources[:cli_input_files])
|
|
bind_inputs_from_runner_api(profile_name, sources[:runner_api])
|
|
bind_inputs_from_cli_args(profile_name, sources[:cli_input_arg])
|
|
end
|
|
|
|
private
|
|
|
|
def bind_inputs_from_cli_args(profile_name, input_list)
|
|
# TODO: move this into a core plugin
|
|
|
|
return if input_list.nil?
|
|
return if input_list.empty?
|
|
|
|
# These arrive as an array of "name=value" strings
|
|
# If the user used a comma, we'll see unfortunately see it as "name=value," pairs
|
|
input_list.each do |pair|
|
|
unless pair.include?("=")
|
|
if pair.end_with?(".yaml")
|
|
raise ArgumentError, "ERROR: --input is used for individual input values, as --input name=value. Use --input-file to load a YAML file."
|
|
else
|
|
raise ArgumentError, "ERROR: An '=' is required when using --input. Usage: --input input_name1=input_value1 input2=value2"
|
|
end
|
|
end
|
|
input_name, input_value = pair.split("=")
|
|
input_value = parse_cli_input_value(input_name, input_value)
|
|
evt = Inspec::Input::Event.new(
|
|
value: input_value,
|
|
provider: :cli,
|
|
priority: 50
|
|
)
|
|
find_or_register_input(input_name, profile_name, event: evt)
|
|
end
|
|
end
|
|
|
|
# Remove trailing commas, resolve type.
|
|
def parse_cli_input_value(input_name, given_value)
|
|
value = given_value.chomp(",") # Trim trailing comma if any
|
|
case value
|
|
when /^true|false$/i
|
|
value = !!(value =~ /true/i)
|
|
when /^-?\d+$/
|
|
value = value.to_i
|
|
when /^-?\d+\.\d+$/
|
|
value = value.to_f
|
|
when /^(\[|\{).*(\]|\})$/
|
|
# Look for complex values and try to parse them.
|
|
require "yaml"
|
|
begin
|
|
value = YAML.load(value)
|
|
rescue Psych::SyntaxError => yaml_error
|
|
# It could be that we just tried to run JSON through the YAML parser.
|
|
require "json"
|
|
begin
|
|
value = JSON.parse(value)
|
|
rescue JSON::ParserError => json_error
|
|
msg = "Unparseable value '#{value}' for --input #{input_name}.\n"
|
|
msg += "When treated as YAML, error: #{yaml_error.message}\n"
|
|
msg += "When treated as JSON, error: #{json_error.message}"
|
|
Inspec::Log.warn msg
|
|
end
|
|
end
|
|
end
|
|
value
|
|
end
|
|
|
|
def bind_inputs_from_runner_api(profile_name, input_hash)
|
|
# TODO: move this into a core plugin
|
|
|
|
return if input_hash.nil?
|
|
return if input_hash.empty?
|
|
|
|
# These arrive as a bare hash - values are raw values, not options
|
|
input_hash.each do |input_name, input_value|
|
|
loc = Inspec::Input::Event.probe_stack # TODO: likely modify this to look for a kitchen.yml, if that is realistic
|
|
evt = Inspec::Input::Event.new(
|
|
value: input_value,
|
|
provider: :runner_api, # TODO: suss out if audit cookbook or kitchen-inspec or something unknown
|
|
priority: 40,
|
|
file: loc.path,
|
|
line: loc.lineno
|
|
)
|
|
find_or_register_input(input_name, profile_name, event: evt)
|
|
end
|
|
end
|
|
|
|
def bind_inputs_from_input_files(profile_name, file_list)
|
|
# TODO: move this into a core plugin
|
|
|
|
return if file_list.nil?
|
|
return if file_list.empty?
|
|
|
|
file_list.each do |path|
|
|
validate_inputs_file_readability!(path)
|
|
|
|
# TODO: drop this SecretsBackend stuff, will be handled by plugin system
|
|
data = Inspec::SecretsBackend.resolve(path)
|
|
if data.nil?
|
|
raise Inspec::Exceptions::SecretsBackendNotFound,
|
|
"Cannot find parser for inputs file '#{path}'. " \
|
|
"Check to make sure file has the appropriate extension."
|
|
end
|
|
|
|
next if data.inputs.nil?
|
|
|
|
data.inputs.each do |input_name, input_value|
|
|
evt = Inspec::Input::Event.new(
|
|
value: input_value,
|
|
provider: :cli_files,
|
|
priority: 40,
|
|
file: path
|
|
# TODO: any way we could get a line number?
|
|
)
|
|
find_or_register_input(input_name, profile_name, event: evt)
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_inputs_file_readability!(path)
|
|
unless File.exist?(path)
|
|
raise Inspec::Exceptions::InputsFileDoesNotExist,
|
|
"Cannot find input file '#{path}'. " \
|
|
"Check to make sure file exists."
|
|
end
|
|
|
|
unless File.readable?(path)
|
|
raise Inspec::Exceptions::InputsFileNotReadable,
|
|
"Cannot read input file '#{path}'. " \
|
|
"Check to make sure file is readable."
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def bind_inputs_from_metadata(profile_name, profile_metadata_obj)
|
|
# TODO: move this into a core plugin
|
|
return if profile_metadata_obj.nil? # Metadata files are technically optional
|
|
|
|
if profile_metadata_obj.params.key?(:inputs)
|
|
raw_inputs = profile_metadata_obj.params[:inputs]
|
|
elsif profile_metadata_obj.params.key?(:attributes)
|
|
Inspec.deprecate(:attrs_rename_in_metadata, "Profile: '#{profile_name}'.")
|
|
raw_inputs = profile_metadata_obj.params[:attributes]
|
|
else
|
|
return
|
|
end
|
|
|
|
unless raw_inputs.is_a?(Array)
|
|
Inspec::Log.warn "Inputs must be defined as an Array in metadata files. Skipping definition from #{profile_name}."
|
|
return
|
|
end
|
|
|
|
raw_inputs.each { |i| handle_raw_input_from_metadata(i, profile_name) }
|
|
end
|
|
|
|
def handle_raw_input_from_metadata(input_orig, profile_name)
|
|
input_options = input_orig.dup
|
|
input_name = input_options.delete(:name)
|
|
input_options[:provider] = :profile_metadata
|
|
input_options[:file] = File.join(profile_name, "inspec.yml")
|
|
input_options[:priority] ||= 30
|
|
evt = Inspec::Input.infer_event(input_options)
|
|
|
|
# Profile metadata may set inputs in other profiles by naming them.
|
|
if input_options[:profile]
|
|
profile_name = input_options[:profile] || profile_name
|
|
# Override priority to force this to win. Allow user to set their own priority.
|
|
evt.priority = input_orig[:priority] || 35
|
|
end
|
|
find_or_register_input(
|
|
input_name,
|
|
profile_name,
|
|
type: input_options[:type],
|
|
required: input_options[:required],
|
|
event: evt
|
|
)
|
|
end
|
|
|
|
#-------------------------------------------------------------#
|
|
# Other Support
|
|
#-------------------------------------------------------------#
|
|
public
|
|
|
|
# Used in testing
|
|
def __reset
|
|
@inputs_by_profile = {}
|
|
@profile_aliases = {}
|
|
end
|
|
|
|
# These class methods are convenience methods so you don't always
|
|
# have to call #instance when calling the registry
|
|
%i{
|
|
find_or_register_input
|
|
register_profile_alias
|
|
list_inputs_for_profile
|
|
list_potential_input_names_for_profile
|
|
bind_profile_inputs
|
|
}.each do |meth|
|
|
define_singleton_method(meth) do |*args|
|
|
instance.send(meth, *args)
|
|
end
|
|
end
|
|
end
|
|
end
|