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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +57 -10
- data/lib/helpers/activity_helper.rb +8 -0
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +20 -4
- data/lib/helpers/diff_markdown_helper.rb +21 -5
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +17 -2
- data/lib/helpers/markdown_helper.rb +118 -1
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +13 -24
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +105 -3
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +4 -0
- data/lib/helpers/terminal_helper.rb +144 -6
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +212 -9
- data/lib/still_active/config.rb +28 -1
- data/lib/still_active/config_file.rb +27 -0
- data/lib/still_active/deps_dev_client.rb +115 -5
- data/lib/still_active/diff.rb +22 -0
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/options.rb +44 -1
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/sarif/rules.rb +33 -9
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +6 -5
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +201 -8
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- 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
|
|
9
|
-
# expiry date, replacing the all-or-nothing whole-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
|
|
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
|
data/lib/still_active/version.rb
CHANGED
|
@@ -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
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
20
|
-
"suppression via .still_active.yml. " \
|
|
21
|
-
"
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
283
|
-
and
|
|
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: []
|