cem_acpt 0.11.2 → 0.12.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.
@@ -267,15 +267,15 @@ module CemAcpt
267
267
  # @raise [StandardError] If there is an error formatting the provision data.
268
268
  def provision_node_data
269
269
  node_data = @provision_data[:nodes].each_with_object({}) do |node, h|
270
- h[node.node_name] = node.node_data.merge(
271
- {
272
- goss_file: node.test_data[:goss_file],
273
- puppet_manifest: node.test_data[:puppet_manifest],
274
- provision_dir_source: @backend.provision_directory,
275
- provision_dir_dest: @backend.destination_provision_directory,
276
- provision_commands: provision_commands_for(node),
277
- }
278
- )
270
+ base = {
271
+ goss_file: resolve_goss_file(node),
272
+ puppet_manifest: node.test_data[:puppet_manifest],
273
+ provision_dir_source: @backend.provision_directory,
274
+ provision_dir_dest: @backend.destination_provision_directory,
275
+ provision_commands: provision_commands_for(node),
276
+ }
277
+ base.merge!(scan_node_data_for(node)) if scan_mode?
278
+ h[node.node_name] = node.node_data.merge(base)
279
279
  end
280
280
  node_data.to_json
281
281
  rescue StandardError => e
@@ -287,7 +287,7 @@ module CemAcpt
287
287
  def provision_commands_for(node)
288
288
  case @backend
289
289
  when CemAcpt::Provision::Linux
290
- @backend.provision_commands_wrapper(node.node_data[:image])
290
+ @backend.provision_commands_wrapper(node.node_data[:image], scan_mode: scan_mode?)
291
291
  when CemAcpt::Provision::Windows
292
292
  []
293
293
  else
@@ -295,6 +295,143 @@ module CemAcpt
295
295
  end
296
296
  end
297
297
 
