still_active 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32a0544a8668d86b73edc4296a246b875e3c5bfbc5016544ea2b1133e1332bb8
4
- data.tar.gz: bdbbaa17281dac2425cbe96a386164ae608566b3a4dd594e7e8d3d262a10f206
3
+ metadata.gz: 186f947f74db917e368e84dde7810495ae047720e9483dade42543028059530d
4
+ data.tar.gz: 756e3bc581887835db75fcc175ff3654c832c2e81550e1c9447a9f753c37339d
5
5
  SHA512:
6
- metadata.gz: a63140b613dc6eb0f7fe618a4845db833524993c00ab6773699dbbd150fead81ff5f74fad283444d6d84d5aa07ef79f8cba7ab055ae3e81d720bcbeaee52f800
7
- data.tar.gz: 00f898a6edefc7405e4e5d9a35b37c803b185b1e4e7edcbb76c8faf7eb9e93ef3e18660b1719b9db5b6d9961a4300dc1c3b017cd366bb195f6ea3e33b0f584bc
6
+ metadata.gz: 96ed9789ee0d1901d4e01beed98c094d22f800e32c23a447edb8cc11da9a751a2d6d94ca318bfee3748cce2329a0f79a5fcbc6850be18769296b3ffac07bdd19
7
+ data.tar.gz: b70bac1b02082583f41b49ab2521f01c436eba2275fa437adfe29de96b14a08554e0ae05e34a73dff181130f88127ac352831bb7d0698da9231b5574697c7e13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-05-23
4
+
5
+ ### Added
6
+
7
+ - `--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.
8
+ - 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`.
9
+ - 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`.
10
+ - 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).
11
+ - 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`.
12
+
3
13
  ## [1.4.2] - 2026-05-22
4
14
 
5
15
  ### Fixed
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  `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.
6
6
 
7
+ 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.
8
+
7
9
  [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active)
8
10
  [![GitHub Action](https://img.shields.io/badge/Marketplace-still__active--action-2ea44f?logo=github)](https://github.com/marketplace/actions/still_active)
9
11
  ![Code Quality analysis](https://github.com/SeanLF/still_active/actions/workflows/codeql-analysis.yml/badge.svg)
@@ -11,15 +13,15 @@
11
13
  ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg)
12
14
 
13
15
  ```
14
- Name Version Activity OpenSSF Vulns
15
- ───────────────────────────────────────────────────────────────────
16
- async 2.36.0 (latest) ok 7.1/10 0
17
- backbone-rails 1.2.3 (latest) archived 3.6/10 0
18
- bootstrap-slider-rails 9.8.0 (latest) critical - 0
19
- gitlab-markup 2.0.0 (latest) ok - 0
20
- local_gem 0.1.0 (path) - - 0
21
- nested_form 0.3.2 (git) archived 3.3/10 0
22
- remotipart 1.4.4 (git) critical 3.1/10 0
16
+ Name Version Activity OpenSSF Vulns License
17
+ ──────────────────────────────────────────────────────────────────────────────
18
+ async 2.36.0 (latest) ok 7.1/10 0 MIT
19
+ backbone-rails 1.2.3 (latest) archived 3.6/10 0 MIT
20
+ bootstrap-slider-rails 9.8.0 (latest) critical - 0 MIT
21
+ gitlab-markup 2.0.0 (latest) ok - 0 MIT
22
+ local_gem 0.1.0 (path) - - 0 -
23
+ nested_form 0.3.2 (git) archived 3.3/10 0 MIT
24
+ remotipart 1.4.4 (git) critical 3.1/10 0 MIT
23
25
 
24
26
  7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
25
27
  Ruby 4.0.1 (latest)
@@ -32,7 +34,7 @@ Ruby 4.0.1 (latest)
32
34
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
33
35
  | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
34
36
  | Outdated versions | Yes | - | Yes | Yes |
35
- | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev) |
37
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
36
38
  | Libyear drift | - | - | Yes | Yes |
37
39
  | **Last commit activity** | - | - | - | **Yes** |
38
40
  | **Archived repo detection** | - | - | - | **Yes** |
@@ -41,9 +43,9 @@ Ruby 4.0.1 (latest)
41
43
  | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
42
44
  | GitLab support | - | - | - | Yes |
43
45
  | CI quality gates | - | Exit code | - | Yes (4 flags) |
44
- | Output formats | Text | Text | Text | Terminal, JSON, Markdown |
46
+ | Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
45
47
 
46
- 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`.
48
+ 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.
47
49
 
48
50
  ## Installation
49
51
 
@@ -98,6 +100,8 @@ Usage: still_active [options]
98
100
  --markdown Markdown table output
99
101
  --json JSON output (default when piped)
100
102
  --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
103
+ --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
104
+ --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
101
105
  --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
102
106
  --github-oauth-token=TOKEN GitHub OAuth token to make API calls
103
107
  --gitlab-token=TOKEN GitLab personal access token for API calls
@@ -141,6 +145,7 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
141
145
  "archived": false,
142
146
  "scorecard_score": 7.1,
143
147
  "vulnerability_count": 0,
148
+ "license": "MIT",
144
149
  "libyear": 0.0
145
150
  },
146
151
  "nested_form": {
@@ -174,15 +179,25 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
174
179
  still_active --markdown
175
180
  ```
176
181
 
