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
@@ -6,6 +6,7 @@ require "time"
6
6
  require_relative "../still_active/sarif/rules"
7
7
  require_relative "lockfile_indexer"
8
8
  require_relative "activity_helper"
9
+ require_relative "constraint_helper"
9
10
 
10
11
  module StillActive
11
12
  # Renders a still_active workflow result as a SARIF 2.1.0 document.
@@ -137,9 +138,101 @@ module StillActive
137
138
  out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
138
139
  end
139
140
 
141
+ if data[:poison] && !Array(data[:constraints]).empty?
142
+ # Level tracks the poison tier (critical->error, ...); a plain majors-behind
143
+ # tier change keeps the (rule_id, gem_name) fingerprint so it doesn't re-alert.
144
+ # But an escalation up the tiers (maintenance -> security-relevant -> below the
145
+ # fix) adds a fingerprint dimension so a past dismissal of a weaker tier can't
146
+ # silently mute the stronger one -- the transition a human must see.
147
+ state = if data[:poison_below_fix]
148
+ "below_fix"
149
+ elsif data[:poison_security_relevant]
150
+ "security"
151
+ else
152
+ "maintenance"
153
+ end
154
+ out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
155
+ end
156
+
157
+ if data[:language_ceiling]
158
+ # Level tracks the ceiling tier: critical (EOL-forced) -> error, note
159
+ # (latest-not-yet) -> note. Fingerprint is (rule_id, gem_name, state) where
160
+ # state is the eol_forced boolean: cosmetic churn (which patch, which
161
+ # latest) doesn't re-alert, but a note -> EOL-forced escalation (a supported
162
+ # runtime went EOL) mints a NEW alert so a past dismissal of the note can't
163
+ # silently mute the critical -- that transition is exactly the one a human
164
+ # must see.
165
+ state = data[:language_ceiling][:eol_forced] ? "eol_forced" : "latest_not_yet"
166
+ out << mark_suppressed(result("SA009", name, language_ceiling_message(name, version, data), location, level: language_ceiling_level(data), fp_extra: state), name, :language_ceiling)
167
+ end
168
+
140
169
  out
141
170
  end
142
171
 
172
+ def language_ceiling_level(data)
173
+ { critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
174
+ end
175
+
176
+ def language_ceiling_message(name, version, data)
177
+ ceiling = data[:language_ceiling]
178
+ runtime = ceiling[:runtime]
179
+ body =
180
+ if ceiling[:eol_forced]
181
+ eol = ceiling[:ceiling_eol_date]
182
+ eol_part = eol ? " (EOL #{format_date(eol)})" : ""
183
+ "stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
184
+ else
185
+ "no #{runtime} #{ceiling[:latest_stable]} support yet"
186
+ end
187
+ fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
188
+ "#{name} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
189
+ end
190
+
191
+ # The poison receipt for a Code Scanning alert: the worst 3 caps (shared
192
+ # ranking via ConstraintHelper.top_findings so it can't drift from the other
193
+ # renderers) with the exact latest version (a machine-read alert wants the
194
+ # precise version, not the "8.x" the terminal abbreviates to), plus "+N more"
195
+ # and the transitive parent. Note result() fingerprints on (rule_id, gem_name)
196
+ # only, so this volatile detail never re-alerts a finding the user triaged.
197
+ def poison_level(data)
198
+ # A security-relevant cap (a dormant dep pins a known-vulnerable dependency
199
+ # below its fix) escalates to error regardless of majors-behind: it's a real
200
+ # security finding, not the maintenance-tier signal poison usually is.
201
+ return "error" if data[:poison_security_relevant]
202
+
203
+ { critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
204
+ end
205
+
206
+ def poison_message(name, version, data)
207
+ top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
208
+ caps = top[:shown].map do |finding|
209
+ behind = finding[:majors_behind]
210
+ "#{finding[:dependency]} #{finding[:requirement]} (#{behind} major#{"s" unless behind == 1} behind, latest #{finding[:dep_latest]})"
211
+ end
212
+ remaining = top[:total] - top[:shown].length
213
+ caps << "+#{remaining} more" if remaining.positive?
214
+ "#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
215
+ end
216
+
217
+ # Spells out the security escalation for a code-scanning alert. Only claims
218
+ # "below the fix" (with the CVE and its nearest fix, a version the cap forbids)
219
+ # when that is actually established. Otherwise we say only that the dep is
220
+ # known-vulnerable, WITHOUT asserting patchability: this branch also covers a
221
+ # HIGH advisory with no released fix at all (class C), which is not patchable in
222
+ # place, so an "(patchable in place)" claim here would be false on the worst case.
223
+ def poison_security_note(data)
224
+ return "" unless data[:poison_security_relevant]
225
+
226
+ below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
227
+ if below.any?
228
+ receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]}, outside the cap)" }.uniq
229
+ " -- pins #{receipts.join("; ")}"
230
+ else
231
+ pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
232
+ " -- pins known-vulnerable #{pinned.join(", ")}"
233
+ end
234
+ end
235
+
143
236
  # Attaches a SARIF native suppressions[] entry when this finding is covered
