bundler-security 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +1 -0
- data.tar.gz.sig +0 -0
- data/.circleci/config.yml +25 -0
- data/.coditsu/ci.yml +3 -0
- data/.gitignore +47 -4
- data/.ruby-version +1 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +20 -0
- data/LICENSE +165 -0
- data/README.md +6 -26
- data/bundler-security.gemspec +27 -19
- data/certs/mensfeld.pem +25 -0
- data/lib/bundler/security.rb +64 -3
- data/lib/bundler/security/commands.rb +13 -0
- data/lib/bundler/security/config/fetcher.rb +29 -0
- data/lib/bundler/security/config/file_finder.rb +43 -0
- data/lib/bundler/security/errors.rb +15 -0
- data/lib/bundler/security/version.rb +6 -1
- data/lib/bundler/security/voting.rb +72 -0
- data/lib/bundler/security/voting/build_failure.rb +59 -0
- data/lib/bundler/security/voting/build_success.rb +55 -0
- data/lib/bundler/security/voting/build_unsafe_gem.rb +73 -0
- data/lib/bundler/security/voting/gem_policy.rb +67 -0
- data/lib/bundler/security/voting/remote_policy.rb +24 -0
- data/lib/bundler/security/voting/request.rb +88 -0
- data/lib/bundler/security/voting/versions/local.rb +102 -0
- data/lib/bundler/security/voting/versions/remote.rb +60 -0
- data/plugins.rb +5 -0
- metadata +64 -22
- metadata.gz.sig +0 -0
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -2
- data/bin/console +0 -14
- data/bin/setup +0 -8
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
# Module for all the components related to setting up the config
|
6
|
+
module Config
|
7
|
+
# Class responsible for fetching the config from .coditsu.yml
|
8
|
+
module Fetcher
|
9
|
+
class << self
|
10
|
+
# @param build_path [String] path of the current build
|
11
|
+
# @return [OpenStruct] open struct with config details
|
12
|
+
# @example
|
13
|
+
# details = Fetcher.new.call('./')
|
14
|
+
# details.build_path #=> './'
|
15
|
+
def call(build_path)
|
16
|
+
FileFinder
|
17
|
+
.call(build_path)
|
18
|
+
.then(&File.method(:read))
|
19
|
+
.then(&ERB.method(:new))
|
20
|
+
.then(&:result)
|
21
|
+
.then(&YAML.method(:load))
|
22
|
+
.then { |data| data.merge(build_path: build_path) }
|
23
|
+
.then(&OpenStruct.method(:new))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
module Config
|
6
|
+
# Class used to figure out the file from which we should load the settings
|
7
|
+
module FileFinder
|
8
|
+
# Names of the files or paths where we will look for the settings
|
9
|
+
#
|
10
|
+
# @note We do the double dot trick, to look outside of the current dir because when
|
11
|
+
# executed from a docker container, we copy the local uncommitted settings into the
|
12
|
+
# dir above the app location not to pollute the reset state of the git repo
|
13
|
+
#
|
14
|
+
# @note Order is important, as for local env we should load from
|
15
|
+
# local file (if present first)
|
16
|
+
FILE_NAMES = %w[
|
17
|
+
.coditsu/local.yml
|
18
|
+
.coditsu/ci.yml
|
19
|
+
.coditsu/*.yml
|
20
|
+
.coditsu.yml
|
21
|
+
].map { |name| ["../#{name}", name] }.tap(&:flatten!).freeze
|
22
|
+
|
23
|
+
private_constant :FILE_NAMES
|
24
|
+
|
25
|
+
class << self
|
26
|
+
# Looks for coditsu settings file for a given env
|
27
|
+
#
|
28
|
+
# @param build_path [String] path of the current build
|
29
|
+
#
|
30
|
+
# @return [String] path to the file from which we should load all the settings
|
31
|
+
def call(build_path)
|
32
|
+
FILE_NAMES
|
33
|
+
.map { |name| File.join(build_path, name) }
|
34
|
+
.map { |name| Dir[name] }
|
35
|
+
.find { |selection| !selection.empty? }
|
36
|
+
.tap { |path| path || raise(Errors::MissingConfigurationFile) }
|
37
|
+
.first
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
# Build runner app errors
|
6
|
+
module Errors
|
7
|
+
# Base error class from which all the errors should inherit
|
8
|
+
BaseError = Class.new(StandardError)
|
9
|
+
# Raised when we couldn't find a valid configuration file
|
10
|
+
MissingConfigurationFile = Class.new(BaseError)
|
11
|
+
# When we receive invalid remote versions type
|
12
|
+
InvalidRemoteVersionsType = Class.new(BaseError)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
# Verifies voting verdicts for gems
|
6
|
+
module Voting
|
7
|
+
class << self
|
8
|
+
# Build verdict
|
9
|
+
#
|
10
|
+
# @param command [String] either install or update
|
11
|
+
# @param definition [Bundler::Definition] definition for your source
|
12
|
+
def call(command, definition)
|
13
|
+
remote_data(command, definition)
|
14
|
+
.then { |policy, gems| [policy, build_gems(policy, gems)] }
|
15
|
+
.then { |policy, errors| build_status(policy.type, command, errors) }
|
16
|
+
end
|
17
|
+
|
18
|
+
# Build gems that don't have enough approvals
|
19
|
+
#
|
20
|
+
# @param policy [Voting::RemotePolicy] remote policy settings
|
21
|
+
# @param gems [Hash] remote gem statistics
|
22
|
+
#
|
23
|
+
# @return [Array] gems that don't have enough approvals based on remote policy
|
24
|
+
def build_gems(policy, gems)
|
25
|
+
gems.each_with_object([]) do |(name, data), errors|
|
26
|
+
gem_policy = GemPolicy.new(name, data, policy)
|
27
|
+
|
28
|
+
next if gem_policy.approved?
|
29
|
+
next unless gem_policy.rejected?
|
30
|
+
|
31
|
+
errors << BuildUnsafeGem.call(gem_policy)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Fetch data from the differ
|
36
|
+
#
|
37
|
+
# @param command [String] either install or update
|
38
|
+
# @param definition [Bundler::Definition]
|
39
|
+
def remote_data(command, definition)
|
40
|
+
Versions::Remote
|
41
|
+
.call(command, definition)
|
42
|
+
.yield_self { |response| [build_remote_policy(response['policy']), response['gems']] }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Build remote policy based on Coditsu differ settings
|
46
|
+
#
|
47
|
+
# @param policy [Hash] remote policy settings
|
48
|
+
#
|
49
|
+
# @return [Voting::RemotePolicy]
|
50
|
+
def build_remote_policy(policy)
|
51
|
+
RemotePolicy.new(
|
52
|
+
policy['type'], policy['threshold']
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Build security verdict
|
57
|
+
#
|
58
|
+
# @param remote_policy_type [String]
|
59
|
+
# @param command [String] either install or update
|
60
|
+
# @param errors [Array] detected security errors
|
61
|
+
def build_status(remote_policy_type, command, errors)
|
62
|
+
if errors.empty?
|
63
|
+
BuildSuccess.call(remote_policy_type, command)
|
64
|
+
else
|
65
|
+
BuildFailure.call(remote_policy_type, command, errors)
|
66
|
+
exit 1
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
module Voting
|
6
|
+
# Build failure security verdict
|
7
|
+
module BuildFailure
|
8
|
+
class << self
|
9
|
+
# Prints failure security verdict
|
10
|
+
#
|
11
|
+
# @param policy_type [String]
|
12
|
+
# @param command [String] either install or update
|
13
|
+
# @param errors [Array] detected security errors
|
14
|
+
def call(policy_type, command, errors)
|
15
|
+
Bundler.ui.error(
|
16
|
+
build(policy_type, command, errors)
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Builds failure security verdict message
|
21
|
+
#
|
22
|
+
# @param policy_type [String]
|
23
|
+
# @param command [String] either install or update
|
24
|
+
# @param errors [Array] detected security errors
|
25
|
+
#
|
26
|
+
# @return [String]
|
27
|
+
def build(policy_type, command, errors)
|
28
|
+
[
|
29
|
+
"\n",
|
30
|
+
message_type(policy_type),
|
31
|
+
", blocking #{command}",
|
32
|
+
"\n\n",
|
33
|
+
errors.join("\n"),
|
34
|
+
"\n\n"
|
35
|
+
].join
|
36
|
+
end
|
37
|
+
|
38
|
+
# Builds a message based on policy type
|
39
|
+
#
|
40
|
+
# @param policy_type [String]
|
41
|
+
#
|
42
|
+
# @return [String]
|
43
|
+
#
|
44
|
+
# @raise InvalidPolicyType if policy type was not recognized
|
45
|
+
def message_type(policy_type)
|
46
|
+
case policy_type
|
47
|
+
when 'organization'
|
48
|
+
'Not enough reviews on your organization'
|
49
|
+
when 'community'
|
50
|
+
'Not enough reviews in the community'
|
51
|
+
else
|
52
|
+
raise InvalidPolicyType, policy_type
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
module Voting
|
6
|
+
# Build successful security verdict
|
7
|
+
module BuildSuccess
|
8
|
+
class << self
|
9
|
+
# Prints successful security verdict
|
10
|
+
#
|
11
|
+
# @param policy_type [String]
|
12
|
+
# @param command [String] either install or update
|
13
|
+
def call(policy_type, command)
|
14
|
+
Bundler.ui.confirm(
|
15
|
+
build(policy_type, command)
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Builds successful security verdict message
|
20
|
+
#
|
21
|
+
# @param policy_type [String]
|
22
|
+
# @param command [String] either install or update
|
23
|
+
#
|
24
|
+
# @return [String]
|
25
|
+
def build(policy_type, command)
|
26
|
+
[
|
27
|
+
"\n",
|
28
|
+
message_type(policy_type),
|
29
|
+
", commencing #{command}",
|
30
|
+
"\n\n"
|
31
|
+
].join
|
32
|
+
end
|
33
|
+
|
34
|
+
# Builds a message based on policy type
|
35
|
+
#
|
36
|
+
# @param policy_type [String]
|
37
|
+
#
|
38
|
+
# @return [String]
|
39
|
+
#
|
40
|
+
# @raise InvalidPolicyType if policy type was not recognized
|
41
|
+
def message_type(policy_type)
|
42
|
+
case policy_type
|
43
|
+
when 'organization'
|
44
|
+
'All gems approved by your organization'
|
45
|
+
when 'community'
|
46
|
+
'All gems approved by community'
|
47
|
+
else
|
48
|
+
raise InvalidPolicyType, policy_type
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
module Voting
|
6
|
+
# Module responsible for building unsafe gem details
|
7
|
+
module BuildUnsafeGem
|
8
|
+
# Differ url
|
9
|
+
DIFF_URL = 'https://diff.coditsu.io/gems'
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Builds details of an unsafe gem
|
13
|
+
#
|
14
|
+
# @param gem_policy [Voting::GemPolicy]
|
15
|
+
#
|
16
|
+
# @return [String]
|
17
|
+
def call(gem_policy)
|
18
|
+
[
|
19
|
+
gem_policy.name,
|
20
|
+
gem_policy.new_version? ? gem_policy.new_version : gem_policy.current_version,
|
21
|
+
'-',
|
22
|
+
[
|
23
|
+
approved(gem_policy.remote_policy, gem_policy),
|
24
|
+
rejected(gem_policy.remote_policy, gem_policy)
|
25
|
+
].compact.join(', ')
|
26
|
+
].join(' ')
|
27
|
+
end
|
28
|
+
|
29
|
+
# Builds safe details
|
30
|
+
#
|
31
|
+
# @param remote_policy [Voting::RemotePolicy]
|
32
|
+
# @param gem_policy [Voting::GemPolicy]
|
33
|
+
#
|
34
|
+
# @return [String]
|
35
|
+
def approved(remote_policy, gem_policy)
|
36
|
+
return if gem_policy.approved?
|
37
|
+
|
38
|
+
array = []
|
39
|
+
array << "approved (#{gem_policy.approved} expected #{remote_policy.approved})"
|
40
|
+
array << differ_url(gem_policy)
|
41
|
+
array.join(' ')
|
42
|
+
end
|
43
|
+
|
44
|
+
# Builds malicious details
|
45
|
+
#
|
46
|
+
# @param remote_policy [Voting::RemotePolicy]
|
47
|
+
# @param gem_policy [Voting::GemPolicy]
|
48
|
+
#
|
49
|
+
# @return [String]
|
50
|
+
def rejected(remote_policy, gem_policy)
|
51
|
+
return if gem_policy.rejected?
|
52
|
+
|
53
|
+
array = []
|
54
|
+
array << "rejected (#{gem_policy.rejected} expected #{remote_policy.rejected})"
|
55
|
+
array << differ_url(gem_policy)
|
56
|
+
array.join(' ')
|
57
|
+
end
|
58
|
+
|
59
|
+
# Builds differ url for gem with version details
|
60
|
+
#
|
61
|
+
# @param gem_policy [Voting::GemPolicy]
|
62
|
+
#
|
63
|
+
# @return [String]
|
64
|
+
def differ_url(gem_policy)
|
65
|
+
array = [DIFF_URL, gem_policy.name, gem_policy.current_version]
|
66
|
+
array << gem_policy.new_version if gem_policy.new_version?
|
67
|
+
array.join('/')
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Security
|
5
|
+
module Voting
|
6
|
+
# Gem policy with statistics from Coditsu differ
|
7
|
+
class GemPolicy
|
8
|
+
attr_reader :name, :current_version, :new_version, :remote_policy
|
9
|
+
|
10
|
+
# Build gem policy
|
11
|
+
#
|
12
|
+
# @param name [String] gem name
|
13
|
+
# @param gem_data [Array] gem version and statistics from Coditsu
|
14
|
+
# @param remote_policy [Voting::RemotePolicy]
|
15
|
+
def initialize(name, gem_data, remote_policy)
|
16
|
+
@name = name
|
17
|
+
@new_version = nil
|
18
|
+
|
19
|
+
versions = gem_data.first
|
20
|
+
|
21
|
+
raise Errors::InvalidRemoteVersionsType, versions.class unless versions.is_a?(Array)
|
22
|
+
|
23
|
+
@current_version = versions.first.empty? ? versions.last : versions.first
|
24
|
+
@new_version = versions.last if @current_version != versions.last
|
25
|
+
|
26
|
+
@remote_policy = remote_policy
|
27
|
+
@threshold = gem_data.last[remote_policy.type]
|
28
|
+
end
|
29
|
+
|
30
|
+
# How many time gem was marked as safe
|
31
|
+
#
|
32
|
+
# @return [Integer]
|
33
|
+
def approved
|
34
|
+
@threshold['up'].to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
# How many time gem was marked as malicious
|
38
|
+
#
|
39
|
+
# @return [Integer]
|
40
|
+
def rejected
|
41
|
+
@threshold['down'].to_i
|
42
|
+
end
|
43
|
+
|
44
|
+
# Checks if a gem is safe based on a remote policy
|
45
|
+
#
|
46
|
+
# @return [Boolean] true if it's safe, false otherwise
|
47
|
+
def approved?
|
48
|
+
approved >= @remote_policy.approved
|
49
|
+
end
|
50
|
+
|
51
|
+
# Checks if a gem is malicious based on a remote policy
|
52
|
+
#
|
53
|
+
# @return [Boolean] true if it's malicious, false otherwise
|
54
|
+
def rejected?
|
55
|
+
@remote_policy.rejected >= rejected
|
56
|
+
end
|
57
|
+
|
58
|
+
# Check if a new version was requested
|
59
|
+
#
|
60
|
+
# @return [Boolean] true if new version was requested, false otherwise
|
61
|
+
def new_version?
|
62
|
+
!@new_version.nil?
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|