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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Translates a PEP 440 `requires_python` specifier into a RubyGems requirement
5
+ # STRING, so the generic RuntimeCeilingHelper can reason about a Python runtime
6
+ # ceiling with no core change -- the same way Ruby's `ruby_version` already
7
+ # feeds it. PEP 440 and RubyGems disagree on two operators that matter here:
8
+ #
9
+ # - `~=` (compatible release) is a syntax error to Gem::Requirement, so a
10
+ # naive parse raises and the ceiling is silently missed. It maps cleanly to
11
+ # RubyGems `~>` for the major.minor.patch shapes `requires_python` uses.
12
+ # - `== X.*` / `== X.Y.*` prefix wildcards are likewise unparseable and map to
13
+ # a pessimistic `~>` over the prefix.
14
+ #
15
+ # `!=` exclusions are dropped: a hole-punch can never create an upper bound, so
16
+ # it cannot be a runtime ceiling, and dropping it can only ADMIT more runtimes
17
+ # (fewer findings) -- conservative, never a false ceiling. Anything that still
18
+ # won't parse degrades to a dropped clause (or nil overall), never a raise: this
19
+ # feeds the core audit and must fail safe, matching RuntimeCeilingHelper's
20
+ # best-effort contract.
21
+ module Pep440Helper
22
+ extend self
23
+
24
+ # Match ConstraintHelper / RuntimeCeilingHelper: bound pathological registry
25
+ # input before it reaches Gem::Requirement's own regex.
26
+ MAX_SPECIFIER_LENGTH = 256
27
+
28
+ # Operator, then the version as a run of non-space chars. `\s*(\S+)` (not
29
+ # `\s*(.+)`) keeps the two groups over disjoint character classes so there's no
30
+ # polynomial backtracking on pathological input like "<" + many spaces (a PEP
31
+ # 440 version never contains internal spaces, so this loses nothing).
32
+ CLAUSE_PATTERN = /\A(===|==|~=|!=|<=|>=|<|>)\s*(\S+)\z/
33
+
34
+ # => a comma-joined RubyGems requirement string, or nil when nothing usable
35
+ # survives translation. The caller feeds the string to RuntimeCeilingHelper,
36
+ # which splits and splats it exactly like a `ruby_version` string.
37
+ def to_gem_requirement_string(specifier)
38
+ spec = specifier.to_s
39
+ return if spec.strip.empty? || spec.length > MAX_SPECIFIER_LENGTH
40
+
41
+ clauses = spec.split(",").filter_map { |clause| translate_clause(clause.strip) }
42
+ return if clauses.empty?
43
+
44
+ clauses.join(", ")
45
+ end
46
+
47
+ private
48
+
49
+ def translate_clause(clause)
50
+ match = CLAUSE_PATTERN.match(clause)
51
+ return if match.nil?
52
+
53
+ operator = match[1]
54
+ version = match[2].strip
55
+
56
+ case operator
57
+ when "!=" then nil # hole-punch: never an upper bound, drop it
58
+ when "~=" then compatible_release(version)
59
+ when "==", "===" then equality(version)
60
+ else comparison(operator, version)
61
+ end
62
+ end
63
+
64
+ # `~= X.Y[.Z]` is `>= X.Y[.Z], == X.Y.*` which is exactly RubyGems `~> X.Y[.Z]`.
65
+ def compatible_release(version)
66
+ return unless parseable?(version)
67
+
68
+ "~> #{version}"
69
+ end
70
+
71
+ def equality(version)
72
+ if version.end_with?(".*")
73
+ prefix_match(version.delete_suffix(".*"))
74
+ elsif parseable?(version)
75
+ "= #{version}"
76
+ end
77
+ end
78
+
79
+ # `== P.*` matches every release whose version starts with prefix P, i.e.
80
+ # `>= P, < P-with-last-segment-incremented`. RubyGems spells that as `~> P.0`
81
+ # (append one segment so the pessimistic bump lands on P's last segment):
82
+ # ==3.* -> ~> 3.0 (>=3, <4)
83
+ # ==3.7.* -> ~> 3.7.0 (>=3.7, <3.8)
84
+ def prefix_match(prefix)
85
+ return unless parseable?(prefix)
86
+
87
+ "~> #{prefix}.0"
88
+ end
89
+
90
+ def comparison(operator, version)
91
+ return unless parseable?(version)
92
+
93
+ "#{operator} #{version}"
94
+ end
95
+
96
+ def parseable?(version)
97
+ Gem::Version.correct?(version)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "endoflife_helper"
4
+
5
+ module StillActive
6
+ # Python's calibration for the language-runtime ceiling: the thin sibling of
7
+ # RubyHelper.supported_ruby_range. Python declares its runtime constraint as a
8
+ # PEP 440 `requires_python` specifier (translate with Pep440Helper before
9
+ # handing it to RuntimeCeilingHelper), and its release calendar lives in the
10
+ # same endoflife.date feed. Everything ecosystem-neutral is in EndoflifeHelper;
11
+ # Python only chooses the feed path.
12
+ module PythonHelper
13
+ extend self
14
+
15
+ def supported_python_range
16
+ EndoflifeHelper.support_window(feed_path: "/api/python.json")
17
+ end
18
+ end
19
+ end
@@ -13,6 +13,9 @@ module StillActive
13
13
  extend self