144
237
  # by a whole-gem --ignore or a granular .still_active.yml suppression, so a
145
238
  # GitHub code-scanning consumer renders it dismissed rather than open. The
@@ -161,18 +254,27 @@ module StillActive
161
254
  end
162
255
 
163
256
  def vulnerability_result(name, version, vuln, location, data = {})
164
- score = vuln[:cvss3_score] || vuln[:cvss2_score]
165
- level = Sarif::Rules.cvss_to_level(score)
257
+ # Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
258
+ # label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
259
+ # exports as error, not a note/warning a code-scanning gate reads as
260
+ # informational. The security-severity NUMBER stays tied to a real CVSS score
261
+ # (effective_score): a label-only advisory carries no invented number rather
262
+ # than a fabricated 7.0. A confirmed-but-unscored advisory still fails closed.
263
+ score = VulnerabilityHelper.effective_score(vuln)
264
+ level = Sarif::Rules.severity_to_level(VulnerabilityHelper.advisory_severity(vuln))
166
265
  severity = Sarif::Rules.cvss_to_security_severity(score)
167
266
  advisory_id = vuln[:id] || Array(vuln[:aliases]).first || "unknown"
168
267
  aliases = Array(vuln[:aliases]).first(3).join(", ")
169
268
  alias_suffix = aliases.empty? ? "" : " [#{aliases}]"
170
269
  title = vuln[:title] ? ": #{vuln[:title]}" : ""
270
+ # ruby-advisory-db records no safe version: upgrading can't clear it, which
271
+ # is the actionable distinction from an ordinary (patchable) advisory.
272
+ no_fix = vuln[:no_fix_available] ? " (no fixed version available)" : ""
171
273
 
