bundle-safe-update 1.0.14

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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ class GemChecker
5
+ CheckResult = Struct.new(:name, :version, :current_version, :age_days, :allowed, :reason, keyword_init: true)
6
+
7
+ DEFAULT_MAX_THREADS = 32
8
+
9
+ def initialize(config:, api: nil, lockfile_parser: nil, max_threads: nil)
10
+ @config = config
11
+ @api = api || RubygemsApi.new
12
+ @lockfile_parser = lockfile_parser || LockfileParser.new
13
+ @max_threads = max_threads || DEFAULT_MAX_THREADS
14
+ end
15
+
16
+ def check_gem(gem_info)
17
+ return ignored_result(gem_info) if @config.ignored?(gem_info.name)
18
+ return trusted_source_result(gem_info) if trusted_source?(gem_info.name)
19
+ return trusted_owner_result(gem_info) if trusted_owner?(gem_info.name)
20
+
21
+ age_days = @api.version_age_days(gem_info.name, gem_info.newest_version)
22
+ return not_found_result(gem_info) if age_days.nil?
23
+
24
+ age_check_result(gem_info, age_days)
25
+ end
26
+
27
+ def check_all(outdated_gems)
28
+ return [] if outdated_gems.empty?
29
+
30
+ check_all_parallel(outdated_gems)
31
+ end
32
+
33
+ private
34
+
35
+ def check_all_parallel(outdated_gems)
36
+ results = Array.new(outdated_gems.size)
37
+ queue = Queue.new
38
+ outdated_gems.each_with_index { |gem_info, idx| queue << [gem_info, idx] }
39
+
40
+ threads = spawn_worker_threads(queue, results)
41
+ threads.each(&:join)
42
+
43
+ results
44
+ end
45
+
46
+ def spawn_worker_threads(queue, results)
47
+ thread_count = [@max_threads, queue.size].min
48
+ Array.new(thread_count) do
49
+ Thread.new do
50
+ process_queue(queue, results)
51
+ end
52
+ end
53
+ end
54
+
55
+ def process_queue(queue, results)
56
+ loop do
57
+ gem_info, idx = queue.pop(true)
58
+ results[idx] = check_gem(gem_info)
59
+ rescue ThreadError
60
+ break
61
+ end
62
+ end
63
+
64
+ def trusted_source?(gem_name)
65
+ source_url = @lockfile_parser.source_for(gem_name)
66
+ @config.trusted_source?(source_url)
67
+ end
68
+
69
+ def trusted_owner?(gem_name)
70
+ return false if @config.trusted_owners.empty?
71
+
72
+ owners = @api.fetch_owners(gem_name)
73
+ @config.trusted_owners.intersect?(owners)
74
+ end
75
+
76
+ def ignored_result(gem_info)
77
+ build_result(gem_info, age_days: nil, allowed: true, reason: 'ignored')
78
+ end
79
+
80
+ def trusted_source_result(gem_info)
81
+ build_result(gem_info, age_days: nil, allowed: true, reason: 'trusted source')
82
+ end
83
+
84
+ def trusted_owner_result(gem_info)
85
+ build_result(gem_info, age_days: nil, allowed: true, reason: 'trusted owner')
86
+ end
87
+
88
+ def not_found_result(gem_info)
89
+ build_result(gem_info, age_days: nil, allowed: false, reason: 'version not found')
90
+ end
91
+
92
+ def age_check_result(gem_info, age_days)
93
+ allowed = age_days >= @config.cooldown_days
94
+ reason = allowed ? 'satisfies minimum age' : 'too new'
95
+ build_result(gem_info, age_days: age_days, allowed: allowed, reason: reason)
96
+ end
97
+
98
+ def build_result(gem_info, age_days:, allowed:, reason:)
99
+ CheckResult.new(
100
+ name: gem_info.name,
101
+ version: gem_info.newest_version,
102
+ current_version: gem_info.current_version,
103
+ age_days: age_days,
104
+ allowed: allowed,
105
+ reason: reason
106
+ )
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ class LockfileParser
5
+ LOCKFILE_NAME = 'Gemfile.lock'
6
+ GEM_SECTION_START = /^GEM$/
7
+ SECTION_HEADER = /^[A-Z]+$/
8
+ REMOTE_LINE = /^\s+remote:\s+(.+)$/
9
+ GEM_LINE = /^\s{4}(\S+)\s+\(/
10
+
11
+ def initialize(lockfile_path: nil)
12
+ @lockfile_path = lockfile_path || File.join(Dir.pwd, LOCKFILE_NAME)
13
+ end
14
+
15
+ def gem_sources
16
+ return @gem_sources if defined?(@gem_sources)
17
+
18
+ @gem_sources = parse_lockfile
19
+ end
20
+
21
+ def source_for(gem_name)
22
+ gem_sources[gem_name]
23
+ end
24
+
25
+ private
26
+
27
+ def parse_lockfile
28
+ return {} unless File.exist?(@lockfile_path)
29
+
30
+ content = File.read(@lockfile_path)
31
+ extract_gem_sources(content)
32
+ rescue StandardError => e
33
+ warn("Warning: Could not parse #{@lockfile_path}: #{e.message}")
34
+ {}
35
+ end
36
+
37
+ def extract_gem_sources(content)
38
+ sources = {}
39
+ state = { in_gem_section: false, current_remote: nil }
40
+
41
+ content.each_line do |line|
42
+ process_line(line, state, sources)
43
+ end
44
+
45
+ sources
46
+ end
47
+
48
+ def process_line(line, state, sources)
49
+ case line
50
+ when GEM_SECTION_START then state[:in_gem_section] = true
51
+ when SECTION_HEADER then reset_section(state)
52
+ when REMOTE_LINE then update_remote(state, ::Regexp.last_match(1))
53
+ when GEM_LINE then add_gem_source(sources, state, ::Regexp.last_match(1))
54
+ end
55
+ end
56
+
57
+ def reset_section(state)
58
+ state[:in_gem_section] = false
59
+ state[:current_remote] = nil
60
+ end
61
+
62
+ def update_remote(state, remote)
63
+ state[:current_remote] = remote.strip if state[:in_gem_section]
64
+ end
65
+
66
+ def add_gem_source(sources, state, gem_name)
67
+ return unless state[:in_gem_section] && state[:current_remote]
68
+
69
+ sources[gem_name] = state[:current_remote]
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ require 'open3'
5
+
6
+ module BundleSafeUpdate
7
+ class OutdatedChecker
8
+ OutdatedGem = Struct.new(:name, :current_version, :newest_version, keyword_init: true)
9
+
10
+ BUNDLE_COMMAND = %w[bundle outdated --parseable].freeze
11
+
12
+ def initialize(gems: [], executor: nil)
13
+ @gems = gems
14
+ @executor = executor || method(:execute_command)
15
+ end
16
+
17
+ def outdated_gems
18
+ command = BUNDLE_COMMAND + @gems
19
+ output = @executor.call(command)
20
+ parse_output(output)
21
+ end
22
+
23
+ private
24
+
25
+ def execute_command(command)
26
+ stdout, stderr, status = Bundler.with_unbundled_env do
27
+ Open3.capture3(*command)
28
+ end
29
+ check_for_errors(stderr, status)
30
+ stdout
31
+ end
32
+
33
+ def check_for_errors(stderr, status)
34
+ return if status.success? || status.exitstatus == 1
35
+
36
+ raise stderr.strip if stderr.include?('Your Ruby version is')
37
+
38
+ raise "bundle outdated failed with exit code #{status.exitstatus}: #{stderr.strip}"
39
+ end
40
+
41
+ def parse_output(output)
42
+ output
43
+ .lines
44
+ .map(&:strip)
45
+ .select { |line| line.include?('(newest') }
46
+ .map { |line| parse_line(line) }
47
+ .compact
48
+ end
49
+
50
+ def parse_line(line)
51
+ match = line.match(/^(\S+)\s+\(newest\s+([\d.]+),?\s*installed\s+([\d.]+)/)
52
+ return nil unless match
53
+
54
+ OutdatedGem.new(
55
+ name: match[1],
56
+ newest_version: match[2],
57
+ current_version: match[3]
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require 'time'
6
+
7
+ module BundleSafeUpdate
8
+ class RiskCache
9
+ CACHE_FILENAME = 'bundle-safe-update-cache.yml'
10
+ CACHE_VERSION = 1
11
+
12
+ OwnerChange = Struct.new(:gem_name, :previous_owners, :current_owners, keyword_init: true)
13
+
14
+ def initialize(cache_path: nil)
15
+ @cache_path = cache_path || default_cache_path
16
+ @data = load_cache
17
+ end
18
+
19
+ def owners_for(gem_name)
20
+ @data['owners'][gem_name] || []
21
+ end
22
+
23
+ def owner_changed?(gem_name, current_owners)
24
+ cached = owners_for(gem_name)
25
+ return false if cached.empty?
26
+
27
+ cached.sort != current_owners.compact.sort
28
+ end
29
+
30
+ def detect_owner_change(gem_name, current_owners)
31
+ cached = owners_for(gem_name)
32
+ sanitized = current_owners.compact
33
+ return nil if cached.empty? || cached.sort == sanitized.sort
34
+
35
+ OwnerChange.new(
36
+ gem_name: gem_name,
37
+ previous_owners: cached,
38
+ current_owners: sanitized
39
+ )
40
+ end
41
+
42
+ def update_owners(gem_name, owners)
43
+ @data['owners'][gem_name] = owners.compact.sort
44
+ end
45
+
46
+ def save
47
+ @data['updated_at'] = Time.now.iso8601
48
+ File.write(@cache_path, YAML.dump(@data))
49
+ end
50
+
51
+ def exists?
52
+ File.exist?(@cache_path)
53
+ end
54
+
55
+ private
56
+
57
+ def load_cache
58
+ return default_cache unless File.exist?(@cache_path)
59
+
60
+ loaded = YAML.safe_load_file(@cache_path) || {}
61
+ return default_cache unless loaded['version'] == CACHE_VERSION
62
+
63
+ loaded
64
+ rescue Psych::SyntaxError
65
+ default_cache
66
+ end
67
+
68
+ def default_cache
69
+ {
70
+ 'version' => CACHE_VERSION,
71
+ 'updated_at' => nil,
72
+ 'owners' => {}
73
+ }
74
+ end
75
+
76
+ def default_cache_path
77
+ bundle_dir = File.join(Dir.pwd, '.bundle')
78
+ FileUtils.mkdir_p(bundle_dir)
79
+ File.join(bundle_dir, CACHE_FILENAME)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module BundleSafeUpdate
6
+ class RiskChecker
7
+ RiskResult = Struct.new(:gem_name, :version, :signals, :blocked, keyword_init: true)
8
+ RiskSignal = Struct.new(:type, :message, :mode, keyword_init: true)
9
+
10
+ SECONDS_PER_YEAR = 365.25 * 24 * 60 * 60
11
+
12
+ def initialize(config:, api: nil, cache: nil, lockfile_parser: nil, max_threads: nil)
13
+ @config = config
14
+ @api = api || RubygemsApi.new
15
+ @cache = cache || RiskCache.new
16
+ @lockfile_parser = lockfile_parser || LockfileParser.new
17
+ @max_threads = max_threads || @config.max_threads
18
+ end
19
+
20
+ def check_all(gem_results)
21
+ return [] if gem_results.empty?
22
+
23
+ check_all_parallel(gem_results)
24
+ end
25
+
26
+ def save_cache
27
+ @cache.save
28
+ end
29
+
30
+ private
31
+
32
+ def check_all_parallel(gem_results)
33
+ results = Array.new(gem_results.size)
34
+ queue = Queue.new
35
+ gem_results.each_with_index { |result, idx| queue << [result, idx] }
36
+
37
+ threads = spawn_worker_threads(queue, results)
38
+ threads.each(&:join)
39
+
40
+ results.compact
41
+ end
42
+
43
+ def spawn_worker_threads(queue, results)
44
+ thread_count = [@max_threads, queue.size].min
45
+ Array.new(thread_count) do
46
+ Thread.new { process_queue(queue, results) }
47
+ end
48
+ end
49
+
50
+ def process_queue(queue, results)
51
+ loop do
52
+ gem_result, idx = queue.pop(true)
53
+ results[idx] = check_gem(gem_result)
54
+ rescue ThreadError
55
+ break
56
+ end
57
+ end
58
+
59
+ def check_gem(gem_result)
60
+ signals = []
61
+ signals.concat(check_low_downloads(gem_result))
62
+ signals.concat(check_stale_gem(gem_result))
63
+ signals.concat(check_new_owner(gem_result))
64
+ signals.concat(check_version_jump(gem_result))
65
+
66
+ return nil if signals.empty?
67
+
68
+ blocked = signals.any? { |s| s.mode == 'block' }
69
+ RiskResult.new(gem_name: gem_result.name, version: gem_result.version, signals: signals, blocked: blocked)
70
+ end
71
+
72
+ def check_low_downloads(gem_result)
73
+ return [] unless @config.risk_signal_enabled?(:low_downloads)
74
+ return [] unless from_rubygems?(gem_result.name)
75
+
76
+ gem_info = @api.fetch_gem_info(gem_result.name)
77
+ return [] unless gem_info
78
+
79
+ threshold = @config.risk_signal_threshold(:low_downloads, :threshold)
80
+ return [] if gem_info.downloads >= threshold
81
+
82
+ [build_signal(:low_downloads, "low downloads (#{gem_info.downloads} total)")]
83
+ end
84
+
85
+ def check_stale_gem(gem_result)
86
+ return [] unless @config.risk_signal_enabled?(:stale_gem)
87
+ return [] unless from_rubygems?(gem_result.name)
88
+
89
+ gem_info = @api.fetch_gem_info(gem_result.name)
90
+ return [] unless gem_info&.version_created_at
91
+
92
+ age_years = calculate_age_years(gem_info.version_created_at)
93
+ threshold_years = @config.risk_signal_threshold(:stale_gem, :threshold_years)
94
+ return [] if age_years < threshold_years
95
+
96
+ [build_signal(:stale_gem, "stale gem (last release #{age_years.round(1)} years ago)")]
97
+ end
98
+
99
+ def check_new_owner(gem_result)
100
+ return [] unless @config.risk_signal_enabled?(:new_owner)
101
+ return [] unless from_rubygems?(gem_result.name)
102
+
103
+ current_owners = @api.fetch_owners(gem_result.name)
104
+ return [] if current_owners.empty?
105
+
106
+ change = @cache.detect_owner_change(gem_result.name, current_owners)
107
+ @cache.update_owners(gem_result.name, current_owners)
108
+ return [] unless change
109
+
110
+ [build_signal(:new_owner, owner_change_message(change))]
111
+ end
112
+
113
+ def from_rubygems?(gem_name)
114
+ source = @lockfile_parser.source_for(gem_name)
115
+ source.nil? || source.include?('rubygems.org')
116
+ end
117
+
118
+ def check_version_jump(gem_result)
119
+ return [] unless @config.risk_signal_enabled?(:version_jump)
120
+ return [] unless major_version_jump?(gem_result)
121
+
122
+ [build_signal(:version_jump, "major version jump (was #{gem_result.current_version})")]
123
+ end
124
+
125
+ def build_signal(type, message)
126
+ RiskSignal.new(type: type, message: message, mode: @config.risk_signal_mode(type))
127
+ end
128
+
129
+ def calculate_age_years(timestamp)
130
+ (Time.now - timestamp) / SECONDS_PER_YEAR
131
+ end
132
+
133
+ def owner_change_message(change)
134
+ new_owners = change.current_owners.join(', ')
135
+ old_owners = change.previous_owners.join(', ')
136
+ "ownership changed (new: #{new_owners}, was: #{old_owners})"
137
+ end
138
+
139
+ def major_version_jump?(gem_result)
140
+ return false unless gem_result.respond_to?(:current_version) && gem_result.current_version
141
+
142
+ current = parse_version(gem_result.current_version)
143
+ newest = parse_version(gem_result.version)
144
+ current && newest && newest[:major] > current[:major]
145
+ end
146
+
147
+ def parse_version(version_string)
148
+ parts = version_string.to_s.split('.')
149
+ return nil if parts.empty?
150
+
151
+ { major: parts[0].to_i, minor: parts[1].to_i, patch: parts[2].to_i }
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module BundleSafeUpdate
8
+ class RubygemsApi
9
+ VERSIONS_API_BASE = 'https://rubygems.org/api/v1/versions'
10
+ OWNERS_API_BASE = 'https://rubygems.org/api/v1/gems'
11
+ GEM_API_BASE = 'https://rubygems.org/api/v1/gems'
12
+ SECONDS_PER_DAY = 86_400
13
+ HTTP_OPEN_TIMEOUT = 10
14
+ HTTP_READ_TIMEOUT = 30
15
+
16
+ class ApiError < StandardError; end
17
+
18
+ GemInfo = Struct.new(:downloads, :version_created_at, keyword_init: true)
19
+
20
+ def initialize(http_client: nil)
21
+ @http_client = http_client
22
+ end
23
+
24
+ def fetch_version_info(gem_name, version)
25
+ versions = fetch_versions(gem_name)
26
+ versions.find { |v| v['number'] == version }
27
+ end
28
+
29
+ def fetch_versions(gem_name)
30
+ uri = URI("#{VERSIONS_API_BASE}/#{gem_name}.json")
31
+ response = perform_request(uri)
32
+
33
+ unless response.is_a?(Net::HTTPSuccess)
34
+ raise ApiError, "Failed to fetch versions for #{gem_name}: #{response.code}"
35
+ end
36
+
37
+ JSON.parse(response.body)
38
+ rescue JSON::ParserError => e
39
+ raise ApiError, "Invalid JSON response for #{gem_name}: #{e.message}"
40
+ end
41
+
42
+ def version_age_days(gem_name, version)
43
+ info = fetch_version_info(gem_name, version)
44
+ return nil unless info
45
+
46
+ created_at = Time.parse(info['created_at'])
47
+ ((Time.now - created_at) / SECONDS_PER_DAY).to_i
48
+ end
49
+
50
+ def fetch_owners(gem_name)
51
+ uri = URI("#{OWNERS_API_BASE}/#{gem_name}/owners.json")
52
+ response = perform_request(uri)
53
+
54
+ return [] unless response.is_a?(Net::HTTPSuccess)
55
+
56
+ JSON
57
+ .parse(response.body)
58
+ .filter_map { |owner| owner['handle'] }
59
+ rescue JSON::ParserError
60
+ []
61
+ end
62
+
63
+ def fetch_gem_info(gem_name)
64
+ uri = URI("#{GEM_API_BASE}/#{gem_name}.json")
65
+ response = perform_request(uri)
66
+
67
+ return nil unless response.is_a?(Net::HTTPSuccess)
68
+
69
+ data = JSON.parse(response.body)
70
+ GemInfo.new(
71
+ downloads: data['downloads'],
72
+ version_created_at: parse_time(data['version_created_at'])
73
+ )
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ def parse_time(time_string)
81
+ return nil unless time_string
82
+
83
+ Time.parse(time_string)
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+
88
+ def perform_request(uri)
89
+ return @http_client.call(uri) if @http_client
90
+
91
+ http = Net::HTTP.new(uri.host, uri.port)
92
+ http.use_ssl = true
93
+ http.open_timeout = HTTP_OPEN_TIMEOUT
94
+ http.read_timeout = HTTP_READ_TIMEOUT
95
+ http.get(uri.request_uri)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ VERSION = '1.0.14'
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bundle_safe_update/version'
4
+ require_relative 'bundle_safe_update/config'
5
+ require_relative 'bundle_safe_update/color_output'
6
+ require_relative 'bundle_safe_update/rubygems_api'
7
+ require_relative 'bundle_safe_update/lockfile_parser'
8
+ require_relative 'bundle_safe_update/outdated_checker'
9
+ require_relative 'bundle_safe_update/gem_checker'
10
+ require_relative 'bundle_safe_update/audit_checker'
11
+ require_relative 'bundle_safe_update/risk_cache'
12
+ require_relative 'bundle_safe_update/risk_checker'
13
+ require_relative 'bundle_safe_update/cli'
14
+
15
+ module BundleSafeUpdate
16
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundle-safe-update
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.14
5
+ platform: ruby
6
+ authors:
7
+ - Denis Sablic
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A CLI tool that prevents installation of gem versions that are too new
13
+ (e.g., <14 days old), helping protect against supply chain attacks.
14
+ email:
15
+ - denis.sablic@gmail.com
16
+ executables:
17
+ - bundle-safe-update
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CLAUDE.md
22
+ - Gemfile
23
+ - Gemfile.lock
24
+ - LICENSE.txt
25
+ - README.md
26
+ - Rakefile
27
+ - bin/console
28
+ - bin/install-hooks
29
+ - bin/setup
30
+ - bundle-safe-update.gemspec
31
+ - exe/bundle-safe-update
32
+ - lib/bundle_safe_update.rb
33
+ - lib/bundle_safe_update/audit_checker.rb
34
+ - lib/bundle_safe_update/cli.rb
35
+ - lib/bundle_safe_update/cli/options.rb
36
+ - lib/bundle_safe_update/cli/output.rb
37
+ - lib/bundle_safe_update/color_output.rb
38
+ - lib/bundle_safe_update/config.rb
39
+ - lib/bundle_safe_update/gem_checker.rb
40
+ - lib/bundle_safe_update/lockfile_parser.rb
41
+ - lib/bundle_safe_update/outdated_checker.rb
42
+ - lib/bundle_safe_update/risk_cache.rb
43
+ - lib/bundle_safe_update/risk_checker.rb
44
+ - lib/bundle_safe_update/rubygems_api.rb
45
+ - lib/bundle_safe_update/version.rb
46
+ homepage: https://github.com/dsablic/bundle-safe-update
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ rubygems_mfa_required: 'true'
51
+ source_code_uri: https://github.com/dsablic/bundle-safe-update
52
+ changelog_uri: https://github.com/dsablic/bundle-safe-update/releases
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.6
68
+ specification_version: 4
69
+ summary: Enforce minimum release age for Ruby gems during updates
70
+ test_files: []