still_active 2.0.0 → 3.0.0.rc2

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +104 -357
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +27 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +205 -9
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. metadata +61 -11
@@ -5,8 +5,9 @@ require "date"
5
5
  module StillActive
6
6
  # Granular, auditable suppression of individual findings, loaded from the
7
7
  # `ignore:` block of a committed .still_active.yml. Each entry silences one
8
- # signal (activity / libyear) or one advisory for one gem, optionally until an
9
- # expiry date, replacing the all-or-nothing whole-gem --ignore. A bare gem
8
+ # signal (activity / vulnerability / libyear / poison) or one advisory for one
9
+ # gem, optionally until an expiry date, replacing the all-or-nothing whole-gem
10
+ # --ignore. A bare gem
10
11
  # name (or a gem-only mapping) keeps the old whole-gem behaviour so --ignore
11
12
  # can union into the same list.
12
13
  #
@@ -15,7 +16,7 @@ module StillActive
15
16
  # name an explicit advisory id, so a newly disclosed CVE on the same gem is
16
17
  # never pre-silenced.
17
18
  class Suppressions
18
- GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear].freeze
19
+ GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear, :poison, :language_ceiling].freeze
19
20
 
20
21
  Entry = Struct.new(:gem, :advisory, :signal, :reason, :expires, keyword_init: true) do
21
22
  def whole_gem?
@@ -75,14 +76,14 @@ module StillActive
75
76
  return false
76
77
  end
77
78
  if signal && !GATEABLE_SIGNALS.include?(signal)
78
- warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, or libyear)"
79
+ warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, libyear, or poison)"
79
80
  return false
80
81
  end
81
82
  if signal == :vulnerability && advisory.nil?
82
83
  warnings << "ignoring vulnerability suppression for #{label}: must name an advisory id so newly disclosed advisories still surface"
83
84
  return false
84
85
  end
85
- if [:activity, :libyear].include?(signal) && gem.nil?
86
+ if [:activity, :libyear, :poison].include?(signal) && gem.nil?
86
87
  warnings << "ignoring #{signal} suppression: must name a gem"
87
88
  return false
88
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "2.0.0"
4
+ VERSION = "3.0.0.rc2"
5
5
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "artifactory_client"
4
+ require_relative "ceiling_reconciler"
4
5
  require_relative "deps_dev_client"
6
+ require_relative "osv_client"
7
+ require_relative "poison_security_correlator"
8
+ require_relative "ecosystems_client"
5
9
  require_relative "forgejo_client"
6
10
  require_relative "github_client"
7
11
  require_relative "gitlab_client"
@@ -9,9 +13,12 @@ require_relative "repository"
9
13
  require_relative "../helpers/activity_helper"
10
14
  require_relative "../helpers/alternatives_helper"
11
15
  require_relative "../helpers/catalog_index"
16
+ require_relative "../helpers/constraint_helper"
17
+ require_relative "../helpers/http_helper"
12
18
  require_relative "../helpers/libyear_helper"
13
19
  require_relative "../helpers/ruby_advisory_db"
14
20
  require_relative "../helpers/ruby_helper"
21
+ require_relative "../helpers/runtime_ceiling_helper"
15
22
  require_relative "../helpers/version_helper"
16
23
  require_relative "../helpers/vulnerability_helper"
17
24
  require "async"
@@ -30,9 +37,33 @@ module StillActive
30
37
  # read-only Database is shared across fibers rather than reloaded per gem.
31
38
  advisory_db = RubyAdvisoryDb.load
32
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
33
59
  barrier = Async::Barrier.new
34
60
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
35
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 = {}
36
67
  total = StillActive.config.gems.size
37
68
  completed = 0
38
69
  StillActive.config.gems.each_with_object(result_object) do |gem, hash|
@@ -47,6 +78,8 @@ module StillActive
47
78
  dependency_path: gem[:dependency_path],
48
79
  advisory_db: advisory_db,
49
80
  catalog: catalog,
81
+ constraint_cache: constraint_cache,
82
+ ruby_range: ruby_range,
50
83
  )
51
84
  rescue Octokit::TooManyRequests
52
85
  $stderr.print("\r\e[K") if on_progress
@@ -60,6 +93,13 @@ module StillActive
60
93
  end
61
94
  end
62
95
  barrier.wait
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)
63
103
  # Gems are inserted as their async tasks finish, so the natural order is
64
104
  # nondeterministic completion order. Sort by name once here so every
