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
6 changes: 4 additions & 2 deletions .github/PRE_RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
42 changes: 42 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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
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
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()
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -97,6 +101,7 @@ PLATFORMS

DEPENDENCIES
archunit!
bundler-audit (~> 0.9.3)
irb (~> 1.15)
kramdown (~> 2.5.2)
minitest (~> 5.25)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
27 changes: 27 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

104 changes: 104 additions & 0 deletions scripts/check_dependency_security.rb
Original file line number Diff line number Diff line change
@@ -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__
Loading
Loading