172
274
  base = result(
173
275
  "SA003",
174
276
  name,
175
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{transitive_suffix(data)}.",
277
+ "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
176
278
  location,
177
279
  level: level,
178
280
  fp_extra: advisory_id,
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_range"
4
+
5
+ module StillActive
6
+ # Does a concrete version satisfy a declared requirement, at PATCH precision,
7
+ # for npm and cargo? This is the primitive the cross-ecosystem below-the-fix
8
+ # signal needs: a CVE's fix is usually a same-major patch bump, so "can this fix
9
+ # be reached within the package's constraint" cannot be answered by the coarse,
10
+ # major-precision ConstraintHelper. node-semver's caret/tilde/OR/prerelease rules
11
+ # are a correctness minefield, so we lean on the semantic_range gem (a node-semver
12
+ # port) rather than reimplement them.
13
+ #
14
+ # npm ranges are node-semver as-is. cargo's VersionReq agrees with node-semver on
15
+ # every operator form, and diverges on ANY operator-less bare version: cargo treats
16
+ # a bare version as a caret (`1.2.3` = `^1.2.3`, `1.2` = `^1.2` = `>=1.2.0 <2.0.0`,
17
+ # `1` = `^1`), while node-semver reads a bare full version as an exact pin and a
18
+ # partial `1.2` as the narrower `1.2.x` (`<1.3.0`). Per the Cargo Book's caret
19
+ # table, node-semver's caret expands identically to cargo's for every one of these
20
+ # forms, so the whole shim is: prefix `^` to a bare version before evaluating.
21
+ # Getting it wrong (e.g. leaving `1.2` unshimmed) reads a reachable fix as
22
+ # unreachable and fabricates a below-the-fix security finding.
23
+ module SemverSatisfaction
24
+ extend self
25
+
26
+ # An operator-less bare version, 1 to 3 numeric components, optional prerelease
27
+ # and/or build tail (`1`, `1.2`, `1.2.3`, `0.10.38`, `1.2.3-alpha+001`). cargo
28
+ # reads any of these as a caret; node-semver does not, so cargo shims them.
29
+ BARE_VERSION = /\A\s*v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*\z/
30
+
31
+ # Does `version` satisfy `requirement`? true / false when decidable; nil when the
32
+ # requirement or version isn't valid semver for the ecosystem, or the ecosystem
33
+ # isn't one we model. The tri-state is deliberate (hence a plain verb, not a
34
+ # `?` predicate): semantic_range returns false for garbage, and a false would read
35
+ # as "the fix can't be reached" in a wall test and fabricate a below-the-fix flag.
36
+ # The caller must treat nil as "cannot establish a wall", never as unreachable.
37
+ def evaluate(requirement:, version:, ecosystem:)
38
+ range = range_for(requirement, ecosystem)
39
+ return if range.nil? # undecidable: unmodelled ecosystem or unparseable requirement
40
+ return if SemanticRange.valid(version.to_s).nil? # undecidable: unparseable version
41
+
42
+ SemanticRange.satisfies?(version.to_s, range)
43
+ end
44
+
45
+ private
46
+
47
+ # The node-semver range string for this ecosystem's requirement, or nil if it
48
+ # isn't a valid range (or the ecosystem is unmodelled). cargo's bare-version
49
+ # caret is applied BEFORE validation so the shimmed form is what gets checked.
50
+ def range_for(requirement, ecosystem)
51
+ requirement = requirement.to_s.strip
52
+ # A blank requirement is missing data (a failed extraction), NOT a package that
53
+ # allows any version: semantic_range reads "" as `*` (matches everything), which
54
+ # would be a false-confident answer. Keep it undecidable, like a blank version.
55
+ return if requirement.empty?
56
+
57
+ case ecosystem
58
+ when :npm then SemanticRange.valid_range(requirement)
59
+ when :cargo then SemanticRange.valid_range(cargo_normalize(requirement))
60
+ end
61
+ end
62
+
63
+ # `requirement` arrives already stripped from range_for.
64
+ def cargo_normalize(requirement)
65
+ requirement.match?(BARE_VERSION) ? "^#{requirement}" : requirement
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activity_helper"
4
+
5
+ module StillActive
6
+ # Collapses a gem's several maintenance signals into one categorical verdict,
7
+ # so a machine/LLM consumer (or another tool's report) can display and threshold
8
+ # a single value instead of re-deriving it from activity_level + archived +
9
+ # vulnerability_count. This is deliberately NOT a numeric composite: an earlier
10
+ # 0-100 score was removed because a weighted average let missing data read as
11
+ # "perfect health". Here, :unknown stays :unknown -- absence of data is never
12
+ # rendered as :ok.
13
+ module StatusHelper
14
+ extend self
15
+
16
+ # Worst-first lifecycle verdict. The key distinction (from the "done gems"
17
+ # critique and validated against the maintenance-tooling landscape): a clean,
18
+ # long-dormant gem is :legacy ("done", low risk), NOT a problem -- whereas a
19
+ # dormant or archived gem carrying an unpatched advisory is :dead (no one is
20
+ # going to fix it, migrate). :unknown is least severe -- an absence, not a
21
+ # finding -- so missing data never reads as :ok.
22
+ SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :vulnerable, :dead].freeze
23
+
24
+ # Returns :dead, :vulnerable, :archived, :legacy, :stale, :ok, or :unknown.
25
+ def gem_status(gem_data)
26
+ vulnerable = gem_data[:vulnerability_count].to_i.positive?
27
+
28
+ # A pinned version the registry can't resolve (yanked, typo, or nonexistent)
29
+ # has no version-specific data to judge, so package-level health must not read
30
+ # it as :ok. Absence of data is :unknown, never a false all-clear. Guarded on
31
+ # `!vulnerable` so a detected advisory always wins, in case a future source
32
+ # ever attaches one to an otherwise-unresolved version.
33
+ return :unknown if gem_data[:version_unresolved] && !vulnerable
34
+
35
+ level = ActivityHelper.activity_level(gem_data)
36
+
37
+ if vulnerable
38
+ # A vulnerability in a dormant or archived gem won't be patched -> :dead;
39
+ # in an actively-released gem a fix is plausible -> :vulnerable.
40
+ return [:critical, :archived].include?(level) ? :dead : :vulnerable
41
+ end
42
+
43
+ case level
44
+ when :archived
45
+ # archived != EOL: a repo archived while the gem still publishes recent
46
+ # releases (development moved to a monorepo) isn't dead -- let the
47
+ # releases speak, but keep :stale so the archived repo stays a yellow flag.
48
+ ActivityHelper.release_recency_level(gem_data) == :ok ? :stale : :archived
49
+ when :critical then :legacy # long-dormant but clean: feature-complete, not a fire
50
+ else level # :stale / :ok / :unknown
51
+ end
52
+ end
53
+
54
+ # The single worst gem status across the audit. An EOL Ruby floors the
55
+ # project at :vulnerable (the runtime itself is a live, actionable risk).
56
+ # :unknown only wins when nothing better is known.
57
+ def project_status(result, ruby_info: nil)
58
+ statuses = result.each_value.map { |data| gem_status(data) }
59
+ statuses << :vulnerable if ruby_info&.dig(:eol) == true
60
+ return :unknown if statuses.empty?
61
+
62
+ # Every value gem_status returns is in SEVERITY today; if a future status
63
+ # isn't, rank it most-severe so it surfaces in the rollup rather than being
64
+ # silently masked by a milder finding.
65
+ statuses.max_by { |status| SEVERITY.index(status) || SEVERITY.length }
66
+ end
67
+ end
68
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "activity_helper"
4
+ require_relative "status_helper"
4
5
 