65
105
  # consumer (JSON, SARIF, the baseline diff) gets a stable, diffable order.
@@ -74,7 +114,7 @@ module StillActive
74
114
 
75
115
  private
76
116
 
77
- 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)
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)
78
118
  result_object[gem_name] = { source_type: source_type, direct: direct }
79
119
  result_object[gem_name][:dependency_path] = dependency_path if dependency_path
80
120
  result_object[gem_name][:version_used] = gem_version if gem_version
@@ -89,13 +129,20 @@ module StillActive
89
129
  result_object: result_object,
90
130
  source_uri: source_uri,
91
131
  advisory_db: advisory_db,
132
+ ruby_range: ruby_range,
92
133
  )
93
134
  end
94
135
 
95
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
96
143
  end
97
144
 
98
- 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)
99
146
  vs = versions(gem_name: gem_name, source_uri: source_uri)
100
147
  repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
101
148
  signals = repo_signals(
@@ -106,7 +153,10 @@ module StillActive
106
153
  commit_date = signals[:last_commit_date]
107
154
  archived = signals[:archived]
108
155
  last_release = VersionHelper.find_version(versions: vs, pre_release: false)
109
- last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
156
+ last_pre_release = VersionHelper.upcoming_pre_release(
157
+ pre_release: VersionHelper.find_version(versions: vs, pre_release: true),
158
+ release: last_release,
159
+ )
110
160
  deps_dev = fetch_deps_dev_info(
111
161
  gem_name: gem_name,
112
162
  version: gem_version || VersionHelper.gem_version(version_hash: last_release),
@@ -138,8 +188,8 @@ module StillActive
138
188
  )
139
189
  end
140
190
 
191
+ version_used = gem_version ? VersionHelper.find_version(versions: vs, version_string: gem_version) : nil
141
192
  if gem_version
142
- version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
143
193
  result_object[gem_name].merge!({
144
194
  up_to_date: VersionHelper.up_to_date(
145
195
  version_used: version_used,
@@ -156,6 +206,75 @@ module StillActive
156
206
  ),
157
207
  })
158
208
  end
209
+
210
+ # A version pinned to a now-yanked release has no resolvable ruby_version to
211
+ # judge, and `pinned: !version_used.nil?` would otherwise fall through to the
212
+ # latest version's cap and attach IT to the locked gem -- a false ceiling on a
213
+ # version we can't actually read. Skip; the yanked lock is already flagged.
214
+ yanked_lock = !gem_version.nil? && version_used.nil? && !vs.empty?
215
+ unless yanked_lock
216
+ attach_ruby_ceiling(
217
+ gem_data: result_object[gem_name],
218
+ used_ruby_requirement: canonical_ruby_requirement(vs, version_used),
219
+ latest_ruby_requirement: canonical_ruby_requirement(vs, last_release),
220
+ pinned: !version_used.nil?,
221
+ ruby_range: ruby_range,
222
+ )
223
+ end
224
+ end
225
+
226
+ # The ruby_version to judge a language ceiling against, read from the canonical
227
+ # `ruby` (source) platform entry of a version rather than whichever variant the
228
+ # registry happens to list first. Native gems ship a permissive `ruby` platform
229
+ # plus precompiled per-platform variants that cap ruby_version to the ABIs they
230
+ # were built for; the source platform is the gem's true Ruby support (it can be
231
+ # compiled on a newer Ruby), so a precompiled variant's tighter cap must not be
232
+ # read as the gem's ceiling. (Flagging that a project's LOCKED precompiled
233
+ # variant lacks a newer-Ruby build is a distinct, narrower signal, and needs the
234
+ # locked platform, which isn't threaded here yet.)
235
+ def canonical_ruby_requirement(versions, version_hash)
236
+ number = version_hash && version_hash["number"]
237
+ return if number.nil?
238
+
239
+ entries = versions.select { |version| version["number"] == number }
240
+ chosen = entries.find { |version| version["platform"] == "ruby" || version["platform"].nil? } || entries.first
241
+ VersionHelper.ruby_requirement(version_hash: chosen)
242
+ end
243
+
244
+ # Language-runtime ceiling: the sibling of poison. A gem's resolved version
245
+ # declares a `ruby_version` that caps the runtime -- either onto an end-of-life
246
+ # Ruby (critical: no patched runtime is reachable) or below the latest stable
247
+ # (note: a compatibility ceiling to plan around / a place to contribute Ruby-N
248
+ # support). Unlike poison this is NOT gated on dormancy: a cap is a fact of the
249
+ # resolved version whether or not the gem is maintained. Best-effort: a nil
250
+ # range (endoflife feed down) or absent ruby_version yields no finding.
251
+ def attach_ruby_ceiling(gem_data:, used_ruby_requirement:, latest_ruby_requirement:, pinned:, ruby_range:)
252
+ return if ruby_range.nil?
253
+
254
+ # Analyze the version actually in the tree. Fall back to latest ONLY when
255
+ # there's no pinned version (a latest-only audit), never when the pinned
256
+ # version merely declares no ruby_version: an absent cap runs on any Ruby, so
257
+ # projecting a newer release's cap back onto it would mint a false ceiling.
258
+ requirement = pinned ? used_ruby_requirement : latest_ruby_requirement
259
+ finding = RuntimeCeilingHelper.analyze(requirement: requirement, support_window: ruby_range)
260
+ return if finding.nil?
261
+
262
+ finding[:fixed_by_upgrade] = ruby_ceiling_lifted_by_upgrade?(used_req: used_ruby_requirement, latest_req: latest_ruby_requirement, ruby_range: ruby_range)
263
+ # The runtime this cap is against, so the shared renderers (terminal, md,
264
+ # SARIF) name it without hardcoding "Ruby". The Python SBOM path attaches the
265
+ # same shape with runtime "Python"; everything downstream is runtime-neutral.
266
+ finding[:runtime] = "Ruby"
267
+ gem_data[:language_ceiling] = finding
268
+ end
269
+
270
+ # Does bumping the gem to its latest lift the ceiling? True only when the cap
271
+ # is on the resolved (older) version and the latest version declares no ceiling
272
+ # of its own -- the actionable "upgrade the gem" case (e.g. CFPropertyList
273
+ # 3.0.9's `< 3.2` cap, gone by 4.0.0). False when already on latest.
274
+ def ruby_ceiling_lifted_by_upgrade?(used_req:, latest_req:, ruby_range:)
275
+ return false if used_req.nil? || used_req == latest_req
276
+
277
+ RuntimeCeilingHelper.analyze(requirement: latest_req, support_window: ruby_range).nil?
159
278
  end
160
279
 
161
280
  def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil)
