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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +93 -0
- data/docs/ARCHITECTURE.md +10 -16
- data/exe/cem_acpt_scan +16 -0
- data/lib/cem_acpt/cli.rb +24 -0
- data/lib/cem_acpt/config/base.rb +10 -3
- data/lib/cem_acpt/config/cem_acpt_scan.rb +112 -0
- data/lib/cem_acpt/config.rb +1 -0
- data/lib/cem_acpt/platform/gcp.rb +6 -9
- data/lib/cem_acpt/provision/terraform/linux.rb +52 -6
- data/lib/cem_acpt/provision/terraform.rb +147 -10
- data/lib/cem_acpt/scan/daemon_client.rb +91 -0
- data/lib/cem_acpt/scan/errors.rb +44 -0
- data/lib/cem_acpt/scan/result.rb +89 -0
- data/lib/cem_acpt/scan.rb +17 -0
- data/lib/cem_acpt/test_data.rb +59 -3
- data/lib/cem_acpt/test_runner/log_formatter/scan_result_formatter.rb +72 -0
- data/lib/cem_acpt/test_runner/log_formatter.rb +4 -1
- data/lib/cem_acpt/test_runner.rb +103 -5
- data/lib/cem_acpt/version.rb +1 -1
- data/lib/cem_acpt.rb +18 -0
- data/lib/terraform/gcp/linux/main.tf +129 -1
- data/lib/terraform/gcp/linux/scan/scan_service.rb +148 -0
- data/lib/terraform/gcp/linux/scan/scan_service.service +12 -0
- data/lib/terraform/gcp/windows/main.tf +1 -1
- data/lib/terraform/image/gcp/linux/main.tf +1 -1
- data/specifications/CEM-6511.md +286 -0
- data/specifications/CEM-6720.md +187 -0
- data/specifications/CEM-6759.md +168 -0
- data/specifications/CEM-6760.md +120 -0
- data/specifications/CEM-6761.md +136 -0
- data/specifications/CEM-6762.md +163 -0
- data/specifications/CEM-6765.md +101 -0
- metadata +23 -4
- data/lib/cem_acpt/provision.rb +0 -20
|
@@ -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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
data/lib/cem_acpt/test_data.rb
CHANGED
|
@@ -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
|
-
|
|
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
|