Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions exe/bundler-ui
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

$LOAD_PATH.unshift "/Users/zirni/dev/bundle_update_interactive/lib"
puts $LOAD_PATH

require "bundle_update_interactive"


BundleUpdateInteractive::CLI.new.run
34 changes: 30 additions & 4 deletions lib/bundle_update_interactive/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
report, updater = generate_report(options)

puts_legend_and_withheld_gems(report) unless report.empty?
puts("No gems to update.").then { return } if report.updatable_gems.empty?
updatable_gems = select_updatable_gems(report, options)
puts(no_gems_message(options)).then { return } if updatable_gems.empty?

selected_gems = MultiSelect.prompt_for_gems_to_update(report.updatable_gems)
selected_gems = select_gems_to_update(updatable_gems, options)
puts("No gems to update.").then { return } if selected_gems.empty?

puts "Updating the following gems."
puts "baem the Updating the following gems."
puts Table.updatable(selected_gems).render
puts

if options.commit?
GitCommitter.new(updater).apply_updates_as_individual_commits(*selected_gems.keys)
committer = GitCommitter.new(updater, advisories: advisories_for_commit(selected_gems, options))
committer.apply_updates_as_individual_commits(*selected_gems.keys)
else
updater.apply_updates(*selected_gems.keys)
end
Expand All @@ -31,6 +33,30 @@ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize

private

def select_updatable_gems(report, options)
return report.updatable_gems unless options.only_security_updates?

report.security_updates(include_major_updates: options.with_major_update?)
end

def select_gems_to_update(updatable_gems, options)
return updatable_gems if options.auto_update?

MultiSelect.prompt_for_gems_to_update(updatable_gems)
end

def no_gems_message(options)
return "No gems to update." unless options.only_security_updates?

"No security updates to apply."
end

def advisories_for_commit(selected_gems, options)
return {} unless options.commit_bundle_audit_message?

selected_gems.transform_values(&:advisories)
end

def puts_gemfile_modified_notice
puts BundleUpdateInteractive.pastel.yellow("Your Gemfile was changed to accommodate the latest gem versions.")
end
Expand Down
45 changes: 44 additions & 1 deletion lib/bundle_update_interactive/cli/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ def help # rubocop:disable Metrics/AbcSize
Allow the latest gem versions, ignoring Gemfile pins. May modify the Gemfile.
#{pastel.green('bundle update-interactive')} #{pastel.yellow('--latest')}

Update only gems with known security vulnerabilities, excluding major updates.
#{pastel.green('bundle update-interactive')} #{pastel.yellow('--only-security-updates')}

Update gems with known security vulnerabilities, allowing major updates.
#{pastel.green('bundle update-interactive')} #{pastel.yellow('--only-security-updates --with-major-update')}

HELP
end

Expand All @@ -74,6 +80,22 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt
parser.on("--only-explicit", "Update Gemfile gems only (no indirect dependencies)") do
options.only_explicit = true
end
parser.on("--auto-update", "Update every gem in the list without prompting for selection") do
options.auto_update = true
end
parser.on("--only-security-updates", "Update only gems with known security vulnerabilities") do
options.only_security_updates = true
end
parser.on("--with-major-update", "Include major version bumps among --only-security-updates") do
options.with_major_update = true
end
parser.on(
"--commit-bundle-audit-message",
"Append bundler-audit advisory details to each commit body (implies --commit)"
) do
options.commit = true
options.commit_bundle_audit_message = true
end
parser.on(
"--exclusively=GROUP",
"Update gems exclusively belonging to the specified Gemfile GROUP(s)"
Expand All @@ -97,26 +119,47 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt
end

attr_accessor :exclusively
attr_writer :commit, :latest, :only_explicit
attr_writer :commit, :latest, :only_explicit, :only_security_updates, :with_major_update,
:commit_bundle_audit_message, :auto_update

def initialize
@exclusively = []
@commit = false
@latest = false
@only_explicit = false
@only_security_updates = false
@with_major_update = false
@commit_bundle_audit_message = false
@auto_update = false
end

def commit?
@commit
end

def auto_update?
@auto_update
end

def commit_bundle_audit_message?
@commit_bundle_audit_message
end

def latest?
@latest
end

def only_explicit?
@only_explicit
end

def only_security_updates?
@only_security_updates
end

def with_major_update?
@with_major_update
end
end
end
end
46 changes: 40 additions & 6 deletions lib/bundle_update_interactive/git_committer.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# frozen_string_literal: true

require "shellwords"

module BundleUpdateInteractive
class GitCommitter
def initialize(updater)
# Most to least severe. Advisories without a CVSS score report a nil criticality
# and are sorted last.
CRITICALITY_ORDER = %i[critical high medium low none].freeze

def initialize(updater, advisories: {})
@updater = updater
@advisories = advisories
end

def apply_updates_as_individual_commits(*gem_names)
Expand All @@ -17,9 +20,8 @@ def apply_updates_as_individual_commits(*gem_names)
updated_gem = updates[name] || updates.values.first
next if updated_gem.nil?

commit_message = format_commit_message(updated_gem)
system "git add Gemfile Gemfile.lock", exception: true
system "git commit -m #{commit_message.shellescape}", exception: true
system(*["git", "commit", *commit_message_args(name, updated_gem)], exception: true)
end
end

Expand All @@ -35,9 +37,41 @@ def format_commit_message(outdated_gem)
].compact.join(" ")
end