177
- | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |
178
- | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- |
179
- | | ✅ | 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 |
180
- | 🚩 | ✅ | 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 |
181
- | ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - |
182
- | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - |
182
+ | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |
183
+ | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | ------- |
184
+ | | ✅ | 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 |
185
+ | 🚩 | ✅ | 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 |
186
+ | ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | - |
187
+ | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | MIT |
183
188
 
184
189
  **Ruby 4.0.1** (latest) ✅
185
190
 
191
+ **CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, or Snyk:
192
+
193
+ ```bash
194
+ still_active --cyclonedx # CycloneDX 1.6 to stdout
195
+ still_active --cyclonedx=sbom.json # write to a file
196
+ still_active --cyclonedx --cyclonedx-version=1.7
197
+ ```
198
+
199
+ 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.
200
+
186
201
  ### SARIF output (GitHub Code Scanning)
187
202
 
188
203
  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.
@@ -245,6 +260,53 @@ In CI, capture a baseline on main and compare on PR branches. Exits 1 if any reg
245
260
 
246
261
  The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set.
247
262
 
263
+ 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.
264
+
265
+ ### Alongside `dependency-review-action`
266
+
267
+ 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:
268
+
269
+ | | `dependency-review-action` | `still_active` |
270
+ | ---------------------------- | ---------------------------------- | ------------------------------------------- |
271
+ | Platform | GitHub.com / GHES only | Any CI |
272
+ | Languages | Multi (GitHub dep graph) | Ruby |
273
+ | Vulnerabilities | GHSA | deps.dev + ruby-advisory-db (merged) |
274
+ | Licenses | Yes (allow/deny gating) | Surfaced (no gating) |
275
+ | OpenSSF Scorecard | Yes (display) | Yes (display + threshold) |
276
+ | **Last-commit activity** | - | **Yes** |
277
+ | **Archived repo detection** | - | **Yes** |
278
+ | **Libyear drift** | - | **Yes** |
279
+ | **Ruby EOL detection** | - | **Yes** |
280
+ | **Yanked version detection** | - | **Yes** |
281
+ | Diff vs base | Native (GitHub API) | `--baseline=FILE` |
282
+ | Output | Inline PR annotations | Terminal / Markdown / JSON / SARIF / CycloneDX |
283
+
284
+ Run both: let `dependency-review-action` gate CVEs and licenses, and `still_active` add the maintenance lens on the same PR.
285
+
286
+ ```yaml
287
+ on: pull_request
288
+
289
+ jobs:
290
+ dependency-review:
291
+ runs-on: ubuntu-latest
292
+ steps:
293
+ - uses: actions/checkout@v4
294
+ - uses: actions/dependency-review-action@v4
295
+ with:
296
+ fail-on-severity: high
297
+ show-openssf-scorecard: true
298
+
299
+ maintenance-review:
300
+ runs-on: ubuntu-latest
301
+ steps:
302
+ - uses: actions/checkout@v4
303
+ - uses: ruby/setup-ruby@v1
304
+ with: { ruby-version: ".ruby-version", bundler-cache: true }
305
+ - uses: SeanLF/still_active-action@v0
306
+ with:
307
+ fail-if-critical: true
308
+ ```
309
+
248
310
  ### CI quality gating
249
311
 
250
312
  Use exit-code flags to fail CI pipelines based on dependency status:
@@ -279,9 +341,10 @@ Activity is determined by the most recent signal across last commit date, latest
279
341
 
280
342
  ### Data sources
281
343
 
282
- - **Versions and release dates** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
344
+ - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
283
345
  - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
284
346
  - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
347
+ - **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)
285
348
  - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
286
349
 
