still_active 1.0.1 → 1.2.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +92 -46
  4. data/lib/helpers/activity_helper.rb +3 -1
  5. data/lib/helpers/bundler_helper.rb +30 -1
  6. data/lib/helpers/emoji_helper.rb +1 -1
  7. data/lib/helpers/http_helper.rb +38 -10
  8. data/lib/helpers/libyear_helper.rb +20 -0
  9. data/lib/helpers/markdown_helper.rb +51 -10
  10. data/lib/helpers/ruby_helper.rb +83 -0
  11. data/lib/helpers/terminal_helper.rb +57 -7
  12. data/lib/helpers/version_helper.rb +1 -1
  13. data/lib/helpers/vulnerability_helper.rb +36 -0
  14. data/lib/still_active/cli.rb +41 -12
  15. data/lib/still_active/config.rb +6 -0
  16. data/lib/still_active/core_ext.rb +1 -1
  17. data/lib/still_active/deps_dev_client.rb +18 -0
  18. data/lib/still_active/gitlab_client.rb +31 -10
  19. data/lib/still_active/options.rb +22 -1
  20. data/lib/still_active/repository.rb +4 -1
  21. data/lib/still_active/version.rb +1 -1
  22. data/lib/still_active/workflow.rb +143 -13
  23. data/lib/still_active.rb +4 -0
  24. data/still_active.gemspec +8 -4
  25. metadata +10 -23
  26. data/.github/dependabot.yml +0 -21
  27. data/.github/workflows/codeql-analysis.yml +0 -39
  28. data/.github/workflows/publish.yml +0 -19
  29. data/.github/workflows/rspec.yml +0 -26
  30. data/.github/workflows/rubocop-analysis.yml +0 -38
  31. data/.gitignore +0 -18
  32. data/.rspec +0 -4
  33. data/.rubocop.yml +0 -23
  34. data/Gemfile +0 -12
  35. data/Gemfile.lock +0 -239
  36. data/Rakefile +0 -12
  37. data/bin/console +0 -11
  38. data/bin/setup +0 -8
  39. data/fixtures/debug_versions.json +0 -38
  40. data/fixtures/still_active_version.json +0 -9
  41. data/fixtures/vcr_cassettes/deps_dev_project.yml +0 -46
  42. data/fixtures/vcr_cassettes/deps_dev_version.yml +0 -56
  43. data/fixtures/vcr_cassettes/gems.yml +0 -3762
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4f584514824888d1b457c3b3662c449d5aa5ef8244cb31760ed005afc2204d3
4
- data.tar.gz: 9e2c109064b073de04025c36730a171760beae79866e97bff44d4008d7cf1187
3
+ metadata.gz: 8c861ae8a727347f9b276576f3da48d806fbc25ab05020d849e5e92c8de49732
4
+ data.tar.gz: bc3e5d7429e17adc0b6b0e955f50a6ddb9e7f157f1c656bf8c9044113cd5a97e
5
5
  SHA512:
