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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d2e1f0a7bfd0f9bf0856ef7acb0a7aeb238dd35dfedccf2acd833824ba59790
4
- data.tar.gz: c91bf1095a846cc47c1d73e551065c8fa80b0e7a1d18b474684b1b07d5127a38
3
+ metadata.gz: f5981f5ba5ce1973957b47fafc885a8ed8327addd63ba39d3a00a321011d7cf0
4
+ data.tar.gz: b51390f31766614e1ef417aa8461e65927305ef5426b3bee061f3e566b5432a8
5
5
  SHA512:
6
- metadata.gz: be53822e1090aed249a18a64ad21b12f4142092fc8b9a8dafe7f4f204dbca0ab6f11893501480c1df5507a030ba89921b172b5bb80c457f8a4a8a321d015290f
7
- data.tar.gz: 674c34da1ff2274566619e5ea9690cbf4537f285e95ec5f9d880272f081e5bae57e26e6b3aa079bdc246ffb7b758995a82a48a1aeed5e27d1732f34802b587fb
6
+ metadata.gz: b6c52f57ed6247e1755775962a46cd2856da13a6ade5e2ab66c41f03140730b3596c147ce61f700991626d797919278f1d5d175ad8ad7f7d7a6388cfe4a2013f
7
+ data.tar.gz: 9e9e12fdab5950f9f28a136da8c8f52f9c003f82da2788d752471e363e06514f7576c95118d006e2bf8d7ef2126f74d04eca6e2183ff1e0bf6fc0f55a1601a2f
data/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # ForemanCveScanner
2
2
 
3
- Version: 0.5.0
4
-
5
3
  Plugin to:
6
4
  1. install Trivy/Grype on a host using Foreman Remote Execution (REX)
7
5
  2. run CVE scans via REX and parse the results
@@ -10,18 +8,19 @@ Plugin to:
10
8
  5. show CVE findings in the Host details UI
11
9
  6. show summary status in the Hosts list
12
10
 
