still_active 3.0.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -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/cvss_helper.rb +14 -48
  19. data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
  20. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  21. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  22. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  23. data/lib/still_active/helpers/http_cache.rb +109 -0
  24. data/lib/still_active/helpers/http_helper.rb +148 -28
  25. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  26. data/lib/still_active/helpers/repository_check.rb +33 -0
  27. data/lib/still_active/helpers/status_helper.rb +11 -0
  28. data/lib/still_active/helpers/summary_helper.rb +6 -1
  29. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  30. data/lib/still_active/helpers/vulnerability_helper.rb +4 -5
  31. data/lib/still_active/options.rb +7 -0
  32. data/lib/still_active/osv_client.rb +39 -2
  33. data/lib/still_active/repository_signals.rb +113 -0
  34. data/lib/still_active/rubygems_client.rb +34 -0
  35. data/lib/still_active/sarif/rules.rb +1 -1
  36. data/lib/still_active/sbom_workflow.rb +16 -2
  37. data/lib/still_active/source_health.rb +88 -0
  38. data/lib/still_active/version.rb +1 -1
  39. data/lib/still_active/workflow.rb +63 -52
  40. data/lib/still_active.rb +1 -0
  41. data/still_active.gemspec +6 -7
  42. metadata +13 -6
@@ -3,10 +3,11 @@
3
3
  require "net/http"
4
4
  require "openssl"
5
5
  require "json"
6
+ require_relative "http_cache"
6
7
 
7
8
  module StillActive
8
9
  module HttpHelper
9
- TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
10
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.org", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
10
11
  # Transport-level failures ("host unreachable / connection broke", not an HTTP
11
12
  # error status). Every network entry point degrades to a safe empty result on
12
13
  # these rather than letting one escape and vanish a gem from the audit.
@@ -22,6 +23,11 @@ module StillActive
22
23
  EOFError
23
24
  ].freeze
24
25
  MAX_REDIRECTS = 3
26
+ # A 429 or 503 that says when to come back gets one retry, if that's soon.
27
+ MAX_RETRY_AFTER_SECONDS = 10
28
+ # Idle connections kept per host. The fan-out runs 10 at a time, so more than
29
+ # that would only ever sit idle.
30
+ MAX_IDLE_PER_HOST = 10
25
31
  # Ceiling on a single response body. These are metadata endpoints (version
26
32
  # lists, scorecards, advisories); legitimate responses are well under this.
27
33
  # A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-
@@ -30,16 +36,33 @@ module StillActive
30
36
  # gem with thousands of versions while bounding worst-case memory.
31
37
  MAX_BODY_BYTES = 16 * 1024 * 1024
32
38
  JSON_PARSER = ->(body) { JSON.parse(body) }
39
+ # Raised in strict mode when the source couldn't answer: a transport error, a
40
+ # non-404 error status, a refused redirect, an oversized or garbled body. A 404
41
+ # is an answer ("no such record") and still returns nil. A caller that must not
42
+ # read a failure as "nothing there", like an advisory lookup, asks for strict.
43
+ Unavailable = Class.new(StandardError)
33
44
  IDENTITY = ->(body) { body }
34
45
 
35
46
  extend self
36
47
 
37
- def get_json(base_uri, path, headers: {}, params: {})
48
+ # Close and forget every pooled connection. Specs call it between examples,
49
+ # since a pooled connection holds WebMock's fake socket.
50
+ def reset_connections!
51
+ pool.each_value { |idle| idle.each { close(_1) } }
52
+ @pool = nil
53
+ end
54
+
55
+ # `cache: false` skips the disk cache (a canary has to see the source as it is
56
+ # now); `cache_if` decides whether an answer is sound enough to keep, so a
57
+ # garbled one isn't reread for its whole TTL.
58
+ def get_json(base_uri, path, headers: {}, params: {}, strict: false, cache: true, cache_if: nil)
38
59
  uri = base_uri.dup
39
60
  uri.path = path
40
61
  uri.query = URI.encode_www_form(params) unless params.empty?
41
62
 