5
6
  module StillActive
6
7
  # Builds the JSON output's summary{} digest: the headline posture of the audit
@@ -40,6 +41,9 @@ module StillActive
40
41
  outdated: outdated,
41
42
  vulnerable_gems: vulnerable_gems,
42
43
  vulnerabilities: vulnerabilities,
44
+ # The single worst per-gem verdict (plus EOL Ruby), so a consumer reads
45
+ # one project-level posture without scanning every gem's status.
46
+ status: StatusHelper.project_status(result, ruby_info: ruby_info),
43
47
  }
44
48
  summary[:ruby_eol] = ruby_info[:eol] == true if ruby_info
45
49
  summary
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "activity_helper"
4
4
  require_relative "ansi_helper"
5
+ require_relative "constraint_helper"
5
6
  require_relative "summary_helper"
6
7
  require_relative "libyear_helper"
7
8
  require_relative "version_helper"
@@ -23,11 +24,7 @@ module StillActive
23
24
  lines << separator_line(widths)
24
25
  names.each_with_index do |name, i|
25
26
  lines << row_line(rows[i], widths)
26
- data = result[name]
27
- # Transitive gems can't be swapped directly, so point at the direct
28
- # parent instead of suggesting alternatives for them (#60).
29
- extra = data[:direct] == false ? dependency_path_line(data) : alternatives_line(data)
30
- lines << extra if extra
27
+ lines.concat(sub_lines(result[name]))
31
28
  end
32
29
  lines << ""
33
30
  lines << summary_line(result)
@@ -105,7 +102,8 @@ module StillActive
105
102
  return AnsiHelper.green("0") if count.zero?
106
103
 
107
104
  severity = VulnerabilityHelper.highest_severity(data[:vulnerabilities])
108
- label = severity ? "#{count} (#{severity})" : count.to_s
105
+ notes = [severity, ("no fix" if VulnerabilityHelper.no_fix_available?(data[:vulnerabilities]))].compact
106
+ label = notes.empty? ? count.to_s : "#{count} (#{notes.join(", ")})"
109
107
  AnsiHelper.red(label)
110
108
  end
111
109
 
@@ -134,6 +132,138 @@ module StillActive
134
132
  .join
135
133
  end
136
134
 