@@ -163,8 +282,15 @@ module StillActive
163
282
  source, owner, name = repo_info.values_at(:source, :owner, :name)
164
283
  deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {}
165
284
 
166
- # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
167
- deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
285
+ # Fall back to repo-derived project_id for scorecard when deps.dev doesn't
286
+ # have the version. Use ||= so a maintained score already found via the
287
+ # version's scorecard is never clobbered by a nil from the repo fallback
288
+ # (0.0 is truthy, so a measured "unmaintained" is preserved too).
289
+ if deps_dev[:scorecard_score].nil?
290
+ fallback = DepsDevClient.project_scorecard(project_id: repo_info[:project_id])
291
+ deps_dev[:scorecard_score] ||= fallback&.dig(:score)
292
+ deps_dev[:scorecard_maintained] ||= fallback&.dig(:maintained)
293
+ end
168
294
 
169
295
  signals = repo_signals(source:, repository_owner: owner, repository_name: name)
170
296
  result_object[gem_name].merge!({
@@ -189,15 +315,74 @@ module StillActive
189
315
  nil # cosmetic best-effort: lead-fetching must never break the core audit
190
316
  end
191
317
 
318
+ # Poison-pill detection. A dormant gem that declares a runtime constraint
319
+ # capping a still-evolving dep BELOW that dep's current latest major holds the
320
+ # tree hostage: the cap will never lift because nobody is shipping the gem, and
321
+ # it gets more poisonous with time as the capped dep keeps releasing majors.
322
+ #
323
+ # Gated on dormancy so a MAINTAINED gem with a cap is never flagged (it'll bump
324
+ # the cap) -- that gate is the whole discipline of the signal. Each surviving
325
+ # finding carries its receipt (caps X; latest Y; N majors behind). Best-effort
326
+ # and non-raising: every underlying call degrades to []/nil on failure, so a
327
+ # lookup miss just means "no constraints known", never a dropped gem.
328
+ def attach_constraints(gem_name:, result_object:, cache:)
329
+ gem_data = result_object[gem_name]
330
+ # The locked version if the caller pinned one (that's the requirement set
331
+ # actually in their tree), else the gem's latest.
332
+ version = gem_data[:version_used] || gem_data[:latest_version]
333
+ return if version.nil?
334
+ return unless [:critical, :archived].include?(ActivityHelper.activity_level(gem_data))
335
+
336
+ declared = EcosystemsClient.declared_dependencies(name: gem_name, version: version)
337
+ constraints = ConstraintHelper.poison_findings(declared) do |dep_name|
338
+ resolve_latest_version(dep_name, result_object: result_object, cache: cache)
339
+ end
340
+ return if constraints.empty?
341
+
342
+ gem_data[:constraints] = constraints
343
+ # Poison = a version ceiling that blocks upgrades. An exact-pin below latest
344
+ # is a milder resolution hazard: still surfaced in constraints, but it may be
345
+ # deliberate, so it doesn't earn the poison label on its own.
346
+ gem_data[:poison] = constraints.any? { |constraint| constraint[:kind] == :ceiling }
347
+ gem_data[:poison_severity] = ConstraintHelper.worst_severity(constraints) if gem_data[:poison]
348
+ end
349
+
350
+ # The capped dep's current latest stable version, for the majors-behind math.
351
+ # Reuse the tree's already-computed latest_version when the dep is itself in
352
+ # the audit (no extra request); otherwise fetch and memoize per run, so a dep
353
+ # capped by several dormant gems is looked up once. Only a RESOLVED version is
354
+ # cached: an unresolved lookup returns nil whether the dep is genuinely absent
355
+ # or rubygems.org was momentarily rate-limited, and caching the transient case
356
+ # would drop the pill for every later gem capping the same dep. So a miss is
357
+ # re-attempted rather than remembered.
358
+ def resolve_latest_version(dep_name, result_object:, cache:)
359
+ cache[dep_name] ||= result_object[dep_name]&.dig(:latest_version) || fetch_latest_version(dep_name)
360
+ end
361
+
362
+ def fetch_latest_version(dep_name)
363
+ latest = VersionHelper.find_version(versions: versions(gem_name: dep_name), pre_release: false)
364
+ VersionHelper.gem_version(version_hash: latest)
365
+ end
366
+
192
367
  def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
193
368
  info = DepsDevClient.version_info(gem_name: gem_name, version: version)
194
369
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
195
370
  advisory_keys = info&.dig(:advisory_keys) || []
196
- deps_dev_vulns = advisory_keys.filter_map { |id| DepsDevClient.advisory_detail(advisory_id: id) }
371
+ # The keys ARE the evidence the version is vulnerable; the detail fetch only
372
+ # enriches CVSS/title. A failed enrichment (429/timeout/5xx -> nil) must not
373
+ # drop the advisory and read a known-vulnerable gem as clean, so an
374
+ # un-enriched key still contributes a minimal advisory (mirrors EcosystemLens;
375
+ # ruby-advisory-db then fills the score on merge when it also carries it).
376
+ deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || { id: id, source: "deps.dev" } }
197
377
  radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
198
378
  vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
379
+ # Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
380
+ # advisory) and the fixed-version ranges the "capped below the fix" signal needs.
381
+ # Native path is rubygems.
382
+ OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name)
199
383
  {
200
384
  scorecard_score: scorecard&.dig(:score),
385
+ scorecard_maintained: scorecard&.dig(:maintained),
201
386
  vulnerability_count: vulnerabilities.length,
202
387
  vulnerabilities: vulnerabilities,
203
388
  }
