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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -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/cvss_helper.rb +14 -48
- 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/helpers/vulnerability_helper.rb +4 -5
- 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 +6 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
174
|
+
unavailable(strict, uri)
|
|
124
175
|
rescue *TRANSPORT_ERRORS => e
|
|
125
176
|
warn("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
|
|
126
|
-
|
|
177
|
+
unavailable(strict, uri)
|
|
127
178
|
rescue JSON::ParserError => e
|
|
128
179
|
warn("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
# [:
|
|
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
|
|
141
|
-
#
|
|
142
|
-
#
|
|
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}")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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]
|
data/lib/still_active/options.rb
CHANGED
|
@@ -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
|