From 5de2868c481e27b87faefc83dfa9571609f6fb81 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 09:35:49 +0100 Subject: [PATCH 01/12] feat: surface gem license from RubyGems API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the per-version `licenses` field already present in the RubyGems versions payload we fetch — no extra request — and exposes it as a `License` column (terminal + markdown) and an additive `license` JSON field. Comma-joined for multi-license gems; nil/"-" for git/path sources with no RubyGems metadata. Read-only metadata only: license policy (allow/deny gating) stays the domain of license_finder. schema_version stays 1 (additive field). First of the v1.5 ecosystem-integration commits; unblocks the CycloneDX renderer, which maps this into component.licenses. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 ++++++ docs/schema.md | 1 + lib/helpers/markdown_helper.rb | 11 ++++++++-- lib/helpers/terminal_helper.rb | 9 +++++++- lib/helpers/version_helper.rb | 9 ++++++++ lib/still_active/workflow.rb | 1 + spec/still_active/markdown_helper_spec.rb | 7 +++++- spec/still_active/terminal_helper_spec.rb | 8 +++++++ spec/still_active/version_helper_spec.rb | 26 +++++++++++++++++++++++ spec/still_active/workflow_spec.rb | 1 + 10 files changed, 75 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d4a8..47ce979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.5.0] - Unreleased + +### Added + +- Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. + ## [1.4.2] - 2026-05-22 ### Fixed diff --git a/docs/schema.md b/docs/schema.md index bc5c563..40ab078 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -46,6 +46,7 @@ | `up_to_date` | bool \| absent | Present when `version_used` is known. | | `version_used_release_date` | string \| nil | ISO-8601 timestamp. | | `version_yanked` | bool \| absent | `true` if `version_used` has been yanked. | +| `license` | string \| nil | SPDX license identifier(s) for `version_used`, comma-joined when more than one. `nil` when unknown (e.g. git/path sources). | | `libyear` | float \| nil | Years between `version_used` and `latest_version`. | ### Vulnerability fields diff --git a/lib/helpers/markdown_helper.rb b/lib/helpers/markdown_helper.rb index 8437540..b396856 100644 --- a/lib/helpers/markdown_helper.rb +++ b/lib/helpers/markdown_helper.rb @@ -26,8 +26,8 @@ def ruby_line(ruby_info) end def markdown_table_header_line - "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \ - "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |" + "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |\n" \ + "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- | ------- |" end def markdown_table_body_line(gem_name:, data:) @@ -78,6 +78,7 @@ def markdown_table_body_line(gem_name:, data:) formatted_latest_pre_release || unsure, formatted_last_commit || unsure, format_libyear(data[:libyear]), + format_license(data[:license]), ] "| #{cells.join(" | ")} |" @@ -113,6 +114,12 @@ def format_libyear(value) "#{value}y" end + def format_license(license) + return "-" if license.nil? || license.empty? + + license + end + def format_vulns(data) count = data[:vulnerability_count] return StillActive.config.unsure_emoji if count.nil? diff --git a/lib/helpers/terminal_helper.rb b/lib/helpers/terminal_helper.rb index 58c9b37..8c83118 100644 --- a/lib/helpers/terminal_helper.rb +++ b/lib/helpers/terminal_helper.rb @@ -10,7 +10,7 @@ module StillActive module TerminalHelper extend self - HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"].freeze + HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "License"].freeze def render(result, ruby_info: nil) rows = result.keys.sort.map { |name| build_row(name, result[name]) } @@ -35,9 +35,16 @@ def build_row(name, data) format_activity(data), format_scorecard(data[:scorecard_score]), format_vulns(data), + format_license(data[:license]), ] end + def format_license(license) + return AnsiHelper.dim("-") if license.nil? || license.empty? + + license + end + def format_version(data) used = data[:version_used] latest = data[:latest_version] diff --git a/lib/helpers/version_helper.rb b/lib/helpers/version_helper.rb index d79ff7b..3d90084 100644 --- a/lib/helpers/version_helper.rb +++ b/lib/helpers/version_helper.rb @@ -38,6 +38,15 @@ def release_date(version_hash:) Time.parse(release_date) unless release_date.nil? end + # SPDX license identifier(s) from the RubyGems versions payload. + # Comma-joined when a gem declares more than one. nil when unknown. + def license(version_hash:) + licenses = version_hash&.dig("licenses") + return if licenses.nil? || licenses.empty? + + licenses.join(", ") + end + private def normalize_version(version) diff --git a/lib/still_active/workflow.rb b/lib/still_active/workflow.rb index f0fad23..1087a41 100644 --- a/lib/still_active/workflow.rb +++ b/lib/still_active/workflow.rb @@ -118,6 +118,7 @@ def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:) version_used_release_date: VersionHelper.release_date(version_hash: version_used), version_yanked: !vs.empty? && version_used.nil?, + license: VersionHelper.license(version_hash: version_used), libyear: LibyearHelper.gem_libyear( version_used_release_date: VersionHelper.release_date(version_hash: version_used), latest_version_release_date: VersionHelper.release_date(version_hash: last_release), diff --git a/spec/still_active/markdown_helper_spec.rb b/spec/still_active/markdown_helper_spec.rb index 14b07b7..52aa2e4 100644 --- a/spec/still_active/markdown_helper_spec.rb +++ b/spec/still_active/markdown_helper_spec.rb @@ -9,7 +9,7 @@ subject(:header) { described_class.markdown_table_header_line } it("includes all column names") do - ["activity", "OpenSSF", "vulns", "name"].each do |col| + ["activity", "OpenSSF", "vulns", "name", "license"].each do |col| expect(header).to(include(col)) end end @@ -37,6 +37,7 @@ last_commit_date: Time.new(2024, 7, 1), scorecard_score: 5.7, vulnerability_count: 0, + license: "MIT", } end @@ -57,6 +58,10 @@ expect(line).to(include("5.7/10")) end + it("includes the license") do + expect(line).to(include("MIT")) + end + it("includes success emoji for zero vulnerabilities") do expect(line).to(include("✅")) end diff --git a/spec/still_active/terminal_helper_spec.rb b/spec/still_active/terminal_helper_spec.rb index 17d3361..d432d62 100644 --- a/spec/still_active/terminal_helper_spec.rb +++ b/spec/still_active/terminal_helper_spec.rb @@ -18,6 +18,7 @@ vulnerability_count: 0, repository_url: "https://github.com/rails/rails", ruby_gems_url: "https://rubygems.org/gems/rails", + license: "MIT", }, "stale_gem" => { version_used: "1.0.0", @@ -36,6 +37,7 @@ repository_url: "https://github.com/example/stale", ruby_gems_url: "https://rubygems.org/gems/stale_gem", libyear: 2.5, + license: "GPL-3.0", }, } end @@ -72,6 +74,12 @@ expect(output).to(include("5.7/10")) end + it("shows the license column") do + expect(output).to(include("License")) + expect(output).to(include("MIT")) + expect(output).to(include("GPL-3.0")) + end + it("shows vulnerability count with severity") do expect(output).to(include("3 (critical)")) end diff --git a/spec/still_active/version_helper_spec.rb b/spec/still_active/version_helper_spec.rb index db1b1fe..ece036c 100644 --- a/spec/still_active/version_helper_spec.rb +++ b/spec/still_active/version_helper_spec.rb @@ -105,4 +105,30 @@ expect(described_class.release_date(version_hash: still_active_version)).to(eq(Time.parse("2021-11-07T13:07:51.346Z"))) end end + + describe("#license") do + it("returns nil for nil input") do + expect(described_class.license(version_hash: nil)).to(be_nil) + end + + it("returns the single SPDX identifier") do + expect(described_class.license(version_hash: { "licenses" => ["MIT"] })).to(eq("MIT")) + end + + it("joins multiple licenses with a comma") do + expect(described_class.license(version_hash: { "licenses" => ["MIT", "Apache-2.0"] })).to(eq("MIT, Apache-2.0")) + end + + it("returns nil when the licenses array is empty") do + expect(described_class.license(version_hash: { "licenses" => [] })).to(be_nil) + end + + it("returns nil when the licenses key is absent") do + expect(described_class.license(version_hash: { "number" => "1.0.0" })).to(be_nil) + end + + it("returns nil when licenses is null") do + expect(described_class.license(version_hash: { "licenses" => nil })).to(be_nil) + end + end end diff --git a/spec/still_active/workflow_spec.rb b/spec/still_active/workflow_spec.rb index 484fc54..7d1bdc9 100644 --- a/spec/still_active/workflow_spec.rb +++ b/spec/still_active/workflow_spec.rb @@ -204,6 +204,7 @@ up_to_date: false, scorecard_score: a_value > 0, vulnerability_count: an_instance_of(Integer), + license: "MIT", ), "nokogiri" => hash_including( version_used: "1.12.5", From 6b49e3d873b2edfd7ccfefb9b77e2ebdd54aaa25 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 10:03:37 +0100 Subject: [PATCH 02/12] feat: merge ruby-advisory-db advisories when bundler-audit is installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit still_active reads vulnerabilities from deps.dev; bundler-audit reads rubysec/ruby-advisory-db. The two diverge on Ruby-specific advisories the rubysec maintainers curate before they reach OSV. Users running both saw different counts with no explanation. When bundler-audit is installed with a current advisory checkout, we now read its advisories through its own Database loader (we're a consumer — no YAML parsing or range matching of our own) and merge them with deps.dev, deduplicating on shared identifiers. Each advisory carries a `source` (deps.dev / ruby-advisory-db / merged); deps.dev wins on CVSS/title/vector, ruby-advisory-db fills gaps. Opt-in by composition: no bundler-audit, no second source — falls back silently to deps.dev with a hint to run `bundle audit update`. A malformed advisory in the checkout is surfaced with a warning, not swallowed as []: a silent empty result there would hide a real vulnerability, the exact gap this feature closes. Verified against bundler-audit 0.9.3 (CVSS in #to_h; Database.new raises ArgumentError when the checkout is absent) and dogfooded against the real ruby-advisory-db. schema_version stays 1 (source is additive); SA003 unchanged. bundler-audit is a dev dependency only — detected at runtime, never a runtime dep. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + Gemfile.lock | 7 + docs/rules.md | 4 +- docs/schema.md | 3 +- lib/helpers/ruby_advisory_db.rb | 93 +++++++++++++ lib/helpers/vulnerability_helper.rb | 35 +++++ lib/still_active/deps_dev_client.rb | 1 + lib/still_active/workflow.rb | 24 +++- spec/still_active/deps_dev_client_spec.rb | 1 + spec/still_active/ruby_advisory_db_spec.rb | 130 ++++++++++++++++++ .../still_active/vulnerability_helper_spec.rb | 53 +++++++ spec/still_active/workflow_spec.rb | 41 ++++++ still_active.gemspec | 1 + 13 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 lib/helpers/ruby_advisory_db.rb create mode 100644 spec/still_active/ruby_advisory_db_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ce979..b1aac33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. ## [1.4.2] - 2026-05-22 diff --git a/Gemfile.lock b/Gemfile.lock index dac901f..d3f9270 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,9 @@ GEM metrics (~> 0.12) traces (~> 0.18) bigdecimal (4.1.2) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) code-scanning-rubocop (0.6.1) rubocop (~> 1.0) concurrent-ruby (1.3.6) @@ -143,6 +146,7 @@ GEM faraday (>= 0.17.3, < 3) simpleidn (0.2.3) stringio (3.2.0) + thor (1.5.0) traces (0.18.2) tsort (0.2.0) unicode-display_width (3.2.0) @@ -160,6 +164,7 @@ PLATFORMS ruby DEPENDENCIES + bundler-audit code-scanning-rubocop debug faker @@ -180,6 +185,7 @@ CHECKSUMS async (2.39.0) sha256=df18730073f2bbb45788077dfa20cb365ecc1b9453969f44de6796b5191a00aa bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 code-scanning-rubocop (0.6.1) sha256=f6036d4541307ab982b46b424b7577be3a78982a770a4d92307029a9f668cb2f concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab console (1.35.1) sha256=6d2bfdd0bc7e57830540a6c0ce3bc83fff471844db44be89a38aef9f95df296a @@ -239,6 +245,7 @@ CHECKSUMS simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 still_active (1.4.2) stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 diff --git a/docs/rules.md b/docs/rules.md index f9d7aac..ad4ac8c 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -41,9 +41,9 @@ When uploaded via `github/codeql-action/upload-sarif`, findings appear in the Gi ## SA003 — Vulnerable Gem {#sa003} -**Triggers when:** deps.dev / OSV reports one or more security advisories affecting the resolved version of the gem. +**Triggers when:** deps.dev / OSV reports one or more security advisories affecting the resolved version of the gem — and, when `bundler-audit` is installed with a current advisory checkout, also any advisories `rubysec/ruby-advisory-db` reports for that version. Results from the two sources are merged and deduplicated on shared identifiers (each advisory's `source` is recorded in the JSON output as `deps.dev`, `ruby-advisory-db`, or `merged`). -**Why it matters:** known CVEs against your pinned version are the most actionable signal in the catalog. One SARIF result is emitted per advisory so each can be tracked, dismissed, or remediated independently. +**Why it matters:** known CVEs against your pinned version are the most actionable signal in the catalog. One SARIF result is emitted per advisory so each can be tracked, dismissed, or remediated independently. The optional ruby-advisory-db source catches Ruby-specific advisories that the rubysec maintainers curate before they propagate to OSV/deps.dev. **SARIF level:** mapped from CVSS — `error` for ≥ 7.0, `warning` for 4.0–6.9, `note` below 4.0. **security-severity:** per-result, formatted CVSS3 (or CVSS2 fallback). **CWE:** [CWE-1104](https://cwe.mitre.org/data/definitions/1104.html) (use of unmaintained third-party components, as a default — advisory-specific CWEs may also apply). diff --git a/docs/schema.md b/docs/schema.md index 40ab078..25c6fe2 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -58,8 +58,9 @@ | `title` | string \| nil | Short title from deps.dev. | | `aliases` | array | Cross-referenced IDs. | | `cvss3_score` | float \| nil | CVSS v3 base score (0.0–10.0). | -| `cvss3_vector` | string \| nil | CVSS v3 vector string. | +| `cvss3_vector` | string \| nil | CVSS v3 vector string. (Always `nil` for `ruby-advisory-db`-only advisories — bundler-audit exposes no vector.) | | `cvss2_score` | float \| nil | CVSS v2 fallback for older advisories. | +| `source` | string | Which source reported the advisory: `"deps.dev"`, `"ruby-advisory-db"`, or `"merged"` (both). `ruby-advisory-db` entries appear only when `bundler-audit` is installed with a current advisory checkout. | ## Ruby fields diff --git a/lib/helpers/ruby_advisory_db.rb b/lib/helpers/ruby_advisory_db.rb new file mode 100644 index 0000000..f220743 --- /dev/null +++ b/lib/helpers/ruby_advisory_db.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module StillActive + # Optional second vulnerability source: rubysec/ruby-advisory-db, read through + # bundler-audit's own loader when the user has it installed. We are a consumer — + # no YAML parsing or version-range matching of our own. Advisories are mapped + # into the same shape as deps.dev results and merged by VulnerabilityHelper. + # + # Verified against bundler-audit 0.9.3: Advisory CVSS scores live in #to_h + # (:cvss_v3 / :cvss_v2), not in dedicated methods; Database.new raises + # ArgumentError when the ~/.local/share/ruby-advisory-db checkout is absent. + module RubyAdvisoryDb + extend self + + STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days + + # bundler-audit's Database#check_gem expects an object responding to + # #name and #version (a Gem::Version). + GemRef = Struct.new(:name, :version) + + # Returns a loaded bundler-audit Database, or nil when bundler-audit isn't + # installed or its advisory checkout is absent. Never raises — a missing + # second source just means we fall back to deps.dev only. + def load + require "bundler/audit" + require "bundler/audit/database" + database = Bundler::Audit::Database.new + warn_if_stale(database) + database + rescue LoadError + nil # bundler-audit not installed + rescue ArgumentError + warn("still_active: ruby-advisory-db not found — run `bundle audit update` to enable dual-source vulnerability data") + nil + end + + # Maps advisories the database reports for gem_name@version into our + # vulnerability shape. Returns [] when the database is unavailable or the + # version can't be parsed (e.g. a git sha). A malformed advisory in the + # checkout (a corrupt/partial `bundle audit update`) is surfaced, not + # swallowed — silently returning [] there would hide a missed vulnerability. + def advisories_for(database:, gem_name:, version:) + return [] if database.nil? + + parsed = parse_version(version) + return [] if parsed.nil? + + advisories = [] + database.check_gem(GemRef.new(gem_name, parsed)) { |advisory| advisories << to_vulnerability(advisory) } + advisories + rescue Gem::Requirement::BadRequirementError => e + warn("still_active: ruby-advisory-db has a malformed advisory for #{gem_name} (#{e.message}) — run `bundle audit update` to repair the checkout") + [] + end + + # Translates a bundler-audit Advisory into the deps.dev-compatible hash. + # bundler-audit has no CVSS vector, so cvss3_vector is always nil here. + def to_vulnerability(advisory) + primary = advisory.ghsa_id || advisory.cve_id || advisory.id + details = advisory.to_h + { + id: primary, + url: details[:url], + title: details[:title], + aliases: advisory.identifiers.reject { |identifier| identifier == primary }, + cvss3_score: details[:cvss_v3], + cvss3_vector: nil, + cvss2_score: details[:cvss_v2], + source: "ruby-advisory-db", + } + end + + private + + # nil for versions Gem::Version can't parse (e.g. a git sha); such a "version" + # has nothing to match in the advisory DB, so the caller returns []. + def parse_version(version) + Gem::Version.new(version) + rescue ArgumentError + nil + end + + def warn_if_stale(database) + updated = database.last_updated_at + return if updated.nil? # can't determine age — don't warn (not a swallowed error) + + age = Time.now - updated + return if age < STALE_AFTER_SECONDS + + warn("still_active: ruby-advisory-db is #{(age / 86_400).round} days old — run `bundle audit update` for current advisories") + end + end +end diff --git a/lib/helpers/vulnerability_helper.rb b/lib/helpers/vulnerability_helper.rb index 2f69f22..8f0c8dc 100644 --- a/lib/helpers/vulnerability_helper.rb +++ b/lib/helpers/vulnerability_helper.rb @@ -22,8 +22,43 @@ def severity_at_or_above?(vulnerabilities, threshold) SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold) end + # Combines advisories from deps.dev and ruby-advisory-db (via bundler-audit), + # deduplicating on shared identifiers. deps.dev is preferred for CVSS/title/url + # (it carries the vector string); ruby-advisory-db fills gaps. Advisories present + # in both sources are tagged source: "merged"; otherwise the per-source tag is kept. + def merge_advisories(deps_dev:, ruby_advisory_db:) + merged = deps_dev.map(&:dup) + + ruby_advisory_db.each do |advisory| + existing = merged.find { |m| identifiers(m).intersect?(identifiers(advisory)) } + if existing + combine!(existing, advisory) + else + merged << advisory + end + end + + merged + end + private + def identifiers(advisory) + [advisory[:id], *advisory[:aliases]].compact + end + + # Folds a ruby-advisory-db advisory into a matching deps.dev advisory in place: + # deps.dev values win where present, ruby-advisory-db fills nils, aliases union. + def combine!(into, from) + into[:cvss3_score] ||= from[:cvss3_score] + into[:cvss2_score] ||= from[:cvss2_score] + into[:cvss3_vector] ||= from[:cvss3_vector] + into[:title] ||= from[:title] + into[:url] ||= from[:url] + into[:aliases] = (identifiers(into) | identifiers(from)).reject { |id| id == into[:id] }.sort + into[:source] = "merged" + end + def severity_label(score) case score when 9.0..Float::INFINITY then "critical" diff --git a/lib/still_active/deps_dev_client.rb b/lib/still_active/deps_dev_client.rb index 1a117a0..a37d9dd 100644 --- a/lib/still_active/deps_dev_client.rb +++ b/lib/still_active/deps_dev_client.rb @@ -52,6 +52,7 @@ def advisory_detail(advisory_id:) cvss3_score: body["cvss3Score"], cvss3_vector: body["cvss3Vector"], cvss2_score: body["cvss2Score"], + source: "deps.dev", } end diff --git a/lib/still_active/workflow.rb b/lib/still_active/workflow.rb index 1087a41..fd81e63 100644 --- a/lib/still_active/workflow.rb +++ b/lib/still_active/workflow.rb @@ -4,8 +4,10 @@ require_relative "gitlab_client" require_relative "repository" require_relative "../helpers/libyear_helper" +require_relative "../helpers/ruby_advisory_db" require_relative "../helpers/ruby_helper" require_relative "../helpers/version_helper" +require_relative "../helpers/vulnerability_helper" require "async" require "async/barrier" require "async/semaphore" @@ -17,6 +19,9 @@ module Workflow def call(&on_progress) task = Async do + # Load the optional ruby-advisory-db once, before the fan-out, so the + # read-only Database is shared across fibers rather than reloaded per gem. + advisory_db = RubyAdvisoryDb.load barrier = Async::Barrier.new semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier) result_object = {} @@ -30,6 +35,7 @@ def call(&on_progress) gem_version: gem[:version], source_type: gem[:source_type] || :rubygems, source_uri: gem[:source_uri], + advisory_db: advisory_db, ) rescue Octokit::TooManyRequests $stderr.print("\r\e[K") if on_progress @@ -54,24 +60,25 @@ def ruby_freshness private - def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil) + def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil) result_object[gem_name] = { source_type: source_type } result_object[gem_name][:version_used] = gem_version if gem_version case source_type when :path, :git - gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri) + gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri, advisory_db: advisory_db) else gem_info_rubygems( gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri, + advisory_db: advisory_db, ) end end - def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:) + def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil) vs = versions(gem_name: gem_name, source_uri: source_uri) repo_info = repository_info(gem_name: gem_name, versions: vs) commit_date = last_commit_date( @@ -89,6 +96,7 @@ def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:) deps_dev = fetch_deps_dev_info( gem_name: gem_name, version: gem_version || VersionHelper.gem_version(version_hash: last_release), + advisory_db: advisory_db, ) result_object[gem_name].merge!({ latest_version: VersionHelper.gem_version(version_hash: last_release), @@ -127,10 +135,10 @@ def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:) end end - def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil) + def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil) repo_info = repository_info_for_non_rubygems(gem_name: gem_name, source_uri: source_uri) source, owner, name = repo_info.values_at(:source, :owner, :name) - deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version) : {} + deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {} # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score) @@ -143,11 +151,13 @@ def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: n }) end - def fetch_deps_dev_info(gem_name:, version:) + def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil) info = DepsDevClient.version_info(gem_name: gem_name, version: version) scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id)) advisory_keys = info&.dig(:advisory_keys) || [] - vulnerabilities = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) } + deps_dev_vulns = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) } + radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version) + vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns) { scorecard_score: scorecard&.dig(:score), vulnerability_count: vulnerabilities.length, diff --git a/spec/still_active/deps_dev_client_spec.rb b/spec/still_active/deps_dev_client_spec.rb index c73372a..c64bd32 100644 --- a/spec/still_active/deps_dev_client_spec.rb +++ b/spec/still_active/deps_dev_client_spec.rb @@ -54,6 +54,7 @@ title: "Test vulnerability", cvss3_score: 9.8, aliases: ["CVE-2024-1234"], + source: "deps.dev", )) end diff --git a/spec/still_active/ruby_advisory_db_spec.rb b/spec/still_active/ruby_advisory_db_spec.rb new file mode 100644 index 0000000..96fb2da --- /dev/null +++ b/spec/still_active/ruby_advisory_db_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "bundler/audit" +require "bundler/audit/database" + +RSpec.describe(StillActive::RubyAdvisoryDb) do + # A stand-in for a bundler-audit Advisory: the CVSS scores live in #to_h, + # while ids are exposed as methods (matches bundler-audit 0.9.3). + def fake_advisory(ghsa_id:, cve_id:, id:, identifiers:, to_h:) + instance_double( + Bundler::Audit::Advisory, + ghsa_id: ghsa_id, + cve_id: cve_id, + id: id, + identifiers: identifiers, + to_h: to_h, + ) + end + + describe(".to_vulnerability") do + subject(:vulnerability) { described_class.to_vulnerability(advisory) } + + let(:advisory) do + fake_advisory( + ghsa_id: "GHSA-5r2p-j47h-mhpg", + cve_id: "CVE-2018-16471", + id: "CVE-2018-16471", + identifiers: ["CVE-2018-16471", "GHSA-5r2p-j47h-mhpg"], + to_h: { + cvss_v3: 6.1, + cvss_v2: nil, + title: "Possible XSS vulnerability in Rack", + url: "https://groups.google.com/forum/#!topic/ruby-security-ann/x", + }, + ) + end + + it("prefers the GHSA id as the primary identifier") do + expect(vulnerability[:id]).to(eq("GHSA-5r2p-j47h-mhpg")) + end + + it("lists the remaining identifiers as aliases") do + expect(vulnerability[:aliases]).to(contain_exactly("CVE-2018-16471")) + end + + it("reads the CVSS scores from to_h") do + expect(vulnerability).to(include(cvss3_score: 6.1, cvss2_score: nil)) + end + + it("has no CVSS vector (bundler-audit does not expose one)") do + expect(vulnerability[:cvss3_vector]).to(be_nil) + end + + it("carries title and url from to_h") do + expect(vulnerability).to(include( + title: "Possible XSS vulnerability in Rack", + url: "https://groups.google.com/forum/#!topic/ruby-security-ann/x", + )) + end + + it("tags the source as ruby-advisory-db") do + expect(vulnerability[:source]).to(eq("ruby-advisory-db")) + end + + it("falls back to the CVE id when there is no GHSA id") do + advisory = fake_advisory( + ghsa_id: nil, + cve_id: "CVE-2011-0001", + id: "OSVDB-1", + identifiers: ["CVE-2011-0001"], + to_h: { cvss_v3: nil, cvss_v2: 5.0 }, + ) + expect(described_class.to_vulnerability(advisory)[:id]).to(eq("CVE-2011-0001")) + end + end + + describe(".advisories_for") do + let(:advisory) do + fake_advisory( + ghsa_id: "GHSA-xxx", + cve_id: "CVE-9", + id: "CVE-9", + identifiers: ["CVE-9", "GHSA-xxx"], + to_h: { cvss_v3: 7.5, cvss_v2: nil }, + ) + end + + it("returns an empty array when the database is unavailable") do + expect(described_class.advisories_for(database: nil, gem_name: "rack", version: "2.0.0")).to(eq([])) + end + + it("maps each advisory the database yields for the gem") do + database = instance_double(Bundler::Audit::Database) + allow(database).to(receive(:check_gem).and_yield(advisory)) + + result = described_class.advisories_for(database: database, gem_name: "rack", version: "2.0.0") + + expect(result.length).to(eq(1)) + expect(result.first).to(include(id: "GHSA-xxx", source: "ruby-advisory-db")) + end + + it("returns an empty array (not a raise) for an unparseable version") do + database = instance_double(Bundler::Audit::Database) + expect(described_class.advisories_for(database: database, gem_name: "rack", version: "not-a-version")).to(eq([])) + end + + it("warns and returns [] when the checkout has a malformed advisory, rather than silently hiding it") do + database = instance_double(Bundler::Audit::Database) + allow(database).to(receive(:check_gem).and_raise(Gem::Requirement::BadRequirementError.new("bad"))) + + expect do + result = described_class.advisories_for(database: database, gem_name: "rack", version: "2.0.0") + expect(result).to(eq([])) + end.to(output(/malformed advisory for rack.*bundle audit update/).to_stderr) + end + end + + describe(".load") do + it("returns nil when the advisory database directory is absent") do + allow(Bundler::Audit::Database).to(receive(:new).and_raise(ArgumentError, "not a directory")) + expect(described_class.load).to(be_nil) + end + + it("returns the database when it is present") do + database = instance_double(Bundler::Audit::Database, last_updated_at: Time.now) + allow(Bundler::Audit::Database).to(receive(:new).and_return(database)) + expect(described_class.load).to(eq(database)) + end + end +end diff --git a/spec/still_active/vulnerability_helper_spec.rb b/spec/still_active/vulnerability_helper_spec.rb index ee2dd4c..83ac87a 100644 --- a/spec/still_active/vulnerability_helper_spec.rb +++ b/spec/still_active/vulnerability_helper_spec.rb @@ -53,4 +53,57 @@ expect(described_class.highest_severity(vulns)).to(be_nil) end end + + describe(".merge_advisories") do + it("returns an empty array when both sources are empty") do + expect(described_class.merge_advisories(deps_dev: [], ruby_advisory_db: [])).to(eq([])) + end + + it("returns deps.dev advisories unchanged when ruby-advisory-db is empty") do + deps_dev = [{ id: "GHSA-aaa", aliases: ["CVE-1"], cvss3_score: 7.5, source: "deps.dev" }] + expect(described_class.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: [])).to(eq(deps_dev)) + end + + it("returns ruby-advisory-db advisories when deps.dev is empty") do + radb = [{ id: "GHSA-bbb", aliases: [], cvss3_score: 5.0, source: "ruby-advisory-db" }] + expect(described_class.merge_advisories(deps_dev: [], ruby_advisory_db: radb)).to(eq(radb)) + end + + it("keeps disjoint advisories from both sources") do + deps_dev = [{ id: "GHSA-aaa", aliases: [], source: "deps.dev" }] + radb = [{ id: "GHSA-bbb", aliases: [], source: "ruby-advisory-db" }] + result = described_class.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: radb) + expect(result.map { |v| v[:id] }).to(contain_exactly("GHSA-aaa", "GHSA-bbb")) + end + + it("merges advisories that share a primary id, tagging the result merged") do + deps_dev = [{ id: "GHSA-aaa", aliases: ["CVE-1"], title: "from deps.dev", cvss3_score: 7.5, cvss3_vector: "V", source: "deps.dev" }] + radb = [{ id: "GHSA-aaa", aliases: ["OSVDB-9"], title: "from radb", cvss3_score: 6.0, source: "ruby-advisory-db" }] + result = described_class.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: radb) + + expect(result.length).to(eq(1)) + merged = result.first + expect(merged[:source]).to(eq("merged")) + expect(merged[:title]).to(eq("from deps.dev")) # deps.dev preferred + expect(merged[:cvss3_score]).to(eq(7.5)) # deps.dev preferred + expect(merged[:aliases]).to(contain_exactly("CVE-1", "OSVDB-9")) # unioned + end + + it("merges when a deps.dev id matches a ruby-advisory-db alias") do + deps_dev = [{ id: "GHSA-aaa", aliases: [], source: "deps.dev" }] + radb = [{ id: "CVE-1", aliases: ["GHSA-aaa"], source: "ruby-advisory-db" }] + result = described_class.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: radb) + + expect(result.length).to(eq(1)) + expect(result.first[:source]).to(eq("merged")) + end + + it("fills a missing deps.dev CVSS score from ruby-advisory-db on merge") do + deps_dev = [{ id: "GHSA-aaa", aliases: [], cvss3_score: nil, source: "deps.dev" }] + radb = [{ id: "GHSA-aaa", aliases: [], cvss3_score: 8.1, source: "ruby-advisory-db" }] + result = described_class.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: radb) + + expect(result.first[:cvss3_score]).to(eq(8.1)) + end + end end diff --git a/spec/still_active/workflow_spec.rb b/spec/still_active/workflow_spec.rb index 7d1bdc9..22a2158 100644 --- a/spec/still_active/workflow_spec.rb +++ b/spec/still_active/workflow_spec.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true RSpec.describe(StillActive::Workflow) do + # Keep the optional ruby-advisory-db second source out of the default path so + # tests don't depend on a local `bundle audit update` checkout. The merge is + # exercised explicitly in its own context below. + before { allow(StillActive::RubyAdvisoryDb).to(receive(:load).and_return(nil)) } + describe("#call") do subject(:result) { described_class.call } @@ -33,6 +38,42 @@ end end + context("when ruby-advisory-db is available as a second source") do + before do + StillActive.config.gems = [{ name: "rack", version: "2.0.0" }] + allow(Gems).to(receive(:versions).with("rack").and_return([ + { "number" => "2.0.0", "prerelease" => false, "created_at" => "2016-05-06T00:00:00Z", "licenses" => ["MIT"] }, + ])) + allow(Gems).to(receive(:info).with("rack").and_return({ "homepage_uri" => nil, "source_code_uri" => nil })) + allow(StillActive::DepsDevClient).to(receive_messages( + version_info: { advisory_keys: ["GHSA-deps"], project_id: nil }, + project_scorecard: nil, + advisory_detail: { id: "GHSA-deps", aliases: [], title: "from deps.dev", cvss3_score: 7.5, source: "deps.dev" }, + )) + allow(StillActive::RubyAdvisoryDb).to(receive(:load).and_return(:fake_db)) + end + + it("appends advisories unique to ruby-advisory-db") do + allow(StillActive::RubyAdvisoryDb).to(receive(:advisories_for).and_return( + [{ id: "GHSA-radb", aliases: [], cvss3_score: 5.0, source: "ruby-advisory-db" }], + )) + + data = result["rack"] + expect(data[:vulnerability_count]).to(eq(2)) + expect(data[:vulnerabilities].map { |v| v[:source] }).to(contain_exactly("deps.dev", "ruby-advisory-db")) + end + + it("deduplicates an advisory reported by both sources into one merged entry") do + allow(StillActive::RubyAdvisoryDb).to(receive(:advisories_for).and_return( + [{ id: "GHSA-deps", aliases: ["OSVDB-1"], cvss3_score: 6.0, source: "ruby-advisory-db" }], + )) + + data = result["rack"] + expect(data[:vulnerability_count]).to(eq(1)) + expect(data[:vulnerabilities].first).to(include(source: "merged", title: "from deps.dev", cvss3_score: 7.5)) + end + end + context("when a gem version is yanked") do before do StillActive.config.gems = [{ name: "yanked_gem", version: "0.9.0" }] diff --git a/still_active.gemspec b/still_active.gemspec index 54d1c4d..239dc64 100644 --- a/still_active.gemspec +++ b/still_active.gemspec @@ -36,6 +36,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Abin/still_active}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_development_dependency("bundler-audit") spec.add_development_dependency("debug") spec.add_development_dependency("faker") spec.add_development_dependency("json_schemer") From ebe2e189cf042d04bed367ee88d954ffa1fd2862 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 10:12:52 +0100 Subject: [PATCH 03/12] feat: detect Dependabot/Renovate context in PR-diff and report output In --baseline (and terminal/markdown/JSON) runs, a bot-authored update is now recognized and the report leads with a narrative ("Dependabot bump: rack 2.0.0 -> 2.0.6") so reviewers see intent, not just a list. JSON gains an additive top-level `pr_context` ({ bot, bumps: [{ gem, from, to }] }). Detection cascade: GITHUB_ACTOR -> dependabot/|renovate/ branch prefix -> commit subject. The subject patterns are deliberately conservative because false positives are worse than misses: Dependabot's `bump X from Y to Z` skeleton is safe unprefixed, but the Renovate `update X to vN` pattern requires a v-prefixed version so it can't fire on ordinary "Update README to mention SARIF" commits. Git probes rescue SystemCallError so a missing/unlaunchable git only costs the narrative, never the run. schema_version stays 1 (pr_context additive); SARIF unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + docs/schema.md | 1 + lib/helpers/bot_context.rb | 100 ++++++++++++++++++++++++ lib/still_active/cli.rb | 14 +++- spec/still_active/bot_context_spec.rb | 106 ++++++++++++++++++++++++++ spec/still_active/cli_spec.rb | 36 +++++++++ 6 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 lib/helpers/bot_context.rb create mode 100644 spec/still_active/bot_context_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b1aac33..9f9d327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Dependabot/Renovate awareness: when a run is detected as bot-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject — including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Best-effort and conservative: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. diff --git a/docs/schema.md b/docs/schema.md index 25c6fe2..ee4e9c9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -25,6 +25,7 @@ | `generated_at` | string | ISO-8601 UTC timestamp (e.g. `"2026-05-22T14:33:00Z"`). | | `gems` | object | Map of gem name → gem data (see below). | | `ruby` | object \| absent | Ruby freshness info; absent when not detectable. | +| `pr_context` | object \| absent | Present only when the run is detected as Dependabot/Renovate-authored. `{ "bot": "dependabot" \| "renovate", "bumps": [{ "gem", "from", "to" }] }`. `from` is `null` for Renovate (its commit subject carries no source version); `bumps` is `[]` for grouped/unparseable subjects. Best-effort detection — absence does not guarantee the run is not a bot's. | ## Per-gem fields diff --git a/lib/helpers/bot_context.rb b/lib/helpers/bot_context.rb new file mode 100644 index 0000000..674a7bc --- /dev/null +++ b/lib/helpers/bot_context.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "open3" + +module StillActive + # Best-effort detection of a Dependabot/Renovate-authored run, so --baseline + # reports can lead with a narrative ("Dependabot bump: rack 2.0.0 → 2.0.6") + # instead of an unattributed list. Detection is heuristic: false negatives are + # fine (we just lose the narrative), false positives are not, so the subject + # patterns are anchored and require the literal bump/update keyword. + module BotContext + extend self + + # Dependabot's *default* subject is "Bump X from Y to Z" (capitalized, no + # prefix). The `from … to …` skeleton rarely occurs in human commits, so it + # is safe unprefixed. The conventional-commit prefix only appears when configured. + DEPENDABOT_SUBJECT = /\A(?:build\(deps(?:-dev)?\):\s*)?bump (\S+) from (\S+) to (\S+)/i + + # Renovate's default is "Update dependency X to vN.…" — note the **required** + # `v`+digit version. Matching a bare "to " would false-positive on ordinary + # commits ("Update README to mention SARIF"), so we anchor on the v-prefixed + # version. False negatives (a no-`v` Renovate config) are acceptable; false + # positives are not. The `v` is consumed, so the captured version excludes it. + RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i + + # Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] } + # or nil when no bot signal is present. `bumps` is parsed from the head + # commit subject; a grouped or unparseable subject yields an empty list. + def detect(env: ENV, head_subject: head_commit_subject) + bot = detect_bot(env: env, head_subject: head_subject) + return if bot.nil? + + { bot: bot, bumps: bumps_from(bot, head_subject) } + end + + # A one-line, format-agnostic narrative for the detected context. + def summary(context) + label = context[:bot] == "renovate" ? "Renovate" : "Dependabot" + bumps = context[:bumps] + + case bumps.length + when 0 then "#{label} dependency update" + when 1 then single_bump_summary(label, bumps.first) + else "#{label}: #{bumps.length} dependency updates" + end + end + + private + + def detect_bot(env:, head_subject:) + actor = env["GITHUB_ACTOR"] + return "dependabot" if actor == "dependabot[bot]" + return "renovate" if actor == "renovate[bot]" + + ref = env["GITHUB_HEAD_REF"] || current_branch + return "dependabot" if ref&.start_with?("dependabot/") + return "renovate" if ref&.start_with?("renovate/", "renovate-bot/") + + return "dependabot" if head_subject&.match?(DEPENDABOT_SUBJECT) + return "renovate" if head_subject&.match?(RENOVATE_SUBJECT) + + nil + end + + def bumps_from(bot, subject) + return [] if subject.nil? + + if bot == "dependabot" && (match = subject.match(DEPENDABOT_SUBJECT)) + [{ gem: match[1], from: match[2], to: match[3] }] + elsif bot == "renovate" && (match = subject.match(RENOVATE_SUBJECT)) + [{ gem: match[1], from: nil, to: match[2] }] + else + [] + end + end + + def single_bump_summary(label, bump) + arrow = bump[:from] ? "#{bump[:from]} → #{bump[:to]}" : "→ #{bump[:to]}" + verb = label == "Renovate" ? "update" : "bump" + "#{label} #{verb}: #{bump[:gem]} #{arrow}" + end + + # SystemCallError (not just Errno::ENOENT) so a git that's missing *or* + # unlaunchable can't crash a run over a cosmetic narrative. git *logic* + # failures surface as a non-zero status, not an exception, and yield nil. + def current_branch + out, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD") + status.success? ? out.strip : nil + rescue SystemCallError + nil + end + + def head_commit_subject + out, _, status = Open3.capture3("git", "log", "-1", "--pretty=%s") + status.success? ? out.strip : nil + rescue SystemCallError + nil + end + end +end diff --git a/lib/still_active/cli.rb b/lib/still_active/cli.rb index e4ca5e2..52339bf 100644 --- a/lib/still_active/cli.rb +++ b/lib/still_active/cli.rb @@ -3,6 +3,7 @@ require_relative "options" require_relative "diff" require_relative "../helpers/activity_helper" +require_relative "../helpers/bot_context" require_relative "../helpers/bundler_helper" require_relative "../helpers/diff_markdown_helper" require_relative "../helpers/emoji_helper" @@ -34,9 +35,10 @@ def run(args) $stderr.print("\r\e[K") if $stderr.tty? ruby_info = Workflow.ruby_freshness + pr_context = BotContext.detect if (baseline_path = StillActive.config.baseline_path) - emit_diff(result, ruby_info, baseline_path) + emit_diff(result, ruby_info, baseline_path, pr_context) elsif (sarif_path = StillActive.config.sarif_path) emit_sarif(result, ruby_info, sarif_path) else @@ -49,11 +51,13 @@ def run(args) gems: result, } output[:ruby] = ruby_info if ruby_info + output[:pr_context] = pr_context if pr_context puts output.to_json when :terminal + puts BotContext.summary(pr_context) if pr_context puts TerminalHelper.render(result, ruby_info: ruby_info) when :markdown - render_markdown(result, ruby_info: ruby_info) + render_markdown(result, ruby_info: ruby_info, pr_context: pr_context) end end @@ -90,10 +94,11 @@ def resolve_lockfile_path(gemfile) "#{gemfile}.lock" end - def emit_diff(result, ruby_info, baseline_path) + def emit_diff(result, ruby_info, baseline_path, pr_context = nil) current = current_snapshot(result, ruby_info) baseline = JSON.parse(File.read(baseline_path)) diff = Diff.call(baseline: baseline, current: current) + puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context puts DiffMarkdownHelper.render(diff) exit(1) if diff.regressions.any? rescue JSON::ParserError => e @@ -125,7 +130,8 @@ def resolve_format $stdout.tty? ? :terminal : :json end - def render_markdown(result, ruby_info: nil) + def render_markdown(result, ruby_info: nil, pr_context: nil) + puts "> **#{BotContext.summary(pr_context)}**\n" if pr_context puts MarkdownHelper.markdown_table_header_line result.keys.sort.each do |name| gem_data = result[name] diff --git a/spec/still_active/bot_context_spec.rb b/spec/still_active/bot_context_spec.rb new file mode 100644 index 0000000..8c24af1 --- /dev/null +++ b/spec/still_active/bot_context_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +RSpec.describe(StillActive::BotContext) do + describe(".detect") do + it("returns nil when there is no bot signal") do + expect(described_class.detect(env: {}, head_subject: "Refactor the parser")).to(be_nil) + end + + # The Renovate subject pattern must not fire on ordinary "Update X to Y" commits. + ["Update CI to use Node 24", "Update README to mention SARIF", "Update Ruby to 3.4", "Bump version to 1.5.0"].each do |subject| + it("does not false-positive on the human commit #{subject.inspect}") do + expect(described_class.detect(env: {}, head_subject: subject)).to(be_nil) + end + end + + context("when GITHUB_ACTOR is set") do + it("detects Dependabot") do + result = described_class.detect(env: { "GITHUB_ACTOR" => "dependabot[bot]" }, head_subject: nil) + expect(result[:bot]).to(eq("dependabot")) + end + + it("detects Renovate") do + result = described_class.detect(env: { "GITHUB_ACTOR" => "renovate[bot]" }, head_subject: nil) + expect(result[:bot]).to(eq("renovate")) + end + end + + context("when the branch has a bot prefix") do + it("detects Dependabot from a dependabot/ branch") do + result = described_class.detect(env: { "GITHUB_HEAD_REF" => "dependabot/bundler/rack-2.0.6" }, head_subject: nil) + expect(result[:bot]).to(eq("dependabot")) + end + + it("detects Renovate from a renovate/ branch") do + result = described_class.detect(env: { "GITHUB_HEAD_REF" => "renovate/rack-2.x" }, head_subject: nil) + expect(result[:bot]).to(eq("renovate")) + end + end + + context("when only the commit subject signals a bot") do + it("detects Dependabot's default (unprefixed, capitalized) subject and extracts the bump") do + result = described_class.detect(env: {}, head_subject: "Bump rack from 2.0.0 to 2.0.6") + expect(result[:bot]).to(eq("dependabot")) + expect(result[:bumps]).to(eq([{ gem: "rack", from: "2.0.0", to: "2.0.6" }])) + end + + it("detects Dependabot's conventional-commit prefixed subject") do + result = described_class.detect(env: {}, head_subject: "build(deps): bump rack from 2.0.0 to 2.0.6") + expect(result[:bot]).to(eq("dependabot")) + expect(result[:bumps]).to(eq([{ gem: "rack", from: "2.0.0", to: "2.0.6" }])) + end + + it("detects Renovate's default subject (no from version available)") do + result = described_class.detect(env: {}, head_subject: "Update dependency rack to v2.0.6") + expect(result[:bot]).to(eq("renovate")) + expect(result[:bumps]).to(eq([{ gem: "rack", from: nil, to: "2.0.6" }])) + end + + it("detects Renovate's conventional-commit prefixed subject") do + result = described_class.detect(env: {}, head_subject: "chore(deps): update dependency rack to v2.0.6") + expect(result[:bot]).to(eq("renovate")) + end + end + + context("when the bot is detected but the subject does not parse (e.g. grouped update)") do + it("returns the bot with no bumps") do + result = described_class.detect( + env: { "GITHUB_HEAD_REF" => "dependabot/bundler/the-bundler-group" }, + head_subject: "Bump the bundler group with 3 updates", + ) + expect(result[:bot]).to(eq("dependabot")) + expect(result[:bumps]).to(eq([])) + end + end + + it("prefers GITHUB_ACTOR over branch and subject") do + result = described_class.detect( + env: { "GITHUB_ACTOR" => "dependabot[bot]", "GITHUB_HEAD_REF" => "renovate/x" }, + head_subject: "Update dependency rack to v2.0.6", + ) + expect(result[:bot]).to(eq("dependabot")) + end + end + + describe(".summary") do + it("describes a single Dependabot bump with from and to") do + summary = described_class.summary({ bot: "dependabot", bumps: [{ gem: "rack", from: "2.0.0", to: "2.0.6" }] }) + expect(summary).to(eq("Dependabot bump: rack 2.0.0 → 2.0.6")) + end + + it("describes a single Renovate update without a from version") do + summary = described_class.summary({ bot: "renovate", bumps: [{ gem: "rack", from: nil, to: "2.0.6" }] }) + expect(summary).to(eq("Renovate update: rack → 2.0.6")) + end + + it("summarizes multiple bumps by count") do + summary = described_class.summary({ bot: "dependabot", bumps: [{ gem: "a" }, { gem: "b" }] }) + expect(summary).to(eq("Dependabot: 2 dependency updates")) + end + + it("falls back to a generic line when there are no parsed bumps") do + summary = described_class.summary({ bot: "dependabot", bumps: [] }) + expect(summary).to(eq("Dependabot dependency update")) + end + end +end diff --git a/spec/still_active/cli_spec.rb b/spec/still_active/cli_spec.rb index 896516c..f541196 100644 --- a/spec/still_active/cli_spec.rb +++ b/spec/still_active/cli_spec.rb @@ -14,6 +14,8 @@ before do allow(StillActive::Workflow).to(receive_messages(call: workflow_result, ruby_freshness: nil)) allow($stdout).to(receive(:puts)) + # No bot context by default — keeps tests off the git subprocesses BotContext shells to. + allow(StillActive::BotContext).to(receive(:detect).and_return(nil)) StillActive.reset end @@ -397,6 +399,40 @@ def write_baseline(path, gems) end end + describe("Dependabot/Renovate context") do + let(:workflow_result) { { "rack" => gem_data(last_commit_date: recent_date) } } + let(:context) { { bot: "dependabot", bumps: [{ gem: "rack", from: "2.0.0", to: "2.0.6" }] } } + + before do + allow($stdout).to(receive(:tty?).and_return(false)) + allow(StillActive::BotContext).to(receive(:detect).and_return(context)) + end + + it("includes pr_context in JSON output when a bot is detected") do + captured = nil + allow($stdout).to(receive(:puts)) { |arg| captured = arg } + cli.run(["--gems=rack", "--json"]) + parsed = JSON.parse(captured) + expect(parsed["pr_context"]).to(include("bot" => "dependabot")) + expect(parsed["pr_context"]["bumps"]).to(eq([{ "gem" => "rack", "from" => "2.0.0", "to" => "2.0.6" }])) + end + + it("prepends a narrative header to markdown output") do + lines = [] + allow($stdout).to(receive(:puts)) { |arg| lines << arg } + cli.run(["--gems=rack", "--markdown"]) + expect(lines.first).to(include("Dependabot bump: rack 2.0.0 → 2.0.6")) + end + + it("omits pr_context from JSON when no bot is detected") do + allow(StillActive::BotContext).to(receive(:detect).and_return(nil)) + captured = nil + allow($stdout).to(receive(:puts)) { |arg| captured = arg } + cli.run(["--gems=rack", "--json"]) + expect(JSON.parse(captured)).not_to(have_key("pr_context")) + end + end + describe("--fail-if-warning") do context("when a gem has warning activity") do let(:workflow_result) { { "aging_gem" => gem_data(last_commit_date: old_date) } } From 09745db8697c438021c5db50ecdca3f3989ae801 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 10:19:21 +0100 Subject: [PATCH 04/12] feat: emit CycloneDX 1.6 SBOM with --cyclonedx (1.7 opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes still_active's data portable: --cyclonedx[=PATH] emits a CycloneDX SBOM so the dep graph plus our signals flow into Trivy / Dependency-Track / Snyk / GitHub's dependency-submission API instead of being walled in our own JSON. Defaults to 1.6 — the version mainstream consumers actually ingest today (cyclonedx-core-java/Dependency-Track and cyclonedx-go/Trivy both cap at 1.6 as of 2026); --cyclonedx-version=1.7 opts into the latest. Our emitted subset is identical across both, so only the specVersion string changes, and a consumer that only knows 1.6 would reject a 1.7-stamped doc — hence the conservative default. Native fields carry name/version/purl/licenses/vulnerabilities; maintenance signals with no CycloneDX home (archived, OpenSSF, libyear, last commit, yanked) ride in still_active:-namespaced properties. serialNumber is derived from the component set, so two SBOMs of the same lockfile are byte-identical apart from the generation timestamp (injectable, so tests are deterministic). Separate output format — the schema_version:1 JSON envelope is untouched. Stdlib only (json/digest/time); no new runtime deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + lib/helpers/cyclonedx_helper.rb | 152 +++++++++++++++++++++ lib/still_active/cli.rb | 18 +++ lib/still_active/config.rb | 4 + lib/still_active/options.rb | 10 ++ spec/still_active/cli_spec.rb | 37 +++++ spec/still_active/cyclonedx_helper_spec.rb | 132 ++++++++++++++++++ 7 files changed, 354 insertions(+) create mode 100644 lib/helpers/cyclonedx_helper.rb create mode 100644 spec/still_active/cyclonedx_helper_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9d327..018e8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk / GitHub's dependency-submission API. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly. - Dependabot/Renovate awareness: when a run is detected as bot-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject — including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Best-effort and conservative: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. diff --git a/lib/helpers/cyclonedx_helper.rb b/lib/helpers/cyclonedx_helper.rb new file mode 100644 index 0000000..baa3da4 --- /dev/null +++ b/lib/helpers/cyclonedx_helper.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "json" +require "digest" +require "time" +require_relative "vulnerability_helper" + +module StillActive + # Renders a still_active workflow result as a CycloneDX SBOM. Emits 1.6 by + # default (the version mainstream consumers — Dependency-Track via + # cyclonedx-core-java, Trivy/Syft via cyclonedx-go — actually ingest as of + # 2026); 1.7 is opt-in. Our emitted subset is identical across both versions, + # so only the specVersion string changes. + # + # Maintenance signals that have no native CycloneDX field (scorecard, libyear, + # archived, last commit) are emitted as `still_active:`-namespaced component + # properties — lossy by spec design, ignorable by consumers that don't care. + module CyclonedxHelper + extend self + + SUPPORTED_SPEC_VERSIONS = ["1.6", "1.7"].freeze + + # result: gem_name => gem_data (as StillActive::Workflow.call returns) + # ruby_info: Ruby freshness hash or nil + # now: injectable clock so output is deterministic in tests + def render(result:, ruby_info:, tool_version:, spec_version: "1.6", now: Time.now.utc) + components = build_components(result, ruby_info) + vulnerabilities = build_vulnerabilities(result) + + document = { + "bomFormat" => "CycloneDX", + "specVersion" => spec_version, + "serialNumber" => deterministic_serial(components), + "version" => 1, + "metadata" => { + "timestamp" => now.iso8601, + "tools" => [{ "vendor" => "SeanLF", "name" => "still_active", "version" => tool_version }], + }, + "components" => components, + } + document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty? + JSON.pretty_generate(document) + end + + private + + def build_components(result, ruby_info) + components = result.sort_by { |name, _| name.to_s }.map { |name, data| gem_component(name.to_s, data) } + components << ruby_component(ruby_info) if ruby_info && ruby_info[:version] + components + end + + def gem_component(name, data) + version = data[:version_used] + component = { "type" => "library", "name" => name } + component["version"] = version if version + component["bom-ref"] = bom_ref(name, data) + component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version + component["licenses"] = [{ "license" => { "id" => data[:license] } }] if data[:license] + if data[:repository_url] + component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }] + end + properties = gem_properties(data) + component["properties"] = properties unless properties.empty? + component + end + + def bom_ref(name, data) + version = data[:version_used] + return purl(name, version) if data[:source_type] == :rubygems && version + + "#{data[:source_type]}-source:#{name}@#{version || "unknown"}" + end + + def purl(name, version) + "pkg:gem/#{name}@#{version}" + end + + def gem_properties(data) + { + "still_active:archived" => boolean_property(data[:archived]), + "still_active:scorecard_score" => data[:scorecard_score]&.to_s, + "still_active:libyear" => data[:libyear]&.to_s, + "still_active:last_commit_date" => iso8601(data[:last_commit_date]), + "still_active:version_yanked" => boolean_property(data[:version_yanked]), + }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? } + end + + def ruby_component(ruby_info) + { + "type" => "platform", + "name" => "ruby", + "version" => ruby_info[:version], + "bom-ref" => "platform:ruby@#{ruby_info[:version]}", + "properties" => [ + { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) }, + { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s }, + ].reject { |p| p["value"].nil? }, + } + end + + def build_vulnerabilities(result) + result.sort_by { |name, _| name.to_s }.flat_map do |name, data| + ref = bom_ref(name.to_s, data) + (data[:vulnerabilities] || []).map { |advisory| vulnerability(advisory, ref) } + end + end + + def vulnerability(advisory, component_ref) + entry = { + "bom-ref" => "#{advisory[:id]}:#{component_ref}", + "id" => advisory[:id], + "affects" => [{ "ref" => component_ref }], + } + entry["source"] = { "name" => advisory[:source] } if advisory[:source] + advisory_rating = rating(advisory) + entry["ratings"] = [advisory_rating] if advisory_rating + entry + end + + def rating(advisory) + score = advisory[:cvss3_score] || advisory[:cvss2_score] + return if score.nil? + + method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2" + rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method } + rating["vector"] = advisory[:cvss3_vector] if advisory[:cvss3_vector] + rating + end + + def boolean_property(value) + return if value.nil? + + value.to_s + end + + def iso8601(time) + return if time.nil? + + time.respond_to?(:iso8601) ? time.iso8601 : time.to_s + end + + # Deterministic urn:uuid derived from the component identifiers, so two SBOMs + # of the same lockfile are byte-identical (diffable; golden-test friendly). + def deterministic_serial(components) + basis = components.map { |c| c["bom-ref"] }.sort.join("\n") + hex = Digest::SHA256.hexdigest(basis) + uuid = "#{hex[0, 8]}-#{hex[8, 4]}-5#{hex[13, 3]}-8#{hex[17, 3]}-#{hex[20, 12]}" + "urn:uuid:#{uuid}" + end + end +end diff --git a/lib/still_active/cli.rb b/lib/still_active/cli.rb index 52339bf..651dec6 100644 --- a/lib/still_active/cli.rb +++ b/lib/still_active/cli.rb @@ -5,6 +5,7 @@ require_relative "../helpers/activity_helper" require_relative "../helpers/bot_context" require_relative "../helpers/bundler_helper" +require_relative "../helpers/cyclonedx_helper" require_relative "../helpers/diff_markdown_helper" require_relative "../helpers/emoji_helper" require_relative "../helpers/markdown_helper" @@ -41,6 +42,8 @@ def run(args) emit_diff(result, ruby_info, baseline_path, pr_context) elsif (sarif_path = StillActive.config.sarif_path) emit_sarif(result, ruby_info, sarif_path) + elsif (cyclonedx_path = StillActive.config.cyclonedx_path) + emit_cyclonedx(result, ruby_info, cyclonedx_path) else case resolve_format when :json @@ -87,6 +90,21 @@ def emit_sarif(result, ruby_info, sarif_path) end end + def emit_cyclonedx(result, ruby_info, cyclonedx_path) + sbom = CyclonedxHelper.render( + result: result, + ruby_info: ruby_info, + tool_version: StillActive::VERSION, + spec_version: StillActive.config.cyclonedx_version, + ) + + if cyclonedx_path == "-" + puts sbom + else + File.write(cyclonedx_path, sbom) + end + end + # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise .lock. def resolve_lockfile_path(gemfile) return gemfile.sub(/gems\.rb\z/, "gems.locked") if gemfile.end_with?("gems.rb") diff --git a/lib/still_active/config.rb b/lib/still_active/config.rb index 09229db..ae50612 100644 --- a/lib/still_active/config.rb +++ b/lib/still_active/config.rb @@ -9,6 +9,8 @@ class Config attr_writer :github_oauth_token, :gitlab_token, :gemfile_path attr_accessor :baseline_path, :critical_warning_emoji, + :cyclonedx_path, + :cyclonedx_version, :fail_if_critical, :fail_if_warning, :futurist_emoji, @@ -41,6 +43,8 @@ def initialize @output_format = :auto @sarif_path = nil @baseline_path = nil + @cyclonedx_path = nil + @cyclonedx_version = "1.6" @critical_warning_emoji = "🚩" @futurist_emoji = "🔮" diff --git a/lib/still_active/options.rb b/lib/still_active/options.rb index c078f70..475aed9 100644 --- a/lib/still_active/options.rb +++ b/lib/still_active/options.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "optparse" +require_relative "../helpers/cyclonedx_helper" require_relative "../helpers/vulnerability_helper" module StillActive @@ -70,6 +71,15 @@ def add_output_options(opts) options[:provided_baseline] = true StillActive.config { |config| config.baseline_path = value } end + opts.on("--cyclonedx[=PATH]", "CycloneDX SBOM output (default to stdout; PATH to write a file). Overrides --terminal/--markdown/--json.") do |value| + StillActive.config { |config| config.cyclonedx_path = value || "-" } + end + opts.on("--cyclonedx-version=VERSION", String, "CycloneDX spec version to emit: 1.6 (default) or 1.7.") do |value| + supported = StillActive::CyclonedxHelper::SUPPORTED_SPEC_VERSIONS + raise ArgumentError, "--cyclonedx-version must be one of: #{supported.join(", ")} (got #{value})" unless supported.include?(value) + + StillActive.config { |config| config.cyclonedx_version = value } + end end def add_token_options(opts) diff --git a/spec/still_active/cli_spec.rb b/spec/still_active/cli_spec.rb index f541196..6dfa9bf 100644 --- a/spec/still_active/cli_spec.rb +++ b/spec/still_active/cli_spec.rb @@ -399,6 +399,43 @@ def write_baseline(path, gems) end end + describe("--cyclonedx") do + let(:workflow_result) { { "rack" => gem_data(last_commit_date: recent_date).merge(license: "MIT") } } + + before { allow($stdout).to(receive(:tty?).and_return(false)) } + + it("emits a CycloneDX 1.6 document to stdout when --cyclonedx=-") do + captured = nil + allow($stdout).to(receive(:puts)) { |arg| captured = arg } + cli.run(["--gems=rack", "--cyclonedx=-"]) + doc = JSON.parse(captured) + expect(doc["bomFormat"]).to(eq("CycloneDX")) + expect(doc["specVersion"]).to(eq("1.6")) + expect(doc["components"].map { |c| c["name"] }).to(include("rack")) + end + + it("honours --cyclonedx-version=1.7") do + captured = nil + allow($stdout).to(receive(:puts)) { |arg| captured = arg } + cli.run(["--gems=rack", "--cyclonedx=-", "--cyclonedx-version=1.7"]) + expect(JSON.parse(captured)["specVersion"]).to(eq("1.7")) + end + + it("writes to a file when given a path") do + Dir.mktmpdir do |dir| + path = "#{dir}/sbom.json" + cli.run(["--gems=rack", "--cyclonedx=#{path}"]) + expect(File.exist?(path)).to(be(true)) + expect(JSON.parse(File.read(path))["bomFormat"]).to(eq("CycloneDX")) + end + end + + it("rejects an unsupported spec version") do + expect { cli.run(["--gems=rack", "--cyclonedx", "--cyclonedx-version=2.0"]) } + .to(raise_error(ArgumentError, /1\.6.*1\.7/)) + end + end + describe("Dependabot/Renovate context") do let(:workflow_result) { { "rack" => gem_data(last_commit_date: recent_date) } } let(:context) { { bot: "dependabot", bumps: [{ gem: "rack", from: "2.0.0", to: "2.0.6" }] } } diff --git a/spec/still_active/cyclonedx_helper_spec.rb b/spec/still_active/cyclonedx_helper_spec.rb new file mode 100644 index 0000000..c5b1ea2 --- /dev/null +++ b/spec/still_active/cyclonedx_helper_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "json" + +RSpec.describe(StillActive::CyclonedxHelper) do + let(:fixed_time) { Time.utc(2026, 5, 23, 12, 0, 0) } + + let(:result) do + { + "rack" => { + source_type: :rubygems, + version_used: "2.0.0", + latest_version: "3.2.6", + repository_url: "https://github.com/rack/rack", + last_commit_date: Time.utc(2026, 4, 1), + archived: false, + scorecard_score: 6.5, + license: "MIT", + libyear: 4.4, + version_yanked: false, + vulnerability_count: 1, + vulnerabilities: [ + { id: "GHSA-xxx", url: "https://osv.dev/GHSA-xxx", title: "XSS", aliases: ["CVE-1"], cvss3_score: 7.5, cvss3_vector: "CVSS:3.1/AV:N", cvss2_score: nil, source: "merged" }, + ], + }, + "local_gem" => { + source_type: :path, + version_used: "0.1.0", + license: nil, + vulnerability_count: 0, + vulnerabilities: [], + }, + } + end + + let(:ruby_info) { { version: "3.4.0", eol: false, libyear: 0.0 } } + + def render(spec_version: "1.6") + JSON.parse(described_class.render(result: result, ruby_info: ruby_info, tool_version: "1.5.0", spec_version: spec_version, now: fixed_time)) + end + + it("emits a CycloneDX document with the default spec version 1.6") do + doc = render + expect(doc["bomFormat"]).to(eq("CycloneDX")) + expect(doc["specVersion"]).to(eq("1.6")) + end + + it("emits the requested spec version 1.7 with identical structure") do + expect(render(spec_version: "1.7")["specVersion"]).to(eq("1.7")) + end + + it("stamps the injected timestamp") do + expect(render["metadata"]["timestamp"]).to(eq("2026-05-23T12:00:00Z")) + end + + it("produces a deterministic serialNumber for identical input") do + first = described_class.render(result: result, ruby_info: ruby_info, tool_version: "1.5.0", now: fixed_time) + second = described_class.render(result: result, ruby_info: ruby_info, tool_version: "1.5.0", now: fixed_time) + expect(JSON.parse(first)["serialNumber"]).to(eq(JSON.parse(second)["serialNumber"])) + expect(JSON.parse(first)["serialNumber"]).to(match(/\Aurn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)) + end + + describe("components") do + subject(:components) { render["components"] } + + it("gives a rubygems gem a pkg:gem purl and matching bom-ref") do + rack = components.find { |c| c["name"] == "rack" } + expect(rack["purl"]).to(eq("pkg:gem/rack@2.0.0")) + expect(rack["bom-ref"]).to(eq("pkg:gem/rack@2.0.0")) + expect(rack["type"]).to(eq("library")) + end + + it("maps the license to the licenses array") do + rack = components.find { |c| c["name"] == "rack" } + expect(rack["licenses"]).to(eq([{ "license" => { "id" => "MIT" } }])) + end + + it("carries the repository URL as a vcs externalReference") do + rack = components.find { |c| c["name"] == "rack" } + expect(rack["externalReferences"]).to(include("type" => "vcs", "url" => "https://github.com/rack/rack")) + end + + it("puts maintenance signals in still_active-namespaced properties") do + rack = components.find { |c| c["name"] == "rack" } + props = rack["properties"].to_h { |p| [p["name"], p["value"]] } + expect(props).to(include( + "still_active:archived" => "false", + "still_active:scorecard_score" => "6.5", + "still_active:libyear" => "4.4", + )) + end + + it("omits the purl for a path-sourced gem but still gives it a bom-ref") do + local = components.find { |c| c["name"] == "local_gem" } + expect(local).not_to(have_key("purl")) + expect(local["bom-ref"]).not_to(be_empty) + end + + it("includes Ruby as a platform component") do + ruby = components.find { |c| c["name"] == "ruby" } + expect(ruby["type"]).to(eq("platform")) + expect(ruby["version"]).to(eq("3.4.0")) + end + end + + describe("vulnerabilities") do + subject(:vulnerabilities) { render["vulnerabilities"] } + + it("emits one entry per advisory, referencing the affected component") do + expect(vulnerabilities.length).to(eq(1)) + vuln = vulnerabilities.first + expect(vuln["id"]).to(eq("GHSA-xxx")) + expect(vuln["affects"]).to(eq([{ "ref" => "pkg:gem/rack@2.0.0" }])) + end + + it("maps CVSS into a rating with severity and vector") do + rating = vulnerabilities.first["ratings"].first + expect(rating).to(include("score" => 7.5, "severity" => "high", "method" => "CVSSv3", "vector" => "CVSS:3.1/AV:N")) + end + + it("records the advisory source") do + expect(vulnerabilities.first["source"]).to(eq({ "name" => "merged" })) + end + + it("references only components that exist in the BOM") do + refs = render["components"].map { |c| c["bom-ref"] } + vulnerabilities.each do |v| + v["affects"].each { |a| expect(refs).to(include(a["ref"])) } + end + end + end +end From e52b5cacc394887228fcaa7f21094cc237b4c876 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 10:23:09 +0100 Subject: [PATCH 05/12] =?UTF-8?q?chore:=201.5.0=20=E2=80=94=20ecosystem=20?= =?UTF-8?q?integrations=20+=20dependency-review-action=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the v1.5 features (license column, dual-source vulns, CycloneDX, Dependabot/Renovate context) across the README and bumps the version. Adds an "Alongside dependency-review-action" section: a comparison matrix and a dual-job workflow. GitHub's first-party action gates CVEs/licenses server-side but surfaces no maintenance signals and is GitHub-only — so the recipe runs both, letting still_active add the maintenance lens. Records on paper the "complementary, not a replacement" positioning the gem is built on. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- Gemfile.lock | 4 +- README.md | 101 +++++++++++++++++++++++++++++------- lib/still_active/version.rb | 2 +- 4 files changed, 85 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 018e8f0..e235abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.5.0] - Unreleased +## [1.5.0] - 2026-05-23 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index d3f9270..69af580 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - still_active (1.4.2) + still_active (1.5.0) async bundler (>= 2.0) faraday-retry @@ -243,7 +243,7 @@ CHECKSUMS ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 sawyer (0.9.3) sha256=0d0f19298408047037638639fe62f4794483fb04320269169bd41af2bdcf5e41 simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 - still_active (1.4.2) + still_active (1.5.0) stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 diff --git a/README.md b/README.md index 961bdfe..6b3e332 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ `bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your Gemfile. -Findings ship as **terminal / markdown / JSON / SARIF** — the last lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep. +Findings ship as **terminal / markdown / JSON / SARIF / CycloneDX** — SARIF lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`; CycloneDX feeds Trivy / Dependency-Track / Snyk. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep. [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active) [![GitHub Action](https://img.shields.io/badge/Marketplace-still__active--action-2ea44f?logo=github)](https://github.com/marketplace/actions/still_active) @@ -13,15 +13,15 @@ Findings ship as **terminal / markdown / JSON / SARIF** — the last lands in yo ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg) ``` -Name Version Activity OpenSSF Vulns -─────────────────────────────────────────────────────────────────── -async 2.36.0 (latest) ok 7.1/10 0 -backbone-rails 1.2.3 (latest) archived 3.6/10 0 -bootstrap-slider-rails 9.8.0 (latest) critical - 0 -gitlab-markup 2.0.0 (latest) ok - 0 -local_gem 0.1.0 (path) - - 0 -nested_form 0.3.2 (git) archived 3.3/10 0 -remotipart 1.4.4 (git) critical 3.1/10 0 +Name Version Activity OpenSSF Vulns License +────────────────────────────────────────────────────────────────────────────── +async 2.36.0 (latest) ok 7.1/10 0 MIT +backbone-rails 1.2.3 (latest) archived 3.6/10 0 MIT +bootstrap-slider-rails 9.8.0 (latest) critical - 0 MIT +gitlab-markup 2.0.0 (latest) ok - 0 MIT +local_gem 0.1.0 (path) - - 0 - +nested_form 0.3.2 (git) archived 3.3/10 0 MIT +remotipart 1.4.4 (git) critical 3.1/10 0 MIT 7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities Ruby 4.0.1 (latest) @@ -34,7 +34,7 @@ Ruby 4.0.1 (latest) | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** | | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ | | Outdated versions | Yes | - | Yes | Yes | -| Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev) | +| Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) | | Libyear drift | - | - | Yes | Yes | | **Last commit activity** | - | - | - | **Yes** | | **Archived repo detection** | - | - | - | **Yes** | @@ -43,9 +43,9 @@ Ruby 4.0.1 (latest) | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) | | GitLab support | - | - | - | Yes | | CI quality gates | - | Exit code | - | Yes (4 flags) | -| Output formats | Text | Text | Text | Terminal, JSON, Markdown | +| Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX | -The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` and `still_active` use **different data sources** (`ruby-advisory-db` vs `deps.dev`), so coverage isn't identical. If you care about CVEs in CI, keep running `bundler-audit` alongside `still_active`. +The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` reads `ruby-advisory-db` and `still_active` reads `deps.dev`, which sometimes diverge. **If `bundler-audit` is installed alongside `still_active`, we read its `ruby-advisory-db` checkout too and merge the results** (deduplicated, each advisory tagged with its `source`) — so running both no longer means reconciling two different vuln counts by hand. ## Installation @@ -100,6 +100,8 @@ Usage: still_active [options] --markdown Markdown table output --json JSON output (default when piped) --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning + --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path) + --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7 --baseline=PATH Compare current state to baseline JSON; emit markdown deltas --github-oauth-token=TOKEN GitHub OAuth token to make API calls --gitlab-token=TOKEN GitLab personal access token for API calls @@ -143,6 +145,7 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile "archived": false, "scorecard_score": 7.1, "vulnerability_count": 0, + "license": "MIT", "libyear": 0.0 }, "nested_form": { @@ -176,15 +179,25 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile still_active --markdown ``` -| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | -| -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | -| | ✅ | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | ❓ | [2026/01](https://github.com/socketry/async) | 0.0y | -| 🚩 | ✅ | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y | -| ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | -| 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | +| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license | +| -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | ------- | +| | ✅ | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | ❓ | [2026/01](https://github.com/socketry/async) | 0.0y | MIT | +| 🚩 | ✅ | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y | MIT | +| ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | - | +| 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | MIT | **Ruby 4.0.1** (latest) ✅ +**CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, Snyk, or GitHub's dependency-submission API: + +```bash +still_active --cyclonedx # CycloneDX 1.6 to stdout +still_active --cyclonedx=sbom.json # write to a file +still_active --cyclonedx --cyclonedx-version=1.7 +``` + +Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java`/Dependency-Track and `cyclonedx-go`/Trivy both cap at 1.6 as of 2026); `--cyclonedx-version=1.7` opts into the latest. Gem name/version/`purl`/licenses and vulnerabilities map to native CycloneDX fields; maintenance signals with no native home (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`. The `serialNumber` is content-derived, so two SBOMs of the same lockfile differ only by their generation timestamp. + ### SARIF output (GitHub Code Scanning) Emit findings as SARIF 2.1.0 — they show up in the GitHub Security tab and as inline annotations on `Gemfile.lock` in pull requests. @@ -247,6 +260,53 @@ In CI, capture a baseline on main and compare on PR branches. Exits 1 if any reg The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set. +When a run is detected as Dependabot- or Renovate-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject), the report leads with a one-line narrative — "Dependabot bump: `rack` 2.0.0 → 2.0.6" — and `--json` gains a top-level `pr_context`. Detection is best-effort and conservative: it never produces a false positive on an ordinary commit, and a miss costs only the narrative line. + +### Alongside `dependency-review-action` + +GitHub's first-party [`dependency-review-action`](https://github.com/actions/dependency-review-action) runs server-side on PRs and surfaces **vulnerabilities, licenses, and OpenSSF Scorecard** scores from GitHub's dependency-graph diff. It does not surface maintenance signals — last-commit activity, archived repos, libyear, Ruby EOL, or yanked versions — and is GitHub.com / GHES only. `still_active` is the complement, not a replacement: + +| | `dependency-review-action` | `still_active` | +| ---------------------------- | ---------------------------------- | ------------------------------------------- | +| Platform | GitHub.com / GHES only | Any CI | +| Languages | Multi (GitHub dep graph) | Ruby | +| Vulnerabilities | GHSA | deps.dev + ruby-advisory-db (merged) | +| Licenses | Yes (allow/deny gating) | Surfaced (no gating) | +| OpenSSF Scorecard | Yes (display) | Yes (display + threshold) | +| **Last-commit activity** | - | **Yes** | +| **Archived repo detection** | - | **Yes** | +| **Libyear drift** | - | **Yes** | +| **Ruby EOL detection** | - | **Yes** | +| **Yanked version detection** | - | **Yes** | +| Diff vs base | Native (GitHub API) | `--baseline=FILE` | +| Output | Inline PR annotations | Terminal / Markdown / JSON / SARIF / CycloneDX | + +Run both: let `dependency-review-action` gate CVEs and licenses, and `still_active` add the maintenance lens on the same PR. + +```yaml +on: pull_request + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + show-openssf-scorecard: true + + maintenance-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: ".ruby-version", bundler-cache: true } + - uses: SeanLF/still_active-action@v0 + with: + fail-if-critical: true +``` + ### CI quality gating Use exit-code flags to fail CI pipelines based on dependency status: @@ -281,9 +341,10 @@ Activity is determined by the most recent signal across last commit date, latest ### Data sources -- **Versions and release dates** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages) +- **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages) - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API +- **Additional advisories** from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db), merged in when `bundler-audit` is installed alongside (run `bundle audit update` to keep its checkout current) - **Ruby version freshness** from [endoflife.date](https://endoflife.date) ### Configuration defaults diff --git a/lib/still_active/version.rb b/lib/still_active/version.rb index e6f36a8..91ebe76 100644 --- a/lib/still_active/version.rb +++ b/lib/still_active/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module StillActive - VERSION = "1.4.2" + VERSION = "1.5.0" end From 1f35a4bbbf28ff533e0f0ba65630e00fa520f397 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 10:31:55 +0100 Subject: [PATCH 06/12] fix: emit one CycloneDX license entry per SPDX id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionHelper joins multiple licenses with ", " for terminal/markdown display ("Hippocratic-2.1, MIT"); the CycloneDX renderer was feeding that joined string straight into license.id, which must be a single SPDX identifier. A joined value is neither a valid id nor a valid SPDX expression, so SBOM validators and Dependency-Track's license matcher reject or drop it — undercutting the "feeds Trivy/Dependency-Track" claim. Split back into one license entry per id. Surfaced by a full-branch review (every test used single-license gems, so it slipped through); confirmed fixed against vcr (Hippocratic-2.1, MIT) and debug (Ruby, BSD-2-Clause). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/helpers/cyclonedx_helper.rb | 9 ++++++++- spec/still_active/cyclonedx_helper_spec.rb | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/helpers/cyclonedx_helper.rb b/lib/helpers/cyclonedx_helper.rb index baa3da4..421db39 100644 --- a/lib/helpers/cyclonedx_helper.rb +++ b/lib/helpers/cyclonedx_helper.rb @@ -56,7 +56,7 @@ def gem_component(name, data) component["version"] = version if version component["bom-ref"] = bom_ref(name, data) component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version - component["licenses"] = [{ "license" => { "id" => data[:license] } }] if data[:license] + component["licenses"] = licenses(data[:license]) if data[:license] if data[:repository_url] component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }] end @@ -76,6 +76,13 @@ def purl(name, version) "pkg:gem/#{name}@#{version}" end + # VersionHelper joins multiple SPDX ids with ", " for terminal/markdown + # display; CycloneDX's license.id must be a single SPDX id, so split back + # into one entry per license rather than emitting an invalid joined id. + def licenses(license) + license.split(", ").map { |id| { "license" => { "id" => id } } } + end + def gem_properties(data) { "still_active:archived" => boolean_property(data[:archived]), diff --git a/spec/still_active/cyclonedx_helper_spec.rb b/spec/still_active/cyclonedx_helper_spec.rb index c5b1ea2..8f61b15 100644 --- a/spec/still_active/cyclonedx_helper_spec.rb +++ b/spec/still_active/cyclonedx_helper_spec.rb @@ -75,6 +75,15 @@ def render(spec_version: "1.6") expect(rack["licenses"]).to(eq([{ "license" => { "id" => "MIT" } }])) end + it("splits a multi-license gem into one valid SPDX entry each (not a comma-joined id)") do + result["multi"] = { source_type: :rubygems, version_used: "1.0.0", license: "Hippocratic-2.1, MIT", vulnerability_count: 0, vulnerabilities: [] } + multi = components.find { |c| c["name"] == "multi" } + expect(multi["licenses"]).to(eq([ + { "license" => { "id" => "Hippocratic-2.1" } }, + { "license" => { "id" => "MIT" } }, + ])) + end + it("carries the repository URL as a vcs externalReference") do rack = components.find { |c| c["name"] == "rack" } expect(rack["externalReferences"]).to(include("type" => "vcs", "url" => "https://github.com/rack/rack")) From 99fc86bf6e4f3c7a54fc49d2991473b397faf98d Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 18:15:36 +0100 Subject: [PATCH 07/12] feat: tolerate Dependabot/Renovate commit-message prefix config when extracting bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot's commit-message.prefix / prefix-development / include:scope (and Renovate's commitMessagePrefix) change the subject prefix, so a configured "chore(deps): bump …" or "deps: bump …" previously fell back to the generic "Dependabot dependency update" narrative even though the bot was detected. Separate the two concerns: detection patterns stay anchored and conservative (they must never false-positive on a human commit), while extraction now uses unanchored skeleton patterns. Extraction only runs after a bot is already confirmed via GITHUB_ACTOR/branch/anchored-subject, so ignoring the prefix there can't widen detection — the human-commit false-positive guards still pass. Detection was already prefix-independent in CI (it keys off GITHUB_ACTOR and the branch); this fixes the narrative for custom-prefix configs. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/helpers/bot_context.rb | 12 ++++++++++-- spec/still_active/bot_context_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/helpers/bot_context.rb b/lib/helpers/bot_context.rb index 674a7bc..5144fa1 100644 --- a/lib/helpers/bot_context.rb +++ b/lib/helpers/bot_context.rb @@ -23,6 +23,14 @@ module BotContext # positives are not. The `v` is consumed, so the captured version excludes it. RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i + # Unanchored variants used only to EXTRACT the bump *after* a bot is already + # confirmed (via GITHUB_ACTOR / branch / the anchored subject above). Because + # detection has already happened, these can ignore whatever commit-message + # prefix or scope Dependabot/Renovate is configured with and just find the + # "bump X from Y to Z" / "update X to vN" skeleton anywhere in the subject. + DEPENDABOT_BUMP = /bump (\S+) from (\S+) to (\S+)/i + RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i + # Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] } # or nil when no bot signal is present. `bumps` is parsed from the head # commit subject; a grouped or unparseable subject yields an empty list. @@ -65,9 +73,9 @@ def detect_bot(env:, head_subject:) def bumps_from(bot, subject) return [] if subject.nil? - if bot == "dependabot" && (match = subject.match(DEPENDABOT_SUBJECT)) + if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP)) [{ gem: match[1], from: match[2], to: match[3] }] - elsif bot == "renovate" && (match = subject.match(RENOVATE_SUBJECT)) + elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP)) [{ gem: match[1], from: nil, to: match[2] }] else [] diff --git a/spec/still_active/bot_context_spec.rb b/spec/still_active/bot_context_spec.rb index 8c24af1..500f06a 100644 --- a/spec/still_active/bot_context_spec.rb +++ b/spec/still_active/bot_context_spec.rb @@ -62,6 +62,30 @@ end end + # Dependabot's commit-message.prefix / prefix-development / include:scope configs + # change the subject prefix. Detection still fires via GITHUB_ACTOR; extraction + # must tolerate any prefix/scope around the "bump X from Y to Z" skeleton. + context("when Dependabot is configured with a custom commit-message prefix or scope") do + [ + "chore(deps): bump rack from 2.0.0 to 2.0.6", + "chore: bump rack from 2.0.0 to 2.0.6", + "deps: bump rack from 2.0.0 to 2.0.6", + "build(deps-dev): bump rack from 2.0.0 to 2.0.6", + ].each do |subject| + it("still extracts the bump from #{subject.inspect}") do + result = described_class.detect(env: { "GITHUB_ACTOR" => "dependabot[bot]" }, head_subject: subject) + expect(result[:bumps]).to(eq([{ gem: "rack", from: "2.0.0", to: "2.0.6" }])) + end + end + end + + context("when Renovate is configured with a custom commit-message prefix") do + it("extracts the bump from a prefixed Renovate subject") do + result = described_class.detect(env: { "GITHUB_ACTOR" => "renovate[bot]" }, head_subject: "chore(deps): update dependency rack to v2.0.6") + expect(result[:bumps]).to(eq([{ gem: "rack", from: nil, to: "2.0.6" }])) + end + end + context("when the bot is detected but the subject does not parse (e.g. grouped update)") do it("returns the bot with no bumps") do result = described_class.detect( From 1e71a790378eca110d8495394a5c8040d90ddad5 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 18:15:46 +0100 Subject: [PATCH 08/12] feat: warn when mutually-exclusive output flags are combined --baseline, --sarif, and --cyclonedx are mutually exclusive and resolved by precedence (baseline > sarif > cyclonedx > terminal/markdown/json). Combining them previously dropped the losers silently. Now a stderr warning names the mode that wins and the ones ignored, and a separate warning fires when --cyclonedx-version is set without --cyclonedx (no effect otherwise). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/still_active/cli.rb | 25 +++++++++++++++++++++++++ lib/still_active/options.rb | 1 + spec/still_active/cli_spec.rb | 25 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/lib/still_active/cli.rb b/lib/still_active/cli.rb index 651dec6..ab8a752 100644 --- a/lib/still_active/cli.rb +++ b/lib/still_active/cli.rb @@ -28,6 +28,8 @@ def run(args) end end + warn_output_flag_conflicts(options) + result = if $stderr.tty? Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") } else @@ -69,6 +71,29 @@ def run(args) private + # The output destinations are mutually exclusive and resolved by precedence + # (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than + # silently dropping the loser when more than one is set. + def warn_output_flag_conflicts(options) + modes = active_output_modes + if modes.size > 1 + $stderr.puts("warning: multiple output modes set (#{modes.join(", ")}); using #{modes.first}, ignoring #{modes.drop(1).join(", ")}") + end + if options[:provided_cyclonedx_version] && StillActive.config.cyclonedx_path.nil? + $stderr.puts("warning: --cyclonedx-version has no effect without --cyclonedx") + end + end + + # In precedence order, so the first entry is the one that actually runs. + def active_output_modes + config = StillActive.config + [ + ("--baseline" if config.baseline_path), + ("--sarif" if config.sarif_path), + ("--cyclonedx" if config.cyclonedx_path), + ].compact + end + def emit_sarif(result, ruby_info, sarif_path) lockfile = resolve_lockfile_path(StillActive.config.gemfile_path) unless File.exist?(lockfile) diff --git a/lib/still_active/options.rb b/lib/still_active/options.rb index 475aed9..7935cf3 100644 --- a/lib/still_active/options.rb +++ b/lib/still_active/options.rb @@ -78,6 +78,7 @@ def add_output_options(opts) supported = StillActive::CyclonedxHelper::SUPPORTED_SPEC_VERSIONS raise ArgumentError, "--cyclonedx-version must be one of: #{supported.join(", ")} (got #{value})" unless supported.include?(value) + options[:provided_cyclonedx_version] = true StillActive.config { |config| config.cyclonedx_version = value } end end diff --git a/spec/still_active/cli_spec.rb b/spec/still_active/cli_spec.rb index 6dfa9bf..46ea13c 100644 --- a/spec/still_active/cli_spec.rb +++ b/spec/still_active/cli_spec.rb @@ -436,6 +436,31 @@ def write_baseline(path, gems) end end + describe("conflicting output flags") do + before { allow($stdout).to(receive(:tty?).and_return(false)) } + + it("warns which mode wins when --sarif and --cyclonedx are combined") do + expect do + Dir.mktmpdir do |dir| + File.write("#{dir}/Gemfile", "") + File.write("#{dir}/Gemfile.lock", "GEM\n remote: https://rubygems.org/\n specs:\n rack (1.0)\n") + StillActive.config.gemfile_path = "#{dir}/Gemfile" + cli.run(["--gems=rack", "--sarif=-", "--cyclonedx=-"]) + end + end.to(output(/multiple output modes set.*using --sarif.*ignoring --cyclonedx/m).to_stderr) + end + + it("warns that --cyclonedx-version has no effect without --cyclonedx") do + expect { cli.run(["--gems=rack", "--cyclonedx-version=1.7"]) } + .to(output(/--cyclonedx-version has no effect without --cyclonedx/).to_stderr) + end + + it("does not warn for a single output mode") do + expect { cli.run(["--gems=rack", "--cyclonedx=-"]) } + .not_to(output(/multiple output modes/).to_stderr) + end + end + describe("Dependabot/Renovate context") do let(:workflow_result) { { "rack" => gem_data(last_commit_date: recent_date) } } let(:context) { { bot: "dependabot", bumps: [{ gem: "rack", from: "2.0.0", to: "2.0.6" }] } } From c3ca01b0febfe1ed0e21de1719592404c3479893 Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 18:16:18 +0100 Subject: [PATCH 09/12] docs: note prefix-tolerant bump extraction and output-flag warnings in 1.5.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e235abb..ca99cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### Added - `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk / GitHub's dependency-submission API. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly. -- Dependabot/Renovate awareness: when a run is detected as bot-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject — including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Best-effort and conservative: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. +- Dependabot/Renovate awareness: when a run is detected as bot-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject — including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. +- A warning is emitted when mutually-exclusive output flags are combined (`--baseline`/`--sarif`/`--cyclonedx`), naming which one wins, and when `--cyclonedx-version` is set without `--cyclonedx`. - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. From e3d432fba378a08f00e348030ed18b3a2b12c7dd Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 19:06:53 +0100 Subject: [PATCH 10/12] feat: detect bot PRs from the event payload author, not just GITHUB_ACTOR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research (dependabot/fetch-metadata) confirms the authoritative signal is the PR author in the GitHub event payload (pull_request.user.login), not GITHUB_ACTOR. GITHUB_ACTOR reflects who triggered *this* run, so it flips to a human who re-runs the workflow or pushes to the bot's branch, while the PR author does not. Make pull_request.user.login the primary signal, keeping GITHUB_ACTOR/branch/subject as fallbacks. pr_author_login never raises: a missing/unreadable/malformed-or-wrong-shape payload falls through to the weaker signals (rescue includes TypeError so a top-level array or non-Hash pull_request/user can't crash the audit over a cosmetic narrative — detect runs unguarded in CLI#run). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- lib/helpers/bot_context.rb | 24 +++++++++++ spec/still_active/bot_context_spec.rb | 60 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca99cf4..a170808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk / GitHub's dependency-submission API. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly. -- Dependabot/Renovate awareness: when a run is detected as bot-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject — including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. +- Dependabot/Renovate awareness: when a run is detected as bot-authored (primarily via the PR author in the GitHub event payload — `pull_request.user.login`, the same authoritative signal `dependabot/fetch-metadata` uses, which unlike `GITHUB_ACTOR` survives a human re-running the workflow — falling back to `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. - A warning is emitted when mutually-exclusive output flags are combined (`--baseline`/`--sarif`/`--cyclonedx`), naming which one wins, and when `--cyclonedx-version` is set without `--cyclonedx`. - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`. diff --git a/lib/helpers/bot_context.rb b/lib/helpers/bot_context.rb index 5144fa1..64ae60c 100644 --- a/lib/helpers/bot_context.rb +++ b/lib/helpers/bot_context.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "json" require "open3" module StillActive @@ -56,6 +57,13 @@ def summary(context) private def detect_bot(env:, head_subject:) + # The PR author from the event payload is the authoritative signal — it's + # what `dependabot/fetch-metadata` keys on, and unlike GITHUB_ACTOR it + # doesn't flip to a human who re-runs the workflow or pushes to the branch. + login = pr_author_login(env) + return "dependabot" if login == "dependabot[bot]" + return "renovate" if login == "renovate[bot]" + actor = env["GITHUB_ACTOR"] return "dependabot" if actor == "dependabot[bot]" return "renovate" if actor == "renovate[bot]" @@ -70,6 +78,22 @@ def detect_bot(env:, head_subject:) nil end + # Reads pull_request.user.login from the GitHub Actions event payload + # (GITHUB_EVENT_PATH). Returns nil off Actions, on non-PR events, or if the + # file is missing/unreadable/malformed — all of which just fall through to + # the weaker signals. TypeError covers a payload that parses but has the + # wrong shape (e.g. a top-level array, or pull_request/user not a Hash); + # this method must never raise, since detect runs unguarded and a cosmetic + # narrative must not be able to abort the audit. + def pr_author_login(env) + path = env["GITHUB_EVENT_PATH"] + return if path.nil? || !File.file?(path) + + JSON.parse(File.read(path)).dig("pull_request", "user", "login") + rescue JSON::ParserError, SystemCallError, TypeError + nil + end + def bumps_from(bot, subject) return [] if subject.nil? diff --git a/spec/still_active/bot_context_spec.rb b/spec/still_active/bot_context_spec.rb index 500f06a..3fa492e 100644 --- a/spec/still_active/bot_context_spec.rb +++ b/spec/still_active/bot_context_spec.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "tempfile" +require "json" + RSpec.describe(StillActive::BotContext) do describe(".detect") do it("returns nil when there is no bot signal") do @@ -13,6 +16,63 @@ end end + context("when the GitHub event payload names the PR author") do + def event_file(login) + f = Tempfile.new(["event", ".json"]) + f.write({ "pull_request" => { "user" => { "login" => login, "type" => "Bot" } } }.to_json) + f.flush + f + end + + it("detects Dependabot from pull_request.user.login") do + f = event_file("dependabot[bot]") + result = described_class.detect(env: { "GITHUB_EVENT_PATH" => f.path }, head_subject: nil) + expect(result[:bot]).to(eq("dependabot")) + ensure + f.close! + end + + it("detects Renovate from pull_request.user.login") do + f = event_file("renovate[bot]") + result = described_class.detect(env: { "GITHUB_EVENT_PATH" => f.path }, head_subject: nil) + expect(result[:bot]).to(eq("renovate")) + ensure + f.close! + end + + # The event author is the authoritative signal: it must win even when + # GITHUB_ACTOR is a human who re-ran or pushed to the bot's PR. + it("trusts the PR author over a human GITHUB_ACTOR") do + f = event_file("dependabot[bot]") + result = described_class.detect( + env: { "GITHUB_EVENT_PATH" => f.path, "GITHUB_ACTOR" => "octocat" }, + head_subject: "Bump rack from 2.0.0 to 2.0.6", + ) + expect(result[:bot]).to(eq("dependabot")) + ensure + f.close! + end + + it("falls through when the event file is missing or unreadable") do + result = described_class.detect(env: { "GITHUB_EVENT_PATH" => "/no/such/event.json", "GITHUB_ACTOR" => "renovate[bot]" }, head_subject: nil) + expect(result[:bot]).to(eq("renovate")) + end + + # A payload that parses but is the wrong shape must not crash the run. + ["[]", '{"pull_request":[]}', '{"pull_request":{"user":"oops"}}'].each do |malformed| + it("falls through (no crash) on a wrong-shape payload #{malformed.inspect}") do + f = Tempfile.new(["event", ".json"]) + f.write(malformed) + f.flush + result = nil + expect { result = described_class.detect(env: { "GITHUB_EVENT_PATH" => f.path }, head_subject: nil) }.not_to(raise_error) + expect(result).to(be_nil) + ensure + f.close! + end + end + end + context("when GITHUB_ACTOR is set") do it("detects Dependabot") do result = described_class.detect(env: { "GITHUB_ACTOR" => "dependabot[bot]" }, head_subject: nil) From 5b6a15bd6483e3a8261216e05932a489594e906a Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 19:25:14 +0100 Subject: [PATCH 11/12] docs: drop the GitHub dependency-submission claim for CycloneDX Verified against the docs: the dependency-submission API ingests a proprietary snapshot format, not CycloneDX directly (submission goes through SPDX/Anchore converter actions). Our --cyclonedx output doesn't feed it, so the claim was false. Trivy / Dependency-Track / Snyk are confirmed direct consumers (Snyk's `sbom test` takes CycloneDX 1.4-1.6 and requires the purl we emit), so they stay. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a170808..c4f7e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk / GitHub's dependency-submission API. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly. +- `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly. - Dependabot/Renovate awareness: when a run is detected as bot-authored (primarily via the PR author in the GitHub event payload — `pull_request.user.login`, the same authoritative signal `dependabot/fetch-metadata` uses, which unlike `GITHUB_ACTOR` survives a human re-running the workflow — falling back to `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`. - A warning is emitted when mutually-exclusive output flags are combined (`--baseline`/`--sarif`/`--cyclonedx`), naming which one wins, and when `--cyclonedx-version` is set without `--cyclonedx`. - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003). diff --git a/README.md b/README.md index 6b3e332..78fbe7c 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ still_active --markdown **Ruby 4.0.1** (latest) ✅ -**CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, Snyk, or GitHub's dependency-submission API: +**CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, or Snyk: ```bash still_active --cyclonedx # CycloneDX 1.6 to stdout From 26872c81d4d199d5172cc48579974367f04180df Mon Sep 17 00:00:00 2001 From: Sean Floyd Date: Sat, 23 May 2026 20:02:19 +0100 Subject: [PATCH 12/12] ci: fetch ruby-advisory-db in the PR-diff workflow to exercise dual-source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff workflow runs `bundle exec still_active`, so bundler-audit (a dev dependency) is on the load path — but its advisory DB ships separately and wasn't fetched, leaving the v1.5 dual-source merge dormant. Add a `bundle-audit update` step so our own CI actually exercises the deps.dev + ruby-advisory-db merge. Best-effort: on failure still_active falls back to deps.dev only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/still_active_diff.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/still_active_diff.yml b/.github/workflows/still_active_diff.yml index 6a1d488..5cfb3a1 100644 --- a/.github/workflows/still_active_diff.yml +++ b/.github/workflows/still_active_diff.yml @@ -35,6 +35,13 @@ jobs: working-directory: current run: bundle install --jobs 4 --retry 3 + # Fetch rubysec/ruby-advisory-db so still_active's dual-source merge is + # active (bundler-audit is a dev dependency; the DB ships separately). + # Best-effort: on failure still_active falls back to deps.dev only. + - name: Update ruby-advisory-db (enables dual-source vulnerabilities) + working-directory: current + run: bundle exec bundle-audit update || echo "::warning::bundle-audit update failed; still_active will use deps.dev only" + - name: Capture baseline JSON from main env: GITHUB_TOKEN: ${{ github.token }}