6
- metadata.gz: 00a1c67f51fc48961185bfcab6f6ec1a30cfa15f417a8feec70606d673f2ac7278d7dd267376af27e6670d88316ba31ae7d8d63ad3f470f94b5c483e03cbbd1e
7
- data.tar.gz: bb4bc6f91c96a3f24381179cc295ea21f3f9545ef9a148f95fc53525195e6cda4111887c8d247c1bf50e59f2edd9e94ac5987609ab588300f3a612aae5228e5c
6
+ metadata.gz: e922835a769aeb9817dd27e99bf194c39e21e43794ee66d6d7d1db4c2c9ccaf11cf40d75ac251302f014f3df539998ab4e285f446fb68b6aaa080259421cc1fb
7
+ data.tar.gz: 932b7e3dbd72cd02492070eced16a77e1a8a1649bb86132f2a7f827339d3f42138633139cbfd5134e31b1c4a92d18b28758afafc74674402f72a9ed2d7512399
data/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-02-20
4
+
5
+ ### Added
6
+
7
+ - `--fail-if-vulnerable[=SEVERITY]` flag: exit 1 if any gem has known vulnerabilities, optionally filtered by severity (low/medium/high/critical)
8
+ - `--fail-if-outdated=LIBYEARS` flag: exit 1 if any gem exceeds the given libyear threshold
9
+ - Coloured OpenSSF column in terminal output: green for strong practices (7.0+), yellow for notably weak (below 4.0)
10
+
11
+ ### Changed
12
+
13
+ - Removed composite health score (0-100) and Health column from terminal, markdown, and JSON output; individual columns (vulns, OpenSSF, activity, version) communicate these signals without collapsing them into one number
14
+ - Replaced `--fail-below-score` with `--fail-if-vulnerable` and `--fail-if-outdated` for targeted CI gating
15
+
16
+ ### Fixed
17
+
18
+ - Repository URLs with `.git` suffix (e.g. `socketry/async.git`) caused 404s against GitHub/GitLab APIs
19
+ - GitLab 301 redirects for renamed projects silently failed; now follows up to 3 redirects with trusted host check
20
+ - Network errors (`ECONNRESET`, timeouts, etc.) during RubyGems version lookup or HTTP API calls dropped the entire gem from results instead of warning
21
+ - GitHub Packages URI check used substring match, allowing crafted URLs to bypass host validation; now parses URI and compares host exactly
22
+ - Tri-state `archived?` predicate renamed to `archived` to honestly reflect `true`/`false`/`nil` return contract
23
+ - Rubocop offences from code scanning (WordArray, IfInsideElse, MultilineHash, frozen_string_literal)
24
+
25
+ ## [1.1.0] - 2026-02-20
26
+
27
+ ### Added
28
+
29
+ - `--ignore=GEM,GEM2,...` flag to exclude gems from pass/fail checks while keeping them in output
30
+ - `--fail-below-score=SCORE` flag for health-based CI gating (exit 1 if any gem scores below threshold)
31
+ - Yanked version detection: flags pinned versions that have been pulled from RubyGems
32
+ - Archived repo detection via GitHub and GitLab APIs, treated as critical for exit checks
33
+ - Libyear metric: years between installed and latest release per gem, total in summary
34
+ - Advisory enrichment: CVSS scores, titles, and IDs from deps.dev per vulnerability
35
+ - Composite health score (0-100) combining version freshness, activity, OpenSSF Scorecard, and vulnerabilities
36
+ - Health column in terminal and markdown output, system average in terminal summary
37
+ - Ruby version freshness: reports current Ruby version, EOL status, and libyear behind latest via endoflife.date API
38
+ - Source detection: identifies gem source type (rubygems, git, path) from Bundler lockfile
39
+ - Non-rubygems gem handling: git/path-sourced gems show gracefully with source indicator instead of failing silently
40
+ - GitHub Packages registry support: fetches versions from `rubygems.pkg.github.com` using existing `--github-oauth-token` (requires `read:packages` scope)
41
+ - CVSS v2 fallback: older advisories without v3 scores now show severity using v2 scores from deps.dev
42
+
43
+ ### Changed
44
+
45
+ - Vulnerability column shows count with highest severity label (e.g. "3 (critical)")
46
+ - Markdown vulnerability column shows advisory IDs
47
+ - Markdown table adds libyear and health columns
48
+ - Terminal summary includes libyear total and health average
49
+ - JSON output wrapped in `{ "gems": ..., "ruby": ... }` structure
50
+ - Version string validation guards against malformed versions from git-sourced gems
51
+ - Progress counter on stderr during gem checking so large Gemfiles don't appear frozen
52
+ - Actionable rate limit message when GitHub API quota is exhausted
53
+ - `--fail-below-score` now validates range (0-100) at parse time
54
+ - `--gems` option stores structured data from the start instead of mutating mid-run
55
+ - API failures (timeouts, HTTP errors, malformed responses) now warn on stderr instead of degrading silently
56
+ - Vulnerability count based on successfully fetched advisories so count and severity always agree
57
+
58
+ ### Fixed
59
+
60
+ - Vulnerability counts now checked against installed version, not latest (was masking CVEs in older pinned versions)
61
+ - `GitlabClient.archived?` returned `false` on API failure instead of `nil`, incorrectly asserting repos were not archived
62
+ - `repo_archived?` rescued all `StandardError`, masking bugs; now catches only `Octokit::Error` and `Faraday::Error`
63
+ - `last_commit_date` had no error handling; any failure dropped the entire gem from results
64
+ - Malformed date strings from GitHub/GitLab APIs no longer raise unhandled `ArgumentError`
65
+
66
+ ## [1.0.2] - 2026-02-19
67
+
68
+ ### Changed
69
+
70
+ - Reduce gem package from 2.4MB to essentials only (lib/, bin/still_active, LICENSE, README, CHANGELOG, gemspec)
71
+
3
72
  ## [1.0.1] - 2026-02-19