def format_commit_body(name)
advisories = advisories_for(name)
return nil if advisories.empty?

lines = ["Fixes known security vulnerabilities:", ""]
sort_by_criticality(advisories).each do |advisory|
criticality = advisory[:criticality]
lines << "* #{"[#{criticality}] " if criticality}#{advisory[:title]}".rstrip
lines << " #{advisory[:url]}" if advisory[:url]
end
lines.join("\n")
end

private

attr_reader :updater
attr_reader :updater, :advisories

def commit_message_args(name, updated_gem)
subject = format_commit_message(updated_gem)
body = format_commit_body(name)
return ["-m", subject] if body.nil?

["-m", subject, "-m", body]
end

def advisories_for(name)
Array(advisories[name])
end

# Stable sort: most severe first, preserving original order within the same criticality.
def sort_by_criticality(advisories)
advisories.each_with_index.sort_by do |advisory, index|
[CRITICALITY_ORDER.index(advisory[:criticality]) || CRITICALITY_ORDER.length, index]
end.map(&:first)
end

def assert_git_executable!
success = begin
Expand Down
2 changes: 2 additions & 0 deletions lib/bundle_update_interactive/outdated_gem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ class OutdatedGem
:updated_version,
:updated_git_version

attr_accessor :advisories
attr_writer :changelog_uri, :rubygems_source, :vulnerable

def initialize(**attrs)
@vulnerable = nil
@advisories = []
@changelog_locator = ChangelogLocator.new

attrs.each { |name, value| public_send(:"#{name}=", value) }
Expand Down
24 changes: 21 additions & 3 deletions lib/bundle_update_interactive/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
require "bundler"
require "bundler/audit"
require "bundler/audit/scanner"
require "set"

module BundleUpdateInteractive
class Report
Expand All @@ -23,22 +22,41 @@ def all_gems
@all_gems ||= withheld_gems.merge(updatable_gems)
end

# Gems with a known security vulnerability that can be updated. Major version bumps are
# excluded unless include_major_updates is true. scan_for_vulnerabilities! must be run first.
def security_updates(include_major_updates: false)
updatable_gems.select do |_name, gem|
gem.vulnerable? && (include_major_updates || !gem.semver_change.major?)
end
end

def scan_for_vulnerabilities!
return false if all_gems.empty?

Bundler::Audit::Database.update!(quiet: true)
audit_report = Bundler::Audit::Scanner.new.report
vulnerable_gem_names = Set.new(audit_report.vulnerable_gems.map(&:name))
advisories_by_gem = collect_advisories(audit_report)

all_gems.each do |name, gem|
exact_deps = current_lockfile && current_lockfile[name].exact_dependencies
gem.vulnerable = (vulnerable_gem_names & [name, *Array(exact_deps)]).any?
gem.advisories = [name, *Array(exact_deps)].flat_map { |n| advisories_by_gem[n] }
gem.vulnerable = gem.advisories.any?
end
true
end

private

attr_reader :current_lockfile

def collect_advisories(audit_report)
audit_report.unpatched_gems.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |result, advisories|
advisory = result.advisory
url = advisory.url
url = "https://github.com/advisories/GHSA-#{advisory.ghsa}" if !url.to_s.include?("github.com") && advisory.ghsa

advisories[result.gem.name] << { title: advisory.title, url: url, criticality: advisory.criticality }
end
end
end
end