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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -16
  3. data/Rakefile +2 -0
  4. data/app/controllers/api/v2/cve_scans_controller.rb +66 -0
  5. data/app/lib/actions/foreman_cve_scanner/cve_scanner_job.rb +3 -18
  6. data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +16 -0
  7. data/app/models/foreman_cve_scanner/cve_scan.rb +15 -0
  8. data/app/models/host_status/cve_status.rb +71 -0
  9. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +37 -35
  10. data/app/services/foreman_cve_scanner/scan_importer.rb +105 -0
  11. data/app/views/api/v2/cve_scans/base.json.rabl +5 -0
  12. data/app/views/api/v2/cve_scans/index.json.rabl +5 -0
  13. data/app/views/api/v2/cve_scans/latest.json.rabl +5 -0
  14. data/app/views/api/v2/cve_scans/main.json.rabl +7 -0
  15. data/app/views/api/v2/cve_scans/show.json.rabl +5 -0
  16. data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +4 -2
  17. data/config/routes.rb +20 -0
  18. data/db/migrate/20260221000000_create_foreman_cve_scanner_cve_scans.rb +22 -0
  19. data/db/seeds.d/75_job_templates.rb +17 -0
  20. data/lib/foreman_cve_scanner/engine.rb +25 -3
  21. data/lib/foreman_cve_scanner/version.rb +3 -1
  22. data/lib/foreman_cve_scanner.rb +4 -1
  23. data/lib/tasks/foreman_cve_scanner_seeds.rake +12 -0
  24. data/lib/tasks/rubocop.rake +33 -0
  25. data/package.json +48 -0
  26. data/test/actions/foreman_cve_scanner/cve_scanner_job_test.rb +54 -0
  27. data/test/controllers/api/v2/cve_scans_controller_test.rb +78 -0
  28. data/test/models/host_status/cve_status_test.rb +65 -0
  29. data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +45 -17
  30. data/test/services/foreman_cve_scanner/scan_importer_test.rb +85 -0
  31. data/test/test_plugin_helper.rb +2 -0
  32. data/webpack/components/CveDetailsCard.js +258 -0
  33. data/webpack/components/CveFindingsModal.js +274 -0
  34. data/webpack/components/CveHistoryTable.js +58 -0
  35. data/webpack/components/CveOverviewCard.js +67 -0
  36. data/webpack/components/CveScansTab.js +192 -0
  37. data/webpack/components/CveSummaryCell.js +72 -0
  38. data/webpack/components/SeverityIcon.js +56 -0
  39. data/webpack/components/__tests__/CveDetailsCard.test.js +126 -0
  40. data/webpack/components/__tests__/CveFindingsModal.test.js +78 -0
  41. data/webpack/components/__tests__/CveScansTab.test.js +76 -0
  42. data/webpack/components/__tests__/cve_helpers.test.js +42 -0
  43. data/webpack/components/cve_helpers.js +35 -0
  44. data/webpack/components/cve_scans.scss +200 -0
  45. data/webpack/fills.js +1 -0
  46. data/webpack/fills_index.js +38 -0
  47. data/webpack/index.js +1 -0
  48. 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;