287
350
  ### Configuration defaults
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module StillActive
7
+ # Best-effort detection of a Dependabot/Renovate-authored run, so --baseline
8
+ # reports can lead with a narrative ("Dependabot bump: rack 2.0.0 → 2.0.6")
9
+ # instead of an unattributed list. Detection is heuristic: false negatives are
10
+ # fine (we just lose the narrative), false positives are not, so the subject
11
+ # patterns are anchored and require the literal bump/update keyword.
12
+ module BotContext
13
+ extend self
14
+
15
+ # Dependabot's *default* subject is "Bump X from Y to Z" (capitalized, no
16
+ # prefix). The `from … to …` skeleton rarely occurs in human commits, so it
17
+ # is safe unprefixed. The conventional-commit prefix only appears when configured.
18
+ DEPENDABOT_SUBJECT = /\A(?:build\(deps(?:-dev)?\):\s*)?bump (\S+) from (\S+) to (\S+)/i
19
+
20
+ # Renovate's default is "Update dependency X to vN.…" — note the **required**
21
+ # `v`+digit version. Matching a bare "to <word>" would false-positive on ordinary
22
+ # commits ("Update README to mention SARIF"), so we anchor on the v-prefixed
23
+ # version. False negatives (a no-`v` Renovate config) are acceptable; false
24
+ # positives are not. The `v` is consumed, so the captured version excludes it.
25
+ RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
26
+
27
+ # Unanchored variants used only to EXTRACT the bump *after* a bot is already
28
+ # confirmed (via GITHUB_ACTOR / branch / the anchored subject above). Because
29
+ # detection has already happened, these can ignore whatever commit-message
30
+ # prefix or scope Dependabot/Renovate is configured with and just find the
31
+ # "bump X from Y to Z" / "update X to vN" skeleton anywhere in the subject.
32
+ DEPENDABOT_BUMP = /bump (\S+) from (\S+) to (\S+)/i
33
+ RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
34
+
35
+ # Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] }
36
+ # or nil when no bot signal is present. `bumps` is parsed from the head
37
+ # commit subject; a grouped or unparseable subject yields an empty list.
38
+ def detect(env: ENV, head_subject: head_commit_subject)
39
+ bot = detect_bot(env: env, head_subject: head_subject)
40
+ return if bot.nil?
41
+
42
+ { bot: bot, bumps: bumps_from(bot, head_subject) }
43
+ end
44
+
45
+ # A one-line, format-agnostic narrative for the detected context.
46
+ def summary(context)
47
+ label = context[:bot] == "renovate" ? "Renovate" : "Dependabot"
48
+ bumps = context[:bumps]
49
+
50
+ case bumps.length
51
+ when 0 then "#{label} dependency update"
52
+ when 1 then single_bump_summary(label, bumps.first)
53
+ else "#{label}: #{bumps.length} dependency updates"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def detect_bot(env:, head_subject:)
60
+ # The PR author from the event payload is the authoritative signal — it's
61
+ # what `dependabot/fetch-metadata` keys on, and unlike GITHUB_ACTOR it
62
+ # doesn't flip to a human who re-runs the workflow or pushes to the branch.
63
+ login = pr_author_login(env)
64
+ return "dependabot" if login == "dependabot[bot]"
65
+ return "renovate" if login == "renovate[bot]"
66
+
67
+ actor = env["GITHUB_ACTOR"]
68
+ return "dependabot" if actor == "dependabot[bot]"
69
+ return "renovate" if actor == "renovate[bot]"
70
+
71
+ ref = env["GITHUB_HEAD_REF"] || current_branch
72
+ return "dependabot" if ref&.start_with?("dependabot/")
73
+ return "renovate" if ref&.start_with?("renovate/", "renovate-bot/")
74
+
75
+ return "dependabot" if head_subject&.match?(DEPENDABOT_SUBJECT)
76
+ return "renovate" if head_subject&.match?(RENOVATE_SUBJECT)
77
+
78
+ nil
79
+ end
80
+
81
+ # Reads pull_request.user.login from the GitHub Actions event payload
82
+ # (GITHUB_EVENT_PATH). Returns nil off Actions, on non-PR events, or if the
83
+ # file is missing/unreadable/malformed — all of which just fall through to
84
+ # the weaker signals. TypeError covers a payload that parses but has the
85
+ # wrong shape (e.g. a top-level array, or pull_request/user not a Hash);
86
+ # this method must never raise, since detect runs unguarded and a cosmetic
87
+ # narrative must not be able to abort the audit.
88
+ def pr_author_login(env)
89
+ path = env["GITHUB_EVENT_PATH"]
90
+ return if path.nil? || !File.file?(path)
91
+
92
+ JSON.parse(File.read(path)).dig("pull_request", "user", "login")
93
+ rescue JSON::ParserError, SystemCallError, TypeError
94
+ nil
95
+ end
96
+
97
+ def bumps_from(bot, subject)
98
+ return [] if subject.nil?
99
+
100
+ if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP))
101
+ [{ gem: match[1], from: match[2], to: match[3] }]
102
+ elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP))
103
+ [{ gem: match[1], from: nil, to: match[2] }]
104
+ else
105
+ []
106
+ end
107
+ end
108
+
109
+ def single_bump_summary(label, bump)
110
+ arrow = bump[:from] ? "#{bump[:from]} → #{bump[:to]}" : "→ #{bump[:to]}"
111
+ verb = label == "Renovate" ? "update" : "bump"
112
+ "#{label} #{verb}: #{bump[:gem]} #{arrow}"
113
+ end
114
+
115
+ # SystemCallError (not just Errno::ENOENT) so a git that's missing *or*
116
+ # unlaunchable can't crash a run over a cosmetic narrative. git *logic*
117
+ # failures surface as a non-zero status, not an exception, and yield nil.
118
+ def current_branch
119
+ out, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD")
120
+ status.success? ? out.strip : nil
121
+ rescue SystemCallError
122
+ nil
123
+ end
124
+
125
+ def head_commit_subject
126
+ out, _, status = Open3.capture3("git", "log", "-1", "--pretty=%s")
127
+ status.success? ? out.strip : nil
128
+ rescue SystemCallError
129
+ nil
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+ require_relative "vulnerability_helper"
7
+
8
+ module StillActive
9
+ # Renders a still_active workflow result as a CycloneDX SBOM. Emits 1.6 by
10
+ # default (the version mainstream consumers — Dependency-Track via
11
+ # cyclonedx-core-java, Trivy/Syft via cyclonedx-go — actually ingest as of
12
+ # 2026); 1.7 is opt-in. Our emitted subset is identical across both versions,
13
+ # so only the specVersion string changes.
14
+ #
15
+ # Maintenance signals that have no native CycloneDX field (scorecard, libyear,
16
+ # archived, last commit) are emitted as `still_active:`-namespaced component
17
+ # properties — lossy by spec design, ignorable by consumers that don't care.
18
+ module CyclonedxHelper
19
+ extend self
20
+
21
+ SUPPORTED_SPEC_VERSIONS = ["1.6", "1.7"].freeze
22
+
23
+ # result: gem_name => gem_data (as StillActive::Workflow.call returns)
24
+ # ruby_info: Ruby freshness hash or nil
25
+ # now: injectable clock so output is deterministic in tests
26
+ def render(result:, ruby_info:, tool_version:, spec_version: "1.6", now: Time.now.utc)
27
+ components = build_components(result, ruby_info)
28
+ vulnerabilities = build_vulnerabilities(result)
29
+
30
+ document = {
31
+ "bomFormat" => "CycloneDX",
32
+ "specVersion" => spec_version,
33
+ "serialNumber" => deterministic_serial(components),
34
+ "version" => 1,
35
+ "metadata" => {
36
+ "timestamp" => now.iso8601,
37
+ "tools" => [{ "vendor" => "SeanLF", "name" => "still_active", "version" => tool_version }],
38
+ },
39
+ "components" => components,
40
+ }
41
+ document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
42
+ JSON.pretty_generate(document)
43
+ end
44
+
45
+ private
46
+
47
+ def build_components(result, ruby_info)
48
+ components = result.sort_by { |name, _| name.to_s }.map { |name, data| gem_component(name.to_s, data) }
49
+ components << ruby_component(ruby_info) if ruby_info && ruby_info[:version]
50
+ components
51
+ end
52
+
53
+ def gem_component(name, data)
54
+ version = data[:version_used]
55
+ component = { "type" => "library", "name" => name }
56
+ component["version"] = version if version
57
+ component["bom-ref"] = bom_ref(name, data)
58
+ component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version
59
+ component["licenses"] = licenses(data[:license]) if data[:license]
60
+ if data[:repository_url]
61
+ component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
62
+ end
63
+ properties = gem_properties(data)
64
+ component["properties"] = properties unless properties.empty?
65
+ component
66
+ end
67
+
68
+ def bom_ref(name, data)
69
+ version = data[:version_used]
70
+ return purl(name, version) if data[:source_type] == :rubygems && version
71
+
72
+ "#{data[:source_type]}-source:#{name}@#{version || "unknown"}"
73
+ end
74
+
75
+ def purl(name, version)
76
+ "pkg:gem/#{name}@#{version}"
77
+ end
78
+
79
+ # VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
80
+ # display; CycloneDX's license.id must be a single SPDX id, so split back
81
+ # into one entry per license rather than emitting an invalid joined id.
82
+ def licenses(license)
83
+ license.split(", ").map { |id| { "license" => { "id" => id } } }
84
+ end
85
+
86
+ def gem_properties(data)
87
+ {
88
+ "still_active:archived" => boolean_property(data[:archived]),
89
+ "still_active:scorecard_score" => data[:scorecard_score]&.to_s,
90
+ "still_active:libyear" => data[:libyear]&.to_s,
91
+ "still_active:last_commit_date" => iso8601(data[:last_commit_date]),
92
+ "still_active:version_yanked" => boolean_property(data[:version_yanked]),
93
+ }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
94
+ end
95
+
96
+ def ruby_component(ruby_info)
97
+ {
98
+ "type" => "platform",
99
+ "name" => "ruby",
100
+ "version" => ruby_info[:version],
101
+ "bom-ref" => "platform:ruby@#{ruby_info[:version]}",
102
+ "properties" => [
103
+ { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
104
+ { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
105
+ ].reject { |p| p["value"].nil? },
106
+ }
107
+ end
108
+
109
+ def build_vulnerabilities(result)
110
+ result.sort_by { |name, _| name.to_s }.flat_map do |name, data|
111
+ ref = bom_ref(name.to_s, data)
112
+ (data[:vulnerabilities] || []).map { |advisory| vulnerability(advisory, ref) }
113
+ end
114
+ end
115
+
116
+ def vulnerability(advisory, component_ref)
117
+ entry = {
118
+ "bom-ref" => "#{advisory[:id]}:#{component_ref}",
119
+ "id" => advisory[:id],
120
+ "affects" => [{ "ref" => component_ref }],
121
+ }
122
+ entry["source"] = { "name" => advisory[:source] } if advisory[:source]
123
+ advisory_rating = rating(advisory)
124
+ entry["ratings"] = [advisory_rating] if advisory_rating
125
+ entry
126
+ end
127
+
128
+ def rating(advisory)
129
+ score = advisory[:cvss3_score] || advisory[:cvss2_score]
130
+ return if score.nil?
131
+
132
+ method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2"
133
+ rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method }
134
+ rating["vector"] = advisory[:cvss3_vector] if advisory[:cvss3_vector]
135
+ rating
136
+ end
137
+
138
+ def boolean_property(value)
139
+ return if value.nil?
140
+
141
+ value.to_s
142
+ end
143
+
144
+ def iso8601(time)
145
+ return if time.nil?
146
+
147
+ time.respond_to?(:iso8601) ? time.iso8601 : time.to_s
148
+ end
149
+
150
+ # Deterministic urn:uuid derived from the component identifiers, so two SBOMs
151
+ # of the same lockfile are byte-identical (diffable; golden-test friendly).
152
+ def deterministic_serial(components)
153
+ basis = components.map { |c| c["bom-ref"] }.sort.join("\n")
154
+ hex = Digest::SHA256.hexdigest(basis)
155
+ uuid = "#{hex[0, 8]}-#{hex[8, 4]}-5#{hex[13, 3]}-8#{hex[17, 3]}-#{hex[20, 12]}"
156
+ "urn:uuid:#{uuid}"
157
+ end
158
+ end
159
+ end
@@ -26,8 +26,8 @@ module StillActive
26
26
  end
27
27
 
28
28
  def markdown_table_header_line
29
- "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \
30
- "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |"
29
+ "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |\n" \
30
+ "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- | ------- |"
31
31
  end
32
32
 
33
33
  def markdown_table_body_line(gem_name:, data:)
@@ -78,6 +78,7 @@ module StillActive
78
78
  formatted_latest_pre_release || unsure,
79
79
  formatted_last_commit || unsure,
80
80
  format_libyear(data[:libyear]),
81
+ format_license(data[:license]),
81
82
  ]
