foreman_cve_scanner 0.5.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -3
  3. data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
  4. data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
  5. data/app/models/host_status/cve_status.rb +3 -1
  6. data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
  7. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +3 -6
  8. data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
  9. data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
  10. data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
  11. data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
  12. data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
  13. data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +3 -3
  14. data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
  15. data/config/routes.rb +6 -1
  16. data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
  17. data/lib/foreman_cve_scanner/engine.rb +68 -17
  18. data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
  19. data/lib/foreman_cve_scanner/version.rb +1 -1
  20. data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
  21. data/package.json +1 -1
  22. data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
  23. data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
  24. data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
  25. data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
  26. data/test/models/host_status/cve_status_test.rb +12 -3
  27. data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
  28. data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
  29. data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
  30. data/webpack/components/CveCompareModal.js +298 -0
  31. data/webpack/components/CveDetailsCard.js +141 -121
  32. data/webpack/components/CveFindingsModal.js +131 -111
  33. data/webpack/components/CveScansReports.js +227 -0
  34. data/webpack/components/CveScansTab.js +122 -119
  35. data/webpack/components/CveTrendChart.js +264 -0
  36. data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
  37. data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
  38. data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
  39. data/webpack/components/__tests__/CveScansTab.test.js +185 -5
  40. data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
  41. data/webpack/components/__tests__/cve_helpers.test.js +18 -0
  42. data/webpack/components/cve_helpers.js +139 -0
  43. data/webpack/components/cve_scans.scss +464 -9
  44. data/webpack/components/useModalScan.js +26 -0
  45. metadata +24 -3
  46. data/webpack/components/CveHistoryTable.js +0 -58
  47. data/webpack/components/CveOverviewCard.js +0 -67
@@ -9,7 +9,7 @@ module ForemanCveScanner
9
9
 
10
10
  def import_for_host!(host)
11
11
  scan_json = format_output(@job_output)
12
- return nil if scan_json.nil?
12
+ raise ::Foreman::Exception, _('CVE scan output did not contain JSON content') if scan_json.nil?
13
13
 
14
14
  scanner_name = ::ForemanCveScanner::CveReportScanner.detect_scanner(scan_json)
15
15
  persist_scan!(host, scanner_name, scan_json)
@@ -32,12 +32,14 @@ module ForemanCveScanner
32
32
  end
33
33
 
34
34
  def extract_json(output_source)
35
- output = output_source.to_s.each_line(chomp: true)
36
- .drop_while { |line| !line.start_with?('===START') }
37
- .drop(1)
38
- .take_while { |line| !line.start_with?('===END') }
39
- .reject(&:empty?)
40
- .join
35
+ lines = output_source.to_s.each_line(chomp: true).to_a
36
+ return nil unless lines.include?('===START') && lines.include?('===END')
37
+
38
+ output = lines.drop_while { |line| !line.start_with?('===START') }
39
+ .drop(1)
40
+ .take_while { |line| !line.start_with?('===END') }
41
+ .reject(&:empty?)
42
+ .join
41
43
  output.strip
42
44
  end
43
45
 
@@ -63,6 +65,7 @@ module ForemanCveScanner
63
65
  build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
64
66
  )
65
67
  refresh_host_status(host)
68
+ cleanup_old_scans(host)
66
69
  scan
67
70
  end
68
71
 
@@ -73,18 +76,13 @@ module ForemanCveScanner
73
76
  Rails.logger.error("CVE status refresh failed for host_id=#{host.id}: #{e}")
74
77
  end
75
78
 
76
- def worst_severity(metrics)
77
- %w[critical high medium low].each do |severity|
78
- return severity if metrics[severity].to_i.positive?
79
- end
80
- 'none'
81
- end
82
-
83
79
  def build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
