foreman_cve_scanner 0.6.0 → 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.
- checksums.yaml +4 -4
- data/README.md +22 -4
- data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
- data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
- data/app/models/host_status/cve_status.rb +4 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
- data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
- data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
- data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
- data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
- data/config/routes.rb +1 -0
- data/lib/foreman_cve_scanner/engine.rb +28 -19
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +96 -0
- data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
- data/test/models/host_status/cve_status_test.rb +8 -0
- data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
- data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
- data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
- data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
- data/webpack/components/CveCompareModal.js +9 -28
- data/webpack/components/CveDetailsCard.js +54 -65
- data/webpack/components/CveFindingsModal.js +15 -234
- data/webpack/components/CveFindingsView.js +307 -0
- data/webpack/components/CveKatelloFixLink.js +59 -0
- data/webpack/components/CveLink.js +28 -0
- data/webpack/components/CveScansReports.js +25 -62
- data/webpack/components/CveScansTab.js +42 -9
- data/webpack/components/CveSummaryCell.js +31 -19
- data/webpack/components/CveTrendChart.js +1 -16
- data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
- data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
- data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
- data/webpack/components/__tests__/CveScansTab.test.js +141 -147
- data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
- data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
- data/webpack/components/__tests__/cve_helpers.test.js +84 -0
- data/webpack/components/cve_helpers.js +114 -0
- data/webpack/components/cve_scans.scss +49 -0
- data/webpack/components/cve_summary_store.js +91 -0
- data/webpack/components/useSortBy.js +34 -0
- metadata +24 -4
|
@@ -23,82 +23,117 @@ jest.mock('../CveCompareModal', () => ({ isOpen, scanIds }) =>
|
|
|
23
23
|
const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
|
|
24
24
|
|
|
25
25
|
describe('CveScansTab', () => {
|
|
26
|
+
const historyResponse = {
|
|
27
|
+
results: [
|
|
28
|
+
{
|
|
29
|
+
id: 1,
|
|
30
|
+
scanned_at: '2026-02-20',
|
|
31
|
+
scanner: 'trivy',
|
|
32
|
+
source: 'rex',
|
|
33
|
+
total: 10,
|
|
34
|
+
critical: 1,
|
|
35
|
+
high: 2,
|
|
36
|
+
medium: 3,
|
|
37
|
+
low: 4,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 2,
|
|
41
|
+
scanned_at: '2026-02-21',
|
|
42
|
+
scanner: 'grype',
|
|
43
|
+
source: 'external',
|
|
44
|
+
total: 5,
|
|
45
|
+
critical: 0,
|
|
46
|
+
high: 1,
|
|
47
|
+
medium: 1,
|
|
48
|
+
low: 3,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
total: 2,
|
|
52
|
+
};
|
|
53
|
+
const latestResponse = {
|
|
54
|
+
id: 2,
|
|
55
|
+
scanned_at: '2026-02-21',
|
|
56
|
+
scanner: 'grype',
|
|
57
|
+
source: 'external',
|
|
58
|
+
total: 5,
|
|
59
|
+
findings: [
|
|
60
|
+
{
|
|
61
|
+
id: 'CVE-2026-0001',
|
|
62
|
+
name: 'openssl',
|
|
63
|
+
version: '1.0',
|
|
64
|
+
fixed: '1.1',
|
|
65
|
+
status: 'open',
|
|
66
|
+
severity: 'HIGH',
|
|
67
|
+
title: 'OpenSSL issue',
|
|
68
|
+
published: '2026-02-11T00:00:00Z',
|
|
69
|
+
url: 'https://example.test/CVE-2026-0001',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const mockScanApis = ({
|
|
75
|
+
latest = latestResponse,
|
|
76
|
+
history = historyResponse,
|
|
77
|
+
latestStatus = 'RESOLVED',
|
|
78
|
+
historyStatus = 'RESOLVED',
|
|
79
|
+
} = {}) => {
|
|
80
|
+
useAPI.mockImplementation((_method, url) => {
|
|
81
|
+
if (url && url.includes('/cve_scans/latest')) {
|
|
82
|
+
return { response: latest, status: latestStatus };
|
|
83
|
+
}
|
|
84
|
+
return { response: history, status: historyStatus };
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
26
88
|
beforeEach(() => {
|
|
27
89
|
useAPI.mockReset();
|
|
28
90
|
});
|
|
29
91
|
|
|
30
|
-
it('renders
|
|
31
|
-
|
|
32
|
-
results: [
|
|
33
|
-
{
|
|
34
|
-
id: 1,
|
|
35
|
-
scanned_at: '2026-02-20',
|
|
36
|
-
scanner: 'trivy',
|
|
37
|
-
source: 'rex',
|
|
38
|
-
total: 10,
|
|
39
|
-
critical: 1,
|
|
40
|
-
high: 2,
|
|
41
|
-
medium: 3,
|
|
42
|
-
low: 4,
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
id: 2,
|
|
46
|
-
scanned_at: '2026-02-21',
|
|
47
|
-
scanner: 'grype',
|
|
48
|
-
source: 'external',
|
|
49
|
-
total: 5,
|
|
50
|
-
critical: 0,
|
|
51
|
-
high: 1,
|
|
52
|
-
medium: 1,
|
|
53
|
-
low: 3,
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
total: 2,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
|
|
92
|
+
it('renders latest scan, trends and history tabs', () => {
|
|
93
|
+
mockScanApis();
|
|
60
94
|
|
|
61
95
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
62
96
|
|
|
63
|
-
expect(wrapper.text()).toContain('
|
|
64
|
-
expect(wrapper.text()).toContain('
|
|
65
|
-
expect(wrapper.text()).toContain('
|
|
66
|
-
expect(wrapper.text()).toContain('
|
|
97
|
+
expect(wrapper.text()).toContain('Latest Scan');
|
|
98
|
+
expect(wrapper.text()).toContain('Trends');
|
|
99
|
+
expect(wrapper.text()).toContain('History');
|
|
100
|
+
expect(wrapper.text()).toContain('Report from');
|
|
101
|
+
expect(wrapper.text()).toContain('grype / external');
|
|
102
|
+
expect(wrapper.text()).toContain('openssl');
|
|
67
103
|
});
|
|
68
104
|
|
|
69
|
-
it('
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
medium: 1,
|
|
92
|
-
low: 3,
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
total: 2,
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
|
|
105
|
+
it('renders a success state when the latest scan has no findings', () => {
|
|
106
|
+
mockScanApis({
|
|
107
|
+
latest: {
|
|
108
|
+
id: 2,
|
|
109
|
+
scanned_at: '2026-02-21',
|
|
110
|
+
scanner: 'grype',
|
|
111
|
+
source: 'external',
|
|
112
|
+
total: 0,
|
|
113
|
+
findings: [],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
118
|
+
|
|
119
|
+
expect(wrapper.text()).toContain('No CVEs found');
|
|
120
|
+
expect(wrapper.text()).toContain(
|
|
121
|
+
'The latest CVE scan found no vulnerabilities for this host.'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('opens compare modal from trends selection', () => {
|
|
126
|
+
mockScanApis();
|
|
99
127
|
|
|
100
128
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
101
129
|
|
|
130
|
+
wrapper
|
|
131
|
+
.find('[role="tab"]')
|
|
132
|
+
.filterWhere(node => node.text() === 'Trends')
|
|
133
|
+
.first()
|
|
134
|
+
.simulate('click');
|
|
135
|
+
wrapper.update();
|
|
136
|
+
|
|
102
137
|
wrapper
|
|
103
138
|
.find('button')
|
|
104
139
|
.filterWhere(node => node.text() === 'Compare 2 reports')
|
|
@@ -113,42 +148,14 @@ describe('CveScansTab', () => {
|
|
|
113
148
|
expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
|
|
114
149
|
});
|
|
115
150
|
|
|
116
|
-
it('renders scan rows in the
|
|
117
|
-
|
|
118
|
-
results: [
|
|
119
|
-
{
|
|
120
|
-
id: 1,
|
|
121
|
-
scanned_at: '2026-02-20',
|
|
122
|
-
scanner: 'trivy',
|
|
123
|
-
source: 'rex',
|
|
124
|
-
total: 10,
|
|
125
|
-
critical: 1,
|
|
126
|
-
high: 2,
|
|
127
|
-
medium: 3,
|
|
128
|
-
low: 4,
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
id: 2,
|
|
132
|
-
scanned_at: '2026-02-21',
|
|
133
|
-
scanner: 'grype',
|
|
134
|
-
source: 'external',
|
|
135
|
-
total: 5,
|
|
136
|
-
critical: 0,
|
|
137
|
-
high: 1,
|
|
138
|
-
medium: 1,
|
|
139
|
-
low: 3,
|
|
140
|
-
},
|
|
141
|
-
],
|
|
142
|
-
total: 2,
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
|
|
151
|
+
it('renders scan rows in the history tab', () => {
|
|
152
|
+
mockScanApis();
|
|
146
153
|
|
|
147
154
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
148
155
|
|
|
149
156
|
wrapper
|
|
150
157
|
.find('[role="tab"]')
|
|
151
|
-
.filterWhere(node => node.text() === '
|
|
158
|
+
.filterWhere(node => node.text() === 'History')
|
|
152
159
|
.first()
|
|
153
160
|
.simulate('click');
|
|
154
161
|
wrapper.update();
|
|
@@ -161,41 +168,13 @@ describe('CveScansTab', () => {
|
|
|
161
168
|
});
|
|
162
169
|
|
|
163
170
|
it('opens compare modal when two scans are selected', () => {
|
|
164
|
-
|
|
165
|
-
results: [
|
|
166
|
-
{
|
|
167
|
-
id: 1,
|
|
168
|
-
scanned_at: '2026-02-20',
|
|
169
|
-
scanner: 'trivy',
|
|
170
|
-
source: 'rex',
|
|
171
|
-
total: 10,
|
|
172
|
-
critical: 1,
|
|
173
|
-
high: 2,
|
|
174
|
-
medium: 3,
|
|
175
|
-
low: 4,
|
|
176
|
-
},
|
|
177
|
-
{
|
|
178
|
-
id: 2,
|
|
179
|
-
scanned_at: '2026-02-21',
|
|
180
|
-
scanner: 'grype',
|
|
181
|
-
source: 'external',
|
|
182
|
-
total: 5,
|
|
183
|
-
critical: 0,
|
|
184
|
-
high: 1,
|
|
185
|
-
medium: 1,
|
|
186
|
-
low: 3,
|
|
187
|
-
},
|
|
188
|
-
],
|
|
189
|
-
total: 2,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
|
|
171
|
+
mockScanApis();
|
|
193
172
|
|
|
194
173
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
195
174
|
|
|
196
175
|
wrapper
|
|
197
176
|
.find('[role="tab"]')
|
|
198
|
-
.filterWhere(node => node.text() === '
|
|
177
|
+
.filterWhere(node => node.text() === 'History')
|
|
199
178
|
.first()
|
|
200
179
|
.simulate('click');
|
|
201
180
|
wrapper.update();
|
|
@@ -215,40 +194,55 @@ describe('CveScansTab', () => {
|
|
|
215
194
|
});
|
|
216
195
|
|
|
217
196
|
it('renders empty state with no scans', () => {
|
|
218
|
-
|
|
197
|
+
mockScanApis({ latest: null, history: { results: [] } });
|
|
219
198
|
|
|
220
199
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
221
200
|
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
222
201
|
});
|
|
223
202
|
|
|
224
|
-
it('renders trend
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
203
|
+
it('renders trend content in the trends tab when recent scans have no cves', () => {
|
|
204
|
+
mockScanApis({
|
|
205
|
+
latest: {
|
|
206
|
+
id: 1,
|
|
207
|
+
scanned_at: '2026-02-20',
|
|
208
|
+
scanner: 'trivy',
|
|
209
|
+
source: 'rex',
|
|
210
|
+
total: 0,
|
|
211
|
+
findings: [],
|
|
212
|
+
},
|
|
213
|
+
history: {
|
|
214
|
+
results: [
|
|
215
|
+
{
|
|
216
|
+
id: 1,
|
|
217
|
+
scanned_at: '2026-02-20',
|
|
218
|
+
scanner: 'trivy',
|
|
219
|
+
source: 'rex',
|
|
220
|
+
total: 0,
|
|
221
|
+
critical: 0,
|
|
222
|
+
high: 0,
|
|
223
|
+
medium: 0,
|
|
224
|
+
low: 0,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
total: 1,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
243
230
|
|
|
244
231
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
245
|
-
|
|
232
|
+
|
|
233
|
+
wrapper
|
|
234
|
+
.find('[role="tab"]')
|
|
235
|
+
.filterWhere(node => node.text() === 'Trends')
|
|
236
|
+
.first()
|
|
237
|
+
.simulate('click');
|
|
238
|
+
wrapper.update();
|
|
239
|
+
|
|
246
240
|
expect(wrapper.text()).toContain('Latest total');
|
|
247
241
|
expect(wrapper.text()).toContain('0');
|
|
248
242
|
});
|
|
249
243
|
|
|
250
244
|
it('does not render when host id is missing', () => {
|
|
251
|
-
|
|
245
|
+
mockScanApis({ latest: null, history: { results: [] } });
|
|
252
246
|
|
|
253
247
|
const wrapper = mount(<CveScansTab response={{}} />);
|
|
254
248
|
expect(wrapper.isEmptyRender()).toBe(true);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
5
|
+
import CveSummaryCell from '../CveSummaryCell';
|
|
6
|
+
import { resetCveSummaryStore } from '../cve_summary_store';
|
|
7
|
+
|
|
8
|
+
jest.mock('../CveFindingsModal', () => () => null);
|
|
9
|
+
|
|
10
|
+
const flushBatch = () =>
|
|
11
|
+
new Promise(resolve => {
|
|
12
|
+
setTimeout(resolve, 0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('CveSummaryCell', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
resetCveSummaryStore();
|
|
18
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
19
|
+
ok: true,
|
|
20
|
+
json: async () => ({
|
|
21
|
+
results: [
|
|
22
|
+
{
|
|
23
|
+
id: 11,
|
|
24
|
+
host_id: 1,
|
|
25
|
+
total: 2,
|
|
26
|
+
summary: { worst: 'high' },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 22,
|
|
30
|
+
host_id: 2,
|
|
31
|
+
total: 0,
|
|
32
|
+
summary: { worst: 'none' },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
resetCveSummaryStore();
|
|
41
|
+
delete global.fetch;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('batches host summary requests across cells', async () => {
|
|
45
|
+
let wrapper;
|
|
46
|
+
|
|
47
|
+
await act(async () => {
|
|
48
|
+
wrapper = mount(
|
|
49
|
+
<div>
|
|
50
|
+
<CveSummaryCell hostId={1} hostName="alpha" />
|
|
51
|
+
<CveSummaryCell hostId={2} hostName="beta" />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
await flushBatch();
|
|
55
|
+
await flushBatch();
|
|
56
|
+
});
|
|
57
|
+
wrapper.update();
|
|
58
|
+
|
|
59
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(global.fetch.mock.calls[0][0]).toContain(
|
|
61
|
+
'/api/v2/cve_scans/latest_by_hosts?'
|
|
62
|
+
);
|
|
63
|
+
expect(global.fetch.mock.calls[0][0]).toContain('host_ids%5B%5D=1');
|
|
64
|
+
expect(global.fetch.mock.calls[0][0]).toContain('host_ids%5B%5D=2');
|
|
65
|
+
expect(wrapper.text()).toContain('2');
|
|
66
|
+
expect(wrapper.text()).toContain('0');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -4,7 +4,7 @@ import { mount } from 'enzyme';
|
|
|
4
4
|
import CveTrendChart from '../CveTrendChart';
|
|
5
5
|
|
|
6
6
|
describe('CveTrendChart', () => {
|
|
7
|
-
it('renders the
|
|
7
|
+
it('renders the scan summary and legend', () => {
|
|
8
8
|
const wrapper = mount(
|
|
9
9
|
<CveTrendChart
|
|
10
10
|
scans={[
|
|
@@ -30,8 +30,6 @@ describe('CveTrendChart', () => {
|
|
|
30
30
|
/>
|
|
31
31
|
);
|
|
32
32
|
|
|
33
|
-
expect(wrapper.text()).toContain('Trend');
|
|
34
|
-
expect(wrapper.text()).toContain('Last 2 scans');
|
|
35
33
|
expect(wrapper.text()).toContain('Latest total');
|
|
36
34
|
expect(wrapper.text()).toContain('18');
|
|
37
35
|
expect(wrapper.text()).toContain('Critical');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
2
|
import {
|
|
3
|
+
findingIdentity,
|
|
3
4
|
severityRank,
|
|
4
5
|
riskLevelFromWorst,
|
|
5
6
|
formatDateTime,
|
|
@@ -7,6 +8,12 @@ import {
|
|
|
7
8
|
formatScannedAt,
|
|
8
9
|
compareStrings,
|
|
9
10
|
visibleScanSource,
|
|
11
|
+
katelloFixLabel,
|
|
12
|
+
katelloFixTitle,
|
|
13
|
+
katelloFixCounts,
|
|
14
|
+
katelloFixErrataSearch,
|
|
15
|
+
katelloFixInstallUrl,
|
|
16
|
+
shouldShowKatelloFixColumn,
|
|
10
17
|
} from '../cve_helpers';
|
|
11
18
|
|
|
12
19
|
describe('cve_helpers', () => {
|
|
@@ -57,4 +64,81 @@ describe('cve_helpers', () => {
|
|
|
57
64
|
expect(visibleScanSource('rex')).toBe('');
|
|
58
65
|
expect(visibleScanSource('external')).toBe('external');
|
|
59
66
|
});
|
|
67
|
+
|
|
68
|
+
it('builds finding identity from cve and package', () => {
|
|
69
|
+
expect(findingIdentity({ id: 'CVE-2026-0001', name: 'openssl' })).toBe(
|
|
70
|
+
'CVE-2026-0001::openssl'
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('formats fix availability status labels and titles', () => {
|
|
75
|
+
const fix = {
|
|
76
|
+
status: 'installable',
|
|
77
|
+
errata: [{ errata_id: 'RHSA-2026:0001' }],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(katelloFixLabel(fix)).toBe('Installable');
|
|
81
|
+
expect(katelloFixTitle(fix)).toContain('RHSA-2026:0001');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('hides fix availability column when findings are not enriched', () => {
|
|
85
|
+
expect(
|
|
86
|
+
shouldShowKatelloFixColumn([
|
|
87
|
+
{
|
|
88
|
+
id: 'CVE-2026-0001',
|
|
89
|
+
},
|
|
90
|
+
])
|
|
91
|
+
).toBe(false);
|
|
92
|
+
|
|
93
|
+
expect(
|
|
94
|
+
shouldShowKatelloFixColumn([
|
|
95
|
+
{
|
|
96
|
+
katello_fix: {
|
|
97
|
+
status: 'applicable',
|
|
98
|
+
reason: null,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
])
|
|
102
|
+
).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('counts fix availability statuses', () => {
|
|
106
|
+
expect(
|
|
107
|
+
katelloFixCounts([
|
|
108
|
+
{ katello_fix: { status: 'installable' } },
|
|
109
|
+
{ katello_fix: { status: 'installable' } },
|
|
110
|
+
{ katello_fix: { status: 'applicable' } },
|
|
111
|
+
{ katello_fix: { status: 'unavailable' } },
|
|
112
|
+
])
|
|
113
|
+
).toEqual({ installable: 2, applicable: 1 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('builds errata install remote execution urls for installable fixes', () => {
|
|
117
|
+
const fix = {
|
|
118
|
+
status: 'installable',
|
|
119
|
+
errata: [{ errata_id: 'RHSA-2026:0001' }],
|
|
120
|
+
};
|
|
121
|
+
const url = katelloFixInstallUrl({ hostName: 'host.example.com', fix });
|
|
122
|
+
const params = new URL(url, 'http://example.test').searchParams;
|
|
123
|
+
|
|
124
|
+
expect(katelloFixErrataSearch(fix)).toBe('errata_id = RHSA-2026:0001');
|
|
125
|
+
expect(url).toContain('/job_invocations/new?');
|
|
126
|
+
expect(params.get('feature')).toBe('katello_errata_install_by_search');
|
|
127
|
+
expect(params.get('search')).toBe('name ^ (host.example.com)');
|
|
128
|
+
expect(params.get('inputs[Errata search query]')).toBe(
|
|
129
|
+
'errata_id = RHSA-2026:0001'
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not build install urls for non-installable fixes', () => {
|
|
134
|
+
expect(
|
|
135
|
+
katelloFixInstallUrl({
|
|
136
|
+
hostName: 'host.example.com',
|
|
137
|
+
fix: {
|
|
138
|
+
status: 'applicable',
|
|
139
|
+
errata: [{ errata_id: 'RHSA-2026:0001' }],
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
).toBe('');
|
|
143
|
+
});
|
|
60
144
|
});
|
|
@@ -13,6 +13,14 @@ const COMPARE_STATUS_RANK = {
|
|
|
13
13
|
updated: 2,
|
|
14
14
|
unchanged: 1,
|
|
15
15
|
};
|
|
16
|
+
const KATELLO_FIX_RANK = {
|
|
17
|
+
installable: 3,
|
|
18
|
+
applicable: 2,
|
|
19
|
+
unavailable: 1,
|
|
20
|
+
unknown: 0,
|
|
21
|
+
};
|
|
22
|
+
const KATELLO_ERRATA_INSTALL_FEATURE = 'katello_errata_install_by_search';
|
|
23
|
+
const ERRATA_SEARCH_INPUT = 'Errata search query';
|
|
16
24
|
const COMPARISON_DIFF_LABELS = {
|
|
17
25
|
severity: __('Severity'),
|
|
18
26
|
version: __('Version'),
|
|
@@ -88,6 +96,8 @@ export const findingMatchesSearch = (finding, query) =>
|
|
|
88
96
|
finding.title,
|
|
89
97
|
finding.url,
|
|
90
98
|
finding.severity,
|
|
99
|
+
katelloFixLabel(finding.katello_fix),
|
|
100
|
+
katelloFixTitle(finding.katello_fix),
|
|
91
101
|
].some(value =>
|
|
92
102
|
(value || '')
|
|
93
103
|
.toString()
|
|
@@ -95,11 +105,16 @@ export const findingMatchesSearch = (finding, query) =>
|
|
|
95
105
|
.includes(query)
|
|
96
106
|
);
|
|
97
107
|
|
|
108
|
+
export const findingIdentity = finding =>
|
|
109
|
+
[finding.id, finding.name].map(value => value || '').join('::');
|
|
110
|
+
|
|
98
111
|
export const findingSorters = {
|
|
99
112
|
published: (a, b) => new Date(a.published || 0) - new Date(b.published || 0),
|
|
100
113
|
name: (a, b) => compareStrings(a.name, b.name),
|
|
101
114
|
version: (a, b) => compareStrings(a.version, b.version),
|
|
102
115
|
fixed: (a, b) => compareStrings(a.fixed, b.fixed),
|
|
116
|
+
katello_fix: (a, b) =>
|
|
117
|
+
katelloFixRank(a.katello_fix) - katelloFixRank(b.katello_fix),
|
|
103
118
|
id: (a, b) => compareStrings(a.id, b.id),
|
|
104
119
|
status: (a, b) => compareStrings(a.status, b.status),
|
|
105
120
|
title: (a, b) => compareStrings(a.title, b.title),
|
|
@@ -172,3 +187,102 @@ export const formatScanOrigin = (scanner, source, fallback = __('Unknown')) => {
|
|
|
172
187
|
};
|
|
173
188
|
export const formatScannedAt = value =>
|
|
174
189
|
formatDateTime(value) || __('Unknown time');
|
|
190
|
+
|
|
191
|
+
export const katelloFixRank = fix =>
|
|
192
|
+
KATELLO_FIX_RANK[(fix?.status || '').toLowerCase()] ?? 0;
|
|
193
|
+
|
|
194
|
+
export const katelloFixLabel = fix => {
|
|
195
|
+
switch ((fix?.status || '').toLowerCase()) {
|
|
196
|
+
case 'installable':
|
|
197
|
+
return __('Installable');
|
|
198
|
+
case 'applicable':
|
|
199
|
+
return __('Applicable');
|
|
200
|
+
case 'unavailable':
|
|
201
|
+
return __('No managed fix');
|
|
202
|
+
case 'unknown':
|
|
203
|
+
return __('Unknown');
|
|
204
|
+
default:
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const katelloFixTitle = fix => {
|
|
210
|
+
const errataIds = (fix?.errata || [])
|
|
211
|
+
.map(erratum => erratum.errata_id)
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.join(', ');
|
|
214
|
+
|
|
215
|
+
switch ((fix?.status || '').toLowerCase()) {
|
|
216
|
+
case 'installable':
|
|
217
|
+
return errataIds
|
|
218
|
+
? `${__(
|
|
219
|
+
'A matching erratum is installable for this host'
|
|
220
|
+
)}: ${errataIds}`
|
|
221
|
+
: __('A matching erratum is installable for this host');
|
|
222
|
+
case 'applicable':
|
|
223
|
+
return errataIds
|
|
224
|
+
? `${__(
|
|
225
|
+
'A matching erratum applies to this host, but is not installable in the current content'
|
|
226
|
+
)}: ${errataIds}`
|
|
227
|
+
: __(
|
|
228
|
+
'A matching erratum applies to this host, but is not installable in the current content'
|
|
229
|
+
);
|
|
230
|
+
case 'unavailable':
|
|
231
|
+
return __(
|
|
232
|
+
'No matching managed erratum was found for this CVE and package'
|
|
233
|
+
);
|
|
234
|
+
case 'unknown':
|
|
235
|
+
if (fix?.reason === 'missing_cve') {
|
|
236
|
+
return __('This finding has no CVE identifier');
|
|
237
|
+
}
|
|
238
|
+
if (fix?.reason === 'query_failed') {
|
|
239
|
+
return __('Fix availability could not be calculated');
|
|
240
|
+
}
|
|
241
|
+
return __('Fix availability is unknown');
|
|
242
|
+
default:
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const shouldShowKatelloFixColumn = findings =>
|
|
248
|
+
(findings || []).some(finding => finding.katello_fix);
|
|
249
|
+
|
|
250
|
+
export const katelloFixCounts = findings =>
|
|
251
|
+
(findings || []).reduce(
|
|
252
|
+
(counts, finding) => {
|
|
253
|
+
const status = (finding.katello_fix?.status || '').toLowerCase();
|
|
254
|
+
if (status === 'installable') counts.installable += 1;
|
|
255
|
+
if (status === 'applicable') counts.applicable += 1;
|
|
256
|
+
return counts;
|
|
257
|
+
},
|
|
258
|
+
{ installable: 0, applicable: 0 }
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
export const katelloFixErrataIds = fix =>
|
|
262
|
+
(fix?.errata || []).map(erratum => erratum.errata_id).filter(Boolean);
|
|
263
|
+
|
|
264
|
+
export const katelloFixErrataSearch = fix => {
|
|
265
|
+
const errataIds = katelloFixErrataIds(fix);
|
|
266
|
+
if (errataIds.length === 0) return '';
|
|
267
|
+
if (errataIds.length === 1) return `errata_id = ${errataIds[0]}`;
|
|
268
|
+
return `errata_id ^ (${errataIds.join(',')})`;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const katelloFixInstallUrl = ({ hostName, fix }) => {
|
|
272
|
+
if ((fix?.status || '').toLowerCase() !== 'installable') return '';
|
|
273
|
+
const errataSearch = katelloFixErrataSearch(fix);
|
|
274
|
+
if (!hostName || !errataSearch) return '';
|
|
275
|
+
|
|
276
|
+
const params = new URLSearchParams();
|
|
277
|
+
params.set('feature', KATELLO_ERRATA_INSTALL_FEATURE);
|
|
278
|
+
params.set('search', `name ^ (${hostName})`);
|
|
279
|
+
params.set(`inputs[${ERRATA_SEARCH_INPUT}]`, errataSearch);
|
|
280
|
+
return `/job_invocations/new?${params.toString()}`;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const katelloFixDetailsUrl = fix => {
|
|
284
|
+
const erratumId = (fix?.errata || [])
|
|
285
|
+
.map(erratum => erratum.id)
|
|
286
|
+
.find(Boolean);
|
|
287
|
+
return erratumId ? `/errata/${erratumId}` : '';
|
|
288
|
+
};
|