still_active 1.6.0 → 3.0.0.rc1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. metadata +72 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e5e5e599cdd630ec6e916800c4abd7df4a3c9d37e24122e80d8859accb1c4e3
4
- data.tar.gz: 0cb0dfd2c761612f665a9d2f4e0ba20f47bf8b082c42281af09b8429335aa2a9
3
+ metadata.gz: b3541d297e3d8059ffdaee4c1377ab7333fc5d2a54940219d7a4f2611f4c445e
4
+ data.tar.gz: d121da3f25a1db5bbcf5cc3e2351acbaef84076350bccc3074ed869d46881f75
5
5
  SHA512:
6
- metadata.gz: 571797c9863bf6597e7c9474e8cf718019925d37d163002e9d594b137458ca0a6be155dfb7a5069777afe83963fb4dc595026a69c8237ce1aa7d024eb254be99
7
- data.tar.gz: 4e7ba6a7777973b6fe7f1fa54fc1fad00a06639da18944f0bc89befd6a3e794b1d55eeaca62f33b193bebb54cdb2d6d85c63ae48a73adc722e58a3f194c0aeae
6
+ metadata.gz: e6f9391ed541b846f1c65cd0231e6894c69606cfbce1f6c9e636926607e99191c7029f7c79d5fe42257bbf33bb822f84b7772903c3444ce0b593ae8c57c875d6
7
+ data.tar.gz: 15b01401efb0f22e189c16cd5baf5f3086a2969686e3efd701c85c34ef7dc29eba1ede2eecb717f59e10b6d827d8ae729be07a6c42261583f8256dd7c060a7ab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ### Upgrading to 3.0
6
+
7
+ 3.0 is a major bump because a handful of changes can flip a `--fail-if-*` outcome or an exit code on upgrade. The JSON `schema_version` stays `1` (every output change is additive, apart from `up_to_date` widening from `boolean` to `boolean | null`), and no CLI flag was removed or renamed, but review these before upgrading a pipeline:
8
+
9
+ - **`--fail-if-vulnerable=<severity>` now fails CLOSED on an unscored advisory** (and bare `--fail-if-vulnerable` now fires on advisories a failed deps.dev fetch used to silently drop). A previously-green run can go red. Review the named advisory (a per-gem stderr note explains why), then fix it or accept it in `.still_active.yml`. Installing the optional `cvss-suite` gem lets a CVSS-4-only advisory get a real score and clear the gate when it's below threshold.
10
+ - **Bad CLI input now exits 2** with a friendly `error:` line instead of a Ruby backtrace (previously exit 1). SBOM read/parse errors also exit 2. Wrapper scripts that branch on exit codes should treat 2 as "bad invocation".
11
+ - **Two new REQUIRED runtime dependencies**: `semantic_range` (npm/cargo version matching for the below-the-fix signal) and `packageurl-ruby` (SBOM PURL parsing). Both resolve automatically on `gem install`; the Ruby floor is unchanged. `cvss-suite` is deliberately NOT a hard dependency (it exact-pins bundler and caps bigdecimal, the poison-pill pattern still_active itself flags), so install it yourself to light up CVSS-4 scoring.
12
+ - **New SARIF rules SA008 (poison-pill) and SA009 (runtime ceiling)** appear automatically in the GitHub Security tab for `--sarif` users, and an unscored advisory now maps to `warning` (was `note`), so a code-scanning policy set to fail on `warning` can newly fire. Rule IDs SA001-SA007 are unchanged.
13
+ - **Tokenless runs now resolve GitHub repo signals via ecosyste.ms** instead of hitting the 60/hour API wall and degrading to `unknown`. A tokenless user with `--fail-if-critical`/`--fail-if-warning` may see gems that were silently `unknown` now resolve to a real `stale`/`critical`/`archived` and trip the activity gate. Tokened runs are unaffected.
14
+ - **New outbound hosts**: `api.osv.dev` (advisory enrichment) and `endoflife.date` (the runtime-ceiling support window) on the native path, plus `*.ecosyste.ms` for tokenless audits. Egress-restricted CI should allowlist these; each degrades to best-effort (no crash) if blocked.
15
+ - **Re-capture your `--baseline` JSON** after upgrading: the new signals and fields show as changes on the first run.
16
+
17
+ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates default to **off**, so they never break an existing pipeline unless you opt in.
18
+
19
+ ### Added
20
+
21
+ - **Poison-pill / compatibility-ceiling signal (SA008).** A dormant or archived package that caps one of its runtime dependencies below that dependency's current latest major holds the tree below a ceiling no upstream release will raise, and it grows more constraining over time as the capped dep ships new majors. still_active flags it with a receipt naming the capped dep, the cap, and how many majors behind it holds you, ranked by a severity tier (`note`/`warning`/`critical`, scaling with majors-behind) and enforceable in CI with `--fail-if-poison[=TIER]`. Rendered in terminal, markdown, and SARIF, and computed for `rubygems` (native) and the flat-resolution SBOM ecosystems. Scoped to flat resolution deliberately: npm nests versions and cargo coexists majors, so a below-latest cap there is subtree-local noise, not a tree-wide block, and is suppressed.
22
+ - **Security "below the fix", the strongest poison case, now cross-ecosystem.** When a dormant/archived package pins a dependency that is *itself* known-vulnerable in the same tree, below the version that patches a HIGH-or-critical advisory, you cannot patch the CVE without replacing the dead capper. still_active leads with these findings (red in terminal/markdown, escalated to `error` in SARIF) and names the advisory and its nearest fix. This case travels to **npm and cargo** at patch precision (their fixes are mostly same-major patch bumps, invisible to a major-level check), via a real node-semver/Cargo matcher and a tree-copy soundness guard that only fires when every resolved copy the constraint governs is vulnerable (a safe copy elsewhere, or a patched copy the cap can reach, clears it). The correlation is whole-tree from data already assembled, no extra fetches.
23
+ - **Language-runtime ceiling signal (SA009), poison's sibling.** A pinned dependency version can strand you on an end-of-life language runtime (its `required_ruby_version` / `requires_python` forbids a supported one). still_active flags it against the runtime's EOL schedule (from endoflife.date), enforceable with `--fail-if-language-ceiling[=TIER]`, and reconciled against poison so an "upgrade to lift the ceiling" can't contradict a cap that forbids that upgrade. Ruby on the native path; **Python on the `--sbom` path** (pypi).
24
+ - **CVSS-4-only advisory scoring via an optional `cvss-suite`.** deps.dev stores only CVSS 3.x, so a CVSS-4-only advisory arrives unscored; still_active enriches every advisory with OSV's real severity label and fixed-version ranges, and, when the optional `cvss-suite` gem is installed, computes the CVSS-4 base score from the OSV vector. Without it, the OSV/GHSA label still gates and only the computed number is skipped, so the fail-closed logic never reads a real HIGH as clean. `cvss-suite` is opt-in (see Upgrading).
25
+ - **Vulnerabilities with no fixed version available are flagged** (`no_fix_available`): a known advisory you cannot upgrade out of, the one you most need to see because upgrading isn't the answer.
26
+ - **A schema canary for the deps.dev advisory field** (`api.deps.dev`'s alpha `advisoryKeys`): every cross-ecosystem vulnerability count flows through it, so a silent field rename would zero every count and read a vulnerable package as clean. A known-vulnerable canary (`django 3.0.0`) is checked each run; an empty result warns loudly rather than presenting a possibly-understated "all clear".
27
+ - **The `--sbom` path now carries the same version signals as the native audit**: `libyear` drift, a prerelease/ahead-aware `up_to_date`, and (when the SBOM marks CycloneDX dev-vs-prod scope) a `production` boolean so a consumer can separate prod risk from test debt. A private/alternative registry named in a PURL's `repository_url` is refused a public-by-name lookup (the cross-ecosystem dependency-confusion guard), and a pinned version deps.dev can't resolve reads `unknown` rather than riding the fresh package date to a false `ok`.
28
+ - **`--sbom=PATH` audits a CycloneDX SBOM cross-ecosystem**, so the maintenance lens travels beyond Ruby: point it at a Syft/Trivy-produced SBOM and still_active assesses every `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` package the same way it does gems (latest release date, archived repo, advisories, OpenSSF Scorecard, lifecycle `status`), sourcing signals from deps.dev and ecosyste.ms instead of Bundler. Mutually exclusive with `--gemfile`/`--gems`; emits JSON only (its shape differs from the Ruby audit, so it deliberately omits the `$schema` contract). Results are keyed `ecosystem/name@version` so nothing collides and overwrites, not a same-named package across two ecosystems nor two versions of one package pinned by different subprojects of a monorepo (a lockfile resolves one version per name, a merged SBOM doesn't), and each dependency carries its `activity_level` and `status`. The SBOM is treated as **untrusted input**: the only things read from it are ecosystem/name/version, the repository is discovered from deps.dev (never a lockfile-supplied URL, so a hostile SBOM can't redirect a lookup), and anything deps.dev doesn't index degrades to `unknown` rather than a fabricated `ok`. Packages it can't assess (unsupported ecosystems, a component with no version or PURL, **and** a dependency whose lookup raised, e.g. a rate-limited or flaky deps.dev) are surfaced in an `unassessable` list and a stderr count, never silently dropped, so a transient upstream hiccup can't shrink the audit scope invisibly and let it read "all clear" while ignoring the deps it skipped. A present-but-unreadable SBOM (truncated, or not CycloneDX) errors rather than degrading to an empty clean report. OS packages land in `unassessable` too; the differentiated play is maintenance, not vuln scanning, so compose Trivy/Grype for CVEs.
29
+ - **Tokenless GitHub repo signals via [ecosyste.ms](https://ecosyste.ms).** Without a GitHub token the live API caps at 60 requests/hour, unusable past a handful of gems; still_active now falls back to ecosyste.ms (5000 anonymous requests/hour) for GitHub repos' archived + last-commit signals, so a large Gemfile audits cleanly with no token. GitHub-only by design (ecosyste.ms doesn't track commit recency for GitLab/Codeberg, so those keep their own clients); a configured token still takes precedence (freshest data, and it carries `--unreleased-commits`). Requests identify still_active in the User-Agent, and an optional `--ecosystems-email` / `STILL_ACTIVE_ECOSYSTEMS_EMAIL` joins ecosyste.ms's "polite pool" (higher rate limit) as a courtesy to the free service. ecosyste.ms data is CC-BY-SA 4.0 and attributed in the README.
30
+
31
+ - Each gem now carries the OpenSSF Scorecard **`Maintained` sub-check** (`scorecard_maintained`, 0.0-10.0) alongside the aggregate `scorecard_score`. The sub-check scores recent commit and issue activity directly, the question still_active exists to answer, and it was already present in the deps.dev response still_active fetches (previously discarded). `nil` when a project has no scorecard, deliberately distinct from `0.0` ("measured: unmaintained") so absent data never reads as healthy.
32
+ - A single categorical **lifecycle `status`** per gem and a project-level `summary.status`, so a machine/LLM consumer or another tool reads one verdict instead of re-deriving it from `activity_level` + `archived` + `vulnerability_count`. Worst-first: `dead` (dormant/archived **and** carrying an unpatched advisory; no one is fixing it, migrate) > `vulnerable` (a fixable advisory on an actively-released gem) > `archived` > `stale` > `legacy` (long-dormant but **clean**: feature-complete, low risk, the "done gem" that recency alone wrongly flags) > `ok` > `unknown` (never silently `ok`). An EOL Ruby floors the project at `vulnerable`. **archived is not dead:** a repo archived while the gem still publishes recent releases (development moved to a monorepo) reads as `stale`, not dead; only a genuinely dormant archived repo is `archived`. This is a display/threshold convenience over the raw fields, not the numeric 0-100 composite removed earlier (which let missing data read as perfect health). Documented in `docs/schema.md`. Additive, so `schema_version` stays `1`.
33
+
34
+ ### Fixed
35
+
36
+ - **`--baseline` now honours a committed `.still_active.yml`.** The PR-diff gate was the only gate that ignored the suppression list: a maintenance regression the audit and SARIF gates already accept still tripped `--baseline` the moment the dependency was added, with no way to accept it short of merging it to the baseline first. An accepted regression is now moved out of the CI-failable set and shown under an "Accepted (suppressed via `.still_active.yml`)" section with its reason, so the acceptance stays visible rather than silently vanishing. Only maintenance kinds a bare gem+signal entry can cover are accepted (archived/staleness map to `activity`/`libyear`); a newly introduced vulnerability still fails, since suppressing one needs an explicit advisory id the diff regression doesn't carry.
37
+ - **The fail-open on the vulnerability gate is closed on both paths.** `--fail-if-vulnerable=<severity>` no longer passes a confirmed advisory that carries no CVSS score (an unscored advisory read as "below threshold" and cleared the gate, worst for a freshly disclosed CVE); it now fails closed with a per-gem stderr note (see Upgrading). And the native path no longer drops a confirmed advisory when its deps.dev detail fetch fails (429/timeout/5xx); it previously `filter_map`'d the failure away and zeroed `vulnerability_count`, it now keeps a minimal advisory so the finding survives and the fail-closed logic applies.
38
+ - **deps.dev CVE aliases are surfaced correctly.** The v3alpha API returns aliases as bare id strings, not objects; the parser read `a["id"]` on a string and dropped every alias, so cross-referenced CVE/GHSA ids vanished. It now tolerates both shapes (and coerces a drifted non-string alias to a string, warning once, so it can't crash the advisory merge and read a vulnerable gem as clean).
39
+ - **A multi-range advisory no longer hides a stuck cap or names a downgrade as the fix.** OSV lists a fixed version per affected range; the below-the-fix check used the global-minimum fix, so a downgrade to an older line could read as "patchable in place" (a false negative) or be named as the fix. It now considers only fixes above the version in hand.
40
+ - **A pinned version the registry can't resolve reads `unknown`, not `ok`.** A nonexistent or yanked pinned version on the `--sbom` path rode the fresh package date to a false `ok`; it now reports `unknown`, with a single retry distinguishing a real 404 from a transient miss.
41
+ - **Graceful degradation on real SBOM/CLI input.** A git-scheme or scp `SOURCE_REPO` URL from deps.dev now parses correctly (recovering repo signals and silencing a warning flood); a malformed PURL (`pkg:npm/@1.0.0`, bad `%`-encoding) degrades to `unassessable` instead of backtracing and dropping every other dependency's verdict; and bad CLI input exits 2 with a friendly error instead of a stack trace (see Upgrading).
42
+ - **The published JSON Schema matches real output.** It had rejected any report containing a vulnerability, because OSV enrichment adds fields (`osv_severity`, `fixed_versions`, and more) the strict `additionalProperties: false` schema didn't list. The schema now covers them (and the poison/below-the-fix keys), and the contract test validates enriched output so it can't drift silently again.
43
+
44
+ ## [2.0.0] - 2026-06-14
45
+
46
+ ### Upgrading to 2.0
47
+
48
+ 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):
49
+
50
+ - **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.
51
+ - **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`.
52
+ - **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.
53
+
54
+ ### Added
55
+
56
+ - 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)
57
+ - 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)
58
+ - 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)
59
+ - `--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)
60
+ - 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)
61
+ - 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)
62
+ - 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`).
63
+
64
+ ### Changed
65
+
66
+ - 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.
67
+ - 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)
68
+ - 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)
69
+ - 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)
70
+
71
+ ### Fixed
72
+
73
+ - `--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.
74
+ - 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)
75
+ - 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)
76
+ - 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)
77
+ - 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)
78
+ - `--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)
79
+ - `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)
80
+ - 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)
81
+ - 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)
82
+
83
+ ### Security
84
+
85
+ - 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.
86
+ - 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)
87
+ - `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)
88
+ - 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)
89
+ - 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.
90
+
3
91
  ## [1.6.0] - 2026-06-08
4
92
 
5
93
  ### Added
data/README.md CHANGED
@@ -2,7 +2,9 @@
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 (flagging those with **no fixed version available**, the ones you can't upgrade out of), 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
+
7
+ Ruby-first, but the maintenance lens isn't Ruby-only: point [`--sbom`](#cross-ecosystem-sbom-audit---sbom) at a CycloneDX SBOM and `still_active` audits your **npm, PyPI, Cargo, Go, Maven, and NuGet** dependencies the same way it does gems.
6
8
 
7
9
  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
10
 
@@ -34,15 +36,16 @@ Ruby 4.0.1 (latest)
34
36
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
35
37
  | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
36
38
  | Outdated versions | Yes | - | Yes | Yes |
37
- | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
39
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + OSV + ruby-advisory-db) |
38
40
  | Libyear drift | - | - | Yes | Yes |
39
41
  | **Last commit activity** | - | - | - | **Yes** |
40
42
  | **Archived repo detection** | - | - | - | **Yes** |
41
43
  | **OpenSSF Scorecard** | - | - | - | **Yes** |
42
44
  | **Yanked version detection** | - | - | - | **Yes** |
43
45
  | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
46
+ | **Cross-ecosystem** (npm/PyPI/Cargo/Go/Maven/NuGet) | - | - | - | **Yes** (`--sbom`) |
44
47
  | GitLab support | - | - | - | Yes |
45
- | CI quality gates | - | Exit code | - | Yes (4 flags) |
48
+ | CI quality gates | - | Exit code | - | Yes (6 flags) |
46
49
  | Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
47
50
 
48
51
  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.
@@ -85,10 +88,18 @@ still_active --markdown
85
88
  3. `GH_TOKEN` environment variable (`gh` CLI convention)
86
89
  4. `gh auth token` (if `gh` is installed and authenticated)
87
90
 
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.
91
+ With a token, GitHub repo signals (archived, last commit) come from the GitHub API (limit 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 run less often.
92
+
93
+ **Without a token**, the unauthenticated GitHub API is capped at 60 requests/hour — unusable past a handful of gems. So still_active falls back to [ecosyste.ms](https://ecosyste.ms) for GitHub repo signals (5000 anonymous requests/hour), which keeps a large Gemfile working with no token at all. The fallback is GitHub-only: ecosyste.ms doesn't track commit recency for GitLab/Codeberg, so those hosts still need their own token (or report `unknown`) when unauthenticated. A token still gives the freshest data and unlocks `--unreleased-commits`, so prefer one in CI. To be a good citizen of the free ecosyste.ms service, you can join its "polite pool" (a higher rate limit, and they can reach you) by passing a contact email via `--ecosystems-email=you@example.com` or `STILL_ACTIVE_ECOSYSTEMS_EMAIL` — optional, since the anonymous limit already covers a typical lockfile.
89
94
 
90
95
  GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
91
96
 
97
+ 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.
98
+
99
+ 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`).
100
+
101
+ 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.
102
+
92
103
  ### CLI options
