require_relative 'xccdf_score' module Utils # Data conversions for Inspec output into XCCDF format. class ToXCCDF # @param attribute [Hash] XCCDF supplemental attributes # @param data [Hash] Converted Inspec output data def initialize(attribute, data) @attribute = attribute @data = data @benchmark = HappyMapperTools::Benchmark::Benchmark.new end # Build entire XML document and produce final output # @param metadata [Hash] Data representing a system under scan def to_xml(metadata) build_benchmark_header build_groups # Only populate results if a target is defined so that conformant XML is produced. @benchmark.testresult = build_test_results(metadata) if metadata['fqdn'] @benchmark.to_xml end private # Sets top level XCCDF Benchmark attributes def build_benchmark_header @benchmark.title = @attribute['benchmark.title'] @benchmark.id = @attribute['benchmark.id'] @benchmark.description = @attribute['benchmark.description'] @benchmark.version = @attribute['benchmark.version'] @benchmark.xmlns = 'http://checklists.nist.gov/xccdf/1.1' @benchmark.status = HappyMapperTools::Benchmark::Status.new @benchmark.status.status = @attribute['benchmark.status'] @benchmark.status.date = @attribute['benchmark.status.date'] if @attribute['benchmark.notice.id'] @benchmark.notice = HappyMapperTools::Benchmark::Notice.new @benchmark.notice.id = @attribute['benchmark.notice.id'] end if @attribute['benchmark.plaintext'] || @attribute['benchmark.plaintext.id'] @benchmark.plaintext = HappyMapperTools::Benchmark::Plaintext.new @benchmark.plaintext.plaintext = @attribute['benchmark.plaintext'] @benchmark.plaintext.id = @attribute['benchmark.plaintext.id'] end @benchmark.reference = HappyMapperTools::Benchmark::ReferenceBenchmark.new @benchmark.reference.href = @attribute['reference.href'] @benchmark.reference.dc_publisher = @attribute['reference.dc.publisher'] @benchmark.reference.dc_source = @attribute['reference.dc.source'] end # Translate join of Inspec results and input attributes to XCCDF Groups def build_groups group_array = [] @data['controls'].each do |control| group = HappyMapperTools::Benchmark::Group.new group.id = control['id'] group.title = control['gtitle'] group.description = "<GroupDescription>#{control['gdescription']}</GroupDescription>" if control['gdescription'] group.rule = HappyMapperTools::Benchmark::Rule.new group.rule.id = control['rid'] group.rule.severity = control['severity'] group.rule.weight = control['rweight'] group.rule.version = control['rversion'] group.rule.title = control['title'].tr("\n", ' ') if control['title'] group.rule.description = "<VulnDiscussion>#{control['desc']}</VulnDiscussion><FalsePositives></FalsePositives><FalseNegatives></FalseNegatives><Documentable>false</Documentable><Mitigations></Mitigations><SeverityOverrideGuidance></SeverityOverrideGuidance><PotentialImpacts></PotentialImpacts><ThirdPartyTools></ThirdPartyTools><MitigationControl></MitigationControl><Responsibility></Responsibility><IAControls></IAControls>" if ['reference.dc.publisher', 'reference.dc.title', 'reference.dc.subject', 'reference.dc.type', 'reference.dc.identifier'].any? { |a| @attribute.key?(a) } group.rule.reference = build_rule_reference end group.rule.ident = build_rule_idents(control['cci']) if control['cci'] group.rule.ident += build_rule_idents(control['legacy']) if control['legacy'] group.rule.fixtext = HappyMapperTools::Benchmark::Fixtext.new group.rule.fixtext.fixref = control['fix_id'] group.rule.fixtext.fixtext = control['fix'] group.rule.fix = build_rule_fix(control['fix_id']) if control['fix_id'] group.rule.check = HappyMapperTools::Benchmark::Check.new group.rule.check.system = control['checkref'] # content_ref is optional for schema compliance if @attribute['content_ref.name'] || @attribute['content_ref.href'] group.rule.check.content_ref = HappyMapperTools::Benchmark::ContentRef.new group.rule.check.content_ref.name = @attribute['content_ref.name'] group.rule.check.content_ref.href = @attribute['content_ref.href'] end group.rule.check.content = control['check'] group_array << group end @benchmark.group = group_array end # Construct a Benchmark Testresult from Inspec data. This must be called after all XML processing has occurred for profiles # and groups. # @param metadata [Hash] # @return [TestResult] def build_test_results(metadata) test_result = HappyMapperTools::Benchmark::TestResult.new test_result.version = @benchmark.version populate_remark(test_result) populate_target_facts(test_result, metadata) populate_identity(test_result, metadata) populate_results(test_result) populate_score(test_result, @benchmark.group) test_result end # Contruct a Rule / RuleResult fix element with the provided id. def build_rule_fix(fix_id) HappyMapperTools::Benchmark::Fix.new.tap { |f| f.id = fix_id } end # Construct rule identifiers for rule # @param idents [Array] def build_rule_idents(idents) raise "#{idents} is not an Array type." unless idents.is_a?(Array) # Each rule identifier is a different element idents.map do |identifier| HappyMapperTools::Benchmark::Ident.new identifier end end # Contruct a Rule reference element def build_rule_reference reference = HappyMapperTools::Benchmark::ReferenceGroup.new reference.dc_publisher = @attribute['reference.dc.publisher'] reference.dc_title = @attribute['reference.dc.title'] reference.dc_subject = @attribute['reference.dc.subject'] reference.dc_type = @attribute['reference.dc.type'] reference.dc_identifier = @attribute['reference.dc.identifier'] reference end # Create a remark with contextual information about the Inspec version and profiles used # @param result [HappyMapperTools::Benchmark::TestResult] def populate_remark(result) result.remark = "Results created using Inspec version #{@data['inspec_version']}.\n#{@data['profiles'].map { |p| "Profile: #{p['name']} Version: #{p['version']}" }.join("\n")}" end # Create all target specific information. # @param result [HappyMapperTools::Benchmark::TestResult] # @param metadata [Hash] def populate_target_facts(result, metadata) result.target = metadata['fqdn'] result.target_address = metadata['ip'] if metadata['ip'] all_facts = [] if metadata['mac'] fact = HappyMapperTools::Benchmark::Fact.new fact.name = 'urn:xccdf:fact:asset:identifier:mac' fact.type = 'string' fact.fact = metadata['mac'] all_facts << fact end if metadata['ip'] fact = HappyMapperTools::Benchmark::Fact.new fact.name = 'urn:xccdf:fact:asset:identifier:ipv4' fact.type = 'string' fact.fact = metadata['ip'] all_facts << fact end return unless all_facts.size.nonzero? facts = HappyMapperTools::Benchmark::TargetFact.new facts.fact = all_facts result.target_facts = facts end # Build out the TestResult given all the control and result data. def populate_results(test_result) # NOTE: id is not an XCCDF 1.2 compliant identifier and will need to be updated when that support is added. test_result.id = 'result_1' test_result.starttime = run_start_time test_result.endtime = run_end_time # Build out individual results all_rule_result = [] @data['controls'].each do |control| next if control['results'].empty? control_results = control['results'].map do |result| populate_rule_result(control, result, xccdf_status(result['status'], control['impact'])) end # Consolidate results into single rule result do to lack of multiple=true attribute on Rule. # 1. Select the unified result status selected_status = control_results.reduce(control_results.first.result) { |f_status, rule_result| xccdf_and_result(f_status, rule_result.result) } # 2. Only choose results with that status # 3. Combine those results all_rule_result << combine_results(control_results.select { |r| r.result == selected_status }) end test_result.rule_result = all_rule_result test_result end # Create rule-result from the control and Inspec result information def populate_rule_result(control, result, result_status) rule_result = HappyMapperTools::Benchmark::RuleResultType.new rule_result.idref = control['rid'] rule_result.severity = control['severity'] rule_result.time = end_time(result['start_time'], result['run_time']) rule_result.weight = control['rweight'] rule_result.result = result_status rule_result.message = result_message(result, result_status) if result_message(result, result_status) rule_result.instance = result['code_desc'] rule_result.ident = build_rule_idents(control['cci']) if control['cci'] rule_result.ident += build_rule_idents(control['legacy']) if control['legacy'] # Fix information is only necessary when there are failed tests rule_result.fix = build_rule_fix(control['fix_id']) if control['fix_id'] && result_status == 'fail' rule_result.check = HappyMapperTools::Benchmark::Check.new rule_result.check.system = control['checkref'] rule_result.check.content = result['code_desc'] rule_result end # Combines rule results with the same result into a single rule result. def combine_results(rule_results) return rule_results.first if rule_results.size == 1 # Can combine, result, idents (duplicate, take first instance), instance - combine into an array removing duplicates # check.content - Only one value allowed, combine by joining with line feed. Prior to, make sure all values are unique. rule_result = HappyMapperTools::Benchmark::RuleResultType.new rule_result.idref = rule_results.first.idref rule_result.severity = rule_results.first.severity # Take latest time rule_result.time = rule_results.reduce(rule_results.first.time) { |time, r| time > r.time ? time : r.time } rule_result.weight = rule_results.first.weight rule_result.result = rule_results.first.result rule_result.message = rule_results.reduce([]) { |messages, r| r.message ? messages.push(r.message) : messages } rule_result.instance = rule_results.reduce([]) { |instances, r| r.instance ? instances.push(r.instance) : instances }.join("\n") rule_result.ident = rule_results.first.ident rule_result.fix = rule_results.first.fix if rule_results.first.check rule_result.check = HappyMapperTools::Benchmark::Check.new rule_result.check.system = rule_results.first.check.system rule_result.check.content = rule_results.map { |r| r.check.content }.join("\n") end rule_result end # Add information about the the account and organization executing the tests. def populate_identity(test_result, metadata) if metadata['identity'] test_result.identity = HappyMapperTools::Benchmark::IdentityType.new test_result.identity.authenticated = true test_result.identity.identity = metadata['identity']['identity'] test_result.identity.privileged = metadata['identity']['privileged'] end test_result.organization = metadata['organization'] if metadata['organization'] end # Return the earliest time of execution. def run_start_time @data['controls'].map { |control| control['results'].map { |result| DateTime.parse(result['start_time']) } }.flatten.min end # Return the latest time of execution accounting for Inspec duration. def run_end_time end_times = @data['controls'].map do |control| control['results'].map { |result| end_time(result['start_time'], result['run_time']) } end end_times.flatten.max end # Calculate an end time given a start time and second duration def end_time(start, duration) DateTime.parse(start) + (duration / (24*60*60)) end # Map the Inspec result status to appropriate XCCDF test result status. # XCCDF options include: pass, fail, error, unknown, notapplicable, notchecked, notselected, informational, fixed # # @param inspec_status [String] The reported Inspec status from an individual test # @param impact [String] A value of 0.0 - 1.0 # @return A valid Inspec status. def xccdf_status(inspec_status, impact) # Currently, there is no good way to map an Inspec result status to one of XCCDF status unknown or notselected. case inspec_status when 'failed' 'fail' when 'passed' 'pass' when 'skipped' if impact.to_f.zero? 'notapplicable' else 'notchecked' end else # In the event Inspec adds a new unaccounted for status, mapping to XCCDF unknown. 'unknown' end end # When more than one result occurs for a rule and the specification does not declare multiple, the result must be combined. # This determines the appropriate result to be selected when there are two to compare. # @param one [String] A rule-result status # @param two [String] A rule-result status # @return The result of the AND operation. def xccdf_and_result(one, two) # From XCCDF specification truth table # P = pass # F = fail # U = unknown # E = error # N = notapplicable # K = notchecked # S = notselected # I = informational case one when 'pass' %w{fail unknown}.any? { |s| s == two } ? two : one when 'fail' one when 'unknown' two == 'fail' ? two : one when 'notapplicable' %w{pass fail unknown}.any? { |s| s == two } ? two : one when 'notchecked' %w{pass fail unknown notapplicable}.any? { |s| s == two } ? two : one end end # Builds the message information for rule results # @param result [Hash] A single Inspec result # @param xccdf_status [String] the xccdf calculated result status for the provided result def result_message(result, xccdf_status) return unless result['message'] || result['skip_message'] message = HappyMapperTools::Benchmark::MessageType.new # Including the code of the check and the resulting message if there is one. message.message = "#{result['code_desc'] ? "#{result['code_desc']}\n\n" : ''}#{result['message'] || result['skip_message']}" message.severity = result_message_severity(xccdf_status) message end # All rule-result messages require a defined severity. This determines a value to use based upon the result XCCDF status. def result_message_severity(xccdf_status) case xccdf_status when 'fail' 'error' when 'notapplicable' 'warning' else 'info' end end # Set scores for all 4 required/recommended scoring systems. def populate_score(test_result, groups) score = Utils::XCCDFScore.new(groups, test_result.rule_result) test_result.score = [score.default_score, score.flat_score, score.flat_unweighted_score, score.absolute_score] end end end