298
+ def scan_mode?
299
+ @config.respond_to?(:scan_mode?) && @config.scan_mode?
300
+ end
301
+
302
+ # In scan mode the test directory may not contain a goss.yaml; the
303
+ # Terraform `provisioner "file"` block requires a real source path, so
304
+ # write a small stub file once into the working dir and reuse it for
305
+ # any node that lacks a real goss.yaml. The on-node Goss systemd units
306
+ # are not started in scan mode, so the stub is never used at runtime.
307
+ def resolve_goss_file(node)
308
+ real = node.test_data[:goss_file]
309
+ return real if real && File.exist?(real)
310
+ return nil unless scan_mode?
311
+
312
+ @scan_goss_stub_path ||= write_scan_goss_stub!
313
+ end
314
+
315
+ def write_scan_goss_stub!
316
+ path = File.join(working_dir, 'scan-stub-goss.yaml')
317
+ File.write(path, "# Empty Goss stub used by cem_acpt_scan when no goss.yaml is present.\n")
318
+ path
319
+ end
320
+
321
+ # Returns the additional Terraform node_data fields needed to drive the
322
+ # scan_config_upload and cis_cat_pro_upload null_resources. The license
323
+ # fields are populated alongside the bundle fields so that the four
324
+ # provisioners inside cis_cat_pro_upload (bundle upload, bundle extract,
325
+ # license upload, license extract) all have the data they need from a
326
+ # single resource invocation.
327
+ def scan_node_data_for(node)
328
+ bundle = cis_cat_pro_bundle_for(node)
329
+ license = cis_cat_pro_license_for(node)
330
+ {
331
+ scan_config_json: scan_config_json_for(node),
332
+ cis_cat_pro_bundle: bundle || '',
333
+ cis_cat_pro_format: bundle ? archive_format(bundle) : '',
334
+ cis_cat_pro_license: license || '',
335
+ cis_cat_pro_license_format: license ? archive_format(license) : '',
336
+ }
337
+ end
338
+
339
+ # Builds the JSON content uploaded to /opt/cem_acpt/scan/scan_config.json
340
+ # at provision time. The on-node scan_service reads this on each /scan
341
+ # invocation to pick the right scanner, profile, and level.
342
+ def scan_config_json_for(node)
343
+ cfg = node.test_data[:scan] || {}
344
+ require 'json'
345
+ JSON.generate(
346
+ 'scanner' => cfg[:scanner].to_s,
347
+ 'profile' => cfg[:profile].to_s,
348
+ 'level' => cfg[:level],
349
+ 'datastream' => cfg[:datastream],
350
+ 'benchmark' => cfg[:benchmark],
351
+ )
352
+ end
353
+
354
+ # Resolves the local path to the CIS-CAT Pro bundle for this node.
355
+ # Returns nil if the resolved scanner is openscap (no bundle needed).
356
+ # Honors `cem_acpt_scan.cis_cat_pro_source` from config (local path or
357
+ # gs:// URI; gs:// URIs are downloaded to a per-run cache file before
358
+ # being passed to Terraform).
359
+ def cis_cat_pro_bundle_for(node)
360
+ cfg = node.test_data[:scan] || {}
361
+ return nil unless cfg[:scanner].to_s == 'ciscat'
362
+
363
+ @cis_cat_pro_local_path ||= resolve_cis_cat_pro_source!
364
+ end
365
+
366
+ # Resolves the local path to the CIS-CAT Pro license bundle for this
367
+ # node. Mirrors {#cis_cat_pro_bundle_for}: returns nil unless the
368
+ # resolved scanner is `ciscat`, otherwise memoizes the resolved local
369
+ # path. The license is required separately from the assessor bundle so
370
+ # that license rotation does not force rebundling the assessor.
371
+ def cis_cat_pro_license_for(node)
372
+ cfg = node.test_data[:scan] || {}
373
+ return nil unless cfg[:scanner].to_s == 'ciscat'
374
+
375
+ @cis_cat_pro_license_local_path ||= resolve_cis_cat_pro_license!
376
+ end
377
+
378
+ def resolve_cis_cat_pro_source!
379
+ source = @config.get('cem_acpt_scan.cis_cat_pro_source')
380
+ raise 'cem_acpt_scan.cis_cat_pro_source is required for CIS-CAT Pro scans' if source.nil? || source.to_s.empty?
381
+
382
+ if source.start_with?('gs://')
383
+ # Preserve the source's archive extension so the local cache file
384
+ # carries its real format. archive_format raises if the extension
385
+ # is unrecognized, which is the right behavior here too.
386
+ ext = archive_format(source) == 'zip' ? '.zip' : '.tar.gz'
387
+ dest = File.join(working_dir, "cis-cat-pro#{ext}")
388
+ logger.info('CemAcpt::Provision::Terraform') { "Downloading CIS-CAT Pro bundle from #{source}" }
389
+ CemAcpt::Utils::Shell.run_cmd("gcloud storage cp #{source} #{dest}")
390
+ dest
391
+ else
392
+ File.expand_path(source)
393
+ end
394
+ end
395
+
396
+ # Mirror of {#resolve_cis_cat_pro_source!} for the license bundle.
397
+ # Raises {CemAcpt::Scan::LicenseNotFoundError} (rather than a plain
398
+ # RuntimeError as source does) so callers can rescue it specifically —
399
+ # missing license is a recoverable operator-config error, not a bug.
400
+ def resolve_cis_cat_pro_license!
401
+ source = @config.get('cem_acpt_scan.cis_cat_pro_license')
402
+ raise CemAcpt::Scan::LicenseNotFoundError if source.nil? || source.to_s.empty?
403
+
404
+ if source.start_with?('gs://')
405
+ ext = archive_format(source) == 'zip' ? '.zip' : '.tar.gz'
406
+ dest = File.join(working_dir, "cis-cat-pro-license#{ext}")
407
+ logger.info('CemAcpt::Provision::Terraform') { "Downloading CIS-CAT Pro license bundle from #{source}" }
408
+ CemAcpt::Utils::Shell.run_cmd("gcloud storage cp #{source} #{dest}")
409
+ dest
410
+ else
411
+ File.expand_path(source)
412
+ end
413
+ end
414
+
415
+ # Detects the archive format of a CIS-CAT Pro bundle from its path
416
+ # extension. CIS-CAT Pro ships either a tarball or a zip; we accept
417
+ # both, dispatch to the appropriate on-node extractor in the
418
+ # cis_cat_pro_upload null_resource, and raise on anything else so the
419
+ # operator gets a clear error before provisioning starts instead of
420
+ # an obscure terraform-apply failure.
421
+ # @param path [String] local path or gs:// URI to the bundle.
422
+ # @return [String] 'tar.gz' or 'zip'.
423
+ # @raise [RuntimeError] if the extension is not recognized.
424
+ def archive_format(path)
425
+ case path.to_s.downcase
426
+ when /\.tar\.gz\z/, /\.tgz\z/
427
+ 'tar.gz'
428
+ when /\.zip\z/
429
+ 'zip'
430
+ else
431
+ raise "Unsupported CIS-CAT Pro archive format: '#{path}'. Expected .tar.gz, .tgz, or .zip."
432
+ end
433
+ end
434
+
298
435
  # @return [Hash] The variables to be passed to Terraform, including the provision data for the nodes and any
