Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,17 @@ bundler-audit also supports a per-project configuration file:

```yaml
---
inherit_from:
- ../base.yml
ignore:
- CVE-YYYY-XXXX
- ...
```

* `ignore:` \[Array\<String\>\] - A list of advisory IDs to ignore.
* `inherit_from:` \[Array\<String\>\] - 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:

Expand Down
54 changes: 50 additions & 4 deletions lib/bundler/audit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] 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")
Expand All @@ -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
Expand All @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
inherit_from:
- foo: bar
2 changes: 2 additions & 0 deletions spec/fixtures/config/bad/inherit_from_cycle_a.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
inherit_from:
- ./inherit_from_cycle_b.yml
2 changes: 2 additions & 0 deletions spec/fixtures/config/bad/inherit_from_cycle_b.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
inherit_from:
- ./inherit_from_cycle_a.yml
1 change: 1 addition & 0 deletions spec/fixtures/config/bad/inherit_from_is_not_an_array.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
inherit_from: some_string
2 changes: 2 additions & 0 deletions spec/fixtures/config/bad/inherit_from_missing_target.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
inherit_from:
- ./does_not_exist.yml
3 changes: 3 additions & 0 deletions spec/fixtures/config/inherit_from/base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ignore:
- CVE-BASE-1
- CVE-BASE-2
4 changes: 4 additions & 0 deletions spec/fixtures/config/inherit_from/child.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
inherit_from:
- ./base.yml
ignore:
- CVE-CHILD-1
4 changes: 4 additions & 0 deletions spec/fixtures/config/inherit_from/grandchild.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
inherit_from:
- ./middle.yml
ignore:
- CVE-GRANDCHILD-1
4 changes: 4 additions & 0 deletions spec/fixtures/config/inherit_from/middle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
inherit_from:
- ./base.yml
ignore:
- CVE-MIDDLE-1