chelsea 0.0.7 → 0.0.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bin/chelsea +7 -2
- data/lib/chelsea.rb +19 -1
- data/lib/chelsea/bom.rb +97 -0
- data/lib/chelsea/cli.rb +41 -26
- data/lib/chelsea/config.rb +56 -37
- data/lib/chelsea/db.rb +39 -0
- data/lib/chelsea/deps.rb +22 -123
- data/lib/chelsea/formatters/factory.rb +13 -10
- data/lib/chelsea/formatters/text.rb +19 -18
- data/lib/chelsea/gems.rb +56 -71
- data/lib/chelsea/iq_client.rb +61 -0
- data/lib/chelsea/oss_index.rb +44 -17
- data/lib/chelsea/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eee37ec30bb7ddc5a3817c2d966bce9b7cd3ab35cd711c5e2ab79a82bee3f8bd
|
4
|
+
data.tar.gz: 31b9f377cf841c8c4e07580e6bc3a5a957baeac1ef6ddf0e019910c27c3213ee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: aa2e1cde68d722b3d4adbaff36e8b25fe0175b208b20ca18a9a592259dd6445e3481401f2de7c5708c74c2bbf80b5c945f4f9f046c7f6b688e424404428cb246
|
7
|
+
data.tar.gz: '019a53c49865e117967e9cbed7711e5b0b2d339bf5b3457e99440b118f7775a07505f2311fae4bb3ce9fa8c00e3c4187cc01cf1dd1a5718a3c74acda9b9a3fda'
|
data/bin/chelsea
CHANGED
@@ -7,11 +7,16 @@ opts =
|
|
7
7
|
Slop.parse do |o|
|
8
8
|
o.string '-f', '--file', 'path to your Gemfile.lock'
|
9
9
|
o.bool '-c', '--config', 'Set persistent config for OSS Index'
|
10
|
-
o.string '-u', '--user', 'Specify OSS Index Username'
|
11
|
-
o.string '-p', '--token', 'Specify OSS Index API Token'
|
10
|
+
o.string '-u', '--user', 'Specify OSS Index Username'
|
11
|
+
o.string '-p', '--token', 'Specify OSS Index API Token'
|
12
|
+
o.string '-a', '--application', 'Specify the IQ application id', default: 'testapp'
|
13
|
+
o.string '-i', '--server', 'Specific the IQ server url', default: 'http://localhost:8070'
|
14
|
+
o.string '-iu', '--iquser', 'Specify the IQ username', default: 'admin'
|
15
|
+
o.string '-it', '--iqpass', 'Specify the IQ auth token', default: 'admin123'
|
12
16
|
o.string '-w', '--whitelist', 'Set path to vulnerability whitelist file'
|
13
17
|
o.bool '-q', '--quiet', 'make chelsea only output vulnerable third party dependencies for text output (default: false)', default: false
|
14
18
|
o.string '-t', '--format', 'choose what type of format you want your report in (default: text) (options: text, json, xml)', default: 'text'
|
19
|
+
o.bool '-b', '--sbom', 'generate an sbom'
|
15
20
|
o.on '--version', 'print the version' do
|
16
21
|
puts Chelsea::VERSION
|
17
22
|
exit
|
data/lib/chelsea.rb
CHANGED
@@ -1 +1,19 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Lazy loading
|
4
|
+
require_relative 'chelsea/cli'
|
5
|
+
require_relative 'chelsea/deps'
|
6
|
+
require_relative 'chelsea/bom'
|
7
|
+
require_relative 'chelsea/iq_client'
|
8
|
+
require_relative 'chelsea/oss_index'
|
9
|
+
require_relative 'chelsea/config'
|
10
|
+
require_relative 'chelsea/version'
|
11
|
+
# module Chelsea
|
12
|
+
# autoload :CLI, 'chelsea/cli'
|
13
|
+
# autoload :Deps, 'chelsea/deps'
|
14
|
+
# autoload :Bom, 'chelsea/bom'
|
15
|
+
# autoload :IQClient, 'chelsea/iq_client'
|
16
|
+
# autoload :OSSIndex, 'chelsea/oss_index'
|
17
|
+
# autoload :Config, 'chelsea/config'
|
18
|
+
# autoload :Version, 'chelsea/version'
|
19
|
+
# end
|
data/lib/chelsea/bom.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'ox'
|
5
|
+
|
6
|
+
module Chelsea
|
7
|
+
# Class to convext dependencies to BOM xml
|
8
|
+
class Bom
|
9
|
+
attr_accessor :xml
|
10
|
+
def initialize(dependencies)
|
11
|
+
@dependencies = dependencies
|
12
|
+
@xml = _get_xml
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
Ox.dump(@xml).to_s
|
17
|
+
end
|
18
|
+
|
19
|
+
def random_urn_uuid
|
20
|
+
'urn:uuid:' + SecureRandom.uuid
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def _get_xml
|
26
|
+
doc = Ox::Document.new
|
27
|
+
doc << _root_xml
|
28
|
+
bom = _bom_xml
|
29
|
+
doc << bom
|
30
|
+
components = Ox::Element.new('components')
|
31
|
+
@dependencies.each do |_, (name, version)|
|
32
|
+
components << _component_xml(name, version)
|
33
|
+
end
|
34
|
+
bom << components
|
35
|
+
doc
|
36
|
+
end
|
37
|
+
|
38
|
+
def _bom_xml
|
39
|
+
bom = Ox::Element.new('bom')
|
40
|
+
bom[:xmlns] = 'http://cyclonedx.org/schema/bom/1.1'
|
41
|
+
bom[:version] = '1'
|
42
|
+
bom[:serialNumber] = random_urn_uuid
|
43
|
+
bom
|
44
|
+
end
|
45
|
+
|
46
|
+
def _root_xml
|
47
|
+
instruct = Ox::Instruct.new(:xml)
|
48
|
+
instruct[:version] = '1.0'
|
49
|
+
instruct[:encoding] = 'UTF-8'
|
50
|
+
instruct[:standalone] = 'yes'
|
51
|
+
instruct
|
52
|
+
end
|
53
|
+
|
54
|
+
def _component_xml(name, version)
|
55
|
+
component = Ox::Element.new('component')
|
56
|
+
component[:type] = 'library'
|
57
|
+
n = Ox::Element.new('name')
|
58
|
+
n << name
|
59
|
+
v = Ox::Element.new('version')
|
60
|
+
v << version.version
|
61
|
+
purl = Ox::Element.new('purl')
|
62
|
+
purl << Chelsea.to_purl(name, version.version)
|
63
|
+
component << n << v << purl
|
64
|
+
component
|
65
|
+
end
|
66
|
+
|
67
|
+
def _show_logo
|
68
|
+
logo = %Q(
|
69
|
+
-o/
|
70
|
+
-+hNmNN-
|
71
|
+
.:+osyhddddyso/-``ody+- .NN.
|
72
|
+
/mMMdhssooooooosyhdmhs/. /Mm-
|
73
|
+
oMs` `.-:. oMNs. .
|
74
|
+
`N. `. .+hNh+` +N.
|
75
|
+
yo -m` -d` `dm. `:smd+. `yMM.
|
76
|
+
-m`mM/ -mN/`ddMs -sNh/ .dy-M-
|
77
|
+
dmdsd/m--dmo Nh `o: /o` `+md- :m/ N:
|
78
|
+
/y `Nd` do my .dMy. .hMy` `oN+ om- m:
|
79
|
+
+ . `No +NN+oNm: .d+ `hd` d:
|
80
|
+
`Mo .dMMy SBOM `d+ms` d:
|
81
|
+
`. -M+ `yMhmNo` BABY `hN/- d/
|
82
|
+
/: yd /o -M/ /NN/ +Nm: +Nd.-mo m+
|
83
|
+
dm`/mmo-NMo /M- .dMs` `o/ /mN+ `hh. N+
|
84
|
+
-MMdN//NNhhMysm /- `+mMs` +mo N+
|
85
|
+
sN/Ny hd``yMMh :yNNs. `sm+M+
|
86
|
+
dd.`` `` /d- oy. `/yNNh/` .yM+
|
87
|
+
`yNy/` oMm` `/sdMdo- ..
|
88
|
+
`/ymmys+///++shN+/Nm. /NMNo.
|
89
|
+
`-/+ooo+/:.` :NN- /MMo`
|
90
|
+
-NNoNM+
|
91
|
+
:MMM+
|
92
|
+
:d/
|
93
|
+
)
|
94
|
+
puts logo
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
data/lib/chelsea/cli.rb
CHANGED
@@ -4,50 +4,66 @@ require 'tty-font'
|
|
4
4
|
|
5
5
|
require_relative 'version'
|
6
6
|
require_relative 'gems'
|
7
|
+
require_relative 'iq_client'
|
7
8
|
require_relative 'config'
|
8
9
|
|
9
10
|
module Chelsea
|
10
11
|
##
|
11
12
|
# This class provides an interface to the oss index, gems and deps
|
12
13
|
class CLI
|
13
|
-
|
14
14
|
def initialize(opts)
|
15
15
|
@opts = opts
|
16
16
|
@pastel = Pastel.new
|
17
17
|
_validate_arguments
|
18
|
-
_show_logo
|
18
|
+
_show_logo # Move to formatter
|
19
19
|
end
|
20
20
|
|
21
21
|
def process!
|
22
22
|
if @opts.config?
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
23
|
+
_set_config # move to init
|
24
|
+
elsif @opts.file? # don't process unless there was a file
|
25
|
+
_process_file
|
26
|
+
_submit_sbom if @opts.sbom?
|
27
|
+
elsif @opts.help? # quit on opts.help earlier
|
28
|
+
puts _cli_flags # this doesn't exist
|
28
29
|
end
|
29
30
|
end
|
30
31
|
|
31
|
-
# this is how you do static methods in ruby, because in a test we want to
|
32
|
-
# check for version without opts, and heck, we don't even want a dang object!
|
33
32
|
def self.version
|
34
33
|
Chelsea::VERSION
|
35
34
|
end
|
36
35
|
|
37
|
-
|
36
|
+
private
|
38
37
|
|
39
|
-
def
|
40
|
-
|
41
|
-
|
38
|
+
def _submit_sbom
|
39
|
+
iq = Chelsea::IQClient.new(
|
40
|
+
@opts[:application],
|
41
|
+
@opts[:server],
|
42
|
+
@opts[:iquser],
|
43
|
+
@opts[:iqpass]
|
44
|
+
)
|
45
|
+
bom = Chelsea::Bom.new(@gems.deps)
|
46
|
+
iq.submit_sbom(bom)
|
47
|
+
end
|
48
|
+
|
49
|
+
def _process_file
|
50
|
+
gems = Chelsea::Gems.new(
|
51
|
+
file: @opts[:file],
|
52
|
+
quiet: @opts[:quiet],
|
53
|
+
options: @opts
|
54
|
+
)
|
55
|
+
gems.execute # should be more like collect
|
56
|
+
end
|
42
57
|
|
58
|
+
def _flags_error
|
59
|
+
switches = _flags.collect { |f| "--#{f}" }
|
43
60
|
abort "please set one of #{switches}"
|
44
61
|
end
|
45
62
|
|
46
63
|
def _validate_arguments
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
end
|
64
|
+
return unless !_flags_set? && !@opts.file?
|
65
|
+
|
66
|
+
_flags_error
|
51
67
|
end
|
52
68
|
|
53
69
|
def _flags_set?
|
@@ -58,23 +74,22 @@ module Chelsea
|
|
58
74
|
|
59
75
|
def _flags
|
60
76
|
# Seems wrong, should all be handled by bin
|
61
|
-
[
|
77
|
+
%i[file help config]
|
62
78
|
end
|
63
79
|
|
64
|
-
def _show_logo
|
80
|
+
def _show_logo
|
65
81
|
font = TTY::Font.new(:doom)
|
66
|
-
puts @pastel.green(font.write(
|
67
|
-
puts @pastel.green(
|
82
|
+
puts @pastel.green(font.write('Chelsea'))
|
83
|
+
puts @pastel.green('Version: ' + CLI.version)
|
68
84
|
end
|
69
85
|
|
70
|
-
def
|
86
|
+
def _load_config
|
71
87
|
config = Chelsea::Config.new
|
72
|
-
|
88
|
+
config.oss_index_config
|
73
89
|
end
|
74
90
|
|
75
|
-
def
|
76
|
-
|
77
|
-
config.get_oss_index_config_from_command_line()
|
91
|
+
def _set_config
|
92
|
+
Chelsea.oss_index_config_from_command_line
|
78
93
|
end
|
79
94
|
end
|
80
95
|
end
|
data/lib/chelsea/config.rb
CHANGED
@@ -1,53 +1,72 @@
|
|
1
1
|
require 'yaml'
|
2
|
+
require_relative 'oss_index'
|
2
3
|
|
3
4
|
module Chelsea
|
4
|
-
|
5
|
-
|
6
|
-
@oss_index_config_location = File.join("#{Dir.home}", ".ossindex")
|
7
|
-
@oss_index_config_filename = ".oss-index-config"
|
8
|
-
end
|
5
|
+
@oss_index_config_location = File.join(Dir.home.to_s, '.ossindex')
|
6
|
+
@oss_index_config_filename = '.oss-index-config'
|
9
7
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
else
|
14
|
-
oss_index_config = YAML.load(File.read(File.join(@oss_index_config_location, @oss_index_config_filename)))
|
8
|
+
def self.to_purl(name, version)
|
9
|
+
"pkg:gem/#{name}@#{version}"
|
10
|
+
end
|
15
11
|
|
16
|
-
|
17
|
-
|
12
|
+
def self.config(options = {})
|
13
|
+
if !options[:user].nil? && !options[:token].nil?
|
14
|
+
Chelsea::OSSIndex.new(
|
15
|
+
oss_index_user_name: options[:user],
|
16
|
+
oss_index_user_token: options[:token]
|
17
|
+
)
|
18
|
+
else
|
19
|
+
Chelsea::OSSIndex.new(oss_index_config)
|
18
20
|
end
|
21
|
+
end
|
19
22
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
white_list_vuln_config = YAML.load(File.read(white_list_config_path))
|
25
|
-
end
|
23
|
+
def self.client(options = {})
|
24
|
+
@client ||= config(options)
|
25
|
+
@client
|
26
|
+
end
|
26
27
|
|
27
|
-
|
28
|
+
def self.oss_index_config
|
29
|
+
if !File.exist? File.join(@oss_index_config_location, @oss_index_config_filename)
|
30
|
+
{ oss_index_user_name: '', oss_index_user_token: '' }
|
31
|
+
else
|
32
|
+
conf_hash = YAML.safe_load(
|
33
|
+
File.read(
|
34
|
+
File.join(@@oss_index_config_location, @@oss_index_config_filename)
|
35
|
+
)
|
36
|
+
)
|
37
|
+
{
|
38
|
+
oss_index_user_name: conf_hash['Username'],
|
39
|
+
oss_index_user_token: conf_hash['Token']
|
40
|
+
}
|
28
41
|
end
|
42
|
+
end
|
29
43
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
puts "What token do you want to use? "
|
37
|
-
config["Token"] = STDIN.gets.chomp
|
38
|
-
|
39
|
-
_set_oss_index_config(config)
|
44
|
+
def get_white_list_vuln_config(white_list_config_path)
|
45
|
+
if white_list_config_path.nil?
|
46
|
+
YAML.safe_load(File.read(File.join(Dir.pwd, 'chelsea-ignore.yaml')))
|
47
|
+
else
|
48
|
+
YAML.safe_load(File.read(white_list_config_path))
|
40
49
|
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.read_oss_index_config_from_command_line
|
53
|
+
config = {}
|
41
54
|
|
42
|
-
|
55
|
+
puts 'What username do you want to authenticate as (ex: your email address)? '
|
56
|
+
config['Username'] = STDIN.gets.chomp
|
43
57
|
|
44
|
-
|
45
|
-
|
58
|
+
puts 'What token do you want to use? '
|
59
|
+
config['Token'] = STDIN.gets.chomp
|
46
60
|
|
47
|
-
|
48
|
-
|
49
|
-
end
|
50
|
-
end
|
61
|
+
_write_oss_index_config_file(config)
|
62
|
+
end
|
51
63
|
|
64
|
+
def self._write_oss_index_config_file(config)
|
65
|
+
unless File.exist? @oss_index_config_location
|
66
|
+
Dir.mkdir(@oss_index_config_location)
|
67
|
+
end
|
68
|
+
File.open(File.join(@oss_index_config_location, @oss_index_config_filename), "w") do |file|
|
69
|
+
file.write config.to_yaml
|
70
|
+
end
|
52
71
|
end
|
53
|
-
end
|
72
|
+
end
|
data/lib/chelsea/db.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'pstore'
|
2
|
+
|
3
|
+
module Chelsea
|
4
|
+
class DB
|
5
|
+
def initialize
|
6
|
+
@store = PStore.new(_get_db_store_location)
|
7
|
+
end
|
8
|
+
|
9
|
+
# This method will take an array of values, and save them to a pstore database
|
10
|
+
# and as well set a TTL of Time.now to be checked later
|
11
|
+
def save_values_to_db(values)
|
12
|
+
values.each do |val|
|
13
|
+
next unless get_cached_value_from_db(val['coordinates']).nil?
|
14
|
+
|
15
|
+
new_val = val.dup
|
16
|
+
new_val['ttl'] = Time.now
|
17
|
+
@store.transaction do
|
18
|
+
@store[new_val['coordinates']] = new_val
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def _get_db_store_location()
|
24
|
+
initial_path = File.join(Dir.home.to_s, '.ossindex')
|
25
|
+
Dir.mkdir(initial_path) unless File.exist? initial_path
|
26
|
+
File.join(initial_path, 'chelsea.pstore')
|
27
|
+
end
|
28
|
+
|
29
|
+
# Checks pstore to see if a coordinate exists, and if it does also
|
30
|
+
# checks to see if it's ttl has expired. Returns nil unless a record
|
31
|
+
# is valid in the cache (ttl has not expired) and found
|
32
|
+
def get_cached_value_from_db(val)
|
33
|
+
record = @store.transaction { @store[val] }
|
34
|
+
return if record.nil?
|
35
|
+
|
36
|
+
(Time.now - record['ttl']) / 3600 > 12 ? nil : record
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/chelsea/deps.rb
CHANGED
@@ -4,34 +4,16 @@ require 'rubygems'
|
|
4
4
|
require 'rubygems/commands/dependency_command'
|
5
5
|
require 'json'
|
6
6
|
require 'rest-client'
|
7
|
-
require 'pstore'
|
8
7
|
|
9
8
|
require_relative 'dependency_exception'
|
10
9
|
require_relative 'oss_index'
|
11
10
|
|
12
11
|
module Chelsea
|
13
12
|
class Deps
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
@
|
18
|
-
@path, @quiet = path, quiet
|
19
|
-
ENV['BUNDLE_GEMFILE'] = File.expand_path(path).chomp(".lock")
|
20
|
-
|
21
|
-
begin
|
22
|
-
@lockfile = Bundler::LockfileParser.new(
|
23
|
-
File.read(@path)
|
24
|
-
)
|
25
|
-
rescue
|
26
|
-
raise "Gemfile.lock not parseable, please check file or that it's path is valid"
|
27
|
-
end
|
28
|
-
|
29
|
-
@dependencies = {}
|
30
|
-
@reverse_dependencies = {}
|
31
|
-
@dependencies_versions = {}
|
32
|
-
@coordinates = { 'coordinates' => [] }
|
33
|
-
@server_response = []
|
34
|
-
@store = PStore.new(_get_db_store_location())
|
13
|
+
def initialize(path:, quiet: false)
|
14
|
+
@quiet = quiet
|
15
|
+
ENV['BUNDLE_GEMFILE'] = File.expand_path(path).chomp('.lock')
|
16
|
+
@lockfile = Bundler::LockfileParser.new(File.read(path))
|
35
17
|
end
|
36
18
|
|
37
19
|
def nil?
|
@@ -39,119 +21,36 @@ module Chelsea
|
|
39
21
|
end
|
40
22
|
|
41
23
|
def self.to_purl(name, version)
|
42
|
-
|
24
|
+
"pkg:gem/#{name}@#{version}"
|
43
25
|
end
|
44
26
|
|
45
|
-
# Parses specs from lockfile instanct var and
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
rescue StandardError => e
|
51
|
-
raise Chelsea::DependencyException e, "Parsing dependency line #{gem} failed."
|
52
|
-
end
|
27
|
+
# Parses specs from lockfile instanct var and
|
28
|
+
# inserts into dependenices instance var
|
29
|
+
def dependencies
|
30
|
+
@lockfile.specs.each_with_object({}) do |gem, h|
|
31
|
+
h[gem.name] = [gem.name, gem.version]
|
53
32
|
end
|
54
33
|
end
|
55
34
|
|
56
35
|
# Collects all reverse dependencies in reverse_dependencies instance var
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
36
|
+
# this rescue block honks
|
37
|
+
def reverse_dependencies
|
38
|
+
reverse = Gem::Commands::DependencyCommand.new
|
39
|
+
reverse.options[:reverse_dependencies] = true
|
40
|
+
# We want to filter the reverses dependencies by specs in lockfile
|
41
|
+
spec_names = @lockfile.specs.map { |i| i.to_s.split }.map { |n, _| "#{n}" }
|
42
|
+
reverse.reverse_dependencies(@lockfile.specs).to_h.transform_values do |reverse_dep|
|
43
|
+
# Add filtering if version meets range
|
44
|
+
reverse_dep.select { |name, dep, req, _| spec_names.include?(name.split("-")[0]) }
|
64
45
|
end
|
65
46
|
end
|
66
47
|
|
67
48
|
# Iterates over all dependencies and stores them
|
68
49
|
# in dependencies_versions and coordinates instance vars
|
69
|
-
def
|
70
|
-
|
71
|
-
|
72
|
-
v = r[1].to_s
|
73
|
-
if v.split('.').length == 1 then
|
74
|
-
v = v + ".0.0"
|
75
|
-
elsif v.split('.').length == 2 then
|
76
|
-
v = v + ".0"
|
77
|
-
end
|
78
|
-
@dependencies_versions[p] = v
|
79
|
-
end
|
80
|
-
|
81
|
-
@dependencies_versions.each do |p, v|
|
82
|
-
@coordinates["coordinates"] << self.class.to_purl(p,v);
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
# Makes REST calls to OSS for vulnerabilities 128 coordinates at a time
|
87
|
-
# Checks cache and stores results in cache
|
88
|
-
def get_vulns()
|
89
|
-
_check_db_for_cached_values()
|
90
|
-
|
91
|
-
if @coordinates["coordinates"].count() > 0
|
92
|
-
chunked = Hash.new()
|
93
|
-
@coordinates["coordinates"].each_slice(128).to_a.each do |coords|
|
94
|
-
chunked["coordinates"] = coords
|
95
|
-
res_json = @oss_index_client.call_oss_index(chunked)
|
96
|
-
@server_response = @server_response.concat(res_json)
|
97
|
-
_save_values_to_db(res_json)
|
98
|
-
end
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
protected
|
103
|
-
# This method will take an array of values, and save them to a pstore database
|
104
|
-
# and as well set a TTL of Time.now to be checked later
|
105
|
-
def _save_values_to_db(values)
|
106
|
-
values.each do |val|
|
107
|
-
if _get_cached_value_from_db(val["coordinates"]).nil?
|
108
|
-
new_val = val.dup
|
109
|
-
new_val["ttl"] = Time.now
|
110
|
-
@store.transaction do
|
111
|
-
@store[new_val["coordinates"]] = new_val
|
112
|
-
end
|
113
|
-
end
|
114
|
-
end
|
115
|
-
end
|
116
|
-
|
117
|
-
def _get_db_store_location()
|
118
|
-
initial_path = File.join("#{Dir.home}", ".ossindex")
|
119
|
-
Dir.mkdir(initial_path) unless File.exists? initial_path
|
120
|
-
path = File.join(initial_path, "chelsea.pstore")
|
121
|
-
end
|
122
|
-
|
123
|
-
# Checks pstore to see if a coordinate exists, and if it does also
|
124
|
-
# checks to see if it's ttl has expired. Returns nil unless a record
|
125
|
-
# is valid in the cache (ttl has not expired) and found
|
126
|
-
def _get_cached_value_from_db(coordinate)
|
127
|
-
record = @store.transaction { @store[coordinate] }
|
128
|
-
if !record.nil?
|
129
|
-
diff = (Time.now - record['ttl']) / 3600
|
130
|
-
if diff > 12
|
131
|
-
return nil
|
132
|
-
else
|
133
|
-
return record
|
134
|
-
end
|
135
|
-
else
|
136
|
-
return nil
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
# Goes through the list of @coordinates and checks pstore for them, if it finds a valid coord
|
141
|
-
# it will add it to the server response. If it does not, it will append the coord to a new hash
|
142
|
-
# and eventually set @coordinates to the new hash, so we query OSS Index on only coords not in cache
|
143
|
-
def _check_db_for_cached_values()
|
144
|
-
new_coords = Hash.new
|
145
|
-
new_coords["coordinates"] = Array.new
|
146
|
-
@coordinates["coordinates"].each do |coord|
|
147
|
-
record = _get_cached_value_from_db(coord)
|
148
|
-
if !record.nil?
|
149
|
-
@server_response << record
|
150
|
-
else
|
151
|
-
new_coords["coordinates"].push(coord)
|
152
|
-
end
|
50
|
+
def coordinates
|
51
|
+
dependencies.each_with_object({ 'coordinates' => [] }) do |(name, v), coords|
|
52
|
+
coords['coordinates'] << self.class.to_purl(name, v[1]);
|
153
53
|
end
|
154
|
-
@coordinates = new_coords
|
155
54
|
end
|
156
55
|
end
|
157
56
|
end
|
@@ -2,15 +2,18 @@ require_relative 'json'
|
|
2
2
|
require_relative 'xml'
|
3
3
|
require_relative 'text'
|
4
4
|
|
5
|
+
# Factory for formatting dependencies
|
5
6
|
class FormatterFactory
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
7
|
+
def get_formatter(format: 'text', quiet: false)
|
8
|
+
case format
|
9
|
+
when 'text'
|
10
|
+
Chelsea::TextFormatter.new quiet: quiet
|
11
|
+
when 'json'
|
12
|
+
Chelsea::JsonFormatter.new quiet: quiet
|
13
|
+
when 'xml'
|
14
|
+
Chelsea::XMLFormatter.new quiet: quiet
|
15
|
+
else
|
16
|
+
Chelsea::TextFormatter.new quiet: quiet
|
17
|
+
end
|
15
18
|
end
|
16
|
-
end
|
19
|
+
end
|
@@ -8,7 +8,7 @@ module Chelsea
|
|
8
8
|
@pastel = Pastel.new
|
9
9
|
end
|
10
10
|
|
11
|
-
def get_results(
|
11
|
+
def get_results(server_response, reverse_dependencies)
|
12
12
|
response = String.new
|
13
13
|
if !@quiet
|
14
14
|
response += "\n"\
|
@@ -17,25 +17,25 @@ module Chelsea
|
|
17
17
|
end
|
18
18
|
|
19
19
|
i = 0
|
20
|
-
count =
|
21
|
-
|
20
|
+
count = server_response.count()
|
21
|
+
server_response.each do |r|
|
22
22
|
i += 1
|
23
|
-
package = r[
|
24
|
-
vulnerable = r[
|
25
|
-
coord = r[
|
23
|
+
package = r['coordinates']
|
24
|
+
vulnerable = r['vulnerabilities'].length.positive?
|
25
|
+
coord = r['coordinates'].sub('pkg:gem/', '')
|
26
26
|
name = coord.split('@')[0]
|
27
27
|
version = coord.split('@')[1]
|
28
|
-
reverse_deps =
|
28
|
+
reverse_deps = reverse_dependencies["#{name}-#{version}"]
|
29
29
|
if vulnerable
|
30
|
-
response += @pastel.red("[#{i}/#{count}] - #{package} ") +
|
31
|
-
response += _get_reverse_deps(reverse_deps, name)
|
32
|
-
r[
|
33
|
-
response +=
|
30
|
+
response += @pastel.red("[#{i}/#{count}] - #{package} ") + @pastel.red.bold("Vulnerable.\n")
|
31
|
+
response += _get_reverse_deps(reverse_deps, name) if reverse_deps
|
32
|
+
r['vulnerabilities'].each do |k, v|
|
33
|
+
response += _format_vuln(v)
|
34
34
|
end
|
35
35
|
else
|
36
36
|
if !@quiet
|
37
37
|
response += @pastel.white("[#{i}/#{count}] - #{package} ") + @pastel.green.bold("No vulnerabilities found!\n")
|
38
|
-
response += _get_reverse_deps(reverse_deps, name)
|
38
|
+
response += _get_reverse_deps(reverse_deps, name) if reverse_deps
|
39
39
|
end
|
40
40
|
end
|
41
41
|
end
|
@@ -47,17 +47,18 @@ module Chelsea
|
|
47
47
|
puts results
|
48
48
|
end
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
50
|
+
def _format_vuln(vuln)
|
51
|
+
@pastel.red.bold("\n#{vuln}\n")
|
52
|
+
end
|
53
|
+
|
54
|
+
def _get_reverse_deps(coords, name)
|
55
|
+
coords.each_with_object(String.new) do |dep, s|
|
54
56
|
dep.each do |gran|
|
55
57
|
if gran.class == String && !gran.include?(name)
|
56
|
-
|
58
|
+
s << "\tRequired by: #{gran}\n"
|
57
59
|
end
|
58
60
|
end
|
59
61
|
end
|
60
|
-
response
|
61
62
|
end
|
62
63
|
end
|
63
64
|
end
|
data/lib/chelsea/gems.rb
CHANGED
@@ -1,118 +1,107 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
|
3
2
|
require 'pastel'
|
4
3
|
require 'tty-spinner'
|
5
4
|
require 'bundler'
|
6
5
|
require 'bundler/lockfile_parser'
|
7
6
|
require 'rubygems'
|
8
7
|
require 'rubygems/commands/dependency_command'
|
8
|
+
|
9
9
|
require_relative 'version'
|
10
10
|
require_relative 'formatters/factory'
|
11
11
|
require_relative 'deps'
|
12
|
-
|
12
|
+
require_relative 'bom'
|
13
13
|
|
14
14
|
module Chelsea
|
15
15
|
class Gems
|
16
16
|
def initialize(file:, quiet: false, options: {})
|
17
|
-
@
|
18
|
-
|
19
|
-
|
20
|
-
raise "Gemfile.lock not found, check --file path"
|
17
|
+
@quiet = quiet
|
18
|
+
unless File.file?(file) || file.nil?
|
19
|
+
raise 'Gemfile.lock not found, check --file path'
|
21
20
|
end
|
21
|
+
_silence_stderr if @quiet
|
22
|
+
|
22
23
|
@pastel = Pastel.new
|
23
|
-
@formatter = FormatterFactory.new.get_formatter(format:
|
24
|
-
@
|
24
|
+
@formatter = FormatterFactory.new.get_formatter(format: options[:format], quiet: @quiet)
|
25
|
+
@client = Chelsea.client(options)
|
26
|
+
@deps = Chelsea::Deps.new(path: Pathname.new(file))
|
25
27
|
end
|
26
28
|
|
27
29
|
# Audits depenencies using deps library and prints results
|
28
30
|
# using formatter library
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
31
|
+
|
32
|
+
def execute
|
33
|
+
server_response, dependencies, reverse_dependencies = audit
|
34
|
+
if dependencies.nil?
|
35
|
+
_print_err 'No dependencies retrieved. Exiting.'
|
33
36
|
return
|
34
37
|
end
|
35
|
-
if
|
36
|
-
_print_err
|
38
|
+
if server_response.nil?
|
39
|
+
_print_err 'No vulnerability data retrieved from server. Exiting.'
|
37
40
|
return
|
38
41
|
end
|
39
|
-
|
40
|
-
|
41
|
-
# end
|
42
|
-
@formatter.do_print(@formatter.get_results(@deps))
|
42
|
+
results = @formatter.get_results(server_response, reverse_dependencies)
|
43
|
+
@formatter.do_print(results)
|
43
44
|
end
|
44
45
|
|
45
46
|
# Runs through auditing algorithm, raising exceptions
|
46
47
|
# on REST calls made by @deps.get_vulns
|
47
48
|
def audit
|
48
|
-
|
49
|
-
|
50
|
-
|
49
|
+
# This spinner management is out of control
|
50
|
+
# we should wrap a block with start and stop messages,
|
51
|
+
# or use a stack to ensure all spinners stop.
|
52
|
+
spinner = _spin_msg 'Parsing dependencies'
|
51
53
|
|
52
54
|
begin
|
53
|
-
@deps.
|
54
|
-
|
55
|
-
spinner.success("...done.")
|
56
|
-
end
|
55
|
+
dependencies = @deps.dependencies
|
56
|
+
spinner.success('...done.')
|
57
57
|
rescue StandardError => e
|
58
|
-
|
59
|
-
spinner.stop
|
60
|
-
end
|
58
|
+
spinner.stop
|
61
59
|
_print_err "Parsing dependency line #{gem} failed."
|
62
60
|
end
|
63
61
|
|
64
|
-
@deps.
|
62
|
+
reverse_dependencies = @deps.reverse_dependencies
|
65
63
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
unless @quiet
|
71
|
-
spinner.success("...done.")
|
72
|
-
end
|
73
|
-
|
74
|
-
unless @quiet
|
75
|
-
spinner = _spin_msg "Making request to OSS Index server"
|
76
|
-
end
|
64
|
+
spinner = _spin_msg 'Parsing Versions'
|
65
|
+
coordinates = @deps.coordinates
|
66
|
+
spinner.success('...done.')
|
67
|
+
spinner = _spin_msg 'Making request to OSS Index server'
|
77
68
|
|
78
69
|
begin
|
79
|
-
@
|
80
|
-
|
81
|
-
spinner.success("...done.")
|
82
|
-
end
|
70
|
+
server_response = @client.get_vulns(coordinates)
|
71
|
+
spinner.success('...done.')
|
83
72
|
rescue SocketError => e
|
84
|
-
|
85
|
-
|
86
|
-
end
|
87
|
-
_print_err "Socket error getting data from OSS Index server."
|
73
|
+
spinner.stop('...request failed.')
|
74
|
+
_print_err 'Socket error getting data from OSS Index server.'
|
88
75
|
rescue RestClient::RequestFailed => e
|
89
|
-
|
90
|
-
spinner.stop("...request failed.")
|
91
|
-
end
|
76
|
+
spinner.stop('...request failed.')
|
92
77
|
_print_err "Error getting data from OSS Index server:#{e.response}."
|
93
|
-
rescue RestClient::
|
94
|
-
|
95
|
-
|
96
|
-
end
|
97
|
-
_print_err "Error getting data from OSS Index server. Resource not found."
|
78
|
+
rescue RestClient::ResourceNotFound => e
|
79
|
+
spinner.stop('...request failed.')
|
80
|
+
_print_err 'Error getting data from OSS Index server. Resource not found.'
|
98
81
|
rescue Errno::ECONNREFUSED => e
|
99
|
-
|
100
|
-
|
101
|
-
end
|
102
|
-
_print_err "Error getting data from OSS Index server. Connection refused."
|
82
|
+
spinner.stop('...request failed.')
|
83
|
+
_print_err 'Error getting data from OSS Index server. Connection refused.'
|
103
84
|
rescue StandardError => e
|
104
|
-
|
105
|
-
|
106
|
-
end
|
107
|
-
_print_err "UNKNOWN Error getting data from OSS Index server."
|
85
|
+
spinner.stop('...request failed.')
|
86
|
+
_print_err 'UNKNOWN Error getting data from OSS Index server.'
|
108
87
|
end
|
88
|
+
[server_response, dependencies, reverse_dependencies]
|
109
89
|
end
|
110
90
|
|
111
91
|
protected
|
92
|
+
|
93
|
+
def _silence_stderr
|
94
|
+
$stderr.reopen('/dev/null', 'w')
|
95
|
+
end
|
96
|
+
|
112
97
|
def _spin_msg(msg)
|
113
98
|
format = "[#{@pastel.green(':spinner')}] " + @pastel.white(msg)
|
114
|
-
spinner = TTY::Spinner.new(
|
115
|
-
|
99
|
+
spinner = TTY::Spinner.new(
|
100
|
+
format,
|
101
|
+
success_mark: @pastel.green('+'),
|
102
|
+
hide_cursor: true
|
103
|
+
)
|
104
|
+
spinner.auto_spin
|
116
105
|
spinner
|
117
106
|
end
|
118
107
|
|
@@ -123,9 +112,5 @@ module Chelsea
|
|
123
112
|
def _print_success(s)
|
124
113
|
puts @pastel.green.bold(s)
|
125
114
|
end
|
126
|
-
|
127
|
-
def _gemfile_lock_file_exists?
|
128
|
-
::File.file? @file
|
129
|
-
end
|
130
115
|
end
|
131
|
-
end
|
116
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'rest-client'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Chelsea
|
5
|
+
class IQClient
|
6
|
+
DEFAULT_OPTIONS = {
|
7
|
+
public_application_id: 'testapp',
|
8
|
+
server_url: 'http://localhost:8070',
|
9
|
+
username: 'admin',
|
10
|
+
auth_token: 'admin123',
|
11
|
+
internal_application_id: ''
|
12
|
+
}
|
13
|
+
def initialize(options: DEFAULT_OPTIONS)
|
14
|
+
@options = options
|
15
|
+
end
|
16
|
+
|
17
|
+
def submit_sbom(sbom)
|
18
|
+
@internal_application_id = _get_internal_application_id()
|
19
|
+
resource = RestClient::Resource.new(
|
20
|
+
_api_url,
|
21
|
+
user: @options[:username],
|
22
|
+
password: @options[:auth_token]
|
23
|
+
)
|
24
|
+
_headers['Content-Type'] = 'application/xml'
|
25
|
+
resource.post sbom.to_s, _headers
|
26
|
+
end
|
27
|
+
|
28
|
+
def status_url(res)
|
29
|
+
res = JSON.parse(res.body)
|
30
|
+
res['statusUrl']
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def _get_internal_application_id
|
36
|
+
resource = RestClient::Resource.new(
|
37
|
+
_internal_application_id_api_url,
|
38
|
+
user: @username,
|
39
|
+
password: @auth_token
|
40
|
+
)
|
41
|
+
res = JSON.parse(resource.get(headers))
|
42
|
+
res['applications'][0]['id']
|
43
|
+
end
|
44
|
+
|
45
|
+
def _headers
|
46
|
+
{ 'User-Agent' => _user_agent }
|
47
|
+
end
|
48
|
+
|
49
|
+
def _api_url
|
50
|
+
"#{@options[:server_url]}/api/v2/scan/applications/#{@@internal_application_id}/sources/chelsea"
|
51
|
+
end
|
52
|
+
|
53
|
+
def _internal_application_id_api_url
|
54
|
+
"#{@options[:server_url]}/api/v2/applications?publicId=#{@options[:public_application_id]}"
|
55
|
+
end
|
56
|
+
|
57
|
+
def _user_agent
|
58
|
+
"chelsea/#{Chelsea::VERSION}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
data/lib/chelsea/oss_index.rb
CHANGED
@@ -1,50 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative 'config'
|
2
4
|
require 'rest-client'
|
5
|
+
require_relative 'db'
|
3
6
|
|
4
7
|
module Chelsea
|
5
8
|
class OSSIndex
|
9
|
+
def initialize(oss_index_user_name: '', oss_index_user_token: '')
|
10
|
+
@oss_index_user_name = oss_index_user_name
|
11
|
+
@oss_index_user_token = oss_index_user_token
|
12
|
+
@db = DB.new
|
13
|
+
end
|
6
14
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
# Makes REST calls to OSS for vulnerabilities 128 coordinates at a time
|
16
|
+
# Checks cache and stores results in cache
|
17
|
+
|
18
|
+
def get_vulns(coordinates)
|
19
|
+
remaining_coordinates, cached_server_response = _cache(coordinates)
|
20
|
+
unless remaining_coordinates['coordinates'].count.positive?
|
21
|
+
return cached_server_response
|
22
|
+
end
|
23
|
+
|
24
|
+
remaining_coordinates['coordinates'].each_slice(128).to_a.each do |coords|
|
25
|
+
res_json = call_oss_index({ 'coordinates' => coords })
|
26
|
+
cached_server_response = cached_server_response.concat(res_json)
|
27
|
+
@db.save_values_to_db(res_json)
|
17
28
|
end
|
29
|
+
cached_server_response
|
18
30
|
end
|
19
31
|
|
20
32
|
def call_oss_index(coords)
|
21
33
|
r = _resource.post coords.to_json, _headers
|
22
|
-
|
23
|
-
JSON.parse(r.body)
|
24
|
-
end
|
34
|
+
r.code == 200 ? JSON.parse(r.body) : {}
|
25
35
|
end
|
26
36
|
|
27
37
|
private
|
28
38
|
|
39
|
+
def _cache(coordinates)
|
40
|
+
new_coords = { 'coordinates' => [] }
|
41
|
+
cached_server_response = []
|
42
|
+
coordinates['coordinates'].each do |coord|
|
43
|
+
record = @db.get_cached_value_from_db(coord)
|
44
|
+
if !record.nil?
|
45
|
+
cached_server_response << record
|
46
|
+
else
|
47
|
+
new_coords['coordinates'].push(coord)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
[new_coords, cached_server_response]
|
51
|
+
end
|
52
|
+
|
29
53
|
def _headers
|
30
54
|
{ :content_type => :json, :accept => :json, 'User-Agent' => _user_agent }
|
31
55
|
end
|
32
56
|
|
33
57
|
def _resource
|
34
58
|
if !@oss_index_user_name.empty? && !@oss_index_user_token.empty?
|
35
|
-
RestClient::Resource.new
|
59
|
+
RestClient::Resource.new(
|
60
|
+
_api_url,
|
61
|
+
user: @oss_index_user_name,
|
62
|
+
password: @oss_index_user_token
|
63
|
+
)
|
36
64
|
else
|
37
65
|
RestClient::Resource.new _api_url
|
38
66
|
end
|
39
67
|
end
|
40
68
|
|
41
69
|
def _api_url
|
42
|
-
|
70
|
+
'https://ossindex.sonatype.org/api/v3/component-report'
|
43
71
|
end
|
44
72
|
|
45
73
|
def _user_agent
|
46
74
|
"chelsea/#{Chelsea::VERSION}"
|
47
75
|
end
|
48
|
-
|
49
76
|
end
|
50
|
-
end
|
77
|
+
end
|
data/lib/chelsea/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chelsea
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Allister Beharry
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-04-
|
11
|
+
date: 2020-04-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: tty-font
|
@@ -198,8 +198,10 @@ files:
|
|
198
198
|
- chelsea
|
199
199
|
- chelsea.gemspec
|
200
200
|
- lib/chelsea.rb
|
201
|
+
- lib/chelsea/bom.rb
|
201
202
|
- lib/chelsea/cli.rb
|
202
203
|
- lib/chelsea/config.rb
|
204
|
+
- lib/chelsea/db.rb
|
203
205
|
- lib/chelsea/dependency_exception.rb
|
204
206
|
- lib/chelsea/deps.rb
|
205
207
|
- lib/chelsea/formatters/factory.rb
|
@@ -208,6 +210,7 @@ files:
|
|
208
210
|
- lib/chelsea/formatters/text.rb
|
209
211
|
- lib/chelsea/formatters/xml.rb
|
210
212
|
- lib/chelsea/gems.rb
|
213
|
+
- lib/chelsea/iq_client.rb
|
211
214
|
- lib/chelsea/oss_index.rb
|
212
215
|
- lib/chelsea/version.rb
|
213
216
|
homepage: https://github.com/sonatype-nexus-community/chelsea
|