foreman_cve_scanner 0.0.3 → 0.5.1
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 +28 -18
- data/Rakefile +2 -0
- data/app/controllers/api/v2/cve_scans_controller.rb +66 -0
- data/app/lib/actions/foreman_cve_scanner/cve_scanner_job.rb +3 -18
- data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +16 -0
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -0
- data/app/models/host_status/cve_status.rb +71 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +39 -37
- data/app/services/foreman_cve_scanner/scan_importer.rb +105 -0
- data/app/views/api/v2/cve_scans/base.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/index.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/latest.json.rabl +5 -0
- data/app/views/api/v2/cve_scans/main.json.rabl +7 -0
- data/app/views/api/v2/cve_scans/show.json.rabl +5 -0
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +19 -6
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +4 -2
- data/config/routes.rb +20 -0
- data/db/migrate/20260221000000_create_foreman_cve_scanner_cve_scans.rb +22 -0
- data/db/seeds.d/75_job_templates.rb +17 -0
- data/lib/foreman_cve_scanner/engine.rb +25 -3
- data/lib/foreman_cve_scanner/version.rb +3 -1
- data/lib/foreman_cve_scanner.rb +4 -1
- data/lib/tasks/foreman_cve_scanner_seeds.rake +12 -0
- data/lib/tasks/rubocop.rake +33 -0
- data/package.json +48 -0
- data/test/actions/foreman_cve_scanner/cve_scanner_job_test.rb +54 -0
- data/test/controllers/api/v2/cve_scans_controller_test.rb +78 -0
- data/test/models/host_status/cve_status_test.rb +65 -0
- data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +45 -17
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +85 -0
- data/test/test_plugin_helper.rb +2 -0
- data/webpack/components/CveDetailsCard.js +258 -0
- data/webpack/components/CveFindingsModal.js +274 -0
- data/webpack/components/CveHistoryTable.js +58 -0
- data/webpack/components/CveOverviewCard.js +67 -0
- data/webpack/components/CveScansTab.js +192 -0
- data/webpack/components/CveSummaryCell.js +72 -0
- data/webpack/components/SeverityIcon.js +56 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +126 -0
- data/webpack/components/__tests__/CveFindingsModal.test.js +78 -0
- data/webpack/components/__tests__/CveScansTab.test.js +76 -0
- data/webpack/components/__tests__/cve_helpers.test.js +42 -0
- data/webpack/components/cve_helpers.js +35 -0
- data/webpack/components/cve_scans.scss +200 -0
- data/webpack/fills.js +1 -0
- data/webpack/fills_index.js +38 -0
- data/webpack/index.js +1 -0
- metadata +47 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ef55cf85764a1394ea0e94d9cdf96520e905f49a5e479d2c7254867c6797be9
|
|
4
|
+
data.tar.gz: 874cd48378be0b12d341e06b4f15172b7ad1c45cd607ee7d0c513a38b5a172ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ba5bf4caff8597578e07c110961376c6c3ae242b8f71092e023993c1514a5a0ae185fe34fc32b052fbb1d2d773ce1dfdfb5a17a0f8ec1bff955d0b69984e6c2
|
|
7
|
+
data.tar.gz: d89adc8ffb92d8075c133084815b9db47b4f17e7dcb73c9e8cb1811cf52441636d1c9b2b6bf2e781438252c3d7a041f1f616476fb0237a17dafeeef2b5fd5c37
|
data/README.md
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
# ForemanCveScanner
|
|
2
2
|
|
|
3
|
-
Plugin to
|
|
4
|
-
1. install
|
|
5
|
-
2. run
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Plugin to:
|
|
4
|
+
1. install Trivy/Grype on a host using Foreman Remote Execution (REX)
|
|
5
|
+
2. run CVE scans via REX and parse the results
|
|
6
|
+
3. store scan history per host in a dedicated table
|
|
7
|
+
4. expose scan data via API
|
|
8
|
+
5. show CVE findings in the Host details UI
|
|
9
|
+
6. show summary status in the Hosts list
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- REX job templates for installing and running CVE scans
|
|
16
|
+
- JSON output parsing with robust handling of chunked stdout
|
|
17
|
+
- Per-host scan history with totals and severity counts
|
|
18
|
+
- API endpoints for scan history and latest scan
|
|
19
|
+
- Host Details card with findings and modal view
|
|
20
|
+
- Host Details tab “CVE scans” for full history
|
|
21
|
+
- Hosts overview list column “CVE” with quick summary and modal
|
|
22
|
+
- Integrated in the Host Status
|
|
11
23
|
|
|
12
24
|
## Installation
|
|
13
25
|
|
|
@@ -16,19 +28,18 @@ for how to install Foreman plugins
|
|
|
16
28
|
|
|
17
29
|
## Usage
|
|
18
30
|
|
|
19
|
-
- Run the REX job to install
|
|
31
|
+
- Run the REX job to install Trivy and/or Grype
|
|
20
32
|
- Run the REX job to scan a host
|
|
21
|
-
-
|
|
33
|
+
- You can configure recurring CVE scans via `Monitor -> Jobs`
|
|
34
|
+
- View results in:
|
|
35
|
+
- Hosts overview list column “CVE” (use 'Manage Columns' to enable)
|
|
36
|
+
- Host Details card and modal
|
|
37
|
+
- Host Details tab “CVE scans”
|
|
22
38
|
|
|
23
39
|
## TODO
|
|
24
40
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- Make it visible on the Host Details page or on Foreman directly, if a high priority CVE on a host occurs
|
|
28
|
-
- Export a CVE scan
|
|
29
|
-
- Deliver trivy / grype via Katello
|
|
30
|
-
- More tests
|
|
31
|
-
- API
|
|
41
|
+
- Export scan results
|
|
42
|
+
- Deliver Trivy/Grype via Katello
|
|
32
43
|
|
|
33
44
|
## Contributing
|
|
34
45
|
|
|
@@ -50,4 +61,3 @@ GNU General Public License for more details.
|
|
|
50
61
|
|
|
51
62
|
You should have received a copy of the GNU General Public License
|
|
52
63
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
53
|
-
|
data/Rakefile
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api
|
|
4
|
+
module V2
|
|
5
|
+
# API controller for CVE scans per host.
|
|
6
|
+
class CveScansController < V2::BaseController
|
|
7
|
+
before_action :find_host
|
|
8
|
+
|
|
9
|
+
def resource_class
|
|
10
|
+
::ForemanCveScanner::CveScan
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
api :GET, '/hosts/:host_id/cve_scans', N_('List CVE scans for a host')
|
|
14
|
+
description N_('Returns paginated CVE scan summaries for a host.')
|
|
15
|
+
param :host_id, :identifier, required: true
|
|
16
|
+
param :page, :number, desc: N_('Page number, starting at 1')
|
|
17
|
+
param :per_page, :number, desc: N_('Number of results per page')
|
|
18
|
+
def index
|
|
19
|
+
@cve_scans = cve_scans_index_scope.paginate(paginate_options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
api :GET, '/hosts/:host_id/cve_scans/latest', N_('Get latest CVE scan for a host')
|
|
23
|
+
description N_('Returns the most recent CVE scan for a host.')
|
|
24
|
+
param :host_id, :identifier, required: true
|
|
25
|
+
def latest
|
|
26
|
+
@cve_scan = cve_scans_index_scope.first
|
|
27
|
+
head :no_content if @cve_scan.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
api :GET, '/hosts/:host_id/cve_scans/:id', N_('Show CVE scan for a host')
|
|
31
|
+
description N_('Returns a specific CVE scan by id for a host.')
|
|
32
|
+
param :host_id, :identifier, required: true
|
|
33
|
+
param :id, :identifier, required: true
|
|
34
|
+
def show
|
|
35
|
+
@cve_scan = resource_class.for_host(@host.id).find(params[:id])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
api :DELETE, '/hosts/:host_id/cve_scans/:id', N_('Delete a CVE scan')
|
|
39
|
+
description N_('Deletes a specific CVE scan by id for a host.')
|
|
40
|
+
param :host_id, :identifier, required: true
|
|
41
|
+
param :id, :identifier, required: true
|
|
42
|
+
def destroy
|
|
43
|
+
@cve_scan = resource_class.for_host(@host.id).find(params[:id])
|
|
44
|
+
process_response @cve_scan.destroy
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def cve_scans_index_scope
|
|
50
|
+
scope = resource_class.for_host(@host.id).recent_first
|
|
51
|
+
return scope unless respond_to?(:resource_scope_for_index, true)
|
|
52
|
+
return scope unless scope.respond_to?(:search_for)
|
|
53
|
+
|
|
54
|
+
resource_scope_for_index(scope)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_host
|
|
58
|
+
scope = ::Host::Base.authorized(:view_hosts)
|
|
59
|
+
@host = scope.find_by(name: params[:host_id]) || scope.find_by(id: params[:host_id])
|
|
60
|
+
return if @host.present?
|
|
61
|
+
|
|
62
|
+
not_found
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Actions
|
|
4
4
|
module ForemanCveScanner
|
|
5
|
+
# Dynflow action that parses a CVE scan job output and stores the results.
|
|
5
6
|
class CveScannerJob < Actions::EntryAction
|
|
6
7
|
def self.subscribe
|
|
7
8
|
Actions::RemoteExecution::RunHostJob
|
|
@@ -15,16 +16,9 @@ module Actions
|
|
|
15
16
|
|
|
16
17
|
def finalize(*_args)
|
|
17
18
|
host = Host.find(input[:host_id])
|
|
18
|
-
return if host.blank?
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'logs' => [],
|
|
23
|
-
'scan' => format_output(task.main_action.continuous_output.humanize),
|
|
24
|
-
'reported_at' => Time.now.utc.to_s,
|
|
25
|
-
'reporter' => 'cve_scan'
|
|
26
|
-
}
|
|
27
|
-
ConfigReportImporter.import(report)
|
|
20
|
+
::ForemanCveScanner::ScanImporter.new(task.main_action.continuous_output)
|
|
21
|
+
.import_for_host!(host)
|
|
28
22
|
end
|
|
29
23
|
|
|
30
24
|
private
|
|
@@ -34,15 +28,6 @@ module Actions
|
|
|
34
28
|
.first
|
|
35
29
|
.template_id, label: feature).any?
|
|
36
30
|
end
|
|
37
|
-
|
|
38
|
-
def format_output(job_output)
|
|
39
|
-
output = job_output.each_line(chomp: true)
|
|
40
|
-
.drop_while { |l| !l.start_with? '===START' }.drop(1)
|
|
41
|
-
.take_while { |l| !l.start_with? '===END' }
|
|
42
|
-
.reject(&:empty?)
|
|
43
|
-
.join('')
|
|
44
|
-
JSON.parse(output)
|
|
45
|
-
end
|
|
46
31
|
end
|
|
47
32
|
end
|
|
48
33
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
# Adds CVE scan associations to hosts.
|
|
5
|
+
module HostExtensions
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
has_many :cve_scans,
|
|
10
|
+
class_name: 'ForemanCveScanner::CveScan',
|
|
11
|
+
foreign_key: :host_id,
|
|
12
|
+
dependent: :destroy,
|
|
13
|
+
inverse_of: :host
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
# Stores a single CVE scan result for a host.
|
|
5
|
+
class CveScan < ApplicationRecord
|
|
6
|
+
self.table_name = 'foreman_cve_scanner_cve_scans'
|
|
7
|
+
|
|
8
|
+
belongs_to :host, class_name: '::Host::Managed'
|
|
9
|
+
|
|
10
|
+
validates :host_id, :scanner, :raw, :summary, :findings, presence: true
|
|
11
|
+
|
|
12
|
+
scope :for_host, ->(host_id) { where(host_id: host_id) }
|
|
13
|
+
scope :recent_first, -> { order(created_at: :desc, id: :desc) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HostStatus
|
|
4
|
+
# Host status entry reflecting latest CVE scan severities.
|
|
5
|
+
class CveStatus < Status
|
|
6
|
+
CVE_SCANNER_STATUS_NONE = 0
|
|
7
|
+
CVE_SCANNER_STATUS_LOW = 1
|
|
8
|
+
CVE_SCANNER_STATUS_MEDIUM = 2
|
|
9
|
+
CVE_SCANNER_STATUS_CRITICAL_HIGH = 3
|
|
10
|
+
|
|
11
|
+
def self.status_name
|
|
12
|
+
N_('CVE')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_label(_options = {})
|
|
16
|
+
case latest_scan_severity
|
|
17
|
+
when :critical_high
|
|
18
|
+
N_('Critical or high CVEs')
|
|
19
|
+
when :medium
|
|
20
|
+
N_('Medium CVEs')
|
|
21
|
+
when :low
|
|
22
|
+
N_('Low CVEs')
|
|
23
|
+
when :none
|
|
24
|
+
N_('No CVE scans')
|
|
25
|
+
else
|
|
26
|
+
N_('No CVEs')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_global(_options = {})
|
|
31
|
+
case latest_scan_severity
|
|
32
|
+
when :critical_high
|
|
33
|
+
HostStatus::Global::ERROR
|
|
34
|
+
when :medium, :none
|
|
35
|
+
HostStatus::Global::WARN
|
|
36
|
+
else
|
|
37
|
+
HostStatus::Global::OK
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_status(_options = {})
|
|
42
|
+
case latest_scan_severity
|
|
43
|
+
when :critical_high
|
|
44
|
+
CVE_SCANNER_STATUS_CRITICAL_HIGH
|
|
45
|
+
when :medium
|
|
46
|
+
CVE_SCANNER_STATUS_MEDIUM
|
|
47
|
+
when :low
|
|
48
|
+
CVE_SCANNER_STATUS_LOW
|
|
49
|
+
else
|
|
50
|
+
CVE_SCANNER_STATUS_NONE
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def latest_scan
|
|
57
|
+
@latest_scan ||= ::ForemanCveScanner::CveScan.for_host(host_id).recent_first.first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def latest_scan_severity
|
|
61
|
+
scan = latest_scan
|
|
62
|
+
return :none if scan.nil?
|
|
63
|
+
|
|
64
|
+
return :critical_high if scan.critical.to_i.positive? || scan.high.to_i.positive?
|
|
65
|
+
return :medium if scan.medium.to_i.positive?
|
|
66
|
+
return :low if scan.low.to_i.positive?
|
|
67
|
+
|
|
68
|
+
:none
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ForemanCveScanner
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Parses raw CVE scanner reports and produces unified logs/metrics.
|
|
5
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
6
|
class CveReportScanner
|
|
7
|
-
|
|
8
|
-
scanner = ForemanCveScanner::CveReportScanner.new(raw)
|
|
9
|
-
scanner.generate
|
|
10
|
-
raw['logs'] = scanner.logs
|
|
11
|
-
raw['status'] = scanner.status
|
|
12
|
-
raw['metrics'] = scanner.metrics
|
|
13
|
-
raw['report_status_calculator_options'] = { :metrics => %w[critical high medium low total] }
|
|
14
|
-
end
|
|
7
|
+
SEVERITY_ORDER = %w[CRITICAL HIGH MEDIUM LOW UNKNOWN].freeze
|
|
15
8
|
|
|
16
9
|
def self.identify_origin(raw)
|
|
17
10
|
'CveScanner' if cve_scanner_report?(raw)
|
|
@@ -37,9 +30,21 @@ module ForemanCveScanner
|
|
|
37
30
|
|
|
38
31
|
attr_reader :logs, :status
|
|
39
32
|
|
|
33
|
+
def unified_vulnerabilities
|
|
34
|
+
@cve_report_data
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.detect_scanner(scan_json)
|
|
38
|
+
return 'grype' if scan_json.is_a?(Hash) && scan_json.key?('matches')
|
|
39
|
+
return 'trivy' if scan_json.is_a?(Hash) && scan_json.key?('Results')
|
|
40
|
+
|
|
41
|
+
'unknown'
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
def metrics
|
|
41
|
-
|
|
42
|
-
res
|
|
45
|
+
known = %w[critical high medium low]
|
|
46
|
+
res = @status.slice(*known)
|
|
47
|
+
res['total'] = res.values.sum
|
|
43
48
|
res
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -47,43 +52,38 @@ module ForemanCveScanner
|
|
|
47
52
|
|
|
48
53
|
def generate_log_from_unified(id, entry)
|
|
49
54
|
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
message: "#{id}: #{entry['title']} # url: #{entry['url']}"
|
|
55
|
+
log: {
|
|
56
|
+
level: consume_severity_level(entry['severity']),
|
|
57
|
+
messages: {
|
|
58
|
+
message: "#{id}: #{entry['title']} # url: #{entry['url']}",
|
|
54
59
|
},
|
|
55
60
|
sources: {
|
|
56
|
-
source: "#{entry['name']} @ #{entry['version']}"
|
|
57
|
-
}
|
|
58
|
-
}
|
|
61
|
+
source: "#{entry['name']} @ #{entry['version']}",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
59
64
|
}.deep_stringify_keys
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def consume_severity_level(severity)
|
|
68
|
+
severity = severity.to_s.strip.upcase
|
|
63
69
|
@status[severity.downcase] = 0 unless @status.key?(severity.downcase)
|
|
64
70
|
@status[severity.downcase] += 1
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
'info'
|
|
73
|
-
when 'LOW'
|
|
74
|
-
'debug'
|
|
75
|
-
else
|
|
76
|
-
'info'
|
|
77
|
-
end
|
|
72
|
+
{
|
|
73
|
+
'CRITICAL' => 'err',
|
|
74
|
+
'HIGH' => 'warning',
|
|
75
|
+
'MEDIUM' => 'info',
|
|
76
|
+
'LOW' => 'debug',
|
|
77
|
+
}.fetch(severity, 'info')
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def generate_grype_entry(entry)
|
|
81
81
|
{
|
|
82
82
|
'name' => entry['artifact']['name'],
|
|
83
83
|
'version' => entry['artifact']['version'],
|
|
84
|
-
'title' => entry['vulnerability']['description'].gsub(/[\[\]"\\]/, ''),
|
|
84
|
+
'title' => entry['vulnerability']['description'].to_s.gsub(/[\[\]"\\]/, ''),
|
|
85
85
|
'severity' => entry['vulnerability']['severity'],
|
|
86
|
-
'url' => entry['vulnerability']['dataSource']
|
|
86
|
+
'url' => entry['vulnerability']['dataSource'],
|
|
87
87
|
}
|
|
88
88
|
end
|
|
89
89
|
|
|
@@ -91,17 +91,17 @@ module ForemanCveScanner
|
|
|
91
91
|
unified = {
|
|
92
92
|
'name' => entry['PkgName'],
|
|
93
93
|
'version' => entry['InstalledVersion'],
|
|
94
|
-
'title' => entry['Title'].gsub(/[\[\]"\\]/, ''),
|
|
94
|
+
'title' => entry['Title'].to_s.gsub(/[\[\]"\\]/, ''),
|
|
95
95
|
'severity' => entry['Severity'],
|
|
96
96
|
'url' => entry['PrimaryURL'],
|
|
97
97
|
'status' => entry['Status'],
|
|
98
|
-
'fixed' => entry['FixedVersion'] || 'open'
|
|
98
|
+
'fixed' => entry['FixedVersion'] || 'open',
|
|
99
99
|
}
|
|
100
100
|
unified['published'] = entry['PublishedDate'] if entry.key?('PublishedDate')
|
|
101
101
|
unified
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
# rubocop:disable Metrics/AbcSize
|
|
104
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
105
105
|
def generate_unified_vuls
|
|
106
106
|
raise ::Foreman::Exception, _('Invalid CVE scanner report') unless @raw_data.key?('scan')
|
|
107
107
|
|
|
@@ -114,6 +114,7 @@ module ForemanCveScanner
|
|
|
114
114
|
elsif j.key?('Results') # Trivy
|
|
115
115
|
j['Results'].each do |r|
|
|
116
116
|
next unless r.key? 'Vulnerabilities'
|
|
117
|
+
|
|
117
118
|
r['Vulnerabilities'].each do |vul|
|
|
118
119
|
vuls[vul['VulnerabilityID']] = generate_trivy_entry(vul)
|
|
119
120
|
end
|
|
@@ -125,6 +126,7 @@ module ForemanCveScanner
|
|
|
125
126
|
|
|
126
127
|
vuls
|
|
127
128
|
end
|
|
128
|
-
# rubocop:enable Metrics/AbcSize
|
|
129
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
129
130
|
end
|
|
131
|
+
# rubocop:enable Metrics/ClassLength
|
|
130
132
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanCveScanner
|
|
4
|
+
# Import CVE scan results from REX job output and persist them.
|
|
5
|
+
class ScanImporter
|
|
6
|
+
def initialize(job_output)
|
|
7
|
+
@job_output = job_output
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def import_for_host!(host)
|
|
11
|
+
scan_json = format_output(@job_output)
|
|
12
|
+
return nil if scan_json.nil?
|
|
13
|
+
|
|
14
|
+
scanner_name = ::ForemanCveScanner::CveReportScanner.detect_scanner(scan_json)
|
|
15
|
+
persist_scan!(host, scanner_name, scan_json)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def format_output(job_output)
|
|
21
|
+
output_source = normalize_job_output(job_output)
|
|
22
|
+
json_text = extract_json(output_source)
|
|
23
|
+
if json_text.blank? && output_source.to_s.strip.present?
|
|
24
|
+
Rails.logger.warn('CVE scan output did not contain markers or JSON content')
|
|
25
|
+
end
|
|
26
|
+
return nil if json_text.blank?
|
|
27
|
+
|
|
28
|
+
JSON.parse(json_text)
|
|
29
|
+
rescue JSON::ParserError => e
|
|
30
|
+
Rails.logger.error("CVE scan output parse failed: #{e}")
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def extract_json(output_source)
|
|
35
|
+
output = output_source.to_s.each_line(chomp: true)
|
|
36
|
+
.drop_while { |line| !line.start_with?('===START') }
|
|
37
|
+
.drop(1)
|
|
38
|
+
.take_while { |line| !line.start_with?('===END') }
|
|
39
|
+
.reject(&:empty?)
|
|
40
|
+
.join
|
|
41
|
+
output.strip
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_job_output(job_output)
|
|
45
|
+
return concat_proxy_output(job_output) if job_output.is_a?(Hash) && job_output.key?('proxy_output')
|
|
46
|
+
|
|
47
|
+
output_source = job_output
|
|
48
|
+
output_source = job_output.humanize if job_output.respond_to?(:humanize) && !job_output.is_a?(String)
|
|
49
|
+
output_source.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def concat_proxy_output(job_output)
|
|
53
|
+
result = job_output.dig('proxy_output', 'result') || []
|
|
54
|
+
result.filter_map { |item| item['output'] if item['output_type'] == 'stdout' }.join
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def persist_scan!(host, scanner_name, scan_json)
|
|
58
|
+
scanner = ::ForemanCveScanner::CveReportScanner.new('scan' => scan_json)
|
|
59
|
+
scanner.generate
|
|
60
|
+
|
|
61
|
+
metrics = scanner.metrics
|
|
62
|
+
scan = ::ForemanCveScanner::CveScan.create!(
|
|
63
|
+
build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
|
|
64
|
+
)
|
|
65
|
+
refresh_host_status(host)
|
|
66
|
+
scan
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def refresh_host_status(host)
|
|
70
|
+
status = ::HostStatus::CveStatus.find_or_initialize_by(host: host)
|
|
71
|
+
status.refresh!
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Rails.logger.error("CVE status refresh failed for host_id=#{host.id}: #{e}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def worst_severity(metrics)
|
|
77
|
+
%w[critical high medium low].each do |severity|
|
|
78
|
+
return severity if metrics[severity].to_i.positive?
|
|
79
|
+
end
|
|
80
|
+
'none'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_scan_attributes(host, scanner_name, scan_json, metrics, scanner)
|
|
84
|
+
summary = metrics.merge('worst' => worst_severity(metrics))
|
|
85
|
+
{
|
|
86
|
+
host: host,
|
|
87
|
+
scanner: scanner_name,
|
|
88
|
+
raw: scan_json,
|
|
89
|
+
summary: summary,
|
|
90
|
+
findings: build_findings(scanner),
|
|
91
|
+
total: metrics['total'].to_i,
|
|
92
|
+
critical: metrics['critical'].to_i,
|
|
93
|
+
high: metrics['high'].to_i,
|
|
94
|
+
medium: metrics['medium'].to_i,
|
|
95
|
+
low: metrics['low'].to_i,
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_findings(scanner)
|
|
100
|
+
scanner.unified_vulnerabilities.map do |id, entry|
|
|
101
|
+
entry.merge('id' => id)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -59,13 +59,26 @@ grype_url = "https://github.com/anchore/grype/releases/download/v#{grype_version
|
|
|
59
59
|
|
|
60
60
|
case @host.operatingsystem.family
|
|
61
61
|
when 'Debian'
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
trivy_download_cmd = "wget -O /tmp/trivy.deb \"#{trivy_url}\""
|
|
63
|
+
grype_download_cmd = "wget -O /tmp/grype.deb \"#{grype_url}\""
|
|
64
|
+
trivy_install_cmd = "dpkg -i /tmp/trivy.deb"
|
|
65
|
+
grype_install_cmd = "dpkg -i /tmp/grype.deb"
|
|
66
|
+
trivy_cleanup_cmd = "rm -f /tmp/trivy.deb"
|
|
67
|
+
grype_cleanup_cmd = "rm -f /tmp/grype.deb"
|
|
64
68
|
when 'Redhat', 'Suse'
|
|
65
|
-
trivy_install_cmd = "rpm -ivh #{trivy_url}"
|
|
66
|
-
grype_install_cmd = "rpm -ivh #{grype_url}"
|
|
69
|
+
trivy_install_cmd = "rpm -ivh \"#{trivy_url}\""
|
|
70
|
+
grype_install_cmd = "rpm -ivh \"#{grype_url}\""
|
|
67
71
|
end
|
|
68
72
|
-%>
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
<%=
|
|
74
|
+
<% if input('scanner_to_install') == 'both' || input('scanner_to_install') == 'trivy' -%>
|
|
75
|
+
<%= trivy_download_cmd unless trivy_install_cmd.nil? %>
|
|
76
|
+
<%= trivy_install_cmd %>
|
|
77
|
+
<%= trivy_cleanup_cmd unless trivy_cleanup_cmd.nil? %>
|
|
78
|
+
<% end -%>
|
|
79
|
+
|
|
80
|
+
<% if input('scanner_to_install') == 'both' || input('scanner_to_install') == 'grype' -%>
|
|
81
|
+
<%= grype_download_cmd unless grype_download_cmd.nil? %>
|
|
82
|
+
<%= grype_install_cmd %>
|
|
83
|
+
<%= grype_cleanup_cmd unless grype_cleanup_cmd.nil? %>
|
|
84
|
+
<% end %>
|
|
@@ -13,6 +13,7 @@ template_inputs:
|
|
|
13
13
|
options: "filesystem\r\ndocker"
|
|
14
14
|
advanced: false
|
|
15
15
|
value_type: plain
|
|
16
|
+
default: filesystem
|
|
16
17
|
hidden_value: false
|
|
17
18
|
- name: options
|
|
18
19
|
required: false
|
|
@@ -25,6 +26,7 @@ template_inputs:
|
|
|
25
26
|
input_type: user
|
|
26
27
|
advanced: false
|
|
27
28
|
value_type: plain
|
|
29
|
+
default: /
|
|
28
30
|
hidden_value: false
|
|
29
31
|
- name: scanner
|
|
30
32
|
required: true
|
|
@@ -39,7 +41,7 @@ template_inputs:
|
|
|
39
41
|
scanner = input('scanner')
|
|
40
42
|
target = input('target').to_sym
|
|
41
43
|
path = input('path')
|
|
42
|
-
options = input('options')
|
|
44
|
+
options = input('options').to_s
|
|
43
45
|
|
|
44
46
|
scanners = {
|
|
45
47
|
trivy: {
|
|
@@ -57,7 +59,7 @@ if scanner == 'trivy'
|
|
|
57
59
|
options += " --exit-code 0 --scanners vuln --quiet --format json"
|
|
58
60
|
elsif scanner == 'grype'
|
|
59
61
|
cmd = "#{scanners[:grype][target]}:#{path}"
|
|
60
|
-
options += " --quiet --
|
|
62
|
+
options += " --quiet --output json"
|
|
61
63
|
end
|
|
62
64
|
exec_command = "#{scanner} #{cmd} #{options}"
|
|
63
65
|
-%>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Rails.application.routes.draw do
|
|
4
|
+
namespace :api, defaults: { format: 'json' } do
|
|
5
|
+
scope '(:apiv)', module: :v2,
|
|
6
|
+
defaults: { apiv: 'v2' },
|
|
7
|
+
apiv: /v1|v2/,
|
|
8
|
+
constraints: ApiConstraints.new(version: 2, default: true) do
|
|
9
|
+
constraints(host_id: %r{[^/]+}) do
|
|
10
|
+
resources :hosts, only: [] do
|
|
11
|
+
resources :cve_scans, only: %i[index show] do
|
|
12
|
+
collection do
|
|
13
|
+
get :latest
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|