bundle-patch 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d8b17466b5eb121598328313cfad1610ffc24389696fe4560d4a069d77a0964b
4
+ data.tar.gz: 8f27ad6af43d58e7d4d45b1c4929ea0a5f81f07acdedd392f07b22fbb3e71e8e
5
+ SHA512:
6
+ metadata.gz: fdee9fb0b56afdb55eaba3c51139b6ceff09b81bda3e2845b89ee84400508061f1386e04d1a13475bbf7dbf18186380de33cd57d1209ea276916d6ab85e7a4db
7
+ data.tar.gz: 4b47dc321f9a3f976d65ab41c5bba00e3bc6ad4de4824503a588cad8d375c1711bea149a99c8240cfb7a44d9138d29d46a6144e8a26dfddf112287d1403724e6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-04-10
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 rishijain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # πŸ”’ bundle-patch
2
+
3
+ A command-line tool to **automatically patch vulnerable gems** in your Gemfile using [`bundler-audit`](https://github.com/rubysec/bundler-audit) under the hood.
4
+
5
+ It parses audit output, finds the **best patchable version** for each vulnerable gem, and updates your Gemfile accordingly.
6
+
7
+ ---
8
+
9
+ ## ✨ Features
10
+
11
+ - Runs `bundle audit` and parses vulnerabilities
12
+ - Computes the minimal patchable version required
13
+ - Updates your `Gemfile` (and optionally runs `bundle install`)
14
+ - Supports patch/minor/major upgrade strategies
15
+ - Handles indirect dependencies by explicitly adding them
16
+ - Has a dry-run mode
17
+
18
+ ---
19
+
20
+ ## πŸ’‘ Example
21
+
22
+ ```bash
23
+ bundle-patch --mode=minor
24
+ ```
25
+
26
+ Example output
27
+
28
+ ```
29
+ πŸ” Running `bundle-audit check --format json`...
30
+ πŸ”’ Found 2 vulnerabilities:
31
+ - sidekiq (5.2.10): sidekiq Denial of Service vulnerability
32
+ βœ… Patchable β†’ 6.5.10
33
+ - actionpack (6.1.4.1): XSS vulnerability
34
+ βœ… Patchable β†’ 6.1.7.7
35
+ πŸ“ Backing up Gemfile to Gemfile.bak...
36
+ πŸ”§ Updating existing gem: actionpack to '6.1.7.7'
37
+ βž• Gem sidekiq is a dependency. Adding it explicitly to Gemfile with version 6.5.10.
38
+ βœ… Gemfile updated!
39
+ πŸ“¦ Running `bundle install`...
40
+ βœ… bundle install completed successfully
41
+ ```
42
+
43
+ ## βš™οΈ Options
44
+
45
+ | Option | Description |
46
+ | ----------------------- | ------------------------------------------------------------------------- |
47
+ | `--mode=patch` | Only allow patch-level updates (default) |
48
+ | `--mode=minor` | Allow minor version updates |
49
+ | `--mode=all` | Allow all updates including major versions |
50
+ | `--dry-run` | Only print what would be changed, don’t touch the Gemfile or install gems |
51
+ | `--skip_bundle_install` | Modify the Gemfile, but skip `bundle install` |
52
+
53
+ ## πŸ“¦ Installation
54
+
55
+ Add this gem to your system:
56
+
57
+ ```bash
58
+ gem install bundle-patch
59
+ ```
60
+
61
+ Or add it to your project's Gemfile for use in development:
62
+
63
+ ```bash
64
+ # Gemfile
65
+ group :development do
66
+ gem 'bundle-patch'
67
+ end
68
+ ```
69
+
70
+ And then:
71
+
72
+ ```
73
+ bundle install
74
+ ```
75
+
76
+ ## 🧼 How it works
77
+
78
+ 1. Runs `bundle audit check --format json`
79
+ 2. Groups advisories by gem
80
+ 3. Determines the best patchable version for each gem based on `--mode`
81
+ 4. Ensures the gem is either updated or explicitly added to the `Gemfile`
82
+ 5. Optionally runs `bundle install` (unless `--skip_bundle_install` or `--dry-run` is used)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "bundle/patch"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+
5
+ module Bundle
6
+ module Patch
7
+ module Audit
8
+ class Advisory
9
+ attr_reader :name, :version, :patched_versions, :raw
10
+
11
+ def initialize(raw)
12
+ @raw = raw
13
+ @name = raw.dig("gem", "name")
14
+ @version = Gem::Version.new(raw.dig("gem", "version"))
15
+ @patched_versions = Array(raw.dig("advisory", "patched_versions")).map { Gem::Requirement.new(_1) }
16
+ end
17
+
18
+ def patchable?
19
+ latest_patch_version && (latest_patch_version.segments[1] == version.segments[1])
20
+ end
21
+
22
+ def latest_patch_version
23
+ @latest_patch_version ||= begin
24
+ candidates = patched_versions.flat_map(&:requirements)
25
+ .map { |op, v| Gem::Version.new(v) if op == ">=" }
26
+ .compact
27
+
28
+ candidates
29
+ .select { |v| v.segments[0..1] == version.segments[0..1] } # Same major.minor
30
+ .max
31
+ end
32
+ end
33
+
34
+ def to_h
35
+ @raw
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ require "json"
2
+ require "open3"
3
+ require_relative "advisory"
4
+
5
+ module Bundle
6
+ module Patch
7
+ module Audit
8
+ class Parser
9
+ def self.run
10
+ puts "πŸ” Running `bundle-audit check --format json`..."
11
+
12
+ output, _status = Open3.capture2("bundle-audit check --format json")
13
+
14
+ # Even if status is non-zero, it's likely due to found vulnerabilities
15
+ begin
16
+ parsed = JSON.parse(output)
17
+ # parsed["results"] || []
18
+ parsed["results"].map { |data| Advisory.new(data) }
19
+ rescue JSON::ParserError => e
20
+ abort "❌ Could not parse bundle-audit output: #{e.message}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ module Bundle
2
+ module Patch
3
+ class BundlerAuditInstaller
4
+ def self.ensure_installed!
5
+ return if system("bundle-audit --version > /dev/null 2>&1")
6
+
7
+ puts "πŸ” bundler-audit not found. Installing..."
8
+ success = system("gem install bundler-audit")
9
+
10
+ unless success
11
+ abort "❌ Failed to install bundler-audit. Please check your RubyGems setup."
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ # lib/bundle/patch/config.rb
2
+ module Bundle
3
+ module Patch
4
+ class Config
5
+ attr_reader :dry_run, :mode, :skip_bundle_install
6
+
7
+ def initialize(dry_run: false, mode: "patch", skip_bundle_install: false)
8
+ @dry_run = dry_run
9
+ @mode = mode
10
+ @skip_bundle_install = skip_bundle_install
11
+ end
12
+
13
+ def allow_update?(from_version, to_version)
14
+ return true if mode == "all"
15
+
16
+ from = Gem::Version.new(from_version)
17
+ to = Gem::Version.new(to_version)
18
+
19
+ case mode
20
+ when "patch"
21
+ same_major?(from, to) && same_minor?(from, to)
22
+ when "minor"
23
+ same_major?(from, to)
24
+ else
25
+ true
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def same_major?(v1, v2)
32
+ v1.segments[0] == v2.segments[0]
33
+ end
34
+
35
+ def same_minor?(v1, v2)
36
+ v1.segments[1] == v2.segments[1]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundle
4
+ module Patch
5
+ class GemfileEditor
6
+ GEMFILE_PATH = "Gemfile"
7
+ LOCKFILE_PATH = "Gemfile.lock"
8
+ BACKUP_PATH = "Gemfile.bak"
9
+
10
+ def self.update!(patchable_gems)
11
+ unless File.exist?(GEMFILE_PATH)
12
+ abort "❌ No Gemfile found in the current directory."
13
+ end
14
+
15
+ puts "πŸ“ Backing up Gemfile to #{BACKUP_PATH}..."
16
+ File.write(BACKUP_PATH, File.read(GEMFILE_PATH))
17
+
18
+ lines = File.readlines(GEMFILE_PATH)
19
+ updated_lines = lines.dup
20
+
21
+ patchable_gems.each do |gem_info|
22
+ name = gem_info["name"]
23
+ version = gem_info["required_version"]
24
+
25
+ in_gemfile = gem_declared_in_gemfile?(name, lines)
26
+ in_lockfile = gem_declared_in_lockfile?(name)
27
+
28
+ if in_gemfile
29
+ puts "πŸ”§ Updating existing gem: #{name} β†’ '#{version}'"
30
+ updated_lines = update_version_in_lines(updated_lines, name, version)
31
+ elsif in_lockfile
32
+ puts "βž• Adding dependency gem: #{name} β†’ '#{version}'"
33
+ updated_lines << "gem \"#{name}\", \"#{version}\"\n"
34
+ else
35
+ puts "⚠️ Skipping #{name} β€” not found in Gemfile or Gemfile.lock."
36
+ end
37
+ end
38
+
39
+ File.write(GEMFILE_PATH, updated_lines.join)
40
+ puts "βœ… Gemfile updated!"
41
+ end
42
+
43
+ def self.gem_declared_in_gemfile?(name, lines)
44
+ lines.any? { |line| line.match?(/^\s*gem\s+['"]#{name}['"]/) }
45
+ end
46
+
47
+ def self.gem_declared_in_lockfile?(name)
48
+ return false unless File.exist?(LOCKFILE_PATH)
49
+ File.read(LOCKFILE_PATH).include?("\n #{name} ")
50
+ end
51
+
52
+ def self.update_version_in_lines(lines, name, version)
53
+ lines.map do |line|
54
+ if line.match?(/^\s*gem\s+['"]#{name}['"]/)
55
+ parts = line.strip.split(",").map(&:strip)
56
+ gem_declaration = parts[0]
57
+ "#{gem_declaration}, '#{version}'\n"
58
+ else
59
+ line
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundle
4
+ module Patch
5
+ class GemfileUpdater
6
+ def self.update(gemfile_path:, advisories:)
7
+ contents = File.read(gemfile_path)
8
+ updated = false
9
+
10
+ advisories.each do |adv|
11
+ name = adv["name"]
12
+ min_safe_version = adv["required_version"]
13
+ next unless min_safe_version
14
+
15
+ # This regex matches lines like: gem 'somegem', '1.2.3'
16
+ regex = /^(\s*gem\s+["']#{Regexp.escape(name)}["']\s*,\s*)["'][^"']*["'](.*)$/
17
+
18
+ contents.gsub!(regex) do
19
+ updated = true
20
+ "#{$1}\"#{min_safe_version}\"#{$2}"
21
+ end
22
+ end
23
+
24
+ if updated
25
+ File.write(gemfile_path, contents)
26
+ puts "πŸ“ Updated Gemfile with patched versions"
27
+ else
28
+ puts "βœ… No existing Gemfile entries needed updating"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundle
4
+ module Patch
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,87 @@
1
+ # lib/bundle/patch.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "patch/version"
5
+ require_relative "patch/bundler_audit_installer"
6
+ require_relative "patch/audit/parser"
7
+ require_relative "patch/gemfile_editor"
8
+ require_relative "patch/gemfile_updater"
9
+ require_relative "patch/config"
10
+
11
+ module Bundle
12
+ module Patch
13
+ def self.start(config = Config.new)
14
+ BundlerAuditInstaller.ensure_installed!
15
+ advisories = Audit::Parser.run
16
+
17
+ if advisories.empty?
18
+ puts "πŸŽ‰ No vulnerabilities found!"
19
+ return
20
+ end
21
+
22
+ puts "πŸ”’ Found #{advisories.size} vulnerabilities:"
23
+ patchable = []
24
+
25
+ advisories.group_by { |adv| adv.to_h.dig("gem", "name") }.each do |name, gem_advisories|
26
+ current = gem_advisories.first.to_h.dig("gem", "version")
27
+ current_version = Gem::Version.new(current)
28
+
29
+ # Collect all requirements from advisories
30
+ all_requirements = gem_advisories.flat_map do |adv|
31
+ adv.to_h.dig("advisory", "patched_versions").map do |req|
32
+ Gem::Requirement.new(req) rescue nil
33
+ end
34
+ end.compact
35
+
36
+ # Find versions that satisfy all requirements
37
+ candidate_versions = all_requirements
38
+ .map { |req| best_version_matching(req) }
39
+ .compact
40
+ .uniq
41
+ .select { |v| config.allow_update?(current_version, v) }
42
+ .sort
43
+
44
+ if candidate_versions.any?
45
+ best_patch = candidate_versions.first
46
+ title_list = gem_advisories.map { |a| a.to_h.dig("advisory", "title") }.uniq
47
+ puts "- #{name} (#{current}):"
48
+ title_list.each { |t| puts " β€’ #{t}" }
49
+ puts " βœ… Patchable β†’ #{best_patch}"
50
+
51
+ patchable << { "name" => name, "required_version" => best_patch.to_s }
52
+ else
53
+ puts "- #{name} (#{current}):"
54
+ gem_advisories.each do |adv|
55
+ puts " β€’ #{adv.to_h.dig("advisory", "title")}"
56
+ end
57
+ puts " ⚠️ Not patchable (no version satisfies all advisories in current mode)"
58
+ end
59
+ end
60
+
61
+ if patchable.any?
62
+ if config.dry_run
63
+ puts "πŸ’‘ Skipped Gemfile update and bundle install (dry run)"
64
+ elsif config.skip_bundle_install
65
+ puts "πŸ’‘ Skipped bundle install (per --skip-bundle-install)"
66
+ GemfileEditor.update!(patchable)
67
+ GemfileUpdater.update(gemfile_path: "Gemfile", advisories: patchable)
68
+ else
69
+ GemfileEditor.update!(patchable)
70
+ GemfileUpdater.update(gemfile_path: "Gemfile", advisories: patchable)
71
+ puts "πŸ“¦ Running `bundle install`..."
72
+ success = system("bundle install")
73
+ if success
74
+ puts "βœ… bundle install completed successfully"
75
+ else
76
+ puts "❌ bundle install failed. Please run it manually."
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def self.best_version_matching(req)
83
+ # Approximate best patch version using upper bound from requirement
84
+ req.requirements.map { |_, v| v }.compact.min rescue nil
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,38 @@
1
+ require "optparse"
2
+ require "bundle/patch"
3
+
4
+ # Default options
5
+ options = {
6
+ dry_run: false,
7
+ mode: "patch"
8
+ }
9
+
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: bundle-patch [options]"
12
+
13
+ opts.on("--dry-run", "Do not modify files or run bundle install") do
14
+ options[:dry_run] = true
15
+ end
16
+
17
+ opts.on("--skip-bundle-install", "Update Gemfile but skip running bundle install") do
18
+ options[:skip_bundle_install] = true
19
+ end
20
+
21
+ opts.on("--mode=MODE", "Update mode: patch (default), minor, all") do |mode|
22
+ allowed = %w[patch minor all]
23
+ if allowed.include?(mode)
24
+ options[:mode] = mode
25
+ else
26
+ puts "❌ Invalid mode: #{mode}. Must be one of: #{allowed.join(', ')}"
27
+ exit 1
28
+ end
29
+ end
30
+ end.parse!
31
+
32
+ config = Bundle::Patch::Config.new(
33
+ dry_run: options[:dry_run],
34
+ mode: options[:mode],
35
+ skip_bundle_install: options[:skip_bundle_install]
36
+ )
37
+
38
+ Bundle::Patch.start(config)
@@ -0,0 +1,6 @@
1
+ module Bundle
2
+ module Patch
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundle-patch
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rishijain
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-04-12 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler-audit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ description: bundle-patch is a CLI tool that detects vulnerable gems in your Gemfile
27
+ and automatically upgrades them to a patchable version based on your configured
28
+ strategy (patch/minor/all). Uses bundler-audit under the hood.
29
+ email:
30
+ - jainrishi.37@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - bin/console
40
+ - bin/setup
41
+ - lib/bundle-patch.rb
42
+ - lib/bundle/patch.rb
43
+ - lib/bundle/patch/audit/advisory.rb
44
+ - lib/bundle/patch/audit/parser.rb
45
+ - lib/bundle/patch/bundler_audit_installer.rb
46
+ - lib/bundle/patch/config.rb
47
+ - lib/bundle/patch/gemfile_editor.rb
48
+ - lib/bundle/patch/gemfile_updater.rb
49
+ - lib/bundle/patch/version.rb
50
+ - sig/bundle/patch.rbs
51
+ homepage: https://github.com/rishijain/bundle-patch
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ allowed_push_host: https://rubygems.org
56
+ homepage_uri: https://github.com/rishijain/bundle-patch
57
+ source_code_uri: https://github.com/rishijain/bundle-patch
58
+ changelog_uri: https://github.com/rishijain/bundle-patch/blob/main/CHANGELOG.md
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.1.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.2
74
+ specification_version: 4
75
+ summary: Automatically patch vulnerable gems using bundler-audit
76
+ test_files: []