still_active 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e5e5e599cdd630ec6e916800c4abd7df4a3c9d37e24122e80d8859accb1c4e3
4
- data.tar.gz: 0cb0dfd2c761612f665a9d2f4e0ba20f47bf8b082c42281af09b8429335aa2a9
3
+ metadata.gz: 1db517014109d081c46e71d63cf61d4446d60f7c7205793c520293f6cc03e329
4
+ data.tar.gz: 5239b26426cb8a8359c3efbafb1aaf3480150bf6a190a0912cc1f78d4ca0e226
5
5
  SHA512:
6
- metadata.gz: 571797c9863bf6597e7c9474e8cf718019925d37d163002e9d594b137458ca0a6be155dfb7a5069777afe83963fb4dc595026a69c8237ce1aa7d024eb254be99
7
- data.tar.gz: 4e7ba6a7777973b6fe7f1fa54fc1fad00a06639da18944f0bc89befd6a3e794b1d55eeaca62f33b193bebb54cdb2d6d85c63ae48a73adc722e58a3f194c0aeae
6
+ metadata.gz: 49f561e7ea5a5e0de227e5c861cd4b1ef2bcca57ad967712e5411fd25ea00cea49ee07801f3b4b6be7dc205259c8f2e3c36481ec5148fa52849a8246070beb62
7
+ data.tar.gz: 5552c0f3c9ba60c3e9046372923f4fa3aee2b2152e5eea6765f99f0c14fdf7785798df477605e04c11a7ac5f6bb2fec06dbc1f0ece3e46b1ee91aaa341cc2ae7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ### Upgrading to 2.0
6
+
7
+ 2.0 is a major bump because two changes can alter `--fail-if-*` outcomes on upgrade (the CLI flags and JSON `schema_version` are otherwise backward-compatible):
8
+
9
+ - **Transitive by default.** Maintenance signals now cover the full resolved lockfile, so a CI gate can newly fail on a transitive critical/vulnerable/outdated gem. Add `--direct-only` to restore the pre-2.0 declared-deps scope.
10
+ - **Activity recalibration.** The "ok" ceiling moved 12 → 18 months and the level is release-driven, so some gems are reclassified. Tune with `--safe-range-end` / `--warning-range-end`.
11
+ - **Baselines:** re-capture your `--baseline` JSON after upgrading. The transitive expansion shows the new gems as additions (and any unhealthy transitive deps as regressions) on the first run.
12
+
13
+ ### Added
14
+
15
+ - The `--json` output is now a **versioned, contract-tested machine schema** ([`docs/still_active.schema.json`](docs/still_active.schema.json), JSON Schema 2020-12), carried as a `$schema` URL so the output is self-describing, and it gains a `summary{}` digest, the audit's headline posture (total / direct / transitive, the activity-level breakdown, archived / up-to-date / outdated counts, and vulnerability totals) in one object so a machine or LLM consumer reads it without iterating every gem. The terminal summary line now derives from the same digest, so the human and machine summaries can't drift. Unlike SARIF (findings-only) and CycloneDX (SBOM-only), this is the complete correlation-layer view. (#33)
16
+ - Maintenance signals (stale releases, archived repos, last-commit age, advisories, libyear) now cover the **full transitive lockfile graph** by default, not just declared dependencies, matching libyear-bundler and the CVE scanners still_active composes with. Each gem carries `direct: true|false`, and a flagged transitive gem carries a `dependency_path` back to the direct dependency that pulls it in (e.g. `["rails", "actionpack", "rack"]`), turning an un-actionable transitive finding into an actionable "replace your direct gem" in terminal, markdown, JSON, and SARIF output. `--alternatives` stays **direct-only** by design (you can't swap a gem you didn't choose). `--direct-only` opts back to the previous declared-deps-only scope. Because this multiplies the number of repo/version lookups, prefer running on a schedule rather than per-commit (see the README). (#60)
17
+ - A committed `.still_active.yml` config file, with granular finding-level suppression replacing the all-or-nothing `--ignore`. `--ignore=GEM` drops a gem from every gate at once, so accepting one unfixable advisory also hid that gem going archived or getting a *new* CVE. The file's `ignore:` block keys suppressions by advisory id and/or signal (`activity` / `vulnerability` / `libyear`), each with an optional `reason` and `expires` date. A vulnerability suppression must name an explicit advisory id, so a newly disclosed CVE on the same gem still fails; a lapsed `expires:` makes the finding re-surface as a normal failure (Trivy-style) rather than rotting silently, and a suppression that names a gem not in your dependency graph (a typo, or a gem you've since removed) is reported as a warning, so dead entries surface instead of lingering. The file also mirrors the policy flags (gates, thresholds, `output`, `alternatives`, `unreleased_commits`, `direct_only`) with precedence CLI flag > env var > config file > default, and an `import: [.bundler-audit.yml]` opt-in folds bundler-audit's accepted-advisory list in so teams keep one ignore list. Secrets (tokens) and invocation-specific paths are deliberately not read from the file, so a committed config never carries a credential. Suppressed findings still appear in JSON/terminal/markdown output and are marked in SARIF as native `suppressions[]` entries (with the reason as justification), so GitHub Code Scanning renders them dismissed rather than open. (#46)
18
+ - `--unreleased-commits` adds an `unreleased_commits` count per gem: commits on the default branch since the latest release's tag, the "unreleased work" signal no Ruby tool surfaces today (only GitHub's UI shows it). It distinguishes a gem that looks stale but is genuinely done (no unreleased work) from one with a recent release but a pile of merged-but-unreleased fixes. Opt-in and GitHub-only: it adds one API call per GitHub-hosted gem (the tag is resolved from the RubyGems version, trying `v1.2.3` then `1.2.3`), non-GitHub sources report `null` (the signal is duck-typed via `respond_to?`, no base-class interface), and it is purely informational, never gating a run. Inflated for monorepos and release-branch projects, so it is documented as a lead, not a verdict. (#32)
19
+ - Forgejo/Codeberg repos are now a recognised source for the archived and last-commit signals, alongside GitHub and GitLab. A gem whose canonical `source_code_uri` points at `codeberg.org` previously fell through to no repo signals at all; it now resolves through a new `ForgejoClient` (the Gitea `/api/v1` surface every Forgejo/Gitea instance shares), so the host is a parameter for later self-hosted support. Reads are anonymous by default; `STILL_ACTIVE_FORGEJO_TOKEN`/`CODEBERG_TOKEN` only raise the rate limit or reach private repos. Codeberg-hosted repos are correctly left out of deps.dev OpenSSF Scorecard lookups (deps.dev indexes only github.com/gitlab.com) rather than minting a bogus `github.com/owner/name` project id. (#31)
20
+ - JSON output now includes a derived `activity_level` per gem (`"ok"`, `"stale"`, `"critical"`, `"archived"`, or `"unknown"`), so a machine or LLM consumer reads still_active's maintenance verdict directly instead of re-deriving it from the raw dates. Documented in `docs/schema.md`. (#33)
21
+ - JFrog Artifactory gem registry support: fetches versions from `.jfrog.io` RubyGems-compatible registries via the versions API with an AQL search fallback. Auth reuses Bundler's per-source credentials when present, otherwise a global token via `--artifactory-token` or `STILL_ACTIVE_ARTIFACTORY_TOKEN` (requires a matching `--artifactory-host` / `STILL_ACTIVE_ARTIFACTORY_HOST`).
22
+
23
+ ### Changed
24
+
25
+ - Per-gem date fields in `--json` (`last_commit_date` and the `*_release_date` fields) are now ISO8601 UTC (e.g. `2026-01-02T01:04:05Z`), matching `generated_at`, and the published schema marks them `date-time`. They were previously serialized in Ruby's default `Time` format in the machine's local timezone (`2026-01-02 03:04:05 +0200`), so a consumer parsing them got an inconsistent, machine-dependent value. Consumers that parsed those fields should re-check their date handling.
26
+ - A gem's `archived` flag and last-activity date now come from a **single repository call per gem instead of two**, halving the repo-signal API requests (and easing the rate limit on the full-transitive audits of #60). The repo object already carries both the archived flag and a last-activity timestamp (GitHub `pushed_at`, GitLab `last_activity_at`, Forgejo `updated_at`), so the separate "latest commit" call was redundant: across 11 GitHub repos plus GitLab and Forgejo checks, that timestamp matched the default-branch commit date **to the day**. `last_commit_date` is now that repo last-activity timestamp; it tracks the last commit in practice and, since the activity verdict is release-driven (#32), this doesn't change classifications. (#35)
27
+ - A GitHub rate-limit response is now waited out and retried once when its reset is near (at most 60 seconds away), instead of silently dropping that gem's repo signals. GitHub's concurrent fan-out can trip the secondary/burst limit even with a token, especially now that the full transitive graph is audited (#60); honouring the `Retry-After` / `x-ratelimit-reset` header lets the run self-heal rather than return blanks. Under the async reactor the wait yields to other fibers rather than blocking. A far-away reset (hourly-limit exhaustion) is not auto-waited; it still warns and moves on (set a token, or run less often). (#35)
28
+ - A gem's activity level is now driven by release recency rather than the most recent of release-or-commit. A single trivial commit (a rubocop autofix, a README tweak) on a gem whose last real release was years ago previously masked the release drought and read as healthy; the commit date is now context only, and stands in for the level solely when a gem has no releases at all (e.g. a git-sourced gem). The "ok" ceiling also moves from 12 to 18 months, calibrated against real RubyGems release cadence rather than the npm-derived annual convention, since healthy mature gems (mime-types, bcrypt, mail) routinely go a year or more between releases. (#32)
29
+
30
+ ### Fixed
31
+
32
+ - `--baseline` no longer crashes when pointed at a JSON file that isn't a still_active snapshot (a top-level array, a non-object `gems` section, or a gem/`ruby`/field of the wrong type); it exits 2 with a message naming the problem, honouring the exit-code contract documented since 1.4.0.
33
+ - SARIF `SA002` (AbandonedGem) now uses the same release-driven activity level as the rest of the tool, instead of its own separate commit-date threshold. A gem with recent commits but a years-old release was silently missed by the SARIF/code-scanning output (the inverse of the terminal fix), and the message reported commit age ("no commits in 2.0 years") rather than the release gap that actually triggered the finding ("no release in 4.4 years"). SA002 now fires on the `:critical` tier (no release in over 3 years; the last commit date is used only for gems with no releases, e.g. git-sourced), and the message names the real signal. (#32)
34
+ - CycloneDX SBOM: every versioned component now carries a purl. git/path gems were previously emitted as versioned `type:library` components with no purl, which made Datadog SCA and strict CycloneDX consumers reject the document. The Ruby runtime component also gains a `pkg:generic/ruby` purl and a `ruby-lang:ruby` CPE so interpreter CVEs can match. (#45)
35
+ - deps.dev OpenSSF Scorecard lookups now keep the full GitLab subgroup path. `extract_project_id` truncated `gitlab.com/group/subgroup/project` to `gitlab.com/group/subgroup`, so the score was fetched for the wrong project on any nested GitLab namespace. (#44)
36
+ - GitHub Packages version lookups now URL-escape the (lockfile-derived) gem name, matching the Artifactory path. A name with URL-unsafe characters previously raised `URI::InvalidComponentError`, which was swallowed and silently dropped that gem from the audit. Defensive hardening for the untrusted-lockfile stance; the GitHub token is never sent off the fixed `rubygems.pkg.github.com` host. (#50)
37
+ - `--gemfile` is now honoured under `bundle exec`. Dependency loading and the Ruby-version lookup derived their target from a memoized `Bundler.definition` / the ambient `BUNDLE_GEMFILE`, so an explicit `--gemfile` was ignored; both now read the given path directly. (#42)
38
+ - `HttpHelper` no longer crashes on a 3xx response with a missing or malformed `Location` header. `uri + nil` raised `ArgumentError` and a malformed value raised `URI::InvalidURIError`, neither rescued, so the gem was silently dropped; both now return nil with a warning. (#39)
39
+ - Gems from an unqueryable private source (Gemfury, Gemstash, geminabox, a private mirror) are no longer silently looked up on public rubygems.org. A private name with no public match reported blank data, and one that collided with a public gem reported the *public* gem's versions/dates/libyear/repository as if they were the private gem's. still_active now detects a non-rubygems.org rubygems source, warns, and skips both the public version lookup and the public repository-metadata fallback rather than substituting public data. (#43)
40
+ - A gemspec project's (or local Rails engine's) runtime dependencies are now audited. The `gemspec` / `gem path:` directive surfaces the local gem's *development* deps in the lockfile's DEPENDENCIES, but its *runtime* deps appear only as that gem's nested lockfile deps, so a maintainer auditing their own repo never saw the deps they ship. still_active now expands local path-sourced gems' runtime deps (transitively through nested engines) into the audited set, still parsing the lockfile only and never the gemspec. (#41)
41
+
42
+ ### Security
43
+
44
+ - Credentials are no longer retained on a redirect that changes the port or downgrades the scheme. The redirect follower previously dropped auth headers only when the host changed, so a same-host redirect to a different port (a different service) kept the token; it now requires a full-origin match (scheme, host, and port) and refuses a non-https redirect.
45
+ - still_active no longer evaluates the audited project's Gemfile. `gemfile_dependencies` loaded it via `Bundler.definition`, executing arbitrary Ruby straight from the Gemfile, an unauthenticated RCE when run on an untrusted repository (e.g. CI on a pull request). It now parses `Gemfile.lock` directly with a side-effect-free parser, which also neutralizes `Bundler::LockfileParser`'s own `PLUGIN SOURCE` registry resolution. (#37)
46
+ - `HttpHelper` now caps a response body at 16 MiB, streaming the read rather than buffering the whole body. A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-registerable, so an unbounded body was an unauthenticated OOM triggerable by lockfile content alone. (#40)
47
+ - Markdown output now escapes untrusted metadata. Gem names, licences, versions, repository URLs, and advisory ids drawn from registry/repo metadata, the Gemfile/lockfile, `--baseline`, or `--gems` could otherwise forge table columns or links, break a code span, or inject a list item/heading into a PR comment. GFM escaping is centralised in `StillActive::MarkdownEscape` and applied to both the audit table and the PR diff. (#38)
48
+ - The Ruby Toolbox catalog (used by `--alternatives`) is now fetched via `URI.parse(url).open` instead of `URI.open`, resolving a CodeQL `rb/non-constant-kernel-open` finding. The URL is a constant repo-archive link with no injection path, so this is hardening rather than a fix for a reachable issue.
49
+
3
50
  ## [1.6.0] - 2026-06-08
4
51
 
5
52
  ### Added
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **How do you know if your Ruby dependencies 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 Gemfile.
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`.
6
6
 
7
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
8
 
@@ -85,10 +85,16 @@ still_active --markdown
85
85
  3. `GH_TOKEN` environment variable (`gh` CLI convention)
86
86
  4. `gh auth token` (if `gh` is installed and authenticated)
87
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.
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
89
 
90
90
  GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
91
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
+
92
98
  ### CLI options
93
99
 
94
100
  ```text
@@ -102,15 +108,19 @@ Usage: still_active [options]
102
108
  --markdown Markdown table output
103
109
  --json JSON output (default when piped)
104
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
105
113
  --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
106
114
  --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
107
115
  --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
108
116
  --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
109
117
  --github-oauth-token=TOKEN GitHub OAuth token to make API calls
110
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)
111
121
  --simultaneous-requests=QTY Number of simultaneous requests made
112
- --safe-range-end=YEARS maximum years since last activity considered safe (no warning)
113
- --warning-range-end=YEARS maximum years since last activity that triggers a warning (beyond this is critical)
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)
114
124
  --fail-if-critical Exit 1 if any gem has critical activity warning
115
125
  --fail-if-warning Exit 1 if any gem has warning or critical activity warning
116
126
  --fail-if-vulnerable[=SEVERITY]
@@ -334,13 +344,54 @@ still_active --fail-if-outdated=3 --json
334
344
  still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
335
345
  ```
336
346
 
347
+ ### Configuration file (`.still_active.yml`)
348
+
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.
350
+
351
+ ```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
363
+
364
+ ignore:
365
+ # Accept ONE advisory, by id -- a different/new CVE on nokogiri still fails
366
+ - advisory: CVE-2024-1234
367
+ gem: nokogiri
368
+ 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
+
337
388
  ### Activity thresholds
338
389
 
339
- Activity is determined by the most recent signal across last commit date, latest release date, and latest pre-release date:
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:
340
391
 
341
- - **ok**: last activity within 1 year (configurable with `--safe-range-end`)
342
- - **stale**: last activity between 1 and 3 years ago (configurable with `--warning-range-end`)
343
- - **critical**: last activity over 3 years ago
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`)
344
395
 
345
396
  ### Alternative gem leads (opt-in)
346
397
 
@@ -356,10 +407,26 @@ still_active --gems=paperclip --alternatives
356
407
 
357
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).
358
409
 
410
+ ### Transitive dependencies
411
+
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).
413
+
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
+
359
426
  ### Data sources
360
427
 
361
- - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
362
- - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
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
363
430
  - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
364
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)
365
432
  - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
@@ -370,8 +437,8 @@ These are **leads, not recommendations**: same-category does not mean drop-in re
370
437
  | Option | Default | Description |
371
438
  | ----------------------- | ----------- | ---------------------------------------------------------------- |
372
439
  | `output_format` | auto-detect | Coloured terminal on TTY, JSON when piped |
373
- | `safe_range_end` | 1 year | Last activity within this range is "ok" |
374
- | `warning_range_end` | 3 years | Last activity within this range is "stale"; beyond is "critical" |
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" |
375
442
  | `simultaneous_requests` | 10 | Concurrent API requests |
376
443
 
377
444
  ## Development
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require_relative "../still_active/core_ext"
4
5
 
5
6
  module StillActive
@@ -8,22 +9,46 @@ module StillActive
8
9
 
9
10
  using StillActive::CoreExt
10
11
 
12
+ # The most recent activity signal that drives the level, or nil if none:
13
+ # { date:, kind: } where kind is :release (preferred) or :commit. Release
14
+ # recency drives the level because a release is what a consumer can actually
15
+ # consume (you can't `bundle update` to unreleased commits), so a lone
16
+ # rubocop/README commit can't mask a multi-year release drought. The commit
17
+ # date stands in only when a gem has no releases at all (e.g. a git-sourced
18
+ # gem), where it is the only signal available.
19
+ def last_activity(gem_data)
20
+ release = [
21
+ gem_data[:latest_version_release_date],
22
+ gem_data[:latest_pre_release_version_release_date],
23
+ ].filter_map { parse_time(_1) }.max
24
+ return { date: release, kind: :release } if release
25
+
26
+ commit = parse_time(gem_data[:last_commit_date])
27
+ commit ? { date: commit, kind: :commit } : nil
28
+ end
29
+
30
+ # Coerce a Time or an iso8601-ish string (the SARIF path may supply either)
31
+ # to a Time, or nil if absent/unparseable.
32
+ def parse_time(value)
33
+ return value if value.is_a?(Time)
34
+ return if value.nil?
35
+
36
+ Time.parse(value.to_s)
37
+ rescue ArgumentError, TypeError, RangeError
38
+ nil
39
+ end
40
+
11
41
  # Returns :archived, :ok, :stale, :critical, or :unknown
12
42
  def activity_level(gem_data)
13
43
  return :archived if gem_data[:archived]
14
44
 
15
- most_recent = [
16
- gem_data[:last_commit_date],
17
- gem_data[:latest_version_release_date],
18
- gem_data[:latest_pre_release_version_release_date],
19
- ].compact.max
20
-
21
- return :unknown if most_recent.nil?
45
+ activity = last_activity(gem_data)
46
+ return :unknown if activity.nil?
22
47
 
23
48
  config = StillActive.config
24
- if most_recent >= config.no_warning_range_end.years.ago
49
+ if activity[:date] >= config.no_warning_range_end.years.ago
25
50
  :ok
26
- elsif most_recent >= config.warning_range_end.years.ago
51
+ elsif activity[:date] >= config.warning_range_end.years.ago
27
52
  :stale
28
53
  else
29
54
  :critical
@@ -1,52 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+ require_relative "lockfile_dependency_parser"
5
+
3
6
  module StillActive
4
7
  module BundlerHelper
5
8
  extend self
6
9
 
7
10
  def gemfile_dependencies(gemfile_path: StillActive.config.gemfile_path)
8
11
  absolute_gemfile = File.expand_path(gemfile_path)
9
- ::Bundler::SharedHelpers.set_env("BUNDLE_GEMFILE", absolute_gemfile)
10
- gemfile_gems = ::Bundler.definition.dependencies.map(&:name)
11
- locked_gems = ::Bundler.definition.locked_gems
12
- if locked_gems.nil?
12
+ lockfile = lockfile_path_for(absolute_gemfile)
13
+ unless File.file?(lockfile)
13
14
  raise MissingLockfileError,
14
- "no lockfile next to #{absolute_gemfile} run `bundle lock` (or `bundle install`) first"
15
+ "no lockfile next to #{absolute_gemfile}; run `bundle lock` (or `bundle install`) first"
16
+ end
17
+
18
+ parsed = LockfileDependencyParser.parse(File.read(lockfile))
19
+ if parsed[:plugin_source?]
20
+ warn("warning: lockfile contains a PLUGIN SOURCE block; still_active does not audit Bundler plugins, skipping it")
15
21
  end
16
22
 
17
- locked_gems
18
- .specs
19
- .select { |spec| gemfile_gems.include?(spec.name) }
23
+ direct = audited_names(parsed)
24
+ # Maintenance signals cover the full resolved graph by default (matching
25
+ # libyear-bundler and the CVE scanners we compose with); --direct-only
26
+ # opts back to just the declared/shipped set. Transitive gems carry the
27
+ # path back to the direct dep that pulls them in, so an un-actionable
28
+ # transitive flag becomes an actionable "replace your direct gem A". #60.
29
+ audited = StillActive.config.direct_only ? direct : parsed[:specs].map(&:name)
30
+ direct_set = direct.to_set
31
+ paths = StillActive.config.direct_only ? {} : dependency_paths(parsed[:specs], direct)
32
+
33
+ parsed[:specs]
34
+ .select { |spec| audited.include?(spec.name) }
20
35
  .uniq(&:name)
21
36
  .map do |spec|
37
+ is_direct = direct_set.include?(spec.name)
22
38
  {
23
39
  name: spec.name,
24
- version: spec.version.version,
25
- source_type: detect_source_type(spec),
26
- source_uri: detect_source_uri(spec),
40
+ version: spec.version,
41
+ source_type: spec.source_type || :unknown,
42
+ source_uri: spec.source_uri,
43
+ direct: is_direct,
44
+ dependency_path: is_direct ? nil : paths[spec.name],
27
45
  }
28
46
  end
29
47
  end
30
48
 
31
- private
49
+ # Shortest path from a direct dependency to each reachable gem, by BFS over
50
+ # the lockfile's resolved dependency edges. A direct root maps to [name];
51
+ # a transitive gem maps to [direct_root, ..., name], whose head names the
52
+ # direct dependency a maintainer can actually act on. An unreachable spec
53
+ # (no declared ancestor) gets no path.
54
+ def dependency_paths(specs, roots)
55
+ specs_by_name = specs.to_h { |spec| [spec.name, spec] }
56
+ paths = {}
57
+ queue = []
58
+ roots.each do |name|
59
+ paths[name] = [name]
60
+ queue << name
61
+ end
62
+
63
+ until queue.empty?
64
+ name = queue.shift
65
+ specs_by_name[name]&.dependencies&.each do |dep|
66
+ next if paths.key?(dep)
67
+
68
+ paths[dep] = paths[name] + [dep]
69
+ queue << dep
70
+ end
71
+ end
72
+
73
+ paths
74
+ end
75
+
76
+ # The DEPENDENCIES names, plus the runtime deps of any local path-sourced gem
77
+ # reachable from them (a gemspec project's own gem, or a local Rails engine).
78
+ # A `gemspec` / `gem path:` directive surfaces the local gem's *development*
79
+ # deps in DEPENDENCIES but its *runtime* deps arrive only as that gem's
80
+ # nested lockfile deps, so without this a gem maintainer auditing their own
81
+ # repo would never see the deps they ship. We follow path gems transitively
82
+ # (nested engines) but never expand a regular gem's transitive graph, keeping
83
+ # parity with the "audit what you declare" scope for normal projects. Refs #41.
84
+ def audited_names(parsed)
85
+ specs_by_name = parsed[:specs].to_h { |spec| [spec.name, spec] }
86
+ names = []
87
+ queue = parsed[:direct].dup
88
+
89
+ until queue.empty?
90
+ name = queue.shift
91
+ next if names.include?(name)
32
92
 
33
- def detect_source_type(spec)
34
- case spec.source
35
- when ::Bundler::Source::Rubygems then :rubygems
36
- when ::Bundler::Source::Git then :git
37
- when ::Bundler::Source::Path then :path
38
- else :unknown
93
+ names << name
94
+ spec = specs_by_name[name]
95
+ queue.concat(spec.dependencies) if spec&.source_type == :path
39
96
  end
97
+
98
+ names
40
99
  end
41
100
 
42
- def detect_source_uri(spec)
43
- case spec.source
44
- when ::Bundler::Source::Rubygems
45
- spec.source.remotes&.first&.to_s
46
- when ::Bundler::Source::Git
47
- spec.source.uri
48
- when ::Bundler::Source::Path
49
- spec.source.path&.to_s
101
+ # Bundler's lockfile naming: `gems.rb` pairs with `gems.locked`, every other
102
+ # Gemfile with `<gemfile>.lock`. Derived from the explicit path rather than
103
+ # global Bundler state so `--gemfile` is honoured even under `bundle exec`
104
+ # (where a memoized Bundler.definition / ambient BUNDLE_GEMFILE would
105
+ # otherwise win). Refs #42.
106
+ def lockfile_path_for(gemfile)
107
+ if File.basename(gemfile) == "gems.rb"
108
+ File.join(File.dirname(gemfile), "gems.locked")
109
+ else
110
+ "#{gemfile}.lock"
50
111
  end
51
112
  end
52
113
  end
@@ -80,7 +80,10 @@ module StillActive
80
80
 
81
81
  def download
82
82
  url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
83
- URI.open(url) { |io| io.read(MAX_DOWNLOAD_BYTES) } # rubocop:disable Security/Open
83
+ # URI(url).open (not URI.open/Kernel#open) so a "|cmd" string can never be
84
+ # treated as a shell command — the URL is a constant-repo archive link, but
85
+ # this keeps the open path injection-proof regardless.
86
+ URI.parse(url).open { |io| io.read(MAX_DOWNLOAD_BYTES) }
84
87
  end
85
88
 
86
89
  def build_siblings(categories)
@@ -55,7 +55,7 @@ module StillActive
55
55
  component = { "type" => "library", "name" => name }
56
56
  component["version"] = version if version
57
57
  component["bom-ref"] = bom_ref(name, data)
58
- component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version
58
+ component["purl"] = purl(name, version, data[:source_type]) if version
59
59
  component["licenses"] = licenses(data[:license]) if data[:license]
60
60
  if data[:repository_url]
61
61
  component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
@@ -67,13 +67,19 @@ module StillActive
67
67
 
68
68
  def bom_ref(name, data)
69
69
  version = data[:version_used]
70
- return purl(name, version) if data[:source_type] == :rubygems && version
70
+ return purl(name, version, data[:source_type]) if version
71
71
 
72
- "#{data[:source_type]}-source:#{name}@#{version || "unknown"}"
72
+ "#{data[:source_type]}-source:#{name}@unknown"
73
73
  end
74
74
 
75
- def purl(name, version)
76
- "pkg:gem/#{name}@#{version}"
75
+ # Datadog SCA (and strict CycloneDX consumers) hard-reject a versioned
76
+ # library component with no purl, so every gem with a version needs one.
77
+ # A path gem is local and not on rubygems, so it gets pkg:generic to avoid
78
+ # false-matching a public gem of the same name; git/rubygems-sourced gems
79
+ # get pkg:gem so a fork still matches the upstream gem's advisories.
80
+ def purl(name, version, source_type)
81
+ type = source_type == :path ? "generic" : "gem"
82
+ "pkg:#{type}/#{name}@#{version}"
77
83
  end
78
84
 
79
85
  # VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
@@ -93,12 +99,22 @@ module StillActive
93
99
  }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
94
100
  end
95
101
 
102
+ # The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
103
+ # nothing consumes it and strict SCA validators (Datadog) only accept
104
+ # "library" + a purl. We follow Syft's convention for an unmanaged runtime:
105
+ # type "library", purl pkg:generic/ruby@<ver>, plus a CPE — the CPE is what
106
+ # actually lets a matcher (NVD/Grype) hit interpreter CVEs, since no purl
107
+ # type maps the Ruby runtime in OSV. The EOL/libyear signals stay as
108
+ # still_active properties.
96
109
  def ruby_component(ruby_info)
110
+ version = ruby_info[:version]
97
111
  {
98
- "type" => "platform",
112
+ "type" => "library",
99
113
  "name" => "ruby",
100
- "version" => ruby_info[:version],
101
- "bom-ref" => "platform:ruby@#{ruby_info[:version]}",
114
+ "version" => version,
115
+ "bom-ref" => "pkg:generic/ruby@#{version}",
116
+ "purl" => "pkg:generic/ruby@#{version}",
117
+ "cpe" => "cpe:2.3:a:ruby-lang:ruby:#{version}:*:*:*:*:*:*:*",
102
118
  "properties" => [
103
119
  { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
104
120
  { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "markdown_escape"
4
+
3
5
  module StillActive
4
6
  # Renders a StillActive::Diff::Result as PR-comment-friendly markdown.
5
7
  # Section taxonomy mirrors GitHub's dependency-review-action so reviewers
@@ -49,7 +51,7 @@ module StillActive
49
51
  def regressions_section(regressions)
50
52
  return "" if regressions.empty?
51
53
 
52
- lines = regressions.map { |r| "- **#{r.kind}** `#{r.gem}` — #{r.detail}" }
54
+ lines = regressions.map { |r| "- **#{r.kind}** #{MarkdownEscape.code_span(r.gem)} — #{MarkdownEscape.inline(r.detail)}" }
53
55
  section("Regressions (CI-failable)", lines)
54
56
  end
55
57
 
@@ -63,7 +65,7 @@ module StillActive
63
65
  def removed_section(removed)
64
66
  return "" if removed.empty?
65
67
 
66
- lines = removed.map { |r| "- `#{r.name}` (was #{(r.data || {})["version_used"] || "?"})" }
68
+ lines = removed.map { |r| "- #{MarkdownEscape.code_span(r.name)} (was #{MarkdownEscape.inline((r.data || {})["version_used"] || "?")})" }
67
69
  section("Removed", lines)
68
70
  end
69
71
 
@@ -88,9 +90,9 @@ module StillActive
88
90
  lines = []
89
91
  if ruby[:version_changed]
90
92
  eol_suffix = ruby[:newly_eol] ? " (now EOL)" : ""
91
- lines << "- Ruby `#{ruby[:from]}``#{ruby[:to]}`#{eol_suffix}"
93
+ lines << "- Ruby #{MarkdownEscape.code_span(ruby[:from])} → #{MarkdownEscape.code_span(ruby[:to])}#{eol_suffix}"
92
94
  elsif ruby[:newly_eol]
93
- lines << "- Ruby `#{ruby[:to]}` is now EOL"
95
+ lines << "- Ruby #{MarkdownEscape.code_span(ruby[:to])} is now EOL"
94
96
  end
95
97
  section("Ruby", lines)
96
98
  end
@@ -108,30 +110,31 @@ module StillActive
108
110
  (data["archived"] ? "archived" : nil),
109
111
  data["libyear"] && "#{data["libyear"]}y behind",
110
112
  ].compact
111
- "`#{added.name}` (#{bits.join(", ")})"
113
+ "#{MarkdownEscape.code_span(added.name)} (#{MarkdownEscape.inline(bits.join(", "))})"
112
114
  end
113
115
 
114
116
  def format_bump(bump)
115
117
  label = BUMP_KIND_LABELS[bump.kind]
116
118
  suffix = label ? " (#{label})" : ""
117
- "- `#{bump.name}` #{bump.before_version} → #{bump.after_version}#{suffix}"
119
+ "- #{MarkdownEscape.code_span(bump.name)} #{MarkdownEscape.inline(bump.before_version)} → #{MarkdownEscape.inline(bump.after_version)}#{suffix}"
118
120
  end
119
121
 
120
122
  def format_signal_change_lines(sc)
123
+ name = MarkdownEscape.code_span(sc.name)
121
124
  sc.changes.filter_map do |ch|
122
125
  case ch[:kind]
123
126
  when :archived
124
- "- `#{sc.name}` — archived (false → true)"
127
+ "- #{name} — archived (false → true)"
125
128
  when :new_vulnerability
126
- ids = Array(ch[:ids]).join(", ")
127
- "- `#{sc.name}` — new vulnerability (#{ch[:from]} → #{ch[:to]}#{" — #{ids}" unless ids.empty?})"
129
+ ids = MarkdownEscape.inline(Array(ch[:ids]).join(", "))
130
+ "- #{name} — new vulnerability (#{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{" — #{ids}" unless ids.empty?})"
128
131
  when :scorecard_dropped
129
132
  note = ch[:crossed_good] ? " (crossed 7.0)" : ""
130
- "- `#{sc.name}` — scorecard #{ch[:from]} → #{ch[:to]}#{note}"
133
+ "- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
131
134
  when :version_yanked
132
- "- `#{sc.name}` — version yanked from rubygems"
135
+ "- #{name} — version yanked from rubygems"
133
136
  when :libyear_worsened
134
- "- `#{sc.name}` — libyear #{ch[:from]} → #{ch[:to]} (+#{ch[:delta]}y; same pinned version)"
137
+ "- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
135
138
  end
136
139
  end
137
140
  end