still_active 2.0.0 → 3.0.0.rc1
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 +41 -0
- data/README.md +57 -10
- data/lib/helpers/activity_helper.rb +8 -0
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +20 -4
- data/lib/helpers/diff_markdown_helper.rb +21 -5
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +17 -2
- data/lib/helpers/markdown_helper.rb +118 -1
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +13 -24
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +105 -3
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +4 -0
- data/lib/helpers/terminal_helper.rb +144 -6
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +212 -9
- data/lib/still_active/config.rb +28 -1
- data/lib/still_active/config_file.rb +27 -0
- data/lib/still_active/deps_dev_client.rb +115 -5
- data/lib/still_active/diff.rb +22 -0
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/options.rb +44 -1
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/sarif/rules.rb +33 -9
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +6 -5
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +201 -8
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- metadata +61 -11
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "http_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# The ecosystem-neutral support-window builder over an endoflife.date feed.
|
|
8
|
+
# Given a feed path (`/api/ruby.json`, `/api/python.json`, ...) it returns the
|
|
9
|
+
# runtime facts a per-package language ceiling is measured against: the oldest
|
|
10
|
+
# cycle still receiving security releases, the latest stable release, a
|
|
11
|
+
# grace-window flag, and the normalized cycle list. RubyHelper and PythonHelper
|
|
12
|
+
# are thin calibration wrappers that only choose the feed path, exactly as
|
|
13
|
+
# RuntimeCeilingHelper keeps the ceiling math generic and pushes the runtime
|
|
14
|
+
# specifics to the edges. RubyHelper#ruby_freshness also reuses the cycle-date
|
|
15
|
+
# parsing here so there is one home for reading this best-effort feed.
|
|
16
|
+
#
|
|
17
|
+
# Everything degrades on bad third-party data rather than raising: the window is
|
|
18
|
+
# fetched once outside the per-package rescue, so an unguarded raise would abort
|
|
19
|
+
# the whole audit. A single malformed field must cost at most the finding it
|
|
20
|
+
# feeds, never the whole signal.
|
|
21
|
+
module EndoflifeHelper
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
ENDOFLIFE_URI = URI("https://endoflife.date/")
|
|
25
|
+
|
|
26
|
+
# How long a newly-released runtime gets before packages are held accountable
|
|
27
|
+
# for not yet declaring support for it. Below this, a latest-not-yet ceiling
|
|
28
|
+
# is about the release calendar, not the package, so the note is suppressed.
|
|
29
|
+
LATEST_STABLE_GRACE_SECONDS = 90 * 24 * 60 * 60
|
|
30
|
+
|
|
31
|
+
# => { oldest_supported:, latest_stable:, latest_stable_fresh:, cycles: } of
|
|
32
|
+
# Gem::Versions plus normalized EOL cycles, or nil when the feed is
|
|
33
|
+
# unavailable, empty, or every known cycle is EOL (no supported floor to
|
|
34
|
+
# compare against). `latest_stable` is nil when the newest cycle's `latest` is
|
|
35
|
+
# malformed -- see below.
|
|
36
|
+
def support_window(feed_path:)
|
|
37
|
+
cycles = fetch_cycles(feed_path)
|
|
38
|
+
return if cycles.nil? || cycles.empty?
|
|
39
|
+
|
|
40
|
+
normalized = cycles.filter_map { |cycle| normalize_cycle(cycle) }
|
|
41
|
+
supported = normalized.reject { |cycle| cycle[:eol] }
|
|
42
|
+
return if supported.empty?
|
|
43
|
+
|
|
44
|
+
# A malformed/preview `latest` on the newest cycle must not sink the whole
|
|
45
|
+
# window: latest_stable feeds only the latest-not-yet NOTE, whereas the
|
|
46
|
+
# EOL-forced CRITICAL depends solely on the cycles' eol flags. Degrade it to
|
|
47
|
+
# nil (the note is then suppressed) rather than returning nil, which would
|
|
48
|
+
# silently disable criticals too.
|
|
49
|
+
latest = cycles.first["latest"]
|
|
50
|
+
latest_stable = latest && Gem::Version.correct?(latest) ? Gem::Version.new(latest) : nil
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
oldest_supported: supported.map { |cycle| cycle[:version] }.min,
|
|
54
|
+
latest_stable: latest_stable,
|
|
55
|
+
latest_stable_fresh: !latest_stable.nil? && latest_stable_fresh?(cycles.first),
|
|
56
|
+
cycles: normalized,
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A best-effort date parse over this feed's values: nil for a missing OR
|
|
61
|
+
# malformed date, never a raise. endoflife.date is third-party data; a garbled
|
|
62
|
+
# date on one cycle must degrade that cycle, not abort the run.
|
|
63
|
+
def parse_date(date_string)
|
|
64
|
+
return if date_string.nil?
|
|
65
|
+
|
|
66
|
+
Time.parse(date_string)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_eol(value)
|
|
72
|
+
case value
|
|
73
|
+
when String then parse_date(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# true / false when the eol date is known, nil when it's absent or malformed
|
|
78
|
+
# (so callers can decide how to treat "unknown"). Routes through parse_date, so
|
|
79
|
+
# a garbled eol string degrades to nil rather than raising.
|
|
80
|
+
def eol_reached?(value)
|
|
81
|
+
case value
|
|
82
|
+
when true then true
|
|
83
|
+
when false then false
|
|
84
|
+
when String
|
|
85
|
+
parsed = parse_date(value)
|
|
86
|
+
parsed.nil? ? nil : parsed <= Time.now
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def fetch_cycles(feed_path)
|
|
93
|
+
HttpHelper.get_json(ENDOFLIFE_URI, feed_path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_cycle(cycle)
|
|
97
|
+
version = cycle["cycle"]
|
|
98
|
+
return unless version && Gem::Version.correct?(version)
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
version: Gem::Version.new(version),
|
|
102
|
+
eol: eol_reached?(cycle["eol"]) == true,
|
|
103
|
+
eol_date: parse_eol(cycle["eol"]),
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def latest_stable_fresh?(latest_cycle)
|
|
108
|
+
released = parse_date(latest_cycle["releaseDate"])
|
|
109
|
+
return false if released.nil?
|
|
110
|
+
|
|
111
|
+
(Time.now - released) < LATEST_STABLE_GRACE_SECONDS
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/helpers/http_helper.rb
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "json"
|
|
5
6
|
|
|
6
7
|
module StillActive
|
|
7
8
|
module HttpHelper
|
|
8
|
-
TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
|
|
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
|
+
# Transport-level failures ("host unreachable / connection broke", not an HTTP
|
|
11
|
+
# error status). Every network entry point degrades to a safe empty result on
|
|
12
|
+
# these rather than letting one escape and vanish a gem from the audit.
|
|
13
|
+
# SystemCallError is the superclass of the whole Errno::* family (EHOSTUNREACH,
|
|
14
|
+
# ENETUNREACH, ETIMEDOUT, EPIPE, ECONNREFUSED, ECONNRESET, ...), so we don't
|
|
15
|
+
# have to enumerate each one and miss the next.
|
|
16
|
+
TRANSPORT_ERRORS = [
|
|
17
|
+
Net::OpenTimeout,
|
|
18
|
+
Net::ReadTimeout,
|
|
19
|
+
SocketError,
|
|
20
|
+
SystemCallError,
|
|
21
|
+
OpenSSL::SSL::SSLError,
|
|
22
|
+
EOFError,
|
|
23
|
+
].freeze
|
|
9
24
|
MAX_REDIRECTS = 3
|
|
10
25
|
# Ceiling on a single response body. These are metadata endpoints (version
|
|
11
26
|
# lists, scorecards, advisories); legitimate responses are well under this.
|
|
@@ -94,7 +109,7 @@ module StillActive
|
|
|
94
109
|
|
|
95
110
|
$stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
|
|
96
111
|
nil
|
|
97
|
-
rescue
|
|
112
|
+
rescue *TRANSPORT_ERRORS => e
|
|
98
113
|
$stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
|
|
99
114
|
nil
|
|
100
115
|
rescue JSON::ParserError => e
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "markdown_escape"
|
|
4
4
|
require_relative "vulnerability_helper"
|
|
5
5
|
require_relative "activity_helper"
|
|
6
|
+
require_relative "constraint_helper"
|
|
6
7
|
|
|
7
8
|
module StillActive
|
|
8
9
|
module MarkdownHelper
|
|
@@ -112,8 +113,123 @@ module StillActive
|
|
|
112
113
|
lines.join("\n")
|
|
113
114
|
end
|
|
114
115
|
|
|
116
|
+
# A dormant dep that caps your tree below its latest major (the poison-pill
|
|
117
|
+
# signal). Consistent with the other finding sections: worst caps + total, the
|
|
118
|
+
# direct parent named for a transitive pill. The full cap list is in the JSON.
|
|
119
|
+
def poison_section(result)
|
|
120
|
+
# Require constraints present, not just the poison flag, so an unexpected
|
|
121
|
+
# empty set can't emit a dangling "- `gem` caps" bullet (symmetric with the
|
|
122
|
+
# terminal's poison_line guard).
|
|
123
|
+
flagged = result.select { |_name, data| data[:poison] && !Array(data[:constraints]).empty? }
|
|
124
|
+
return "" if flagged.empty?
|
|
125
|
+
|
|
126
|
+
lines = ["", "**Poison-pill findings** (dormant deps capping your tree below its latest major):"]
|
|
127
|
+
# Worst-first so the act-now findings lead, then by magnitude for a stable,
|
|
128
|
+
# triage-friendly order.
|
|
129
|
+
ranked = flagged.sort_by do |name, data|
|
|
130
|
+
[
|
|
131
|
+
poison_rank(data), # below-the-fix leads, then security-relevant, then the rest
|
|
132
|
+
-ConstraintHelper::SEVERITY.index(data[:poison_severity] || :note),
|
|
133
|
+
-Array(data[:constraints]).map { |c| c[:majors_behind].to_i }.max,
|
|
134
|
+
name.to_s,
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
ranked.each do |name, data|
|
|
138
|
+
lines << "- **#{data[:poison_severity] || :note}** #{poison_gem_ref(name, data)} caps #{poison_caps(data[:constraints])}#{poison_security_marker(data)}"
|
|
139
|
+
end
|
|
140
|
+
lines.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Ranks poison findings for the report: a cap that holds you BELOW THE FIX first
|
|
144
|
+
# (unpatchable without replacing the capper), then a merely security-relevant cap
|
|
145
|
+
# (vulnerable but patchable in place), then everything else.
|
|
146
|
+
def poison_rank(data)
|
|
147
|
+
return 0 if data[:poison_below_fix]
|
|
148
|
+
return 1 if data[:poison_security_relevant]
|
|
149
|
+
|
|
150
|
+
2
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A prominent marker naming the known-vulnerable dependency a security-relevant
|
|
154
|
+
# cap pins you to. Leads with the stronger BELOW-THE-FIX claim (the CVE and its
|
|
155
|
+
# nearest fix, a version the cap forbids) when it applies, else the weaker
|
|
156
|
+
# "vulnerable but patchable" form.
|
|
157
|
+
def poison_security_marker(data)
|
|
158
|
+
return "" unless data[:poison_security_relevant]
|
|
159
|
+
|
|
160
|
+
below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
|
|
161
|
+
if below.any?
|
|
162
|
+
receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]})" }.uniq
|
|
163
|
+
" ⚠ **pins #{receipts.join("; ")}**"
|
|
164
|
+
else
|
|
165
|
+
pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
|
|
166
|
+
" ⚠ **pins vulnerable #{pinned.join(", ")}**"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The language-runtime ceiling: a resolved version whose declared runtime
|
|
171
|
+
# constraint (Ruby `ruby_version`, Python `requires_python`) caps the runtime
|
|
172
|
+
# onto an EOL release (critical) or below the latest stable (note). Ranked
|
|
173
|
+
# worst-first with a tier label, mirroring the poison section.
|
|
174
|
+
def language_ceiling_section(result)
|
|
175
|
+
flagged = result.select { |_name, data| data[:language_ceiling] }
|
|
176
|
+
return "" if flagged.empty?
|
|
177
|
+
|
|
178
|
+
lines = ["", "**Runtime ceiling findings** (a resolved version capping the language runtime it can run on):"]
|
|
179
|
+
ranked = flagged.sort_by { |name, data| [-ConstraintHelper::SEVERITY.index(data[:language_ceiling][:severity] || :note), name.to_s] }
|
|
180
|
+
ranked.each do |name, data|
|
|
181
|
+
ceiling = data[:language_ceiling]
|
|
182
|
+
lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(name)} #{language_ceiling_receipt(ceiling, data)}"
|
|
183
|
+
end
|
|
184
|
+
lines.join("\n")
|
|
185
|
+
end
|
|
186
|
+
|
|
115
187
|
private
|
|
116
188
|
|
|
189
|
+
def language_ceiling_receipt(ceiling, data)
|
|
190
|
+
runtime = ceiling[:runtime]
|
|
191
|
+
req = MarkdownEscape.code_span(ceiling[:requirement])
|
|
192
|
+
base =
|
|
193
|
+
if ceiling[:eol_forced]
|
|
194
|
+
eol = ceiling[:ceiling_eol_date]
|
|
195
|
+
eol_part = eol ? " (EOL #{eol.strftime("%Y-%m-%d")})" : ""
|
|
196
|
+
"requires #{runtime} #{req}, stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
|
|
197
|
+
else
|
|
198
|
+
"requires #{runtime} #{req}, no #{runtime} #{ceiling[:latest_stable]} support yet"
|
|
199
|
+
end
|
|
200
|
+
fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
|
|
201
|
+
"#{base}#{fix}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def poison_gem_ref(name, data)
|
|
205
|
+
ref = MarkdownEscape.code_span(name)
|
|
206
|
+
path = data[:dependency_path]
|
|
207
|
+
# Transitive pill: name the direct parent the user can actually act on (#60).
|
|
208
|
+
return ref unless data[:direct] == false && Array(path).length >= 2
|
|
209
|
+
|
|
210
|
+
"#{ref} via #{MarkdownEscape.code_span(path.first)}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The worst 3 caps; the first carries the full receipt (requirement + latest
|
|
214
|
+
# major), the rest just the gap, and "— N total" when there are more.
|
|
215
|
+
def poison_caps(constraints)
|
|
216
|
+
top = ConstraintHelper.top_findings(Array(constraints), limit: 3)
|
|
217
|
+
parts = top[:shown].each_with_index.map do |finding, i|
|
|
218
|
+
dep = MarkdownEscape.code_span(finding[:dependency])
|
|
219
|
+
req = MarkdownEscape.code_span(finding[:requirement])
|
|
220
|
+
behind = finding[:majors_behind]
|
|
221
|
+
i.zero? ? "#{dep} #{req} (#{behind} major#{"s" unless behind == 1} behind, latest #{poison_major(finding[:dep_latest])})" : "#{dep} #{req} (#{behind})"
|
|
222
|
+
end
|
|
223
|
+
joined = parts.join(", ")
|
|
224
|
+
top[:total] > 1 ? "#{joined} — #{top[:total]} total" : joined
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# "8.0.1" -> "8.x": the cap is a major-level gap.
|
|
228
|
+
def poison_major(version)
|
|
229
|
+
major = version.to_s[/\d+/]
|
|
230
|
+
major ? "#{major}.x" : version
|
|
231
|
+
end
|
|
232
|
+
|
|
117
233
|
def transitive_flagged?(data)
|
|
118
234
|
[:archived, :critical].include?(ActivityHelper.activity_level(data)) || data[:vulnerability_count].to_i.positive?
|
|
119
235
|
end
|
|
@@ -161,7 +277,8 @@ module StillActive
|
|
|
161
277
|
severity = VulnerabilityHelper.highest_severity(vulnerabilities)
|
|
162
278
|
ids = vulnerabilities.flat_map { |v| [v[:id], *v[:aliases]] }.compact.uniq.first(3)
|
|
163
279
|
|
|
164
|
-
|
|
280
|
+
notes = [severity, ("no fix" if VulnerabilityHelper.no_fix_available?(vulnerabilities))].compact
|
|
281
|
+
parts = [notes.empty? ? count.to_s : "#{count} (#{notes.join(", ")})"]
|
|
165
282
|
parts << MarkdownEscape.cell(ids.join(", ")) unless ids.empty?
|
|
166
283
|
parts.join(" ")
|
|
167
284
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# Translates a PEP 440 `requires_python` specifier into a RubyGems requirement
|
|
5
|
+
# STRING, so the generic RuntimeCeilingHelper can reason about a Python runtime
|
|
6
|
+
# ceiling with no core change -- the same way Ruby's `ruby_version` already
|
|
7
|
+
# feeds it. PEP 440 and RubyGems disagree on two operators that matter here:
|
|
8
|
+
#
|
|
9
|
+
# - `~=` (compatible release) is a syntax error to Gem::Requirement, so a
|
|
10
|
+
# naive parse raises and the ceiling is silently missed. It maps cleanly to
|
|
11
|
+
# RubyGems `~>` for the major.minor.patch shapes `requires_python` uses.
|
|
12
|
+
# - `== X.*` / `== X.Y.*` prefix wildcards are likewise unparseable and map to
|
|
13
|
+
# a pessimistic `~>` over the prefix.
|
|
14
|
+
#
|
|
15
|
+
# `!=` exclusions are dropped: a hole-punch can never create an upper bound, so
|
|
16
|
+
# it cannot be a runtime ceiling, and dropping it can only ADMIT more runtimes
|
|
17
|
+
# (fewer findings) -- conservative, never a false ceiling. Anything that still
|
|
18
|
+
# won't parse degrades to a dropped clause (or nil overall), never a raise: this
|
|
19
|
+
# feeds the core audit and must fail safe, matching RuntimeCeilingHelper's
|
|
20
|
+
# best-effort contract.
|
|
21
|
+
module Pep440Helper
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
# Match ConstraintHelper / RuntimeCeilingHelper: bound pathological registry
|
|
25
|
+
# input before it reaches Gem::Requirement's own regex.
|
|
26
|
+
MAX_SPECIFIER_LENGTH = 256
|
|
27
|
+
|
|
28
|
+
# Operator, then the version as a run of non-space chars. `\s*(\S+)` (not
|
|
29
|
+
# `\s*(.+)`) keeps the two groups over disjoint character classes so there's no
|
|
30
|
+
# polynomial backtracking on pathological input like "<" + many spaces (a PEP
|
|
31
|
+
# 440 version never contains internal spaces, so this loses nothing).
|
|
32
|
+
CLAUSE_PATTERN = /\A(===|==|~=|!=|<=|>=|<|>)\s*(\S+)\z/
|
|
33
|
+
|
|
34
|
+
# => a comma-joined RubyGems requirement string, or nil when nothing usable
|
|
35
|
+
# survives translation. The caller feeds the string to RuntimeCeilingHelper,
|
|
36
|
+
# which splits and splats it exactly like a `ruby_version` string.
|
|
37
|
+
def to_gem_requirement_string(specifier)
|
|
38
|
+
spec = specifier.to_s
|
|
39
|
+
return if spec.strip.empty? || spec.length > MAX_SPECIFIER_LENGTH
|
|
40
|
+
|
|
41
|
+
clauses = spec.split(",").filter_map { |clause| translate_clause(clause.strip) }
|
|
42
|
+
return if clauses.empty?
|
|
43
|
+
|
|
44
|
+
clauses.join(", ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def translate_clause(clause)
|
|
50
|
+
match = CLAUSE_PATTERN.match(clause)
|
|
51
|
+
return if match.nil?
|
|
52
|
+
|
|
53
|
+
operator = match[1]
|
|
54
|
+
version = match[2].strip
|
|
55
|
+
|
|
56
|
+
case operator
|
|
57
|
+
when "!=" then nil # hole-punch: never an upper bound, drop it
|
|
58
|
+
when "~=" then compatible_release(version)
|
|
59
|
+
when "==", "===" then equality(version)
|
|
60
|
+
else comparison(operator, version)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `~= X.Y[.Z]` is `>= X.Y[.Z], == X.Y.*` which is exactly RubyGems `~> X.Y[.Z]`.
|
|
65
|
+
def compatible_release(version)
|
|
66
|
+
return unless parseable?(version)
|
|
67
|
+
|
|
68
|
+
"~> #{version}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def equality(version)
|
|
72
|
+
if version.end_with?(".*")
|
|
73
|
+
prefix_match(version.delete_suffix(".*"))
|
|
74
|
+
elsif parseable?(version)
|
|
75
|
+
"= #{version}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `== P.*` matches every release whose version starts with prefix P, i.e.
|
|
80
|
+
# `>= P, < P-with-last-segment-incremented`. RubyGems spells that as `~> P.0`
|
|
81
|
+
# (append one segment so the pessimistic bump lands on P's last segment):
|
|
82
|
+
# ==3.* -> ~> 3.0 (>=3, <4)
|
|
83
|
+
# ==3.7.* -> ~> 3.7.0 (>=3.7, <3.8)
|
|
84
|
+
def prefix_match(prefix)
|
|
85
|
+
return unless parseable?(prefix)
|
|
86
|
+
|
|
87
|
+
"~> #{prefix}.0"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def comparison(operator, version)
|
|
91
|
+
return unless parseable?(version)
|
|
92
|
+
|
|
93
|
+
"#{operator} #{version}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parseable?(version)
|
|
97
|
+
Gem::Version.correct?(version)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "endoflife_helper"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Python's calibration for the language-runtime ceiling: the thin sibling of
|
|
7
|
+
# RubyHelper.supported_ruby_range. Python declares its runtime constraint as a
|
|
8
|
+
# PEP 440 `requires_python` specifier (translate with Pep440Helper before
|
|
9
|
+
# handing it to RuntimeCeilingHelper), and its release calendar lives in the
|
|
10
|
+
# same endoflife.date feed. Everything ecosystem-neutral is in EndoflifeHelper;
|
|
11
|
+
# Python only chooses the feed path.
|
|
12
|
+
module PythonHelper
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
def supported_python_range
|
|
16
|
+
EndoflifeHelper.support_window(feed_path: "/api/python.json")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -13,6 +13,9 @@ module StillActive
|
|
|
13
13
|
extend self
|
|
14
14
|
|
|
15
15
|
STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
|
|
16
|
+
# Requirement operators whose safe versions are OLDER than the flaw, i.e. not a
|
|
17
|
+
# forward fix a consumer can upgrade to. Anything else ("> X" etc.) is.
|
|
18
|
+
OLDER_THAN_FLAW_OPERATORS = ["<", "<="].freeze
|
|
16
19
|
|
|
17
20
|
# bundler-audit's Database#check_gem expects an object responding to
|
|
18
21
|
# #name and #version (a Gem::Version).
|
|
@@ -66,12 +69,32 @@ module StillActive
|
|
|
66
69
|
cvss3_score: details[:cvss_v3],
|
|
67
70
|
cvss3_vector: nil,
|
|
68
71
|
cvss2_score: details[:cvss_v2],
|
|
72
|
+
# No safe version a consumer can upgrade TO: the correlation bundler-audit
|
|
73
|
+
# + `bundle outdated` can't produce alone. A factual read of the DB.
|
|
74
|
+
no_fix_available: no_forward_fix?(advisory),
|
|
69
75
|
source: "ruby-advisory-db",
|
|
70
76
|
}
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
private
|
|
74
80
|
|
|
81
|
+
# True when the advisory records no version the consumer can move forward to.
|
|
82
|
+
# Mirrors bundler-audit's own `!patched? && !unaffected?` rather than only the
|
|
83
|
+
# patched half: a clean release shipped AFTER a backdoored/yanked version is
|
|
84
|
+
# recorded in unaffected_versions as a "> X" range, not in patched_versions
|
|
85
|
+
# (the CVE-2019-15224 bootstrap-sass pattern). Be conservative -- only a purely
|
|
86
|
+
# older-than-the-flaw safe range ("< X") counts as no-forward-fix -- so we never
|
|
87
|
+
# claim "no fix" while a later safe release exists.
|
|
88
|
+
def no_forward_fix?(advisory)
|
|
89
|
+
return false unless advisory.patched_versions.empty?
|
|
90
|
+
|
|
91
|
+
advisory.unaffected_versions.all? { |requirement| older_than_flaw_only?(requirement) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def older_than_flaw_only?(requirement)
|
|
95
|
+
requirement.requirements.all? { |operator, _| OLDER_THAN_FLAW_OPERATORS.include?(operator) }
|
|
96
|
+
end
|
|
97
|
+
|
|
75
98
|
# nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
|
|
76
99
|
# has nothing to match in the advisory DB, so the caller returns [].
|
|
77
100
|
def parse_version(version)
|
data/lib/helpers/ruby_helper.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "bundler_helper"
|
|
5
|
+
require_relative "endoflife_helper"
|
|
5
6
|
require_relative "http_helper"
|
|
6
7
|
require_relative "libyear_helper"
|
|
7
8
|
|
|
@@ -24,15 +25,15 @@ module StillActive
|
|
|
24
25
|
return if latest_cycle.nil?
|
|
25
26
|
|
|
26
27
|
latest_version = latest_cycle["latest"]
|
|
27
|
-
latest_release_date = parse_date(latest_cycle["releaseDate"])
|
|
28
|
-
current_release_date = parse_date(current_cycle&.dig("releaseDate"))
|
|
28
|
+
latest_release_date = EndoflifeHelper.parse_date(latest_cycle["releaseDate"])
|
|
29
|
+
current_release_date = EndoflifeHelper.parse_date(current_cycle&.dig("releaseDate"))
|
|
29
30
|
eol_value = current_cycle&.dig("eol")
|
|
30
31
|
|
|
31
32
|
{
|
|
32
33
|
version: current,
|
|
33
34
|
release_date: current_release_date,
|
|
34
|
-
eol_date: parse_eol(eol_value),
|
|
35
|
-
eol: eol_reached?(eol_value),
|
|
35
|
+
eol_date: EndoflifeHelper.parse_eol(eol_value),
|
|
36
|
+
eol: EndoflifeHelper.eol_reached?(eol_value),
|
|
36
37
|
latest_version: latest_version,
|
|
37
38
|
latest_release_date: latest_release_date,
|
|
38
39
|
libyear: LibyearHelper.gem_libyear(
|
|
@@ -42,6 +43,14 @@ module StillActive
|
|
|
42
43
|
}
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# The runtime facts a per-gem language ceiling is measured against, sourced
|
|
47
|
+
# from the shared endoflife.date support-window builder. Ruby only picks the
|
|
48
|
+
# feed; the ecosystem-neutral logic (support floor, latest stable, grace
|
|
49
|
+
# window, cycle normalization) lives in EndoflifeHelper.
|
|
50
|
+
def supported_ruby_range
|
|
51
|
+
EndoflifeHelper.support_window(feed_path: "/api/ruby.json")
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
private
|
|
46
55
|
|
|
47
56
|
def current_ruby_version
|
|
@@ -68,25 +77,5 @@ module StillActive
|
|
|
68
77
|
major_minor = version.split(".")[0..1].join(".")
|
|
69
78
|
cycles.find { |c| c["cycle"] == major_minor }
|
|
70
79
|
end
|
|
71
|
-
|
|
72
|
-
def parse_date(date_string)
|
|
73
|
-
return if date_string.nil?
|
|
74
|
-
|
|
75
|
-
Time.parse(date_string)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def parse_eol(value)
|
|
79
|
-
case value
|
|
80
|
-
when String then parse_date(value)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def eol_reached?(value)
|
|
85
|
-
case value
|
|
86
|
-
when true then true
|
|
87
|
-
when false then false
|
|
88
|
-
when String then Time.parse(value) <= Time.now
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
80
|
end
|
|
92
81
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constraint_helper"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# The language-runtime sibling of the poison-pill signal, kept deliberately
|
|
7
|
+
# generic: it reads a constraint a package declares on its RUNTIME (the version
|
|
8
|
+
# range it can run on) and, given that runtime's support window, answers how much
|
|
9
|
+
# the constraint holds you back. Ruby (`ruby_version`) is the first consumer, but
|
|
10
|
+
# nothing here is Ruby-specific: the same core serves any runtime with an
|
|
11
|
+
# end-of-life calendar (Python's `requires_python`, etc.). The runtime-specific
|
|
12
|
+
# parts -- where the constraint string comes from, and how the support window is
|
|
13
|
+
# built -- live in the calibration layer (RubyHelper.supported_ruby_range) and
|
|
14
|
+
# the workflow boundary, exactly as poison keeps ConstraintHelper generic and
|
|
15
|
+
# pushes ecosystem resolution to the lens.
|
|
16
|
+
#
|
|
17
|
+
# Two tiers, mirroring the severity model:
|
|
18
|
+
# - EOL-forced (critical): the constraint admits no still-supported runtime,
|
|
19
|
+
# stranding you on an end-of-life release with no security patches. A genuine
|
|
20
|
+
# upgrade blocker (e.g. a gem's `ruby_version < 3.2` caps at the EOL Ruby 3.1).
|
|
21
|
+
# - latest-not-yet (note): runs on a supported runtime but caps below the latest
|
|
22
|
+
# stable. An FYI ceiling to plan around, or a place to contribute support for
|
|
23
|
+
# the newest release before you invest.
|
|
24
|
+
#
|
|
25
|
+
# A bare floor (`>= 3.1`) or a requires-newer constraint (`>= 4.1`, `~> 5.0`) is
|
|
26
|
+
# NOT a ceiling: it raises the minimum, it doesn't cap you onto a dead release.
|
|
27
|
+
# The distinction is drawn against the live EOL cycles, not the operator alone.
|
|
28
|
+
module RuntimeCeilingHelper
|
|
29
|
+
extend self
|
|
30
|
+
|
|
31
|
+
# Gem::Requirement caps input via a regex; a registry-derived string could be
|
|
32
|
+
# pathological. Bound it up front like ConstraintHelper does.
|
|
33
|
+
MAX_REQUIREMENT_LENGTH = 256
|
|
34
|
+
|
|
35
|
+
# => { requirement:, eol_forced:, severity:, ... } or nil when there's no
|
|
36
|
+
# ceiling. `support_window` is a { oldest_supported:, latest_stable:, cycles: }
|
|
37
|
+
# hash of Gem::Versions plus normalized EOL cycles (see supported_ruby_range).
|
|
38
|
+
def analyze(requirement:, support_window:)
|
|
39
|
+
return if support_window.nil?
|
|
40
|
+
|
|
41
|
+
req = safe_requirement(requirement)
|
|
42
|
+
return if req.nil? || !capping?(req)
|
|
43
|
+
|
|
44
|
+
supported_allowed = support_window[:cycles].reject { |c| c[:eol] }.select { |c| req.satisfied_by?(c[:version]) }
|
|
45
|
+
|
|
46
|
+
if supported_allowed.empty?
|
|
47
|
+
eol_forced_finding(req, requirement, support_window)
|
|
48
|
+
elsif !support_window[:latest_stable].nil? && !req.satisfied_by?(support_window[:latest_stable]) && !support_window[:latest_stable_fresh]
|
|
49
|
+
# Runs on a supported runtime but not the latest stable. Suppressed while
|
|
50
|
+
# the latest stable is still within its grace window (see supported_ruby_
|
|
51
|
+
# range): right after a runtime ships, "doesn't support it yet" indicts the
|
|
52
|
+
# release calendar, not the gem. After the window it is a real note.
|
|
53
|
+
latest_not_yet_finding(requirement, support_window)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# A ceiling exists only if the constraint has an upper bound. A pure lower
|
|
60
|
+
# bound (`>`, `>=`) can never strand you on an old release, so skip it before
|
|
61
|
+
# any cycle math (this is also what keeps `>= 4.1` requires-newer out). Exact
|
|
62
|
+
# pins (`=`) are excluded deliberately: pinning a runtime to an exact patch is
|
|
63
|
+
# pathological (unheard of in practice) and can't be matched against the
|
|
64
|
+
# major.minor EOL cycles anyway, so we don't fabricate support for it.
|
|
65
|
+
CAPPING_OPERATORS = ["<", "<=", "~>"].freeze
|
|
66
|
+
|
|
67
|
+
def capping?(req)
|
|
68
|
+
req.requirements.any? { |operator, _version| CAPPING_OPERATORS.include?(operator) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def eol_forced_finding(req, requirement, support_window)
|
|
72
|
+
# No supported release is admitted. Only a genuine cap (admits some EOL
|
|
73
|
+
# release) is EOL-forced; a requires-newer floor admits nothing at or below
|
|
74
|
+
# the oldest supported and is not our concern.
|
|
75
|
+
allowed_eol = support_window[:cycles].select { |c| c[:eol] && req.satisfied_by?(c[:version]) }
|
|
76
|
+
return if allowed_eol.empty?
|
|
77
|
+
|
|
78
|
+
ceiling = allowed_eol.max_by { |c| c[:version] }
|
|
79
|
+
finding = {
|
|
80
|
+
requirement: requirement,
|
|
81
|
+
eol_forced: true,
|
|
82
|
+
ceiling_version: ceiling[:version].to_s,
|
|
83
|
+
ceiling_eol_date: ceiling[:eol_date],
|
|
84
|
+
oldest_supported: support_window[:oldest_supported].to_s,
|
|
85
|
+
latest_stable: support_window[:latest_stable].to_s,
|
|
86
|
+
}
|
|
87
|
+
finding.merge(severity: ConstraintHelper.constraint_severity(finding))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def latest_not_yet_finding(requirement, support_window)
|
|
91
|
+
finding = {
|
|
92
|
+
requirement: requirement,
|
|
93
|
+
eol_forced: false,
|
|
94
|
+
oldest_supported: support_window[:oldest_supported].to_s,
|
|
95
|
+
latest_stable: support_window[:latest_stable].to_s,
|
|
96
|
+
}
|
|
97
|
+
finding.merge(severity: ConstraintHelper.constraint_severity(finding))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Registries render a floor+ceiling requirement as a single comma-joined string
|
|
101
|
+
# (e.g. ">= 2.5, < 3.2" -- common for `ruby_version`/`required_ruby_version`).
|
|
102
|
+
# Gem::Requirement.new can't parse that one-string form (it raises), so split
|
|
103
|
+
# into clauses and splat, preserving BOTH bounds. Reading only the string as-is
|
|
104
|
+
# would drop the `< 3.2` ceiling and silently miss the very case this exists to
|
|
105
|
+
# catch. Best-effort: a genuinely malformed clause degrades to nil (no finding),
|
|
106
|
+
# never a raised exception that could break the core audit.
|
|
107
|
+
def safe_requirement(requirement)
|
|
108
|
+
return if requirement.nil? || requirement.to_s.length > MAX_REQUIREMENT_LENGTH
|
|
109
|
+
|
|
110
|
+
clauses = requirement.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
111
|
+
return if clauses.empty?
|
|
112
|
+
|
|
113
|
+
Gem::Requirement.new(*clauses)
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
# Covers Gem::Requirement::BadRequirementError (a subclass) plus any other
|
|
116
|
+
# malformed-input ArgumentError, so a pathological registry string degrades
|
|
117
|
+
# to "no ceiling" and never breaks the core audit.
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|