4
73
 
5
74
  ### Changed
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, and vulnerabilities for every gem in your Gemfile -- in one pass.
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.
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active)
8
8
  ![Code Quality analysis](https://github.com/SeanLF/still_active/actions/workflows/codeql-analysis.yml/badge.svg)
@@ -10,16 +10,18 @@
10
10
  ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg)
11
11
 
12
12
  ```
13
- Name Version Activity OpenSSF Vulns
14
- ──────────────────────────────────────────────────────────────────
15
- code-scanning-rubocop 0.6.1 (latest) stale 3.1/10 0
16
- debug 1.11.1 (latest) ok 5.2/10 0
17
- faker 3.6.0 (latest) ok 7.4/10 0
18
- rake 13.3.1 (latest) ok 5.3/10 0
19
- rspec 3.13.2 (latest) ok 6.9/10 0
20
- rubocop 1.84.2 (latest) ok 5.9/10 0
21
-
22
- 12 gems: 12 up to date, 0 outdated · 11 active, 1 stale · 0 vulnerabilities
13
+ Name Version Activity OpenSSF Vulns
14
+ ───────────────────────────────────────────────────────────────────
15
+ async 2.36.0 (latest) ok 7.1/10 0
16
+ backbone-rails 1.2.3 (latest) archived 3.6/10 0
17
+ bootstrap-slider-rails 9.8.0 (latest) critical - 0
18
+ gitlab-markup 2.0.0 (latest) ok - 0
19
+ local_gem 0.1.0 (path) - - 0
20
+ nested_form 0.3.2 (git) archived 3.3/10 0
21
+ remotipart 1.4.4 (git) critical 3.1/10 0
22
+
23
+ 7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
24
+ Ruby 4.0.1 (latest)
23
25
  ```
24
26
 
25
27
  ## Why `still_active`?
@@ -29,11 +31,16 @@ Most dependency tools answer one question. `still_active` answers all of them at
29
31
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
30
32
  | ---------------------------- | ----------------- | --------------- | ----------------- | ---------------------------- |
31
33
  | Outdated versions | Yes | - | Yes | **Yes** |
32
- | Known vulnerabilities (CVEs) | - | Yes | - | **Yes** |
34
+ | Known vulnerabilities (CVEs) | - | Yes | - | **Yes** (with severity) |
33
35
  | OpenSSF Scorecard | - | - | - | **Yes** |
34
36
  | Last commit activity | - | - | - | **Yes** |
37
+ | Libyear drift | - | - | Yes | **Yes** |
38
+ | Archived repo detection | - | - | - | **Yes** |
39
+ | Yanked version detection | - | - | - | **Yes** |
40
+ | Ruby version freshness | - | - | - | **Yes** (EOL + libyear) |
41
+ | Git/path/GH Packages sources | - | - | - | **Yes** |
35
42
  | GitLab support | - | - | - | **Yes** |
36
- | CI quality gates | - | Exit code | - | **Yes** |
43
+ | CI quality gates | - | Exit code | - | **Yes** (5 modes) |
37
44
  | Multiple output formats | - | - | - | **Terminal, JSON, Markdown** |
38
45
  | Single command | Yes | Yes | Yes | **Yes** |
39
46
 
@@ -54,8 +61,11 @@ still_active
54
61
  # check specific gems
55
62
  still_active --gems=rails,nokogiri,sidekiq
56
63
 
57
- # CI pipeline: fail if any gem is critically stale
58
- still_active --fail-if-critical
64
+ # CI pipeline: fail if any gem is critically stale or has vulnerabilities
65
+ still_active --fail-if-critical --fail-if-vulnerable
66
+
67
+ # ignore specific gems in CI checks
68
+ still_active --fail-if-warning --ignore=legacy_gem,internal_gem
59
69
 
60
70
  # markdown table for pull requests or documentation
61
71
  still_active --markdown
@@ -86,6 +96,10 @@ Usage: still_active [options]
86
96
  --warning-range-end=YEARS maximum years since last activity that triggers a warning (beyond this is critical)
87
97
  --fail-if-critical Exit 1 if any gem has critical activity warning
88
98
  --fail-if-warning Exit 1 if any gem has warning or critical activity warning