135
+ # The ↳ sub-lines under a gem row. A poison gem gets the poison receipt (which
136
+ # names the direct parent itself when transitive, so the generic transitive
137
+ # line is redundant and dropped); a direct poison gem may still get its
138
+ # alternatives line. A non-poison gem keeps the prior behaviour: transitive
139
+ # gems point at the parent (#60), direct ones offer alternatives.
140
+ def sub_lines(data)
141
+ lines = if data[:poison]
142
+ [poison_line(data), (data[:direct] == false ? nil : alternatives_line(data))]
143
+ else
144
+ [data[:direct] == false ? dependency_path_line(data) : alternatives_line(data)]
145
+ end
146
+ # A language-runtime ceiling is orthogonal to poison/alternatives (a gem can
147
+ # be maintained yet still cap your Ruby), so it always gets its own line.
148
+ lines << language_ceiling_line(data)
149
+ lines.compact
150
+ end
151
+
152
+ # " ↳ ruby ceiling: <receipt>" (or "python ceiling"), coloured by tier (red =
153
+ # strands you on an EOL runtime, dim = an FYI cap below the latest stable).
154
+ # Reuses poison_colour; the runtime name comes off the finding, not hardcoded.
155
+ def language_ceiling_line(data)
156
+ ceiling = data[:language_ceiling]
157
+ return if ceiling.nil?
158
+
159
+ AnsiHelper.public_send(poison_colour(ceiling[:severity]), " ↳ #{ceiling[:runtime].downcase} ceiling: #{language_ceiling_receipt(ceiling, data)}")
160
+ end
161
+
162
+ def language_ceiling_receipt(ceiling, data)
163
+ runtime = ceiling[:runtime]
164
+ base =
165
+ if ceiling[:eol_forced]
166
+ "requires #{runtime} #{ceiling[:requirement]}, stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_suffix(ceiling[:ceiling_eol_date])}"
167
+ else
168
+ "requires #{runtime} #{ceiling[:requirement]}, no #{runtime} #{ceiling[:latest_stable]} support yet"
169
+ end
170
+ "#{base}#{language_ceiling_fix_hint(ceiling, data)}"
171
+ end
172
+
173
+ def eol_suffix(eol_date)
174
+ eol_date ? " (EOL #{eol_date.strftime("%Y-%m-%d")})" : ""
175
+ end
176
+
177
+ # Name the actionable fix when a newer release of the gem lifts the cap; the
178
+ # gem's own name sits on the row directly above, so "upgrade to <latest>" reads
179
+ # unambiguously without repeating it.
180
+ def language_ceiling_fix_hint(ceiling, data)
181
+ return "" unless ceiling[:fixed_by_upgrade] && data[:latest_version]
182
+
183
+ "; upgrade to #{data[:latest_version]} to lift it"
184
+ end
185
+
186
+ # " ↳ poison: caps <receipt>", coloured by severity tier (see poison_colour).
187
+ # The row is already red (poison requires a dormant gem); the sub-line tier
188
+ # says how urgent the cap is. Transitive gems name the direct parent that pulls
189
+ # the pill in, the actionable target.
190
+ def poison_line(data)
191
+ constraints = data[:constraints]
192
+ return if constraints.nil? || constraints.empty?
193
+
194
+ path = data[:dependency_path]
195
+ via = data[:direct] == false && path && path.length >= 2 ? " (via #{path.first})" : ""
196
+ # Colour carries the tier: red = act-now (3+ majors behind), yellow = plan,
197
+ # dim = a minor/FYI cap (1 behind). A security-relevant cap (it pins a
198
+ # vulnerable dependency below the fix) is always red -- that's the finding to
199
+ # act on regardless of how many majors behind it happens to be.
200
+ colour = data[:poison_security_relevant] ? :red : poison_colour(data[:poison_severity])
201
+ AnsiHelper.public_send(colour, " ↳ poison#{via}: #{poison_receipt(constraints)}#{poison_security_suffix(data)}")
202
+ end
203
+
204
+ # Names the vulnerable dependency a security-relevant poison cap pins you to. When
205
+ # the cap holds you BELOW THE FIX (every patched version is a major it forbids) we
206
+ # lead with that stronger, enforced claim and name the CVE and its nearest fix --
207
+ # "you cannot patch this without replacing the dormant capper". Otherwise the
208
+ # weaker "pins vulnerable X" (the dep is vulnerable, but patchable in place).
209
+ def poison_security_suffix(data)
210
+ return "" unless data[:poison_security_relevant]
211
+
212
+ below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
213
+ if below.any?
214
+ receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]})" }.uniq
215
+ " ⚠ pins #{receipts.join("; ")}"
216
+ else
217
+ pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
218
+ " ⚠ pins vulnerable #{pinned.join(", ")}"
219
+ end
220
+ end
221
+
222
+ def poison_colour(severity)
223
+ { critical: :red, warning: :yellow, note: :dim }.fetch(severity, :yellow)
224
+ end
225
+
226
+ # A worst-first "N label (X critical, Y note)" summary fragment, coloured by
227
+ # the worst tier present, or nil when there are none. Shared by the poison and
228
+ # Ruby-ceiling counts (both roll a set of tiered findings up the same way).
229
+ def tier_summary_part(severities, label)
230
+ return if severities.empty?
231
+
232
+ by_tier = severities.group_by { |severity| severity || :note }
233
+ present = ConstraintHelper::SEVERITY.reverse.select { |tier| by_tier[tier] } # worst-first
234
+ breakdown = present.map { |tier| "#{by_tier[tier].size} #{tier}" }.join(", ")
235
+ AnsiHelper.public_send(poison_colour(present.first), "#{label} (#{breakdown})")
236
+ end
237
+
238
+ # Single cap: the full receipt (requirement + latest major), since there's
239
+ # room. Many caps: the worst 3 by majors-behind + "+N more", a glanceable
240
+ # summary; the complete list stays in the JSON output.
241
+ def poison_receipt(constraints)
242
+ top = ConstraintHelper.top_findings(constraints, limit: 3)
243
+ return single_cap_receipt(top[:shown].first) if top[:total] == 1
244
+
245
+ parts = top[:shown].each_with_index.map do |finding, i|
246
+ count = finding[:majors_behind]
247
+ i.zero? ? "#{finding[:dependency]} (#{count} behind)" : "#{finding[:dependency]} (#{count})"
248
+ end
249
+ remaining = top[:total] - top[:shown].length
250
+ receipt = "caps #{parts.join(", ")}"
251
+ remaining.positive? ? "#{receipt} +#{remaining} more" : receipt
252
+ end
253
+
254
+ def single_cap_receipt(finding)
255
+ behind = finding[:majors_behind]
256
+ "caps #{finding[:dependency]} #{finding[:requirement]} " \
257
+ "(#{behind} major#{"s" unless behind == 1} behind, latest #{major_x(finding[:dep_latest])})"
258
+ end
259
+
260
+ # "8.0.1" -> "8.x": the cap is a major-level gap, so the major is the honest
261
+ # granularity to name as the current latest.
262
+ def major_x(version)
263
+ major = version.to_s[/\d+/]
264
+ major ? "#{major}.x" : version
265
+ end
266
+
137
267
  def alternatives_line(data)