@@ -216,7 +401,14 @@ module StillActive
216
401
  end
217
402
  rescue Gems::NotFound
218
403
  []
219
- rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
404
+ # Gems::GemError is the `gems` library's catch-all for any non-success,
405
+ # non-404 response -- crucially a 429 rate-limit or a 5xx. Left unrescued it
406
+ # escapes to the per-gem rescue in #call and strips the gem of ALL its signals
407
+ # (not just versions), blaming a generic "error occurred". The poison-pill
408
+ # enrichment adds extra version lookups that make a 429 likelier, so a
409
+ # best-effort feature must not be able to degrade an unrelated gem's core data:
410
+ # degrade to "no versions known" here, exactly like Gems::NotFound.
411
+ rescue Gems::GemError, *HttpHelper::TRANSPORT_ERRORS => e
220
412
  $stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
221
413
  []
222
414
  end
@@ -342,7 +534,11 @@ module StillActive
342
534
  # assumption that every source supports every signal.
343
535
  def provider_for(source)
344
536
  case source
345
- when :github then GithubClient
537
+ # Without a GitHub token, the live API caps at 60 req/hr -- unusable past a
538
+ # handful of gems. Fall back to ecosyste.ms (5000 anonymous) so a large
539
+ # Gemfile still resolves. With a token, the live API stays primary (freshest,
540
+ # and it carries commits_since_release, which ecosyste.ms doesn't).
541
+ when :github then StillActive.config.github_oauth_token ? GithubClient : EcosystemsClient
346
542
  when :gitlab then GitlabClient
