foreman_cve_scanner 0.6.1 → 0.7.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 (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 +96 -0
  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 +21 -1
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module ForemanCveScanner
6
+ class KatelloFixAvailabilityTest < ActiveSupport::TestCase
7
+ Erratum = Struct.new(
8
+ :id,
9
+ :errata_id,
10
+ :title,
11
+ :severity,
12
+ :cves,
13
+ :packages,
14
+ :deb_packages
15
+ )
16
+ Cve = Struct.new(:cve_id)
17
+ Package = Struct.new(:name)
18
+ ContentFacet = Struct.new(:installable_errata, :applicable_errata)
19
+
20
+ class ErrataRelation
21
+ include Enumerable
22
+
23
+ attr_reader :search_queries
24
+
25
+ def initialize(errata)
26
+ @errata = errata
27
+ @search_queries = []
28
+ end
29
+
30
+ def each(&block)
31
+ @errata.each(&block)
32
+ end
33
+
34
+ def security
35
+ self
36
+ end
37
+
38
+ def includes(*)
39
+ self
40
+ end
41
+
42
+ def search_for(query)
43
+ @search_queries << query
44
+ self
45
+ end
46
+
47
+ def distinct
48
+ @errata.uniq
49
+ end
50
+ end
51
+
52
+ def setup
53
+ @host = FactoryBot.create(:host)
54
+ @findings = [
55
+ {
56
+ 'id' => 'CVE-2026-0001',
57
+ 'name' => 'openssl',
58
+ 'severity' => 'HIGH',
59
+ },
60
+ ]
61
+ end
62
+
63
+ test 'does not add katello enrichment when katello is not installed' do
64
+ service = KatelloFixAvailability.new(@host, @findings)
65
+ Foreman::Plugin.stubs(:installed?).with(:katello).returns(false)
66
+ service.expects(:enabled?).never
67
+
68
+ enriched_findings = service.call
69
+
70
+ assert_equal @findings, enriched_findings
71
+ assert_not enriched_findings.first.key?('katello_fix')
72
+ end
73
+
74
+ test 'does not add katello enrichment when disabled' do
75
+ service = KatelloFixAvailability.new(@host, @findings)
76
+ service.stubs(:enabled?).returns(false)
77
+ service.expects(:installable_errata).never
78
+
79
+ enriched_findings = service.call
80
+
81
+ assert_equal @findings, enriched_findings
82
+ assert_not enriched_findings.first.key?('katello_fix')
83
+ end
84
+
85
+ test 'adds unknown enrichment when katello lookup fails' do
86
+ service = KatelloFixAvailability.new(@host, @findings)
87
+ service.stubs(:enabled?).returns(true)
88
+ service.stubs(:katello_available?).returns(true)
89
+ service.stubs(:installable_errata).raises(NameError)
90
+
91
+ enriched_findings = service.call
92
+ fix = enriched_findings.first['katello_fix']
93
+
94
+ assert_equal 'unknown', fix['status']
95
+ assert_equal 'query_failed', fix['reason']
96
+ end
97
+
98
+ test 'marks matching installable errata as installable' do
99
+ service = KatelloFixAvailability.new(@host, @findings)
100
+ erratum = erratum_for('CVE-2026-0001', 'openssl')
101
+ service.stubs(:enabled?).returns(true)
102
+ service.stubs(:katello_available?).returns(true)
103
+ service.stubs(:installable_errata).returns([erratum])
104
+ service.stubs(:applicable_errata).returns([])
105
+
106
+ enriched_findings = service.call
107
+ fix = enriched_findings.first['katello_fix']
108
+
109
+ assert_equal 'installable', fix['status']
110
+ assert_equal 'package_match', fix['confidence']
111
+ assert_equal(
112
+ ['RHSA-2026:0001'],
113
+ fix['errata'].map { |item| item['errata_id'] }
114
+ )
115
+ end
116
+
117
+ test 'marks matching applicable errata as applicable when not installable' do
118
+ service = KatelloFixAvailability.new(@host, @findings)
119
+ erratum = erratum_for('CVE-2026-0001', 'openssl')
120
+ service.stubs(:enabled?).returns(true)
121
+ service.stubs(:katello_available?).returns(true)
122
+ service.stubs(:installable_errata).returns([])
123
+ service.stubs(:applicable_errata).returns([erratum])
124
+
125
+ enriched_findings = service.call
126
+ fix = enriched_findings.first['katello_fix']
127
+
128
+ assert_equal 'applicable', fix['status']
129
+ assert_equal 'package_match', fix['confidence']
130
+ end
131
+
132
+ test 'looks up errata through host content facet cve search' do
133
+ installable_erratum = erratum_for('CVE-2026-0001', 'openssl')
134
+ installable_relation = ErrataRelation.new([installable_erratum])
135
+ applicable_relation = ErrataRelation.new([])
136
+ content_facet = ContentFacet.new(installable_relation, applicable_relation)
137
+
138
+ @host.stubs(:content_facet).returns(content_facet)
139
+ service = KatelloFixAvailability.new(@host, @findings)
140
+ service.stubs(:enabled?).returns(true)
141
+ service.stubs(:katello_available?).returns(true)
142
+
143
+ enriched_findings = service.call
144
+
145
+ assert_equal ['cve = "CVE-2026-0001"'], installable_relation.search_queries
146
+ assert_equal ['cve = "CVE-2026-0001"'], applicable_relation.search_queries
147
+ assert_equal 'installable', enriched_findings.first['katello_fix']['status']
148
+ end
149
+
150
+ private
151
+
152
+ def erratum_for(cve_id, package_name)
153
+ Erratum.new(
154
+ 1,
155
+ 'RHSA-2026:0001',
156
+ 'Security update',
157
+ 'Important',
158
+ [Cve.new(cve_id)],
159
+ [Package.new(package_name)],
160
+ []
161
+ )
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module ForemanCveScanner
6
+ class LatestScanSummariesTest < ActiveSupport::TestCase
7
+ def setup
8
+ @host = FactoryBot.create(:host)
9
+ @other_host = FactoryBot.create(:host)
10
+ end
11
+
12
+ test 'call returns the latest scan summary for each requested host' do
13
+ create_scan(host: @host, scanned_at: 2.hours.ago, total: 1, metrics: { low: 1 })
14
+ latest_for_host = create_scan(host: @host, scanned_at: 1.hour.ago, total: 2, metrics: { high: 2 })
15
+ latest_for_other_host = create_scan(
16
+ host: @other_host,
17
+ scanned_at: 30.minutes.ago,
18
+ total: 3,
19
+ metrics: { medium: 3 }
20
+ )
21
+
22
+ results = LatestScanSummaries.new(
23
+ host_scope: Host::Base.where(id: [@host.id, @other_host.id]),
24
+ host_ids: [@host.id, @other_host.id]
25
+ ).call
26
+ summaries = results.index_by { |row| row[:host_id] }
27
+
28
+ assert_equal latest_for_host.id, summaries[@host.id][:id]
29
+ assert_equal latest_for_other_host.id, summaries[@other_host.id][:id]
30
+ assert_equal 2, summaries[@host.id][:total]
31
+ assert_equal 3, summaries[@other_host.id][:total]
32
+ assert_equal 3, summaries[@other_host.id][:medium]
33
+ end
34
+
35
+ test 'call ignores duplicate invalid and out of scope host ids' do
36
+ latest_for_host = create_scan(host: @host, scanned_at: 1.hour.ago, total: 2, metrics: { high: 2 })
37
+ create_scan(host: @other_host, scanned_at: 30.minutes.ago, total: 3, metrics: { medium: 3 })
38
+
39
+ results = LatestScanSummaries.new(
40
+ host_scope: Host::Base.where(id: @host.id),
41
+ host_ids: [@host.id, @host.id, @other_host.id, 'nope', nil]
42
+ ).call
43
+
44
+ assert_equal 1, results.size
45
+ assert_equal @host.id, results[0][:host_id]
46
+ assert_equal latest_for_host.id, results[0][:id]
47
+ end
48
+
49
+ test 'call prefers higher id when scans share the same scanned_at' do
50
+ timestamp = 1.hour.ago
51
+ older_id_scan = create_scan(host: @host, scanned_at: timestamp, total: 1, metrics: { low: 1 })
52
+ newer_id_scan = create_scan(host: @host, scanned_at: timestamp, total: 2, metrics: { high: 2 })
53
+
54
+ results = LatestScanSummaries.new(
55
+ host_scope: Host::Base.where(id: @host.id),
56
+ host_ids: [@host.id]
57
+ ).call
58
+
59
+ assert_equal 1, results.size
60
+ assert_equal newer_id_scan.id, results[0][:id]
61
+ assert_operator newer_id_scan.id, :>, older_id_scan.id
62
+ end
63
+
64
+ private
65
+
66
+ def create_scan(host:, scanned_at:, total:, metrics: {})
67
+ critical = metrics.fetch(:critical, 0)
68
+ high = metrics.fetch(:high, 0)
69
+ medium = metrics.fetch(:medium, 0)
70
+ low = metrics.fetch(:low, 0)
71
+
72
+ ForemanCveScanner::CveScan.create!(
73
+ host: host,
74
+ scanner: 'trivy',
75
+ source: 'rex',
76
+ scanned_at: scanned_at,
77
+ raw: { 'dummy' => true },
78
+ summary: {
79
+ 'worst' => ForemanCveScanner::CveScan.worst_severity(
80
+ 'critical' => critical,
81
+ 'high' => high,
82
+ 'medium' => medium,
83
+ 'low' => low
84
+ ),
85
+ },
86
+ findings: total.zero? ? [] : [{ 'id' => 'CVE-0000-0000' }],
87
+ total: total,
88
+ critical: critical,
89
+ high: high,
90
+ medium: medium,
91
+ low: low
92
+ )
93
+ end
94
+ end
95
+ end
@@ -39,6 +39,34 @@ module ForemanCveScanner
39
39
  assert_equal @second_scan.scanner, comparison[:current][:scanner]
40
40
  end
41
41
 
42
+ test 'compare keeps duplicate cve ids separate across packages' do
43
+ first_scan = create_scan(
44
+ scanned_at: 4.hours.ago,
45
+ findings: [
46
+ finding('CVE-1', 'openssl', 'HIGH', '1.0'),
47
+ finding('CVE-1', 'curl', 'LOW', '1.0'),
48
+ ]
49
+ )
50
+ second_scan = create_scan(
51
+ scanned_at: 3.hours.ago,
52
+ findings: [
53
+ finding('CVE-1', 'openssl', 'CRITICAL', '1.0'),
54
+ finding('CVE-1', 'curl', 'LOW', '1.0'),
55
+ ]
56
+ )
57
+
58
+ comparison = ScanComparison.compare(first_scan, second_scan)
59
+ rows = comparison[:results].to_h { |row| [row[:key], row] }
60
+
61
+ assert_equal 2, comparison[:results].size
62
+ assert_equal 'updated', rows['CVE-1::openssl'][:status]
63
+ assert_equal 'unchanged', rows['CVE-1::curl'][:status]
64
+ assert_equal(
65
+ { old: 'HIGH', new: 'CRITICAL' },
66
+ rows['CVE-1::openssl'][:diff]['severity']
67
+ )
68
+ end
69
+
42
70
  private
43
71
 
44
72
  def finding(id, name, severity, version)
@@ -58,6 +58,24 @@ module ForemanCveScanner
58
58
  assert_operator scan.total, :>, 0
59
59
  end
60
60
 
61
+ test 'import_for_host! ignores noise outside markers' do
62
+ output = [
63
+ 'stderr noise before',
64
+ '===START',
65
+ load_fixture('trivy.json'),
66
+ '===END',
67
+ 'stderr noise after',
68
+ ].join("\n")
69
+ importer = ForemanCveScanner::ScanImporter.new(output)
70
+
71
+ scan = importer.import_for_host!(@host)
72
+
73
+ assert_not_nil scan
74
+ assert_equal 'trivy', scan.scanner
75
+ assert_operator scan.total, :>, 0
76
+ assert_equal scan.total, scan.findings.count
77
+ end
78
+
61
79
  test 'import_for_host! raises when no json markers' do
62
80
  importer = ForemanCveScanner::ScanImporter.new('no markers here')
63
81
 
@@ -130,6 +148,43 @@ module ForemanCveScanner
130
148
  assert_equal 'none', scan.summary['worst']
131
149
  end
132
150
 
151
+ test 'import_for_host! persists duplicate cves for different packages' do
152
+ output = wrap_output(
153
+ {
154
+ 'Results' => [
155
+ {
156
+ 'Vulnerabilities' => [
157
+ {
158
+ 'VulnerabilityID' => 'CVE-2026-0001',
159
+ 'PkgName' => 'openssl',
160
+ 'InstalledVersion' => '1.0',
161
+ 'Title' => 'openssl issue',
162
+ 'Severity' => 'HIGH',
163
+ 'PrimaryURL' => 'https://example.com/openssl',
164
+ },
165
+ {
166
+ 'VulnerabilityID' => 'CVE-2026-0001',
167
+ 'PkgName' => 'curl',
168
+ 'InstalledVersion' => '2.0',
169
+ 'Title' => 'curl issue',
170
+ 'Severity' => 'MEDIUM',
171
+ 'PrimaryURL' => 'https://example.com/curl',
172
+ },
173
+ ],
174
+ },
175
+ ],
176
+ }.to_json
177
+ )
178
+ importer = ForemanCveScanner::ScanImporter.new(output)
179
+
180
+ scan = importer.import_for_host!(@host)
181
+ findings = scan.findings.map { |finding| [finding['id'], finding['name']] }
182
+
183
+ assert_equal 2, scan.findings.size
184
+ assert_equal [%w[CVE-2026-0001 openssl], %w[CVE-2026-0001 curl]], findings
185
+ assert_equal [2, 1, 1], [scan.total, scan.high, scan.medium]
186
+ end
187
+
133
188
  private
134
189
 
135
190
  def load_fixture(name)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module ForemanCveScanner
6
+ class ScanPostProcessorTest < ActiveSupport::TestCase
7
+ def setup
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
14
+ end
15
+
16
+ test 'refreshes cve host status' do
17
+ create_scan(critical: 1)
18
+
19
+ assert_difference('HostStatus::CveStatus.count', 1) do
20
+ ScanPostProcessor.new(@host).call
21
+ end
22
+
23
+ status = HostStatus::CveStatus.find_by(host: @host)
24
+ assert_equal HostStatus::CveStatus::CVE_SCANNER_STATUS_CRITICAL_HIGH, status.status
25
+ end
26
+
27
+ test 'cleans up old scans for host using retention setting' do
28
+ Setting[:cve_scan_delete_after_days] = 1
29
+ old_scan = create_scan(scanned_at: 3.days.ago)
30
+ kept_scan = create_scan(scanned_at: Time.current)
31
+
32
+ ScanPostProcessor.new(@host).call
33
+
34
+ assert_not CveScan.exists?(old_scan.id)
35
+ assert CveScan.exists?(kept_scan.id)
36
+ end
37
+
38
+ private
39
+
40
+ def create_scan(options = {})
41
+ critical = options.fetch(:critical, 0)
42
+ scanned_at = options.fetch(:scanned_at, Time.current)
43
+ CveScan.create!(
44
+ host: @host,
45
+ scanner: 'trivy',
46
+ source: 'rex',
47
+ scanned_at: scanned_at,
48
+ raw: { 'dummy' => true },
49
+ summary: { 'worst' => critical.positive? ? 'critical' : 'none' },
50
+ findings: critical.positive? ? [{ 'id' => 'CVE-0000-0000' }] : [],
51
+ total: critical,
52
+ critical: critical,
53
+ high: 0,
54
+ medium: 0,
55
+ low: 0
56
+ )
57
+ end
58
+ end
59
+ end
@@ -22,6 +22,8 @@ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
22
22
  import { foremanUrl } from 'foremanReact/common/helpers';
23
23
  import { translate as __ } from 'foremanReact/common/I18n';
24
24
  import { STATUS } from 'foremanReact/constants';
25
+ import CveLink from './CveLink';
26
+ import useSortBy from './useSortBy';
25
27
  import {
26
28
  comparisonDiffEntries,
27
29
  comparisonColumns,
@@ -39,10 +41,6 @@ import './cve_scans.scss';
39
41
  const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
40
42
  const [filter, setFilter] = useState('all');
41
43
  const [search, setSearch] = useState('');
42
- const [sortBy, setSortBy] = useState({
43
- direction: SortByDirection.desc,
44
- column: 'status',
45
- });
46
44
  const previousScanId = scanIds[0];
47
45
  const currentScanId = scanIds[1];
48
46
  const compareUrl =
@@ -62,7 +60,7 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
62
60
  direction: SortByDirection.desc,
63
61
  column: 'status',
64
62
  });
