still_active 2.0.0 → 3.0.0.rc2

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 +42 -0
  3. data/README.md +104 -357
  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 +27 -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 +205 -9
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. metadata +61 -11
@@ -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
@@ -142,15 +142,31 @@ module StillActive
142
142
  end
143
143
 
144
144
  def rating(advisory)
145
- 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)
146
149
  return if score.nil?
147
150
 
148
- method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2"
149
- rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method }
150
- 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
151
154
  rating
152
155
  end
153
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
+
154
170
  def boolean_property(value)
155
171
  return if value.nil?
156
172
 
@@ -19,13 +19,14 @@ module StillActive
19
19
  unknown: nil,
20
20
  }.freeze
21
21
 
22
- def render(diff)
22
+ def render(diff, accepted: [])
23
23
  sections = [
24
24
  "## still_active diff",
25
25
  "",
26
- summary_line(diff),
26
+ summary_line(diff, accepted),
27
27
  "",
28
28
  regressions_section(diff.regressions),
29
+ accepted_section(accepted),
29
30
  added_section(diff.added),
30
31
  removed_section(diff.removed),
31
32
  bumps_section(diff.bumped),
@@ -38,14 +39,16 @@ module StillActive
38
39
 
39
40
  private
40
41
 
41
- def summary_line(diff)
42
- [
42
+ def summary_line(diff, accepted)
43
+ parts = [
43
44
  ["regressions", diff.regressions.size],
44
45
  ["added", diff.added.size],
45
46
  ["removed", diff.removed.size],
46
47
  ["bumped", diff.bumped.size],
47
48
  ["signal-changes", diff.signal_changes.size],
48
- ].map { |label, n| "#{n} #{label}" }.join(" · ")
49
+ ]
50
+ parts << ["accepted", accepted.size] unless accepted.empty?
51
+ parts.map { |label, n| "#{n} #{label}" }.join(" · ")
49
52
  end
50
53
 
51
54
  def regressions_section(regressions)
@@ -55,6 +58,19 @@ module StillActive
55
58
  section("Regressions (CI-failable)", lines)
56
59
  end
57
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
+
58
74
  def added_section(added)
59
75
  return "" if added.empty?
60
76
 
@@ -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