299
436
  # necessary credentials and module package paths.
300
437
  # @raise [StandardError] If there is an error formatting the variables.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'errors'
7
+ require_relative 'result'
8
+
9
+ module CemAcpt
10
+ module Scan
11
+ # HTTP client for the on-node scan daemon. Mirrors the role of
12
+ # {CemAcpt::Goss::Api}: build URIs, GET them, parse the JSON response,
13
+ # turn the response into a typed result.
14
+ #
15
+ # The daemon is installed by {CemAcpt::Provision::Linux#scan_provision_commands}
16
+ # and serves two endpoints on the configurable scan port:
17
+ #
18
+ # GET /health -> 200 OK once the daemon has started and the scanner
19
+ # binaries it needs are present on disk.
20
+ # GET /scan -> 200 with a JSON body shaped like:
21
+ # { "score": 87.4, "passed_count": 187, "failed_count": 27,
22
+ # "not_applicable_count": 14, "error_count": 0, "rules": [...] }
23
+ # Non-200 responses or unparseable bodies raise
24
+ # {ScannerInvocationError}.
25
+ class DaemonClient
26
+ DEFAULT_PORT = 8084
27
+ DEFAULT_READY_TIMEOUT = 60
28
+ DEFAULT_HTTP_TIMEOUT = 1800 # 30 minutes — scans can be long
29
+
30
+ # @param host [String] The IP or DNS name of the test node.
31
+ # @param port [Integer] The port the daemon listens on.
32
+ # @param ready_timeout [Integer] How long to wait for /health.
33
+ # @param http_timeout [Integer] How long a single /scan request may take.
34
+ def initialize(host:, port: DEFAULT_PORT, ready_timeout: DEFAULT_READY_TIMEOUT, http_timeout: DEFAULT_HTTP_TIMEOUT)
35
+ @host = host
36
+ @port = port
37
+ @ready_timeout = ready_timeout
38
+ @http_timeout = http_timeout
39
+ end
40
+
41
+ # Polls /health until it returns 200 or the timeout elapses.
42
+ # @raise [DaemonNotReadyError] if the timeout is reached without a 200.
43
+ def wait_until_ready
44
+ deadline = Time.now + @ready_timeout
45
+ last_error = nil
46
+ while Time.now < deadline
47
+ begin
48
+ return true if get(URI("http://#{@host}:#{@port}/health"), timeout: 5).first.to_i == 200
49
+ rescue StandardError => e
50
+ last_error = e
51
+ end
52
+ sleep 2
53
+ end
54
+ msg = "Scan daemon at #{@host}:#{@port} did not become ready within #{@ready_timeout}s"
55
+ msg += " (last error: #{last_error.class}: #{last_error.message})" if last_error
56
+ raise DaemonNotReadyError, msg
57
+ end
58
+
59
+ # Hits /scan and turns the response into a {Result}.
60
+ # @param test_case [String] Acceptance-test directory name.
61
+ # @param scanner [Symbol] :openscap or :ciscat.
62
+ # @param profile [String] Scanner-native profile id.
63
+ # @param threshold [Float] Pass threshold (0-100).
64
+ # @return [Result]
65
+ # @raise [ScannerInvocationError] on non-200 or unparseable body.
66
+ def scan(test_case:, scanner:, profile:, threshold:)
67
+ uri = URI("http://#{@host}:#{@port}/scan")
68
+ status, body = get(uri, timeout: @http_timeout)
69
+ unless status.to_i == 200
70
+ raise ScannerInvocationError, "Scan daemon at #{@host}:#{@port} returned status #{status}: #{body.inspect}"
71
+ end
72
+
73
+ Result.new(test_case: test_case, scanner: scanner, profile: profile, threshold: threshold, body: body)
74
+ end
75
+
76
+ private
77
+
78
+ def get(uri, timeout:)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.read_timeout = timeout
81
+ http.open_timeout = [timeout, 30].min
82
+ response = http.get(uri.request_uri)
83
+ body = response.body.to_s
84
+ parsed = body.empty? ? {} : JSON.parse(body)
85
+ [response.code, parsed]
86
+ rescue JSON::ParserError => e
87
+ raise ScannerInvocationError, "Scan daemon at #{@host}:#{@port} returned non-JSON body: #{e.message}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module Scan
5
+ # Raised when the on-node scan daemon does not respond healthily within
6
+ # the configured `cem_acpt_scan.daemon.ready_timeout` window.
7
+ class DaemonNotReadyError < StandardError; end
8
+
9
+ # Raised when a test case has no entry in `cem_acpt_scan.profiles.<scanner>`
10
+ # and so no scanner profile id can be resolved. The message carries the
11
+ # missing config key so the operator can fix it without re-running.
12
+ class ProfileNotFoundError < StandardError
13
+ def initialize(test_case, scanner, config_key)
14
+ super("No scanner profile id configured for test case '#{test_case}' (scanner: #{scanner}). Set '#{config_key}' in cem_acpt config.")
15
+ end
16
+ end
17
+
18
+ # Raised when the scan daemon returns a non-200 response or a payload
19
+ # that cannot be parsed as a scan result. Mirrors the role of the
20
+ # error path in {CemAcpt::Goss::Api}.
21
+ class ScannerInvocationError < StandardError; end
22
+
23
+ # Raised when `cem_acpt_scan.cis_cat_pro_license` is unset (or empty) and
24
+ # any test case in the run resolves to scanner `ciscat`. The license is
25
+ # required for CIS-CAT Pro and is separate from the assessor bundle so
26
+ # that license rotation does not require rebundling the assessor. The
27
+ # message carries the missing config key so the operator can fix it
28
+ # without re-running.
29
+ class LicenseNotFoundError < StandardError
30
+ def initialize(config_key = 'cem_acpt_scan.cis_cat_pro_license')
31
+ super("CIS-CAT Pro license is required for CIS-CAT Pro scans. Set '#{config_key}' in cem_acpt config.")
32
+ end
33
+ end
34
+
35
+ # Raised when a test case has no entry in `cem_acpt_scan.benchmarks.cis_cat`
36
+ # and so no benchmark XCCDF filename can be resolved. The message carries the
37
+ # missing config key so the operator can fix it without re-running.
38
+ class BenchmarkNotFoundError < StandardError
39
+ def initialize(test_case, scanner, config_key)
40
+ super("No benchmark configured for test case '#{test_case}' (scanner: #{scanner}). Set '#{config_key}' in cem_acpt config.")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CemAcpt
6
+ module Scan
7
+ # Normalized scan result. Wraps the JSON payload returned by the on-node
8
+ # scan daemon and exposes the bits the runner needs: the score the daemon
9
+ # reported (scanner-native pass-rate, 0-100), the pass/fail decision
10
+ # against the configured threshold, and a status code compatible with
11
+ # {CemAcpt::TestRunner::TestResults} so the existing result-aggregation
12
+ # plumbing doesn't have to grow a new branch.
13
+ #
14
+ # Status semantics: 200 if `score >= threshold`, 1 otherwise. This makes
15
+ # {Result} look-alike enough to {CemAcpt::Goss::Api::ActionResponse} for
16
+ # the runner's `process_test_results` loop to treat it uniformly.
17
+ class Result
18
+ attr_reader :test_case, :scanner, :profile, :score, :threshold, :body
19
+
20
+ # @param test_case [String] The acceptance-test directory name.
21
+ # @param scanner [Symbol, String] :openscap or :ciscat.
22
+ # @param profile [String] The scanner-native profile id used.
23
+ # @param threshold [Float] The pass threshold this result was evaluated against.
24
+ # @param body [Hash] The parsed JSON payload from the scan daemon. Must
25
+ # include numeric `score` and counts (`passed_count`, `failed_count`,
26
+ # `not_applicable_count`, `error_count`); may include a `rules` array.
27
+ def initialize(test_case:, scanner:, profile:, threshold:, body:)
28
+ @test_case = test_case
29
+ @scanner = scanner.to_sym
30
+ @profile = profile
31
+ @threshold = threshold.to_f
32
+ @body = body || {}
33
+ @score = (@body['score'] || @body[:score] || 0.0).to_f
34
+ end
35
+
36
+ # @return [Boolean] true if the score met the threshold.
37
+ def pass?
38
+ @score >= @threshold
39
+ end
40
+ alias success? pass?
41
+
42
+ # @return [Boolean] true if the score did not meet the threshold.
43
+ def fail?
44
+ !pass?
45
+ end
46
+ alias error? fail?
47
+
48
+ # @return [Integer] HTTP-style status: 200 on pass, 1 on fail.
49
+ # Conforms to the contract {CemAcpt::TestRunner::Runner#process_test_results}
50
+ # uses to decide the run's exit code.
51
+ def status
52
+ pass? ? 200 : 1
53
+ end
54
+ alias http_status status
55
+
56
+ # @return [Hash] The full normalized result, suitable for `to_json`.
57
+ def to_h
58
+ {
59
+ test_case: @test_case,
60
+ scanner: @scanner,
61
+ profile: @profile,
62
+ score: @score,
63
+ threshold: @threshold,
64
+ passed_count: counts(:passed_count),
65
+ failed_count: counts(:failed_count),
66
+ not_applicable_count: counts(:not_applicable_count),
67
+ error_count: counts(:error_count),
68
+ rules: @body['rules'] || @body[:rules] || [],
69
+ pass: pass?,
70
+ }
71
+ end
72
+
73
+ def to_json(*args)
74
+ to_h.to_json(*args)
75
+ end
76
+
77
+ def to_s
78
+ "<#{self.class.name} test_case=#{@test_case} scanner=#{@scanner} score=#{@score} threshold=#{@threshold} pass=#{pass?}>"
79
+ end
80
+ alias inspect to_s
81
+
82
+ private
83
+
84
+ def counts(key)
85
+ (@body[key.to_s] || @body[key] || 0).to_i
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace for the benchmark-scan subsystem used by `cem_acpt_scan`. Mirrors
4
+ # the file-organization pattern of {CemAcpt::Bolt} and {CemAcpt::Goss}: a
5
+ # top-level umbrella module with subordinate files for the HTTP client to the
6
+ # on-node scan daemon, the normalized result wrapper, and error classes.
7
+ #
8
+ # The on-node scan daemon source lives in `lib/terraform/gcp/linux/scan/`
9
+ # alongside the systemd unit; both are uploaded to the test node via the
10
+ # existing recursive `provisioner "file"` block in main.tf.
11
+ module CemAcpt
12
+ module Scan
13
+ require_relative 'scan/errors'
14
+ require_relative 'scan/result'
15
+ require_relative 'scan/daemon_client'
16
+ end
17
+ end
@@ -42,15 +42,15 @@ module CemAcpt
42
42
 
