chelsea 0.0.26 → 0.0.31
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +10 -5
- data/.github/ISSUE_TEMPLATE/bug_report.md +1 -1
- data/.rubocop.yml +6 -0
- data/Gemfile +7 -2
- data/Gemfile.lock +28 -8
- data/README.md +268 -1
- data/Rakefile +5 -3
- data/bin/chelsea +14 -7
- data/bin/console +5 -4
- data/chelsea.gemspec +30 -29
- data/lib/chelsea/bom.rb +3 -3
- data/lib/chelsea/cli.rb +36 -9
- data/lib/chelsea/config.rb +12 -11
- data/lib/chelsea/db.rb +4 -1
- data/lib/chelsea/dependency_exception.rb +4 -1
- data/lib/chelsea/deps.rb +5 -2
- data/lib/chelsea/formatters/factory.rb +4 -2
- data/lib/chelsea/formatters/formatter.rb +15 -11
- data/lib/chelsea/formatters/json.rb +5 -1
- data/lib/chelsea/formatters/text.rb +9 -4
- data/lib/chelsea/formatters/xml.rb +20 -16
- data/lib/chelsea/gems.rb +16 -17
- data/lib/chelsea/iq_client.rb +66 -38
- data/lib/chelsea/oss_index.rb +8 -9
- data/lib/chelsea/spinner.rb +4 -1
- data/lib/chelsea/version.rb +3 -1
- metadata +49 -62
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
#
|
2
4
|
# Copyright 2019-Present Sonatype Inc.
|
3
5
|
#
|
@@ -18,12 +20,14 @@ require 'json'
|
|
18
20
|
require_relative 'formatter'
|
19
21
|
|
20
22
|
module Chelsea
|
23
|
+
# Produce output in json format
|
21
24
|
class JsonFormatter < Formatter
|
22
25
|
def initialize(options)
|
26
|
+
super()
|
23
27
|
@options = options
|
24
28
|
end
|
25
29
|
|
26
|
-
def
|
30
|
+
def fetch_results(server_response, _reverse_deps)
|
27
31
|
server_response.to_json
|
28
32
|
end
|
29
33
|
|
@@ -1,3 +1,6 @@
|
|
1
|
+
# rubocop:disable Style/FrozenStringLiteralComment
|
2
|
+
# rubocop:enable Style/FrozenStringLiteralComment
|
3
|
+
|
1
4
|
#
|
2
5
|
# Copyright 2019-Present Sonatype Inc.
|
3
6
|
#
|
@@ -19,13 +22,16 @@ require 'tty-table'
|
|
19
22
|
require_relative 'formatter'
|
20
23
|
|
21
24
|
module Chelsea
|
25
|
+
# Produce output in text format
|
22
26
|
class TextFormatter < Formatter
|
23
27
|
def initialize(options)
|
28
|
+
super()
|
24
29
|
@options = options
|
25
30
|
@pastel = Pastel.new
|
26
31
|
end
|
27
32
|
|
28
|
-
|
33
|
+
# rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
34
|
+
def fetch_results(server_response, reverse_dependencies) # rubocop:disable Metrics/MethodLength
|
29
35
|
response = ''
|
30
36
|
if @options[:verbose]
|
31
37
|
response += "\n"\
|
@@ -65,6 +71,7 @@ module Chelsea
|
|
65
71
|
response += table.render(:unicode)
|
66
72
|
response
|
67
73
|
end
|
74
|
+
# rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
68
75
|
|
69
76
|
def do_print(results)
|
70
77
|
puts results
|
@@ -109,9 +116,7 @@ module Chelsea
|
|
109
116
|
def _get_reverse_deps(coords, name)
|
110
117
|
coords.each_with_object('') do |dep, s|
|
111
118
|
dep.each do |gran|
|
112
|
-
if gran.
|
113
|
-
s << "\tRequired by: #{gran}\n"
|
114
|
-
end
|
119
|
+
s << "\tRequired by: #{gran}\n" if gran.instance_of?(String) && !gran.include?(name)
|
115
120
|
end
|
116
121
|
end
|
117
122
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
#
|
2
4
|
# Copyright 2019-Present Sonatype Inc.
|
3
5
|
#
|
@@ -17,12 +19,14 @@
|
|
17
19
|
require 'ox'
|
18
20
|
require_relative 'formatter'
|
19
21
|
module Chelsea
|
22
|
+
# Produce output in xml format
|
20
23
|
class XMLFormatter < Formatter
|
21
24
|
def initialize(options)
|
25
|
+
super()
|
22
26
|
@options = options
|
23
27
|
end
|
24
28
|
|
25
|
-
def
|
29
|
+
def fetch_results(server_response, _reverse_deps) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
26
30
|
doc = Ox::Document.new
|
27
31
|
instruct = Ox::Instruct.new(:xml)
|
28
32
|
instruct[:version] = '1.0'
|
@@ -37,13 +41,13 @@ module Chelsea
|
|
37
41
|
|
38
42
|
server_response.each do |coord|
|
39
43
|
testcase = Ox::Element.new('testcase')
|
40
|
-
testcase[:classname] = coord[
|
41
|
-
testcase[:name] = coord[
|
44
|
+
testcase[:classname] = coord['coordinates']
|
45
|
+
testcase[:name] = coord['coordinates']
|
42
46
|
|
43
47
|
if coord['vulnerabilities'].length.positive?
|
44
48
|
failure = Ox::Element.new('failure')
|
45
|
-
failure[:type] =
|
46
|
-
failure << get_vulnerability_block(coord[
|
49
|
+
failure[:type] = 'Vulnerable Dependency'
|
50
|
+
failure << get_vulnerability_block(coord['vulnerabilities'])
|
47
51
|
testcase << failure
|
48
52
|
testsuite << testcase
|
49
53
|
elsif @options[:verbose]
|
@@ -58,20 +62,20 @@ module Chelsea
|
|
58
62
|
puts Ox.dump(results)
|
59
63
|
end
|
60
64
|
|
61
|
-
def get_vulnerability_block(vulnerabilities)
|
62
|
-
|
65
|
+
def get_vulnerability_block(vulnerabilities) # rubocop:disable Metrics/MethodLength
|
66
|
+
vuln_block = ''
|
63
67
|
vulnerabilities.each do |vuln|
|
64
|
-
|
65
|
-
"ID: #{vuln[
|
66
|
-
"Description: #{vuln[
|
67
|
-
"CVSS Score: #{vuln[
|
68
|
-
"CVSS Vector: #{vuln[
|
69
|
-
"CVE: #{vuln[
|
70
|
-
"Reference: #{vuln[
|
68
|
+
vuln_block += "Vulnerability Title: #{vuln['title']}\n"\
|
69
|
+
"ID: #{vuln['id']}\n"\
|
70
|
+
"Description: #{vuln['description']}\n"\
|
71
|
+
"CVSS Score: #{vuln['cvssScore']}\n"\
|
72
|
+
"CVSS Vector: #{vuln['cvssVector']}\n"\
|
73
|
+
"CVE: #{vuln['cve']}\n"\
|
74
|
+
"Reference: #{vuln['reference']}"\
|
71
75
|
"\n"
|
72
76
|
end
|
73
|
-
|
74
|
-
|
77
|
+
|
78
|
+
vuln_block
|
75
79
|
end
|
76
80
|
end
|
77
81
|
end
|
data/lib/chelsea/gems.rb
CHANGED
@@ -15,6 +15,7 @@
|
|
15
15
|
#
|
16
16
|
|
17
17
|
# frozen_string_literal: true
|
18
|
+
|
18
19
|
require 'pastel'
|
19
20
|
require 'bundler'
|
20
21
|
require 'bundler/lockfile_parser'
|
@@ -31,11 +32,10 @@ module Chelsea
|
|
31
32
|
# Class to collect and audit packages from a Gemfile.lock
|
32
33
|
class Gems
|
33
34
|
attr_accessor :deps
|
34
|
-
|
35
|
+
|
36
|
+
def initialize(file:, verbose:, options: { format: 'text' }) # rubocop:disable Metrics/MethodLength
|
35
37
|
@verbose = verbose
|
36
|
-
unless File.file?(file) || file.nil?
|
37
|
-
raise 'Gemfile.lock not found, check --file path'
|
38
|
-
end
|
38
|
+
raise 'Gemfile.lock not found, check --file path' unless File.file?(file) || file.nil?
|
39
39
|
|
40
40
|
_silence_stderr unless @verbose
|
41
41
|
|
@@ -52,7 +52,7 @@ module Chelsea
|
|
52
52
|
# Audits depenencies using deps library and prints results
|
53
53
|
# using formatter library
|
54
54
|
|
55
|
-
def execute
|
55
|
+
def execute # rubocop:disable Metrics/MethodLength
|
56
56
|
server_response, dependencies, reverse_dependencies = audit
|
57
57
|
if dependencies.nil?
|
58
58
|
_print_err 'No dependencies retrieved. Exiting.'
|
@@ -62,20 +62,19 @@ module Chelsea
|
|
62
62
|
_print_success 'No vulnerability data retrieved from server. Exiting.'
|
63
63
|
return
|
64
64
|
end
|
65
|
-
results = @formatter.
|
65
|
+
results = @formatter.fetch_results(server_response, reverse_dependencies)
|
66
66
|
@formatter.do_print(results)
|
67
67
|
|
68
68
|
server_response.map { |r| r['vulnerabilities'].length.positive? }.any?
|
69
69
|
end
|
70
70
|
|
71
71
|
def collect_iq
|
72
|
-
|
73
|
-
dependencies
|
72
|
+
@deps.dependencies
|
74
73
|
end
|
75
74
|
|
76
75
|
# Runs through auditing algorithm, raising exceptions
|
77
76
|
# on REST calls made by @deps.get_vulns
|
78
|
-
def audit
|
77
|
+
def audit # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
79
78
|
# This spinner management is out of control
|
80
79
|
# we should wrap a block with start and stop messages,
|
81
80
|
# or use a stack to ensure all spinners stop.
|
@@ -84,7 +83,7 @@ module Chelsea
|
|
84
83
|
begin
|
85
84
|
dependencies = @deps.dependencies
|
86
85
|
spin.success('...done.')
|
87
|
-
rescue StandardError
|
86
|
+
rescue StandardError
|
88
87
|
spin.stop
|
89
88
|
_print_err "Parsing dependency line #{gem} failed."
|
90
89
|
end
|
@@ -100,16 +99,16 @@ module Chelsea
|
|
100
99
|
begin
|
101
100
|
server_response = @client.get_vulns(coordinates)
|
102
101
|
spin.success('...done.')
|
103
|
-
rescue SocketError
|
102
|
+
rescue SocketError
|
104
103
|
spin.stop('...request failed.')
|
105
104
|
_print_err 'Socket error getting data from OSS Index server.'
|
106
105
|
rescue RestClient::RequestFailed => e
|
107
106
|
spin.stop('...request failed.')
|
108
107
|
_print_err "Error getting data from OSS Index server:#{e.response}."
|
109
|
-
rescue RestClient::ResourceNotFound
|
108
|
+
rescue RestClient::ResourceNotFound
|
110
109
|
spin.stop('...request failed.')
|
111
110
|
_print_err 'Error getting data from OSS Index server. Resource not found.'
|
112
|
-
rescue Errno::ECONNREFUSED
|
111
|
+
rescue Errno::ECONNREFUSED
|
113
112
|
spin.stop('...request failed.')
|
114
113
|
_print_err 'Error getting data from OSS Index server. Connection refused.'
|
115
114
|
end
|
@@ -122,12 +121,12 @@ module Chelsea
|
|
122
121
|
$stderr.reopen('/dev/null', 'w')
|
123
122
|
end
|
124
123
|
|
125
|
-
def _print_err(
|
126
|
-
puts @pastel.red.bold(
|
124
|
+
def _print_err(msg)
|
125
|
+
puts @pastel.red.bold(msg)
|
127
126
|
end
|
128
127
|
|
129
|
-
def _print_success(
|
130
|
-
puts @pastel.green.bold(
|
128
|
+
def _print_success(msg)
|
129
|
+
puts @pastel.green.bold(msg)
|
131
130
|
end
|
132
131
|
end
|
133
132
|
end
|
data/lib/chelsea/iq_client.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
#
|
2
4
|
# Copyright 2019-Present Sonatype Inc.
|
3
5
|
#
|
@@ -17,12 +19,13 @@
|
|
17
19
|
require 'rest-client'
|
18
20
|
require 'json'
|
19
21
|
require 'pastel'
|
22
|
+
require 'uri'
|
20
23
|
|
21
24
|
require_relative 'spinner'
|
22
25
|
|
23
26
|
module Chelsea
|
24
|
-
|
25
|
-
|
27
|
+
# IQ audit operations
|
28
|
+
class IQClient # rubocop:disable Metrics/ClassLength
|
26
29
|
DEFAULT_OPTIONS = {
|
27
30
|
public_application_id: 'testapp',
|
28
31
|
server_url: 'http://localhost:8070',
|
@@ -30,15 +33,16 @@ module Chelsea
|
|
30
33
|
auth_token: 'admin123',
|
31
34
|
internal_application_id: '',
|
32
35
|
stage: 'build'
|
33
|
-
}
|
36
|
+
}.freeze
|
37
|
+
|
34
38
|
def initialize(options: DEFAULT_OPTIONS)
|
35
39
|
@options = options
|
36
40
|
@pastel = Pastel.new
|
37
41
|
@spinner = Chelsea::Spinner.new
|
38
42
|
end
|
39
43
|
|
40
|
-
def post_sbom(sbom)
|
41
|
-
spin = @spinner.spin_msg
|
44
|
+
def post_sbom(sbom) # rubocop:disable Metrics/MethodLength
|
45
|
+
spin = @spinner.spin_msg 'Submitting sbom to Nexus IQ Server'
|
42
46
|
@internal_application_id = _get_internal_application_id
|
43
47
|
resource = RestClient::Resource.new(
|
44
48
|
_api_url,
|
@@ -46,8 +50,8 @@ module Chelsea
|
|
46
50
|
password: @options[:auth_token]
|
47
51
|
)
|
48
52
|
res = resource.post sbom.to_s, _headers.merge(content_type: 'application/xml')
|
49
|
-
|
50
|
-
spin.success(
|
53
|
+
if res.code == 202
|
54
|
+
spin.success('...done.')
|
51
55
|
status_url(res)
|
52
56
|
else
|
53
57
|
spin.stop('...request failed.')
|
@@ -61,33 +65,51 @@ module Chelsea
|
|
61
65
|
end
|
62
66
|
|
63
67
|
def poll_status(url)
|
64
|
-
spin = @spinner.spin_msg
|
68
|
+
spin = @spinner.spin_msg 'Polling Nexus IQ Server for results'
|
65
69
|
loop do
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
_handle_response(res)
|
71
|
-
break
|
72
|
-
end
|
73
|
-
rescue
|
74
|
-
sleep(1)
|
70
|
+
res = _poll_iq_server(url)
|
71
|
+
if res.code == 200
|
72
|
+
spin.success('...done.')
|
73
|
+
return _handle_response(res)
|
75
74
|
end
|
75
|
+
rescue StandardError
|
76
|
+
sleep(1)
|
76
77
|
end
|
77
78
|
end
|
78
79
|
|
80
|
+
# colors to use when printing message
|
81
|
+
COLOR_FAILURE = 31
|
82
|
+
COLOR_WARNING = 33 # want yellow, but doesn't appear to print
|
83
|
+
COLOR_NONE = 32
|
84
|
+
# Known policy actions
|
85
|
+
POLICY_ACTION_FAILURE = 'Failure'
|
86
|
+
POLICY_ACTION_WARNING = 'Warning'
|
87
|
+
POLICY_ACTION_NONE = 'None'
|
88
|
+
|
79
89
|
private
|
80
90
|
|
81
|
-
def _handle_response(res)
|
91
|
+
def _handle_response(res) # rubocop:disable Metrics/MethodLength
|
82
92
|
res = JSON.parse(res.body)
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
93
|
+
# get absolute report url
|
94
|
+
absolute_report_html_url = URI.join(@options[:server_url], res['reportHtmlUrl'])
|
95
|
+
|
96
|
+
case res['policyAction']
|
97
|
+
when POLICY_ACTION_FAILURE
|
98
|
+
['Hi! Chelsea here, you have some policy violations to clean up!'\
|
99
|
+
"\nReport URL: #{absolute_report_html_url}",
|
100
|
+
COLOR_FAILURE, 1]
|
101
|
+
when POLICY_ACTION_WARNING
|
102
|
+
['Hi! Chelsea here, you have some policy warnings to peck at!'\
|
103
|
+
"\nReport URL: #{absolute_report_html_url}",
|
104
|
+
COLOR_WARNING, 0]
|
105
|
+
when POLICY_ACTION_NONE
|
106
|
+
['Hi! Chelsea here, no policy violations for this audit!'\
|
107
|
+
"\nReport URL: #{absolute_report_html_url}",
|
108
|
+
COLOR_NONE, 0]
|
87
109
|
else
|
88
|
-
|
89
|
-
|
90
|
-
|
110
|
+
['Hi! Chelsea here, no policy violations for this audit, but unknown policy action!'\
|
111
|
+
"\nReport URL: #{absolute_report_html_url}",
|
112
|
+
COLOR_FAILURE, 1]
|
91
113
|
end
|
92
114
|
end
|
93
115
|
|
@@ -115,33 +137,37 @@ module Chelsea
|
|
115
137
|
res['statusUrl']
|
116
138
|
end
|
117
139
|
|
118
|
-
|
119
|
-
|
120
|
-
def _poll_status
|
140
|
+
def _poll_status # rubocop:disable Metrics/MethodLength
|
121
141
|
return unless @status_url
|
122
142
|
|
123
143
|
loop do
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
break
|
129
|
-
end
|
130
|
-
rescue RestClient::ResourceNotFound => _e
|
131
|
-
print '.'
|
132
|
-
sleep(1)
|
144
|
+
res = check_status(@status_url)
|
145
|
+
if res.code == 200
|
146
|
+
puts JSON.parse(res.body)
|
147
|
+
break
|
133
148
|
end
|
149
|
+
rescue RestClient::ResourceNotFound => _e
|
150
|
+
print '.'
|
151
|
+
sleep(1)
|
134
152
|
end
|
135
153
|
end
|
136
154
|
|
137
|
-
def _get_internal_application_id
|
155
|
+
def _get_internal_application_id # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
138
156
|
resource = RestClient::Resource.new(
|
139
157
|
_internal_application_id_api_url,
|
140
158
|
user: @options[:username],
|
141
159
|
password: @options[:auth_token]
|
142
160
|
)
|
143
161
|
res = resource.get _headers
|
162
|
+
if res.code != 200
|
163
|
+
puts "failure reading application id: #{@options[:public_application_id]}. response status: #{res.code}"
|
164
|
+
return
|
165
|
+
end
|
144
166
|
body = JSON.parse(res)
|
167
|
+
if body['applications'].empty?
|
168
|
+
puts "failed to get internal application id for IQ application id: #{@options[:public_application_id]}"
|
169
|
+
return
|
170
|
+
end
|
145
171
|
body['applications'][0]['id']
|
146
172
|
end
|
147
173
|
|
@@ -150,7 +176,9 @@ module Chelsea
|
|
150
176
|
end
|
151
177
|
|
152
178
|
def _api_url
|
179
|
+
# rubocop:disable Layout/LineLength
|
153
180
|
"#{@options[:server_url]}/api/v2/scan/applications/#{@internal_application_id}/sources/chelsea?stageId=#{@options[:stage]}"
|
181
|
+
# rubocop:enable Layout/LineLength
|
154
182
|
end
|
155
183
|
|
156
184
|
def _internal_application_id_api_url
|
data/lib/chelsea/oss_index.rb
CHANGED
@@ -21,11 +21,12 @@ require 'rest-client'
|
|
21
21
|
require_relative 'db'
|
22
22
|
|
23
23
|
module Chelsea
|
24
|
+
# OSS Index audit operations
|
24
25
|
class OSSIndex
|
25
26
|
DEFAULT_OPTIONS = {
|
26
27
|
oss_index_username: '',
|
27
28
|
oss_index_user_token: ''
|
28
|
-
}
|
29
|
+
}.freeze
|
29
30
|
def initialize(options: DEFAULT_OPTIONS)
|
30
31
|
@oss_index_user_name = options[:oss_index_user_name]
|
31
32
|
@oss_index_user_token = options[:oss_index_user_token]
|
@@ -37,13 +38,11 @@ module Chelsea
|
|
37
38
|
|
38
39
|
def get_vulns(coordinates)
|
39
40
|
remaining_coordinates, cached_server_response = _cache(coordinates)
|
40
|
-
unless remaining_coordinates['coordinates'].count.positive?
|
41
|
-
return cached_server_response
|
42
|
-
end
|
41
|
+
return cached_server_response unless remaining_coordinates['coordinates'].count.positive?
|
43
42
|
|
44
43
|
remaining_coordinates['coordinates'].each_slice(128).to_a.each do |coords|
|
45
44
|
res_json = JSON.parse(call_oss_index({ 'coordinates' => coords }))
|
46
|
-
cached_server_response
|
45
|
+
cached_server_response.concat(res_json)
|
47
46
|
@db.save_values_to_db(res_json)
|
48
47
|
end
|
49
48
|
|
@@ -57,15 +56,15 @@ module Chelsea
|
|
57
56
|
|
58
57
|
private
|
59
58
|
|
60
|
-
def _cache(coordinates)
|
59
|
+
def _cache(coordinates) # rubocop:disable Metrics/MethodLength
|
61
60
|
new_coords = { 'coordinates' => [] }
|
62
61
|
cached_server_response = []
|
63
62
|
coordinates['coordinates'].each do |coord|
|
64
63
|
record = @db.get_cached_value_from_db(coord)
|
65
|
-
if
|
66
|
-
cached_server_response << record
|
67
|
-
else
|
64
|
+
if record.nil?
|
68
65
|
new_coords['coordinates'].push(coord)
|
66
|
+
else
|
67
|
+
cached_server_response << record
|
69
68
|
end
|
70
69
|
end
|
71
70
|
[new_coords, cached_server_response]
|