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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module ForemanCveScanner
6
+ class ScanComparisonTest < ActiveSupport::TestCase
7
+ def setup
8
+ @host = FactoryBot.create(:host)
9
+ @first_scan = create_scan(
10
+ scanned_at: 2.hours.ago,
11
+ findings: first_findings
12
+ )
13
+ @second_scan = create_scan(
14
+ scanned_at: 1.hour.ago,
15
+ findings: second_findings
16
+ )
17
+ end
18
+
19
+ test 'compare builds summary and result rows' do
20
+ comparison = ScanComparison.compare(@first_scan, @second_scan)
21
+ statuses = comparison[:results].to_h { |row| [row[:id], row] }
22
+
23
+ assert_equal 1, comparison[:summary]['updated']
24
+ assert_equal 1, comparison[:summary]['resolved']
25
+ assert_equal 1, comparison[:summary]['new']
26
+ assert_equal 'updated', statuses['CVE-1'][:status]
27
+ assert_equal(
28
+ { old: 'HIGH', new: 'CRITICAL' },
29
+ statuses['CVE-1'][:diff]['severity']
30
+ )
31
+ end
32
+
33
+ test 'compare includes previous and current scan metadata' do
34
+ comparison = ScanComparison.compare(@first_scan, @second_scan)
35
+
36
+ assert_equal @first_scan.id, comparison[:previous][:id]
37
+ assert_equal @second_scan.id, comparison[:current][:id]
38
+ assert_equal @first_scan.scanner, comparison[:previous][:scanner]
39
+ assert_equal @second_scan.scanner, comparison[:current][:scanner]
40
+ end
41
+
42
+ private
43
+
44
+ def finding(id, name, severity, version)
45
+ {
46
+ 'id' => id,
47
+ 'name' => name,
48
+ 'severity' => severity,
49
+ 'version' => version,
50
+ }
51
+ end
52
+
53
+ def first_findings
54
+ [
55
+ finding('CVE-1', 'openssl', 'HIGH', '1.0'),
56
+ finding('CVE-2', 'curl', 'LOW', '1.0'),
57
+ ]
58
+ end
59
+
60
+ def second_findings
61
+ [
62
+ finding('CVE-1', 'openssl', 'CRITICAL', '1.0'),
63
+ finding('CVE-3', 'glibc', 'HIGH', '1.0'),
64
+ ]
65
+ end
66
+
67
+ def create_scan(scanned_at:, findings:)
68
+ ForemanCveScanner::CveScan.create!(
69
+ host: @host,
70
+ scanner: 'trivy',
71
+ source: 'rex',
72
+ scanned_at: scanned_at,
73
+ raw: { 'dummy' => true },
74
+ summary: { 'worst' => 'high' },
75
+ findings: findings,
76
+ total: findings.size,
77
+ critical: findings.count { |finding| finding['severity'] == 'CRITICAL' },
78
+ high: findings.count { |finding| finding['severity'] == 'HIGH' },
79
+ medium: findings.count { |finding| finding['severity'] == 'MEDIUM' },
80
+ low: findings.count { |finding| finding['severity'] == 'LOW' }
81
+ )
82
+ end
83
+ end
84
+ end
@@ -6,6 +6,11 @@ module ForemanCveScanner
6
6
  class ScanImporterTest < ActiveSupport::TestCase
7
7
  def setup
8
8
  @host = FactoryBot.create(:host)
9
+ @previous_retention = Setting[:cve_scan_delete_after_days]
10
+ end
11
+
12
+ def teardown
13
+ Setting[:cve_scan_delete_after_days] = @previous_retention
9
14
  end
10
15
 
11
16
  test 'import_for_host! persists scan from trivy output' do
@@ -19,6 +24,7 @@ module ForemanCveScanner
19
24
  assert_not_nil scan
20
25
  assert_equal @host.id, scan.host_id
21
26
  assert_equal 'trivy', scan.scanner
27
+ assert_equal 'rex', scan.source
22
28
  end
23
29
 
24
30
  test 'import_for_host! sets totals and findings for trivy output' do
@@ -29,6 +35,7 @@ module ForemanCveScanner
29
35
 
30
36
  assert_operator scan.total, :>, 0
31
37
  assert_equal scan.total, scan.findings.count
38
+ assert_not_nil scan.scanned_at
32
39
  end
33
40
 
34
41
  test 'import_for_host! persists scan from proxy output' do
@@ -51,20 +58,76 @@ module ForemanCveScanner
51
58
  assert_operator scan.total, :>, 0
52
59
  end
53
60
 
54
- test 'import_for_host! returns nil when no json markers' do
61
+ test 'import_for_host! raises when no json markers' do
55
62
  importer = ForemanCveScanner::ScanImporter.new('no markers here')