82
83
 
83
84
  "| #{cells.join(" | ")} |"
@@ -113,6 +114,12 @@ module StillActive
113
114
  "#{value}y"
114
115
  end
115
116
 
117
+ def format_license(license)
118
+ return "-" if license.nil? || license.empty?
119
+
120
+ license
121
+ end
122
+
116
123
  def format_vulns(data)
117
124
  count = data[:vulnerability_count]
118
125
  return StillActive.config.unsure_emoji if count.nil?
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Optional second vulnerability source: rubysec/ruby-advisory-db, read through
5
+ # bundler-audit's own loader when the user has it installed. We are a consumer —
6
+ # no YAML parsing or version-range matching of our own. Advisories are mapped
7
+ # into the same shape as deps.dev results and merged by VulnerabilityHelper.
8
+ #
9
+ # Verified against bundler-audit 0.9.3: Advisory CVSS scores live in #to_h
10
+ # (:cvss_v3 / :cvss_v2), not in dedicated methods; Database.new raises
11
+ # ArgumentError when the ~/.local/share/ruby-advisory-db checkout is absent.
12
+ module RubyAdvisoryDb
13
+ extend self
14
+
15
+ STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
16
+
17
+ # bundler-audit's Database#check_gem expects an object responding to
18
+ # #name and #version (a Gem::Version).
19
+ GemRef = Struct.new(:name, :version)
20
+
21
+ # Returns a loaded bundler-audit Database, or nil when bundler-audit isn't
22
+ # installed or its advisory checkout is absent. Never raises — a missing
23
+ # second source just means we fall back to deps.dev only.
24
+ def load
25
+ require "bundler/audit"
26
+ require "bundler/audit/database"
27
+ database = Bundler::Audit::Database.new
28
+ warn_if_stale(database)
29
+ database
30
+ rescue LoadError
31
+ nil # bundler-audit not installed
32
+ rescue ArgumentError
33
+ warn("still_active: ruby-advisory-db not found — run `bundle audit update` to enable dual-source vulnerability data")
34
+ nil
35
+ end
36
+
37
+ # Maps advisories the database reports for gem_name@version into our
38
+ # vulnerability shape. Returns [] when the database is unavailable or the
39
+ # version can't be parsed (e.g. a git sha). A malformed advisory in the
40
+ # checkout (a corrupt/partial `bundle audit update`) is surfaced, not
41
+ # swallowed — silently returning [] there would hide a missed vulnerability.
42
+ def advisories_for(database:, gem_name:, version:)
43
+ return [] if database.nil?
44
+
45
+ parsed = parse_version(version)
46
+ return [] if parsed.nil?
47
+
48
+ advisories = []
49
+ database.check_gem(GemRef.new(gem_name, parsed)) { |advisory| advisories << to_vulnerability(advisory) }
50
+ advisories
51
+ rescue Gem::Requirement::BadRequirementError => e
52
+ warn("still_active: ruby-advisory-db has a malformed advisory for #{gem_name} (#{e.message}) — run `bundle audit update` to repair the checkout")
53
+ []
54
+ end
55
+
56
+ # Translates a bundler-audit Advisory into the deps.dev-compatible hash.
57
+ # bundler-audit has no CVSS vector, so cvss3_vector is always nil here.
58
+ def to_vulnerability(advisory)
59
+ primary = advisory.ghsa_id || advisory.cve_id || advisory.id
60
+ details = advisory.to_h
61
+ {
62
+ id: primary,
63
+ url: details[:url],
64
+ title: details[:title],
65
+ aliases: advisory.identifiers.reject { |identifier| identifier == primary },
66
+ cvss3_score: details[:cvss_v3],
67
+ cvss3_vector: nil,
68
+ cvss2_score: details[:cvss_v2],
69
+ source: "ruby-advisory-db",
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ # nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
76
+ # has nothing to match in the advisory DB, so the caller returns [].
77
+ def parse_version(version)
78
+ Gem::Version.new(version)
79
+ rescue ArgumentError
80
+ nil
81
+ end
82
+
83
+ def warn_if_stale(database)
84
+ updated = database.last_updated_at
85
+ return if updated.nil? # can't determine age — don't warn (not a swallowed error)
86
+
87
+ age = Time.now - updated
88
+ return if age < STALE_AFTER_SECONDS
89
+
90
+ warn("still_active: ruby-advisory-db is #{(age / 86_400).round} days old — run `bundle audit update` for current advisories")
91
+ end
92
+ end
93
+ end
@@ -10,7 +10,7 @@ module StillActive
10
10
  module TerminalHelper
11
11
  extend self
12
12
 
13
- HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"].freeze
13
+ HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "License"].freeze
14
14
 
