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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -4
  3. data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
  4. data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
  5. data/app/models/host_status/cve_status.rb +4 -0
  6. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
  7. data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
  8. data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
  9. data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
  10. data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
  11. data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
  12. data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
  13. data/config/routes.rb +1 -0
  14. data/lib/foreman_cve_scanner/engine.rb +28 -19
  15. data/lib/foreman_cve_scanner/version.rb +1 -1
  16. data/test/controllers/api/v2/cve_scans_controller_test.rb +96 -0
  17. data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
  18. data/test/models/host_status/cve_status_test.rb +8 -0
  19. data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
  20. data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
  21. data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
  22. data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
  23. data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
  24. data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
  25. data/webpack/components/CveCompareModal.js +9 -28
  26. data/webpack/components/CveDetailsCard.js +54 -65
  27. data/webpack/components/CveFindingsModal.js +15 -234
  28. data/webpack/components/CveFindingsView.js +307 -0
  29. data/webpack/components/CveKatelloFixLink.js +59 -0
  30. data/webpack/components/CveLink.js +28 -0
  31. data/webpack/components/CveScansReports.js +25 -62
  32. data/webpack/components/CveScansTab.js +42 -9
  33. data/webpack/components/CveSummaryCell.js +31 -19
  34. data/webpack/components/CveTrendChart.js +1 -16
  35. data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
  36. data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
  37. data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
  38. data/webpack/components/__tests__/CveScansTab.test.js +141 -147
  39. data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
  40. data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
  41. data/webpack/components/__tests__/cve_helpers.test.js +84 -0
  42. data/webpack/components/cve_helpers.js +114 -0
  43. data/webpack/components/cve_scans.scss +49 -0
  44. data/webpack/components/cve_summary_store.js +91 -0
  45. data/webpack/components/useSortBy.js +34 -0
  46. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7158a1750507e0b726300cce1fbed2c725aaa81e8e4c9d58733b059ef7729f10
4
- data.tar.gz: 432bce08dd6d67c7343ac4e5218e2dfe552fc3cbdf9767f4dca423c9fec5b177
3
+ metadata.gz: 23c87498422d0e807ebe6db02c5390db04cc8bc8d267eaefc8477ebbb78846ee
4
+ data.tar.gz: f84b770d2f95428b58e6981ca58a1753f98f4592c652a25376374bcfa45badb1
5
5
  SHA512:
6
- metadata.gz: 45cad3cdfac54911ad7196a4d0f8ad686ae9f6ea671b73c3e002287e733ef9ccce05854b41796e44c836e426890406fecad678ab42bda44baed5b9305d74e698
7
- data.tar.gz: 9e2643ac1f5e2ca20880c58526514dc99e170f345bb6aa62160c21cc9adada4e2707efad8783a1fdf49b31be01b5946dcb9079ddb9e7ee562bb147e8e5de6dcc
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
@@ -53,6 +53,10 @@ module HostStatus
53
53
  end
54
54
  end
55
55
 
56
+ def relevant?(_options = {})
57
+ persisted?
58
+ end
59
+
56
60
  private
57
61
 
58
62
  def latest_scan
@@ -21,8 +21,8 @@ module ForemanCveScanner
21
21
  def generate
22
22
  @status = {}
23
23
  @logs = []
24
- @cve_report_data.each do |id, cve|
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/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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[vul['vulnerability']['id']] = generate_grype_entry(vul)
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[vul['VulnerabilityID']] = generate_trivy_entry(vul)
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/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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
- refresh_host_status(host)
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 do |id, entry|
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
@@ -4,4 +4,6 @@ object @cve_scan
4
4
 
5
5
  extends 'api/v2/cve_scans/base'
6
6
 
7
- attributes :findings, :updated_at
7
+ attributes :updated_at
8
+
9
+ node(:findings) { |scan| @cve_scan_findings || scan.findings }