foreman_cve_scanner 0.6.1 → 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 +21 -1
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
|
-
import { Button, Checkbox, Pagination
|
|
4
|
+
import { Button, Checkbox, Pagination } from '@patternfly/react-core';
|
|
5
5
|
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
6
6
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
7
7
|
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
8
8
|
import { formatScanOrigin } from './cve_helpers';
|
|
9
9
|
|
|
10
|
+
const SCAN_COUNT_COLUMNS = [
|
|
11
|
+
{ key: 'total', label: __('Total'), filter: 'all' },
|
|
12
|
+
{ key: 'critical', label: __('Critical'), filter: 'critical' },
|
|
13
|
+
{ key: 'high', label: __('High'), filter: 'high' },
|
|
14
|
+
{ key: 'medium', label: __('Medium'), filter: 'medium' },
|
|
15
|
+
{ key: 'low', label: __('Low'), filter: 'low' },
|
|
16
|
+
];
|
|
17
|
+
|
|
10
18
|
const CveScansReports = ({
|
|
11
19
|
scans,
|
|
12
20
|
itemCount,
|
|
@@ -22,13 +30,8 @@ const CveScansReports = ({
|
|
|
22
30
|
onOpenModal,
|
|
23
31
|
exportUrlFor,
|
|
24
32
|
}) => (
|
|
25
|
-
<section className="cve-scans-section" aria-label="CVE scan
|
|
33
|
+
<section className="cve-scans-section" aria-label="CVE scan history">
|
|
26
34
|
<div className="cve-scans-section-header">
|
|
27
|
-
<div className="cve-scans-section-title">
|
|
28
|
-
<Title headingLevel="h3" size="lg" ouiaId="cve-scans-reports-title">
|
|
29
|
-
{__('Reports')}
|
|
30
|
-
</Title>
|
|
31
|
-
</div>
|
|
32
35
|
<Pagination
|
|
33
36
|
itemCount={itemCount}
|
|
34
37
|
perPage={perPage}
|
|
@@ -73,11 +76,9 @@ const CveScansReports = ({
|
|
|
73
76
|
<Th>{__('Select')}</Th>
|
|
74
77
|
<Th>{__('Scanned at')}</Th>
|
|
75
78
|
<Th>{__('Scanner')}</Th>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<Th>{__('Medium')}</Th>
|
|
80
|
-
<Th>{__('Low')}</Th>
|
|
79
|
+
{SCAN_COUNT_COLUMNS.map(column => (
|
|
80
|
+
<Th key={column.key}>{column.label}</Th>
|
|
81
|
+
))}
|
|
81
82
|
<Th>{__('Export')}</Th>
|
|
82
83
|
</Tr>
|
|
83
84
|
</Thead>
|
|
@@ -113,56 +114,18 @@ const CveScansReports = ({
|
|
|
113
114
|
<Td dataLabel={__('Scanner')}>
|
|
114
115
|
{formatScanOrigin(scan.scanner, scan.source)}
|
|
115
116
|
</Td>
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
variant="link"
|
|
129
|
-
className="cve-summary-link"
|
|
130
|
-
onClick={() => onOpenModal(scan.id, 'critical')}
|
|
131
|
-
ouiaId={`cve-scans-critical-${scan.id}`}
|
|
132
|
-
>
|
|
133
|
-
{scan.critical}
|
|
134
|
-
</Button>
|
|
135
|
-
</Td>
|
|
136
|
-
<Td dataLabel={__('High')}>
|
|
137
|
-
<Button
|
|
138
|
-
variant="link"
|
|
139
|
-
className="cve-summary-link"
|
|
140
|
-
onClick={() => onOpenModal(scan.id, 'high')}
|
|
141
|
-
ouiaId={`cve-scans-high-${scan.id}`}
|
|
142
|
-
>
|
|
143
|
-
{scan.high}
|
|
144
|
-
</Button>
|
|
145
|
-
</Td>
|
|
146
|
-
<Td dataLabel={__('Medium')}>
|
|
147
|
-
<Button
|
|
148
|
-
variant="link"
|
|
149
|
-
className="cve-summary-link"
|
|
150
|
-
onClick={() => onOpenModal(scan.id, 'medium')}
|
|
151
|
-
ouiaId={`cve-scans-medium-${scan.id}`}
|
|
152
|
-
>
|
|
153
|
-
{scan.medium}
|
|
154
|
-
</Button>
|
|
155
|
-
</Td>
|
|
156
|
-
<Td dataLabel={__('Low')}>
|
|
157
|
-
<Button
|
|
158
|
-
variant="link"
|
|
159
|
-
className="cve-summary-link"
|
|
160
|
-
onClick={() => onOpenModal(scan.id, 'low')}
|
|
161
|
-
ouiaId={`cve-scans-low-${scan.id}`}
|
|
162
|
-
>
|
|
163
|
-
{scan.low}
|
|
164
|
-
</Button>
|
|
165
|
-
</Td>
|
|
117
|
+
{SCAN_COUNT_COLUMNS.map(column => (
|
|
118
|
+
<Td key={column.key} dataLabel={column.label}>
|
|
119
|
+
<Button
|
|
120
|
+
variant="link"
|
|
121
|
+
className="cve-summary-link"
|
|
122
|
+
onClick={() => onOpenModal(scan.id, column.filter)}
|
|
123
|
+
ouiaId={`cve-scans-${column.key}-${scan.id}`}
|
|
124
|
+
>
|
|
125
|
+
{scan[column.key]}
|
|
126
|
+
</Button>
|
|
127
|
+
</Td>
|
|
128
|
+
))}
|
|
166
129
|
<Td dataLabel={__('Export')}>
|
|
167
130
|
<Button
|
|
168
131
|
component="a"
|
|
@@ -17,6 +17,7 @@ import { translate as __ } from 'foremanReact/common/I18n';
|
|
|
17
17
|
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
|
18
18
|
import { STATUS } from 'foremanReact/constants';
|
|
19
19
|
import CveTrendChart from './CveTrendChart';
|
|
20
|
+
import CveFindingsView from './CveFindingsView';
|
|
20
21
|
import CveScansReports from './CveScansReports';
|
|
21
22
|
import CveFindingsModal from './CveFindingsModal';
|
|
22
23
|
import CveCompareModal from './CveCompareModal';
|
|
@@ -25,17 +26,19 @@ import { noReportsBody, noReportsTitle } from './cve_helpers';
|
|
|
25
26
|
import './cve_scans.scss';
|
|
26
27
|
|
|
27
28
|
const DEFAULT_PER_PAGE = 20;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
29
|
+
const LATEST_SCAN_TAB = 0;
|
|
30
|
+
const TRENDS_TAB = 1;
|
|
31
|
+
const HISTORY_TAB = 2;
|
|
30
32
|
|
|
31
33
|
const CveScansTab = ({ response }) => {
|
|
32
34
|
const hostId = response?.id;
|
|
35
|
+
const hostName = response?.name;
|
|
33
36
|
const [page, setPage] = useState(1);
|
|
34
37
|
const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
|
|
35
38
|
const [isCompareOpen, setIsCompareOpen] = useState(false);
|
|
36
39
|
const [selectedScanIds, setSelectedScanIds] = useState([]);
|
|
37
40
|
const [isTrendCompareMode, setIsTrendCompareMode] = useState(false);
|
|
38
|
-
const [activeTabKey, setActiveTabKey] = useState(
|
|
41
|
+
const [activeTabKey, setActiveTabKey] = useState(LATEST_SCAN_TAB);
|
|
39
42
|
const { isOpen, scanId, filter, openModal, closeModal } = useModalScan();
|
|
40
43
|
|
|
41
44
|
const historyUrl = hostId
|
|
@@ -43,11 +46,22 @@ const CveScansTab = ({ response }) => {
|
|
|
43
46
|
`/api/v2/hosts/${hostId}/cve_scans?page=${page}&per_page=${perPage}`
|
|
44
47
|
)
|
|
45
48
|
: null;
|
|
49
|
+
const latestUrl = hostId
|
|
50
|
+
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
51
|
+
: null;
|
|
46
52
|
const { response: apiResponse, status } = useAPI('get', historyUrl, {
|
|
47
53
|
key: `CVE_SCANS_TAB_${hostId}_${page}_${perPage}`,
|
|
48
54
|
});
|
|
55
|
+
const { response: latestResponse, status: latestStatus } = useAPI(
|
|
56
|
+
'get',
|
|
57
|
+
latestUrl,
|
|
58
|
+
{
|
|
59
|
+
key: `CVE_SCANS_TAB_LATEST_${hostId}`,
|
|
60
|
+
}
|
|
61
|
+
);
|
|
49
62
|
|
|
50
63
|
const scans = useMemo(() => apiResponse?.results || [], [apiResponse]);
|
|
64
|
+
const latestScan = latestResponse?.id ? latestResponse : null;
|
|
51
65
|
const selectedScans = useMemo(
|
|
52
66
|
() => scans.filter(scan => selectedScanIds.includes(scan.id)),
|
|
53
67
|
[scans, selectedScanIds]
|
|
@@ -123,9 +137,26 @@ const CveScansTab = ({ response }) => {
|
|
|
123
137
|
ouiaId="cve-scans-subtabs"
|
|
124
138
|
>
|
|
125
139
|
<Tab
|
|
126
|
-
eventKey={
|
|
127
|
-
title={<TabTitleText>{__('
|
|
128
|
-
ouiaId="cve-scans-
|
|
140
|
+
eventKey={LATEST_SCAN_TAB}
|
|
141
|
+
title={<TabTitleText>{__('Latest Scan')}</TabTitleText>}
|
|
142
|
+
ouiaId="cve-scans-latest-tab"
|
|
143
|
+
>
|
|
144
|
+
<section
|
|
145
|
+
className="cve-scans-section"
|
|
146
|
+
aria-label="Latest CVE scan"
|
|
147
|
+
>
|
|
148
|
+
<CveFindingsView
|
|
149
|
+
scan={latestScan}
|
|
150
|
+
status={latestStatus}
|
|
151
|
+
initialFilter="all"
|
|
152
|
+
hostName={hostName}
|
|
153
|
+
/>
|
|
154
|
+
</section>
|
|
155
|
+
</Tab>
|
|
156
|
+
<Tab
|
|
157
|
+
eventKey={TRENDS_TAB}
|
|
158
|
+
title={<TabTitleText>{__('Trends')}</TabTitleText>}
|
|
159
|
+
ouiaId="cve-scans-trends-tab"
|
|
129
160
|
>
|
|
130
161
|
<section
|
|
131
162
|
className="cve-scans-section"
|
|
@@ -142,9 +173,9 @@ const CveScansTab = ({ response }) => {
|
|
|
142
173
|
</section>
|
|
143
174
|
</Tab>
|
|
144
175
|
<Tab
|
|
145
|
-
eventKey={
|
|
146
|
-
title={<TabTitleText>{__('
|
|
147
|
-
ouiaId="cve-scans-
|
|
176
|
+
eventKey={HISTORY_TAB}
|
|
177
|
+
title={<TabTitleText>{__('History')}</TabTitleText>}
|
|
178
|
+
ouiaId="cve-scans-history-tab"
|
|
148
179
|
>
|
|
149
180
|
<CveScansReports
|
|
150
181
|
scans={scans}
|
|
@@ -169,6 +200,7 @@ const CveScansTab = ({ response }) => {
|
|
|
169
200
|
isOpen={isOpen}
|
|
170
201
|
onClose={closeModal}
|
|
171
202
|
hostId={hostId}
|
|
203
|
+
hostName={hostName}
|
|
172
204
|
scanId={scanId}
|
|
173
205
|
initialFilter={filter}
|
|
174
206
|
/>
|
|
@@ -185,6 +217,7 @@ const CveScansTab = ({ response }) => {
|
|
|
185
217
|
CveScansTab.propTypes = {
|
|
186
218
|
response: PropTypes.shape({
|
|
187
219
|
id: PropTypes.number,
|
|
220
|
+
name: PropTypes.string,
|
|
188
221
|
}),
|
|
189
222
|
};
|
|
190
223
|
|
|
@@ -1,33 +1,45 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
|
-
import React, { useState } from 'react';
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
4
|
import { Spinner } from '@patternfly/react-core';
|
|
5
|
-
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
6
|
-
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
7
5
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
8
|
-
import { STATUS } from 'foremanReact/constants';
|
|
9
6
|
import SeverityIcon from './SeverityIcon';
|
|
10
7
|
import CveFindingsModal from './CveFindingsModal';
|
|
8
|
+
import {
|
|
9
|
+
getCachedCveSummary,
|
|
10
|
+
subscribeToCveSummary,
|
|
11
|
+
} from './cve_summary_store';
|
|
11
12
|
import './cve_scans.scss';
|
|
12
13
|
|
|
13
14
|
const CveSummaryCell = ({ hostId, hostName }) => {
|
|
14
|
-
const url = hostId
|
|
15
|
-
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
16
|
-
: null;
|
|
17
|
-
const { response, status } = useAPI('get', url, {
|
|
18
|
-
key: `CVE_SUMMARY_${hostId}`,
|
|
19
|
-
});
|
|
20
|
-
|
|
21
15
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
16
|
+
const [response, setResponse] = useState(
|
|
17
|
+
hostId ? getCachedCveSummary(hostId) : null
|
|
18
|
+
);
|
|
19
|
+
const [isLoaded, setIsLoaded] = useState(
|
|
20
|
+
hostId ? getCachedCveSummary(hostId) !== undefined : true
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!hostId) {
|
|
25
|
+
setResponse(null);
|
|
26
|
+
setIsLoaded(true);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cached = getCachedCveSummary(hostId);
|
|
31
|
+
setResponse(cached);
|
|
32
|
+
setIsLoaded(cached !== undefined);
|
|
33
|
+
|
|
34
|
+
return subscribeToCveSummary(hostId, value => {
|
|
35
|
+
setResponse(value);
|
|
36
|
+
setIsLoaded(true);
|
|
37
|
+
});
|
|
38
|
+
}, [hostId]);
|
|
39
|
+
|
|
22
40
|
if (!hostId) return <span className="cve-summary-empty">--</span>;
|
|
23
|
-
if (
|
|
24
|
-
return (
|
|
25
|
-
<Spinner
|
|
26
|
-
size="sm"
|
|
27
|
-
aria-label={__('Loading CVE findings')}
|
|
28
|
-
ouiaId="cve-summary-loading"
|
|
29
|
-
/>
|
|
30
|
-
);
|
|
41
|
+
if (!isLoaded) {
|
|
42
|
+
return <Spinner size="sm" aria-label={__('Loading CVE findings')} />;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
if (!response) return <span className="cve-summary-empty">--</span>;
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
2
|
import React, { useMemo } from 'react';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
|
-
import {
|
|
5
|
-
Button,
|
|
6
|
-
Text,
|
|
7
|
-
TextContent,
|
|
8
|
-
TextVariants,
|
|
9
|
-
Tooltip,
|
|
10
|
-
} from '@patternfly/react-core';
|
|
4
|
+
import { Button, Tooltip } from '@patternfly/react-core';
|
|
11
5
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
12
6
|
import { formatScannedAt } from './cve_helpers';
|
|
13
7
|
|
|
@@ -97,15 +91,6 @@ const CveTrendChart = ({
|
|
|
97
91
|
|
|
98
92
|
return (
|
|
99
93
|
<section className="cve-trend" aria-label="CVE trend chart">
|
|
100
|
-
<TextContent className="cve-section-title">
|
|
101
|
-
<Text component={TextVariants.h4} ouiaId="cve-trend-title">
|
|
102
|
-
{__('Trend')}
|
|
103
|
-
</Text>
|
|
104
|
-
<Text component={TextVariants.small} ouiaId="cve-trend-subtitle">
|
|
105
|
-
{__('Last %s scans').replace('%s', visibleScans.length)}
|
|
106
|
-
</Text>
|
|
107
|
-
</TextContent>
|
|
108
|
-
|
|
109
94
|
<div className="cve-trend-summary">
|
|
110
95
|
<div className="cve-trend-card">
|
|
111
96
|
<span className="cve-trend-card-label">{__('Latest total')}</span>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* eslint-disable import/no-unresolved */
|
|
1
|
+
/* eslint-disable import/no-unresolved, react/prop-types */
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { mount } from 'enzyme';
|
|
4
4
|
import CveDetailsCard from '../CveDetailsCard';
|
|
@@ -7,17 +7,20 @@ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
|
|
7
7
|
useAPI: jest.fn(),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
jest.mock(
|
|
11
|
-
|
|
12
|
-
) => <div>{children}</div>
|
|
10
|
+
jest.mock(
|
|
11
|
+
'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate',
|
|
12
|
+
() => ({ children }) => <div>{children}</div>
|
|
13
|
+
);
|
|
13
14
|
|
|
14
|
-
jest.mock(
|
|
15
|
-
|
|
16
|
-
) => <div>{children}</div>
|
|
15
|
+
jest.mock(
|
|
16
|
+
'foremanReact/components/common/SkeletonLoader',
|
|
17
|
+
() => ({ children }) => <div>{children}</div>
|
|
18
|
+
);
|
|
17
19
|
|
|
18
|
-
jest.mock(
|
|
19
|
-
|
|
20
|
-
) => <span>{date || defaultValue}</span>
|
|
20
|
+
jest.mock(
|
|
21
|
+
'foremanReact/components/common/dates/RelativeDateTime',
|
|
22
|
+
() => ({ date, defaultValue }) => <span>{date || defaultValue}</span>
|
|
23
|
+
);
|
|
21
24
|
|
|
22
25
|
jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
|
|
23
26
|
|
|
@@ -32,9 +35,27 @@ describe('CveDetailsCard', () => {
|
|
|
32
35
|
const hostDetails = { id: 1 };
|
|
33
36
|
const historyResponse = {
|
|
34
37
|
results: [
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
{
|
|
39
|
+
id: 1,
|
|
40
|
+
scanned_at: '2026-02-20',
|
|
41
|
+
scanner: 'trivy',
|
|
42
|
+
source: 'rex',
|
|
43
|
+
total: 10,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 2,
|
|
47
|
+
scanned_at: '2026-02-21',
|
|
48
|
+
scanner: 'trivy',
|
|
49
|
+
source: 'rex',
|
|
50
|
+
total: 12,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 3,
|
|
54
|
+
scanned_at: '2026-02-22',
|
|
55
|
+
scanner: 'grype',
|
|
56
|
+
source: 'external',
|
|
57
|
+
total: 8,
|
|
58
|
+
},
|
|
38
59
|
],
|
|
39
60
|
};
|
|
40
61
|
const latestResponse = {
|
|
@@ -73,6 +94,46 @@ describe('CveDetailsCard', () => {
|
|
|
73
94
|
expect(wrapper.text()).toContain('pkg');
|
|
74
95
|
});
|
|
75
96
|
|
|
97
|
+
it('renders fix availability summary when latest scan is enriched', () => {
|
|
98
|
+
const hostDetails = { id: 1 };
|
|
99
|
+
const latestResponse = {
|
|
100
|
+
id: 3,
|
|
101
|
+
scanned_at: '2026-02-22',
|
|
102
|
+
scanner: 'grype',
|
|
103
|
+
source: 'external',
|
|
104
|
+
total: 3,
|
|
105
|
+
summary: { worst: 'high' },
|
|
106
|
+
critical: 0,
|
|
107
|
+
high: 1,
|
|
108
|
+
medium: 1,
|
|
109
|
+
low: 1,
|
|
110
|
+
findings: [
|
|
111
|
+
{
|
|
112
|
+
id: 'CVE-1',
|
|
113
|
+
name: 'openssl',
|
|
114
|
+
version: '1.0',
|
|
115
|
+
severity: 'HIGH',
|
|
116
|
+
katello_fix: { status: 'installable' },
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'CVE-2',
|
|
120
|
+
name: 'curl',
|
|
121
|
+
version: '8.0',
|
|
122
|
+
severity: 'MEDIUM',
|
|
123
|
+
katello_fix: { status: 'applicable' },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
useAPI.mockReturnValue({ response: latestResponse, status: 'RESOLVED' });
|
|
129
|
+
|
|
130
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
131
|
+
|
|
132
|
+
expect(wrapper.text()).toContain('Fix availability');
|
|
133
|
+
expect(wrapper.text()).toContain('Fixable: 1');
|
|
134
|
+
expect(wrapper.text()).toContain('Applicable: 1');
|
|
135
|
+
});
|
|
136
|
+
|
|
76
137
|
it('renders empty state when latest is empty', () => {
|
|
77
138
|
const hostDetails = { id: 1 };
|
|
78
139
|
useAPI.mockReturnValue({ response: {}, status: 'RESOLVED' });
|
|
@@ -84,7 +145,15 @@ describe('CveDetailsCard', () => {
|
|
|
84
145
|
it('prefers latest when present', () => {
|
|
85
146
|
const hostDetails = { id: 1 };
|
|
86
147
|
const historyResponse = {
|
|
87
|
-
results: [
|
|
148
|
+
results: [
|
|
149
|
+
{
|
|
150
|
+
id: 1,
|
|
151
|
+
scanned_at: '2026-02-20',
|
|
152
|
+
scanner: 'trivy',
|
|
153
|
+
source: 'rex',
|
|
154
|
+
total: 10,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
88
157
|
};
|
|
89
158
|
const latestResponse = {
|
|
90
159
|
id: 2,
|
|
@@ -55,7 +55,7 @@ describe('CveFindingsModal', () => {
|
|
|
55
55
|
expect(wrapper.text()).not.toContain('CVE-1');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('shows
|
|
58
|
+
it('shows success state when no findings', () => {
|
|
59
59
|
useAPI.mockReturnValue({
|
|
60
60
|
response: {
|
|
61
61
|
id: 1,
|
|
@@ -78,7 +78,10 @@ describe('CveFindingsModal', () => {
|
|
|
78
78
|
/>
|
|
79
79
|
);
|
|
80
80
|
|
|
81
|
-
expect(wrapper.text()).toContain('No
|
|
81
|
+
expect(wrapper.text()).toContain('No CVEs found');
|
|
82
|
+
expect(wrapper.text()).toContain(
|
|
83
|
+
'The latest CVE scan found no vulnerabilities for this host.'
|
|
84
|
+
);
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
it('filters findings by search text', () => {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
5
|
+
import CveFindingsView from '../CveFindingsView';
|
|
6
|
+
|
|
7
|
+
describe('CveFindingsView', () => {
|
|
8
|
+
it('renders no reports state when scan is missing', () => {
|
|
9
|
+
const wrapper = mount(
|
|
10
|
+
<CveFindingsView scan={null} status="RESOLVED" initialFilter="all" />
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
14
|
+
expect(wrapper.text()).toContain('Run a CVE scan to see results here.');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders success state when scan has no findings', () => {
|
|
18
|
+
const wrapper = mount(
|
|
19
|
+
<CveFindingsView
|
|
20
|
+
scan={{
|
|
21
|
+
id: 1,
|
|
22
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
23
|
+
scanner: 'trivy',
|
|
24
|
+
source: 'rex',
|
|
25
|
+
total: 0,
|
|
26
|
+
findings: [],
|
|
27
|
+
}}
|
|
28
|
+
status="RESOLVED"
|
|
29
|
+
initialFilter="all"
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(wrapper.text()).toContain('No CVEs found');
|
|
34
|
+
expect(wrapper.text()).toContain(
|
|
35
|
+
'The latest CVE scan found no vulnerabilities for this host.'
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('filters findings by severity and search text', () => {
|
|
40
|
+
const wrapper = mount(
|
|
41
|
+
<CveFindingsView
|
|
42
|
+
scan={{
|
|
43
|
+
id: 1,
|
|
44
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
45
|
+
scanner: 'trivy',
|
|
46
|
+
source: 'rex',
|
|
47
|
+
total: 2,
|
|
48
|
+
findings: [
|
|
49
|
+
{
|
|
50
|
+
id: 'CVE-1',
|
|
51
|
+
severity: 'HIGH',
|
|
52
|
+
name: 'openssl',
|
|
53
|
+
version: '1.1',
|
|
54
|
+
title: 'OpenSSL issue',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'CVE-2',
|
|
58
|
+
severity: 'LOW',
|
|
59
|
+
name: 'curl',
|
|
60
|
+
version: '8.0',
|
|
61
|
+
title: 'Curl issue',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}}
|
|
65
|
+
status="RESOLVED"
|
|
66
|
+
initialFilter="all"
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const lowButton = wrapper
|
|
71
|
+
.find('button')
|
|
72
|
+
.filterWhere(node => node.text().toLowerCase() === 'low')
|
|
73
|
+
.first();
|
|
74
|
+
lowButton.simulate('click');
|
|
75
|
+
wrapper.update();
|
|
76
|
+
|
|
77
|
+
expect(wrapper.text()).toContain('CVE-2');
|
|
78
|
+
expect(wrapper.text()).not.toContain('CVE-1');
|
|
79
|
+
|
|
80
|
+
const allButton = wrapper
|
|
81
|
+
.find('button')
|
|
82
|
+
.filterWhere(node => node.text().toLowerCase() === 'all')
|
|
83
|
+
.first();
|
|
84
|
+
allButton.simulate('click');
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
wrapper.find('SearchInput').prop('onChange')('openssl');
|
|
88
|
+
});
|
|
89
|
+
wrapper.update();
|
|
90
|
+
|
|
91
|
+
expect(wrapper.text()).toContain('CVE-1');
|
|
92
|
+
expect(wrapper.text()).not.toContain('CVE-2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders fix availability when present', () => {
|
|
96
|
+
const wrapper = mount(
|
|
97
|
+
<CveFindingsView
|
|
98
|
+
scan={{
|
|
99
|
+
id: 1,
|
|
100
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
101
|
+
scanner: 'trivy',
|
|
102
|
+
source: 'rex',
|
|
103
|
+
total: 2,
|
|
104
|
+
findings: [
|
|
105
|
+
{
|
|
106
|
+
id: 'CVE-1',
|
|
107
|
+
severity: 'HIGH',
|
|
108
|
+
name: 'openssl',
|
|
109
|
+
version: '1.1',
|
|
110
|
+
title: 'OpenSSL issue',
|
|
111
|
+
katello_fix: {
|
|
112
|
+
status: 'installable',
|
|
113
|
+
errata: [{ errata_id: 'RHSA-2026:0001' }],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'CVE-2',
|
|
118
|
+
severity: 'LOW',
|
|
119
|
+
name: 'curl',
|
|
120
|
+
version: '8.0',
|
|
121
|
+
title: 'Curl issue',
|
|
122
|
+
katello_fix: {
|
|
123
|
+
status: 'applicable',
|
|
124
|
+
errata: [{ id: 1, errata_id: 'RHSA-2026:0002' }],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
}}
|
|
129
|
+
status="RESOLVED"
|
|
130
|
+
initialFilter="all"
|
|
131
|
+
hostName="host.example.com"
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(wrapper.text()).toContain('Fix availability');
|
|
136
|
+
expect(wrapper.text()).toContain('Installable');
|
|
137
|
+
expect(wrapper.text()).toContain('Applicable');
|
|
138
|
+
const installLink = wrapper
|
|
139
|
+
.find('a')
|
|
140
|
+
.filterWhere(node => node.text() === 'Installable')
|
|
141
|
+
.first();
|
|
142
|
+
const applicableLink = wrapper
|
|
143
|
+
.find('a')
|
|
144
|
+
.filterWhere(node => node.text() === 'Applicable')
|
|
145
|
+
.first();
|
|
146
|
+
|
|
147
|
+
expect(installLink.prop('href')).toContain('/job_invocations/new?');
|
|
148
|
+
expect(installLink.prop('href')).toContain(
|
|
149
|
+
'katello_errata_install_by_search'
|
|
150
|
+
);
|
|
151
|
+
expect(applicableLink.prop('href')).toBe('/errata/1');
|
|
152
|
+
});
|
|
153
|
+
});
|