foreman_cve_scanner 0.5.1 → 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 -3
- 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 +3 -6
- 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 +3 -3
- 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,34 +1,42 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
|
-
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
4
|
import {
|
|
5
|
-
Button,
|
|
6
|
-
Pagination,
|
|
7
5
|
EmptyState,
|
|
8
6
|
EmptyStateIcon,
|
|
9
7
|
EmptyStateBody,
|
|
8
|
+
Tab,
|
|
9
|
+
TabTitleText,
|
|
10
|
+
Tabs,
|
|
10
11
|
Title,
|
|
11
12
|
} from '@patternfly/react-core';
|
|
12
13
|
import { SearchIcon } from '@patternfly/react-icons';
|
|
13
|
-
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
14
14
|
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
15
15
|
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
16
16
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
17
17
|
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
|
18
18
|
import { STATUS } from 'foremanReact/constants';
|
|
19
|
-
import
|
|
19
|
+
import CveTrendChart from './CveTrendChart';
|
|
20
|
+
import CveScansReports from './CveScansReports';
|
|
20
21
|
import CveFindingsModal from './CveFindingsModal';
|
|
22
|
+
import CveCompareModal from './CveCompareModal';
|
|
23
|
+
import useModalScan from './useModalScan';
|
|
21
24
|
import { noReportsBody, noReportsTitle } from './cve_helpers';
|
|
25
|
+
import './cve_scans.scss';
|
|
22
26
|
|
|
23
27
|
const DEFAULT_PER_PAGE = 20;
|
|
28
|
+
const OVERVIEW_TAB = 0;
|
|
29
|
+
const REPORTS_TAB = 1;
|
|
24
30
|
|
|
25
31
|
const CveScansTab = ({ response }) => {
|
|
26
32
|
const hostId = response?.id;
|
|
27
33
|
const [page, setPage] = useState(1);
|
|
28
34
|
const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
|
|
29
|
-
const [
|
|
30
|
-
const [
|
|
31
|
-
const [
|
|
35
|
+
const [isCompareOpen, setIsCompareOpen] = useState(false);
|
|
36
|
+
const [selectedScanIds, setSelectedScanIds] = useState([]);
|
|
37
|
+
const [isTrendCompareMode, setIsTrendCompareMode] = useState(false);
|
|
38
|
+
const [activeTabKey, setActiveTabKey] = useState(OVERVIEW_TAB);
|
|
39
|
+
const { isOpen, scanId, filter, openModal, closeModal } = useModalScan();
|
|
32
40
|
|
|
33
41
|
const historyUrl = hostId
|
|
34
42
|
? foremanUrl(
|
|
@@ -40,23 +48,64 @@ const CveScansTab = ({ response }) => {
|
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
const scans = useMemo(() => apiResponse?.results || [], [apiResponse]);
|
|
51
|
+
const selectedScans = useMemo(
|
|
52
|
+
() => scans.filter(scan => selectedScanIds.includes(scan.id)),
|
|
53
|
+
[scans, selectedScanIds]
|
|
54
|
+
);
|
|
55
|
+
const orderedCompareIds = useMemo(
|
|
56
|
+
() =>
|
|
57
|
+
[...selectedScans]
|
|
58
|
+
.sort(
|
|
59
|
+
(a, b) =>
|
|
60
|
+
new Date(a.scanned_at || 0).getTime() -
|
|
61
|
+
new Date(b.scanned_at || 0).getTime()
|
|
62
|
+
)
|
|
63
|
+
.map(scan => scan.id),
|
|
64
|
+
[selectedScans]
|
|
65
|
+
);
|
|
43
66
|
const itemCount = apiResponse?.total ?? scans.length;
|
|
44
|
-
|
|
67
|
+
const exportUrlFor = reportId =>
|
|
68
|
+
foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/${reportId}/export`);
|
|
45
69
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
const onSetPage = (_event, newPage) => {
|
|
71
|
+
setPage(newPage);
|
|
72
|
+
setSelectedScanIds([]);
|
|
73
|
+
setIsTrendCompareMode(false);
|
|
50
74
|
};
|
|
51
|
-
|
|
52
|
-
const onSetPage = (_event, newPage) => setPage(newPage);
|
|
53
75
|
const onPerPageSelect = (_event, newPerPage) => {
|
|
54
76
|
setPerPage(newPerPage);
|
|
55
77
|
setPage(1);
|
|
78
|
+
setSelectedScanIds([]);
|
|
79
|
+
setIsTrendCompareMode(false);
|
|
80
|
+
};
|
|
81
|
+
const toggleScanSelection = reportId => {
|
|
82
|
+
setSelectedScanIds(currentSelection => {
|
|
83
|
+
if (currentSelection.includes(reportId)) {
|
|
84
|
+
return currentSelection.filter(id => id !== reportId);
|
|
85
|
+
}
|
|
86
|
+
if (currentSelection.length >= 2) return currentSelection;
|
|
87
|
+
return [...currentSelection, reportId];
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const clearSelection = () => {
|
|
91
|
+
setSelectedScanIds([]);
|
|
92
|
+
setIsTrendCompareMode(false);
|
|
93
|
+
};
|
|
94
|
+
const toggleTrendCompareMode = () => {
|
|
95
|
+
setIsTrendCompareMode(current => !current);
|
|
96
|
+
setSelectedScanIds([]);
|
|
56
97
|
};
|
|
57
98
|
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!isTrendCompareMode || selectedScanIds.length !== 2) return;
|
|
101
|
+
setIsCompareOpen(true);
|
|
102
|
+
setIsTrendCompareMode(false);
|
|
103
|
+
}, [isTrendCompareMode, selectedScanIds]);
|
|
104
|
+
|
|
105
|
+
if (!hostId) return null;
|
|
106
|
+
|
|
58
107
|
return (
|
|
59
|
-
|
|
108
|
+
<div className="cve-scans-tab">
|
|
60
109
|
<SkeletonLoader status={status || STATUS.PENDING}>
|
|
61
110
|
{scans.length === 0 ? (
|
|
62
111
|
<EmptyState>
|
|
@@ -67,115 +116,69 @@ const CveScansTab = ({ response }) => {
|
|
|
67
116
|
<EmptyStateBody>{noReportsBody()}</EmptyStateBody>
|
|
68
117
|
</EmptyState>
|
|
69
118
|
) : (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
ouiaId="cve-scans-
|
|
80
|
-
/>
|
|
81
|
-
<Table
|
|
82
|
-
variant="compact"
|
|
83
|
-
aria-label="CVE scans history"
|
|
84
|
-
ouiaId="cve-scans-table"
|
|
119
|
+
<Tabs
|
|
120
|
+
activeKey={activeTabKey}
|
|
121
|
+
onSelect={(_event, tabIndex) => setActiveTabKey(tabIndex)}
|
|
122
|
+
className="cve-scans-subtabs"
|
|
123
|
+
ouiaId="cve-scans-subtabs"
|
|
124
|
+
>
|
|
125
|
+
<Tab
|
|
126
|
+
eventKey={OVERVIEW_TAB}
|
|
127
|
+
title={<TabTitleText>{__('Overview')}</TabTitleText>}
|
|
128
|
+
ouiaId="cve-scans-overview-tab"
|
|
85
129
|
>
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
</Button>
|
|
123
|
-
</Td>
|
|
124
|
-
<Td dataLabel={__('Critical')}>
|
|
125
|
-
<Button
|
|
126
|
-
variant="link"
|
|
127
|
-
className="cve-summary-link"
|
|
128
|
-
onClick={() => openModal(scan.id, 'critical')}
|
|
129
|
-
ouiaId={`cve-scans-critical-${scan.id}`}
|
|
130
|
-
>
|
|
131
|
-
{scan.critical}
|
|
132
|
-
</Button>
|
|
133
|
-
</Td>
|
|
134
|
-
<Td dataLabel={__('High')}>
|
|
135
|
-
<Button
|
|
136
|
-
variant="link"
|
|
137
|
-
className="cve-summary-link"
|
|
138
|
-
onClick={() => openModal(scan.id, 'high')}
|
|
139
|
-
ouiaId={`cve-scans-high-${scan.id}`}
|
|
140
|
-
>
|
|
141
|
-
{scan.high}
|
|
142
|
-
</Button>
|
|
143
|
-
</Td>
|
|
144
|
-
<Td dataLabel={__('Medium')}>
|
|
145
|
-
<Button
|
|
146
|
-
variant="link"
|
|
147
|
-
className="cve-summary-link"
|
|
148
|
-
onClick={() => openModal(scan.id, 'medium')}
|
|
149
|
-
ouiaId={`cve-scans-medium-${scan.id}`}
|
|
150
|
-
>
|
|
151
|
-
{scan.medium}
|
|
152
|
-
</Button>
|
|
153
|
-
</Td>
|
|
154
|
-
<Td dataLabel={__('Low')}>
|
|
155
|
-
<Button
|
|
156
|
-
variant="link"
|
|
157
|
-
className="cve-summary-link"
|
|
158
|
-
onClick={() => openModal(scan.id, 'low')}
|
|
159
|
-
ouiaId={`cve-scans-low-${scan.id}`}
|
|
160
|
-
>
|
|
161
|
-
{scan.low}
|
|
162
|
-
</Button>
|
|
163
|
-
</Td>
|
|
164
|
-
</Tr>
|
|
165
|
-
))}
|
|
166
|
-
</Tbody>
|
|
167
|
-
</Table>
|
|
168
|
-
</>
|
|
130
|
+
<section
|
|
131
|
+
className="cve-scans-section"
|
|
132
|
+
aria-label="CVE scan overview"
|
|
133
|
+
>
|
|
134
|
+
<CveTrendChart
|
|
135
|
+
scans={scans}
|
|
136
|
+
onOpen={reportId => openModal(reportId, 'all')}
|
|
137
|
+
compareMode={isTrendCompareMode}
|
|
138
|
+
selectedScanIds={selectedScanIds}
|
|
139
|
+
onToggleSelection={toggleScanSelection}
|
|
140
|
+
onToggleCompareMode={toggleTrendCompareMode}
|
|
141
|
+
/>
|
|
142
|
+
</section>
|
|
143
|
+
</Tab>
|
|
144
|
+
<Tab
|
|
145
|
+
eventKey={REPORTS_TAB}
|
|
146
|
+
title={<TabTitleText>{__('Reports')}</TabTitleText>}
|
|
147
|
+
ouiaId="cve-scans-reports-tab"
|
|
148
|
+
>
|
|
149
|
+
<CveScansReports
|
|
150
|
+
scans={scans}
|
|
151
|
+
itemCount={itemCount}
|
|
152
|
+
page={page}
|
|
153
|
+
perPage={perPage}
|
|
154
|
+
onSetPage={onSetPage}
|
|
155
|
+
onPerPageSelect={onPerPageSelect}
|
|
156
|
+
selectedScanIds={selectedScanIds}
|
|
157
|
+
selectedCount={selectedScans.length}
|
|
158
|
+
onCompare={() => setIsCompareOpen(true)}
|
|
159
|
+
onClearSelection={clearSelection}
|
|
160
|
+
onToggleSelection={toggleScanSelection}
|
|
161
|
+
onOpenModal={openModal}
|
|
162
|
+
exportUrlFor={exportUrlFor}
|
|
163
|
+
/>
|
|
164
|
+
</Tab>
|
|
165
|
+
</Tabs>
|
|
169
166
|
)}
|
|
170
167
|
</SkeletonLoader>
|
|
171
168
|
<CveFindingsModal
|
|
172
|
-
isOpen={
|
|
173
|
-
onClose={
|
|
169
|
+
isOpen={isOpen}
|
|
170
|
+
onClose={closeModal}
|
|
171
|
+
hostId={hostId}
|
|
172
|
+
scanId={scanId}
|
|
173
|
+
initialFilter={filter}
|
|
174
|
+
/>
|
|
175
|
+
<CveCompareModal
|
|
176
|
+
isOpen={isCompareOpen}
|
|
177
|
+
onClose={() => setIsCompareOpen(false)}
|
|
174
178
|
hostId={hostId}
|
|
175
|
-
|
|
176
|
-
initialFilter={modalFilter}
|
|
179
|
+
scanIds={orderedCompareIds}
|
|
177
180
|
/>
|
|
178
|
-
|
|
181
|
+
</div>
|
|
179
182
|
);
|
|
180
183
|
};
|
|
181
184
|
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React, { useMemo } from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Text,
|
|
7
|
+
TextContent,
|
|
8
|
+
TextVariants,
|
|
9
|
+
Tooltip,
|
|
10
|
+
} from '@patternfly/react-core';
|
|
11
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
12
|
+
import { formatScannedAt } from './cve_helpers';
|
|
13
|
+
|
|
14
|
+
const TREND_LIMIT = 10;
|
|
15
|
+
const STACK_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
16
|
+
const TREND_LEGEND = [
|
|
17
|
+
{ key: 'critical', label: __('Critical') },
|
|
18
|
+
{ key: 'high', label: __('High') },
|
|
19
|
+
{ key: 'medium', label: __('Medium') },
|
|
20
|
+
{ key: 'low', label: __('Low') },
|
|
21
|
+
];
|
|
22
|
+
const totalDeltaText = __(
|
|
23
|
+
'Difference in total CVE count compared to the previous scan.'
|
|
24
|
+
);
|
|
25
|
+
const criticalHighDeltaText = __(
|
|
26
|
+
'Difference in critical and high CVE count compared to the previous scan.'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const shortDateLabel = value => {
|
|
30
|
+
const date = new Date(value || 0);
|
|
31
|
+
if (Number.isNaN(date.getTime())) return '--';
|
|
32
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
33
|
+
day: '2-digit',
|
|
34
|
+
month: 'short',
|
|
35
|
+
})
|
|
36
|
+
.format(date)
|
|
37
|
+
.replace(',', '');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const formatDelta = value => {
|
|
41
|
+
if (value > 0) return `+${value}`;
|
|
42
|
+
if (value < 0) return String(value);
|
|
43
|
+
return '0';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const deltaClassName = value => {
|
|
47
|
+
if (value > 0) return 'is-up';
|
|
48
|
+
if (value < 0) return 'is-down';
|
|
49
|
+
return 'is-flat';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tooltipContentFor = scan => (
|
|
53
|
+
<div className="cve-trend-tooltip">
|
|
54
|
+
<div className="cve-trend-tooltip-line">
|
|
55
|
+
{formatScannedAt(scan.scanned_at)}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="cve-trend-tooltip-line">
|
|
58
|
+
{`${__('Total')}: ${scan.total}`}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="cve-trend-tooltip-line">
|
|
61
|
+
{`${__('Critical')}: ${scan.critical}`}
|
|
62
|
+
</div>
|
|
63
|
+
<div className="cve-trend-tooltip-line">
|
|
64
|
+
{`${__('High')}: ${scan.high}`}
|
|
65
|
+
</div>
|
|
66
|
+
<div className="cve-trend-tooltip-line">
|
|
67
|
+
{`${__('Medium')}: ${scan.medium}`}
|
|
68
|
+
</div>
|
|
69
|
+
<div className="cve-trend-tooltip-line">{`${__('Low')}: ${scan.low}`}</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const CveTrendChart = ({
|
|
74
|
+
scans,
|
|
75
|
+
onOpen,
|
|
76
|
+
compareMode,
|
|
77
|
+
selectedScanIds,
|
|
78
|
+
onToggleSelection,
|
|
79
|
+
onToggleCompareMode,
|
|
80
|
+
}) => {
|
|
81
|
+
const visibleScans = useMemo(
|
|
82
|
+
() => [...scans].slice(0, TREND_LIMIT).reverse(),
|
|
83
|
+
[scans]
|
|
84
|
+
);
|
|
85
|
+
const latestScan = scans[0];
|
|
86
|
+
const previousScan = scans[1];
|
|
87
|
+
const isCompareSelectable = typeof onToggleSelection === 'function';
|
|
88
|
+
const selectedCount = selectedScanIds.length;
|
|
89
|
+
const maxTotal = Math.max(...visibleScans.map(scan => scan.total || 0), 1);
|
|
90
|
+
const criticalHighDelta =
|
|
91
|
+
(latestScan?.critical || 0) +
|
|
92
|
+
(latestScan?.high || 0) -
|
|
93
|
+
((previousScan?.critical || 0) + (previousScan?.high || 0));
|
|
94
|
+
const totalDelta = (latestScan?.total || 0) - (previousScan?.total || 0);
|
|
95
|
+
|
|
96
|
+
if (!latestScan) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<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
|
+
<div className="cve-trend-summary">
|
|
110
|
+
<div className="cve-trend-card">
|
|
111
|
+
<span className="cve-trend-card-label">{__('Latest total')}</span>
|
|
112
|
+
<span className="cve-trend-card-value">{latestScan.total}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="cve-trend-card">
|
|
115
|
+
<Tooltip content={criticalHighDeltaText}>
|
|
116
|
+
<span className="cve-trend-card-label cve-trend-card-label--help">
|
|
117
|
+
{__('Critical/high delta')}
|
|
118
|
+
</span>
|
|
119
|
+
</Tooltip>
|
|
120
|
+
<span
|
|
121
|
+
className={`cve-trend-card-value ${deltaClassName(
|
|
122
|
+
criticalHighDelta
|
|
123
|
+
)}`}
|
|
124
|
+
>
|
|
125
|
+
{previousScan
|
|
126
|
+
? formatDelta(criticalHighDelta)
|
|
127
|
+
: __('No previous scan')}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="cve-trend-card">
|
|
131
|
+
<Tooltip content={totalDeltaText}>
|
|
132
|
+
<span className="cve-trend-card-label cve-trend-card-label--help">
|
|
133
|
+
{__('Total delta')}
|
|
134
|
+
</span>
|
|
135
|
+
</Tooltip>
|
|
136
|
+
<span
|
|
137
|
+
className={`cve-trend-card-value ${deltaClassName(totalDelta)}`}
|
|
138
|
+
>
|
|
139
|
+
{previousScan ? formatDelta(totalDelta) : __('No previous scan')}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="cve-trend-card">
|
|
143
|
+
<span className="cve-trend-card-label">{__('Last scanned')}</span>
|
|
144
|
+
<span className="cve-trend-card-value cve-trend-card-value--small">
|
|
145
|
+
{formatScannedAt(latestScan.scanned_at)}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{isCompareSelectable && (
|
|
151
|
+
<div className="cve-trend-controls">
|
|
152
|
+
<div className="cve-trend-actions">
|
|
153
|
+
<Button
|
|
154
|
+
variant={compareMode ? 'link' : 'secondary'}
|
|
155
|
+
onClick={onToggleCompareMode}
|
|
156
|
+
ouiaId="cve-trend-compare-mode"
|
|
157
|
+
>
|
|
158
|
+
{compareMode ? __('Cancel compare') : __('Compare 2 reports')}
|
|
159
|
+
</Button>
|
|
160
|
+
{compareMode && (
|
|
161
|
+
<span className="cve-trend-compare-state">
|
|
162
|
+
{`${selectedCount}/2 ${__('selected')}`}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<div className="cve-trend-legend" aria-label="CVE trend legend">
|
|
170
|
+
{TREND_LEGEND.map(item => (
|
|
171
|
+
<span key={item.key} className="cve-trend-legend-item">
|
|
172
|
+
<span
|
|
173
|
+
className={`cve-trend-legend-swatch cve-trend-segment--${item.key}`}
|
|
174
|
+
/>
|
|
175
|
+
<span>{item.label}</span>
|
|
176
|
+
</span>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="cve-trend-bars">
|
|
181
|
+
{visibleScans.map(scan => (
|
|
182
|
+
<div key={scan.id} className="cve-trend-bar-item">
|
|
183
|
+
<Tooltip content={tooltipContentFor(scan)}>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className={
|
|
187
|
+
compareMode && selectedScanIds.includes(scan.id)
|
|
188
|
+
? 'cve-trend-bar-button is-selected'
|
|
189
|
+
: 'cve-trend-bar-button'
|
|
190
|
+
}
|
|
191
|
+
onClick={() =>
|
|
192
|
+
compareMode ? onToggleSelection(scan.id) : onOpen(scan.id)
|
|
193
|
+
}
|
|
194
|
+
aria-label={
|
|
195
|
+
compareMode
|
|
196
|
+
? __('Select scan from %s for comparison').replace(
|
|
197
|
+
'%s',
|
|
198
|
+
formatScannedAt(scan.scanned_at)
|
|
199
|
+
)
|
|
200
|
+
: __('Open scan details for %s').replace(
|
|
201
|
+
'%s',
|
|
202
|
+
formatScannedAt(scan.scanned_at)
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
>
|
|
206
|
+
<span
|
|
207
|
+
className={
|
|
208
|
+
scan.total === 0
|
|
209
|
+
? 'cve-trend-bar-frame cve-trend-bar-frame--clean'
|
|
210
|
+
: 'cve-trend-bar-frame'
|
|
211
|
+
}
|
|
212
|
+
>
|
|
213
|
+
{STACK_ORDER.map(level => (
|
|
214
|
+
<span
|
|
215
|
+
key={level}
|
|
216
|
+
className={`cve-trend-segment cve-trend-segment--${level}`}
|
|
217
|
+
style={{
|
|
218
|
+
height: `${((scan[level] || 0) / maxTotal) * 100}%`,
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
))}
|
|
222
|
+
</span>
|
|
223
|
+
<span className="cve-trend-bar-total">{scan.total}</span>
|
|
224
|
+
<span className="cve-trend-bar-label">
|
|
225
|
+
{shortDateLabel(scan.scanned_at)}
|
|
226
|
+
</span>
|
|
227
|
+
</button>
|
|
228
|
+
</Tooltip>
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
CveTrendChart.propTypes = {
|
|
237
|
+
scans: PropTypes.arrayOf(
|
|
238
|
+
PropTypes.shape({
|
|
239
|
+
id: PropTypes.number.isRequired,
|
|
240
|
+
scanned_at: PropTypes.string,
|
|
241
|
+
total: PropTypes.number,
|
|
242
|
+
critical: PropTypes.number,
|
|
243
|
+
high: PropTypes.number,
|
|
244
|
+
medium: PropTypes.number,
|
|
245
|
+
low: PropTypes.number,
|
|
246
|
+
})
|
|
247
|
+
),
|
|
248
|
+
onOpen: PropTypes.func,
|
|
249
|
+
compareMode: PropTypes.bool,
|
|
250
|
+
selectedScanIds: PropTypes.arrayOf(PropTypes.number),
|
|
251
|
+
onToggleSelection: PropTypes.func,
|
|
252
|
+
onToggleCompareMode: PropTypes.func,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
CveTrendChart.defaultProps = {
|
|
256
|
+
scans: [],
|
|
257
|
+
onOpen: () => {},
|
|
258
|
+
compareMode: false,
|
|
259
|
+
selectedScanIds: [],
|
|
260
|
+
onToggleSelection: undefined,
|
|
261
|
+
onToggleCompareMode: () => {},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export default CveTrendChart;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import CveCompareModal from '../CveCompareModal';
|
|
5
|
+
|
|
6
|
+
jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
|
7
|
+
useAPI: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
|
|
11
|
+
|
|
12
|
+
describe('CveCompareModal', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
useAPI.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders comparison summary and filters rows by status', () => {
|
|
18
|
+
useAPI.mockReturnValue({
|
|
19
|
+
response: {
|
|
20
|
+
previous: {
|
|
21
|
+
id: 1,
|
|
22
|
+
scanned_at: '2026-02-20T10:00:00Z',
|
|
23
|
+
scanner: 'trivy',
|
|
24
|
+
source: 'rex',
|
|
25
|
+
},
|
|
26
|
+
current: {
|
|
27
|
+
id: 2,
|
|
28
|
+
scanned_at: '2026-02-21T10:00:00Z',
|
|
29
|
+
scanner: 'grype',
|
|
30
|
+
source: 'external',
|
|
31
|
+
},
|
|
32
|
+
summary: {
|
|
33
|
+
new: 1,
|
|
34
|
+
resolved: 1,
|
|
35
|
+
updated: 1,
|
|
36
|
+
unchanged: 0,
|
|
37
|
+
},
|
|
38
|
+
results: [
|
|
39
|
+
{
|
|
40
|
+
key: 'CVE-1::pkg-a',
|
|
41
|
+
status: 'updated',
|
|
42
|
+
id: 'CVE-1',
|
|
43
|
+
name: 'pkg-a',
|
|
44
|
+
severity: 'CRITICAL',
|
|
45
|
+
version: '1.0',
|
|
46
|
+
fixed: 'open',
|
|
47
|
+
scan_status: 'affected',
|
|
48
|
+
diff: {
|
|
49
|
+
severity: { old: 'HIGH', new: 'CRITICAL' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'CVE-2::pkg-b',
|
|
54
|
+
status: 'resolved',
|
|
55
|
+
id: 'CVE-2',
|
|
56
|
+
name: 'pkg-b',
|
|
57
|
+
severity: 'LOW',
|
|
58
|
+
version: '1.0',
|
|
59
|
+
fixed: 'open',
|
|
60
|
+
scan_status: 'affected',
|
|
61
|
+
diff: {},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: 'CVE-3::pkg-c',
|
|
65
|
+
status: 'new',
|
|
66
|
+
id: 'CVE-3',
|
|
67
|
+
name: 'pkg-c',
|
|
68
|
+
severity: 'MEDIUM',
|
|
69
|
+
version: '1.0',
|
|
70
|
+
fixed: '2.0',
|
|
71
|
+
scan_status: 'affected',
|
|
72
|
+
diff: {},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
status: 'RESOLVED',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const wrapper = mount(
|
|
80
|
+
<CveCompareModal
|
|
81
|
+
hostId={1}
|
|
82
|
+
isOpen
|
|
83
|
+
onClose={() => {}}
|
|
84
|
+
scanIds={[1, 2]}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(wrapper.text()).toContain('Compare CVE reports');
|
|
89
|
+
expect(wrapper.text()).toContain('Updated');
|
|
90
|
+
expect(wrapper.text()).toContain('Resolved');
|
|
91
|
+
expect(wrapper.text()).toContain('New');
|
|
92
|
+
expect(wrapper.text()).toContain('Severity: HIGH -> CRITICAL');
|
|
93
|
+
|
|
94
|
+
wrapper
|
|
95
|
+
.find('button')
|
|
96
|
+
.filterWhere(node => node.text() === 'Resolved')
|
|
97
|
+
.last()
|
|
98
|
+
.simulate('click');
|
|
99
|
+
wrapper.update();
|
|
100
|
+
|
|
101
|
+
expect(wrapper.text()).toContain('CVE-2');
|
|
102
|
+
expect(wrapper.text()).not.toContain('CVE-3');
|
|
103
|
+
});
|
|
104
|
+
});
|