43
43
  goss_file = File.expand_path(File.join(test_dir, 'goss.yaml'))
44
44
  puppet_manifest = File.expand_path(File.join(test_dir, 'manifest.pp'))
45
- raise "Goss file not found for test #{test_name}" unless File.exist?(goss_file)
46
45
  raise "Puppet manifest not found for test #{test_name}" unless File.exist?(puppet_manifest)
46
+ raise "Goss file not found for test #{test_name}" unless scan_mode? || File.exist?(goss_file)
47
47
 
48
48
  bolt_test = File.expand_path(File.join(test_dir, 'bolt.yaml'))
49
49
  logger.debug('CemAcpt::TestData') { "Complete test directory found for test #{test_name}: #{test_dir}" }
50
50
  test_data = {
51
51
  test_name: test_name,
52
52
  test_dir: File.expand_path(test_dir),
53
- goss_file: goss_file,
53
+ goss_file: File.exist?(goss_file) ? goss_file : nil,
54
54
  puppet_manifest: puppet_manifest,
55
55
  }
56
56
  test_data[:bolt_test] = bolt_test if File.exist?(bolt_test)
@@ -59,6 +59,7 @@ module CemAcpt
59
59
  process_static_vars(test_data_i)
60
60
  process_name_pattern_vars(test_name, test_data_i)
