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
@@ -16,6 +16,9 @@ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
16
16
  ) => <span>{date || defaultValue}</span>);
17
17
 
18
18
  jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
19
+ jest.mock('../CveCompareModal', () => ({ isOpen, scanIds }) =>
20
+ isOpen ? <div data-test="compare-modal">{scanIds.join(',')}</div> : null
21
+ );
19
22
 
20
23
  const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
21
24
 
@@ -24,13 +27,14 @@ describe('CveScansTab', () => {
24
27
  useAPI.mockReset();
25
28
  });
26
29
 
27
- it('renders rows for scans', () => {
30
+ it('renders overview and reports sub-tabs', () => {
28
31
  const scansResponse = {
29
32
  results: [
30
33
  {
31
34
  id: 1,
32
- created_at: '2026-02-20',
35
+ scanned_at: '2026-02-20',
33
36
  scanner: 'trivy',
37
+ source: 'rex',
34
38
  total: 10,
35
39
  critical: 1,
36
40
  high: 2,
@@ -39,8 +43,9 @@ describe('CveScansTab', () => {
39
43
  },
40
44
  {
41
45
  id: 2,
42
- created_at: '2026-02-21',
46
+ scanned_at: '2026-02-21',
43
47
  scanner: 'grype',
48
+ source: 'external',
44
49
  total: 5,
45
50
  critical: 0,
46
51
  high: 1,
@@ -55,9 +60,158 @@ describe('CveScansTab', () => {
55
60
 
56
61
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
57
62
 
58
- expect(wrapper.text()).toContain('Reported at');
63
+ expect(wrapper.text()).toContain('Overview');
64
+ expect(wrapper.text()).toContain('Reports');
65
+ expect(wrapper.text()).toContain('Trend');
66
+ expect(wrapper.text()).toContain('Latest total');
67
+ });
68
+
69
+ it('opens compare modal from overview trend selection', () => {
70
+ const scansResponse = {
71
+ results: [
72
+ {
73
+ id: 1,
74
+ scanned_at: '2026-02-20',
75
+ scanner: 'trivy',
76
+ source: 'rex',
77
+ total: 10,
78
+ critical: 1,
79
+ high: 2,
80
+ medium: 3,
81
+ low: 4,
82
+ },
83
+ {
84
+ id: 2,
85
+ scanned_at: '2026-02-21',
86
+ scanner: 'grype',
87
+ source: 'external',
88
+ total: 5,
89
+ critical: 0,
90
+ high: 1,
91
+ medium: 1,
92
+ low: 3,
93
+ },
94
+ ],
95
+ total: 2,
96
+ };
97
+
98
+ useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
99
+
100
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
101
+
102
+ wrapper
103
+ .find('button')
104
+ .filterWhere(node => node.text() === 'Compare 2 reports')
105
+ .first()
106
+ .simulate('click');
107
+ wrapper.update();
108
+
109
+ wrapper.find('button.cve-trend-bar-button').at(0).simulate('click');
110
+ wrapper.find('button.cve-trend-bar-button').at(1).simulate('click');
111
+ wrapper.update();
112
+
113
+ expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
114
+ });
115
+
116
+ it('renders scan rows in the reports sub-tab', () => {
117
+ const scansResponse = {
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' });
146
+
147
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
148
+
149
+ wrapper
150
+ .find('[role="tab"]')
151
+ .filterWhere(node => node.text() === 'Reports')
152
+ .first()
153
+ .simulate('click');
154
+ wrapper.update();
155
+
156
+ expect(wrapper.text()).toContain('Scanned at');
59
157
  expect(wrapper.text()).toContain('trivy');
60
- expect(wrapper.text()).toContain('grype');
158
+ expect(wrapper.text()).toContain('grype / external');
159
+ expect(wrapper.text()).toContain('Export CSV');
160
+ expect(wrapper.text()).toContain('Compare selected');
161
+ });
162
+
163
+ it('opens compare modal when two scans are selected', () => {
164
+ const scansResponse = {
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' });
193
+
194
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
195
+
196
+ wrapper
197
+ .find('[role="tab"]')
198
+ .filterWhere(node => node.text() === 'Reports')
199
+ .first()
200
+ .simulate('click');
201
+ wrapper.update();
202
+
203
+ wrapper.find('input#cve-scan-select-1').simulate('change');
204
+ wrapper.find('input#cve-scan-select-2').simulate('change');
205
+ wrapper.update();
206
+
207
+ wrapper
208
+ .find('button')
209
+ .filterWhere(node => node.text() === 'Compare selected')
210
+ .first()
211
+ .simulate('click');
212
+ wrapper.update();
213
+
214
+ expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
61
215
  });
62
216
 
63
217
  it('renders empty state with no scans', () => {
@@ -67,6 +221,32 @@ describe('CveScansTab', () => {
67
221
  expect(wrapper.text()).toContain('No CVE reports for this host');
68
222
  });
69
223
 
224
+ it('renders trend overview when recent scans have no cves', () => {
225
+ const scansResponse = {
226
+ results: [
227
+ {
228
+ id: 1,
229
+ scanned_at: '2026-02-20',
230
+ scanner: 'trivy',
231
+ source: 'rex',
232
+ total: 0,
233
+ critical: 0,
234
+ high: 0,
235
+ medium: 0,
236
+ low: 0,
237
+ },
238
+ ],
239
+ total: 1,
240
+ };
241
+
242
+ useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
243
+
244
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
245
+ expect(wrapper.text()).toContain('Trend');
246
+ expect(wrapper.text()).toContain('Latest total');
247
+ expect(wrapper.text()).toContain('0');
248
+ });
249
+
70
250
  it('does not render when host id is missing', () => {
71
251
  useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
72
252
 
@@ -0,0 +1,122 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import CveTrendChart from '../CveTrendChart';
5
+
6
+ describe('CveTrendChart', () => {
7
+ it('renders the last scan summary and legend', () => {
8
+ const wrapper = mount(
9
+ <CveTrendChart
10
+ scans={[
11
+ {
12
+ id: 12,
13
+ scanned_at: '2026-05-14T10:00:00Z',
14
+ total: 18,
15
+ critical: 2,
16
+ high: 4,
17
+ medium: 5,
18
+ low: 7,
19
+ },
20
+ {
21
+ id: 11,
22
+ scanned_at: '2026-05-10T10:00:00Z',
23
+ total: 15,
24
+ critical: 1,
25
+ high: 3,
26
+ medium: 5,
27
+ low: 6,
28
+ },
29
+ ]}
30
+ />
31
+ );
32
+
33
+ expect(wrapper.text()).toContain('Trend');
34
+ expect(wrapper.text()).toContain('Last 2 scans');
35
+ expect(wrapper.text()).toContain('Latest total');
36
+ expect(wrapper.text()).toContain('18');
37
+ expect(wrapper.text()).toContain('Critical');
38
+ expect(wrapper.text()).toContain('Low');
39
+ });
40
+
41
+ it('opens the selected scan when a bar is clicked', () => {
42
+ const onOpen = jest.fn();
43
+ const wrapper = mount(
44
+ <CveTrendChart
45
+ scans={[
46
+ {
47
+ id: 2,
48
+ scanned_at: '2026-05-14T10:00:00Z',
49
+ total: 12,
50
+ critical: 2,
51
+ high: 3,
52
+ medium: 3,
53
+ low: 4,
54
+ },
55
+ {
56
+ id: 1,
57
+ scanned_at: '2026-05-10T10:00:00Z',
58
+ total: 8,
59
+ critical: 1,
60
+ high: 1,
61
+ medium: 2,
62
+ low: 4,
63
+ },
64
+ ]}
65
+ onOpen={onOpen}
66
+ />
67
+ );
68
+
69
+ wrapper.find('button.cve-trend-bar-button').at(0).simulate('click');
70
+
71
+ expect(onOpen).toHaveBeenCalledWith(1);
72
+ });
73
+
74
+ it('renders compare mode controls and uses bar clicks for selection', () => {
75
+ const onToggleSelection = jest.fn();
76
+ const onToggleCompareMode = jest.fn();
77
+ const onOpen = jest.fn();
78
+ const wrapper = mount(
79
+ <CveTrendChart
80
+ scans={[
81
+ {
82
+ id: 2,
83
+ scanned_at: '2026-05-14T10:00:00Z',
84
+ total: 12,
85
+ critical: 2,
86
+ high: 3,
87
+ medium: 3,
88
+ low: 4,
89
+ },
90
+ {
91
+ id: 1,
92
+ scanned_at: '2026-05-10T10:00:00Z',
93
+ total: 8,
94
+ critical: 1,
95
+ high: 1,
96
+ medium: 2,
97
+ low: 4,
98
+ },
99
+ ]}
100
+ compareMode
101
+ selectedScanIds={[2]}
102
+ onOpen={onOpen}
103
+ onToggleSelection={onToggleSelection}
104
+ onToggleCompareMode={onToggleCompareMode}
105
+ />
106
+ );
107
+
108
+ expect(wrapper.text()).toContain('Cancel compare');
109
+ expect(wrapper.text()).toContain('1/2 selected');
110
+
111
+ wrapper.find('button.cve-trend-bar-button').at(1).simulate('click');
112
+ wrapper
113
+ .find('button')
114
+ .filterWhere(node => node.text() === 'Cancel compare')
115
+ .first()
116
+ .simulate('click');
117
+
118
+ expect(onToggleSelection).toHaveBeenCalledWith(2);
119
+ expect(onOpen).not.toHaveBeenCalled();
120
+ expect(onToggleCompareMode).toHaveBeenCalled();
121
+ });
122
+ });
@@ -3,7 +3,10 @@ import {
3
3
  severityRank,
4
4
  riskLevelFromWorst,
5
5
  formatDateTime,
6
+ formatScanOrigin,
7
+ formatScannedAt,
6
8
  compareStrings,
9
+ visibleScanSource,
7
10
  } from '../cve_helpers';
8
11
 
9
12
  describe('cve_helpers', () => {
@@ -33,10 +36,25 @@ describe('cve_helpers', () => {
33
36
  expect(formatDateTime('not-a-date')).toBe('not-a-date');
34
37
  });
35
38
 
39
+ it('formats scan origin without rex source noise', () => {
40
+ expect(formatScanOrigin('trivy', 'rex')).toBe('trivy');
41
+ expect(formatScanOrigin('grype', 'external')).toBe('grype / external');
42
+ });
43
+
44
+ it('formats scanned_at with unknown fallback', () => {
45
+ expect(formatScannedAt('2026-02-22T10:05:00Z')).toContain('2026-02-22');
46
+ expect(formatScannedAt('')).toBe('Unknown time');
47
+ });
48
+
36
49
  it('compares strings safely', () => {
37
50
  expect(compareStrings('a', 'b')).toBeLessThan(0);
38
51
  expect(compareStrings('b', 'a')).toBeGreaterThan(0);
39
52
  expect(compareStrings('a', 'a')).toBe(0);
40
53
  expect(compareStrings(null, 'a')).toBeLessThan(0);
41
54
  });
55
+
56
+ it('hides rex source and keeps external source visible', () => {
57
+ expect(visibleScanSource('rex')).toBe('');
58
+ expect(visibleScanSource('external')).toBe('external');
59
+ });
42
60
  });
@@ -7,6 +7,47 @@ const SEVERITY_RANK = {
7
7
  MEDIUM: 2,
8
8
  LOW: 1,
9
9
  };
10
+ const COMPARE_STATUS_RANK = {
11
+ new: 4,
12
+ resolved: 3,
13
+ updated: 2,
14
+ unchanged: 1,
15
+ };
16
+ const COMPARISON_DIFF_LABELS = {
17
+ severity: __('Severity'),
18
+ version: __('Version'),
19
+ fixed: __('Fixed'),
20
+ scan_status: __('Scan status'),
21
+ title: __('Title'),
22
+ published: __('Published'),
23
+ url: __('URL'),
24
+ };
25
+
26
+ export const comparisonFilters = [
27
+ { key: 'all', label: __('All') },
28
+ { key: 'new', label: __('New') },
29
+ { key: 'resolved', label: __('Resolved') },
30
+ { key: 'updated', label: __('Updated') },
31
+ { key: 'unchanged', label: __('Unchanged') },
32
+ ];
33
+ export const comparisonColumns = [
34
+ { key: 'status', label: __('Status') },
35
+ { key: 'id', label: __('CVE') },
36
+ { key: 'name', label: __('Package') },
37
+ { key: 'severity', label: __('Severity') },
38
+ { key: 'version', label: __('Version') },
39
+ { key: 'fixed', label: __('Fixed') },
40
+ { key: 'scan_status', label: __('Scan status') },
41
+ { key: 'diff', label: __('Diff') },
42
+ { key: 'published', label: __('Published') },
43
+ { key: 'title', label: __('Title') },
44
+ ];
45
+ export const comparisonStatusLabels = {
46
+ new: __('New'),
47
+ resolved: __('Resolved'),
48
+ updated: __('Updated'),
49
+ unchanged: __('Unchanged'),
50
+ };
10
51
 
11
52
  export const severityRank = sev =>
12
53
  SEVERITY_RANK[(sev || '').toUpperCase()] || 0;
@@ -31,5 +72,103 @@ export const formatDateTime = value => {
31
72
  export const compareStrings = (a, b) =>
32
73
  (a || '').toString().localeCompare((b || '').toString());
33
74
 
75
+ export const normalizeSearchInputValue = (value, event) => {
76
+ if (typeof value === 'string') return value;
77
+ if (typeof event === 'string') return event;
78
+ return value?.target?.value || event?.target?.value || '';
79
+ };
80
+
81
+ export const findingMatchesSearch = (finding, query) =>
82
+ [
83
+ finding.id,
84
+ finding.name,
85
+ finding.version,
86
+ finding.fixed,
87
+ finding.status,
88
+ finding.title,
89
+ finding.url,
90
+ finding.severity,
91
+ ].some(value =>
92
+ (value || '')
93
+ .toString()
94
+ .toLowerCase()
95
+ .includes(query)
96
+ );
97
+
98
+ export const findingSorters = {
99
+ published: (a, b) => new Date(a.published || 0) - new Date(b.published || 0),
100
+ name: (a, b) => compareStrings(a.name, b.name),
101
+ version: (a, b) => compareStrings(a.version, b.version),
102
+ fixed: (a, b) => compareStrings(a.fixed, b.fixed),
103
+ id: (a, b) => compareStrings(a.id, b.id),
104
+ status: (a, b) => compareStrings(a.status, b.status),
105
+ title: (a, b) => compareStrings(a.title, b.title),
106
+ severity: (a, b) => severityRank(a.severity) - severityRank(b.severity),
107
+ };
108
+
109
+ export const comparisonStatusRank = status => COMPARE_STATUS_RANK[status] || 0;
110
+
111
+ export const comparisonDiffEntries = diff =>
112
+ Object.entries(diff || {}).map(([field, values]) => ({
113
+ field,
114
+ label: COMPARISON_DIFF_LABELS[field] || field,
115
+ oldValue: values.old || '-',
116
+ newValue: values.new || '-',
117
+ }));
118
+
119
+ export const formatComparisonDiff = diff =>
120
+ comparisonDiffEntries(diff).map(
121
+ entry => `${entry.label}: ${entry.oldValue} -> ${entry.newValue}`
122
+ );
123
+
124
+ export const comparisonMatchesSearch = (row, query) =>
125
+ [
126
+ row.id,
127
+ row.name,
128
+ row.title,
129
+ row.published,
130
+ row.severity,
131
+ row.version,
132
+ row.fixed,
133
+ row.scan_status,
134
+ row.status,
135
+ ...formatComparisonDiff(row.diff),
136
+ ].some(value =>
137
+ (value || '')
138
+ .toString()
139
+ .toLowerCase()
140
+ .includes(query)
141
+ );
142
+
143
+ export const comparisonSorters = {
144
+ status: (a, b) =>
145
+ comparisonStatusRank(a.status) - comparisonStatusRank(b.status),
146
+ id: (a, b) => compareStrings(a.id, b.id),
147
+ name: (a, b) => compareStrings(a.name, b.name),
148
+ published: (a, b) => new Date(a.published || 0) - new Date(b.published || 0),
149
+ severity: (a, b) => severityRank(a.severity) - severityRank(b.severity),
150
+ version: (a, b) => compareStrings(a.version, b.version),
151
+ fixed: (a, b) => compareStrings(a.fixed, b.fixed),
152
+ scan_status: (a, b) => compareStrings(a.scan_status, b.scan_status),
153
+ diff: (a, b) =>
154
+ compareStrings(
155
+ formatComparisonDiff(a.diff).join(' '),
156
+ formatComparisonDiff(b.diff).join(' ')
157
+ ),
158
+ title: (a, b) => compareStrings(a.title, b.title),
159
+ };
160
+
34
161
  export const noReportsTitle = () => __('No CVE reports for this host');
35
162
  export const noReportsBody = () => __('Run a CVE scan to see results here.');
163
+ export const noFindingsTitle = () => __('No CVEs found');
164
+ export const noFindingsBody = () =>
165
+ __('The latest CVE scan found no vulnerabilities for this host.');
166
+ export const visibleScanSource = source =>
167
+ (source || '').toLowerCase() === 'rex' ? '' : source || '';
168
+ export const formatScanOrigin = (scanner, source, fallback = __('Unknown')) => {
169
+ const scannerLabel = scanner || fallback;
170
+ const visibleSource = visibleScanSource(source);
171
+ return visibleSource ? `${scannerLabel} / ${visibleSource}` : scannerLabel;
172
+ };
173
+ export const formatScannedAt = value =>
174
+ formatDateTime(value) || __('Unknown time');