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
@@ -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
- created_at: Time.now.utc,
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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module ForemanCveScanner
6
+ class ScanComparisonTest < ActiveSupport::TestCase
7
+ def setup
8
+ @host = FactoryBot.create(:host)
9
+ @first_scan = create_scan(
10
+ scanned_at: 2.hours.ago,
11
+ findings: first_findings
12
+ )
13
+ @second_scan = create_scan(
14
+ scanned_at: 1.hour.ago,
15
+ findings: second_findings
16
+ )
17
+ end
18
+
19
+ test 'compare builds summary and result rows' do
20
+ comparison = ScanComparison.compare(@first_scan, @second_scan)
21
+ statuses = comparison[:results].to_h { |row| [row[:id], row] }
22
+
23
+ assert_equal 1, comparison[:summary]['updated']
24
+ assert_equal 1, comparison[:summary]['resolved']
25
+ assert_equal 1, comparison[:summary]['new']
26
+ assert_equal 'updated', statuses['CVE-1'][:status]
27
+ assert_equal(
28
+ { old: 'HIGH', new: 'CRITICAL' },
29
+ statuses['CVE-1'][:diff]['severity']
30
+ )
31
+ end
32
+
33
+ test 'compare includes previous and current scan metadata' do
34
+ comparison = ScanComparison.compare(@first_scan, @second_scan)
35
+
36
+ assert_equal @first_scan.id, comparison[:previous][:id]
37
+ assert_equal @second_scan.id, comparison[:current][:id]
38
+ assert_equal @first_scan.scanner, comparison[:previous][:scanner]
39
+ assert_equal @second_scan.scanner, comparison[:current][:scanner]
40
+ end
41
+
42
+ private
43
+
44
+ def finding(id, name, severity, version)
45
+ {
46
+ 'id' => id,
47
+ 'name' => name,
48
+ 'severity' => severity,
49
+ 'version' => version,
50
+ }
51
+ end
52
+
53
+ def first_findings
54
+ [
55
+ finding('CVE-1', 'openssl', 'HIGH', '1.0'),
56
+ finding('CVE-2', 'curl', 'LOW', '1.0'),
57
+ ]
58
+ end
59
+
60
+ def second_findings
61
+ [
62
+ finding('CVE-1', 'openssl', 'CRITICAL', '1.0'),
63
+ finding('CVE-3', 'glibc', 'HIGH', '1.0'),
64
+ ]
65
+ end
66
+
67
+ def create_scan(scanned_at:, findings:)
68
+ ForemanCveScanner::CveScan.create!(
69
+ host: @host,
70
+ scanner: 'trivy',
71
+ source: 'rex',
72
+ scanned_at: scanned_at,
73
+ raw: { 'dummy' => true },
74
+ summary: { 'worst' => 'high' },
75
+ findings: findings,
76
+ total: findings.size,
77
+ critical: findings.count { |finding| finding['severity'] == 'CRITICAL' },
78
+ high: findings.count { |finding| finding['severity'] == 'HIGH' },
79
+ medium: findings.count { |finding| finding['severity'] == 'MEDIUM' },
80
+ low: findings.count { |finding| finding['severity'] == 'LOW' }
81
+ )
82
+ end
83
+ end
84
+ end
@@ -6,6 +6,11 @@ module ForemanCveScanner
6
6
  class ScanImporterTest < ActiveSupport::TestCase
7
7
  def setup
8
8
  @host = FactoryBot.create(:host)
9
+ @previous_retention = Setting[:cve_scan_delete_after_days]
10
+ end
11
+
12
+ def teardown
13
+ Setting[:cve_scan_delete_after_days] = @previous_retention
9
14
  end
10
15
 
11
16
  test 'import_for_host! persists scan from trivy output' do
@@ -19,6 +24,7 @@ module ForemanCveScanner
19
24
  assert_not_nil scan
20
25
  assert_equal @host.id, scan.host_id
21
26
  assert_equal 'trivy', scan.scanner
27
+ assert_equal 'rex', scan.source
22
28
  end
23
29
 
24
30
  test 'import_for_host! sets totals and findings for trivy output' do
@@ -29,6 +35,7 @@ module ForemanCveScanner
29
35
 
30
36
  assert_operator scan.total, :>, 0
31
37
  assert_equal scan.total, scan.findings.count
38
+ assert_not_nil scan.scanned_at
32
39
  end
33
40
 
34
41
  test 'import_for_host! persists scan from proxy output' do
@@ -51,20 +58,76 @@ module ForemanCveScanner
51
58
  assert_operator scan.total, :>, 0
52
59
  end
53
60
 
54
- test 'import_for_host! returns nil when no json markers' do
61
+ test 'import_for_host! raises when no json markers' do
55
62
  importer = ForemanCveScanner::ScanImporter.new('no markers here')
56
63
 
64
+ assert_raises(::Foreman::Exception) do
65
+ importer.import_for_host!(@host)
66
+ end
67
+ end
68
+
69
+ test 'import_for_host! raises when json is invalid' do
70
+ importer = ForemanCveScanner::ScanImporter.new("===START\n{bad\n===END")
71
+
72
+ assert_raises(::Foreman::Exception) do
73
+ importer.import_for_host!(@host)
74
+ end
75
+ end
76
+
77
+ test 'import_for_host! raises when marker block is empty' do
78
+ importer = ForemanCveScanner::ScanImporter.new("===START\n===END")
79
+
80
+ assert_raises(::Foreman::Exception) do
81
+ importer.import_for_host!(@host)
82
+ end
83
+ end
84
+
85
+ test 'import_for_host! cleans up old scans for the host using retention setting' do
86
+ Setting[:cve_scan_delete_after_days] = 1
87
+ old_scan = ForemanCveScanner::CveScan.create!(
88
+ host: @host,
89
+ scanner: 'trivy',
90
+ source: 'rex',
91
+ scanned_at: 3.days.ago,
92
+ raw: { 'dummy' => true },
93
+ summary: { 'worst' => 'low' },
94
+ findings: [{ 'id' => 'CVE-0000-0000' }],
95
+ total: 1,
96
+ critical: 0,
97
+ high: 0,
98
+ medium: 0,
99
+ low: 1
100
+ )
101
+ output = wrap_output(load_fixture('trivy.json'))
102
+ importer = ForemanCveScanner::ScanImporter.new(output)
103
+
57
104
  scan = importer.import_for_host!(@host)
58
105
 
59
- assert_nil scan
106
+ assert_not_nil scan
107
+ assert_not ForemanCveScanner::CveScan.exists?(old_scan.id)
108
+ assert ForemanCveScanner::CveScan.exists?(scan.id)
60
109
  end
61
110
 
62
- test 'import_for_host! returns nil when json is invalid' do
63
- importer = ForemanCveScanner::ScanImporter.new("===START\n{bad\n===END")
111
+ test 'import_for_host! persists scan with zero findings' do
112
+ output = wrap_output(
113
+ {
114
+ 'Results' => [
115
+ {
116
+ 'Target' => '/',
117
+ 'Class' => 'os-pkgs',
118
+ },
119
+ ],
120
+ }.to_json
121
+ )
122
+ importer = ForemanCveScanner::ScanImporter.new(output)
64
123
 
65
124
  scan = importer.import_for_host!(@host)
66
125
 
67
- assert_nil scan
126
+ assert_not_nil scan
127
+ assert_equal 'trivy', scan.scanner
128
+ assert_empty scan.findings
129
+ assert_equal 0, scan.total
130
+ assert_equal 'none', scan.summary['worst']
68
131
  end
69
132
 
70
133
  private