inspec_tools 2.0.6 → 2.3.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,9 +3,10 @@ require 'inspec-objects'
3
3
  require 'word_wrap'
4
4
  require 'yaml'
5
5
  require 'digest'
6
- require 'roo'
7
6
 
8
7
  require_relative '../utilities/inspec_util'
8
+ require_relative '../utilities/cis_to_nist'
9
+ require_relative '../utilities/mapping_validator'
9
10
 
10
11
  # rubocop:disable Metrics/AbcSize
11
12
  # rubocop:disable Metrics/PerceivedComplexity
@@ -14,15 +15,14 @@ require_relative '../utilities/inspec_util'
14
15
  module InspecTools
15
16
  # Methods for converting from XLS to various formats
16
17
  class XLSXTool
17
- CIS_2_NIST_XLSX = Roo::Spreadsheet.open(File.join(File.dirname(__FILE__), '../data/NIST_Map_02052020_CIS_Controls_Version_7.1_Implementation_Groups_1.2.xlsx'))
18
18
  LATEST_NIST_REV = 'Rev_4'.freeze
19
19
 
20
20
  def initialize(xlsx, mapping, name, verbose = false)
21
21
  @name = name
22
22
  @xlsx = xlsx
23
- @mapping = mapping
23
+ @mapping = Utils::MappingValidator.validate(mapping)
24
24
  @verbose = verbose
25
- @cis_to_nist = get_cis_to_nist_control_mapping(CIS_2_NIST_XLSX)
25
+ @cis_to_nist = Utils::CisToNist.get_mapping('cis_to_nist_mapping')
26
26
  end
27
27
 
28
28
  def to_ckl
@@ -46,18 +46,6 @@ module InspecTools
46
46
 
47
47
  private
48
48
 
49
- def get_cis_to_nist_control_mapping(spreadsheet)
50
- cis_to_nist = {}
51
- spreadsheet.sheet(3).each do |row|
52
- if row[3].is_a? Numeric
53
- cis_to_nist[row[3].to_s] = row[0]
54
- else
55
- cis_to_nist[row[2].to_s] = row[0] unless (row[2] == '') || row[2].to_i.nil?
56
- end
57
- end
58
- cis_to_nist
59
- end
60
-
61
49
  def insert_json_metadata
62
50
  @profile['name'] = @name
63
51
  @profile['title'] = 'InSpec Profile'
@@ -0,0 +1,13 @@
1
+ require 'nokogiri'
2
+
3
+ module Utils
4
+ class CciXml
5
+ def self.get_cci_list(cci_list_file)
6
+ path = File.expand_path(File.join(File.expand_path(__dir__), '..', 'data', cci_list_file))
7
+ raise "CCI list does not exist at #{path}" unless File.exist?(path)
8
+
9
+ cci_list = Nokogiri::XML(File.open(path))
10
+ cci_list.remove_namespaces!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module Utils
2
+ class CisToNist
3
+ def self.get_mapping(mapping_file)
4
+ path = File.expand_path(File.join(File.expand_path(__dir__), '..', 'data', mapping_file))
5
+ raise "CIS to NIST control mapping does not exist at #{path}. Has it been generated?" unless File.exist?(path)
6
+
7
+ mapping = File.open(path)
8
+ Marshal.load(mapping)
9
+ end
10
+ end
11
+ end
@@ -13,15 +13,8 @@ require 'overrides/object'
13
13
  require 'overrides/string'
14
14
  require 'rubocop'
15
15
 
16
- # rubocop:disable Metrics/ClassLength
17
- # rubocop:disable Metrics/AbcSize
18
- # rubocop:disable Metrics/PerceivedComplexity
19
- # rubocop:disable Metrics/CyclomaticComplexity
20
- # rubocop:disable Metrics/MethodLength
21
-
22
16
  module Utils
23
- class InspecUtil
24
- DATA_NOT_FOUND_MESSAGE = 'N/A'.freeze
17
+ class InspecUtil # rubocop:disable Metrics/ClassLength
25
18
  WIDTH = 80
26
19
  IMPACT_SCORES = {
27
20
  'none' => 0.0,
@@ -31,56 +24,7 @@ module Utils
31
24
  'critical' => 0.9
32
25
  }.freeze
33
26
 