84
- summary = metrics.merge('worst' => worst_severity(metrics))
80
+ summary = metrics.merge('worst' => ::ForemanCveScanner::CveScan.worst_severity(metrics))
85
81
  {
86
82
  host: host,
87
83
  scanner: scanner_name,
84
+ source: 'rex',
85
+ scanned_at: Time.current,
88
86
  raw: scan_json,
89
87
  summary: summary,
90
88
  findings: build_findings(scanner),
@@ -101,5 +99,9 @@ module ForemanCveScanner
101
99
  entry.merge('id' => id)
102
100
  end
103
101
  end
102
+
103
+ def cleanup_old_scans(host)
104
+ ::ForemanCveScanner::ScanCleanup.new(scope: host.cve_scans).cleanup!
105
+ end
104
106
  end
105
107
  end
@@ -2,4 +2,4 @@
2
2
 
3
3
  object @cve_scan
4
4
 
5
- attributes :id, :host_id, :scanner, :created_at, :total, :critical, :high, :medium, :low, :summary
5
+ attributes :id, :host_id, :scanner, :source, :scanned_at, :created_at, :total, :critical, :high, :medium, :low, :summary
@@ -4,4 +4,4 @@ object @cve_scan
4
4
 
5
5
  extends 'api/v2/cve_scans/base'
6
6
 
7
- attributes :findings, :created_at, :updated_at
7
+ attributes :findings, :updated_at
@@ -43,13 +43,13 @@ end
43
43
  case @host.architecture.to_s
44
44
  when 'x86_64'
45
45
  trivy_arch = 'Linux-64bit'
46
- grype_arch = 'linux-amd64'
46
+ grype_arch = 'linux_amd64'
47
47
  when 'ppc64le'
48
48
  trivy_arch = 'Linux-PPC64LE'
49
- grype_arch = 'linux-ppc64le'
49
+ grype_arch = 'linux_ppc64le'
50
50
  when 'aarch64'
51
51
  trivy_arch = 'Linux-ARM64'
52
- grype_arch = 'linux-arm64'
52
+ grype_arch = 'linux_arm64'
53
53
  else
54
54
  raise("Architecture '#{@host.architecture}' not supported by template #{template_name}")
55
55
  end
@@ -29,16 +29,16 @@ template_inputs:
29
29
  default: /
30
30
  hidden_value: false
31
31
  - name: scanner
32
- required: true
32
+ required: false
33
33
  input_type: user
34
34
  options: "trivy\r\ngrype"
35
35
  advanced: false
36
36
  value_type: plain
37
- default: trivy
38
37
  hidden_value: false
39
38
  %>
40
39
  <%
41
- scanner = input('scanner')
40
+ scanner = input('scanner').to_s.strip
41
+ scanner = foreman_cve_scanner('preferred_scanner') if scanner.empty?
42
42
  target = input('target').to_sym
43
43
  path = input('path')
44
44
  options = input('options').to_s
@@ -69,5 +69,7 @@ exit 1
69
69
  <% else -%>
70
70
  echo "===START"
71
71
  <%= exec_command %>
72
+ rc=$?
72
73
  echo "===END"
74
+ exit $rc
73
75
  <% end -%>
data/config/routes.rb CHANGED
@@ -8,9 +8,14 @@ Rails.application.routes.draw do
8
8
  constraints: ApiConstraints.new(version: 2, default: true) do
9
9
  constraints(host_id: %r{[^/]+}) do
10
10
  resources :hosts, only: [] do
11
- resources :cve_scans, only: %i[index show] do
11
+ resources :cve_scans, only: %i[index show destroy] do
12
12
  collection do
13
+ post :import
13
14
  get :latest
15
+ get :compare
16
+ end
17
+ member do
18
+ get :export
14
19
  end
15
20
  end
16
21
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddSourceAndScannedAtToForemanCveScannerCveScans < ActiveRecord::Migration[6.1]
4
+ def up
5
+ add_column :foreman_cve_scanner_cve_scans, :source, :string
6
+ add_column :foreman_cve_scanner_cve_scans, :scanned_at, :datetime
7
+
8
+ execute <<~SQL.squish
9
+ UPDATE foreman_cve_scanner_cve_scans
10
+ SET source = 'rex', scanned_at = created_at
11
+ WHERE source IS NULL OR scanned_at IS NULL
12
+ SQL
13
+
14
+ change_column_null :foreman_cve_scanner_cve_scans, :source, false
15
+ change_column_null :foreman_cve_scanner_cve_scans, :scanned_at, false
16
+
17
+ add_index :foreman_cve_scanner_cve_scans, %i[host_id scanned_at],
18
+ order: { scanned_at: :desc }
19
+ end
20
+
21
+ def down
22
+ remove_index :foreman_cve_scanner_cve_scans, %i[host_id scanned_at]
23
+ remove_column :foreman_cve_scanner_cve_scans, :scanned_at
24
+ remove_column :foreman_cve_scanner_cve_scans, :source
25
+ end
26
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'foreman_remote_execution'
4
+ require 'foreman_cve_scanner/template_helpers'
4
5
 
5
6
  module ForemanCveScanner
6
7
  # Rails engine for the Foreman CVE Scanner plugin.
@@ -15,20 +16,7 @@ module ForemanCveScanner
15
16
 
16
17
  initializer 'foreman_cve_scanner.register_plugin', before: :finisher_hook do |app|
17
18
  app.reloader.to_prepare do
18
- Foreman::Plugin.register :foreman_cve_scanner do
19
- requires_foreman '>= 3.13'
20
- register_global_js_file 'fills'
21
-
22
- apipie_documented_controllers ["#{ForemanCveScanner::Engine.root}/app/controllers/api/v2/*.rb"]
23
-
24
- security_block :foreman_cve_scanner do
25
- permission :view_cve_scans,
26
- { 'api/v2/cve_scans': %i[index latest show destroy] },
27
- resource_type: 'Host'
28
- end
29
-
30
- add_all_permissions_to_default_roles
31
- end
19
+ ForemanCveScanner::Engine.register_plugin
32
20
  end
33
21
  end
34
22
 
@@ -38,6 +26,7 @@ module ForemanCveScanner
38
26
  Host::Managed.include ForemanCveScanner::HostExtensions
39
27
  require_dependency 'host_status/cve_status'
40
28
  HostStatus.status_registry.add(HostStatus::CveStatus)
29
+ ForemanCveScanner::Engine.register_katello_integration
41
30
  ForemanCveScanner::Engine.register_rex_features
42
31
  end
43
32
 
@@ -50,10 +39,72 @@ module ForemanCveScanner
50
39
  def self.register_rex_features
51
40
  RemoteExecutionFeature.register(
52
41
  :run_cve_scan,
53
- N_('Run a CVE scan on a host'),
54
- description: N_('Run CVE scan on host'),
55
- host_action_button: true
42
+ N_('Run CVE scan'),
43
+ description: N_('Run CVE scan'),
44
+ host_action_button: true,
45
+ provided_inputs: %w[scanner]
56
46
  )
57
47
  end
48
+
49
+ def self.register_plugin
50
+ Foreman::Plugin.register :foreman_cve_scanner do
51
+ requires_foreman '>= 3.13'
52
+ register_global_js_file 'fills'
53
+ apipie_documented_controllers ForemanCveScanner::Engine.documented_controllers
54
+ extend_template_helpers ForemanCveScanner::TemplateHelpers
55
+ ForemanCveScanner::Engine.register_settings(self)
56
+ ForemanCveScanner::Engine.register_permissions(self)
57
+ add_all_permissions_to_default_roles
58
+ end
59
+ end
60
+
61
+ def self.documented_controllers
62
+ ["#{ForemanCveScanner::Engine.root}/app/controllers/api/v2/*.rb"]
63
+ end
64
+
65
+ def self.register_katello_integration
66
+ return unless Foreman::Plugin.installed?(:katello)
67
+ return unless defined?(::Katello::Host::ProfilesUploader)
68
+ ::Katello::Host::ProfilesUploader.prepend(ForemanCveScanner::ProfilesUploader)
69
+ end
70
+
71
+ def self.register_settings(plugin)
72
+ plugin.settings do
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
+ )
92
+ end
93
+ end
94
+ end
95
+
96
+ def self.register_permissions(plugin)
97
+ plugin.security_block :foreman_cve_scanner do
98
+ permission :view_cve_scans,
99
+ { 'api/v2/cve_scans': %i[index latest show export compare] },
100
+ resource_type: 'Host'
101
+ permission :import_cve_scans,
102
+ { 'api/v2/cve_scans': %i[import] },
103
+ resource_type: 'Host'
104
+ permission :destroy_cve_scans,
105
+ { 'api/v2/cve_scans': %i[destroy] },
106
+ resource_type: 'Host'
107
+ end
108
+ end
58
109
  end
