inspec_tools 2.0.4 → 2.2.0

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.
@@ -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
@@ -0,0 +1,116 @@
1
+ module Utils
2
+ # Perform scoring calculations for the different types that is used in a TestResult score.
3
+ class XCCDFScore
4
+ # @param groups [Array[HappyMapperTools::Benchmark::Group]]
5
+ # @param rule_results [Array[RuleResultType]]
6
+ def initialize(groups, rule_results)
7
+ @groups = groups
8
+ @rule_results = rule_results
9
+ end
10
+
11
+ # Calculate and return the urn:xccdf:scoring:default score for the entire benchmark.
12
+ # @return ScoreType
13
+ def default_score
14
+ HappyMapperTools::Benchmark::ScoreType.new('urn:xccdf:scoring:default', 100, score_benchmark_default)
15
+ end
16
+
17
+ # urn:xccdf:scoring:flat
18
+ # @return ScoreType
19
+ def flat_score
20
+ results = score_benchmark_with_weights(true)
21
+ HappyMapperTools::Benchmark::ScoreType.new('urn:xccdf:scoring:flat', results[:max], results[:score])
22
+ end
23
+
24
+ # urn:xccdf:scoring:flat-unweighted
25
+ # @return ScoreType
26
+ def flat_unweighted_score
27
+ results = score_benchmark_with_weights(false)
28
+ HappyMapperTools::Benchmark::ScoreType.new('urn:xccdf:scoring:flat-unweighted', results[:max], results[:score])
29
+ end
30
+
31
+ # urn:xccdf:scoring:absolute
32
+ # @return ScoreType
33
+ def absolute_score
34
+ results = score_benchmark_with_weights(true)
35
+ HappyMapperTools::Benchmark::ScoreType.new('urn:xccdf:scoring:absolute', 1, (results[:max] == results[:score] && results[:max].positive? ? 1 : 0))
36
+ end
37
+
38
+ private
39
+
40
+ # Return the overall score for the default model
41
+ def score_benchmark_default
42
+ return 0.0 unless @groups
43
+
44
+ count = 0
45
+ cumulative_score = 0.0
46
+
47
+ @groups.each do |group|
48
+ # Default weighted scoring only provides value when more than one rule exists per group. This implementation
49
+ # is not currently supporting more than one rule per group so weight need not apply.
50
+ rule_score = score_default_rule(test_results(group.rule.id))
51
+
52
+ if rule_score[:rule_count].positive?
53
+ count += 1
54
+ cumulative_score += rule_score[:rule_score]
55
+ end
56
+ end
57
+
58
+ return 0.0 unless count.positive?
59
+
60
+ (cumulative_score / count).round(2)
61
+ end
62
+
63
+ # @param weighted [Boolean] Indicate to apply with weights.
64
+ def score_benchmark_with_weights(weighted)
65
+ score = 0.0
66
+ max_score = 0.0
67
+
68
+ return { score: score, max: max_score } unless @groups
69
+
70
+ @groups.each do |group|
71
+ # Default weighted scoring only provides value when more than one rule exists per group. This implementation
72
+ # is not currently supporting more than one rule per group so weight need not apply.
73
+ rule_score = rule_counts_and_score(test_results(group.rule.id))
74
+
75
+ next unless rule_score[:rule_count].positive?
76
+
77
+ weight =
78
+ if weighted
79
+ group.rule.weight.nil? ? 1.0 : group.rule.weight.to_f
80
+ else
81
+ group.rule.weight.nil? || group.rule.weight.to_f != 0.0 ? 1.0 : 0.0
82
+ end
83
+
84
+ max_score += weight
85
+ score += (weight * rule_score[:rule_score]) / rule_score[:rule_count]
86
+ end
87
+
88
+ { score: score.round(2), max: max_score }
89
+ end
90
+
91
+ def score_default_rule(results)
92
+ sum = rule_counts_and_score(results)
93
+ return sum if sum[:rule_count].zero?
94
+
95
+ sum[:rule_score] = (100 * sum[:rule_score]) / sum[:rule_count]
96
+ sum
97
+ end
98
+
99
+ # Perform basic summation of rule results and passing tests
100
+ def rule_counts_and_score(results)
101
+ excluded_results = %w{notapplicable notchecked informational notselected}
102
+ rule_count = results.count { |r| !excluded_results.include?(r.result) }
103
+ rule_score = results.count { |r| r.result == 'pass' }
104
+
105
+ { rule_count: rule_count, rule_score: rule_score }
106
+ end
107
+
108
+ # Get all test results with the matching rule id
109
+ # @return [Array]
110
+ def test_results(id)
111
+ return [] unless @rule_results
112
+
113
+ @rule_results.select { |r| r.idref == id }
114
+ end
115
+ end
116
+ end