still_active 3.1.0 → 3.2.0
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 +27 -0
- data/README.md +3 -1
- data/lib/still_active/artifactory_client.rb +2 -20
- data/lib/still_active/assessment.rb +85 -0
- data/lib/still_active/cli.rb +84 -23
- data/lib/still_active/config.rb +2 -0
- data/lib/still_active/deps_dev_client.rb +161 -15
- data/lib/still_active/diff.rb +38 -0
- data/lib/still_active/ecosystem_lens.rb +80 -44
- data/lib/still_active/ecosystems_client.rb +10 -7
- data/lib/still_active/errors.rb +10 -0
- data/lib/still_active/forgejo_client.rb +5 -1
- data/lib/still_active/github_client.rb +28 -5
- data/lib/still_active/gitlab_client.rb +5 -1
- data/lib/still_active/go_toolchain.rb +75 -0
- data/lib/still_active/helpers/alternatives_helper.rb +2 -2
- data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
- data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
- data/lib/still_active/helpers/emoji_helper.rb +2 -1
- data/lib/still_active/helpers/endoflife_helper.rb +3 -2
- data/lib/still_active/helpers/http_cache.rb +109 -0
- data/lib/still_active/helpers/http_helper.rb +148 -28
- data/lib/still_active/helpers/markdown_helper.rb +1 -1
- data/lib/still_active/helpers/repository_check.rb +33 -0
- data/lib/still_active/helpers/status_helper.rb +11 -0
- data/lib/still_active/helpers/summary_helper.rb +6 -1
- data/lib/still_active/helpers/terminal_helper.rb +9 -1
- data/lib/still_active/options.rb +7 -0
- data/lib/still_active/osv_client.rb +39 -2
- data/lib/still_active/repository_signals.rb +113 -0
- data/lib/still_active/rubygems_client.rb +34 -0
- data/lib/still_active/sarif/rules.rb +1 -1
- data/lib/still_active/sbom_workflow.rb +16 -2
- data/lib/still_active/source_health.rb +88 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +63 -52
- data/lib/still_active.rb +1 -0
- data/still_active.gemspec +0 -1
- metadata +9 -16
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "helpers/http_helper"
|
|
4
4
|
require_relative "helpers/version_helper"
|
|
5
|
+
require_relative "helpers/http_cache"
|
|
5
6
|
|
|
6
7
|
module StillActive
|
|
7
8
|
module DepsDevClient
|
|
@@ -21,19 +22,29 @@ module StillActive
|
|
|
21
22
|
# canary comes back empty (schema drift) or unreachable (can't confirm). The
|
|
22
23
|
# caller warns loudly rather than presenting a possibly-understated "all clear".
|
|
23
24
|
def advisory_schema_ok?
|
|
24
|
-
info = version_info(gem_name: ADVISORY_CANARY[:name], version: ADVISORY_CANARY[:version], system: ADVISORY_CANARY[:system])
|
|
25
|
+
info = version_info(gem_name: ADVISORY_CANARY[:name], version: ADVISORY_CANARY[:version], system: ADVISORY_CANARY[:system], cache: false)
|
|
25
26
|
!(info.nil? || info[:advisory_keys].empty?)
|
|
27
|
+
rescue HttpHelper::Unavailable
|
|
28
|
+
false
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
# `system` is the deps.dev package system, lowercased: rubygems, npm, pypi,
|
|
29
32
|
# cargo, go, maven, nuget. It matches the ecosystem symbol SbomReader emits,
|
|
30
|
-
# so a cross-ecosystem caller threads it straight through.
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
+
# so a cross-ecosystem caller threads it straight through. nil when deps.dev
|
|
34
|
+
# has no record (a 404: an unknown system, a private package, a yanked
|
|
35
|
+
# version). This response carries the advisories, so a failure to answer is
|
|
36
|
+
# NOT nil: it is retried once, then raises HttpHelper::Unavailable, and the
|
|
37
|
+
# caller reports the advisories unchecked rather than clean.
|
|
38
|
+
def version_info(gem_name:, version:, system: :rubygems, cache: true)
|
|
33
39
|
return if gem_name.nil? || version.nil?
|
|
34
40
|
|
|
35
|
-
path =
|
|
36
|
-
|
|
41
|
+
path = version_path(system, gem_name, version)
|
|
42
|
+
# A caller that skips the cache (a canary) skips the prefetched table too.
|
|
43
|
+
body = (cache && @prefetched&.[](path)) || begin
|
|
44
|
+
version_record(path, cache: cache)
|
|
45
|
+
rescue HttpHelper::Unavailable
|
|
46
|
+
version_record(path, cache: cache)
|
|
47
|
+
end
|
|
37
48
|
return if body.nil?
|
|
38
49
|
|
|
39
50
|
{
|
|
@@ -61,6 +72,33 @@ module StillActive
|
|
|
61
72
|
}
|
|
62
73
|
end
|
|
63
74
|
|
|
75
|
+
# The most versions one versionbatch request takes (deps.dev rejects more).
|
|
76
|
+
BATCH_LIMIT = 5000
|
|
77
|
+
# deps.dev pages a batch 100 records at a time, so 5000 keys take 50 pages;
|
|
78
|
+
# past this, the pagination is broken and the batch is abandoned, not trusted.
|
|
79
|
+
MAX_BATCH_PAGES = 60
|
|
80
|
+
|
|
81
|
+
# Fetches the version records for [system, name, version] keys in batches of
|
|
82
|
+
# BATCH_LIMIT, before a fan-out, so version_info answers from memory instead of
|
|
83
|
+
# a GET per version. Only a well-formed record is kept. A key the batch left
|
|
84
|
+
# out, a malformed record, or a failed batch is simply not prefetched, and
|
|
85
|
+
# version_info fetches it itself as before: the batch saves requests but never
|
|
86
|
+
# decides an answer, so a partial failure can't pass for "no record". Each
|
|
87
|
+
# record also goes into the disk cache under its GET's key. Run-scoped: pair
|
|
88
|
+
# with clear_prefetch.
|
|
89
|
+
def prefetch_versions(keys)
|
|
90
|
+
@prefetched = {}
|
|
91
|
+
keys.uniq.each_slice(BATCH_LIMIT) { prefetch_batch(_1) }
|
|
92
|
+
rescue => e
|
|
93
|
+
# An optimisation must never abort the audit: whatever didn't prefetch is
|
|
94
|
+
# fetched per version as before.
|
|
95
|
+
warn("warning: deps.dev batch prefetch failed: #{e.class} (#{e.message}); looking versions up one at a time")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def clear_prefetch
|
|
99
|
+
@prefetched = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
# The package's default version and its release date: { version:,
|
|
65
103
|
# published_at: }, or nil. deps.dev's default version is the latest stable;
|
|
66
104
|
# when none is flagged (all pre-release), the newest publishedAt stands in so
|
|
@@ -84,21 +122,50 @@ module StillActive
|
|
|
84
122
|
# upgrade. So rank by version and take the newest STABLE release; fall back to
|
|
85
123
|
# isDefault, then newest-by-date, only when no stable version parses (a
|
|
86
124
|
# genuinely prerelease-only package still reads active, not dormant).
|
|
87
|
-
entry = latest_stable_version(versions) ||
|
|
125
|
+
entry = latest_stable_version(versions, system: system) ||
|
|
88
126
|
versions.find { |v| v.is_a?(Hash) && v["isDefault"] } ||
|
|
89
|
-
newest_version(versions)
|
|
127
|
+
newest_version(versions, system: system)
|
|
90
128
|
return if entry.nil?
|
|
91
129
|
|
|
92
|
-
{
|
|
130
|
+
{
|
|
131
|
+
version: entry.dig("versionKey", "version"),
|
|
132
|
+
published_at: entry["publishedAt"],
|
|
133
|
+
# The latest version's own deprecation, from the listing already fetched.
|
|
134
|
+
deprecated: entry["isDeprecated"] == true,
|
|
135
|
+
deprecation_reason: presence(entry["deprecatedReason"])
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A Go module's deprecation, as deps.dev words it: `// Deprecated:` in the
|
|
140
|
+
# latest go.mod, which covers every version. deps.dev uses the same flag for
|
|
141
|
+
# a retracted version ("Version retracted: ..."), which covers only that one.
|
|
142
|
+
MODULE_DEPRECATED = "Module deprecated:"
|
|
143
|
+
|
|
144
|
+
def module_deprecation?(reason) = reason.to_s.start_with?(MODULE_DEPRECATED)
|
|
145
|
+
|
|
146
|
+
# The newest publishedAt across every version, prereleases and pseudo-versions
|
|
147
|
+
# included: when deps.dev last ingested anything for this package, which is the
|
|
148
|
+
# index-freshness question, not "what is the latest release". nil when no
|
|
149
|
+
# version carries a date or the package isn't indexed; raises
|
|
150
|
+
# HttpHelper::Unavailable when deps.dev can't answer, after one retry.
|
|
151
|
+
def newest_publish_date(name:, system:)
|
|
152
|
+
path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(name)}"
|
|
153
|
+
# A canary: it has to see deps.dev as it is now, so never from the cache.
|
|
154
|
+
body = begin
|
|
155
|
+
HttpHelper.get_json(BASE_URI, path, strict: true, cache: false)
|
|
156
|
+
rescue HttpHelper::Unavailable
|
|
157
|
+
HttpHelper.get_json(BASE_URI, path, strict: true, cache: false)
|
|
158
|
+
end
|
|
159
|
+
Array(body.is_a?(Hash) ? body["versions"] : nil).filter_map { _1["publishedAt"] if _1.is_a?(Hash) && _1["publishedAt"].is_a?(String) }.max
|
|
93
160
|
end
|
|
94
161
|
|
|
95
162
|
# The newest non-prerelease version by version number (not publishedAt: a
|
|
96
163
|
# backported patch on an old line can post-date the latest major). nil when no
|
|
97
164
|
# version parses as a stable release.
|
|
98
|
-
def latest_stable_version(versions)
|
|
165
|
+
def latest_stable_version(versions, system: :rubygems)
|
|
99
166
|
versions
|
|
100
167
|
.filter_map do |v|
|
|
101
|
-
next unless v.is_a?(Hash) && !v
|
|
168
|
+
next unless v.is_a?(Hash) && !superseded?(v, system)
|
|
102
169
|
|
|
103
170
|
gem_version = VersionHelper.comparable(v.dig("versionKey", "version"))
|
|
104
171
|
[gem_version, v] if gem_version && !gem_version.prerelease?
|
|
@@ -115,8 +182,13 @@ module StillActive
|
|
|
115
182
|
default_version_info(name: name, system: system)&.dig(:published_at)
|
|
116
183
|
end
|
|
117
184
|
|
|
185
|
+
# The only hosts deps.dev keys projects under; any other answers 400 "invalid
|
|
186
|
+
# project key" (go.googlesource.com, self-hosted GitLab, Codeberg), so asking is
|
|
187
|
+
# a guaranteed warning with nothing to gain.
|
|
188
|
+
PROJECT_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"].freeze
|
|
189
|
+
|
|
118
190
|
def project_scorecard(project_id:)
|
|
119
|
-
return
|
|
191
|
+
return unless PROJECT_HOSTS.include?(project_id.to_s.split("/").first)
|
|
120
192
|
|
|
121
193
|
path = "/v3alpha/projects/#{encode(project_id)}"
|
|
122
194
|
body = HttpHelper.get_json(BASE_URI, path)
|
|
@@ -174,6 +246,77 @@ module StillActive
|
|
|
174
246
|
|
|
175
247
|
private
|
|
176
248
|
|
|
249
|
+
# A version not to count as the latest: deprecated on its own (npm's one bad
|
|
250
|
+
# release, a retracted Go version). A Go module's deprecation covers every
|
|
251
|
+
# version since, so skipping those would make a years-old one the latest.
|
|
252
|
+
def superseded?(version, system)
|
|
253
|
+
version["isDeprecated"] == true && !(system.to_sym == :go && module_deprecation?(version["deprecatedReason"]))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def version_path(system, name, version)
|
|
257
|
+
"/v3alpha/systems/#{encode(system)}/packages/#{encode(name)}/versions/#{encode(version)}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Each batch carries the advisory canary, and its records are used (and
|
|
261
|
+
# cached) only if the canary comes back with its advisories: the GET canary
|
|
262
|
+
# in SourceHealth can't vouch for this endpoint, and a batch that silently
|
|
263
|
+
# zeroed advisoryKeys would otherwise read every dependency clean. Every page
|
|
264
|
+
# is collected before that check, since the canary can land on any of them.
|
|
265
|
+
def prefetch_batch(keys)
|
|
266
|
+
canary = [ADVISORY_CANARY[:system], ADVISORY_CANARY[:name], ADVISORY_CANARY[:version]]
|
|
267
|
+
requests = (keys + [canary]).uniq.map { |system, name, version| {versionKey: {system: system.to_s.upcase, name: name, version: version}} }
|
|
268
|
+
records = {}
|
|
269
|
+
page_token = nil
|
|
270
|
+
MAX_BATCH_PAGES.times do
|
|
271
|
+
body = HttpHelper.post_json(BASE_URI, "/v3alpha/versionbatch", body: JSON.generate({requests: requests, pageToken: page_token}.compact))
|
|
272
|
+
return unless body.is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
Array(body["responses"]).each { |response| batch_record(response)&.then { |path, record| records[path] = record } }
|
|
275
|
+
page_token = body["nextPageToken"]
|
|
276
|
+
return accept_batch(records) if page_token.nil? || page_token.to_s.empty?
|
|
277
|
+
end
|
|
278
|
+
warn("warning: deps.dev's version batch ran past #{MAX_BATCH_PAGES} pages; looking those versions up one at a time")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# [path, record] for a well-formed record in a batch response, or nil.
|
|
282
|
+
def batch_record(response)
|
|
283
|
+
key = response.is_a?(Hash) && response.dig("request", "versionKey")
|
|
284
|
+
record = response.is_a?(Hash) && response["version"]
|
|
285
|
+
return unless key.is_a?(Hash) && record.is_a?(Hash) && version_record?(record)
|
|
286
|
+
|
|
287
|
+
[version_path(key["system"].to_s.downcase, key["name"], key["version"]), record]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def accept_batch(records)
|
|
291
|
+
canary = records[version_path(ADVISORY_CANARY[:system], ADVISORY_CANARY[:name], ADVISORY_CANARY[:version])]
|
|
292
|
+
unless canary && !canary["advisoryKeys"].empty?
|
|
293
|
+
warn("warning: deps.dev's version batch returned its advisory canary without advisories; looking those versions up one at a time")
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
records.each do |path, record|
|
|
298
|
+
@prefetched[path] = record
|
|
299
|
+
uri = BASE_URI.dup.tap { _1.path = path }
|
|
300
|
+
HttpCache.write(HttpCache.key("GET", uri), record) if HttpCache.ttl(uri, {})
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# deps.dev sends `advisoryKeys` on every version record, as [] when there are
|
|
305
|
+
# none (verified 2026-09-24). A record without it is schema drift or a garbled
|
|
306
|
+
# answer, and reading it as "no advisories" is the silent zero the canary
|
|
307
|
+
# guards against, so it counts as no answer, per dependency.
|
|
308
|
+
def version_record(path, cache: true)
|
|
309
|
+
body = HttpHelper.get_json(BASE_URI, path, strict: true, cache: cache, cache_if: method(:version_record?))
|
|
310
|
+
return body if body.nil? || version_record?(body)
|
|
311
|
+
|
|
312
|
+
warn("warning: api.deps.dev#{path} returned a version record with no advisoryKeys list; the API may have changed")
|
|
313
|
+
raise HttpHelper::Unavailable, path
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def version_record?(body)
|
|
317
|
+
body.is_a?(Hash) && body["advisoryKeys"].is_a?(Array)
|
|
318
|
+
end
|
|
319
|
+
|
|
177
320
|
# "" is how deps.dev renders "no deprecation message"; nil keeps an absent
|
|
178
321
|
# reason from rendering as a present blank.
|
|
179
322
|
def presence(value)
|
|
@@ -222,12 +365,15 @@ module StillActive
|
|
|
222
365
|
# any `scheme://`, and ssh userinfo (`git@`) so what remains is host/owner/repo.
|
|
223
366
|
# Left unnormalized, a git-scheme URL mis-parses (host becomes `git:`), which
|
|
224
367
|
# 400s the projects lookup and drops the repo signals.
|
|
225
|
-
|
|
368
|
+
# A fragment or query (`...bar.git#main`, `...?tab=readme`) isn't part of
|
|
369
|
+
# the repository's path.
|
|
370
|
+
cleaned = url.strip.sub(/[#?].*\z/m, "").delete_prefix("git+").sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").sub(%r{\A[^/@]+@}, "")
|
|
226
371
|
# scp-style git remotes separate host from path with a colon (`host:owner/repo`)
|
|
227
372
|
# rather than a slash. Convert it so the split yields host/owner/repo, but leave
|
|
228
373
|
# a real port (`host:443/...`) alone -- a colon before a digit isn't a path.
|
|
229
374
|
cleaned = cleaned.sub(%r{\A([^/:]+):(?=\D)}, '\1/')
|
|
230
375
|
host, *segments = cleaned.split("/")
|
|
376
|
+
host = host&.downcase&.delete_prefix("www.")
|
|
231
377
|
segments = repo_path_segments(host, segments)
|
|
232
378
|
return if host.nil? || segments.empty?
|
|
233
379
|
|
|
@@ -265,9 +411,9 @@ module StillActive
|
|
|
265
411
|
# flagged default. Versions without a publishedAt are dropped; the rest
|
|
266
412
|
# compare lexicographically (deps.dev returns RFC3339 UTC, so string order
|
|
267
413
|
# is chronological), and a dated release always wins over an undated one.
|
|
268
|
-
def newest_version(versions)
|
|
414
|
+
def newest_version(versions, system: :rubygems)
|
|
269
415
|
versions
|
|
270
|
-
.select { |v| v.is_a?(Hash) && v["publishedAt"] && !v
|
|
416
|
+
.select { |v| v.is_a?(Hash) && v["publishedAt"] && !superseded?(v, system) }
|
|
271
417
|
.max_by { |v| v["publishedAt"].to_s }
|
|
272
418
|
end
|
|
273
419
|
|
data/lib/still_active/diff.rb
CHANGED
|
@@ -24,6 +24,8 @@ module StillActive
|
|
|
24
24
|
SUPPRESSIBLE_REGRESSION_SIGNALS = {
|
|
25
25
|
new_gem_archived: :activity,
|
|
26
26
|
archived: :activity,
|
|
27
|
+
new_gem_repository_check_failed: :activity,
|
|
28
|
+
repository_unchecked: :activity,
|
|
27
29
|
new_gem_stale: :libyear,
|
|
28
30
|
libyear_worsened: :libyear
|
|
29
31
|
}.freeze
|
|
@@ -160,12 +162,16 @@ module StillActive
|
|
|
160
162
|
end
|
|
161
163
|
|
|
162
164
|
# Categorises a version bump:
|
|
165
|
+
# - :advisories_unchecked - no source answered for the new version's advisories
|
|
163
166
|
# - :introduced_vulns - new advisories appeared on the resolved version
|
|
164
167
|
# - :closed_vulns - all advisories cleared
|
|
165
168
|
# - :older_relative - libyear-to-latest grew (rare; usually unchanged)
|
|
166
169
|
# - :fresher - libyear-to-latest shrank
|
|
167
170
|
# - :neutral - no obvious signal change
|
|
168
171
|
def classify_bump(before, after)
|
|
172
|
+
# No source answered for the new version, so its zero is not a fix.
|
|
173
|
+
return :advisories_unchecked if unchecked?(after)
|
|
174
|
+
|
|
169
175
|
opened = vuln_count(after) - vuln_count(before)
|
|
170
176
|
return :introduced_vulns if opened.positive?
|
|
171
177
|
return :closed_vulns if opened.negative?
|
|
@@ -219,6 +225,16 @@ module StillActive
|
|
|
219
225
|
changes << {kind: :version_yanked}
|
|
220
226
|
end
|
|
221
227
|
|
|
228
|
+
if !unchecked?(before) && unchecked?(after) && before["version_used"] == after["version_used"]
|
|
229
|
+
changes << {kind: :advisories_unchecked}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# An archived repo would be a regression; one no longer checked might be one
|
|
233
|
+
# (a check that failed, or a repository that has gone private or away).
|
|
234
|
+
if repository_answered?(before) && !repository_answered?(after)
|
|
235
|
+
changes << {kind: :repository_unchecked, check: after["repository_check"]}
|
|
236
|
+
end
|
|
237
|
+
|
|
222
238
|
changes
|
|
223
239
|
end
|
|
224
240
|
|
|
@@ -229,6 +245,10 @@ module StillActive
|
|
|
229
245
|
data = a.data
|
|
230
246
|
if vuln_count(data).positive?
|
|
231
247
|
regs << Regression.new(kind: :new_gem_with_vulns, gem: a.name, detail: "#{vuln_count(data)} vulns at introduction")
|
|
248
|
+
elsif unchecked?(data)
|
|
249
|
+
regs << Regression.new(kind: :new_gem_unchecked, gem: a.name, detail: "added gem's advisories could not be checked")
|
|
250
|
+
elsif data["repository_check"] == "failed"
|
|
251
|
+
regs << Regression.new(kind: :new_gem_repository_check_failed, gem: a.name, detail: "added gem's repository check failed, so it may be archived")
|
|
232
252
|
elsif data["archived"]
|
|
233
253
|
regs << Regression.new(kind: :new_gem_archived, gem: a.name, detail: "added gem points at archived repo")
|
|
234
254
|
elsif data["libyear"] && data["libyear"] > NEW_GEM_LIBYEAR_THRESHOLD
|
|
@@ -243,6 +263,8 @@ module StillActive
|
|
|
243
263
|
gem: b.name,
|
|
244
264
|
detail: "#{b.before_version} -> #{b.after_version}"
|
|
245
265
|
)
|
|
266
|
+
elsif b.kind == :advisories_unchecked
|
|
267
|
+
regs << Regression.new(kind: :bump_unchecked, gem: b.name, detail: "#{b.before_version} -> #{b.after_version}; advisories could not be checked")
|
|
246
268
|
end
|
|
247
269
|
end
|
|
248
270
|
|
|
@@ -259,6 +281,10 @@ module StillActive
|
|
|
259
281
|
regs << Regression.new(kind: :scorecard_dropped, gem: sc.name, detail: "#{ch[:from]} -> #{ch[:to]}#{note}")
|
|
260
282
|
when :version_yanked
|
|
261
283
|
regs << Regression.new(kind: :version_yanked, gem: sc.name, detail: "pinned version yanked from rubygems")
|
|
284
|
+
when :advisories_unchecked
|
|
285
|
+
regs << Regression.new(kind: :advisories_unchecked, gem: sc.name, detail: "advisories could not be checked")
|
|
286
|
+
when :repository_unchecked
|
|
287
|
+
regs << Regression.new(kind: :repository_unchecked, gem: sc.name, detail: "repository no longer checked (#{ch[:check]}), so it may be archived")
|
|
262
288
|
when :libyear_worsened
|
|
263
289
|
regs << Regression.new(
|
|
264
290
|
kind: :libyear_worsened,
|
|
@@ -291,6 +317,18 @@ module StillActive
|
|
|
291
317
|
}
|
|
292
318
|
end
|
|
293
319
|
|
|
320
|
+
# A baseline from before repository_check existed (3.1.0) was answered only
|
|
321
|
+
# where its archived state was known.
|
|
322
|
+
def repository_answered?(data)
|
|
323
|
+
return data["repository_check"] == "answered" if data.key?("repository_check")
|
|
324
|
+
|
|
325
|
+
[true, false].include?(data["archived"])
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def unchecked?(data)
|
|
329
|
+
data["vulnerabilities_checked"] == false
|
|
330
|
+
end
|
|
331
|
+
|
|
294
332
|
def vuln_count(data)
|
|
295
333
|
data["vulnerability_count"].to_i
|
|
296
334
|
end
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "repository_signals"
|
|
3
5
|
require_relative "deps_dev_client"
|
|
4
6
|
require_relative "osv_client"
|
|
5
7
|
require_relative "ecosystems_client"
|
|
6
8
|
require_relative "github_client"
|
|
9
|
+
require_relative "go_toolchain"
|
|
7
10
|
require_relative "pypi_client"
|
|
8
11
|
require "time"
|
|
9
12
|
require_relative "helpers/activity_helper"
|
|
@@ -55,37 +58,24 @@ module StillActive
|
|
|
55
58
|
FLAT_RESOLUTION_ECOSYSTEMS = [:rubygems, :pypi].freeze
|
|
56
59
|
|
|
57
60
|
def assess(ecosystem:, name:, version:, constraint_cache: {}, runtime_ranges: {})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# vanishes and a still-fresh package date would read a false :ok with
|
|
68
|
-
# archived/scorecard silently dropped. The native Bundler path resolves the
|
|
69
|
-
# repo independently of deps.dev's per-version record; this gives the lens
|
|
70
|
-
# the same resilience.
|
|
71
|
-
# The pinned version isn't indexed by deps.dev (info nil) while the package IS
|
|
72
|
-
# (default present, so the feed is up): the version is yanked/nonexistent, not a
|
|
73
|
-
# transient miss. Flag it so status reads :unknown rather than letting the still-
|
|
74
|
-
# fresh PACKAGE date report a nonexistent version as :ok.
|
|
75
|
-
version_unresolved = info.nil? && !default.nil?
|
|
76
|
-
project_id = info&.dig(:project_id) || project_id_from(name, ecosystem, default)
|
|
77
|
-
vulnerabilities = vulnerabilities_for(info)
|
|
78
|
-
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
79
|
-
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
80
|
-
# Passing the version also lets OSV confirm the advisory actually applies to it,
|
|
81
|
-
# correcting deps.dev's lag on an advisory amended with backport fixes.
|
|
82
|
-
vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: ecosystem, name: name, version: version)
|
|
61
|
+
signals =
|
|
62
|
+
if GoToolchain.toolchain?(ecosystem, name)
|
|
63
|
+
version = GoToolchain.release(version)
|
|
64
|
+
GoToolchain.signals(version: version)
|
|
65
|
+
else
|
|
66
|
+
registry_signals(ecosystem: ecosystem, name: name, version: version)
|
|
67
|
+
end
|
|
68
|
+
info, default, vulnerabilities, vulnerabilities_checked, project_id, version_unresolved =
|
|
69
|
+
signals.values_at(:info, :default, :vulnerabilities, :vulnerabilities_checked, :project_id, :version_unresolved)
|
|
83
70
|
scorecard = DepsDevClient.project_scorecard(project_id: project_id)
|
|
84
|
-
|
|
71
|
+
# The Go toolchain is the language: there's no package repository to check,
|
|
72
|
+
# and its lifecycle comes from endoflife.date instead.
|
|
73
|
+
repo = GoToolchain.toolchain?(ecosystem, name) ? {check: "not_applicable"} : repo_signals(project_id)
|
|
85
74
|
|
|
86
75
|
used_release_date = info&.dig(:published_at)
|
|
87
76
|
latest_release_date = default&.dig(:published_at)
|
|
88
77
|
latest_version = default&.dig(:version)
|
|
78
|
+
deprecation = deprecation(ecosystem, name, version, info, default)
|
|
89
79
|
gem_data = {
|
|
90
80
|
ecosystem: ecosystem,
|
|
91
81
|
name: name,
|
|
@@ -116,17 +106,24 @@ module StillActive
|
|
|
116
106
|
# it. A declared fact rather than a date heuristic, and the one signal a
|
|
117
107
|
# release-recency check can never produce: a package deprecated last month
|
|
118
108
|
# with a release last week looks perfectly healthy by dates alone.
|
|
119
|
-
deprecated:
|
|
120
|
-
deprecation_reason:
|
|
109
|
+
deprecated: deprecation[:deprecated],
|
|
110
|
+
deprecation_reason: deprecation[:deprecation_reason],
|
|
121
111
|
repository_url: project_id && "https://#{project_id}",
|
|
122
112
|
last_commit_date: repo[:last_commit_date],
|
|
123
113
|
archived: repo[:archived],
|
|
124
114
|
scorecard_score: scorecard&.dig(:score),
|
|
125
115
|
scorecard_maintained: scorecard&.dig(:maintained),
|
|
126
116
|
vulnerability_count: vulnerabilities.length,
|
|
117
|
+
# false when a source couldn't answer, so the zero above is "not looked
|
|
118
|
+
# at", never "clean". The status and --fail-if-vulnerable both read it.
|
|
119
|
+
vulnerabilities_checked: vulnerabilities_checked,
|
|
127
120
|
vulnerabilities: vulnerabilities
|
|
128
121
|
}
|
|
129
122
|
gem_data[:version_unresolved] = true if version_unresolved
|
|
123
|
+
# Whether a service said if the repository is archived; a blank archived
|
|
124
|
+
# flag isn't "not archived" unless this is "answered".
|
|
125
|
+
gem_data[:repository_check] = repo[:check]
|
|
126
|
+
gem_data[:repository_source] = repo[:source] if repo[:source]
|
|
130
127
|
attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
|
|
131
128
|
attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), runtime_ranges: runtime_ranges)
|
|
132
129
|
gem_data
|
|
@@ -134,6 +131,55 @@ module StillActive
|
|
|
134
131
|
|
|
135
132
|
private
|
|
136
133
|
|
|
134
|
+
# A package's signals from deps.dev, with OSV enrichment: { info:, default:,
|
|
135
|
+
# vulnerabilities:, vulnerabilities_checked:, project_id:, version_unresolved: }.
|
|
136
|
+
def registry_signals(ecosystem:, name:, version:)
|
|
137
|
+
# The version record carries the advisories, so deps.dev failing to answer it
|
|
138
|
+
# (after version_info's own retry) leaves them unchecked, not clean. A 404 is
|
|
139
|
+
# an answer: nothing public lists this version.
|
|
140
|
+
checked = true
|
|
141
|
+
info = begin
|
|
142
|
+
DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
|
|
143
|
+
rescue HttpHelper::Unavailable
|
|
144
|
+
checked = false
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
default = DepsDevClient.default_version_info(name: name, system: ecosystem)
|
|
148
|
+
# Recover the repo from the default version when the exact locked version
|
|
149
|
+
# isn't indexed (yanked/normalization mismatch): otherwise its project link
|
|
150
|
+
# vanishes and a still-fresh package date would read a false :ok with
|
|
151
|
+
# archived/scorecard silently dropped. The native Bundler path resolves the
|
|
152
|
+
# repo independently of deps.dev's per-version record; this gives the lens
|
|
153
|
+
# the same resilience.
|
|
154
|
+
# The pinned version isn't indexed by deps.dev (info nil) while the package IS
|
|
155
|
+
# (default present, so the feed is up): the version is yanked/nonexistent, not a
|
|
156
|
+
# transient miss. Flag it so status reads :unknown rather than letting the still-
|
|
157
|
+
# fresh PACKAGE date report a nonexistent version as :ok.
|
|
158
|
+
version_unresolved = checked && info.nil? && !default.nil?
|
|
159
|
+
project_id = info&.dig(:project_id) || project_id_from(name, ecosystem, default)
|
|
160
|
+
vulnerabilities = vulnerabilities_for(info)
|
|
161
|
+
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
162
|
+
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
163
|
+
# Passing the version also lets OSV confirm the advisory actually applies to it,
|
|
164
|
+
# correcting deps.dev's lag on an advisory amended with backport fixes.
|
|
165
|
+
vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: ecosystem, name: name, version: version)
|
|
166
|
+
{info: info, default: default, vulnerabilities: vulnerabilities, vulnerabilities_checked: checked, project_id: project_id, version_unresolved: version_unresolved}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The maintainer's deprecation, from the pinned version's record. Go deprecates
|
|
170
|
+
# a module: `// Deprecated:` in the latest go.mod covers every version (it's
|
|
171
|
+
# what `go list -m -u` reports), but deps.dev flags only the versions whose
|
|
172
|
+
# go.mod carries it, so a module deprecated at its latest is deprecated
|
|
173
|
+
# whichever version is pinned. A retracted latest isn't: that's one release.
|
|
174
|
+
# npm deprecates versions, so it stays per version.
|
|
175
|
+
def deprecation(ecosystem, name, version, info, default)
|
|
176
|
+
if info&.dig(:deprecated) != true && ecosystem == :go && DepsDevClient.module_deprecation?(default&.dig(:deprecation_reason))
|
|
177
|
+
return {deprecated: true, deprecation_reason: default[:deprecation_reason]}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
{deprecated: info&.dig(:deprecated) == true, deprecation_reason: info&.dig(:deprecation_reason)}
|
|
181
|
+
end
|
|
182
|
+
|
|
137
183
|
# Language-runtime ceiling for the cross-ecosystem path, the sibling of the
|
|
138
184
|
# native Ruby ceiling in Workflow. Python declares its runtime constraint as a
|
|
139
185
|
# PEP 440 `requires_python`, an enforced pip install wall; translate it and run
|
|
@@ -290,25 +336,15 @@ module StillActive
|
|
|
290
336
|
return if version.nil?
|
|
291
337
|
|
|
292
338
|
DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)&.dig(:project_id)
|
|
339
|
+
rescue HttpHelper::Unavailable
|
|
340
|
+
nil
|
|
293
341
|
end
|
|
294
342
|
|
|
295
|
-
# archived + last-
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
# so a gitlab (or nested, or unresolved) project keeps archived unknown rather
|
|
299
|
-
# than risk a bogus owner/name lookup.
|
|
343
|
+
# archived + last-activity date, which service answered, and what came of the
|
|
344
|
+
# check, for the project deps.dev linked. Every dependency here came through
|
|
345
|
+
# deps.dev, so from a public registry.
|
|
300
346
|
def repo_signals(project_id)
|
|
301
|
-
|
|
302
|
-
return {} unless host == "github.com" && owner && name && rest.empty?
|
|
303
|
-
|
|
304
|
-
repo_provider.repo_signals(owner: owner, name: name) || {}
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# Mirrors Workflow#provider_for(:github): the live GitHub API when a token is
|
|
308
|
-
# configured (freshest), else ecosyste.ms (5000 anonymous req/hr vs GitHub's
|
|
309
|
-
# 60), so an untokened cross-ecosystem run still resolves a large SBOM.
|
|
310
|
-
def repo_provider
|
|
311
|
-
StillActive.config.github_oauth_token ? GithubClient : EcosystemsClient
|
|
347
|
+
RepositorySignals.for_project(project_id, public: true)
|
|
312
348
|
end
|
|
313
349
|
|
|
314
350
|
# Is the pinned version at or ahead of latest stable? nil when latest is unknown.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "errors"
|
|
4
5
|
require_relative "helpers/http_helper"
|
|
5
6
|
require_relative "version"
|
|
6
7
|
|
|
@@ -30,23 +31,25 @@ module StillActive
|
|
|
30
31
|
|
|
31
32
|
# archived + last-commit date from a single repository call. ecosyste.ms's
|
|
32
33
|
# pushed_at mirrors GitHub's, so this returns the same shape as GithubClient.
|
|
33
|
-
# Returns {} when
|
|
34
|
+
# Returns {} when ecosyste.ms has no record of the repo (a 404), and raises
|
|
35
|
+
# RepoSignalsUnavailable when it couldn't answer, including a 200 whose body
|
|
36
|
+
# isn't a repository record (an error envelope, schema drift).
|
|
34
37
|
def repo_signals(owner:, name:)
|
|
35
38
|
return {} if owner.nil? || name.nil?
|
|
36
39
|
|
|
37
40
|
path = "/api/v1/hosts/GitHub/repositories/#{encode_repo(owner, name)}"
|
|
38
|
-
body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# the workflow's rescue; degrade to "no signal" like any other read failure.
|
|
42
|
-
return {} unless body.is_a?(Hash)
|
|
41
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params, strict: true)
|
|
42
|
+
return {} if body.nil?
|
|
43
|
+
raise RepoSignalsUnavailable, "ecosyste.ms returned a #{body.class} for #{owner}/#{name}" unless body.is_a?(Hash)
|
|
43
44
|
|
|
44
45
|
signals = {last_commit_date: parse_time(body["pushed_at"], owner, name)}
|
|
45
46
|
# Only assert archived when the field is actually present. A missing field
|
|
46
47
|
# must read as unknown, not be invented as false -- otherwise a partial
|
|
47
48
|
# crawl could silently mask the most actionable verdict (gem is archived).
|
|
48
|
-
signals[:archived] = body["archived"]
|
|
49
|
+
signals[:archived] = body["archived"] if [true, false].include?(body["archived"])
|
|
49
50
|
signals
|
|
51
|
+
rescue HttpHelper::Unavailable => e
|
|
52
|
+
raise RepoSignalsUnavailable, e.message
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
# ecosyste.ms dependency-kind labels that ship at RUNTIME (so a cap on one
|
data/lib/still_active/errors.rb
CHANGED
|
@@ -3,4 +3,14 @@
|
|
|
3
3
|
module StillActive
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
class MissingLockfileError < Error; end
|
|
6
|
+
|
|
7
|
+
# A repository provider couldn't answer (a rate limit, a timeout, an error
|
|
8
|
+
# status, a garbled body), as opposed to answering that the repo isn't there.
|
|
9
|
+
# The caller marks the repository unavailable rather than reading its blank
|
|
10
|
+
# archived flag as "not archived".
|
|
11
|
+
class RepoSignalsUnavailable < Error; end
|
|
12
|
+
|
|
13
|
+
# The forge refused the credentials (401/403): the repository may be private,
|
|
14
|
+
# so its name must not then go to a third-party mirror.
|
|
15
|
+
class RepoAccessDenied < RepoSignalsUnavailable; end
|
|
6
16
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "errors"
|
|
4
5
|
require_relative "helpers/http_helper"
|
|
5
6
|
|
|
6
7
|
module StillActive
|
|
@@ -21,10 +22,13 @@ module StillActive
|
|
|
21
22
|
def repo_signals(owner:, name:, host: DEFAULT_HOST)
|
|
22
23
|
return {} if owner.nil? || name.nil?
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
# {} for a 404 (an answer); RepoSignalsUnavailable when Forgejo couldn't answer.
|
|
26
|
+
body = HttpHelper.get_json(base_uri(host), "/api/v1/repos/#{owner}/#{name}", headers: auth_headers, strict: true)
|
|
25
27
|
return {} if body.nil?
|
|
26
28
|
|
|
27
29
|
{archived: body["archived"] == true, last_commit_date: parse_time(body["updated_at"], owner, name)}
|
|
30
|
+
rescue HttpHelper::Unavailable => e
|
|
31
|
+
raise RepoSignalsUnavailable, e.message
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
private
|