inspec_tools 2.0.4 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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