15
15
  def render(result, ruby_info: nil)
16
16
  rows = result.keys.sort.map { |name| build_row(name, result[name]) }
@@ -35,9 +35,16 @@ module StillActive
35
35
  format_activity(data),
36
36
  format_scorecard(data[:scorecard_score]),
37
37
  format_vulns(data),
38
+ format_license(data[:license]),
38
39
  ]
39
40
  end
40
41
 
42
+ def format_license(license)
43
+ return AnsiHelper.dim("-") if license.nil? || license.empty?
44
+
45
+ license
46
+ end
47
+
41
48
  def format_version(data)
42
49
  used = data[:version_used]
43
50
  latest = data[:latest_version]
@@ -38,6 +38,15 @@ module StillActive
38
38
  Time.parse(release_date) unless release_date.nil?
39
39
  end
40
40
 
41
+ # SPDX license identifier(s) from the RubyGems versions payload.
42
+ # Comma-joined when a gem declares more than one. nil when unknown.
43
+ def license(version_hash:)
44
+ licenses = version_hash&.dig("licenses")
45
+ return if licenses.nil? || licenses.empty?
46
+
47
+ licenses.join(", ")
48
+ end
49
+
41
50
  private
42
51
 
43
52
  def normalize_version(version)
@@ -22,8 +22,43 @@ module StillActive
22
22
  SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold)
