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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ require 'open3'
5
+
6
+ module BundleSafeUpdate
7
+ class AuditChecker
8
+ AuditResult = Struct.new(:available, :vulnerabilities, :error, keyword_init: true)
9
+ Vulnerability = Struct.new(:gem_name, :cve, :title, :solution, keyword_init: true)
10
+
11
+ AUDIT_COMMAND = %w[bundle audit check --update].freeze
12
+
13
+ def initialize(executor: nil)
14
+ @executor = executor || method(:execute_command)
15
+ end
16
+
17
+ def check
18
+ return unavailable_result unless audit_available?
19
+
20
+ stdout, stderr, status = @executor.call(AUDIT_COMMAND)
21
+ parse_result(stdout, stderr, status)
22
+ end
23
+
24
+ def audit_available?
25
+ Bundler.with_unbundled_env do
26
+ _stdout, _stderr, status = Open3.capture3('bundle', 'audit', '--version')
27
+ status.success?
28
+ end
29
+ rescue Errno::ENOENT
30
+ false
31
+ end
32
+
33
+ private
34
+
35
+ def execute_command(command)
36
+ Bundler.with_unbundled_env do
37
+ Open3.capture3(*command)
38
+ end
39
+ end
40
+
41
+ def unavailable_result
42
+ AuditResult.new(available: false, vulnerabilities: [], error: nil)
43
+ end
44
+
45
+ def parse_result(stdout, stderr, status)
46
+ if status.success?
47
+ AuditResult.new(available: true, vulnerabilities: [], error: nil)
48
+ elsif stdout.include?('Vulnerabilities found!')
49
+ AuditResult.new(available: true, vulnerabilities: parse_vulnerabilities(stdout), error: nil)
50
+ else
51
+ AuditResult.new(available: true, vulnerabilities: [], error: stderr.strip)
52
+ end
53
+ end
54
+
55
+ def parse_vulnerabilities(output)
56
+ vulnerabilities = []
57
+ current = {}
58
+
59
+ output.each_line do |line|
60
+ current, completed = parse_vulnerability_line(line, current)
61
+ vulnerabilities << completed if completed
62
+ end
63
+
64
+ vulnerabilities
65
+ end
66
+
67
+ def parse_vulnerability_line(line, current)
68
+ key, value = extract_field(line)
69
+ return [current, nil] unless key
70
+
71
+ current[key] = value
72
+ return [{}, Vulnerability.new(**current)] if key == :solution
73
+
74
+ [current, nil]
75
+ end
76
+
77
+ def extract_field(line)
78
+ case line
79
+ when /^Name:\s+(.+)$/ then [:gem_name, ::Regexp.last_match(1).strip]
80
+ when /^CVE:\s+(.+)$/ then [:cve, ::Regexp.last_match(1).strip]
81
+ when /^Title:\s+(.+)$/ then [:title, ::Regexp.last_match(1).strip]
82
+ when /^Solution:\s+(.+)$/ then [:solution, ::Regexp.last_match(1).strip]
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ class CLI
5
+ module Options
6
+ def build_option_parser(options)
7
+ OptionParser.new do |opts|
8
+ opts.banner = 'Usage: bundle-safe-update [options] [gem1 gem2 ...]'
9
+ define_config_options(opts, options)
10
+ define_output_options(opts, options)
11
+ define_info_options(opts)
12
+ end
13
+ end
14
+
15
+ def define_config_options(opts, options)
16
+ define_basic_config_options(opts, options)
17
+ define_skip_options(opts, options)
18
+ end
19
+
20
+ def define_basic_config_options(opts, options)
21
+ opts.on('--config PATH', 'Path to config file') { |path| options[:config] = path }
22
+ opts.on('--cooldown DAYS', Integer, 'Minimum age in days') { |days| options[:cooldown] = days }
23
+ opts.on('--update', 'Update gems that pass the cooldown check') { options[:update] = true }
24
+ opts.on('--lock-only', 'Update Gemfile.lock without installing gems') { options[:lock_only] = true }
25
+ opts.on('--warn-only', 'Report violations but exit with success') { options[:warn_only] = true }
26
+ opts.on('--dry-run', 'Show configuration without checking') { options[:dry_run] = true }
27
+ end
28
+
29
+ def define_skip_options(opts, options)
30
+ opts.on('--no-audit', 'Skip vulnerability audit') { options[:audit] = false }
31
+ opts.on('--no-risk', 'Skip risk signal checking') { options[:risk] = false }
32
+ opts.on('--refresh-cache', 'Refresh owner cache without warnings') { options[:refresh_cache] = true }
33
+ end
34
+
35
+ def define_output_options(opts, options)
36
+ opts.on('--json', 'Output in JSON format') { options[:json] = true }
37
+ opts.on('--verbose', 'Enable verbose output') { options[:verbose] = true }
38
+ end
39
+
40
+ def define_info_options(opts)
41
+ opts.on('-v', '--version', 'Show version') do
42
+ puts("bundle-safe-update #{VERSION}")
43
+ exit(EXIT_SUCCESS)
44
+ end
45
+ opts.on('-h', '--help', 'Show this help') do
46
+ puts(opts)
47
+ exit(EXIT_SUCCESS)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ class CLI
5
+ module Output
6
+ def dry_run_output(config)
7
+ puts('Configuration (dry-run):')
8
+ config_lines(config).each { |line| puts(line) }
9
+ end
10
+
11
+ def config_lines(config)
12
+ config_values(config).map { |label, value| " #{label}: #{value}" }
13
+ end
14
+
15
+ def config_values(config)
16
+ { 'Cooldown days' => config.cooldown_days, 'Ignored gems' => format_list(config.ignore_gems),
17
+ 'Ignored prefixes' => format_list(config.ignore_prefixes),
18
+ 'Trusted sources' => format_list(config.trusted_sources),
19
+ 'Trusted owners' => format_list(config.trusted_owners), 'Max threads' => config.max_threads,
20
+ 'Audit' => config.audit, 'Update' => config.update, 'Lock only' => config.lock_only,
21
+ 'Warn only' => config.warn_only,
22
+ 'Verbose' => config.verbose }
23
+ end
24
+
25
+ def format_list(items)
26
+ items.empty? ? '(none)' : items.join(', ')
27
+ end
28
+
29
+ def output_json(results, blocked, config)
30
+ puts(JSON.pretty_generate(build_json_output(results, blocked, config)))
31
+ end
32
+
33
+ def build_json_output(results, blocked, config)
34
+ {
35
+ ok: blocked.empty?,
36
+ cooldown_days: config.cooldown_days,
37
+ checked: results.length,
38
+ blocked: blocked.map { |r| { name: r.name, version: r.version, age_days: r.age_days } }
39
+ }
40
+ end
41
+
42
+ def output_human(results, blocked, config)
43
+ results.each { |result| print_result(result, config) }
44
+ print_summary(blocked)
45
+ end
46
+
47
+ def print_result(result, config)
48
+ if result.allowed
49
+ puts(green("OK: #{result.name} (#{result.version}) - #{result.reason}"))
50
+ else
51
+ puts(yellow("BLOCKED: #{result.name} (#{result.version}) - #{blocked_reason(result, config)}"))
52
+ end
53
+ end
54
+
55
+ def blocked_reason(result, config)
56
+ age_info = result.age_days ? "published #{result.age_days} days ago" : result.reason
57
+ "#{age_info} (< #{config.cooldown_days} required)"
58
+ end
59
+
60
+ def print_summary(blocked)
61
+ puts
62
+ if blocked.empty?
63
+ puts(green('All gem versions satisfy minimum age requirements.'))
64
+ else
65
+ puts(yellow("#{blocked.length} gem(s) violate minimum release age"))
66
+ end
67
+ end
68
+
69
+ def output_audit_result(result)
70
+ return print_audit_unavailable unless result.available
71
+ return print_audit_error(result.error) if result.error
72
+
73
+ print_audit_results(result.vulnerabilities)
74
+ end
75
+
76
+ def print_audit_unavailable
77
+ warn(yellow('Warning: bundler-audit not installed. Run: gem install bundler-audit'))
78
+ end
79
+
80
+ def print_audit_error(error)
81
+ warn(red("Audit error: #{error}"))
82
+ end
83
+
84
+ def print_audit_results(vulnerabilities)
85
+ puts
86
+ puts(cyan('Checking for vulnerabilities...'))
87
+
88
+ if vulnerabilities.empty?
89
+ puts(green('No vulnerabilities found.'))
90
+ else
91
+ vulnerabilities.each { |v| print_vulnerability(v) }
92
+ puts
93
+ puts(yellow("#{vulnerabilities.length} vulnerability(ies) found"))
94
+ end
95
+ end
96
+
97
+ def print_vulnerability(vuln)
98
+ puts(red("VULNERABLE: #{vuln.gem_name} (#{vuln.cve}) - #{vuln.title}"))
99
+ puts(" Solution: #{vuln.solution}") if vuln.solution
100
+ end
101
+
102
+ def output_risk_results(risk_results, options)
103
+ return if risk_results.empty? || options[:json]
104
+
105
+ puts
106
+ puts(cyan('Risk signals:'))
107
+ risk_results.each { |result| print_risk_result(result) }
108
+ print_risk_summary(risk_results)
109
+ end
110
+
111
+ def print_risk_result(result)
112
+ result.signals.each { |signal| print_risk_signal(result, signal) }
113
+ end
114
+
115
+ def print_risk_signal(result, signal)
116
+ prefix = signal.mode == 'block' ? red('BLOCKED') : yellow('WARNING')
117
+ puts("#{prefix}: #{result.gem_name} (#{result.version}) - #{signal.message}")
118
+ end
119
+
120
+ def print_risk_summary(risk_results)
121
+ warnings = risk_results.sum { |r| r.signals.count { |s| s.mode == 'warn' } }
122
+ blocks = risk_results.count(&:blocked)
123
+
124
+ puts
125
+ puts(yellow("#{blocks} gem(s) blocked by risk signals")) if blocks.positive?
126
+ puts(yellow("#{warnings} risk warning(s)")) if warnings.positive?
127
+ end
128
+
129
+ def print_update_start(gem_names, lock_only: false)
130
+ puts
131
+ if lock_only
132
+ puts(cyan("Updating lock file for #{gem_names.length} gem(s): #{gem_names.join(', ')}"))
133
+ puts(cyan("Running: bundle lock --update #{gem_names.join(' ')}"))
134
+ else
135
+ puts(cyan("Updating #{gem_names.length} gem(s): #{gem_names.join(', ')}"))
136
+ puts(cyan("Running: bundle update #{gem_names.join(' ')}"))
137
+ end
138
+ end
139
+
140
+ def print_update_result(success)
141
+ puts(success ? green('Bundle updated successfully.') : red('Bundle update failed.'))
142
+ end
143
+
144
+ def print_skipped(blocked, risk_blocked_names)
145
+ total_skipped = blocked.length + risk_blocked_names.length
146
+ return if total_skipped.zero?
147
+
148
+ cooldown_names = blocked.map(&:name)
149
+ all_skipped = (cooldown_names + risk_blocked_names).uniq.join(', ')
150
+ puts
151
+ puts(yellow("Skipped #{total_skipped} blocked gem(s): #{all_skipped}"))
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ require 'optparse'
5
+ require 'json'
6
+ require_relative 'cli/options'
7
+ require_relative 'cli/output'
8
+
9
+ module BundleSafeUpdate
10
+ class CLI
11
+ include ColorOutput
12
+ include Options
13
+ include Output
14
+
15
+ EXIT_SUCCESS = 0
16
+ EXIT_VIOLATIONS = 1
17
+ EXIT_ERROR = 2
18
+
19
+ def self.run(args)
20
+ new.run(args)
21
+ end
22
+
23
+ def run(args)
24
+ options, gems = parse_options(args)
25
+ config = Config.new(options)
26
+ return dry_run(config) if options[:dry_run]
27
+
28
+ results = check_gems(config, options[:verbose], gems)
29
+ process_results(results, config, options)
30
+ rescue StandardError => e
31
+ handle_error(e, options[:verbose])
32
+ EXIT_ERROR
33
+ end
34
+
35
+ private
36
+
37
+ def parse_options(args)
38
+ options = {}
39
+ build_option_parser(options).parse!(args)
40
+ [options, args]
41
+ end
42
+
43
+ def dry_run(config)
44
+ dry_run_output(config)
45
+ EXIT_SUCCESS
46
+ end
47
+
48
+ def check_gems(config, verbose, gems = [])
49
+ puts(cyan('Checking gem versions...')) if verbose
50
+ outdated_gems = OutdatedChecker
51
+ .new(gems:)
52
+ .outdated_gems
53
+ return log_empty_result(verbose) if outdated_gems.empty?
54
+
55
+ puts(cyan("Found #{outdated_gems.length} outdated gem(s)")) if verbose
56
+ GemChecker
57
+ .new(config:, max_threads: config.max_threads)
58
+ .check_all(outdated_gems)
59
+ end
60
+
61
+ def log_empty_result(verbose)
62
+ puts(green('No outdated gems found.')) if verbose
63
+ []
64
+ end
65
+
66
+ def process_results(results, config, options)
67
+ allowed, blocked = partition_results(results)
68
+ output_results(results, blocked, config, options)
69
+ risk_results = run_risk_check(results, config, options)
70
+ perform_update(allowed, blocked, risk_results, config) if config.update && allowed.any?
71
+ determine_exit_code(config, blocked, risk_results, run_audit(config, options))
72
+ end
73
+
74
+ def run_risk_check(results, config, options)
75
+ return [] if options[:risk] == false
76
+
77
+ risk_checker = RiskChecker.new(config: config)
78
+ risk_results = options[:refresh_cache] ? [] : risk_checker.check_all(results)
79
+ output_risk_results(risk_results, options) unless options[:json]
80
+ risk_checker.save_cache
81
+ risk_results
82
+ end
83
+
84
+ def partition_results(results)
85
+ [results.select(&:allowed), results.reject(&:allowed)]
86
+ end
87
+
88
+ def output_results(results, blocked, config, options)
89
+ options[:json] ? output_json(results, blocked, config) : output_human(results, blocked, config)
90
+ end
91
+
92
+ def determine_exit_code(config, blocked, risk_results, audit_result)
93
+ return EXIT_SUCCESS if config.warn_only
94
+ return EXIT_SUCCESS unless violations?(blocked, risk_results, audit_result)
95
+
96
+ EXIT_VIOLATIONS
97
+ end
98
+
99
+ def violations?(blocked, risk_results, audit_result)
100
+ blocked.any? || risk_results.any?(&:blocked) || audit_result&.vulnerabilities&.any?
101
+ end
102
+
103
+ def run_audit(config, options)
104
+ return nil unless config.audit
105
+
106
+ audit_result = AuditChecker.new.check
107
+ output_audit_result(audit_result) unless options[:json]
108
+ audit_result
109
+ end
110
+
111
+ def perform_update(allowed, blocked, risk_results, config)
112
+ risk_blocked_names = risk_results.select(&:blocked).map(&:gem_name)
113
+ updatable = allowed.reject { |r| risk_blocked_names.include?(r.name) }
114
+ return if updatable.empty?
115
+
116
+ gem_names = updatable.map(&:name)
117
+ run_bundle_update(gem_names, config.lock_only)
118
+ print_skipped(blocked, risk_blocked_names)
119
+ end
120
+
121
+ def run_bundle_update(gem_names, lock_only)
122
+ print_update_start(gem_names, lock_only:)
123
+ result = Bundler.with_unbundled_env { system(*update_command(gem_names, lock_only)) }
124
+ print_update_result(result)
125
+ end
126
+
127
+ def update_command(gem_names, lock_only)
128
+ if lock_only
129
+ %w[bundle lock --update] + gem_names
130
+ else
131
+ %w[bundle update] + gem_names
132
+ end
133
+ end
134
+
135
+ def handle_error(error, verbose)
136
+ warn(red("Error: #{error.message}"))
137
+ warn(error.backtrace.join("\n")) if verbose
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleSafeUpdate
4
+ module ColorOutput
5
+ COLORS = {
6
+ green: "\e[32m",
7
+ yellow: "\e[33m",
8
+ red: "\e[31m",
9
+ cyan: "\e[36m",
10
+ reset: "\e[0m"
11
+ }.freeze
12
+
13
+ def colorize(text, color)
14
+ return text unless tty?
15
+
16
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
17
+ end
18
+
19
+ def green(text)
20
+ colorize(text, :green)
21
+ end
22
+
23
+ def yellow(text)
24
+ colorize(text, :yellow)
25
+ end
26
+
27
+ def red(text)
28
+ colorize(text, :red)
29
+ end
30
+
31
+ def cyan(text)
32
+ colorize(text, :cyan)
33
+ end
34
+
35
+ private
36
+
37
+ def tty?
38
+ $stdout.tty?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module BundleSafeUpdate
6
+ class Config
7
+ DEFAULT_COOLDOWN_DAYS = 14
8
+ CONFIG_FILENAME = '.bundle-safe-update.yml'
9
+
10
+ DEFAULT_MAX_THREADS = 32
11
+
12
+ DEFAULT_RISK_SIGNALS = {
13
+ 'low_downloads' => { 'mode' => 'warn', 'threshold' => 1000 },
14
+ 'stale_gem' => { 'mode' => 'warn', 'threshold_years' => 3 },
15
+ 'new_owner' => { 'mode' => 'warn', 'threshold_days' => 90 },
16
+ 'version_jump' => { 'mode' => 'warn' }
17
+ }.freeze
18
+
19
+ DEFAULTS = {
20
+ 'cooldown_days' => DEFAULT_COOLDOWN_DAYS,
21
+ 'ignore_prefixes' => [],
22
+ 'ignore_gems' => [],
23
+ 'trusted_sources' => [],
24
+ 'trusted_owners' => [],
25
+ 'max_threads' => DEFAULT_MAX_THREADS,
26
+ 'audit' => true,
27
+ 'verbose' => false,
28
+ 'update' => false,
29
+ 'lock_only' => false,
30
+ 'warn_only' => false,
31
+ 'risk_signals' => DEFAULT_RISK_SIGNALS
32
+ }.freeze
33
+
34
+ attr_reader :cooldown_days, :ignore_prefixes, :ignore_gems, :trusted_sources, :trusted_owners,
35
+ :max_threads, :audit, :verbose, :update, :lock_only, :warn_only, :risk_signals
36
+
37
+ def initialize(options = {})
38
+ config = merge_configs(options)
39
+ assign_basic_options(config)
40
+ assign_risk_options(config)
41
+ end
42
+
43
+ def risk_signal_mode(signal_name)
44
+ @risk_signals.dig(signal_name.to_s, 'mode') || 'off'
45
+ end
46
+
47
+ def risk_signal_enabled?(signal_name)
48
+ risk_signal_mode(signal_name) != 'off'
49
+ end
50
+
51
+ def risk_signal_threshold(signal_name, key)
52
+ @risk_signals.dig(signal_name.to_s, key.to_s)
53
+ end
54
+
55
+ def ignored?(gem_name)
56
+ return true if @ignore_gems.include?(gem_name)
57
+
58
+ @ignore_prefixes.any? { |prefix| gem_name.start_with?(prefix) }
59
+ end
60
+
61
+ def trusted_source?(source_url)
62
+ return false if source_url.nil? || @trusted_sources.empty?
63
+
64
+ @trusted_sources.any? { |pattern| source_url.include?(pattern) }
65
+ end
66
+
67
+ private
68
+
69
+ def assign_basic_options(config)
70
+ @cooldown_days = config['cooldown_days']
71
+ @ignore_prefixes = config['ignore_prefixes']
72
+ @ignore_gems = config['ignore_gems']
73
+ @trusted_sources = config['trusted_sources']
74
+ @trusted_owners = config['trusted_owners']
75
+ @max_threads = config['max_threads']
76
+ end
77
+
78
+ def assign_risk_options(config)
79
+ @audit = config['audit']
80
+ @verbose = config['verbose']
81
+ @update = config['update']
82
+ @lock_only = config['lock_only']
83
+ @warn_only = config['warn_only']
84
+ @risk_signals = config['risk_signals']
85
+ end
86
+
87
+ def merge_configs(cli_options)
88
+ config = DEFAULTS.dup
89
+ config = deep_merge(config, load_global_config)
90
+ config = deep_merge(config, load_local_config)
91
+ config = deep_merge(config, load_custom_config(cli_options[:config]))
92
+ apply_cli_overrides(config, cli_options)
93
+ end
94
+
95
+ def load_global_config
96
+ global_path = File.join(Dir.home, CONFIG_FILENAME)
97
+ load_config_file(global_path)
98
+ end
99
+
100
+ def load_local_config
101
+ local_path = File.join(Dir.pwd, CONFIG_FILENAME)
102
+ load_config_file(local_path)
103
+ end
104
+
105
+ def load_custom_config(path)
106
+ return {} unless path
107
+
108
+ load_config_file(path)
109
+ end
110
+
111
+ def load_config_file(path)
112
+ return {} unless File.exist?(path)
113
+
114
+ YAML.safe_load_file(path) || {}
115
+ rescue Psych::SyntaxError => e
116
+ warn("Warning: Invalid YAML in #{path}: #{e.message}")
117
+ {}
118
+ end
119
+
120
+ def apply_cli_overrides(config, options)
121
+ config['cooldown_days'] = options[:cooldown] if options[:cooldown]
122
+ apply_boolean_overrides(config, options)
123
+ config
124
+ end
125
+
126
+ def apply_boolean_overrides(config, options)
127
+ %i[audit verbose update lock_only warn_only].each do |key|
128
+ config[key.to_s] = options[key] if options.key?(key)
129
+ end
130
+ end
131
+
132
+ def deep_merge(base, override)
133
+ base.merge(override) do |_key, old_val, new_val|
134
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
135
+ deep_merge(old_val, new_val)
136
+ else
137
+ new_val
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end