foreman_cve_scanner 0.6.1 → 0.7.1
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/README.md +22 -4
- data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
- data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
- data/app/models/host_status/cve_status.rb +4 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
- data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
- data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
- data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
- data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
- data/config/routes.rb +1 -0
- data/lib/foreman_cve_scanner/engine.rb +28 -19
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +97 -1
- data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
- data/test/models/host_status/cve_status_test.rb +8 -0
- data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
- data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
- data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
- data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
- data/webpack/components/CveCompareModal.js +9 -28
- data/webpack/components/CveDetailsCard.js +54 -65
- data/webpack/components/CveFindingsModal.js +15 -234
- data/webpack/components/CveFindingsView.js +307 -0
- data/webpack/components/CveKatelloFixLink.js +59 -0
- data/webpack/components/CveLink.js +28 -0
- data/webpack/components/CveScansReports.js +25 -62
- data/webpack/components/CveScansTab.js +42 -9
- data/webpack/components/CveSummaryCell.js +31 -19
- data/webpack/components/CveTrendChart.js +1 -16
- data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
- data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
- data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
- data/webpack/components/__tests__/CveScansTab.test.js +141 -147
- data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
- data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
- data/webpack/components/__tests__/cve_helpers.test.js +84 -0
- data/webpack/components/cve_helpers.js +114 -0
- data/webpack/components/cve_scans.scss +49 -0
- data/webpack/components/cve_summary_store.js +91 -0
- data/webpack/components/useSortBy.js +34 -0
- metadata +24 -4
|
@@ -5,80 +5,117 @@ description_format: 'Install CVE scanners on a host'
|
|
|
5
5
|
model: JobTemplate
|
|
6
6
|
job_category: Security
|
|
7
7
|
provider_type: script
|
|
8
|
+
foreign_input_sets:
|
|
9
|
+
- template: Package Action - Script Default
|
|
10
|
+
exclude: action,package
|
|
8
11
|
template_inputs:
|
|
9
|
-
- name:
|
|
10
|
-
description: '
|
|
11
|
-
required:
|
|
12
|
+
- name: install_from_github
|
|
13
|
+
description: 'Install scanner release packages directly from GitHub'
|
|
14
|
+
required: false
|
|
12
15
|
input_type: user
|
|
13
16
|
advanced: false
|
|
14
|
-
|
|
17
|
+
value_type: plain
|
|
18
|
+
hidden_value: false
|
|
19
|
+
default: "false"
|
|
20
|
+
options: "true\r\nfalse"
|
|
21
|
+
- name: trivy_version
|
|
22
|
+
description: 'Trivy version to install from GitHub'
|
|
23
|
+
required: false
|
|
24
|
+
input_type: user
|
|
25
|
+
advanced: true
|
|
26
|
+
value_type: plain
|
|
27
|
+
hidden_value: false
|
|
28
|
+
default: "0.71.2"
|
|
15
29
|
- name: grype_version
|
|
16
|
-
description: 'Grype version to install'
|
|
17
|
-
required:
|
|
30
|
+
description: 'Grype version to install from GitHub'
|
|
31
|
+
required: false
|
|
18
32
|
input_type: user
|
|
19
|
-
advanced:
|
|
20
|
-
|
|
33
|
+
advanced: true
|
|
34
|
+
value_type: plain
|
|
35
|
+
hidden_value: false
|
|
36
|
+
default: "0.115.0"
|
|
21
37
|
- name: scanner_to_install
|
|
22
38
|
description: 'Choose which scanner to install (Default: both)'
|
|
23
39
|
required: false
|
|
24
40
|
input_type: user
|
|
25
41
|
advanced: false
|
|
26
|
-
|
|
42
|
+
value_type: plain
|
|
43
|
+
hidden_value: false
|
|
44
|
+
default: "both"
|
|
27
45
|
options: "both\r\ntrivy\r\ngrype"
|
|
28
46
|
%>
|
|
29
|
-
# Install CVE scanners from github
|
|
30
47
|
<%
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
install_from_github = input('install_from_github') == 'true'
|
|
49
|
+
scanner_to_install = input('scanner_to_install').present? ? input('scanner_to_install') : 'both'
|
|
50
|
+
trivy_version = input('trivy_version').present? ? input('trivy_version') : '0.71.2'
|
|
51
|
+
grype_version = input('grype_version').present? ? input('grype_version') : '0.115.0'
|
|
52
|
+
os_family = @host.operatingsystem.family
|
|
33
53
|
|
|
34
|
-
case
|
|
35
|
-
when '
|
|
36
|
-
|
|
37
|
-
when '
|
|
38
|
-
|
|
39
|
-
else
|
|
40
|
-
|
|
41
|
-
end
|
|
54
|
+
selected_scanners = case scanner_to_install
|
|
55
|
+
when 'trivy'
|
|
56
|
+
['trivy']
|
|
57
|
+
when 'grype'
|
|
58
|
+
['grype']
|
|
59
|
+
else
|
|
60
|
+
%w[trivy grype]
|
|
61
|
+
end
|
|
62
|
+
package_list = selected_scanners.join(' ')
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
when '
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
grype_arch = 'linux_arm64'
|
|
53
|
-
else
|
|
54
|
-
raise("Architecture '#{@host.architecture}' not supported by template #{template_name}")
|
|
55
|
-
end
|
|
64
|
+
if install_from_github
|
|
65
|
+
case os_family
|
|
66
|
+
when 'Debian'
|
|
67
|
+
pkg = 'deb'
|
|
68
|
+
when 'Redhat', 'Suse'
|
|
69
|
+
pkg = 'rpm'
|
|
70
|
+
else
|
|
71
|
+
raise("OS '#{@host.operatingsystem.family}' not supported by template #{template_name}")
|
|
72
|
+
end
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
case @host.architecture.name
|
|
75
|
+
when 'x86_64'
|
|
76
|
+
trivy_arch = 'Linux-64bit'
|
|
77
|
+
grype_arch = 'linux_amd64'
|
|
78
|
+
when 'ppc64le'
|
|
79
|
+
trivy_arch = 'Linux-PPC64LE'
|
|
80
|
+
grype_arch = 'linux_ppc64le'
|
|
81
|
+
when 'aarch64'
|
|
82
|
+
trivy_arch = 'Linux-ARM64'
|
|
83
|
+
grype_arch = 'linux_arm64'
|
|
84
|
+
else
|
|
85
|
+
raise("Architecture '#{@host.architecture}' not supported by template #{template_name}")
|
|
86
|
+
end
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
trivy_url = "https://github.com/aquasecurity/trivy/releases/download/v#{trivy_version}/trivy_#{trivy_version}_#{trivy_arch}.#{pkg}"
|
|
89
|
+
grype_url = "https://github.com/anchore/grype/releases/download/v#{grype_version}/grype_#{grype_version}_#{grype_arch}.#{pkg}"
|
|
90
|
+
|
|
91
|
+
case os_family
|
|
92
|
+
when 'Debian'
|
|
93
|
+
install_steps = {
|
|
94
|
+
'trivy' => [
|
|
95
|
+
"wget -O /tmp/trivy.deb \"#{trivy_url}\"",
|
|
96
|
+
'dpkg -i /tmp/trivy.deb',
|
|
97
|
+
'rm -f /tmp/trivy.deb'
|
|
98
|
+
],
|
|
99
|
+
'grype' => [
|
|
100
|
+
"wget -O /tmp/grype.deb \"#{grype_url}\"",
|
|
101
|
+
'dpkg -i /tmp/grype.deb',
|
|
102
|
+
'rm -f /tmp/grype.deb'
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
when 'Redhat', 'Suse'
|
|
106
|
+
install_steps = {
|
|
107
|
+
'trivy' => ["rpm -ivh \"#{trivy_url}\""],
|
|
108
|
+
'grype' => ["rpm -ivh \"#{grype_url}\""]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
71
111
|
end
|
|
72
112
|
-%>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
<% unless install_from_github -%>
|
|
114
|
+
<%= render_template('Package Action - Script Default', :action => 'install', :package => package_list) %>
|
|
115
|
+
<% else -%>
|
|
116
|
+
<% selected_scanners.each do |scanner| -%>
|
|
117
|
+
<% install_steps[scanner].each do |step| -%>
|
|
118
|
+
<%= step %>
|
|
119
|
+
<% end -%>
|
|
120
|
+
<% end -%>
|
|
78
121
|
<% end -%>
|
|
79
|
-
|
|
80
|
-
<% if input('scanner_to_install') == 'both' || input('scanner_to_install') == 'grype' -%>
|
|
81
|
-
<%= grype_download_cmd unless grype_download_cmd.nil? %>
|
|
82
|
-
<%= grype_install_cmd %>
|
|
83
|
-
<%= grype_cleanup_cmd unless grype_cleanup_cmd.nil? %>
|
|
84
|
-
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -6,6 +6,7 @@ Rails.application.routes.draw do
|
|
|
6
6
|
defaults: { apiv: 'v2' },
|
|
7
7
|
apiv: /v1|v2/,
|
|
8
8
|
constraints: ApiConstraints.new(version: 2, default: true) do
|
|
9
|
+
get 'cve_scans/latest_by_hosts', to: 'cve_scans#latest_by_hosts'
|
|
9
10
|
constraints(host_id: %r{[^/]+}) do
|
|
10
11
|
resources :hosts, only: [] do
|
|
11
12
|
resources :cve_scans, only: %i[index show destroy] do
|
|
@@ -71,32 +71,41 @@ module ForemanCveScanner
|
|
|
71
71
|
def self.register_settings(plugin)
|
|
72
72
|
plugin.settings do
|
|
73
73
|
category :foreman_cve_scanner, N_('CVE Scanner') do
|
|
74
|
-
|
|
75
|
-
type: :string,
|
|
76
|
-
default: 'trivy',
|
|
77
|
-
full_name: N_('Preferred CVE scanner'),
|
|
78
|
-
description: N_('Default scanner used by the Run CVE scan job template.')
|
|
79
|
-
setting 'run_cve_scan_after_host_profiles_upload',
|
|
80
|
-
type: :boolean,
|
|
81
|
-
default: false,
|
|
82
|
-
full_name: N_('Run CVE scan after host profiles upload'),
|
|
83
|
-
description: N_('When Katello is installed, schedule a CVE scan after a host profiles upload completes.')
|
|
84
|
-
setting 'cve_scan_delete_after_days',
|
|
85
|
-
type: :integer,
|
|
86
|
-
default: 90,
|
|
87
|
-
full_name: N_('Delete CVE scans after X days'),
|
|
88
|
-
description: N_(
|
|
89
|
-
'Delete CVE scans older than the configured number of days. ' \
|
|
90
|
-
'Set to 0 to disable automatic cleanup.'
|
|
91
|
-
)
|
|
74
|
+
ForemanCveScanner::Engine.register_cve_scanner_settings(self)
|
|
92
75
|
end
|
|
93
76
|
end
|
|
94
77
|
end
|
|
95
78
|
|
|
79
|
+
def self.register_cve_scanner_settings(settings)
|
|
80
|
+
settings.setting 'preferred_cve_scanner',
|
|
81
|
+
type: :string,
|
|
82
|
+
default: 'trivy',
|
|
83
|
+
full_name: N_('Preferred CVE scanner'),
|
|
84
|
+
description: N_('Default scanner used by the Run CVE scan job template.')
|
|
85
|
+
settings.setting 'run_cve_scan_after_host_profiles_upload',
|
|
86
|
+
type: :boolean,
|
|
87
|
+
default: false,
|
|
88
|
+
full_name: N_('Run CVE scan after host profiles upload'),
|
|
89
|
+
description: N_('When Katello is installed, schedule a CVE scan after a host profiles upload completes.')
|
|
90
|
+
settings.setting 'enable_katello_cve_fix_availability',
|
|
91
|
+
type: :boolean,
|
|
92
|
+
default: true,
|
|
93
|
+
full_name: N_('Show CVE fix availability from content'),
|
|
94
|
+
description: N_('When Katello is installed, enrich the latest CVE scan with managed errata fix availability.')
|
|
95
|
+
settings.setting 'cve_scan_delete_after_days',
|
|
96
|
+
type: :integer,
|
|
97
|
+
default: 90,
|
|
98
|
+
full_name: N_('Delete CVE scans after X days'),
|
|
99
|
+
description: N_(
|
|
100
|
+
'Delete CVE scans older than the configured number of days. ' \
|
|
101
|
+
'Set to 0 to disable automatic cleanup.'
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
96
105
|
def self.register_permissions(plugin)
|
|
97
106
|
plugin.security_block :foreman_cve_scanner do
|
|
98
107
|
permission :view_cve_scans,
|
|
99
|
-
{ 'api/v2/cve_scans': %i[index latest show export compare] },
|
|
108
|
+
{ 'api/v2/cve_scans': %i[index latest latest_by_hosts show export compare] },
|
|
100
109
|
resource_type: 'Host'
|
|
101
110
|
permission :import_cve_scans,
|
|
102
111
|
{ 'api/v2/cve_scans': %i[import] },
|
|
@@ -7,10 +7,15 @@ module Api
|
|
|
7
7
|
class CveScansControllerTest < ActionController::TestCase
|
|
8
8
|
def setup
|
|
9
9
|
@host = FactoryBot.create(:host)
|
|
10
|
+
@previous_retention = Setting[:cve_scan_delete_after_days]
|
|
10
11
|
@scan_old = create_scan(created_at: 2.hours.ago, scanned_at: 2.hours.ago, total: 1, low: 1)
|
|
11
12
|
@scan_new = create_scan(created_at: 1.hour.ago, scanned_at: 1.hour.ago, total: 2, high: 2)
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
def teardown
|
|
16
|
+
Setting[:cve_scan_delete_after_days] = @previous_retention
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
test 'index returns scans for host' do
|
|
15
20
|
get :index, params: { host_id: @host.id }
|
|
16
21
|
assert_response :success
|
|
@@ -26,6 +31,63 @@ module Api
|
|
|
26
31
|
assert_equal @scan_new.id, body['id']
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
test 'latest_by_hosts returns latest scan summary for each requested host' do
|
|
35
|
+
other_host = FactoryBot.create(:host)
|
|
36
|
+
other_scan = create_scan(
|
|
37
|
+
host: other_host,
|
|
38
|
+
created_at: 30.minutes.ago,
|
|
39
|
+
scanned_at: 30.minutes.ago,
|
|
40
|
+
total: 3,
|
|
41
|
+
medium: 3
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
get :latest_by_hosts, params: { host_ids: [@host.id, other_host.id] }
|
|
45
|
+
|
|
46
|
+
assert_response :success
|
|
47
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
48
|
+
results = body['results'].index_by { |row| row['host_id'] }
|
|
49
|
+
|
|
50
|
+
assert_equal @scan_new.id, results[@host.id]['id']
|
|
51
|
+
assert_equal other_scan.id, results[other_host.id]['id']
|
|
52
|
+
assert_equal 2, results[@host.id]['total']
|
|
53
|
+
assert_equal 3, results[other_host.id]['total']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test 'latest_by_hosts ignores duplicate and unknown host ids' do
|
|
57
|
+
get :latest_by_hosts, params: { host_ids: [@host.id, @host.id, 'nope', 999_999] }
|
|
58
|
+
|
|
59
|
+
assert_response :success
|
|
60
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
61
|
+
|
|
62
|
+
assert_equal 1, body['results'].size
|
|
63
|
+
assert_equal @host.id, body['results'][0]['host_id']
|
|
64
|
+
assert_equal @scan_new.id, body['results'][0]['id']
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
test 'latest_by_hosts omits requested hosts that have no scans' do
|
|
68
|
+
host_without_scan = FactoryBot.create(:host)
|
|
69
|
+
|
|
70
|
+
get :latest_by_hosts, params: { host_ids: [@host.id, host_without_scan.id] }
|
|
71
|
+
|
|
72
|
+
assert_response :success
|
|
73
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
74
|
+
|
|
75
|
+
assert_equal 1, body['results'].size
|
|
76
|
+
assert_equal @host.id, body['results'][0]['host_id']
|
|
77
|
+
assert_equal @scan_new.id, body['results'][0]['id']
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test 'latest_by_hosts returns empty results when requested hosts have no scans' do
|
|
81
|
+
ForemanCveScanner::CveScan.delete_all
|
|
82
|
+
|
|
83
|
+
get :latest_by_hosts, params: { host_ids: [@host.id] }
|
|
84
|
+
|
|
85
|
+
assert_response :success
|
|
86
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
87
|
+
|
|
88
|
+
assert_empty body['results']
|
|
89
|
+
end
|
|
90
|
+
|
|
29
91
|
test 'show returns scan by id' do
|
|
30
92
|
get :show, params: { host_id: @host.id, id: @scan_old.id }
|
|
31
93
|
assert_response :success
|
|
@@ -33,6 +95,14 @@ module Api
|
|
|
33
95
|
assert_equal @scan_old.id, body['id']
|
|
34
96
|
end
|
|
35
97
|
|
|
98
|
+
test 'show does not include fix availability data' do
|
|
99
|
+
get :show, params: { host_id: @host.id, id: @scan_old.id }
|
|
100
|
+
|
|
101
|
+
assert_response :success
|
|
102
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
103
|
+
assert_not body['findings'].first.key?('katello_fix')
|
|
104
|
+
end
|
|
105
|
+
|
|
36
106
|
test 'import imports external scan for host' do
|
|
37
107
|
assert_difference('ForemanCveScanner::CveScan.count', 1) do
|
|
38
108
|
post :import, params: {
|
|
@@ -47,6 +117,32 @@ module Api
|
|
|
47
117
|
assert_equal 1, body['critical']
|
|
48
118
|
end
|
|
49
119
|
|
|
120
|
+
test 'import refreshes cve host status' do
|
|
121
|
+
assert_difference('HostStatus::CveStatus.count', 1) do
|
|
122
|
+
post :import, params: {
|
|
123
|
+
host_id: @host.id,
|
|
124
|
+
cve_scan: create_payload,
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
assert_response :created
|
|
129
|
+
status = HostStatus::CveStatus.find_by(host: @host)
|
|
130
|
+
assert_equal HostStatus::CveStatus::CVE_SCANNER_STATUS_CRITICAL_HIGH, status.status
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
test 'import cleans up old scans for host using retention setting' do
|
|
134
|
+
Setting[:cve_scan_delete_after_days] = 1
|
|
135
|
+
old_scan = create_scan(scanned_at: 3.days.ago, created_at: 3.days.ago)
|
|
136
|
+
|
|
137
|
+
post :import, params: {
|
|
138
|
+
host_id: @host.id,
|
|
139
|
+
cve_scan: create_payload,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
assert_response :created
|
|
143
|
+
assert_not ForemanCveScanner::CveScan.exists?(old_scan.id)
|
|
144
|
+
end
|
|
145
|
+
|
|
50
146
|
test 'import rejects scan without scanned_at' do
|
|
51
147
|
post :import, params: {
|
|
52
148
|
host_id: @host.id,
|
|
@@ -236,7 +332,7 @@ module Api
|
|
|
236
332
|
{
|
|
237
333
|
scanner: 'custom-scanner',
|
|
238
334
|
source: 'external',
|
|
239
|
-
scanned_at:
|
|
335
|
+
scanned_at: Time.current.iso8601,
|
|
240
336
|
findings: [external_finding],
|
|
241
337
|
}
|
|
242
338
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class HostExtensionsTest < ActiveSupport::TestCase
|
|
7
|
+
def setup
|
|
8
|
+
@host = FactoryBot.create(:host)
|
|
9
|
+
@other_host = FactoryBot.create(:host)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
test 'cve_scanned finds hosts with or without scans' do
|
|
13
|
+
create_scan(host: @host)
|
|
14
|
+
|
|
15
|
+
assert_search_includes('cve_scanned = true', @host)
|
|
16
|
+
assert_search_excludes('cve_scanned = true', @other_host)
|
|
17
|
+
assert_search_includes('cve_scanned = false', @other_host)
|
|
18
|
+
assert_search_excludes('cve_scanned = false', @host)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test 'severity and total searches use latest scan only' do
|
|
22
|
+
create_scan(host: @host, scanned_at: 2.hours.ago, total: 7, critical: 1, high: 2, medium: 3, low: 1)
|
|
23
|
+
create_scan(host: @host, scanned_at: 1.hour.ago, total: 0)
|
|
24
|
+
create_scan(host: @other_host, total: 6, critical: 1, high: 1, medium: 2, low: 2)
|
|
25
|
+
|
|
26
|
+
%w[total critical high medium low].each do |column|
|
|
27
|
+
assert_search_includes("cve_#{column} > 0", @other_host)
|
|
28
|
+
assert_search_excludes("cve_#{column} > 0", @host)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test 'scanner and source searches use latest scan only' do
|
|
33
|
+
create_scan(host: @host, scanned_at: 2.hours.ago, scanner: 'trivy', source: 'rex')
|
|
34
|
+
create_scan(host: @host, scanned_at: 1.hour.ago, scanner: 'grype', source: 'external')
|
|
35
|
+
create_scan(host: @other_host, scanner: 'trivy', source: 'rex')
|
|
36
|
+
|
|
37
|
+
assert_search_includes('cve_scanner = trivy', @other_host)
|
|
38
|
+
assert_search_excludes('cve_scanner = trivy', @host)
|
|
39
|
+
assert_search_includes('cve_source = rex', @other_host)
|
|
40
|
+
assert_search_excludes('cve_source = rex', @host)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test 'scanned_at search uses latest scan only' do
|
|
44
|
+
cutoff = Time.zone.parse('2026-06-01 12:00:00')
|
|
45
|
+
create_scan(host: @host, scanned_at: cutoff - 1.day)
|
|
46
|
+
create_scan(host: @other_host, scanned_at: cutoff + 1.day)
|
|
47
|
+
|
|
48
|
+
assert_search_includes("cve_scanned_at > \"#{cutoff.iso8601}\"", @other_host)
|
|
49
|
+
assert_search_excludes("cve_scanned_at > \"#{cutoff.iso8601}\"", @host)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
test 'rejects unsupported SQL operators' do
|
|
53
|
+
assert_raises ::ScopedSearch::QueryNotSupported do
|
|
54
|
+
::Host::Managed.search_by_cve_total('cve_total', '>= 0) OR 1=1 --', '0')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def assert_search_includes(query, host)
|
|
61
|
+
assert_includes search_host_ids(query), host.id
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def assert_search_excludes(query, host)
|
|
65
|
+
assert_not_includes search_host_ids(query), host.id
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def search_host_ids(query)
|
|
69
|
+
::Host::Managed.search_for(query).pluck(:id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_scan(host:, scanned_at: Time.current, scanner: 'trivy', source: 'rex', **counts)
|
|
73
|
+
counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 }.merge(counts)
|
|
74
|
+
|
|
75
|
+
CveScan.create!(
|
|
76
|
+
host: host,
|
|
77
|
+
scanner: scanner,
|
|
78
|
+
source: source,
|
|
79
|
+
scanned_at: scanned_at,
|
|
80
|
+
raw: { 'dummy' => true },
|
|
81
|
+
summary: { 'worst' => CveScan.worst_severity(counts.transform_keys(&:to_s)) },
|
|
82
|
+
findings: counts[:total].zero? ? [] : [{ 'id' => 'CVE-0000-0000' }],
|
|
83
|
+
total: counts[:total],
|
|
84
|
+
critical: counts[:critical],
|
|
85
|
+
high: counts[:high],
|
|
86
|
+
medium: counts[:medium],
|
|
87
|
+
low: counts[:low]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -13,6 +13,7 @@ module HostStatus
|
|
|
13
13
|
assert_equal 'No CVE scans', status.to_label
|
|
14
14
|
assert_equal HostStatus::Global::WARN, status.to_global
|
|
15
15
|
assert_equal 0, status.to_status
|
|
16
|
+
assert_not status.relevant?
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
test 'critical or high scan returns error status' do
|
|
@@ -51,6 +52,13 @@ module HostStatus
|
|
|
51
52
|
assert_includes HostStatus.status_registry, HostStatus::CveStatus
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
test 'persisted status is relevant without looking up latest scan' do
|
|
56
|
+
status = HostStatus::CveStatus.create!(host: @host, status: 0)
|
|
57
|
+
status.expects(:latest_scan).never
|
|
58
|
+
|
|
59
|
+
assert status.relevant?
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
private
|
|
55
63
|
|
|
56
64
|
def create_scan(critical:, high:, medium:, low:)
|
|
@@ -22,7 +22,7 @@ module ForemanCveScanner
|
|
|
22
22
|
data = JSON.parse(File.read(File.join(ForemanCveScanner::Engine.root, 'test/fixtures/trivy.json')))
|
|
23
23
|
scanner = ForemanCveScanner::CveReportScanner.new('scan' => data)
|
|
24
24
|
scanner.generate
|
|
25
|
-
assert_equal(
|
|
25
|
+
assert_equal(trivy_vulnerability_count(data), scanner.logs.count)
|
|
26
26
|
assert_equal('info', scanner.logs[0]['log']['level'])
|
|
27
27
|
expected_message = [
|
|
28
28
|
'CVE-2020-12762: json-c, libfastjson: integer overflow and out-of-',
|
|
@@ -36,7 +36,7 @@ module ForemanCveScanner
|
|
|
36
36
|
data = JSON.parse(File.read(File.join(ForemanCveScanner::Engine.root, 'test/fixtures/grype.json')))
|
|
37
37
|
scanner = ForemanCveScanner::CveReportScanner.new('scan' => data)
|
|
38
38
|
scanner.generate
|
|
39
|
-
assert_equal(
|
|
39
|
+
assert_equal(Array(data['matches']).size, scanner.logs.count)
|
|
40
40
|
first_severity = data.dig('matches', 0, 'vulnerability', 'severity')
|
|
41
41
|
expected_level = expected_level_for(first_severity)
|
|
42
42
|
assert_equal scanner.logs[0]['log']['level'], expected_level
|
|
@@ -56,8 +56,92 @@ module ForemanCveScanner
|
|
|
56
56
|
assert_equal 'unknown', ForemanCveScanner::CveReportScanner.detect_scanner('foo' => 'bar')
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
test 'trivy scan keeps duplicate cves for different packages' do
|
|
60
|
+
data = {
|
|
61
|
+
'Results' => [
|
|
62
|
+
{
|
|
63
|
+
'Vulnerabilities' => [
|
|
64
|
+
{
|
|
65
|
+
'VulnerabilityID' => 'CVE-2026-0001',
|
|
66
|
+
'PkgName' => 'openssl',
|
|
67
|
+
'InstalledVersion' => '1.0',
|
|
68
|
+
'Title' => 'openssl issue',
|
|
69
|
+
'Severity' => 'HIGH',
|
|
70
|
+
'PrimaryURL' => 'https://example.com/openssl',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
'VulnerabilityID' => 'CVE-2026-0001',
|
|
74
|
+
'PkgName' => 'curl',
|
|
75
|
+
'InstalledVersion' => '2.0',
|
|
76
|
+
'Title' => 'curl issue',
|
|
77
|
+
'Severity' => 'MEDIUM',
|
|
78
|
+
'PrimaryURL' => 'https://example.com/curl',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
scanner = ForemanCveScanner::CveReportScanner.new('scan' => data)
|
|
86
|
+
finding_names = scanner.unified_vulnerabilities.map { |finding| finding['name'] }
|
|
87
|
+
|
|
88
|
+
assert_equal 2, scanner.unified_vulnerabilities.size
|
|
89
|
+
assert_equal %w[openssl curl], finding_names
|
|
90
|
+
|
|
91
|
+
scanner.generate
|
|
92
|
+
|
|
93
|
+
assert_equal 2, scanner.logs.count
|
|
94
|
+
assert_equal 1, scanner.metrics['high']
|
|
95
|
+
assert_equal 1, scanner.metrics['medium']
|
|
96
|
+
assert_equal 2, scanner.metrics['total']
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
test 'grype scan keeps duplicate cves for different packages' do
|
|
100
|
+
data = {
|
|
101
|
+
'matches' => [
|
|
102
|
+
{
|
|
103
|
+
'artifact' => { 'name' => 'openssl', 'version' => '1.0' },
|
|
104
|
+
'vulnerability' => {
|
|
105
|
+
'id' => 'CVE-2026-0002',
|
|
106
|
+
'description' => 'openssl issue',
|
|
107
|
+
'severity' => 'HIGH',
|
|
108
|
+
'dataSource' => 'https://example.com/openssl',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
'artifact' => { 'name' => 'curl', 'version' => '2.0' },
|
|
113
|
+
'vulnerability' => {
|
|
114
|
+
'id' => 'CVE-2026-0002',
|
|
115
|
+
'description' => 'curl issue',
|
|
116
|
+
'severity' => 'LOW',
|
|
117
|
+
'dataSource' => 'https://example.com/curl',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scanner = ForemanCveScanner::CveReportScanner.new('scan' => data)
|
|
124
|
+
finding_names = scanner.unified_vulnerabilities.map { |finding| finding['name'] }
|
|
125
|
+
|
|
126
|
+
assert_equal 2, scanner.unified_vulnerabilities.size
|
|
127
|
+
assert_equal %w[openssl curl], finding_names
|
|
128
|
+
|
|
129
|
+
scanner.generate
|
|
130
|
+
|
|
131
|
+
assert_equal 2, scanner.logs.count
|
|
132
|
+
assert_equal 1, scanner.metrics['high']
|
|
133
|
+
assert_equal 1, scanner.metrics['low']
|
|
134
|
+
assert_equal 2, scanner.metrics['total']
|
|
135
|
+
end
|
|
136
|
+
|
|
59
137
|
private
|
|
60
138
|
|
|
139
|
+
def trivy_vulnerability_count(data)
|
|
140
|
+
Array(data['Results']).sum do |result|
|
|
141
|
+
Array(result['Vulnerabilities']).size
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
61
145
|
def expected_level_for(severity)
|
|
62
146
|
map = {
|
|
63
147
|
'CRITICAL' => 'err',
|