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
@@ -8,6 +8,7 @@ import {
8
8
  Text,
9
9
  TextVariants,
10
10
  FormGroup,
11
+ SearchInput,
11
12
  } from '@patternfly/react-core';
12
13
  import {
13
14
  Table,
@@ -23,7 +24,14 @@ import { foremanUrl } from 'foremanReact/common/helpers';
23
24
  import { translate as __ } from 'foremanReact/common/I18n';
24
25
  import { STATUS } from 'foremanReact/constants';
25
26
  import SeverityIcon from './SeverityIcon';
26
- import { compareStrings, formatDateTime, severityRank } from './cve_helpers';
27
+ import {
28
+ findingMatchesSearch,
29
+ formatDateTime,
30
+ formatScanOrigin,
31
+ formatScannedAt,
32
+ findingSorters,
33
+ normalizeSearchInputValue,
34
+ } from './cve_helpers';
27
35
  import './cve_scans.scss';
28
36
 
29
37
  const SEVERITY_OPTIONS = ['all', 'critical', 'high', 'medium', 'low'];
@@ -46,6 +54,7 @@ const CveFindingsModal = ({
46
54
  initialFilter,
47
55
  }) => {
48
56
  const [filter, setFilter] = useState(initialFilter || 'all');
57
+ const [search, setSearch] = useState('');
49
58
  const [sortBy, setSortBy] = useState({
50
59
  direction: SortByDirection.desc,
51
60
  column: 'severity',
@@ -65,10 +74,12 @@ const CveFindingsModal = ({
65
74
  useEffect(() => {
66
75
  if (!isOpen) return;
67
76
  setFilter(initialFilter || 'all');
77
+ setSearch('');
68
78
  }, [initialFilter, isOpen, scanId]);
69
79
 
70
80
  const payload = response || {};
71
81
  const findings = Array.isArray(payload.findings) ? payload.findings : [];
82
+ const origin = formatScanOrigin(payload.scanner, payload.source);
72
83
 
73
84
  const normalizedFilter = (filter || 'all').toLowerCase();
74
85
  const filteredFindings = useMemo(() => {
@@ -77,33 +88,23 @@ const CveFindingsModal = ({
77
88
  f => (f.severity || '').toLowerCase() === normalizedFilter
78
89
  );
79
90
  }, [normalizedFilter, findings]);
80
-
81
- const formatPublished = formatDateTime;
82
-
83
- const sorters = useMemo(
84
- () => ({
85
- published: (a, b) =>
86
- new Date(a.published || 0) - new Date(b.published || 0),
87
- name: (a, b) => compareStrings(a.name, b.name),
88
- version: (a, b) => compareStrings(a.version, b.version),
89
- fixed: (a, b) => compareStrings(a.fixed, b.fixed),
90
- id: (a, b) => compareStrings(a.id, b.id),
91
- status: (a, b) => compareStrings(a.status, b.status),
92
- title: (a, b) => compareStrings(a.title, b.title),
93
- severity: (a, b) => severityRank(a.severity) - severityRank(b.severity),
94
- }),
95
- []
96
- );
91
+ const normalizedSearch = search.trim().toLowerCase();
92
+ const visibleFindings = useMemo(() => {
93
+ if (!normalizedSearch) return filteredFindings;
94
+ return filteredFindings.filter(finding =>
95
+ findingMatchesSearch(finding, normalizedSearch)
96
+ );
97
+ }, [filteredFindings, normalizedSearch]);
97
98
  const sortedFindings = useMemo(() => {
98
- const list = [...filteredFindings];
99
+ const list = [...visibleFindings];
99
100
  const sortKey = sortBy.column || 'severity';
100
- const sorter = sorters[sortKey] || sorters.severity;
101
+ const sorter = findingSorters[sortKey] || findingSorters.severity;
101
102
  list.sort((a, b) => {
102
103
  const result = sorter(a, b);
103
104
  return sortBy.direction === SortByDirection.asc ? result : -result;
104
105
  });
105
106
  return list;
106
- }, [filteredFindings, sortBy, sorters]);
107
+ }, [visibleFindings, sortBy]);
107
108
 
108
109
  const onSort = column => {
109
110
  const nextDirection =
@@ -112,14 +113,17 @@ const CveFindingsModal = ({
112
113
  : SortByDirection.asc;
113
114
  setSortBy({ column, direction: nextDirection });
114
115
  };
116
+ const onSearchChange = (value, event) => {
117
+ setSearch(normalizeSearchInputValue(value, event));
118
+ };
115
119
 
116
120
  return (
117
121
  <Modal
118
122
  title={
119
- payload?.created_at && typeof payload?.total !== 'undefined'
120
- ? `${__('Report from')} ${formatPublished(payload.created_at)} - ${__(
121
- 'Total'
122
- )}: ${payload.total}`
123
+ payload?.scanned_at && typeof payload?.total !== 'undefined'
124
+ ? `${__('Report from')} ${formatScannedAt(
125
+ payload.scanned_at
126
+ )} - ${origin} - ${__('Total')}: ${payload.total}`
123
127
  : __('CVE findings')
124
128
  }
125
129
  isOpen={isOpen}
@@ -145,7 +149,18 @@ const CveFindingsModal = ({
145
149
  ) : (
146
150
  <div className="cve-modal-body">
147
151
  <div className="cve-modal-filters">
148
- <FormGroup fieldId="cve-filter">
152
+ <FormGroup fieldId="cve-search" className="cve-modal-search">
153
+ <SearchInput
154
+ id="cve-search"
155
+ value={search}
156
+ onChange={onSearchChange}
157
+ onClear={() => setSearch('')}
158
+ onSearch={onSearchChange}
159
+ placeholder={__('Search CVE, package, status, title...')}
160
+ aria-label={__('Search CVE report')}
161
+ />
162
+ </FormGroup>
163
+ <FormGroup fieldId="cve-filter" className="cve-modal-quick-filters">
149
164
  <div className="cve-filter-buttons">
150
165
  {SEVERITY_OPTIONS.map(opt => (
151
166
  <button
@@ -166,98 +181,103 @@ const CveFindingsModal = ({
166
181
  </FormGroup>
167
182
  </div>
168
183
 
169
- {response?.error && (
170
- <Text component={TextVariants.small} ouiaId="cve-findings-error">
171
- {response.error.message}
172
- </Text>
173
- )}
174
- {sortedFindings.length === 0 && !response?.error ? (
175
- <Text component={TextVariants.small} ouiaId="cve-findings-empty">
176
- {__('No findings for selected filter')}
177
- </Text>
178
- ) : (
179
- <Table
180
- variant="compact"
181
- aria-label="CVE findings table"
182
- ouiaId="cve-findings-table"
183
- >
184
- <Thead>
185
- <Tr ouiaId="cve-findings-header">
186
- {COLUMNS.map(col => (
187
- <Th
188
- key={col.key}
189
- className={`cve-col-${col.key} cve-sortable`}
190
- aria-label={
191
- col.key === 'severity' ? __('Severity') : undefined
192
- }
193
- onClick={() => onSort(col.key)}
194
- >
195
- {col.key === 'severity' ? (
196
- <SeverityIcon severity="high" />
197
- ) : (
198
- col.label
199
- )}
200
- {sortBy.column === col.key && (
201
- <span className="cve-sort-indicator">
202
- {sortBy.direction === SortByDirection.asc
203
- ? ' ▲'
204
- : ' ▼'}
205
- </span>
206
- )}
207
- </Th>
208
- ))}
209
- </Tr>
210
- </Thead>
211
- <Tbody>
212
- {sortedFindings.map((finding, index) => (
213
- <Tr key={finding.id} ouiaId={`cve-findings-row-${index}`}>
214
- <Td dataLabel={__('Severity')}>
215
- <span
216
- className="cve-summary cve-summary--icon-only"
217
- title={finding.severity}
218
- aria-label={finding.severity}
184
+ <div className="cve-modal-results">
185
+ {response?.error && (
186
+ <Text component={TextVariants.small} ouiaId="cve-findings-error">
187
+ {response.error.message}
188
+ </Text>
189
+ )}
190
+ {sortedFindings.length === 0 && !response?.error ? (
191
+ <Text component={TextVariants.small} ouiaId="cve-findings-empty">
192
+ {__('No findings for selected filter')}
193
+ </Text>
194
+ ) : (
195
+ <Table
196
+ variant="compact"
197
+ aria-label="CVE findings table"
198
+ ouiaId="cve-findings-table"
199
+ >
200
+ <Thead>
201
+ <Tr ouiaId="cve-findings-header">
202
+ {COLUMNS.map(col => (
203
+ <Th
204
+ key={col.key}
205
+ className={`cve-col-${col.key} cve-sortable`}
206
+ aria-label={
207
+ col.key === 'severity' ? __('Severity') : undefined
208
+ }
209
+ onClick={() => onSort(col.key)}
219
210
  >
220
- <SeverityIcon
221
- severity={(finding.severity || '').toLowerCase()}
222
- />
223
- </span>
224
- </Td>
225
- <Td dataLabel={__('Published')}>
226
- {formatPublished(finding.published)}
227
- </Td>
228
- <Td dataLabel={__('Package')}>{finding.name}</Td>
229
- <Td dataLabel={__('Affected version')}>
230
- {finding.version}
231
- </Td>
232
- <Td dataLabel={__('Fixed version')} title={finding.fixed}>
233
- <span className="cve-truncate">{finding.fixed}</span>
234
- </Td>
235
- <Td dataLabel={__('Status')}>
236
- {finding.status || __('open')}
237
- </Td>
238
- <Td dataLabel={__('CVE')}>
239
- {finding.url ? (
240
- <a href={finding.url} target="_blank" rel="noreferrer">
241
- {finding.id}
242
- </a>
243
- ) : (
244
- finding.id
245
- )}
246
- </Td>
247
- <Td dataLabel={__('Title')} title={finding.title}>
248
- <span className="cve-truncate">{finding.title}</span>
249
- </Td>
211
+ {col.key === 'severity' ? (
212
+ <SeverityIcon severity="high" />
213
+ ) : (
214
+ col.label
215
+ )}
216
+ {sortBy.column === col.key && (
217
+ <span className="cve-sort-indicator">
218
+ {sortBy.direction === SortByDirection.asc
219
+ ? ''
220
+ : ' '}
221
+ </span>
222
+ )}
223
+ </Th>
224
+ ))}
250
225
  </Tr>
251
- ))}
252
- </Tbody>
253
- </Table>
254
- )}
226
+ </Thead>
227
+ <Tbody>
228
+ {sortedFindings.map((finding, index) => (
229
+ <Tr key={finding.id} ouiaId={`cve-findings-row-${index}`}>
230
+ <Td dataLabel={__('Severity')}>
231
+ <span
232
+ className="cve-summary cve-summary--icon-only"
233
+ title={finding.severity}
234
+ aria-label={finding.severity}
235
+ >
236
+ <SeverityIcon
237
+ severity={(finding.severity || '').toLowerCase()}
238
+ />
239
+ </span>
240
+ </Td>
241
+ <Td dataLabel={__('Published')}>
242
+ {formatDateTime(finding.published)}
243
+ </Td>
244
+ <Td dataLabel={__('Package')}>{finding.name}</Td>
245
+ <Td dataLabel={__('Affected version')}>
246
+ {finding.version}
247
+ </Td>
248
+ <Td dataLabel={__('Fixed version')} title={finding.fixed}>
249
+ <span className="cve-truncate">{finding.fixed}</span>
250
+ </Td>
251
+ <Td dataLabel={__('Status')}>
252
+ {finding.status || __('open')}
253
+ </Td>
254
+ <Td dataLabel={__('CVE')}>
255
+ {finding.url ? (
256
+ <a
257
+ href={finding.url}
258
+ target="_blank"
259
+ rel="noreferrer"
260
+ >
261
+ {finding.id}
262
+ </a>
263
+ ) : (
264
+ finding.id
265
+ )}
266
+ </Td>
267
+ <Td dataLabel={__('Title')} title={finding.title}>
268
+ <span className="cve-truncate">{finding.title}</span>
269
+ </Td>
270
+ </Tr>
271
+ ))}
272
+ </Tbody>
273
+ </Table>
274
+ )}
275
+ </div>
255
276
  </div>
256
277
  )}
257
278
  </Modal>
258
279
  );
259
280
  };
260
-
261
281
  CveFindingsModal.propTypes = {
262
282
  isOpen: PropTypes.bool.isRequired,
263
283
  onClose: PropTypes.func.isRequired,
@@ -0,0 +1,227 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import { Button, Checkbox, Pagination, Title } from '@patternfly/react-core';
5
+ import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
6
+ import { translate as __ } from 'foremanReact/common/I18n';
7
+ import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
8
+ import { formatScanOrigin } from './cve_helpers';
9
+
10
+ const CveScansReports = ({
11
+ scans,
12
+ itemCount,
13
+ page,
14
+ perPage,
15
+ onSetPage,
16
+ onPerPageSelect,
17
+ selectedScanIds,
18
+ selectedCount,
19
+ onCompare,
20
+ onClearSelection,
21
+ onToggleSelection,
22
+ onOpenModal,
23
+ exportUrlFor,
24
+ }) => (
25
+ <section className="cve-scans-section" aria-label="CVE scan reports">
26
+ <div className="cve-scans-section-header">
27
+ <div className="cve-scans-section-title">
28
+ <Title headingLevel="h3" size="lg" ouiaId="cve-scans-reports-title">
29
+ {__('Reports')}
30
+ </Title>
31
+ </div>
32
+ <Pagination
33
+ itemCount={itemCount}
34
+ perPage={perPage}
35
+ page={page}
36
+ onSetPage={onSetPage}
37
+ onPerPageSelect={onPerPageSelect}
38
+ variant="top"
39
+ isCompact
40
+ ouiaId="cve-scans-pagination"
41
+ />
42
+ </div>
43
+ <div className="cve-scans-toolbar">
44
+ <div className="cve-scans-actions">
45
+ <span className="cve-scans-selection">
46
+ {__('Selected')}: {selectedCount}/2
47
+ </span>
48
+ <Button
49
+ variant="secondary"
50
+ isDisabled={selectedCount !== 2}
51
+ onClick={onCompare}
52
+ ouiaId="cve-scans-compare"
53
+ >
54
+ {__('Compare selected')}
55
+ </Button>
56
+ <Button
57
+ variant="link"
58
+ isDisabled={selectedCount === 0}
59
+ onClick={onClearSelection}
60
+ ouiaId="cve-scans-clear-selection"
61
+ >
62
+ {__('Clear selection')}
63
+ </Button>
64
+ </div>
65
+ </div>
66
+ <Table
67
+ variant="compact"
68
+ aria-label="CVE scans history"
69
+ ouiaId="cve-scans-table"
70
+ >
71
+ <Thead>
72
+ <Tr ouiaId="cve-scans-header">
73
+ <Th>{__('Select')}</Th>
74
+ <Th>{__('Scanned at')}</Th>
75
+ <Th>{__('Scanner')}</Th>
76
+ <Th>{__('Total')}</Th>
77
+ <Th>{__('Critical')}</Th>
78
+ <Th>{__('High')}</Th>
79
+ <Th>{__('Medium')}</Th>
80
+ <Th>{__('Low')}</Th>
81
+ <Th>{__('Export')}</Th>
82
+ </Tr>
83
+ </Thead>
84
+ <Tbody>
85
+ {scans.map((scan, index) => (
86
+ <Tr key={scan.id} ouiaId={`cve-scans-row-${index}`}>
87
+ <Td dataLabel={__('Select')}>
88
+ <Checkbox
89
+ id={`cve-scan-select-${scan.id}`}
90
+ isChecked={selectedScanIds.includes(scan.id)}
91
+ isDisabled={
92
+ selectedScanIds.length === 2 &&
93
+ !selectedScanIds.includes(scan.id)
94
+ }
95
+ onChange={() => onToggleSelection(scan.id)}
96
+ aria-label={__('Select scan for comparison')}
97
+ ouiaId={`cve-scan-select-${scan.id}`}
98
+ />
99
+ </Td>
100
+ <Td dataLabel={__('Scanned at')}>
101
+ <Button
102
+ variant="link"
103
+ className="cve-summary-link"
104
+ onClick={() => onOpenModal(scan.id, 'all')}
105
+ ouiaId={`cve-scans-open-${scan.id}`}
106
+ >
107
+ <RelativeDateTime
108
+ date={scan.scanned_at}
109
+ defaultValue={__('Unknown time')}
110
+ />
111
+ </Button>
112
+ </Td>
113
+ <Td dataLabel={__('Scanner')}>
114
+ {formatScanOrigin(scan.scanner, scan.source)}
115
+ </Td>
116
+ <Td dataLabel={__('Total')}>
117
+ <Button
118
+ variant="link"
119
+ className="cve-summary-link"
120
+ onClick={() => onOpenModal(scan.id, 'all')}
121
+ ouiaId={`cve-scans-total-${scan.id}`}
122
+ >
123
+ {scan.total}
124
+ </Button>
125
+ </Td>
126
+ <Td dataLabel={__('Critical')}>
127
+ <Button
128
+ variant="link"
129
+ className="cve-summary-link"
130
+ onClick={() => onOpenModal(scan.id, 'critical')}
131
+ ouiaId={`cve-scans-critical-${scan.id}`}
132
+ >
133
+ {scan.critical}
134
+ </Button>
135
+ </Td>
136
+ <Td dataLabel={__('High')}>
137
+ <Button
138
+ variant="link"
139
+ className="cve-summary-link"
140
+ onClick={() => onOpenModal(scan.id, 'high')}
141
+ ouiaId={`cve-scans-high-${scan.id}`}
142
+ >
143
+ {scan.high}
144
+ </Button>
145
+ </Td>
146
+ <Td dataLabel={__('Medium')}>
147
+ <Button
148
+ variant="link"
149
+ className="cve-summary-link"
150
+ onClick={() => onOpenModal(scan.id, 'medium')}
151
+ ouiaId={`cve-scans-medium-${scan.id}`}
152
+ >
153
+ {scan.medium}
154
+ </Button>
155
+ </Td>
156
+ <Td dataLabel={__('Low')}>
157
+ <Button
158
+ variant="link"
159
+ className="cve-summary-link"
160
+ onClick={() => onOpenModal(scan.id, 'low')}
161
+ ouiaId={`cve-scans-low-${scan.id}`}
162
+ >
163
+ {scan.low}
164
+ </Button>
165
+ </Td>
166
+ <Td dataLabel={__('Export')}>
167
+ <Button
168
+ component="a"
169
+ variant="secondary"
170
+ href={exportUrlFor(scan.id)}
171
+ ouiaId={`cve-scans-export-${scan.id}`}
172
+ >
173
+ {__('Export CSV')}
174
+ </Button>
175
+ </Td>
176
+ </Tr>
177
+ ))}
178
+ </Tbody>
179
+ </Table>
180
+ </section>
181
+ );
182
+
183
+ CveScansReports.propTypes = {
184
+ scans: PropTypes.arrayOf(
185
+ PropTypes.shape({
186
+ id: PropTypes.number.isRequired,
187
+ scanned_at: PropTypes.string,
188
+ scanner: PropTypes.string,
189
+ source: PropTypes.string,
190
+ total: PropTypes.number,
191
+ critical: PropTypes.number,
192
+ high: PropTypes.number,
193
+ medium: PropTypes.number,
194
+ low: PropTypes.number,
195
+ })
196
+ ),
197
+ itemCount: PropTypes.number,
198
+ page: PropTypes.number,
199
+ perPage: PropTypes.number,
200
+ onSetPage: PropTypes.func,
201
+ onPerPageSelect: PropTypes.func,
202
+ selectedScanIds: PropTypes.arrayOf(PropTypes.number),
203
+ selectedCount: PropTypes.number,
204
+ onCompare: PropTypes.func,
205
+ onClearSelection: PropTypes.func,
206
+ onToggleSelection: PropTypes.func,
207
+ onOpenModal: PropTypes.func,
208
+ exportUrlFor: PropTypes.func,
209
+ };
210
+
211
+ CveScansReports.defaultProps = {
212
+ scans: [],
213
+ itemCount: 0,
214
+ page: 1,
215
+ perPage: 20,
216
+ onSetPage: () => {},
217
+ onPerPageSelect: () => {},
218
+ selectedScanIds: [],
219
+ selectedCount: 0,
220
+ onCompare: () => {},
221
+ onClearSelection: () => {},
222
+ onToggleSelection: () => {},
223
+ onOpenModal: () => {},
224
+ exportUrlFor: () => '#',
225
+ };
226
+
227
+ export default CveScansReports;