61
61
  vars_post_processing!(test_data_i)
62
+ scan_post_processing!(test_data_i) if scan_mode?
62
63
  test_data_i.format!
63
64
  a << test_data_i
64
65
  end
@@ -68,14 +69,69 @@ module CemAcpt
68
69
  private
69
70
 
70
71
  # Locates acceptance tests in the module directory.
72
+ # In scan mode the predicate is `manifest.pp` (goss.yaml is optional);
73
+ # otherwise it is `goss.yaml` so the existing acceptance flow is unchanged.
71
74
  # @return [Array<String>] the list of acceptance test paths
72
75
  def find_acceptance_tests!
73
- @acceptance_tests = acpt_test_dir.children.select { |f| f.directory? && File.exist?(File.join(f, 'goss.yaml')) }.map(&:to_s)
76
+ predicate = scan_mode? ? 'manifest.pp' : 'goss.yaml'
77
+ @acceptance_tests = acpt_test_dir.children.select { |f| f.directory? && File.exist?(File.join(f, predicate)) }.map(&:to_s)
74
78
  raise 'No acceptance tests found' if @acceptance_tests.empty?
75
79
 
76
80
  logger.info('CemAcpt') { "Found #{@acceptance_tests.size} acceptance tests" }
77
81
  end
78
82
 