13
- ![image](https://github.com/ATIX-AG/foreman_cve_scanner/assets/25485845/85e3b676-7d90-41e5-bea5-7e0b5f4a685c)
14
-
15
11
  ## Features
16
12
 
13
+ - Support for Trivy and Grype scanners
17
14
  - REX job templates for installing and running CVE scans
18
15
  - JSON output parsing with robust handling of chunked stdout
19
16
  - Per-host scan history with totals and severity counts
20
17
  - API endpoints for scan history and latest scan
18
+ - API endpoint to push external CVE scans
21
19
  - Host Details card with findings and modal view
22
20
  - Host Details tab “CVE scans” for full history
23
21
  - Hosts overview list column “CVE” with quick summary and modal
24
22
  - Integrated in the Host Status
23
+ - Export scan results as CSV
25
24
 
26
25
  ## Installation
27
26
 
@@ -33,14 +32,111 @@ for how to install Foreman plugins
33
32
  - Run the REX job to install Trivy and/or Grype
34
33
  - Run the REX job to scan a host
35
34
  - You can configure recurring CVE scans via `Monitor -> Jobs`
35
+ - You can configure the default scanner for the `Run CVE scan` template via `Administer -> Settings -> CVE Scanner -> Preferred CVE scanner`
36
+ - 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
36
37
  - View results in:
37
38
  - Hosts overview list column “CVE” (use 'Manage Columns' to enable)
38
39
  - Host Details card and modal
39
40
  - Host Details tab “CVE scans”
40
41
 
42
+ ## External Push API
43
+
44
+ Push a normalized CVE scan for a host with:
45
+
46
+ - `POST /api/v2/hosts/:host_id/cve_scans/import`
47
+
48
+ Required permission:
49
+
50
+ - `import_cve_scans`
51
+
52
+ Required payload structure:
53
+
54
+ ```json
55
+ {
56
+ "cve_scan": {
57
+ "scanner": "custom-scanner",
58
+ "source": "external",
59
+ "scanned_at": "2026-05-14T08:00:00Z",
60
+ "findings": [
61
+ {
62
+ "id": "CVE-2024-8373",
63
+ "name": "angular",
64
+ "severity": "LOW",
65
+ "version": "1.8.2",
66
+ "fixed": "open",
67
+ "status": "affected",
68
+ "title": "angular: From NVD collector",
69
+ "published": "2024-09-09T15:15:12.887Z",
70
+ "url": "https://avd.aquasec.com/nvd/cve-2024-8373"
71
+ },
72
+ {
73
+ "id": "CVE-2024-9991",
74
+ "name": "openssl",
75
+ "severity": "CRITICAL",
76
+ "version": "3.0.1",
77
+ "fixed": "3.0.8",
78
+ "status": "affected",
79
+ "title": "openssl: Example critical issue",
80
+ "published": "2024-10-01T10:00:00Z",
81
+ "url": "https://example.test/CVE-2024-9991"
82
+ },
83
+ {
84
+ "id": "CVE-2024-9992",
85
+ "name": "curl",
86
+ "severity": "HIGH",
87
+ "version": "8.5.0",
88
+ "fixed": "8.5.1",
89
+ "status": "affected",
90
+ "title": "curl: Example high issue",
91
+ "published": "2024-10-02T10:00:00Z",
92
+ "url": "https://example.test/CVE-2024-9992"
93
+ },
94
+ {
95
+ "id": "CVE-2024-9993",
96
+ "name": "tar",
97
+ "severity": "MEDIUM",
98
+ "version": "1.35",
99
+ "fixed": "1.36",
100
+ "status": "affected",
101
+ "title": "tar: Example medium issue",
102
+ "published": "2024-10-03T10:00:00Z",
103
+ "url": "https://example.test/CVE-2024-9993"
104
+ }
105
+ ]
106
+ }
107
+ }
108
+ ```
109
+
110
+ Notes:
111
+
112
+ - `scanner` is a free-form producer name and can be an unknown external tool
113
+ - `source` is stored and shown in the UI, for example `external`
114
+ - `scanned_at` is required and is the only scan timestamp used in the UI
115
+ - `summary` and severity counters are calculated on the server from `findings`
116
+ - `raw` is not part of the public API contract
117
+
118
+ ## Retention
119
+
120
+ You can configure automatic CVE scan cleanup with the plugin setting:
121
+
122
+ - `Administer -> Settings -> CVE Scanner -> Delete CVE scans after X days`
123
+
124
+ Behavior:
125
+
126
+ - `0` disables automatic cleanup
127
+ - scans older than the configured number of days are deleted
128
+ - the cleanup runs after imports and can also be triggered manually
129
+
130
+ Manual cleanup task:
131
+
132
+ - `bundle exec rake foreman_cve_scanner:cleanup_scans`
133
+
134
+ Override the configured retention for one run:
135
+
136
+ - `bundle exec rake foreman_cve_scanner:cleanup_scans DAYS=30`
137
+
41
138
  ## TODO
42
139
 
43
- - Export scan results
44
140
  - Deliver Trivy/Grype via Katello
45
141
 
46
142
  ## Contributing
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'csv'
4
+
3
5
  module Api
4
6
  module V2
5
7
  # API controller for CVE scans per host.
@@ -35,6 +37,55 @@ module Api
35
37
  @cve_scan = resource_class.for_host(@host.id).find(params[:id])
36
38
  end
37
39
 
40
+ api :POST, '/hosts/:host_id/cve_scans/import', N_('Import CVE scan for a host')
41
+ description N_('Imports a CVE scan for a host from normalized findings data.')
42
+ param :host_id, :identifier, required: true
43
+ param :cve_scan, Hash, required: true do
44
+ param :scanner, String, required: true
45
+ param :source, String, required: true
46
+ param :scanned_at, String, required: true
47
+ param :findings, Array, required: true, desc: N_('Array of CVE finding objects.') do
48
+ param :id, String, required: true, desc: N_('CVE identifier')
49
+ param :name, String, required: true, desc: N_('Affected package name')
50
+ param :severity, String, required: true, desc: N_('Severity, for example CRITICAL, HIGH, MEDIUM or LOW')
51
+ param :version, String, required: false, desc: N_('Affected or installed version')
52
+ param :fixed, String, required: false, desc: N_('Fixed version or open')
53
+ param :status, String, required: false, desc: N_('Scanner-specific status such as affected or fixed')
54
+ param :title, String, required: false, desc: N_('Human-readable finding title')
55
+ param :published, String, required: false, desc: N_('Published timestamp')
56
+ param :url, String, required: false, desc: N_('Reference URL')
57
+ end
58
+ end
59
+ def import
60
+ @cve_scan = resource_class.new(build_import_attributes)
61
+
62
+ if @cve_scan.save
63
+ render 'api/v2/cve_scans/show', status: :created
64
+ else
65
+ render(
66
+ json: { error: { message: @cve_scan.errors.full_messages.to_sentence } },
67
+ status: :unprocessable_entity
68
+ )
69
+ end
70
+ end
71
+
72
+ api :GET, '/hosts/:host_id/cve_scans/compare', N_('Compare two CVE scans for a host')
73
+ description N_('Returns a comparison between two CVE scans for a host.')
74
+ param :host_id, :identifier, required: true
75
+ param :first_id, :identifier, required: true
76
+ param :second_id, :identifier, required: true
77
+ def compare
78
+ return compare_params_missing unless params[:first_id].present? && params[:second_id].present?
79
+
80
+ first_scan = find_scan_for_host(params[:first_id])
81
+ return unless first_scan
82
+
83
+ second_scan = find_scan_for_host(params[:second_id])
84
+ return unless second_scan
85
+
86
+ render json: ::ForemanCveScanner::ScanComparison.compare(first_scan, second_scan)
87
+ end
88
+
38
89
  api :DELETE, '/hosts/:host_id/cve_scans/:id', N_('Delete a CVE scan')
39
90
  description N_('Deletes a specific CVE scan by id for a host.')
40
91
  param :host_id, :identifier, required: true
@@ -44,8 +95,35 @@ module Api
44
95
  process_response @cve_scan.destroy
45
96
  end
46
97
 
98
+ api :GET, '/hosts/:host_id/cve_scans/:id/export', N_('Export a CVE scan as CSV')
99
+ description N_('Exports the findings of a specific CVE scan as CSV.')
100
+ param :host_id, :identifier, required: true
101
+ param :id, :identifier, required: true
102
+ def export
103
+ @cve_scan = resource_class.for_host(@host.id).find(params[:id])
104
+
105
+ send_data(
106
+ findings_csv(@cve_scan),
107
+ filename: export_filename(@cve_scan),
108
+ type: 'text/csv; charset=utf-8',
109
+ disposition: 'attachment'
110
+ )
111
+ end
112
+
47
113
  private
48
114
 
115
+ CSV_HEADERS = [
116
+ 'Severity',
117
+ 'Published',
118
+ 'Package',
119
+ 'Affected version',
120
+ 'Fixed version',
121
+ 'Status',
122
+ 'CVE',
123
+ 'Title',
124
+ 'URL',
125
+ ].freeze
126
+
49
127
  def cve_scans_index_scope
50
128
  scope = resource_class.for_host(@host.id).recent_first
51
129
  return scope unless respond_to?(:resource_scope_for_index, true)
@@ -55,12 +133,103 @@ module Api
55
133
  end
56
134
 
57
135
  def find_host
58
- scope = ::Host::Base.authorized(:view_hosts)
136
+ scope = ::Host::Base.authorized(host_permission)
59
137
  @host = scope.find_by(name: params[:host_id]) || scope.find_by(id: params[:host_id])
60
138
  return if @host.present?
61
139
 
62
140
  not_found
63
141
  end
142
+
143
+ def findings_csv(scan)
144
+ CSV.generate do |csv|
145
+ csv << CSV_HEADERS
146
+ Array(scan.findings).each { |finding| csv << csv_row_for(finding) }
147
+ end
148
+ end
149
+
150
+ def export_filename(scan)
151
+ timestamp = scan.scanned_at&.utc&.strftime('%Y-%m-%d-%H%M%S')
152
+ "#{['cve-report', @host.shortname, scan.id, timestamp].compact.join('-')}.csv"
153
+ end
154
+
155
+ def csv_row_for(finding)
156
+ [
157
+ finding['severity'],
158
+ finding['published'],
159
+ finding['name'],
160
+ finding['version'],
161
+ finding['fixed'],
162
+ finding['status'].presence || 'open',
163
+ finding['id'],
164
+ finding['title'],
165
+ finding['url'],
166
+ ]
167
+ end
168
+
169
+ def compare_params_missing
170
+ render(
171
+ json: { error: { message: 'first_id and second_id are required' } },
172
+ status: :unprocessable_entity
173
+ )
174
+ end
175
+
176
+ def scan_not_found(id)
177
+ render(
178
+ json: { error: { message: "CVE scan with id '#{id}' not found for this host" } },
179
+ status: :not_found
180
+ )
181
+ end
182
+
183
+ def find_scan_for_host(scan_id)
184
+ scan = resource_class.for_host(@host.id).find_by(id: scan_id)
185
+ scan_not_found(scan_id) unless scan
186
+ scan
187
+ end
188
+
189
+ def build_import_attributes
190
+ payload = import_params.to_h
191
+ findings = Array(payload['findings']).map(&:to_h)
192
+ metrics = findings_metrics(findings)
193
+
194
+ {
195
+ host: @host,
196
+ scanner: payload['scanner'],
197
+ source: payload['source'],
198
+ scanned_at: payload['scanned_at'],
199
+ raw: payload,
200
+ summary: metrics.merge('worst' => ::ForemanCveScanner::CveScan.worst_severity(metrics)),
201
+ findings: findings,
202
+ total: metrics['total'],
203
+ critical: metrics['critical'],
204
+ high: metrics['high'],
205
+ medium: metrics['medium'],
206
+ low: metrics['low'],
207
+ }
208
+ end
209
+
210
+ def import_params
211
+ params.require(:cve_scan).permit(
212
+ :scanner,
213
+ :source,
214
+ :scanned_at,
215
+ findings: %i[id name severity version fixed status title published url]
216
+ )
217
+ end
218
+
219
+ def findings_metrics(findings)
220
+ severities = findings.map { |finding| finding['severity'].to_s.upcase }
221
+ metrics = ::ForemanCveScanner::CveScan::SEVERITY_LEVELS.index_with do |severity|
222
+ severities.count(severity.upcase)
223
+ end
224
+ metrics.merge('total' => findings.size)
225
+ end
226
+
227
+ def host_permission
228
+ return :import_cve_scans if action_name == 'import'
229
+ return :destroy_cve_scans if action_name == 'destroy'
230
+
231
+ :view_cve_scans
232
+ end
64
233
  end
65
234
  end
66
235
  end
@@ -5,11 +5,24 @@ module ForemanCveScanner
5
5
  class CveScan < ApplicationRecord
6
6
  self.table_name = 'foreman_cve_scanner_cve_scans'
7
7
 
8
+ SEVERITY_LEVELS = %w[critical high medium low].freeze
9
+
8
10
  belongs_to :host, class_name: '::Host::Managed'
9
11
 
10
- validates :host_id, :scanner, :raw, :summary, :findings, presence: true
12
+ validates :host_id, :scanner, :source, :scanned_at, :raw, :summary, presence: true
13
+ validate :findings_must_be_present
11
14
 
12
15
  scope :for_host, ->(host_id) { where(host_id: host_id) }
13
- scope :recent_first, -> { order(created_at: :desc, id: :desc) }
16
+ scope :recent_first, -> { order(scanned_at: :desc, id: :desc) }
17
+
18
+ def self.worst_severity(metrics)
19
+ SEVERITY_LEVELS.find { |severity| metrics[severity].to_i.positive? } || 'none'
20
+ end
21
+
22
+ private
23
+
24
+ def findings_must_be_present
25
+ errors.add(:findings, :blank) if findings.nil?
26
+ end
14
27
  end
15
28
  end
@@ -20,6 +20,8 @@ module HostStatus
20
20
  N_('Medium CVEs')
21
21
  when :low
22
22
  N_('Low CVEs')
23
+ when :clean
24
+ N_('No CVEs found')
23
25
  when :none
24
26
  N_('No CVE scans')
25
27
  else
@@ -65,7 +67,7 @@ module HostStatus
65
67
  return :medium if scan.medium.to_i.positive?
66
68
  return :low if scan.low.to_i.positive?
67
69
 
68
- :none
70
+ :clean
69
71
  end
70
72
  end
71
73
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForemanCveScanner
4
+ module ProfilesUploader
5
+ def upload
6
+ result = super
7
+ return result unless result && Setting[:run_cve_scan_after_host_profiles_upload]
8
+
9
+ begin
10
+ composer = ::JobInvocationComposer.for_feature(
11
+ :run_cve_scan,
12
+ @host,
13
+ scanner: Setting[:preferred_cve_scanner]
14
+ )
15
+ composer.trigger!
16
+ rescue StandardError => e
17
+ Rails.logger.error(
18
+ "Failed to schedule CVE scan after host profiles upload: #{e.class}: #{e.message}"
19
+ )
20
+ end
21
+
22
+ result
23
+ end
24
+ end
25
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module ForemanCveScanner
4
4
  # Parses raw CVE scanner reports and produces unified logs/metrics.
5
- # rubocop:disable Metrics/ClassLength
6
5
  class CveReportScanner
7
6
  SEVERITY_ORDER = %w[CRITICAL HIGH MEDIUM LOW UNKNOWN].freeze
8
7
 
@@ -42,8 +41,7 @@ module ForemanCveScanner
42
41
  end
43
42
 
44
43
  def metrics
45
- known = %w[critical high medium low]
46
- res = @status.slice(*known)
44
+ res = @status.slice(*::ForemanCveScanner::CveScan::SEVERITY_LEVELS)
47
45
  res['total'] = res.values.sum
48
46
  res
49
47
  end
@@ -81,7 +79,7 @@ module ForemanCveScanner
81
79
  {
82
80
  'name' => entry['artifact']['name'],
83
81
  'version' => entry['artifact']['version'],
84
- 'title' => entry['vulnerability']['description'].gsub(/[\[\]"\\]/, ''),
82
+ 'title' => entry['vulnerability']['description'].to_s.gsub(/[\[\]"\\]/, ''),
85
83
  'severity' => entry['vulnerability']['severity'],
86
84
  'url' => entry['vulnerability']['dataSource'],
87
85
  }
@@ -91,7 +89,7 @@ module ForemanCveScanner
91
89
  unified = {
92
90
  'name' => entry['PkgName'],
93
91
  'version' => entry['InstalledVersion'],
94
- 'title' => entry['Title'].gsub(/[\[\]"\\]/, ''),
92
+ 'title' => entry['Title'].to_s.gsub(/[\[\]"\\]/, ''),
95
93
  'severity' => entry['Severity'],
96
94
  'url' => entry['PrimaryURL'],
97
95
  'status' => entry['Status'],
@@ -101,7 +99,7 @@ module ForemanCveScanner
101
99
  unified
102
100
  end
103
101
 
104
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
102
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
105
103
  def generate_unified_vuls
106
104
  raise ::Foreman::Exception, _('Invalid CVE scanner report') unless @raw_data.key?('scan')
107
105
 
@@ -126,7 +124,6 @@ module ForemanCveScanner
126
124
 
127
125
  vuls
128
126
  end
129
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
127
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
130
128
  end
131
- # rubocop:enable Metrics/ClassLength
132
129
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForemanCveScanner
4
+ class ScanCleanup
5
+ SETTING_NAME = :cve_scan_delete_after_days
6
+
7
+ def initialize(scope: ::ForemanCveScanner::CveScan.all, days: nil)
8
+ @scope = scope
9
+ @days = days
10
+ end
11
+
12
+ def cleanup!
13
+ return 0 unless retention_days.positive?
14
+
15
+ deleted = @scope.where('scanned_at < ?', retention_cutoff).delete_all
16
+ Rails.logger.info("Deleted #{deleted} CVE scans older than #{retention_days} days") if deleted.positive?
17
+ deleted
18
+ end
19
+
20
+ private
21
+
22
+ def retention_days
23
+ @retention_days ||= configured_days.to_i
24
+ end
25
+
26
+ def configured_days
27
+ @days.nil? ? Setting[SETTING_NAME] : @days
28
+ end
29
+
30
+ def retention_cutoff
31
+ retention_days.days.ago
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForemanCveScanner
4
+ class ScanComparison
5
+ DIFF_FIELDS = %w[severity version fixed status title published url].freeze
6
+
7
+ def self.compare(first_scan, second_scan)
8
+ new(first_scan, second_scan).compare
9
+ end
10
+
11
+ def initialize(first_scan, second_scan)
12
+ @first_scan = first_scan
13
+ @second_scan = second_scan
14
+ end
15
+
16
+ def compare
17
+ rows = comparison_rows
18
+
19
+ {
20
+ previous: scan_payload(@first_scan),
21
+ current: scan_payload(@second_scan),
22
+ summary: summarize(rows),
23
+ results: rows,
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def comparison_rows
30
+ identities.map do |identity|
31
+ previous_finding = previous_map[identity]
32
+ current_finding = current_map[identity]
33
+ current_values = current_finding || previous_finding
34
+ diff = diff_for(previous_finding, current_finding)
35
+
36
+ {
37
+ key: identity,
38
+ status: comparison_status_for(previous_finding, current_finding, diff),
39
+ id: value_for(current_values, 'id'),
40
+ name: value_for(current_values, 'name'),
41
+ title: value_for(current_values, 'title'),
42
+ published: value_for(current_values, 'published'),
43
+ severity: value_for(current_values, 'severity'),
44
+ version: value_for(current_values, 'version'),
45
+ fixed: value_for(current_values, 'fixed'),
46
+ scan_status: normalized_value_for(current_values, 'status'),
47
+ url: value_for(current_values, 'url'),
48
+ diff: diff,
49
+ }
50
+ end
51
+ end
52
+
53
+ def summarize(rows)
54
+ rows.each_with_object(default_summary) do |row, summary|
55
+ summary[row[:status]] += 1
56
+ end
57
+ end
58
+
59
+ def default_summary
60
+ {
61
+ 'new' => 0,
62
+ 'resolved' => 0,
63
+ 'updated' => 0,
64
+ 'unchanged' => 0,
65
+ }
66
+ end
67
+
68
+ def comparison_status_for(previous_finding, current_finding, diff)
69
+ return 'new' if previous_finding.nil?
70
+ return 'resolved' if current_finding.nil?
71
+ return 'updated' if diff.any?
72
+
73
+ 'unchanged'
74
+ end
75
+
76
+ def diff_for(previous_finding, current_finding)
77
+ return {} if previous_finding.nil? || current_finding.nil?
78
+
79
+ DIFF_FIELDS.each_with_object({}) do |field, diff|
80
+ old_value = normalized_value_for(previous_finding, field)
81
+ new_value = normalized_value_for(current_finding, field)
82
+ next if old_value == new_value
83
+
84
+ diff[response_field_name(field)] = {
85
+ old: old_value,
86
+ new: new_value,
87
+ }
88
+ end
89
+ end
90
+
91
+ def previous_map
92
+ @previous_map ||= findings_map(@first_scan)
93
+ end
94
+
95
+ def current_map
96
+ @current_map ||= findings_map(@second_scan)
97
+ end
98
+
99
+ def identities
100
+ @identities ||= (previous_map.keys + current_map.keys).uniq
101
+ end
102
+
103
+ def findings_map(scan)
104
+ Array(scan.findings).index_by { |finding| finding_identity(finding) }
105
+ end
106
+
107
+ def finding_identity(finding)
108
+ "#{finding['id']}::#{finding['name']}"
109
+ end
110
+
111
+ def normalize_status(status)
112
+ status.presence || 'open'
113
+ end
114
+
115
+ def value_for(finding, field)
116
+ finding&.[](field).to_s
117
+ end
118
+
119
+ def normalized_value_for(finding, field)
120
+ return normalize_status(finding&.[]('status')) if field == 'status'
121
+
122
+ value_for(finding, field)
123
+ end
124
+
125
+ def response_field_name(field)
126
+ field == 'status' ? 'scan_status' : field
127
+ end
128
+
129
+ def scan_payload(scan)
130
+ {
131
+ id: scan.id,
132
+ host_id: scan.host_id,
133
+ scanner: scan.scanner,
134
+ source: scan.source,
135
+ scanned_at: scan.scanned_at,
136
+ total: scan.total,
137
+ critical: scan.critical,
138
+ high: scan.high,
139
+ medium: scan.medium,
140
+ low: scan.low,
141
+ summary: scan.summary,
142
+ }
143
+ end
144
+ end
145
+ end