23
23
  end
24
24
 
25
+ # Combines advisories from deps.dev and ruby-advisory-db (via bundler-audit),
26
+ # deduplicating on shared identifiers. deps.dev is preferred for CVSS/title/url
27
+ # (it carries the vector string); ruby-advisory-db fills gaps. Advisories present
28
+ # in both sources are tagged source: "merged"; otherwise the per-source tag is kept.
29
+ def merge_advisories(deps_dev:, ruby_advisory_db:)
30
+ merged = deps_dev.map(&:dup)
31
+
32
+ ruby_advisory_db.each do |advisory|
33
+ existing = merged.find { |m| identifiers(m).intersect?(identifiers(advisory)) }
34
+ if existing
35
+ combine!(existing, advisory)
36
+ else
37
+ merged << advisory
38
+ end
39
+ end
40
+
41
+ merged
42
+ end
43
+
25
44
  private
26
45
 
46
+ def identifiers(advisory)
47
+ [advisory[:id], *advisory[:aliases]].compact
48
+ end
49
+
50
+ # Folds a ruby-advisory-db advisory into a matching deps.dev advisory in place:
51
+ # deps.dev values win where present, ruby-advisory-db fills nils, aliases union.
52
+ def combine!(into, from)
53
+ into[:cvss3_score] ||= from[:cvss3_score]
54
+ into[:cvss2_score] ||= from[:cvss2_score]
55
+ into[:cvss3_vector] ||= from[:cvss3_vector]
56
+ into[:title] ||= from[:title]
57
+ into[:url] ||= from[:url]
58
+ into[:aliases] = (identifiers(into) | identifiers(from)).reject { |id| id == into[:id] }.sort
59
+ into[:source] = "merged"
60
+ end
61
+
27
62
  def severity_label(score)
28
63
  case score
29
64
  when 9.0..Float::INFINITY then "critical"
@@ -3,7 +3,9 @@
3
3
  require_relative "options"
4
4
  require_relative "diff"
5
5
  require_relative "../helpers/activity_helper"
6
+ require_relative "../helpers/bot_context"
6
7
  require_relative "../helpers/bundler_helper"
8
+ require_relative "../helpers/cyclonedx_helper"
7
9
  require_relative "../helpers/diff_markdown_helper"
8
10
  require_relative "../helpers/emoji_helper"
9
11
  require_relative "../helpers/markdown_helper"
@@ -26,6 +28,8 @@ module StillActive
26
28
  end
27
29
  end
28
30
 
31
+ warn_output_flag_conflicts(options)
32
+
29
33
  result = if $stderr.tty?
30
34
  Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
31
35
  else
@@ -34,11 +38,14 @@ module StillActive
34
38
  $stderr.print("\r\e[K") if $stderr.tty?
35
39
 
36
40
  ruby_info = Workflow.ruby_freshness
41
+ pr_context = BotContext.detect
37
42
 
38
43
  if (baseline_path = StillActive.config.baseline_path)
39
- emit_diff(result, ruby_info, baseline_path)
44
+ emit_diff(result, ruby_info, baseline_path, pr_context)
40
45
  elsif (sarif_path = StillActive.config.sarif_path)
41
46
  emit_sarif(result, ruby_info, sarif_path)
47
+ elsif (cyclonedx_path = StillActive.config.cyclonedx_path)
48
+ emit_cyclonedx(result, ruby_info, cyclonedx_path)
42
49
  else
43
50
  case resolve_format
44
51
  when :json
@@ -49,11 +56,13 @@ module StillActive
49
56
  gems: result,
50
57
  }
51
58
  output[:ruby] = ruby_info if ruby_info
59
+ output[:pr_context] = pr_context if pr_context
52
60
  puts output.to_json
53
61
  when :terminal
62
+ puts BotContext.summary(pr_context) if pr_context
54
63
  puts TerminalHelper.render(result, ruby_info: ruby_info)
55
64
  when :markdown
56
- render_markdown(result, ruby_info: ruby_info)
65
+ render_markdown(result, ruby_info: ruby_info, pr_context: pr_context)
57
66
  end
58
67
  end
59
68
 
@@ -62,6 +71,29 @@ module StillActive
62
71
 
63
72
  private
64
73
 