93
104
 
94
105
  ```text
@@ -98,24 +109,33 @@ Usage: still_active [options]
98
109
 
99
110
  --gemfile=GEMFILE path to gemfile
100
111
  --gems=GEM,GEM2,... Gem(s)
112
+ --sbom=PATH audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget); JSON output
101
113
  --terminal Coloured terminal output (default in TTY)
102
114
  --markdown Markdown table output
103
115
  --json JSON output (default when piped)
104
116
  --alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
117
+ --unreleased-commits Count commits on the default branch since the latest release (GitHub only; opt-in)
118
+ --direct-only Audit only direct (declared) deps, not the full transitive graph
105
119
  --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
106
120
  --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
107
121
  --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
108
122
  --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
109
123
  --github-oauth-token=TOKEN GitHub OAuth token to make API calls
110
124
  --gitlab-token=TOKEN GitLab personal access token for API calls
125
+ --artifactory-token=TOKEN Artifactory token for private gem registry API calls
126
+ --artifactory-host=HOST Artifactory host allowed to receive the global token (e.g. my-org.jfrog.io)
127
+ --ecosystems-email=EMAIL Contact email to join the ecosyste.ms polite pool (higher rate limit)
111
128
  --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)
129
+ --safe-range-end=YEARS maximum years since last release considered safe, no warning (default 1.5)
130
+ --warning-range-end=YEARS maximum years since last release that triggers a warning, beyond this is critical (default 3)
114
131
  --fail-if-critical Exit 1 if any gem has critical activity warning
115
132
  --fail-if-warning Exit 1 if any gem has warning or critical activity warning
116
133
  --fail-if-vulnerable[=SEVERITY]
117
134
  Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY)
118
135
  --fail-if-outdated=LIBYEARS Exit 1 if any gem exceeds LIBYEARS behind latest
136
+ --fail-if-poison[=TIER] Exit 1 on a poison-pill at or above TIER (note|warning|critical; default warning)
137
+ --fail-if-language-ceiling[=TIER]
138
+ Exit 1 on a language-runtime ceiling (Ruby/Python; default: EOL-forced only; =note also gates latest-not-yet)
119
139
  --ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
120
140
  --critical-warning-emoji=EMOJI
121
141
  --futurist-emoji=EMOJI
@@ -201,11 +221,45 @@ still_active --cyclonedx --cyclonedx-version=1.7
201
221
 
202
222
  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.
203
223
 
224
+ ### Cross-ecosystem SBOM audit (`--sbom`)
225
+
226
+ still_active is Ruby-first, but the maintenance lens isn't Ruby-only. Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft), Trivy, or any producer) and it assesses `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` packages the same way it does gems, sourcing signals from [deps.dev](https://deps.dev) and ecosyste.ms instead of Bundler:
227
+
228
+ ```bash
229
+ syft dir:. -o cyclonedx-json > sbom.json
230
+ still_active --sbom=sbom.json # JSON report, keyed "ecosystem/name@version"
231
+ ```
232
+
233
+ Each dependency carries the same `activity_level` and lifecycle `status` as the Ruby path, and the `summary` reports the worst status across the SBOM. When the SBOM marks dev-vs-prod scope (CycloneDX `scope`, or a `cyclonedx-npm`-style development property), each dependency also carries a `production` boolean so a consumer can separate prod risk from test debt; generators that don't mark it (Syft drops the label) leave the key absent, which reads as unknown rather than a false all-clear. `--sbom` is mutually exclusive with `--gemfile`/`--gems` and always emits JSON (its shape differs from the Ruby audit, so it omits the `$schema` contract).
234
+
235
+ The SBOM is **untrusted input**: only ecosystem/name/version are read from it, the repository is resolved from deps.dev (never a URL the SBOM supplies, so a hostile SBOM can't redirect a lookup), and anything deps.dev doesn't index degrades to `unknown` rather than a fabricated `ok`. Packages it can't assess (unsupported ecosystems, a component with no version or PURL, or one whose lookup failed against a rate-limited/flaky deps.dev) are surfaced in an `unassessable` list plus a stderr count, never silently dropped, so an audit can't read "all clear" while ignoring what it skipped; a present-but-unreadable SBOM errors rather than reporting a clean empty audit. OS packages land in `unassessable` too: the differentiated play is **maintenance**, not vulnerability scanning, so compose Trivy/Grype for CVEs.
236
+
237
+ The activity (`--fail-if-critical`/`--fail-if-warning`), vulnerability (`--fail-if-vulnerable`), and libyear (`--fail-if-outdated`) gates all apply to SBOM dependencies, which carry `libyear` and `up_to_date` the same as the Ruby path. **The full poison-pill signal is scoped to flat-resolution ecosystems** (rubygems, pypi): there a dormant gem's below-latest cap forces the whole tree onto the old version. npm nests multiple versions and cargo lets majors coexist (and their caret default makes "below latest major" the norm, not a hazard), so the plain below-latest cap there is subtree-local noise and stays suppressed.
238
+
239
+ **The security "below the fix" case, though, does travel to npm and cargo.** When a *dormant or archived* package pins a dependency below the version that patches a HIGH-or-critical advisory, still_active flags it — at patch precision (npm/cargo fixes are mostly same-major patch bumps), and only when *every* resolved copy the constraint governs is vulnerable (a safe copy elsewhere in the tree, or a patched copy the constraint can reach, clears it), so it stays silent unless the tree is genuinely stuck. Example: an archived `request@2.88.2` pinning `form-data ~2.3.2` below the fix for a critical CVE — you can't patch it without replacing `request`. Other Ruby-shaped policy features still don't travel to the SBOM path: `.still_active.yml` suppressions and `--ignore` key on the gem name (SBOM results key on `ecosystem/name@version`).
240
+
241
+ #### Which signal covers which path
242
+
243
+ Most maintenance signals apply everywhere; a few are deliberately scoped (see [`docs/rules.md`](docs/rules.md) for the full rule reference).
244
+
245
+ | Signal (rule) | Ruby (native) | `--sbom` (cross-ecosystem) |
246
+ | ------------- | ------------- | -------------------------- |
247
+ | Archived repo (SA001) | Yes | Yes (npm, PyPI, Cargo, Go, Maven, NuGet) |
248
+ | Abandoned / no recent release (SA002) | Yes | Yes (all of the above) |
249
+ | Low OpenSSF Scorecard (SA005) | Yes | Yes (all of the above) |
250
+ | Libyear drift (SA004) | Yes | Yes (all of the above) |
251
+ | Vulnerabilities (SA003) | Yes (deps.dev + OSV + ruby-advisory-db) | Yes (deps.dev + OSV) |
252
+ | &nbsp;&nbsp;↳ "below the fix" (dead capper pins a vulnerable dep) | Yes | npm, Cargo, PyPI (patch precision) |
253
+ | Poison-pill / compatibility ceiling (SA008) | Yes (rubygems) | PyPI only (flat resolution; npm/Cargo suppressed) |
254
+ | Language-runtime ceiling (SA009) | Yes (`ruby_version`) | Python only (`requires_python`) |
255
+ | Ruby EOL (SA006) | Yes | n/a (Ruby-runtime signal) |
256
+ | Yanked version (SA007) | Yes | n/a |
257
+
204
258
  ### SARIF output (GitHub Code Scanning)
205
259
 
206
260
  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.
207
261
 
208
- > **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).
262
+ > **See it live:** this repo audits itself on every push. Browse the current open findings in the [Code Scanning Security tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen).
209
263
 
210
264
  ```bash