56
63
 
64
+ assert_raises(::Foreman::Exception) do
65
+ importer.import_for_host!(@host)
66
+ end
67
+ end
68
+
69
+ test 'import_for_host! raises when json is invalid' do
70
+ importer = ForemanCveScanner::ScanImporter.new("===START\n{bad\n===END")
71
+
72
+ assert_raises(::Foreman::Exception) do
73
+ importer.import_for_host!(@host)
74
+ end
75
+ end
76
+
77
+ test 'import_for_host! raises when marker block is empty' do
78
+ importer = ForemanCveScanner::ScanImporter.new("===START\n===END")
79
+
80
+ assert_raises(::Foreman::Exception) do
81
+ importer.import_for_host!(@host)
82
+ end
83
+ end
84
+
85
+ test 'import_for_host! cleans up old scans for the host using retention setting' do
86
+ Setting[:cve_scan_delete_after_days] = 1
87
+ old_scan = ForemanCveScanner::CveScan.create!(
88
+ host: @host,
89
+ scanner: 'trivy',
90
+ source: 'rex',
91
+ scanned_at: 3.days.ago,
92
+ raw: { 'dummy' => true },
93
+ summary: { 'worst' => 'low' },
94
+ findings: [{ 'id' => 'CVE-0000-0000' }],
95
+ total: 1,
96
+ critical: 0,
97
+ high: 0,
98
+ medium: 0,
99
+ low: 1
100
+ )
101
+ output = wrap_output(load_fixture('trivy.json'))
102
+ importer = ForemanCveScanner::ScanImporter.new(output)
103
+
57
104
  scan = importer.import_for_host!(@host)
58
105
 
59
- assert_nil scan
106
+ assert_not_nil scan
107
+ assert_not ForemanCveScanner::CveScan.exists?(old_scan.id)
108
+ assert ForemanCveScanner::CveScan.exists?(scan.id)
60
109
  end
61
110
 
62
- test 'import_for_host! returns nil when json is invalid' do
63
- importer = ForemanCveScanner::ScanImporter.new("===START\n{bad\n===END")
111
+ test 'import_for_host! persists scan with zero findings' do
112
+ output = wrap_output(
113
+ {
114
+ 'Results' => [
115
+ {
116
+ 'Target' => '/',
117
+ 'Class' => 'os-pkgs',
118
+ },
119
+ ],
120
+ }.to_json
121
+ )
122
+ importer = ForemanCveScanner::ScanImporter.new(output)
64
123
 
65
124
  scan = importer.import_for_host!(@host)
66
125
 
67
- assert_nil scan
126
+ assert_not_nil scan
127
+ assert_equal 'trivy', scan.scanner
128
+ assert_empty scan.findings
129
+ assert_equal 0, scan.total
130
+ assert_equal 'none', scan.summary['worst']
68
131
  end
69
132
 
70
133
  private