14
14
 
15
15
  STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
16
+ # Requirement operators whose safe versions are OLDER than the flaw, i.e. not a
17
+ # forward fix a consumer can upgrade to. Anything else ("> X" etc.) is.
18
+ OLDER_THAN_FLAW_OPERATORS = ["<", "<="].freeze
16
19
 
17
20
  # bundler-audit's Database#check_gem expects an object responding to
18
21
  # #name and #version (a Gem::Version).
@@ -66,12 +69,32 @@ module StillActive
66
69
  cvss3_score: details[:cvss_v3],
67
70
  cvss3_vector: nil,
68
71
  cvss2_score: details[:cvss_v2],
72
+ # No safe version a consumer can upgrade TO: the correlation bundler-audit
73
+ # + `bundle outdated` can't produce alone. A factual read of the DB.
74
+ no_fix_available: no_forward_fix?(advisory),
69
75
  source: "ruby-advisory-db",
70
76
  }
71
77
  end
72
78
 
73
79
  private
74
80
 
81
+ # True when the advisory records no version the consumer can move forward to.
82
+ # Mirrors bundler-audit's own `!patched? && !unaffected?` rather than only the
83
+ # patched half: a clean release shipped AFTER a backdoored/yanked version is
84
+ # recorded in unaffected_versions as a "> X" range, not in patched_versions
85
+ # (the CVE-2019-15224 bootstrap-sass pattern). Be conservative -- only a purely
86
+ # older-than-the-flaw safe range ("< X") counts as no-forward-fix -- so we never
87
+ # claim "no fix" while a later safe release exists.
88
+ def no_forward_fix?(advisory)
89
+ return false unless advisory.patched_versions.empty?
90
+
91
+ advisory.unaffected_versions.all? { |requirement| older_than_flaw_only?(requirement) }
92
+ end
93
+
94
+ def older_than_flaw_only?(requirement)
95
+ requirement.requirements.all? { |operator, _| OLDER_THAN_FLAW_OPERATORS.include?(operator) }
96
+ end
97
+
75
98
  # nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
76
99
  # has nothing to match in the advisory DB, so the caller returns [].
77
100
  def parse_version(version)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "bundler_helper"
5
+ require_relative "endoflife_helper"
5
6
  require_relative "http_helper"
6
7
  require_relative "libyear_helper"
7
8
 
@@ -24,15 +25,15 @@ module StillActive
24
25
  return if latest_cycle.nil?
25
26
 
26
27
  latest_version = latest_cycle["latest"]
27
- latest_release_date = parse_date(latest_cycle["releaseDate"])
28
- current_release_date = parse_date(current_cycle&.dig("releaseDate"))
28
+ latest_release_date = EndoflifeHelper.parse_date(latest_cycle["releaseDate"])
29
+ current_release_date = EndoflifeHelper.parse_date(current_cycle&.dig("releaseDate"))
29
30
  eol_value = current_cycle&.dig("eol")
30
31
 