138
268
  level = ActivityHelper.activity_level(data)
139
269
  return unless [:archived, :critical].include?(level)
@@ -194,6 +324,14 @@ module StillActive
194
324
  activity << ", #{archived} archived" if archived > 0
195
325
  parts << activity
196
326
  parts << "#{summary[:vulnerabilities]} vulnerabilities"
327
+ poison_tiers = result.each_value.select { |data| data[:poison] }.map { |data| data[:poison_severity] }
328
+ poison_part = tier_summary_part(poison_tiers, "#{poison_tiers.size} poison-#{poison_tiers.size == 1 ? "pill" : "pills"}")
329
+ parts << poison_part if poison_part
330
+ ceilings = result.each_value.filter_map { |data| data[:language_ceiling] }
331
+ runtimes = ceilings.map { |ceiling| ceiling[:runtime] }.uniq
332
+ runtime_label = runtimes.size == 1 ? runtimes.first : "language"
333
+ ceiling_part = tier_summary_part(ceilings.map { |ceiling| ceiling[:severity] }, "#{ceilings.size} #{runtime_label} ceiling#{"s" unless ceilings.size == 1}")
334
+ parts << ceiling_part if ceiling_part
197
335
  total_libyear = LibyearHelper.total_libyear(result)
198
336
  parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
199
337
  parts.join(" · ")
@@ -44,6 +44,15 @@ module StillActive
44
44
  Time.parse(release_date) unless release_date.nil?
45
45
  end
46
46
 
47
+ # The version's declared Ruby requirement (the `ruby_version` field in the
48
+ # RubyGems versions payload, e.g. ">= 3.2", "< 3.2"), or nil when absent. This
49
+ # is the language-runtime ceiling's raw input; note it is `ruby_version`, not
50
+ # the gemspec's `required_ruby_version`.
51
+ def ruby_requirement(version_hash:)
52
+ requirement = version_hash&.dig("ruby_version")
53
+ requirement unless requirement.nil? || requirement.empty?
54
+ end
55
+
47
56
  # SPDX license identifier(s) from the RubyGems versions payload.
48
57
  # Comma-joined when a gem declares more than one. nil when unknown.
49
58
  def license(version_hash:)
@@ -6,20 +6,77 @@ module StillActive
6
6
 
7
7
  SEVERITY_ORDER = ["low", "medium", "high", "critical"].freeze
8
8
 
