still_active 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +3 -1
- data/lib/still_active/artifactory_client.rb +2 -20
- data/lib/still_active/assessment.rb +85 -0
- data/lib/still_active/cli.rb +84 -23
- data/lib/still_active/config.rb +2 -0
- data/lib/still_active/deps_dev_client.rb +161 -15
- data/lib/still_active/diff.rb +38 -0
- data/lib/still_active/ecosystem_lens.rb +80 -44
- data/lib/still_active/ecosystems_client.rb +10 -7
- data/lib/still_active/errors.rb +10 -0
- data/lib/still_active/forgejo_client.rb +5 -1
- data/lib/still_active/github_client.rb +28 -5
- data/lib/still_active/gitlab_client.rb +5 -1
- data/lib/still_active/go_toolchain.rb +75 -0
- data/lib/still_active/helpers/alternatives_helper.rb +2 -2
- data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
- data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
- data/lib/still_active/helpers/emoji_helper.rb +2 -1
- data/lib/still_active/helpers/endoflife_helper.rb +3 -2
- data/lib/still_active/helpers/http_cache.rb +109 -0
- data/lib/still_active/helpers/http_helper.rb +148 -28
- data/lib/still_active/helpers/markdown_helper.rb +1 -1
- data/lib/still_active/helpers/repository_check.rb +33 -0
- data/lib/still_active/helpers/status_helper.rb +11 -0
- data/lib/still_active/helpers/summary_helper.rb +6 -1
- data/lib/still_active/helpers/terminal_helper.rb +9 -1
- data/lib/still_active/options.rb +7 -0
- data/lib/still_active/osv_client.rb +39 -2
- data/lib/still_active/repository_signals.rb +113 -0
- data/lib/still_active/rubygems_client.rb +34 -0
- data/lib/still_active/sarif/rules.rb +1 -1
- data/lib/still_active/sbom_workflow.rb +16 -2
- data/lib/still_active/source_health.rb +88 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +63 -52
- data/lib/still_active.rb +1 -0
- data/still_active.gemspec +0 -1
- metadata +9 -16
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require "octokit"
|
|
5
|
+
require_relative "errors"
|
|
5
6
|
|
|
6
7
|
module StillActive
|
|
7
8
|
# Repo signals (archived?, last commit date) for github.com-hosted gems.
|
|
@@ -20,19 +21,37 @@ module StillActive
|
|
|
20
21
|
# object's pushed_at (last push) stands in for the last-commit date: it
|
|
21
22
|
# matches the default-branch commit date to the day in practice, and folding
|
|
22
23
|
# the two signals into one call halves the per-gem GitHub requests. Returns
|
|
23
|
-
# {} when the repo
|
|
24
|
+
# {} when GitHub says the repo doesn't exist; raises RepoAccessDenied when it
|
|
25
|
+
# refuses the token, and RepoSignalsUnavailable when it can't answer (a rate
|
|
26
|
+
# limit past its short wait, an error status, a network failure).
|
|
27
|
+
# RepositorySignals decides what to ask next.
|
|
24
28
|
def repo_signals(owner:, name:)
|
|
25
29
|
return {} if owner.nil? || name.nil?
|
|
26
30
|
|
|
27
31
|
repo = with_rate_limit_retry("repo #{owner}/#{name}") do
|
|
28
32
|
StillActive.config.github_client.repository("#{owner}/#{name}")
|
|
29
33
|
end
|
|
30
|
-
|
|
34
|
+
raise RepoSignalsUnavailable, "#{owner}/#{name}: rate limited" if repo == :rate_limited
|
|
31
35
|
|
|
32
36
|
{archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name)}
|
|
37
|
+
rescue Octokit::NotFound
|
|
38
|
+
{}
|
|
39
|
+
# Permanent "don't know", like a 404: a name Octokit refuses up front (an
|
|
40
|
+
# ArgumentError), a repository GitHub has blocked, or one withheld for legal
|
|
41
|
+
# reasons. A rerun won't change them, so they're not a failure.
|
|
42
|
+
rescue Octokit::InvalidRepository, Octokit::RepositoryUnavailable, Octokit::UnavailableForLegalReasons
|
|
43
|
+
{}
|
|
44
|
+
# Rate limits are Forbidden subclasses in Octokit (a 403), but they say
|
|
45
|
+
# nothing about the repository being private, so they don't stop the chain.
|
|
46
|
+
rescue Octokit::TooManyRequests, Octokit::AbuseDetected, Octokit::TooManyLoginAttempts => e
|
|
47
|
+
warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
|
|
48
|
+
raise RepoSignalsUnavailable, "#{owner}/#{name}: #{e.class}"
|
|
49
|
+
rescue Octokit::Unauthorized, Octokit::Forbidden => e
|
|
50
|
+
warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
|
|
51
|
+
raise RepoAccessDenied, "#{owner}/#{name}: #{e.class}"
|
|
33
52
|
rescue Octokit::Error, Faraday::Error => e
|
|
34
53
|
warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
|
|
35
|
-
{}
|
|
54
|
+
raise RepoSignalsUnavailable, "#{owner}/#{name}: #{e.class}"
|
|
36
55
|
end
|
|
37
56
|
|
|
38
57
|
# Commits on the default branch since the latest release's tag: the
|
|
@@ -48,7 +67,8 @@ module StillActive
|
|
|
48
67
|
|
|
49
68
|
repo = "#{owner}/#{name}"
|
|
50
69
|
["v#{version}", version.to_s].each do |tag|
|
|
51
|
-
|
|
70
|
+
ahead = with_rate_limit_retry("unreleased-commits #{repo}") { StillActive.config.github_client.compare(repo, tag, "HEAD").ahead_by }
|
|
71
|
+
return (ahead == :rate_limited) ? nil : ahead
|
|
52
72
|
rescue Octokit::NotFound
|
|
53
73
|
# this tag form doesn't exist; fall through to try the next one
|
|
54
74
|
end
|
|
@@ -60,6 +80,9 @@ module StillActive
|
|
|
60
80
|
|
|
61
81
|
private
|
|
62
82
|
|
|
83
|
+
# Only an answer that says whether the repo is archived counts: ecosyste.ms
|
|
84
|
+
# 404s a repo it hasn't crawled, and after GitHub failed that is nobody
|
|
85
|
+
# answering.
|
|
63
86
|
# Pause-and-retry on a rate-limit response when the reset is near, so a
|
|
64
87
|
# transient secondary/burst limit (which GitHub's concurrent fan-out can
|
|
65
88
|
# trip even with a token) self-heals instead of dropping the gem's signal.
|
|
@@ -76,7 +99,7 @@ module StillActive
|
|
|
76
99
|
# Surface the one actionable hint rather than a generic class name,
|
|
77
100
|
# then return nil so this signal is simply absent for the gem.
|
|
78
101
|
warn("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
|
|
79
|
-
return
|
|
102
|
+
return :rate_limited
|
|
80
103
|
end
|
|
81
104
|
|
|
82
105
|
retried = true
|
|
@@ -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
|
|
@@ -17,10 +18,13 @@ module StillActive
|
|
|
17
18
|
return {} if owner.nil? || name.nil?
|
|
18
19
|
|
|
19
20
|
path = "/api/v4/projects/#{encode_project(owner, name)}"
|
|
20
|
-
|
|
21
|
+
# {} for a 404 (an answer); RepoSignalsUnavailable when GitLab couldn't answer.
|
|
22
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers, strict: true)
|
|
21
23
|
return {} if body.nil?
|
|
22
24
|
|
|
23
25
|
{archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name)}
|
|
26
|
+
rescue HttpHelper::Unavailable => e
|
|
27
|
+
raise RepoSignalsUnavailable, e.message
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
private
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "osv_client"
|
|
4
|
+
require_relative "helpers/endoflife_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# The Go toolchain, which Syft emits as the module `stdlib` (pkg:golang/stdlib@1.27.1).
|
|
8
|
+
# It is a runtime, and deps.dev can't serve it: it keys versions `go1.27.1` so the
|
|
9
|
+
# purl's version 404s, its index stops at go1.25.5, and its advisory list is the same
|
|
10
|
+
# for every version (verified 2026-09-24). So the release lines come from
|
|
11
|
+
# endoflife.date and the advisories from OSV by version, the sources the Ruby
|
|
12
|
+
# runtime check already trusts.
|
|
13
|
+
module GoToolchain
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
MODULE = "stdlib"
|
|
17
|
+
|
|
18
|
+
def toolchain?(ecosystem, name)
|
|
19
|
+
ecosystem == :go && name == MODULE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Syft writes 1.27.1, a component version often go1.27.1, Trivy v1.27.1.
|
|
23
|
+
def release(version)
|
|
24
|
+
version.to_s.delete_prefix("go").delete_prefix("v")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The same { info:, default:, vulnerabilities:, vulnerabilities_checked:,
|
|
28
|
+
# project_id:, version_unresolved: }
|
|
29
|
+
# EcosystemLens builds from deps.dev for any other package.
|
|
30
|
+
def signals(version:)
|
|
31
|
+
release = release(version)
|
|
32
|
+
cycles = EndoflifeHelper.fetch_cycles("/api/go.json")
|
|
33
|
+
cycles = nil unless cycles.is_a?(Array) && cycles.all?(Hash) && !cycles.empty?
|
|
34
|
+
latest = cycles&.first
|
|
35
|
+
cycle = cycles&.find { |c| c["cycle"] == release.split(".").first(2).join(".") }
|
|
36
|
+
# Only a plain release (1.20 and earlier shipped as go1.20): OSV writes Go
|
|
37
|
+
# prereleases as 1.27.0-rc.1, so a `1.27rc1` query would come back empty and
|
|
38
|
+
# read as clean.
|
|
39
|
+
vulnerabilities = OsvClient.advisories(ecosystem: :go, name: MODULE, version: release) if release.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
|
40
|
+
warn("warning: go/#{MODULE}@#{release} advisories not checked (not a plain release, or OSV gave no complete answer); treat it as unknown, not clean") if vulnerabilities.nil?
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
info: cycle && {published_at: release_date(cycle, release), licenses: [], **end_of_life(cycle, latest)},
|
|
44
|
+
default: latest && {version: latest["latest"], published_at: latest["latestReleaseDate"]},
|
|
45
|
+
vulnerabilities: vulnerabilities || [],
|
|
46
|
+
vulnerabilities_checked: !vulnerabilities.nil?,
|
|
47
|
+
project_id: nil,
|
|
48
|
+
# The feed is up but doesn't know this release line.
|
|
49
|
+
version_unresolved: !cycles.nil? && cycle.nil?
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The feed dates each line's first and latest release only.
|
|
56
|
+
def release_date(cycle, release)
|
|
57
|
+
if release == cycle["latest"]
|
|
58
|
+
cycle["latestReleaseDate"]
|
|
59
|
+
elsif [cycle["cycle"], "#{cycle["cycle"]}.0"].include?(release)
|
|
60
|
+
cycle["releaseDate"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Go supports its two newest lines. Past that the Go team has said a line gets no
|
|
65
|
+
# more fixes, which is the maintainer's deprecation this field already carries.
|
|
66
|
+
def end_of_life(cycle, latest)
|
|
67
|
+
return {deprecated: false, deprecation_reason: nil} unless EndoflifeHelper.eol_reached?(cycle["eol"])
|
|
68
|
+
|
|
69
|
+
# Worded as what the Go team did, since output labels it the maintainer's note.
|
|
70
|
+
on = EndoflifeHelper.parse_eol(cycle["eol"])&.strftime(" on %Y-%m-%d")
|
|
71
|
+
upgrade = latest && " Upgrade to #{latest["latest"]}."
|
|
72
|
+
{deprecated: true, deprecation_reason: "The Go team ended the #{cycle["cycle"]} release line#{on}; it gets no further security fixes.#{upgrade}"}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "../rubygems_client"
|
|
4
4
|
|
|
5
5
|
module StillActive
|
|
6
6
|
# Turns a gem's catalog siblings into ranked "leads" -- the most-downloaded
|
|
@@ -33,7 +33,7 @@ module StillActive
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def downloads(gem_name)
|
|
36
|
-
info =
|
|
36
|
+
info = RubygemsClient.info(gem_name)
|
|
37
37
|
info && info["downloads"]
|
|
38
38
|
rescue
|
|
39
39
|
nil
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require_relative "activity_helper"
|
|
7
7
|
require_relative "status_helper"
|
|
8
8
|
require_relative "vulnerability_helper"
|
|
9
|
+
require_relative "repository_check"
|
|
9
10
|
|
|
10
11
|
module StillActive
|
|
11
12
|
# Renders a still_active workflow result as a CycloneDX SBOM. Emits 1.6 by
|
|
@@ -152,6 +153,12 @@ module StillActive
|
|
|
152
153
|
"still_active:archived" => boolean_property(data[:archived]),
|
|
153
154
|
"still_active:deprecated" => boolean_property(data[:deprecated]),
|
|
154
155
|
"still_active:deprecation_reason" => data[:deprecation_reason],
|
|
156
|
+
# An empty vulnerabilities list reads as clean downstream; this says whether
|
|
157
|
+
# anything was actually looked up.
|
|
158
|
+
"still_active:vulnerabilities_checked" => boolean_property(data[:vulnerabilities_checked]),
|
|
159
|
+
# Present only when no service answered: a blank archived flag here is
|
|
160
|
+
# "unknown", not "no".
|
|
161
|
+
"still_active:repository_check" => (RepositoryCheck.unanswered?(data) ? data[:repository_check] : nil),
|
|
155
162
|
"still_active:scorecard_score" => data[:scorecard_score]&.to_s,
|
|
156
163
|
"still_active:libyear" => data[:libyear]&.to_s,
|
|
157
164
|
"still_active:last_commit_date" => iso8601(data[:last_commit_date])
|
|
@@ -12,6 +12,7 @@ module StillActive
|
|
|
12
12
|
|
|
13
13
|
BUMP_KIND_LABELS = {
|
|
14
14
|
closed_vulns: "closed vulns",
|
|
15
|
+
advisories_unchecked: "advisories UNCHECKED",
|
|
15
16
|
introduced_vulns: "INTRODUCED vulns",
|
|
16
17
|
fresher: "fresher",
|
|
17
18
|
older_relative: "older relative to latest",
|
|
@@ -149,6 +150,10 @@ module StillActive
|
|
|
149
150
|
"- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
|
|
150
151
|
when :version_yanked
|
|
151
152
|
"- #{name} — version yanked from rubygems"
|
|
153
|
+
when :advisories_unchecked
|
|
154
|
+
"- #{name} — advisories could not be checked"
|
|
155
|
+
when :repository_unchecked
|
|
156
|
+
"- #{name} — repository no longer checked (#{MarkdownEscape.inline(ch[:check])}; may be archived)"
|
|
152
157
|
when :libyear_worsened
|
|
153
158
|
"- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
|
|
154
159
|
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
|
module EmojiHelper
|
|
@@ -8,7 +9,7 @@ module StillActive
|
|
|
8
9
|
|
|
9
10
|
def inactive_gem_emoji(result_hash)
|
|
10
11
|
case ActivityHelper.activity_level(result_hash)
|
|
11
|
-
when :ok then ""
|
|
12
|
+
when :ok then RepositoryCheck.unanswered?(result_hash) ? StillActive.config.unsure_emoji : ""
|
|
12
13
|
when :stale then StillActive.config.warning_emoji
|
|
13
14
|
when :archived, :critical then StillActive.config.critical_warning_emoji
|
|
14
15
|
when :unknown then StillActive.config.unsure_emoji
|
|
@@ -87,12 +87,13 @@ module StillActive
|
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
# The feed's raw cycles, newest first, or nil when it's unavailable.
|
|
92
91
|
def fetch_cycles(feed_path)
|
|
93
92
|
HttpHelper.get_json(ENDOFLIFE_URI, feed_path)
|
|
94
93
|
end
|
|
95
94
|
|
|
95
|
+
private
|
|
96
|
+
|
|
96
97
|
def normalize_cycle(cycle)
|
|
97
98
|
version = cycle["cycle"]
|
|
98
99
|
return unless version && Gem::Version.correct?(version)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# An on-disk cache of public sources' JSON answers, so a rerun, or the baseline
|
|
9
|
+
# and current audits of one CI job, don't ask for the same thing twice. Only the
|
|
10
|
+
# hosts and paths in RULES are cached, each for as long as that data can be
|
|
11
|
+
# trusted to hold: an answer that carries advisories for an hour, so a new CVE
|
|
12
|
+
# is at most an hour late (--no-cache for none), a published version's record
|
|
13
|
+
# for a week, since it doesn't change. Nothing credentialed is cached: those
|
|
14
|
+
# hosts aren't listed, and a request with an Authorization header is skipped
|
|
15
|
+
# whatever its host. Failures and 404s aren't cached, only answers.
|
|
16
|
+
#
|
|
17
|
+
# OSV's version query is deliberately absent. It is the arbiter that drops a
|
|
18
|
+
# deps.dev finding the version isn't affected by, so it must never be older
|
|
19
|
+
# than the deps.dev record it judges; cached separately, it could be, and would
|
|
20
|
+
# drop an advisory published after it was cached.
|
|
21
|
+
module HttpCache
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
HOUR = 60 * 60
|
|
25
|
+
DAY = 24 * HOUR
|
|
26
|
+
WEEK = 7 * DAY
|
|
27
|
+
|
|
28
|
+
# [host, path pattern, seconds], first match wins.
|
|
29
|
+
RULES = [
|
|
30
|
+
["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+/versions/[^/]+:requirements\z}, WEEK],
|
|
31
|
+
["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+/versions/}, HOUR], # carries advisoryKeys
|
|
32
|
+
["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+\z}, 6 * HOUR], # latest version
|
|
33
|
+
["api.deps.dev", %r{\A/v3alpha/advisories/}, 6 * HOUR], # CVSS, which the severity gates read
|
|
34
|
+
["api.deps.dev", %r{\A/v3alpha/projects/}, DAY],
|
|
35
|
+
["api.osv.dev", %r{\A/v1/vulns/}, 6 * HOUR], # severity and fixed versions
|
|
36
|
+
["rubygems.org", %r{\A/api/v1/versions/}, 6 * HOUR],
|
|
37
|
+
["rubygems.org", %r{\A/api/v1/gems/}, DAY],
|
|
38
|
+
["pypi.org", %r{\A/pypi/[^/]+/[^/]+/json\z}, WEEK], # one published version
|
|
39
|
+
["packages.ecosyste.ms", %r{/versions/}, WEEK], # one published version's declared deps
|
|
40
|
+
["repos.ecosyste.ms", %r{\A/api/v1/hosts/}, DAY],
|
|
41
|
+
["endoflife.date", %r{\A/api/}, DAY]
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# Entries older than any rule allows are deleted when the cache is first used.
|
|
45
|
+
MAX_AGE = WEEK
|
|
46
|
+
|
|
47
|
+
# Seconds to cache this request's answer for, or nil to not cache it.
|
|
48
|
+
def ttl(uri, headers)
|
|
49
|
+
return unless StillActive.config.http_cache
|
|
50
|
+
return if headers.keys.any? { _1.to_s.casecmp?("Authorization") }
|
|
51
|
+
|
|
52
|
+
RULES.find { |host, pattern, _| uri.host == host && uri.path.match?(pattern) }&.last
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The cached answer, or :miss.
|
|
56
|
+
def read(key, ttl)
|
|
57
|
+
path = path_for(key)
|
|
58
|
+
return :miss unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
entry = JSON.parse(File.read(path))
|
|
61
|
+
age = Time.now.to_f - entry.fetch("stored_at")
|
|
62
|
+
# A future stamp (clock skew, a cache restored from another machine) would
|
|
63
|
+
# otherwise never expire.
|
|
64
|
+
return :miss if age.negative? || age > ttl
|
|
65
|
+
|
|
66
|
+
entry.fetch("body")
|
|
67
|
+
rescue JSON::ParserError, KeyError, TypeError, SystemCallError
|
|
68
|
+
:miss
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Best-effort: a cache that can't be written is just a cache miss next time.
|
|
72
|
+
def write(key, body)
|
|
73
|
+
sweep_once
|
|
74
|
+
path = path_for(key)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
76
|
+
temp = "#{path}.#{Process.pid}.#{rand(1 << 32)}.tmp"
|
|
77
|
+
File.write(temp, JSON.generate({"stored_at" => Time.now.to_f, "body" => body}))
|
|
78
|
+
File.rename(temp, path)
|
|
79
|
+
rescue SystemCallError, IOError
|
|
80
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def key(method, uri)
|
|
84
|
+
Digest::SHA256.hexdigest("#{method}\n#{uri}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def directory
|
|
88
|
+
base = ENV["XDG_CACHE_HOME"]
|
|
89
|
+
base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
|
|
90
|
+
File.join(base, "still_active", "http")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def path_for(key)
|
|
96
|
+
File.join(directory, key[0, 2], "#{key}.json")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sweep_once
|
|
100
|
+
return if @swept
|
|
101
|
+
|
|
102
|
+
@swept = true
|
|
103
|
+
cutoff = Time.now - MAX_AGE
|
|
104
|
+
Dir.glob(File.join(directory, "*", "*.{json,tmp}")).each { File.delete(_1) if File.mtime(_1) < cutoff }
|
|
105
|
+
rescue SystemCallError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -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)
|