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.
- checksums.yaml +4 -4
- data/README.md +101 -5
- data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
- data/app/models/host_status/cve_status.rb +3 -1
- data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +5 -8
- data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
- data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
- data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
- data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +22 -9
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
- data/config/routes.rb +6 -1
- data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
- data/lib/foreman_cve_scanner/engine.rb +68 -17
- data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
- data/package.json +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
- data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
- data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
- data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
- data/test/models/host_status/cve_status_test.rb +12 -3
- data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
- data/webpack/components/CveCompareModal.js +298 -0
- data/webpack/components/CveDetailsCard.js +141 -121
- data/webpack/components/CveFindingsModal.js +131 -111
- data/webpack/components/CveScansReports.js +227 -0
- data/webpack/components/CveScansTab.js +122 -119
- data/webpack/components/CveTrendChart.js +264 -0
- data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
- data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
- data/webpack/components/__tests__/CveScansTab.test.js +185 -5
- data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
- data/webpack/components/__tests__/cve_helpers.test.js +18 -0
- data/webpack/components/cve_helpers.js +139 -0
- data/webpack/components/cve_scans.scss +464 -9
- data/webpack/components/useModalScan.js +26 -0
- metadata +24 -3
- data/webpack/components/CveHistoryTable.js +0 -58
- data/webpack/components/CveOverviewCard.js +0 -67
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5981f5ba5ce1973957b47fafc885a8ed8327addd63ba39d3a00a321011d7cf0
|
|
4
|
+
data.tar.gz: b51390f31766614e1ef417aa8461e65927305ef5426b3bee061f3e566b5432a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-

|
|
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(
|
|
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, :
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|