9
+ # OSV's GHSA severity labels, mapped to our order. deps.dev stores only CVSS 3.x, so
10
+ # a CVSS-4-only advisory returns cvss3Score 0 (unscored); OSV's
11
+ # `database_specific.severity` reads correctly for it and rescues a real HIGH from
12
+ # fail-closed noise. GitHub uses MODERATE where we use medium.
13
+ OSV_LABELS = { "critical" => "critical", "high" => "high", "moderate" => "medium", "medium" => "medium", "low" => "low" }.freeze
14
+
9
15
  def highest_severity(vulnerabilities)
10
16
  return if vulnerabilities.nil? || vulnerabilities.empty?
11
17
 
12
- max_score = vulnerabilities.filter_map { |v| v[:cvss3_score] || v[:cvss2_score] }.max
13
- return if max_score.nil?
18
+ vulnerabilities
19
+ .filter_map { |v| advisory_severity(v) }
20
+ .max_by { |label| SEVERITY_ORDER.index(label) }
21
+ end
22
+
23
+ # One advisory's severity label ("low".."critical"), or nil when genuinely unscored.
24
+ # The authoritative GHSA/OSV label is a FLOOR: a real CVSS number sharpens precision
25
+ # and can RAISE the band, but never lowers it below the label. This matters because
26
+ # the OSV-derived score is cvss-suite's overall_score, which folds in any
27
+ # threat/environmental metrics a vector carries (and can diverge from GHSA at a band
28
+ # edge) -- on a fail-closed, no-false-positive tool a computed number must not
29
+ # silently demote a GHSA-HIGH finding out of a severity gate. With only one of the
30
+ # two present, that one stands.
31
+ def advisory_severity(vulnerability)
32
+ from_score = (score = effective_score(vulnerability)) ? severity_label(score) : nil
33
+ from_label = OSV_LABELS[vulnerability[:osv_severity].to_s.downcase]
34
+ return from_score || from_label if from_score.nil? || from_label.nil?
35
+
36
+ [from_score, from_label].max_by { |label| SEVERITY_ORDER.index(label) }
37
+ end
38
+
39
+ # Unknown = no CVSS score AND no OSV label. A freshly disclosed CVE often has
40
+ # neither yet, and the cross-ecosystem lens emits a minimal advisory when
41
+ # detail-fetch fails. The CVSS-4-only case (deps.dev returns cvss3Score 0, its
42
+ # "no 3.x score" sentinel, for two HIGH protobuf GHSAs scored CVSS 4.0) is no
43
+ # longer unknown once OSV enrichment attaches the GHSA severity label -- that's
44
+ # the whole point of the enrichment: a real HIGH stops failing closed as unscored.
45
+ # A truly unknown advisory still fails closed on a gate.
46
+ def unknown_severity?(vulnerability)
47
+ advisory_severity(vulnerability).nil?
48
+ end
14
49
 
15
- severity_label(max_score)
50
+ # True when at least one advisory has no fixed version available -- the gem
51
+ # can't be upgraded out of the vulnerability. Only ruby-advisory-db sets this
52
+ # today; a deps.dev-only advisory leaves it nil (unknown), which reads false.
53
+ def no_fix_available?(vulnerabilities)
54
+ Array(vulnerabilities).any? { |vulnerability| vulnerability[:no_fix_available] }
55
+ end
56
+
57
+ # The best real CVSS score for an advisory, or nil when none is usable. Prefers
58
+ # deps.dev's v3/v2, then the score OSV enrichment computed from a v4 (or v3)
59
+ # vector. A score of 0 is treated as absent: deps.dev only stores CVSS 3.x, so a
60
+ # CVSS-4-only advisory (e.g. two HIGH protobuf GHSAs) comes back with cvss3Score
61
+ # 0 -- its "no 3.x score" sentinel, not a genuine severity of zero (which
62
+ # published advisories never carry) -- and the OSV v4 score fills that gap. Every
63
+ # consumer of a numeric score (severity labels, SARIF level, CycloneDX rating)
64
+ # routes through here so a 0 can't masquerade as "low" and silently clear a gate.
65
+ def effective_score(vulnerability)
66
+ [vulnerability[:cvss3_score], vulnerability[:cvss2_score], vulnerability[:osv_cvss_score]].find { |score| score&.positive? }
16
67
  end
17
68
 
18
69
  def severity_at_or_above?(vulnerabilities, threshold)
