From e8c4745d6442822e19afe04f1753329cc372ff21 Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Thu, 30 Jul 2026 18:16:23 +0200 Subject: [PATCH 1/4] feat: Add flag --only-security-updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In our workflow, we regularly check for security updates and require this capability. Additionally, excluding major updates by default—even those containing security fixes—is a sensible approach, with the option to include them via the `--with-major-update` flag. We continuously check for security updates in our workflow and need to maintain this behavior. By default, major updates are excluded even if they contain security fixes. This can be overridden using the `--with-major-update` flag when needed. --- lib/bundle_update_interactive/cli.rb | 19 +++++++++++++--- lib/bundle_update_interactive/cli/options.rb | 24 +++++++++++++++++++- lib/bundle_update_interactive/report.rb | 8 +++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/bundle_update_interactive/cli.rb b/lib/bundle_update_interactive/cli.rb index 302bf10..2baac49 100644 --- a/lib/bundle_update_interactive/cli.rb +++ b/lib/bundle_update_interactive/cli.rb @@ -9,12 +9,13 @@ 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 = MultiSelect.prompt_for_gems_to_update(updatable_gems) 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 @@ -31,6 +32,18 @@ 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 no_gems_message(options) + return "No gems to update." unless options.only_security_updates? + + "No security updates to apply." + 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..a3bd813 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,12 @@ 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("--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( "--exclusively=GROUP", "Update gems exclusively belonging to the specified Gemfile GROUP(s)" @@ -97,13 +109,15 @@ 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 def initialize @exclusively = [] @commit = false @latest = false @only_explicit = false + @only_security_updates = false + @with_major_update = false end def commit? @@ -117,6 +131,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/report.rb b/lib/bundle_update_interactive/report.rb index f63739f..a52ce55 100644 --- a/lib/bundle_update_interactive/report.rb +++ b/lib/bundle_update_interactive/report.rb @@ -23,6 +23,14 @@ 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? From 2c3d1d4bab6e121f0ad5f4702d22278fd2a15fbf Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Thu, 30 Jul 2026 18:40:51 +0200 Subject: [PATCH 2/4] feat: Add --commit-bundle-audit-message When applying security updates, the commit message must include: - The bundle audit title - CVE reference number This ensures traceability for future reference. --- lib/bundle_update_interactive/cli.rb | 9 +++- lib/bundle_update_interactive/cli/options.rb | 15 +++++- .../git_committer.rb | 46 ++++++++++++++++--- lib/bundle_update_interactive/outdated_gem.rb | 2 + lib/bundle_update_interactive/report.rb | 16 +++++-- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/lib/bundle_update_interactive/cli.rb b/lib/bundle_update_interactive/cli.rb index 2baac49..88321d4 100644 --- a/lib/bundle_update_interactive/cli.rb +++ b/lib/bundle_update_interactive/cli.rb @@ -20,7 +20,8 @@ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize 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 @@ -44,6 +45,12 @@ def no_gems_message(options) "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 a3bd813..138c928 100644 --- a/lib/bundle_update_interactive/cli/options.rb +++ b/lib/bundle_update_interactive/cli/options.rb @@ -86,6 +86,13 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt 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)" @@ -109,7 +116,8 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt end attr_accessor :exclusively - attr_writer :commit, :latest, :only_explicit, :only_security_updates, :with_major_update + attr_writer :commit, :latest, :only_explicit, :only_security_updates, :with_major_update, + :commit_bundle_audit_message def initialize @exclusively = [] @@ -118,12 +126,17 @@ def initialize @only_explicit = false @only_security_updates = false @with_major_update = false + @commit_bundle_audit_message = false end def commit? @commit end + def commit_bundle_audit_message? + @commit_bundle_audit_message + end + def latest? @latest 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 a52ce55..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 @@ -36,11 +35,12 @@ def scan_for_vulnerabilities! 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 @@ -48,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 From b81a6cba9111ae86f7e05729cac10e6be8c5d512 Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Thu, 30 Jul 2026 19:01:37 +0200 Subject: [PATCH 3/4] feat: Add flag --auto-update Especially useful in combination with --only-security-updates and automation --- lib/bundle_update_interactive/cli.rb | 8 +++++++- lib/bundle_update_interactive/cli/options.rb | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/bundle_update_interactive/cli.rb b/lib/bundle_update_interactive/cli.rb index 88321d4..9eb4ce8 100644 --- a/lib/bundle_update_interactive/cli.rb +++ b/lib/bundle_update_interactive/cli.rb @@ -12,7 +12,7 @@ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize 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(updatable_gems) + selected_gems = select_gems_to_update(updatable_gems, options) puts("No gems to update.").then { return } if selected_gems.empty? puts "baem the Updating the following gems." @@ -39,6 +39,12 @@ def select_updatable_gems(report, options) 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? diff --git a/lib/bundle_update_interactive/cli/options.rb b/lib/bundle_update_interactive/cli/options.rb index 138c928..5684cd9 100644 --- a/lib/bundle_update_interactive/cli/options.rb +++ b/lib/bundle_update_interactive/cli/options.rb @@ -80,6 +80,9 @@ 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 @@ -117,7 +120,7 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt attr_accessor :exclusively attr_writer :commit, :latest, :only_explicit, :only_security_updates, :with_major_update, - :commit_bundle_audit_message + :commit_bundle_audit_message, :auto_update def initialize @exclusively = [] @@ -127,12 +130,17 @@ def initialize @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 From 57ee8510e99823a673ae0ae658755005f016cd09 Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Thu, 30 Jul 2026 19:03:11 +0200 Subject: [PATCH 4/4] WIP: Set LOAD_PATH to local lib Makes it easy to run exe/bundle-ui of my local repo on any local rails project with my personal changes. Delete this in the future when those features will be seriously used --- exe/bundler-ui | 4 ++++ 1 file changed, 4 insertions(+) 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