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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +57 -10
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +9 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +201 -8
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. 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
@@ -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 Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
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
- parts = [severity ? "#{count} (#{severity})" : count.to_s]
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)
@@ -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