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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -4
  3. data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
  4. data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
  5. data/app/models/host_status/cve_status.rb +4 -0
  6. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
  7. data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
  8. data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
  9. data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
  10. data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
  11. data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
  12. data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
  13. data/config/routes.rb +1 -0
  14. data/lib/foreman_cve_scanner/engine.rb +28 -19
  15. data/lib/foreman_cve_scanner/version.rb +1 -1
  16. data/test/controllers/api/v2/cve_scans_controller_test.rb +97 -1
  17. data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
  18. data/test/models/host_status/cve_status_test.rb +8 -0
  19. data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
  20. data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
  21. data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
  22. data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
  23. data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
  24. data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
  25. data/webpack/components/CveCompareModal.js +9 -28
  26. data/webpack/components/CveDetailsCard.js +54 -65
  27. data/webpack/components/CveFindingsModal.js +15 -234
  28. data/webpack/components/CveFindingsView.js +307 -0
  29. data/webpack/components/CveKatelloFixLink.js +59 -0
  30. data/webpack/components/CveLink.js +28 -0
  31. data/webpack/components/CveScansReports.js +25 -62
  32. data/webpack/components/CveScansTab.js +42 -9
  33. data/webpack/components/CveSummaryCell.js +31 -19
  34. data/webpack/components/CveTrendChart.js +1 -16
  35. data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
  36. data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
  37. data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
  38. data/webpack/components/__tests__/CveScansTab.test.js +141 -147
  39. data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
  40. data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
  41. data/webpack/components/__tests__/cve_helpers.test.js +84 -0
  42. data/webpack/components/cve_helpers.js +114 -0
  43. data/webpack/components/cve_scans.scss +49 -0
  44. data/webpack/components/cve_summary_store.js +91 -0
  45. data/webpack/components/useSortBy.js +34 -0
  46. 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: trivy_version
10
- description: 'Trivy version to install'
11
- required: true
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
- default: 0.53.0
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: true
30
+ description: 'Grype version to install from GitHub'
31
+ required: false
18
32
  input_type: user
19
- advanced: false
20
- default: 0.79.3
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
- default: both
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
- trivy_version = input('trivy_version')
32
- grype_version = input('grype_version')
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 @host.operatingsystem.family.to_s
35
- when 'Debian'
36
- pkg = 'deb'
37
- when 'Redhat', 'Suse'
38
- pkg = 'rpm'
39
- else
40
- raise("OS '#{@host.operatingsystem.family}' not supported by template #{template_name}")
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
- case @host.architecture.to_s
44
- when 'x86_64'
45
- trivy_arch = 'Linux-64bit'
46
- grype_arch = 'linux_amd64'
47
- when 'ppc64le'
48
- trivy_arch = 'Linux-PPC64LE'
49
- grype_arch = 'linux_ppc64le'
50
- when 'aarch64'
51
- trivy_arch = 'Linux-ARM64'
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
- trivy_url = "https://github.com/aquasecurity/trivy/releases/download/v#{trivy_version}/trivy_#{trivy_version}_#{trivy_arch}.#{pkg}"
58
- grype_url = "https://github.com/anchore/grype/releases/download/v#{grype_version}/grype_#{grype_version}_#{grype_arch}.#{pkg}"
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
- case @host.operatingsystem.family
61
- when 'Debian'
62
- trivy_download_cmd = "wget -O /tmp/trivy.deb \"#{trivy_url}\""
63
- grype_download_cmd = "wget -O /tmp/grype.deb \"#{grype_url}\""
64
- trivy_install_cmd = "dpkg -i /tmp/trivy.deb"
65
- grype_install_cmd = "dpkg -i /tmp/grype.deb"
66
- trivy_cleanup_cmd = "rm -f /tmp/trivy.deb"
67
- grype_cleanup_cmd = "rm -f /tmp/grype.deb"
68
- when 'Redhat', 'Suse'
69
- trivy_install_cmd = "rpm -ivh \"#{trivy_url}\""
70
- grype_install_cmd = "rpm -ivh \"#{grype_url}\""
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
- <% if input('scanner_to_install') == 'both' || input('scanner_to_install') == 'trivy' -%>
75
- <%= trivy_download_cmd unless trivy_install_cmd.nil? %>
76
- <%= trivy_install_cmd %>
77
- <%= trivy_cleanup_cmd unless trivy_cleanup_cmd.nil? %>
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
- setting 'preferred_cve_scanner',
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] },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanCveScanner
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.1'
5
5
  end
@@ -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: '2026-05-14T08:00:00Z',
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(10, scanner.logs.count)
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(18, scanner.logs.count)
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',