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
|
@@ -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
|
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
|
|
@@ -180,7 +180,7 @@ module StillActive
|
|
|
180
180
|
tags: ["security", "supply-chain", "maintenance", "external/cwe/cwe-1104"],
|
|
181
181
|
neutral: {
|
|
182
182
|
short: "Package is deprecated by its maintainer",
|
|
183
|
-
full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
|
|
183
|
+
full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals. A language runtime counts too: when the Go team ends a Go release line, the toolchain on it is reported here.",
|
|
184
184
|
help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches."
|
|
185
185
|
}
|
|
186
186
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ecosystem_lens"
|
|
4
|
+
require_relative "assessment"
|
|
5
|
+
require_relative "source_health"
|
|
4
6
|
require_relative "ceiling_reconciler"
|
|
5
7
|
require_relative "poison_security_correlator"
|
|
6
8
|
require_relative "helpers/python_helper"
|
|
@@ -30,7 +32,9 @@ module StillActive
|
|
|
30
32
|
# rate-limited run read "all clear" while silently skipping deps.
|
|
31
33
|
Outcome = Data.define(:assessed, :failures)
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
# `health` is the run's SourceHealth report: in an ecosystem it distrusts,
|
|
36
|
+
# deps.dev's advisories count as unchecked. The Go toolchain's come from OSV.
|
|
37
|
+
def call(sbom_result, health: SourceHealth::Report.healthy, &on_progress)
|
|
34
38
|
dependencies = sbom_result.dependencies
|
|
35
39
|
Async do
|
|
36
40
|
# The language-runtime support windows, fetched once for the whole SBOM
|
|
@@ -42,6 +46,9 @@ module StillActive
|
|
|
42
46
|
dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
|
|
43
47
|
dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range }
|
|
44
48
|
}
|
|
49
|
+
# Every version record in one batch request, rather than one GET each; the
|
|
50
|
+
# Go toolchain's come from OSV and endoflife.date instead.
|
|
51
|
+
DepsDevClient.prefetch_versions(dependencies.filter_map { [_1[:ecosystem], _1[:name], _1[:version]] unless GoToolchain.toolchain?(_1[:ecosystem], _1[:name]) })
|
|
45
52
|
barrier = Async::Barrier.new
|
|
46
53
|
semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
|
|
47
54
|
result = {}
|
|
@@ -78,6 +85,11 @@ module StillActive
|
|
|
78
85
|
end
|
|
79
86
|
end
|
|
80
87
|
barrier.wait
|
|
88
|
+
result.each_value do |data|
|
|
89
|
+
next if health.deps_dev_trusted?(data[:ecosystem]) || GoToolchain.toolchain?(data[:ecosystem], data[:name])
|
|
90
|
+
|
|
91
|
+
data[:vulnerabilities_checked] = false
|
|
92
|
+
end
|
|
81
93
|
# Whole-tree correlation once every package's signals are in: a Python
|
|
82
94
|
# ceiling's "upgrade to lift it" must not contradict a poison finding that
|
|
83
95
|
# caps the same package below that upgrade (same guarantee as the native path).
|
|
@@ -87,10 +99,12 @@ module StillActive
|
|
|
87
99
|
PoisonSecurityCorrelator.correlate(result)
|
|
88
100
|
# Stable, diffable order regardless of async completion order.
|
|
89
101
|
Outcome.new(
|
|
90
|
-
assessed: result.sort_by { |key, _| key }.to_h,
|
|
102
|
+
assessed: result.sort_by { |key, _| key }.to_h.transform_values { Assessment.from(_1) },
|
|
91
103
|
failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" }
|
|
92
104
|
)
|
|
93
105
|
end.wait
|
|
106
|
+
ensure
|
|
107
|
+
DepsDevClient.clear_prefetch
|
|
94
108
|
end
|
|
95
109
|
|
|
96
110
|
private
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "deps_dev_client"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# Whether deps.dev is answering in the shape we read and is current, checked once
|
|
8
|
+
# per run before the fan-out. deps.dev is an alpha API and it is the advisory
|
|
9
|
+
# source for everything but ruby-advisory-db and the Go toolchain, so when it
|
|
10
|
+
# isn't healthy its advisories count as unchecked rather than clean.
|
|
11
|
+
#
|
|
12
|
+
# Two checks. The advisory canary asks for a package with permanent advisories
|
|
13
|
+
# and expects them back (a renamed field reads as zero everywhere). The
|
|
14
|
+
# freshness canary asks, per ecosystem in the audit, for packages that publish
|
|
15
|
+
# often and expects one of them to have published in the last 30 days: a stalled index
|
|
16
|
+
# serves stale versions and misses new advisories without any error. Neither
|
|
17
|
+
# catches one stale package in a live index (deps.dev's Go stdlib was that).
|
|
18
|
+
module SourceHealth
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# Packages that publish often; an ecosystem is fresh if ANY of its set has a
|
|
22
|
+
# version published in the window. Chosen by replaying two years of deps.dev
|
|
23
|
+
# publish dates (2026-09-24): the longest stretch with no version from the
|
|
24
|
+
# set is 10 days (nuget), 8.7 (npm), 7 (cargo, rubygems), 6.7 (pypi, maven),
|
|
25
|
+
# 6 (go), so 30 days is a 3x margin. Single canaries weren't enough:
|
|
26
|
+
# aws-sdk-core went 34 days and golang.org/x/net 35, which would have failed
|
|
27
|
+
# a healthy index closed. Recheck with each set's publishedAt history.
|
|
28
|
+
FRESHNESS_CANARIES = {
|
|
29
|
+
rubygems: ["aws-partitions", "aws-sdk-ec2"],
|
|
30
|
+
npm: ["next", "@types/node"],
|
|
31
|
+
pypi: ["boto3", "botocore"],
|
|
32
|
+
cargo: ["aws-sdk-ec2", "oxc_parser", "swc_core"],
|
|
33
|
+
go: ["cloud.google.com/go/storage", "github.com/aws/aws-sdk-go-v2/service/s3"],
|
|
34
|
+
maven: ["software.amazon.awssdk:s3", "software.amazon.awssdk:ec2"],
|
|
35
|
+
nuget: ["AWSSDK.S3", "AWSSDK.Core"]
|
|
36
|
+
}.freeze
|
|
37
|
+
FRESH_WITHIN_SECONDS = 30 * 24 * 60 * 60
|
|
38
|
+
|
|
39
|
+
Report = Data.define(:advisory_schema_ok, :stale_ecosystems) do
|
|
40
|
+
def self.healthy = new(advisory_schema_ok: true, stale_ecosystems: [])
|
|
41
|
+
|
|
42
|
+
# Whether deps.dev's advisories for this ecosystem count as an answer.
|
|
43
|
+
def deps_dev_trusted?(ecosystem)
|
|
44
|
+
advisory_schema_ok && !stale_ecosystems.include?(ecosystem)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def degraded? = !advisory_schema_ok || !stale_ecosystems.empty?
|
|
48
|
+
|
|
49
|
+
def as_json
|
|
50
|
+
{
|
|
51
|
+
status: degraded? ? "degraded" : "ok",
|
|
52
|
+
deps_dev_advisories: advisory_schema_ok ? "ok" : "failed",
|
|
53
|
+
deps_dev_stale_ecosystems: stale_ecosystems.map(&:to_s)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def warnings
|
|
58
|
+
lines = []
|
|
59
|
+
lines << "deps.dev's advisory check failed (its `advisoryKeys` field may have changed, or the API is unreachable); its advisories count as unchecked unless another source answers for them (ruby-advisory-db, OSV for the Go toolchain)" unless advisory_schema_ok
|
|
60
|
+
stale_ecosystems.each do |ecosystem|
|
|
61
|
+
lines << "deps.dev's #{ecosystem} index has nothing published by #{FRESHNESS_CANARIES[ecosystem].join(", ")} in 30 days (or couldn't be read), so it looks stale; its #{ecosystem} advisories count as unchecked unless another source answers for them, and its latest versions may be behind"
|
|
62
|
+
end
|
|
63
|
+
lines
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check(ecosystems:, now: Time.now)
|
|
68
|
+
Report.new(
|
|
69
|
+
advisory_schema_ok: DepsDevClient.advisory_schema_ok?,
|
|
70
|
+
stale_ecosystems: ecosystems.uniq.select { FRESHNESS_CANARIES.key?(_1) && !fresh?(_1, now) }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Fresh when any canary has a version published in the window. One that can't
|
|
77
|
+
# be read (after the client's retry) just doesn't count; if none can, the check
|
|
78
|
+
# can't confirm freshness and reads stale.
|
|
79
|
+
def fresh?(ecosystem, now)
|
|
80
|
+
FRESHNESS_CANARIES[ecosystem].any? do |name|
|
|
81
|
+
date = DepsDevClient.newest_publish_date(name: name, system: ecosystem)
|
|
82
|
+
!date.nil? && now - Time.parse(date) < FRESH_WITHIN_SECONDS
|
|
83
|
+
rescue HttpHelper::Unavailable, ArgumentError, TypeError
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/still_active/version.rb
CHANGED