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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +3 -1
  4. data/lib/still_active/artifactory_client.rb +2 -20
  5. data/lib/still_active/assessment.rb +85 -0
  6. data/lib/still_active/cli.rb +84 -23
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/deps_dev_client.rb +161 -15
  9. data/lib/still_active/diff.rb +38 -0
  10. data/lib/still_active/ecosystem_lens.rb +80 -44
  11. data/lib/still_active/ecosystems_client.rb +10 -7
  12. data/lib/still_active/errors.rb +10 -0
  13. data/lib/still_active/forgejo_client.rb +5 -1
  14. data/lib/still_active/github_client.rb +28 -5
  15. data/lib/still_active/gitlab_client.rb +5 -1
  16. data/lib/still_active/go_toolchain.rb +75 -0
  17. data/lib/still_active/helpers/alternatives_helper.rb +2 -2
  18. data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
  19. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  20. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  21. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  22. data/lib/still_active/helpers/http_cache.rb +109 -0
  23. data/lib/still_active/helpers/http_helper.rb +148 -28
  24. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  25. data/lib/still_active/helpers/repository_check.rb +33 -0
  26. data/lib/still_active/helpers/status_helper.rb +11 -0
  27. data/lib/still_active/helpers/summary_helper.rb +6 -1
  28. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  29. data/lib/still_active/options.rb +7 -0
  30. data/lib/still_active/osv_client.rb +39 -2
  31. data/lib/still_active/repository_signals.rb +113 -0
  32. data/lib/still_active/rubygems_client.rb +34 -0
  33. data/lib/still_active/sarif/rules.rb +1 -1
  34. data/lib/still_active/sbom_workflow.rb +16 -2
  35. data/lib/still_active/source_health.rb +88 -0
  36. data/lib/still_active/version.rb +1 -1
  37. data/lib/still_active/workflow.rb +63 -52
  38. data/lib/still_active.rb +1 -0
  39. data/still_active.gemspec +0 -1
  40. 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. An unknown system
31
- # 404s and degrades to nil (HttpHelper swallows 404), never raising.
32
- def version_info(gem_name:, version:, system: :rubygems)
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 = "/v3alpha/systems/#{encode(system)}/packages/#{encode(gem_name)}/versions/#{encode(version)}"
36
- body = HttpHelper.get_json(BASE_URI, path)
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
- {version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"]}
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["isDeprecated"]
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 if project_id.nil?
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
- cleaned = url.strip.delete_prefix("git+").sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").sub(%r{\A[^/@]+@}, "")
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["isDeprecated"] }
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
 
@@ -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
- info = DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
59
- default = DepsDevClient.default_version_info(name: name, system: ecosystem)
60
- # Retry the version lookup once when it came back empty but the package DID
61
- # resolve: HttpHelper collapses a genuine 404 and a transient network blip to
62
- # the same nil, and a healthy version must not be mis-flagged as unresolved
63
- # (below) on a one-off miss. A real 404 stays nil; a blip recovers the data.
64
- info ||= DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem) if default
65
- # Recover the repo from the default version when the exact locked version
66
- # isn't indexed (yanked/normalization mismatch): otherwise its project link
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
- repo = repo_signals(project_id)
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: info&.dig(:deprecated) == true,
120
- deprecation_reason: info&.dig(: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-commit for a flat github.com/owner/repo project, or {} for
296
- # anything else. deps.dev indexes github.com and gitlab.com only, but gitlab
297
- # subgroups nest arbitrarily and ecosyste.ms's repo crawler is GitHub-centric,
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
- host, owner, name, *rest = project_id.to_s.split("/")
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 the repo can't be read, so the caller leaves both blank.
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
- # A non-Hash 200 body (error envelope rendered as an array, schema drift)
40
- # would otherwise raise on indexing and vanish the gem from the audit via
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"] == true if body.key?("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
@@ -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
- body = HttpHelper.get_json(base_uri(host), "/api/v1/repos/#{owner}/#{name}", headers: auth_headers)
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