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,192 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React, { useMemo, useState } from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Pagination,
|
|
7
|
+
EmptyState,
|
|
8
|
+
EmptyStateIcon,
|
|
9
|
+
EmptyStateBody,
|
|
10
|
+
Title,
|
|
11
|
+
} from '@patternfly/react-core';
|
|
12
|
+
import { SearchIcon } from '@patternfly/react-icons';
|
|
13
|
+
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
14
|
+
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
15
|
+
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
16
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
17
|
+
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
|
|
18
|
+
import { STATUS } from 'foremanReact/constants';
|
|
19
|
+
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
20
|
+
import CveFindingsModal from './CveFindingsModal';
|
|
21
|
+
import { noReportsBody, noReportsTitle } from './cve_helpers';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PER_PAGE = 20;
|
|
24
|
+
|
|
25
|
+
const CveScansTab = ({ response }) => {
|
|
26
|
+
const hostId = response?.id;
|
|
27
|
+
const [page, setPage] = useState(1);
|
|
28
|
+
const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
|
|
29
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
30
|
+
const [modalScanId, setModalScanId] = useState(null);
|
|
31
|
+
const [modalFilter, setModalFilter] = useState('all');
|
|
32
|
+
|
|
33
|
+
const historyUrl = hostId
|
|
34
|
+
? foremanUrl(
|
|
35
|
+
`/api/v2/hosts/${hostId}/cve_scans?page=${page}&per_page=${perPage}`
|
|
36
|
+
)
|
|
37
|
+
: null;
|
|
38
|
+
const { response: apiResponse, status } = useAPI('get', historyUrl, {
|
|
39
|
+
key: `CVE_SCANS_TAB_${hostId}_${page}_${perPage}`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const scans = useMemo(() => apiResponse?.results || [], [apiResponse]);
|
|
43
|
+
const itemCount = apiResponse?.total ?? scans.length;
|
|
44
|
+
if (!hostId) return null;
|
|
45
|
+
|
|
46
|
+
const openModal = (scanId, filter) => {
|
|
47
|
+
setModalScanId(scanId);
|
|
48
|
+
setModalFilter(filter || 'all');
|
|
49
|
+
setIsModalOpen(true);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onSetPage = (_event, newPage) => setPage(newPage);
|
|
53
|
+
const onPerPageSelect = (_event, newPerPage) => {
|
|
54
|
+
setPerPage(newPerPage);
|
|
55
|
+
setPage(1);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<SkeletonLoader status={status || STATUS.PENDING}>
|
|
61
|
+
{scans.length === 0 ? (
|
|
62
|
+
<EmptyState>
|
|
63
|
+
<EmptyStateIcon icon={SearchIcon} />
|
|
64
|
+
<Title headingLevel="h4" size="md" ouiaId="cve-scans-empty-title">
|
|
65
|
+
{noReportsTitle()}
|
|
66
|
+
</Title>
|
|
67
|
+
<EmptyStateBody>{noReportsBody()}</EmptyStateBody>
|
|
68
|
+
</EmptyState>
|
|
69
|
+
) : (
|
|
70
|
+
<>
|
|
71
|
+
<Pagination
|
|
72
|
+
itemCount={itemCount}
|
|
73
|
+
perPage={perPage}
|
|
74
|
+
page={page}
|
|
75
|
+
onSetPage={onSetPage}
|
|
76
|
+
onPerPageSelect={onPerPageSelect}
|
|
77
|
+
variant="top"
|
|
78
|
+
isCompact
|
|
79
|
+
ouiaId="cve-scans-pagination"
|
|
80
|
+
/>
|
|
81
|
+
<Table
|
|
82
|
+
variant="compact"
|
|
83
|
+
aria-label="CVE scans history"
|
|
84
|
+
ouiaId="cve-scans-table"
|
|
85
|
+
>
|
|
86
|
+
<Thead>
|
|
87
|
+
<Tr ouiaId="cve-scans-header">
|
|
88
|
+
<Th>{__('Reported at')}</Th>
|
|
89
|
+
<Th>{__('Scanner')}</Th>
|
|
90
|
+
<Th>{__('Total')}</Th>
|
|
91
|
+
<Th>{__('Critical')}</Th>
|
|
92
|
+
<Th>{__('High')}</Th>
|
|
93
|
+
<Th>{__('Medium')}</Th>
|
|
94
|
+
<Th>{__('Low')}</Th>
|
|
95
|
+
</Tr>
|
|
96
|
+
</Thead>
|
|
97
|
+
<Tbody>
|
|
98
|
+
{scans.map((scan, index) => (
|
|
99
|
+
<Tr key={scan.id} ouiaId={`cve-scans-row-${index}`}>
|
|
100
|
+
<Td dataLabel={__('Reported at')}>
|
|
101
|
+
<Button
|
|
102
|
+
variant="link"
|
|
103
|
+
className="cve-summary-link"
|
|
104
|
+
onClick={() => openModal(scan.id, 'all')}
|
|
105
|
+
ouiaId={`cve-scans-open-${scan.id}`}
|
|
106
|
+
>
|
|
107
|
+
<RelativeDateTime
|
|
108
|
+
date={scan.created_at}
|
|
109
|
+
defaultValue={__('Unknown time')}
|
|
110
|
+
/>
|
|
111
|
+
</Button>
|
|
112
|
+
</Td>
|
|
113
|
+
<Td dataLabel={__('Scanner')}>{scan.scanner}</Td>
|
|
114
|
+
<Td dataLabel={__('Total')}>
|
|
115
|
+
<Button
|
|
116
|
+
variant="link"
|
|
117
|
+
className="cve-summary-link"
|
|
118
|
+
onClick={() => openModal(scan.id, 'all')}
|
|
119
|
+
ouiaId={`cve-scans-total-${scan.id}`}
|
|
120
|
+
>
|
|
121
|
+
{scan.total}
|
|
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
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</SkeletonLoader>
|
|
171
|
+
<CveFindingsModal
|
|
172
|
+
isOpen={isModalOpen}
|
|
173
|
+
onClose={() => setIsModalOpen(false)}
|
|
174
|
+
hostId={hostId}
|
|
175
|
+
scanId={modalScanId}
|
|
176
|
+
initialFilter={modalFilter}
|
|
177
|
+
/>
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
CveScansTab.propTypes = {
|
|
183
|
+
response: PropTypes.shape({
|
|
184
|
+
id: PropTypes.number,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
CveScansTab.defaultProps = {
|
|
189
|
+
response: undefined,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default CveScansTab;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import { Spinner } 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 { STATUS } from 'foremanReact/constants';
|
|
9
|
+
import SeverityIcon from './SeverityIcon';
|
|
10
|
+
import CveFindingsModal from './CveFindingsModal';
|
|
11
|
+
import './cve_scans.scss';
|
|
12
|
+
|
|
13
|
+
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
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
22
|
+
if (!hostId) return <span className="cve-summary-empty">--</span>;
|
|
23
|
+
if (status === STATUS.PENDING) {
|
|
24
|
+
return (
|
|
25
|
+
<Spinner
|
|
26
|
+
size="sm"
|
|
27
|
+
aria-label={__('Loading CVE findings')}
|
|
28
|
+
ouiaId="cve-summary-loading"
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!response) return <span className="cve-summary-empty">--</span>;
|
|
34
|
+
|
|
35
|
+
const total = response.total || 0;
|
|
36
|
+
const worst = response.summary?.worst || 'none';
|
|
37
|
+
const scanId = response.id;
|
|
38
|
+
const title = `${hostName || __('Host')}: ${total} ${__('findings')}`;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className="cve-summary cve-summary-link"
|
|
45
|
+
title={title}
|
|
46
|
+
onClick={() => setIsModalOpen(true)}
|
|
47
|
+
>
|
|
48
|
+
<SeverityIcon severity={worst} />
|
|
49
|
+
<span className="cve-summary-count">{total}</span>
|
|
50
|
+
</button>
|
|
51
|
+
<CveFindingsModal
|
|
52
|
+
isOpen={isModalOpen && !!scanId}
|
|
53
|
+
onClose={() => setIsModalOpen(false)}
|
|
54
|
+
hostId={hostId}
|
|
55
|
+
scanId={scanId}
|
|
56
|
+
initialFilter="all"
|
|
57
|
+
/>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
CveSummaryCell.propTypes = {
|
|
63
|
+
hostId: PropTypes.number,
|
|
64
|
+
hostName: PropTypes.string,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
CveSummaryCell.defaultProps = {
|
|
68
|
+
hostId: undefined,
|
|
69
|
+
hostName: undefined,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default CveSummaryCell;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import {
|
|
5
|
+
ExclamationCircleIcon,
|
|
6
|
+
ExclamationTriangleIcon,
|
|
7
|
+
InfoCircleIcon,
|
|
8
|
+
CheckCircleIcon,
|
|
9
|
+
} from '@patternfly/react-icons';
|
|
10
|
+
import { Icon } from '@patternfly/react-core';
|
|
11
|
+
import './cve_scans.scss';
|
|
12
|
+
|
|
13
|
+
const SeverityIcon = ({ severity }) => {
|
|
14
|
+
switch (severity) {
|
|
15
|
+
case 'critical':
|
|
16
|
+
return (
|
|
17
|
+
<Icon className="cve-severity cve-severity--critical">
|
|
18
|
+
<ExclamationCircleIcon />
|
|
19
|
+
</Icon>
|
|
20
|
+
);
|
|
21
|
+
case 'high':
|
|
22
|
+
return (
|
|
23
|
+
<Icon className="cve-severity cve-severity--high">
|
|
24
|
+
<ExclamationTriangleIcon />
|
|
25
|
+
</Icon>
|
|
26
|
+
);
|
|
27
|
+
case 'medium':
|
|
28
|
+
return (
|
|
29
|
+
<Icon className="cve-severity cve-severity--medium">
|
|
30
|
+
<InfoCircleIcon />
|
|
31
|
+
</Icon>
|
|
32
|
+
);
|
|
33
|
+
case 'low':
|
|
34
|
+
return (
|
|
35
|
+
<Icon className="cve-severity cve-severity--low">
|
|
36
|
+
<InfoCircleIcon />
|
|
37
|
+
</Icon>
|
|
38
|
+
);
|
|
39
|
+
default:
|
|
40
|
+
return (
|
|
41
|
+
<Icon className="cve-severity cve-severity--none">
|
|
42
|
+
<CheckCircleIcon />
|
|
43
|
+
</Icon>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
SeverityIcon.propTypes = {
|
|
49
|
+
severity: PropTypes.string,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
SeverityIcon.defaultProps = {
|
|
53
|
+
severity: 'none',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default SeverityIcon;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import CveDetailsCard from '../CveDetailsCard';
|
|
5
|
+
|
|
6
|
+
jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
|
7
|
+
useAPI: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('foremanReact/components/HostDetails/Templates/CardItem/CardTemplate', () => (
|
|
11
|
+
{ children }
|
|
12
|
+
) => <div>{children}</div>);
|
|
13
|
+
|
|
14
|
+
jest.mock('foremanReact/components/common/SkeletonLoader', () => (
|
|
15
|
+
{ children }
|
|
16
|
+
) => <div>{children}</div>);
|
|
17
|
+
|
|
18
|
+
jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
|
|
19
|
+
{ date, defaultValue }
|
|
20
|
+
) => <span>{date || defaultValue}</span>);
|
|
21
|
+
|
|
22
|
+
jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
|
|
23
|
+
|
|
24
|
+
const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
|
|
25
|
+
|
|
26
|
+
describe('CveDetailsCard', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
useAPI.mockReset();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders recent scans and findings', () => {
|
|
32
|
+
const hostDetails = { id: 1 };
|
|
33
|
+
const historyResponse = {
|
|
34
|
+
results: [
|
|
35
|
+
{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 },
|
|
36
|
+
{ id: 2, created_at: '2026-02-21', scanner: 'trivy', total: 12 },
|
|
37
|
+
{ id: 3, created_at: '2026-02-22', scanner: 'grype', total: 8 },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
const latestResponse = {
|
|
41
|
+
id: 3,
|
|
42
|
+
created_at: '2026-02-22',
|
|
43
|
+
scanner: 'grype',
|
|
44
|
+
total: 8,
|
|
45
|
+
summary: { worst: 'high' },
|
|
46
|
+
critical: 1,
|
|
47
|
+
high: 1,
|
|
48
|
+
medium: 2,
|
|
49
|
+
low: 4,
|
|
50
|
+
findings: [
|
|
51
|
+
{
|
|
52
|
+
id: 'CVE-1',
|
|
53
|
+
name: 'pkg',
|
|
54
|
+
version: '1.0',
|
|
55
|
+
severity: 'HIGH',
|
|
56
|
+
published: '2026-02-10',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
useAPI.mockImplementation((_method, url) => {
|
|
62
|
+
if (url && url.includes('/latest')) {
|
|
63
|
+
return { response: latestResponse, status: 'RESOLVED' };
|
|
64
|
+
}
|
|
65
|
+
return { response: historyResponse, status: 'RESOLVED' };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
69
|
+
|
|
70
|
+
expect(wrapper.text()).toContain('Recent scans');
|
|
71
|
+
expect(wrapper.find('table').length).toBeGreaterThan(0);
|
|
72
|
+
expect(wrapper.text()).toContain('CVEs');
|
|
73
|
+
expect(wrapper.text()).toContain('pkg');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('falls back to history when latest is empty', () => {
|
|
77
|
+
const hostDetails = { id: 1 };
|
|
78
|
+
const historyResponse = {
|
|
79
|
+
results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
useAPI.mockImplementation((_method, url) => {
|
|
83
|
+
if (url && url.includes('/latest')) {
|
|
84
|
+
return { response: {}, status: 'RESOLVED' };
|
|
85
|
+
}
|
|
86
|
+
return { response: historyResponse, status: 'RESOLVED' };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
90
|
+
expect(wrapper.text()).toContain('Report');
|
|
91
|
+
expect(wrapper.text()).toContain('trivy');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('prefers latest when present', () => {
|
|
95
|
+
const hostDetails = { id: 1 };
|
|
96
|
+
const historyResponse = {
|
|
97
|
+
results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
|
|
98
|
+
};
|
|
99
|
+
const latestResponse = {
|
|
100
|
+
id: 2,
|
|
101
|
+
created_at: '2026-02-21',
|
|
102
|
+
scanner: 'grype',
|
|
103
|
+
total: 8,
|
|
104
|
+
summary: { worst: 'high' },
|
|
105
|
+
findings: [],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
useAPI.mockImplementation((_method, url) => {
|
|
109
|
+
if (url && url.includes('/latest')) {
|
|
110
|
+
return { response: latestResponse, status: 'RESOLVED' };
|
|
111
|
+
}
|
|
112
|
+
return { response: historyResponse, status: 'RESOLVED' };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
116
|
+
expect(wrapper.text()).toContain('grype');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('renders empty state when no scans', () => {
|
|
120
|
+
const hostDetails = { id: 1 };
|
|
121
|
+
useAPI.mockReturnValue({ response: null, status: 'RESOLVED' });
|
|
122
|
+
|
|
123
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
124
|
+
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import CveFindingsModal from '../CveFindingsModal';
|
|
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('CveFindingsModal', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
useAPI.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('filters findings by severity', () => {
|
|
18
|
+
useAPI.mockReturnValue({
|
|
19
|
+
response: {
|
|
20
|
+
id: 1,
|
|
21
|
+
created_at: '2026-02-22T10:00:00Z',
|
|
22
|
+
total: 2,
|
|
23
|
+
findings: [
|
|
24
|
+
{ id: 'CVE-1', severity: 'HIGH', name: 'a', version: '1' },
|
|
25
|
+
{ id: 'CVE-2', severity: 'LOW', name: 'b', version: '2' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
status: 'RESOLVED',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const wrapper = mount(
|
|
32
|
+
<CveFindingsModal
|
|
33
|
+
isOpen
|
|
34
|
+
onClose={() => {}}
|
|
35
|
+
hostId={1}
|
|
36
|
+
scanId={1}
|
|
37
|
+
initialFilter="all"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(wrapper.text()).toContain('CVE-1');
|
|
42
|
+
expect(wrapper.text()).toContain('CVE-2');
|
|
43
|
+
|
|
44
|
+
const lowButton = wrapper
|
|
45
|
+
.find('button')
|
|
46
|
+
.filterWhere(node => node.text().toLowerCase() === 'low')
|
|
47
|
+
.first();
|
|
48
|
+
lowButton.simulate('click');
|
|
49
|
+
wrapper.update();
|
|
50
|
+
|
|
51
|
+
expect(wrapper.text()).toContain('CVE-2');
|
|
52
|
+
expect(wrapper.text()).not.toContain('CVE-1');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shows empty state when no findings', () => {
|
|
56
|
+
useAPI.mockReturnValue({
|
|
57
|
+
response: {
|
|
58
|
+
id: 1,
|
|
59
|
+
created_at: '2026-02-22T10:00:00Z',
|
|
60
|
+
total: 0,
|
|
61
|
+
findings: [],
|
|
62
|
+
},
|
|
63
|
+
status: 'RESOLVED',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const wrapper = mount(
|
|
67
|
+
<CveFindingsModal
|
|
68
|
+
isOpen
|
|
69
|
+
onClose={() => {}}
|
|
70
|
+
hostId={1}
|
|
71
|
+
scanId={1}
|
|
72
|
+
initialFilter="all"
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(wrapper.text()).toContain('No findings for selected filter');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import CveScansTab from '../CveScansTab';
|
|
5
|
+
|
|
6
|
+
jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
|
7
|
+
useAPI: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('foremanReact/components/common/SkeletonLoader', () => (
|
|
11
|
+
{ children }
|
|
12
|
+
) => <div>{children}</div>);
|
|
13
|
+
|
|
14
|
+
jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
|
|
15
|
+
{ date, defaultValue }
|
|
16
|
+
) => <span>{date || defaultValue}</span>);
|
|
17
|
+
|
|
18
|
+
jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
|
|
19
|
+
|
|
20
|
+
const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
|
|
21
|
+
|
|
22
|
+
describe('CveScansTab', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
useAPI.mockReset();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders rows for scans', () => {
|
|
28
|
+
const scansResponse = {
|
|
29
|
+
results: [
|
|
30
|
+
{
|
|
31
|
+
id: 1,
|
|
32
|
+
created_at: '2026-02-20',
|
|
33
|
+
scanner: 'trivy',
|
|
34
|
+
total: 10,
|
|
35
|
+
critical: 1,
|
|
36
|
+
high: 2,
|
|
37
|
+
medium: 3,
|
|
38
|
+
low: 4,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 2,
|
|
42
|
+
created_at: '2026-02-21',
|
|
43
|
+
scanner: 'grype',
|
|
44
|
+
total: 5,
|
|
45
|
+
critical: 0,
|
|
46
|
+
high: 1,
|
|
47
|
+
medium: 1,
|
|
48
|
+
low: 3,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
total: 2,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
|
|
55
|
+
|
|
56
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
57
|
+
|
|
58
|
+
expect(wrapper.text()).toContain('Reported at');
|
|
59
|
+
expect(wrapper.text()).toContain('trivy');
|
|
60
|
+
expect(wrapper.text()).toContain('grype');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders empty state with no scans', () => {
|
|
64
|
+
useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
|
|
65
|
+
|
|
66
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
67
|
+
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not render when host id is missing', () => {
|
|
71
|
+
useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
|
|
72
|
+
|
|
73
|
+
const wrapper = mount(<CveScansTab response={{}} />);
|
|
74
|
+
expect(wrapper.isEmptyRender()).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import {
|
|
3
|
+
severityRank,
|
|
4
|
+
riskLevelFromWorst,
|
|
5
|
+
formatDateTime,
|
|
6
|
+
compareStrings,
|
|
7
|
+
} from '../cve_helpers';
|
|
8
|
+
|
|
9
|
+
describe('cve_helpers', () => {
|
|
10
|
+
it('maps severity ranks', () => {
|
|
11
|
+
expect(severityRank('CRITICAL')).toBe(4);
|
|
12
|
+
expect(severityRank('high')).toBe(3);
|
|
13
|
+
expect(severityRank('MEDIUM')).toBe(2);
|
|
14
|
+
expect(severityRank('low')).toBe(1);
|
|
15
|
+
expect(severityRank('unknown')).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('maps risk levels from worst', () => {
|
|
19
|
+
expect(riskLevelFromWorst('critical')).toBe('high');
|
|
20
|
+
expect(riskLevelFromWorst('high')).toBe('high');
|
|
21
|
+
expect(riskLevelFromWorst('medium')).toBe('medium');
|
|
22
|
+
expect(riskLevelFromWorst('low')).toBe('low');
|
|
23
|
+
expect(riskLevelFromWorst('none')).toBe('none');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('formats dates into yyyy-mm-dd hh:mm', () => {
|
|
27
|
+
const result = formatDateTime('2026-02-22T10:05:00Z');
|
|
28
|
+
expect(result).toContain('2026-02-22');
|
|
29
|
+
expect(result).toContain('10:05');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns input when date is invalid', () => {
|
|
33
|
+
expect(formatDateTime('not-a-date')).toBe('not-a-date');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('compares strings safely', () => {
|
|
37
|
+
expect(compareStrings('a', 'b')).toBeLessThan(0);
|
|
38
|
+
expect(compareStrings('b', 'a')).toBeGreaterThan(0);
|
|
39
|
+
expect(compareStrings('a', 'a')).toBe(0);
|
|
40
|
+
expect(compareStrings(null, 'a')).toBeLessThan(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
3
|
+
|
|
4
|
+
const SEVERITY_RANK = {
|
|
5
|
+
CRITICAL: 4,
|
|
6
|
+
HIGH: 3,
|
|
7
|
+
MEDIUM: 2,
|
|
8
|
+
LOW: 1,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const severityRank = sev =>
|
|
12
|
+
SEVERITY_RANK[(sev || '').toUpperCase()] || 0;
|
|
13
|
+
|
|
14
|
+
export const riskLevelFromWorst = worst => {
|
|
15
|
+
if (['critical', 'high'].includes(worst)) return 'high';
|
|
16
|
+
if (worst === 'medium') return 'medium';
|
|
17
|
+
if (worst === 'low') return 'low';
|
|
18
|
+
return 'none';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const formatDateTime = value => {
|
|
22
|
+
if (!value) return '';
|
|
23
|
+
const d = new Date(value);
|
|
24
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
25
|
+
const pad = n => String(n).padStart(2, '0');
|
|
26
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
|
|
27
|
+
d.getHours()
|
|
28
|
+
)}:${pad(d.getMinutes())}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const compareStrings = (a, b) =>
|
|
32
|
+
(a || '').toString().localeCompare((b || '').toString());
|
|
33
|
+
|
|
34
|
+
export const noReportsTitle = () => __('No CVE reports for this host');
|
|
35
|
+
export const noReportsBody = () => __('Run a CVE scan to see results here.');
|