still_active 1.6.0 → 3.0.0.rc1
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 +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- 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 +145 -6
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- 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 +58 -6
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- metadata +72 -11
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "artifactory_client"
|
|
4
|
+
require_relative "ceiling_reconciler"
|
|
3
5
|
require_relative "deps_dev_client"
|
|
6
|
+
require_relative "osv_client"
|
|
7
|
+
require_relative "poison_security_correlator"
|
|
8
|
+
require_relative "ecosystems_client"
|
|
9
|
+
require_relative "forgejo_client"
|
|
10
|
+
require_relative "github_client"
|
|
4
11
|
require_relative "gitlab_client"
|
|
5
12
|
require_relative "repository"
|
|
6
13
|
require_relative "../helpers/activity_helper"
|
|
7
14
|
require_relative "../helpers/alternatives_helper"
|
|
8
15
|
require_relative "../helpers/catalog_index"
|
|
16
|
+
require_relative "../helpers/constraint_helper"
|
|
17
|
+
require_relative "../helpers/http_helper"
|
|
9
18
|
require_relative "../helpers/libyear_helper"
|
|
10
19
|
require_relative "../helpers/ruby_advisory_db"
|
|
11
20
|
require_relative "../helpers/ruby_helper"
|
|
21
|
+
require_relative "../helpers/runtime_ceiling_helper"
|
|
12
22
|
require_relative "../helpers/version_helper"
|
|
13
23
|
require_relative "../helpers/vulnerability_helper"
|
|
14
24
|
require "async"
|
|
15
25
|
require "async/barrier"
|
|
16
26
|
require "async/semaphore"
|
|
27
|
+
require "cgi"
|
|
17
28
|
require "gems"
|
|
18
29
|
|
|
19
30
|
module StillActive
|
|
@@ -26,9 +37,33 @@ module StillActive
|
|
|
26
37
|
# read-only Database is shared across fibers rather than reloaded per gem.
|
|
27
38
|
advisory_db = RubyAdvisoryDb.load
|
|
28
39
|
catalog = StillActive.config.alternatives ? CatalogIndex.load : nil
|
|
40
|
+
# The Ruby support window (oldest-supported / latest-stable / EOL cycles)
|
|
41
|
+
# is a per-run constant, so fetch it once here rather than per gem. nil when
|
|
42
|
+
# the endoflife feed is unavailable -> the language-ceiling signal quietly
|
|
43
|
+
# sits out, exactly like a missing advisory_db. This runs OUTSIDE the
|
|
44
|
+
# per-gem rescue, so a defensive rescue keeps a best-effort enrichment from
|
|
45
|
+
# ever aborting the whole audit (the "never crash the core" contract).
|
|
46
|
+
ruby_range =
|
|
47
|
+
begin
|
|
48
|
+
RubyHelper.supported_ruby_range
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
$stderr.puts("warning: Ruby support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
# Resolve the GitHub token once here, single-fibered, before the fan-out:
|
|
54
|
+
# provider_for reads it per gem across fibers and gh_cli_token shells out,
|
|
55
|
+
# so resolving eagerly keeps that off the concurrent path and guarantees
|
|
56
|
+
# every fiber sees one consistent value (token -> live GitHub, incl. private
|
|
57
|
+
# repos; only a genuinely absent token falls back to ecosyste.ms).
|
|
58
|
+
StillActive.config.github_oauth_token
|
|
29
59
|
barrier = Async::Barrier.new
|
|
30
60
|
semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
|
|
31
61
|
result_object = {}
|
|
62
|
+
# Memoizes each capped dep's latest version across gems for the run, so a
|
|
63
|
+
# dep pinned by several dormant gems is resolved once. Shared, single-writer
|
|
64
|
+
# under Async's cooperative scheduling (a duplicate concurrent miss just
|
|
65
|
+
# refetches the same value; it can't corrupt the map).
|
|
66
|
+
constraint_cache = {}
|
|
32
67
|
total = StillActive.config.gems.size
|
|
33
68
|
completed = 0
|
|
34
69
|
StillActive.config.gems.each_with_object(result_object) do |gem, hash|
|
|
@@ -39,8 +74,12 @@ module StillActive
|
|
|
39
74
|
gem_version: gem[:version],
|
|
40
75
|
source_type: gem[:source_type] || :rubygems,
|
|
41
76
|
source_uri: gem[:source_uri],
|
|
77
|
+
direct: gem.fetch(:direct, true),
|
|
78
|
+
dependency_path: gem[:dependency_path],
|
|
42
79
|
advisory_db: advisory_db,
|
|
43
80
|
catalog: catalog,
|
|
81
|
+
constraint_cache: constraint_cache,
|
|
82
|
+
ruby_range: ruby_range,
|
|
44
83
|
)
|
|
45
84
|
rescue Octokit::TooManyRequests
|
|
46
85
|
$stderr.print("\r\e[K") if on_progress
|
|
@@ -54,7 +93,17 @@ module StillActive
|
|
|
54
93
|
end
|
|
55
94
|
end
|
|
56
95
|
barrier.wait
|
|
57
|
-
|
|
96
|
+
# Whole-tree correlation, once every gem's signals are in: a ceiling's
|
|
97
|
+
# "upgrade to lift it" must not contradict a poison finding that caps the
|
|
98
|
+
# same gem below that upgrade.
|
|
99
|
+
CeilingReconciler.reconcile_ceiling_with_poison(result_object)
|
|
100
|
+
# Flag poison caps that pin a vulnerable dependency: "a dormant package is
|
|
101
|
+
# holding you on a known-vulnerable dep, below the fix." No extra fetches.
|
|
102
|
+
PoisonSecurityCorrelator.correlate(result_object)
|
|
103
|
+
# Gems are inserted as their async tasks finish, so the natural order is
|
|
104
|
+
# nondeterministic completion order. Sort by name once here so every
|
|
105
|
+
# consumer (JSON, SARIF, the baseline diff) gets a stable, diffable order.
|
|
106
|
+
result_object.sort_by { |name, _| name }.to_h
|
|
58
107
|
end
|
|
59
108
|
task.wait
|
|
60
109
|
end
|
|
@@ -65,8 +114,9 @@ module StillActive
|
|
|
65
114
|
|
|
66
115
|
private
|
|
67
116
|
|
|
68
|
-
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil, catalog: nil)
|
|
69
|
-
result_object[gem_name] = { source_type: source_type }
|
|
117
|
+
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, direct: true, dependency_path: nil, advisory_db: nil, catalog: nil, constraint_cache: {}, ruby_range: nil)
|
|
118
|
+
result_object[gem_name] = { source_type: source_type, direct: direct }
|
|
119
|
+
result_object[gem_name][:dependency_path] = dependency_path if dependency_path
|
|
70
120
|
result_object[gem_name][:version_used] = gem_version if gem_version
|
|
71
121
|
|
|
72
122
|
case source_type
|
|
@@ -79,25 +129,29 @@ module StillActive
|
|
|
79
129
|
result_object: result_object,
|
|
80
130
|
source_uri: source_uri,
|
|
81
131
|
advisory_db: advisory_db,
|
|
132
|
+
ruby_range: ruby_range,
|
|
82
133
|
)
|
|
83
134
|
end
|
|
84
135
|
|
|
85
136
|
attach_alternatives(gem_name: gem_name, result_object: result_object, catalog: catalog)
|
|
137
|
+
# Poison-pill enrichment runs last and only on the rubygems path (git/path
|
|
138
|
+
# gems aren't in the registry). Placed after alternatives so a stray fetch
|
|
139
|
+
# error can never cost the gem its other, already-assembled signals.
|
|
140
|
+
unless [:path, :git].include?(source_type)
|
|
141
|
+
attach_constraints(gem_name: gem_name, result_object: result_object, cache: constraint_cache)
|
|
142
|
+
end
|
|
86
143
|
end
|
|
87
144
|
|
|
88
|
-
def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
|
|
145
|
+
def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil, ruby_range: nil)
|
|
89
146
|
vs = versions(gem_name: gem_name, source_uri: source_uri)
|
|
90
|
-
repo_info = repository_info(gem_name: gem_name, versions: vs)
|
|
91
|
-
|
|
92
|
-
source: repo_info[:source],
|
|
93
|
-
repository_owner: repo_info[:owner],
|
|
94
|
-
repository_name: repo_info[:name],
|
|
95
|
-
)
|
|
96
|
-
archived = repo_archived(
|
|
147
|
+
repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
|
|
148
|
+
signals = repo_signals(
|
|
97
149
|
source: repo_info[:source],
|
|
98
150
|
repository_owner: repo_info[:owner],
|
|
99
151
|
repository_name: repo_info[:name],
|
|
100
152
|
)
|
|
153
|
+
commit_date = signals[:last_commit_date]
|
|
154
|
+
archived = signals[:archived]
|
|
101
155
|
last_release = VersionHelper.find_version(versions: vs, pre_release: false)
|
|
102
156
|
last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
|
|
103
157
|
deps_dev = fetch_deps_dev_info(
|
|
@@ -122,8 +176,17 @@ module StillActive
|
|
|
122
176
|
result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
|
|
123
177
|
end
|
|
124
178
|
|
|
179
|
+
if StillActive.config.unreleased_commits
|
|
180
|
+
result_object[gem_name][:unreleased_commits] = unreleased_commits(
|
|
181
|
+
source: repo_info[:source],
|
|
182
|
+
repository_owner: repo_info[:owner],
|
|
183
|
+
repository_name: repo_info[:name],
|
|
184
|
+
version: VersionHelper.gem_version(version_hash: last_release),
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
version_used = gem_version ? VersionHelper.find_version(versions: vs, version_string: gem_version) : nil
|
|
125
189
|
if gem_version
|
|
126
|
-
version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
|
|
127
190
|
result_object[gem_name].merge!({
|
|
128
191
|
up_to_date: VersionHelper.up_to_date(
|
|
129
192
|
version_used: version_used,
|
|
@@ -140,6 +203,75 @@ module StillActive
|
|
|
140
203
|
),
|
|
141
204
|
})
|
|
142
205
|
end
|
|
206
|
+
|
|
207
|
+
# A version pinned to a now-yanked release has no resolvable ruby_version to
|
|
208
|
+
# judge, and `pinned: !version_used.nil?` would otherwise fall through to the
|
|
209
|
+
# latest version's cap and attach IT to the locked gem -- a false ceiling on a
|
|
210
|
+
# version we can't actually read. Skip; the yanked lock is already flagged.
|
|
211
|
+
yanked_lock = !gem_version.nil? && version_used.nil? && !vs.empty?
|
|
212
|
+
unless yanked_lock
|
|
213
|
+
attach_ruby_ceiling(
|
|
214
|
+
gem_data: result_object[gem_name],
|
|
215
|
+
used_ruby_requirement: canonical_ruby_requirement(vs, version_used),
|
|
216
|
+
latest_ruby_requirement: canonical_ruby_requirement(vs, last_release),
|
|
217
|
+
pinned: !version_used.nil?,
|
|
218
|
+
ruby_range: ruby_range,
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The ruby_version to judge a language ceiling against, read from the canonical
|
|
224
|
+
# `ruby` (source) platform entry of a version rather than whichever variant the
|
|
225
|
+
# registry happens to list first. Native gems ship a permissive `ruby` platform
|
|
226
|
+
# plus precompiled per-platform variants that cap ruby_version to the ABIs they
|
|
227
|
+
# were built for; the source platform is the gem's true Ruby support (it can be
|
|
228
|
+
# compiled on a newer Ruby), so a precompiled variant's tighter cap must not be
|
|
229
|
+
# read as the gem's ceiling. (Flagging that a project's LOCKED precompiled
|
|
230
|
+
# variant lacks a newer-Ruby build is a distinct, narrower signal, and needs the
|
|
231
|
+
# locked platform, which isn't threaded here yet.)
|
|
232
|
+
def canonical_ruby_requirement(versions, version_hash)
|
|
233
|
+
number = version_hash && version_hash["number"]
|
|
234
|
+
return if number.nil?
|
|
235
|
+
|
|
236
|
+
entries = versions.select { |version| version["number"] == number }
|
|
237
|
+
chosen = entries.find { |version| version["platform"] == "ruby" || version["platform"].nil? } || entries.first
|
|
238
|
+
VersionHelper.ruby_requirement(version_hash: chosen)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Language-runtime ceiling: the sibling of poison. A gem's resolved version
|
|
242
|
+
# declares a `ruby_version` that caps the runtime -- either onto an end-of-life
|
|
243
|
+
# Ruby (critical: no patched runtime is reachable) or below the latest stable
|
|
244
|
+
# (note: a compatibility ceiling to plan around / a place to contribute Ruby-N
|
|
245
|
+
# support). Unlike poison this is NOT gated on dormancy: a cap is a fact of the
|
|
246
|
+
# resolved version whether or not the gem is maintained. Best-effort: a nil
|
|
247
|
+
# range (endoflife feed down) or absent ruby_version yields no finding.
|
|
248
|
+
def attach_ruby_ceiling(gem_data:, used_ruby_requirement:, latest_ruby_requirement:, pinned:, ruby_range:)
|
|
249
|
+
return if ruby_range.nil?
|
|
250
|
+
|
|
251
|
+
# Analyze the version actually in the tree. Fall back to latest ONLY when
|
|
252
|
+
# there's no pinned version (a latest-only audit), never when the pinned
|
|
253
|
+
# version merely declares no ruby_version: an absent cap runs on any Ruby, so
|
|
254
|
+
# projecting a newer release's cap back onto it would mint a false ceiling.
|
|
255
|
+
requirement = pinned ? used_ruby_requirement : latest_ruby_requirement
|
|
256
|
+
finding = RuntimeCeilingHelper.analyze(requirement: requirement, support_window: ruby_range)
|
|
257
|
+
return if finding.nil?
|
|
258
|
+
|
|
259
|
+
finding[:fixed_by_upgrade] = ruby_ceiling_lifted_by_upgrade?(used_req: used_ruby_requirement, latest_req: latest_ruby_requirement, ruby_range: ruby_range)
|
|
260
|
+
# The runtime this cap is against, so the shared renderers (terminal, md,
|
|
261
|
+
# SARIF) name it without hardcoding "Ruby". The Python SBOM path attaches the
|
|
262
|
+
# same shape with runtime "Python"; everything downstream is runtime-neutral.
|
|
263
|
+
finding[:runtime] = "Ruby"
|
|
264
|
+
gem_data[:language_ceiling] = finding
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Does bumping the gem to its latest lift the ceiling? True only when the cap
|
|
268
|
+
# is on the resolved (older) version and the latest version declares no ceiling
|
|
269
|
+
# of its own -- the actionable "upgrade the gem" case (e.g. CFPropertyList
|
|
270
|
+
# 3.0.9's `< 3.2` cap, gone by 4.0.0). False when already on latest.
|
|
271
|
+
def ruby_ceiling_lifted_by_upgrade?(used_req:, latest_req:, ruby_range:)
|
|
272
|
+
return false if used_req.nil? || used_req == latest_req
|
|
273
|
+
|
|
274
|
+
RuntimeCeilingHelper.analyze(requirement: latest_req, support_window: ruby_range).nil?
|
|
143
275
|
end
|
|
144
276
|
|
|
145
277
|
def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil)
|
|
@@ -147,19 +279,31 @@ module StillActive
|
|
|
147
279
|
source, owner, name = repo_info.values_at(:source, :owner, :name)
|
|
148
280
|
deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {}
|
|
149
281
|
|
|
150
|
-
# Fall back to repo-derived project_id for scorecard when deps.dev doesn't
|
|
151
|
-
|
|
282
|
+
# Fall back to repo-derived project_id for scorecard when deps.dev doesn't
|
|
283
|
+
# have the version. Use ||= so a maintained score already found via the
|
|
284
|
+
# version's scorecard is never clobbered by a nil from the repo fallback
|
|
285
|
+
# (0.0 is truthy, so a measured "unmaintained" is preserved too).
|
|
286
|
+
if deps_dev[:scorecard_score].nil?
|
|
287
|
+
fallback = DepsDevClient.project_scorecard(project_id: repo_info[:project_id])
|
|
288
|
+
deps_dev[:scorecard_score] ||= fallback&.dig(:score)
|
|
289
|
+
deps_dev[:scorecard_maintained] ||= fallback&.dig(:maintained)
|
|
290
|
+
end
|
|
152
291
|
|
|
292
|
+
signals = repo_signals(source:, repository_owner: owner, repository_name: name)
|
|
153
293
|
result_object[gem_name].merge!({
|
|
154
294
|
repository_url: repo_info[:url],
|
|
155
|
-
last_commit_date: last_commit_date
|
|
156
|
-
archived:
|
|
295
|
+
last_commit_date: signals[:last_commit_date],
|
|
296
|
+
archived: signals[:archived],
|
|
157
297
|
**deps_dev,
|
|
158
298
|
})
|
|
159
299
|
end
|
|
160
300
|
|
|
161
301
|
def attach_alternatives(gem_name:, result_object:, catalog:)
|
|
162
302
|
return if catalog.nil?
|
|
303
|
+
# Direct-only by design: "replace gem X with better-maintained Y" is
|
|
304
|
+
# incoherent for a transitive gem the user never chose (#60). The
|
|
305
|
+
# path-to-parent points them at the direct gem they can actually swap.
|
|
306
|
+
return unless result_object[gem_name][:direct]
|
|
163
307
|
return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
|
|
164
308
|
|
|
165
309
|
leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
|
|
@@ -168,15 +312,74 @@ module StillActive
|
|
|
168
312
|
nil # cosmetic best-effort: lead-fetching must never break the core audit
|
|
169
313
|
end
|
|
170
314
|
|
|
315
|
+
# Poison-pill detection. A dormant gem that declares a runtime constraint
|
|
316
|
+
# capping a still-evolving dep BELOW that dep's current latest major holds the
|
|
317
|
+
# tree hostage: the cap will never lift because nobody is shipping the gem, and
|
|
318
|
+
# it gets more poisonous with time as the capped dep keeps releasing majors.
|
|
319
|
+
#
|
|
320
|
+
# Gated on dormancy so a MAINTAINED gem with a cap is never flagged (it'll bump
|
|
321
|
+
# the cap) -- that gate is the whole discipline of the signal. Each surviving
|
|
322
|
+
# finding carries its receipt (caps X; latest Y; N majors behind). Best-effort
|
|
323
|
+
# and non-raising: every underlying call degrades to []/nil on failure, so a
|
|
324
|
+
# lookup miss just means "no constraints known", never a dropped gem.
|
|
325
|
+
def attach_constraints(gem_name:, result_object:, cache:)
|
|
326
|
+
gem_data = result_object[gem_name]
|
|
327
|
+
# The locked version if the caller pinned one (that's the requirement set
|
|
328
|
+
# actually in their tree), else the gem's latest.
|
|
329
|
+
version = gem_data[:version_used] || gem_data[:latest_version]
|
|
330
|
+
return if version.nil?
|
|
331
|
+
return unless [:critical, :archived].include?(ActivityHelper.activity_level(gem_data))
|
|
332
|
+
|
|
333
|
+
declared = EcosystemsClient.declared_dependencies(name: gem_name, version: version)
|
|
334
|
+
constraints = ConstraintHelper.poison_findings(declared) do |dep_name|
|
|
335
|
+
resolve_latest_version(dep_name, result_object: result_object, cache: cache)
|
|
336
|
+
end
|
|
337
|
+
return if constraints.empty?
|
|
338
|
+
|
|
339
|
+
gem_data[:constraints] = constraints
|
|
340
|
+
# Poison = a version ceiling that blocks upgrades. An exact-pin below latest
|
|
341
|
+
# is a milder resolution hazard: still surfaced in constraints, but it may be
|
|
342
|
+
# deliberate, so it doesn't earn the poison label on its own.
|
|
343
|
+
gem_data[:poison] = constraints.any? { |constraint| constraint[:kind] == :ceiling }
|
|
344
|
+
gem_data[:poison_severity] = ConstraintHelper.worst_severity(constraints) if gem_data[:poison]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# The capped dep's current latest stable version, for the majors-behind math.
|
|
348
|
+
# Reuse the tree's already-computed latest_version when the dep is itself in
|
|
349
|
+
# the audit (no extra request); otherwise fetch and memoize per run, so a dep
|
|
350
|
+
# capped by several dormant gems is looked up once. Only a RESOLVED version is
|
|
351
|
+
# cached: an unresolved lookup returns nil whether the dep is genuinely absent
|
|
352
|
+
# or rubygems.org was momentarily rate-limited, and caching the transient case
|
|
353
|
+
# would drop the pill for every later gem capping the same dep. So a miss is
|
|
354
|
+
# re-attempted rather than remembered.
|
|
355
|
+
def resolve_latest_version(dep_name, result_object:, cache:)
|
|
356
|
+
cache[dep_name] ||= result_object[dep_name]&.dig(:latest_version) || fetch_latest_version(dep_name)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def fetch_latest_version(dep_name)
|
|
360
|
+
latest = VersionHelper.find_version(versions: versions(gem_name: dep_name), pre_release: false)
|
|
361
|
+
VersionHelper.gem_version(version_hash: latest)
|
|
362
|
+
end
|
|
363
|
+
|
|
171
364
|
def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
|
|
172
365
|
info = DepsDevClient.version_info(gem_name: gem_name, version: version)
|
|
173
366
|
scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
|
|
174
367
|
advisory_keys = info&.dig(:advisory_keys) || []
|
|
175
|
-
|
|
368
|
+
# The keys ARE the evidence the version is vulnerable; the detail fetch only
|
|
369
|
+
# enriches CVSS/title. A failed enrichment (429/timeout/5xx -> nil) must not
|
|
370
|
+
# drop the advisory and read a known-vulnerable gem as clean, so an
|
|
371
|
+
# un-enriched key still contributes a minimal advisory (mirrors EcosystemLens;
|
|
372
|
+
# ruby-advisory-db then fills the score on merge when it also carries it).
|
|
373
|
+
deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || { id: id, source: "deps.dev" } }
|
|
176
374
|
radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
|
|
177
375
|
vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
|
|
376
|
+
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
377
|
+
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
378
|
+
# Native path is rubygems.
|
|
379
|
+
OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name)
|
|
178
380
|
{
|
|
179
381
|
scorecard_score: scorecard&.dig(:score),
|
|
382
|
+
scorecard_maintained: scorecard&.dig(:maintained),
|
|
180
383
|
vulnerability_count: vulnerabilities.length,
|
|
181
384
|
vulnerabilities: vulnerabilities,
|
|
182
385
|
}
|
|
@@ -185,12 +388,24 @@ module StillActive
|
|
|
185
388
|
def versions(gem_name:, source_uri: nil)
|
|
186
389
|
if github_packages_uri?(source_uri)
|
|
187
390
|
fetch_github_packages_versions(gem_name: gem_name, source_uri: source_uri)
|
|
391
|
+
elsif ArtifactoryClient.artifactory_uri?(source_uri)
|
|
392
|
+
ArtifactoryClient.versions(gem_name: gem_name, source_uri: source_uri)
|
|
393
|
+
elsif unqueryable_private_source?(source_uri)
|
|
394
|
+
warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
|
|
395
|
+
[]
|
|
188
396
|
else
|
|
189
397
|
Gems.versions(gem_name)
|
|
190
398
|
end
|
|
191
399
|
rescue Gems::NotFound
|
|
192
400
|
[]
|
|
193
|
-
|
|
401
|
+
# Gems::GemError is the `gems` library's catch-all for any non-success,
|
|
402
|
+
# non-404 response -- crucially a 429 rate-limit or a 5xx. Left unrescued it
|
|
403
|
+
# escapes to the per-gem rescue in #call and strips the gem of ALL its signals
|
|
404
|
+
# (not just versions), blaming a generic "error occurred". The poison-pill
|
|
405
|
+
# enrichment adds extra version lookups that make a 429 likelier, so a
|
|
406
|
+
# best-effort feature must not be able to degrade an unrelated gem's core data:
|
|
407
|
+
# degrade to "no versions known" here, exactly like Gems::NotFound.
|
|
408
|
+
rescue Gems::GemError, *HttpHelper::TRANSPORT_ERRORS => e
|
|
194
409
|
$stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
|
|
195
410
|
[]
|
|
196
411
|
end
|
|
@@ -201,10 +416,36 @@ module StillActive
|
|
|
201
416
|
false
|
|
202
417
|
end
|
|
203
418
|
|
|
419
|
+
# A rubygems-type source that isn't public rubygems.org and that we have no
|
|
420
|
+
# client for (Gemfury, Gemstash, geminabox, a private mirror). We must NOT
|
|
421
|
+
# fall through to Gems.versions, which always hits public rubygems.org: that
|
|
422
|
+
# would silently report a public name-collision's data, or blanks, as if it
|
|
423
|
+
# were the private gem's. github_packages/artifactory are handled above, so
|
|
424
|
+
# anything left with a non-rubygems.org host is unqueryable. Refs #43.
|
|
425
|
+
def unqueryable_private_source?(source_uri)
|
|
426
|
+
return false unless source_uri.is_a?(String)
|
|
427
|
+
|
|
428
|
+
# Hostnames are case-insensitive; a trailing dot (FQDN form) is equivalent.
|
|
429
|
+
host = URI(source_uri).host&.downcase&.chomp(".")
|
|
430
|
+
return false if host.nil?
|
|
431
|
+
|
|
432
|
+
host != "rubygems.org" && !host.end_with?(".rubygems.org")
|
|
433
|
+
rescue URI::InvalidURIError
|
|
434
|
+
false
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def warn_unqueryable_private_source(gem_name:, source_uri:)
|
|
438
|
+
host = URI(source_uri).host
|
|
439
|
+
$stderr.puts(
|
|
440
|
+
"warning: #{gem_name} resolves from a private source (#{host}) still_active cannot query; " \
|
|
441
|
+
"reporting no version/latest/libyear data for it rather than substituting public rubygems.org data",
|
|
442
|
+
)
|
|
443
|
+
end
|
|
444
|
+
|
|
204
445
|
def fetch_github_packages_versions(gem_name:, source_uri:)
|
|
205
446
|
base = URI(source_uri.chomp("/"))
|
|
206
447
|
namespace_path = base.path
|
|
207
|
-
path = "#{namespace_path}/api/v1/gems/#{gem_name}/versions.json"
|
|
448
|
+
path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
|
|
208
449
|
token = StillActive.config.github_oauth_token
|
|
209
450
|
headers = token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
210
451
|
HttpHelper.get_json(base, path, headers: headers) || []
|
|
@@ -214,32 +455,47 @@ module StillActive
|
|
|
214
455
|
valid_repository_url =
|
|
215
456
|
[source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
|
|
216
457
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
217
|
-
project_id
|
|
218
|
-
host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
|
|
219
|
-
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
220
|
-
end
|
|
221
|
-
repo.merge(project_id: project_id)
|
|
458
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
222
459
|
end
|
|
223
460
|
|
|
224
461
|
def repository_info_from_installed_gem(gem_name:)
|
|
225
462
|
valid_repository_url =
|
|
226
463
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
227
464
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
228
|
-
project_id
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
465
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
|
|
469
|
+
# repo has no deps.dev project, so leave its project_id nil rather than minting
|
|
470
|
+
# a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
|
|
471
|
+
DEPS_DEV_HOST_BY_SOURCE = { github: "github.com", gitlab: "gitlab.com" }.freeze
|
|
472
|
+
|
|
473
|
+
def deps_dev_project_id(repo)
|
|
474
|
+
host = DEPS_DEV_HOST_BY_SOURCE[repo[:source]]
|
|
475
|
+
return unless repo[:url] && host
|
|
476
|
+
|
|
477
|
+
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
233
478
|
end
|
|
234
479
|
|
|
235
|
-
def repository_info(gem_name:, versions:)
|
|
480
|
+
def repository_info(gem_name:, versions:, source_uri: nil)
|
|
236
481
|
valid_repository_url =
|
|
237
482
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) } ||
|
|
238
483
|
rubygems_versions_repository_url(versions: versions).find { |url| Repository.valid?(url: url) } ||
|
|
239
|
-
|
|
484
|
+
public_rubygems_repository_url(gem_name: gem_name, source_uri: source_uri)
|
|
240
485
|
Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
241
486
|
end
|
|
242
487
|
|
|
488
|
+
# Locally-installed gem metadata and the gem's own version payload are
|
|
489
|
+
# source-accurate. This public rubygems.org Gems.info lookup is the last
|
|
490
|
+
# resort, and is skipped for an unqueryable private source: otherwise a
|
|
491
|
+
# public name-collision's repo/archived/last-commit data would stand in for
|
|
492
|
+
# the private gem, the same substitution #43 prevents for versions.
|
|
493
|
+
def public_rubygems_repository_url(gem_name:, source_uri:)
|
|
494
|
+
return if unqueryable_private_source?(source_uri)
|
|
495
|
+
|
|
496
|
+
rubygems_gem_repository_url(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
497
|
+
end
|
|
498
|
+
|
|
243
499
|
def installed_gem_urls(gem_name:)
|
|
244
500
|
info = Gem::Dependency.new(gem_name).matching_specs.first
|
|
245
501
|
return [] if info.nil?
|
|
@@ -268,40 +524,35 @@ module StillActive
|
|
|
268
524
|
[]
|
|
269
525
|
end
|
|
270
526
|
|
|
271
|
-
|
|
527
|
+
# The repo-signal provider for a source, or nil for an unhandled host. Every
|
|
528
|
+
# provider answers archived/last_commit_date; richer signals (e.g.
|
|
529
|
+
# commits_since_release) are duck-typed and dispatched by respond_to?, so a
|
|
530
|
+
# provider opts into them by defining the method, with no base class and no
|
|
531
|
+
# assumption that every source supports every signal.
|
|
532
|
+
def provider_for(source)
|
|
272
533
|
case source
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
534
|
+
# Without a GitHub token, the live API caps at 60 req/hr -- unusable past a
|
|
535
|
+
# handful of gems. Fall back to ecosyste.ms (5000 anonymous) so a large
|
|
536
|
+
# Gemfile still resolves. With a token, the live API stays primary (freshest,
|
|
537
|
+
# and it carries commits_since_release, which ecosyste.ms doesn't).
|
|
538
|
+
when :github then StillActive.config.github_oauth_token ? GithubClient : EcosystemsClient
|
|
539
|
+
when :gitlab then GitlabClient
|
|
540
|
+
when :forgejo then ForgejoClient
|
|
278
541
|
end
|
|
279
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
280
|
-
$stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
281
|
-
nil
|
|
282
542
|
end
|
|
283
543
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
nil
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
when :gitlab
|
|
300
|
-
GitlabClient.last_commit_date(owner: repository_owner, name: repository_name)
|
|
301
|
-
end
|
|
302
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
303
|
-
$stderr.puts("warning: last commit check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
304
|
-
nil
|
|
544
|
+
# One provider call yields both archived and the last-activity date (the
|
|
545
|
+
# repo object carries both), so a gem's repo signals cost a single request
|
|
546
|
+
# instead of two. Returns {} for an unhandled host.
|
|
547
|
+
def repo_signals(source:, repository_owner:, repository_name:)
|
|
548
|
+
provider_for(source)&.repo_signals(owner: repository_owner, name: repository_name) || {}
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def unreleased_commits(source:, repository_owner:, repository_name:, version:)
|
|
552
|
+
provider = provider_for(source)
|
|
553
|
+
return unless provider.respond_to?(:commits_since_release)
|
|
554
|
+
|
|
555
|
+
provider.commits_since_release(owner: repository_owner, name: repository_name, version: version)
|
|
305
556
|
end
|
|
306
557
|
end
|
|
307
558
|
end
|
data/lib/still_active.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require_relative "still_active/version"
|
|
4
4
|
require_relative "still_active/errors"
|
|
5
5
|
require_relative "still_active/config"
|
|
6
|
+
require_relative "still_active/sbom_reader"
|
|
7
|
+
require_relative "still_active/ecosystem_lens"
|
|
8
|
+
require_relative "still_active/sbom_workflow"
|
|
6
9
|
require_relative "still_active/cli"
|
|
7
10
|
|
|
8
11
|
# Octokit depends on Faraday and emits a deprecation warning at load time
|
data/still_active.gemspec
CHANGED
|
@@ -8,15 +8,22 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Sean Floyd"]
|
|
9
9
|
spec.email = ["contact@seanfloyd.dev"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Audit your
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
11
|
+
spec.summary = "Audit your dependencies for maintenance health, abandonment, and below-the-fix vulnerabilities. " \
|
|
12
|
+
"Ruby gems natively; npm, PyPI, Cargo, Go, Maven, and NuGet via a CycloneDX SBOM."
|
|
13
|
+
spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
|
|
14
|
+
"whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
|
|
15
|
+
"release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
|
|
16
|
+
"(deps.dev and OSV, merged with ruby-advisory-db, flagging advisories with no fix and pins that sit below " \
|
|
17
|
+
"the fix), poison-pill compatibility ceilings, and libyear drift. Ruby version freshness with EOL detection. " \
|
|
18
|
+
"The same maintenance lens travels cross-ecosystem: point --sbom at a CycloneDX SBOM to assess npm, PyPI, " \
|
|
19
|
+
"Cargo, Go, Maven, and NuGet packages via deps.dev and ecosyste.ms. " \
|
|
20
|
+
"Handles rubygems, git, path, GitHub Packages, and JFrog Artifactory sources. " \
|
|
21
|
+
"Outputs coloured terminal tables, markdown, JSON (with a versioned, contract-tested schema), " \
|
|
22
|
+
"SARIF for GitHub code scanning, and a CycloneDX SBOM. " \
|
|
23
|
+
"CI quality gates (--fail-if-critical / -warning / -vulnerable / -outdated / -poison / -language-ceiling) " \
|
|
24
|
+
"with granular, committed suppression via .still_active.yml. " \
|
|
25
|
+
"Complements bundle outdated, bundler-audit, and libyear-bundler by adding the maintenance signal they " \
|
|
26
|
+
"don't, and folds their version, CVE, and libyear checks into one report."
|
|
20
27
|
spec.homepage = "https://github.com/SeanLF/still_active"
|
|
21
28
|
spec.license = "MIT"
|
|
22
29
|
spec.required_ruby_version = ">= 3.3.0"
|
|
@@ -24,6 +31,7 @@ Gem::Specification.new do |spec|
|
|
|
24
31
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
25
32
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
26
33
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
34
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
|
|
27
35
|
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
28
36
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
29
37
|
|
|
@@ -51,7 +59,24 @@ Gem::Specification.new do |spec|
|
|
|
51
59
|
# carry an artificial floor.
|
|
52
60
|
spec.add_runtime_dependency("async", ">= 2.2")
|
|
53
61
|
spec.add_runtime_dependency("bundler", ">= 2.0")
|
|
62
|
+
# cvss-suite (CVSS v4.0 base-score from an OSV vector, for the CVSS-4-only advisory
|
|
63
|
+
# deps.dev can't score) is an OPTIONAL dependency, NOT declared here: it exact-pins
|
|
64
|
+
# bundler and caps bigdecimal, the poison-pill pattern still_active flags, so we
|
|
65
|
+
# won't force it on users or trip our own audit. CvssHelper soft-requires it; absent,
|
|
66
|
+
# the OSV/GHSA severity label still carries gating + level and the number is skipped.
|
|
67
|
+
# Install cvss-suite (or run the distribution that bundles it) to light up the number.
|
|
54
68
|
spec.add_runtime_dependency("faraday-retry")
|
|
55
69
|
spec.add_runtime_dependency("gems")
|
|
56
70
|
spec.add_runtime_dependency("octokit")
|
|
71
|
+
# Spec-compliant package-URL parsing for SBOM ingestion (decodes npm scopes,
|
|
72
|
+
# maven group:artifact, qualifiers). 0.1 is the verified floor; the official
|
|
73
|
+
# package-url org gem (vetted via still_active itself: maintained, no advisories).
|
|
74
|
+
spec.add_runtime_dependency("packageurl-ruby", ">= 0.1.0")
|
|
75
|
+
# node-semver range satisfaction for the npm/cargo below-the-fix signal: deciding
|
|
76
|
+
# whether a CVE's fixed version escapes a package's declared constraint needs
|
|
77
|
+
# PATCH precision (their fixes are mostly same-major patch bumps), and hand-rolling
|
|
78
|
+
# node-semver's prerelease + caret-on-0.x rules is the correctness minefield this
|
|
79
|
+
# composes away. Vetted via still_active itself: maintained (2026 release), MIT,
|
|
80
|
+
# zero runtime dependencies. cargo reuses it behind a bare-version->caret shim.
|
|
81
|
+
spec.add_runtime_dependency("semantic_range", ">= 3.0")
|
|
57
82
|
end
|