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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. 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
- result_object
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
- commit_date = last_commit_date(
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 have the version
151
- deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
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(source:, repository_owner: owner, repository_name: name),
156
- archived: repo_archived(source:, repository_owner: owner, repository_name: name),
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
- deps_dev_vulns = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) }
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
- rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
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 = if repo[:url]
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 = if repo[:url]
229
- host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
230
- "#{host}/#{repo[:owner]}/#{repo[:name]}"
231
- end
232
- repo.merge(project_id: project_id)
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
- rubygems_gem_repository_url(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
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
- def repo_archived(source:, repository_owner:, repository_name:)
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
- when :github
274
- repo = StillActive.config.github_client.repository("#{repository_owner}/#{repository_name}")
275
- repo&.archived
276
- when :gitlab
277
- GitlabClient.archived(owner: repository_owner, name: repository_name)
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
- def last_commit_date(source:, repository_owner:, repository_name:)
285
- case source
286
- when :github
287
- commit = StillActive.config.github_client.commits("#{repository_owner}/#{repository_name}", per_page: 1)&.first
288
- date = commit&.commit&.author&.date
289
- case date
290
- when Time then date
291
- when String
292
- begin
293
- Time.parse(date)
294
- rescue ArgumentError
295
- $stderr.puts("warning: could not parse commit date for #{repository_owner}/#{repository_name}: #{date.inspect}")
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 Ruby dependencies for maintenance health, outdated versions, vulnerabilities, and abandoned gems."
12
- spec.description = "Analyses your Gemfile for dependency health: checks if gems are actively maintained " \
13
- "(last commit dates via GitHub and GitLab, release dates), outdated versions, archived repos, " \
14
- "OpenSSF Scorecard security scores, known vulnerabilities via deps.dev, and libyear drift. " \
15
- "Ruby version freshness with EOL detection. " \
16
- "Handles rubygems, git, path, and GitHub Packages sources. " \
17
- "Outputs coloured terminal tables, markdown, or JSON. " \
18
- "CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable, --fail-if-outdated, and --ignore. " \
19
- "A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
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