74
+ # The output destinations are mutually exclusive and resolved by precedence
75
+ # (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than
76
+ # silently dropping the loser when more than one is set.
77
+ def warn_output_flag_conflicts(options)
78
+ modes = active_output_modes
79
+ if modes.size > 1
80
+ $stderr.puts("warning: multiple output modes set (#{modes.join(", ")}); using #{modes.first}, ignoring #{modes.drop(1).join(", ")}")
81
+ end
82
+ if options[:provided_cyclonedx_version] && StillActive.config.cyclonedx_path.nil?
83
+ $stderr.puts("warning: --cyclonedx-version has no effect without --cyclonedx")
84
+ end
85
+ end
86
+
87
+ # In precedence order, so the first entry is the one that actually runs.
88
+ def active_output_modes
89
+ config = StillActive.config
90
+ [
91
+ ("--baseline" if config.baseline_path),
92
+ ("--sarif" if config.sarif_path),
93
+ ("--cyclonedx" if config.cyclonedx_path),
94
+ ].compact
95
+ end
96
+
65
97
  def emit_sarif(result, ruby_info, sarif_path)
66
98
  lockfile = resolve_lockfile_path(StillActive.config.gemfile_path)
67
99
  unless File.exist?(lockfile)
@@ -83,6 +115,21 @@ module StillActive
83
115
  end
84
116
  end
85
117
 
118
+ def emit_cyclonedx(result, ruby_info, cyclonedx_path)
119
+ sbom = CyclonedxHelper.render(
120
+ result: result,
121
+ ruby_info: ruby_info,
122
+ tool_version: StillActive::VERSION,
123
+ spec_version: StillActive.config.cyclonedx_version,
124
+ )
125
+
126
+ if cyclonedx_path == "-"
127
+ puts sbom
128
+ else
129
+ File.write(cyclonedx_path, sbom)
130
+ end
131
+ end
132
+
86
133
  # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise <gemfile>.lock.
87
134
  def resolve_lockfile_path(gemfile)
88
135
  return gemfile.sub(/gems\.rb\z/, "gems.locked") if gemfile.end_with?("gems.rb")
@@ -90,10 +137,11 @@ module StillActive
90
137
  "#{gemfile}.lock"
91
138
  end
92
139
 
93
- def emit_diff(result, ruby_info, baseline_path)
140
+ def emit_diff(result, ruby_info, baseline_path, pr_context = nil)
94
141
  current = current_snapshot(result, ruby_info)
95
142
  baseline = JSON.parse(File.read(baseline_path))
96
143
  diff = Diff.call(baseline: baseline, current: current)
144
+ puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
97
145
  puts DiffMarkdownHelper.render(diff)
98
146
  exit(1) if diff.regressions.any?
99
147
  rescue JSON::ParserError => e
@@ -125,7 +173,8 @@ module StillActive
125
173
  $stdout.tty? ? :terminal : :json
126
174
  end
127
175
 
128
- def render_markdown(result, ruby_info: nil)
176
+ def render_markdown(result, ruby_info: nil, pr_context: nil)
177
+ puts "> **#{BotContext.summary(pr_context)}**\n" if pr_context
129
178
  puts MarkdownHelper.markdown_table_header_line
130
179
  result.keys.sort.each do |name|
131
180
  gem_data = result[name]
@@ -9,6 +9,8 @@ module StillActive
9
9
  attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
10
10
  attr_accessor :baseline_path,
11
11
  :critical_warning_emoji,
12
+ :cyclonedx_path,
13
+ :cyclonedx_version,
12
14
  :fail_if_critical,
13
15
  :fail_if_warning,
14
16
  :futurist_emoji,
@@ -41,6 +43,8 @@ module StillActive
41
43
  @output_format = :auto
42
44
  @sarif_path = nil
43
45
  @baseline_path = nil
46
+ @cyclonedx_path = nil
47
+ @cyclonedx_version = "1.6"
44
48
 
45
49
  @critical_warning_emoji = "🚩"
46
50
  @futurist_emoji = "🔮"
@@ -52,6 +52,7 @@ module StillActive
52
52
  cvss3_score: body["cvss3Score"],
53
53
  cvss3_vector: body["cvss3Vector"],
54
54
  cvss2_score: body["cvss2Score"],
55
+ source: "deps.dev",
55
56
  }
56
57
  end
57
58
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../helpers/cyclonedx_helper"
4
5
  require_relative "../helpers/vulnerability_helper"
5
6
 
6
7
  module StillActive
@@ -70,6 +71,16 @@ module StillActive
70
71
  options[:provided_baseline] = true
71
72
  StillActive.config { |config| config.baseline_path = value }
72
73
  end
74
+ opts.on("--cyclonedx[=PATH]", "CycloneDX SBOM output (default to stdout; PATH to write a file). Overrides --terminal/--markdown/--json.") do |value|
75
+ StillActive.config { |config| config.cyclonedx_path = value || "-" }
76
+ end
77
+ opts.on("--cyclonedx-version=VERSION", String, "CycloneDX spec version to emit: 1.6 (default) or 1.7.") do |value|
78
+ supported = StillActive::CyclonedxHelper::SUPPORTED_SPEC_VERSIONS
79
+ raise ArgumentError, "--cyclonedx-version must be one of: #{supported.join(", ")} (got #{value})" unless supported.include?(value)
80
+
81
+ options[:provided_cyclonedx_version] = true
82
+ StillActive.config { |config| config.cyclonedx_version = value }
83
+ end
73
84
  end
74
85
 
75
86
  def add_token_options(opts)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.4.2"
4
+ VERSION = "1.5.0"
5
5
  end
@@ -4,8 +4,10 @@ require_relative "deps_dev_client"
4
4
  require_relative "gitlab_client"
5
5
  require_relative "repository"
6
6
  require_relative "../helpers/libyear_helper"
7
+ require_relative "../helpers/ruby_advisory_db"
7
8
  require_relative "../helpers/ruby_helper"
