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
|
@@ -1,64 +1,21 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
|
-
|
|
3
|
-
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import React from 'react';
|
|
4
3
|
import PropTypes from 'prop-types';
|
|
5
|
-
import {
|
|
6
|
-
Modal,
|
|
7
|
-
Button,
|
|
8
|
-
Text,
|
|
9
|
-
TextVariants,
|
|
10
|
-
FormGroup,
|
|
11
|
-
SearchInput,
|
|
12
|
-
} from '@patternfly/react-core';
|
|
13
|
-
import {
|
|
14
|
-
Table,
|
|
15
|
-
Tbody,
|
|
16
|
-
Tr,
|
|
17
|
-
Th,
|
|
18
|
-
Thead,
|
|
19
|
-
Td,
|
|
20
|
-
SortByDirection,
|
|
21
|
-
} from '@patternfly/react-table';
|
|
4
|
+
import { Modal, Button } from '@patternfly/react-core';
|
|
22
5
|
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
23
6
|
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
24
7
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
25
|
-
import
|
|
26
|
-
import SeverityIcon from './SeverityIcon';
|
|
27
|
-
import {
|
|
28
|
-
findingMatchesSearch,
|
|
29
|
-
formatDateTime,
|
|
30
|
-
formatScanOrigin,
|
|
31
|
-
formatScannedAt,
|
|
32
|
-
findingSorters,
|
|
33
|
-
normalizeSearchInputValue,
|
|
34
|
-
} from './cve_helpers';
|
|
8
|
+
import CveFindingsView from './CveFindingsView';
|
|
35
9
|
import './cve_scans.scss';
|
|
36
10
|
|
|
37
|
-
const SEVERITY_OPTIONS = ['all', 'critical', 'high', 'medium', 'low'];
|
|
38
|
-
const COLUMNS = [
|
|
39
|
-
{ key: 'severity', label: __('Severity') },
|
|
40
|
-
{ key: 'published', label: __('Published') },
|
|
41
|
-
{ key: 'name', label: __('Package') },
|
|
42
|
-
{ key: 'version', label: __('Affected version') },
|
|
43
|
-
{ key: 'fixed', label: __('Fixed version') },
|
|
44
|
-
{ key: 'status', label: __('Status') },
|
|
45
|
-
{ key: 'id', label: __('CVE') },
|
|
46
|
-
{ key: 'title', label: __('Title') },
|
|
47
|
-
];
|
|
48
|
-
|
|
49
11
|
const CveFindingsModal = ({
|
|
50
12
|
isOpen,
|
|
51
13
|
onClose,
|
|
52
14
|
hostId,
|
|
15
|
+
hostName,
|
|
53
16
|
scanId,
|
|
54
17
|
initialFilter,
|
|
55
18
|
}) => {
|
|
56
|
-
const [filter, setFilter] = useState(initialFilter || 'all');
|
|
57
|
-
const [search, setSearch] = useState('');
|
|
58
|
-
const [sortBy, setSortBy] = useState({
|
|
59
|
-
direction: SortByDirection.desc,
|
|
60
|
-
column: 'severity',
|
|
61
|
-
});
|
|
62
19
|
const normalizedScanId =
|
|
63
20
|
scanId !== null && scanId !== undefined && String(scanId).trim() !== ''
|
|
64
21
|
? scanId
|
|
@@ -68,64 +25,12 @@ const CveFindingsModal = ({
|
|
|
68
25
|
: foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`);
|
|
69
26
|
|
|
70
27
|
const { response, status } = useAPI(isOpen ? 'get' : null, url, {
|
|
71
|
-
key: `CVE_SCAN_${normalizedScanId || 'latest'}`,
|
|
28
|
+
key: `CVE_SCAN_${hostId}_${normalizedScanId || 'latest'}`,
|
|
72
29
|
});
|
|
73
30
|
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (!isOpen) return;
|
|
76
|
-
setFilter(initialFilter || 'all');
|
|
77
|
-
setSearch('');
|
|
78
|
-
}, [initialFilter, isOpen, scanId]);
|
|
79
|
-
|
|
80
|
-
const payload = response || {};
|
|
81
|
-
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
|
82
|
-
const origin = formatScanOrigin(payload.scanner, payload.source);
|
|
83
|
-
|
|
84
|
-
const normalizedFilter = (filter || 'all').toLowerCase();
|
|
85
|
-
const filteredFindings = useMemo(() => {
|
|
86
|
-
if (normalizedFilter === 'all') return findings;
|
|
87
|
-
return findings.filter(
|
|
88
|
-
f => (f.severity || '').toLowerCase() === normalizedFilter
|
|
89
|
-
);
|
|
90
|
-
}, [normalizedFilter, findings]);
|
|
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]);
|
|
98
|
-
const sortedFindings = useMemo(() => {
|
|
99
|
-
const list = [...visibleFindings];
|
|
100
|
-
const sortKey = sortBy.column || 'severity';
|
|
101
|
-
const sorter = findingSorters[sortKey] || findingSorters.severity;
|
|
102
|
-
list.sort((a, b) => {
|
|
103
|
-
const result = sorter(a, b);
|
|
104
|
-
return sortBy.direction === SortByDirection.asc ? result : -result;
|
|
105
|
-
});
|
|
106
|
-
return list;
|
|
107
|
-
}, [visibleFindings, sortBy]);
|
|
108
|
-
|
|
109
|
-
const onSort = column => {
|
|
110
|
-
const nextDirection =
|
|
111
|
-
sortBy.column === column && sortBy.direction === SortByDirection.asc
|
|
112
|
-
? SortByDirection.desc
|
|
113
|
-
: SortByDirection.asc;
|
|
114
|
-
setSortBy({ column, direction: nextDirection });
|
|
115
|
-
};
|
|
116
|
-
const onSearchChange = (value, event) => {
|
|
117
|
-
setSearch(normalizeSearchInputValue(value, event));
|
|
118
|
-
};
|
|
119
|
-
|
|
120
31
|
return (
|
|
121
32
|
<Modal
|
|
122
|
-
title={
|
|
123
|
-
payload?.scanned_at && typeof payload?.total !== 'undefined'
|
|
124
|
-
? `${__('Report from')} ${formatScannedAt(
|
|
125
|
-
payload.scanned_at
|
|
126
|
-
)} - ${origin} - ${__('Total')}: ${payload.total}`
|
|
127
|
-
: __('CVE findings')
|
|
128
|
-
}
|
|
33
|
+
title={__('CVE findings')}
|
|
129
34
|
isOpen={isOpen}
|
|
130
35
|
onClose={onClose}
|
|
131
36
|
width="70%"
|
|
@@ -142,151 +47,27 @@ const CveFindingsModal = ({
|
|
|
142
47
|
</Button>,
|
|
143
48
|
]}
|
|
144
49
|
>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<div className="cve-modal-filters">
|
|
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">
|
|
164
|
-
<div className="cve-filter-buttons">
|
|
165
|
-
{SEVERITY_OPTIONS.map(opt => (
|
|
166
|
-
<button
|
|
167
|
-
key={opt}
|
|
168
|
-
type="button"
|
|
169
|
-
aria-pressed={filter === opt}
|
|
170
|
-
className={
|
|
171
|
-
filter === opt
|
|
172
|
-
? 'cve-filter-button is-active'
|
|
173
|
-
: 'cve-filter-button'
|
|
174
|
-
}
|
|
175
|
-
onClick={() => setFilter(opt)}
|
|
176
|
-
>
|
|
177
|
-
{__(opt)}
|
|
178
|
-
</button>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
181
|
-
</FormGroup>
|
|
182
|
-
</div>
|
|
183
|
-
|
|
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)}
|
|
210
|
-
>
|
|
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
|
-
))}
|
|
225
|
-
</Tr>
|
|
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>
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
50
|
+
<CveFindingsView
|
|
51
|
+
scan={response}
|
|
52
|
+
status={status}
|
|
53
|
+
initialFilter={initialFilter}
|
|
54
|
+
hostName={hostName}
|
|
55
|
+
/>
|
|
278
56
|
</Modal>
|
|
279
57
|
);
|
|
280
58
|
};
|
|
59
|
+
|
|
281
60
|
CveFindingsModal.propTypes = {
|
|
282
61
|
isOpen: PropTypes.bool.isRequired,
|
|
283
62
|
onClose: PropTypes.func.isRequired,
|
|
284
63
|
hostId: PropTypes.number.isRequired,
|
|
64
|
+
hostName: PropTypes.string,
|
|
285
65
|
scanId: PropTypes.number,
|
|
286
66
|
initialFilter: PropTypes.string,
|
|
287
67
|
};
|
|
288
68
|
|
|
289
69
|
CveFindingsModal.defaultProps = {
|
|
70
|
+
hostName: undefined,
|
|
290
71
|
scanId: undefined,
|
|
291
72
|
initialFilter: 'all',
|
|
292
73
|
};
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
/* eslint-disable camelcase */
|
|
3
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import PropTypes from 'prop-types';
|
|
5
|
+
import {
|
|
6
|
+
EmptyState,
|
|
7
|
+
EmptyStateBody,
|
|
8
|
+
EmptyStateIcon,
|
|
9
|
+
FormGroup,
|
|
10
|
+
SearchInput,
|
|
11
|
+
Text,
|
|
12
|
+
TextContent,
|
|
13
|
+
TextVariants,
|
|
14
|
+
Title,
|
|
15
|
+
} from '@patternfly/react-core';
|
|
16
|
+
import {
|
|
17
|
+
Table,
|
|
18
|
+
Tbody,
|
|
19
|
+
Tr,
|
|
20
|
+
Th,
|
|
21
|
+
Thead,
|
|
22
|
+
Td,
|
|
23
|
+
SortByDirection,
|
|
24
|
+
} from '@patternfly/react-table';
|
|
25
|
+
import { CheckCircleIcon, SearchIcon } from '@patternfly/react-icons';
|
|
26
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
27
|
+
import { STATUS } from 'foremanReact/constants';
|
|
28
|
+
import SeverityIcon from './SeverityIcon';
|
|
29
|
+
import CveLink from './CveLink';
|
|
30
|
+
import CveKatelloFixLink from './CveKatelloFixLink';
|
|
31
|
+
import useSortBy from './useSortBy';
|
|
32
|
+
import {
|
|
33
|
+
findingIdentity,
|
|
34
|
+
findingMatchesSearch,
|
|
35
|
+
findingSorters,
|
|
36
|
+
formatDateTime,
|
|
37
|
+
formatScanOrigin,
|
|
38
|
+
formatScannedAt,
|
|
39
|
+
katelloFixTitle,
|
|
40
|
+
noFindingsBody,
|
|
41
|
+
noFindingsTitle,
|
|
42
|
+
noReportsBody,
|
|
43
|
+
noReportsTitle,
|
|
44
|
+
normalizeSearchInputValue,
|
|
45
|
+
shouldShowKatelloFixColumn,
|
|
46
|
+
} from './cve_helpers';
|
|
47
|
+
import './cve_scans.scss';
|
|
48
|
+
|
|
49
|
+
const SEVERITY_OPTIONS = ['all', 'critical', 'high', 'medium', 'low'];
|
|
50
|
+
const COLUMNS = [
|
|
51
|
+
{ key: 'severity', label: __('Severity') },
|
|
52
|
+
{ key: 'published', label: __('Published') },
|
|
53
|
+
{ key: 'name', label: __('Package') },
|
|
54
|
+
{ key: 'version', label: __('Affected version') },
|
|
55
|
+
{ key: 'fixed', label: __('Fixed version') },
|
|
56
|
+
{ key: 'katello_fix', label: __('Fix availability') },
|
|
57
|
+
{ key: 'status', label: __('Status') },
|
|
58
|
+
{ key: 'id', label: __('CVE') },
|
|
59
|
+
{ key: 'title', label: __('Title') },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const CveFindingsView = ({ scan, status, initialFilter, hostName }) => {
|
|
63
|
+
const [filter, setFilter] = useState(initialFilter || 'all');
|
|
64
|
+
const [search, setSearch] = useState('');
|
|
65
|
+
const scanId = scan?.id;
|
|
66
|
+
const scanFindings = scan?.findings;
|
|
67
|
+
const findings = useMemo(
|
|
68
|
+
() => (Array.isArray(scanFindings) ? scanFindings : []),
|
|
69
|
+
[scanFindings]
|
|
70
|
+
);
|
|
71
|
+
const showKatelloFixColumn = shouldShowKatelloFixColumn(findings);
|
|
72
|
+
const columns = showKatelloFixColumn
|
|
73
|
+
? COLUMNS
|
|
74
|
+
: COLUMNS.filter(column => column.key !== 'katello_fix');
|
|
75
|
+
const origin = formatScanOrigin(scan?.scanner, scan?.source);
|
|
76
|
+
const normalizedFilter = (filter || 'all').toLowerCase();
|
|
77
|
+
const filteredFindings =
|
|
78
|
+
normalizedFilter === 'all'
|
|
79
|
+
? findings
|
|
80
|
+
: findings.filter(
|
|
81
|
+
finding => (finding.severity || '').toLowerCase() === normalizedFilter
|
|
82
|
+
);
|
|
83
|
+
const normalizedSearch = search.trim().toLowerCase();
|
|
84
|
+
const visibleFindings = useMemo(() => {
|
|
85
|
+
if (!normalizedSearch) return filteredFindings;
|
|
86
|
+
return filteredFindings.filter(finding =>
|
|
87
|
+
findingMatchesSearch(finding, normalizedSearch)
|
|
88
|
+
);
|
|
89
|
+
}, [filteredFindings, normalizedSearch]);
|
|
90
|
+
const { sortBy, onSort, sortedItems: sortedFindings } = useSortBy(
|
|
91
|
+
'severity',
|
|
92
|
+
findingSorters,
|
|
93
|
+
visibleFindings
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
setFilter(initialFilter || 'all');
|
|
98
|
+
setSearch('');
|
|
99
|
+
}, [initialFilter, scanId]);
|
|
100
|
+
|
|
101
|
+
if (status === STATUS.PENDING) {
|
|
102
|
+
return (
|
|
103
|
+
<Text component={TextVariants.small} ouiaId="cve-findings-loading">
|
|
104
|
+
{__('Loading...')}
|
|
105
|
+
</Text>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (scan?.error) {
|
|
110
|
+
return (
|
|
111
|
+
<Text component={TextVariants.small} ouiaId="cve-findings-error">
|
|
112
|
+
{scan.error.message}
|
|
113
|
+
</Text>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!scan?.id) {
|
|
118
|
+
return (
|
|
119
|
+
<EmptyState>
|
|
120
|
+
<EmptyStateIcon icon={SearchIcon} />
|
|
121
|
+
<Title headingLevel="h4" size="md" ouiaId="cve-findings-empty-title">
|
|
122
|
+
{noReportsTitle()}
|
|
123
|
+
</Title>
|
|
124
|
+
<EmptyStateBody>{noReportsBody()}</EmptyStateBody>
|
|
125
|
+
</EmptyState>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const onSearchChange = (value, event) => {
|
|
130
|
+
setSearch(normalizeSearchInputValue(value, event));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="cve-modal-body">
|
|
135
|
+
<TextContent className="cve-findings-meta">
|
|
136
|
+
<Text component={TextVariants.small} ouiaId="cve-findings-summary">
|
|
137
|
+
{`${__('Report from')} ${formatScannedAt(
|
|
138
|
+
scan.scanned_at
|
|
139
|
+
)} - ${origin} - ${__('Total')}: ${scan.total}`}
|
|
140
|
+
</Text>
|
|
141
|
+
</TextContent>
|
|
142
|
+
|
|
143
|
+
{findings.length === 0 ? (
|
|
144
|
+
<EmptyState className="cve-empty-state cve-empty-state--success">
|
|
145
|
+
<EmptyStateIcon icon={CheckCircleIcon} />
|
|
146
|
+
<Title headingLevel="h4" size="md" ouiaId="cve-findings-clean-title">
|
|
147
|
+
{noFindingsTitle()}
|
|
148
|
+
</Title>
|
|
149
|
+
<EmptyStateBody>{noFindingsBody()}</EmptyStateBody>
|
|
150
|
+
</EmptyState>
|
|
151
|
+
) : (
|
|
152
|
+
<>
|
|
153
|
+
<div className="cve-modal-filters">
|
|
154
|
+
<FormGroup fieldId="cve-search" className="cve-modal-search">
|
|
155
|
+
<SearchInput
|
|
156
|
+
id="cve-search"
|
|
157
|
+
value={search}
|
|
158
|
+
onChange={onSearchChange}
|
|
159
|
+
onClear={() => setSearch('')}
|
|
160
|
+
onSearch={onSearchChange}
|
|
161
|
+
placeholder={__('Search CVE, package, status, title...')}
|
|
162
|
+
aria-label={__('Search CVE report')}
|
|
163
|
+
/>
|
|
164
|
+
</FormGroup>
|
|
165
|
+
<FormGroup fieldId="cve-filter" className="cve-modal-quick-filters">
|
|
166
|
+
<div className="cve-filter-buttons">
|
|
167
|
+
{SEVERITY_OPTIONS.map(opt => (
|
|
168
|
+
<button
|
|
169
|
+
key={opt}
|
|
170
|
+
type="button"
|
|
171
|
+
aria-pressed={filter === opt}
|
|
172
|
+
className={
|
|
173
|
+
filter === opt
|
|
174
|
+
? 'cve-filter-button is-active'
|
|
175
|
+
: 'cve-filter-button'
|
|
176
|
+
}
|
|
177
|
+
onClick={() => setFilter(opt)}
|
|
178
|
+
>
|
|
179
|
+
{__(opt)}
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</FormGroup>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="cve-modal-results">
|
|
187
|
+
{sortedFindings.length === 0 ? (
|
|
188
|
+
<Text component={TextVariants.small} ouiaId="cve-findings-empty">
|
|
189
|
+
{__('No findings for selected filter')}
|
|
190
|
+
</Text>
|
|
191
|
+
) : (
|
|
192
|
+
<Table
|
|
193
|
+
variant="compact"
|
|
194
|
+
aria-label="CVE findings table"
|
|
195
|
+
ouiaId="cve-findings-table"
|
|
196
|
+
>
|
|
197
|
+
<Thead>
|
|
198
|
+
<Tr ouiaId="cve-findings-header">
|
|
199
|
+
{columns.map(col => (
|
|
200
|
+
<Th
|
|
201
|
+
key={col.key}
|
|
202
|
+
className={`cve-col-${col.key} cve-sortable`}
|
|
203
|
+
aria-label={
|
|
204
|
+
col.key === 'severity' ? __('Severity') : undefined
|
|
205
|
+
}
|
|
206
|
+
onClick={() => onSort(col.key)}
|
|
207
|
+
>
|
|
208
|
+
{col.key === 'severity' ? (
|
|
209
|
+
<SeverityIcon severity="high" />
|
|
210
|
+
) : (
|
|
211
|
+
col.label
|
|
212
|
+
)}
|
|
213
|
+
{sortBy.column === col.key && (
|
|
214
|
+
<span className="cve-sort-indicator">
|
|
215
|
+
{sortBy.direction === SortByDirection.asc
|
|
216
|
+
? ' ▲'
|
|
217
|
+
: ' ▼'}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</Th>
|
|
221
|
+
))}
|
|
222
|
+
</Tr>
|
|
223
|
+
</Thead>
|
|
224
|
+
<Tbody>
|
|
225
|
+
{sortedFindings.map((finding, index) => (
|
|
226
|
+
<Tr
|
|
227
|
+
key={findingIdentity(finding)}
|
|
228
|
+
ouiaId={`cve-findings-row-${index}`}
|
|
229
|
+
>
|
|
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
|
+
{showKatelloFixColumn && (
|
|
252
|
+
<Td
|
|
253
|
+
dataLabel={__('Fix availability')}
|
|
254
|
+
title={katelloFixTitle(finding.katello_fix)}
|
|
255
|
+
>
|
|
256
|
+
<CveKatelloFixLink
|
|
257
|
+
fix={finding.katello_fix}
|
|
258
|
+
hostName={hostName}
|
|
259
|
+
/>
|
|
260
|
+
</Td>
|
|
261
|
+
)}
|
|
262
|
+
<Td dataLabel={__('Status')}>
|
|
263
|
+
{finding.status || __('open')}
|
|
264
|
+
</Td>
|
|
265
|
+
<Td dataLabel={__('CVE')}>
|
|
266
|
+
<CveLink id={finding.id} url={finding.url} />
|
|
267
|
+
</Td>
|
|
268
|
+
<Td dataLabel={__('Title')} title={finding.title}>
|
|
269
|
+
<span className="cve-truncate">{finding.title}</span>
|
|
270
|
+
</Td>
|
|
271
|
+
</Tr>
|
|
272
|
+
))}
|
|
273
|
+
</Tbody>
|
|
274
|
+
</Table>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
CveFindingsView.propTypes = {
|
|
284
|
+
scan: PropTypes.shape({
|
|
285
|
+
id: PropTypes.number,
|
|
286
|
+
scanned_at: PropTypes.string,
|
|
287
|
+
scanner: PropTypes.string,
|
|
288
|
+
source: PropTypes.string,
|
|
289
|
+
total: PropTypes.number,
|
|
290
|
+
findings: PropTypes.arrayOf(PropTypes.object),
|
|
291
|
+
error: PropTypes.shape({
|
|
292
|
+
message: PropTypes.string,
|
|
293
|
+
}),
|
|
294
|
+
}),
|
|
295
|
+
status: PropTypes.string,
|
|
296
|
+
initialFilter: PropTypes.string,
|
|
297
|
+
hostName: PropTypes.string,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
CveFindingsView.defaultProps = {
|
|
301
|
+
scan: undefined,
|
|
302
|
+
status: undefined,
|
|
303
|
+
initialFilter: 'all',
|
|
304
|
+
hostName: undefined,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export default CveFindingsView;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import {
|
|
5
|
+
katelloFixDetailsUrl,
|
|
6
|
+
katelloFixInstallUrl,
|
|
7
|
+
katelloFixLabel,
|
|
8
|
+
katelloFixTitle,
|
|
9
|
+
} from './cve_helpers';
|
|
10
|
+
|
|
11
|
+
const katelloFixClassName = fix =>
|
|
12
|
+
`cve-katello-fix cve-katello-fix--${(
|
|
13
|
+
fix?.status || 'unknown'
|
|
14
|
+
).toLowerCase()}`;
|
|
15
|
+
|
|
16
|
+
const CveKatelloFixLink = ({ fix, hostName }) => {
|
|
17
|
+
const label = katelloFixLabel(fix);
|
|
18
|
+
const title = katelloFixTitle(fix);
|
|
19
|
+
const installUrl = katelloFixInstallUrl({ hostName, fix });
|
|
20
|
+
const detailsUrl = katelloFixDetailsUrl(fix);
|
|
21
|
+
const className = katelloFixClassName(fix);
|
|
22
|
+
|
|
23
|
+
if (installUrl) {
|
|
24
|
+
return (
|
|
25
|
+
<a className={className} href={installUrl} title={title}>
|
|
26
|
+
{label}
|
|
27
|
+
</a>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if ((fix?.status || '').toLowerCase() === 'applicable' && detailsUrl) {
|
|
32
|
+
return (
|
|
33
|
+
<a className={className} href={detailsUrl} title={title}>
|
|
34
|
+
{label}
|
|
35
|
+
</a>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span className={className} title={title}>
|
|
41
|
+
{label}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
CveKatelloFixLink.propTypes = {
|
|
47
|
+
fix: PropTypes.shape({
|
|
48
|
+
status: PropTypes.string,
|
|
49
|
+
errata: PropTypes.arrayOf(PropTypes.object),
|
|
50
|
+
}),
|
|
51
|
+
hostName: PropTypes.string,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
CveKatelloFixLink.defaultProps = {
|
|
55
|
+
fix: undefined,
|
|
56
|
+
hostName: undefined,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default CveKatelloFixLink;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
|
|
5
|
+
const SAFE_URL = /^https?:\/\//i;
|
|
6
|
+
|
|
7
|
+
const CveLink = ({ id, url }) => {
|
|
8
|
+
if (url && SAFE_URL.test(url)) {
|
|
9
|
+
return (
|
|
10
|
+
<a href={url} target="_blank" rel="noreferrer">
|
|
11
|
+
{id}
|
|
12
|
+
</a>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return <>{id}</>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
CveLink.propTypes = {
|
|
20
|
+
id: PropTypes.string.isRequired,
|
|
21
|
+
url: PropTypes.string,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
CveLink.defaultProps = {
|
|
25
|
+
url: undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default CveLink;
|