foreman_cve_scanner 0.5.0 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -5
  3. data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
  4. data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
  5. data/app/models/host_status/cve_status.rb +3 -1
  6. data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
  7. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +5 -8
  8. data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
  9. data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
  10. data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
  11. data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
  12. data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
  13. data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +22 -9
  14. data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
  15. data/config/routes.rb +6 -1
  16. data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
  17. data/lib/foreman_cve_scanner/engine.rb +68 -17
  18. data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
  19. data/lib/foreman_cve_scanner/version.rb +1 -1
  20. data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
  21. data/package.json +1 -1
  22. data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
  23. data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
  24. data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
  25. data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
  26. data/test/models/host_status/cve_status_test.rb +12 -3
  27. data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
  28. data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
  29. data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
  30. data/webpack/components/CveCompareModal.js +298 -0
  31. data/webpack/components/CveDetailsCard.js +141 -121
  32. data/webpack/components/CveFindingsModal.js +131 -111
  33. data/webpack/components/CveScansReports.js +227 -0
  34. data/webpack/components/CveScansTab.js +122 -119
  35. data/webpack/components/CveTrendChart.js +264 -0
  36. data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
  37. data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
  38. data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
  39. data/webpack/components/__tests__/CveScansTab.test.js +185 -5
  40. data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
  41. data/webpack/components/__tests__/cve_helpers.test.js +18 -0
  42. data/webpack/components/cve_helpers.js +139 -0
  43. data/webpack/components/cve_scans.scss +464 -9
  44. data/webpack/components/useModalScan.js +26 -0
  45. metadata +24 -3
  46. data/webpack/components/CveHistoryTable.js +0 -58
  47. data/webpack/components/CveOverviewCard.js +0 -67
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable import/no-unresolved */
2
- import React, { useState } from 'react';
2
+ import React from 'react';
3
3
  import PropTypes from 'prop-types';
4
4
  import { Table, Tbody, Tr, Th, Thead, Td } from '@patternfly/react-table';