34
- def self.parse_data_for_xccdf(json)
35
- data = {}
36
-
37
- controls = []
38
- if json['profiles'].nil?
39
- controls = json['controls']
40
- elsif json['profiles'].length == 1
41
- controls = json['profiles'].last['controls']
42
- else
43
- json['profiles'].each do |profile|
44
- controls.concat(profile['controls'])
45
- end
46
- end
47
- c_data = {}
48
-
49
- controls.each do |control|
50
- c_id = control['id'].to_sym
51
- c_data[c_id] = {}
52
- c_data[c_id]['id'] = control['id'] || DATA_NOT_FOUND_MESSAGE
53
- c_data[c_id]['title'] = control['title'] || DATA_NOT_FOUND_MESSAGE
54
- c_data[c_id]['desc'] = control['desc'] || DATA_NOT_FOUND_MESSAGE
55
- c_data[c_id]['severity'] = control['tags']['severity'] || DATA_NOT_FOUND_MESSAGE
56
- c_data[c_id]['gid'] = control['tags']['gid'] || DATA_NOT_FOUND_MESSAGE
57
- c_data[c_id]['gtitle'] = control['tags']['gtitle'] || DATA_NOT_FOUND_MESSAGE
58
- c_data[c_id]['gdescription'] = control['tags']['gdescription'] || DATA_NOT_FOUND_MESSAGE
59
- c_data[c_id]['rid'] = control['tags']['rid'] || DATA_NOT_FOUND_MESSAGE
60
- c_data[c_id]['rversion'] = control['tags']['rversion'] || DATA_NOT_FOUND_MESSAGE
61
- c_data[c_id]['rweight'] = control['tags']['rweight'] || DATA_NOT_FOUND_MESSAGE
62
- c_data[c_id]['stig_id'] = control['tags']['stig_id'] || DATA_NOT_FOUND_MESSAGE
63
- c_data[c_id]['cci'] = control['tags']['cci'] || DATA_NOT_FOUND_MESSAGE
64
- c_data[c_id]['nist'] = control['tags']['nist'] || ['unmapped']
65
- c_data[c_id]['check'] = control['tags']['check'] || DATA_NOT_FOUND_MESSAGE
66
- c_data[c_id]['checkref'] = control['tags']['checkref'] || DATA_NOT_FOUND_MESSAGE
67
- c_data[c_id]['fix'] = control['tags']['fix'] || DATA_NOT_FOUND_MESSAGE
68
- c_data[c_id]['fixref'] = control['tags']['fixref'] || DATA_NOT_FOUND_MESSAGE
69
- c_data[c_id]['fix_id'] = control['tags']['fix_id'] || DATA_NOT_FOUND_MESSAGE
70
- c_data[c_id]['rationale'] = control['tags']['rationale'] || DATA_NOT_FOUND_MESSAGE
71
- c_data[c_id]['cis_family'] = control['tags']['cis_family'] || DATA_NOT_FOUND_MESSAGE
72
- c_data[c_id]['cis_rid'] = control['tags']['cis_rid'] || DATA_NOT_FOUND_MESSAGE
73
- c_data[c_id]['cis_level'] = control['tags']['cis_level'] || DATA_NOT_FOUND_MESSAGE
74
- c_data[c_id]['impact'] = control['impact'].to_s || DATA_NOT_FOUND_MESSAGE
75
- c_data[c_id]['code'] = control['code'].to_s || DATA_NOT_FOUND_MESSAGE
76
- end
77
-
78
- data['controls'] = c_data.values
79
- data['status'] = 'success'
80
- data
81
- end
82
-
83
- def self.parse_data_for_ckl(json)
27
+ def self.parse_data_for_ckl(json) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
84
28
  data = {}
85
29
 
86
30
  # Parse for inspec profile results json
@@ -104,8 +48,8 @@ module Utils
104
48
  end
105
49
 
106
50
  if control['descriptions'].respond_to?(:find)
107
- data[c_id][:check_content] = control['descriptions'].find { |c| c['label'] == 'fix' }&.dig('data')
108
- data[c_id][:fix_text] = control['descriptions'].find { |c| c['label'] == 'check' }&.dig('data')
51
+ data[c_id][:check_content] = control['descriptions'].find { |c| c['label'] == 'check' }&.dig('data')
52
+ data[c_id][:fix_text] = control['descriptions'].find { |c| c['label'] == 'fix' }&.dig('data')
109
53
  end
110
54
 
