still_active 1.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. metadata +72 -11
@@ -80,7 +80,10 @@ module StillActive
80
80
 
81
81
  def download
82
82
  url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
83
- URI.open(url) { |io| io.read(MAX_DOWNLOAD_BYTES) } # rubocop:disable Security/Open
83
+ # URI(url).open (not URI.open/Kernel#open) so a "|cmd" string can never be
84
+ # treated as a shell command — the URL is a constant-repo archive link, but
85
+ # this keeps the open path injection-proof regardless.
86
+ URI.parse(url).open { |io| io.read(MAX_DOWNLOAD_BYTES) }
84
87
  end
85
88
 
86
89
  def build_siblings(categories)
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Reads a declared dependency constraint (the requirement string a package
5
+ # publishes for one of its runtime deps, e.g. "~> 4.2", "< 5.0", "= 1.2.3") and
6
+ # answers how much it caps you: is there an upper bound, and how many majors
7
+ # behind the dependency's current latest does it hold you?
8
+ #
9
+ # This is the constraint half of the poison-pill signal (constraint-tightness x
10
+ # maintenance-state): a dormant package that caps a still-evolving dep below its
11
+ # latest major holds your tree hostage with no hope of the cap lifting. A fixed
12
+ # cap gets more poisonous purely with time, as the capped dep ships new majors
13
+ # while the cap stays frozen.
14
+ #
15
+ # Grammar is coarse but cross-ecosystem: Ruby pessimistic (~>), pip compatible
16
+ # (~=), semver caret/tilde (^ ~), exact (= == ===), and plain </<=. Precision is
17
+ # at the MAJOR level, which is all the signal needs ("N majors behind").
18
+ module ConstraintHelper
19
+ extend self
20
+
21
+ # The constraint kinds that make a dep a poison-pill candidate: an upper bound
22
+ # that blocks upgrades, or an exact pin that freezes the whole tree. A
23
+ # `:permissive` constraint is neither. Shared with the workflow so the poison
24
+ # definition lives in one place.
25
+ POISON_KINDS = [:ceiling, :exact_pin].freeze
26
+
27
+ # An exact pin: = / == / === before a digit (NOT >= or <=).
28
+ EXACT_PIN = /\A={1,3}\s*v?\d/
29
+ # A bare version with no operator (npm/cargo exact form), e.g. "1.2.3", "v1".
30
+ BARE_VERSION = /\Av?\d+(?:\.\d+)*\z/
31
+ # Operator + version at the head of a clause.
32
+ CLAUSE = /\A(===?|=|<=|<|~>|~=|\^|~)?\s*v?(\d+(?:\.\d+)*)/
33
+ # Whitespace that separates npm AND clauses (">=1.2.0 <2.0.0"), i.e. a space
34
+ # before an operator. It won't split a Ruby clause like "~> 4.2" (the space
35
+ # there precedes a digit, not an operator). The quantifier is POSSESSIVE
36
+ # (`\s++`, not `\s+`): a lookahead after a greedy `\s+` backtracks once per
37
+ # trailing space, which is quadratic on a long run of spaces (ReDoS on the
38
+ # lockfile-derived requirement string). Possessive matching never backtracks.
39
+ AND_SEPARATOR = /\s++(?=[<>=~^])/
40
+ # A real declared requirement ("< 5.0, >= 4.0.1", "^1 || ^2 || ^3") is short.
41
+ # Anything past this is not a parseable constraint, so cap the input up front:
42
+ # it bounds every regex below to linear work and refuses to mint a pill from
43
+ # garbage (an over-long string reads as permissive, never a false ceiling).
44
+ MAX_REQUIREMENT_LENGTH = 256
45
+
46
+ # => { kind: :permissive|:ceiling|:exact_pin, majors_behind: Integer }
47
+ def analyze(requirement:, dep_latest:)
48
+ return { kind: :permissive, majors_behind: 0 } if requirement.to_s.length > MAX_REQUIREMENT_LENGTH
49
+
50
+ # `||` is an OR-range (npm): satisfied by ANY branch, so an unbounded branch
51
+ # lifts the cap entirely and the effective ceiling is the LOOSEST branch.
52
+ # Reading only the first branch would invent a false pill on the common
53
+ # `^2.0.0 || ^3.0.0` "supports several majors" form.
54
+ branches = requirement.to_s.split("||").map { |branch| clauses_of(branch) }
55
+ branches = [[]] if branches.empty?
56
+ ceilings = branches.map { |clauses| branch_ceiling(clauses) }
57
+ latest_major = major(dep_latest)
58
+
59
+ return { kind: :permissive, majors_behind: 0 } if ceilings.any?(&:nil?)
60
+
61
+ behind = latest_major ? [latest_major - ceilings.max, 0].max : 0
62
+ kind = branches.all? { |clauses| all_exact?(clauses) } ? :exact_pin : :ceiling
63
+ { kind: kind, majors_behind: behind }
64
+ end
65
+
66
+ # Severity tiers for a compatibility ceiling, worst-last (mirrors
67
+ # StatusHelper::SEVERITY -- an ordinal set compared by index, never a numeric
68
+ # composite). :note is FYI, :warning is review-and-plan, :critical is act-now.
69
+ SEVERITY = [:note, :warning, :critical].freeze
70
+
71
+ # Tier one ceiling finding. Magnitude-driven: the real-project dogfood showed
72
+ # Ruby findings are bimodal -- 1 major behind is trivial (a done gem pinning an
73
+ # old minor), 3+ is a genuine upgrade wall (e.g. capping actionmailer below
74
+ # Rails 6). Popularity was rejected as an input (it ranks the framework blocker
75
+ # below a utility) and a vulnerable-gem escalator was rejected (that is the
76
+ # vulnerability finding's job, not the cap's).
77
+ def constraint_severity(finding)
78
+ # A runtime ceiling that strands you on an end-of-life Ruby is act-now: no
79
+ # patched runtime is reachable. This short-circuit lets the language-ceiling
80
+ # signal (which carries no majors_behind) reuse the same tierer as poison.
81
+ return :critical if finding[:eol_forced]
82
+
83
+ case finding[:majors_behind]
84
+ when 2 then :warning
85
+ when 3.. then :critical
86
+ else :note
87
+ end
88
+ end
89
+
90
+ # The worst tier across a gem's CEILING findings (exact-pins are a milder
91
+ # hazard, not poison, and don't carry a tier), or nil when there are none.
92
+ def worst_severity(findings)
93
+ tiers = findings.filter_map { constraint_severity(_1) if _1[:kind] == :ceiling }
94
+ tiers.max_by { SEVERITY.index(_1) }
95
+ end
96
+
97
+ # For the --fail-if-poison[=TIER] gate: is `severity` at or above `threshold`?
98
+ def severity_at_or_above?(severity, threshold)
99
+ return false if severity.nil?
100
+
101
+ SEVERITY.index(severity) >= SEVERITY.index(threshold)
102
+ end
103
+
104
+ # True when `version` sits at or below the highest major the cap allows -- i.e.
105
+ # you could upgrade the capped dep to it WITHOUT breaking the cap. The ceiling
106
+ # major is recovered from the poison finding itself (dep_latest minus
107
+ # majors_behind), at the same major precision the poison signal already uses, so
108
+ # a fix that lands OUTSIDE the cap ("below the fix") is detected without
109
+ # re-parsing the raw requirement and its pre/dev-release quirks (`< 5.0.0dev`).
110
+ #
111
+ # Precision is MAJOR-level by design, and that errs deliberately toward reachable:
112
+ # a within-major cap (`~> 4.2.1`, `< 5.5`) reads a same-major fix (4.9.0, 5.29.6)
113
+ # as reachable even though the cap forbids it. So a genuinely-stuck within-major
114
+ # cap is UNDER-reported (downgraded to the still-visible "vulnerable" tier), never
115
+ # falsely promoted -- when this DOES return false for every fix, the below-the-fix
116
+ # claim is sound (every fix is in a strictly higher major than the cap allows).
117
+ # Unparseable/absent inputs read false (never claims reachable when we can't tell).
118
+ def reachable_within_cap?(finding, version)
119
+ fix_major = major(version)
120
+ latest_major = major(finding[:dep_latest])
121
+ behind = finding[:majors_behind]
122
+ return false if fix_major.nil? || latest_major.nil? || behind.nil?
123
+
124
+ fix_major <= latest_major - behind
125
+ end
126
+
127
+ # The poison condition: a below-latest ceiling or exact pin. A permissive
128
+ # constraint, or a cap at/above the dep's latest major, is not a pill.
129
+ def poison_ceiling?(requirement:, dep_latest:)
130
+ result = analyze(requirement: requirement, dep_latest: dep_latest)
131
+ POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
132
+ end
133
+
134
+ # Build the poison-pill receipts for a package's declared runtime deps. Each
135
+ # `dep` is { package_name:, requirements: }; the block resolves a dep name to
136
+ # its current latest version string (or nil when unresolvable, which drops the
137
+ # dep rather than guessing). Returns only the below-latest ceilings and exact
138
+ # pins, each as a self-contained receipt. Shared by the native Bundler path and
139
+ # the cross-ecosystem lens, which differ only in how they resolve dep_latest.
140
+ def poison_findings(deps)
141
+ deps.filter_map do |dep|
142
+ dep_latest = yield(dep[:package_name])
143
+ next if dep_latest.nil?
144
+
145
+ result = analyze(requirement: dep[:requirements], dep_latest: dep_latest)
146
+ next unless POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
147
+
148
+ {
149
+ dependency: dep[:package_name],
150
+ requirement: dep[:requirements],
151
+ dep_latest: dep_latest,
152
+ majors_behind: result[:majors_behind],
153
+ kind: result[:kind],
154
+ }
155
+ end
156
+ end
157
+
158
+ # Select the worst `limit` findings for a display receipt, plus the full count
159
+ # so a renderer can say "+N more" / "N total". Worst-first by majors_behind,
160
+ # ties broken by dependency name so the output is stable and diffable. Shared
161
+ # by the terminal and markdown renderers so the selection can't drift.
162
+ def top_findings(findings, limit: 3)
163
+ ranked = findings.sort_by { |f| [-f[:majors_behind], f[:dependency].to_s] }
164
+ { shown: ranked.first(limit), total: findings.length }
165
+ end
166
+
167
+ private
168
+
169
+ # Split one OR-branch into its AND clauses: comma-separated (Ruby/pip) or
170
+ # space-separated (npm ">=1.2.0 <2.0.0").
171
+ def clauses_of(branch)
172
+ branch.split(",").flat_map { |part| part.strip.split(AND_SEPARATOR) }.map(&:strip).reject(&:empty?)
173
+ end
174
+
175
+ # The tightest ceiling major within one AND-branch, or nil when the branch has
176
+ # no upper bound at all (a lone lower bound = permissive, lifts the OR cap).
177
+ def branch_ceiling(clauses)
178
+ clauses.filter_map { |clause| clause_ceiling_major(clause) }.min
179
+ end
180
+
181
+ def all_exact?(clauses)
182
+ !clauses.empty? && clauses.all? { |clause| clause.match?(EXACT_PIN) || clause.match?(BARE_VERSION) }
183
+ end
184
+
185
+ def clause_ceiling_major(clause)
186
+ match = clause.match(CLAUSE)
187
+ return unless match
188
+
189
+ operator = match[1]
190
+ major_of = major(match[2])
191
+ case operator
192
+ # `< X.0.0` excludes major X entirely (max usable major is X-1); `< X.Y`
193
+ # with a non-zero minor still allows major X.
194
+ when "<" then all_zero_after_major?(match[2]) ? major_of - 1 : major_of
195
+ when "<=", "~>", "~=", "^", "~", "=", "==", "===", nil then major_of
196
+ end # `>`/`>=` fall through to nil: a lower bound imposes no ceiling
197
+ end
198
+
199
+ def all_zero_after_major?(version)
200
+ version.split(".").drop(1).all? { |part| part.to_i.zero? }
201
+ end
202
+
203
+ def major(version)
204
+ digits = version.to_s[/\d+/]
205
+ digits&.to_i
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Computes a CVSS score from a vector string, across v2/v3/v4. deps.dev stores only
5
+ # CVSS 3.x, so a CVSS-4-only advisory (the flagship protobuf case) arrives with no
6
+ # numeric score; OSV carries the v4 vector, and this turns it into the number the
7
+ # SARIF security-severity and CycloneDX rating want.
8
+ #
9
+ # cvss-suite is an OPTIONAL, undeclared dependency: it over-constrains its own deps
10
+ # (an exact `bundler` pin, a `bigdecimal ~> 3.1` cap), the poison-pill pattern
11
+ # still_active itself flags, so hard-requiring it would force that liability on every
12
+ # user and trip our own audit. CvssHelper soft-requires it; install cvss-suite (or run
13
+ # the distribution that bundles it) to light it up.
14
+ #
15
+ # Returns cvss-suite's `overall_score`: for the base-only vectors OSV publishes that
16
+ # IS the base score, but a vector carrying threat/environmental metrics would fold
17
+ # those in -- which is why the severity band floors at the authoritative GHSA label
18
+ # (VulnerabilityHelper.advisory_severity) and this number never lowers it: the label
19
+ # drives gating and SARIF level, the number only sharpens display. Fails safe: an
20
+ # absent gem or an absent/unparseable vector yields nil, never a crash.
21
+ module CvssHelper
22
+ extend self
23
+
24
+ def score(vector)
25
+ return unless available?
26
+ return if vector.nil? || vector.to_s.empty?
27
+
28
+ cvss = CvssSuite.new(vector.to_s)
29
+ cvss.valid? ? cvss.overall_score : nil
30
+ rescue StandardError => e
31
+ # We only reach here with cvss-suite loaded (available?), so a raise is the
32
+ # installed gem failing this call site -- an incompatible version, a renamed
33
+ # `overall_score` -- not a bad vector, which cvss-suite reports via valid?.
34
+ # Fail safe to nil (the number is display-only, never gates), but don't
35
+ # swallow it: an opted-in-but-broken scorer that emitted nothing would read
36
+ # as "no advisories are scored". Warn once per run (not per advisory),
37
+ # matching OsvClient.enrich rather than staying silent.
38
+ warn_scorer_failed(e)
39
+ nil
40
+ end
41
+
42
+ # Memoized soft-require: true once cvss-suite loads, false when it isn't installed.
43
+ def available?
44
+ return @available unless @available.nil?
45
+
46
+ @available = begin
47
+ require "cvss_suite"
48
+ true
49
+ rescue LoadError
50
+ false
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def warn_scorer_failed(error)
57
+ return if @warned
58
+
59
+ @warned = true
60
+ $stderr.puts("warning: cvss-suite scoring failed: #{error.class} (#{error.message}); CVSS numbers unavailable this run, check your cvss-suite version")
61
+ end
62
+ end
63
+ end
@@ -55,7 +55,7 @@ module StillActive
55
55
  component = { "type" => "library", "name" => name }
56
56
  component["version"] = version if version
57
57
  component["bom-ref"] = bom_ref(name, data)
58
- component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version
58
+ component["purl"] = purl(name, version, data[:source_type]) if version
59
59
  component["licenses"] = licenses(data[:license]) if data[:license]
60
60
  if data[:repository_url]
61
61
  component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
@@ -67,13 +67,19 @@ module StillActive
67
67
 
68
68
  def bom_ref(name, data)
69
69
  version = data[:version_used]
70
- return purl(name, version) if data[:source_type] == :rubygems && version
70
+ return purl(name, version, data[:source_type]) if version
71
71
 
72
- "#{data[:source_type]}-source:#{name}@#{version || "unknown"}"
72
+ "#{data[:source_type]}-source:#{name}@unknown"
73
73
  end
74
74
 
75
- def purl(name, version)
76
- "pkg:gem/#{name}@#{version}"
75
+ # Datadog SCA (and strict CycloneDX consumers) hard-reject a versioned
76
+ # library component with no purl, so every gem with a version needs one.
77
+ # A path gem is local and not on rubygems, so it gets pkg:generic to avoid
78
+ # false-matching a public gem of the same name; git/rubygems-sourced gems
79
+ # get pkg:gem so a fork still matches the upstream gem's advisories.
80
+ def purl(name, version, source_type)
81
+ type = source_type == :path ? "generic" : "gem"
82
+ "pkg:#{type}/#{name}@#{version}"
77
83
  end
78
84
 
79
85
  # VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
@@ -93,12 +99,22 @@ module StillActive
93
99
  }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
