bundler-security 0.0.1 → 0.1.0

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.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Security
5
+ # Modules grouping supported bundler commands
6
+ module Commands
7
+ # Install bundler command
8
+ INSTALL = 'install'
9
+ # Update bundler command
10
+ UPDATE = 'update'
11
+ end
12
+ end
13
+ end
@@ -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
@@ -1,5 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Bundler
2
4
  module Security
3
- VERSION = "0.0.1"
5
+ # Current BundlerSecurity version
6
+ VERSION = '0.1.0'
7
+ # Coditsu differ homepage
8
+ HOMEPAGE = 'https://diff.coditsu.io'
4
9
  end
5
10
  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