diff --git a/ChangeLog.md b/ChangeLog.md index b8287b8b..04fab25e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,3 +1,8 @@ +### 0.9.4 / TBD + +* Added `inherit_from:` config option, which merges additional config files + into the current config. + ### 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..5768eb07 100644 --- a/README.md +++ b/README.md @@ -176,12 +176,17 @@ bundler-audit also supports a per-project configuration file: ```yaml --- +inherit_from: + - ../base.yml ignore: - CVE-YYYY-XXXX - ... ``` * `ignore:` \[Array\\] - A list of advisory IDs to ignore. +* `inherit_from:` \[Array\\] - An optional list of other config + files to merge into this one. Relative paths are resolved from the + directory of the declaring config file. 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..71377b9b 100644 --- a/lib/bundler/audit/configuration.rb +++ b/lib/bundler/audit/configuration.rb @@ -51,9 +51,39 @@ class FileNotFound < StandardError # file passed. # def self.load(file_path) - raise(FileNotFound,"Configuration file '#{file_path}' does not exist") unless File.exist?(file_path) + load_with_chain(file_path,[]) + end - doc = YAML.parse(File.new(file_path)) + # + # Internal loader that tracks the ancestor chain of absolute paths in + # order to detect cycles across `inherit_from:` links. + # + # @param [String] file_path + # Path to the YAML file holding the configuration. + # + # @param [Array] ancestors + # Absolute paths of configuration files already being loaded further up + # the recursion. Used only to detect cycles. + # + # @raise [FileNotFound] + # @raise [InvalidConfigurationError] + # + # @return [Configuration] + # + # @api private + # + def self.load_with_chain(file_path,ancestors) + absolute_path = File.expand_path(file_path) + + if ancestors.include?(absolute_path) + raise(InvalidConfigurationError,"Cycle detected in 'inherit_from': #{(ancestors + [absolute_path]).join(' -> ')}") + end + + unless File.exist?(absolute_path) + raise(FileNotFound,"Configuration file '#{file_path}' does not exist") + end + + doc = YAML.parse(File.new(absolute_path)) unless doc.kind_of?(YAML::Nodes::Document) raise(InvalidConfigurationError,"Configuration found in '#{file_path}' is not YAML") @@ -63,7 +93,7 @@ def self.load(file_path) raise(InvalidConfigurationError,"Configuration found in '#{file_path}' is not a Hash") end - config = {} + config = { ignore: [] } doc.root.children.each_slice(2) do |key,value| case key.value @@ -76,12 +106,28 @@ def self.load(file_path) raise(InvalidConfigurationError,"'ignore' array in config file contains a non-String") end - config[:ignore] = value.children.map(&:value) + config[:ignore].concat(value.children.map(&:value)) + when 'inherit_from' + unless value.is_a?(YAML::Nodes::Sequence) + raise(InvalidConfigurationError,"'inherit_from' key found in config file, but is not an Array") + end + + unless value.children.all? { |node| node.is_a?(YAML::Nodes::Scalar) } + raise(InvalidConfigurationError,"'inherit_from' array in config file contains a non-String") + end + + base_dir = File.dirname(absolute_path) + value.children.each do |child_node| + inherited_path = File.expand_path(child_node.value,base_dir) + parent_config = load_with_chain(inherited_path,ancestors + [absolute_path]) + config[:ignore].concat(parent_config.ignore.to_a) + end end end new(config) end + private_class_method :load_with_chain # # The list of advisory IDs to ignore. diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 0923b45d..541218b5 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -53,6 +53,70 @@ end end end + + context "when inherit_from is not an Array" do + let(:path) { File.join(fixtures_dir,'bad','inherit_from_is_not_an_array.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error(described_class::InvalidConfigurationError, + /'inherit_from' key found in config file, but is not an Array/) + end + end + + context "when inherit_from array contains a non-String" do + let(:path) { File.join(fixtures_dir,'bad','inherit_from_contains_a_non_string.yml') } + + it "raises a validation error" do + expect { subject }.to raise_error(described_class::InvalidConfigurationError, + /'inherit_from' array in config file contains a non-String/) + end + end + + context "when an inherited file does not exist" do + let(:path) { File.join(fixtures_dir,'bad','inherit_from_missing_target.yml') } + + it "raises a FileNotFound error" do + expect { subject }.to raise_error(described_class::FileNotFound, + /Configuration file '.*does_not_exist\.yml' does not exist/) + end + end + + context "when the inherit_from chain forms a cycle" do + let(:path) { File.join(fixtures_dir,'bad','inherit_from_cycle_a.yml') } + + it "raises a validation error identifying the cycle" do + expect { subject }.to raise_error(described_class::InvalidConfigurationError, + /Cycle detected in 'inherit_from'/) + end + end + end + + context "when a file inherits another" do + let(:path) { File.join(fixtures_dir,'inherit_from','child.yml') } + + it { should be_a(described_class) } + + it "must merge the parent's ignore list with the child's" do + expect(subject.ignore).to eq(Set.new(%w[CVE-BASE-1 CVE-BASE-2 CVE-CHILD-1])) + end + end + + context "when the inherit_from chain is 3 deep" do + let(:path) { File.join(fixtures_dir,'inherit_from','grandchild.yml') } + + it "must transitively merge the ignore lists" do + expect(subject.ignore).to eq(Set.new(%w[CVE-BASE-1 CVE-BASE-2 CVE-MIDDLE-1 CVE-GRANDCHILD-1])) + end + end + + context "when the inherit_from path is relative" do + let(:path) { File.join(fixtures_dir,'inherit_from','child.yml') } + + it "must resolve inherited paths relative to the including file" do + Dir.chdir('/tmp') do + expect(subject.ignore).to eq(Set.new(%w[CVE-BASE-1 CVE-BASE-2 CVE-CHILD-1])) + end + end end end diff --git a/spec/fixtures/config/bad/inherit_from_contains_a_non_string.yml b/spec/fixtures/config/bad/inherit_from_contains_a_non_string.yml new file mode 100644 index 00000000..101cb382 --- /dev/null +++ b/spec/fixtures/config/bad/inherit_from_contains_a_non_string.yml @@ -0,0 +1,2 @@ +inherit_from: + - foo: bar diff --git a/spec/fixtures/config/bad/inherit_from_cycle_a.yml b/spec/fixtures/config/bad/inherit_from_cycle_a.yml new file mode 100644 index 00000000..32505fee --- /dev/null +++ b/spec/fixtures/config/bad/inherit_from_cycle_a.yml @@ -0,0 +1,2 @@ +inherit_from: + - ./inherit_from_cycle_b.yml diff --git a/spec/fixtures/config/bad/inherit_from_cycle_b.yml b/spec/fixtures/config/bad/inherit_from_cycle_b.yml new file mode 100644 index 00000000..2102e486 --- /dev/null +++ b/spec/fixtures/config/bad/inherit_from_cycle_b.yml @@ -0,0 +1,2 @@ +inherit_from: + - ./inherit_from_cycle_a.yml diff --git a/spec/fixtures/config/bad/inherit_from_is_not_an_array.yml b/spec/fixtures/config/bad/inherit_from_is_not_an_array.yml new file mode 100644 index 00000000..743f980a --- /dev/null +++ b/spec/fixtures/config/bad/inherit_from_is_not_an_array.yml @@ -0,0 +1 @@ +inherit_from: some_string diff --git a/spec/fixtures/config/bad/inherit_from_missing_target.yml b/spec/fixtures/config/bad/inherit_from_missing_target.yml new file mode 100644 index 00000000..55496d5a --- /dev/null +++ b/spec/fixtures/config/bad/inherit_from_missing_target.yml @@ -0,0 +1,2 @@ +inherit_from: + - ./does_not_exist.yml diff --git a/spec/fixtures/config/inherit_from/base.yml b/spec/fixtures/config/inherit_from/base.yml new file mode 100644 index 00000000..27710365 --- /dev/null +++ b/spec/fixtures/config/inherit_from/base.yml @@ -0,0 +1,3 @@ +ignore: + - CVE-BASE-1 + - CVE-BASE-2 diff --git a/spec/fixtures/config/inherit_from/child.yml b/spec/fixtures/config/inherit_from/child.yml new file mode 100644 index 00000000..afb646a0 --- /dev/null +++ b/spec/fixtures/config/inherit_from/child.yml @@ -0,0 +1,4 @@ +inherit_from: + - ./base.yml +ignore: + - CVE-CHILD-1 diff --git a/spec/fixtures/config/inherit_from/grandchild.yml b/spec/fixtures/config/inherit_from/grandchild.yml new file mode 100644 index 00000000..e9047804 --- /dev/null +++ b/spec/fixtures/config/inherit_from/grandchild.yml @@ -0,0 +1,4 @@ +inherit_from: + - ./middle.yml +ignore: + - CVE-GRANDCHILD-1 diff --git a/spec/fixtures/config/inherit_from/middle.yml b/spec/fixtures/config/inherit_from/middle.yml new file mode 100644 index 00000000..c3df6293 --- /dev/null +++ b/spec/fixtures/config/inherit_from/middle.yml @@ -0,0 +1,4 @@ +inherit_from: + - ./base.yml +ignore: + - CVE-MIDDLE-1