foreman_cve_scanner 0.5.0 → 0.6.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/README.md +101 -5
- data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
- data/app/models/host_status/cve_status.rb +3 -1
- data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +5 -8
- data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
- data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
- data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
- data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +22 -9
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
- data/config/routes.rb +6 -1
- data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
- data/lib/foreman_cve_scanner/engine.rb +68 -17
- data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
- data/package.json +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
- data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
- data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
- data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
- data/test/models/host_status/cve_status_test.rb +12 -3
- data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
- data/webpack/components/CveCompareModal.js +298 -0
- data/webpack/components/CveDetailsCard.js +141 -121
- data/webpack/components/CveFindingsModal.js +131 -111
- data/webpack/components/CveScansReports.js +227 -0
- data/webpack/components/CveScansTab.js +122 -119
- data/webpack/components/CveTrendChart.js +264 -0
- data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
- data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
- data/webpack/components/__tests__/CveScansTab.test.js +185 -5
- data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
- data/webpack/components/__tests__/cve_helpers.test.js +18 -0
- data/webpack/components/cve_helpers.js +139 -0
- data/webpack/components/cve_scans.scss +464 -9
- data/webpack/components/useModalScan.js +26 -0
- metadata +24 -3
- data/webpack/components/CveHistoryTable.js +0 -58
- data/webpack/components/CveOverviewCard.js +0 -67
|
@@ -7,8 +7,8 @@ module Api
|
|
|
7
7
|
class CveScansControllerTest < ActionController::TestCase
|
|
8
8
|
def setup
|
|
9
9
|
@host = FactoryBot.create(:host)
|
|
10
|
-
@scan_old = create_scan(created_at: 2.hours.ago, total: 1, low: 1)
|
|
11
|
-
@scan_new = create_scan(created_at: 1.hour.ago, total: 2, high: 2)
|
|
10
|
+
@scan_old = create_scan(created_at: 2.hours.ago, scanned_at: 2.hours.ago, total: 1, low: 1)
|
|
11
|
+
@scan_new = create_scan(created_at: 1.hour.ago, scanned_at: 1.hour.ago, total: 2, high: 2)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
test 'index returns scans for host' do
|
|
@@ -33,11 +33,130 @@ module Api
|
|
|
33
33
|
assert_equal @scan_old.id, body['id']
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
test 'import imports external scan for host' do
|
|
37
|
+
assert_difference('ForemanCveScanner::CveScan.count', 1) do
|
|
38
|
+
post :import, params: {
|
|
39
|
+
host_id: @host.id,
|
|
40
|
+
cve_scan: create_payload,
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
assert_response :created
|
|
45
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
46
|
+
assert_equal 'external', body['source']
|
|
47
|
+
assert_equal 1, body['critical']
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test 'import rejects scan without scanned_at' do
|
|
51
|
+
post :import, params: {
|
|
52
|
+
host_id: @host.id,
|
|
53
|
+
cve_scan: create_payload.except(:scanned_at),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assert_response :unprocessable_entity
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
test 'compare returns scan comparison for host' do
|
|
60
|
+
set_comparison_findings!
|
|
61
|
+
|
|
62
|
+
get :compare, params: {
|
|
63
|
+
host_id: @host.id,
|
|
64
|
+
first_id: @scan_old.id,
|
|
65
|
+
second_id: @scan_new.id,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
assert_response :success
|
|
69
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
70
|
+
assert_equal @scan_old.id, body['previous']['id']
|
|
71
|
+
assert_equal 1, body['summary']['updated']
|
|
72
|
+
assert_equal 1, body['summary']['resolved']
|
|
73
|
+
assert_equal 1, body['summary']['new']
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test 'compare includes diff payload for updated finding' do
|
|
77
|
+
set_comparison_findings!
|
|
78
|
+
|
|
79
|
+
get :compare, params: {
|
|
80
|
+
host_id: @host.id,
|
|
81
|
+
first_id: @scan_old.id,
|
|
82
|
+
second_id: @scan_new.id,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
assert_response :success
|
|
86
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
87
|
+
updated_row = body['results'].find { |row| row['id'] == 'CVE-1' }
|
|
88
|
+
assert_equal 'updated', updated_row['status']
|
|
89
|
+
assert_equal 'CRITICAL', updated_row['diff']['severity']['new']
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test 'compare requires both scan ids' do
|
|
93
|
+
get :compare, params: { host_id: @host.id, first_id: @scan_old.id }
|
|
94
|
+
|
|
95
|
+
assert_response :unprocessable_entity
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test 'export returns csv headers for scan by id' do
|
|
99
|
+
set_export_findings!
|
|
100
|
+
|
|
101
|
+
get :export, params: { host_id: @host.id, id: @scan_old.id }
|
|
102
|
+
|
|
103
|
+
assert_response :success
|
|
104
|
+
assert_export_headers
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
test 'export returns csv body for scan by id' do
|
|
108
|
+
set_export_findings!
|
|
109
|
+
|
|
110
|
+
get :export, params: { host_id: @host.id, id: @scan_old.id }
|
|
111
|
+
|
|
112
|
+
assert_response :success
|
|
113
|
+
assert_export_body
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
test 'show does not return a scan from another host' do
|
|
117
|
+
other_host = FactoryBot.create(:host)
|
|
118
|
+
other_scan = create_scan(host: other_host, total: 1, low: 1)
|
|
119
|
+
|
|
120
|
+
assert_not_found do
|
|
121
|
+
get :show, params: { host_id: @host.id, id: other_scan.id }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
test 'export does not return a scan from another host' do
|
|
126
|
+
other_host = FactoryBot.create(:host)
|
|
127
|
+
other_scan = create_scan(host: other_host, total: 1, low: 1)
|
|
128
|
+
|
|
129
|
+
assert_not_found do
|
|
130
|
+
get :export, params: { host_id: @host.id, id: other_scan.id }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
test 'compare does not return scans from another host' do
|
|
135
|
+
other_host = FactoryBot.create(:host)
|
|
136
|
+
other_scan = create_scan(host: other_host, total: 1, low: 1)
|
|
137
|
+
|
|
138
|
+
assert_not_found do
|
|
139
|
+
get :compare, params: {
|
|
140
|
+
host_id: @host.id,
|
|
141
|
+
first_id: @scan_old.id,
|
|
142
|
+
second_id: other_scan.id,
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
36
147
|
test 'index returns not found for unknown host' do
|
|
37
148
|
get :index, params: { host_id: 'does-not-exist' }
|
|
38
149
|
assert_response :not_found
|
|
39
150
|
end
|
|
40
151
|
|
|
152
|
+
test 'index finds host by name' do
|
|
153
|
+
get :index, params: { host_id: @host.name }
|
|
154
|
+
assert_response :success
|
|
155
|
+
body = ActiveSupport::JSON.decode(@response.body)
|
|
156
|
+
assert_not_nil body['results']
|
|
157
|
+
assert_equal 2, body['results'].size
|
|
158
|
+
end
|
|
159
|
+
|
|
41
160
|
test 'latest returns no content when no scans exist' do
|
|
42
161
|
ForemanCveScanner::CveScan.delete_all
|
|
43
162
|
|
|
@@ -45,22 +164,44 @@ module Api
|
|
|
45
164
|
assert_response :no_content
|
|
46
165
|
end
|
|
47
166
|
|
|
167
|
+
test 'destroy deletes scan for host' do
|
|
168
|
+
assert_difference('ForemanCveScanner::CveScan.count', -1) do
|
|
169
|
+
delete :destroy, params: { host_id: @host.id, id: @scan_old.id }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
assert_response :success
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
test 'destroy does not delete a scan from another host' do
|
|
176
|
+
other_host = FactoryBot.create(:host)
|
|
177
|
+
other_scan = create_scan(host: other_host, total: 1, low: 1)
|
|
178
|
+
|
|
179
|
+
assert_no_difference('ForemanCveScanner::CveScan.count') do
|
|
180
|
+
assert_not_found do
|
|
181
|
+
delete :destroy, params: { host_id: @host.id, id: other_scan.id }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
48
186
|
private
|
|
49
187
|
|
|
50
|
-
# rubocop:disable Metrics/MethodLength
|
|
51
188
|
def create_scan(options = {})
|
|
52
189
|
defaults = {
|
|
53
190
|
created_at: Time.now.utc,
|
|
191
|
+
scanned_at: Time.current,
|
|
54
192
|
total: 0,
|
|
55
193
|
critical: 0,
|
|
56
194
|
high: 0,
|
|
57
195
|
medium: 0,
|
|
58
196
|
low: 0,
|
|
197
|
+
host: @host,
|
|
59
198
|
}
|
|
60
199
|
options = defaults.merge(options)
|
|
61
200
|
ForemanCveScanner::CveScan.create!(
|
|
62
|
-
host:
|
|
201
|
+
host: options[:host],
|
|
63
202
|
scanner: 'trivy',
|
|
203
|
+
source: 'rex',
|
|
204
|
+
scanned_at: options[:scanned_at],
|
|
64
205
|
created_at: options[:created_at],
|
|
65
206
|
raw: { 'dummy' => true },
|
|
66
207
|
summary: { 'worst' => 'low' },
|
|
@@ -72,7 +213,121 @@ module Api
|
|
|
72
213
|
low: options[:low]
|
|
73
214
|
)
|
|
74
215
|
end
|
|
75
|
-
|
|
216
|
+
|
|
217
|
+
def set_export_findings!
|
|
218
|
+
@scan_old.update!(
|
|
219
|
+
findings: [
|
|
220
|
+
{
|
|
221
|
+
'severity' => 'HIGH',
|
|
222
|
+
'published' => '2026-02-20',
|
|
223
|
+
'name' => 'openssl',
|
|
224
|
+
'version' => '1.1',
|
|
225
|
+
'fixed' => '1.2',
|
|
226
|
+
'status' => 'fixed',
|
|
227
|
+
'id' => 'CVE-2026-0001',
|
|
228
|
+
'title' => 'OpenSSL issue',
|
|
229
|
+
'url' => 'https://example.test/CVE-2026-0001',
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def create_payload
|
|
236
|
+
{
|
|
237
|
+
scanner: 'custom-scanner',
|
|
238
|
+
source: 'external',
|
|
239
|
+
scanned_at: '2026-05-14T08:00:00Z',
|
|
240
|
+
findings: [external_finding],
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def set_comparison_findings!
|
|
245
|
+
@scan_old.update!(findings: old_comparison_findings)
|
|
246
|
+
@scan_new.update!(findings: new_comparison_findings)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def external_finding
|
|
250
|
+
{
|
|
251
|
+
id: 'CVE-2026-1111',
|
|
252
|
+
name: 'openssl',
|
|
253
|
+
severity: 'CRITICAL',
|
|
254
|
+
version: '1.0',
|
|
255
|
+
fixed: '1.1',
|
|
256
|
+
status: 'affected',
|
|
257
|
+
title: 'OpenSSL issue',
|
|
258
|
+
published: '2026-05-14T06:00:00Z',
|
|
259
|
+
url: 'https://example.test/CVE-2026-1111',
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def old_comparison_findings
|
|
264
|
+
[
|
|
265
|
+
comparison_finding(
|
|
266
|
+
id: 'CVE-1',
|
|
267
|
+
name: 'openssl',
|
|
268
|
+
severity: 'HIGH',
|
|
269
|
+
version: '1.0',
|
|
270
|
+
title: 'OpenSSL issue'
|
|
271
|
+
),
|
|
272
|
+
comparison_finding(
|
|
273
|
+
id: 'CVE-2',
|
|
274
|
+
name: 'curl',
|
|
275
|
+
severity: 'LOW',
|
|
276
|
+
version: '1.0',
|
|
277
|
+
title: 'Curl issue'
|
|
278
|
+
),
|
|
279
|
+
]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def new_comparison_findings
|
|
283
|
+
[
|
|
284
|
+
comparison_finding(
|
|
285
|
+
id: 'CVE-1',
|
|
286
|
+
name: 'openssl',
|
|
287
|
+
severity: 'CRITICAL',
|
|
288
|
+
version: '1.0',
|
|
289
|
+
title: 'OpenSSL issue'
|
|
290
|
+
),
|
|
291
|
+
comparison_finding(
|
|
292
|
+
id: 'CVE-3',
|
|
293
|
+
name: 'bash',
|
|
294
|
+
severity: 'MEDIUM',
|
|
295
|
+
version: '2.0',
|
|
296
|
+
title: 'Bash issue',
|
|
297
|
+
published: '2026-02-21'
|
|
298
|
+
),
|
|
299
|
+
]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def comparison_finding(attributes)
|
|
303
|
+
{
|
|
304
|
+
'id' => attributes.fetch(:id),
|
|
305
|
+
'name' => attributes.fetch(:name),
|
|
306
|
+
'severity' => attributes.fetch(:severity),
|
|
307
|
+
'version' => attributes.fetch(:version),
|
|
308
|
+
'title' => attributes.fetch(:title),
|
|
309
|
+
'published' => attributes.fetch(:published, '2026-02-20'),
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def assert_not_found
|
|
314
|
+
yield
|
|
315
|
+
assert_response :not_found
|
|
316
|
+
rescue ActiveRecord::RecordNotFound
|
|
317
|
+
assert true
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def assert_export_headers
|
|
321
|
+
assert_includes @response.header['Content-Type'], 'text/csv'
|
|
322
|
+
assert_includes @response.header['Content-Disposition'], 'attachment'
|
|
323
|
+
assert_includes @response.header['Content-Disposition'], @host.shortname
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def assert_export_body
|
|
327
|
+
assert_includes @response.body, 'Severity,Published,Package'
|
|
328
|
+
assert_includes @response.body, 'CVE-2026-0001'
|
|
329
|
+
assert_includes @response.body, 'openssl'
|
|
330
|
+
end
|
|
76
331
|
end
|
|
77
332
|
end
|
|
78
333
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class ProfilesUploaderTest < ActiveSupport::TestCase
|
|
7
|
+
class DummyUploader
|
|
8
|
+
prepend ForemanCveScanner::ProfilesUploader
|
|
9
|
+
|
|
10
|
+
def initialize(host:, result:)
|
|
11
|
+
@host = host
|
|
12
|
+
@result = result
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def upload
|
|
16
|
+
@result
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup
|
|
21
|
+
skip 'Katello is not installed' unless Foreman::Plugin.installed?(:katello)
|
|
22
|
+
|
|
23
|
+
@host = FactoryBot.create(:host)
|
|
24
|
+
@previous_preferred_scanner = Setting[:preferred_cve_scanner]
|
|
25
|
+
@previous_run_after_upload = Setting[:run_cve_scan_after_host_profiles_upload]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def teardown
|
|
29
|
+
return unless Foreman::Plugin.installed?(:katello)
|
|
30
|
+
|
|
31
|
+
Setting[:preferred_cve_scanner] = @previous_preferred_scanner
|
|
32
|
+
Setting[:run_cve_scan_after_host_profiles_upload] = @previous_run_after_upload
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test 'upload schedules cve scan when setting is enabled' do
|
|
36
|
+
Setting[:preferred_cve_scanner] = 'grype'
|
|
37
|
+
Setting[:run_cve_scan_after_host_profiles_upload] = true
|
|
38
|
+
|
|
39
|
+
captured = {}
|
|
40
|
+
triggering = Struct.new(:mode).new
|
|
41
|
+
composer = Struct.new(:triggering) do
|
|
42
|
+
attr_reader :triggered
|
|
43
|
+
|
|
44
|
+
def trigger!
|
|
45
|
+
@triggered = true
|
|
46
|
+
end
|
|
47
|
+
end.new(triggering)
|
|
48
|
+
|
|
49
|
+
JobInvocationComposer.stub(:for_feature, lambda { |feature, host, scanner:|
|
|
50
|
+
captured[:feature] = feature
|
|
51
|
+
captured[:host] = host
|
|
52
|
+
captured[:scanner] = scanner
|
|
53
|
+
composer
|
|
54
|
+
}) do
|
|
55
|
+
assert DummyUploader.new(host: @host, result: true).upload
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
assert_equal [:run_cve_scan, @host, 'grype'],
|
|
59
|
+
[captured[:feature], captured[:host], captured[:scanner]]
|
|
60
|
+
assert_equal :future, triggering.mode
|
|
61
|
+
assert composer.triggered
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test 'upload keeps upstream result when scan scheduling fails' do
|
|
65
|
+
Setting[:run_cve_scan_after_host_profiles_upload] = true
|
|
66
|
+
|
|
67
|
+
JobInvocationComposer.stub(:for_feature, lambda { |_feature, _host, scanner:|
|
|
68
|
+
raise StandardError, scanner
|
|
69
|
+
}) do
|
|
70
|
+
assert DummyUploader.new(host: @host, result: true).upload
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
test 'upload does not schedule cve scan when setting is disabled' do
|
|
75
|
+
Setting[:run_cve_scan_after_host_profiles_upload] = false
|
|
76
|
+
|
|
77
|
+
JobInvocationComposer.stub(:for_feature, lambda { |_feature, _host, scanner:|
|
|
78
|
+
flunk("unexpected scanner #{scanner}")
|
|
79
|
+
}) do
|
|
80
|
+
assert DummyUploader.new(host: @host, result: true).upload
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class TemplateHelpersTest < ActiveSupport::TestCase
|
|
7
|
+
include ForemanCveScanner::TemplateHelpers
|
|
8
|
+
|
|
9
|
+
def setup
|
|
10
|
+
@previous_preferred_scanner = Setting[:preferred_cve_scanner]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def teardown
|
|
14
|
+
Setting[:preferred_cve_scanner] = @previous_preferred_scanner
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
test 'foreman_cve_scanner returns preferred scanner setting' do
|
|
18
|
+
Setting[:preferred_cve_scanner] = 'grype'
|
|
19
|
+
|
|
20
|
+
assert_equal 'grype', foreman_cve_scanner('preferred_scanner')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test 'foreman_cve_scanner raises for unknown setting name' do
|
|
24
|
+
assert_raises(::Foreman::Exception) do
|
|
25
|
+
foreman_cve_scanner('unknown_setting')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class CveScanTest < ActiveSupport::TestCase
|
|
7
|
+
def setup
|
|
8
|
+
@host = FactoryBot.create(:host)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test 'is invalid without required host, scanner, source and scanned_at attributes' do
|
|
12
|
+
scan = CveScan.new
|
|
13
|
+
|
|
14
|
+
assert_not scan.valid?
|
|
15
|
+
assert_includes scan.errors.attribute_names, :host_id
|
|
16
|
+
assert_includes scan.errors.attribute_names, :scanner
|
|
17
|
+
assert_includes scan.errors.attribute_names, :source
|
|
18
|
+
assert_includes scan.errors.attribute_names, :scanned_at
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test 'is invalid without required raw and summary payload attributes' do
|
|
22
|
+
scan = CveScan.new
|
|
23
|
+
|
|
24
|
+
assert_not scan.valid?
|
|
25
|
+
assert_includes scan.errors.attribute_names, :raw
|
|
26
|
+
assert_includes scan.errors.attribute_names, :summary
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test 'is invalid with nil findings' do
|
|
30
|
+
scan = CveScan.new(
|
|
31
|
+
host: @host,
|
|
32
|
+
scanner: 'trivy',
|
|
33
|
+
source: 'rex',
|
|
34
|
+
scanned_at: Time.current,
|
|
35
|
+
raw: { 'dummy' => true },
|
|
36
|
+
summary: { 'worst' => 'none' },
|
|
37
|
+
findings: nil,
|
|
38
|
+
total: 0,
|
|
39
|
+
critical: 0,
|
|
40
|
+
high: 0,
|
|
41
|
+
medium: 0,
|
|
42
|
+
low: 0
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert_not scan.valid?
|
|
46
|
+
assert_includes scan.errors.attribute_names, :findings
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test 'is valid with empty findings' do
|
|
50
|
+
scan = CveScan.new(
|
|
51
|
+
host: @host,
|
|
52
|
+
scanner: 'trivy',
|
|
53
|
+
source: 'rex',
|
|
54
|
+
scanned_at: Time.current,
|
|
55
|
+
raw: { 'dummy' => true },
|
|
56
|
+
summary: { 'worst' => 'none' },
|
|
57
|
+
findings: [],
|
|
58
|
+
total: 0,
|
|
59
|
+
critical: 0,
|
|
60
|
+
high: 0,
|
|
61
|
+
medium: 0,
|
|
62
|
+
low: 0
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert scan.valid?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
test 'for_host returns only scans for the given host' do
|
|
69
|
+
other_host = FactoryBot.create(:host)
|
|
70
|
+
host_scan = create_scan(host: @host, total: 1)
|
|
71
|
+
other_scan = create_scan(host: other_host, total: 2)
|
|
72
|
+
|
|
73
|
+
result = CveScan.for_host(@host.id)
|
|
74
|
+
|
|
75
|
+
assert_includes result, host_scan
|
|
76
|
+
assert_not_includes result, other_scan
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
test 'recent_first orders by scanned_at desc and id desc' do
|
|
80
|
+
older = create_scan(host: @host, scanned_at: 2.hours.ago)
|
|
81
|
+
newer = create_scan(host: @host, scanned_at: 1.hour.ago)
|
|
82
|
+
timestamp = Time.zone.parse('2026-05-13 10:00:00')
|
|
83
|
+
same_time_a = create_scan(host: @host, scanned_at: timestamp)
|
|
84
|
+
same_time_b = create_scan(host: @host, scanned_at: timestamp)
|
|
85
|
+
|
|
86
|
+
result = CveScan.recent_first.to_a
|
|
87
|
+
|
|
88
|
+
assert_operator result.index(newer), :<, result.index(older)
|
|
89
|
+
assert_operator result.index(same_time_b), :<, result.index(same_time_a)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test 'destroying a host destroys its cve scans' do
|
|
93
|
+
create_scan(host: @host, total: 1)
|
|
94
|
+
create_scan(host: @host, total: 2)
|
|
95
|
+
|
|
96
|
+
assert_difference('ForemanCveScanner::CveScan.count', -2) do
|
|
97
|
+
@host.destroy
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def create_scan(host:, scanned_at: Time.current, total: 0)
|
|
104
|
+
CveScan.create!(
|
|
105
|
+
host: host,
|
|
106
|
+
scanner: 'trivy',
|
|
107
|
+
source: 'rex',
|
|
108
|
+
scanned_at: scanned_at,
|
|
109
|
+
raw: { 'dummy' => true },
|
|
110
|
+
summary: { 'worst' => 'low' },
|
|
111
|
+
findings: [{ 'id' => 'CVE-0000-0000' }],
|
|
112
|
+
total: total,
|
|
113
|
+
critical: 0,
|
|
114
|
+
high: 0,
|
|
115
|
+
medium: 0,
|
|
116
|
+
low: total
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -39,6 +39,14 @@ module HostStatus
|
|
|
39
39
|
assert_equal 1, status.to_status
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
test 'zero findings scan returns ok status' do
|
|
43
|
+
create_scan(critical: 0, high: 0, medium: 0, low: 0)
|
|
44
|
+
status = @host.get_status(HostStatus::CveStatus)
|
|
45
|
+
assert_equal 'No CVEs found', status.to_label
|
|
46
|
+
assert_equal HostStatus::Global::OK, status.to_global
|
|
47
|
+
assert_equal 0, status.to_status
|
|
48
|
+
end
|
|
49
|
+
|
|
42
50
|
test 'status is registered in registry' do
|
|
43
51
|
assert_includes HostStatus.status_registry, HostStatus::CveStatus
|
|
44
52
|
end
|
|
@@ -50,10 +58,11 @@ module HostStatus
|
|
|
50
58
|
ForemanCveScanner::CveScan.create!(
|
|
51
59
|
host: @host,
|
|
52
60
|
scanner: 'trivy',
|
|
53
|
-
|
|
61
|
+
source: 'rex',
|
|
62
|
+
scanned_at: Time.current,
|
|
54
63
|
raw: { 'dummy' => true },
|
|
55
|
-
summary: { 'worst' => 'low' },
|
|
56
|
-
findings: [{ 'id' => 'CVE-0000-0000' }],
|
|
64
|
+
summary: { 'worst' => total.zero? ? 'none' : 'low' },
|
|
65
|
+
findings: total.zero? ? [] : [{ 'id' => 'CVE-0000-0000' }],
|
|
57
66
|
total: total,
|
|
58
67
|
critical: critical,
|
|
59
68
|
high: high,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class ScanCleanupTest < ActiveSupport::TestCase
|
|
7
|
+
def setup
|
|
8
|
+
@host = FactoryBot.create(:host)
|
|
9
|
+
@previous_setting = Setting[:cve_scan_delete_after_days]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def teardown
|
|
13
|
+
Setting[:cve_scan_delete_after_days] = @previous_setting
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test 'cleanup! does nothing when retention is disabled' do
|
|
17
|
+
Setting[:cve_scan_delete_after_days] = 0
|
|
18
|
+
old_scan = create_scan(10.days.ago)
|
|
19
|
+
|
|
20
|
+
deleted = ScanCleanup.new(scope: @host.cve_scans).cleanup!
|
|
21
|
+
|
|
22
|
+
assert_equal 0, deleted
|
|
23
|
+
assert ForemanCveScanner::CveScan.exists?(old_scan.id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
test 'cleanup! removes scans older than configured retention' do
|
|
27
|
+
Setting[:cve_scan_delete_after_days] = 5
|
|
28
|
+
old_scan = create_scan(10.days.ago)
|
|
29
|
+
recent_scan = create_scan(2.days.ago)
|
|
30
|
+
|
|
31
|
+
deleted = ScanCleanup.new(scope: @host.cve_scans).cleanup!
|
|
32
|
+
|
|
33
|
+
assert_equal 1, deleted
|
|
34
|
+
assert_not ForemanCveScanner::CveScan.exists?(old_scan.id)
|
|
35
|
+
assert ForemanCveScanner::CveScan.exists?(recent_scan.id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
test 'cleanup! uses explicit day override' do
|
|
39
|
+
Setting[:cve_scan_delete_after_days] = 30
|
|
40
|
+
old_scan = create_scan(10.days.ago)
|
|
41
|
+
recent_scan = create_scan(2.days.ago)
|
|
42
|
+
|
|
43
|
+
deleted = ScanCleanup.new(scope: @host.cve_scans, days: 5).cleanup!
|
|
44
|
+
|
|
45
|
+
assert_equal 1, deleted
|
|
46
|
+
assert_not ForemanCveScanner::CveScan.exists?(old_scan.id)
|
|
47
|
+
assert ForemanCveScanner::CveScan.exists?(recent_scan.id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def create_scan(scanned_at)
|
|
53
|
+
ForemanCveScanner::CveScan.create!(
|
|
54
|
+
host: @host,
|
|
55
|
+
scanner: 'trivy',
|
|
56
|
+
source: 'rex',
|
|
57
|
+
scanned_at: scanned_at,
|
|
58
|
+
raw: { 'dummy' => true },
|
|
59
|
+
summary: { 'worst' => 'low' },
|
|
60
|
+
findings: [{ 'id' => 'CVE-0000-0000' }],
|
|
61
|
+
total: 1,
|
|
62
|
+
critical: 0,
|
|
63
|
+
high: 0,
|
|
64
|
+
medium: 0,
|
|
65
|
+
low: 1
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|