5
5
  import {
@@ -16,7 +16,7 @@ import {
16
16
  EmptyStateBody,
17
17
  Title,
18
18
  } from '@patternfly/react-core';
19
- import { SearchIcon } from '@patternfly/react-icons';
19
+ import { CheckCircleIcon, SearchIcon } from '@patternfly/react-icons';
20
20
  import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
21
21
  import { foremanUrl } from 'foremanReact/common/helpers';
22
22
  import { translate as __ } from 'foremanReact/common/I18n';
@@ -26,56 +26,54 @@ import { STATUS } from 'foremanReact/constants';
26
26
  import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
27
27
  import SeverityIcon from './SeverityIcon';
28
28
  import CveFindingsModal from './CveFindingsModal';
29
- import CveHistoryTable from './CveHistoryTable';
29
+ import useModalScan from './useModalScan';
30
30
  import {
31
+ noFindingsBody,
32
+ noFindingsTitle,
33
+ formatScanOrigin,
31
34
  noReportsBody,
32
35
  noReportsTitle,
33
36
  riskLevelFromWorst,
37
+ severityRank,
34
38
  } from './cve_helpers';
35
39
  import './cve_scans.scss';
36
40
 
37
41
  const CveDetailsCard = ({ hostDetails }) => {
38
42
  const hostId = hostDetails?.id;
39
- const historyUrl = hostId
40
- ? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans?per_page=3`)
41
- : null;
42
43
  const latestUrl = hostId
43
44
  ? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
44
45
  : null;
45
- const { response: historyResponse, status } = useAPI('get', historyUrl, {
46
- key: `CVE_DETAILS_${hostId}`,
47
- });
48
46
  const { response: latestResponse, status: latestStatus } = useAPI(
49
47
  'get',
50
48
  latestUrl,
51
49
  { key: `CVE_DETAILS_LATEST_${hostId}` }
52
50
  );
53
- const [isModalOpen, setIsModalOpen] = useState(false);
54
- const [modalScanId, setModalScanId] = useState(null);
55
- const [modalFilter, setModalFilter] = useState('all');
51
+ const { isOpen, scanId, filter, openModal, closeModal } = useModalScan();
56
52
  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;
53
+ const latest = latestResponse?.id ? latestResponse : null;
63
54
  const findings = latest?.findings || [];
64
55
  const sortedFindings = [...findings].sort((a, b) => {
56
+ const severityDiff = severityRank(b.severity) - severityRank(a.severity);
57
+ if (severityDiff !== 0) return severityDiff;
65
58
  const aTime = new Date(a.published || 0).getTime();
66
59
  const bTime = new Date(b.published || 0).getTime();
67
60
  return bTime - aTime;
68
61
  });
62
+ // The host details preview should show ranked CVEs first and only fall back
63
+ // to unknown severities when a scan has no ranked findings at all.
64
+ const previewFindings = sortedFindings.filter(
65
+ finding => severityRank(finding.severity) > 0
66
+ );
67
+ const visibleFindings =
68
+ previewFindings.length > 0
69
+ ? previewFindings.slice(0, 5)
70
+ : sortedFindings.slice(0, 5);
69
71
  const worst = latest?.summary?.worst || 'none';
70
72
  const riskLevel = riskLevelFromWorst(worst);
71
- const openModal = (scanId, filter) => {
72
- setModalScanId(scanId);
73
- setModalFilter(filter || 'all');
74
- setIsModalOpen(true);
75
- };
73
+ const origin = formatScanOrigin(latest?.scanner, latest?.source);
76
74
  return (
77
75
  <CardTemplate header={__('CVE scan details')} expandable masonryLayout>
78
- <SkeletonLoader status={status || latestStatus || STATUS.PENDING}>
76
+ <SkeletonLoader status={latestStatus || STATUS.PENDING}>
79
77
  {!latest ? (
80
78
  <EmptyState>
81
79
  <EmptyStateIcon icon={SearchIcon} />
@@ -86,86 +84,122 @@ const CveDetailsCard = ({ hostDetails }) => {
86
84
  </EmptyState>
87
85
  ) : (
88
86
  <>
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"
87
+ <div className="cve-overview">
88
+ <DescriptionList
89
+ isCompact
90
+ isHorizontal
91
+ className="cve-overview-meta"
157
92
  >
158
- <span className="cve-count">
159
- {__('low')}
160
- <span className="cve-bubble">{latest.low}</span>
161
- </span>
162
- </Button>
93
+ <DescriptionListGroup>
94
+ <DescriptionListTerm>{__('Report')}</DescriptionListTerm>
95
+ <DescriptionListDescription>
96
+ <RelativeDateTime
97
+ date={latest.scanned_at}
98
+ defaultValue={__('Unknown time')}
99
+ />
100
+ </DescriptionListDescription>
101
+ </DescriptionListGroup>
102
+ <DescriptionListGroup>
103
+ <DescriptionListTerm>{__('Total')}</DescriptionListTerm>
104
+ <DescriptionListDescription>
105
+ <span className="cve-overview-total">
106
+ <Button
107
+ variant="link"
108
+ className="cve-summary-link"
109
+ onClick={() => openModal(latest.id, 'all')}
110
+ ouiaId="cve-details-total-button"
111
+ >
112
+ <span
113
+ className={`cve-total-bubble cve-total-bubble--${riskLevel}`}
114
+ >
115
+ {latest.total}
116
+ </span>
117
+ </Button>
118
+ <span className="cve-overview-source">{origin}</span>
119
+ </span>
120
+ </DescriptionListDescription>
121
+ </DescriptionListGroup>
122
+ </DescriptionList>
123
+ <div className="cve-counts cve-counts--compact">
124
+ <Button
125
+ variant="plain"
126
+ className="cve-count-card cve-count-card--critical"
127
+ onClick={() => openModal(latest.id, 'critical')}
128
+ ouiaId="cve-details-critical-button"
129
+ >
130
+ <span className="cve-count">
131
+ <span className="cve-count-label">
132
+ <SeverityIcon severity="critical" />
133
+ <span>{__('critical')}</span>
134
+ </span>
135
+ <span className="cve-bubble cve-bubble--critical">
136
+ {latest.critical}
137
+ </span>
138
+ </span>
139
+ </Button>
140
+ <Button
141
+ variant="plain"
142
+ className="cve-count-card cve-count-card--medium"
143
+ onClick={() => openModal(latest.id, 'medium')}
144
+ ouiaId="cve-details-medium-button"
145
+ >
146
+ <span className="cve-count">
147
+ <span className="cve-count-label">
148
+ <SeverityIcon severity="medium" />
149
+ <span>{__('medium')}</span>
150
+ </span>
151
+ <span className="cve-bubble cve-bubble--medium">
152
+ {latest.medium}
153
+ </span>
154
+ </span>
155
+ </Button>
156
+ <Button
157
+ variant="plain"
158
+ className="cve-count-card cve-count-card--high"
159
+ onClick={() => openModal(latest.id, 'high')}
160
+ ouiaId="cve-details-high-button"
161
+ >
162
+ <span className="cve-count">
163
+ <span className="cve-count-label">
164
+ <SeverityIcon severity="high" />
165
+ <span>{__('high')}</span>
166
+ </span>
167
+ <span className="cve-bubble cve-bubble--high">
168
+ {latest.high}
169
+ </span>
170
+ </span>
171
+ </Button>
172
+ <Button
173
+ variant="plain"
174
+ className="cve-count-card cve-count-card--low"
175
+ onClick={() => openModal(latest.id, 'low')}
176
+ ouiaId="cve-details-low-button"
177
+ >
178
+ <span className="cve-count">
179
+ <span className="cve-count-label">
180
+ <SeverityIcon severity="low" />
181
+ <span>{__('low')}</span>
182
+ </span>
183
+ <span className="cve-bubble cve-bubble--low">
184
+ {latest.low}
185
+ </span>
186
+ </span>
187
+ </Button>
188
+ </div>
163
189
  </div>
164
190
 
165
- {sortedFindings.length === 0 ? (
166
- <Text component={TextVariants.small} ouiaId="cve-details-empty">
167
- {__('No vulnerabilities reported')}
168
- </Text>
191
+ {visibleFindings.length === 0 ? (
192
+ <EmptyState className="cve-empty-state cve-empty-state--success">
193
+ <EmptyStateIcon icon={CheckCircleIcon} />
194
+ <Title
195
+ headingLevel="h4"
196
+ size="md"
197
+ ouiaId="cve-details-clean-title"
198
+ >
199
+ {noFindingsTitle()}
200
+ </Title>
201
+ <EmptyStateBody>{noFindingsBody()}</EmptyStateBody>
202
+ </EmptyState>
169
203
  ) : (
170
204
  <>
171
205
  <TextContent className="cve-section-title">
@@ -189,7 +223,7 @@ const CveDetailsCard = ({ hostDetails }) => {
189
223
  </Tr>
190
224
  </Thead>
191
225
  <Tbody>
192
- {sortedFindings.slice(0, 5).map((finding, index) => (
226
+ {visibleFindings.map((finding, index) => (
193
227
  <Tr
194
228
  key={finding.id}
195
229
  ouiaId={`cve-details-finding-row-${index}`}
@@ -208,7 +242,7 @@ const CveDetailsCard = ({ hostDetails }) => {
208
242
  ))}
209
243
  </Tbody>
210
244
  </Table>
211
- {sortedFindings.length > 5 && (
245
+ {sortedFindings.length > visibleFindings.length && (
212
246
  <div className="cve-more">
213
247
  <Button
214
248
  variant="link"
@@ -222,29 +256,15 @@ const CveDetailsCard = ({ hostDetails }) => {
222
256
  )}
223
257
  </>
224
258
  )}
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
259
  </>
240
260
  )}
241
261
  </SkeletonLoader>
242
262
  <CveFindingsModal
243
- isOpen={isModalOpen}
244
- onClose={() => setIsModalOpen(false)}
263
+ isOpen={isOpen}
264
+ onClose={closeModal}
245
265
  hostId={hostId}
246
- scanId={modalScanId}
247
- initialFilter={modalFilter}
266
+ scanId={scanId}
267
+ initialFilter={filter}
248
268
  />
249
269
  </CardTemplate>
250
270
  );
@@ -8,6 +8,7 @@ import {
8
8
  Text,
9
9
  TextVariants,
10
10
  FormGroup,
11
+ SearchInput,
11
12
  } from '@patternfly/react-core';
12
13
  import {
13
14
  Table,
@@ -23,7 +24,14 @@ import { foremanUrl } from 'foremanReact/common/helpers';
23
24
  import { translate as __ } from 'foremanReact/common/I18n';
24
25
  import { STATUS } from 'foremanReact/constants';
25
26
  import SeverityIcon from './SeverityIcon';
26
- import { compareStrings, formatDateTime, severityRank } from './cve_helpers';
27
+ import {
28
+ findingMatchesSearch,
29
+ formatDateTime,
30
+ formatScanOrigin,
31
+ formatScannedAt,
32
+ findingSorters,
33
+ normalizeSearchInputValue,
34
+ } from './cve_helpers';
27
35
  import './cve_scans.scss';
28
36
 
29
37
  const SEVERITY_OPTIONS = ['all', 'critical', 'high', 'medium', 'low'];
@@ -46,6 +54,7 @@ const CveFindingsModal = ({
46
54
  initialFilter,
47
55
  }) => {
48
56
  const [filter, setFilter] = useState(initialFilter || 'all');
57
+ const [search, setSearch] = useState('');
49
58
  const [sortBy, setSortBy] = useState({
50
59
  direction: SortByDirection.desc,
51
60
  column: 'severity',
@@ -65,10 +74,12 @@ const CveFindingsModal = ({
65
74
  useEffect(() => {
66
75
  if (!isOpen) return;
67
76
  setFilter(initialFilter || 'all');
77
+ setSearch('');
68
78
  }, [initialFilter, isOpen, scanId]);
69
79
 
70
80
  const payload = response || {};
71
81
  const findings = Array.isArray(payload.findings) ? payload.findings : [];
82
+ const origin = formatScanOrigin(payload.scanner, payload.source);
72
83
 
73
84
  const normalizedFilter = (filter || 'all').toLowerCase();
74
85
  const filteredFindings = useMemo(() => {
@@ -77,33 +88,23 @@ const CveFindingsModal = ({
77
88
  f => (f.severity || '').toLowerCase() === normalizedFilter
78
89
  );
79
90
  }, [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
- );
91
+ const normalizedSearch = search.trim().toLowerCase();
92
+ const visibleFindings = useMemo(() => {
93
+ if (!normalizedSearch) return filteredFindings;
94
+ return filteredFindings.filter(finding =>
95
+ findingMatchesSearch(finding, normalizedSearch)
96
+ );
97
+ }, [filteredFindings, normalizedSearch]);
97
98
  const sortedFindings = useMemo(() => {
98
- const list = [...filteredFindings];
99
+ const list = [...visibleFindings];
99
100
  const sortKey = sortBy.column || 'severity';
100
- const sorter = sorters[sortKey] || sorters.severity;
101
+ const sorter = findingSorters[sortKey] || findingSorters.severity;
101
102
  list.sort((a, b) => {
102
103
  const result = sorter(a, b);
103
104
  return sortBy.direction === SortByDirection.asc ? result : -result;
104
105
  });
105
106
  return list;
106
- }, [filteredFindings, sortBy, sorters]);
107
+ }, [visibleFindings, sortBy]);
107
108
 
108
109
  const onSort = column => {
109
110
  const nextDirection =
@@ -112,14 +113,17 @@ const CveFindingsModal = ({
112
113
  : SortByDirection.asc;
113
114
  setSortBy({ column, direction: nextDirection });
114
115
  };
116
+ const onSearchChange = (value, event) => {
117
+ setSearch(normalizeSearchInputValue(value, event));
118
+ };
115
119
 
116
120
  return (
117
121
  <Modal
118
122
  title={
119
- payload?.created_at && typeof payload?.total !== 'undefined'
120
- ? `${__('Report from')} ${formatPublished(payload.created_at)} - ${__(
121
- 'Total'
122
- )}: ${payload.total}`
123
+ payload?.scanned_at && typeof payload?.total !== 'undefined'
124
+ ? `${__('Report from')} ${formatScannedAt(
125
+ payload.scanned_at
126
+ )} - ${origin} - ${__('Total')}: ${payload.total}`
123
127
  : __('CVE findings')
124
128
  }
125
129
  isOpen={isOpen}
@@ -145,7 +149,18 @@ const CveFindingsModal = ({
145
149
  ) : (
146
150
  <div className="cve-modal-body">
147
151
  <div className="cve-modal-filters">
148
- <FormGroup fieldId="cve-filter">
152
+ <FormGroup fieldId="cve-search" className="cve-modal-search">
153
+ <SearchInput
154
+ id="cve-search"
155
+ value={search}
156
+ onChange={onSearchChange}
157
+ onClear={() => setSearch('')}
158
+ onSearch={onSearchChange}
159
+ placeholder={__('Search CVE, package, status, title...')}
160
+ aria-label={__('Search CVE report')}
161
+ />
162
+ </FormGroup>
163
+ <FormGroup fieldId="cve-filter" className="cve-modal-quick-filters">
149
164
  <div className="cve-filter-buttons">
150
165
  {SEVERITY_OPTIONS.map(opt => (
151
166
  <button
@@ -166,98 +181,103 @@ const CveFindingsModal = ({
166
181
  </FormGroup>
167
182
  </div>
168
183
 
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}
184
+ <div className="cve-modal-results">
185
+ {response?.error && (
186
+ <Text component={TextVariants.small} ouiaId="cve-findings-error">
187
+ {response.error.message}
188
+ </Text>
189
+ )}
190
+ {sortedFindings.length === 0 && !response?.error ? (
191
+ <Text component={TextVariants.small} ouiaId="cve-findings-empty">
192
+ {__('No findings for selected filter')}
193
+ </Text>
194
+ ) : (
195
+ <Table
196
+ variant="compact"
197
+ aria-label="CVE findings table"
198
+ ouiaId="cve-findings-table"
199
+ >
200
+ <Thead>
201
+ <Tr ouiaId="cve-findings-header">
202
+ {COLUMNS.map(col => (
203
+ <Th
204
+ key={col.key}
205
+ className={`cve-col-${col.key} cve-sortable`}
206
+ aria-label={
207
+ col.key === 'severity' ? __('Severity') : undefined
208
+ }
209
+ onClick={() => onSort(col.key)}
219
210
  >
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>
211
+ {col.key === 'severity' ? (
212
+ <SeverityIcon severity="high" />
213
+ ) : (
214
+ col.label
215
+ )}
216
+ {sortBy.column === col.key && (
217
+ <span className="cve-sort-indicator">
218
+ {sortBy.direction === SortByDirection.asc
219
+ ? ''
220
+ : ' '}
221
+ </span>
222
+ )}
223
+ </Th>
224
+ ))}
250
225
  </Tr>
251
- ))}
252
- </Tbody>
253
- </Table>
254
- )}
226
+ </Thead>
227
+ <Tbody>
228
+ {sortedFindings.map((finding, index) => (
229
+ <Tr key={finding.id} ouiaId={`cve-findings-row-${index}`}>
230
+ <Td dataLabel={__('Severity')}>
231
+ <span
232
+ className="cve-summary cve-summary--icon-only"
233
+ title={finding.severity}
234
+ aria-label={finding.severity}
235
+ >
236
+ <SeverityIcon
237
+ severity={(finding.severity || '').toLowerCase()}
238
+ />
239
+ </span>
240
+ </Td>
241
+ <Td dataLabel={__('Published')}>
242
+ {formatDateTime(finding.published)}
243
+ </Td>
244
+ <Td dataLabel={__('Package')}>{finding.name}</Td>
245
+ <Td dataLabel={__('Affected version')}>
246
+ {finding.version}
247
+ </Td>
248
+ <Td dataLabel={__('Fixed version')} title={finding.fixed}>
249
+ <span className="cve-truncate">{finding.fixed}</span>
250
+ </Td>
251
+ <Td dataLabel={__('Status')}>
252
+ {finding.status || __('open')}
253
+ </Td>
254
+ <Td dataLabel={__('CVE')}>
255
+ {finding.url ? (
256
+ <a
257
+ href={finding.url}
258
+ target="_blank"
259
+ rel="noreferrer"
260
+ >
261
+ {finding.id}
262
+ </a>
263
+ ) : (
264
+ finding.id
265
+ )}
266
+ </Td>
267
+ <Td dataLabel={__('Title')} title={finding.title}>
268
+ <span className="cve-truncate">{finding.title}</span>
269
+ </Td>
270
+ </Tr>
271
+ ))}
272
+ </Tbody>
273
+ </Table>
274
+ )}
275
+ </div>
255
276
  </div>
256
277
  )}
257
278
  </Modal>
258
279
  );
259
280
  };
260
-
261
281
  CveFindingsModal.propTypes = {
262
282
  isOpen: PropTypes.bool.isRequired,
263
283
  onClose: PropTypes.func.isRequired,