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
data/lib/cem_acpt/test_runner.rb
CHANGED
|
@@ -7,7 +7,8 @@ require_relative 'bolt'
|
|
|
7
7
|
require_relative 'goss'
|
|
8
8
|
require_relative 'logging'
|
|
9
9
|
require_relative 'platform'
|
|
10
|
-
require_relative 'provision'
|
|
10
|
+
require_relative 'provision/terraform'
|
|
11
|
+
require_relative 'scan'
|
|
11
12
|
require_relative 'test_data'
|
|
12
13
|
require_relative 'utils'
|
|
13
14
|
require_relative 'version'
|
|
@@ -53,6 +54,7 @@ module CemAcpt
|
|
|
53
54
|
@old_dir = Dir.pwd
|
|
54
55
|
Dir.chdir(module_dir)
|
|
55
56
|
configure_actions
|
|
57
|
+
validate_scan_mode_constraints!
|
|
56
58
|
logger.start_ci_group("CemAcpt v#{CemAcpt::VERSION} run started at #{@start_time}")
|
|
57
59
|
logger.info('CemAcpt::TestRunner') { "Using module directory: #{module_dir}..." }
|
|
58
60
|
pre_provision_test_nodes
|
|
@@ -116,9 +118,24 @@ module CemAcpt
|
|
|
116
118
|
|
|
117
119
|
attr_reader :config
|
|
118
120
|
|
|
119
|
-
# Configures the actions to run based on the config
|
|
121
|
+
# Configures the actions to run based on the config. Acceptance-mode
|
|
122
|
+
# configs register the existing :goss + :bolt groups; scan-mode configs
|
|
123
|
+
# register a single :scan group instead. The dispatch keys off
|
|
124
|
+
# `config.scan_mode?`, which is overridden to true on
|
|
125
|
+
# {Config::CemAcptScan} and false everywhere else.
|
|
120
126
|
def configure_actions
|
|
121
127
|
logger.info('CemAcpt::TestRunner') { 'Configuring and registering actions...' }
|
|
128
|
+
if config.respond_to?(:scan_mode?) && config.scan_mode?
|
|
129
|
+
configure_scan_actions
|
|
130
|
+
else
|
|
131
|
+
configure_acceptance_actions
|
|
132
|
+
end
|
|
133
|
+
logger.info('CemAcpt::TestRunner') do
|
|
134
|
+
"Configured and registered actions, will run actions: #{CemAcpt::Actions.config.action_names.join(', ')}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def configure_acceptance_actions
|
|
122
139
|
CemAcpt::Actions.configure(config) do |c|
|
|
123
140
|
c.register_group(:goss, order: 0, async: true)
|
|
124
141
|
CemAcpt::Goss::Api::ACTIONS.each_key do |a|
|
|
@@ -137,8 +154,15 @@ module CemAcpt
|
|
|
137
154
|
run_bolt_tests(context)
|
|
138
155
|
end
|
|
139
156
|
end
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def configure_scan_actions
|
|
160
|
+
CemAcpt::Actions.configure(config) do |c|
|
|
161
|
+
c.register_group(:scan, order: 0).register_action(:scan) do |context|
|
|
162
|
+
context[:results] = @results
|
|
163
|
+
run_scan_action
|
|
164
|
+
context[:results]
|
|
165
|
+
end
|
|
142
166
|
end
|
|
143
167
|
end
|
|
144
168
|
|
|
@@ -224,7 +248,7 @@ module CemAcpt
|
|
|
224
248
|
|
|
225
249
|
def provision_test_nodes
|
|
226
250
|
logger.info('CemAcpt::TestRunner') { 'Provisioning test nodes...' }
|
|
227
|
-
@provisioner = CemAcpt::Provision.
|
|
251
|
+
@provisioner = CemAcpt::Provision::Terraform.new(config, @run_data)
|
|
228
252
|
@provisioner.provision
|
|
229
253
|
end
|
|
230
254
|
|
|
@@ -281,6 +305,80 @@ module CemAcpt
|
|
|
281
305
|
@results << @bolt_test_runner.results
|
|
282
306
|
end
|
|
283
307
|
|
|
308
|
+
# Per-host scan loop. For each provisioned instance, look up the test
|
|
309
|
+
# case it represents in the run's test_data, build a DaemonClient
|
|
310
|
+
# against the node, wait for the scan daemon to come up, hit /scan,
|
|
311
|
+
# and append the resulting {CemAcpt::Scan::Result} to @results. The
|
|
312
|
+
# JSON view of each result goes to stdout and to disk if
|
|
313
|
+
# `cem_acpt_scan.scan_output` is set.
|
|
314
|
+
def run_scan_action
|
|
315
|
+
logger.info('CemAcpt::TestRunner') { 'Running scan action...' }
|
|
316
|
+
port = config.get('cem_acpt_scan.daemon.port') || CemAcpt::Scan::DaemonClient::DEFAULT_PORT
|
|
317
|
+
ready_timeout = config.get('cem_acpt_scan.daemon.ready_timeout') || CemAcpt::Scan::DaemonClient::DEFAULT_READY_TIMEOUT
|
|
318
|
+
global_threshold = (config.get('cem_acpt_scan.threshold') || 80.0).to_f
|
|
319
|
+
per_case_thresholds = config.get('cem_acpt_scan.test_thresholds') || {}
|
|
320
|
+
scan_output = config.get('cem_acpt_scan.scan_output')
|
|
321
|
+
|
|
322
|
+
scan_results = []
|
|
323
|
+
@instance_names_ips.each do |_instance_name, host_data|
|
|
324
|
+
host = host_data['ip']
|
|
325
|
+
test_name = host_data['test_name']
|
|
326
|
+
scan_meta = scan_meta_for(test_name)
|
|
327
|
+
threshold = per_case_thresholds[test_name] || per_case_thresholds[test_name.to_sym] || global_threshold
|
|
328
|
+
|
|
329
|
+
client = CemAcpt::Scan::DaemonClient.new(host: host, port: port, ready_timeout: ready_timeout)
|
|
330
|
+
client.wait_until_ready
|
|
331
|
+
result = client.scan(
|
|
332
|
+
test_case: test_name,
|
|
333
|
+
scanner: scan_meta[:scanner],
|
|
334
|
+
profile: scan_meta[:profile],
|
|
335
|
+
threshold: threshold.to_f,
|
|
336
|
+
)
|
|
337
|
+
scan_results << result
|
|
338
|
+
@results << result
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
write_scan_output!(scan_output, scan_results) if scan_output
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Writes scan results to disk per the `--scan-output FILE` contract:
|
|
345
|
+
# single-case runs write `FILE`, multi-case runs fan out to
|
|
346
|
+
# `FILE.<test_case>.json` so reports don't collide.
|
|
347
|
+
def write_scan_output!(path, scan_results)
|
|
348
|
+
if scan_results.size <= 1
|
|
349
|
+
File.write(path, JSON.pretty_generate(scan_results.first&.to_h || {}))
|
|
350
|
+
else
|
|
351
|
+
scan_results.each do |r|
|
|
352
|
+
File.write("#{path}.#{r.test_case}.json", JSON.pretty_generate(r.to_h))
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Fail early in scan mode if any requested test is a Windows test or
|
|
358
|
+
# if a required CIS-CAT Pro source is missing. Catches misconfiguration
|
|
359
|
+
# before we spend time provisioning a node we cannot use.
|
|
360
|
+
def validate_scan_mode_constraints!
|
|
361
|
+
return unless config.respond_to?(:scan_mode?) && config.scan_mode?
|
|
362
|
+
|
|
363
|
+
tests = config.get('tests') || []
|
|
364
|
+
windows_tests = tests.select { |t| CemAcpt::Provision::OsData.os_family_for(t) == :windows }
|
|
365
|
+
unless windows_tests.empty?
|
|
366
|
+
raise "Windows scanning is not supported by cem_acpt_scan in this version (got: #{windows_tests.join(', ')})"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def scan_meta_for(test_name)
|
|
371
|
+
td = (@run_data[:test_data] || []).find { |t| (t[:test_name] || t['test_name']) == test_name }
|
|
372
|
+
scan_block = td && (td[:scan] || td['scan'])
|
|
373
|
+
raise "No scan metadata found for test '#{test_name}'" unless scan_block
|
|
374
|
+
|
|
375
|
+
{
|
|
376
|
+
scanner: (scan_block[:scanner] || scan_block['scanner']).to_sym,
|
|
377
|
+
profile: scan_block[:profile] || scan_block['profile'],
|
|
378
|
+
level: scan_block[:level] || scan_block['level'],
|
|
379
|
+
}
|
|
380
|
+
end
|
|
381
|
+
|
|
284
382
|
def filtered_bolt_hosts
|
|
285
383
|
tests_only = config.get('bolt.tests.only')
|
|
286
384
|
tests_only_unset = tests_only.nil? || tests_only.empty?
|
data/lib/cem_acpt/version.rb
CHANGED
data/lib/cem_acpt.rb
CHANGED
|
@@ -4,6 +4,7 @@ module CemAcpt
|
|
|
4
4
|
require_relative 'cem_acpt/config'
|
|
5
5
|
require_relative 'cem_acpt/logging'
|
|
6
6
|
require_relative 'cem_acpt/image_builder'
|
|
7
|
+
require_relative 'cem_acpt/scan'
|
|
7
8
|
require_relative 'cem_acpt/test_runner'
|
|
8
9
|
require_relative 'cem_acpt/version'
|
|
9
10
|
|
|
@@ -34,6 +35,8 @@ module CemAcpt
|
|
|
34
35
|
trace_it(options[:trace], options[:trace_events]) { run_cem_acpt(options) }
|
|
35
36
|
when :cem_acpt_image
|
|
36
37
|
trace_it(options[:trace], options[:trace_events]) { run_cem_acpt_image(options) }
|
|
38
|
+
when :cem_acpt_scan
|
|
39
|
+
trace_it(options[:trace], options[:trace_events]) { run_cem_acpt_scan(options) }
|
|
37
40
|
else
|
|
38
41
|
raise "Command #{command} does not exist"
|
|
39
42
|
end
|
|
@@ -47,6 +50,8 @@ module CemAcpt
|
|
|
47
50
|
CemAcpt::Config::CemAcpt.new(opts: options, config_file: options[:config_file])
|
|
48
51
|
when :cem_acpt_image
|
|
49
52
|
CemAcpt::Config::CemAcptImage.new(opts: options, config_file: options[:config_file])
|
|
53
|
+
when :cem_acpt_scan
|
|
54
|
+
CemAcpt::Config::CemAcptScan.new(opts: options, config_file: options[:config_file])
|
|
50
55
|
else
|
|
51
56
|
raise "Config does not exist for command: #{command}"
|
|
52
57
|
end
|
|
@@ -157,5 +162,18 @@ module CemAcpt
|
|
|
157
162
|
logger.debug('CemAcptImage') { 'Building images...' }
|
|
158
163
|
CemAcpt::ImageBuilder.build_images(@config)
|
|
159
164
|
end
|
|
165
|
+
|
|
166
|
+
def run_cem_acpt_scan(options)
|
|
167
|
+
@config = new_config(options, command: :cem_acpt_scan)
|
|
168
|
+
initialize_logger!
|
|
169
|
+
logger.debug('CemAcptScan') { 'Config set and logger initialized...' }
|
|
170
|
+
runner = new_runner
|
|
171
|
+
logger.debug('CemAcptScan') { 'Runner initialized in scan mode...' }
|
|
172
|
+
|
|
173
|
+
logger.debug('CemAcptScan') { 'Running scan suite...' }
|
|
174
|
+
runner.run
|
|
175
|
+
logger.debug('CemAcptScan') { "Scan suite run complete, exiting with code #{runner.exit_code}" }
|
|
176
|
+
exit runner.exit_code
|
|
177
|
+
end
|
|
160
178
|
end
|
|
161
179
|
end
|
|
@@ -2,7 +2,7 @@ terraform {
|
|
|
2
2
|
required_providers {
|
|
3
3
|
google = {
|
|
4
4
|
source = "hashicorp/google"
|
|
5
|
-
version = "7.
|
|
5
|
+
version = "7.31.0"
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -56,6 +56,21 @@ variable "node_data" {
|
|
|
56
56
|
provision_dir_source = string
|
|
57
57
|
provision_dir_dest = string
|
|
58
58
|
provision_commands = list(string)
|
|
59
|
+
# Scan-mode fields. Empty strings in acceptance mode; populated by
|
|
60
|
+
# cem_acpt_scan to upload the CIS-CAT Pro bundle, the CIS-CAT Pro
|
|
61
|
+
# license, and the scan daemon config. The scan_config_upload and
|
|
62
|
+
# cis_cat_pro_upload null_resources below filter on these.
|
|
63
|
+
# cis_cat_pro_format and cis_cat_pro_license_format are each "tar.gz"
|
|
64
|
+
# or "zip" depending on what the vendor ships; the cis_cat_pro_upload
|
|
65
|
+
# null_resource dispatches to the matching extractor for each. Ruby
|
|
66
|
+
# always populates the *_format fields alongside their *_bundle /
|
|
67
|
+
# *_license paths via archive_format, so the empty default on
|
|
68
|
+
# cis_cat_pro_license_format is purely defensive.
|
|
69
|
+
cis_cat_pro_bundle = optional(string, "")
|
|
70
|
+
cis_cat_pro_format = optional(string, "tar.gz")
|
|
71
|
+
cis_cat_pro_license = optional(string, "")
|
|
72
|
+
cis_cat_pro_license_format = optional(string, "")
|
|
73
|
+
scan_config_json = optional(string, "")
|
|
59
74
|
}))
|
|
60
75
|
}
|
|
61
76
|
|
|
@@ -202,6 +217,119 @@ resource "google_compute_instance" "acpt-test-node" {
|
|
|
202
217
|
tags = ["cem-acpt-test-node"]
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
# Scan-mode config upload. for_each filters to nodes whose node_data
|
|
221
|
+
# carries a non-empty scan_config_json, so this resource is a no-op for
|
|
222
|
+
# cem_acpt acceptance runs and for any scan-mode node that does not need
|
|
223
|
+
# scan_config.json materialized.
|
|
224
|
+
resource "null_resource" "scan_config_upload" {
|
|
225
|
+
for_each = {
|
|
226
|
+
for k, v in var.node_data : k => v if v.scan_config_json != ""
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
triggers = {
|
|
230
|
+
instance_id = google_compute_instance.acpt-test-node[each.key].id
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
connection {
|
|
234
|
+
type = "ssh"
|
|
235
|
+
user = var.username
|
|
236
|
+
timeout = "5m"
|
|
237
|
+
host = google_compute_instance.acpt-test-node[each.key].network_interface.0.access_config.0.nat_ip
|
|
238
|
+
port = 22
|
|
239
|
+
private_key = file(var.private_key)
|
|
240
|
+
agent = false
|
|
241
|
+
script_path = "/var/tmp/terraform_%RAND%.sh"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
provisioner "remote-exec" {
|
|
245
|
+
inline = [
|
|
246
|
+
"sudo mkdir -p ${each.value.provision_dir_dest}/scan",
|
|
247
|
+
"sudo chown -R ${var.username}:${var.username} ${each.value.provision_dir_dest}/scan",
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
provisioner "file" {
|
|
252
|
+
content = each.value.scan_config_json
|
|
253
|
+
destination = "${each.value.provision_dir_dest}/scan/scan_config.json"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# CIS-CAT Pro bundle upload. Filtered separately so OpenSCAP-only scan
|
|
258
|
+
# runs do not require a CIS-CAT Pro bundle to be present.
|
|
259
|
+
resource "null_resource" "cis_cat_pro_upload" {
|
|
260
|
+
for_each = {
|
|
261
|
+
for k, v in var.node_data : k => v if v.cis_cat_pro_bundle != ""
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
triggers = {
|
|
265
|
+
instance_id = google_compute_instance.acpt-test-node[each.key].id
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
connection {
|
|
269
|
+
type = "ssh"
|
|
270
|
+
user = var.username
|
|
271
|
+
timeout = "10m"
|
|
272
|
+
host = google_compute_instance.acpt-test-node[each.key].network_interface.0.access_config.0.nat_ip
|
|
273
|
+
port = 22
|
|
274
|
+
private_key = file(var.private_key)
|
|
275
|
+
agent = false
|
|
276
|
+
script_path = "/var/tmp/terraform_%RAND%.sh"
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
provisioner "file" {
|
|
280
|
+
source = each.value.cis_cat_pro_bundle
|
|
281
|
+
destination = "${each.value.provision_dir_dest}/cis-cat-pro.${each.value.cis_cat_pro_format}"
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# Extract immediately so /opt/cis-cat-pro/Assessor-CLI.sh exists by the time
|
|
285
|
+
# the host-side scan daemon hits /scan. Provisioners within a single
|
|
286
|
+
# resource run sequentially, so the file is guaranteed to be on disk before
|
|
287
|
+
# the extraction commands run. The vendor packages a single top-level
|
|
288
|
+
# Assessor-CLI-<version>/ directory in either format; tar's
|
|
289
|
+
# --strip-components=1 strips it directly, while the zip path stages into a
|
|
290
|
+
# temp dir and moves the inner directory's contents (including dotfiles via
|
|
291
|
+
# `shopt -s dotglob`) up into /opt/cis-cat-pro/.
|
|
292
|
+
provisioner "remote-exec" {
|
|
293
|
+
inline = each.value.cis_cat_pro_format == "zip" ? [
|
|
294
|
+
"sudo dnf install -y unzip || sudo apt-get install -y unzip",
|
|
295
|
+
"sudo mkdir -p /opt/cis-cat-pro",
|
|
296
|
+
"sudo rm -rf /tmp/cis-cat-pro-extract",
|
|
297
|
+
"sudo unzip -q -o ${each.value.provision_dir_dest}/cis-cat-pro.zip -d /tmp/cis-cat-pro-extract",
|
|
298
|
+
"sudo bash -c 'shopt -s dotglob && mv /tmp/cis-cat-pro-extract/*/* /opt/cis-cat-pro/'",
|
|
299
|
+
"sudo rm -rf /tmp/cis-cat-pro-extract",
|
|
300
|
+
"sudo chmod +x /opt/cis-cat-pro/Assessor-CLI.sh",
|
|
301
|
+
] : [
|
|
302
|
+
"sudo mkdir -p /opt/cis-cat-pro",
|
|
303
|
+
"sudo tar -xzf ${each.value.provision_dir_dest}/cis-cat-pro.tar.gz -C /opt/cis-cat-pro --strip-components=1",
|
|
304
|
+
"sudo chmod +x /opt/cis-cat-pro/Assessor-CLI.sh",
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# License upload + extract. Provisioners 1 and 2 (above) place the
|
|
309
|
+
# assessor at /opt/cis-cat-pro/Assessor-CLI.sh; provisioners 3 and 4
|
|
310
|
+
# (below) place the license bundle's contents at /opt/cis-cat-pro/license/.
|
|
311
|
+
# The vendor's license archive contents are dropped in as-packaged: tar
|
|
312
|
+
# uses no --strip-components and zip extracts straight into the license
|
|
313
|
+
# dir, so any subdirectories the vendor ships are preserved. Permissions
|
|
314
|
+
# are left at the archive defaults until a real end-to-end scan tells us
|
|
315
|
+
# what the assessor actually wants.
|
|
316
|
+
provisioner "file" {
|
|
317
|
+
source = each.value.cis_cat_pro_license
|
|
318
|
+
destination = "${each.value.provision_dir_dest}/cis-cat-pro-license.${each.value.cis_cat_pro_license_format}"
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
provisioner "remote-exec" {
|
|
322
|
+
inline = each.value.cis_cat_pro_license_format == "zip" ? [
|
|
323
|
+
"sudo dnf install -y unzip || sudo apt-get install -y unzip",
|
|
324
|
+
"sudo mkdir -p /opt/cis-cat-pro/license",
|
|
325
|
+
"sudo unzip -q -o ${each.value.provision_dir_dest}/cis-cat-pro-license.zip -d /opt/cis-cat-pro/license",
|
|
326
|
+
] : [
|
|
327
|
+
"sudo mkdir -p /opt/cis-cat-pro/license",
|
|
328
|
+
"sudo tar -xzf ${each.value.provision_dir_dest}/cis-cat-pro-license.tar.gz -C /opt/cis-cat-pro/license",
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
205
333
|
output "instance_name_ip" {
|
|
206
334
|
value = {
|
|
207
335
|
for k, v in google_compute_instance.acpt-test-node : v.name => {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# On-node scan daemon installed by cem_acpt_scan. Runs as a systemd unit
|
|
5
|
+
# and exposes /health and /scan endpoints over HTTP. The host-side
|
|
6
|
+
# CemAcpt::Scan::DaemonClient hits these endpoints to drive the scan and
|
|
7
|
+
# pull back results.
|
|
8
|
+
#
|
|
9
|
+
# scan_config.json is uploaded to /opt/cem_acpt/scan/scan_config.json by the
|
|
10
|
+
# scan_config_upload null_resource in main.tf and carries the resolved
|
|
11
|
+
# scanner choice, profile id, and level for the test case running on this
|
|
12
|
+
# node. The daemon reads it on each /scan call so reruns pick up rewritten
|
|
13
|
+
# config without restart.
|
|
14
|
+
|
|
15
|
+
require 'fileutils'
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'open3'
|
|
18
|
+
require 'rexml/document'
|
|
19
|
+
require 'webrick'
|
|
20
|
+
|
|
21
|
+
CONFIG_PATH = '/opt/cem_acpt/scan/scan_config.json'
|
|
22
|
+
REPORT_DIR = '/opt/cem_acpt/scan/reports'
|
|
23
|
+
FileUtils.mkdir_p(REPORT_DIR)
|
|
24
|
+
|
|
25
|
+
def load_config
|
|
26
|
+
JSON.parse(File.read(CONFIG_PATH))
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
{ 'error' => "Cannot read #{CONFIG_PATH}: #{e.class}: #{e.message}" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_openscap(profile, datastream)
|
|
32
|
+
xml_out = File.join(REPORT_DIR, 'openscap-results.xml')
|
|
33
|
+
html_out = File.join(REPORT_DIR, 'openscap-results.html')
|
|
34
|
+
cmd = [
|
|
35
|
+
'sudo', 'oscap', 'xccdf', 'eval',
|
|
36
|
+
'--profile', profile,
|
|
37
|
+
'--results', xml_out,
|
|
38
|
+
'--report', html_out,
|
|
39
|
+
datastream,
|
|
40
|
+
]
|
|
41
|
+
_stdout, stderr, status = Open3.capture3(*cmd)
|
|
42
|
+
parsed = parse_openscap_xml(xml_out)
|
|
43
|
+
unless status.success? || File.exist?(xml_out)
|
|
44
|
+
oscap_msg = "oscap exited #{status.exitstatus}: #{stderr.strip}"
|
|
45
|
+
parsed['error'] = parsed['error'] ? "#{oscap_msg}; #{parsed['error']}" : oscap_msg
|
|
46
|
+
end
|
|
47
|
+
parsed
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_openscap_xml(path)
|
|
51
|
+
return { 'error' => "Result file missing: #{path}" } unless File.exist?(path)
|
|
52
|
+
|
|
53
|
+
doc = REXML::Document.new(File.read(path))
|
|
54
|
+
results = REXML::XPath.match(doc, '//rule-result').map do |r|
|
|
55
|
+
{
|
|
56
|
+
'id' => r.attributes['idref'],
|
|
57
|
+
'severity' => r.attributes['severity'],
|
|
58
|
+
'result' => REXML::XPath.first(r, 'result')&.text,
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
score_el = REXML::XPath.first(doc, '//score')
|
|
62
|
+
score = score_el ? score_el.text.to_f : 0.0
|
|
63
|
+
counts = results.group_by { |r| r['result'] }.transform_values(&:size)
|
|
64
|
+
{
|
|
65
|
+
'score' => score,
|
|
66
|
+
'passed_count' => counts['pass'] || 0,
|
|
67
|
+
'failed_count' => counts['fail'] || 0,
|
|
68
|
+
'not_applicable_count' => (counts['notapplicable'] || 0) + (counts['notselected'] || 0),
|
|
69
|
+
'error_count' => (counts['error'] || 0) + (counts['unknown'] || 0),
|
|
70
|
+
'rules' => results,
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_ciscat(profile, benchmark)
|
|
75
|
+
json_out = File.join(REPORT_DIR, 'ciscat-results.json')
|
|
76
|
+
cmd = [
|
|
77
|
+
'sudo', '/opt/cis-cat-pro/Assessor-CLI.sh',
|
|
78
|
+
'-rd', REPORT_DIR,
|
|
79
|
+
'-rp', 'ciscat-results',
|
|
80
|
+
'-nts',
|
|
81
|
+
'-json',
|
|
82
|
+
'-p', profile,
|
|
83
|
+
]
|
|
84
|
+
cmd += ['-b', "/opt/cis-cat-pro/benchmarks/#{benchmark}"] if benchmark
|
|
85
|
+
_stdout, stderr, status = Open3.capture3(*cmd)
|
|
86
|
+
parsed = parse_ciscat_json(json_out)
|
|
87
|
+
unless status.success? || File.exist?(json_out)
|
|
88
|
+
assessor_msg = "Assessor-CLI exited #{status.exitstatus}: #{stderr.strip}"
|
|
89
|
+
parsed['error'] = parsed['error'] ? "#{assessor_msg}; #{parsed['error']}" : assessor_msg
|
|
90
|
+
end
|
|
91
|
+
parsed
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_ciscat_json(path)
|
|
95
|
+
return { 'error' => "Result file missing: #{path}" } unless File.exist?(path)
|
|
96
|
+
|
|
97
|
+
raw = JSON.parse(File.read(path))
|
|
98
|
+
results = (raw['rules'] || []).map do |r|
|
|
99
|
+
{
|
|
100
|
+
'id' => r['rule-id'],
|
|
101
|
+
'result' => r['result'],
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
score = (raw['score'] || 0.0).to_f
|
|
105
|
+
counts = results.group_by { |r| (r['result'] || '').to_s.downcase }.transform_values(&:size)
|
|
106
|
+
{
|
|
107
|
+
'score' => score,
|
|
108
|
+
'passed_count' => counts['pass'] || 0,
|
|
109
|
+
'failed_count' => counts['fail'] || 0,
|
|
110
|
+
'not_applicable_count' => (counts['notchecked'] || 0) + (counts['notapplicable'] || 0),
|
|
111
|
+
'error_count' => (counts['error'] || 0) + (counts['informational'] || 0),
|
|
112
|
+
'rules' => results,
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def perform_scan
|
|
117
|
+
cfg = load_config
|
|
118
|
+
return cfg if cfg['error']
|
|
119
|
+
|
|
120
|
+
case cfg['scanner']
|
|
121
|
+
when 'openscap'
|
|
122
|
+
run_openscap(cfg['profile'], cfg['datastream'] || '/usr/share/xml/scap/ssg/content/ssg-rhel8-ds.xml')
|
|
123
|
+
when 'ciscat'
|
|
124
|
+
run_ciscat(cfg['profile'], cfg['benchmark'])
|
|
125
|
+
else
|
|
126
|
+
{ 'error' => "Unknown scanner '#{cfg['scanner']}'" }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
port = ENV.fetch('SCAN_DAEMON_PORT', '8084').to_i
|
|
131
|
+
server = WEBrick::HTTPServer.new(Port: port, BindAddress: '0.0.0.0')
|
|
132
|
+
|
|
133
|
+
server.mount_proc('/health') do |_req, res|
|
|
134
|
+
res.status = 200
|
|
135
|
+
res['Content-Type'] = 'application/json'
|
|
136
|
+
res.body = JSON.generate('status' => 'ok')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
server.mount_proc('/scan') do |_req, res|
|
|
140
|
+
payload = perform_scan
|
|
141
|
+
res.status = payload['error'] ? 500 : 200
|
|
142
|
+
res['Content-Type'] = 'application/json'
|
|
143
|
+
res.body = JSON.generate(payload)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
trap('INT') { server.shutdown }
|
|
147
|
+
trap('TERM') { server.shutdown }
|
|
148
|
+
server.start
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=cem_acpt scan daemon
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
Environment=SCAN_DAEMON_PORT=8084
|
|
8
|
+
ExecStart=/opt/puppetlabs/puppet/bin/ruby /opt/cem_acpt/scan/scan_service.rb
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
|
|
11
|
+
[Install]
|
|
12
|
+
WantedBy=multi-user.target
|