foreman_cve_scanner 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +22 -4
- data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
- data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
- data/app/models/host_status/cve_status.rb +4 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
- data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
- data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
- data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
- data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
- data/config/routes.rb +1 -0
- data/lib/foreman_cve_scanner/engine.rb +28 -19
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +96 -0
- data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
- data/test/models/host_status/cve_status_test.rb +8 -0
- data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
- data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
- data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
- data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
- data/webpack/components/CveCompareModal.js +9 -28
- data/webpack/components/CveDetailsCard.js +54 -65
- data/webpack/components/CveFindingsModal.js +15 -234
- data/webpack/components/CveFindingsView.js +307 -0
- data/webpack/components/CveKatelloFixLink.js +59 -0
- data/webpack/components/CveLink.js +28 -0
- data/webpack/components/CveScansReports.js +25 -62
- data/webpack/components/CveScansTab.js +42 -9
- data/webpack/components/CveSummaryCell.js +31 -19
- data/webpack/components/CveTrendChart.js +1 -16
- data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
- data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
- data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
- data/webpack/components/__tests__/CveScansTab.test.js +141 -147
- data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
- data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
- data/webpack/components/__tests__/cve_helpers.test.js +84 -0
- data/webpack/components/cve_helpers.js +114 -0
- data/webpack/components/cve_scans.scss +49 -0
- data/webpack/components/cve_summary_store.js +91 -0
- data/webpack/components/useSortBy.js +34 -0
- metadata +21 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class KatelloFixAvailabilityTest < ActiveSupport::TestCase
|
|
7
|
+
Erratum = Struct.new(
|
|
8
|
+
:id,
|
|
9
|
+
:errata_id,
|
|
10
|
+
:title,
|
|
11
|
+
:severity,
|
|
12
|
+
:cves,
|
|
13
|
+
:packages,
|
|
14
|
+
:deb_packages
|
|
15
|
+
)
|
|
16
|
+
Cve = Struct.new(:cve_id)
|
|
17
|
+
Package = Struct.new(:name)
|
|
18
|
+
ContentFacet = Struct.new(:installable_errata, :applicable_errata)
|
|
19
|
+
|
|
20
|
+
class ErrataRelation
|
|
21
|
+
include Enumerable
|
|
22
|
+
|
|
23
|
+
attr_reader :search_queries
|
|
24
|
+
|
|
25
|
+
def initialize(errata)
|
|
26
|
+
@errata = errata
|
|
27
|
+
@search_queries = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each(&block)
|
|
31
|
+
@errata.each(&block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def security
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def includes(*)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def search_for(query)
|
|
43
|
+
@search_queries << query
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def distinct
|
|
48
|
+
@errata.uniq
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def setup
|
|
53
|
+
@host = FactoryBot.create(:host)
|
|
54
|
+
@findings = [
|
|
55
|
+
{
|
|
56
|
+
'id' => 'CVE-2026-0001',
|
|
57
|
+
'name' => 'openssl',
|
|
58
|
+
'severity' => 'HIGH',
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
test 'does not add katello enrichment when katello is not installed' do
|
|
64
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
65
|
+
Foreman::Plugin.stubs(:installed?).with(:katello).returns(false)
|
|
66
|
+
service.expects(:enabled?).never
|
|
67
|
+
|
|
68
|
+
enriched_findings = service.call
|
|
69
|
+
|
|
70
|
+
assert_equal @findings, enriched_findings
|
|
71
|
+
assert_not enriched_findings.first.key?('katello_fix')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
test 'does not add katello enrichment when disabled' do
|
|
75
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
76
|
+
service.stubs(:enabled?).returns(false)
|
|
77
|
+
service.expects(:installable_errata).never
|
|
78
|
+
|
|
79
|
+
enriched_findings = service.call
|
|
80
|
+
|
|
81
|
+
assert_equal @findings, enriched_findings
|
|
82
|
+
assert_not enriched_findings.first.key?('katello_fix')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
test 'adds unknown enrichment when katello lookup fails' do
|
|
86
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
87
|
+
service.stubs(:enabled?).returns(true)
|
|
88
|
+
service.stubs(:katello_available?).returns(true)
|
|
89
|
+
service.stubs(:installable_errata).raises(NameError)
|
|
90
|
+
|
|
91
|
+
enriched_findings = service.call
|
|
92
|
+
fix = enriched_findings.first['katello_fix']
|
|
93
|
+
|
|
94
|
+
assert_equal 'unknown', fix['status']
|
|
95
|
+
assert_equal 'query_failed', fix['reason']
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test 'marks matching installable errata as installable' do
|
|
99
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
100
|
+
erratum = erratum_for('CVE-2026-0001', 'openssl')
|
|
101
|
+
service.stubs(:enabled?).returns(true)
|
|
102
|
+
service.stubs(:katello_available?).returns(true)
|
|
103
|
+
service.stubs(:installable_errata).returns([erratum])
|
|
104
|
+
service.stubs(:applicable_errata).returns([])
|
|
105
|
+
|
|
106
|
+
enriched_findings = service.call
|
|
107
|
+
fix = enriched_findings.first['katello_fix']
|
|
108
|
+
|
|
109
|
+
assert_equal 'installable', fix['status']
|
|
110
|
+
assert_equal 'package_match', fix['confidence']
|
|
111
|
+
assert_equal(
|
|
112
|
+
['RHSA-2026:0001'],
|
|
113
|
+
fix['errata'].map { |item| item['errata_id'] }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
test 'marks matching applicable errata as applicable when not installable' do
|
|
118
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
119
|
+
erratum = erratum_for('CVE-2026-0001', 'openssl')
|
|
120
|
+
service.stubs(:enabled?).returns(true)
|
|
121
|
+
service.stubs(:katello_available?).returns(true)
|
|
122
|
+
service.stubs(:installable_errata).returns([])
|
|
123
|
+
service.stubs(:applicable_errata).returns([erratum])
|
|
124
|
+
|
|
125
|
+
enriched_findings = service.call
|
|
126
|
+
fix = enriched_findings.first['katello_fix']
|
|
127
|
+
|
|
128
|
+
assert_equal 'applicable', fix['status']
|
|
129
|
+
assert_equal 'package_match', fix['confidence']
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
test 'looks up errata through host content facet cve search' do
|
|
133
|
+
installable_erratum = erratum_for('CVE-2026-0001', 'openssl')
|
|
134
|
+
installable_relation = ErrataRelation.new([installable_erratum])
|
|
135
|
+
applicable_relation = ErrataRelation.new([])
|
|
136
|
+
content_facet = ContentFacet.new(installable_relation, applicable_relation)
|
|
137
|
+
|
|
138
|
+
@host.stubs(:content_facet).returns(content_facet)
|
|
139
|
+
service = KatelloFixAvailability.new(@host, @findings)
|
|
140
|
+
service.stubs(:enabled?).returns(true)
|
|
141
|
+
service.stubs(:katello_available?).returns(true)
|
|
142
|
+
|
|
143
|
+
enriched_findings = service.call
|
|
144
|
+
|
|
145
|
+
assert_equal ['cve = "CVE-2026-0001"'], installable_relation.search_queries
|
|
146
|
+
assert_equal ['cve = "CVE-2026-0001"'], applicable_relation.search_queries
|
|
147
|
+
assert_equal 'installable', enriched_findings.first['katello_fix']['status']
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def erratum_for(cve_id, package_name)
|
|
153
|
+
Erratum.new(
|
|
154
|
+
1,
|
|
155
|
+
'RHSA-2026:0001',
|
|
156
|
+
'Security update',
|
|
157
|
+
'Important',
|
|
158
|
+
[Cve.new(cve_id)],
|
|
159
|
+
[Package.new(package_name)],
|
|
160
|
+
[]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class LatestScanSummariesTest < ActiveSupport::TestCase
|
|
7
|
+
def setup
|
|
8
|
+
@host = FactoryBot.create(:host)
|
|
9
|
+
@other_host = FactoryBot.create(:host)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
test 'call returns the latest scan summary for each requested host' do
|
|
13
|
+
create_scan(host: @host, scanned_at: 2.hours.ago, total: 1, metrics: { low: 1 })
|
|
14
|
+
latest_for_host = create_scan(host: @host, scanned_at: 1.hour.ago, total: 2, metrics: { high: 2 })
|
|
15
|
+
latest_for_other_host = create_scan(
|
|
16
|
+
host: @other_host,
|
|
17
|
+
scanned_at: 30.minutes.ago,
|
|
18
|
+
total: 3,
|
|
19
|
+
metrics: { medium: 3 }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
results = LatestScanSummaries.new(
|
|
23
|
+
host_scope: Host::Base.where(id: [@host.id, @other_host.id]),
|
|
24
|
+
host_ids: [@host.id, @other_host.id]
|
|
25
|
+
).call
|
|
26
|
+
summaries = results.index_by { |row| row[:host_id] }
|
|
27
|
+
|
|
28
|
+
assert_equal latest_for_host.id, summaries[@host.id][:id]
|
|
29
|
+
assert_equal latest_for_other_host.id, summaries[@other_host.id][:id]
|
|
30
|
+
assert_equal 2, summaries[@host.id][:total]
|
|
31
|
+
assert_equal 3, summaries[@other_host.id][:total]
|
|
32
|
+
assert_equal 3, summaries[@other_host.id][:medium]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test 'call ignores duplicate invalid and out of scope host ids' do
|
|
36
|
+
latest_for_host = create_scan(host: @host, scanned_at: 1.hour.ago, total: 2, metrics: { high: 2 })
|
|
37
|
+
create_scan(host: @other_host, scanned_at: 30.minutes.ago, total: 3, metrics: { medium: 3 })
|
|
38
|
+
|
|
39
|
+
results = LatestScanSummaries.new(
|
|
40
|
+
host_scope: Host::Base.where(id: @host.id),
|
|
41
|
+
host_ids: [@host.id, @host.id, @other_host.id, 'nope', nil]
|
|
42
|
+
).call
|
|
43
|
+
|
|
44
|
+
assert_equal 1, results.size
|
|
45
|
+
assert_equal @host.id, results[0][:host_id]
|
|
46
|
+
assert_equal latest_for_host.id, results[0][:id]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test 'call prefers higher id when scans share the same scanned_at' do
|
|
50
|
+
timestamp = 1.hour.ago
|
|
51
|
+
older_id_scan = create_scan(host: @host, scanned_at: timestamp, total: 1, metrics: { low: 1 })
|
|
52
|
+
newer_id_scan = create_scan(host: @host, scanned_at: timestamp, total: 2, metrics: { high: 2 })
|
|
53
|
+
|
|
54
|
+
results = LatestScanSummaries.new(
|
|
55
|
+
host_scope: Host::Base.where(id: @host.id),
|
|
56
|
+
host_ids: [@host.id]
|
|
57
|
+
).call
|
|
58
|
+
|
|
59
|
+
assert_equal 1, results.size
|
|
60
|
+
assert_equal newer_id_scan.id, results[0][:id]
|
|
61
|
+
assert_operator newer_id_scan.id, :>, older_id_scan.id
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def create_scan(host:, scanned_at:, total:, metrics: {})
|
|
67
|
+
critical = metrics.fetch(:critical, 0)
|
|
68
|
+
high = metrics.fetch(:high, 0)
|
|
69
|
+
medium = metrics.fetch(:medium, 0)
|
|
70
|
+
low = metrics.fetch(:low, 0)
|
|
71
|
+
|
|
72
|
+
ForemanCveScanner::CveScan.create!(
|
|
73
|
+
host: host,
|
|
74
|
+
scanner: 'trivy',
|
|
75
|
+
source: 'rex',
|
|
76
|
+
scanned_at: scanned_at,
|
|
77
|
+
raw: { 'dummy' => true },
|
|
78
|
+
summary: {
|
|
79
|
+
'worst' => ForemanCveScanner::CveScan.worst_severity(
|
|
80
|
+
'critical' => critical,
|
|
81
|
+
'high' => high,
|
|
82
|
+
'medium' => medium,
|
|
83
|
+
'low' => low
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
findings: total.zero? ? [] : [{ 'id' => 'CVE-0000-0000' }],
|
|
87
|
+
total: total,
|
|
88
|
+
critical: critical,
|
|
89
|
+
high: high,
|
|
90
|
+
medium: medium,
|
|
91
|
+
low: low
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -39,6 +39,34 @@ module ForemanCveScanner
|
|
|
39
39
|
assert_equal @second_scan.scanner, comparison[:current][:scanner]
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
test 'compare keeps duplicate cve ids separate across packages' do
|
|
43
|
+
first_scan = create_scan(
|
|
44
|
+
scanned_at: 4.hours.ago,
|
|
45
|
+
findings: [
|
|
46
|
+
finding('CVE-1', 'openssl', 'HIGH', '1.0'),
|
|
47
|
+
finding('CVE-1', 'curl', 'LOW', '1.0'),
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
second_scan = create_scan(
|
|
51
|
+
scanned_at: 3.hours.ago,
|
|
52
|
+
findings: [
|
|
53
|
+
finding('CVE-1', 'openssl', 'CRITICAL', '1.0'),
|
|
54
|
+
finding('CVE-1', 'curl', 'LOW', '1.0'),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
comparison = ScanComparison.compare(first_scan, second_scan)
|
|
59
|
+
rows = comparison[:results].to_h { |row| [row[:key], row] }
|
|
60
|
+
|
|
61
|
+
assert_equal 2, comparison[:results].size
|
|
62
|
+
assert_equal 'updated', rows['CVE-1::openssl'][:status]
|
|
63
|
+
assert_equal 'unchanged', rows['CVE-1::curl'][:status]
|
|
64
|
+
assert_equal(
|
|
65
|
+
{ old: 'HIGH', new: 'CRITICAL' },
|
|
66
|
+
rows['CVE-1::openssl'][:diff]['severity']
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
42
70
|
private
|
|
43
71
|
|
|
44
72
|
def finding(id, name, severity, version)
|
|
@@ -58,6 +58,24 @@ module ForemanCveScanner
|
|
|
58
58
|
assert_operator scan.total, :>, 0
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
test 'import_for_host! ignores noise outside markers' do
|
|
62
|
+
output = [
|
|
63
|
+
'stderr noise before',
|
|
64
|
+
'===START',
|
|
65
|
+
load_fixture('trivy.json'),
|
|
66
|
+
'===END',
|
|
67
|
+
'stderr noise after',
|
|
68
|
+
].join("\n")
|
|
69
|
+
importer = ForemanCveScanner::ScanImporter.new(output)
|
|
70
|
+
|
|
71
|
+
scan = importer.import_for_host!(@host)
|
|
72
|
+
|
|
73
|
+
assert_not_nil scan
|
|
74
|
+
assert_equal 'trivy', scan.scanner
|
|
75
|
+
assert_operator scan.total, :>, 0
|
|
76
|
+
assert_equal scan.total, scan.findings.count
|
|
77
|
+
end
|
|
78
|
+
|
|
61
79
|
test 'import_for_host! raises when no json markers' do
|
|
62
80
|
importer = ForemanCveScanner::ScanImporter.new('no markers here')
|
|
63
81
|
|
|
@@ -130,6 +148,43 @@ module ForemanCveScanner
|
|
|
130
148
|
assert_equal 'none', scan.summary['worst']
|
|
131
149
|
end
|
|
132
150
|
|
|
151
|
+
test 'import_for_host! persists duplicate cves for different packages' do
|
|
152
|
+
output = wrap_output(
|
|
153
|
+
{
|
|
154
|
+
'Results' => [
|
|
155
|
+
{
|
|
156
|
+
'Vulnerabilities' => [
|
|
157
|
+
{
|
|
158
|
+
'VulnerabilityID' => 'CVE-2026-0001',
|
|
159
|
+
'PkgName' => 'openssl',
|
|
160
|
+
'InstalledVersion' => '1.0',
|
|
161
|
+
'Title' => 'openssl issue',
|
|
162
|
+
'Severity' => 'HIGH',
|
|
163
|
+
'PrimaryURL' => 'https://example.com/openssl',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
'VulnerabilityID' => 'CVE-2026-0001',
|
|
167
|
+
'PkgName' => 'curl',
|
|
168
|
+
'InstalledVersion' => '2.0',
|
|
169
|
+
'Title' => 'curl issue',
|
|
170
|
+
'Severity' => 'MEDIUM',
|
|
171
|
+
'PrimaryURL' => 'https://example.com/curl',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}.to_json
|
|
177
|
+
)
|
|
178
|
+
importer = ForemanCveScanner::ScanImporter.new(output)
|
|
179
|
+
|
|
180
|
+
scan = importer.import_for_host!(@host)
|
|
181
|
+
findings = scan.findings.map { |finding| [finding['id'], finding['name']] }
|
|
182
|
+
|
|
183
|
+
assert_equal 2, scan.findings.size
|
|
184
|
+
assert_equal [%w[CVE-2026-0001 openssl], %w[CVE-2026-0001 curl]], findings
|
|
185
|
+
assert_equal [2, 1, 1], [scan.total, scan.high, scan.medium]
|
|
186
|
+
end
|
|
187
|
+
|
|
133
188
|
private
|
|
134
189
|
|
|
135
190
|
def load_fixture(name)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_plugin_helper'
|
|
4
|
+
|
|
5
|
+
module ForemanCveScanner
|
|
6
|
+
class ScanPostProcessorTest < ActiveSupport::TestCase
|
|
7
|
+
def setup
|
|
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
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test 'refreshes cve host status' do
|
|
17
|
+
create_scan(critical: 1)
|
|
18
|
+
|
|
19
|
+
assert_difference('HostStatus::CveStatus.count', 1) do
|
|
20
|
+
ScanPostProcessor.new(@host).call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
status = HostStatus::CveStatus.find_by(host: @host)
|
|
24
|
+
assert_equal HostStatus::CveStatus::CVE_SCANNER_STATUS_CRITICAL_HIGH, status.status
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test 'cleans up old scans for host using retention setting' do
|
|
28
|
+
Setting[:cve_scan_delete_after_days] = 1
|
|
29
|
+
old_scan = create_scan(scanned_at: 3.days.ago)
|
|
30
|
+
kept_scan = create_scan(scanned_at: Time.current)
|
|
31
|
+
|
|
32
|
+
ScanPostProcessor.new(@host).call
|
|
33
|
+
|
|
34
|
+
assert_not CveScan.exists?(old_scan.id)
|
|
35
|
+
assert CveScan.exists?(kept_scan.id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def create_scan(options = {})
|
|
41
|
+
critical = options.fetch(:critical, 0)
|
|
42
|
+
scanned_at = options.fetch(:scanned_at, Time.current)
|
|
43
|
+
CveScan.create!(
|
|
44
|
+
host: @host,
|
|
45
|
+
scanner: 'trivy',
|
|
46
|
+
source: 'rex',
|
|
47
|
+
scanned_at: scanned_at,
|
|
48
|
+
raw: { 'dummy' => true },
|
|
49
|
+
summary: { 'worst' => critical.positive? ? 'critical' : 'none' },
|
|
50
|
+
findings: critical.positive? ? [{ 'id' => 'CVE-0000-0000' }] : [],
|
|
51
|
+
total: critical,
|
|
52
|
+
critical: critical,
|
|
53
|
+
high: 0,
|
|
54
|
+
medium: 0,
|
|
55
|
+
low: 0
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -22,6 +22,8 @@ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
|
|
22
22
|
import { foremanUrl } from 'foremanReact/common/helpers';
|
|
23
23
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
24
24
|
import { STATUS } from 'foremanReact/constants';
|
|
25
|
+
import CveLink from './CveLink';
|
|
26
|
+
import useSortBy from './useSortBy';
|
|
25
27
|
import {
|
|
26
28
|
comparisonDiffEntries,
|
|
27
29
|
comparisonColumns,
|
|
@@ -39,10 +41,6 @@ import './cve_scans.scss';
|
|
|
39
41
|
const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
|
|
40
42
|
const [filter, setFilter] = useState('all');
|
|
41
43
|
const [search, setSearch] = useState('');
|
|
42
|
-
const [sortBy, setSortBy] = useState({
|
|
43
|
-
direction: SortByDirection.desc,
|
|
44
|
-
column: 'status',
|
|
45
|
-
});
|
|
46
44
|
const previousScanId = scanIds[0];
|
|
47
45
|
const currentScanId = scanIds[1];
|
|
48
46
|
const compareUrl =
|
|
@@ -62,7 +60,7 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
|
|
|
62
60
|
direction: SortByDirection.desc,
|
|
63
61
|
column: 'status',
|
|
64
62
|
});
|
|
65
|
-
}, [currentScanId, isOpen, previousScanId]);
|
|
63
|
+
}, [currentScanId, isOpen, previousScanId, setSortBy]);
|
|
66
64
|
|
|
67
65
|
const comparison = compareRequest.response || {};
|
|
68
66
|
const previousScan = comparison.previous || {};
|
|
@@ -88,15 +86,11 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
|
|
|
88
86
|
if (!normalizedSearch) return rows;
|
|
89
87
|
return rows.filter(row => comparisonMatchesSearch(row, normalizedSearch));
|
|
90
88
|
}, [comparisonRows, filter, normalizedSearch]);
|
|
91
|
-
const sortedRows =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return sortBy.direction === SortByDirection.asc ? result : -result;
|
|
97
|
-
});
|
|
98
|
-
return rows;
|
|
99
|
-
}, [filteredRows, sortBy]);
|
|
89
|
+
const { onSort, sortedItems: sortedRows, setSortBy } = useSortBy(
|
|
90
|
+
'status',
|
|
91
|
+
comparisonSorters,
|
|
92
|
+
filteredRows
|
|
93
|
+
);
|
|
100
94
|
const status = compareRequest.status || STATUS.PENDING;
|
|
101
95
|
const subtitle =
|
|
102
96
|
previousScan.scanned_at && currentScan.scanned_at
|
|
@@ -105,13 +99,6 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
|
|
|
105
99
|
)}`
|
|
106
100
|
: __('Compare CVE reports');
|
|
107
101
|
|
|
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
102
|
const onSearchChange = (value, event) => {
|
|
116
103
|
setSearch(normalizeSearchInputValue(value, event));
|
|
117
104
|
};
|
|
@@ -254,13 +241,7 @@ const CveCompareModal = ({ hostId, isOpen, onClose, scanIds }) => {
|
|
|
254
241
|
{comparisonStatusLabels[row.status]}
|
|
255
242
|
</Td>
|
|
256
243
|
<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
|
-
)}
|
|
244
|
+
<CveLink id={row.id} url={row.url} />
|
|
264
245
|
</Td>
|
|
265
246
|
<Td dataLabel={__('Package')}>{row.name}</Td>
|
|
266
247
|
<Td dataLabel={__('Severity')}>{row.severity}</Td>
|
|
@@ -28,18 +28,29 @@ import SeverityIcon from './SeverityIcon';
|
|
|
28
28
|
import CveFindingsModal from './CveFindingsModal';
|
|
29
29
|
import useModalScan from './useModalScan';
|
|
30
30
|
import {
|
|
31
|
+
findingIdentity,
|
|
31
32
|
noFindingsBody,
|
|
32
33
|
noFindingsTitle,
|
|
33
34
|
formatScanOrigin,
|
|
35
|
+
katelloFixCounts,
|
|
34
36
|
noReportsBody,
|
|
35
37
|
noReportsTitle,
|
|
36
38
|
riskLevelFromWorst,
|
|
37
39
|
severityRank,
|
|
40
|
+
shouldShowKatelloFixColumn,
|
|
38
41
|
} from './cve_helpers';
|
|
39
42
|
import './cve_scans.scss';
|
|
40
43
|
|
|
44
|
+
const SEVERITY_COUNT_CARDS = [
|
|
45
|
+
{ key: 'critical', label: __('critical') },
|
|
46
|
+
{ key: 'high', label: __('high') },
|
|
47
|
+
{ key: 'medium', label: __('medium') },
|
|
48
|
+
{ key: 'low', label: __('low') },
|
|
49
|
+
];
|
|
50
|
+
|
|
41
51
|
const CveDetailsCard = ({ hostDetails }) => {
|
|
42
52
|
const hostId = hostDetails?.id;
|
|
53
|
+
const hostName = hostDetails?.name;
|
|
43
54
|
const latestUrl = hostId
|
|
44
55
|
? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
|
|
45
56
|
: null;
|
|
@@ -71,6 +82,8 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
71
82
|
const worst = latest?.summary?.worst || 'none';
|
|
72
83
|
const riskLevel = riskLevelFromWorst(worst);
|
|
73
84
|
const origin = formatScanOrigin(latest?.scanner, latest?.source);
|
|
85
|
+
const showKatelloFixSummary = shouldShowKatelloFixColumn(findings);
|
|
86
|
+
const fixCounts = katelloFixCounts(findings);
|
|
74
87
|
return (
|
|
75
88
|
<CardTemplate header={__('CVE scan details')} expandable masonryLayout>
|
|
76
89
|
<SkeletonLoader status={latestStatus || STATUS.PENDING}>
|
|
@@ -119,72 +132,44 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
119
132
|
</span>
|
|
120
133
|
</DescriptionListDescription>
|
|
121
134
|
</DescriptionListGroup>
|
|
135
|
+
{showKatelloFixSummary && (
|
|
136
|
+
<DescriptionListGroup>
|
|
137
|
+
<DescriptionListTerm>
|
|
138
|
+
{__('Fix availability')}
|
|
139
|
+
</DescriptionListTerm>
|
|
140
|
+
<DescriptionListDescription>
|
|
141
|
+
<span className="cve-katello-summary">
|
|
142
|
+
<span className="cve-katello-fix cve-katello-fix--installable">
|
|
143
|
+
{`${__('Fixable')}: ${fixCounts.installable}`}
|
|
144
|
+
</span>
|
|
145
|
+
<span className="cve-katello-fix cve-katello-fix--applicable">
|
|
146
|
+
{`${__('Applicable')}: ${fixCounts.applicable}`}
|
|
147
|
+
</span>
|
|
148
|
+
</span>
|
|
149
|
+
</DescriptionListDescription>
|
|
150
|
+
</DescriptionListGroup>
|
|
151
|
+
)}
|
|
122
152
|
</DescriptionList>
|
|
123
153
|
<div className="cve-counts cve-counts--compact">
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
<span>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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}
|
|
154
|
+
{SEVERITY_COUNT_CARDS.map(card => (
|
|
155
|
+
<Button
|
|
156
|
+
key={card.key}
|
|
157
|
+
variant="plain"
|
|
158
|
+
className={`cve-count-card cve-count-card--${card.key}`}
|
|
159
|
+
onClick={() => openModal(latest.id, card.key)}
|
|
160
|
+
ouiaId={`cve-details-${card.key}-button`}
|
|
161
|
+
>
|
|
162
|
+
<span className="cve-count">
|
|
163
|
+
<span className="cve-count-label">
|
|
164
|
+
<SeverityIcon severity={card.key} />
|
|
165
|
+
<span>{card.label}</span>
|
|
166
|
+
</span>
|
|
167
|
+
<span className={`cve-bubble cve-bubble--${card.key}`}>
|
|
168
|
+
{latest[card.key]}
|
|
169
|
+
</span>
|
|
185
170
|
</span>
|
|
186
|
-
</
|
|
187
|
-
|
|
171
|
+
</Button>
|
|
172
|
+
))}
|
|
188
173
|
</div>
|
|
189
174
|
</div>
|
|
190
175
|
|
|
@@ -225,7 +210,7 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
225
210
|
<Tbody>
|
|
226
211
|
{visibleFindings.map((finding, index) => (
|
|
227
212
|
<Tr
|
|
228
|
-
key={finding
|
|
213
|
+
key={findingIdentity(finding)}
|
|
229
214
|
ouiaId={`cve-details-finding-row-${index}`}
|
|
230
215
|
>
|
|
231
216
|
<Td dataLabel={__('Severity')}>
|
|
@@ -263,6 +248,7 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
263
248
|
isOpen={isOpen}
|
|
264
249
|
onClose={closeModal}
|
|
265
250
|
hostId={hostId}
|
|
251
|
+
hostName={hostName}
|
|
266
252
|
scanId={scanId}
|
|
267
253
|
initialFilter={filter}
|
|
268
254
|
/>
|
|
@@ -270,7 +256,10 @@ const CveDetailsCard = ({ hostDetails }) => {
|
|
|
270
256
|
);
|
|
271
257
|
};
|
|
272
258
|
CveDetailsCard.propTypes = {
|
|
273
|
-
hostDetails: PropTypes.shape({
|
|
259
|
+
hostDetails: PropTypes.shape({
|
|
260
|
+
id: PropTypes.number,
|
|
261
|
+
name: PropTypes.string,
|
|
262
|
+
}),
|
|
274
263
|
};
|
|
275
264
|
CveDetailsCard.defaultProps = {
|
|
276
265
|
hostDetails: undefined,
|