# Copyright 2015 Dominik Richter require "forwardable" unless defined?(Forwardable) require "openssl" unless defined?(OpenSSL) require "pathname" unless defined?(Pathname) require "inspec/input_registry" require "inspec/cached_fetcher" # TODO: split or rename require "inspec/source_reader" require "inspec/profile_context" require "inspec/runtime_profile" require "inspec/method_source" require "inspec/dependencies/cache" require "inspec/dependencies/lockfile" require "inspec/dependencies/dependency_set" require "inspec/utils/json_profile_summary" require "inspec/dependency_loader" require "inspec/dependency_installer" require "inspec/utils/profile_ast_helpers" module Inspec class Profile extend Forwardable def self.resolve_target(target, cache, opts = {}) Inspec::Log.debug "Resolve #{target} into cache #{cache.path}" Inspec::CachedFetcher.new(target, cache, opts) end # Check if the profile contains a vendored cache, move content into global cache # TODO: use relative file provider # TODO: use source reader for Cache as well def self.copy_deps_into_cache(file_provider, opts) # filter content cache = file_provider.files.find_all do |entry| entry.start_with?("vendor") end content = Hash[cache.map { |x| [x, file_provider.binread(x)] }] keys = content.keys keys.each do |key| next if content[key].nil? # remove prefix rel = Pathname.new(key).relative_path_from(Pathname.new("vendor")).to_s tar = Pathname.new(opts[:vendor_cache].path).join(rel) FileUtils.mkdir_p tar.dirname.to_s Inspec::Log.debug "Copy #{tar} to cache directory" File.binwrite(tar.to_s, content[key]) end end def self.for_path(path, opts) file_provider = FileProvider.for_path(path) rp = file_provider.relative_provider # copy embedded dependencies into global cache copy_deps_into_cache(rp, opts) unless opts[:vendor_cache].nil? reader = Inspec::SourceReader.resolve(rp) if reader.nil? raise("Don't understand inspec profile in #{path}, it " \ "doesn't look like a supported profile structure.") end new(reader, opts) end def self.for_fetcher(fetcher, config) opts = config.respond_to?(:final_options) ? config.final_options : config opts[:vendor_cache] = opts[:vendor_cache] || Cache.new path, writable = fetcher.fetch for_path(path, opts.merge(target: fetcher.target, writable: writable)) end def self.for_target(target, opts = {}) opts[:vendor_cache] ||= Cache.new config = {} unless opts[:runner_conf].nil? config = opts[:runner_conf].respond_to?(:final_options) ? opts[:runner_conf].final_options : opts[:runner_conf] end fetcher = resolve_target(target, opts[:vendor_cache], config) for_fetcher(fetcher, opts) end attr_reader :source_reader, :backend, :runner_context, :check_mode attr_accessor :parent_profile, :profile_id, :profile_name def_delegator :@source_reader, :tests def_delegator :@source_reader, :libraries def_delegator :@source_reader, :metadata # rubocop:disable Metrics/AbcSize def initialize(source_reader, options = {}) @source_reader = source_reader @target = options[:target] @logger = options[:logger] || Logger.new(nil) @locked_dependencies = options[:dependencies] @controls = options[:controls] || [] @tags = options[:tags] || [] @writable = options[:writable] || false @profile_id = options[:id] @profile_name = options[:profile_name] @cache = options[:vendor_cache] || Cache.new @input_values = options[:inputs] @tests_collected = false @libraries_loaded = false @state = :loaded @check_mode = options[:check_mode] || false @parent_profile = options[:parent_profile] @legacy_profile_path = options[:profiles_path] || false @check_cookstyle = options[:with_cookstyle] Metadata.finalize(@source_reader.metadata, @profile_id, options) # if a backend has already been created, clone it so each profile has its own unique backend object # otherwise, create a new backend object # # This is necessary since we store the RuntimeProfile on the backend object. If a user runs `inspec exec` # with multiple profiles, only the RuntimeProfile for the last-loaded profile will be available if # we share the backend between profiles. # # This will cause issues if a profile attempts to load a file via `inspec.profile.file` train_options = options.reject { |k, _| k == "target" } # See https://github.com/chef/inspec/pull/1646 @backend = options[:backend].nil? ? Inspec::Backend.create(Inspec::Config.new(train_options)) : options[:backend].dup @runtime_profile = RuntimeProfile.new(self) @backend.profile = @runtime_profile # The AttributeRegistry is in charge of keeping track of inputs; # it is the single source of truth. Now that we have a profile object, # we can create any inputs that were provided by various mechanisms. options[:runner_conf] ||= Inspec::Config.cached # Catch legacy CLI input option usage if options[:runner_conf].key?(:attrs) Inspec.deprecate(:rename_attributes_to_inputs, "Use --input-file on the command line instead of --attrs.") options[:runner_conf][:input_file] = options[:runner_conf].delete(:attrs) elsif options[:runner_conf].key?(:input_files) # The kitchen-inspec docs say to use plural. Our CLI and internal expectations are singular. options[:runner_conf][:input_file] = options[:runner_conf].delete(:input_files) end # Catch legacy kitchen-inspec input usage if options[:runner_conf].key?(:attributes) Inspec.deprecate(:rename_attributes_to_inputs, "Use :inputs in your kitchen.yml verifier config instead of :attributes.") options[:runner_conf][:inputs] = options[:runner_conf].delete(:attributes) end Inspec::InputRegistry.bind_profile_inputs( # Every input only exists in the context of a profile metadata.params[:name], # TODO: test this with profile aliasing # Remaining args are possible sources of inputs cli_input_files: options[:runner_conf][:input_file], # From CLI --input-file profile_metadata: metadata, runner_api: options[:runner_conf][:inputs], # This is the route the audit_cookbook and kitchen-inspec take cli_input_arg: options[:runner_conf][:input] # The --input name=value CLI option ) @runner_context = options[:profile_context] || Inspec::ProfileContext.for_profile(self, @backend) if metadata.supports_platform?(@backend) @supports_platform = true else @supports_platform = false @state = :skipped end @supports_runtime = metadata.supports_runtime? end def name metadata.params[:name] end def version metadata.params[:version] end def writable? @writable end def failed? @state == :failed end # # Is this profile is supported on the current platform of the # backend machine and the current inspec version. # # @returns [TrueClass, FalseClass] # def supported? supports_platform? && supports_runtime? end # We need to check if we're using a Mock'd backend for tests to function. # @returns [TrueClass, FalseClass] def supports_platform? if @supports_platform.nil? @supports_platform = metadata.supports_platform?(@backend) end if @backend.backend.class.to_s == "Train::Transports::Mock::Connection" @supports_platform = true end @supports_platform end def supports_runtime? if @supports_runtime.nil? @supports_runtime = metadata.supports_runtime? end @supports_runtime end def params @params ||= load_params end def collect_tests unless @tests_collected || failed? return unless supports_platform? locked_dependencies.each(&:collect_tests) tests = filter_waived_controls # Collect tests tests.each do |path, content| next if content.nil? || content.empty? abs_path = source_reader.target.abs_path(path) begin @runner_context.load_control_file(content, abs_path, nil) rescue => e @state = :failed raise Inspec::Exceptions::ProfileLoadFailed, "Failed to load source for #{path}: #{e}" end end @tests_collected = true end @runner_context.all_rules end # Wipe out waived controls def filter_waived_controls cfg = Inspec::Config.cached return tests unless cfg["filter_waived_controls"] ## Find the waivers file # - TODO: cli_opts and instance_variable_get could be exposed waiver_paths = cfg.instance_variable_get(:@cli_opts)["waiver_file"] if waiver_paths.nil? || waiver_paths.empty? Inspec::Log.error "Must use --waiver-file with --filter-waived-controls" Inspec::UI.new.exit(:usage_error) end # # Pull together waiver waived_control_ids = [] waiver_paths.each do |waiver_path| waiver_content = YAML.load_file(waiver_path) unless waiver_content # Note that we will have already issued a detailed warning Inspec::Log.error "YAML parsing error in #{waiver_path}" Inspec::UI.new.exit(:usage_error) end waived_control_ids << waiver_content.keys end waived_control_id_regex = "(#{waived_control_ids.join("|")})" ## Purge tests (this could be doone in next block for performance) ## TODO: implement earlier with pure AST and pure autocorrect AST filtered_tests = {} if cfg["retain_waiver_data"] # VERY EXPERIMENTAL, but an empty describe block at the top level # of the control blocks evaluation of ruby code until later-term # waivers (behind the scenes this tells RSpec to hold on and use its internals to lazy load the code). This allows current waiver-data (e.g. skips) to still # be processed and rendered tests.each do |control_filename, source_code| cleared_tests = source_code.scan(/control\s+['"].+?['"].+?(?=(?:control\s+['"].+?['"])|\z)/m).collect do |element| next if element.nil? || element.empty? if element&.match?(waived_control_id_regex) splitlines = element.split("\n") splitlines[0] + "\ndescribe '---' do\n" + splitlines[1..-1].join("\n") + "\nend\n" else element end end.join("") filtered_tests[control_filename] = cleared_tests end else tests.each do |control_filename, source_code| cleared_tests = source_code.scan(/control\s+['"].+?['"].+?(?=(?:control\s+['"].+?['"])|\z)/m).select do |control_code| !control_code.match?(waived_control_id_regex) end.join("") filtered_tests[control_filename] = cleared_tests end end filtered_tests end # This creates the list of controls provided in the --controls options which need to be include # for evaluation. def include_controls_list return [] if @controls.nil? || @controls.empty? included_controls = @controls # Check for anything that might be a regex in the list, and make it official included_controls.each_with_index do |inclusion, index| next if inclusion.is_a?(Regexp) # Insist the user wrap the regex in slashes to demarcate it as a regex next unless inclusion.start_with?("/") && inclusion.end_with?("/") inclusion = inclusion[1..-2] # Trim slashes begin re = Regexp.new(inclusion) included_controls[index] = re rescue RegexpError => e warn "Ignoring unparseable regex '/#{inclusion}/' in --control CLI option: #{e.message}" included_controls[index] = nil end end included_controls.compact! included_controls end # This creates the list of controls to be filtered by tag values provided in the --tags options def include_tags_list return [] if @tags.nil? || @tags.empty? included_tags = @tags # Check for anything that might be a regex in the list, and make it official included_tags.each_with_index do |inclusion, index| next if inclusion.is_a?(Regexp) # Insist the user wrap the regex in slashes to demarcate it as a regex next unless inclusion.start_with?("/") && inclusion.end_with?("/") inclusion = inclusion[1..-2] # Trim slashes begin re = Regexp.new(inclusion) included_tags[index] = re rescue RegexpError => e warn "Ignoring unparseable regex '/#{inclusion}/' in --control CLI option: #{e.message}" included_tags[index] = nil end end included_tags.compact! included_tags end def load_libraries return @runner_context if @libraries_loaded locked_dependencies.dep_list.each_with_index do |(_name, dep), index| d = dep.profile # this will force a dependent profile load so we are only going to add # this metadata if the parent profile is supported. if @supports_platform && !d.supports_platform? # since ruby 1.9 hashes are ordered so we can just use index values here # TODO: NO! this is a violation of encapsulation to an extreme if metadata.dependencies[index] metadata.dependencies[index][:status] = "skipped" msg = "Skipping profile: '#{d.name}' on unsupported platform: '#{d.backend.platform.name}/#{d.backend.platform.release}'." metadata.dependencies[index][:status_message] = msg metadata.dependencies[index][:skip_message] = msg # Repeat as skip_message for backward compatibility end next elsif metadata.dependencies[index] # Currently wrapper profiles will load all dependencies, and then we # load them again when we dive down. This needs to be re-done. metadata.dependencies[index][:status] = "loaded" end # rubocop:disable Layout/ExtraSpacing c = d.load_libraries # !!!RECURSE!!! @runner_context.add_resources(c) end # TODO: why?!? we own both sides of this code libs = libraries.map(&:reverse) @runner_context.load_libraries(libs) @libraries_loaded = true @runner_context end # This collects the gem dependencies data from parent and its dependent profiles def collect_gem_dependencies gem_dependencies = [] # This collects the dependent profiles gem dependencies if any locked_dependencies.dep_list.each do |_name, dep| profile = dep.profile gem_dependencies << profile.metadata.gem_dependencies unless profile.metadata.gem_dependencies.empty? end # Appends the parent profile gem dependencies which are available through metadata gem_dependencies << metadata.gem_dependencies unless metadata.gem_dependencies.empty? gem_dependencies.flatten.uniq end # Loads the required gems specified in the Profile's metadata file from default inspec gems path i.e. ~/.inspec/gems # else installs and loads them. def load_gem_dependencies gem_dependencies = collect_gem_dependencies gem_dependencies.each do |gem_data| dependency_loader = DependencyLoader.new if dependency_loader.gem_version_installed?(gem_data[:name], gem_data[:version]) || dependency_loader.gem_installed?(gem_data[:name]) load_gem_dependency(gem_data) else if Inspec::Config.cached[:auto_install_gems] install_gem_dependency(gem_data) load_gem_dependency(gem_data) else ui = Inspec::UI.new gem_dependencies.each { |gem_dependency| ui.list_item("#{gem_dependency[:name]} #{gem_dependency[:version]}") } choice = ui.prompt.select("The above listed gem dependencies are required to run the profile. Would you like to install them ?", %w{Yes No}) if choice == "Yes" Inspec::Config.cached[:auto_install_gems] = true load_gem_dependencies else ui.error "Unable to resolve above listed profile gem dependencies." Inspec::UI.new.exit(:gem_dependency_load_error) end end end end end # Requires gem_data as argument. # gem_dta example: { name: "gem_name", version: "0.0.1"} def load_gem_dependency(gem_data) dependency_loader = DependencyLoader.new(nil, [gem_data]) dependency_loader.load rescue Inspec::GemDependencyLoadError => e raise e.message end # Requires gem_data as argument. # gem_dta example: { name: "gem_name", version: "0.0.1"} def install_gem_dependency(gem_data) gem_dependency = DependencyInstaller.new(nil, [gem_data]) gem_dependency.install rescue Inspec::GemDependencyInstallError => e raise e.message end def to_s "Inspec::Profile<#{name}>" end # return info using uncached params def info! info(load_params.dup) end def info(res = params.dup) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength # add information about the controls res[:controls] = res[:controls].map do |id, rule| next if id.to_s.empty? data = rule.dup data.delete(:checks) data[:impact] ||= 0.5 data[:impact] = 1.0 if data[:impact] > 1.0 data[:impact] = 0.0 if data[:impact] < 0.0 data[:id] = id # if the code field is empty try and pull info from dependencies if data[:code].empty? && parent_profile.nil? locked_dependencies.dep_list.each do |_name, dep| profile = dep.profile code = Inspec::MethodSource.code_at(data[:source_location], profile.source_reader) data[:code] = code unless code.nil? || code.empty? break unless data[:code].empty? end end data end.compact # resolve hash structure in groups res[:groups] = res[:groups].map do |id, group| group[:id] = id group end # add information about the required inputs if params[:inputs].nil? || params[:inputs].empty? # convert to array for backwards compatability res[:inputs] = [] else res[:inputs] = params[:inputs].values.map(&:to_hash) end res[:sha256] = sha256 res[:parent_profile] = parent_profile unless parent_profile.nil? if @supports_platform res[:status_message] = @status_message || "" res[:status] = failed? ? "failed" : "loaded" else res[:status] = "skipped" msg = "Skipping profile: '#{name}' on unsupported platform: '#{backend.platform.name}/#{backend.platform.release}'." res[:status_message] = msg end # convert legacy os-* supports to their platform counterpart if res[:supports] && !res[:supports].empty? res[:supports].each do |support| # TODO: deprecate support[:"platform-family"] = support.delete(:"os-family") if support.key?(:"os-family") support[:"platform-name"] = support.delete(:"os-name") if support.key?(:"os-name") end end res end # Return data like profile.info(params), but try to do so without evaluating the profile. def info_from_parse(include_tests: false) return @info_from_parse unless @info_from_parse.nil? @info_from_parse = { controls: [], groups: [], } # TODO - look at the various source contents # PASS 1: parse them using rubocop-ast # Look for controls, top-level metadata, and inputs # PASS 2: Using the control IDs, deterimine the extents - # line locations - of the coontrol IDs in each file, and # then extract each source code block. Use this to populate the source code # locations and 'code' properties. # TODO: Verify that it doesn't do evaluation (ideally shouldn't because it is reading simply yaml file) @info_from_parse = @info_from_parse.merge(metadata.params) inputs_hash = {} # Note: This only handles the case when inputs are defined in metadata file if @profile_id.nil? # identifying inputs using profile name inputs_hash = Inspec::InputRegistry.list_inputs_for_profile(@info_from_parse[:name]) else inputs_hash = Inspec::InputRegistry.list_inputs_for_profile(@profile_id) end # TODO: Verify if I need to do the below conversion for inputs to array if inputs_hash.nil? || inputs_hash.empty? # convert to array for backwards compatability @info_from_parse[:inputs] = [] else @info_from_parse[:inputs] = inputs_hash.values.map(&:to_hash) end @info_from_parse[:sha256] = sha256 # Populate :status and :status_message if supports_platform? @info_from_parse[:status_message] = @status_message || "" @info_from_parse[:status] = failed? ? "failed" : "loaded" else @info_from_parse[:status] = "skipped" msg = "Skipping profile: '#{name}' on unsupported platform: '#{backend.platform.name}/#{backend.platform.release}'." @info_from_parse[:status_message] = msg end # @source_reader.tests contains a hash mapping control filenames to control file contents @source_reader.tests.each do |control_filename, control_file_source| # Parse the source code src = RuboCop::AST::ProcessedSource.new(control_file_source, RUBY_VERSION.to_f) source_location_ref = @source_reader.target.abs_path(control_filename) input_collector = Inspec::Profile::AstHelper::InputCollectorOutsideControlBlock.new(@info_from_parse) ctl_id_collector = Inspec::Profile::AstHelper::ControlIDCollector.new(@info_from_parse, source_location_ref, include_tests: include_tests) # Collect all metadata defined in the control block and inputs defined inside the control block src.ast.each_node { |n| ctl_id_collector.process(n) input_collector.process(n) } # For each control ID # Look for per-control metadata # Filter controls by --controls, list of controls to include is available in include_controls_list # NOTE: This is a hack to duplicate refs. # TODO: Fix this in the ref collector or the way we traverse the AST @info_from_parse[:controls].each { |control| control[:refs].uniq! } @info_from_parse[:controls] = filter_controls_by_id_and_tags(@info_from_parse[:controls]) # Update groups after filtering controls to handle --controls option update_groups_from(control_filename, src) # NOTE: This is a hack to duplicate inputs. # TODO: Fix this in the input collector or the way we traverse the AST @info_from_parse[:inputs] = @info_from_parse[:inputs].uniq end @info_from_parse end def filter_controls_by_id_and_tags(controls) controls.select do |control| tag_ids = get_all_tags_list(control[:tags]) (include_controls_list.empty? || include_controls_list.any? { |control_id| control_id.match?(control[:id]) }) && (include_tags_list.empty? || include_tags_list.any? { |tag_id| tag_ids.any? { |tag| tag_id.match?(tag) } }) end end def get_all_tags_list(control_tags) all_tags = [] control_tags.each do |tags| all_tags.push(tags) end all_tags.flatten.compact.uniq.map(&:to_s) rescue [] end def include_group_data?(group_data) unless include_controls_list.empty? # {:id=>"controls/example-tmp.rb", :title=>"/ profile", :controls=>["tmp-1.0"]} # Check if the group should be included based on the controls it contains group_data[:controls].any? do |control_id| include_controls_list.any? { |id| id.match?(control_id) } end else true end end def update_groups_from(control_filename, src) group_data = { id: control_filename, title: nil, } source_location_ref = @source_reader.target.abs_path(control_filename) Inspec::Profile::AstHelper::TitleCollector.new(group_data) .process(src.ast.child_nodes.first) # Picking the title defined for the whole controls file group_controls = @info_from_parse[:controls].select { |control| control[:source_location][:ref] == source_location_ref } group_data[:controls] = group_controls.map { |control| control[:id] } @info_from_parse[:groups].push(group_data) if include_group_data?(group_data) end def cookstyle_linting_check msgs = [] return msgs if Inspec.locally_windows? # See #5723 output = cookstyle_rake_output.split("Offenses:").last msgs = output.split("\n").select { |x| x =~ /[A-Z]:/ } unless output.nil? msgs end # Cookstyle linting rake run output def cookstyle_rake_output require "cookstyle" require "rubocop/rake_task" begin RuboCop::RakeTask.new(:cookstyle_lint) do |spec| spec.options += [ "--display-cop-names", "--parallel", "--only=InSpec/Deprecations", ] spec.patterns += Dir.glob("#{@target}/**/*.rb").reject { |f| File.directory?(f) } spec.fail_on_error = false end rescue LoadError puts "Rubocop is not available. Install the rubocop gem to run the lint tests." end begin stdout = StringIO.new $stdout = stdout Rake::Task["cookstyle_lint"].invoke $stdout = STDOUT Rake.application["cookstyle_lint"].reenable stdout.string rescue => e puts "Cookstyle lint checks could not be performed. Error while running cookstyle - #{e}" "" end end def legacy_check # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength # initial values for response object result = { summary: { valid: false, timestamp: Time.now.iso8601, location: @target, profile: nil, controls: 0, }, errors: [], warnings: [], offenses: [], } entry = lambda { |file, line, column, control, msg| { file: file, line: line, column: column, control_id: control, msg: msg, } } warn = lambda { |file, line, column, control, msg| @logger.warn(msg) result[:warnings].push(entry.call(file, line, column, control, msg)) } error = lambda { |file, line, column, control, msg| @logger.error(msg) result[:errors].push(entry.call(file, line, column, control, msg)) } offense = lambda { |file, line, column, control, msg| result[:offenses].push(entry.call(file, line, column, control, msg)) } @logger.info "Checking profile in #{@target}" meta_path = @source_reader.target.abs_path(@source_reader.metadata.ref) # verify metadata m_errors, m_warnings = metadata.valid m_errors.each { |msg| error.call(meta_path, 0, 0, nil, msg) } m_warnings.each { |msg| warn.call(meta_path, 0, 0, nil, msg) } m_unsupported = metadata.unsupported m_unsupported.each { |u| warn.call(meta_path, 0, 0, nil, "doesn't support: #{u}") } @logger.info "Metadata OK." if m_errors.empty? && m_unsupported.empty? # only run the vendor check if the legacy profile-path is not used as argument if @legacy_profile_path == false # verify that a lockfile is present if we have dependencies unless metadata.dependencies.empty? error.call(meta_path, 0, 0, nil, "Your profile needs to be vendored with `inspec vendor`.") unless lockfile_exists? end if lockfile_exists? # verify if metadata and lockfile are out of sync if lockfile.deps.size != metadata.dependencies.size error.call(meta_path, 0, 0, nil, "inspec.yml and inspec.lock are out-of-sync. Please re-vendor with `inspec vendor`.") end # verify if metadata and lockfile have the same dependency names metadata.dependencies.each do |dep| # Skip if the dependency does not specify a name next if dep[:name].nil? # TODO: should we also verify that the soure is the same? unless lockfile.deps.map { |x| x[:name] }.include? dep[:name] error.call(meta_path, 0, 0, nil, "Cannot find #{dep[:name]} in lockfile. Please re-vendor with `inspec vendor`.") end end end end # extract profile name result[:summary][:profile] = metadata.params[:name] count = params[:controls].values.length result[:summary][:controls] = count if count == 0 warn.call(nil, nil, nil, nil, "No controls or tests were defined.") else @logger.info("Found #{count} controls.") end # iterate over hash of groups params[:controls].each do |id, control| sfile = control[:source_location][:ref] sline = control[:source_location][:line] error.call(sfile, sline, nil, id, "Avoid controls with empty IDs") if id.nil? || id.empty? next if id.start_with? "(generated " warn.call(sfile, sline, nil, id, "Control #{id} has no title") if control[:title].to_s.empty? warn.call(sfile, sline, nil, id, "Control #{id} has no descriptions") if control[:descriptions][:default].to_s.empty? warn.call(sfile, sline, nil, id, "Control #{id} has impact > 1.0") if control[:impact].to_f > 1.0 warn.call(sfile, sline, nil, id, "Control #{id} has impact < 0.0") if control[:impact].to_f < 0.0 warn.call(sfile, sline, nil, id, "Control #{id} has no tests defined") if control[:checks].nil? || control[:checks].empty? end # Running cookstyle to check for code offenses if @check_cookstyle cookstyle_linting_check.each do |lint_output| data = lint_output.split(":") msg = "#{data[-2]}:#{data[-1]}" offense.call(data[0], data[1], data[2], nil, msg) end end # profile is valid if we could not find any error & offenses result[:summary][:valid] = result[:errors].empty? && result[:offenses].empty? @logger.info "Control definitions OK." if result[:warnings].empty? result end # Check if the profile is internally well-structured. The logger will be # used to print information on errors and warnings which are found. # # @return [Boolean] true if no errors were found, false otherwise def check # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength # initial values for response object result = { summary: { valid: false, timestamp: Time.now.iso8601, location: @target, profile: nil, controls: 0, }, errors: [], warnings: [], offenses: [], } # memoize `info_from_parse` with tests info_from_parse(include_tests: true) entry = lambda { |file, line, column, control, msg| { file: file, line: line, column: column, control_id: control, msg: msg, } } warn = lambda { |file, line, column, control, msg| @logger.warn(msg) result[:warnings].push(entry.call(file, line, column, control, msg)) } error = lambda { |file, line, column, control, msg| @logger.error(msg) result[:errors].push(entry.call(file, line, column, control, msg)) } offense = lambda { |file, line, column, control, msg| result[:offenses].push(entry.call(file, line, column, control, msg)) } @logger.info "Checking profile in #{@target}" meta_path = @source_reader.target.abs_path(@source_reader.metadata.ref) # verify metadata m_errors, m_warnings = validity_check m_errors.each { |msg| error.call(meta_path, 0, 0, nil, msg) } m_warnings.each { |msg| warn.call(meta_path, 0, 0, nil, msg) } m_unsupported = metadata.unsupported m_unsupported.each { |u| warn.call(meta_path, 0, 0, nil, "doesn't support: #{u}") } @logger.info "Metadata OK." if m_errors.empty? && m_unsupported.empty? # only run the vendor check if the legacy profile-path is not used as argument if @legacy_profile_path == false # verify that a lockfile is present if we have dependencies unless metadata.dependencies.empty? error.call(meta_path, 0, 0, nil, "Your profile needs to be vendored with `inspec vendor`.") unless lockfile_exists? end if lockfile_exists? # verify if metadata and lockfile are out of sync if lockfile.deps.size != metadata.dependencies.size error.call(meta_path, 0, 0, nil, "inspec.yml and inspec.lock are out-of-sync. Please re-vendor with `inspec vendor`.") end # verify if metadata and lockfile have the same dependency names metadata.dependencies.each do |dep| # Skip if the dependency does not specify a name next if dep[:name].nil? # TODO: should we also verify that the soure is the same? unless lockfile.deps.map { |x| x[:name] }.include? dep[:name] error.call(meta_path, 0, 0, nil, "Cannot find #{dep[:name]} in lockfile. Please re-vendor with `inspec vendor`.") end end end end # extract profile name result[:summary][:profile] = info_from_parse[:name] count = info_from_parse[:controls].count result[:summary][:controls] = count if count == 0 warn.call(nil, nil, nil, nil, "No controls or tests were defined.") else @logger.info("Found #{count} controls.") end # iterate over hash of groups info_from_parse[:controls].each do |control| sfile = control[:source_location][:ref] sline = control[:source_location][:line] id = control[:id] error.call(sfile, sline, nil, id, "Avoid controls with empty IDs") if id.nil? || id.empty? next if id.start_with? "(generated " warn.call(sfile, sline, nil, id, "Control #{id} has no title") if control[:title].to_s.empty? warn.call(sfile, sline, nil, id, "Control #{id} has no descriptions") if control[:descriptions][:default].to_s.empty? warn.call(sfile, sline, nil, id, "Control #{id} has impact > 1.0") if control[:impact].to_f > 1.0 warn.call(sfile, sline, nil, id, "Control #{id} has impact < 0.0") if control[:impact].to_f < 0.0 warn.call(sfile, sline, nil, id, "Control #{id} has no tests defined") if control[:checks].nil? || control[:checks].empty? end # Running cookstyle to check for code offenses if @check_cookstyle cookstyle_linting_check.each do |lint_output| data = lint_output.split(":") msg = "#{data[-2]}:#{data[-1]}" offense.call(data[0], data[1], data[2], nil, msg) end end # profile is valid if we could not find any error & offenses result[:summary][:valid] = result[:errors].empty? && result[:offenses].empty? @logger.info "Control definitions OK." if result[:warnings].empty? result end def validity_check # rubocop:disable Metrics/AbcSize errors = [] warnings = [] info_from_parse.merge!(metadata.params) %w{name version}.each do |field| next unless info_from_parse[field.to_sym].nil? errors.push("Missing profile #{field} in #{metadata.ref}") end if %r{[\/\\]} =~ info_from_parse[:name] errors.push("The profile name (#{info_from_parse[:name]}) contains a slash" \ " which is not permitted. Please remove all slashes from `inspec.yml`.") end # if version is set, ensure it is correct if !info_from_parse[:version].nil? && !metadata.valid_version?(info_from_parse[:version]) errors.push("Version needs to be in SemVer format") end if info_from_parse[:entitlement_id] && info_from_parse[:entitlement_id].strip.empty? errors.push("Entitlement ID should not be blank.") end unless metadata.supports_runtime? warnings.push("The current inspec version #{Inspec::VERSION} cannot satisfy profile inspec_version constraint #{info_from_parse[:inspec_version]}") end %w{title summary maintainer copyright license}.each do |field| next unless info_from_parse[field.to_sym].nil? warnings.push("Missing profile #{field} in #{metadata.ref}") end # if license is set, ensure it is in SPDX format or marked as proprietary if !info_from_parse[:license].nil? && !metadata.valid_license?(info_from_parse[:license]) warnings.push("License '#{info_from_parse[:license]}' needs to be in SPDX format or marked as 'Proprietary'. See https://spdx.org/licenses/.") end # If gem_dependencies is set, it must be an array of hashes with keys name and optional version unless info_from_parse[:gem_dependencies].nil? list = info_from_parse[:gem_dependencies] if list.is_a?(Array) && list.all? { |e| e.is_a? Hash } list.each do |entry| errors.push("gem_dependencies entries must all have a 'name' field") unless entry.key?(:name) if entry[:version] orig = entry[:version] begin # Split on commas as we may have a complex dep orig.split(",").map { |c| Gem::Requirement.parse(c) } rescue Gem::Requirement::BadRequirementError errors.push "Unparseable gem dependency '#{orig}' for #{entry[:name]}" rescue Inspec::GemDependencyInstallError => e errors.push e.message end end extra = (entry.keys - %i{name version}) unless extra.empty? warnings.push "Unknown gem_dependencies key(s) #{extra.join(",")} seen for entry '#{entry[:name]}'" end end else errors.push("gem_dependencies must be a List of Hashes") end end [errors, warnings] end def set_status_message(msg) @status_message = msg.to_s end # generates a archive of a folder profile def archive(opts) # check if file exists otherwise overwrite the archive dst = archive_name(opts) if dst.exist? && !opts[:overwrite] @logger.info "Archive #{dst} exists already. Use --overwrite." return false end # remove existing archive FileUtils.rm_f(dst) if dst.exist? @logger.info "Generate archive #{dst}." # filter files that should not be part of the profile # TODO ignore all .files, but add the files to debug output # Generate temporary inspec.json for archive export_opt_enabled = opts[:export] || opts[:legacy_export] if export_opt_enabled info_for_profile_summary = opts[:legacy_export] ? info : info_from_parse Inspec::Utils::JsonProfileSummary.produce_json( info: info_for_profile_summary, write_path: "#{root_path}inspec.json", suppress_output: true ) end # display all files that will be part of the archive @logger.debug "Add the following files to archive:" files.each { |f| @logger.debug " " + f } @logger.debug " inspec.json" if export_opt_enabled archive_files = export_opt_enabled ? files.push("inspec.json") : files if opts[:zip] # generate zip archive require "inspec/archive/zip" zag = Inspec::Archive::ZipArchiveGenerator.new zag.archive(root_path, archive_files, dst) else # generate tar archive require "inspec/archive/tar" tag = Inspec::Archive::TarArchiveGenerator.new tag.archive(root_path, archive_files, dst) end # Cleanup FileUtils.rm_f("#{root_path}inspec.json") if export_opt_enabled @logger.info "Finished archive generation." true end def locked_dependencies @locked_dependencies ||= load_dependencies end def lockfile_exists? @source_reader.target.files.include?("inspec.lock") end def lockfile_path File.join(cwd, "inspec.lock") end def root_path @source_reader.target.prefix end def files @source_reader.target.files end def readme @source_reader.readme&.values&.first end def metadata_src @source_reader.metadata_src end # # TODO(ssd): Relative path handling really needs to be carefully # thought through, especially with respect to relative paths in # tarballs. # def cwd @target.is_a?(String) && File.directory?(@target) ? @target : "./" end def lockfile @lockfile ||= if lockfile_exists? Inspec::Lockfile.from_content(@source_reader.target.read("inspec.lock")) else generate_lockfile end end # # Generate an in-memory lockfile. This won't render the lock file # to disk, it must be explicitly written to disk by the caller. # # @param vendor_path [String] Path to the on-disk vendor dir # @return [Inspec::Lockfile] # def generate_lockfile res = Inspec::DependencySet.new(cwd, @cache, nil, @backend) res.vendor(metadata.dependencies) Inspec::Lockfile.from_dependency_set(res) end def load_dependencies config = { cwd: cwd, cache: @cache, backend: @backend, parent_profile: name, } Inspec::DependencySet.from_lockfile(lockfile, config, { inputs: @input_values }) end # Calculate this profile's SHA256 checksum. Includes metadata, dependencies, # libraries, data files, and controls. # # @return [Type] description of returned object def sha256 # get all dependency checksums deps = Hash[locked_dependencies.list.map { |k, v| [k, v.profile.sha256] }] res = OpenSSL::Digest.new("SHA256") files = source_reader.tests.to_a + source_reader.libraries.to_a + source_reader.data_files.to_a + [["inspec.yml", source_reader.metadata.content]] + [["inspec.lock.deps", YAML.dump(deps)]] files.sort_by { |a| a[0] } .map { |f| res << f[0] << "\0" << f[1] << "\0" } res.digest.unpack("H*")[0] end private # Create an archive name for this profile and an additional options # configuration. Either use :output or generate the name from metadata. # # @param [Hash] configuration options # @return [Pathname] path for the archive def archive_name(opts) if (name = opts[:output]) return Pathname.new(name) end # Using metadata to fetch basic info of name and version metadata = @source_reader.metadata.params name = metadata[:name] || raise("Cannot create an archive without a profile name! Please "\ "specify the name in metadata or use --output to create the archive.") version = metadata[:version] || raise("Cannot create an archive without a profile version! Please "\ "specify the version in metadata or use --output to create the archive.") ext = opts[:zip] ? "zip" : "tar.gz" slug = name.downcase.strip.tr(" ", "-").gsub(/[^\w-]/, "_") Pathname.new(Dir.pwd).join("#{slug}-#{version}.#{ext}") end def load_params params = @source_reader.metadata.params params[:name] = @profile_id unless @profile_id.nil? load_checks_params(params) @profile_id ||= params[:name] params end def load_checks_params(params) load_gem_dependencies load_libraries tests = collect_tests params[:controls] = controls = {} params[:groups] = groups = {} prefix = @source_reader.target.prefix || "" tests&.each do |rule| next if rule.nil? f = load_rule_filepath(prefix, rule) load_rule(rule, f, controls, groups) end if @profile_id.nil? # identifying inputs using profile name params[:inputs] = Inspec::InputRegistry.list_inputs_for_profile(params[:name]) else params[:inputs] = Inspec::InputRegistry.list_inputs_for_profile(@profile_id) end params end def load_rule_filepath(prefix, rule) file = rule.instance_variable_get(:@__file) file = file[prefix.length..-1] if file.start_with?(prefix) file end def load_rule(rule, file, controls, groups) id = Inspec::Rule.rule_id(rule) location = rule.instance_variable_get(:@__source_location) controls[id] = { title: rule.title, desc: rule.desc, descriptions: rule.descriptions, impact: rule.impact, refs: rule.ref, tags: rule.tag, checks: Inspec::Rule.checks(rule), code: Inspec::MethodSource.code_at(location, source_reader), source_location: location, } # try and grab code text from merge locations if controls[id][:code].empty? && Inspec::Rule.merge_count(rule) > 0 Inspec::Rule.merge_changes(rule).each do |merge_location| code = Inspec::MethodSource.code_at(merge_location, source_reader) unless code.empty? controls[id][:code] = code break end end end groups[file] ||= { title: rule.instance_variable_get(:@__group_title), controls: [], } groups[file][:controls].push(id) end end end