@@ -0,0 +1,298 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ /* eslint-disable camelcase */
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+ import PropTypes from 'prop-types';
5
+ import {
6
+ Modal,
7
+ Button,
8
+ Text,
9
+ TextVariants,
10
+ SearchInput,
11
+ } from '@patternfly/react-core';
12
+ import {
13
+ Table,
14
+ Tbody,
15
+ Tr,
16
+ Th,
17
+ Thead,
18
+ Td,
19
+ SortByDirection,
20
+ } from '@patternfly/react-table';
21
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
22
+ import { foremanUrl } from 'foremanReact/common/helpers';
23
+ import { translate as __ } from 'foremanReact/common/I18n';
24
+ import { STATUS } from 'foremanReact/constants';
25
+ import {
26
+ comparisonDiffEntries,
27
+ comparisonColumns,
28
+ comparisonFilters,
29
+ comparisonMatchesSearch,
30
+ comparisonSorters,
31
+ comparisonStatusLabels,
32
+ formatDateTime,
33
+ formatScanOrigin,
34
+ formatScannedAt,
35
+ normalizeSearchInputValue,
36
+ } from './cve_helpers';
37
+ import './cve_scans.scss';
38
+
39
+ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
40
+ const [filter, setFilter] = useState('all');
41
+ const [search, setSearch] = useState('');
42
+ const [sortBy, setSortBy] = useState({
43
+ direction: SortByDirection.desc,
44
+ column: 'status',
45
+ });
46
+ const previousScanId = scanIds[0];
47
+ const currentScanId = scanIds[1];
48
+ const compareUrl =
49
+ isOpen && previousScanId && currentScanId
50
+ ? foremanUrl(
51
+ `/api/v2/hosts/${hostId}/cve_scans/compare?first_id=${previousScanId}&second_id=${currentScanId}`
52
+ )
53
+ : null;
54
+ const compareRequest = useAPI(isOpen ? 'get' : null, compareUrl, {
55
+ key: `CVE_COMPARE_${previousScanId}_${currentScanId}`,
56
+ });
57
+ useEffect(() => {
58
+ if (!isOpen) return;
59
+ setFilter('all');
60
+ setSearch('');
61
+ setSortBy({
62
+ direction: SortByDirection.desc,
63
+ column: 'status',
64
+ });
65
+ }, [currentScanId, isOpen, previousScanId]);
66
+
67
+ const comparison = compareRequest.response || {};
68
+ const previousScan = comparison.previous || {};
69
+ const currentScan = comparison.current || {};
70
+ const previousOrigin = formatScanOrigin(
71
+ previousScan.scanner,
72
+ previousScan.source
73
+ );
74
+ const currentOrigin = formatScanOrigin(
75
+ currentScan.scanner,
76
+ currentScan.source
77
+ );
78
+ const comparisonRows = useMemo(() => comparison.results || [], [
79
+ comparison.results,
80
+ ]);
81
+ const summary = comparison.summary || {};
82
+ const normalizedSearch = search.trim().toLowerCase();
83
+ const filteredRows = useMemo(() => {
84
+ let rows = comparisonRows;
85
+ if (filter !== 'all') {
86
+ rows = rows.filter(row => row.status === filter);
87
+ }
88
+ if (!normalizedSearch) return rows;
89
+ return rows.filter(row => comparisonMatchesSearch(row, normalizedSearch));
90
+ }, [comparisonRows, filter, normalizedSearch]);
91
+ const sortedRows = useMemo(() => {
92
+ const rows = [...filteredRows];
93
+ const sorter = comparisonSorters[sortBy.column] || comparisonSorters.status;
94
+ rows.sort((a, b) => {
95
+ const result = sorter(a, b);
96
+ return sortBy.direction === SortByDirection.asc ? result : -result;
97
+ });
98
+ return rows;
99
+ }, [filteredRows, sortBy]);
100
+ const status = compareRequest.status || STATUS.PENDING;
101
+ const subtitle =
102
+ previousScan.scanned_at && currentScan.scanned_at
103
+ ? `${formatScannedAt(previousScan.scanned_at)} -> ${formatScannedAt(
104
+ currentScan.scanned_at
105
+ )}`
106
+ : __('Compare CVE reports');
107
+
108
+ const onSort = column => {
109
+ const nextDirection =
110
+ sortBy.column === column && sortBy.direction === SortByDirection.asc
111
+ ? SortByDirection.desc
112
+ : SortByDirection.asc;
113
+ setSortBy({ column, direction: nextDirection });
114
+ };
115
+ const onSearchChange = (value, event) => {
116
+ setSearch(normalizeSearchInputValue(value, event));
117
+ };
118
+ const renderDiff = diff => {
119
+ const entries = comparisonDiffEntries(diff);
120
+ if (entries.length === 0) return '-';
121
+ return entries.map(entry => (
122
+ <div key={entry.field} className="cve-compare-diff-entry">
123
+ <span className="cve-compare-diff-label">{entry.label}:</span>{' '}
124
+ <span className="cve-compare-diff-values">
125
+ <span className="cve-compare-diff-old">{entry.oldValue}</span>{' '}
126
+ <span className="cve-compare-diff-arrow">-&gt;</span>{' '}
127
+ <span className="cve-compare-diff-new">{entry.newValue}</span>
128
+ </span>
129
+ </div>
130
+ ));
131
+ };
132
+
133
+ return (
134
+ <Modal
135
+ title={__('Compare CVE reports')}
136
+ description={subtitle}
137
+ isOpen={isOpen}
138
+ onClose={onClose}
139
+ width="80%"
140
+ maxWidth="80%"
141
+ ouiaId="cve-compare-modal"
142
+ actions={[
143
+ <Button
144
+ key="close"
145
+ variant="primary"
146
+ onClick={onClose}
147
+ ouiaId="cve-compare-close"
148
+ >
149
+ {__('Close')}
150
+ </Button>,
151
+ ]}
152
+ >
153
+ {status === STATUS.PENDING ? (
154
+ <Text component={TextVariants.small} ouiaId="cve-compare-loading">
155
+ {__('Loading...')}
156
+ </Text>
157
+ ) : (
158
+ <div className="cve-modal-body cve-compare-body">
159
+ <div className="cve-compare-summary">
160
+ <div className="cve-compare-meta">
161
+ <Text
162
+ component={TextVariants.small}
163
+ ouiaId="cve-compare-previous"
164
+ >
165
+ {__('Previous')}: {previousOrigin} /{' '}
166
+ {formatScannedAt(previousScan.scanned_at)}
167
+ </Text>
168
+ <Text component={TextVariants.small} ouiaId="cve-compare-current">
169
+ {__('Current')}: {currentOrigin} /{' '}
170
+ {formatScannedAt(currentScan.scanned_at)}
171
+ </Text>
172
+ </div>
173
+ <div className="cve-compare-cards">
174
+ {comparisonFilters
175
+ .filter(item => item.key !== 'all')
176
+ .map(item => (
177
+ <button
178
+ key={item.key}
179
+ type="button"
180
+ className={
181
+ filter === item.key
182
+ ? 'cve-compare-card is-active'
183
+ : 'cve-compare-card'
184
+ }
185
+ onClick={() => setFilter(item.key)}
186
+ >
187
+ <span className="cve-compare-card-label">{item.label}</span>
188
+ <span className="cve-compare-card-value">
189
+ {summary[item.key]}
190
+ </span>
191
+ </button>
192
+ ))}
193
+ </div>
194
+ </div>
195
+ <div className="cve-modal-filters">
196
+ <SearchInput
197
+ id="cve-compare-search"
198
+ value={search}
199
+ onChange={onSearchChange}
200
+ onClear={() => setSearch('')}
201
+ onSearch={onSearchChange}
202
+ placeholder={__('Search comparison by CVE, package, diff...')}
203
+ aria-label={__('Search CVE comparison')}
204
+ className="cve-modal-search"
205
+ />
206
+ <div className="cve-modal-quick-filters">
207
+ <div className="cve-filter-buttons">
208
+ {comparisonFilters.map(item => (
209
+ <button
210
+ key={item.key}
211
+ type="button"
212
+ aria-pressed={filter === item.key}
213
+ className={
214
+ filter === item.key
215
+ ? 'cve-filter-button is-active'
216
+ : 'cve-filter-button'
217
+ }
218
+ onClick={() => setFilter(item.key)}
219
+ >
220
+ {item.label}
221
+ </button>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ <div className="cve-modal-results">
227
+ {sortedRows.length === 0 ? (
228
+ <Text component={TextVariants.small} ouiaId="cve-compare-empty">
229
+ {__('No CVE differences match the selected filters')}
230
+ </Text>
231
+ ) : (
232
+ <Table
233
+ variant="compact"
234
+ aria-label="CVE comparison table"
235
+ ouiaId="cve-compare-table"
236
+ >
237
+ <Thead>
238
+ <Tr ouiaId="cve-compare-header">
239
+ {comparisonColumns.map(column => (
240
+ <Th
241
+ key={column.key}
242
+ className="cve-sortable"
243
+ onClick={() => onSort(column.key)}
244
+ >
245
+ {column.label}
246
+ </Th>
247
+ ))}
248
+ </Tr>
249
+ </Thead>
250
+ <Tbody>
251
+ {sortedRows.map((row, index) => (
252
+ <Tr key={row.key} ouiaId={`cve-compare-row-${index}`}>
253
+ <Td dataLabel={__('Status')}>
254
+ {comparisonStatusLabels[row.status]}
255
+ </Td>
256
+ <Td dataLabel={__('CVE')}>
257
+ {row.url ? (
258
+ <a href={row.url} target="_blank" rel="noreferrer">
259
+ {row.id}
260
+ </a>
261
+ ) : (
262
+ row.id
263
+ )}
264
+ </Td>
265
+ <Td dataLabel={__('Package')}>{row.name}</Td>
266
+ <Td dataLabel={__('Severity')}>{row.severity}</Td>
267
+ <Td dataLabel={__('Version')}>{row.version}</Td>
268
+ <Td dataLabel={__('Fixed')}>{row.fixed}</Td>
269
+ <Td dataLabel={__('Scan status')}>{row.scan_status}</Td>
270
+ <Td dataLabel={__('Diff')}>{renderDiff(row.diff)}</Td>
271
+ <Td dataLabel={__('Published')}>
272
+ {formatDateTime(row.published)}
273
+ </Td>
274
+ <Td dataLabel={__('Title')} title={row.title}>
275
+ <span className="cve-truncate">{row.title}</span>
276
+ </Td>
277
+ </Tr>
278
+ ))}
279
+ </Tbody>
280
+ </Table>
281
+ )}
282
+ </div>
283
+ </div>
284
+ )}
285
+ </Modal>
286
+ );
287
+ };
288
+
289
+ CveCompareModal.propTypes = {
290
+ hostId: PropTypes.number.isRequired,
291
+ isOpen: PropTypes.bool.isRequired,
292
+ onClose: PropTypes.func.isRequired,
293
+ scanIds: PropTypes.arrayOf(PropTypes.number),
294
+ };
295
+ CveCompareModal.defaultProps = {
296
+ scanIds: [],
297
+ };
298
+ export default CveCompareModal;