347
543
  when :forgejo then ForgejoClient
348
544
  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,17 +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."
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."
12
13
  spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
13
14
  "whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
14
15
  "release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
15
- "(deps.dev merged with ruby-advisory-db), and libyear drift. Ruby version freshness with EOL detection. " \
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. " \
16
20
  "Handles rubygems, git, path, GitHub Packages, and JFrog Artifactory sources. " \
17
21
  "Outputs coloured terminal tables, markdown, JSON (with a versioned, contract-tested schema), " \
18
22
  "SARIF for GitHub code scanning, and a CycloneDX SBOM. " \
19
- "CI quality gates (--fail-if-critical / -warning / -vulnerable / -outdated) with granular, committed " \
20
- "suppression via .still_active.yml. " \
21
- "A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
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."
22
27
  spec.homepage = "https://github.com/SeanLF/still_active"
23
28
  spec.license = "MIT"
24
29
  spec.required_ruby_version = ">= 3.3.0"
@@ -26,6 +31,7 @@ Gem::Specification.new do |spec|
26
31
  spec.metadata["homepage_uri"] = spec.homepage
27
32
  spec.metadata["source_code_uri"] = spec.homepage
28
33
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
34
+ spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
29
35
  spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
30
36
  spec.metadata["rubygems_mfa_required"] = "true"
31
37
 
@@ -53,7 +59,24 @@ Gem::Specification.new do |spec|
53
59
  # carry an artificial floor.
54
60
  spec.add_runtime_dependency("async", ">= 2.2")
55
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.
56
68
  spec.add_runtime_dependency("faraday-retry")
57
69
  spec.add_runtime_dependency("gems")
58
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")
59
82
  end
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: 2.0.0
4
+ version: 3.0.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -191,17 +191,49 @@ dependencies:
191
191
  - - ">="
192
192
  - !ruby/object:Gem::Version
193
193
  version: '0'
194
+ - !ruby/object:Gem::Dependency
195
+ name: packageurl-ruby
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: 0.1.0
201
+ type: :runtime
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - ">="
206
+ - !ruby/object:Gem::Version
207
+ version: 0.1.0
208
+ - !ruby/object:Gem::Dependency
209
+ name: semantic_range
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - ">="
213
+ - !ruby/object:Gem::Version
214
+ version: '3.0'
215
+ type: :runtime
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - ">="
220
+ - !ruby/object:Gem::Version
221
+ version: '3.0'
194
222
  description: 'Analyses your Gemfile.lock for dependency health across the full transitive
195
223
  graph: whether each gem is actively maintained (last activity on GitHub, GitLab,
196
224
  or Codeberg/Forgejo, plus release recency), outdated versions, archived repos, OpenSSF
197
- Scorecard scores, known vulnerabilities (deps.dev merged with ruby-advisory-db),
198
- and libyear drift. Ruby version freshness with EOL detection. Handles rubygems,
199
- git, path, GitHub Packages, and JFrog Artifactory sources. Outputs coloured terminal
200
- tables, markdown, JSON (with a versioned, contract-tested schema), SARIF for GitHub
201
- code scanning, and a CycloneDX SBOM. CI quality gates (--fail-if-critical / -warning
202
- / -vulnerable / -outdated) with granular, committed suppression via .still_active.yml.
203
- A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler
204
- separately.'
225
+ Scorecard scores, known vulnerabilities (deps.dev and OSV, merged with ruby-advisory-db,
226
+ flagging advisories with no fix and pins that sit below the fix), poison-pill compatibility
227
+ ceilings, and libyear drift. Ruby version freshness with EOL detection. The same
228
+ maintenance lens travels cross-ecosystem: point --sbom at a CycloneDX SBOM to assess
229
+ npm, PyPI, Cargo, Go, Maven, and NuGet packages via deps.dev and ecosyste.ms. Handles
230
+ rubygems, git, path, GitHub Packages, and JFrog Artifactory sources. Outputs coloured
231
+ terminal tables, markdown, JSON (with a versioned, contract-tested schema), SARIF
232
+ for GitHub code scanning, and a CycloneDX SBOM. CI quality gates (--fail-if-critical
233
+ / -warning / -vulnerable / -outdated / -poison / -language-ceiling) with granular,
234
+ committed suppression via .still_active.yml. Complements bundle outdated, bundler-audit,
235
+ and libyear-bundler by adding the maintenance signal they don''t, and folds their
236
+ version, CVE, and libyear checks into one report.'
205
237
  email:
