foreman_cve_scanner 0.0.2 → 0.5.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 +28 -16
- data/Rakefile +2 -0
- data/app/controllers/api/v2/cve_scans_controller.rb +66 -0
- data/app/lib/actions/foreman_cve_scanner/cve_scanner_job.rb +3 -18
- data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +16 -0
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -0
- data/app/models/host_status/cve_status.rb +71 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +37 -35
- data/app/services/foreman_cve_scanner/scan_importer.rb +105 -0
- data/app/views/api/v2/cve_scans/base.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/index.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/latest.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/main.json.rabl +7 -0
- data/app/views/api/v2/cve_scans/show.json.rabl +5 -0
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +4 -2
- data/config/routes.rb +20 -0
- data/db/migrate/20260221000000_create_foreman_cve_scanner_cve_scans.rb +22 -0
- data/db/seeds.d/75_job_templates.rb +17 -0
- data/lib/foreman_cve_scanner/engine.rb +25 -3
- data/lib/foreman_cve_scanner/version.rb +3 -1
- data/lib/foreman_cve_scanner.rb +4 -1
- data/lib/tasks/foreman_cve_scanner_seeds.rake +12 -0
- data/lib/tasks/rubocop.rake +33 -0
- data/package.json +48 -0
- data/test/actions/foreman_cve_scanner/cve_scanner_job_test.rb +54 -0
- data/test/controllers/api/v2/cve_scans_controller_test.rb +78 -0
- data/test/models/host_status/cve_status_test.rb +65 -0
- data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +45 -17
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +85 -0
- data/test/test_plugin_helper.rb +2 -0
- data/webpack/components/CveDetailsCard.js +258 -0
- data/webpack/components/CveFindingsModal.js +274 -0
- data/webpack/components/CveHistoryTable.js +58 -0
- data/webpack/components/CveOverviewCard.js +67 -0
- data/webpack/components/CveScansTab.js +192 -0
- data/webpack/components/CveSummaryCell.js +72 -0
- data/webpack/components/SeverityIcon.js +56 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +126 -0
- data/webpack/components/__tests__/CveFindingsModal.test.js +78 -0
- data/webpack/components/__tests__/CveScansTab.test.js +76 -0
- data/webpack/components/__tests__/cve_helpers.test.js +42 -0
- data/webpack/components/cve_helpers.js +35 -0
- data/webpack/components/cve_scans.scss +200 -0
- data/webpack/fills.js +1 -0
- data/webpack/fills_index.js +38 -0
- data/webpack/index.js +1 -0
- metadata +49 -5
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import { Table, Tbody, Tr, Th, Thead, Td } from '@patternfly/react-table';
|
|
5
|
+
import {
|
|
6
|
+
Text,
|
|
7
|
+
TextContent,
|
|
8
|
+
TextVariants,
|
|
9
|
+
Button,
|
|
10
|
+
DescriptionList,
|
|
11
|
+
DescriptionListGroup,
|
|
12
|
+
DescriptionListTerm,
|
|
13
|
+
DescriptionListDescription,
|
|
14
|
+
EmptyState,
|
|
15
|
+
EmptyStateIcon,
|
|
16
|
+
EmptyStateBody,
|
|
17
|
+
Title,
|
|
18
|
+
} from '@patternfly/react-core';
|
|
19
|
+
import { SearchIcon } from '@patternfly/react-icons';
|
|
20
|
+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
21
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
22
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
23
|
+
import CardTemplate from 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate';
|
|
24
|
+
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
|
25
|
+
import { STATUS } from 'foremanReact/constants';
|
|
26
|
+
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
27
|
+
import SeverityIcon from './SeverityIcon';
|
|
28
|
+
import CveFindingsModal from './CveFindingsModal';
|
|
29
|
+
import CveHistoryTable from './CveHistoryTable';
|
|
30
|
+
import {
|
|
31
|
+
noReportsBody,
|
|
32
|
+
noReportsTitle,
|
|
33
|
+
riskLevelFromWorst,
|
|
34
|
+
} from './cve_helpers';
|
|
35
|
+
import './cve_scans.scss';
|
|
36
|
+
|
|
37
|
+
const CveDetailsCard = ({ hostDetails }) => {
|
|
38
|
+
const hostId = hostDetails?.id;
|
|
39
|
+
const historyUrl = hostId
|
|
40
|
+
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans?per_page=3`)
|
|
41
|
+
: null;
|
|
42
|
+
const latestUrl = hostId
|
|
43
|
+
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
44
|
+
: null;
|
|
45
|
+
const { response: historyResponse, status } = useAPI('get', historyUrl, {
|
|
46
|
+
key: `CVE_DETAILS_${hostId}`,
|
|
47
|
+
});
|
|
48
|
+
const { response: latestResponse, status: latestStatus } = useAPI(
|
|
49
|
+
'get',
|
|
50
|
+
latestUrl,
|
|
51
|
+
{ key: `CVE_DETAILS_LATEST_${hostId}` }
|
|
52
|
+
);
|
|
53
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
54
|
+
const [modalScanId, setModalScanId] = useState(null);
|
|
55
|
+
const [modalFilter, setModalFilter] = useState('all');
|
|
56
|
+
if (!hostId) return null;
|
|
57
|
+
const scans = historyResponse?.results || [];
|
|
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;
|
|
63
|
+
const findings = latest?.findings || [];
|
|
64
|
+
const sortedFindings = [...findings].sort((a, b) => {
|
|
65
|
+
const aTime = new Date(a.published || 0).getTime();
|
|
66
|
+
const bTime = new Date(b.published || 0).getTime();
|
|
67
|
+
return bTime - aTime;
|
|
68
|
+
});
|
|
69
|
+
const worst = latest?.summary?.worst || 'none';
|
|
70
|
+
const riskLevel = riskLevelFromWorst(worst);
|
|
71
|
+
const openModal = (scanId, filter) => {
|
|
72
|
+
setModalScanId(scanId);
|
|
73
|
+
setModalFilter(filter || 'all');
|
|
74
|
+
setIsModalOpen(true);
|
|
75
|
+
};
|
|
76
|
+
return (
|
|
77
|
+
<CardTemplate header={__('CVE scan details')} expandable masonryLayout>
|
|
78
|
+
<SkeletonLoader status={status || latestStatus || STATUS.PENDING}>
|
|
79
|
+
{!latest ? (
|
|
80
|
+
<EmptyState>
|
|
81
|
+
<EmptyStateIcon icon={SearchIcon} />
|
|
82
|
+
<Title headingLevel="h4" size="md" ouiaId="cve-details-empty-title">
|
|
83
|
+
{noReportsTitle()}
|
|
84
|
+
</Title>
|
|
85
|
+
<EmptyStateBody>{noReportsBody()}</EmptyStateBody>
|
|
86
|
+
</EmptyState>
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
<DescriptionList isCompact isHorizontal>
|
|
90
|
+
<DescriptionListGroup>
|
|
91
|
+
<DescriptionListTerm>{__('Report')}</DescriptionListTerm>
|
|
92
|
+
<DescriptionListDescription>
|
|
93
|
+
<RelativeDateTime
|
|
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"
|
|
157
|
+
>
|
|
158
|
+
<span className="cve-count">
|
|
159
|
+
{__('low')}
|
|
160
|
+
<span className="cve-bubble">{latest.low}</span>
|
|
161
|
+
</span>
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{sortedFindings.length === 0 ? (
|
|
166
|
+
<Text component={TextVariants.small} ouiaId="cve-details-empty">
|
|
167
|
+
{__('No vulnerabilities reported')}
|
|
168
|
+
</Text>
|
|
169
|
+
) : (
|
|
170
|
+
<>
|
|
171
|
+
<TextContent className="cve-section-title">
|
|
172
|
+
<Text
|
|
173
|
+
component={TextVariants.h5}
|
|
174
|
+
ouiaId="cve-details-cves-title"
|
|
175
|
+
>
|
|
176
|
+
{__('CVEs')}
|
|
177
|
+
</Text>
|
|
178
|
+
</TextContent>
|
|
179
|
+
<Table
|
|
180
|
+
variant="compact"
|
|
181
|
+
aria-label="CVE findings table"
|
|
182
|
+
ouiaId="cve-details-findings-table"
|
|
183
|
+
>
|
|
184
|
+
<Thead>
|
|
185
|
+
<Tr ouiaId="cve-details-findings-header">
|
|
186
|
+
<Th>{__('Severity')}</Th>
|
|
187
|
+
<Th>{__('Package')}</Th>
|
|
188
|
+
<Th>{__('Installed')}</Th>
|
|
189
|
+
</Tr>
|
|
190
|
+
</Thead>
|
|
191
|
+
<Tbody>
|
|
192
|
+
{sortedFindings.slice(0, 5).map((finding, index) => (
|
|
193
|
+
<Tr
|
|
194
|
+
key={finding.id}
|
|
195
|
+
ouiaId={`cve-details-finding-row-${index}`}
|
|
196
|
+
>
|
|
197
|
+
<Td dataLabel={__('Severity')}>
|
|
198
|
+
<span className="cve-summary">
|
|
199
|
+
<SeverityIcon
|
|
200
|
+
severity={finding.severity?.toLowerCase()}
|
|
201
|
+
/>
|
|
202
|
+
<span>{finding.severity}</span>
|
|
203
|
+
</span>
|
|
204
|
+
</Td>
|
|
205
|
+
<Td dataLabel={__('Package')}>{finding.name}</Td>
|
|
206
|
+
<Td dataLabel={__('Installed')}>{finding.version}</Td>
|
|
207
|
+
</Tr>
|
|
208
|
+
))}
|
|
209
|
+
</Tbody>
|
|
210
|
+
</Table>
|
|
211
|
+
{sortedFindings.length > 5 && (
|
|
212
|
+
<div className="cve-more">
|
|
213
|
+
<Button
|
|
214
|
+
variant="link"
|
|
215
|
+
className="cve-summary-link"
|
|
216
|
+
onClick={() => openModal(latest.id, 'all')}
|
|
217
|
+
ouiaId="cve-details-more-button"
|
|
218
|
+
>
|
|
219
|
+
{__('More')}
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
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
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</SkeletonLoader>
|
|
242
|
+
<CveFindingsModal
|
|
243
|
+
isOpen={isModalOpen}
|
|
244
|
+
onClose={() => setIsModalOpen(false)}
|
|
245
|
+
hostId={hostId}
|
|
246
|
+
scanId={modalScanId}
|
|
247
|
+
initialFilter={modalFilter}
|
|
248
|
+
/>
|
|
249
|
+
</CardTemplate>
|
|
250
|
+
);
|
|
251
|
+
};
|
|
252
|
+
CveDetailsCard.propTypes = {
|
|
253
|
+
hostDetails: PropTypes.shape({ id: PropTypes.number }),
|
|
254
|
+
};
|
|
255
|
+
CveDetailsCard.defaultProps = {
|
|
256
|
+
hostDetails: undefined,
|
|
257
|
+
};
|
|
258
|
+
export default CveDetailsCard;
|
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
Modal,
|
|
7
|
+
Button,
|
|
8
|
+
Text,
|
|
9
|
+
TextVariants,
|
|
10
|
+
FormGroup,
|
|
11
|
+
} from '@patternfly/react-core';
|
|
12
|
+
import {
|
|
13
|
+
Table,
|
|
14
|
+
Tbody,
|
|
15
|
+
Tr,
|
|
16
|
+
Th,
|
|
17
|
+
Thead,
|
|
18
|
+
Td,
|
|
19
|
+
SortByDirection,
|
|
20
|
+
} from '@patternfly/react-table';
|
|
21
|
+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
22
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
23
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
24
|
+
import { STATUS } from 'foremanReact/constants';
|
|
25
|
+
import SeverityIcon from './SeverityIcon';
|
|
26
|
+
import { compareStrings, formatDateTime, severityRank } from './cve_helpers';
|
|
27
|
+
import './cve_scans.scss';
|
|
28
|
+
|
|
29
|
+
const SEVERITY_OPTIONS = ['all', 'critical', 'high', 'medium', 'low'];
|
|
30
|
+
const COLUMNS = [
|
|
31
|
+
{ key: 'severity', label: __('Severity') },
|
|
32
|
+
{ key: 'published', label: __('Published') },
|
|
33
|
+
{ key: 'name', label: __('Package') },
|
|
34
|
+
{ key: 'version', label: __('Affected version') },
|
|
35
|
+
{ key: 'fixed', label: __('Fixed version') },
|
|
36
|
+
{ key: 'status', label: __('Status') },
|
|
37
|
+
{ key: 'id', label: __('CVE') },
|
|
38
|
+
{ key: 'title', label: __('Title') },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const CveFindingsModal = ({
|
|
42
|
+
isOpen,
|
|
43
|
+
onClose,
|
|
44
|
+
hostId,
|
|
45
|
+
scanId,
|
|
46
|
+
initialFilter,
|
|
47
|
+
}) => {
|
|
48
|
+
const [filter, setFilter] = useState(initialFilter || 'all');
|
|
49
|
+
const [sortBy, setSortBy] = useState({
|
|
50
|
+
direction: SortByDirection.desc,
|
|
51
|
+
column: 'severity',
|
|
52
|
+
});
|
|
53
|
+
const normalizedScanId =
|
|
54
|
+
scanId !== null && scanId !== undefined && String(scanId).trim() !== ''
|
|
55
|
+
? scanId
|
|
56
|
+
: null;
|
|
57
|
+
const url = normalizedScanId
|
|
58
|
+
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/${normalizedScanId}`)
|
|
59
|
+
: foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`);
|
|
60
|
+
|
|
61
|
+
const { response, status } = useAPI(isOpen ? 'get' : null, url, {
|
|
62
|
+
key: `CVE_SCAN_${normalizedScanId || 'latest'}`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isOpen) return;
|
|
67
|
+
setFilter(initialFilter || 'all');
|
|
68
|
+
}, [initialFilter, isOpen, scanId]);
|
|
69
|
+
|
|
70
|
+
const payload = response || {};
|
|
71
|
+
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
|
72
|
+
|
|
73
|
+
const normalizedFilter = (filter || 'all').toLowerCase();
|
|
74
|
+
const filteredFindings = useMemo(() => {
|
|
75
|
+
if (normalizedFilter === 'all') return findings;
|
|
76
|
+
return findings.filter(
|
|
77
|
+
f => (f.severity || '').toLowerCase() === normalizedFilter
|
|
78
|
+
);
|
|
79
|
+
}, [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
|
+
);
|
|
97
|
+
const sortedFindings = useMemo(() => {
|
|
98
|
+
const list = [...filteredFindings];
|
|
99
|
+
const sortKey = sortBy.column || 'severity';
|
|
100
|
+
const sorter = sorters[sortKey] || sorters.severity;
|
|
101
|
+
list.sort((a, b) => {
|
|
102
|
+
const result = sorter(a, b);
|
|
103
|
+
return sortBy.direction === SortByDirection.asc ? result : -result;
|
|
104
|
+
});
|
|
105
|
+
return list;
|
|
106
|
+
}, [filteredFindings, sortBy, sorters]);
|
|
107
|
+
|
|
108
|
+
const onSort = column => {
|
|
109
|
+
const nextDirection =
|
|
110
|
+
sortBy.column === column && sortBy.direction === SortByDirection.asc
|
|
111
|
+
? SortByDirection.desc
|
|
112
|
+
: SortByDirection.asc;
|
|
113
|
+
setSortBy({ column, direction: nextDirection });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Modal
|
|
118
|
+
title={
|
|
119
|
+
payload?.created_at && typeof payload?.total !== 'undefined'
|
|
120
|
+
? `${__('Report from')} ${formatPublished(payload.created_at)} - ${__(
|
|
121
|
+
'Total'
|
|
122
|
+
)}: ${payload.total}`
|
|
123
|
+
: __('CVE findings')
|
|
124
|
+
}
|
|
125
|
+
isOpen={isOpen}
|
|
126
|
+
onClose={onClose}
|
|
127
|
+
width="70%"
|
|
128
|
+
maxWidth="70%"
|
|
129
|
+
ouiaId="cve-findings-modal"
|
|
130
|
+
actions={[
|
|
131
|
+
<Button
|
|
132
|
+
key="close"
|
|
133
|
+
variant="primary"
|
|
134
|
+
onClick={onClose}
|
|
135
|
+
ouiaId="cve-findings-close"
|
|
136
|
+
>
|
|
137
|
+
{__('Close')}
|
|
138
|
+
</Button>,
|
|
139
|
+
]}
|
|
140
|
+
>
|
|
141
|
+
{status === STATUS.PENDING ? (
|
|
142
|
+
<Text component={TextVariants.small} ouiaId="cve-findings-loading">
|
|
143
|
+
{__('Loading...')}
|
|
144
|
+
</Text>
|
|
145
|
+
) : (
|
|
146
|
+
<div className="cve-modal-body">
|
|
147
|
+
<div className="cve-modal-filters">
|
|
148
|
+
<FormGroup fieldId="cve-filter">
|
|
149
|
+
<div className="cve-filter-buttons">
|
|
150
|
+
{SEVERITY_OPTIONS.map(opt => (
|
|
151
|
+
<button
|
|
152
|
+
key={opt}
|
|
153
|
+
type="button"
|
|
154
|
+
aria-pressed={filter === opt}
|
|
155
|
+
className={
|
|
156
|
+
filter === opt
|
|
157
|
+
? 'cve-filter-button is-active'
|
|
158
|
+
: 'cve-filter-button'
|
|
159
|
+
}
|
|
160
|
+
onClick={() => setFilter(opt)}
|
|
161
|
+
>
|
|
162
|
+
{__(opt)}
|
|
163
|
+
</button>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</FormGroup>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
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}
|
|
219
|
+
>
|
|
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>
|
|
250
|
+
</Tr>
|
|
251
|
+
))}
|
|
252
|
+
</Tbody>
|
|
253
|
+
</Table>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</Modal>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
CveFindingsModal.propTypes = {
|
|
262
|
+
isOpen: PropTypes.bool.isRequired,
|
|
263
|
+
onClose: PropTypes.func.isRequired,
|
|
264
|
+
hostId: PropTypes.number.isRequired,
|
|
265
|
+
scanId: PropTypes.number,
|
|
266
|
+
initialFilter: PropTypes.string,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
CveFindingsModal.defaultProps = {
|
|
270
|
+
scanId: undefined,
|
|
271
|
+
initialFilter: 'all',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default CveFindingsModal;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
5
|
+
import { Button } from '@patternfly/react-core';
|
|
6
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
7
|
+
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
8
|
+
|
|
9
|
+
const CveHistoryTable = ({ scans, onOpen }) => (
|
|
10
|
+
<Table
|
|
11
|
+
variant="compact"
|
|
12
|
+
aria-label="CVE scan history table"
|
|
13
|
+
ouiaId="cve-details-history-table"
|
|
14
|
+
>
|
|
15
|
+
<Thead>
|
|
16
|
+
<Tr ouiaId="cve-details-history-header">
|
|
17
|
+
<Th>{__('Reported at')}</Th>
|
|
18
|
+
<Th>{__('Scanner')}</Th>
|
|
19
|
+
<Th>{__('Total')}</Th>
|
|
20
|
+
</Tr>
|
|
21
|
+
</Thead>
|
|
22
|
+
<Tbody>
|
|
23
|
+
{scans.map((scan, index) => (
|
|
24
|
+
<Tr key={scan.id} ouiaId={`cve-details-history-row-${index}`}>
|
|
25
|
+
<Td dataLabel={__('Reported at')}>
|
|
26
|
+
<Button
|
|
27
|
+
variant="link"
|
|
28
|
+
className="cve-summary-link"
|
|
29
|
+
onClick={() => onOpen(scan.id, 'all')}
|
|
30
|
+
ouiaId={`cve-details-history-open-${scan.id}`}
|
|
31
|
+
>
|
|
32
|
+
<RelativeDateTime
|
|
33
|
+
date={scan.created_at}
|
|
34
|
+
defaultValue={__('Unknown time')}
|
|
35
|
+
/>
|
|
36
|
+
</Button>
|
|
37
|
+
</Td>
|
|
38
|
+
<Td dataLabel={__('Scanner')}>{scan.scanner}</Td>
|
|
39
|
+
<Td dataLabel={__('Total')}>{scan.total}</Td>
|
|
40
|
+
</Tr>
|
|
41
|
+
))}
|
|
42
|
+
</Tbody>
|
|
43
|
+
</Table>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CveHistoryTable.propTypes = {
|
|
47
|
+
scans: PropTypes.arrayOf(
|
|
48
|
+
PropTypes.shape({
|
|
49
|
+
id: PropTypes.number.isRequired,
|
|
50
|
+
created_at: PropTypes.string,
|
|
51
|
+
scanner: PropTypes.string,
|
|
52
|
+
total: PropTypes.number,
|
|
53
|
+
})
|
|
54
|
+
).isRequired,
|
|
55
|
+
onOpen: PropTypes.func.isRequired,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default CveHistoryTable;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import { Text, TextContent, TextVariants } from '@patternfly/react-core';
|
|
5
|
+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
6
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
7
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
8
|
+
import CardTemplate from 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate';
|
|
9
|
+
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
|
10
|
+
import { STATUS } from 'foremanReact/constants';
|
|
11
|
+
import SeverityIcon from './SeverityIcon';
|
|
12
|
+
import { noReportsTitle } from './cve_helpers';
|
|
13
|
+
import './cve_scans.scss';
|
|
14
|
+
|
|
15
|
+
const CveOverviewCard = ({ hostDetails }) => {
|
|
16
|
+
const hostId = hostDetails?.id;
|
|
17
|
+
const url = hostId
|
|
18
|
+
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
19
|
+
: null;
|
|
20
|
+
const { response, status } = useAPI('get', url, {
|
|
21
|
+
key: `CVE_OVERVIEW_${hostId}`,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!hostId) return null;
|
|
25
|
+
|
|
26
|
+
const total = response?.total || 0;
|
|
27
|
+
const worst = response?.summary?.worst || 'none';
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<CardTemplate header={__('CVE findings')}>
|
|
31
|
+
<SkeletonLoader status={status || STATUS.PENDING}>
|
|
32
|
+
{!response ? (
|
|
33
|
+
<TextContent ouiaId="cve-overview-empty">
|
|
34
|
+
<Text
|
|
35
|
+
component={TextVariants.small}
|
|
36
|
+
ouiaId="cve-overview-empty-text"
|
|
37
|
+
>
|
|
38
|
+
{noReportsTitle()}
|
|
39
|
+
</Text>
|
|
40
|
+
</TextContent>
|
|
41
|
+
) : (
|
|
42
|
+
<div className="cve-overview">
|
|
43
|
+
<div className="cve-summary">
|
|
44
|
+
<SeverityIcon severity={worst} />
|
|
45
|
+
<Text component={TextVariants.h2} ouiaId="cve-overview-total">
|
|
46
|
+
{total}
|
|
47
|
+
</Text>
|
|
48
|
+
</div>
|
|
49
|
+
<Text component={TextVariants.small} ouiaId="cve-overview-caption">
|
|
50
|
+
{__('Total findings in latest scan')}
|
|
51
|
+
</Text>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</SkeletonLoader>
|
|
55
|
+
</CardTemplate>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
CveOverviewCard.propTypes = {
|
|
60
|
+
hostDetails: PropTypes.shape({ id: PropTypes.number }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
CveOverviewCard.defaultProps = {
|
|
64
|
+
hostDetails: undefined,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default CveOverviewCard;
|