19
- highest = highest_severity(vulnerabilities)
20
- return false if highest.nil?
70
+ return false if vulnerabilities.nil? || vulnerabilities.empty?
71
+
72
+ # Fail closed on an unscored advisory: a confirmed advisory we can't score
73
+ # could be anything up to critical (and fresh CVEs commonly lack a score),
74
+ # so passing it as "below threshold" would silently clear a severity gate on
75
+ # a real finding. The user accepts a specific advisory via a suppression if
76
+ # they've reviewed it. Only when every advisory is scored do we compare.
77
+ return true if vulnerabilities.any? { |vulnerability| unknown_severity?(vulnerability) }
21
78
 
22
- SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold)
79
+ SEVERITY_ORDER.index(highest_severity(vulnerabilities)) >= SEVERITY_ORDER.index(threshold)
23
80
  end
24
81
 
25
82
  # Combines advisories from deps.dev and ruby-advisory-db (via bundler-audit),
@@ -44,7 +101,11 @@ module StillActive
44
101
  private
45
102
 
46
103
  def identifiers(advisory)
47
- [advisory[:id], *advisory[:aliases]].compact
104
+ # Coerce to strings: advisory ids/aliases are strings, but deps.dev's ALPHA API
105
+ # could drift and hand back a non-string alias, and a mixed array makes the
106
+ # union's `.sort` (in combine!) raise -- which escapes to the per-gem rescue that
107
+ # strips ALL the gem's signals, reading a known-vulnerable gem as clean.
108
+ [advisory[:id], *advisory[:aliases]].compact.map(&:to_s)
48
109
  end
49
110
 
50
111
  # Folds a ruby-advisory-db advisory into a matching deps.dev advisory in place:
@@ -56,6 +117,11 @@ module StillActive
56
117
  into[:title] ||= from[:title]
57
118
  into[:url] ||= from[:url]
58
119
  into[:aliases] = (identifiers(into) | identifiers(from)).reject { |id| id == into[:id] }.sort
120
+ # ruby-advisory-db is the sole authority for fix availability; its
121
+ # determination wins whenever it has one (deps.dev leaves the key absent
122
+ # today, but this fails safe if that ever changes: a radb "no fix" can't be
123
+ # overridden by a stale/absent deps.dev value on a security-adjacent field).
124
+ into[:no_fix_available] = from[:no_fix_available] unless from[:no_fix_available].nil?
59
125
  into[:source] = "merged"
60
126
  end
61
127
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Whole-tree correlation between the two capping signals, run once after the
5
+ # fan-out (native or SBOM) has assembled every package's findings. A language
6
+ # ceiling that says "upgrade <package> to lift it" is wrong if the same tree
7
+ # poisons <package> below that upgrade (a dormant dependency caps it): the
8
+ # report must never advise an upgrade its own poison finding says is impossible.
9
+ # All data is already in the assembled result, so this is one pass, no fetches.
10
+ # "The correlation layer must correlate."
11
+ module CeilingReconciler
12
+ extend self
13
+
14
+ def reconcile_ceiling_with_poison(result_object)
15
+ # Qualify each capped dependency by the ecosystem that declares the cap. A
16
+ # capped dep resolves in its parent's ecosystem, so a poison finding on a
17
+ # `pypi` package caps a `pypi` dependency. A mixed SBOM has two flat-
18
+ # resolution ecosystems (rubygems + pypi), so a bare-name match alone would
19
+ # let a rubygems poison-cap on "foo" wrongly block a pypi "foo"'s ceiling.
20
+ # Native results carry no :ecosystem (all rubygems) -> nil on both sides,
21
+ # still consistent.
22
+ capped = result_object.each_value
23
+ .flat_map do |data|
24
+ Array(data[:constraints])
25
+ .select { |constraint| constraint[:majors_behind].to_i.positive? }
26
+ .map { |constraint| "#{data[:ecosystem]}/#{constraint[:dependency]}" }
27
+ end
28
+ .uniq
29
+ result_object.each do |key, data|
30
+ ceiling = data[:language_ceiling]
31
+ next unless ceiling && ceiling[:fixed_by_upgrade]
32
+
33
+ # Native results are keyed by the bare gem name; SBOM results are keyed by
34
+ # "ecosystem/name@version" and carry the bare name in :name.
35
+ package_name = data[:name] || key
36
+ next unless capped.include?("#{data[:ecosystem]}/#{package_name}")
37
+
38
+ ceiling[:fixed_by_upgrade] = false
39
+ ceiling[:upgrade_blocked] = true
40
+ end
41
+ end
42
+ end
43
+ end