211
265
  still_active --sarif # writes still_active.sarif.json
@@ -247,7 +301,7 @@ jobs:
247
301
  with: { sarif_file: still_active.sarif.json }
248
302
  ```
249
303
 
250
- Rule reference (SA001–SA007) and how to suppress: see [`docs/rules.md`](docs/rules.md).
304
+ Rule reference (SA001–SA009) and how to suppress: see [`docs/rules.md`](docs/rules.md).
251
305
 
252
306
  ### Baseline diff (PR review)
253
307
 
@@ -334,13 +388,56 @@ still_active --fail-if-outdated=3 --json
334
388
  still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
335
389
  ```
336
390
 
391
+ ### Configuration file (`.still_active.yml`)
392
+
393
+ `--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.
394
+
395
+ ```yaml
396
+ # .still_active.yml -- policy defaults plus granular suppression
397
+ fail_if_critical: true
398
+ fail_if_vulnerable: high # true, or a minimum severity: low|medium|high|critical
399
+ fail_if_outdated: 3 # libyears
400
+ fail_if_poison: warning # true (=warning), or a tier: note|warning|critical (severity scales with majors-behind)
401
+ fail_if_language_ceiling: true # true = EOL-forced ceilings only; :note also gates latest-not-yet (fluctuates with the runtime's release calendar)
402
+ unreleased_commits: true
403
+ output: json # terminal | markdown | json
404
+ direct_only: true # audit only declared deps, not the full transitive graph (--direct-only)
405
+
406
+ # Pull bundler-audit's accepted-advisory list instead of maintaining two files
407
+ import:
408
+ - .bundler-audit.yml
409
+
410
+ ignore:
411
+ # Accept ONE advisory, by id -- a different/new CVE on nokogiri still fails
412
+ - advisory: CVE-2024-1234
413
+ gem: nokogiri
414
+ reason: "no fix released; not reachable from our code path"
415
+ expires: 2026-09-01 # re-surfaces as a normal failure after this date
416
+
417
+ # Accept staleness on a vendored gem, but still fail if it gets a CVE
418
+ - gem: legacy_thing
419
+ signal: activity # activity | vulnerability | libyear | poison | language_ceiling
420
+ reason: "vendored, intentionally frozen"
421
+
422
+ # A bare gem name keeps the old whole-gem behaviour (mutes every signal)
423
+ - some_internal_gem
424
+ ```
425
+
426
+ Design:
427
+
428
+ - **Granularity.** Key by `advisory:` (one CVE) and/or `signal:` (`activity` / `vulnerability` / `libyear` / `poison`), 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.
429
+ - **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.
430
+ - **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.
431
+ - **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.
432
+ - **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.
433
+
337
434
  ### Activity thresholds
338
435
 
339
- Activity is determined by the most recent signal across last commit date, latest release date, and latest pre-release date:
436
+ 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
437
 
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
438
+ - **ok**: last release within 18 months (configurable with `--safe-range-end`)
439
+ - **stale**: last release between 18 months and 3 years ago
440
+ - **critical**: last release over 3 years ago (configurable with `--warning-range-end`)
344
441
 
345
442
  ### Alternative gem leads (opt-in)
346
443
 
@@ -356,11 +453,28 @@ still_active --gems=paperclip --alternatives
356
453
 
357
454
  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
455
 
456
+ ### Transitive dependencies
457
+
458
+ 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).
459
+
460
+ 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.
461
+
462
+ `--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.
463
+
464
+ > **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.
465
+
466
+ ### Unreleased commits (opt-in)
467
+
468
+ `--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.
469
+
470
+ 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).
471
+
359
472
  ### Data sources
360
473
 
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
363
- - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
474
+ - **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
475
+ - **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 — or, for GitHub repos when no token is configured, from [ecosyste.ms](https://ecosyste.ms) ([repos API](https://repos.ecosyste.ms)). ecosyste.ms data is licensed [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
476
+ - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API (also the cross-ecosystem source on the `--sbom` path)
477
+ - **Advisory severity labels and fixed-version ranges** from [OSV](https://osv.dev) (`api.osv.dev`), which drive the "below the fix" signal. An advisory that publishes only a CVSS **4.0** vector stays unscored unless you install the optional [`cvss-suite`](https://rubygems.org/gems/cvss-suite) gem (`gem install cvss-suite`), which computes its 4.0 base score; without it the gate still fires on the OSV/GHSA severity label, it just skips the number.
364
478
  - **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
479
  - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
366
480
  - **Alternative gem leads** (with `--alternatives`) from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data
@@ -370,8 +484,8 @@ These are **leads, not recommendations**: same-category does not mean drop-in re
370
484
  | Option | Default | Description |
371
485
  | ----------------------- | ----------- | ---------------------------------------------------------------- |
372
486
  | `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" |
487
+ | `safe_range_end` | 1.5 years | Last release within this range is "ok" |
488
+ | `warning_range_end` | 3 years | Last release within this range is "stale"; beyond is "critical" |
375
489
  | `simultaneous_requests` | 10 | Concurrent API requests |
376
490
 
377
491
  ## 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,54 @@ 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
45
+ release_recency_level(gem_data)
46
+ end
20
47
 
21
- return :unknown if most_recent.nil?
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)
53
+ activity = last_activity(gem_data)
54
+ return :unknown if activity.nil?
22
55
 
23
56
  config = StillActive.config
24
- if most_recent >= config.no_warning_range_end.years.ago
57
+ if activity[:date] >= config.no_warning_range_end.years.ago
25
58
  :ok
26
- elsif most_recent >= config.warning_range_end.years.ago
59
+ elsif activity[:date] >= config.warning_range_end.years.ago
27
60
  :stale
28
61
  else
29
62
  :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