65
- }, [currentScanId, isOpen, previousScanId]);
63
+ }, [currentScanId, isOpen, previousScanId, setSortBy]);
66
64
 
67
65
  const comparison = compareRequest.response || {};
68
66
  const previousScan = comparison.previous || {};
@@ -88,15 +86,11 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
88
86
  if (!normalizedSearch) return rows;
89
87
  return rows.filter(row => comparisonMatchesSearch(row, normalizedSearch));
90
88
  }, [comparisonRows, filter, normalizedSearch]);
91
- const sortedRows = useMemo(() => {
92
- const rows = [...filteredRows];
93
- const sorter = comparisonSorters[sortBy.column] || comparisonSorters.status;
94
- rows.sort((a, b) => {
95
- const result = sorter(a, b);
96
- return sortBy.direction === SortByDirection.asc ? result : -result;
97
- });
98
- return rows;
99
- }, [filteredRows, sortBy]);
89
+ const { onSort, sortedItems: sortedRows, setSortBy } = useSortBy(
90
+ 'status',
91
+ comparisonSorters,
92
+ filteredRows
93
+ );
100
94
  const status = compareRequest.status || STATUS.PENDING;
101
95
  const subtitle =
102
96
  previousScan.scanned_at && currentScan.scanned_at
@@ -105,13 +99,6 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
105
99
  )}`
106
100
  : __('Compare CVE reports');
107
101
 
108
- const onSort = column => {
109
- const nextDirection =
110
- sortBy.column === column && sortBy.direction === SortByDirection.asc
111
- ? SortByDirection.desc
112
- : SortByDirection.asc;
113
- setSortBy({ column, direction: nextDirection });
114
- };
115
102
  const onSearchChange = (value, event) => {
116
103
  setSearch(normalizeSearchInputValue(value, event));
117
104
  };
@@ -254,13 +241,7 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
254
241
  {comparisonStatusLabels[row.status]}
255
242
  </Td>
256
243
  <Td dataLabel={__('CVE')}>
257
- {row.url ? (
258
- <a href={row.url} target="_blank" rel="noreferrer">
259
- {row.id}
260
- </a>
261
- ) : (
262
- row.id
263
- )}
244
+ <CveLink id={row.id} url={row.url} />
264
245
  </Td>
265
246
  <Td dataLabel={__('Package')}>{row.name}</Td>
266
247
  <Td dataLabel={__('Severity')}>{row.severity}</Td>
@@ -28,18 +28,29 @@ import SeverityIcon from './SeverityIcon';
28
28
  import CveFindingsModal from './CveFindingsModal';
29
29
  import useModalScan from './useModalScan';
30
30
  import {
31
+ findingIdentity,
31
32
  noFindingsBody,
32
33
  noFindingsTitle,
33
34
  formatScanOrigin,
35
+ katelloFixCounts,
34
36
  noReportsBody,
35
37
  noReportsTitle,
36
38
  riskLevelFromWorst,
37
39
  severityRank,
40
+ shouldShowKatelloFixColumn,
38
41
  } from './cve_helpers';
39
42
  import './cve_scans.scss';
40
43
 
44
+ const SEVERITY_COUNT_CARDS = [
45
+ { key: 'critical', label: __('critical') },
46
+ { key: 'high', label: __('high') },
47
+ { key: 'medium', label: __('medium') },
48
+ { key: 'low', label: __('low') },
49
+ ];
50
+
41
51
  const CveDetailsCard = ({ hostDetails }) => {
42
52
  const hostId = hostDetails?.id;
53
+ const hostName = hostDetails?.name;
43
54
  const latestUrl = hostId
44
55
  ? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
45
56
  : null;
@@ -71,6 +82,8 @@ const CveDetailsCard = ({ hostDetails }) => {
71
82
  const worst = latest?.summary?.worst || 'none';
72
83
  const riskLevel = riskLevelFromWorst(worst);
73
84
  const origin = formatScanOrigin(latest?.scanner, latest?.source);
85
+ const showKatelloFixSummary = shouldShowKatelloFixColumn(findings);
86
+ const fixCounts = katelloFixCounts(findings);
74
87
  return (
75
88
  <CardTemplate header={__('CVE scan details')} expandable masonryLayout>
76
89
  <SkeletonLoader status={latestStatus || STATUS.PENDING}>
@@ -119,72 +132,44 @@ const CveDetailsCard = ({ hostDetails }) => {
119
132
  </span>
120
133
  </DescriptionListDescription>
121
134
  </DescriptionListGroup>
135
+ {showKatelloFixSummary && (
136
+ <DescriptionListGroup>
137
+ <DescriptionListTerm>
138
+ {__('Fix availability')}
139
+ </DescriptionListTerm>
140
+ <DescriptionListDescription>
141
+ <span className="cve-katello-summary">
142
+ <span className="cve-katello-fix cve-katello-fix--installable">
143
+ {`${__('Fixable')}: ${fixCounts.installable}`}
144
+ </span>
145
+ <span className="cve-katello-fix cve-katello-fix--applicable">
146
+ {`${__('Applicable')}: ${fixCounts.applicable}`}
147
+ </span>
148
+ </span>
149
+ </DescriptionListDescription>
150
+ </DescriptionListGroup>
151
+ )}
122
152
  </DescriptionList>
123
153
  <div className="cve-counts cve-counts--compact">
124
- <Button
125
- variant="plain"
126
- className="cve-count-card cve-count-card--critical"
127
- onClick={() => openModal(latest.id, 'critical')}
128
- ouiaId="cve-details-critical-button"
129
- >
130
- <span className="cve-count">
131
- <span className="cve-count-label">
132
- <SeverityIcon severity="critical" />
133
- <span>{__('critical')}</span>
134
- </span>
135
- <span className="cve-bubble cve-bubble--critical">
136
- {latest.critical}
137
- </span>
138
- </span>
139
- </Button>
140
- <Button
141
- variant="plain"
142
- className="cve-count-card cve-count-card--medium"
143
- onClick={() => openModal(latest.id, 'medium')}
144
- ouiaId="cve-details-medium-button"
145
- >
146
- <span className="cve-count">
147
- <span className="cve-count-label">
148
- <SeverityIcon severity="medium" />
149
- <span>{__('medium')}</span>
150
- </span>
151
- <span className="cve-bubble cve-bubble--medium">
152
- {latest.medium}
153
- </span>
154
- </span>
155
- </Button>
156
- <Button
157
- variant="plain"
158
- className="cve-count-card cve-count-card--high"
159
- onClick={() => openModal(latest.id, 'high')}
160
- ouiaId="cve-details-high-button"
161
- >
162
- <span className="cve-count">
163
- <span className="cve-count-label">
164
- <SeverityIcon severity="high" />
165
- <span>{__('high')}</span>
166
- </span>
167
- <span className="cve-bubble cve-bubble--high">
168
- {latest.high}
169
- </span>
170
- </span>
171
- </Button>
172
- <Button
173
- variant="plain"
174
- className="cve-count-card cve-count-card--low"
175
- onClick={() => openModal(latest.id, 'low')}
176
- ouiaId="cve-details-low-button"
177
- >
178
- <span className="cve-count">
179
- <span className="cve-count-label">
180
- <SeverityIcon severity="low" />
181
- <span>{__('low')}</span>
182
- </span>
183
- <span className="cve-bubble cve-bubble--low">
184
- {latest.low}
154
+ {SEVERITY_COUNT_CARDS.map(card => (
155
+ <Button
156
+ key={card.key}
157
+ variant="plain"
158
+ className={`cve-count-card cve-count-card--${card.key}`}
159
+ onClick={() => openModal(latest.id, card.key)}
160
+ ouiaId={`cve-details-${card.key}-button`}
161
+ >
162
+ <span className="cve-count">
163
+ <span className="cve-count-label">
164
+ <SeverityIcon severity={card.key} />
165
+ <span>{card.label}</span>
166
+ </span>
167
+ <span className={`cve-bubble cve-bubble--${card.key}`}>
168
+ {latest[card.key]}
169
+ </span>
185
170
  </span>
186
- </span>
187
- </Button>
171
+ </Button>
172
+ ))}
188
173
  </div>
189
174
  </div>
190
175
 
@@ -225,7 +210,7 @@ const CveDetailsCard = ({ hostDetails }) => {
225
210
  <Tbody>
226
211
  {visibleFindings.map((finding, index) => (
227
212
  <Tr
228
- key={finding.id}
213
+ key={findingIdentity(finding)}
229
214
  ouiaId={`cve-details-finding-row-${index}`}
230
215
  >
231
216
  <Td dataLabel={__('Severity')}>
@@ -263,6 +248,7 @@ const CveDetailsCard = ({ hostDetails }) => {
263
248
  isOpen={isOpen}
264
249
  onClose={closeModal}
265
250
  hostId={hostId}
251
+ hostName={hostName}
266
252
  scanId={scanId}
267
253
  initialFilter={filter}
268
254
  />
@@ -270,7 +256,10 @@ const CveDetailsCard = ({ hostDetails }) => {
270
256
  );
271
257
  };
272
258
  CveDetailsCard.propTypes = {
273
- hostDetails: PropTypes.shape({ id: PropTypes.number }),
259
+ hostDetails: PropTypes.shape({
260
+ id: PropTypes.number,
261
+ name: PropTypes.string,
262
+ }),
274
263
  };
275
264
  CveDetailsCard.defaultProps = {
276
265
  hostDetails: undefined,