foreman_cve_scanner 0.5.0 → 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 -5
  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 +5 -8
  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 +22 -9
  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,264 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React, { useMemo } from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import {
5
+ Button,
6
+ Text,
7
+ TextContent,
8
+ TextVariants,
9
+ Tooltip,
10
+ } from '@patternfly/react-core';
11
+ import { translate as __ } from 'foremanReact/common/I18n';
12
+ import { formatScannedAt } from './cve_helpers';
13
+
14
+ const TREND_LIMIT = 10;
15
+ const STACK_ORDER = ['low', 'medium', 'high', 'critical'];
16
+ const TREND_LEGEND = [
17
+ { key: 'critical', label: __('Critical') },
18
+ { key: 'high', label: __('High') },
19
+ { key: 'medium', label: __('Medium') },
20
+ { key: 'low', label: __('Low') },
21
+ ];
22
+ const totalDeltaText = __(
23
+ 'Difference in total CVE count compared to the previous scan.'
24
+ );
25
+ const criticalHighDeltaText = __(
26
+ 'Difference in critical and high CVE count compared to the previous scan.'
27
+ );
28
+
29
+ const shortDateLabel = value => {
30
+ const date = new Date(value || 0);
31
+ if (Number.isNaN(date.getTime())) return '--';
32
+ return new Intl.DateTimeFormat(undefined, {
33
+ day: '2-digit',
34
+ month: 'short',
35
+ })
36
+ .format(date)
37
+ .replace(',', '');
38
+ };
39
+
40
+ const formatDelta = value => {
41
+ if (value > 0) return `+${value}`;
42
+ if (value < 0) return String(value);
43
+ return '0';
44
+ };
45
+
46
+ const deltaClassName = value => {
47
+ if (value > 0) return 'is-up';
48
+ if (value < 0) return 'is-down';
49
+ return 'is-flat';
50
+ };
51
+
52
+ const tooltipContentFor = scan => (
53
+ <div className="cve-trend-tooltip">
54
+ <div className="cve-trend-tooltip-line">
55
+ {formatScannedAt(scan.scanned_at)}
56
+ </div>
57
+ <div className="cve-trend-tooltip-line">
58
+ {`${__('Total')}: ${scan.total}`}
59
+ </div>
60
+ <div className="cve-trend-tooltip-line">
61
+ {`${__('Critical')}: ${scan.critical}`}
62
+ </div>
63
+ <div className="cve-trend-tooltip-line">
64
+ {`${__('High')}: ${scan.high}`}
65
+ </div>
66
+ <div className="cve-trend-tooltip-line">
67
+ {`${__('Medium')}: ${scan.medium}`}
68
+ </div>
69
+ <div className="cve-trend-tooltip-line">{`${__('Low')}: ${scan.low}`}</div>
70
+ </div>
71
+ );
72
+
73
+ const CveTrendChart = ({
74
+ scans,
75
+ onOpen,
76
+ compareMode,
77
+ selectedScanIds,
78
+ onToggleSelection,
79
+ onToggleCompareMode,
80
+ }) => {
81
+ const visibleScans = useMemo(
82
+ () => [...scans].slice(0, TREND_LIMIT).reverse(),
83
+ [scans]
84
+ );
85
+ const latestScan = scans[0];
86
+ const previousScan = scans[1];
87
+ const isCompareSelectable = typeof onToggleSelection === 'function';
88
+ const selectedCount = selectedScanIds.length;
89
+ const maxTotal = Math.max(...visibleScans.map(scan => scan.total || 0), 1);
90
+ const criticalHighDelta =
91
+ (latestScan?.critical || 0) +
92
+ (latestScan?.high || 0) -
93
+ ((previousScan?.critical || 0) + (previousScan?.high || 0));
94
+ const totalDelta = (latestScan?.total || 0) - (previousScan?.total || 0);
95
+
96
+ if (!latestScan) return null;
97
+
98
+ return (
99
+ <section className="cve-trend" aria-label="CVE trend chart">
100
+ <TextContent className="cve-section-title">
101
+ <Text component={TextVariants.h4} ouiaId="cve-trend-title">
102
+ {__('Trend')}
103
+ </Text>
104
+ <Text component={TextVariants.small} ouiaId="cve-trend-subtitle">
105
+ {__('Last %s scans').replace('%s', visibleScans.length)}
106
+ </Text>
107
+ </TextContent>
108
+
109
+ <div className="cve-trend-summary">
110
+ <div className="cve-trend-card">
111
+ <span className="cve-trend-card-label">{__('Latest total')}</span>
112
+ <span className="cve-trend-card-value">{latestScan.total}</span>
113
+ </div>
114
+ <div className="cve-trend-card">
115
+ <Tooltip content={criticalHighDeltaText}>
116
+ <span className="cve-trend-card-label cve-trend-card-label--help">
117
+ {__('Critical/high delta')}
118
+ </span>
119
+ </Tooltip>
120
+ <span
121
+ className={`cve-trend-card-value ${deltaClassName(
122
+ criticalHighDelta
123
+ )}`}
124
+ >
125
+ {previousScan
126
+ ? formatDelta(criticalHighDelta)
127
+ : __('No previous scan')}
128
+ </span>
129
+ </div>
130
+ <div className="cve-trend-card">
131
+ <Tooltip content={totalDeltaText}>
132
+ <span className="cve-trend-card-label cve-trend-card-label--help">
133
+ {__('Total delta')}
134
+ </span>
135
+ </Tooltip>
136
+ <span
137
+ className={`cve-trend-card-value ${deltaClassName(totalDelta)}`}
138
+ >
139
+ {previousScan ? formatDelta(totalDelta) : __('No previous scan')}
140
+ </span>
141
+ </div>
142
+ <div className="cve-trend-card">
143
+ <span className="cve-trend-card-label">{__('Last scanned')}</span>
144
+ <span className="cve-trend-card-value cve-trend-card-value--small">
145
+ {formatScannedAt(latestScan.scanned_at)}
146
+ </span>
147
+ </div>
148
+ </div>
149
+
150
+ {isCompareSelectable && (
151
+ <div className="cve-trend-controls">
152
+ <div className="cve-trend-actions">
153
+ <Button
154
+ variant={compareMode ? 'link' : 'secondary'}
155
+ onClick={onToggleCompareMode}
156
+ ouiaId="cve-trend-compare-mode"
157
+ >
158
+ {compareMode ? __('Cancel compare') : __('Compare 2 reports')}
159
+ </Button>
160
+ {compareMode && (
161
+ <span className="cve-trend-compare-state">
162
+ {`${selectedCount}/2 ${__('selected')}`}
163
+ </span>
164
+ )}
165
+ </div>
166
+ </div>
167
+ )}
168
+
169
+ <div className="cve-trend-legend" aria-label="CVE trend legend">
170
+ {TREND_LEGEND.map(item => (
171
+ <span key={item.key} className="cve-trend-legend-item">
172
+ <span
173
+ className={`cve-trend-legend-swatch cve-trend-segment--${item.key}`}
174
+ />
175
+ <span>{item.label}</span>
176
+ </span>
177
+ ))}
178
+ </div>
179
+
180
+ <div className="cve-trend-bars">
181
+ {visibleScans.map(scan => (
182
+ <div key={scan.id} className="cve-trend-bar-item">
183
+ <Tooltip content={tooltipContentFor(scan)}>
184
+ <button
185
+ type="button"
186
+ className={
187
+ compareMode && selectedScanIds.includes(scan.id)
188
+ ? 'cve-trend-bar-button is-selected'
189
+ : 'cve-trend-bar-button'
190
+ }
191
+ onClick={() =>
192
+ compareMode ? onToggleSelection(scan.id) : onOpen(scan.id)
193
+ }
194
+ aria-label={
195
+ compareMode
196
+ ? __('Select scan from %s for comparison').replace(
197
+ '%s',
198
+ formatScannedAt(scan.scanned_at)
199
+ )
200
+ : __('Open scan details for %s').replace(
201
+ '%s',
202
+ formatScannedAt(scan.scanned_at)
203
+ )
204
+ }
205
+ >
206
+ <span
207
+ className={
208
+ scan.total === 0
209
+ ? 'cve-trend-bar-frame cve-trend-bar-frame--clean'
210
+ : 'cve-trend-bar-frame'
211
+ }
212
+ >
213
+ {STACK_ORDER.map(level => (
214
+ <span
215
+ key={level}
216
+ className={`cve-trend-segment cve-trend-segment--${level}`}
217
+ style={{
218
+ height: `${((scan[level] || 0) / maxTotal) * 100}%`,
219
+ }}
220
+ />
221
+ ))}
222
+ </span>
223
+ <span className="cve-trend-bar-total">{scan.total}</span>
224
+ <span className="cve-trend-bar-label">
225
+ {shortDateLabel(scan.scanned_at)}
226
+ </span>
227
+ </button>
228
+ </Tooltip>
229
+ </div>
230
+ ))}
231
+ </div>
232
+ </section>
233
+ );
234
+ };
235
+
236
+ CveTrendChart.propTypes = {
237
+ scans: PropTypes.arrayOf(
238
+ PropTypes.shape({
239
+ id: PropTypes.number.isRequired,
240
+ scanned_at: PropTypes.string,
241
+ total: PropTypes.number,
242
+ critical: PropTypes.number,
243
+ high: PropTypes.number,
244
+ medium: PropTypes.number,
245
+ low: PropTypes.number,
246
+ })
247
+ ),
248
+ onOpen: PropTypes.func,
249
+ compareMode: PropTypes.bool,
250
+ selectedScanIds: PropTypes.arrayOf(PropTypes.number),
251
+ onToggleSelection: PropTypes.func,
252
+ onToggleCompareMode: PropTypes.func,
253
+ };
254
+
255
+ CveTrendChart.defaultProps = {
256
+ scans: [],
257
+ onOpen: () => {},
258
+ compareMode: false,
259
+ selectedScanIds: [],
260
+ onToggleSelection: undefined,
261
+ onToggleCompareMode: () => {},
262
+ };
263
+
264
+ export default CveTrendChart;
@@ -0,0 +1,104 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import CveCompareModal from '../CveCompareModal';
5
+
6
+ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
7
+ useAPI: jest.fn(),
8
+ }));
9
+
10
+ const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
11
+
12
+ describe('CveCompareModal', () => {
13
+ beforeEach(() => {
14
+ useAPI.mockReset();
15
+ });
16
+
17
+ it('renders comparison summary and filters rows by status', () => {
18
+ useAPI.mockReturnValue({
19
+ response: {
20
+ previous: {
21
+ id: 1,
22
+ scanned_at: '2026-02-20T10:00:00Z',
23
+ scanner: 'trivy',
24
+ source: 'rex',
25
+ },
26
+ current: {
27
+ id: 2,
28
+ scanned_at: '2026-02-21T10:00:00Z',
29
+ scanner: 'grype',
30
+ source: 'external',
31
+ },
32
+ summary: {
33
+ new: 1,
34
+ resolved: 1,
35
+ updated: 1,
36
+ unchanged: 0,
37
+ },
38
+ results: [
39
+ {
40
+ key: 'CVE-1::pkg-a',
41
+ status: 'updated',
42
+ id: 'CVE-1',
43
+ name: 'pkg-a',
44
+ severity: 'CRITICAL',
45
+ version: '1.0',
46
+ fixed: 'open',
47
+ scan_status: 'affected',
48
+ diff: {
49
+ severity: { old: 'HIGH', new: 'CRITICAL' },
50
+ },
51
+ },
52
+ {
53
+ key: 'CVE-2::pkg-b',
54
+ status: 'resolved',
55
+ id: 'CVE-2',
56
+ name: 'pkg-b',
57
+ severity: 'LOW',
58
+ version: '1.0',
59
+ fixed: 'open',
60
+ scan_status: 'affected',
61
+ diff: {},
62
+ },
63
+ {
64
+ key: 'CVE-3::pkg-c',
65
+ status: 'new',
66
+ id: 'CVE-3',
67
+ name: 'pkg-c',
68
+ severity: 'MEDIUM',
69
+ version: '1.0',
70
+ fixed: '2.0',
71
+ scan_status: 'affected',
72
+ diff: {},
73
+ },
74
+ ],
75
+ },
76
+ status: 'RESOLVED',
77
+ });
78
+
79
+ const wrapper = mount(
80
+ <CveCompareModal
81
+ hostId={1}
82
+ isOpen
83
+ onClose={() => {}}
84
+ scanIds={[1, 2]}
85
+ />
86
+ );
87
+
88
+ expect(wrapper.text()).toContain('Compare CVE reports');
89
+ expect(wrapper.text()).toContain('Updated');
90
+ expect(wrapper.text()).toContain('Resolved');
91
+ expect(wrapper.text()).toContain('New');
92
+ expect(wrapper.text()).toContain('Severity: HIGH -> CRITICAL');
93
+
94
+ wrapper
95
+ .find('button')
96
+ .filterWhere(node => node.text() === 'Resolved')
97
+ .last()
98
+ .simulate('click');
99
+ wrapper.update();
100
+
101
+ expect(wrapper.text()).toContain('CVE-2');
102
+ expect(wrapper.text()).not.toContain('CVE-3');
103
+ });
104
+ });
@@ -32,15 +32,16 @@ describe('CveDetailsCard', () => {
32
32
  const hostDetails = { id: 1 };
33
33
  const historyResponse = {
34
34
  results: [
35
- { id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 },
36
- { id: 2, created_at: '2026-02-21', scanner: 'trivy', total: 12 },
37
- { id: 3, created_at: '2026-02-22', scanner: 'grype', total: 8 },
35
+ { id: 1, scanned_at: '2026-02-20', scanner: 'trivy', source: 'rex', total: 10 },
36
+ { id: 2, scanned_at: '2026-02-21', scanner: 'trivy', source: 'rex', total: 12 },
37
+ { id: 3, scanned_at: '2026-02-22', scanner: 'grype', source: 'external', total: 8 },
38
38
  ],
39
39
  };
40
40
  const latestResponse = {
41
41
  id: 3,
42
- created_at: '2026-02-22',
42
+ scanned_at: '2026-02-22',
43
43
  scanner: 'grype',
44
+ source: 'external',
44
45
  total: 8,
45
46
  summary: { worst: 'high' },
46
47
  critical: 1,
@@ -67,39 +68,29 @@ describe('CveDetailsCard', () => {
67
68
 
68
69
  const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
69
70
 
70
- expect(wrapper.text()).toContain('Recent scans');
71
71
  expect(wrapper.find('table').length).toBeGreaterThan(0);
72
72
  expect(wrapper.text()).toContain('CVEs');
73
73
  expect(wrapper.text()).toContain('pkg');
74
74
  });
75
75
 
76
- it('falls back to history when latest is empty', () => {
76
+ it('renders empty state when latest is empty', () => {
77
77
  const hostDetails = { id: 1 };
78
- const historyResponse = {
79
- results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
80
- };
81
-
82
- useAPI.mockImplementation((_method, url) => {
83
- if (url && url.includes('/latest')) {
84
- return { response: {}, status: 'RESOLVED' };
85
- }
86
- return { response: historyResponse, status: 'RESOLVED' };
87
- });
78
+ useAPI.mockReturnValue({ response: {}, status: 'RESOLVED' });
88
79
 
89
80
  const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
90
- expect(wrapper.text()).toContain('Report');
91
- expect(wrapper.text()).toContain('trivy');
81
+ expect(wrapper.text()).toContain('No CVE reports for this host');
92
82
  });
93
83
 
94
84
  it('prefers latest when present', () => {
95
85
  const hostDetails = { id: 1 };
96
86
  const historyResponse = {
97
- results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
87
+ results: [{ id: 1, scanned_at: '2026-02-20', scanner: 'trivy', source: 'rex', total: 10 }],
98
88
  };
99
89
  const latestResponse = {
100
90
  id: 2,
101
- created_at: '2026-02-21',
91
+ scanned_at: '2026-02-21',
102
92
  scanner: 'grype',
93
+ source: 'external',
103
94
  total: 8,
104
95
  summary: { worst: 'high' },
105
96
  findings: [],
@@ -116,6 +107,76 @@ describe('CveDetailsCard', () => {
116
107
  expect(wrapper.text()).toContain('grype');
117
108
  });
118
109
 
110
+ it('keeps unknown severities out of the preview when ranked CVEs exist', () => {
111
+ const hostDetails = { id: 1 };
112
+ const latestResponse = {
113
+ id: 2,
114
+ scanned_at: '2026-02-21',
115
+ scanner: 'grype',
116
+ source: 'external',
117
+ total: 6,
118
+ summary: { worst: 'critical' },
119
+ findings: [
120
+ {
121
+ id: 'CVE-unknown',
122
+ name: 'unknown-pkg',
123
+ version: '1.0',
124
+ severity: 'UNKNOWN',
125
+ published: '2026-02-20',
126
+ },
127
+ {
128
+ id: 'CVE-critical',
129
+ name: 'critical-pkg',
130
+ version: '1.0',
131
+ severity: 'CRITICAL',
132
+ published: '2026-02-10',
133
+ },
134
+ {
135
+ id: 'CVE-high',
136
+ name: 'high-pkg',
137
+ version: '1.0',
138
+ severity: 'HIGH',
139
+ published: '2026-02-11',
140
+ },
141
+ {
142
+ id: 'CVE-medium',
143
+ name: 'medium-pkg',
144
+ version: '1.0',
145
+ severity: 'MEDIUM',
146
+ published: '2026-02-12',
147
+ },
148
+ {
149
+ id: 'CVE-low-a',
150
+ name: 'low-a-pkg',
151
+ version: '1.0',
152
+ severity: 'LOW',
153
+ published: '2026-02-13',
154
+ },
155
+ {
156
+ id: 'CVE-low-b',
157
+ name: 'low-b-pkg',
158
+ version: '1.0',
159
+ severity: 'LOW',
160
+ published: '2026-02-14',
161
+ },
162
+ ],
163
+ };
164
+
165
+ useAPI.mockImplementation((_method, url) => {
166
+ if (url && url.includes('/latest')) {
167
+ return { response: latestResponse, status: 'RESOLVED' };
168
+ }
169
+ return { response: { results: [] }, status: 'RESOLVED' };
170
+ });
171
+
172
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
173
+
174
+ expect(wrapper.text()).toContain('critical-pkg');
175
+ expect(wrapper.text()).toContain('low-b-pkg');
176
+ expect(wrapper.text()).not.toContain('unknown-pkg');
177
+ expect(wrapper.text()).toContain('More');
178
+ });
179
+
119
180
  it('renders empty state when no scans', () => {
120
181
  const hostDetails = { id: 1 };
121
182
  useAPI.mockReturnValue({ response: null, status: 'RESOLVED' });
@@ -123,4 +184,29 @@ describe('CveDetailsCard', () => {
123
184
  const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
124
185
  expect(wrapper.text()).toContain('No CVE reports for this host');
125
186
  });
187
+
188
+ it('renders success state when latest scan has no findings', () => {
189
+ const hostDetails = { id: 1 };
190
+ const latestResponse = {
191
+ id: 2,
192
+ scanned_at: '2026-02-21',
193
+ scanner: 'trivy',
194
+ source: 'rex',
195
+ total: 0,
196
+ summary: { worst: 'none' },
197
+ critical: 0,
198
+ high: 0,
199
+ medium: 0,
200
+ low: 0,
201
+ findings: [],
202
+ };
203
+
204
+ useAPI.mockReturnValue({ response: latestResponse, status: 'RESOLVED' });
205
+
206
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
207
+ expect(wrapper.text()).toContain('No CVEs found');
208
+ expect(wrapper.text()).toContain(
209
+ 'The latest CVE scan found no vulnerabilities for this host.'
210
+ );
211
+ });
126
212
  });
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable import/no-unresolved */
2
2
  import React from 'react';
3
3
  import { mount } from 'enzyme';
4
+ import { act } from 'react-dom/test-utils';
4
5
  import CveFindingsModal from '../CveFindingsModal';
5
6
 
6
7
  jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
@@ -18,7 +19,9 @@ describe('CveFindingsModal', () => {
18
19
  useAPI.mockReturnValue({
19
20
  response: {
20
21
  id: 1,
21
- created_at: '2026-02-22T10:00:00Z',
22
+ scanned_at: '2026-02-22T10:00:00Z',
23
+ scanner: 'trivy',
24
+ source: 'rex',
22
25
  total: 2,
23
26
  findings: [
24
27
  { id: 'CVE-1', severity: 'HIGH', name: 'a', version: '1' },
@@ -56,7 +59,9 @@ describe('CveFindingsModal', () => {
56
59
  useAPI.mockReturnValue({
57
60
  response: {
58
61
  id: 1,
59
- created_at: '2026-02-22T10:00:00Z',
62
+ scanned_at: '2026-02-22T10:00:00Z',
63
+ scanner: 'trivy',
64
+ source: 'rex',
60
65
  total: 0,
61
66
  findings: [],
62
67
  },
@@ -75,4 +80,51 @@ describe('CveFindingsModal', () => {
75
80
 
76
81
  expect(wrapper.text()).toContain('No findings for selected filter');
77
82
  });
83
+
84
+ it('filters findings by search text', () => {
85
+ useAPI.mockReturnValue({
86
+ response: {
87
+ id: 1,
88
+ scanned_at: '2026-02-22T10:00:00Z',
89
+ scanner: 'trivy',
90
+ source: 'rex',
91
+ total: 2,
92
+ findings: [
93
+ {
94
+ id: 'CVE-1',
95
+ severity: 'HIGH',
96
+ name: 'openssl',
97
+ version: '1.1',
98
+ title: 'OpenSSL issue',
99
+ },
100
+ {
101
+ id: 'CVE-2',
102
+ severity: 'LOW',
103
+ name: 'curl',
104
+ version: '8.0',
105
+ title: 'Curl issue',
106
+ },
107
+ ],
108
+ },
109
+ status: 'RESOLVED',
110
+ });
111
+
112
+ const wrapper = mount(
113
+ <CveFindingsModal
114
+ isOpen
115
+ onClose={() => {}}
116
+ hostId={1}
117
+ scanId={1}
118
+ initialFilter="all"
119
+ />
120
+ );
121
+
122
+ act(() => {
123
+ wrapper.find('SearchInput').prop('onChange')('openssl');
124
+ });
125
+ wrapper.update();
126
+
127
+ expect(wrapper.text()).toContain('CVE-1');
128
+ expect(wrapper.text()).not.toContain('CVE-2');
129
+ });
78
130
  });