From 28e152dec26fd6a95fa491995b189a7669b66033 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Thu, 22 Oct 2026 10:15:21 +0200 Subject: [PATCH 1/2] Add fail-closed locked dependency security scanning --- .github/PRE_RELEASE_CHECKLIST.md | 6 +- .github/workflows/security.yml | 42 +++++++++ CHANGELOG.md | 3 + CONTRIBUTING.md | 4 + Gemfile | 1 + Gemfile.lock | 7 ++ Rakefile | 5 ++ SECURITY.md | 27 ++++++ scripts/check_dependency_security.rb | 104 +++++++++++++++++++++ spec/ci/dependency_security_spec.rb | 130 +++++++++++++++++++++++++++ 10 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 scripts/check_dependency_security.rb create mode 100644 spec/ci/dependency_security_spec.rb diff --git a/.github/PRE_RELEASE_CHECKLIST.md b/.github/PRE_RELEASE_CHECKLIST.md index 19cfe74..133bfd5 100644 --- a/.github/PRE_RELEASE_CHECKLIST.md +++ b/.github/PRE_RELEASE_CHECKLIST.md @@ -17,6 +17,8 @@ PR stack. They are not considered shipped until those changes reach `main` and a instructions, and replace prototype/pre-release wording with an accurate stable API contract. - Run `bundle exec ruby scripts/check_release_metadata.rb --release`; development checks alone intentionally do not prove publication readiness. +- Run `bundle exec rake security` with a freshly updated advisory database and review the JSON + evidence. Resolve actionable findings; do not add unexplained advisory ignores to pass CI. - Publish only after maintainer approval via the trusted-publisher workflow. Install the published version in a clean project and rerun a passing/failing architecture example before announcing it. @@ -39,5 +41,5 @@ an actively changing or hostile filesystem. Dense graphs can have exponentially - Establish repeated real-world performance baselines on pinned Ruby repositories before setting tighter thresholds. Report cold analysis, warm cache reuse, graph contents, and memory separately. - Add an opt-in, non-blocking real-world canary once the repository set and update policy are agreed. -- Consider dependency vulnerability scanning and mutation testing as separate, measured additions; - do not confuse high line coverage with proof of semantic correctness. +- Dependency vulnerability scanning is available as a separate task/workflow. Consider mutation + testing as another measured addition; high line coverage is not proof of semantic correctness. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..cd9da27 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,42 @@ +name: Dependency security + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + schedule: + - cron: '17 7 * * 1' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: Locked dependency vulnerability scan + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + BUNDLER_AUDIT_DB: ${{ runner.temp }}/ruby-advisory-db + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Update advisories, verify detection, and scan locked dependencies + run: bundle exec ruby scripts/check_dependency_security.rb + - name: Preserve dated scan evidence, including failures + if: always() + uses: actions/upload-artifact@v7 + with: + name: dependency-audit-${{ github.run_id }} + path: pkg/dependency-audit.json + if-no-files-found: warn + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af2301..5cc8edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.0.2 (unreleased) +- Add development-only locked dependency scanning with advisory refresh, fail-closed health checks, + Windows path normalization, and retained CI reports. + - Avoid stack overflows in strongly-connected-component detection, elementary-cycle traversal, and blocked-vertex propagation on deep dependency graphs. - Skip symlinked source entries and enforce canonical project boundaries for direct file metadata. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4490a93..4592879 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,10 @@ The coverage suite enforces at least 98% line and 90% branch coverage. The docum the complete site, checks the public API guide, rejects unrendered Markdown, and validates internal links. +Run `bundle exec rake security` separately to refresh advisories and scan the locked development +and runtime dependencies. See [SECURITY.md](SECURITY.md) for report locations and offline usage. +The normal test suite uses a synthetic local advisory and never fetches vulnerability data. + For a stable release, follow the [pre-release checklist](.github/PRE_RELEASE_CHECKLIST.md) and [publishing guide](.github/RELEASING.md). Development allows an unreleased changelog heading and installation instructions for the published gem. The separate `--release` metadata check requires diff --git a/Gemfile b/Gemfile index 9690491..757942d 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'https://rubygems.org' gemspec +gem 'bundler-audit', '~> 0.9.3', require: false gem 'irb', '~> 1.15' gem 'kramdown', '~> 2.5.2' gem 'minitest', '~> 5.25' diff --git a/Gemfile.lock b/Gemfile.lock index 96a35f4..ba915e0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,6 +10,9 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) csv (3.3.6) diff-lcs (1.6.2) docile (1.4.1) @@ -85,6 +88,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + thor (1.5.0) tsort (0.2.0) unicode-display_width (3.3.0) unicode-emoji (~> 4.3) @@ -97,6 +101,7 @@ PLATFORMS DEPENDENCIES archunit! + bundler-audit (~> 0.9.3) irb (~> 1.15) kramdown (~> 2.5.2) minitest (~> 5.25) @@ -109,6 +114,7 @@ DEPENDENCIES CHECKSUMS archunit (0.0.2) ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e @@ -145,6 +151,7 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613 unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce diff --git a/Rakefile b/Rakefile index 86c7875..be693ad 100644 --- a/Rakefile +++ b/Rakefile @@ -13,6 +13,11 @@ task :release_metadata do ruby 'scripts/check_release_metadata.rb' end +desc 'Refresh advisories and scan locked development/runtime dependencies' +task :security do + ruby 'scripts/check_dependency_security.rb' +end + desc 'Build the GitHub Pages documentation site' task :docs do sh 'yard doc' diff --git a/SECURITY.md b/SECURITY.md index 10df734..f88ef71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,3 +28,30 @@ Run architecture analysis with the same filesystem permissions you would grant a review exported reports before sharing them because paths and dependency names may reveal project structure. +## Locked dependency scanning + +`bundle exec rake security` refreshes the public [Ruby advisory database](https://github.com/rubysec/ruby-advisory-db) +and scans this checkout's `Gemfile.lock` with [bundler-audit](https://github.com/rubysec/bundler-audit). +The scanner is a development dependency, not a runtime dependency of the published gem. + +The separate dependency-security workflow runs on pull requests, pushes to `main`, manual dispatch, +and Mondays at 07:17 UTC after this workflow reaches the default branch. It has read-only repository +permissions, a ten-minute limit, and retains JSON evidence for 14 days even on failure. No automatic +advisory exceptions or dependency upgrades are applied. + +The wrapper normalizes Windows database paths, rejects empty databases, and verifies detection of +a known-vulnerable synthetic lockfile before accepting a clean result. No vulnerable gem is installed +or executed for that health check. Failed database updates fail the scan rather than silently using +stale data. Reports include the advisory count and database commit. + +For an explicitly offline repeat using an already downloaded database: + +~~~bash +bundle exec ruby scripts/check_dependency_security.rb --no-update +~~~ + +The report is saved to `pkg/dependency-audit.json`; `--database PATH` and `--output PATH` override +locations. A clean scan means no matches in that database snapshot, not a security certification. +It does not audit Ruby itself, all versions allowed by the gemspec, or an application's unrelated +dependency lockfile. Default tests remain offline; only the separate security task refreshes data. + diff --git a/scripts/check_dependency_security.rb b/scripts/check_dependency_security.rb new file mode 100644 index 0000000..cfbe5a8 --- /dev/null +++ b/scripts/check_dependency_security.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'bundler/audit/scanner' +require 'fileutils' +require 'json' +require 'optparse' +require 'tmpdir' + +# Development tooling only: no scanner dependency is added to the published gem. +module DependencySecurity + # Fail-closed adapter around the audited lockfile scanner and its advisory database. + class Checker + def initialize(root:, database_path:) + @root = File.expand_path(root) + # Database advisory globs interpret Windows backslashes as escapes. + @database_path = File.expand_path(database_path).tr('\\', '/') + end + + def run(update: true) + database = load_database(update) + # rubocop:disable-next Style/ZeroLengthPredicate -- Database does not implement empty?. + raise 'The advisory database is empty; refusing a false-clean result' if database.size.zero? + + verify_detection(database) + report = Bundler::Audit::Scanner.new(@root, 'Gemfile.lock', database).report + report.to_h.merge( + database_commit: database.commit_id, advisory_count: database.size, + vulnerable_fixture_detected: true + ) + end + + private + + def load_database(update) + unless Bundler::Audit::Database.exists?(@database_path) + raise 'No advisory database is available for an offline scan' unless update + + return Bundler::Audit::Database.download(path: @database_path, quiet: true) + end + database = Bundler::Audit::Database.new(@database_path) + raise 'Advisory database update did not complete' if update && !database.update!(quiet: true) + + database + end + + def verify_detection(database) + Dir.mktmpdir('archunit-audit-health') do |root| + File.write(File.join(root, 'Gemfile.lock'), vulnerable_lockfile) + report = Bundler::Audit::Scanner.new(root, 'Gemfile.lock', database).report + unless report.unpatched_gems.any? { |result| result.gem.name == 'rack' } + raise 'The scanner missed a known-vulnerable Rack fixture; refusing a false-clean result' + end + end + end + + def vulnerable_lockfile + "GEM\n remote: https://rubygems.org/\n specs:\n rack (1.0.0)\n\n" \ + "PLATFORMS\n ruby\n\nDEPENDENCIES\n rack (= 1.0.0)\n" + end + end + + module_function + + def execute(arguments) + root = File.expand_path('..', __dir__) + options = parse_options(arguments, root) + result = Checker.new(root:, database_path: options[:database]).run(update: options[:update]) + complete_scan(options, result) + rescue StandardError => e + failed_scan(options, e) + end + + def complete_scan(options, result) + write_report(options[:output], result) + puts "Checked #{result[:advisory_count]} advisories; #{result[:results].length} findings." + result[:results].empty? ? 0 : 1 + end + + def failed_scan(options, error) + write_report(options[:output], error: "#{error.class}: #{error.message}") if options + warn error.message + 1 + end + + def parse_options(arguments, root) + options = { database: ENV.fetch('BUNDLER_AUDIT_DB', File.join(root, 'pkg/ruby-advisory-db')), + output: File.join(root, 'pkg/dependency-audit.json'), update: true } + OptionParser.new do |parser| + parser.on('--database PATH') { |value| options[:database] = value } + parser.on('--output PATH') { |value| options[:output] = value } + parser.on('--no-update') { options[:update] = false } + end.parse!(arguments) + raise ArgumentError, 'Unexpected positional arguments' unless arguments.empty? + + options + end + + def write_report(path, payload) + FileUtils.mkdir_p(File.dirname(File.expand_path(path))) + File.write(path, JSON.pretty_generate(payload)) + end +end + +exit DependencySecurity.execute(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/spec/ci/dependency_security_spec.rb b/spec/ci/dependency_security_spec.rb new file mode 100644 index 0000000..7a00ed4 --- /dev/null +++ b/spec/ci/dependency_security_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'yaml' +require_relative '../../scripts/check_dependency_security' + +RSpec.describe DependencySecurity::Checker do + around do |example| + Dir.mktmpdir('archunit-security-spec') do |directory| + @root = File.join(directory, 'project') + @database = File.join(directory, 'database') + FileUtils.mkdir_p([@root, File.join(@database, 'gems/rack')]) + File.write(File.join(@database, 'gems/rack/CVE-2099-0001.yml'), { + 'gem' => 'rack', 'cve' => '2099-0001', 'title' => 'Synthetic test advisory', + 'url' => 'https://example.invalid/test-advisory', 'date' => '2026-01-01', + 'description' => 'Test data, not a real vulnerability.', 'patched_versions' => ['>= 1.0.1'] + }.to_yaml) + write_lock('1.0.1') + example.run + end + end + + def write_lock(version) + File.write(File.join(@root, 'Gemfile.lock'), <<~LOCK) + GEM + remote: https://rubygems.org/ + specs: + rack (#{version}) + + PLATFORMS + ruby + + DEPENDENCIES + rack (= #{version}) + LOCK + end + + def checker(path = @database) + described_class.new(root: @root, database_path: path) + end + + it 'reports a clean lockfile only after a known-vulnerable fixture was detected' do + report = checker.run(update: false) + expect(report).to include(results: [], advisory_count: 1, vulnerable_fixture_detected: true) + end + + it 'reports vulnerable locked versions without installing or loading them' do + write_lock('1.0.0') + expect(checker.run(update: false).fetch(:results)).not_to be_empty + expect(Gem.loaded_specs.fetch('rack', nil)).to be_nil + end + + it 'normalizes database separators on Windows before advisory globbing' do + skip 'Windows path syntax' unless Gem.win_platform? + + expect(checker(@database.tr('/', '\\')).run(update: false).fetch(:advisory_count)).to eq(1) + end + + it 'rejects an empty database rather than reporting clean' do + File.delete(File.join(@database, 'gems/rack/CVE-2099-0001.yml')) + expect { checker.run(update: false) }.to raise_error(/database is empty/) + end + + it 'rejects a database that cannot detect the known-vulnerable health fixture' do + FileUtils.mkdir_p(File.join(@database, 'gems/other')) + FileUtils.move(File.join(@database, 'gems/rack/CVE-2099-0001.yml'), + File.join(@database, 'gems/other/CVE-2099-0001.yml')) + expect { checker.run(update: false) }.to raise_error(/missed a known-vulnerable/) + end + + it 'never downloads a database during an explicitly offline scan' do + expect(Bundler::Audit::Database).not_to receive(:download) + expect { checker(File.join(@database, 'missing')).run(update: false) } + .to raise_error(/offline scan/) + end + + it 'fails closed when an update is skipped or fails' do + allow_any_instance_of(Bundler::Audit::Database).to receive(:update!).and_return(nil) + expect { checker.run }.to raise_error(/update did not complete/) + allow_any_instance_of(Bundler::Audit::Database).to receive(:update!).and_raise( + Bundler::Audit::Database::UpdateFailed, 'network failed' + ) + expect { checker.run }.to raise_error(/network failed/) + end + + it 'keeps security checks read-only, bounded, scheduled, and preserves failure evidence' do + workflow = YAML.safe_load_file( + File.expand_path('../../.github/workflows/security.yml', __dir__) + ) + expect(workflow.fetch('permissions')).to eq('contents' => 'read') + job = workflow.fetch('jobs').fetch('audit') + expect(job.fetch('timeout-minutes')).to eq(10) + expect(job.fetch('steps')).to include(include('if' => 'always()')) + triggers = workflow['on'] || workflow[true] + expect(triggers).to include('pull_request', 'workflow_dispatch', 'schedule') + end +end + +RSpec.describe DependencySecurity do + around do |example| + Dir.mktmpdir('archunit-audit-evidence') do |root| + @output = File.join(root, 'report.json') + example.run + end + end + + [[], [{ 'type' => 'unpatched_gem' }]].each do |findings| + it "returns the correct exit code and persists #{findings.length} findings" do + report = { results: findings, advisory_count: 1 } + checker = instance_double(DependencySecurity::Checker, run: report) + allow(DependencySecurity::Checker).to receive(:new).and_return(checker) + status = nil + + expect { status = described_class.execute(['--no-update', '--output', @output]) } + .to output(/#{findings.length} findings/).to_stdout + expect(status).to eq(findings.empty? ? 0 : 1) + expect(JSON.parse(File.read(@output)).fetch('results')).to eq(findings) + end + end + + it 'fails and persists diagnostic evidence on scanner errors' do + allow(DependencySecurity::Checker).to receive(:new).and_raise('update failed') + status = nil + expect { status = described_class.execute(['--output', @output]) } + .to output(/update failed/).to_stderr + expect(status).to eq(1) + expect(JSON.parse(File.read(@output)).fetch('error')).to include('update failed') + end +end From 3d71731f489528cc2053830137d4c3db6bbe0486 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Thu, 22 Oct 2026 10:41:38 +0200 Subject: [PATCH 2/2] Scope runner-dependent security environment to its scan step --- .github/workflows/security.yml | 4 ++-- spec/ci/dependency_security_spec.rb | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index cd9da27..73a7c8f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -20,8 +20,6 @@ jobs: name: Locked dependency vulnerability scan runs-on: ubuntu-latest timeout-minutes: 10 - env: - BUNDLER_AUDIT_DB: ${{ runner.temp }}/ruby-advisory-db steps: - uses: actions/checkout@v7 with: @@ -31,6 +29,8 @@ jobs: ruby-version: '4.0' bundler-cache: true - name: Update advisories, verify detection, and scan locked dependencies + env: + BUNDLER_AUDIT_DB: ${{ runner.temp }}/ruby-advisory-db run: bundle exec ruby scripts/check_dependency_security.rb - name: Preserve dated scan evidence, including failures if: always() diff --git a/spec/ci/dependency_security_spec.rb b/spec/ci/dependency_security_spec.rb index 7a00ed4..a36e133 100644 --- a/spec/ci/dependency_security_spec.rb +++ b/spec/ci/dependency_security_spec.rb @@ -91,6 +91,11 @@ def checker(path = @database) expect(workflow.fetch('permissions')).to eq('contents' => 'read') job = workflow.fetch('jobs').fetch('audit') expect(job.fetch('timeout-minutes')).to eq(10) + expect(job).not_to have_key('env') + scan = job.fetch('steps').find do |step| + step['run'] == 'bundle exec ruby scripts/check_dependency_security.rb' + end + expect(scan.fetch('env')).to have_key('BUNDLER_AUDIT_DB') expect(job.fetch('steps')).to include(include('if' => 'always()')) triggers = workflow['on'] || workflow[true] expect(triggers).to include('pull_request', 'workflow_dispatch', 'schedule')