chelsea 0.0.26 → 0.0.31
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/.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]
|