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,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
|