111
55
  data[c_id][:impact] = control['impact'].to_s unless control['impact'].nil?
@@ -221,7 +165,7 @@ module Utils
221
165
  end
222
166
 
223
167
  private_class_method def self.string_to_impact(severity, use_cvss_terms)
224
- if /none|na|n\/a|not[_|(\s*)]?applicable/i.match?(severity)
168
+ if %r{none|na|n/a|not[_|(\s*)]?applicable}i.match?(severity)
225
169
  impact = 0.0 # Informative
226
170
  elsif /low|cat(egory)?\s*(iii|3)/i.match?(severity)
227
171
  impact = 0.3 # Low Impact
@@ -248,13 +192,10 @@ module Utils
248
192
  end
249
193
 
250
194
  IMPACT_SCORES.reverse_each do |name, impact_score|
251
- if name == 'critical' && value >= impact_score && use_cvss_terms
252
- return 'high'
253
- elsif value >= impact_score
254
- return name
255
- else
256
- next
257
- end
195
+ return 'high' if name == 'critical' && value >= impact_score && use_cvss_terms
196
+ return name if value >= impact_score
197
+
198
+ next
258
199
  end
259
200
  end
260
201
 
@@ -277,7 +218,7 @@ module Utils
277
218
  WordWrap.ww(str.to_s, width)
278
219
  end
279
220
 
280
- private_class_method def self.generate_controls(inspec_json)
221
+ private_class_method def self.generate_controls(inspec_json) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
281
222
  controls = []
282
223
  inspec_json['controls'].each do |json_control|
283
224
  control = ::Inspec::Object::Control.new
@@ -384,7 +325,7 @@ module Utils
384
325
  myfile.puts readme_contents
385
326
  end
386
327
 
387
- private_class_method def self.unpack_profile(directory, controls, separated, output_format)
328
+ private_class_method def self.unpack_profile(directory, controls, separated, output_format) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
388
329
  FileUtils.rm_rf(directory) if Dir.exist?(directory)
389
330
  Dir.mkdir directory unless Dir.exist?(directory)
390
331
  Dir.mkdir "#{directory}/controls" unless Dir.exist?("#{directory}/controls")
@@ -433,9 +374,3 @@ module Utils
433
374
  end
434
375
  end
435
376
  end
