inspec 1.51.0 → 1.51.6
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 +4 -4
- data/CHANGELOG.md +29 -15
- data/README.md +1 -1
- data/docs/glossary.md +99 -0
- data/docs/resources/aide_conf.md.erb +16 -9
- data/docs/resources/apache.md.erb +66 -0
- data/docs/resources/apache_conf.md.erb +11 -5
- data/docs/resources/apt.md.erb +1 -1
- data/docs/resources/audit_policy.md.erb +1 -1
- data/docs/resources/auditd_conf.md.erb +12 -9
- data/docs/resources/bash.md.erb +24 -12
- data/docs/resources/bond.md.erb +26 -24
- data/docs/resources/bridge.md.erb +18 -11
- data/docs/resources/bsd_service.md.erb +11 -2
- data/docs/resources/command.md.erb +30 -29
- data/docs/resources/cpan.md.erb +33 -17
- data/docs/resources/cran.md.erb +26 -17
- data/docs/resources/crontab.md.erb +18 -1
- data/docs/resources/csv.md.erb +13 -7
- data/docs/resources/{dh_params.md → dh_params.md.erb} +30 -6
- data/docs/resources/directory.md.erb +9 -4
- data/docs/resources/docker.md.erb +1 -1
- data/docs/resources/docker_container.md.erb +32 -26
- data/docs/resources/docker_image.md.erb +29 -26
- data/docs/resources/docker_service.md.erb +37 -31
- data/docs/resources/elasticsearch.md.erb +18 -32
- data/docs/resources/etc_fstab.md.erb +19 -15
- data/docs/resources/etc_group.md.erb +13 -39
- data/docs/resources/etc_hosts.md.erb +12 -5
- data/docs/resources/etc_hosts_allow.md.erb +9 -4
- data/docs/resources/etc_hosts_deny.md.erb +12 -7
- data/docs/resources/file.md.erb +139 -134
- data/docs/resources/filesystem.md.erb +5 -4
- data/docs/resources/firewalld.md.erb +1 -1
- data/docs/resources/gem.md.erb +2 -2
- data/docs/resources/group.md.erb +1 -1
- data/docs/resources/host.md.erb +1 -1
- data/docs/resources/iis_app.md.erb +1 -1
- data/docs/resources/iis_site.md.erb +1 -1
- data/docs/resources/interface.md.erb +1 -1
- data/docs/resources/iptables.md.erb +1 -1
- data/docs/resources/json.md.erb +1 -1
- data/docs/resources/kernel_module.md.erb +1 -1
- data/docs/resources/kernel_parameter.md.erb +1 -1
- data/docs/resources/launchd_service.md.erb +1 -1
- data/docs/resources/limits_conf.md.erb +1 -1
- data/docs/resources/login_def.md.erb +1 -1
- data/docs/resources/mount.md.erb +1 -1
- data/docs/resources/mysql_conf.md.erb +1 -1
- data/docs/resources/nginx_conf.md.erb +1 -1
- data/docs/resources/npm.md.erb +1 -1
- data/docs/resources/oneget.md.erb +1 -1
- data/docs/resources/os.md.erb +1 -1
- data/docs/resources/os_env.md.erb +2 -2
- data/docs/resources/package.md.erb +1 -1
- data/docs/resources/packages.md.erb +66 -0
- data/docs/resources/parse_config.md.erb +1 -1
- data/docs/resources/parse_config_file.md.erb +1 -1
- data/docs/resources/passwd.md.erb +1 -1
- data/docs/resources/pip.md.erb +1 -1
- data/docs/resources/port.md.erb +1 -1
- data/docs/resources/postgres_conf.md.erb +1 -1
- data/docs/resources/postgres_session.md.erb +1 -1
- data/docs/resources/powershell.md.erb +2 -2
- data/docs/resources/processes.md.erb +1 -1
- data/docs/resources/registry_key.md.erb +1 -1
- data/docs/resources/runit_service.md.erb +1 -1
- data/docs/resources/security_policy.md.erb +1 -1
- data/docs/resources/service.md.erb +1 -1
- data/docs/resources/shadow.md.erb +1 -1
- data/docs/resources/ssh_config.md.erb +1 -1
- data/docs/resources/sshd_config.md.erb +1 -1
- data/docs/resources/ssl.md.erb +1 -1
- data/docs/resources/sys_info.md.erb +1 -1
- data/docs/resources/systemd_service.md.erb +1 -1
- data/docs/resources/sysv_service.md.erb +1 -1
- data/docs/resources/upstart_service.md.erb +1 -1
- data/docs/resources/user.md.erb +1 -1
- data/docs/resources/users.md.erb +1 -1
- data/docs/resources/windows_feature.md.erb +1 -1
- data/docs/resources/windows_hotfix.md.erb +1 -1
- data/docs/resources/xinetd_conf.md.erb +1 -1
- data/docs/resources/xml.md.erb +1 -1
- data/docs/resources/yaml.md.erb +1 -1
- data/docs/resources/yum.md.erb +1 -1
- data/lib/inspec.rb +2 -1
- data/lib/inspec/base_cli.rb +98 -18
- data/lib/inspec/cli.rb +33 -21
- data/lib/inspec/formatters.rb +3 -0
- data/lib/inspec/formatters/base.rb +208 -0
- data/lib/inspec/formatters/json_rspec.rb +20 -0
- data/lib/inspec/formatters/show_progress.rb +12 -0
- data/lib/inspec/objects.rb +1 -0
- data/lib/inspec/objects/describe.rb +92 -0
- data/lib/inspec/reporters.rb +33 -0
- data/lib/inspec/reporters/base.rb +23 -0
- data/lib/inspec/reporters/cli.rb +395 -0
- data/lib/inspec/reporters/json.rb +132 -0
- data/lib/inspec/reporters/json_min.rb +44 -0
- data/lib/inspec/reporters/junit.rb +77 -0
- data/lib/inspec/runner.rb +14 -1
- data/lib/inspec/runner_rspec.rb +34 -14
- data/lib/inspec/schema.rb +1 -0
- data/lib/inspec/shell.rb +0 -1
- data/lib/inspec/version.rb +1 -1
- data/lib/resources/apache.rb +20 -0
- data/lib/resources/apache_conf.rb +33 -8
- data/lib/resources/audit_policy.rb +1 -1
- data/lib/resources/packages.rb +4 -3
- metadata +17 -4
- data/lib/inspec/rspec_json_formatter.rb +0 -940
@@ -0,0 +1,208 @@
|
|
1
|
+
require 'rspec/core'
|
2
|
+
require 'rspec/core/formatters/base_formatter'
|
3
|
+
|
4
|
+
module Inspec::Formatters
|
5
|
+
class Base < RSpec::Core::Formatters::BaseFormatter
|
6
|
+
RSpec::Core::Formatters.register self, :close, :dump_summary, :stop
|
7
|
+
|
8
|
+
attr_accessor :backend, :run_data
|
9
|
+
|
10
|
+
def initialize(output)
|
11
|
+
super(output)
|
12
|
+
|
13
|
+
@run_data = {}
|
14
|
+
@profiles = []
|
15
|
+
@profiles_info = nil
|
16
|
+
@backend = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
# RSpec Override: #dump_summary
|
20
|
+
#
|
21
|
+
# Supply run summary data, such as the InSpec version and the total duration.
|
22
|
+
def dump_summary(summary)
|
23
|
+
run_data[:version] = Inspec::VERSION
|
24
|
+
run_data[:statistics] = {
|
25
|
+
duration: summary.duration,
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
# RSpec Override: #stop
|
30
|
+
#
|
31
|
+
# Called at the end of a complete RSpec run.
|
32
|
+
# We use this to map tests to controls and flesh out the rest of the run_data
|
33
|
+
# hash to include details about the run, the platform, etc.
|
34
|
+
def stop(notification)
|
35
|
+
# This might be a bit confusing. The results are not actually organized
|
36
|
+
# by control. It is organized by test. So if a control has 3 tests, the
|
37
|
+
# output will have 3 control entries, each one with the same control id
|
38
|
+
# and different test results. An rspec example maps to an inspec test.
|
39
|
+
run_data[:controls] = notification.examples.map do |example|
|
40
|
+
format_example(example).tap do |hash|
|
41
|
+
e = example.exception
|
42
|
+
next unless e
|
43
|
+
|
44
|
+
if example.metadata[:sensitive]
|
45
|
+
hash[:message] = '*** sensitive output suppressed ***'
|
46
|
+
else
|
47
|
+
hash[:message] = exception_message(e)
|
48
|
+
end
|
49
|
+
|
50
|
+
next if e.is_a? RSpec::Expectations::ExpectationNotMetError
|
51
|
+
hash[:exception] = e.class.name
|
52
|
+
hash[:backtrace] = e.backtrace
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# include any tests that were run that were not part of a control
|
57
|
+
run_data[:other_checks] = examples_without_controls
|
58
|
+
examples_with_controls.each do |example|
|
59
|
+
control = example2control(example)
|
60
|
+
move_example_into_control(example, control)
|
61
|
+
end
|
62
|
+
|
63
|
+
# flesh out the profiles key with additional profile information
|
64
|
+
run_data[:profiles] = profiles_info
|
65
|
+
|
66
|
+
# add the platform information for this particular target
|
67
|
+
run_data[:platform] = {
|
68
|
+
name: platform(:name),
|
69
|
+
release: platform(:release),
|
70
|
+
target: backend_target,
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
# Add the current profile to the list of executed profiles.
|
75
|
+
# Called by the runner during example collection.
|
76
|
+
def add_profile(profile)
|
77
|
+
@profiles.push(profile)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Return all the collected output to the caller
|
81
|
+
def results
|
82
|
+
run_data
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def exception_message(exception)
|
88
|
+
if exception.is_a?(RSpec::Core::MultipleExceptionError)
|
89
|
+
exception.all_exceptions.map(&:message).uniq.join("\n\n")
|
90
|
+
else
|
91
|
+
exception.message
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# RSpec Override: #format_example
|
96
|
+
#
|
97
|
+
# Called after test execution, this allows us to populate our own hash with data
|
98
|
+
# for this test that is necessary for the rest of our reports.
|
99
|
+
def format_example(example) # rubocop:disable Metrics/AbcSize
|
100
|
+
if !example.metadata[:description_args].empty? && example.metadata[:skip]
|
101
|
+
# For skipped profiles, rspec returns in full_description the skip_message as well. We don't want
|
102
|
+
# to mix the two, so we pick the full_description from the example.metadata[:example_group] hash.
|
103
|
+
code_description = example.metadata[:example_group][:description]
|
104
|
+
else
|
105
|
+
code_description = example.metadata[:full_description]
|
106
|
+
end
|
107
|
+
|
108
|
+
res = {
|
109
|
+
id: example.metadata[:id],
|
110
|
+
profile_id: example.metadata[:profile_id],
|
111
|
+
status: example.execution_result.status.to_s,
|
112
|
+
code_desc: code_description,
|
113
|
+
run_time: example.execution_result.run_time,
|
114
|
+
start_time: example.execution_result.started_at.to_s,
|
115
|
+
resource_title: example.metadata[:described_class] || example.metadata[:example_group][:description],
|
116
|
+
expectation_message: format_expectation_message(example),
|
117
|
+
}
|
118
|
+
|
119
|
+
unless (pid = example.metadata[:profile_id]).nil?
|
120
|
+
res[:profile_id] = pid
|
121
|
+
end
|
122
|
+
|
123
|
+
if res[:status] == 'pending'
|
124
|
+
res[:status] = 'skipped'
|
125
|
+
res[:skip_message] = example.metadata[:description]
|
126
|
+
res[:resource] = example.metadata[:described_class].to_s
|
127
|
+
end
|
128
|
+
|
129
|
+
res
|
130
|
+
end
|
131
|
+
|
132
|
+
def format_expectation_message(example)
|
133
|
+
if (example.metadata[:example_group][:description_args].first == example.metadata[:example_group][:described_class]) ||
|
134
|
+
example.metadata[:example_group][:described_class].nil?
|
135
|
+
example.metadata[:description]
|
136
|
+
else
|
137
|
+
"#{example.metadata[:example_group][:description]} #{example.metadata[:description]}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def platform(field)
|
142
|
+
return nil if @backend.nil?
|
143
|
+
@backend.platform[field]
|
144
|
+
end
|
145
|
+
|
146
|
+
def backend_target
|
147
|
+
return nil if @backend.nil?
|
148
|
+
connection = @backend.backend
|
149
|
+
connection.respond_to?(:uri) ? connection.uri : nil
|
150
|
+
end
|
151
|
+
|
152
|
+
def examples
|
153
|
+
run_data[:controls]
|
154
|
+
end
|
155
|
+
|
156
|
+
def examples_without_controls
|
157
|
+
examples.find_all { |example| example2control(example).nil? }
|
158
|
+
end
|
159
|
+
|
160
|
+
def examples_with_controls
|
161
|
+
examples.find_all { |example| !example2control(example).nil? }
|
162
|
+
end
|
163
|
+
|
164
|
+
def profiles_info
|
165
|
+
@profiles_info ||= @profiles.map(&:info!).map(&:dup)
|
166
|
+
end
|
167
|
+
|
168
|
+
def example2control(example)
|
169
|
+
profile = profile_from_example(example)
|
170
|
+
return nil unless profile&.[](:controls)
|
171
|
+
profile[:controls].find { |x| x[:id] == example[:id] }
|
172
|
+
end
|
173
|
+
|
174
|
+
def profile_from_example(example)
|
175
|
+
profiles_info.find { |p| profile_contains_example?(p, example) }
|
176
|
+
end
|
177
|
+
|
178
|
+
def profile_contains_example?(profile, example)
|
179
|
+
profile_name = profile[:name]
|
180
|
+
example_profile_id = example[:profile_id]
|
181
|
+
|
182
|
+
# if either the profile name is nil or the profile in the given example
|
183
|
+
# is nil, assume the profile doesn't contain the example and default
|
184
|
+
# to creating a new profile. Otherwise, for profiles that have no
|
185
|
+
# metadata, this may incorrectly match a profile that does not contain
|
186
|
+
# this example, leading to Ruby exceptions.
|
187
|
+
return false if profile_name.nil? || example_profile_id.nil?
|
188
|
+
|
189
|
+
# The correct profile is one where the name of the profile, and the profile
|
190
|
+
# name in the example match. Additionally, the list of controls in the
|
191
|
+
# profile must contain the example in question (which we match by ID).
|
192
|
+
#
|
193
|
+
# While the profile name match is usually good enough, we must also match by
|
194
|
+
# the control ID in the case where an InSpec runner has multiple profiles of
|
195
|
+
# the same name (i.e. when Test Kitchen is running concurrently using a
|
196
|
+
# single test suite that uses the Flat source reader, in which case InSpec
|
197
|
+
# creates a fake profile with a name like "tests from /path/to/tests")
|
198
|
+
profile_name == example_profile_id && profile[:controls].any? { |control| control[:id] == example[:id] }
|
199
|
+
end
|
200
|
+
|
201
|
+
def move_example_into_control(example, control)
|
202
|
+
control[:results] ||= []
|
203
|
+
example.delete(:id)
|
204
|
+
example.delete(:profile_id)
|
205
|
+
control[:results].push(example)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Inspec::Formatters
|
2
|
+
class RspecJson < RSpec::Core::Formatters::JsonFormatter
|
3
|
+
RSpec::Core::Formatters.register self
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
# We are cheating and overriding a private method in RSpec's core JsonFormatter.
|
8
|
+
# This is to avoid having to repeat this id functionality in both dump_summary
|
9
|
+
# and dump_profile (both of which call format_example).
|
10
|
+
# See https://github.com/rspec/rspec-core/blob/master/lib/rspec/core/formatters/json_formatter.rb
|
11
|
+
#
|
12
|
+
# rspec's example id here corresponds to an inspec test's control name -
|
13
|
+
# either explicitly specified or auto-generated by rspec itself.
|
14
|
+
def format_example(example)
|
15
|
+
res = super(example)
|
16
|
+
res[:id] = example.metadata[:id]
|
17
|
+
res
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Inspec::Formatters
|
2
|
+
class ShowProgress < RSpec::Core::Formatters::ProgressFormatter
|
3
|
+
RSpec::Core::Formatters.register self
|
4
|
+
|
5
|
+
# remove summary output from progress
|
6
|
+
%w{dump_summary dump_failures dump_pending message seed start_dump}.each do |m|
|
7
|
+
define_method(m) do |*args|
|
8
|
+
# override
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/lib/inspec/objects.rb
CHANGED
@@ -4,6 +4,7 @@ module Inspec
|
|
4
4
|
autoload :Attribute, 'inspec/objects/attribute'
|
5
5
|
autoload :Tag, 'inspec/objects/tag'
|
6
6
|
autoload :Control, 'inspec/objects/control'
|
7
|
+
autoload :Describe, 'inspec/objects/describe'
|
7
8
|
autoload :EachLoop, 'inspec/objects/each_loop'
|
8
9
|
autoload :List, 'inspec/objects/list'
|
9
10
|
autoload :OrTest, 'inspec/objects/or_test'
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# encoding:utf-8
|
2
|
+
|
3
|
+
module Inspec
|
4
|
+
class Describe
|
5
|
+
# Internal helper to structure test objects.
|
6
|
+
# Should not be exposed to the user as it is hidden behind
|
7
|
+
# `add_test`, `to_hash`, and `to_ruby` in Inspec::Describe
|
8
|
+
Test = Struct.new(:its, :matcher, :expectation, :negated) do
|
9
|
+
def negate!
|
10
|
+
self.negated = !negated
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_ruby
|
14
|
+
itsy = its.nil? ? 'it' : 'its(' + its.to_s.inspect + ')'
|
15
|
+
naughty = negated ? '_not' : ''
|
16
|
+
xpect = if expectation.nil?
|
17
|
+
''
|
18
|
+
elsif expectation.class == Regexp
|
19
|
+
# without this, xpect values like / \/zones\// will not be parsed properly
|
20
|
+
"(#{expectation.inspect})"
|
21
|
+
else
|
22
|
+
' ' + expectation.inspect
|
23
|
+
end
|
24
|
+
format('%s { should%s %s%s }', itsy, naughty, matcher, xpect)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# A qualifier describing the resource that will be tested. It may consist
|
29
|
+
# of the resource identification and multiple accessors to pinpoint the data
|
30
|
+
# the user wants to test.
|
31
|
+
attr_accessor :qualifier
|
32
|
+
|
33
|
+
# An array of individual tests for the qualifier. Every entry will be
|
34
|
+
# translated into an `it` or `its` clause.
|
35
|
+
attr_accessor :tests
|
36
|
+
|
37
|
+
# Optional variables which are used by tests.
|
38
|
+
attr_accessor :variables
|
39
|
+
|
40
|
+
# Optional method to skip this describe block altogether. If `skip` is
|
41
|
+
# defined it takes precendence and will print the skip statement instead
|
42
|
+
# of adding other tests.
|
43
|
+
attr_accessor :skip
|
44
|
+
|
45
|
+
include RubyHelper
|
46
|
+
|
47
|
+
def initialize
|
48
|
+
@qualifier = []
|
49
|
+
@tests = []
|
50
|
+
@variables = []
|
51
|
+
end
|
52
|
+
|
53
|
+
def add_test(its, matcher, expectation)
|
54
|
+
test = Inspec::Describe::Test.new(its, matcher, expectation, false)
|
55
|
+
tests.push(test)
|
56
|
+
test
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_ruby
|
60
|
+
return rb_skip if !skip.nil?
|
61
|
+
rb_describe
|
62
|
+
end
|
63
|
+
|
64
|
+
def to_hash
|
65
|
+
{ qualifier: qualifier, tests: tests.map(&:to_h), variables: variables, skip: skip }
|
66
|
+
end
|
67
|
+
|
68
|
+
def resource
|
69
|
+
return nil if qualifier.empty? || qualifier[0].empty? || qualifier[0][0].empty?
|
70
|
+
qualifier[0][0]
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def rb_describe
|
76
|
+
vars = variables.map(&:to_ruby).join("\n")
|
77
|
+
vars += "\n" unless vars.empty?
|
78
|
+
|
79
|
+
objarr = @qualifier
|
80
|
+
objarr = [['unknown object'.inspect]] if objarr.nil? || objarr.empty?
|
81
|
+
obj = objarr.map { |q| ruby_qualifier(q) }.join('.')
|
82
|
+
|
83
|
+
rbtests = tests.map(&:to_ruby).join("\n ")
|
84
|
+
format("%sdescribe %s do\n %s\nend", vars, obj, rbtests)
|
85
|
+
end
|
86
|
+
|
87
|
+
def rb_skip
|
88
|
+
obj = @qualifier || skip.inspect
|
89
|
+
format("describe %s do\n skip %s\nend", obj, skip.inspect)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'inspec/reporters/base'
|
2
|
+
require 'inspec/reporters/cli'
|
3
|
+
require 'inspec/reporters/json'
|
4
|
+
require 'inspec/reporters/json_min'
|
5
|
+
require 'inspec/reporters/junit'
|
6
|
+
|
7
|
+
module Inspec::Reporters
|
8
|
+
def self.render(reporter, run_data)
|
9
|
+
name, config = reporter
|
10
|
+
config[:run_data] = run_data
|
11
|
+
case name
|
12
|
+
when 'cli'
|
13
|
+
reporter = Inspec::Reporters::CLI.new(config)
|
14
|
+
when 'json'
|
15
|
+
reporter = Inspec::Reporters::Json.new(config)
|
16
|
+
when 'json-min'
|
17
|
+
reporter = Inspec::Reporters::JsonMin.new(config)
|
18
|
+
when 'junit'
|
19
|
+
reporter = Inspec::Reporters::Junit.new(config)
|
20
|
+
else
|
21
|
+
raise NotImplementedError, "'#{name}' is not a valid reporter type."
|
22
|
+
end
|
23
|
+
|
24
|
+
reporter.render
|
25
|
+
output = reporter.rendered_output
|
26
|
+
|
27
|
+
if config['file']
|
28
|
+
File.write(config['file'], output)
|
29
|
+
elsif config['stdout'] == true
|
30
|
+
puts output
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Inspec::Reporters
|
2
|
+
class Base
|
3
|
+
attr_reader :run_data
|
4
|
+
|
5
|
+
def initialize(config)
|
6
|
+
@run_data = config[:run_data]
|
7
|
+
@output = ''
|
8
|
+
end
|
9
|
+
|
10
|
+
def output(str)
|
11
|
+
@output << "#{str}\n"
|
12
|
+
end
|
13
|
+
|
14
|
+
def rendered_output
|
15
|
+
@output
|
16
|
+
end
|
17
|
+
|
18
|
+
# each reporter must implement #render
|
19
|
+
def render
|
20
|
+
raise NotImplementedError, "#{self.class} must implement a `#render` method to format its output."
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,395 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Inspec::Reporters
|
4
|
+
class CLI < Base
|
5
|
+
case RUBY_PLATFORM
|
6
|
+
when /windows|mswin|msys|mingw|cygwin/
|
7
|
+
# Most currently available Windows terminals have poor support
|
8
|
+
# for ANSI extended colors
|
9
|
+
COLORS = {
|
10
|
+
'critical' => "\033[0;1;31m",
|
11
|
+
'major' => "\033[0;1;31m",
|
12
|
+
'minor' => "\033[0;36m",
|
13
|
+
'failed' => "\033[0;1;31m",
|
14
|
+
'passed' => "\033[0;1;32m",
|
15
|
+
'skipped' => "\033[0;37m",
|
16
|
+
'reset' => "\033[0m",
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
# Most currently available Windows terminals have poor support
|
20
|
+
# for UTF-8 characters so use these boring indicators
|
21
|
+
INDICATORS = {
|
22
|
+
'critical' => '[CRIT]',
|
23
|
+
'major' => '[MAJR]',
|
24
|
+
'minor' => '[MINR]',
|
25
|
+
'failed' => '[FAIL]',
|
26
|
+
'skipped' => '[SKIP]',
|
27
|
+
'passed' => '[PASS]',
|
28
|
+
'unknown' => '[UNKN]',
|
29
|
+
}.freeze
|
30
|
+
else
|
31
|
+
# Extended colors for everyone else
|
32
|
+
COLORS = {
|
33
|
+
'critical' => "\033[38;5;9m",
|
34
|
+
'major' => "\033[38;5;208m",
|
35
|
+
'minor' => "\033[0;36m",
|
36
|
+
'failed' => "\033[38;5;9m",
|
37
|
+
'passed' => "\033[38;5;41m",
|
38
|
+
'skipped' => "\033[38;5;247m",
|
39
|
+
'reset' => "\033[0m",
|
40
|
+
}.freeze
|
41
|
+
|
42
|
+
# Groovy UTF-8 characters for everyone else...
|
43
|
+
# ...even though they probably only work on Mac
|
44
|
+
INDICATORS = {
|
45
|
+
'critical' => '×',
|
46
|
+
'major' => '∅',
|
47
|
+
'minor' => '⊚',
|
48
|
+
'failed' => '×',
|
49
|
+
'skipped' => '↺',
|
50
|
+
'passed' => '✔',
|
51
|
+
'unknown' => '?',
|
52
|
+
}.freeze
|
53
|
+
end
|
54
|
+
|
55
|
+
MULTI_TEST_CONTROL_SUMMARY_MAX_LEN = 60
|
56
|
+
|
57
|
+
def render
|
58
|
+
run_data[:profiles].each do |profile|
|
59
|
+
@control_count = 0
|
60
|
+
output('')
|
61
|
+
print_profile_header(profile)
|
62
|
+
print_standard_control_results(profile)
|
63
|
+
print_anonymous_control_results(profile)
|
64
|
+
output(format_message(
|
65
|
+
indentation: 5,
|
66
|
+
message: 'No tests executed.',
|
67
|
+
)) if @control_count == 0
|
68
|
+
end
|
69
|
+
|
70
|
+
output('')
|
71
|
+
print_profile_summary
|
72
|
+
print_tests_summary
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def print_profile_header(profile)
|
78
|
+
output("Profile: #{format_profile_name(profile)}")
|
79
|
+
output("Version: #{profile[:version] || '(not specified)'}")
|
80
|
+
output("Target: #{run_data[:platform][:target]}") unless run_data[:platform][:target].nil?
|
81
|
+
output('')
|
82
|
+
end
|
83
|
+
|
84
|
+
def print_standard_control_results(profile)
|
85
|
+
standard_controls_from_profile(profile).each do |control_from_profile|
|
86
|
+
control = Control.new(control_from_profile)
|
87
|
+
next if control.results.nil?
|
88
|
+
output(format_control_header(control))
|
89
|
+
control.results.each do |result|
|
90
|
+
output(format_result(control, result, :standard))
|
91
|
+
@control_count += 1
|
92
|
+
end
|
93
|
+
end
|
94
|
+
output('') if @control_count > 0
|
95
|
+
end
|
96
|
+
|
97
|
+
def print_anonymous_control_results(profile)
|
98
|
+
anonymous_controls_from_profile(profile).each do |control_from_profile|
|
99
|
+
control = Control.new(control_from_profile)
|
100
|
+
next if control.results.nil?
|
101
|
+
output(format_control_header(control))
|
102
|
+
control.results.each do |result|
|
103
|
+
output(format_result(control, result, :anonymous))
|
104
|
+
@control_count += 1
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def format_profile_name(profile)
|
110
|
+
if profile[:title].nil?
|
111
|
+
(profile[:name] || 'unknown').to_s
|
112
|
+
else
|
113
|
+
"#{profile[:title]} (#{profile[:name] || 'unknown'})"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def format_control_header(control)
|
118
|
+
impact = control.impact_string
|
119
|
+
format_message(
|
120
|
+
color: impact,
|
121
|
+
indicator: impact,
|
122
|
+
message: control.title_for_report,
|
123
|
+
)
|
124
|
+
end
|
125
|
+
|
126
|
+
def format_result(control, result, type)
|
127
|
+
impact = control.impact_string_for_result(result)
|
128
|
+
|
129
|
+
message = if result[:status] == 'skipped'
|
130
|
+
result[:skip_message]
|
131
|
+
elsif type == :anonymous
|
132
|
+
result[:expectation_message]
|
133
|
+
else
|
134
|
+
result[:code_desc]
|
135
|
+
end
|
136
|
+
|
137
|
+
# append any failure details to the message if they exist
|
138
|
+
message += "\n#{result[:message]}" if result[:message]
|
139
|
+
|
140
|
+
format_message(
|
141
|
+
color: impact,
|
142
|
+
indicator: impact,
|
143
|
+
indentation: 5,
|
144
|
+
message: message,
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
def format_message(message_info)
|
149
|
+
indicator = message_info[:indicator]
|
150
|
+
color = message_info[:color]
|
151
|
+
indentation = message_info.fetch(:indentation, 2)
|
152
|
+
message = message_info[:message]
|
153
|
+
|
154
|
+
message_to_format = ''
|
155
|
+
message_to_format += "#{INDICATORS[indicator]} " unless indicator.nil?
|
156
|
+
message_to_format += message.to_s.lstrip
|
157
|
+
|
158
|
+
format_with_color(color, indent_lines(message_to_format, indentation))
|
159
|
+
end
|
160
|
+
|
161
|
+
def format_with_color(color_name, text)
|
162
|
+
return text if defined?(RSpec.configuration) && !RSpec.configuration.color
|
163
|
+
return text unless COLORS.key?(color_name)
|
164
|
+
|
165
|
+
"#{COLORS[color_name]}#{text}#{COLORS['reset']}"
|
166
|
+
end
|
167
|
+
|
168
|
+
def all_unique_controls
|
169
|
+
return @unique_controls unless @unique_controls.nil?
|
170
|
+
|
171
|
+
@unique_controls = Set.new
|
172
|
+
run_data[:profiles].each do |profile|
|
173
|
+
profile[:controls].map { |control| @unique_controls.add(control) }
|
174
|
+
end
|
175
|
+
|
176
|
+
@unique_controls
|
177
|
+
end
|
178
|
+
|
179
|
+
def profile_summary
|
180
|
+
return @profile_summary unless @profile_summary.nil?
|
181
|
+
|
182
|
+
failed = 0
|
183
|
+
skipped = 0
|
184
|
+
passed = 0
|
185
|
+
critical = 0
|
186
|
+
major = 0
|
187
|
+
minor = 0
|
188
|
+
|
189
|
+
all_unique_controls.each do |control|
|
190
|
+
next if control[:id].start_with? '(generated from '
|
191
|
+
next unless control[:results]
|
192
|
+
if control[:results].any? { |r| r[:status] == 'failed' }
|
193
|
+
failed += 1
|
194
|
+
if control[:impact] >= 0.7
|
195
|
+
critical += 1
|
196
|
+
elsif control[:impact] >= 0.4
|
197
|
+
major += 1
|
198
|
+
else
|
199
|
+
minor += 1
|
200
|
+
end
|
201
|
+
elsif control[:results].any? { |r| r[:status] == 'skipped' }
|
202
|
+
skipped += 1
|
203
|
+
else
|
204
|
+
passed += 1
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
total = failed + passed + skipped
|
209
|
+
|
210
|
+
@profile_summary = {
|
211
|
+
'total' => total,
|
212
|
+
'failed' => {
|
213
|
+
'total' => failed,
|
214
|
+
'critical' => critical,
|
215
|
+
'major' => major,
|
216
|
+
'minor' => minor,
|
217
|
+
},
|
218
|
+
'skipped' => skipped,
|
219
|
+
'passed' => passed,
|
220
|
+
}
|
221
|
+
end
|
222
|
+
|
223
|
+
def tests_summary
|
224
|
+
return @tests_summary unless @tests_summary.nil?
|
225
|
+
|
226
|
+
total = 0
|
227
|
+
failed = 0
|
228
|
+
skipped = 0
|
229
|
+
passed = 0
|
230
|
+
|
231
|
+
all_unique_controls.each do |control|
|
232
|
+
next unless control[:results]
|
233
|
+
control[:results].each do |result|
|
234
|
+
if result[:status] == 'failed'
|
235
|
+
failed += 1
|
236
|
+
elsif result[:status] == 'skipped'
|
237
|
+
skipped += 1
|
238
|
+
else
|
239
|
+
passed += 1
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
@tests_summary = { 'total' => total, 'failed' => failed, 'skipped' => skipped, 'passed' => passed }
|
245
|
+
end
|
246
|
+
|
247
|
+
def print_profile_summary
|
248
|
+
summary = profile_summary
|
249
|
+
return unless summary['total'] > 0
|
250
|
+
|
251
|
+
success_str = summary['passed'] == 1 ? '1 successful control' : "#{summary['passed']} successful controls"
|
252
|
+
failed_str = summary['failed']['total'] == 1 ? '1 control failure' : "#{summary['failed']['total']} control failures"
|
253
|
+
skipped_str = summary['skipped'] == 1 ? '1 control skipped' : "#{summary['skipped']} controls skipped"
|
254
|
+
|
255
|
+
success_color = summary['passed'] > 0 ? 'passed' : 'no_color'
|
256
|
+
failed_color = summary['failed']['total'] > 0 ? 'failed' : 'no_color'
|
257
|
+
skipped_color = summary['skipped'] > 0 ? 'skipped' : 'no_color'
|
258
|
+
|
259
|
+
s = format(
|
260
|
+
'Profile Summary: %s, %s, %s',
|
261
|
+
format_with_color(success_color, success_str),
|
262
|
+
format_with_color(failed_color, failed_str),
|
263
|
+
format_with_color(skipped_color, skipped_str),
|
264
|
+
)
|
265
|
+
output(s) if summary['total'] > 0
|
266
|
+
end
|
267
|
+
|
268
|
+
def print_tests_summary
|
269
|
+
summary = tests_summary
|
270
|
+
|
271
|
+
failed_str = summary['failed'] == 1 ? '1 failure' : "#{summary['failed']} failures"
|
272
|
+
|
273
|
+
success_color = summary['passed'] > 0 ? 'passed' : 'no_color'
|
274
|
+
failed_color = summary['failed'] > 0 ? 'failed' : 'no_color'
|
275
|
+
skipped_color = summary['skipped'] > 0 ? 'skipped' : 'no_color'
|
276
|
+
|
277
|
+
s = format(
|
278
|
+
'Test Summary: %s, %s, %s',
|
279
|
+
format_with_color(success_color, "#{summary['passed']} successful"),
|
280
|
+
format_with_color(failed_color, failed_str),
|
281
|
+
format_with_color(skipped_color, "#{summary['skipped']} skipped"),
|
282
|
+
)
|
283
|
+
|
284
|
+
output(s)
|
285
|
+
end
|
286
|
+
|
287
|
+
def standard_controls_from_profile(profile)
|
288
|
+
profile[:controls].reject { |c| is_anonymous_control?(c) }
|
289
|
+
end
|
290
|
+
|
291
|
+
def anonymous_controls_from_profile(profile)
|
292
|
+
profile[:controls].select { |c| is_anonymous_control?(c) && !c[:results].nil? }
|
293
|
+
end
|
294
|
+
|
295
|
+
def is_anonymous_control?(control)
|
296
|
+
control[:id].start_with?('(generated from ')
|
297
|
+
end
|
298
|
+
|
299
|
+
def indent_lines(message, indentation)
|
300
|
+
message.lines.map { |line| ' ' * indentation + line }.join
|
301
|
+
end
|
302
|
+
|
303
|
+
class Control
|
304
|
+
IMPACT_SCORES = {
|
305
|
+
critical: 0.7,
|
306
|
+
major: 0.4,
|
307
|
+
}.freeze
|
308
|
+
|
309
|
+
attr_reader :data
|
310
|
+
|
311
|
+
def initialize(control_hash)
|
312
|
+
@data = control_hash
|
313
|
+
end
|
314
|
+
|
315
|
+
def id
|
316
|
+
data[:id]
|
317
|
+
end
|
318
|
+
|
319
|
+
def title
|
320
|
+
data[:title]
|
321
|
+
end
|
322
|
+
|
323
|
+
def results
|
324
|
+
data[:results]
|
325
|
+
end
|
326
|
+
|
327
|
+
def impact
|
328
|
+
data[:impact]
|
329
|
+
end
|
330
|
+
|
331
|
+
def anonymous?
|
332
|
+
id.start_with?('(generated from ')
|
333
|
+
end
|
334
|
+
|
335
|
+
def title_for_report
|
336
|
+
# if this is an anonymous control, just grab the resource title from any result entry
|
337
|
+
return results.first[:resource_title] if anonymous?
|
338
|
+
|
339
|
+
title_for_report = "#{id}: #{title || results.first[:resource_title]}"
|
340
|
+
|
341
|
+
# we will not add any additional data to the title if there's only
|
342
|
+
# zero or one test for this control.
|
343
|
+
return title_for_report if results.nil? || results.size <= 1
|
344
|
+
|
345
|
+
# append a failure summary if appropriate.
|
346
|
+
title_for_report += " (#{failure_count} failed)" if failure_count > 0
|
347
|
+
title_for_report += " (#{skipped_count} skipped)" if skipped_count > 0
|
348
|
+
|
349
|
+
title_for_report
|
350
|
+
end
|
351
|
+
|
352
|
+
def impact_string
|
353
|
+
if anonymous?
|
354
|
+
nil
|
355
|
+
elsif impact.nil?
|
356
|
+
'unknown'
|
357
|
+
elsif results&.find { |r| r[:status] == 'skipped' }
|
358
|
+
'skipped'
|
359
|
+
elsif results.nil? || results.empty? || results.all? { |r| r[:status] == 'passed' }
|
360
|
+
'passed'
|
361
|
+
elsif impact >= IMPACT_SCORES[:critical]
|
362
|
+
'critical'
|
363
|
+
elsif impact >= IMPACT_SCORES[:major]
|
364
|
+
'major'
|
365
|
+
else
|
366
|
+
'minor'
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
def impact_string_for_result(result)
|
371
|
+
if result[:status] == 'skipped'
|
372
|
+
'skipped'
|
373
|
+
elsif result[:status] == 'passed'
|
374
|
+
'passed'
|
375
|
+
elsif impact.nil?
|
376
|
+
'unknown'
|
377
|
+
elsif impact >= IMPACT_SCORES[:critical]
|
378
|
+
'critical'
|
379
|
+
elsif impact >= IMPACT_SCORES[:major]
|
380
|
+
'major'
|
381
|
+
else
|
382
|
+
'minor'
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
def failure_count
|
387
|
+
results.select { |r| r[:status] == 'failed' }.size
|
388
|
+
end
|
389
|
+
|
390
|
+
def skipped_count
|
391
|
+
results.select { |r| r[:status] == 'skipped' }.size
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|