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.
- checksums.yaml +7 -0
- data/CLAUDE.md +4 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +314 -0
- data/Rakefile +12 -0
- data/bin/console +11 -0
- data/bin/install-hooks +42 -0
- data/bin/setup +8 -0
- data/bundle-safe-update.gemspec +30 -0
- data/exe/bundle-safe-update +6 -0
- data/lib/bundle_safe_update/audit_checker.rb +86 -0
- data/lib/bundle_safe_update/cli/options.rb +52 -0
- data/lib/bundle_safe_update/cli/output.rb +155 -0
- data/lib/bundle_safe_update/cli.rb +140 -0
- data/lib/bundle_safe_update/color_output.rb +41 -0
- data/lib/bundle_safe_update/config.rb +142 -0
- data/lib/bundle_safe_update/gem_checker.rb +109 -0
- data/lib/bundle_safe_update/lockfile_parser.rb +72 -0
- data/lib/bundle_safe_update/outdated_checker.rb +61 -0
- data/lib/bundle_safe_update/risk_cache.rb +82 -0
- data/lib/bundle_safe_update/risk_checker.rb +154 -0
- data/lib/bundle_safe_update/rubygems_api.rb +98 -0
- data/lib/bundle_safe_update/version.rb +5 -0
- data/lib/bundle_safe_update.rb +16 -0
- metadata +70 -0
|
@@ -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,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: []
|