206
238
  - contact@seanfloyd.dev
207
239
  executables:
@@ -219,37 +251,53 @@ files:
219
251
  - lib/helpers/bot_context.rb
220
252
  - lib/helpers/bundler_helper.rb
221
253
  - lib/helpers/catalog_index.rb
254
+ - lib/helpers/constraint_helper.rb
255
+ - lib/helpers/cvss_helper.rb
222
256
  - lib/helpers/cyclonedx_helper.rb
223
257
  - lib/helpers/diff_markdown_helper.rb
224
258
  - lib/helpers/emoji_helper.rb
259
+ - lib/helpers/endoflife_helper.rb
225
260
  - lib/helpers/http_helper.rb
226
261
  - lib/helpers/libyear_helper.rb
227
262
  - lib/helpers/lockfile_dependency_parser.rb
228
263
  - lib/helpers/lockfile_indexer.rb
229
264
  - lib/helpers/markdown_escape.rb
230
265
  - lib/helpers/markdown_helper.rb
266
+ - lib/helpers/pep440_helper.rb
267
+ - lib/helpers/python_helper.rb
231
268
  - lib/helpers/ruby_advisory_db.rb
232
269
  - lib/helpers/ruby_helper.rb
270
+ - lib/helpers/runtime_ceiling_helper.rb
233
271
  - lib/helpers/sarif_helper.rb
272
+ - lib/helpers/semver_satisfaction.rb
273
+ - lib/helpers/status_helper.rb
234
274
  - lib/helpers/summary_helper.rb
235
275
  - lib/helpers/terminal_helper.rb
236
276
  - lib/helpers/version_helper.rb
237
277
  - lib/helpers/vulnerability_helper.rb
238
278
  - lib/still_active.rb
239
279
  - lib/still_active/artifactory_client.rb
280
+ - lib/still_active/ceiling_reconciler.rb
240
281
  - lib/still_active/cli.rb
241
282
  - lib/still_active/config.rb
242
283
  - lib/still_active/config_file.rb
243
284
  - lib/still_active/core_ext.rb
244
285
  - lib/still_active/deps_dev_client.rb
245
286
  - lib/still_active/diff.rb
287
+ - lib/still_active/ecosystem_lens.rb
288
+ - lib/still_active/ecosystems_client.rb
246
289
  - lib/still_active/errors.rb
247
290
  - lib/still_active/forgejo_client.rb
248
291
  - lib/still_active/github_client.rb
249
292
  - lib/still_active/gitlab_client.rb
250
293
  - lib/still_active/options.rb
294
+ - lib/still_active/osv_client.rb
295
+ - lib/still_active/poison_security_correlator.rb
296
+ - lib/still_active/pypi_client.rb
251
297
  - lib/still_active/repository.rb
252
298
  - lib/still_active/sarif/rules.rb
299
+ - lib/still_active/sbom_reader.rb
300
+ - lib/still_active/sbom_workflow.rb
253
301
  - lib/still_active/suppressions.rb
254
302
  - lib/still_active/version.rb
255
303
  - lib/still_active/workflow.rb
@@ -261,6 +309,7 @@ metadata:
261
309
  homepage_uri: https://github.com/SeanLF/still_active
262
310
  source_code_uri: https://github.com/SeanLF/still_active
263
311
  changelog_uri: https://github.com/SeanLF/still_active/blob/main/CHANGELOG.md
312
+ documentation_uri: https://github.com/SeanLF/still_active#readme
264
313
  bug_tracker_uri: https://github.com/SeanLF/still_active/issues
265
314
  rubygems_mfa_required: 'true'
266
315
  rdoc_options: []
@@ -279,6 +328,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
279
328
  requirements: []
280
329
  rubygems_version: 4.0.10
281
330
  specification_version: 4
282
- summary: Audit your Ruby dependencies for maintenance health, outdated versions, vulnerabilities,
283
- and abandoned gems.
331
+ summary: Audit your dependencies for maintenance health, abandonment, and below-the-fix
332
+ vulnerabilities. Ruby gems natively; npm, PyPI, Cargo, Go, Maven, and NuGet via
333
+ a CycloneDX SBOM.
284
334
  test_files: []