From 9c3398ed6e56c7d3cfdba743d3515ee4cdb907d2 Mon Sep 17 00:00:00 2001 From: Matt Larraz Date: Thu, 2 Apr 2026 17:08:42 -0400 Subject: [PATCH 1/2] Add `exclude` option to skip gems by name during audit Allow users to exclude entire gems from scanning via config file (`exclude:` key in .bundler-audit.yml) or CLI (`--exclude`/`-e` flag). Unlike `ignore` which skips specific advisory IDs after lookup, `exclude` skips gems entirely before any advisory database check. This is useful for Rails apps that bundle gems they don't actually use (e.g. activestorage, actiontext) where every new CVE triggers a false audit failure. Co-Authored-By: Claude Opus 4.6 --- lib/bundler/audit/cli.rb | 3 +- lib/bundler/audit/configuration.rb | 23 ++++++++++- lib/bundler/audit/scanner.rb | 17 ++++++++ .../.bundler-audit.yml | 3 ++ .../Gemfile | 3 ++ spec/configuration_spec.rb | 40 +++++++++++++++++++ .../bad/exclude_contains_a_non_string.yml | 4 ++ .../config/bad/exclude_is_not_an_array.yml | 3 ++ spec/fixtures/config/valid_with_exclude.yml | 7 ++++ spec/scanner_spec.rb | 24 +++++++++++ 10 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 spec/bundle/unpatched_gems_with_exclude_configuration/.bundler-audit.yml create mode 100644 spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile create mode 100644 spec/fixtures/config/bad/exclude_contains_a_non_string.yml create mode 100644 spec/fixtures/config/bad/exclude_is_not_an_array.yml create mode 100644 spec/fixtures/config/valid_with_exclude.yml diff --git a/lib/bundler/audit/cli.rb b/lib/bundler/audit/cli.rb index 97979ecc..adf88ce8 100644 --- a/lib/bundler/audit/cli.rb +++ b/lib/bundler/audit/cli.rb @@ -37,6 +37,7 @@ class CLI < ::Thor method_option :quiet, type: :boolean, aliases: '-q' method_option :verbose, type: :boolean, aliases: '-v' method_option :ignore, type: :array, aliases: '-i' + method_option :exclude, type: :array, aliases: '-e' method_option :update, type: :boolean, aliases: '-u' method_option :database, type: :string, aliases: '-D', default: Database::DEFAULT_PATH @@ -73,7 +74,7 @@ def check(dir=Dir.pwd) exit 1 end - report = scanner.report(ignore: options.ignore) + report = scanner.report(ignore: options.ignore, exclude: options.exclude) output = if options[:output] File.new(options[:output],'w') diff --git a/lib/bundler/audit/configuration.rb b/lib/bundler/audit/configuration.rb index d08a1578..915b5902 100644 --- a/lib/bundler/audit/configuration.rb +++ b/lib/bundler/audit/configuration.rb @@ -77,6 +77,16 @@ def self.load(file_path) end config[:ignore] = value.children.map(&:value) + when 'exclude' + unless value.is_a?(YAML::Nodes::Sequence) + raise(InvalidConfigurationError,"'exclude' key found in config file, but is not an Array") + end + + unless value.children.all? { |node| node.is_a?(YAML::Nodes::Scalar) } + raise(InvalidConfigurationError,"'exclude' array in config file contains a non-String") + end + + config[:exclude] = value.children.map(&:value) end end @@ -90,6 +100,13 @@ def self.load(file_path) # attr_reader :ignore + # + # The list of gem names to exclude from scanning. + # + # @return [Set] + # + attr_reader :exclude + # # Initializes the configuration. # @@ -99,8 +116,12 @@ def self.load(file_path) # @option config [Array] :ignore # The list of advisory IDs to ignore. # + # @option config [Array] :exclude + # The list of gem names to exclude from scanning. + # def initialize(config={}) - @ignore = Set.new(config[:ignore]) + @ignore = Set.new(config[:ignore]) + @exclude = Set.new(config[:exclude]) end end diff --git a/lib/bundler/audit/scanner.rb b/lib/bundler/audit/scanner.rb index f0e79a30..846946f2 100644 --- a/lib/bundler/audit/scanner.rb +++ b/lib/bundler/audit/scanner.rb @@ -104,6 +104,9 @@ def initialize(root=Dir.pwd,gemfile_lock='Gemfile.lock',database=Database.new,co # @option options [Array] :ignore # The advisories to ignore. # + # @option options [Array] :exclude + # The gem names to exclude from scanning. + # # @yield [result] # The given block will be passed the results of the scan. # @@ -134,6 +137,9 @@ def report(options={}) # @option options [Array] :ignore # The advisories to ignore. # + # @option options [Array] :exclude + # The gem names to exclude from scanning. + # # @yield [result] # The given block will be passed the results of the scan. # @@ -202,6 +208,9 @@ def scan_sources(options={}) # @option options [Array] :ignore # The advisories to ignore. # + # @option options [Array] :exclude + # The gem names to exclude from scanning. + # # @yield [result] # The given block will be passed the results of the scan. # @@ -224,7 +233,15 @@ def scan_specs(options={}) config.ignore end + exclude = if options[:exclude] + Set.new(options[:exclude]) + else + config.exclude + end + @lockfile.specs.each do |gem| + next if exclude.include?(gem.name) + @database.check_gem(gem) do |advisory| is_ignored = ignore.intersect?(advisory.identifiers.to_set) next if is_ignored diff --git a/spec/bundle/unpatched_gems_with_exclude_configuration/.bundler-audit.yml b/spec/bundle/unpatched_gems_with_exclude_configuration/.bundler-audit.yml new file mode 100644 index 00000000..9e686925 --- /dev/null +++ b/spec/bundle/unpatched_gems_with_exclude_configuration/.bundler-audit.yml @@ -0,0 +1,3 @@ +--- +exclude: +- activerecord diff --git a/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile b/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile new file mode 100644 index 00000000..40f61778 --- /dev/null +++ b/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'activerecord', '3.2.10' diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 0923b45d..0d36afe4 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -53,6 +53,30 @@ end end end + + context "when exclude is not an array" do + let(:path) { File.join(fixtures_dir,'bad','exclude_is_not_an_array.yml') } + + it 'raises a validation error' do + expect { subject }.to raise_error(described_class::InvalidConfigurationError) + end + end + + context 'when exclude is an array' do + context 'when exclude only contains strings' do + let(:path) { File.join(fixtures_dir,'valid_with_exclude.yml') } + + it { should be_a(described_class) } + end + + describe "when exclude contains non-strings" do + let(:path) { File.join(fixtures_dir,'bad','exclude_contains_a_non_string.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error(described_class::InvalidConfigurationError) + end + end + end end end @@ -62,6 +86,11 @@ expect(subject.ignore).to be_kind_of(Set) expect(subject.ignore).to be_empty end + + it "must set @exclude to an empty Set" do + expect(subject.exclude).to be_kind_of(Set) + expect(subject.exclude).to be_empty + end end context "when given :ignore" do @@ -74,5 +103,16 @@ expect(subject.ignore).to be == Set.new(advisory_ids) end end + + context "when given :exclude" do + let(:gem_names) { %w[activestorage actiontext] } + + subject { described_class.new(exclude: gem_names) } + + it "must initialize @exclude to contain :exclude" do + expect(subject.exclude).to be_kind_of(Set) + expect(subject.exclude).to be == Set.new(gem_names) + end + end end end diff --git a/spec/fixtures/config/bad/exclude_contains_a_non_string.yml b/spec/fixtures/config/bad/exclude_contains_a_non_string.yml new file mode 100644 index 00000000..3782bc25 --- /dev/null +++ b/spec/fixtures/config/bad/exclude_contains_a_non_string.yml @@ -0,0 +1,4 @@ +--- +exclude: + - activestorage + - hello: world diff --git a/spec/fixtures/config/bad/exclude_is_not_an_array.yml b/spec/fixtures/config/bad/exclude_is_not_an_array.yml new file mode 100644 index 00000000..a38639c0 --- /dev/null +++ b/spec/fixtures/config/bad/exclude_is_not_an_array.yml @@ -0,0 +1,3 @@ +--- +exclude: + foo: bar diff --git a/spec/fixtures/config/valid_with_exclude.yml b/spec/fixtures/config/valid_with_exclude.yml new file mode 100644 index 00000000..16e06f00 --- /dev/null +++ b/spec/fixtures/config/valid_with_exclude.yml @@ -0,0 +1,7 @@ +--- +ignore: + - CVE-123 + - CVE-456 +exclude: + - activestorage + - actiontext diff --git a/spec/scanner_spec.rb b/spec/scanner_spec.rb index 66f7d6f7..929f48c5 100644 --- a/spec/scanner_spec.rb +++ b/spec/scanner_spec.rb @@ -169,6 +169,16 @@ expect(ids).not_to include('CVE-2013-0156') end end + + context "when the :exclude option is given" do + subject { super().scan(exclude: ['activerecord']) } + + it "should not include results for the excluded gem" do + gem_names = subject.map { |result| result.gem.name } + + expect(gem_names).not_to include('activerecord') + end + end end context "when auditing a bundle with insecure sources" do @@ -192,6 +202,20 @@ end end + context "when the exclude option is configured in .bundler-audit.yml" do + let(:bundle) { 'unpatched_gems_with_exclude_configuration' } + let(:directory) { File.join('spec','bundle',bundle) } + let(:scanner) { described_class.new(directory) } + + subject { scanner.scan } + + it "should not include results for the excluded gem" do + gem_names = subject.map { |result| result.gem.name } + + expect(gem_names).not_to include('activerecord') + end + end + context "when the ignore option is configured in .bundler-audit.yml" do let(:bundle) { 'unpatched_gems_with_dot_configuration' } let(:directory) { File.join('spec','bundle',bundle) } From ab5b9b82f75b82febe5b57e74f21ca19bd683f94 Mon Sep 17 00:00:00 2001 From: Matt Larraz Date: Tue, 16 Jun 2026 10:14:50 -0400 Subject: [PATCH 2/2] Add missing Gemfile.lock for exclude fixture The new spec/bundle/unpatched_gems_with_exclude_configuration fixture was missing its Gemfile.lock, causing the scanner spec to raise Bundler::GemfileLockNotFound in CI across all Ruby versions. The lockfile is matched by .gitignore (Gemfile.lock is ignored globally), which is why a plain `git add` silently skipped it. Force-added to match the pattern used for every other fixture under spec/bundle/. Co-Authored-By: Claude Opus 4.7 --- .../Gemfile.lock | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile.lock diff --git a/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile.lock b/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile.lock new file mode 100644 index 00000000..81e6b636 --- /dev/null +++ b/spec/bundle/unpatched_gems_with_exclude_configuration/Gemfile.lock @@ -0,0 +1,31 @@ +GEM + remote: https://rubygems.org/ + specs: + activemodel (3.2.10) + activesupport (= 3.2.10) + builder (~> 3.0.0) + activerecord (3.2.10) + activemodel (= 3.2.10) + activesupport (= 3.2.10) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activesupport (3.2.10) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.3) + builder (3.0.4) + concurrent-ruby (1.1.7) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + multi_json (1.15.0) + tzinfo (0.3.58) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + activerecord (= 3.2.10) + +BUNDLED WITH + 2.2.0