99
+ --fail-if-vulnerable[=SEVERITY]
100
+ Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY)
101
+ --fail-if-outdated=LIBYEARS Exit 1 if any gem exceeds LIBYEARS behind latest
102
+ --ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
89
103
  --critical-warning-emoji=EMOJI
90
104
  --futurist-emoji=EMOJI
91
105
  --success-emoji=EMOJI
@@ -102,32 +116,44 @@ Usage: still_active [options]
102
116
  **JSON** (default when piped) -- structured data for automation:
103
117
 
104
118
  ```bash
105
- still_active --json --gems=rails,nokogiri
119
+ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
106
120
  ```
107
121
 
108
122
  ```json
109
123
  {
110
- "rails": {
111
- "latest_version": "8.1.2",
112
- "latest_version_release_date": "2026-01-08 20:18:51 UTC",
113
- "latest_pre_release_version": "8.1.0.rc1",
114
- "latest_pre_release_version_release_date": "2025-10-15 00:52:14 UTC",
115
- "repository_url": "https://github.com/rails/rails",
116
- "last_commit_date": "2026-02-19 09:39:03 UTC",
117
- "scorecard_score": 5.7,
118
- "vulnerability_count": 0,
119
- "ruby_gems_url": "https://rubygems.org/gems/rails"
124
+ "gems": {
125
+ "async": {
126
+ "source_type": "rubygems",
127
+ "version_used": "2.36.0",
128
+ "latest_version": "2.36.0",
129
+ "repository_url": "https://github.com/socketry/async",
130
+ "last_commit_date": "2026-01-22 04:09:48 UTC",
131
+ "archived": false,
132
+ "scorecard_score": 7.1,
133
+ "vulnerability_count": 0,
134
+ "libyear": 0.0
135
+ },
136
+ "nested_form": {
137
+ "source_type": "git",
138
+ "version_used": "0.3.2",
139
+ "repository_url": "https://github.com/ryanb/nested_form",
140
+ "last_commit_date": "2021-12-11 21:47:02 UTC",
141
+ "archived": true,
142
+ "scorecard_score": 3.3,
143
+ "vulnerability_count": 0
144
+ },
145
+ "local_gem": {
146
+ "source_type": "path",
147
+ "version_used": "0.1.0",
148
+ "scorecard_score": null,
149
+ "vulnerability_count": 0
150
+ }
120
151
  },
121
- "nokogiri": {
122
- "latest_version": "1.19.1",
123
- "latest_version_release_date": "2026-02-16 23:31:21 UTC",
124
- "latest_pre_release_version": "1.18.0.rc1",
125
- "latest_pre_release_version_release_date": "2024-12-16 17:48:44 UTC",
126
- "repository_url": "https://github.com/sparklemotion/nokogiri",
127
- "last_commit_date": "2026-02-17 19:13:22 UTC",
128
- "scorecard_score": 6.5,
129
- "vulnerability_count": 0,
130
- "ruby_gems_url": "https://rubygems.org/gems/nokogiri"
152
+ "ruby": {
153
+ "version": "4.0.1",
154
+ "eol": false,
155
+ "latest_version": "4.0.1",
156
+ "libyear": 0.0
131
157
  }
132
158
  }
133
159
  ```
@@ -138,18 +164,37 @@ still_active --json --gems=rails,nokogiri
138
164
  still_active --markdown
139
165
  ```
140
166
 
141
- | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit |
142
- | -------- | ----------- | ------- | ----- | --------------------- | ---------------- | ---------------- | ------------------- | ----------- |
143
- | ⚠️ | ✅ | 3.1/10 | ✅ | code-scanning-rubocop | 0.6.1 (2022/02) | 0.6.1 (2022/02) | ❓ | 2024/06 |
144
- | | ✅ | 5.2/10 | ✅ | debug | 1.11.1 (2025/12) | 1.11.1 (2025/12) | 1.0.0.rc2 (2021/09) | 2025/12 |
145
- | | | 7.4/10 | ✅ | faker | 3.6.0 (2026/01) | 3.6.0 (2026/01) | ❓ | 2026/02 |
167
+ | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |
168
+ | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- |
169
+ | | ✅ | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | ❓ | [2026/01](https://github.com/socketry/async) | 0.0y |
170
+ | 🚩 | ✅ | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y |
171
+ || | | ✅ | local_gem | 0.1.0 (path) | ❓ | | ❓ | - |
172
+ | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - |
173
+
174
+ **Ruby 4.0.1** (latest) ✅
146
175
 
147
176
  ### CI quality gating
148
177
 
149
- Use `--fail-if-critical` or `--fail-if-warning` to fail CI pipelines when dependencies exceed activity thresholds:
178
+ Use exit-code flags to fail CI pipelines based on dependency status:
150
179
 
151
180
  ```bash
