diff --git a/ChangeLog.md b/ChangeLog.md index b8287b8b..96525968 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,3 +1,7 @@ +### Unreleased + +* Added support for expiring advisory ignores in `.bundler-audit.yml`. + ### 0.9.3 / 2025-11-28 * Officially support Ruby 3.4, 3.5, and 4.0. diff --git a/README.md b/README.md index ac6d4fd7..9dae1388 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,14 @@ bundler-audit also supports a per-project configuration file: --- ignore: - CVE-YYYY-XXXX - - ... + - id: CVE-YYYY-ZZZZ + until: 2026-08-31 ``` -* `ignore:` \[Array\\] - A list of advisory IDs to ignore. +* `ignore:` \[Array\\] - A list of advisory IDs to ignore. + String entries are ignored indefinitely. A timed entry must contain an `id` + and an ISO 8601 `until` date (`YYYY-MM-DD`); the advisory is ignored through + that date and reported again the following day. You can provide a path to a config file using the `--config` flag: diff --git a/lib/bundler/audit/configuration.rb b/lib/bundler/audit/configuration.rb index a7fabb66..860d5e37 100644 --- a/lib/bundler/audit/configuration.rb +++ b/lib/bundler/audit/configuration.rb @@ -16,6 +16,7 @@ # require 'yaml' +require 'date' require 'set' module Bundler @@ -72,11 +73,7 @@ def self.load(file_path) raise(InvalidConfigurationError,"'ignore' key found in config file, but is not an Array") end - unless value.children.all? { |node| node.is_a?(YAML::Nodes::Scalar) } - raise(InvalidConfigurationError,"'ignore' array in config file contains a non-String") - end - - config[:ignore] = value.children.map(&:value) + config[:ignore] = value.children.map { |node| parse_ignore(node) } end end @@ -84,11 +81,71 @@ def self.load(file_path) end # - # The list of advisory IDs to ignore. + # Parses and validates an entry in the `ignore` array. + # + # @param [YAML::Nodes::Node] node + # + # @return [String, Hash] + # + # @api private + # + def self.parse_ignore(node) + return node.value if node.is_a?(YAML::Nodes::Scalar) + + unless node.is_a?(YAML::Nodes::Mapping) + raise(InvalidConfigurationError,"'ignore' array contains an invalid entry") + end + + entry = {} + + node.children.each_slice(2) do |key,value| + unless key.is_a?(YAML::Nodes::Scalar) && value.is_a?(YAML::Nodes::Scalar) + raise(InvalidConfigurationError,"timed 'ignore' entries must contain String values") + end + + case key.value + when 'id' + entry[:id] = value.value + when 'until' + entry[:until] = value.value + else + raise(InvalidConfigurationError,"unknown key #{key.value.inspect} in timed 'ignore' entry") + end + end + + unless entry[:id] && entry[:until] + raise(InvalidConfigurationError,"timed 'ignore' entries require both 'id' and 'until'") + end + + unless entry[:until] =~ /\A\d{4}-\d{2}-\d{2}\z/ + raise(InvalidConfigurationError,"'until' in timed 'ignore' entry must be an ISO 8601 date (YYYY-MM-DD)") + end + + begin + entry[:until] = Date.iso8601(entry[:until]) + rescue ArgumentError + raise(InvalidConfigurationError,"'until' in timed 'ignore' entry must be a valid date") + end + + entry + end + private_class_method :parse_ignore + + # + # The set of advisory IDs which are currently ignored. # # @return [Set] # - attr_reader :ignore + def ignore + ignored = @ignore.dup + today = Date.today + + @timed_ignores.each do |id,ignore_until| + ignored << id if ignore_until >= today + end + + ignored + end # # Initializes the configuration. @@ -96,11 +153,21 @@ def self.load(file_path) # @param [Hash] config # The configuration hash. # - # @option config [Array] :ignore - # The list of advisory IDs to ignore. + # @option config [Array] :ignore + # The list of advisory IDs to ignore, optionally through a specific + # date. # def initialize(config={}) - @ignore = Set.new(config[:ignore]) + @ignore = Set.new + @timed_ignores = {} + + Array(config[:ignore]).each do |entry| + if entry.is_a?(Hash) + @timed_ignores[entry[:id]] = entry[:until] + else + @ignore << entry + end + end end end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 0923b45d..46528ef6 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -19,6 +19,14 @@ let(:path) { File.join(fixtures_dir,'valid.yml') } it { should be_a(described_class) } + + it "includes timed ignores which have not expired" do + expect(subject.ignore).to include('CVE-789') + end + + it "excludes timed ignores which have expired" do + expect(subject.ignore).not_to include('CVE-EXPIRED') + end end context "validations" do @@ -52,6 +60,39 @@ expect { subject }.to raise_error(described_class::InvalidConfigurationError) end end + + describe "when a timed ignore is missing until" do + let(:path) { File.join(fixtures_dir,'bad','timed_ignore_missing_until.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error( + described_class::InvalidConfigurationError, + /require both 'id' and 'until'/ + ) + end + end + + describe "when a timed ignore has an invalid date" do + let(:path) { File.join(fixtures_dir,'bad','timed_ignore_invalid_date.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error( + described_class::InvalidConfigurationError, + /must be a valid date/ + ) + end + end + + describe "when a timed ignore has an unknown key" do + let(:path) { File.join(fixtures_dir,'bad','timed_ignore_unknown_key.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error( + described_class::InvalidConfigurationError, + /unknown key "expires"/ + ) + end + end end end end @@ -74,5 +115,24 @@ expect(subject.ignore).to be == Set.new(advisory_ids) end end + + context "when given timed ignores" do + subject do + described_class.new( + ignore: [ + {id: 'CVE-ACTIVE', until: Date.today}, + {id: 'CVE-EXPIRED', until: Date.today - 1} + ] + ) + end + + it "ignores an advisory through its until date" do + expect(subject.ignore).to include('CVE-ACTIVE') + end + + it "does not ignore an advisory after its until date" do + expect(subject.ignore).not_to include('CVE-EXPIRED') + end + end end end diff --git a/spec/fixtures/config/bad/timed_ignore_invalid_date.yml b/spec/fixtures/config/bad/timed_ignore_invalid_date.yml new file mode 100644 index 00000000..d48ab6d0 --- /dev/null +++ b/spec/fixtures/config/bad/timed_ignore_invalid_date.yml @@ -0,0 +1,4 @@ +--- +ignore: + - id: CVE-123 + until: 2026-02-30 diff --git a/spec/fixtures/config/bad/timed_ignore_missing_until.yml b/spec/fixtures/config/bad/timed_ignore_missing_until.yml new file mode 100644 index 00000000..6e8d17f4 --- /dev/null +++ b/spec/fixtures/config/bad/timed_ignore_missing_until.yml @@ -0,0 +1,3 @@ +--- +ignore: + - id: CVE-123 diff --git a/spec/fixtures/config/bad/timed_ignore_unknown_key.yml b/spec/fixtures/config/bad/timed_ignore_unknown_key.yml new file mode 100644 index 00000000..8c5e4ede --- /dev/null +++ b/spec/fixtures/config/bad/timed_ignore_unknown_key.yml @@ -0,0 +1,4 @@ +--- +ignore: + - id: CVE-123 + expires: 2026-12-31 diff --git a/spec/fixtures/config/valid.yml b/spec/fixtures/config/valid.yml index da26b960..1eca28a7 100644 --- a/spec/fixtures/config/valid.yml +++ b/spec/fixtures/config/valid.yml @@ -2,3 +2,7 @@ ignore: - CVE-123 - CVE-456 + - id: CVE-789 + until: 2999-12-31 + - id: CVE-EXPIRED + until: 2000-01-01 diff --git a/spec/scanner_spec.rb b/spec/scanner_spec.rb index 66f7d6f7..25f7736d 100644 --- a/spec/scanner_spec.rb +++ b/spec/scanner_spec.rb @@ -169,6 +169,44 @@ expect(ids).not_to include('CVE-2013-0156') end end + + context "with a timed ignore in the configuration" do + let(:scanner) { described_class.new(directory) } + + subject { scanner.scan.to_a } + + before do + allow(scanner).to receive(:config).and_return(config) + end + + context "when the ignore is still active" do + let(:config) do + Configuration.new( + ignore: [{id: 'CVE-2013-0155', until: Date.today}] + ) + end + + it "ignores the advisory" do + ids = subject.flat_map { |result| result.advisory.identifiers } + + expect(ids).not_to include('CVE-2013-0155') + end + end + + context "when the ignore has expired" do + let(:config) do + Configuration.new( + ignore: [{id: 'CVE-2013-0155', until: Date.today - 1}] + ) + end + + it "reports the advisory again" do + ids = subject.flat_map { |result| result.advisory.identifiers } + + expect(ids).to include('CVE-2013-0155') + end + end + end end context "when auditing a bundle with insecure sources" do