42
- request_json(uri, headers) { |target| Net::HTTP::Get.new(target) }
63
+ cached(HttpCache.key("GET", uri), uri, headers, enabled: cache, cache_if: cache_if) do
64
+ request_json(uri, headers, strict: strict) { |target| Net::HTTP::Get.new(target) }
65
+ end
43
66
  end
44
67
 
45
68
  # As get_json, but for endpoints that answer in plain text (the RubyGems
@@ -52,11 +75,13 @@ module StillActive
52
75
  request_json(uri, headers, parse: IDENTITY) { |target| Net::HTTP::Get.new(target) }
53
76
  end
54
77
 
55
- def post_json(base_uri, path, body:, headers: {})
78
+ # Never cached: the POSTs this tool makes (OSV's version query, deps.dev's
79
+ # batch, Artifactory's AQL) each have to be fresh, or aren't public.
80
+ def post_json(base_uri, path, body:, headers: {}, strict: false)
56
81
  uri = base_uri.dup
57
82
  uri.path = path
58
83
 
59
- request_json(uri, headers) do |target|
84
+ request_json(uri, headers, strict: strict) do |target|
60
85
  request = Net::HTTP::Post.new(target)
61
86
  request.body = body
62
87
  request
@@ -65,6 +90,18 @@ module StillActive
65
90
 
66
91
  private
67
92
 
93
+ # A cached answer when there's a fresh one; otherwise asks, and caches a
94
+ # non-nil answer. nil is a 404 or a failure, neither of which is kept.
95
+ def cached(key, uri, headers, enabled:, cache_if: nil)
96
+ ttl = enabled && HttpCache.ttl(uri, headers)
97
+ return yield unless ttl
98
+
99
+ hit = HttpCache.read(key, ttl)
100
+ return hit unless hit == :miss
101
+
102
+ yield.tap { |body| HttpCache.write(key, body) if !body.nil? && (cache_if.nil? || cache_if.call(body)) }
103
+ end
104
+
68
105
  # Two URIs share an origin when scheme, host, and port all match. URI fills
69
106
  # in the default port (443 for https), so the bare and explicit forms of the
70
107
  # same origin compare equal.
@@ -76,40 +113,54 @@ module StillActive
76
113
  # and returns the parsed body (or nil), where `parse` decides JSON vs text.
77
114
  # The block is yielded each URI and returns the request object, so GET and
78
115
  # POST share the redirect/auth/cap logic.
79
- def request_json(uri, headers, parse: JSON_PARSER)
80
- MAX_REDIRECTS.times do
81
- http = Net::HTTP.new(uri.host, uri.port)
82
- http.use_ssl = true
83
- http.open_timeout = 10
84
- http.read_timeout = 10
85
-
116
+ def request_json(uri, headers, parse: JSON_PARSER, strict: false)
117
+ retried = false
118
+ # The Retry-After retry repeats a pass rather than spending one of these.
119
+ passes = 0
120
+ while (passes += 1) <= MAX_REDIRECTS
86
121
  request = yield(uri)
87
122
  headers.each { |key, value| request[key] = value }
88
123
 
89
- outcome, payload = perform(http, request, uri, parse)
124
+ outcome, payload = with_connection(uri) { |http| perform(http, request, uri, parse) }
90
125
  case outcome
126
+ when :retry_after
127
+ if retried
128
+ warn("warning: #{uri.host}#{uri.path} is still rate limited after one retry")
129
+ return unavailable(strict, uri)
130
+ end
131
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{payload[:code]}, retrying in #{payload[:seconds]}s as it asked")
132
+ sleep(payload[:seconds])
133
+ retried = true
134
+ passes -= 1
135
+ next
91
136
  when :done
92
- return payload
93
- when :stop
137
+ # A 404 is how a source says "no record"; a 200 of `null` says nothing.
138
+ return payload unless payload.nil? && strict
139
+
140
+ warn("warning: #{uri.host}#{uri.path} returned an empty (null) body")
141
+ return unavailable(strict, uri)
142
+ when :not_found
94
143
  return
144
+ when :stop
145
+ return unavailable(strict, uri)
95
146
  when :redirect
96
147
  location = payload["Location"]
97
148
  if location.nil? || location.empty?
