still_active 1.6.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +79 -12
- data/lib/helpers/activity_helper.rb +34 -9
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/cyclonedx_helper.rb +24 -8
- data/lib/helpers/diff_markdown_helper.rb +15 -12
- data/lib/helpers/http_helper.rb +101 -14
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +27 -5
- data/lib/helpers/ruby_helper.rb +5 -4
- data/lib/helpers/sarif_helper.rb +53 -26
- data/lib/helpers/summary_helper.rb +48 -0
- data/lib/helpers/terminal_helper.rb +27 -17
- data/lib/helpers/version_helper.rb +7 -1
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/cli.rb +87 -18
- data/lib/still_active/config.rb +32 -2
- data/lib/still_active/config_file.rb +180 -0
- data/lib/still_active/deps_dev_client.rb +27 -6
- data/lib/still_active/diff.rb +59 -2
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +14 -5
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +2 -2
- data/lib/still_active/suppressions.rb +142 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +113 -55
- data/still_active.gemspec +9 -7
- metadata +19 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1db517014109d081c46e71d63cf61d4446d60f7c7205793c520293f6cc03e329
|
|
4
|
+
data.tar.gz: 5239b26426cb8a8359c3efbafb1aaf3480150bf6a190a0912cc1f78d4ca0e226
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49f561e7ea5a5e0de227e5c861cd4b1ef2bcca57ad967712e5411fd25ea00cea49ee07801f3b4b6be7dc205259c8f2e3c36481ec5148fa52849a8246070beb62
|
|
7
|
+
data.tar.gz: 5552c0f3c9ba60c3e9046372923f4fa3aee2b2152e5eea6765f99f0c14fdf7785798df477605e04c11a7ac5f6bb2fec06dbc1f0ece3e46b1ee91aaa341cc2ae7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Upgrading to 2.0
|
|
6
|
+
|
|
7
|
+
2.0 is a major bump because two changes can alter `--fail-if-*` outcomes on upgrade (the CLI flags and JSON `schema_version` are otherwise backward-compatible):
|
|
8
|
+
|
|
9
|
+
- **Transitive by default.** Maintenance signals now cover the full resolved lockfile, so a CI gate can newly fail on a transitive critical/vulnerable/outdated gem. Add `--direct-only` to restore the pre-2.0 declared-deps scope.
|
|
10
|
+
- **Activity recalibration.** The "ok" ceiling moved 12 → 18 months and the level is release-driven, so some gems are reclassified. Tune with `--safe-range-end` / `--warning-range-end`.
|
|
11
|
+
- **Baselines:** re-capture your `--baseline` JSON after upgrading. The transitive expansion shows the new gems as additions (and any unhealthy transitive deps as regressions) on the first run.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The `--json` output is now a **versioned, contract-tested machine schema** ([`docs/still_active.schema.json`](docs/still_active.schema.json), JSON Schema 2020-12), carried as a `$schema` URL so the output is self-describing, and it gains a `summary{}` digest, the audit's headline posture (total / direct / transitive, the activity-level breakdown, archived / up-to-date / outdated counts, and vulnerability totals) in one object so a machine or LLM consumer reads it without iterating every gem. The terminal summary line now derives from the same digest, so the human and machine summaries can't drift. Unlike SARIF (findings-only) and CycloneDX (SBOM-only), this is the complete correlation-layer view. (#33)
|
|
16
|
+
- Maintenance signals (stale releases, archived repos, last-commit age, advisories, libyear) now cover the **full transitive lockfile graph** by default, not just declared dependencies, matching libyear-bundler and the CVE scanners still_active composes with. Each gem carries `direct: true|false`, and a flagged transitive gem carries a `dependency_path` back to the direct dependency that pulls it in (e.g. `["rails", "actionpack", "rack"]`), turning an un-actionable transitive finding into an actionable "replace your direct gem" in terminal, markdown, JSON, and SARIF output. `--alternatives` stays **direct-only** by design (you can't swap a gem you didn't choose). `--direct-only` opts back to the previous declared-deps-only scope. Because this multiplies the number of repo/version lookups, prefer running on a schedule rather than per-commit (see the README). (#60)
|
|
17
|
+
- A committed `.still_active.yml` config file, with granular finding-level suppression replacing the all-or-nothing `--ignore`. `--ignore=GEM` drops a gem from every gate at once, so accepting one unfixable advisory also hid that gem going archived or getting a *new* CVE. The file's `ignore:` block keys suppressions by advisory id and/or signal (`activity` / `vulnerability` / `libyear`), each with an optional `reason` and `expires` date. A vulnerability suppression must name an explicit advisory id, so a newly disclosed CVE on the same gem still fails; a lapsed `expires:` makes the finding re-surface as a normal failure (Trivy-style) rather than rotting silently, and a suppression that names a gem not in your dependency graph (a typo, or a gem you've since removed) is reported as a warning, so dead entries surface instead of lingering. The file also mirrors the policy flags (gates, thresholds, `output`, `alternatives`, `unreleased_commits`, `direct_only`) with precedence CLI flag > env var > config file > default, and an `import: [.bundler-audit.yml]` opt-in folds bundler-audit's accepted-advisory list in so teams keep one ignore list. Secrets (tokens) and invocation-specific paths are deliberately not read from the file, so a committed config never carries a credential. Suppressed findings still appear in JSON/terminal/markdown output and are marked in SARIF as native `suppressions[]` entries (with the reason as justification), so GitHub Code Scanning renders them dismissed rather than open. (#46)
|
|
18
|
+
- `--unreleased-commits` adds an `unreleased_commits` count per gem: commits on the default branch since the latest release's tag, the "unreleased work" signal no Ruby tool surfaces today (only GitHub's UI shows it). It distinguishes a gem that looks stale but is genuinely done (no unreleased work) from one with a recent release but a pile of merged-but-unreleased fixes. Opt-in and GitHub-only: it adds one API call per GitHub-hosted gem (the tag is resolved from the RubyGems version, trying `v1.2.3` then `1.2.3`), non-GitHub sources report `null` (the signal is duck-typed via `respond_to?`, no base-class interface), and it is purely informational, never gating a run. Inflated for monorepos and release-branch projects, so it is documented as a lead, not a verdict. (#32)
|
|
19
|
+
- Forgejo/Codeberg repos are now a recognised source for the archived and last-commit signals, alongside GitHub and GitLab. A gem whose canonical `source_code_uri` points at `codeberg.org` previously fell through to no repo signals at all; it now resolves through a new `ForgejoClient` (the Gitea `/api/v1` surface every Forgejo/Gitea instance shares), so the host is a parameter for later self-hosted support. Reads are anonymous by default; `STILL_ACTIVE_FORGEJO_TOKEN`/`CODEBERG_TOKEN` only raise the rate limit or reach private repos. Codeberg-hosted repos are correctly left out of deps.dev OpenSSF Scorecard lookups (deps.dev indexes only github.com/gitlab.com) rather than minting a bogus `github.com/owner/name` project id. (#31)
|
|
20
|
+
- JSON output now includes a derived `activity_level` per gem (`"ok"`, `"stale"`, `"critical"`, `"archived"`, or `"unknown"`), so a machine or LLM consumer reads still_active's maintenance verdict directly instead of re-deriving it from the raw dates. Documented in `docs/schema.md`. (#33)
|
|
21
|
+
- JFrog Artifactory gem registry support: fetches versions from `.jfrog.io` RubyGems-compatible registries via the versions API with an AQL search fallback. Auth reuses Bundler's per-source credentials when present, otherwise a global token via `--artifactory-token` or `STILL_ACTIVE_ARTIFACTORY_TOKEN` (requires a matching `--artifactory-host` / `STILL_ACTIVE_ARTIFACTORY_HOST`).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Per-gem date fields in `--json` (`last_commit_date` and the `*_release_date` fields) are now ISO8601 UTC (e.g. `2026-01-02T01:04:05Z`), matching `generated_at`, and the published schema marks them `date-time`. They were previously serialized in Ruby's default `Time` format in the machine's local timezone (`2026-01-02 03:04:05 +0200`), so a consumer parsing them got an inconsistent, machine-dependent value. Consumers that parsed those fields should re-check their date handling.
|
|
26
|
+
- A gem's `archived` flag and last-activity date now come from a **single repository call per gem instead of two**, halving the repo-signal API requests (and easing the rate limit on the full-transitive audits of #60). The repo object already carries both the archived flag and a last-activity timestamp (GitHub `pushed_at`, GitLab `last_activity_at`, Forgejo `updated_at`), so the separate "latest commit" call was redundant: across 11 GitHub repos plus GitLab and Forgejo checks, that timestamp matched the default-branch commit date **to the day**. `last_commit_date` is now that repo last-activity timestamp; it tracks the last commit in practice and, since the activity verdict is release-driven (#32), this doesn't change classifications. (#35)
|
|
27
|
+
- A GitHub rate-limit response is now waited out and retried once when its reset is near (at most 60 seconds away), instead of silently dropping that gem's repo signals. GitHub's concurrent fan-out can trip the secondary/burst limit even with a token, especially now that the full transitive graph is audited (#60); honouring the `Retry-After` / `x-ratelimit-reset` header lets the run self-heal rather than return blanks. Under the async reactor the wait yields to other fibers rather than blocking. A far-away reset (hourly-limit exhaustion) is not auto-waited; it still warns and moves on (set a token, or run less often). (#35)
|
|
28
|
+
- A gem's activity level is now driven by release recency rather than the most recent of release-or-commit. A single trivial commit (a rubocop autofix, a README tweak) on a gem whose last real release was years ago previously masked the release drought and read as healthy; the commit date is now context only, and stands in for the level solely when a gem has no releases at all (e.g. a git-sourced gem). The "ok" ceiling also moves from 12 to 18 months, calibrated against real RubyGems release cadence rather than the npm-derived annual convention, since healthy mature gems (mime-types, bcrypt, mail) routinely go a year or more between releases. (#32)
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `--baseline` no longer crashes when pointed at a JSON file that isn't a still_active snapshot (a top-level array, a non-object `gems` section, or a gem/`ruby`/field of the wrong type); it exits 2 with a message naming the problem, honouring the exit-code contract documented since 1.4.0.
|
|
33
|
+
- SARIF `SA002` (AbandonedGem) now uses the same release-driven activity level as the rest of the tool, instead of its own separate commit-date threshold. A gem with recent commits but a years-old release was silently missed by the SARIF/code-scanning output (the inverse of the terminal fix), and the message reported commit age ("no commits in 2.0 years") rather than the release gap that actually triggered the finding ("no release in 4.4 years"). SA002 now fires on the `:critical` tier (no release in over 3 years; the last commit date is used only for gems with no releases, e.g. git-sourced), and the message names the real signal. (#32)
|
|
34
|
+
- CycloneDX SBOM: every versioned component now carries a purl. git/path gems were previously emitted as versioned `type:library` components with no purl, which made Datadog SCA and strict CycloneDX consumers reject the document. The Ruby runtime component also gains a `pkg:generic/ruby` purl and a `ruby-lang:ruby` CPE so interpreter CVEs can match. (#45)
|
|
35
|
+
- deps.dev OpenSSF Scorecard lookups now keep the full GitLab subgroup path. `extract_project_id` truncated `gitlab.com/group/subgroup/project` to `gitlab.com/group/subgroup`, so the score was fetched for the wrong project on any nested GitLab namespace. (#44)
|
|
36
|
+
- GitHub Packages version lookups now URL-escape the (lockfile-derived) gem name, matching the Artifactory path. A name with URL-unsafe characters previously raised `URI::InvalidComponentError`, which was swallowed and silently dropped that gem from the audit. Defensive hardening for the untrusted-lockfile stance; the GitHub token is never sent off the fixed `rubygems.pkg.github.com` host. (#50)
|
|
37
|
+
- `--gemfile` is now honoured under `bundle exec`. Dependency loading and the Ruby-version lookup derived their target from a memoized `Bundler.definition` / the ambient `BUNDLE_GEMFILE`, so an explicit `--gemfile` was ignored; both now read the given path directly. (#42)
|
|
38
|
+
- `HttpHelper` no longer crashes on a 3xx response with a missing or malformed `Location` header. `uri + nil` raised `ArgumentError` and a malformed value raised `URI::InvalidURIError`, neither rescued, so the gem was silently dropped; both now return nil with a warning. (#39)
|
|
39
|
+
- Gems from an unqueryable private source (Gemfury, Gemstash, geminabox, a private mirror) are no longer silently looked up on public rubygems.org. A private name with no public match reported blank data, and one that collided with a public gem reported the *public* gem's versions/dates/libyear/repository as if they were the private gem's. still_active now detects a non-rubygems.org rubygems source, warns, and skips both the public version lookup and the public repository-metadata fallback rather than substituting public data. (#43)
|
|
40
|
+
- A gemspec project's (or local Rails engine's) runtime dependencies are now audited. The `gemspec` / `gem path:` directive surfaces the local gem's *development* deps in the lockfile's DEPENDENCIES, but its *runtime* deps appear only as that gem's nested lockfile deps, so a maintainer auditing their own repo never saw the deps they ship. still_active now expands local path-sourced gems' runtime deps (transitively through nested engines) into the audited set, still parsing the lockfile only and never the gemspec. (#41)
|
|
41
|
+
|
|
42
|
+
### Security
|
|
43
|
+
|
|
44
|
+
- Credentials are no longer retained on a redirect that changes the port or downgrades the scheme. The redirect follower previously dropped auth headers only when the host changed, so a same-host redirect to a different port (a different service) kept the token; it now requires a full-origin match (scheme, host, and port) and refuses a non-https redirect.
|
|
45
|
+
- still_active no longer evaluates the audited project's Gemfile. `gemfile_dependencies` loaded it via `Bundler.definition`, executing arbitrary Ruby straight from the Gemfile, an unauthenticated RCE when run on an untrusted repository (e.g. CI on a pull request). It now parses `Gemfile.lock` directly with a side-effect-free parser, which also neutralizes `Bundler::LockfileParser`'s own `PLUGIN SOURCE` registry resolution. (#37)
|
|
46
|
+
- `HttpHelper` now caps a response body at 16 MiB, streaming the read rather than buffering the whole body. A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-registerable, so an unbounded body was an unauthenticated OOM triggerable by lockfile content alone. (#40)
|
|
47
|
+
- Markdown output now escapes untrusted metadata. Gem names, licences, versions, repository URLs, and advisory ids drawn from registry/repo metadata, the Gemfile/lockfile, `--baseline`, or `--gems` could otherwise forge table columns or links, break a code span, or inject a list item/heading into a PR comment. GFM escaping is centralised in `StillActive::MarkdownEscape` and applied to both the audit table and the PR diff. (#38)
|
|
48
|
+
- The Ruby Toolbox catalog (used by `--alternatives`) is now fetched via `URI.parse(url).open` instead of `URI.open`, resolving a CodeQL `rb/non-constant-kernel-open` finding. The URL is a constant repo-archive link with no injection path, so this is hardening rather than a fix for a reachable issue.
|
|
49
|
+
|
|
3
50
|
## [1.6.0] - 2026-06-08
|
|
4
51
|
|
|
5
52
|
### Added
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**How do you know if your Ruby dependencies are still maintained?**
|
|
4
4
|
|
|
5
|
-
`bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your
|
|
5
|
+
`bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your dependency graph — direct and transitive by default (`--direct-only` to narrow), with granular, committed suppression via `.still_active.yml`.
|
|
6
6
|
|
|
7
7
|
Findings ship as **terminal / markdown / JSON / SARIF / CycloneDX** — SARIF lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`; CycloneDX feeds Trivy / Dependency-Track / Snyk. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep.
|
|
8
8
|
|
|
@@ -85,10 +85,16 @@ still_active --markdown
|
|
|
85
85
|
3. `GH_TOKEN` environment variable (`gh` CLI convention)
|
|
86
86
|
4. `gh auth token` (if `gh` is installed and authenticated)
|
|
87
87
|
|
|
88
|
-
Without a token, GitHub API calls are unauthenticated and rate-limited to 60 requests/hour — you will hit the limit on anything beyond a handful of gems. With a token the limit is 5000 requests/hour.
|
|
88
|
+
Without a token, GitHub API calls are unauthenticated and rate-limited to 60 requests/hour — you will hit the limit on anything beyond a handful of gems. With a token the limit is 5000 requests/hour. When a rate-limit response comes back with a reset that's near (within 60 seconds, typically a secondary/burst limit that the concurrent fan-out can trip even with a token), still_active waits it out and retries rather than dropping that gem's signals; a far-off reset (you've exhausted the hourly limit) isn't waited out, so add a token or run less often.
|
|
89
89
|
|
|
90
90
|
GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
|
|
91
91
|
|
|
92
|
+
Forgejo/Codeberg (the rare gem whose canonical `source_code_uri` is `codeberg.org`) is read anonymously by default; set `STILL_ACTIVE_FORGEJO_TOKEN` (or `CODEBERG_TOKEN`) only to raise the rate limit or reach a private repo. There is no CLI flag, since Codeberg has no ubiquitous CLI to borrow a token from the way `gh`/`glab` do.
|
|
93
|
+
|
|
94
|
+
Artifactory auth looks for Bundler configuration's credentials first so that private registries work without extra configuration if they are already configured in Bundler. To set those credentials in Bundler, run `bundle config set credentials.my-org.jfrog.io user:pass`. If no credentials are set there, still_active falls back using a token provided via `--artifactory-token` or `STILL_ACTIVE_ARTIFACTORY_TOKEN`. Authentication expects a `user:password` format for Basic auth, otherwise it will be treated as a bare token for Bearer auth. Valid authentication is required for private JFrog gem registries (`*.jfrog.io`).
|
|
95
|
+
|
|
96
|
+
When providing the artifactory token via flag or env, you must also set `--artifactory-host` or `STILL_ACTIVE_ARTIFACTORY_HOST` to the expected registry hostname (e.g. `my-org.jfrog.io`). still_active only sends the credentials to that host, ensuring that a lockfile containing other Artifactory hosts will not leak the token (a security risk). Providing a token/host in this manner will work only for a single host. To support multiple Artifactory hosts, use Bundler's configuration for per-host credentials.
|
|
97
|
+
|
|
92
98
|
### CLI options
|
|
93
99
|
|
|
94
100
|
```text
|
|
@@ -102,15 +108,19 @@ Usage: still_active [options]
|
|
|
102
108
|
--markdown Markdown table output
|
|
103
109
|
--json JSON output (default when piped)
|
|
104
110
|
--alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
|
|
111
|
+
--unreleased-commits Count commits on the default branch since the latest release (GitHub only; opt-in)
|
|
112
|
+
--direct-only Audit only direct (declared) deps, not the full transitive graph
|
|
105
113
|
--sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
|
|
106
114
|
--cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
|
|
107
115
|
--cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
|
|
108
116
|
--baseline=PATH Compare current state to baseline JSON; emit markdown deltas
|
|
109
117
|
--github-oauth-token=TOKEN GitHub OAuth token to make API calls
|
|
110
118
|
--gitlab-token=TOKEN GitLab personal access token for API calls
|
|
119
|
+
--artifactory-token=TOKEN Artifactory token for private gem registry API calls
|
|
120
|
+
--artifactory-host=HOST Artifactory host allowed to receive the global token (e.g. my-org.jfrog.io)
|
|
111
121
|
--simultaneous-requests=QTY Number of simultaneous requests made
|
|
112
|
-
--safe-range-end=YEARS maximum years since last
|
|
113
|
-
--warning-range-end=YEARS maximum years since last
|
|
122
|
+
--safe-range-end=YEARS maximum years since last release considered safe, no warning (default 1.5)
|
|
123
|
+
--warning-range-end=YEARS maximum years since last release that triggers a warning, beyond this is critical (default 3)
|
|
114
124
|
--fail-if-critical Exit 1 if any gem has critical activity warning
|
|
115
125
|
--fail-if-warning Exit 1 if any gem has warning or critical activity warning
|
|
116
126
|
--fail-if-vulnerable[=SEVERITY]
|
|
@@ -334,13 +344,54 @@ still_active --fail-if-outdated=3 --json
|
|
|
334
344
|
still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
|
|
335
345
|
```
|
|
336
346
|
|
|
347
|
+
### Configuration file (`.still_active.yml`)
|
|
348
|
+
|
|
349
|
+
`--ignore=GEM` is blunt: it drops a gem from **every** gate at once, so accepting one unfixable advisory also blinds you to that gem going archived or to a *new* CVE. A committed `.still_active.yml` in the project root replaces that with granular, auditable suppression, and lets you keep your policy flags in version control instead of threading them through every invocation.
|
|
350
|
+
|
|
351
|
+
```yaml
|
|
352
|
+
# .still_active.yml -- policy defaults plus granular suppression
|
|
353
|
+
fail_if_critical: true
|
|
354
|
+
fail_if_vulnerable: high # true, or a minimum severity: low|medium|high|critical
|
|
355
|
+
fail_if_outdated: 3 # libyears
|
|
356
|
+
unreleased_commits: true
|
|
357
|
+
output: json # terminal | markdown | json
|
|
358
|
+
direct_only: true # audit only declared deps, not the full transitive graph (--direct-only)
|
|
359
|
+
|
|
360
|
+
# Pull bundler-audit's accepted-advisory list instead of maintaining two files
|
|
361
|
+
import:
|
|
362
|
+
- .bundler-audit.yml
|
|
363
|
+
|
|
364
|
+
ignore:
|
|
365
|
+
# Accept ONE advisory, by id -- a different/new CVE on nokogiri still fails
|
|
366
|
+
- advisory: CVE-2024-1234
|
|
367
|
+
gem: nokogiri
|
|
368
|
+
reason: "no fix released; not reachable from our code path"
|
|
369
|
+
expires: 2026-09-01 # re-surfaces as a normal failure after this date
|
|
370
|
+
|
|
371
|
+
# Accept staleness on a vendored gem, but still fail if it gets a CVE
|
|
372
|
+
- gem: legacy_thing
|
|
373
|
+
signal: activity # activity | vulnerability | libyear
|
|
374
|
+
reason: "vendored, intentionally frozen"
|
|
375
|
+
|
|
376
|
+
# A bare gem name keeps the old whole-gem behaviour (mutes every signal)
|
|
377
|
+
- some_internal_gem
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Design:
|
|
381
|
+
|
|
382
|
+
- **Granularity.** Key by `advisory:` (one CVE) and/or `signal:` (`activity` / `vulnerability` / `libyear`), not the whole gem. A vulnerability suppression **must** name an advisory id, so a newly disclosed CVE on the same gem is never pre-silenced. `--ignore=GEM` (and a bare gem-name entry) still mute everything, by design.
|
|
383
|
+
- **Precedence.** CLI flag > env var > config file > default. A CLI flag always wins; `--ignore` unions with the file's suppressions rather than replacing them. Secrets (tokens) and invocation-specific paths (`--gemfile`, `--gems`, `--baseline`, output paths) are intentionally **not** read from the file, so a committed config never carries a credential.
|
|
384
|
+
- **Expiry.** An `expires:` date makes accepted risk visible: once it passes, the entry stops applying and the finding fails the gate again (Trivy-style), so a suppression can't rot silently. `reason:` is optional but recommended. A suppression naming a gem that isn't in your dependency graph (a typo, or a gem you've since removed) is also surfaced as a warning, so dead entries don't accumulate.
|
|
385
|
+
- **bundler-audit, suggested not absorbed.** still_active never silently inherits another tool's ignore list: auto-importing `.bundler-audit.yml` would suppress vulnerabilities you only accepted in bundler-audit's context, with no reason or expiry here. Instead, when `--fail-if-vulnerable` is on and an un-imported `.bundler-audit.yml` is present, it prints a one-line hint suggesting the `import:` line, leaving the opt-in to you.
|
|
386
|
+
- **Output.** Suppression changes the **exit code** and marks the finding in **SARIF** as a native `suppressions[]` entry (with your `reason` as the justification, so GitHub Code Scanning renders it dismissed rather than open). The finding still appears in JSON/terminal/markdown output; suppression accepts a risk, it doesn't hide that the risk exists.
|
|
387
|
+
|
|
337
388
|
### Activity thresholds
|
|
338
389
|
|
|
339
|
-
Activity is
|
|
390
|
+
Activity is driven by release recency (the latest stable or pre-release date), since a release is what you can actually `bundle update` to. A recent commit does not offset a stale release: the last commit date is shown as context and only stands in when a gem has no releases at all (e.g. git-sourced). Thresholds are calibrated against real RubyGems cadence, where healthy mature gems often go a year or more between releases:
|
|
340
391
|
|
|
341
|
-
- **ok**: last
|
|
342
|
-
- **stale**: last
|
|
343
|
-
- **critical**: last
|
|
392
|
+
- **ok**: last release within 18 months (configurable with `--safe-range-end`)
|
|
393
|
+
- **stale**: last release between 18 months and 3 years ago
|
|
394
|
+
- **critical**: last release over 3 years ago (configurable with `--warning-range-end`)
|
|
344
395
|
|
|
345
396
|
### Alternative gem leads (opt-in)
|
|
346
397
|
|
|
@@ -356,10 +407,26 @@ still_active --gems=paperclip --alternatives
|
|
|
356
407
|
|
|
357
408
|
These are **leads, not recommendations**: same-category does not mean drop-in replacement, so verify fit before switching. Ruby has no authoritative "use instead" metadata (unlike npm `deprecate`, Go's `// Deprecated:`, or NuGet's alternate-package field), so this is a best-effort heuristic. It is silent when the catalog has no entry for the gem, and the feature never blocks or fails a run. Leads appear in terminal, markdown, JSON, and SARIF output. When the flag is off, terminal output shows a one-line hint on flagged gems that the option exists (other formats stay silent).
|
|
358
409
|
|
|
410
|
+
### Transitive dependencies
|
|
411
|
+
|
|
412
|
+
Maintenance signals (stale releases, archived repos, last-commit age, advisories, libyear) cover the **full transitive lockfile graph by default** — an unmaintained gem you ship transitively is real risk even though you never named it, and the CVE tools still_active composes with already enumerate the whole resolved graph. This matches libyear-bundler (transitive by default, `--only-explicit` opts out) and every CVE scanner (bundler-audit, npm/cargo/pip-audit are all full-tree).
|
|
413
|
+
|
|
414
|
+
When a transitive gem trips a signal, the output names the **direct dependency that pulls it in**: `dependency_path` in JSON, a dimmed `↳ transitive, pulled in by X` line in the terminal, a `(transitive, pulled in by X)` suffix in SARIF messages, and a **Transitive findings** list in markdown. That turns an un-actionable transitive finding into an actionable one: you can't bump a gem you didn't choose, but you can replace or pressure the direct gem that drags it in.
|
|
415
|
+
|
|
416
|
+
`--alternatives` stays **direct-only** by design — "replace gem X with Y" is incoherent for a gem you never selected. Pass `--direct-only` to audit just your declared dependencies (the pre-1.7 behaviour), which is also much cheaper in API calls.
|
|
417
|
+
|
|
418
|
+
> **Run on a schedule, not on every commit.** Auditing the full graph means a repo/release/advisory lookup for *every* resolved gem (hundreds, for a real app), and the GitHub signals are rate-limited (60 req/hour unauthenticated, 5000 with a token). Maintenance status changes over days and weeks, not per-commit, so a nightly or weekly job (or `--direct-only` in PR gates) gets you the signal without burning your API budget for nothing. This is why advisory tools like Brakeman ship their check database; still_active's signals are inherently live (a release date or an archived flag can't be vendored), so the answer is cadence, not bundling.
|
|
419
|
+
|
|
420
|
+
### Unreleased commits (opt-in)
|
|
421
|
+
|
|
422
|
+
`--unreleased-commits` adds an `unreleased_commits` count to the JSON output: commits on the default branch since the latest release's tag. It catches the case the release-recency signal can't, a gem with a *recent* release but a pile of merged-but-unreleased fixes sitting on top, or conversely one that looks stale but is genuinely *done* (no unreleased work). It is the one maintenance signal no Ruby tool surfaces today; only GitHub's own UI shows it.
|
|
423
|
+
|
|
424
|
+
It is **opt-in and GitHub-only**: enabling it adds one extra API call per GitHub-hosted gem (the git tag is resolved from the RubyGems version by trying `v1.2.3` then `1.2.3` as the compare base), so mind your rate limit on a large lockfile. Non-GitHub sources report `null` (GitLab has no equivalent scalar; the signal is duck-typed, so a provider either implements it or doesn't). The count is **informational and never gates a run**. Read it as a lead, not a verdict: it is inflated for monorepos (the count spans the whole repository, e.g. `bundler` living in `rubygems/rubygems`) and for release-branch projects (the default branch is the next-version trunk, so `rails` reads ~2000 commits ahead of its latest stable tag).
|
|
425
|
+
|
|
359
426
|
### Data sources
|
|
360
427
|
|
|
361
|
-
- **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org)
|
|
362
|
-
- **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest)
|
|
428
|
+
- **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org), [GitHub Packages](https://docs.github.com/en/packages), or [JFrog Artifactory](https://jfrog.com/artifactory/) gem registries
|
|
429
|
+
- **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest), [GitLab](https://docs.gitlab.com/ee/api/), or [Forgejo/Gitea](https://forgejo.org/docs/latest/user/api-usage/) (Codeberg) API
|
|
363
430
|
- **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
|
|
364
431
|
- **Additional advisories** from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db), merged in when `bundler-audit` is installed alongside (run `bundle audit update` to keep its checkout current)
|
|
365
432
|
- **Ruby version freshness** from [endoflife.date](https://endoflife.date)
|
|
@@ -370,8 +437,8 @@ These are **leads, not recommendations**: same-category does not mean drop-in re
|
|
|
370
437
|
| Option | Default | Description |
|
|
371
438
|
| ----------------------- | ----------- | ---------------------------------------------------------------- |
|
|
372
439
|
| `output_format` | auto-detect | Coloured terminal on TTY, JSON when piped |
|
|
373
|
-
| `safe_range_end` | 1
|
|
374
|
-
| `warning_range_end` | 3 years | Last
|
|
440
|
+
| `safe_range_end` | 1.5 years | Last release within this range is "ok" |
|
|
441
|
+
| `warning_range_end` | 3 years | Last release within this range is "stale"; beyond is "critical" |
|
|
375
442
|
| `simultaneous_requests` | 10 | Concurrent API requests |
|
|
376
443
|
|
|
377
444
|
## Development
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
3
4
|
require_relative "../still_active/core_ext"
|
|
4
5
|
|
|
5
6
|
module StillActive
|
|
@@ -8,22 +9,46 @@ module StillActive
|
|
|
8
9
|
|
|
9
10
|
using StillActive::CoreExt
|
|
10
11
|
|
|
12
|
+
# The most recent activity signal that drives the level, or nil if none:
|
|
13
|
+
# { date:, kind: } where kind is :release (preferred) or :commit. Release
|
|
14
|
+
# recency drives the level because a release is what a consumer can actually
|
|
15
|
+
# consume (you can't `bundle update` to unreleased commits), so a lone
|
|
16
|
+
# rubocop/README commit can't mask a multi-year release drought. The commit
|
|
17
|
+
# date stands in only when a gem has no releases at all (e.g. a git-sourced
|
|
18
|
+
# gem), where it is the only signal available.
|
|
19
|
+
def last_activity(gem_data)
|
|
20
|
+
release = [
|
|
21
|
+
gem_data[:latest_version_release_date],
|
|
22
|
+
gem_data[:latest_pre_release_version_release_date],
|
|
23
|
+
].filter_map { parse_time(_1) }.max
|
|
24
|
+
return { date: release, kind: :release } if release
|
|
25
|
+
|
|
26
|
+
commit = parse_time(gem_data[:last_commit_date])
|
|
27
|
+
commit ? { date: commit, kind: :commit } : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Coerce a Time or an iso8601-ish string (the SARIF path may supply either)
|
|
31
|
+
# to a Time, or nil if absent/unparseable.
|
|
32
|
+
def parse_time(value)
|
|
33
|
+
return value if value.is_a?(Time)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
|
|
36
|
+
Time.parse(value.to_s)
|
|
37
|
+
rescue ArgumentError, TypeError, RangeError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
11
41
|
# Returns :archived, :ok, :stale, :critical, or :unknown
|
|
12
42
|
def activity_level(gem_data)
|
|
13
43
|
return :archived if gem_data[:archived]
|
|
14
44
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
gem_data[:latest_version_release_date],
|
|
18
|
-
gem_data[:latest_pre_release_version_release_date],
|
|
19
|
-
].compact.max
|
|
20
|
-
|
|
21
|
-
return :unknown if most_recent.nil?
|
|
45
|
+
activity = last_activity(gem_data)
|
|
46
|
+
return :unknown if activity.nil?
|
|
22
47
|
|
|
23
48
|
config = StillActive.config
|
|
24
|
-
if
|
|
49
|
+
if activity[:date] >= config.no_warning_range_end.years.ago
|
|
25
50
|
:ok
|
|
26
|
-
elsif
|
|
51
|
+
elsif activity[:date] >= config.warning_range_end.years.ago
|
|
27
52
|
:stale
|
|
28
53
|
else
|
|
29
54
|
:critical
|
|
@@ -1,52 +1,113 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "lockfile_dependency_parser"
|
|
5
|
+
|
|
3
6
|
module StillActive
|
|
4
7
|
module BundlerHelper
|
|
5
8
|
extend self
|
|
6
9
|
|
|
7
10
|
def gemfile_dependencies(gemfile_path: StillActive.config.gemfile_path)
|
|
8
11
|
absolute_gemfile = File.expand_path(gemfile_path)
|
|
9
|
-
|
|
10
|
-
|
|
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}
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
25
|
-
source_type:
|
|
26
|
-
source_uri:
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
101
|
+
# Bundler's lockfile naming: `gems.rb` pairs with `gems.locked`, every other
|
|
102
|
+
# Gemfile with `<gemfile>.lock`. Derived from the explicit path rather than
|
|
103
|
+
# global Bundler state so `--gemfile` is honoured even under `bundle exec`
|
|
104
|
+
# (where a memoized Bundler.definition / ambient BUNDLE_GEMFILE would
|
|
105
|
+
# otherwise win). Refs #42.
|
|
106
|
+
def lockfile_path_for(gemfile)
|
|
107
|
+
if File.basename(gemfile) == "gems.rb"
|
|
108
|
+
File.join(File.dirname(gemfile), "gems.locked")
|
|
109
|
+
else
|
|
110
|
+
"#{gemfile}.lock"
|
|
50
111
|
end
|
|
51
112
|
end
|
|
52
113
|
end
|
|
@@ -80,7 +80,10 @@ module StillActive
|
|
|
80
80
|
|
|
81
81
|
def download
|
|
82
82
|
url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
|
|
83
|
-
URI.open(
|
|
83
|
+
# URI(url).open (not URI.open/Kernel#open) so a "|cmd" string can never be
|
|
84
|
+
# treated as a shell command — the URL is a constant-repo archive link, but
|
|
85
|
+
# this keeps the open path injection-proof regardless.
|
|
86
|
+
URI.parse(url).open { |io| io.read(MAX_DOWNLOAD_BYTES) }
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
def build_siblings(categories)
|
|
@@ -55,7 +55,7 @@ module StillActive
|
|
|
55
55
|
component = { "type" => "library", "name" => name }
|
|
56
56
|
component["version"] = version if version
|
|
57
57
|
component["bom-ref"] = bom_ref(name, data)
|
|
58
|
-
component["purl"] = purl(name, version
|
|
58
|
+
component["purl"] = purl(name, version, data[:source_type]) if version
|
|
59
59
|
component["licenses"] = licenses(data[:license]) if data[:license]
|
|
60
60
|
if data[:repository_url]
|
|
61
61
|
component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
|
|
@@ -67,13 +67,19 @@ module StillActive
|
|
|
67
67
|
|
|
68
68
|
def bom_ref(name, data)
|
|
69
69
|
version = data[:version_used]
|
|
70
|
-
return purl(name, version
|
|
70
|
+
return purl(name, version, data[:source_type]) if version
|
|
71
71
|
|
|
72
|
-
"#{data[:source_type]}-source:#{name}
|
|
72
|
+
"#{data[:source_type]}-source:#{name}@unknown"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
# Datadog SCA (and strict CycloneDX consumers) hard-reject a versioned
|
|
76
|
+
# library component with no purl, so every gem with a version needs one.
|
|
77
|
+
# A path gem is local and not on rubygems, so it gets pkg:generic to avoid
|
|
78
|
+
# false-matching a public gem of the same name; git/rubygems-sourced gems
|
|
79
|
+
# get pkg:gem so a fork still matches the upstream gem's advisories.
|
|
80
|
+
def purl(name, version, source_type)
|
|
81
|
+
type = source_type == :path ? "generic" : "gem"
|
|
82
|
+
"pkg:#{type}/#{name}@#{version}"
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
# VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
|
|
@@ -93,12 +99,22 @@ module StillActive
|
|
|
93
99
|
}.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
|
|
94
100
|
end
|
|
95
101
|
|
|
102
|
+
# The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
|
|
103
|
+
# nothing consumes it and strict SCA validators (Datadog) only accept
|
|
104
|
+
# "library" + a purl. We follow Syft's convention for an unmanaged runtime:
|
|
105
|
+
# type "library", purl pkg:generic/ruby@<ver>, plus a CPE — the CPE is what
|
|
106
|
+
# actually lets a matcher (NVD/Grype) hit interpreter CVEs, since no purl
|
|
107
|
+
# type maps the Ruby runtime in OSV. The EOL/libyear signals stay as
|
|
108
|
+
# still_active properties.
|
|
96
109
|
def ruby_component(ruby_info)
|
|
110
|
+
version = ruby_info[:version]
|
|
97
111
|
{
|
|
98
|
-
"type" => "
|
|
112
|
+
"type" => "library",
|
|
99
113
|
"name" => "ruby",
|
|
100
|
-
"version" =>
|
|
101
|
-
"bom-ref" => "
|
|
114
|
+
"version" => version,
|
|
115
|
+
"bom-ref" => "pkg:generic/ruby@#{version}",
|
|
116
|
+
"purl" => "pkg:generic/ruby@#{version}",
|
|
117
|
+
"cpe" => "cpe:2.3:a:ruby-lang:ruby:#{version}:*:*:*:*:*:*:*",
|
|
102
118
|
"properties" => [
|
|
103
119
|
{ "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
|
|
104
120
|
{ "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "markdown_escape"
|
|
4
|
+
|
|
3
5
|
module StillActive
|
|
4
6
|
# Renders a StillActive::Diff::Result as PR-comment-friendly markdown.
|
|
5
7
|
# Section taxonomy mirrors GitHub's dependency-review-action so reviewers
|
|
@@ -49,7 +51,7 @@ module StillActive
|
|
|
49
51
|
def regressions_section(regressions)
|
|
50
52
|
return "" if regressions.empty?
|
|
51
53
|
|
|
52
|
-
lines = regressions.map { |r| "- **#{r.kind}**
|
|
54
|
+
lines = regressions.map { |r| "- **#{r.kind}** #{MarkdownEscape.code_span(r.gem)} — #{MarkdownEscape.inline(r.detail)}" }
|
|
53
55
|
section("Regressions (CI-failable)", lines)
|
|
54
56
|
end
|
|
55
57
|
|
|
@@ -63,7 +65,7 @@ module StillActive
|
|
|
63
65
|
def removed_section(removed)
|
|
64
66
|
return "" if removed.empty?
|
|
65
67
|
|
|
66
|
-
lines = removed.map { |r| "-
|
|
68
|
+
lines = removed.map { |r| "- #{MarkdownEscape.code_span(r.name)} (was #{MarkdownEscape.inline((r.data || {})["version_used"] || "?")})" }
|
|
67
69
|
section("Removed", lines)
|
|
68
70
|
end
|
|
69
71
|
|
|
@@ -88,9 +90,9 @@ module StillActive
|
|
|
88
90
|
lines = []
|
|
89
91
|
if ruby[:version_changed]
|
|
90
92
|
eol_suffix = ruby[:newly_eol] ? " (now EOL)" : ""
|
|
91
|
-
lines << "- Ruby
|
|
93
|
+
lines << "- Ruby #{MarkdownEscape.code_span(ruby[:from])} → #{MarkdownEscape.code_span(ruby[:to])}#{eol_suffix}"
|
|
92
94
|
elsif ruby[:newly_eol]
|
|
93
|
-
lines << "- Ruby
|
|
95
|
+
lines << "- Ruby #{MarkdownEscape.code_span(ruby[:to])} is now EOL"
|
|
94
96
|
end
|
|
95
97
|
section("Ruby", lines)
|
|
96
98
|
end
|
|
@@ -108,30 +110,31 @@ module StillActive
|
|
|
108
110
|
(data["archived"] ? "archived" : nil),
|
|
109
111
|
data["libyear"] && "#{data["libyear"]}y behind",
|
|
110
112
|
].compact
|
|
111
|
-
"
|
|
113
|
+
"#{MarkdownEscape.code_span(added.name)} (#{MarkdownEscape.inline(bits.join(", "))})"
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
def format_bump(bump)
|
|
115
117
|
label = BUMP_KIND_LABELS[bump.kind]
|
|
116
118
|
suffix = label ? " (#{label})" : ""
|
|
117
|
-
"-
|
|
119
|
+
"- #{MarkdownEscape.code_span(bump.name)} #{MarkdownEscape.inline(bump.before_version)} → #{MarkdownEscape.inline(bump.after_version)}#{suffix}"
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
def format_signal_change_lines(sc)
|
|
123
|
+
name = MarkdownEscape.code_span(sc.name)
|
|
121
124
|
sc.changes.filter_map do |ch|
|
|
122
125
|
case ch[:kind]
|
|
123
126
|
when :archived
|
|
124
|
-
"-
|
|
127
|
+
"- #{name} — archived (false → true)"
|
|
125
128
|
when :new_vulnerability
|
|
126
|
-
ids = Array(ch[:ids]).join(", ")
|
|
127
|
-
"-
|
|
129
|
+
ids = MarkdownEscape.inline(Array(ch[:ids]).join(", "))
|
|
130
|
+
"- #{name} — new vulnerability (#{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{" — #{ids}" unless ids.empty?})"
|
|
128
131
|
when :scorecard_dropped
|
|
129
132
|
note = ch[:crossed_good] ? " (crossed 7.0)" : ""
|
|
130
|
-
"-
|
|
133
|
+
"- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
|
|
131
134
|
when :version_yanked
|
|
132
|
-
"-
|
|
135
|
+
"- #{name} — version yanked from rubygems"
|
|
133
136
|
when :libyear_worsened
|
|
134
|
-
"-
|
|
137
|
+
"- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
|
|
135
138
|
end
|
|
136
139
|
end
|
|
137
140
|
end
|