chelsea 0.0.7 → 0.0.8
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/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
|