94
100
  end
95
101
 
102
+ # The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
103
+ # nothing consumes it and strict SCA validators (Datadog) only accept
104
+ # "library" + a purl. We follow Syft's convention for an unmanaged runtime:
105
+ # type "library", purl pkg:generic/ruby@<ver>, plus a CPE — the CPE is what
106
+ # actually lets a matcher (NVD/Grype) hit interpreter CVEs, since no purl
107
+ # type maps the Ruby runtime in OSV. The EOL/libyear signals stay as
108
+ # still_active properties.
96
109
  def ruby_component(ruby_info)
110
+ version = ruby_info[:version]
97
111
  {
98
- "type" => "platform",
112
+ "type" => "library",
99
113
  "name" => "ruby",
100
- "version" => ruby_info[:version],
101
- "bom-ref" => "platform:ruby@#{ruby_info[:version]}",
114
+ "version" => version,
115
+ "bom-ref" => "pkg:generic/ruby@#{version}",
116
+ "purl" => "pkg:generic/ruby@#{version}",
117
+ "cpe" => "cpe:2.3:a:ruby-lang:ruby:#{version}:*:*:*:*:*:*:*",
102
118
  "properties" => [
103
119
  { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
104
120
  { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
@@ -126,15 +142,31 @@ module StillActive
126
142
  end
127
143
 
128
144
  def rating(advisory)
129
- score = advisory[:cvss3_score] || advisory[:cvss2_score]
145
+ # effective_score drops deps.dev's cvss3=0 sentinel; an unscored advisory
146
+ # gets no numeric rating (honest) rather than a masquerading 0.0. A CVSS-4-only
147
+ # advisory now carries the OSV-computed v4 score, so it gets a real rating too.
148
+ score = VulnerabilityHelper.effective_score(advisory)
130
149
  return if score.nil?
131
150
 
132
- method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2"
133
- rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method }
134
- rating["vector"] = advisory[:cvss3_vector] if advisory[:cvss3_vector]
151
+ rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => rating_method(advisory) }
152
+ vector = advisory[:cvss3_vector] || advisory[:cvss_vector]
153
+ rating["vector"] = vector if vector
135
154
  rating
136
155
  end
137
156
 
157
+ # Names the CVSS version behind the score so a v4-derived rating isn't mislabelled
158
+ # CVSSv2. deps.dev's v3/v2 win; otherwise the OSV-enriched vector's version.
159
+ def rating_method(advisory)
160
+ return "CVSSv3" if advisory[:cvss3_score]&.positive?
161
+ return "CVSSv2" if advisory[:cvss2_score]&.positive?
162
+
163
+ case advisory[:cvss_version]
164
+ when "4.0" then "CVSSv4"
165
+ when "2.0" then "CVSSv2"
166
+ else "CVSSv3"
167
+ end
168
+ end
169
+
138
170
  def boolean_property(value)
139
171
  return if value.nil?
140
172
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "markdown_escape"
4
+
3
5
  module StillActive
4
6
  # Renders a StillActive::Diff::Result as PR-comment-friendly markdown.
5
7
  # Section taxonomy mirrors GitHub's dependency-review-action so reviewers
@@ -17,13 +19,14 @@ module StillActive
17
19
  unknown: nil,
18
20
  }.freeze
19
21
 
20
- def render(diff)
22
+ def render(diff, accepted: [])
21
23
  sections = [
22
24
  "## still_active diff",
23
25
  "",
24
- summary_line(diff),
26
+ summary_line(diff, accepted),
25
27
  "",
26
28
  regressions_section(diff.regressions),
29
+ accepted_section(accepted),
27
30
  added_section(diff.added),
28
31
  removed_section(diff.removed),
29
32
  bumps_section(diff.bumped),
@@ -36,23 +39,38 @@ module StillActive
36
39
 
37
40
  private
38
41
 
39
- def summary_line(diff)
40
- [
42
+ def summary_line(diff, accepted)
43
+ parts = [
41
44
  ["regressions", diff.regressions.size],
42
45
  ["added", diff.added.size],
43
46
  ["removed", diff.removed.size],
44
47
  ["bumped", diff.bumped.size],
45
48
  ["signal-changes", diff.signal_changes.size],
46
- ].map { |label, n| "#{n} #{label}" }.join(" · ")
49
+ ]
50
+ parts << ["accepted", accepted.size] unless accepted.empty?
51
+ parts.map { |label, n| "#{n} #{label}" }.join(" · ")
47
52
  end
48
53
 
49
54
  def regressions_section(regressions)
50
55
  return "" if regressions.empty?
51
56
 
52
- lines = regressions.map { |r| "- **#{r.kind}** `#{r.gem}` — #{r.detail}" }
57
+ lines = regressions.map { |r| "- **#{r.kind}** #{MarkdownEscape.code_span(r.gem)} — #{MarkdownEscape.inline(r.detail)}" }
53
58
  section("Regressions (CI-failable)", lines)
54
59
  end
55
60
 
61
+ # Regressions a committed .still_active.yml knowingly accepts: shown so the
62
+ # acceptance is visible in the PR comment, but excluded from the CI-failable
63
+ # count above.
64
+ def accepted_section(accepted)
65
+ return "" if accepted.empty?
66
+
67
+ lines = accepted.map do |a|
68
+ reason = a.reason ? " — #{MarkdownEscape.inline(a.reason)}" : ""
69
+ "- **#{a.regression.kind}** #{MarkdownEscape.code_span(a.regression.gem)}#{reason}"
70
+ end
71
+ section("Accepted (suppressed via .still_active.yml)", lines)
72
+ end
73
+
56
74
  def added_section(added)
57
75
  return "" if added.empty?
58
76
 
@@ -63,7 +81,7 @@ module StillActive
63
81
  def removed_section(removed)
64
82
  return "" if removed.empty?
65
83
 
66
- lines = removed.map { |r| "- `#{r.name}` (was #{(r.data || {})["version_used"] || "?"})" }
84
+ lines = removed.map { |r| "- #{MarkdownEscape.code_span(r.name)} (was #{MarkdownEscape.inline((r.data || {})["version_used"] || "?")})" }
67
85
  section("Removed", lines)
68
86
  end
69
87
 
@@ -88,9 +106,9 @@ module StillActive
88
106
  lines = []
89
107
  if ruby[:version_changed]
90
108
  eol_suffix = ruby[:newly_eol] ? " (now EOL)" : ""
91
- lines << "- Ruby `#{ruby[:from]}``#{ruby[:to]}`#{eol_suffix}"
109
+ lines << "- Ruby #{MarkdownEscape.code_span(ruby[:from])} → #{MarkdownEscape.code_span(ruby[:to])}#{eol_suffix}"
92
110
  elsif ruby[:newly_eol]
93
- lines << "- Ruby `#{ruby[:to]}` is now EOL"
111
+ lines << "- Ruby #{MarkdownEscape.code_span(ruby[:to])} is now EOL"
94
112
  end
95
113
  section("Ruby", lines)
96
114
  end
@@ -108,30 +126,31 @@ module StillActive
108
126
  (data["archived"] ? "archived" : nil),
109
127
  data["libyear"] && "#{data["libyear"]}y behind",
110
128
  ].compact
111
- "`#{added.name}` (#{bits.join(", ")})"
129
+ "#{MarkdownEscape.code_span(added.name)} (#{MarkdownEscape.inline(bits.join(", "))})"
112
130
  end
113
131
 
114
132
  def format_bump(bump)
115
133
  label = BUMP_KIND_LABELS[bump.kind]
116
134
  suffix = label ? " (#{label})" : ""
117
- "- `#{bump.name}` #{bump.before_version} → #{bump.after_version}#{suffix}"
135
+ "- #{MarkdownEscape.code_span(bump.name)} #{MarkdownEscape.inline(bump.before_version)} → #{MarkdownEscape.inline(bump.after_version)}#{suffix}"
118
136
  end
119
137
 
120
138
  def format_signal_change_lines(sc)
139
+ name = MarkdownEscape.code_span(sc.name)
121
140
  sc.changes.filter_map do |ch|
122
141
  case ch[:kind]
123
142
  when :archived
124
- "- `#{sc.name}` — archived (false → true)"
143
+ "- #{name} — archived (false → true)"
125
144
  when :new_vulnerability
126
- ids = Array(ch[:ids]).join(", ")
127
- "- `#{sc.name}` — new vulnerability (#{ch[:from]} → #{ch[:to]}#{" — #{ids}" unless ids.empty?})"
145
+ ids = MarkdownEscape.inline(Array(ch[:ids]).join(", "))
146
+ "- #{name} — new vulnerability (#{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{" — #{ids}" unless ids.empty?})"
128
147
  when :scorecard_dropped
129
148
  note = ch[:crossed_good] ? " (crossed 7.0)" : ""
130
- "- `#{sc.name}` — scorecard #{ch[:from]} → #{ch[:to]}#{note}"
149
+ "- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
131
150
  when :version_yanked
132
- "- `#{sc.name}` — version yanked from rubygems"
151
+ "- #{name} — version yanked from rubygems"
133
152
  when :libyear_worsened
134
- "- `#{sc.name}` — libyear #{ch[:from]} → #{ch[:to]} (+#{ch[:delta]}y; same pinned version)"
153
+ "- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
135
154
  end
136
155
  end
137
156
  end
@@ -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