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.
- checksums.yaml +4 -4
- data/README.md +101 -5
- data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
- data/app/models/host_status/cve_status.rb +3 -1
- data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +5 -8
- data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
- data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
- data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
- data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +22 -9
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
- data/config/routes.rb +6 -1
- data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
- data/lib/foreman_cve_scanner/engine.rb +68 -17
- data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
- data/package.json +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
- data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
- data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
- data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
- data/test/models/host_status/cve_status_test.rb +12 -3
- data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
- data/webpack/components/CveCompareModal.js +298 -0
- data/webpack/components/CveDetailsCard.js +141 -121
- data/webpack/components/CveFindingsModal.js +131 -111
- data/webpack/components/CveScansReports.js +227 -0
- data/webpack/components/CveScansTab.js +122 -119
- data/webpack/components/CveTrendChart.js +264 -0
- data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
- data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
- data/webpack/components/__tests__/CveScansTab.test.js +185 -5
- data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
- data/webpack/components/__tests__/cve_helpers.test.js +18 -0
- data/webpack/components/cve_helpers.js +139 -0
- data/webpack/components/cve_scans.scss +464 -9
- data/webpack/components/useModalScan.js +26 -0
- metadata +24 -3
- data/webpack/components/CveHistoryTable.js +0 -58
- data/webpack/components/CveOverviewCard.js +0 -67
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
|
-
import React
|
|
2
|
+
import React from 'react';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
4
|
import { Table, Tbody, Tr, Th, Thead, Td } from '@patternfly/react-table';
|
|
5
5
|
import {
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
EmptyStateBody,
|
|
17
17
|
Title,
|
|
18
18
|
} from '@patternfly/react-core';
|
|
19
|
-
import { SearchIcon } from '@patternfly/react-icons';
|
|
19
|
+
import { CheckCircleIcon, SearchIcon } from '@patternfly/react-icons';
|
|
20
20
|
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
21
21
|
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
22
22
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
@@ -26,56 +26,54 @@ import { STATUS } from 'foremanReact/constants';
|
|
|
26
26
|
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
27
27
|
import SeverityIcon from './SeverityIcon';
|
|
28
28
|
import CveFindingsModal from './CveFindingsModal';
|
|
29
|
-
import
|
|
29
|
+
import useModalScan from './useModalScan';
|
|
30
30
|
import {
|
|
31
|
+
noFindingsBody,
|
|
32
|
+
noFindingsTitle,
|
|
33
|
+
formatScanOrigin,
|
|
31
34
|
noReportsBody,
|
|
32
35
|
noReportsTitle,
|
|
33
36
|
riskLevelFromWorst,
|
|
37
|
+
severityRank,
|
|
34
38
|
} from './cve_helpers';
|
|
35
39
|
import './cve_scans.scss';
|
|
36
40
|
|
|
37
41
|
const CveDetailsCard = ({ hostDetails }) => {
|
|
38
42
|
const hostId = hostDetails?.id;
|
|
39
|
-
const historyUrl = hostId
|
|
40
|
-
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans?per_page=3`)
|
|
41
|
-
: null;
|
|
42
43
|
const latestUrl = hostId
|
|
43
44
|
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
44
45
|
: null;
|
|
45
|
-
const { response: historyResponse, status } = useAPI('get', historyUrl, {
|
|
46
|
-
key: `CVE_DETAILS_${hostId}`,
|
|
47
|
-
});
|
|
48
46
|
const { response: latestResponse, status: latestStatus } = useAPI(
|
|
49
47
|
'get',
|
|
50
48
|
latestUrl,
|
|
51
49
|
{ key: `CVE_DETAILS_LATEST_${hostId}` }
|
|
52
50
|
);
|
|
53
|
-
const
|
|
54
|
-
const [modalScanId, setModalScanId] = useState(null);
|
|
55
|
-
const [modalFilter, setModalFilter] = useState('all');
|
|
51
|
+
const { isOpen, scanId, filter, openModal, closeModal } = useModalScan();
|
|
56
52
|
if (!hostId) return null;
|
|
57
|
-
const
|
|
58
|
-
const latest = latestResponse?.id ? latestResponse : scans[0];
|
|
59
|
-
const historyScans =
|
|
60
|
-
latest && Array.isArray(scans)
|
|
61
|
-
? scans.filter(scan => scan.id !== latest.id)
|
|
62
|
-
: scans;
|
|
53
|
+
const latest = latestResponse?.id ? latestResponse : null;
|
|
63
54
|
const findings = latest?.findings || [];
|
|
64
55
|
const sortedFindings = [...findings].sort((a, b) => {
|
|
56
|
+
const severityDiff = severityRank(b.severity) - severityRank(a.severity);
|
|
57
|
+
if (severityDiff !== 0) return severityDiff;
|
|
65
58
|
const aTime = new Date(a.published || 0).getTime();
|
|
66
59
|
const bTime = new Date(b.published || 0).getTime();
|
|
67
60
|
return bTime - aTime;
|
|
68
61
|
});
|
|
62
|
+
// The host details preview should show ranked CVEs first and only fall back
|
|
63
|
+
// to unknown severities when a scan has no ranked findings at all.
|
|
64
|
+
const previewFindings = sortedFindings.filter(
|
|
65
|
+
finding => severityRank(finding.severity) > 0
|
|
66
|
+
);
|
|
67
|
+
const visibleFindings =
|
|
68
|
+
previewFindings.length > 0
|
|
69
|
+
? previewFindings.slice(0, 5)
|
|
70
|
+
: sortedFindings.slice(0, 5);
|
|
69
71
|
const worst = latest?.summary?.worst || 'none';
|
|
70
72
|
const riskLevel = riskLevelFromWorst(worst);
|
|
71
|
-
const
|
|
72
|
-
setModalScanId(scanId);
|
|
73
|
-
setModalFilter(filter || 'all');
|
|
74
|
-
setIsModalOpen(true);
|
|
75
|
-
};
|
|
73
|
+
const origin = formatScanOrigin(latest?.scanner, latest?.source);
|
|
76
74
|
return (
|
|
77
75
|
<CardTemplate header={__('CVE scan details')} expandable masonryLayout>
|
|
78
|
-
<SkeletonLoader status={
|
|
76
|
+
<SkeletonLoader status={latestStatus || STATUS.PENDING}>
|
|
79
77
|
{!latest ? (
|
|
80
78
|
<EmptyState>
|
|
81
79
|
<EmptyStateIcon icon={SearchIcon} />
|
|
@@ -86,86 +84,122 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
86
84
|
</EmptyState>
|
|
87
85
|
) : (
|
|
88
86
|
<>
|
|
89
|
-
<
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
date={latest.created_at}
|
|
95
|
-
defaultValue={__('Unknown time')}
|
|
96
|
-
/>
|
|
97
|
-
</DescriptionListDescription>
|
|
98
|
-
</DescriptionListGroup>
|
|
99
|
-
<DescriptionListGroup>
|
|
100
|
-
<DescriptionListTerm>{__('Total')}</DescriptionListTerm>
|
|
101
|
-
<DescriptionListDescription>
|
|
102
|
-
<Button
|
|
103
|
-
variant="link"
|
|
104
|
-
className="cve-summary-link"
|
|
105
|
-
onClick={() => openModal(latest.id, 'all')}
|
|
106
|
-
ouiaId="cve-details-total-button"
|
|
107
|
-
>
|
|
108
|
-
<span
|
|
109
|
-
className={`cve-total-bubble cve-total-bubble--${riskLevel}`}
|
|
110
|
-
>
|
|
111
|
-
{latest.total}
|
|
112
|
-
</span>
|
|
113
|
-
</Button>{' '}
|
|
114
|
-
{__('CVEs by')} {latest.scanner}
|
|
115
|
-
</DescriptionListDescription>
|
|
116
|
-
</DescriptionListGroup>
|
|
117
|
-
</DescriptionList>
|
|
118
|
-
<div className="cve-counts cve-counts--compact">
|
|
119
|
-
<Button
|
|
120
|
-
variant="link"
|
|
121
|
-
className="cve-summary-link"
|
|
122
|
-
onClick={() => openModal(latest.id, 'critical')}
|
|
123
|
-
ouiaId="cve-details-critical-button"
|
|
124
|
-
>
|
|
125
|
-
<span className="cve-count">
|
|
126
|
-
{__('critical')}
|
|
127
|
-
<span className="cve-bubble">{latest.critical}</span>
|
|
128
|
-
</span>
|
|
129
|
-
</Button>
|
|
130
|
-
<Button
|
|
131
|
-
variant="link"
|
|
132
|
-
className="cve-summary-link"
|
|
133
|
-
onClick={() => openModal(latest.id, 'medium')}
|
|
134
|
-
ouiaId="cve-details-medium-button"
|
|
135
|
-
>
|
|
136
|
-
<span className="cve-count">
|
|
137
|
-
{__('medium')}
|
|
138
|
-
<span className="cve-bubble">{latest.medium}</span>
|
|
139
|
-
</span>
|
|
140
|
-
</Button>
|
|
141
|
-
<Button
|
|
142
|
-
variant="link"
|
|
143
|
-
className="cve-summary-link"
|
|
144
|
-
onClick={() => openModal(latest.id, 'high')}
|
|
145
|
-
ouiaId="cve-details-high-button"
|
|
146
|
-
>
|
|
147
|
-
<span className="cve-count">
|
|
148
|
-
{__('high')}
|
|
149
|
-
<span className="cve-bubble">{latest.high}</span>
|
|
150
|
-
</span>
|
|
151
|
-
</Button>
|
|
152
|
-
<Button
|
|
153
|
-
variant="link"
|
|
154
|
-
className="cve-summary-link"
|
|
155
|
-
onClick={() => openModal(latest.id, 'low')}
|
|
156
|
-
ouiaId="cve-details-low-button"
|
|
87
|
+
<div className="cve-overview">
|
|
88
|
+
<DescriptionList
|
|
89
|
+
isCompact
|
|
90
|
+
isHorizontal
|
|
91
|
+
className="cve-overview-meta"
|
|
157
92
|
>
|
|
158
|
-
<
|
|
159
|
-
{__('
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
93
|
+
<DescriptionListGroup>
|
|
94
|
+
<DescriptionListTerm>{__('Report')}</DescriptionListTerm>
|
|
95
|
+
<DescriptionListDescription>
|
|
96
|
+
<RelativeDateTime
|
|
97
|
+
date={latest.scanned_at}
|
|
98
|
+
defaultValue={__('Unknown time')}
|
|
99
|
+
/>
|
|
100
|
+
</DescriptionListDescription>
|
|
101
|
+
</DescriptionListGroup>
|
|
102
|
+
<DescriptionListGroup>
|
|
103
|
+
<DescriptionListTerm>{__('Total')}</DescriptionListTerm>
|
|
104
|
+
<DescriptionListDescription>
|
|
105
|
+
<span className="cve-overview-total">
|
|
106
|
+
<Button
|
|
107
|
+
variant="link"
|
|
108
|
+
className="cve-summary-link"
|
|
109
|
+
onClick={() => openModal(latest.id, 'all')}
|
|
110
|
+
ouiaId="cve-details-total-button"
|
|
111
|
+
>
|
|
112
|
+
<span
|
|
113
|
+
className={`cve-total-bubble cve-total-bubble--${riskLevel}`}
|
|
114
|
+
>
|
|
115
|
+
{latest.total}
|
|
116
|
+
</span>
|
|
117
|
+
</Button>
|
|
118
|
+
<span className="cve-overview-source">{origin}</span>
|
|
119
|
+
</span>
|
|
120
|
+
</DescriptionListDescription>
|
|
121
|
+
</DescriptionListGroup>
|
|
122
|
+
</DescriptionList>
|
|
123
|
+
<div className="cve-counts cve-counts--compact">
|
|
124
|
+
<Button
|
|
125
|
+
variant="plain"
|
|
126
|
+
className="cve-count-card cve-count-card--critical"
|
|
127
|
+
onClick={() => openModal(latest.id, 'critical')}
|
|
128
|
+
ouiaId="cve-details-critical-button"
|
|
129
|
+
>
|
|
130
|
+
<span className="cve-count">
|
|
131
|
+
<span className="cve-count-label">
|
|
132
|
+
<SeverityIcon severity="critical" />
|
|
133
|
+
<span>{__('critical')}</span>
|
|
134
|
+
</span>
|
|
135
|
+
<span className="cve-bubble cve-bubble--critical">
|
|
136
|
+
{latest.critical}
|
|
137
|
+
</span>
|
|
138
|
+
</span>
|
|
139
|
+
</Button>
|
|
140
|
+
<Button
|
|
141
|
+
variant="plain"
|
|
142
|
+
className="cve-count-card cve-count-card--medium"
|
|
143
|
+
onClick={() => openModal(latest.id, 'medium')}
|
|
144
|
+
ouiaId="cve-details-medium-button"
|
|
145
|
+
>
|
|
146
|
+
<span className="cve-count">
|
|
147
|
+
<span className="cve-count-label">
|
|
148
|
+
<SeverityIcon severity="medium" />
|
|
149
|
+
<span>{__('medium')}</span>
|
|
150
|
+
</span>
|
|
151
|
+
<span className="cve-bubble cve-bubble--medium">
|
|
152
|
+
{latest.medium}
|
|
153
|
+
</span>
|
|
154
|
+
</span>
|
|
155
|
+
</Button>
|
|
156
|
+
<Button
|
|
157
|
+
variant="plain"
|
|
158
|
+
className="cve-count-card cve-count-card--high"
|
|
159
|
+
onClick={() => openModal(latest.id, 'high')}
|
|
160
|
+
ouiaId="cve-details-high-button"
|
|
161
|
+
>
|
|
162
|
+
<span className="cve-count">
|
|
163
|
+
<span className="cve-count-label">
|
|
164
|
+
<SeverityIcon severity="high" />
|
|
165
|
+
<span>{__('high')}</span>
|
|
166
|
+
</span>
|
|
167
|
+
<span className="cve-bubble cve-bubble--high">
|
|
168
|
+
{latest.high}
|
|
169
|
+
</span>
|
|
170
|
+
</span>
|
|
171
|
+
</Button>
|
|
172
|
+
<Button
|
|
173
|
+
variant="plain"
|
|
174
|
+
className="cve-count-card cve-count-card--low"
|
|
175
|
+
onClick={() => openModal(latest.id, 'low')}
|
|
176
|
+
ouiaId="cve-details-low-button"
|
|
177
|
+
>
|
|
178
|
+
<span className="cve-count">
|
|
179
|
+
<span className="cve-count-label">
|
|
180
|
+
<SeverityIcon severity="low" />
|
|
181
|
+
<span>{__('low')}</span>
|
|
182
|
+
</span>
|
|
183
|
+
<span className="cve-bubble cve-bubble--low">
|
|
184
|
+
{latest.low}
|
|
185
|
+
</span>
|
|
186
|
+
</span>
|
|
187
|
+
</Button>
|
|
188
|
+
</div>
|
|
163
189
|
</div>
|
|
164
190
|
|
|
165
|
-
{
|
|
166
|
-
<
|
|
167
|
-
{
|
|
168
|
-
|
|
191
|
+
{visibleFindings.length === 0 ? (
|
|
192
|
+
<EmptyState className="cve-empty-state cve-empty-state--success">
|
|
193
|
+
<EmptyStateIcon icon={CheckCircleIcon} />
|
|
194
|
+
<Title
|
|
195
|
+
headingLevel="h4"
|
|
196
|
+
size="md"
|
|
197
|
+
ouiaId="cve-details-clean-title"
|
|
198
|
+
>
|
|
199
|
+
{noFindingsTitle()}
|
|
200
|
+
</Title>
|
|
201
|
+
<EmptyStateBody>{noFindingsBody()}</EmptyStateBody>
|
|
202
|
+
</EmptyState>
|
|
169
203
|
) : (
|
|
170
204
|
<>
|
|
171
205
|
<TextContent className="cve-section-title">
|
|
@@ -189,7 +223,7 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
189
223
|
</Tr>
|
|
190
224
|
</Thead>
|
|
191
225
|
<Tbody>
|
|
192
|
-
{
|
|
226
|
+
{visibleFindings.map((finding, index) => (
|
|
193
227
|
<Tr
|
|
194
228
|
key={finding.id}
|
|
195
229
|
ouiaId={`cve-details-finding-row-${index}`}
|
|
@@ -208,7 +242,7 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
208
242
|
))}
|
|
209
243
|
</Tbody>
|
|
210
244
|
</Table>
|
|
211
|
-
{sortedFindings.length >
|
|
245
|
+
{sortedFindings.length > visibleFindings.length && (
|
|
212
246
|
<div className="cve-more">
|
|
213
247
|
<Button
|
|
214
248
|
variant="link"
|
|
@@ -222,29 +256,15 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
222
256
|
)}
|
|
223
257
|
</>
|
|
224
258
|
)}
|
|
225
|
-
|
|
226
|
-
{historyScans.length > 0 && (
|
|
227
|
-
<>
|
|
228
|
-
<TextContent className="cve-section-title">
|
|
229
|
-
<Text
|
|
230
|
-
component={TextVariants.h4}
|
|
231
|
-
ouiaId="cve-details-recent-title"
|
|
232
|
-
>
|
|
233
|
-
{__('Recent scans')}
|
|
234
|
-
</Text>
|
|
235
|
-
</TextContent>
|
|
236
|
-
<CveHistoryTable scans={historyScans} onOpen={openModal} />
|
|
237
|
-
</>
|
|
238
|
-
)}
|
|
239
259
|
</>
|
|
240
260
|
)}
|
|
241
261
|
</SkeletonLoader>
|
|
242
262
|
<CveFindingsModal
|
|
243
|
-
isOpen={
|
|
244
|
-
onClose={
|
|
263
|
+
isOpen={isOpen}
|
|
264
|
+
onClose={closeModal}
|
|
245
265
|
hostId={hostId}
|
|
246
|
-
scanId={
|
|
247
|
-
initialFilter={
|
|
266
|
+
scanId={scanId}
|
|
267
|
+
initialFilter={filter}
|
|
248
268
|
/>
|
|
249
269
|
</CardTemplate>
|
|
250
270
|
);
|
|
@@ -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 {
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 = [...
|
|
99
|
+
const list = [...visibleFindings];
|
|
99
100
|
const sortKey = sortBy.column || 'severity';
|
|
100
|
-
const sorter =
|
|
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
|
-
}, [
|
|
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?.
|
|
120
|
-
? `${__('Report from')} ${
|
|
121
|
-
|
|
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-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
221
|
-
severity=
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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,
|