still_active 1.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +56 -41
- data/lib/helpers/http_helper.rb +33 -12
- data/lib/helpers/markdown_helper.rb +2 -9
- data/lib/helpers/terminal_helper.rb +11 -17
- data/lib/helpers/vulnerability_helper.rb +9 -0
- data/lib/still_active/cli.rb +13 -3
- data/lib/still_active/config.rb +4 -2
- data/lib/still_active/gitlab_client.rb +1 -1
- data/lib/still_active/options.rb +13 -4
- data/lib/still_active/repository.rb +4 -1
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +29 -12
- data/still_active.gemspec +2 -2
- metadata +6 -7
- data/lib/helpers/health_score_helper.rb +0 -81
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c861ae8a727347f9b276576f3da48d806fbc25ab05020d849e5e92c8de49732
|
|
4
|
+
data.tar.gz: bc3e5d7429e17adc0b6b0e955f50a6ddb9e7f157f1c656bf8c9044113cd5a97e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e922835a769aeb9817dd27e99bf194c39e21e43794ee66d6d7d1db4c2c9ccaf11cf40d75ac251302f014f3df539998ab4e285f446fb68b6aaa080259421cc1fb
|
|
7
|
+
data.tar.gz: 932b7e3dbd72cd02492070eced16a77e1a8a1649bb86132f2a7f827339d3f42138633139cbfd5134e31b1c4a92d18b28758afafc74674402f72a9ed2d7512399
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
3
25
|
## [1.1.0] - 2026-02-20
|
|
4
26
|
|
|
5
27
|
### 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 Gemfile
|
|
5
|
+
`bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your Gemfile.
|
|
6
6
|
|
|
7
7
|
[](https://badge.fury.io/rb/still_active)
|
|
8
8
|

|
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|

|
|
11
11
|
|
|
12
12
|
```
|
|
13
|
-
Name
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
23
24
|
Ruby 4.0.1 (latest)
|
|
24
25
|
```
|
|
25
26
|
|
|
@@ -34,13 +35,12 @@ Most dependency tools answer one question. `still_active` answers all of them at
|
|
|
34
35
|
| OpenSSF Scorecard | - | - | - | **Yes** |
|
|
35
36
|
| Last commit activity | - | - | - | **Yes** |
|
|
36
37
|
| Libyear drift | - | - | Yes | **Yes** |
|
|
37
|
-
| Composite health score | - | - | - | **Yes** (0-100) |
|
|
38
38
|
| Archived repo detection | - | - | - | **Yes** |
|
|
39
39
|
| Yanked version detection | - | - | - | **Yes** |
|
|
40
40
|
| Ruby version freshness | - | - | - | **Yes** (EOL + libyear) |
|
|
41
41
|
| Git/path/GH Packages sources | - | - | - | **Yes** |
|
|
42
42
|
| GitLab support | - | - | - | **Yes** |
|
|
43
|
-
| CI quality gates | - | Exit code | - | **Yes** (
|
|
43
|
+
| CI quality gates | - | Exit code | - | **Yes** (5 modes) |
|
|
44
44
|
| Multiple output formats | - | - | - | **Terminal, JSON, Markdown** |
|
|
45
45
|
| Single command | Yes | Yes | Yes | **Yes** |
|
|
46
46
|
|
|
@@ -61,8 +61,8 @@ still_active
|
|
|
61
61
|
# check specific gems
|
|
62
62
|
still_active --gems=rails,nokogiri,sidekiq
|
|
63
63
|
|
|
64
|
-
# CI pipeline: fail if any gem is critically stale or has
|
|
65
|
-
still_active --fail-if-critical --fail-
|
|
64
|
+
# CI pipeline: fail if any gem is critically stale or has vulnerabilities
|
|
65
|
+
still_active --fail-if-critical --fail-if-vulnerable
|
|
66
66
|
|
|
67
67
|
# ignore specific gems in CI checks
|
|
68
68
|
still_active --fail-if-warning --ignore=legacy_gem,internal_gem
|
|
@@ -96,7 +96,9 @@ Usage: still_active [options]
|
|
|
96
96
|
--warning-range-end=YEARS maximum years since last activity that triggers a warning (beyond this is critical)
|
|
97
97
|
--fail-if-critical Exit 1 if any gem has critical activity warning
|
|
98
98
|
--fail-if-warning Exit 1 if any gem has warning or critical activity warning
|
|
99
|
-
--fail-
|
|
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
|
|
100
102
|
--ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
|
|
101
103
|
--critical-warning-emoji=EMOJI
|
|
102
104
|
--futurist-emoji=EMOJI
|
|
@@ -114,31 +116,37 @@ Usage: still_active [options]
|
|
|
114
116
|
**JSON** (default when piped) -- structured data for automation:
|
|
115
117
|
|
|
116
118
|
```bash
|
|
117
|
-
still_active --json --
|
|
119
|
+
still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
```json
|
|
121
123
|
{
|
|
122
124
|
"gems": {
|
|
123
|
-
"
|
|
125
|
+
"async": {
|
|
124
126
|
"source_type": "rubygems",
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
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",
|
|
128
131
|
"archived": false,
|
|
129
|
-
"scorecard_score":
|
|
132
|
+
"scorecard_score": 7.1,
|
|
130
133
|
"vulnerability_count": 0,
|
|
131
|
-
"
|
|
134
|
+
"libyear": 0.0
|
|
132
135
|
},
|
|
133
|
-
"
|
|
134
|
-
"source_type": "
|
|
135
|
-
"
|
|
136
|
-
"repository_url": "https://github.com/
|
|
137
|
-
"last_commit_date": "
|
|
138
|
-
"archived":
|
|
139
|
-
"scorecard_score":
|
|
140
|
-
"vulnerability_count": 0
|
|
141
|
-
|
|
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
|
|
142
150
|
}
|
|
143
151
|
},
|
|
144
152
|
"ruby": {
|
|
@@ -156,17 +164,18 @@ still_active --json --gems=rails,nokogiri
|
|
|
156
164
|
still_active --markdown
|
|
157
165
|
```
|
|
158
166
|
|
|
159
|
-
| activity | up to date? | OpenSSF | vulns | name
|
|
160
|
-
| -------- | ----------- | ------- | ----- |
|
|
161
|
-
| |
|
|
162
|
-
|
|
|
163
|
-
|
|
|
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) | - |
|
|
164
173
|
|
|
165
174
|
**Ruby 4.0.1** (latest) ✅
|
|
166
175
|
|
|
167
176
|
### CI quality gating
|
|
168
177
|
|
|
169
|
-
Use exit-code flags to fail CI pipelines based on dependency
|
|
178
|
+
Use exit-code flags to fail CI pipelines based on dependency status:
|
|
170
179
|
|
|
171
180
|
```bash
|
|
172
181
|
# fail on critically stale or archived gems
|
|
@@ -175,11 +184,17 @@ still_active --fail-if-critical --json
|
|
|
175
184
|
# fail on any stale, critical, or archived gem
|
|
176
185
|
still_active --fail-if-warning --json
|
|
177
186
|
|
|
178
|
-
# fail if any gem
|
|
179
|
-
still_active --fail-
|
|
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
|
|
180
195
|
|
|
181
196
|
# combine flags and exclude known exceptions
|
|
182
|
-
still_active --fail-if-warning --fail-
|
|
197
|
+
still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
|
|
183
198
|
```
|
|
184
199
|
|
|
185
200
|
### Activity thresholds
|
data/lib/helpers/http_helper.rb
CHANGED
|
@@ -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,22 +15,40 @@ module StillActive
|
|
|
12
15
|
uri.path = path
|
|
13
16
|
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
45
|
+
|
|
46
|
+
return JSON.parse(response.body)
|
|
27
47
|
end
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
52
|
$stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
|
|
32
53
|
nil
|
|
33
54
|
rescue JSON::ParserError => e
|
|
@@ -26,8 +26,8 @@ module StillActive
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def markdown_table_header_line
|
|
29
|
-
"| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear
|
|
30
|
-
"| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |
|
|
29
|
+
"| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \
|
|
30
|
+
"| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |"
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def markdown_table_body_line(gem_name:, data:)
|
|
@@ -78,7 +78,6 @@ module StillActive
|
|
|
78
78
|
formatted_latest_pre_release || unsure,
|
|
79
79
|
formatted_last_commit || unsure,
|
|
80
80
|
format_libyear(data[:libyear]),
|
|
81
|
-
format_health(data[:health_score]),
|
|
82
81
|
]
|
|
83
82
|
|
|
84
83
|
"| #{cells.join(" | ")} |"
|
|
@@ -108,12 +107,6 @@ module StillActive
|
|
|
108
107
|
"#{score}/10"
|
|
109
108
|
end
|
|
110
109
|
|
|
111
|
-
def format_health(score)
|
|
112
|
-
return "-" if score.nil?
|
|
113
|
-
|
|
114
|
-
"#{score}/100"
|
|
115
|
-
end
|
|
116
|
-
|
|
117
110
|
def format_libyear(value)
|
|
118
111
|
return "-" if value.nil?
|
|
119
112
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "activity_helper"
|
|
4
4
|
require_relative "ansi_helper"
|
|
5
|
-
require_relative "health_score_helper"
|
|
6
5
|
require_relative "libyear_helper"
|
|
7
6
|
require_relative "version_helper"
|
|
8
7
|
require_relative "vulnerability_helper"
|
|
@@ -11,7 +10,7 @@ module StillActive
|
|
|
11
10
|
module TerminalHelper
|
|
12
11
|
extend self
|
|
13
12
|
|
|
14
|
-
HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"
|
|
13
|
+
HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"].freeze
|
|
15
14
|
|
|
16
15
|
def render(result, ruby_info: nil)
|
|
17
16
|
rows = result.keys.sort.map { |name| build_row(name, result[name]) }
|
|
@@ -36,7 +35,6 @@ module StillActive
|
|
|
36
35
|
format_activity(data),
|
|
37
36
|
format_scorecard(data[:scorecard_score]),
|
|
38
37
|
format_vulns(data),
|
|
39
|
-
format_health(data[:health_score]),
|
|
40
38
|
]
|
|
41
39
|
end
|
|
42
40
|
|
|
@@ -73,7 +71,16 @@ module StillActive
|
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
def format_scorecard(score)
|
|
76
|
-
|
|
74
|
+
return AnsiHelper.dim("-") if score.nil?
|
|
75
|
+
|
|
76
|
+
text = "#{score}/10"
|
|
77
|
+
if score >= 7.0
|
|
78
|
+
AnsiHelper.green(text)
|
|
79
|
+
elsif score < 4.0
|
|
80
|
+
AnsiHelper.yellow(text)
|
|
81
|
+
else
|
|
82
|
+
text
|
|
83
|
+
end
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
def format_vulns(data)
|
|
@@ -86,17 +93,6 @@ module StillActive
|
|
|
86
93
|
AnsiHelper.red(label)
|
|
87
94
|
end
|
|
88
95
|
|
|
89
|
-
def format_health(score)
|
|
90
|
-
return AnsiHelper.dim("-") if score.nil?
|
|
91
|
-
|
|
92
|
-
text = "#{score}/100"
|
|
93
|
-
case score
|
|
94
|
-
when 80..100 then AnsiHelper.green(text)
|
|
95
|
-
when 50..79 then AnsiHelper.yellow(text)
|
|
96
|
-
else AnsiHelper.red(text)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
96
|
def column_widths(rows)
|
|
101
97
|
return HEADERS.map { |h| h.length + 2 } if rows.empty?
|
|
102
98
|
|
|
@@ -166,8 +162,6 @@ module StillActive
|
|
|
166
162
|
parts << "#{vulns} vulnerabilities"
|
|
167
163
|
total_libyear = LibyearHelper.total_libyear(result)
|
|
168
164
|
parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
|
|
169
|
-
avg = HealthScoreHelper.system_average(result)
|
|
170
|
-
parts << "health #{avg}/100" if avg
|
|
171
165
|
parts.join(" · ")
|
|
172
166
|
end
|
|
173
167
|
end
|
|
@@ -4,6 +4,8 @@ module StillActive
|
|
|
4
4
|
module VulnerabilityHelper
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
|
+
SEVERITY_ORDER = ["low", "medium", "high", "critical"].freeze
|
|
8
|
+
|
|
7
9
|
def highest_severity(vulnerabilities)
|
|
8
10
|
return if vulnerabilities.nil? || vulnerabilities.empty?
|
|
9
11
|
|
|
@@ -13,6 +15,13 @@ module StillActive
|
|
|
13
15
|
severity_label(max_score)
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
def severity_at_or_above?(vulnerabilities, threshold)
|
|
19
|
+
highest = highest_severity(vulnerabilities)
|
|
20
|
+
return false if highest.nil?
|
|
21
|
+
|
|
22
|
+
SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold)
|
|
23
|
+
end
|
|
24
|
+
|
|
16
25
|
private
|
|
17
26
|
|
|
18
27
|
def severity_label(score)
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "../helpers/emoji_helper"
|
|
|
7
7
|
require_relative "../helpers/markdown_helper"
|
|
8
8
|
require_relative "../helpers/terminal_helper"
|
|
9
9
|
require_relative "../helpers/version_helper"
|
|
10
|
+
require_relative "../helpers/vulnerability_helper"
|
|
10
11
|
require_relative "workflow"
|
|
11
12
|
|
|
12
13
|
module StillActive
|
|
@@ -73,7 +74,7 @@ module StillActive
|
|
|
73
74
|
|
|
74
75
|
def check_exit_status(result)
|
|
75
76
|
config = StillActive.config
|
|
76
|
-
return unless config.fail_if_critical || config.fail_if_warning || config.
|
|
77
|
+
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
|
|
77
78
|
|
|
78
79
|
ignored = config.ignored_gems
|
|
79
80
|
checked = result.reject { |name, _| ignored.include?(name) }
|
|
@@ -84,8 +85,17 @@ module StillActive
|
|
|
84
85
|
exit(1) if config.fail_if_critical && levels.intersect?([:critical, :archived])
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
if (
|
|
88
|
-
|
|
88
|
+
if (vuln_setting = config.fail_if_vulnerable)
|
|
89
|
+
checked.each_value do |d|
|
|
90
|
+
next unless d[:vulnerability_count]&.positive?
|
|
91
|
+
|
|
92
|
+
exit(1) if vuln_setting == true
|
|
93
|
+
exit(1) if VulnerabilityHelper.severity_at_or_above?(d[:vulnerabilities], vuln_setting)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if (threshold = config.fail_if_outdated)
|
|
98
|
+
exit(1) if checked.each_value.any? { |d| d[:libyear] && d[:libyear] > threshold }
|
|
89
99
|
end
|
|
90
100
|
end
|
|
91
101
|
end
|
data/lib/still_active/config.rb
CHANGED
|
@@ -13,7 +13,8 @@ module StillActive
|
|
|
13
13
|
:gems,
|
|
14
14
|
:github_oauth_token,
|
|
15
15
|
:gitlab_token,
|
|
16
|
-
:
|
|
16
|
+
:fail_if_outdated,
|
|
17
|
+
:fail_if_vulnerable,
|
|
17
18
|
:ignored_gems,
|
|
18
19
|
:output_format,
|
|
19
20
|
:parallelism,
|
|
@@ -24,8 +25,9 @@ module StillActive
|
|
|
24
25
|
:warning_range_end
|
|
25
26
|
|
|
26
27
|
def initialize
|
|
27
|
-
@fail_below_score = nil
|
|
28
28
|
@fail_if_critical = false
|
|
29
|
+
@fail_if_outdated = nil
|
|
30
|
+
@fail_if_vulnerable = nil
|
|
29
31
|
@fail_if_warning = false
|
|
30
32
|
@gemfile_path = Bundler.default_gemfile.to_s
|
|
31
33
|
@gems = []
|
data/lib/still_active/options.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "../helpers/vulnerability_helper"
|
|
4
5
|
|
|
5
6
|
module StillActive
|
|
6
7
|
class Options
|
|
@@ -100,10 +101,18 @@ module StillActive
|
|
|
100
101
|
opts.on("--fail-if-warning", "Exit 1 if any gem has warning or critical activity warning") do
|
|
101
102
|
StillActive.config { |config| config.fail_if_warning = true }
|
|
102
103
|
end
|
|
103
|
-
opts.on("--fail-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
opts.on("--fail-if-vulnerable[=SEVERITY]", "Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY: low, medium, high, critical)") do |value|
|
|
105
|
+
if value
|
|
106
|
+
valid = VulnerabilityHelper::SEVERITY_ORDER
|
|
107
|
+
raise ArgumentError, "--fail-if-vulnerable severity must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
|
|
108
|
+
|
|
109
|
+
StillActive.config { |config| config.fail_if_vulnerable = value }
|
|
110
|
+
else
|
|
111
|
+
StillActive.config { |config| config.fail_if_vulnerable = true }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
opts.on("--fail-if-outdated=LIBYEARS", Float, "Exit 1 if any gem exceeds LIBYEARS behind latest") do |value|
|
|
115
|
+
StillActive.config { |config| config.fail_if_outdated = value }
|
|
107
116
|
end
|
|
108
117
|
end
|
|
109
118
|
|
|
@@ -16,7 +16,10 @@ module StillActive
|
|
|
16
16
|
match = url&.match(REPO_REGEX)
|
|
17
17
|
return { source: :unhandled, owner: nil, name: nil } unless match
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
url = match[1].delete_suffix(".git")
|
|
20
|
+
name = match[4].delete_suffix(".git")
|
|
21
|
+
|
|
22
|
+
{ url: url, source: match[2].to_sym, owner: match[3], name: name }
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
end
|
data/lib/still_active/version.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require_relative "deps_dev_client"
|
|
4
4
|
require_relative "gitlab_client"
|
|
5
5
|
require_relative "repository"
|
|
6
|
-
require_relative "../helpers/health_score_helper"
|
|
7
6
|
require_relative "../helpers/libyear_helper"
|
|
8
7
|
require_relative "../helpers/ruby_helper"
|
|
9
8
|
require_relative "../helpers/version_helper"
|
|
@@ -61,7 +60,7 @@ module StillActive
|
|
|
61
60
|
|
|
62
61
|
case source_type
|
|
63
62
|
when :path, :git
|
|
64
|
-
gem_info_non_rubygems(gem_name: gem_name, result_object: result_object)
|
|
63
|
+
gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri)
|
|
65
64
|
else
|
|
66
65
|
gem_info_rubygems(
|
|
67
66
|
gem_name: gem_name,
|
|
@@ -70,8 +69,6 @@ module StillActive
|
|
|
70
69
|
source_uri: source_uri,
|
|
71
70
|
)
|
|
72
71
|
end
|
|
73
|
-
|
|
74
|
-
result_object[gem_name][:health_score] = HealthScoreHelper.gem_score(result_object[gem_name])
|
|
75
72
|
end
|
|
76
73
|
|
|
77
74
|
def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:)
|
|
@@ -82,7 +79,7 @@ module StillActive
|
|
|
82
79
|
repository_owner: repo_info[:owner],
|
|
83
80
|
repository_name: repo_info[:name],
|
|
84
81
|
)
|
|
85
|
-
archived = repo_archived
|
|
82
|
+
archived = repo_archived(
|
|
86
83
|
source: repo_info[:source],
|
|
87
84
|
repository_owner: repo_info[:owner],
|
|
88
85
|
repository_name: repo_info[:name],
|
|
@@ -129,15 +126,19 @@ module StillActive
|
|
|
129
126
|
end
|
|
130
127
|
end
|
|
131
128
|
|
|
132
|
-
def gem_info_non_rubygems(gem_name:, result_object:)
|
|
133
|
-
repo_info =
|
|
129
|
+
def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil)
|
|
130
|
+
repo_info = repository_info_for_non_rubygems(gem_name: gem_name, source_uri: source_uri)
|
|
134
131
|
source, owner, name = repo_info.values_at(:source, :owner, :name)
|
|
132
|
+
deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version) : {}
|
|
133
|
+
|
|
134
|
+
# Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
|
|
135
|
+
deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
|
|
135
136
|
|
|
136
137
|
result_object[gem_name].merge!({
|
|
137
138
|
repository_url: repo_info[:url],
|
|
138
139
|
last_commit_date: last_commit_date(source:, repository_owner: owner, repository_name: name),
|
|
139
|
-
archived: repo_archived
|
|
140
|
-
|
|
140
|
+
archived: repo_archived(source:, repository_owner: owner, repository_name: name),
|
|
141
|
+
**deps_dev,
|
|
141
142
|
})
|
|
142
143
|
end
|
|
143
144
|
|
|
@@ -161,10 +162,15 @@ module StillActive
|
|
|
161
162
|
end
|
|
162
163
|
rescue Gems::NotFound
|
|
163
164
|
[]
|
|
165
|
+
rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
166
|
+
$stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
|
|
167
|
+
[]
|
|
164
168
|
end
|
|
165
169
|
|
|
166
170
|
def github_packages_uri?(uri)
|
|
167
|
-
uri.is_a?(String) && uri.
|
|
171
|
+
uri.is_a?(String) && URI(uri).host == "rubygems.pkg.github.com"
|
|
172
|
+
rescue URI::InvalidURIError
|
|
173
|
+
false
|
|
168
174
|
end
|
|
169
175
|
|
|
170
176
|
def fetch_github_packages_versions(gem_name:, source_uri:)
|
|
@@ -176,6 +182,17 @@ module StillActive
|
|
|
176
182
|
HttpHelper.get_json(base, path, headers: headers) || []
|
|
177
183
|
end
|
|
178
184
|
|
|
185
|
+
def repository_info_for_non_rubygems(gem_name:, source_uri: nil)
|
|
186
|
+
valid_repository_url =
|
|
187
|
+
[source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
|
|
188
|
+
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
189
|
+
project_id = if repo[:url]
|
|
190
|
+
host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
|
|
191
|
+
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
192
|
+
end
|
|
193
|
+
repo.merge(project_id: project_id)
|
|
194
|
+
end
|
|
195
|
+
|
|
179
196
|
def repository_info_from_installed_gem(gem_name:)
|
|
180
197
|
valid_repository_url =
|
|
181
198
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
@@ -223,13 +240,13 @@ module StillActive
|
|
|
223
240
|
[]
|
|
224
241
|
end
|
|
225
242
|
|
|
226
|
-
def repo_archived
|
|
243
|
+
def repo_archived(source:, repository_owner:, repository_name:)
|
|
227
244
|
case source
|
|
228
245
|
when :github
|
|
229
246
|
repo = StillActive.config.github_client.repository("#{repository_owner}/#{repository_name}")
|
|
230
247
|
repo&.archived
|
|
231
248
|
when :gitlab
|
|
232
|
-
GitlabClient.archived
|
|
249
|
+
GitlabClient.archived(owner: repository_owner, name: repository_name)
|
|
233
250
|
end
|
|
234
251
|
rescue Octokit::Error, Faraday::Error => e
|
|
235
252
|
$stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
data/still_active.gemspec
CHANGED
|
@@ -12,10 +12,10 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = "Analyses your Gemfile for dependency health: checks if gems are actively maintained " \
|
|
13
13
|
"(last commit dates via GitHub and GitLab, release dates), outdated versions, archived repos, " \
|
|
14
14
|
"OpenSSF Scorecard security scores, known vulnerabilities via deps.dev, and libyear drift. " \
|
|
15
|
-
"
|
|
15
|
+
"Ruby version freshness with EOL detection. " \
|
|
16
16
|
"Handles rubygems, git, path, and GitHub Packages sources. " \
|
|
17
17
|
"Outputs coloured terminal tables, markdown, or JSON. " \
|
|
18
|
-
"CI quality gates with --fail-if-critical, --fail-if-warning, --fail-
|
|
18
|
+
"CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable, --fail-if-outdated, and --ignore. " \
|
|
19
19
|
"A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
|
|
20
20
|
spec.homepage = "https://github.com/SeanLF/still_active"
|
|
21
21
|
spec.license = "MIT"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: still_active
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sean Floyd
|
|
@@ -166,11 +166,11 @@ dependencies:
|
|
|
166
166
|
description: 'Analyses your Gemfile for dependency health: checks if gems are actively
|
|
167
167
|
maintained (last commit dates via GitHub and GitLab, release dates), outdated versions,
|
|
168
168
|
archived repos, OpenSSF Scorecard security scores, known vulnerabilities via deps.dev,
|
|
169
|
-
and libyear drift.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
--fail-if-
|
|
173
|
-
|
|
169
|
+
and libyear drift. Ruby version freshness with EOL detection. Handles rubygems,
|
|
170
|
+
git, path, and GitHub Packages sources. Outputs coloured terminal tables, markdown,
|
|
171
|
+
or JSON. CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable,
|
|
172
|
+
--fail-if-outdated, and --ignore. A comprehensive alternative to running bundle
|
|
173
|
+
outdated, bundler-audit, and libyear-bundler separately.'
|
|
174
174
|
email:
|
|
175
175
|
- contact@seanfloyd.dev
|
|
176
176
|
executables:
|
|
@@ -186,7 +186,6 @@ files:
|
|
|
186
186
|
- lib/helpers/ansi_helper.rb
|
|
187
187
|
- lib/helpers/bundler_helper.rb
|
|
188
188
|
- lib/helpers/emoji_helper.rb
|
|
189
|
-
- lib/helpers/health_score_helper.rb
|
|
190
189
|
- lib/helpers/http_helper.rb
|
|
191
190
|
- lib/helpers/libyear_helper.rb
|
|
192
191
|
- lib/helpers/markdown_helper.rb
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "activity_helper"
|
|
4
|
-
|
|
5
|
-
module StillActive
|
|
6
|
-
module HealthScoreHelper
|
|
7
|
-
extend self
|
|
8
|
-
|
|
9
|
-
WEIGHTS = {
|
|
10
|
-
version_freshness: 30,
|
|
11
|
-
activity: 25,
|
|
12
|
-
scorecard: 20,
|
|
13
|
-
vulnerabilities: 25,
|
|
14
|
-
}.freeze
|
|
15
|
-
|
|
16
|
-
def gem_score(gem_data)
|
|
17
|
-
components = {
|
|
18
|
-
version_freshness: version_freshness_score(gem_data),
|
|
19
|
-
activity: activity_score(gem_data),
|
|
20
|
-
scorecard: scorecard_score(gem_data),
|
|
21
|
-
vulnerabilities: vulnerability_score(gem_data),
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
available = components.compact
|
|
25
|
-
return if available.empty?
|
|
26
|
-
|
|
27
|
-
total_weight = available.keys.sum { |k| WEIGHTS[k] }
|
|
28
|
-
weighted_sum = available.sum { |k, v| WEIGHTS[k] * v }
|
|
29
|
-
(weighted_sum.to_f / total_weight).round
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def system_average(result)
|
|
33
|
-
scores = result.each_value.filter_map { |d| d[:health_score] }
|
|
34
|
-
return if scores.empty?
|
|
35
|
-
|
|
36
|
-
(scores.sum.to_f / scores.size).round
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def version_freshness_score(gem_data)
|
|
42
|
-
return 0 if gem_data[:version_yanked]
|
|
43
|
-
|
|
44
|
-
libyear = gem_data[:libyear]
|
|
45
|
-
return if libyear.nil?
|
|
46
|
-
|
|
47
|
-
[100 - (libyear * 20), 0].max.round
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def activity_score(gem_data)
|
|
51
|
-
return 0 if gem_data[:archived]
|
|
52
|
-
|
|
53
|
-
level = ActivityHelper.activity_level(gem_data)
|
|
54
|
-
case level
|
|
55
|
-
when :ok then 100
|
|
56
|
-
when :stale then 40
|
|
57
|
-
when :critical then 10
|
|
58
|
-
when :unknown then nil
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def scorecard_score(gem_data)
|
|
63
|
-
score = gem_data[:scorecard_score]
|
|
64
|
-
return if score.nil?
|
|
65
|
-
|
|
66
|
-
(score * 10).round
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def vulnerability_score(gem_data)
|
|
70
|
-
count = gem_data[:vulnerability_count]
|
|
71
|
-
return if count.nil?
|
|
72
|
-
|
|
73
|
-
case count
|
|
74
|
-
when 0 then 100
|
|
75
|
-
when 1 then 40
|
|
76
|
-
when 2 then 20
|
|
77
|
-
else 0
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|