inspec/lib/inspec/profile.rb
2016-03-18 01:42:26 +01:00

270 lines
9.1 KiB
Ruby

# encoding: utf-8
# Copyright 2015 Dominik Richter. All rights reserved.
# author: Dominik Richter
# author: Christoph Hartmann
require 'forwardable'
require 'inspec/fetcher'
require 'inspec/source_reader'
require 'inspec/metadata'
module Inspec
class Profile # rubocop:disable Metrics/ClassLength
extend Forwardable
attr_reader :path
def self.resolve_target(target, opts)
# Fetchers retrieve file contents
opts[:target] = target
fetcher = Inspec::Fetcher.resolve(target)
if fetcher.nil?
fail("Could not fetch inspec profile in #{target.inspect}.")
end
# Source readers understand the target's structure and provide
# access to tests, libraries, and metadata
reader = Inspec::SourceReader.resolve(fetcher.relative_target)
if reader.nil?
fail("Don't understand inspec profile in #{target.inspect}, it "\
"doesn't look like a supported profile structure.")
end
reader
end
def self.for_target(target, opts)
new(resolve_target(target, opts), opts)
end
attr_reader :source_reader
def_delegator :@source_reader, :tests
def_delegator :@source_reader, :libraries
def_delegator :@source_reader, :metadata
# rubocop:disable Metrics/AbcSize
def initialize(source_reader, options = nil)
@options = options || {}
@target = @options.delete(:target)
@logger = @options[:logger] || Logger.new(nil)
@source_reader = source_reader
@profile_id = @options[:id]
Metadata.finalize(@source_reader.metadata, @profile_id)
end
def params
@params ||= load_params
end
def info
res = params.dup
rules = {}
res[:rules].each do |gid, group|
next if gid.to_s.empty?
rules[gid] = { title: gid, rules: {} }
group.each 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
rules[gid][:rules][id] = data
# TODO: temporarily flatten the group down; replace this with
# proper hierarchy later on
rules[gid][:title] = data[:group_title]
end
end
res[:rules] = rules
res
end
# Check if the profile is internall 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: [],
}
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))
}
@logger.info "Checking profile in #{@target}"
meta_path = @source_reader.target.abs_path(@source_reader.metadata.ref)
if meta_path =~ /metadata\.rb$/
warn.call(@target, 0, 0, nil, 'The use of `metadata.rb` is deprecated. Use `inspec.yml`.')
end
# 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?
# extract profile name
result[:summary][:profile] = metadata.params[:name]
# check if the profile is using the old test directory instead of the
# new controls directory
if @source_reader.tests.keys.any? { |x| x =~ %r{^test/$} }
warn.call(@target, 0, 0, nil, 'Profile uses deprecated `test` directory, rename it to `controls`.')
end
count = rules_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
params[:rules].each { |group, controls|
@logger.info "Verify all controls in #{group}"
controls.each { |id, control|
sfile, sline = control[:source_location]
error.call(sfile, sline, nil, id, 'Avoid controls with empty IDs') if id.nil? or 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 description") if control[:desc].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? or control[:checks].empty?
}
}
# profile is valid if we could not find any error
result[:summary][:valid] = result[:errors].empty?
@logger.info 'Control definitions OK.' if result[:warnings].empty?
result
end
def rules_count
params[:rules].values.map { |hm| hm.values.length }.inject(:+) || 0
end
# generates a archive of a folder profile
# assumes that the profile was checked before
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
File.delete(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
# display all files that will be part of the archive
@logger.debug 'Add the following files to archive:'
root_path = @source_reader.target.prefix
files = @source_reader.target.files
files.each { |f| @logger.debug ' ' + f }
if opts[:zip]
# generate zip archive
require 'inspec/archive/zip'
zag = Inspec::Archive::ZipArchiveGenerator.new
zag.archive(root_path, files, dst)
else
# generate tar archive
require 'inspec/archive/tar'
tag = Inspec::Archive::TarArchiveGenerator.new
tag.archive(root_path, files, dst)
end
@logger.info 'Finished archive generation.'
true
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
name = params[:name] ||
fail('Cannot create an archive without a profile name! Please '\
'specify the name 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}.#{ext}")
end
def load_params
params = @source_reader.metadata.params
params[:name] = @profile_id unless @profile_id.nil?
params[:rules] = rules = {}
prefix = @source_reader.target.prefix || ''
# we're checking a profile, we don't care if it runs on the host machine
opts = @options.dup
opts[:ignore_supports] = true
runner = Runner.new(
id: @profile_id,
backend: :mock,
test_collector: opts.delete(:test_collector),
)
runner.add_profile(self, opts)
runner.rules.each do |id, rule|
file = rule.instance_variable_get(:@__file)
file = file[prefix.length..-1] if file.start_with?(prefix)
rules[file] ||= {}
rules[file][id] = {
title: rule.title,
desc: rule.desc,
impact: rule.impact,
refs: rule.ref,
tags: rule.tag,
checks: rule.instance_variable_get(:@checks),
code: rule.instance_variable_get(:@__code),
source_location: rule.instance_variable_get(:@__source_location),
group_title: rule.instance_variable_get(:@__group_title),
}
end
@profile_id ||= params[:name]
params
end
end
end