still_active 2.0.0 → 3.0.0.rc2

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +104 -357
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +27 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +205 -9
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. metadata +61 -11
data/README.md CHANGED
@@ -1,51 +1,45 @@
1
1
  # `still_active`
2
2
 
3
- **How do you know if your Ruby dependencies are still maintained?**
3
+ **How do you know the dependencies you ship are still maintained?**
4
4
 
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 dependency graph — direct and transitive by default (`--direct-only` to narrow), with granular, committed suppression via `.still_active.yml`.
5
+ [Install](#installation) · [Quick start](#quick-start) · [GitHub Action & CI](#github-action--ci) · [Cross-ecosystem](#cross-ecosystem-audit) · [Configuration](#configuration) · [Rules](docs/rules.md)
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.
7
+ Your package manager tells you what's outdated and what has a known CVE. Neither tells you whether anyone's still working on the thing. An abandoned dependency is a quiet liability: when it finally breaks or a vulnerability lands, there's no release coming and no one to ping, and you find out at the worst time.
8
8
 
9
- [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active)
10
- [![GitHub Action](https://img.shields.io/badge/Marketplace-still__active--action-2ea44f?logo=github)](https://github.com/marketplace/actions/still_active)
11
- ![Code Quality analysis](https://github.com/SeanLF/still_active/actions/workflows/codeql-analysis.yml/badge.svg)
12
- ![RSpec](https://github.com/SeanLF/still_active/actions/workflows/rspec.yml/badge.svg)
13
- ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg)
9
+ `still_active` flags that risk before it bites, across your whole dependency graph: archived repos, no release in years, low OpenSSF scores, unfixable vulnerabilities, and the poison-pill case where a dormant package holds one of your deps below its own security patch. Native on **Ruby** gems; **npm, PyPI, Cargo, Go, Maven, and NuGet** via a CycloneDX SBOM.
14
10
 
15
11
  ```
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
25
-
26
- 7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
27
- Ruby 4.0.1 (latest)
12
+ Name Version Activity OpenSSF Vulns License
13
+ ────────────────────────────────────────────────────────────────────
14
+ backbone-rails 1.2.3 (latest) archived 2.6/10 0 -
15
+ nokogiri 1.19.4 (latest) ok 5.9/10 0 MIT
16
+ paperclip 6.1.0 (latest) archived 2/10 0 MIT
17
+ ↳ poison: caps terrapin ~> 0.6.0 (1 major behind, latest 1.x)
18
+ rake 13.4.2 (latest) ok 5.5/10 0 MIT
19
+
20
+ 4 gems: 4 up to date · 2 active, 2 archived · 0 vulnerabilities · 1 poison-pill
21
+ Ruby 4.0.5 (latest)
28
22
  ```
29
23
 
30
24
  ## Why `still_active`?
31
25
 
32
- `still_active` is **complementary to** -- not a replacement for -- the established Ruby tooling. `bundle outdated`, `bundler-audit`, and `libyear-bundler` are purpose-built and battle-tested at what they do. `still_active` answers a different question: **is anyone still maintaining this gem?** -- and folds in the version/CVE/libyear signals so you get one report instead of three.
26
+ No package ecosystem's standard tooling answers "is anyone still maintaining this?" `npm audit`, `cargo audit`, `pip-audit`, and `bundler-audit` find known CVEs; the `outdated` commands find version drift. None tell you a dependency's upstream is archived, hasn't shipped in three years, or is holding one of your deps below its security fix. That maintenance gap is what `still_active` fills. It's **complementary to**, not a replacement for, the tools you already run.
27
+
28
+ On Ruby, where it runs natively:
33
29
 
34
30
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
35
31
  | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
36
32
  | Outdated versions | Yes | - | Yes | Yes |
37
- | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
33
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + OSV + ruby-advisory-db) |
38
34
  | Libyear drift | - | - | Yes | Yes |
39
- | **Last commit activity** | - | - | - | **Yes** |
35
+ | **Last-commit activity** | - | - | - | **Yes** |
40
36
  | **Archived repo detection** | - | - | - | **Yes** |
41
37
  | **OpenSSF Scorecard** | - | - | - | **Yes** |
42
- | **Yanked version detection** | - | - | - | **Yes** |
43
- | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
44
- | GitLab support | - | - | - | Yes |
45
- | CI quality gates | - | Exit code | - | Yes (4 flags) |
46
- | Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
38
+ | **Poison-pill / below-the-fix** | - | - | - | **Yes** |
39
+ | **Cross-ecosystem** (npm/PyPI/Cargo/Go/Maven/NuGet) | - | - | - | **Yes** (`--sbom`) |
40
+ | CI quality gates | - | Exit code | - | Yes (6 gate flags) |
47
41
 
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.
42
+ With `bundler-audit` installed alongside, `still_active` merges its `ruby-advisory-db` advisories with its own deps.dev + OSV sources, so running both no longer means reconciling two vuln counts by hand.
49
43
 
50
44
  ## Installation
51
45
 
@@ -53,177 +47,29 @@ The bolded rows are the gap `still_active` fills: nobody else answers "is the ma
53
47
  gem install still_active
54
48
  ```
55
49
 
56
- **Requires an actively-maintained Ruby.** The gemspec's `required_ruby_version` floor tracks Ruby's [EOL schedule](https://endoflife.date/ruby); running a maintenance auditor on an unmaintained runtime would be a bit rich. You don't have to run it *on* the Ruby you're auditing, though: still_active reports on the version your project pins in `Gemfile.lock`, so run it from any current Ruby (locally, in CI, or via the [`still_active-action`](https://github.com/SeanLF/still_active-action)) and it will still flag an EOL target.
50
+ Requires an actively-maintained Ruby (the gemspec floor tracks Ruby's [EOL schedule](https://endoflife.date/ruby)). You don't have to run it *on* the Ruby you audit: it reports on the version pinned in `Gemfile.lock`, so run it from any current Ruby and it still flags an EOL target.
57
51
 
58
- ## Quick Start
52
+ ## Quick start
59
53
 
60
54
  ```bash
61
55
  # audit your Gemfile (auto-detects output format)
62
56
  still_active
63
57
 
64
- # check specific gems
65
- still_active --gems=rails,nokogiri,sidekiq
58
+ # cross-ecosystem: audit any CycloneDX SBOM (npm, PyPI, Cargo, Go, Maven, NuGet)
59
+ syft dir:. -o cyclonedx-json > sbom.json && still_active --sbom=sbom.json
66
60
 
67
- # CI pipeline: fail if any gem is critically stale or has vulnerabilities
61
+ # fail CI if any gem is critically stale or vulnerable
68
62
  still_active --fail-if-critical --fail-if-vulnerable
69
63
 
70
- # ignore specific gems in CI checks
71
- still_active --fail-if-warning --ignore=legacy_gem,internal_gem
72
-
73
- # markdown table for pull requests or documentation
64
+ # markdown table for a pull request
74
65
  still_active --markdown
75
66
  ```
76
67
 
77
- ## Usage
78
-
79
- ### Authentication
80
-
81
- `still_active` discovers a GitHub token in this order:
82
-
83
- 1. `--github-oauth-token=TOKEN` CLI flag
84
- 2. `GITHUB_TOKEN` environment variable (CI convention)
85
- 3. `GH_TOKEN` environment variable (`gh` CLI convention)
86
- 4. `gh auth token` (if `gh` is installed and authenticated)
87
-
88
- Without a token, GitHub API calls are unauthenticated and rate-limited to 60 requests/hour — you will hit the limit on anything beyond a handful of gems. With a token the limit is 5000 requests/hour. When a rate-limit response comes back with a reset that's near (within 60 seconds, typically a secondary/burst limit that the concurrent fan-out can trip even with a token), still_active waits it out and retries rather than dropping that gem's signals; a far-off reset (you've exhausted the hourly limit) isn't waited out, so add a token or run less often.
89
-
90
- GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
91
-
92
- Forgejo/Codeberg (the rare gem whose canonical `source_code_uri` is `codeberg.org`) is read anonymously by default; set `STILL_ACTIVE_FORGEJO_TOKEN` (or `CODEBERG_TOKEN`) only to raise the rate limit or reach a private repo. There is no CLI flag, since Codeberg has no ubiquitous CLI to borrow a token from the way `gh`/`glab` do.
93
-
94
- Artifactory auth looks for Bundler configuration's credentials first so that private registries work without extra configuration if they are already configured in Bundler. To set those credentials in Bundler, run `bundle config set credentials.my-org.jfrog.io user:pass`. If no credentials are set there, still_active falls back using a token provided via `--artifactory-token` or `STILL_ACTIVE_ARTIFACTORY_TOKEN`. Authentication expects a `user:password` format for Basic auth, otherwise it will be treated as a bare token for Bearer auth. Valid authentication is required for private JFrog gem registries (`*.jfrog.io`).
95
-
96
- When providing the artifactory token via flag or env, you must also set `--artifactory-host` or `STILL_ACTIVE_ARTIFACTORY_HOST` to the expected registry hostname (e.g. `my-org.jfrog.io`). still_active only sends the credentials to that host, ensuring that a lockfile containing other Artifactory hosts will not leak the token (a security risk). Providing a token/host in this manner will work only for a single host. To support multiple Artifactory hosts, use Bundler's configuration for per-host credentials.
97
-
98
- ### CLI options
99
-
100
- ```text
101
- Usage: still_active [options]
102
-
103
- all flags are optional
104
-
105
- --gemfile=GEMFILE path to gemfile
106
- --gems=GEM,GEM2,... Gem(s)
107
- --terminal Coloured terminal output (default in TTY)
108
- --markdown Markdown table output
109
- --json JSON output (default when piped)
110
- --alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
111
- --unreleased-commits Count commits on the default branch since the latest release (GitHub only; opt-in)
112
- --direct-only Audit only direct (declared) deps, not the full transitive graph
113
- --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
114
- --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
115
- --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
116
- --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
117
- --github-oauth-token=TOKEN GitHub OAuth token to make API calls
118
- --gitlab-token=TOKEN GitLab personal access token for API calls
119
- --artifactory-token=TOKEN Artifactory token for private gem registry API calls
120
- --artifactory-host=HOST Artifactory host allowed to receive the global token (e.g. my-org.jfrog.io)
121
- --simultaneous-requests=QTY Number of simultaneous requests made
122
- --safe-range-end=YEARS maximum years since last release considered safe, no warning (default 1.5)
123
- --warning-range-end=YEARS maximum years since last release that triggers a warning, beyond this is critical (default 3)
124
- --fail-if-critical Exit 1 if any gem has critical activity warning
125
- --fail-if-warning Exit 1 if any gem has warning or critical activity warning
126
- --fail-if-vulnerable[=SEVERITY]
127
- Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY)
128
- --fail-if-outdated=LIBYEARS Exit 1 if any gem exceeds LIBYEARS behind latest
129
- --ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
130
- --critical-warning-emoji=EMOJI
131
- --futurist-emoji=EMOJI
132
- --success-emoji=EMOJI
133
- --unsure-emoji=EMOJI
134
- --warning-emoji=EMOJI
135
- -h, --help Show this message
136
- -v, --version Show version
137
- ```
138
-
139
- ### Output formats
140
-
141
- **Terminal** (default on TTY) -- coloured table with summary line. Shown above.
142
-
143
- **JSON** (default when piped) -- structured data for automation:
144
-
145
- ```bash
146
- still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
147
- ```
148
-
149
- ```json
150
- {
151
- "gems": {
152
- "async": {
153
- "source_type": "rubygems",
154
- "version_used": "2.36.0",
155
- "latest_version": "2.36.0",
156
- "repository_url": "https://github.com/socketry/async",
157
- "last_commit_date": "2026-01-22 04:09:48 UTC",
158
- "archived": false,
159
- "scorecard_score": 7.1,
160
- "vulnerability_count": 0,
161
- "license": "MIT",
162
- "libyear": 0.0
163
- },
164
- "nested_form": {
165
- "source_type": "git",
166
- "version_used": "0.3.2",
167
- "repository_url": "https://github.com/ryanb/nested_form",
168
- "last_commit_date": "2021-12-11 21:47:02 UTC",
169
- "archived": true,
170
- "scorecard_score": 3.3,
171
- "vulnerability_count": 0
172
- },
173
- "local_gem": {
174
- "source_type": "path",
175
- "version_used": "0.1.0",
176
- "scorecard_score": null,
177
- "vulnerability_count": 0
178
- }
179
- },
180
- "ruby": {
181
- "version": "4.0.1",
182
- "eol": false,
183
- "latest_version": "4.0.1",
184
- "libyear": 0.0
185
- }
186
- }
187
- ```
68
+ Full flags: [`docs/cli.md`](docs/cli.md). Tokens for private registries and self-hosted forges: [`docs/authentication.md`](docs/authentication.md).
188
69
 
189
- **Markdown** -- table for pull requests, documentation, or wikis:
70
+ ## GitHub Action & CI
190
71
 
191
- ```bash
192
- still_active --markdown
193
- ```
194
-
195
- | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |
196
- | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | ------- |
197
- | | ✅ | 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 |
198
- | 🚩 | ✅ | 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 |
199
- | ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | - |
200
- | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | MIT |
201
-
202
- **Ruby 4.0.1** (latest) ✅
203
-
204
- **CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, or Snyk:
205
-
206
- ```bash
207
- still_active --cyclonedx # CycloneDX 1.6 to stdout
208
- still_active --cyclonedx=sbom.json # write to a file
209
- still_active --cyclonedx --cyclonedx-version=1.7
210
- ```
211
-
212
- 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.
213
-
214
- ### SARIF output (GitHub Code Scanning)
215
-
216
- 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.
217
-
218
- > **See it live:** this repo audits itself on every push. Browse the live findings in the [Code Scanning Security tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen) — currently 2× `SA005` (low OpenSSF Scorecard).
219
-
220
- ```bash
221
- still_active --sarif # writes still_active.sarif.json
222
- still_active --sarif=path/to/out.sarif.json
223
- still_active --sarif=- # stdout
224
- ```
225
-
226
- **Easy mode** — use the [`still_active-action`](https://github.com/SeanLF/still_active-action) wrapper:
72
+ The [`still_active-action`](https://github.com/SeanLF/still_active-action) uploads findings to your **GitHub Security tab** as SARIF, with inline PR annotations on `Gemfile.lock`:
227
73
 
228
74
  ```yaml
229
75
  permissions:
@@ -246,213 +92,114 @@ jobs:
246
92
  with: { sarif_file: still_active.sarif.json }
247
93
  ```
248
94
 
249
- **Plain bundle exec** if you'd rather pin still_active in your Gemfile:
95
+ Gate the build on what you care about (all off by default, so nothing breaks until you opt in), and use `--baseline` for PR review.
250
96
 
251
- ```yaml
252
- - run: bundle exec still_active --sarif
253
- env:
254
- GITHUB_TOKEN: ${{ github.token }}
255
- - uses: github/codeql-action/upload-sarif@v3
256
- if: always()
257
- with: { sarif_file: still_active.sarif.json }
258
- ```
259
-
260
- Rule reference (SA001–SA007) and how to suppress: see [`docs/rules.md`](docs/rules.md).
261
-
262
- ### Baseline diff (PR review)
263
-
264
- `--baseline=FILE` compares the current run against a previously captured JSON snapshot and emits a markdown delta report. Designed for the PR question reviewers actually ask: **what got worse?**
97
+ <details>
98
+ <summary>CI gate flags and PR-review diff</summary>
265
99
 
266
100
  ```bash
267
- # Locally capture from main, compare to your branch
268
- git checkout main && still_active --json > /tmp/main.json
269
- git checkout my-branch && still_active --baseline=/tmp/main.json
270
- ```
271
-
272
- In CI, capture a baseline on main and compare on PR branches. Exits 1 if any regression is detected (new vulns, newly-archived deps, scorecard drops crossing 7.0, libyear growth on unchanged versions, Ruby newly EOL, etc.).
101
+ still_active --fail-if-critical # upstream critically stale or archived
102
+ still_active --fail-if-vulnerable # any known vulnerability (or =low|medium|high|critical)
103
+ still_active --fail-if-outdated=3 # more than 3 libyears behind latest
104
+ still_active --fail-if-poison # a dormant package caps a dep below its latest major
105
+ still_active --fail-if-language-ceiling # a pin strands you on an EOL language runtime
273
106
 
274
- The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set.
107
+ # PR review: report only what got worse since a saved snapshot, exit 1 on any regression
108
+ still_active --json > /tmp/main.json && still_active --baseline=/tmp/main.json
109
+ ```
110
+ </details>
275
111
 
276
- 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.
112
+ Rule reference (SA001-SA009), suppression, and composing with `dependency-review-action`: [`docs/rules.md`](docs/rules.md), [`docs/ci.md`](docs/ci.md). This repo audits itself every push, so you can browse live findings in its [Code Scanning tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen).
277
113
 
278
- ### Alongside `dependency-review-action`
114
+ ## Cross-ecosystem audit
279
115
 
280
- 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:
116
+ Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft), Trivy, or any producer) and `still_active` assesses `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` packages the same way it does gems, via [deps.dev](https://deps.dev) and [ecosyste.ms](https://ecosyste.ms). The SBOM is treated as **untrusted input** (only ecosystem/name/version are read, repositories resolve from deps.dev, and anything unassessable is surfaced rather than faked as `ok`). Most signals apply everywhere; a few are deliberately scoped.
281
117
 
282
- | | `dependency-review-action` | `still_active` |
283
- | ---------------------------- | ---------------------------------- | ------------------------------------------- |
284
- | Platform | GitHub.com / GHES only | Any CI |
285
- | Languages | Multi (GitHub dep graph) | Ruby |
286
- | Vulnerabilities | GHSA | deps.dev + ruby-advisory-db (merged) |
287
- | Licenses | Yes (allow/deny gating) | Surfaced (no gating) |
288
- | OpenSSF Scorecard | Yes (display) | Yes (display + threshold) |
289
- | **Last-commit activity** | - | **Yes** |
290
- | **Archived repo detection** | - | **Yes** |
291
- | **Libyear drift** | - | **Yes** |
292
- | **Ruby EOL detection** | - | **Yes** |
293
- | **Yanked version detection** | - | **Yes** |
294
- | Diff vs base | Native (GitHub API) | `--baseline=FILE` |
295
- | Output | Inline PR annotations | Terminal / Markdown / JSON / SARIF / CycloneDX |
118
+ <details>
119
+ <summary>Which signal covers which ecosystem</summary>
296
120
 
297
- Run both: let `dependency-review-action` gate CVEs and licenses, and `still_active` add the maintenance lens on the same PR.
121
+ | Signal (rule) | Ruby (native) | `--sbom` (cross-ecosystem) |
122
+ | ------------- | ------------- | -------------------------- |
123
+ | Archived (SA001), no recent release (SA002), low OpenSSF (SA005), libyear (SA004) | Yes | Yes (all six ecosystems) |
124
+ | Vulnerabilities (SA003) | deps.dev + OSV + ruby-advisory-db | deps.dev + OSV |
125
+ | &nbsp;&nbsp;↳ "below the fix" (a dead package pins a vulnerable dep) | Yes | npm, Cargo, PyPI |
126
+ | Poison-pill (SA008) | rubygems | PyPI (flat resolution only) |
127
+ | Language-runtime ceiling (SA009) | Ruby | Python |
128
+ | Ruby EOL (SA006), yanked (SA007) | Yes | n/a |
298
129
 
299
- ```yaml
300
- on: pull_request
130
+ Full rule detail in [`docs/rules.md`](docs/rules.md).
131
+ </details>
301
132
 
302
- jobs:
303
- dependency-review:
304
- runs-on: ubuntu-latest
305
- steps:
306
- - uses: actions/checkout@v4
307
- - uses: actions/dependency-review-action@v4
308
- with:
309
- fail-on-severity: high
310
- show-openssf-scorecard: true
133
+ The play is **maintenance**, not CVE scanning, so compose Trivy/Grype for full vulnerability coverage.
311
134
 
312
- maintenance-review:
313
- runs-on: ubuntu-latest
314
- steps:
315
- - uses: actions/checkout@v4
316
- - uses: ruby/setup-ruby@v1
317
- with: { ruby-version: ".ruby-version", bundler-cache: true }
318
- - uses: SeanLF/still_active-action@v0
319
- with:
320
- fail-if-critical: true
321
- ```
135
+ ## Output formats
322
136
 
323
- ### CI quality gating
137
+ Auto-detected: a coloured terminal table on a TTY (above), JSON when piped. Or ask explicitly.
324
138
 
325
- Use exit-code flags to fail CI pipelines based on dependency status:
139
+ <details>
140
+ <summary><strong>JSON</strong> (<code>--json</code>): a versioned, contract-tested schema for automation</summary>
326
141
 
327
- ```bash
328
- # fail on critically stale or archived gems
329
- still_active --fail-if-critical --json
142
+ ```json
143
+ {
144
+ "gems": {
145
+ "nested_form": {
146
+ "source_type": "git",
147
+ "version_used": "0.3.2",
148
+ "repository_url": "https://github.com/ryanb/nested_form",
149
+ "archived": true,
150
+ "scorecard_score": 3.3,
151
+ "vulnerability_count": 0,
152
+ "status": "archived"
153
+ }
154
+ },
155
+ "ruby": { "version": "4.0.5", "eol": false, "latest_version": "4.0.5" }
156
+ }
157
+ ```
330
158
 
331
- # fail on any stale, critical, or archived gem
332
- still_active --fail-if-warning --json
159
+ Fields are documented in [`docs/schema.md`](docs/schema.md).
160
+ </details>
333
161
 
334
- # fail if any gem has known vulnerabilities
335
- still_active --fail-if-vulnerable --json
162
+ <details>
163
+ <summary><strong>Markdown</strong> (<code>--markdown</code>): a table for pull requests, docs, or wikis</summary>
336
164
 
337
- # fail only on high/critical severity vulnerabilities
338
- still_active --fail-if-vulnerable=high --json
165
+ | activity | up to date? | OpenSSF | vulns | name | version | last commit |
166
+ | -------- | ----------- | ------- | ----- | ---- | ------- | ----------- |
167
+ | | ✅ | 5.9/10 | ✅ | [nokogiri](https://github.com/sparklemotion/nokogiri) | 1.19.4 | 2026/06 |
168
+ | 🚩 | ✅ | 2/10 | ✅ | [paperclip](https://github.com/thoughtbot/paperclip) | 6.1.0 | 2021/03 |
169
+ </details>
339
170
 
340
- # fail if any gem is more than 3 libyears behind
341
- still_active --fail-if-outdated=3 --json
171
+ **SARIF** feeds GitHub Code Scanning (see [GitHub Action & CI](#github-action--ci)). **CycloneDX** (`--cyclonedx`) emits a standards-track SBOM so your graph and `still_active`'s signals flow into Trivy, Dependency-Track, or Snyk.
342
172
 
343
- # combine flags and exclude known exceptions
344
- still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
345
- ```
173
+ ## Configuration
346
174
 
347
- ### Configuration file (`.still_active.yml`)
175
+ A committed `.still_active.yml` keeps policy in version control and replaces the blunt `--ignore=GEM` (which mutes *every* gate for a gem) with granular, expiring suppression. A vulnerability suppression must name an advisory id (so a new CVE is never pre-silenced), an `expires:` date makes accepted risk re-surface instead of rotting, and a suppressed finding still shows in output (and as a dismissed SARIF entry).
348
176
 
349
- `--ignore=GEM` is blunt: it drops a gem from **every** gate at once, so accepting one unfixable advisory also blinds you to that gem going archived or to a *new* CVE. A committed `.still_active.yml` in the project root replaces that with granular, auditable suppression, and lets you keep your policy flags in version control instead of threading them through every invocation.
177
+ <details>
178
+ <summary><code>.still_active.yml</code> example</summary>
350
179
 
351
180
  ```yaml
352
- # .still_active.yml -- policy defaults plus granular suppression
353
- fail_if_critical: true
354
- fail_if_vulnerable: high # true, or a minimum severity: low|medium|high|critical
355
- fail_if_outdated: 3 # libyears
356
- unreleased_commits: true
357
- output: json # terminal | markdown | json
358
- direct_only: true # audit only declared deps, not the full transitive graph (--direct-only)
359
-
360
- # Pull bundler-audit's accepted-advisory list instead of maintaining two files
361
- import:
362
- - .bundler-audit.yml
181
+ fail_if_vulnerable: high # true, or a minimum severity: low|medium|high|critical
182
+ fail_if_poison: warning # true (=warning), or a tier: note|warning|critical
363
183
 
364
184
  ignore:
365
- # Accept ONE advisory, by id -- a different/new CVE on nokogiri still fails
185
+ # accept ONE advisory by id; a different or new CVE on nokogiri still fails
366
186
  - advisory: CVE-2024-1234
367
187
  gem: nokogiri
368
188
  reason: "no fix released; not reachable from our code path"
369
- expires: 2026-09-01 # re-surfaces as a normal failure after this date
370
-
371
- # Accept staleness on a vendored gem, but still fail if it gets a CVE
372
- - gem: legacy_thing
373
- signal: activity # activity | vulnerability | libyear
374
- reason: "vendored, intentionally frozen"
375
-
376
- # A bare gem name keeps the old whole-gem behaviour (mutes every signal)
377
- - some_internal_gem
378
- ```
379
-
380
- Design:
381
-
382
- - **Granularity.** Key by `advisory:` (one CVE) and/or `signal:` (`activity` / `vulnerability` / `libyear`), not the whole gem. A vulnerability suppression **must** name an advisory id, so a newly disclosed CVE on the same gem is never pre-silenced. `--ignore=GEM` (and a bare gem-name entry) still mute everything, by design.
383
- - **Precedence.** CLI flag > env var > config file > default. A CLI flag always wins; `--ignore` unions with the file's suppressions rather than replacing them. Secrets (tokens) and invocation-specific paths (`--gemfile`, `--gems`, `--baseline`, output paths) are intentionally **not** read from the file, so a committed config never carries a credential.
384
- - **Expiry.** An `expires:` date makes accepted risk visible: once it passes, the entry stops applying and the finding fails the gate again (Trivy-style), so a suppression can't rot silently. `reason:` is optional but recommended. A suppression naming a gem that isn't in your dependency graph (a typo, or a gem you've since removed) is also surfaced as a warning, so dead entries don't accumulate.
385
- - **bundler-audit, suggested not absorbed.** still_active never silently inherits another tool's ignore list: auto-importing `.bundler-audit.yml` would suppress vulnerabilities you only accepted in bundler-audit's context, with no reason or expiry here. Instead, when `--fail-if-vulnerable` is on and an un-imported `.bundler-audit.yml` is present, it prints a one-line hint suggesting the `import:` line, leaving the opt-in to you.
386
- - **Output.** Suppression changes the **exit code** and marks the finding in **SARIF** as a native `suppressions[]` entry (with your `reason` as the justification, so GitHub Code Scanning renders it dismissed rather than open). The finding still appears in JSON/terminal/markdown output; suppression accepts a risk, it doesn't hide that the risk exists.
387
-
388
- ### Activity thresholds
389
-
390
- Activity is driven by release recency (the latest stable or pre-release date), since a release is what you can actually `bundle update` to. A recent commit does not offset a stale release: the last commit date is shown as context and only stands in when a gem has no releases at all (e.g. git-sourced). Thresholds are calibrated against real RubyGems cadence, where healthy mature gems often go a year or more between releases:
391
-
392
- - **ok**: last release within 18 months (configurable with `--safe-range-end`)
393
- - **stale**: last release between 18 months and 3 years ago
394
- - **critical**: last release over 3 years ago (configurable with `--warning-range-end`)
395
-
396
- ### Alternative gem leads (opt-in)
397
-
398
- When a gem is flagged archived or critical, `--alternatives` surfaces up to three maintained gems from the same [Ruby Toolbox](https://www.ruby-toolbox.com) category, ranked by total downloads:
399
-
400
- ```bash
401
- still_active --gems=paperclip --alternatives
189
+ expires: 2026-09-01 # re-surfaces as a normal failure after this date
402
190
  ```
191
+ </details>
403
192
 
404
- ```text
405
- ↳ leads (Ruby Toolbox): shrine · carrierwave · kt-paperclip (verify fit)
406
- ```
407
-
408
- These are **leads, not recommendations**: same-category does not mean drop-in replacement, so verify fit before switching. Ruby has no authoritative "use instead" metadata (unlike npm `deprecate`, Go's `// Deprecated:`, or NuGet's alternate-package field), so this is a best-effort heuristic. It is silent when the catalog has no entry for the gem, and the feature never blocks or fails a run. Leads appear in terminal, markdown, JSON, and SARIF output. When the flag is off, terminal output shows a one-line hint on flagged gems that the option exists (other formats stay silent).
409
-
410
- ### Transitive dependencies
193
+ Full semantics, thresholds, and transitive behaviour: [`docs/configuration.md`](docs/configuration.md).
411
194
 
412
- Maintenance signals (stale releases, archived repos, last-commit age, advisories, libyear) cover the **full transitive lockfile graph by default** — an unmaintained gem you ship transitively is real risk even though you never named it, and the CVE tools still_active composes with already enumerate the whole resolved graph. This matches libyear-bundler (transitive by default, `--only-explicit` opts out) and every CVE scanner (bundler-audit, npm/cargo/pip-audit are all full-tree).
195
+ ## Data sources
413
196
 
414
- When a transitive gem trips a signal, the output names the **direct dependency that pulls it in**: `dependency_path` in JSON, a dimmed `↳ transitive, pulled in by X` line in the terminal, a `(transitive, pulled in by X)` suffix in SARIF messages, and a **Transitive findings** list in markdown. That turns an un-actionable transitive finding into an actionable one: you can't bump a gem you didn't choose, but you can replace or pressure the direct gem that drags it in.
415
-
416
- `--alternatives` stays **direct-only** by design — "replace gem X with Y" is incoherent for a gem you never selected. Pass `--direct-only` to audit just your declared dependencies (the pre-1.7 behaviour), which is also much cheaper in API calls.
417
-
418
- > **Run on a schedule, not on every commit.** Auditing the full graph means a repo/release/advisory lookup for *every* resolved gem (hundreds, for a real app), and the GitHub signals are rate-limited (60 req/hour unauthenticated, 5000 with a token). Maintenance status changes over days and weeks, not per-commit, so a nightly or weekly job (or `--direct-only` in PR gates) gets you the signal without burning your API budget for nothing. This is why advisory tools like Brakeman ship their check database; still_active's signals are inherently live (a release date or an archived flag can't be vendored), so the answer is cadence, not bundling.
419
-
420
- ### Unreleased commits (opt-in)
421
-
422
- `--unreleased-commits` adds an `unreleased_commits` count to the JSON output: commits on the default branch since the latest release's tag. It catches the case the release-recency signal can't, a gem with a *recent* release but a pile of merged-but-unreleased fixes sitting on top, or conversely one that looks stale but is genuinely *done* (no unreleased work). It is the one maintenance signal no Ruby tool surfaces today; only GitHub's own UI shows it.
423
-
424
- It is **opt-in and GitHub-only**: enabling it adds one extra API call per GitHub-hosted gem (the git tag is resolved from the RubyGems version by trying `v1.2.3` then `1.2.3` as the compare base), so mind your rate limit on a large lockfile. Non-GitHub sources report `null` (GitLab has no equivalent scalar; the signal is duck-typed, so a provider either implements it or doesn't). The count is **informational and never gates a run**. Read it as a lead, not a verdict: it is inflated for monorepos (the count spans the whole repository, e.g. `bundler` living in `rubygems/rubygems`) and for release-branch projects (the default branch is the next-version trunk, so `rails` reads ~2000 commits ahead of its latest stable tag).
425
-
426
- ### Data sources
427
-
428
- - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org), [GitHub Packages](https://docs.github.com/en/packages), or [JFrog Artifactory](https://jfrog.com/artifactory/) gem registries
429
- - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest), [GitLab](https://docs.gitlab.com/ee/api/), or [Forgejo/Gitea](https://forgejo.org/docs/latest/user/api-usage/) (Codeberg) API
430
- - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
431
- - **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)
432
- - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
433
- - **Alternative gem leads** (with `--alternatives`) from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data
434
-
435
- ### Configuration defaults
436
-
437
- | Option | Default | Description |
438
- | ----------------------- | ----------- | ---------------------------------------------------------------- |
439
- | `output_format` | auto-detect | Coloured terminal on TTY, JSON when piped |
440
- | `safe_range_end` | 1.5 years | Last release within this range is "ok" |
441
- | `warning_range_end` | 3 years | Last release within this range is "stale"; beyond is "critical" |
442
- | `simultaneous_requests` | 10 | Concurrent API requests |
197
+ Release dates and licenses from [RubyGems](https://rubygems.org) / [GitHub Packages](https://docs.github.com/en/packages) / [Artifactory](https://jfrog.com/artifactory/); repo activity from the [GitHub](https://docs.github.com/en/rest) / [GitLab](https://docs.gitlab.com/ee/api/) / [Codeberg](https://forgejo.org/docs/latest/user/api-usage/) API, or [ecosyste.ms](https://ecosyste.ms) tokenless ([CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)); OpenSSF Scorecard and CVSS from [deps.dev](https://deps.dev); advisory severities and fixed-version ranges from [OSV](https://osv.dev) (CVSS-4 scoring needs the optional [`cvss-suite`](https://rubygems.org/gems/cvss-suite)); extra advisories from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db); runtime EOL from [endoflife.date](https://endoflife.date).
443
198
 
444
199
  ## Development
445
200
 
446
- After checking out the repo, run `bin/setup` to install dependencies and wire git hooks. Then run `rake` to run the full lint + test suite (`rake spec` for just tests, `rake rubocop` for just lint). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
447
-
448
- A pre-push hook runs `rake` automatically before each `git push`, so cross-file rubocop rules don't escape to CI. Skip with `git push --no-verify` if you really need to.
449
-
450
- To install this gem onto your local machine, run `bundle exec rake install`. New versions are published automatically to [rubygems.org](https://rubygems.org) when a GitHub Release is created (via trusted publishing).
451
-
452
- ## Contributing
453
-
454
- Bug reports and pull requests are welcome.
201
+ `bin/setup` installs dependencies and wires git hooks; `rake` runs the full lint + test suite. A pre-push hook runs `rake` automatically. Releases publish to [rubygems.org](https://rubygems.org) automatically on a GitHub Release (trusted publishing). See [`CONTRIBUTING.md`](CONTRIBUTING.md).
455
202
 
456
203
  ## License
457
204
 
458
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
205
+ Open source under the [MIT License](https://opensource.org/licenses/MIT).
@@ -42,6 +42,14 @@ module StillActive
42
42
  def activity_level(gem_data)
43
43
  return :archived if gem_data[:archived]
44
44
 
45
+ release_recency_level(gem_data)
46
+ end
47
+
48
+ # Recency verdict from release/commit dates alone, ignoring the archived
49
+ # flag: :ok, :stale, :critical, or :unknown. Lets the lifecycle status tell
50
+ # an archived-but-still-publishing gem (development moved to a monorepo) from
51
+ # a genuinely dormant one.
52
+ def release_recency_level(gem_data)
45
53
  activity = last_activity(gem_data)
46
54
  return :unknown if activity.nil?
47
55