83
+ def scan_mode?
84
+ @config.respond_to?(:scan_mode?) && @config.scan_mode?
85
+ end
86
+
87
+ # Resolves scanner, profile, and level for a scan-mode test case and
88
+ # stuffs them under `:scan` so Provision::Terraform can fan them out
89
+ # to the on-node scan daemon's config file. The scanner choice prefers
90
+ # the global `cem_acpt_scan.scanner` override; otherwise it derives
91
+ # from the test framework (cis -> ciscat, stig -> openscap). The
92
+ # profile id is looked up by full test-case name from the per-scanner
93
+ # config map, so this method raises a clear error early when the
94
+ # mapping is missing — better than provisioning a node and failing
95
+ # mid-scan.
96
+ def scan_post_processing!(test_data)
97
+ require_relative 'scan/errors'
98
+ framework = test_data[:framework] || test_data['framework']
99
+ override = @config.get('cem_acpt_scan.scanner')
100
+ scanner = (override || derive_scanner(framework))&.to_sym
101
+ raise "Cannot determine scanner for test '#{test_data[:test_name]}' from framework '#{framework}'" unless scanner
102
+
103
+ config_key = scanner == :ciscat ? 'cem_acpt_scan.profiles.cis_cat' : 'cem_acpt_scan.profiles.openscap'
104
+ profiles = @config.get(config_key) || {}
105
+ profile = profiles[test_data[:test_name]] || profiles[test_data[:test_name].to_sym]
106
+ raise CemAcpt::Scan::ProfileNotFoundError.new(test_data[:test_name], scanner, "#{config_key}.#{test_data[:test_name]}") if profile.nil? || profile.to_s.empty?
107
+
108
+ benchmark = nil
109
+ if scanner == :ciscat
110
+ benchmarks = @config.get('cem_acpt_scan.benchmarks.cis_cat') || {}
111
+ benchmark = benchmarks[test_data[:test_name]] || benchmarks[test_data[:test_name].to_sym]
112
+ if benchmark.nil? || benchmark.to_s.empty?
113
+ raise CemAcpt::Scan::BenchmarkNotFoundError.new(
114
+ test_data[:test_name], scanner,
115
+ "cem_acpt_scan.benchmarks.cis_cat.#{test_data[:test_name]}"
116
+ )
117
+ end
118
+ end
119
+
120
+ test_data[:scan] = {
121
+ scanner: scanner,
122
+ profile: profile,
123
+ level: test_data[:level] || test_data['level'],
124
+ benchmark: benchmark,
125
+ }
126
+ end
127
+
128
+ def derive_scanner(framework)
129
+ case framework.to_s
130
+ when 'cis' then 'ciscat'
131
+ when 'stig' then 'openscap'
132
+ end
133
+ end
134
+
79
135
  # Processes a for_each statement in the test data config.