152
- still_active --gemfile=Gemfile --fail-if-warning --json
181
+ # fail on critically stale or archived gems
182
+ still_active --fail-if-critical --json
183
+
184
+ # fail on any stale, critical, or archived gem
185
+ still_active --fail-if-warning --json
186
+
187
+ # fail if any gem has known vulnerabilities
188
+ still_active --fail-if-vulnerable --json
189
+
190
+ # fail only on high/critical severity vulnerabilities
191
+ still_active --fail-if-vulnerable=high --json
192
+
193
+ # fail if any gem is more than 3 libyears behind
194
+ still_active --fail-if-outdated=3 --json
195
+
196
+ # combine flags and exclude known exceptions
197
+ still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
153
198
  ```
154
199
 
155
200
  ### Activity thresholds
@@ -162,9 +207,10 @@ Activity is determined by the most recent signal across last commit date, latest
162
207
 
163
208
  ### Data sources
164
209
 
165
- - **Versions and release dates** from [RubyGems.org](https://rubygems.org)
166
- - **Last commit date** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
167
- - **OpenSSF Scorecard** and **vulnerability counts** from Google's [deps.dev](https://deps.dev) API
210
+ - **Versions and release dates** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
211
+ - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
212
+ - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
213
+ - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
168
214
 
169
215
  ### Configuration defaults
170
216
 
@@ -8,8 +8,10 @@ module StillActive
8
8
 
9
9
  using StillActive::CoreExt
10
10
 
11
- # Returns :ok, :stale, :critical, or :unknown
11
+ # Returns :archived, :ok, :stale, :critical, or :unknown
12
12
  def activity_level(gem_data)
13
+ return :archived if gem_data[:archived]
14
+
13
15
  most_recent = [
14
16
  gem_data[:last_commit_date],
15
17
  gem_data[:latest_version_release_date],
@@ -12,7 +12,36 @@ module StillActive
12
12
  .locked_gems
13
13
  .specs
14
14
  .select { |spec| gemfile_gems.include?(spec.name) }
15
- .each_with_object([]) { |spec, array| array << { name: spec.name, version: spec.version.version } }
15
+ .map do |spec|
16
+ {
17
+ name: spec.name,
18
+ version: spec.version.version,
19
+ source_type: detect_source_type(spec),
20
+ source_uri: detect_source_uri(spec),
21
+ }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def detect_source_type(spec)
28
+ case spec.source
29
+ when ::Bundler::Source::Rubygems then :rubygems
30
+ when ::Bundler::Source::Git then :git
31
+ when ::Bundler::Source::Path then :path
32
+ else :unknown
33
+ end
34
+ end
35
+
36
+ def detect_source_uri(spec)
37
+ case spec.source
38
+ when ::Bundler::Source::Rubygems
39
+ spec.source.remotes&.first&.to_s
40
+ when ::Bundler::Source::Git
41
+ spec.source.uri
42
+ when ::Bundler::Source::Path
43
+ spec.source.path&.to_s
44
+ end
16
45
  end
17
46
  end
18
47
  end
@@ -10,7 +10,7 @@ module StillActive
10
10
  case ActivityHelper.activity_level(result_hash)
11
11
  when :ok then ""
12
12
  when :stale then StillActive.config.warning_emoji
13
- when :critical then StillActive.config.critical_warning_emoji
13
+ when :archived, :critical then StillActive.config.critical_warning_emoji
14
14
  when :unknown then StillActive.config.unsure_emoji
15
15
  end
16
16
  end
@@ -5,6 +5,9 @@ require "json"
5
5
 
6
6
  module StillActive
7
7
  module HttpHelper
8
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
9
+ MAX_REDIRECTS = 3
10
+
8
11
  extend self
9
12
 
10
13
  def get_json(base_uri, path, headers: {}, params: {})
@@ -12,19 +15,44 @@ module StillActive
12
15
  uri.path = path
13
16
  uri.query = URI.encode_www_form(params) unless params.empty?
14
17
 
15
- http = Net::HTTP.new(uri.host, uri.port)
16
- http.use_ssl = true
17
- http.open_timeout = 10
18
- http.read_timeout = 10
18
+ MAX_REDIRECTS.times do
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = true
21
+ http.open_timeout = 10
22
+ http.read_timeout = 10
23
+
24
+ request = Net::HTTP::Get.new(uri)
25
+ headers.each { |key, value| request[key] = value }
26
+
27
+ response = http.request(request)
19
28
 
20
- request = Net::HTTP::Get.new(uri)
21
- headers.each { |key, value| request[key] = value }
29
+ if response.is_a?(Net::HTTPRedirection)
30
+ redirect_uri = uri + response["Location"]
31
+ unless TRUSTED_HOSTS.include?(redirect_uri.host)
32
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
33
+ return
34
+ end
35
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
36
+ headers = {} if redirect_uri.host != uri.host
37
+ uri = redirect_uri
38
+ next
39
+ end
22
40
 
23
- response = http.request(request)
24
- return unless response.is_a?(Net::HTTPSuccess)
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
43
+ return
44
+ end
25
45
 
26
- JSON.parse(response.body)
27
- rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, JSON::ParserError
46
+ return JSON.parse(response.body)
47
+ end
48
+
49
+ $stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
50
+ nil
51
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
52
+ $stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
53
+ nil
54
+ rescue JSON::ParserError => e
55
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
28
56
  nil
29
57
  end
30
58
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../still_active/core_ext"
4
+
5
+ module StillActive
6
+ module LibyearHelper
7
+ extend self
8
+
9
+ def gem_libyear(version_used_release_date:, latest_version_release_date:)
10
+ return if version_used_release_date.nil? || latest_version_release_date.nil?
11
+
12
+ diff = latest_version_release_date - version_used_release_date
13
+ [diff / CoreExt::SECONDS_PER_YEAR, 0.0].max.round(1)
14
+ end
15
+
16
+ def total_libyear(result)
17
+ result.each_value.sum { |d| d[:libyear] || 0.0 }
18
+ end
19
+ end
20
+ end
@@ -1,12 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "vulnerability_helper"
4
+
3
5
  module StillActive
4
6
  module MarkdownHelper
5
7
  extend self
6
8
 
9
+ def ruby_line(ruby_info)
10
+ version = ruby_info[:version]
11
+ latest = ruby_info[:latest_version]
12
+ libyear = ruby_info[:libyear]
13
+ eol = ruby_info[:eol]
14
+ eol_date = ruby_info[:eol_date]
15
+
16
+ return "**Ruby #{version}** (latest) #{StillActive.config.success_emoji}" if version == latest
17
+
18
+ libyear_part = libyear ? "#{libyear} libyears behind #{latest}" : "behind #{latest}"
19
+
20
+ if eol
21
+ eol_part = eol_date ? "EOL #{eol_date.strftime("%Y-%m-%d")}" : "EOL"
22
+ "**Ruby #{version}** (#{eol_part}, #{libyear_part}) #{StillActive.config.critical_warning_emoji}"
23
+ else
24
+ "**Ruby #{version}** (#{libyear_part}) #{StillActive.config.warning_emoji}"
25
+ end
26
+ end
27
+
7
28
  def markdown_table_header_line
8
- "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit |\n" \
9
- "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- |"
29
+ "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \
30
+ "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |"
10
31
  end
11
32
 
12
33
  def markdown_table_body_line(gem_name:, data:)
@@ -18,11 +39,17 @@ module StillActive
18
39
 
19
40
  formatted_name = markdown_url(text: gem_name, url: repository_url)
20
41
 
21
- formatted_version_used = version_with_date(
22
- text: data[:version_used],
23
- url: version_url(ruby_gems_url, data[:version_used]),
24
- date: data[:version_used_release_date],
25
- )
42
+ formatted_version_used = if [:git, :path].include?(data[:source_type])
43
+ data[:version_used] ? "#{data[:version_used]} (#{data[:source_type]})" : "(#{data[:source_type]})"
44
+ elsif data[:version_yanked]
45
+ "#{data[:version_used]} (YANKED #{StillActive.config.critical_warning_emoji})"
46
+ else
47
+ version_with_date(
48
+ text: data[:version_used],
49
+ url: version_url(ruby_gems_url, data[:version_used]),
50
+ date: data[:version_used_release_date],
51
+ )
52
+ end
26
53
 
27
54
  formatted_latest_version = version_with_date(
28
55
  text: data[:latest_version],
@@ -44,12 +71,13 @@ module StillActive
44
71
  inactive_repository_emoji || unsure,
45
72
  using_latest_version_emoji || unsure,
46
73
  format_scorecard(data[:scorecard_score]),
47
- format_vulns(data[:vulnerability_count]),
74
+ format_vulns(data),
48
75
  formatted_name,
49
76
  formatted_version_used || unsure,
50
77
  formatted_latest_version || unsure,
51
78
  formatted_latest_pre_release || unsure,
52
79
  formatted_last_commit || unsure,
80
+ format_libyear(data[:libyear]),
53
81
  ]
54
82
 
55
83
  "| #{cells.join(" | ")} |"
@@ -79,11 +107,24 @@ module StillActive
79
107
  "#{score}/10"
80
108
  end
81
109
 
82
- def format_vulns(count)
110
+ def format_libyear(value)
111
+ return "-" if value.nil?
112
+
113
+ "#{value}y"
114
+ end
115
+
116
+ def format_vulns(data)
117
+ count = data[:vulnerability_count]
83
118
  return StillActive.config.unsure_emoji if count.nil?
84
119
  return StillActive.config.success_emoji if count.zero?
85
120
 
86
- count.to_s
121
+ vulnerabilities = data[:vulnerabilities] || []
122
+ severity = VulnerabilityHelper.highest_severity(vulnerabilities)
123
+ ids = vulnerabilities.flat_map { |v| [v[:id], *v[:aliases]] }.compact.uniq.first(3)
124
+
125
+ parts = [severity ? "#{count} (#{severity})" : count.to_s]
126
+ parts << ids.join(", ") unless ids.empty?
127
+ parts.join(" ")
87
128
  end
88
129
 
89
130
  def markdown_url(text:, url:)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "http_helper"
5
+ require_relative "libyear_helper"
6
+
7
+ module StillActive
8
+ module RubyHelper
9
+ extend self
10
+
11
+ ENDOFLIFE_URI = URI("https://endoflife.date/")
12
+
13
+ def ruby_freshness
14
+ return unless standard_ruby?
15
+
16
+ cycles = fetch_cycles
17
+ return if cycles.nil?
18
+
19
+ current = current_ruby_version
20
+ current_cycle = find_cycle(cycles, current)
21
+ latest_cycle = cycles.first
22
+
23
+ return if latest_cycle.nil?
24
+
25
+ latest_version = latest_cycle["latest"]
26
+ latest_release_date = parse_date(latest_cycle["releaseDate"])
27
+ current_release_date = parse_date(current_cycle&.dig("releaseDate"))
28
+ eol_value = current_cycle&.dig("eol")
29
+
30
+ {
31
+ version: current,
32
+ release_date: current_release_date,
33
+ eol_date: parse_eol(eol_value),
34
+ eol: eol_reached?(eol_value),
35
+ latest_version: latest_version,
36
+ latest_release_date: latest_release_date,
37
+ libyear: LibyearHelper.gem_libyear(
38
+ version_used_release_date: current_release_date,
39
+ latest_version_release_date: latest_release_date,
40
+ ),
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ def standard_ruby?
47
+ RUBY_ENGINE == "ruby"
48
+ end
49
+
50
+ def current_ruby_version
51
+ RUBY_VERSION
52
+ end
53
+
54
+ def fetch_cycles
55
+ HttpHelper.get_json(ENDOFLIFE_URI, "/api/ruby.json")
56
+ end
57
+
58
+ def find_cycle(cycles, version)
59
+ major_minor = version.split(".")[0..1].join(".")
60
+ cycles.find { |c| c["cycle"] == major_minor }
61
+ end
62
+
63
+ def parse_date(date_string)
64
+ return if date_string.nil?
65
+
66
+ Time.parse(date_string)
67
+ end
68
+
69
+ def parse_eol(value)
70
+ case value
71
+ when String then parse_date(value)
72
+ end
73
+ end
74
+
75
+ def eol_reached?(value)
76
+ case value
77
+ when true then true
78
+ when false then false
79
+ when String then Time.parse(value) <= Time.now
80
+ end
81
+ end
82
+ end
83
+ end