still_active 2.0.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +57 -10
  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 +9 -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 +201 -8
  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.rc1"
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(
@@ -138,8 +185,8 @@ module StillActive
138
185
  )
139
186
  end
140
187
 
188
+ version_used = gem_version ? VersionHelper.find_version(versions: vs, version_string: gem_version) : nil
141
189
  if gem_version
142
- version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
143
190
  result_object[gem_name].merge!({
144
191
  up_to_date: VersionHelper.up_to_date(
145
192
  version_used: version_used,
@@ -156,6 +203,75 @@ module StillActive
156
203
  ),
157
204
  })
158
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?
159
275
  end
160
276
 
161
277
  def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil)
@@ -163,8 +279,15 @@ module StillActive
163
279
  source, owner, name = repo_info.values_at(:source, :owner, :name)
164
280
  deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {}
165
281
 
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)
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
168
291
 
169
292
  signals = repo_signals(source:, repository_owner: owner, repository_name: name)
170
293
  result_object[gem_name].merge!({
@@ -189,15 +312,74 @@ module StillActive
189
312
  nil # cosmetic best-effort: lead-fetching must never break the core audit
190
313
  end
191
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
+
192
364
  def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
193
365
  info = DepsDevClient.version_info(gem_name: gem_name, version: version)
194
366
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
195
367
  advisory_keys = info&.dig(:advisory_keys) || []
196
- 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" } }
197
374
  radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
198
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)
199
380
  {
200
381
  scorecard_score: scorecard&.dig(:score),
382
+ scorecard_maintained: scorecard&.dig(:maintained),
201
383
  vulnerability_count: vulnerabilities.length,
202
384
  vulnerabilities: vulnerabilities,
203
385
  }
@@ -216,7 +398,14 @@ module StillActive
216
398
  end
217
399
  rescue Gems::NotFound
218
400
  []
219
- 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
220
409
  $stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
221
410
  []
222
411
  end
@@ -342,7 +531,11 @@ module StillActive
342
531
  # assumption that every source supports every signal.
343
532
  def provider_for(source)
344
533
  case source
345
- when :github then GithubClient
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
346
539
  when :gitlab then GitlabClient
347
540
  when :forgejo then ForgejoClient
348
541
  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.rc1
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: []