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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +82 -19
- data/lib/helpers/bot_context.rb +132 -0
- data/lib/helpers/cyclonedx_helper.rb +159 -0
- data/lib/helpers/markdown_helper.rb +9 -2
- data/lib/helpers/ruby_advisory_db.rb +93 -0
- data/lib/helpers/terminal_helper.rb +8 -1
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +35 -0
- data/lib/still_active/cli.rb +53 -4
- data/lib/still_active/config.rb +4 -0
- data/lib/still_active/deps_dev_client.rb +1 -0
- data/lib/still_active/options.rb +11 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +18 -7
- data/still_active.gemspec +1 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 186f947f74db917e368e84dde7810495ae047720e9483dade42543028059530d
|
|
4
|
+
data.tar.gz: 756e3bc581887835db75fcc175ff3654c832c2e81550e1c9447a9f753c37339d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/still_active)
|
|
8
10
|
[](https://github.com/marketplace/actions/still_active)
|
|
9
11
|

|
|
@@ -11,15 +13,15 @@
|
|
|
11
13
|

|
|
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`
|
|
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
|
|
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"
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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]
|
data/lib/still_active/config.rb
CHANGED
|
@@ -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 = "🔮"
|
data/lib/still_active/options.rb
CHANGED
|
@@ -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)
|
data/lib/still_active/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
+
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
|