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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d4a8..c4f7e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.5.0] - 2026-05-23 + +### 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. 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). +- 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/Gemfile.lock b/Gemfile.lock index dac901f..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 @@ -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 @@ -237,8 +243,9 @@ 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 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 diff --git a/README.md b/README.md index 961bdfe..78fbe7c 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, or Snyk: + +```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/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 bc5c563..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 @@ -46,6 +47,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 @@ -57,8 +59,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/bot_context.rb b/lib/helpers/bot_context.rb new file mode 100644 index 0000000..64ae60c --- /dev/null +++ b/lib/helpers/bot_context.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "json" +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 + + # 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. + 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:) + # 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]" + + 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 + + # 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? + + if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP)) + [{ gem: match[1], from: match[2], to: match[3] }] + elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP)) + [{ 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/helpers/cyclonedx_helper.rb b/lib/helpers/cyclonedx_helper.rb new file mode 100644 index 0000000..421db39 --- /dev/null +++ b/lib/helpers/cyclonedx_helper.rb @@ -0,0 +1,159 @@ +# 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"] = licenses(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 + + # 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]), + "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/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/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/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/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/cli.rb b/lib/still_active/cli.rb index e4ca5e2..ab8a752 100644 --- a/lib/still_active/cli.rb +++ b/lib/still_active/cli.rb @@ -3,7 +3,9 @@ require_relative "options" require_relative "diff" 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" @@ -26,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 @@ -34,11 +38,14 @@ 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) + elsif (cyclonedx_path = StillActive.config.cyclonedx_path) + emit_cyclonedx(result, ruby_info, cyclonedx_path) else case resolve_format when :json @@ -49,11 +56,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 @@ -62,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) @@ -83,6 +115,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") @@ -90,10 +137,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 +173,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/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/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/options.rb b/lib/still_active/options.rb index c078f70..7935cf3 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,16 @@ 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) + + options[:provided_cyclonedx_version] = true + StillActive.config { |config| config.cyclonedx_version = value } + end end def add_token_options(opts) 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 diff --git a/lib/still_active/workflow.rb b/lib/still_active/workflow.rb index f0fad23..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), @@ -118,6 +126,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), @@ -126,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) @@ -142,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/bot_context_spec.rb b/spec/still_active/bot_context_spec.rb new file mode 100644 index 0000000..3fa492e --- /dev/null +++ b/spec/still_active/bot_context_spec.rb @@ -0,0 +1,190 @@ +# 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 + 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 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) + 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 + + # 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( + 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..46ea13c 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,102 @@ 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("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" }] } } + + 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) } } diff --git a/spec/still_active/cyclonedx_helper_spec.rb b/spec/still_active/cyclonedx_helper_spec.rb new file mode 100644 index 0000000..8f61b15 --- /dev/null +++ b/spec/still_active/cyclonedx_helper_spec.rb @@ -0,0 +1,141 @@ +# 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("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")) + 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 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/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/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/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/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 484fc54..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" }] @@ -204,6 +245,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", 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")