module Inspec::Reporters class CLI < Base case RUBY_PLATFORM when /windows|mswin|msys|mingw|cygwin/ # Most currently available Windows terminals have poor support # for ANSI extended colors COLORS = { "failed" => "\033[0;1;31m", "passed" => "\033[0;1;32m", "skipped" => "\033[0;37m", "reset" => "\033[0m", "error" => "\033[34m", "not_applicable" => "\033[36m", "not_reviewed" => "\033[33m", }.freeze # Most currently available Windows terminals have poor support # for UTF-8 characters so use these boring indicators INDICATORS = { "failed" => "[FAIL]", "skipped" => "[SKIP]", "passed" => "[PASS]", "unknown" => "[UNKN]", "error" => "[ERR]", "not_applicable" => "[N/A]", "not_reviewed" => "[N/R]", }.freeze else # Extended colors for everyone else COLORS = { "failed" => "\033[38;5;9m", "passed" => "\033[38;5;41m", "skipped" => "\033[38;5;247m", "reset" => "\033[0m", "error" => "\033[0;38;5;21m", "not_applicable" => "\033[0;38;5;117m", "not_reviewed" => "\033[0;38;5;214m", }.freeze # Groovy UTF-8 characters for everyone else... # ...even though they probably only work on Mac INDICATORS = { "failed" => "×", "skipped" => "↺", "passed" => "✔", "unknown" => "?", "error" => "ERR", "not_applicable" => "N/A", "not_reviewed" => "N/R", }.freeze end MULTI_TEST_CONTROL_SUMMARY_MAX_LEN = 60 def render @src_extent_map = {} run_data[:profiles].each do |profile| if profile[:status] == "skipped" platform = run_data[:platform] output("Skipping profile: '#{profile[:name]}' on unsupported platform: '#{platform[:name]}/#{platform[:release]}'.") next end read_control_source(profile) @control_count = 0 output("") print_profile_header(profile) print_standard_control_results(profile) print_anonymous_control_results(profile) if @control_count == 0 output(format_message( indentation: 5, message: "No tests executed." )) end end output("") if enhanced_outcomes print_control_outcomes_summary else print_profile_summary end print_tests_summary end private def print_profile_header(profile) header = { "Profile" => format_profile_name(profile), "Version" => profile[:version] || "(not specified)", } header["Failure Message"] = profile[:status_message] if profile[:status] == "failed" header["Target"] = run_data[:platform][:target] unless run_data[:platform][:target].nil? header["Target ID"] = run_data[:platform][:target_id] || "" pad = header.keys.max_by(&:length).length + 1 header.each do |title, value| output(format("%-#{pad}s %s", title + ":", value)) end output("") end def print_standard_control_results(profile) standard_controls_from_profile(profile).each do |control_from_profile| control = Control.new(control_from_profile) control.enhanced_outcomes = enhanced_outcomes next if control.results.nil? output(format_control_header(control)) output(format_control_source(control)) if Inspec::Config.cached[:reporter_include_source] control.results.each do |result| output(format_result(control, result, :standard)) @control_count += 1 end end output("") if @control_count > 0 end def print_anonymous_control_results(profile) anonymous_controls_from_profile(profile).each do |control_from_profile| control = Control.new(control_from_profile) next if control.results.nil? output(format_control_header(control)) control.results.each do |result| output(format_result(control, result, :anonymous)) @control_count += 1 end end end def format_profile_name(profile) if profile[:title].nil? (profile[:name] || "unknown").to_s else "#{profile[:title]} (#{profile[:name] || "unknown"})" end end def format_control_header(control) impact = enhanced_outcomes ? control.impact_string_for_enhanced_outcomes : control.impact_string format_message( color: impact, indicator: impact, message: control.title_for_report ) end def format_control_source(control) src = @control_source[control.id] message = "Control Source from #{src[:path]}:#{src[:start]}..#{src[:end]}\n" message += src[:content] format_message( color: "skipped", indentation: 5, message: message ) end def read_control_source(profile) return unless Inspec::Config.cached[:reporter_include_source] @control_source = {} src_extent_map = {} # First pass: build map of paths => ids => [start] all_unique_controls.each do |control| id = control[:id] path = control[:source_location][:ref] start = control[:source_location][:line] next if path.nil? || start.nil? src_extent_map[path] ||= [] src_extent_map[path] << { start: start, id: id } end # Now sort the controls by their starting line in their control file src_extent_map.values.each do |extent_list| extent_list.sort! { |a, b| a[:start] <=> b[:start] } end # Third pass: Read in files and split into lines src_extent_map.keys.each do |path| control_file_lines = File.read(path).lines # TODO error handling last_line_in_file = control_file_lines.count extent_list = src_extent_map[path] extent_list.each_with_index do |extent, idx| if idx == extent_list.count - 1 # Last entry extent[:end] = last_line_in_file else extent[:end] = extent_list[idx + 1][:start] - 1 end @control_source[extent[:id]] = { path: path, start: extent[:start], end: extent[:end], content: control_file_lines.slice(extent[:start] - 1, extent[:end] - extent[:start] + 1).join(""), } end end end def format_result(control, result, type) impact = control.impact_string_for_result(result) message = if result[:status] == "skipped" result[:skip_message] elsif type == :anonymous result[:expectation_message] else result[:code_desc] end # append any failure details to the message if they exist message += "\n#{result[:message]}" if result[:message] format_message( color: impact, indicator: impact, indentation: 5, message: message ) end def format_message(message_info) indicator = message_info[:indicator] color = message_info[:color] indentation = message_info.fetch(:indentation, 2) message = message_info[:message] message_to_format = "" message_to_format += "#{INDICATORS[indicator]} " unless indicator.nil? message_to_format += message.to_s.lstrip.force_encoding(Encoding::UTF_8) format_with_color(color, indent_lines(message_to_format, indentation)) end def format_with_color(color_name, text) return text if defined?(RSpec.configuration) && !RSpec.configuration.color return text unless COLORS.key?(color_name) "#{COLORS[color_name]}#{text}#{COLORS["reset"]}" end def all_unique_controls @unique_controls ||= begin # rubocop:disable Style/RedundantBegin run_data[:profiles].flat_map do |profile| profile[:controls] end.uniq end end def profile_summary failed = 0 skipped = 0 passed = 0 all_unique_controls.each do |control| next if control[:id].start_with? "(generated from " next unless control[:results] if control[:results].any? { |r| r[:status] == "failed" } failed += 1 elsif control[:results].any? { |r| r[:status] == "skipped" } skipped += 1 else passed += 1 end end total = failed + passed + skipped { "total" => total, "failed" => failed, "skipped" => skipped, "passed" => passed, } end def tests_summary total = 0 failed = 0 skipped = 0 passed = 0 all_unique_controls.each do |control| next unless control[:results] control[:results].each do |result| if result[:status] == "failed" failed += 1 elsif result[:status] == "skipped" skipped += 1 else passed += 1 end end end { "total" => total, "failed" => failed, "skipped" => skipped, "passed" => passed, } end def control_outcomes_summary failed = 0 passed = 0 error = 0 not_reviewed = 0 not_applicable = 0 all_unique_controls.each do |control| next if control[:status].nil? || control[:status].empty? if control[:status] == "failed" failed += 1 elsif control[:status] == "error" error += 1 elsif control[:status] == "not_reviewed" not_reviewed += 1 elsif control[:status] == "not_applicable" not_applicable += 1 else passed += 1 end end total = failed + passed + error + not_reviewed + not_applicable { "total" => total, "failed" => failed, "passed" => passed, "error" => error, "not_reviewed" => not_reviewed, "not_applicable" => not_applicable, } end def print_control_outcomes_summary summary = control_outcomes_summary return unless summary["total"] > 0 success_str = summary["passed"] == 1 ? "1 successful control" : "#{summary["passed"]} successful controls" failed_str = summary["failed"] == 1 ? "1 control failure" : "#{summary["failed"]} control failures" error_str = summary["error"] == 1 ? "1 control has error" : "#{summary["error"]} controls have error" not_rev_str = summary["not_reviewed"] == 1 ? "1 control not reviewed" : "#{summary["not_reviewed"]} controls not reviewed" not_app_str = summary["not_applicable"] == 1 ? "1 control not applicable" : "#{summary["not_applicable"]} controls not applicable" success_color = summary["passed"] > 0 ? "passed" : "no_color" failed_color = summary["failed"] > 0 ? "failed" : "no_color" error_color = summary["error"] > 0 ? "error" : "no_color" not_rev_color = summary["not_reviewed"] > 0 ? "not_reviewed" : "no_color" not_app_color = summary["not_applicable"] > 0 ? "not_applicable" : "no_color" s = format( "Profile Summary: %s, %s, %s, %s, %s", format_with_color(success_color, success_str), format_with_color(failed_color, failed_str), format_with_color(not_rev_color, not_rev_str), format_with_color(not_app_color, not_app_str), format_with_color(error_color, error_str) ) output(s) if summary["total"] > 0 end def print_profile_summary summary = profile_summary return unless summary["total"] > 0 success_str = summary["passed"] == 1 ? "1 successful control" : "#{summary["passed"]} successful controls" failed_str = summary["failed"] == 1 ? "1 control failure" : "#{summary["failed"]} control failures" skipped_str = summary["skipped"] == 1 ? "1 control skipped" : "#{summary["skipped"]} controls skipped" success_color = summary["passed"] > 0 ? "passed" : "no_color" failed_color = summary["failed"] > 0 ? "failed" : "no_color" skipped_color = summary["skipped"] > 0 ? "skipped" : "no_color" s = format( "Profile Summary: %s, %s, %s", format_with_color(success_color, success_str), format_with_color(failed_color, failed_str), format_with_color(skipped_color, skipped_str) ) output(s) if summary["total"] > 0 end def print_tests_summary summary = tests_summary failed_str = summary["failed"] == 1 ? "1 failure" : "#{summary["failed"]} failures" success_color = summary["passed"] > 0 ? "passed" : "no_color" failed_color = summary["failed"] > 0 ? "failed" : "no_color" skipped_color = summary["skipped"] > 0 ? "skipped" : "no_color" s = format( "Test Summary: %s, %s, %s", format_with_color(success_color, "#{summary["passed"]} successful"), format_with_color(failed_color, failed_str), format_with_color(skipped_color, "#{summary["skipped"]} skipped") ) output(s) end def standard_controls_from_profile(profile) profile[:controls].reject { |c| is_anonymous_control?(c) } end def anonymous_controls_from_profile(profile) profile[:controls].select { |c| is_anonymous_control?(c) && !c[:results].nil? } end def is_anonymous_control?(control) control[:id].start_with?("(generated from ") end def indent_lines(message, indentation) message.lines.map { |line| " " * indentation + line }.join end class Control attr_reader :data attr_accessor :enhanced_outcomes def initialize(control_hash) @data = control_hash end def id data[:id] end def title data[:title] end def results data[:results] end def impact data[:impact] end def source_location data[:source_location] end def anonymous? id.start_with?("(generated from ") end def status data[:status] end def title_for_report # if this is an anonymous control, just grab the resource title from any result entry return results.first[:resource_title] if anonymous? title_for_report = "#{id}: #{title || results.first[:resource_title]}" # we will not add any additional data to the title if there's only # zero or one test for this control. return title_for_report if results.nil? || results.size <= 1 # append a failure summary if appropriate. title_for_report += " (#{failure_count} failed)" if failure_count > 0 title_for_report += " (#{skipped_count} skipped)" if skipped_count > 0 title_for_report end def impact_string_for_enhanced_outcomes if impact.nil? "unknown" else status end end def impact_string if anonymous? nil elsif impact.nil? "unknown" elsif results&.find { |r| r[:status] == "skipped" } "skipped" elsif results.nil? || results.empty? || results.all? { |r| r[:status] == "passed" } "passed" else "failed" end end def impact_string_for_result(result) if result[:status] == "skipped" "skipped" elsif result[:status] == "passed" "passed" elsif impact.nil? "unknown" else "failed" end end def failure_count results.select { |r| r[:status] == "failed" }.size end def skipped_count results.select { |r| r[:status] == "skipped" }.size end end end end