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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23c87498422d0e807ebe6db02c5390db04cc8bc8d267eaefc8477ebbb78846ee
|
|
4
|
+
data.tar.gz: f84b770d2f95428b58e6981ca58a1753f98f4592c652a25376374bcfa45badb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c7a00415463f41d0b7bf61ebfb6b69d64401f75c194216543c34434f5714900efba97bcd727e1ed94f699d35d0f0a7ae41bad7c0cb2f21fa9ee8100f559ba10
|
|
7
|
+
data.tar.gz: 9afb073399f02c9874caf8a3fc341e5824b5f0e6344be69790a1095c944b371dfaab1eafc694d052d56cb685386d24d133aa80c30560f8739948af6413a4ab41
|
data/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Plugin to:
|
|
|
20
20
|
- Host Details tab “CVE scans” for full history
|
|
21
21
|
- Hosts overview list column “CVE” with quick summary and modal
|
|
22
22
|
- Integrated in the Host Status
|
|
23
|
+
- Optional Katello integration for managed errata fix availability
|
|
23
24
|
- Export scan results as CSV
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
@@ -34,10 +35,31 @@ for how to install Foreman plugins
|
|
|
34
35
|
- You can configure recurring CVE scans via `Monitor -> Jobs`
|
|
35
36
|
- You can configure the default scanner for the `Run CVE scan` template via `Administer -> Settings -> CVE Scanner -> Preferred CVE scanner`
|
|
36
37
|
- The setting `Administer -> Settings -> CVE Scanner -> Run CVE scan after host profiles upload` takes effect only when Katello is installed and triggers a scan after host profiles uploads using the preferred scanner setting
|
|
38
|
+
- The setting `Administer -> Settings -> CVE Scanner -> Show CVE fix availability from content` is enabled by default. When Katello is installed, the latest scan view can show whether a matching managed erratum is installable for the host or only applicable.
|
|
37
39
|
- View results in:
|
|
38
40
|
- Hosts overview list column “CVE” (use 'Manage Columns' to enable)
|
|
39
41
|
- Host Details card and modal
|
|
40
42
|
- Host Details tab “CVE scans”
|
|
43
|
+
- Filter hosts by latest CVE scan data with `cve_scanned`, `cve_total`, `cve_critical`, `cve_high`, `cve_medium`, `cve_low`, `cve_scanner`, `cve_source`, and `cve_scanned_at`
|
|
44
|
+
|
|
45
|
+
## Scanner installation
|
|
46
|
+
|
|
47
|
+
The `Install CVE scanners` Remote Execution job installs scanner packages from the host's enabled repositories by default. For Katello-managed hosts, this means `trivy` and `grype` can be delivered through the assigned content view/environment.
|
|
48
|
+
|
|
49
|
+
Set the job parameter `install_from_github` to `true` to install the scanner release packages directly from GitHub instead. The `trivy_version` and `grype_version` parameters are only used for the GitHub install path.
|
|
50
|
+
|
|
51
|
+
## Katello fix availability
|
|
52
|
+
|
|
53
|
+
When Katello is installed and `Show CVE fix availability from content` is enabled, the latest CVE scan is enriched with matching security errata from the host content facet.
|
|
54
|
+
|
|
55
|
+
The latest scan can show:
|
|
56
|
+
|
|
57
|
+
- `Installable`: a matching erratum is applicable and available in the host's assigned content view environments. The badge links to Foreman's customized Remote Execution flow for `katello_errata_install_by_search`.
|
|
58
|
+
- `Applicable`: a matching erratum applies to the host, but is not currently installable from the assigned content view environments. The badge links to the erratum details page.
|
|
59
|
+
- `No managed fix`: no matching managed erratum was found for the CVE/package.
|
|
60
|
+
- `Unknown`: fix availability could not be calculated.
|
|
61
|
+
|
|
62
|
+
If Katello is not installed, no fix availability data or UI is shown.
|
|
41
63
|
|
|
42
64
|
## External Push API
|
|
43
65
|
|
|
@@ -135,10 +157,6 @@ Override the configured retention for one run:
|
|
|
135
157
|
|
|
136
158
|
- `bundle exec rake foreman_cve_scanner:cleanup_scans DAYS=30`
|
|
137
159
|
|
|
138
|
-
## TODO
|
|
139
|
-
|
|
140
|
-
- Deliver Trivy/Grype via Katello
|
|
141
|
-
|
|
142
160
|
## Contributing
|
|
143
161
|
|
|
144
162
|
Fork and send a Pull Request. Thanks!
|
|
@@ -6,7 +6,7 @@ module Api
|
|
|
6
6
|
module V2
|
|
7
7
|
# API controller for CVE scans per host.
|
|
8
8
|
class CveScansController < V2::BaseController
|
|
9
|
-
before_action :find_host
|
|
9
|
+
before_action :find_host, except: :latest_by_hosts
|
|
10
10
|
|
|
11
11
|
def resource_class
|
|
12
12
|
::ForemanCveScanner::CveScan
|
|
@@ -27,6 +27,19 @@ module Api
|
|
|
27
27
|
def latest
|
|
28
28
|
@cve_scan = cve_scans_index_scope.first
|
|
29
29
|
head :no_content if @cve_scan.nil?
|
|
30
|
+
enrich_cve_scan_findings if @cve_scan
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
api :GET, '/cve_scans/latest_by_hosts', N_('Get latest CVE scan summaries for multiple hosts')
|
|
34
|
+
description N_('Returns the most recent CVE scan summary for each requested host.')
|
|
35
|
+
param :host_ids, Array, of: Integer, required: true
|
|
36
|
+
def latest_by_hosts
|
|
37
|
+
results = ::ForemanCveScanner::LatestScanSummaries.new(
|
|
38
|
+
host_scope: ::Host::Base.authorized(host_permission),
|
|
39
|
+
host_ids: params[:host_ids]
|
|
40
|
+
).call
|
|
41
|
+
|
|
42
|
+
render json: { results: results }
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
api :GET, '/hosts/:host_id/cve_scans/:id', N_('Show CVE scan for a host')
|
|
@@ -60,6 +73,7 @@ module Api
|
|
|
60
73
|
@cve_scan = resource_class.new(build_import_attributes)
|
|
61
74
|
|
|
62
75
|
if @cve_scan.save
|
|
76
|
+
post_process_scan_import
|
|
63
77
|
render 'api/v2/cve_scans/show', status: :created
|
|
64
78
|
else
|
|
65
79
|
render(
|
|
@@ -230,6 +244,14 @@ module Api
|
|
|
230
244
|
|
|
231
245
|
:view_cve_scans
|
|
232
246
|
end
|
|
247
|
+
|
|
248
|
+
def enrich_cve_scan_findings
|
|
249
|
+
@cve_scan_findings = ::ForemanCveScanner::KatelloFixAvailability.new(@host, @cve_scan.findings).call
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def post_process_scan_import
|
|
253
|
+
::ForemanCveScanner::ScanPostProcessor.new(@host).call
|
|
254
|
+
end
|
|
233
255
|
end
|
|
234
256
|
end
|
|
235
257
|
end
|
|
@@ -1,16 +1,137 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ForemanCveScanner
|
|
4
|
-
# Adds CVE scan associations to hosts.
|
|
4
|
+
# Adds CVE scan associations and search predicates to hosts.
|
|
5
5
|
module HostExtensions
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
CVE_SEARCH_COUNT_COLUMNS = %w[total critical high medium low].freeze
|
|
9
|
+
CVE_SEARCH_TEXT_OPERATORS = ['= ', '!= ', '~ ', '!~ '].freeze
|
|
10
|
+
CVE_SEARCH_COMPARE_OPERATORS = ['= ', '!= ', '< ', '> ', '<= ', '>= '].freeze
|
|
11
|
+
CVE_SEARCH_TEXT_SQL_OPERATORS = ['=', '!=', '<>', 'LIKE', 'NOT LIKE', '~', '!~'].freeze
|
|
12
|
+
CVE_SEARCH_COMPARE_SQL_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>='].freeze
|
|
13
|
+
# Scoped Search autocompletion expects symbol keys here; boolean keys raise during typing.
|
|
14
|
+
# rubocop:disable Lint/BooleanSymbol
|
|
15
|
+
CVE_SEARCH_BOOLEAN_VALUES = { true: true, false: false }.freeze
|
|
16
|
+
# rubocop:enable Lint/BooleanSymbol
|
|
17
|
+
CVE_SEARCH_SCANNER_VALUES = { trivy: 'trivy', grype: 'grype' }.freeze
|
|
18
|
+
CVE_SEARCH_SOURCE_VALUES = { rex: 'rex', external: 'external' }.freeze
|
|
19
|
+
|
|
8
20
|
included do
|
|
9
21
|
has_many :cve_scans,
|
|
10
22
|
class_name: 'ForemanCveScanner::CveScan',
|
|
11
23
|
foreign_key: :host_id,
|
|
12
24
|
dependent: :destroy,
|
|
13
25
|
inverse_of: :host
|
|
26
|
+
|
|
27
|
+
scoped_search on: :id, rename: :cve_scanned, only_explicit: true,
|
|
28
|
+
complete_value: CVE_SEARCH_BOOLEAN_VALUES,
|
|
29
|
+
operators: ['= ', '!= '], ext_method: :search_by_cve_scanned
|
|
30
|
+
|
|
31
|
+
CVE_SEARCH_COUNT_COLUMNS.each do |column|
|
|
32
|
+
scoped_search on: :id, rename: "cve_#{column}", only_explicit: true,
|
|
33
|
+
complete_enabled: false, validator: ScopedSearch::Validators::INTEGER,
|
|
34
|
+
operators: CVE_SEARCH_COMPARE_OPERATORS, ext_method: :"search_by_cve_#{column}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
scoped_search on: :id, rename: :cve_scanner, only_explicit: true,
|
|
38
|
+
complete_value: CVE_SEARCH_SCANNER_VALUES, operators: CVE_SEARCH_TEXT_OPERATORS,
|
|
39
|
+
ext_method: :search_by_cve_scanner
|
|
40
|
+
scoped_search on: :id, rename: :cve_source, only_explicit: true,
|
|
41
|
+
complete_value: CVE_SEARCH_SOURCE_VALUES, operators: CVE_SEARCH_TEXT_OPERATORS,
|
|
42
|
+
ext_method: :search_by_cve_source
|
|
43
|
+
scoped_search on: :id, rename: :cve_scanned_at, only_explicit: true,
|
|
44
|
+
complete_enabled: false, operators: CVE_SEARCH_COMPARE_OPERATORS,
|
|
45
|
+
ext_method: :search_by_cve_scanned_at
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module ClassMethods
|
|
49
|
+
def search_by_cve_scanned(_key, operator, value)
|
|
50
|
+
scanned = ::Foreman::Cast.to_bool(value)
|
|
51
|
+
return no_cve_scan_search_results if scanned.nil?
|
|
52
|
+
|
|
53
|
+
positive = searched_by_equal_operator?(operator) ? scanned : !scanned
|
|
54
|
+
cve_scan_host_conditions(latest_cve_scans, positive: positive)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def search_by_cve_total(_key, operator, value)
|
|
58
|
+
search_by_cve_count('total', operator, value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search_by_cve_critical(_key, operator, value)
|
|
62
|
+
search_by_cve_count('critical', operator, value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def search_by_cve_high(_key, operator, value)
|
|
66
|
+
search_by_cve_count('high', operator, value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def search_by_cve_medium(_key, operator, value)
|
|
70
|
+
search_by_cve_count('medium', operator, value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def search_by_cve_low(_key, operator, value)
|
|
74
|
+
search_by_cve_count('low', operator, value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def search_by_cve_scanner(_key, operator, value)
|
|
78
|
+
search_by_cve_text('scanner', operator, value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def search_by_cve_source(_key, operator, value)
|
|
82
|
+
search_by_cve_text('source', operator, value)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def search_by_cve_scanned_at(_key, operator, value)
|
|
86
|
+
search_by_cve_column('scanned_at', operator, value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def search_by_cve_count(column, operator, value)
|
|
92
|
+
search_by_cve_column(column, sql_operator(operator, CVE_SEARCH_COMPARE_SQL_OPERATORS), value.to_i)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def search_by_cve_text(column, operator, value)
|
|
96
|
+
operator = sql_operator(operator, CVE_SEARCH_TEXT_SQL_OPERATORS)
|
|
97
|
+
search_by_cve_column(column, operator, value_to_sql(operator, value))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def search_by_cve_column(column, operator, value)
|
|
101
|
+
scan_table = ::ForemanCveScanner::CveScan.table_name
|
|
102
|
+
condition = sanitize_sql_for_conditions(["#{scan_table}.#{column} #{operator} ?", value])
|
|
103
|
+
cve_scan_host_conditions(latest_cve_scans.where(condition))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def latest_cve_scans
|
|
107
|
+
scan_table = ::ForemanCveScanner::CveScan.table_name
|
|
108
|
+
latest_sql = ::ForemanCveScanner::CveScan
|
|
109
|
+
.select("DISTINCT ON (#{scan_table}.host_id) #{scan_table}.*")
|
|
110
|
+
.order("#{scan_table}.host_id, #{scan_table}.scanned_at DESC, #{scan_table}.id DESC")
|
|
111
|
+
.to_sql
|
|
112
|
+
|
|
113
|
+
::ForemanCveScanner::CveScan.from("(#{latest_sql}) #{scan_table}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def cve_scan_host_conditions(scans, positive: true)
|
|
117
|
+
host_operator = positive ? 'IN' : 'NOT IN'
|
|
118
|
+
{ conditions: "#{table_name}.id #{host_operator} (#{scans.select(:host_id).to_sql})" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def no_cve_scan_search_results
|
|
122
|
+
{ conditions: '1=0' }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def searched_by_equal_operator?(operator)
|
|
126
|
+
operator.to_s.strip == '='
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def sql_operator(operator, allowed_operators)
|
|
130
|
+
operator = operator.to_s.strip.upcase
|
|
131
|
+
return operator if allowed_operators.include?(operator)
|
|
132
|
+
|
|
133
|
+
raise ::ScopedSearch::QueryNotSupported, _("Unsupported CVE scan search operator '%s'") % operator
|
|
134
|
+
end
|
|
14
135
|
end
|
|
15
136
|
end
|
|
16
137
|
end
|
|
@@ -21,8 +21,8 @@ module ForemanCveScanner
|
|
|
21
21
|
def generate
|
|
22
22
|
@status = {}
|
|
23
23
|
@logs = []
|
|
24
|
-
@cve_report_data.each do |
|
|
25
|
-
@logs << generate_log_from_unified(id, cve)
|
|
24
|
+
@cve_report_data.each do |cve|
|
|
25
|
+
@logs << generate_log_from_unified(cve['id'], cve)
|
|
26
26
|
end
|
|
27
27
|
@logs
|
|
28
28
|
end
|
|
@@ -77,6 +77,7 @@ module ForemanCveScanner
|
|
|
77
77
|
|
|
78
78
|
def generate_grype_entry(entry)
|
|
79
79
|
{
|
|
80
|
+
'id' => entry['vulnerability']['id'],
|
|
80
81
|
'name' => entry['artifact']['name'],
|
|
81
82
|
'version' => entry['artifact']['version'],
|
|
82
83
|
'title' => entry['vulnerability']['description'].to_s.gsub(/[\[\]"\\]/, ''),
|
|
@@ -87,6 +88,7 @@ module ForemanCveScanner
|
|
|
87
88
|
|
|
88
89
|
def generate_trivy_entry(entry)
|
|
89
90
|
unified = {
|
|
91
|
+
'id' => entry['VulnerabilityID'],
|
|
90
92
|
'name' => entry['PkgName'],
|
|
91
93
|
'version' => entry['InstalledVersion'],
|
|
92
94
|
'title' => entry['Title'].to_s.gsub(/[\[\]"\\]/, ''),
|
|
@@ -99,22 +101,22 @@ module ForemanCveScanner
|
|
|
99
101
|
unified
|
|
100
102
|
end
|
|
101
103
|
|
|
102
|
-
# rubocop:disable Metrics/
|
|
104
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
103
105
|
def generate_unified_vuls
|
|
104
106
|
raise ::Foreman::Exception, _('Invalid CVE scanner report') unless @raw_data.key?('scan')
|
|
105
107
|
|
|
106
108
|
j = @raw_data['scan']
|
|
107
|
-
vuls =
|
|
109
|
+
vuls = []
|
|
108
110
|
if j.key?('matches') # Grype
|
|
109
111
|
j['matches'].each do |vul|
|
|
110
|
-
vuls
|
|
112
|
+
vuls << generate_grype_entry(vul)
|
|
111
113
|
end
|
|
112
114
|
elsif j.key?('Results') # Trivy
|
|
113
115
|
j['Results'].each do |r|
|
|
114
116
|
next unless r.key? 'Vulnerabilities'
|
|
115
117
|
|
|
116
118
|
r['Vulnerabilities'].each do |vul|
|
|
117
|
-
vuls
|
|
119
|
+
vuls << generate_trivy_entry(vul)
|
|
118
120
|
end
|
|
119
121
|
end
|
|
120
122
|
else
|
|
@@ -124,6 +126,6 @@ module ForemanCveScanner
|
|
|
124
126
|
|
|
125
127
|
vuls
|
|
126
128
|
end
|
|
127
|
-
# rubocop:enable Metrics/
|
|
129
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
128
130
|
end
|
|
129
131
|
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
class KatelloFixAvailability
|
|
5
|
+
STATUS_INSTALLABLE = 'installable'
|
|
6
|
+
STATUS_APPLICABLE = 'applicable'
|
|
7
|
+
STATUS_UNAVAILABLE = 'unavailable'
|
|
8
|
+
STATUS_UNKNOWN = 'unknown'
|
|
9
|
+
|
|
10
|
+
CONFIDENCE_PACKAGE_MATCH = 'package_match'
|
|
11
|
+
CONFIDENCE_CVE_ONLY = 'cve_only'
|
|
12
|
+
|
|
13
|
+
UNKNOWN_MISSING_CVE = 'missing_cve'
|
|
14
|
+
UNKNOWN_QUERY_FAILED = 'query_failed'
|
|
15
|
+
|
|
16
|
+
def initialize(host, findings)
|
|
17
|
+
@host = host
|
|
18
|
+
@findings = Array(findings)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
return findings_without_enrichment unless katello_available? && enabled?
|
|
23
|
+
|
|
24
|
+
decorate_with_katello
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error(
|
|
27
|
+
"Katello CVE fix lookup failed for host_id=#{@host&.id}: #{e.class}: #{e.message}"
|
|
28
|
+
)
|
|
29
|
+
decorate_all_unknown(UNKNOWN_QUERY_FAILED)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def decorate_with_katello
|
|
35
|
+
cve_ids = @findings.filter_map { |finding| cve_id_for(finding) }.uniq
|
|
36
|
+
return decorate_all_unknown(UNKNOWN_MISSING_CVE) if cve_ids.empty?
|
|
37
|
+
|
|
38
|
+
installable_by_cve = errata_by_cve(installable_errata(cve_ids))
|
|
39
|
+
applicable_by_cve = errata_by_cve(applicable_errata(cve_ids))
|
|
40
|
+
|
|
41
|
+
@findings.map do |finding|
|
|
42
|
+
cve_id = cve_id_for(finding)
|
|
43
|
+
next decorate_finding(finding, unknown_fix(UNKNOWN_MISSING_CVE)) if cve_id.blank?
|
|
44
|
+
|
|
45
|
+
fix = fix_for(finding, installable_by_cve[cve_id], applicable_by_cve[cve_id])
|
|
46
|
+
decorate_finding(finding, fix)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fix_for(finding, installable_errata, applicable_errata)
|
|
51
|
+
return matched_fix(STATUS_INSTALLABLE, finding, installable_errata) if installable_errata.present?
|
|
52
|
+
return matched_fix(STATUS_APPLICABLE, finding, applicable_errata) if applicable_errata.present?
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
'status' => STATUS_UNAVAILABLE,
|
|
56
|
+
'reason' => nil,
|
|
57
|
+
'confidence' => nil,
|
|
58
|
+
'errata' => [],
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matched_fix(status, finding, errata)
|
|
63
|
+
matched_errata = errata_matching_package(errata, package_name_for(finding))
|
|
64
|
+
selected_errata = matched_errata.presence || errata
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
'status' => status,
|
|
68
|
+
'reason' => nil,
|
|
69
|
+
'confidence' => confidence_for(matched_errata),
|
|
70
|
+
'errata' => selected_errata.map { |erratum| erratum_payload(erratum) },
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def confidence_for(matched_errata)
|
|
75
|
+
matched_errata.present? ? CONFIDENCE_PACKAGE_MATCH : CONFIDENCE_CVE_ONLY
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def decorate_all_unknown(reason)
|
|
79
|
+
@findings.map { |finding| decorate_finding(finding, unknown_fix(reason)) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def findings_without_enrichment
|
|
83
|
+
@findings.map { |finding| finding.to_h.deep_dup }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def decorate_finding(finding, fix)
|
|
87
|
+
finding.to_h.deep_dup.merge('katello_fix' => fix)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unknown_fix(reason)
|
|
91
|
+
{
|
|
92
|
+
'status' => STATUS_UNKNOWN,
|
|
93
|
+
'reason' => reason,
|
|
94
|
+
'confidence' => nil,
|
|
95
|
+
'errata' => [],
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def installable_errata(cve_ids)
|
|
100
|
+
errata_matching_cves(host_installable_errata, cve_ids)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def applicable_errata(cve_ids)
|
|
104
|
+
errata_matching_cves(host_applicable_errata, cve_ids)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def host_installable_errata
|
|
108
|
+
return ::Katello::Erratum.none unless host_content_facet
|
|
109
|
+
|
|
110
|
+
host_content_facet.installable_errata.security
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def host_applicable_errata
|
|
114
|
+
return ::Katello::Erratum.none unless host_content_facet
|
|
115
|
+
|
|
116
|
+
host_content_facet.applicable_errata.security
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def host_content_facet
|
|
120
|
+
return nil unless @host.respond_to?(:content_facet)
|
|
121
|
+
|
|
122
|
+
@host.content_facet
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def errata_matching_cves(scope, cve_ids)
|
|
126
|
+
scope = scope.includes(:cves, :packages, :deb_packages)
|
|
127
|
+
cve_filtered_errata(scope, cve_ids).distinct
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cve_filtered_errata(scope, cve_ids)
|
|
131
|
+
return cve_join_filtered_errata(scope, cve_ids) unless scope.respond_to?(:search_for)
|
|
132
|
+
|
|
133
|
+
scope.search_for(cve_search_query(cve_ids))
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
Rails.logger.debug do
|
|
136
|
+
"Katello CVE scoped search failed for host_id=#{@host&.id}: #{e.class}: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
cve_join_filtered_errata(scope, cve_ids)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cve_join_filtered_errata(scope, cve_ids)
|
|
142
|
+
scope
|
|
143
|
+
.joins(:cves)
|
|
144
|
+
.where(::Katello::ErratumCve.table_name => { cve_id: cve_ids })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def cve_search_query(cve_ids)
|
|
148
|
+
cve_ids.map { |cve_id| %(cve = "#{escape_search_value(cve_id)}") }.join(' OR ')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def escape_search_value(value)
|
|
152
|
+
value.to_s.gsub(/[\\"]/) { |char| "\\#{char}" }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def errata_by_cve(errata)
|
|
156
|
+
grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
157
|
+
errata.each do |erratum|
|
|
158
|
+
erratum.cves.each { |cve| grouped[cve.cve_id] << erratum }
|
|
159
|
+
end
|
|
160
|
+
grouped
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def errata_matching_package(errata, package_name)
|
|
164
|
+
return [] if package_name.blank?
|
|
165
|
+
|
|
166
|
+
normalized_package_name = normalize_package_name(package_name)
|
|
167
|
+
errata.select do |erratum|
|
|
168
|
+
erratum_package_names(erratum).any? do |name|
|
|
169
|
+
normalize_package_name(name) == normalized_package_name
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def erratum_payload(erratum)
|
|
175
|
+
{
|
|
176
|
+
'id' => erratum.id,
|
|
177
|
+
'errata_id' => erratum.errata_id,
|
|
178
|
+
'title' => erratum.title,
|
|
179
|
+
'severity' => erratum.severity,
|
|
180
|
+
'packages' => erratum_package_names(erratum),
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def erratum_package_names(erratum)
|
|
185
|
+
(erratum.packages.map(&:name) + erratum.deb_packages.map(&:name)).compact.uniq
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cve_id_for(finding)
|
|
189
|
+
finding.to_h['id'].presence
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def package_name_for(finding)
|
|
193
|
+
finding.to_h['name'].presence
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_package_name(name)
|
|
197
|
+
name.to_s.downcase.strip
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def katello_available?
|
|
201
|
+
Foreman::Plugin.installed?(:katello)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def enabled?
|
|
205
|
+
Setting[:enable_katello_cve_fix_availability]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
class LatestScanSummaries
|
|
5
|
+
def initialize(host_scope:, host_ids:)
|
|
6
|
+
@host_scope = host_scope
|
|
7
|
+
@host_ids = host_ids
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
latest_scans.map { |scan| summary_for(scan) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
attr_reader :host_scope, :host_ids
|
|
17
|
+
|
|
18
|
+
def latest_scans
|
|
19
|
+
ids = requested_host_ids
|
|
20
|
+
return [] if ids.empty?
|
|
21
|
+
|
|
22
|
+
table = ::ForemanCveScanner::CveScan.table_name
|
|
23
|
+
::ForemanCveScanner::CveScan.where(host_id: ids)
|
|
24
|
+
.select("DISTINCT ON (host_id) #{table}.*")
|
|
25
|
+
.order(:host_id, scanned_at: :desc, id: :desc)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def requested_host_ids
|
|
29
|
+
ids = Array(host_ids).map(&:to_s).map(&:strip).grep(/\A\d+\z/).uniq
|
|
30
|
+
return [] if ids.empty?
|
|
31
|
+
|
|
32
|
+
host_scope.where(id: ids).pluck(:id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def summary_for(scan)
|
|
36
|
+
{
|
|
37
|
+
id: scan.id,
|
|
38
|
+
host_id: scan.host_id,
|
|
39
|
+
scanner: scan.scanner,
|
|
40
|
+
source: scan.source,
|
|
41
|
+
scanned_at: scan.scanned_at,
|
|
42
|
+
total: scan.total,
|
|
43
|
+
critical: scan.critical,
|
|
44
|
+
high: scan.high,
|
|
45
|
+
medium: scan.medium,
|
|
46
|
+
low: scan.low,
|
|
47
|
+
summary: scan.summary,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -64,18 +64,10 @@ module ForemanCveScanner
|
|
|
64
64
|
scan = ::ForemanCveScanner::CveScan.create!(
|
|
65
65
|
build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
|
|
66
66
|
)
|
|
67
|
-
|
|
68
|
-
cleanup_old_scans(host)
|
|
67
|
+
::ForemanCveScanner::ScanPostProcessor.new(host).call
|
|
69
68
|
scan
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
def refresh_host_status(host)
|
|
73
|
-
status = ::HostStatus::CveStatus.find_or_initialize_by(host: host)
|
|
74
|
-
status.refresh!
|
|
75
|
-
rescue StandardError => e
|
|
76
|
-
Rails.logger.error("CVE status refresh failed for host_id=#{host.id}: #{e}")
|
|
77
|
-
end
|
|
78
|
-
|
|
79
71
|
def build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
|
|
80
72
|
summary = metrics.merge('worst' => ::ForemanCveScanner::CveScan.worst_severity(metrics))
|
|
81
73
|
{
|
|
@@ -95,13 +87,7 @@ module ForemanCveScanner
|
|
|
95
87
|
end
|
|
96
88
|
|
|
97
89
|
def build_findings(scanner)
|
|
98
|
-
scanner.unified_vulnerabilities.map
|
|
99
|
-
entry.merge('id' => id)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def cleanup_old_scans(host)
|
|
104
|
-
::ForemanCveScanner::ScanCleanup.new(scope: host.cve_scans).cleanup!
|
|
90
|
+
scanner.unified_vulnerabilities.map(&:dup)
|
|
105
91
|
end
|
|
106
92
|
end
|
|
107
93
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
class ScanPostProcessor
|
|
5
|
+
def initialize(host)
|
|
6
|
+
@host = host
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
refresh_host_status
|
|
11
|
+
cleanup_old_scans
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
attr_reader :host
|
|
17
|
+
|
|
18
|
+
def refresh_host_status
|
|
19
|
+
status = ::HostStatus::CveStatus.find_or_initialize_by(host: host)
|
|
20
|
+
status.refresh!
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
Rails.logger.error("CVE status refresh failed for host_id=#{host.id}: #{e}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cleanup_old_scans
|
|
26
|
+
::ForemanCveScanner::ScanCleanup.new(scope: host.cve_scans).cleanup!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|