8
9
  require_relative "../helpers/version_helper"
10
+ require_relative "../helpers/vulnerability_helper"
9
11
  require "async"
10
12
  require "async/barrier"
11
13
  require "async/semaphore"
@@ -17,6 +19,9 @@ module StillActive
17
19
 
18
20
  def call(&on_progress)
19
21
  task = Async do
22
+ # Load the optional ruby-advisory-db once, before the fan-out, so the
23
+ # read-only Database is shared across fibers rather than reloaded per gem.
24
+ advisory_db = RubyAdvisoryDb.load
20
25
  barrier = Async::Barrier.new
21
26
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
22
27
  result_object = {}
@@ -30,6 +35,7 @@ module StillActive
30
35
  gem_version: gem[:version],
31
36
  source_type: gem[:source_type] || :rubygems,
32
37
  source_uri: gem[:source_uri],
38
+ advisory_db: advisory_db,
33
39
  )
34
40
  rescue Octokit::TooManyRequests
35
41
  $stderr.print("\r\e[K") if on_progress
@@ -54,24 +60,25 @@ module StillActive
54
60
 
55
61
  private
56
62
 
57
- def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil)
63
+ def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil)
58
64
  result_object[gem_name] = { source_type: source_type }
59
65
  result_object[gem_name][:version_used] = gem_version if gem_version
60
66
 
61
67
  case source_type
62
68
  when :path, :git
63
- gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri)
69
+ gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri, advisory_db: advisory_db)
64
70
  else
65
71
  gem_info_rubygems(
66
72
  gem_name: gem_name,
67
73
  gem_version: gem_version,
68
74
  result_object: result_object,
69
75
  source_uri: source_uri,
76
+ advisory_db: advisory_db,
70
77
  )
71
78
  end
72
79
  end
73
80
 
74
- def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:)
81
+ def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
75
82
  vs = versions(gem_name: gem_name, source_uri: source_uri)
76
83
  repo_info = repository_info(gem_name: gem_name, versions: vs)
77
84
  commit_date = last_commit_date(
@@ -89,6 +96,7 @@ module StillActive
89
96
  deps_dev = fetch_deps_dev_info(
90
97
  gem_name: gem_name,
91
98
  version: gem_version || VersionHelper.gem_version(version_hash: last_release),
99
+ advisory_db: advisory_db,
92
100
  )
93
101
  result_object[gem_name].merge!({
94
102
  latest_version: VersionHelper.gem_version(version_hash: last_release),
@@ -118,6 +126,7 @@ module StillActive
118
126
 
119
127
  version_used_release_date: VersionHelper.release_date(version_hash: version_used),
120
128
  version_yanked: !vs.empty? && version_used.nil?,
129
+ license: VersionHelper.license(version_hash: version_used),
121
130
  libyear: LibyearHelper.gem_libyear(
122
131
  version_used_release_date: VersionHelper.release_date(version_hash: version_used),
123
132
  latest_version_release_date: VersionHelper.release_date(version_hash: last_release),
@@ -126,10 +135,10 @@ module StillActive
126
135
  end
127
136
  end
128
137
 
129
- def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil)
138
+ def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil)
130
139
  repo_info = repository_info_for_non_rubygems(gem_name: gem_name, source_uri: source_uri)
131
140
  source, owner, name = repo_info.values_at(:source, :owner, :name)
132
- deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version) : {}
141
+ deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {}
133
142
 
134
143
  # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
135
144
  deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
@@ -142,11 +151,13 @@ module StillActive
142
151
  })
143
152
  end
144
153
 
145
- def fetch_deps_dev_info(gem_name:, version:)
154
+ def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
146
155
  info = DepsDevClient.version_info(gem_name: gem_name, version: version)
147
156
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
148
157
  advisory_keys = info&.dig(:advisory_keys) || []
149
- vulnerabilities = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) }
158
+ deps_dev_vulns = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) }
159
+ radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
160
+ vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
150
161
  {
151
162
  scorecard_score: scorecard&.dig(:score),
152
163
  vulnerability_count: vulnerabilities.length,
data/still_active.gemspec CHANGED
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
36
36
  spec.executables = spec.files.grep(%r{\Abin/still_active}) { |f| File.basename(f) }
37
37
  spec.require_paths = ["lib"]
38
38
 
39
+ spec.add_development_dependency("bundler-audit")
39
40
  spec.add_development_dependency("debug")
40
41
  spec.add_development_dependency("faker")
41
42
  spec.add_development_dependency("json_schemer")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler-audit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: debug
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -198,13 +212,16 @@ files:
198
212
  - bin/still_active
199
213
  - lib/helpers/activity_helper.rb
200
214
  - lib/helpers/ansi_helper.rb
215
+ - lib/helpers/bot_context.rb
201
216
  - lib/helpers/bundler_helper.rb
217
+ - lib/helpers/cyclonedx_helper.rb
202
218
  - lib/helpers/diff_markdown_helper.rb
203
219
  - lib/helpers/emoji_helper.rb
204
220
  - lib/helpers/http_helper.rb
205
221
  - lib/helpers/libyear_helper.rb
206
222
  - lib/helpers/lockfile_indexer.rb
207
223
  - lib/helpers/markdown_helper.rb
224
+ - lib/helpers/ruby_advisory_db.rb
208
225
  - lib/helpers/ruby_helper.rb
209
226
  - lib/helpers/sarif_helper.rb
210
227
  - lib/helpers/terminal_helper.rb