98
149
  warn("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
99
- return
150
+ return unavailable(strict, uri)
100
151
  end
101
152
 
102
153
  redirect_uri = uri + location
103
154
  unless TRUSTED_HOSTS.include?(redirect_uri.host)
104
155
  warn("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
105
- return
156
+ return unavailable(strict, uri)
106
157
  end
107
158
  # We dial every request over TLS (use_ssl = true). A redirect that
108
159
  # downgrades to http is either a misconfiguration or a downgrade
109
160
  # attempt; refuse it rather than silently dialing http-over-TLS.
110
161
  unless redirect_uri.scheme == "https"
111
162
  warn("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
112
- return
163
+ return unavailable(strict, uri)
113
164
  end
114
165
  warn("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
115
166
  # Auth is scoped to an origin (scheme + host + port), not just a host:
@@ -120,42 +171,111 @@ module StillActive
120
171
  end
121
172
 
122
173
  warn("warning: #{uri.host}#{uri.path} too many redirects")
123
- nil
174
+ unavailable(strict, uri)
124
175
  rescue *TRANSPORT_ERRORS => e
125
176
  warn("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
126
- nil
177
+ unavailable(strict, uri)
127
178
  rescue JSON::ParserError => e
128
179
  warn("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
129
- nil
180
+ unavailable(strict, uri)
130
181
  rescue URI::InvalidURIError => e
131
182
  warn("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
132
- nil
183
+ unavailable(strict, uri)
184
+ end
185
+
186
+ # Every failure has already warned; strict callers also get told.
187
+ def unavailable(strict, uri)
188
+ raise Unavailable, "#{uri.host}#{uri.path}" if strict
133
189
  end
134
190
 
135
191
  # Issues the request in streaming form so the body is read against a size
136
192
  # cap rather than buffered whole. Returns one of:
137
193
  # [:redirect, response] a 3xx, for the caller to follow
138
- # [:stop, nil] non-success (warns unless 404), or body over cap
194
+ # [:not_found, nil] a 404, which is an answer, not a failure
195
+ # [:stop, nil] any other non-success (warns), or body over cap
139
196
  # [:done, parsed] a 2xx with the parsed body (JSON or text)
140
- # Redirect and non-success bodies are never read: returning from the block
141
- # unwinds through Net::HTTP, which closes the connection without draining
142
- # the body, so a huge error/redirect body can't OOM us either.
197
+ # Redirect and error bodies are never read: returning from the block unwinds
198
+ # through Net::HTTP without draining the body, so a huge one can't OOM us, and
199
+ # with_connection closes that connection. A success or a 404 is read to the end
200
+ # and the block finishes normally, so Net::HTTP does its end-of-request
201
+ # bookkeeping: it honours Connection: close and records the time for its idle
202
+ # check, which is what makes the connection safe to pool. Returning from inside
203
+ # skipped both, and a POST on a connection the server had closed then failed.
143
204
  def perform(http, request, uri, parse)
205
+ body = nil
144
206
  http.request(request) do |response|
145
207
  return [:redirect, response] if response.is_a?(Net::HTTPRedirection)
146
208
 
209
+ if response.is_a?(Net::HTTPNotFound)
210
+ return [:stop, nil] if read_capped_body(response, uri).nil?
211
+
212
+ next
213
+ end
214
+
215
+ if (seconds = retry_after(response))
216
+ return [:retry_after, {code: response.code, seconds: seconds}]
217
+ end
218
+
147
219
  unless response.is_a?(Net::HTTPSuccess)
148
- warn("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
220
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}")
149
221
  return [:stop, nil]
150
222
  end
151
223
 
152
224
  body = read_capped_body(response, uri)
153
225
  return [:stop, nil] if body.nil?
226
+ end
227
+ body.nil? ? [:not_found, nil] : [:done, parse.call(body)]
228
+ end
229
+
230
+ # The seconds a 429 or 503 asks us to wait, when it gives a number of them and
231
+ # the wait is short enough to be worth it; nil otherwise. The HTTP-date form is
232
+ # read as "not soon", since the sources we call send seconds.
233
+ def retry_after(response)
234
+ return unless response.code == "429" || response.code == "503"
235
+
236
+ value = response["Retry-After"].to_s.strip
237
+ return unless value.match?(/\A\d+\z/)
238
+
239
+ value.to_i if value.to_i <= MAX_RETRY_AFTER_SECONDS
240
+ end
154
241
 
155
- return [:done, parse.call(body)]
242
+ # One started connection per host, reused across requests. Only a success or a
243
+ # 404, read to the end with Net::HTTP's request finished normally, goes back
244
+ # (see perform); anything else may have unread bytes and is closed. On reuse,
245
+ # Net::HTTP reconnects a connection the server closed or that sat idle past
246
+ # its keep-alive timeout.
247
+ def with_connection(uri)
248
+ http = pool[[uri.host, uri.port]].pop || open_connection(uri)
249
+ outcome = nil
250
+ begin
251
+ outcome = yield(http)
252
+ ensure
253
+ if [:done, :not_found].include?(outcome&.first) && pool[[uri.host, uri.port]].size < MAX_IDLE_PER_HOST
254
+ pool[[uri.host, uri.port]] << http
255
+ else
256
+ close(http)
257
+ end
156
258
  end
157
259
  end
158
260
 
261
+ def open_connection(uri)
262
+ http = Net::HTTP.new(uri.host, uri.port)
263
+ http.use_ssl = true
264
+ http.open_timeout = 10
265
+ http.read_timeout = 10
266
+ http.start
267
+ end
268
+
269
+ def close(http)
270
+ http.finish if http.started?
271
+ rescue IOError
272
+ nil
273
+ end
274
+
275
+ def pool
276
+ @pool ||= Hash.new { |hash, key| hash[key] = [] }
277
+ end
278
+
159
279
  # Reads the body in chunks, abandoning the read (returns nil) as soon as it
160
280
  # exceeds MAX_BODY_BYTES so an oversized body is never fully materialized.
161
281
  def read_capped_body(response, uri)
@@ -294,7 +294,7 @@ module StillActive
294
294
 
295
295
  def format_vulns(data)
296
296
  count = data[:vulnerability_count]
297
- return StillActive.config.unsure_emoji if count.nil?
297
+ return StillActive.config.unsure_emoji if count.nil? || (count.zero? && data[:vulnerabilities_checked] == false)
298
298
  return StillActive.config.success_emoji if count.zero?
299
299
 
300
300
  vulnerabilities = data[:vulnerabilities] || []
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../assessment"
4
+
5
+ module StillActive
6
+ # Reading a dependency's repository_check the same way everywhere: "answered",
7
+ # "failed" or "unknowable" (see RepositorySignals), or "not_applicable" for the
8
+ # Go toolchain, which is the language, not a package with a repository. A hash
9
+ # without the key never had its repository looked at, which is not an answer.
10
+ module RepositoryCheck
11
+ extend self
12
+
13
+ VALUES = Assessment::REPOSITORY_CHECKS
14
+ SETTLED = ["answered", "not_applicable"].freeze
15
+
16
+ def unanswered?(data) = !SETTLED.include?(data[:repository_check])
17
+
18
+ # A missing or unrecognised check means the repository step never ran (an
19
+ # assessment that raised part-way), so it counts as failed, as in tally.
20
+ def failed?(data) = !VALUES.include?(data[:repository_check]) || data[:repository_check] == "failed"
21
+
22
+ # {answered:, failed:, unknowable:} across dependencies.
23
+ def tally(dependencies)
24
+ counts = VALUES.to_h { [_1.to_sym, 0] }
25
+ dependencies.each { counts[(data_check(_1) || "failed").to_sym] += 1 }
26
+ counts
27
+ end
28
+
29
+ private
30
+
31
+ def data_check(data) = VALUES.include?(data[:repository_check]) ? data[:repository_check] : nil
32
+ end
33
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "activity_helper"
4
+ require_relative "repository_check"
4
5
 
5
6
  module StillActive
6
7
  # Collapses a gem's several maintenance signals into one categorical verdict,
@@ -28,6 +29,16 @@ module StillActive
28
29
  # Returns :dead, :vulnerable, :deprecated, :archived, :legacy, :stale, :ok,
29
30
  # or :unknown.
30
31
  def gem_status(gem_data)
32
+ status = lifecycle_status(gem_data)
33
+ # Advisories a source couldn't answer for are not "none", and a repository
34
+ # no source could read isn't "not archived", so a verdict that claims clean
35
+ # (:ok, and :legacy's "dormant but clean") reads :unknown. Verdicts that
36
+ # found a problem stand, since it's true either way.
37
+ unanswered = gem_data[:vulnerabilities_checked] == false || RepositoryCheck.unanswered?(gem_data)
38
+ ([:ok, :legacy].include?(status) && unanswered) ? :unknown : status
39
+ end
40
+
41
+ def lifecycle_status(gem_data)
31
42
  vulnerable = gem_data[:vulnerability_count].to_i.positive?
32
43
 
33
44
  # A pinned version the registry can't resolve (yanked, typo, or nonexistent)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "activity_helper"
4
4
  require_relative "status_helper"
5
+ require_relative "repository_check"
5
6
 
6
7
  module StillActive
7
8
  # Builds the JSON output's summary{} digest: the headline posture of the audit
@@ -16,7 +17,8 @@ module StillActive
16
17
 
17
18
  def summarize(result, ruby_info: nil)
18
19
  activity = ACTIVITY_LEVELS.to_h { |level| [level, 0] }
19
- archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = direct = 0
20
+ archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = unchecked = direct = 0
21
+ repository_checks = RepositoryCheck.tally(result.values)
20
22
 
21
23
  result.each_value do |data|
22
24
  activity[ActivityHelper.activity_level(data)] += 1
@@ -24,6 +26,7 @@ module StillActive
24
26
  archived += 1 if data[:archived]
25
27
  up_to_date += 1 if data[:up_to_date] == true
26
28
  outdated += 1 if data[:up_to_date] == false
29
+ unchecked += 1 if data[:vulnerabilities_checked] == false
27
30
  count = data[:vulnerability_count].to_i
28
31
  next unless count.positive?
29
32
 
@@ -41,6 +44,8 @@ module StillActive
41
44
  outdated: outdated,
42
45
  vulnerable_gems: vulnerable_gems,
43
46
  vulnerabilities: vulnerabilities,
47
+ vulnerabilities_unchecked: unchecked,
48
+ repository_checks: repository_checks,
44
49
  # The single worst per-gem verdict (plus EOL Ruby), so a consumer reads
45
50
  # one project-level posture without scanning every gem's status.
46
51
  status: StatusHelper.project_status(result, ruby_info: ruby_info)
@@ -8,6 +8,7 @@ require_relative "summary_helper"
8
8
  require_relative "libyear_helper"
9
9
  require_relative "version_helper"
10
10
  require_relative "vulnerability_helper"
11
+ require_relative "repository_check"
11
12
 
12
13
  module StillActive
13
14
  module TerminalHelper
@@ -90,7 +91,8 @@ module StillActive
90
91
  def format_activity(data)
91
92
  case ActivityHelper.activity_level(data)
92
93
  when :archived then AnsiHelper.red("archived")
93
- when :ok then AnsiHelper.green("ok")
94
+ # Recent releases, but the repository couldn't be read: it may be archived.
95
+ when :ok then RepositoryCheck.unanswered?(data) ? AnsiHelper.yellow("?") : AnsiHelper.green("ok")
94
96
  when :stale then AnsiHelper.yellow("stale")
95
97
  when :critical then AnsiHelper.red("critical")
96
98
  when :unknown then AnsiHelper.dim("-")
@@ -113,6 +115,8 @@ module StillActive
113
115
  def format_vulns(data)
114
116
  count = data[:vulnerability_count]
115
117
  return AnsiHelper.dim("-") if count.nil?
118
+ # No source answered, so a 0 here would read as clean.
119
+ return AnsiHelper.yellow("?") if count.zero? && data[:vulnerabilities_checked] == false
116
120
  return AnsiHelper.green("0") if count.zero?
117
121
 
118
122
  severity = VulnerabilityHelper.highest_severity(data[:vulnerabilities])
@@ -353,6 +357,10 @@ module StillActive
353
357
  activity << ", #{archived} archived" if archived > 0
354
358
  parts << activity
355
359
  parts << "#{summary[:vulnerabilities]} vulnerabilities"
360
+ parts.last << " (#{summary[:vulnerabilities_unchecked]} unchecked)" if summary[:vulnerabilities_unchecked] > 0
361
+ checks = summary[:repository_checks]
362
+ unchecked = checks[:failed] + checks[:unknowable]
363
+ parts << "#{unchecked} #{(unchecked == 1) ? "repository" : "repositories"} unchecked#{" (#{checks[:failed]} failed)" if checks[:failed] > 0}" if unchecked > 0
356
364
  poison_tiers = result.each_value.select { |data| data[:poison] }.map { |data| data[:poison_severity] }
357
365
  poison_part = tier_summary_part(poison_tiers, "#{poison_tiers.size} poison-#{(poison_tiers.size == 1) ? "pill" : "pills"}")
358
366
  parts << poison_part if poison_part
@@ -23,11 +23,10 @@ module StillActive
23
23
  # One advisory's severity label ("low".."critical"), or nil when genuinely unscored.
24
24
  # The authoritative GHSA/OSV label is a FLOOR: a real CVSS number sharpens precision
25
25
  # and can RAISE the band, but never lowers it below the label. This matters because
26
- # the OSV-derived score is cvss-suite's overall_score, which folds in any
27
- # threat/environmental metrics a vector carries (and can diverge from GHSA at a band
28
- # edge) -- on a fail-closed, no-false-positive tool a computed number must not
29
- # silently demote a GHSA-HIGH finding out of a severity gate. With only one of the
30
- # two present, that one stands.
26
+ # GHSA labels are assigned by people and can sit off the FIRST band edge of the
27
+ # computed base score -- on a fail-closed, no-false-positive tool a computed
28
+ # number must not silently demote a GHSA-HIGH finding out of a severity gate.
29
+ # With only one of the two present, that one stands.
31
30
  def advisory_severity(vulnerability)
32
31
  from_score = (score = effective_score(vulnerability)) ? severity_label(score) : nil
33
32
  from_label = OSV_LABELS[vulnerability[:osv_severity].to_s.downcase]
@@ -21,6 +21,7 @@ module StillActive
21
21
  add_output_options(opts)
22
22
  add_token_options(opts)
23
23
  add_parallelism_options(opts)
24
+ add_cache_option(opts)
24
25
  add_range_options(opts)
25
26
  add_exit_options(opts)
26
27
  add_emoji_options(opts)
@@ -120,6 +121,12 @@ module StillActive
120
121
  end
121
122
  end
122
123
 
124
+ def add_cache_option(opts)
125
+ opts.on("--no-cache", "Skip the on-disk cache of public sources' answers (advisories are kept at most an hour)") do
126
+ StillActive.config { |config| config.http_cache = false }
127
+ end
128
+ end
129
+
123
130
  def add_parallelism_options(opts)
124
131
  opts.on("--simultaneous-requests=QTY", Integer, "Number of simultaneous requests made") do |value|
125
132
  StillActive.config { |config| config.parallelism = value }
@@ -81,6 +81,45 @@ module StillActive
81
81
  body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}")
82
82
  return unless body.is_a?(Hash)
83
83
 
84
+ parse_record(body)
85
+ end
86
+
87
+ # Every advisory OSV lists for this exact version, carrying the same OSV fields
88
+ # enrich adds, or nil when OSV could not answer completely. This is the discovery
89
+ # source for a package deps.dev can't serve per version (the Go toolchain), so
90
+ # nil must stay distinct from [] here too: the caller reads nil as "unknown",
91
+ # never as clean. The query returns whole records, so nothing is fetched per id.
92
+ def advisories(ecosystem:, name:, version:)
93
+ osv_ecosystem = ECOSYSTEM_NAMES[ecosystem]
94
+ return if osv_ecosystem.nil?
95
+
96
+ query = {version: version, package: {name: name, ecosystem: osv_ecosystem}}
97
+ body = HttpHelper.post_json(BASE_URI, "/v1/query", body: JSON.generate(query))
98
+ return unless body.is_a?(Hash) && identifiers_from(body)
99
+
100
+ Array(body["vulns"]).map do |vuln|
101
+ record = parse_record(vuln)
102
+ {
103
+ id: vuln["id"],
104
+ aliases: Array(vuln["aliases"]).grep(String),
105
+ title: vuln["summary"],
106
+ url: "https://osv.dev/vulnerability/#{vuln["id"]}",
107
+ source: "osv",
108
+ osv_severity: record[:severity_label],
109
+ osv_cvss_score: record[:cvss_score],
110
+ cvss_version: record[:cvss_version],
111
+ cvss_vector: record[:cvss_vector],
112
+ fixed_versions: fixed_versions(record, ecosystem: ecosystem, name: name)
113
+ }
114
+ end
115
+ rescue => e
116
+ warn("warning: OSV advisory lookup for #{name}@#{version} failed: #{e.class} (#{e.message})")
117
+ nil
118
+ end
119
+
120
+ private
121
+
122
+ def parse_record(body)
84
123
  cvss = best_cvss(body)
85
124
  {
86
125
  # OSV's own identity for the advisory, unioned into the match against a query
@@ -96,8 +135,6 @@ module StillActive
96
135
  }
97
136
  end
98
137
 
99
- private
100
-
101
138
  # Applies one OSV record to one advisory, in place. Returns OSV's identifiers for
102
139
  # the advisory when it is eligible for the version confirmation below (only OSV
103
140
  # data may overrule OSV-derived data), or nil when it is not and must be kept
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "github_client"
5
+ require_relative "gitlab_client"
6
+ require_relative "forgejo_client"
7
+ require_relative "ecosystems_client"
8
+
9
+ module StillActive
10
+ # One answer to "is this repository archived, and when was it last pushed?"
11
+ # for both audit paths, from whichever service can give it. The clients only
12
+ # report what their service said; which services to ask, in what order, and
13
+ # when to stop, is decided here once.
14
+ #
15
+ # Returns a hash whose :check says what came of it, always one of:
16
+ # "answered" a service said; :archived, :last_commit_date and :source
17
+ # (which service) come with it
18
+ # "failed" a service couldn't answer (a rate limit, an error, a refused
19
+ # token) and none other said; a rerun may help, so the activity
20
+ # gates fail closed on it
21
+ # "unknowable" no service can say: the host has no archived state we can
22
+ # read, there's no repository to ask about, or every service
23
+ # asked didn't know it (ecosyste.ms hasn't crawled it, the forge
24
+ # 404s a private or deleted repository). A rerun won't change
25
+ # it, so it reads unknown without failing the gates.
26
+ #
27
+ # "Don't know" is never an answer: a 404 or an answer without the archived
28
+ # state moves on to the next service.
29
+ #
30
+ # A GitHub repository is asked of GitHub and of ecosyste.ms, which mirrors the
31
+ # same two fields: GitHub first with a token (freshest, 5000/hr), ecosyste.ms
32
+ # first without one (GitHub allows 60 anonymous requests an hour), each the
33
+ # other's fallback. ecosyste.ms sees the repository's name, so it is asked only
34
+ # when the dependency came from a public registry (`public:`), which is the
35
+ # privacy guard; after GitHub refused the token outright (401/403: a bad token,
36
+ # SSO, a suspended account) the chain also stops rather than guess.
37
+ module RepositorySignals
38
+ extend self
39
+
40
+ # A service in a chain: its label for `source`, and how to ask it.
41
+ Service = Data.define(:label, :ask)
42
+
43
+ def for(host:, owner:, name:, public:)
44
+ services = (host && owner && name) ? chain(host.downcase, public) : []
45
+ return {check: "unknowable"} if services.empty?
46
+
47
+ failed = false
48
+ services.each do |service|
49
+ signals = service.ask.call(owner, name)
50
+ next unless signals.key?(:archived)
51
+
52
+ return signals.merge(source: service.label, check: "answered")
53
+ rescue RepoAccessDenied
54
+ failed = true
55
+ break
56
+ rescue RepoSignalsUnavailable
57
+ failed = true
58
+ end
59
+
60
+ if failed
61
+ warn("warning: #{owner}/#{name}: no repository source could answer; archived status unknown")
62
+ {check: "failed"}
63
+ else
64
+ {check: "unknowable"}
65
+ end
66
+ end
67
+
68
+ # For a deps.dev project id (host/owner/name, host/group/.../name on
69
+ # GitLab). gopkg.in is a redirector with a fixed mapping onto GitHub
70
+ # (gopkg.in/pkg.v3 is github.com/go-pkg/pkg, gopkg.in/user/pkg.v3 is
71
+ # github.com/user/pkg), so it is asked about as that GitHub repository.
72
+ def for_project(project_id, public:)
73
+ host, *path = project_id.to_s.split("/")
74
+ host, path = gopkg_in(path) if host&.downcase == "gopkg.in"
75
+ return {check: "unknowable"} if host.nil? || path.size < 2
76
+
77
+ self.for(host: host, owner: path[0..-2].join("/"), name: path.last, public: public)
78
+ end
79
+
80
+ private
81
+
82
+ GOPKG_VERSIONED = /\A(?<name>.+)\.v\d+(?:-unstable)?\z/
83
+
84
+ # The version suffix marks the module: gopkg.in/pkg.vN[/sub...] or
85
+ # gopkg.in/user/pkg.vN[/sub...]; anything after it is a subpackage.
86
+ def gopkg_in(path)
87
+ if (match = path[0]&.match(GOPKG_VERSIONED))
88
+ ["github.com", ["go-#{match[:name]}", match[:name]]]
89
+ elsif (match = path[1]&.match(GOPKG_VERSIONED))
90
+ ["github.com", [path[0], match[:name]]]
91
+ else
92
+ [nil, []]
93
+ end
94
+ end
95
+
96
+ def chain(host, public)
97
+ case host
98
+ when "github.com" then github_chain(public)
99
+ when "gitlab.com" then [Service.new("gitlab", ->(owner, name) { GitlabClient.repo_signals(owner: owner, name: name) })]
100
+ when "codeberg.org" then [Service.new("codeberg", ->(owner, name) { ForgejoClient.repo_signals(owner: owner, name: name, host: host) })]
101
+ else []
102
+ end
103
+ end
104
+
105
+ def github_chain(public)
106
+ github = Service.new("github", ->(owner, name) { GithubClient.repo_signals(owner: owner, name: name) })
107
+ mirror = Service.new("ecosyste.ms", ->(owner, name) { EcosystemsClient.repo_signals(owner: owner, name: name) })
108
+ return [github] unless public
109
+
110
+ StillActive.config.github_oauth_token ? [github, mirror] : [mirror, github]
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers/http_helper"
4
+
5
+ module StillActive
6
+ # The RubyGems JSON API this tool reads, through HttpHelper so it gets the same
7
+ # timeouts, body cap and redirect rules as every other source. rubygems.org by
8
+ # default; Artifactory serves the same API under a repo path, with its own
9
+ # credentials, via `base:` and `headers:`.
10
+ module RubygemsClient
11
+ extend self
12
+
13
+ BASE_URI = URI("https://rubygems.org/")
14
+
15
+ # Every published version, newest first, as rubygems.org serves them; [] when
16
+ # the gem doesn't exist or the lookup failed (HttpHelper has warned).
17
+ def versions(gem_name, base: BASE_URI, headers: {})
18
+ body = HttpHelper.get_json(base, "#{base.path.chomp("/")}/api/v1/versions/#{encode(gem_name)}.json", headers: headers)
19
+ body.is_a?(Array) ? body : []
20
+ end
21
+
22
+ # The gem's metadata (homepage_uri, source_code_uri, downloads, ...), or nil.
23
+ def info(gem_name)
24
+ body = HttpHelper.get_json(BASE_URI, "/api/v1/gems/#{encode(gem_name)}.json")
25
+ body if body.is_a?(Hash)
26
+ end
27
+
28
+ private
29
+
30
+ def encode(value)
31
+ URI.encode_www_form_component(value.to_s)
32
+ end
33
+ end
34
+ end