80
136
  # @param test_data [Hash] the test data hash
81
137
  # @return [Array<Hash>] the list of test data hashes
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module CemAcpt
6
+ module TestRunner
7
+ module LogFormatter
8
+ # Formats the results of a {CemAcpt::Scan::Result}. Mirrors the shape of
9
+ # {GossActionResponse} so the runner's `process_test_results` loop
10
+ # treats scan results the same way: `summary` for the one-liner,
11
+ # `results` for the per-rule detail (used at debug only — full per-rule
12
+ # output bloats normal logs without adding decision-grade information).
13
+ class ScanResultFormatter < Base
14
+ def initialize(config, instance_names_ips, subject: nil)
15
+ super(subject)
16
+ @config = config
17
+ @instance_names_ips = instance_names_ips
18
+ end
19
+
20
+ def summary(response = nil)
21
+ super(response)
22
+ r = log_subject
23
+ status = r.pass? ? 'passed' : 'failed'
24
+ [
25
+ "SUMMARY: #{status.capitalize}: Scan #{r.test_case}:",
26
+ "scanner=#{r.scanner}",
27
+ "score=#{format('%.2f', r.score)}",
28
+ "threshold=#{format('%.2f', r.threshold)}",
29
+ "passed=#{r.to_h[:passed_count]}",
30
+ "failed=#{r.to_h[:failed_count]}",
31
+ ].join(' ')
32
+ end
33
+
34
+ # CIS-CAT Pro v4 JSON does not carry a severity field, and the
35
+ # test_runner routes log lines by past-tense prefix ("Passed:",
36
+ # "Skipped:") to choose log level. Mapping outcome → prefix here
37
+ # keeps passes at verbose, not-applicable rules at info, and
38
+ # surfaces fails/errors at error level.
39
+ OUTCOME_PREFIXES = {
40
+ 'pass' => 'Passed',
41
+ 'fail' => 'Failed',
42
+ 'notchecked' => 'Skipped',
43
+ 'notapplicable' => 'Skipped',
44
+ 'notselected' => 'Skipped',
45
+ }.freeze
46
+
47
+ def results(response = nil)
48
+ super(response)
49
+ rules = log_subject.to_h[:rules]
50
+ return [] unless rules.is_a?(Array)
51
+
52
+ rules.map do |rule|
53
+ outcome = (rule['result'] || rule[:result]).to_s.downcase
54
+ id = rule['id'] || rule[:id]
55
+ prefix = OUTCOME_PREFIXES[outcome] || outcome.capitalize
56
+ "#{prefix}: rule=#{id}"
57
+ end
58
+ end
59
+
60
+ def host_name(response = nil)
61
+ super(response)
62
+ log_subject.test_case
63
+ end
64
+
65
+ def test_name(response = nil)
66
+ super(response)
67
+ log_subject.test_case
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative 'log_formatter/bolt_summary_results_formatter'
4
4
  require_relative 'log_formatter/goss_action_response'
5
5
  require_relative 'log_formatter/goss_error_formatter'
6
+ require_relative 'log_formatter/scan_result_formatter'
6
7
  require_relative 'log_formatter/standard_error_formatter'
7
8
 
8
9
  module CemAcpt
@@ -19,10 +20,12 @@ module CemAcpt
19
20
  end
20
21
  when CemAcpt::Bolt::SummaryResults
21
22
  BoltSummaryResultsFormatter.new(*args, subject: result)
23
+ when CemAcpt::Scan::Result
24
+ ScanResultFormatter.new(*args, subject: result)
22
25
  when StandardError
23
26
  StandardErrorFormatter.new(result)
24
27
  else
25
- raise ArgumentError, "result must be a CemAcpt::Goss::Api::ActionResponse, CemAcpt::Bolt::SummaryResults, or StandardError, got #{result.class}"
28
+ raise ArgumentError, "result must be a CemAcpt::Goss::Api::ActionResponse, CemAcpt::Bolt::SummaryResults, CemAcpt::Scan::Result, or StandardError, got #{result.class}"
26
29
  end
27
30
  end
28
31
  end