31
32
  {
32
33
  version: current,
33
34
  release_date: current_release_date,
34
- eol_date: parse_eol(eol_value),
35
- eol: eol_reached?(eol_value),
35
+ eol_date: EndoflifeHelper.parse_eol(eol_value),
36
+ eol: EndoflifeHelper.eol_reached?(eol_value),
36
37
  latest_version: latest_version,
37
38
  latest_release_date: latest_release_date,
38
39
  libyear: LibyearHelper.gem_libyear(
@@ -42,6 +43,14 @@ module StillActive
42
43
  }
43
44
  end
44
45
 
46
+ # The runtime facts a per-gem language ceiling is measured against, sourced
47
+ # from the shared endoflife.date support-window builder. Ruby only picks the
48
+ # feed; the ecosystem-neutral logic (support floor, latest stable, grace
49
+ # window, cycle normalization) lives in EndoflifeHelper.
50
+ def supported_ruby_range
51
+ EndoflifeHelper.support_window(feed_path: "/api/ruby.json")
52
+ end
53
+
45
54
  private
46
55
 
47
56
  def current_ruby_version
@@ -68,25 +77,5 @@ module StillActive
68
77
  major_minor = version.split(".")[0..1].join(".")
69
78
  cycles.find { |c| c["cycle"] == major_minor }
70
79
  end
71
-
72
- def parse_date(date_string)
73
- return if date_string.nil?
74
-
75
- Time.parse(date_string)
76
- end
77
-
78
- def parse_eol(value)
79
- case value
80
- when String then parse_date(value)
81
- end
82
- end
83
-
84
- def eol_reached?(value)
85
- case value
86
- when true then true
87
- when false then false
88
- when String then Time.parse(value) <= Time.now
89
- end
90
- end
91
80
  end
92
81
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constraint_helper"
4
+
5
+ module StillActive
6
+ # The language-runtime sibling of the poison-pill signal, kept deliberately
7
+ # generic: it reads a constraint a package declares on its RUNTIME (the version
8
+ # range it can run on) and, given that runtime's support window, answers how much
9
+ # the constraint holds you back. Ruby (`ruby_version`) is the first consumer, but
10
+ # nothing here is Ruby-specific: the same core serves any runtime with an
11
+ # end-of-life calendar (Python's `requires_python`, etc.). The runtime-specific
12
+ # parts -- where the constraint string comes from, and how the support window is
13
+ # built -- live in the calibration layer (RubyHelper.supported_ruby_range) and
14
+ # the workflow boundary, exactly as poison keeps ConstraintHelper generic and
15
+ # pushes ecosystem resolution to the lens.
16
+ #
17
+ # Two tiers, mirroring the severity model:
18
+ # - EOL-forced (critical): the constraint admits no still-supported runtime,
19
+ # stranding you on an end-of-life release with no security patches. A genuine
20
+ # upgrade blocker (e.g. a gem's `ruby_version < 3.2` caps at the EOL Ruby 3.1).
21
+ # - latest-not-yet (note): runs on a supported runtime but caps below the latest
22
+ # stable. An FYI ceiling to plan around, or a place to contribute support for
23
+ # the newest release before you invest.
24
+ #
25
+ # A bare floor (`>= 3.1`) or a requires-newer constraint (`>= 4.1`, `~> 5.0`) is
26
+ # NOT a ceiling: it raises the minimum, it doesn't cap you onto a dead release.
27
+ # The distinction is drawn against the live EOL cycles, not the operator alone.
28
+ module RuntimeCeilingHelper
29
+ extend self
30
+
31
+ # Gem::Requirement caps input via a regex; a registry-derived string could be
32
+ # pathological. Bound it up front like ConstraintHelper does.
33
+ MAX_REQUIREMENT_LENGTH = 256
34
+
35
+ # => { requirement:, eol_forced:, severity:, ... } or nil when there's no
36
+ # ceiling. `support_window` is a { oldest_supported:, latest_stable:, cycles: }
37
+ # hash of Gem::Versions plus normalized EOL cycles (see supported_ruby_range).
38
+ def analyze(requirement:, support_window:)
39
+ return if support_window.nil?
40
+
41
+ req = safe_requirement(requirement)
42
+ return if req.nil? || !capping?(req)
43
+
44
+ supported_allowed = support_window[:cycles].reject { |c| c[:eol] }.select { |c| req.satisfied_by?(c[:version]) }
45
+
46
+ if supported_allowed.empty?
47
+ eol_forced_finding(req, requirement, support_window)
48
+ elsif !support_window[:latest_stable].nil? && !req.satisfied_by?(support_window[:latest_stable]) && !support_window[:latest_stable_fresh]
49
+ # Runs on a supported runtime but not the latest stable. Suppressed while
50
+ # the latest stable is still within its grace window (see supported_ruby_
51
+ # range): right after a runtime ships, "doesn't support it yet" indicts the
52
+ # release calendar, not the gem. After the window it is a real note.
53
+ latest_not_yet_finding(requirement, support_window)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # A ceiling exists only if the constraint has an upper bound. A pure lower
60
+ # bound (`>`, `>=`) can never strand you on an old release, so skip it before
61
+ # any cycle math (this is also what keeps `>= 4.1` requires-newer out). Exact
62
+ # pins (`=`) are excluded deliberately: pinning a runtime to an exact patch is
63
+ # pathological (unheard of in practice) and can't be matched against the
64
+ # major.minor EOL cycles anyway, so we don't fabricate support for it.
65
+ CAPPING_OPERATORS = ["<", "<=", "~>"].freeze
66
+
67
+ def capping?(req)
68
+ req.requirements.any? { |operator, _version| CAPPING_OPERATORS.include?(operator) }
69
+ end
70
+
71
+ def eol_forced_finding(req, requirement, support_window)
72
+ # No supported release is admitted. Only a genuine cap (admits some EOL
73
+ # release) is EOL-forced; a requires-newer floor admits nothing at or below
74
+ # the oldest supported and is not our concern.
75
+ allowed_eol = support_window[:cycles].select { |c| c[:eol] && req.satisfied_by?(c[:version]) }
76
+ return if allowed_eol.empty?
77
+
78
+ ceiling = allowed_eol.max_by { |c| c[:version] }
79
+ finding = {
80
+ requirement: requirement,
81
+ eol_forced: true,
82
+ ceiling_version: ceiling[:version].to_s,
83
+ ceiling_eol_date: ceiling[:eol_date],
84
+ oldest_supported: support_window[:oldest_supported].to_s,
85
+ latest_stable: support_window[:latest_stable].to_s,
86
+ }
87
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
88
+ end
89
+
90
+ def latest_not_yet_finding(requirement, support_window)
91
+ finding = {
92
+ requirement: requirement,
93
+ eol_forced: false,
94
+ oldest_supported: support_window[:oldest_supported].to_s,
95
+ latest_stable: support_window[:latest_stable].to_s,
96
+ }
97
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
98
+ end
99
+
100
+ # Registries render a floor+ceiling requirement as a single comma-joined string
101
+ # (e.g. ">= 2.5, < 3.2" -- common for `ruby_version`/`required_ruby_version`).
102
+ # Gem::Requirement.new can't parse that one-string form (it raises), so split
103
+ # into clauses and splat, preserving BOTH bounds. Reading only the string as-is
104
+ # would drop the `< 3.2` ceiling and silently miss the very case this exists to
105
+ # catch. Best-effort: a genuinely malformed clause degrades to nil (no finding),
106
+ # never a raised exception that could break the core audit.
107
+ def safe_requirement(requirement)
108
+ return if requirement.nil? || requirement.to_s.length > MAX_REQUIREMENT_LENGTH
109
+
110
+ clauses = requirement.to_s.split(",").map(&:strip).reject(&:empty?)
111
+ return if clauses.empty?
112
+
113
+ Gem::Requirement.new(*clauses)
114
+ rescue ArgumentError
115
+ # Covers Gem::Requirement::BadRequirementError (a subclass) plus any other
116
+ # malformed-input ArgumentError, so a pathological registry string degrades
117
+ # to "no ceiling" and never breaks the core audit.
118
+ nil
119
+ end
120
+ end
121
+ end
@@ -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