Skip to content

v2.0.0

Choose a tag to compare

@SeanLF SeanLF released this 14 Jun 13:45
03c6ec2

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-only to 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 --baseline JSON after upgrading. The transitive expansion shows the new gems as additions (and any unhealthy transitive deps as regressions) on the first run.

Added

  • The --json output is now a versioned, contract-tested machine schema (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)
  • 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)
  • 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)
  • --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)
  • 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)
  • 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)
  • 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).

Changed

  • 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.
  • 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)
  • 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)
  • 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

  • --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.
  • 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)
  • 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)
  • 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)
  • 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)
  • --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)
  • 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)
  • 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_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)
  • 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)
  • 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)
  • 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.