436
-
437
- # rubocop:enable Metrics/ClassLength
438
- # rubocop:enable Metrics/AbcSize
439
- # rubocop:enable Metrics/PerceivedComplexity
440
- # rubocop:enable Metrics/CyclomaticComplexity
441
- # rubocop:enable Metrics/MethodLength
@@ -0,0 +1,10 @@
1
+ module Utils
2
+ class MappingValidator
3
+ def self.validate(mapping)
4
+ return mapping unless mapping.include?('control.tags') && mapping['control.tags'].include?('nist')
5
+
6
+ raise "Mapping file should not contain an entry for 'control.tags.nist'.
7
+ NIST tags will be autogenerated via 'control.tags.cci'."
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,89 @@
1
+ module Utils
2
+ # Data transformation from Inspec result output into usable data for XCCDF conversions.
3
+ class FromInspec
4
+ DATA_NOT_FOUND_MESSAGE = 'N/A'.freeze
5
+
6
+ # Convert raw Inspec result json into format acceptable for XCCDF transformation.
7
+ def parse_data_for_xccdf(json) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
8
+ data = {}
9
+
10
+ controls = []
11
+ if json['profiles'].nil?
12
+ controls = json['controls']
13
+ elsif json['profiles'].length == 1
14
+ controls = json['profiles'].last['controls']
15
+ else
16
+ json['profiles'].each do |profile|
17
+ controls.concat(profile['controls'])
18
+ end
19
+ end
20
+ c_data = {}
21
+
22
+ controls.each do |control|
23
+ c_id = control['id'].to_sym
24
+ c_data[c_id] = {}
25
+ c_data[c_id]['id'] = control['id'] || DATA_NOT_FOUND_MESSAGE
26
+ c_data[c_id]['title'] = control['title'] if control['title'] # Optional attribute
27
+ c_data[c_id]['desc'] = control['desc'] || DATA_NOT_FOUND_MESSAGE
28
+ c_data[c_id]['severity'] = control['tags']['severity'] || 'unknown'
29
+ c_data[c_id]['gid'] = control['tags']['gid'] || control['id']
30
+ c_data[c_id]['gtitle'] = control['tags']['gtitle'] if control['tags']['gtitle'] # Optional attribute
31
+ c_data[c_id]['gdescription'] = control['tags']['gdescription'] if control['tags']['gdescription'] # Optional attribute
32
+ c_data[c_id]['rid'] = control['tags']['rid'] || 'r_' + c_data[c_id]['gid']
33
+ c_data[c_id]['rversion'] = control['tags']['rversion'] if control['tags']['rversion'] # Optional attribute
34
+ c_data[c_id]['rweight'] = control['tags']['rweight'] if control['tags']['rweight'] # Optional attribute where N/A is not schema compliant
35
+ c_data[c_id]['stig_id'] = control['tags']['stig_id'] || DATA_NOT_FOUND_MESSAGE
36
+ c_data[c_id]['cci'] = control['tags']['cci'] if control['tags']['cci'] # Optional attribute
37
+ c_data[c_id]['nist'] = control['tags']['nist'] || ['unmapped']
38
+ c_data[c_id]['check'] = control['tags']['check'] || DATA_NOT_FOUND_MESSAGE
39
+ c_data[c_id]['checkref'] = control['tags']['checkref'] || DATA_NOT_FOUND_MESSAGE
40
+ c_data[c_id]['fix'] = control['tags']['fix'] || DATA_NOT_FOUND_MESSAGE
41
+ c_data[c_id]['fix_id'] = control['tags']['fix_id'] if control['tags']['fix_id'] # Optional attribute where N/A is not schema compliant
42
+ c_data[c_id]['rationale'] = control['tags']['rationale'] || DATA_NOT_FOUND_MESSAGE
43
+ c_data[c_id]['cis_family'] = control['tags']['cis_family'] || DATA_NOT_FOUND_MESSAGE
44
+ c_data[c_id]['cis_rid'] = control['tags']['cis_rid'] || DATA_NOT_FOUND_MESSAGE
45
+ c_data[c_id]['cis_level'] = control['tags']['cis_level'] || DATA_NOT_FOUND_MESSAGE
46
+ c_data[c_id]['impact'] = control['impact'].to_s || DATA_NOT_FOUND_MESSAGE
47
+ c_data[c_id]['code'] = control['code'].to_s || DATA_NOT_FOUND_MESSAGE
48
+ c_data[c_id]['results'] = parse_results_for_xccdf(control['results']) if control['results']
49
+ end
50
+
51
+ data['controls'] = c_data.values
52
+ data['profiles'] = parse_profiles_for_xccdf(json['profiles'])
53
+ data['status'] = 'success'
54
+ data['inspec_version'] = json['version']
55
+ data
56
+ end
57
+
58
+ private
59
+
60
+ # Convert profile information for result processing
61
+ # @param profiles [Array[Hash]] - The profiles section of the JSON output
62
+ def parse_profiles_for_xccdf(profiles)
63
+ return [] unless profiles
64
+
65
+ profiles.map do |profile|
66
+ data = {}
67
+ data['name'] = profile['name']
68
+ data['version'] = profile['version']
69
+ data
70
+ end
71
+ end
72
+
73
+ # Convert the test result data to a parseable Hash for downstream processing
74
+ # @param results [Array[Hash]] - The results section of the JSON output
75
+ def parse_results_for_xccdf(results)
76
+ results.map do |result|
77
+ data = {}
78
+ data['status'] = result['status']
79
+ data['code_desc'] = result['code_desc']
80
+ data['run_time'] = result['run_time']
81
+ data['start_time'] = result['start_time']
82
+ data['resource'] = result['resource']
83
+ data['message'] = result['message']
84
+ data['skip_message'] = result['skip_message']
85
+ data
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,388 @@
1
+ require_relative 'xccdf_score'
2
+
3
+ module Utils
4
+ # Data conversions for Inspec output into XCCDF format.
5
+ class ToXCCDF # rubocop:disable Metrics/ClassLength
6
+ # @param attribute [Hash] XCCDF supplemental attributes
7
+ # @param data [Hash] Converted Inspec output data
8
+ def initialize(attribute, data)
9
+ @attribute = attribute
10
+ @data = data
11
+ @benchmark = HappyMapperTools::Benchmark::Benchmark.new
12
+ end
13
+
14
+ # Build entire XML document and produce final output
15
+ # @param metadata [Hash] Data representing a system under scan
16
+ def to_xml(metadata)
17
+ build_benchmark_header
18
+ build_groups
19
+ # Only populate results if a target is defined so that conformant XML is produced.
20
+ @benchmark.testresult = build_test_results(metadata) if metadata['fqdn']
21
+ @benchmark.to_xml
22
+ end
23
+
24
+ private
25
+
26
+ # Sets top level XCCDF Benchmark attributes
27
+ def build_benchmark_header
28
+ @benchmark.title = @attribute['benchmark.title']
29
+ @benchmark.id = @attribute['benchmark.id']
30
+ @benchmark.description = @attribute['benchmark.description']
31
+ @benchmark.version = @attribute['benchmark.version']
32
+ @benchmark.xmlns = 'http://checklists.nist.gov/xccdf/1.1'
33
+
34
+ @benchmark.status = HappyMapperTools::Benchmark::Status.new
35
+ @benchmark.status.status = @attribute['benchmark.status']
36
+ @benchmark.status.date = @attribute['benchmark.status.date']
37
+
38
+ if @attribute['benchmark.notice.id']
39
+ @benchmark.notice = HappyMapperTools::Benchmark::Notice.new
40
+ @benchmark.notice.id = @attribute['benchmark.notice.id']
41
+ end
42
+
43
+ if @attribute['benchmark.plaintext'] || @attribute['benchmark.plaintext.id']
44
+ @benchmark.plaintext = HappyMapperTools::Benchmark::Plaintext.new
45
+ @benchmark.plaintext.plaintext = @attribute['benchmark.plaintext']
46
+ @benchmark.plaintext.id = @attribute['benchmark.plaintext.id']
47
+ end
48
+
49
+ @benchmark.reference = HappyMapperTools::Benchmark::ReferenceBenchmark.new
50
+ @benchmark.reference.href = @attribute['reference.href']
51
+ @benchmark.reference.dc_publisher = @attribute['reference.dc.publisher']
52
+ @benchmark.reference.dc_source = @attribute['reference.dc.source']
53
+ end
54
+
55
+ # Translate join of Inspec results and input attributes to XCCDF Groups
56
+ def build_groups # rubocop:disable Metrics/AbcSize
57
+ group_array = []
58
+ @data['controls'].each do |control|
59
+ group = HappyMapperTools::Benchmark::Group.new
60
+ group.id = control['id']
61
+ group.title = control['gtitle']
62
+ group.description = "<GroupDescription>#{control['gdescription']}</GroupDescription>" if control['gdescription']
63
+
64
+ group.rule = HappyMapperTools::Benchmark::Rule.new
65
+ group.rule.id = control['rid']
66
+ group.rule.severity = control['severity']
67
+ group.rule.weight = control['rweight']
68
+ group.rule.version = control['rversion']
69
+ group.rule.title = control['title'].tr("\n", ' ') if control['title']
70
+ group.rule.description = "<VulnDiscussion>#{control['desc'].tr("\n", ' ')}</VulnDiscussion><FalsePositives></FalsePositives><FalseNegatives></FalseNegatives><Documentable>false</Documentable><Mitigations></Mitigations><SeverityOverrideGuidance></SeverityOverrideGuidance><PotentialImpacts></PotentialImpacts><ThirdPartyTools></ThirdPartyTools><MitigationControl></MitigationControl><Responsibility></Responsibility><IAControls></IAControls>"
71
+
72
+ if ['reference.dc.publisher', 'reference.dc.title', 'reference.dc.subject', 'reference.dc.type', 'reference.dc.identifier'].any? { |a| @attribute.key?(a) }
73
+ group.rule.reference = build_rule_reference
74
+ end
75
+
76
+ group.rule.ident = build_rule_idents(control['cci']) if control['cci']
77
+
78
+ group.rule.fixtext = HappyMapperTools::Benchmark::Fixtext.new
79
+ group.rule.fixtext.fixref = control['fix_id']
80
+ group.rule.fixtext.fixtext = control['fix']
81
+
82
+ group.rule.fix = build_rule_fix(control['fix_id']) if control['fix_id']
83
+
84
+ group.rule.check = HappyMapperTools::Benchmark::Check.new
85
+ group.rule.check.system = control['checkref']
86
+
87
+ # content_ref is optional for schema compliance
88
+ if @attribute['content_ref.name'] || @attribute['content_ref.href']
89
+ group.rule.check.content_ref = HappyMapperTools::Benchmark::ContentRef.new
90
+ group.rule.check.content_ref.name = @attribute['content_ref.name']
91
+ group.rule.check.content_ref.href = @attribute['content_ref.href']
92
+ end
93
+
94
+ group.rule.check.content = control['check']
95
+
96
+ group_array << group
97
+ end
98
+ @benchmark.group = group_array
99
+ end
100
+
101
+ # Construct a Benchmark Testresult from Inspec data. This must be called after all XML processing has occurred for profiles
102
+ # and groups.
103
+ # @param metadata [Hash]
104
+ # @return [TestResult]
105
+ def build_test_results(metadata)
106
+ test_result = HappyMapperTools::Benchmark::TestResult.new
107
+ test_result.version = @benchmark.version
108
+ populate_remark(test_result)
109
+ populate_target_facts(test_result, metadata)
110
+ populate_identity(test_result, metadata)
111
+ populate_results(test_result)
112
+ populate_score(test_result, @benchmark.group)
113
+
114
+ test_result
115
+ end
116
+
117
+ # Contruct a Rule / RuleResult fix element with the provided id.
118
+ def build_rule_fix(fix_id)
119
+ HappyMapperTools::Benchmark::Fix.new.tap { |f| f.id = fix_id }
120
+ end
121
+
122
+ # Construct rule identifiers for rule
123
+ # @param idents [Array]
124
+ def build_rule_idents(idents)
125
+ raise "#{idents} is not an Array type." unless idents.is_a?(Array)
126
+
127
+ # Each rule identifier is a different element
128
+ idents.map do |identifier|
129
+ ident = HappyMapperTools::Benchmark::Ident.new
130
+ ident.system = 'https://public.cyber.mil/stigs/cci/'
131
+ ident.ident = identifier
132
+ ident
133
+ end
134
+ end
135
+
136
+ # Contruct a Rule reference element
137
+ def build_rule_reference
138
+ reference = HappyMapperTools::Benchmark::ReferenceGroup.new
139
+ reference.dc_publisher = @attribute['reference.dc.publisher']
140
+ reference.dc_title = @attribute['reference.dc.title']
141
+ reference.dc_subject = @attribute['reference.dc.subject']
142
+ reference.dc_type = @attribute['reference.dc.type']
143
+ reference.dc_identifier = @attribute['reference.dc.identifier']
144
+ reference
145
+ end
146
+
147
+ # Create a remark with contextual information about the Inspec version and profiles used
148
+ # @param result [HappyMapperTools::Benchmark::TestResult]
149
+ def populate_remark(result)
150
+ result.remark = "Results created using Inspec version #{@data['inspec_version']}.\n#{@data['profiles'].map { |p| "Profile: #{p['name']} Version: #{p['version']}" }.join("\n")}"
151
+ end
152
+
153
+ # Create all target specific information.
154
+ # @param result [HappyMapperTools::Benchmark::TestResult]
155
+ # @param metadata [Hash]
156
+ def populate_target_facts(result, metadata)
157
+ result.target = metadata['fqdn']
158
+ result.target_address = metadata['ip'] if metadata['ip']
159
+
160
+ all_facts = []
161
+
162
+ if metadata['mac']
163
+ fact = HappyMapperTools::Benchmark::Fact.new
164
+ fact.name = 'urn:xccdf:fact:asset:identifier:mac'
165
+ fact.type = 'string'
166
+ fact.fact = metadata['mac']
167
+ all_facts << fact
168
+ end
169
+
170
+ if metadata['ip']
171
+ fact = HappyMapperTools::Benchmark::Fact.new
172
+ fact.name = 'urn:xccdf:fact:asset:identifier:ipv4'
173
+ fact.type = 'string'
174
+ fact.fact = metadata['ip']
175
+ all_facts << fact
176
+ end
177
+
178
+ return unless all_facts.size.nonzero?
179
+
180
+ facts = HappyMapperTools::Benchmark::TargetFact.new
181
+ facts.fact = all_facts
182
+ result.target_facts = facts
183
+ end
184
+
185
+ # Build out the TestResult given all the control and result data.
186
+ def populate_results(test_result)
187
+ # Note: id is not an XCCDF 1.2 compliant identifier and will need to be updated when that support is added.
188
+ test_result.id = 'result_1'
189
+ test_result.starttime = run_start_time
190
+ test_result.endtime = run_end_time
191
+
192
+ # Build out individual results
193
+ all_rule_result = []
194
+
195
+ @data['controls'].each do |control|
196
+ next if control['results'].empty?
197
+
198
+ control_results =
199
+ control['results'].map do |result|
200
+ populate_rule_result(control, result, xccdf_status(result['status'], control['impact']))
201
+ end
202
+
203
+ # Consolidate results into single rule result do to lack of multiple=true attribute on Rule.
204
+ # 1. Select the unified result status
205
+ selected_status = control_results.reduce(control_results.first.result) { |f_status, rule_result| xccdf_and_result(f_status, rule_result.result) }
206
+
207
+ # 2. Only choose results with that status
208
+ # 3. Combine those results
209
+ all_rule_result << combine_results(control_results.select { |r| r.result == selected_status })
210
+ end
211
+
212
+ test_result.rule_result = all_rule_result
213
+ test_result
214
+ end
215
+
216
+ # Create rule-result from the control and Inspec result information
217
+ def populate_rule_result(control, result, result_status)
218
+ rule_result = HappyMapperTools::Benchmark::RuleResultType.new
219
+
220
+ rule_result.idref = control['rid']
221
+ rule_result.severity = control['severity']
222
+ rule_result.time = end_time(result['start_time'], result['run_time'])
223
+ rule_result.weight = control['rweight']
224
+
225
+ rule_result.result = result_status
226
+ rule_result.message = result_message(result, result_status) if result_message(result, result_status)
227
+ rule_result.instance = result['code_desc']
228
+
229
+ rule_result.ident = build_rule_idents(control['cci']) if control['cci']
230
+
231
+ # Fix information is only necessary when there are failed tests
232
+ rule_result.fix = build_rule_fix(control['fix_id']) if control['fix_id'] && result_status == 'fail'
233
+
234
+ rule_result.check = HappyMapperTools::Benchmark::Check.new
235
+ rule_result.check.system = control['checkref']
236
+ rule_result.check.content = result['code_desc']
237
+ rule_result
238
+ end
239
+
240
+ # Combines rule results with the same result into a single rule result.
241
+ def combine_results(rule_results) # rubocop:disable Metrics/AbcSize
242
+ return rule_results.first if rule_results.size == 1
243
+
244
+ # Can combine, result, idents (duplicate, take first instance), instance - combine into an array removing duplicates
245
+ # check.content - Only one value allowed, combine by joining with line feed. Prior to, make sure all values are unique.
246
+
247
+ rule_result = HappyMapperTools::Benchmark::RuleResultType.new
248
+ rule_result.idref = rule_results.first.idref
249
+ rule_result.severity = rule_results.first.severity
250
+ # Take latest time
251
+ rule_result.time = rule_results.reduce(rule_results.first.time) { |time, r| time > r.time ? time : r.time }
252
+ rule_result.weight = rule_results.first.weight
253
+
254
+ rule_result.result = rule_results.first.result
255
+ rule_result.message = rule_results.reduce([]) { |messages, r| r.message ? messages.push(r.message) : messages }
256
+ rule_result.instance = rule_results.reduce([]) { |instances, r| r.instance ? instances.push(r.instance) : instances }.join("\n")
257
+
258
+ rule_result.ident = rule_results.first.ident
259
+ rule_result.fix = rule_results.first.fix
260
+
261
+ if rule_results.first.check
262
+ rule_result.check = HappyMapperTools::Benchmark::Check.new
263
+ rule_result.check.system = rule_results.first.check.system
264
+ rule_result.check.content = rule_results.map { |r| r.check.content }.join("\n")
265
+ end
266
+
267
+ rule_result
268
+ end
269
+
270
+ # Add information about the the account and organization executing the tests.
271
+ def populate_identity(test_result, metadata)
272
+ if metadata['identity']
273
+ test_result.identity = HappyMapperTools::Benchmark::IdentityType.new
274
+ test_result.identity.authenticated = true
275
+ test_result.identity.identity = metadata['identity']['identity']
276
+ test_result.identity.privileged = metadata['identity']['privileged']
277
+ end
278
+
279
+ test_result.organization = metadata['organization'] if metadata['organization']
280
+ end
281
+
282
+ # Return the earliest time of execution.
283
+ def run_start_time
284
+ @data['controls'].map { |control| control['results'].map { |result| DateTime.parse(result['start_time']) } }.flatten.min
285
+ end
286
+
287
+ # Return the latest time of execution accounting for Inspec duration.
288
+ def run_end_time
289
+ end_times =
290
+ @data['controls'].map do |control|
291
+ control['results'].map { |result| end_time(result['start_time'], result['run_time']) }
292
+ end
293
+
294
+ end_times.flatten.max
295
+ end
296
+
297
+ # Calculate an end time given a start time and second duration
298
+ def end_time(start, duration)
299
+ DateTime.parse(start) + (duration / (24*60*60))
300
+ end
301
+
302
+ # Map the Inspec result status to appropriate XCCDF test result status.
303
+ # XCCDF options include: pass, fail, error, unknown, notapplicable, notchecked, notselected, informational, fixed
304
+ #
305
+ # @param inspec_status [String] The reported Inspec status from an individual test
306
+ # @param impact [String] A value of 0.0 - 1.0
307
+ # @return A valid Inspec status.
308
+ def xccdf_status(inspec_status, impact)
309
+ # Currently, there is no good way to map an Inspec result status to one of XCCDF status unknown or notselected.
310
+ case inspec_status
311
+ when 'failed'
312
+ 'fail'
313
+ when 'passed'
314
+ 'pass'
315
+ when 'skipped'
316
+ if impact.to_f.zero?
317
+ 'notapplicable'
318
+ else
319
+ 'notchecked'
320
+ end
321
+ else
322
+ # In the event Inspec adds a new unaccounted for status, mapping to XCCDF unknown.
323
+ 'unknown'
324
+ end
325
+ end
326
+
327
+ # When more than one result occurs for a rule and the specification does not declare multiple, the result must be combined.
328
+ # This determines the appropriate result to be selected when there are two to compare.
329
+ # @param one [String] A rule-result status
330
+ # @param two [String] A rule-result status
331
+ # @return The result of the AND operation.
332
+ def xccdf_and_result(one, two) # rubocop:disable Metrics/CyclomaticComplexity
333
+ # From XCCDF specification truth table
334
+ # P = pass
335
+ # F = fail
336
+ # U = unknown
337
+ # E = error
338
+ # N = notapplicable
339
+ # K = notchecked
340
+ # S = notselected
341
+ # I = informational
342
+
343
+ case one
344
+ when 'pass'
345
+ %w{fail unknown}.any? { |s| s == two } ? two : one
346
+ when 'fail'
347
+ one
348
+ when 'unknown'
349
+ two == 'fail' ? two : one
350
+ when 'notapplicable'
351
+ %w{pass fail unknown}.any? { |s| s == two } ? two : one
352
+ when 'notchecked'
353
+ %w{pass fail unknown notapplicable}.any? { |s| s == two } ? two : one
354
+ end
355
+ end
356
+
357
+ # Builds the message information for rule results
358
+ # @param result [Hash] A single Inspec result
359
+ # @param xccdf_status [String] the xccdf calculated result status for the provided result
360
+ def result_message(result, xccdf_status)
361
+ return unless result['message'] || result['skip_message']
362
+
363
+ message = HappyMapperTools::Benchmark::MessageType.new
364
+ # Including the code of the check and the resulting message if there is one.
365
+ message.message = "#{result['code_desc'] ? result['code_desc'] + "\n\n" : ''}#{result['message'] || result['skip_message']}"
366
+ message.severity = result_message_severity(xccdf_status)
367
+ message
368
+ end
369
+
370
+ # All rule-result messages require a defined severity. This determines a value to use based upon the result XCCDF status.
371
+ def result_message_severity(xccdf_status)
372
+ case xccdf_status
373
+ when 'fail'
374
+ 'error'
375
+ when 'notapplicable'
376
+ 'warning'
377
+ else
378
+ 'info'
379
+ end
380
+ end
381
+
382
+ # Set scores for all 4 required/recommended scoring systems.
383
+ def populate_score(test_result, groups)
384
+ score = Utils::XCCDFScore.new(groups, test_result.rule_result)
385
+ test_result.score = [score.default_score, score.flat_score, score.flat_unweighted_score, score.absolute_score]
386
+ end
387
+ end
388
+ end