diff --git a/exe/bundler-ui b/exe/bundler-ui index 6e071b0..530bb75 100755 --- a/exe/bundler-ui +++ b/exe/bundler-ui @@ -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 diff --git a/lib/bundle_update_interactive/cli.rb b/lib/bundle_update_interactive/cli.rb index 302bf10..9eb4ce8 100644 --- a/lib/bundle_update_interactive/cli.rb +++ b/lib/bundle_update_interactive/cli.rb @@ -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 @@ -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 diff --git a/lib/bundle_update_interactive/cli/options.rb b/lib/bundle_update_interactive/cli/options.rb index 9cc06ca..5684cd9 100644 --- a/lib/bundle_update_interactive/cli/options.rb +++ b/lib/bundle_update_interactive/cli/options.rb @@ -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 @@ -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)" @@ -97,19 +119,32 @@ 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 @@ -117,6 +152,14 @@ def latest? 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 diff --git a/lib/bundle_update_interactive/git_committer.rb b/lib/bundle_update_interactive/git_committer.rb index 9b7a942..ed0477e 100644 --- a/lib/bundle_update_interactive/git_committer.rb +++ b/lib/bundle_update_interactive/git_committer.rb @@ -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) @@ -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 @@ -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 diff --git a/lib/bundle_update_interactive/outdated_gem.rb b/lib/bundle_update_interactive/outdated_gem.rb index ce04408..fccf62f 100644 --- a/lib/bundle_update_interactive/outdated_gem.rb +++ b/lib/bundle_update_interactive/outdated_gem.rb @@ -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) } diff --git a/lib/bundle_update_interactive/report.rb b/lib/bundle_update_interactive/report.rb index f63739f..7b085e4 100644 --- a/lib/bundle_update_interactive/report.rb +++ b/lib/bundle_update_interactive/report.rb @@ -3,7 +3,6 @@ require "bundler" require "bundler/audit" require "bundler/audit/scanner" -require "set" module BundleUpdateInteractive class Report @@ -23,16 +22,25 @@ 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 @@ -40,5 +48,15 @@ def scan_for_vulnerabilities! 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