59
110
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForemanCveScanner
4
+ module TemplateHelpers
5
+ extend ApipieDSL::Class
6
+
7
+ apipie :class, 'Macros related to Foreman CVE Scanner templates' do
8
+ name 'Foreman CVE Scanner'
9
+ sections only: %w[all jobs]
10
+ end
11
+
12
+ apipie :method, 'Returns values from Foreman CVE Scanner plugin settings' do
13
+ desc 'Use this macro in templates to access CVE Scanner settings in safe mode'
14
+ param :setting_name, String, desc: 'Setting name to resolve'
15
+ returns String, desc: 'Value of the requested CVE Scanner setting'
16
+ raises error: 'Foreman::Exception', desc: 'Raised when an unknown setting name is requested'
17
+ example "foreman_cve_scanner('preferred_scanner') #=> 'trivy'"
18
+ end
19
+ def foreman_cve_scanner(setting_name)
20
+ case setting_name.to_s
21
+ when 'preferred_scanner'
22
+ Setting[:preferred_cve_scanner]
23
+ else
24
+ raise ::Foreman::Exception, _('Unknown Foreman CVE Scanner template setting')
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanCveScanner
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -14,3 +14,14 @@ namespace :test do
14
14
  end
15
15
 
16
16
  Rake::Task[:test].enhance ['test:foreman_cve_scanner']
17
+
18
+ namespace :foreman_cve_scanner do
19
+ desc 'Delete old CVE scans using the configured retention or a DAYS override'
20
+ task cleanup_scans: :environment do
21
+ override = ENV['DAYS']
22
+ deleted = ForemanCveScanner::ScanCleanup.new(days: override.presence).cleanup!
23
+ basis = override.present? ? "DAYS=#{override}" : 'the configured retention'
24
+
25
+ puts "Deleted #{deleted} CVE scans using #{basis}."
26
+ end
27
+ end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foreman_cve_scanner",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Run CVE scan on host and collect report",
5
5
  "main": "webpack/index.js",
6
6
  "directories": {
@@ -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: @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
- # rubocop:enable Metrics/MethodLength
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