still_active 3.0.0.rc1 → 3.0.0.rc3

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.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # The canonical identity of an assessed dependency, shared by every renderer
5
+ # (terminal/markdown/SARIF display) and by the suppression matchers (--ignore
6
+ # and .still_active.yml).
7
+ module DependencyHelper
8
+ extend self
9
+
10
+ # A cross-ecosystem SBOM dependency is "ecosystem/name" (the lens sets
11
+ # :ecosystem and :name); a native gem is its bare name (the hash key), where
12
+ # gem_data carries no :ecosystem. Deliberately version-independent: it is NOT
13
+ # the composite "ecosystem/name@version" hash key, so one suppression covers a
14
+ # dependency across version bumps and matches the same identity the poison /
15
+ # ceiling correlators key on.
16
+ def identity(name, data)
17
+ data[:ecosystem] ? "#{data[:ecosystem]}/#{data[:name]}" : name
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constraint_helper"
4
+ require_relative "endoflife_helper"
5
+
6
+ module StillActive
7
+ # The NuGet calibration layer for the language-runtime ceiling (SA009), the
8
+ # .NET sibling of RubyHelper/PythonHelper. NuGet is shaped differently from
9
+ # Ruby/Python: a package declares not one version RANGE but a SET of target
10
+ # framework monikers (net6.0, netcoreapp3.1, net48, netstandard2.0). Targeting a
11
+ # framework is an enforced restore-time wall (NuGet error NU1202), so if EVERY
12
+ # concrete runtime the package targets is end-of-life, consuming it forces your
13
+ # project onto a dead runtime -- the same "no supported runtime is reachable"
14
+ # cap the Ruby/Python ceiling catches, expressed as a set rather than a range.
15
+ #
16
+ # The honesty rule is the netstandard exclusion: `netstandard*` is a
17
+ # compatibility contract consumable FROM a modern .NET, not a runtime, so it
18
+ # imposes no ceiling. Without excluding it the signal would fire on nearly every
19
+ # modern library. We therefore fire only when the package has at least one
20
+ # concrete runtime target AND every concrete runtime target is EOL.
21
+ module DotnetHelper
22
+ extend self
23
+
24
+ # The two .NET runtime calendars, fetched once per SBOM. .NET Core/5+ is a
25
+ # single unified endoflife feed; .NET Framework 4.x is its own (`dotnetfx`),
26
+ # with a different EOL schedule tied to Windows servicing.
27
+ def supported_dotnet_range = EndoflifeHelper.support_window(feed_path: "/api/dotnet.json")
28
+ def supported_dotnetfx_range = EndoflifeHelper.support_window(feed_path: "/api/dotnetfx.json")
29
+
30
+ # A target framework moniker -> { family: :dotnet | :dotnetfx, version: } (the
31
+ # runtime it names and the endoflife.date cycle to look it up by), or nil when
32
+ # the moniker is not a concrete runtime (netstandard, or unrecognized -- we
33
+ # never guess). Tolerates deps.dev's short form (net45, netcoreapp3.1), its
34
+ # long form (.NETFramework4.5), platform suffixes (net6.0-windows), and case.
35
+ def classify(moniker)
36
+ return if moniker.nil?
37
+
38
+ m = moniker.to_s.strip.downcase
39
+ return if m.empty?
40
+
41
+ # Drop a platform suffix (net6.0-windows -> net6.0) and the long-form dot.
42
+ dash = m.index("-")
43
+ m = m[0, dash] if dash
44
+ m = m.delete_prefix(".")
45
+
46
+ return if m.start_with?("netstandard") # a compatibility contract, not a runtime
47
+
48
+ # Ordered, mutually-exclusive prefixes; the load-bearing distinction is the
49
+ # dot: a dotted "netX.Y" is .NET 5+ (major cycle), a dot-less "netXY" is .NET
50
+ # Framework (digits expand to the cycle).
51
+ if m.start_with?("netcoreapp")
52
+ dotnet(m.delete_prefix("netcoreapp")) # netcoreapp3.1 -> .NET "3.1" (minor kept)
53
+ elsif m.start_with?("netframework")
54
+ dotnetfx(m.delete_prefix("netframework")) # long form: .NETFramework4.7.2
55
+ elsif m.start_with?("net")
56
+ rest = m.delete_prefix("net")
57
+ if rest.include?(".")
58
+ dotnet(rest.split(".").first) # net6.0 -> .NET "6" (major only)
59
+ elsif all_digits?(rest)
60
+ dotnetfx(expand_framework_digits(rest)) # net48 -> .NET Framework "4.8"
61
+ end
62
+ end
63
+ end
64
+
65
+ # Whether a moniker is a netstandard target (tolerating the long-form ".NETStandard"
66
+ # and platform/case variants) -- the escape hatch analyze must honour.
67
+ def netstandard?(moniker)
68
+ return false if moniker.nil?
69
+
70
+ moniker.to_s.strip.downcase.delete_prefix(".").start_with?("netstandard")
71
+ end
72
+
73
+ # => the SA009 eol_forced finding hash, or nil when the package is not capped.
74
+ # `dotnet` / `dotnetfx` are EndoflifeHelper.support_window results (or nil when
75
+ # the feed is down); only their :cycles list is read.
76
+ def analyze(target_frameworks:, dotnet:, dotnetfx:)
77
+ monikers = Array(target_frameworks)
78
+ # THE honesty rule: a netstandard target is a compatibility contract
79
+ # consumable from a current .NET, so its mere presence is an escape hatch --
80
+ # the package is not capped, whatever else it targets. Without this the signal
81
+ # fires on nearly every modern .NET library (Newtonsoft.Json, EF Core, ...),
82
+ # which all ship a netstandard2.0 target alongside older concrete runtimes.
83
+ return if monikers.any? { |moniker| netstandard?(moniker) }
84
+
85
+ runtimes = monikers.filter_map do |moniker|
86
+ classification = classify(moniker)
87
+ classification&.merge(moniker: moniker, cycle: cycle_for(classification, dotnet: dotnet, dotnetfx: dotnetfx))
88
+ end
89
+ return if runtimes.empty?
90
+
91
+ # A still-supported runtime target is a clear escape hatch: not capped.
92
+ return if runtimes.any? { |r| r[:cycle] && r[:cycle][:eol] == false }
93
+
94
+ eol = runtimes.select { |r| r[:cycle] && r[:cycle][:eol] == true }
95
+ return if eol.empty? # nothing KNOWN-EOL: don't fabricate a ceiling from unknown cycles
96
+
97
+ # The longest-lived dead runtime is the best the package offers -- the ceiling.
98
+ ceiling = eol.max_by { |r| r[:cycle][:eol_date] || Time.at(0) }
99
+ finding = {
100
+ requirement: runtimes.map { |r| r[:moniker] }.uniq.sort.join(", "),
101
+ eol_forced: true,
102
+ runtime: ceiling[:family] == :dotnetfx ? ".NET Framework" : ".NET",
103
+ ceiling_version: ceiling[:version].to_s,
104
+ ceiling_eol_date: ceiling[:cycle][:eol_date],
105
+ }
106
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
107
+ end
108
+
109
+ private
110
+
111
+ # "461" -> "4.6.1", "48" -> "4.8", "45" -> "4.5": first digit is the major, each
112
+ # remaining digit a further segment. Only called on an all-digit remainder.
113
+ def expand_framework_digits(digits) = ([digits[0]] + digits[1..].chars).join(".")
114
+
115
+ def all_digits?(str)
116
+ !str.empty? && str.chars.all? { |c| ("0".."9").cover?(c) }
117
+ end
118
+
119
+ def dotnet(version) = build(:dotnet, version)
120
+ def dotnetfx(version) = build(:dotnetfx, version)
121
+
122
+ def build(family, version)
123
+ return unless version && Gem::Version.correct?(version)
124
+
125
+ { family: family, version: Gem::Version.new(version) }
126
+ end
127
+
128
+ # The endoflife cycle for a classified target, or nil when the feed is down or
129
+ # the cycle isn't tracked (an ancient net20 predating the feed) -- unknown, not
130
+ # assumed EOL.
131
+ def cycle_for(classification, dotnet:, dotnetfx:)
132
+ window = classification[:family] == :dotnetfx ? dotnetfx : dotnet
133
+ return if window.nil?
134
+
135
+ # Match on the release segment, ignoring any prerelease suffix: endoflife
136
+ # keys .NET Framework 3.5 as "3.5-sp1", so net35 ("3.5") must still match it
137
+ # (otherwise a still-supported target reads as unknown and can false-fire).
138
+ target = classification[:version].release
139
+ window[:cycles].find { |cycle| cycle[:version].release == target }
140
+ end
141
+ end
142
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "markdown_escape"
4
+ require_relative "dependency_helper"
4
5
  require_relative "vulnerability_helper"
5
6
  require_relative "activity_helper"
6
7
  require_relative "constraint_helper"
@@ -40,7 +41,7 @@ module StillActive
40
41
  inactive_repository_emoji = data[:last_activity_warning_emoji]
41
42
  using_latest_version_emoji = data[:up_to_date_emoji]
42
43
 
43
- formatted_name = markdown_url(text: gem_name, url: repository_url)
44
+ formatted_name = markdown_url(text: DependencyHelper.identity(gem_name, data), url: repository_url)
44
45
 
45
46
  formatted_version_used = if [:git, :path].include?(data[:source_type])
46
47
  data[:version_used] ? "#{data[:version_used]} (#{data[:source_type]})" : "(#{data[:source_type]})"
@@ -66,7 +67,11 @@ module StillActive
66
67
  date: data[:latest_pre_release_version_release_date],
67
68
  )
68
69
 
69
- formatted_last_commit = markdown_url(text: year_month(data[:last_commit_date]), url: repository_url)
70
+ # Only link when there is a date: markdown_url with a nil date + a present
71
+ # repo returns "[](url)" (an invisible link, and truthy, so the `|| unsure`
72
+ # fallback below never fires). Guard here so a missing date shows unsure.
73
+ last_commit_ym = year_month(data[:last_commit_date])
74
+ formatted_last_commit = markdown_url(text: last_commit_ym, url: repository_url) if last_commit_ym
70
75
 
71
76
  unsure = StillActive.config.unsure_emoji
72
77
 
@@ -179,7 +184,7 @@ module StillActive
179
184
  ranked = flagged.sort_by { |name, data| [-ConstraintHelper::SEVERITY.index(data[:language_ceiling][:severity] || :note), name.to_s] }
180
185
  ranked.each do |name, data|
181
186
  ceiling = data[:language_ceiling]
182
- lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(name)} #{language_ceiling_receipt(ceiling, data)}"
187
+ lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(DependencyHelper.identity(name, data))} #{language_ceiling_receipt(ceiling, data)}"
183
188
  end
184
189
  lines.join("\n")
185
190
  end
@@ -202,7 +207,7 @@ module StillActive
202
207
  end
203
208
 
204
209
  def poison_gem_ref(name, data)
205
- ref = MarkdownEscape.code_span(name)
210
+ ref = MarkdownEscape.code_span(DependencyHelper.identity(name, data))
206
211
  path = data[:dependency_path]
207
212
  # Transitive pill: name the direct parent the user can actually act on (#60).
208
213
  return ref unless data[:direct] == false && Array(path).length >= 2
@@ -290,10 +295,14 @@ module StillActive
290
295
  "[#{safe_text}](#{MarkdownEscape.url(url)})"
291
296
  end
292
297
 
293
- def year_month(time_object)
294
- return if time_object.nil?
298
+ # The native Ruby path stores dates as Time; the cross-ecosystem lens carries
299
+ # deps.dev's raw ISO8601 strings. Parse either (nil-safe) rather than assuming
300
+ # Time, so an SBOM audit renders dates instead of crashing on String#strftime.
301
+ def year_month(value)
302
+ time = ActivityHelper.parse_time(value)
303
+ return if time.nil?
295
304
 
296
- time_object.strftime("%Y/%m")
305
+ time.strftime("%Y/%m")
297
306
  end
298
307
  end
299
308
  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 "dependency_helper"
9
10
  require_relative "constraint_helper"
10
11
 
11
12
  module StillActive
@@ -44,6 +45,27 @@ module StillActive
44
45
  JSON.pretty_generate(document(results: results, tool_version: tool_version))
45
46
  end
46
47
 
48
+ # The SBOM path emits the same rule set and document, but there is no lockfile
49
+ # to annotate: every finding anchors to the SBOM file itself (line 1), and
50
+ # ruby_info is nil (Ruby EOL / SA006 is a native-only signal). Findings are
51
+ # named ecosystem/name (each gem_data carries :ecosystem and :name), so a
52
+ # cross-ecosystem alert reads "npm/left-pad ..." rather than a bare gem name.
53
+ #
54
+ # result: SbomWorkflow assessed hash ("ecosystem/name@version" => gem_data)
55
+ # source_uri: the SBOM file to point findings at (basename)
56
+ # tool_version: StillActive::VERSION at emit time
57
+ def render_sbom(result:, source_uri:, tool_version:)
58
+ results = build_results(
59
+ report: result,
60
+ ruby_info: nil,
61
+ line_index: {},
62
+ ruby_line: nil,
63
+ lockfile_uri: source_uri,
64
+ )
65
+
66
+ JSON.pretty_generate(document(results: results, tool_version: tool_version))
67
+ end
68
+
47
69
  private
48
70
 
49
71
  def document(results:, tool_version:)
@@ -94,13 +116,21 @@ module StillActive
94
116
  results
95
117
  end
96
118
 
119
+ # `name` is the hash key: the bare gem name natively, the composite
120
+ # "ecosystem/name@version" on the SBOM path. It drives the fingerprint and
121
+ # location, both of which MUST stay version-distinct so two pinned versions of
122
+ # one package (a merged monorepo SBOM) never collapse into a single Code
123
+ # Scanning alert. `display` is the version-independent "ecosystem/name" (bare
124
+ # name natively): what the message shows and what a --ignore/.still_active.yml
125
+ # suppression names, so one suppression covers a dependency across versions.
97
126
  def gem_results(name, data, line_index, lockfile_uri)
98
127
  out = []
99
128
  version = data[:version_used]
129
+ display = DependencyHelper.identity(name, data)
100
130
  location = location_for(name, line_index, lockfile_uri)
101
131
 
102
132
  if data[:archived]
103
- out << mark_suppressed(result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), name, :activity)
133
+ out << mark_suppressed(result("SA001", name, "#{display} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), display, :activity)
104
134
  end
105
135
 
106
136
  unless data[:archived]
@@ -112,10 +142,10 @@ module StillActive
112
142
  result(
113
143
  "SA002",
114
144
  name,
115
- "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
145
+ "#{display} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
116
146
  location,
117
147
  ),
118
- name,
148
+ display,
119
149
  :activity,
120
150
  )
121
151
  end
@@ -127,15 +157,15 @@ module StillActive
127
157
 
128
158
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
129
159
  latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
130
- out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
160
+ out << mark_suppressed(result("SA004", name, "#{display} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), display, :libyear)
131
161
  end
132
162
 
133
163
  if data[:scorecard_score] && data[:scorecard_score] < SCORECARD_LOW_THRESHOLD
134
- out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
164
+ out << mark_suppressed(result("SA005", name, "#{display} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), display, :scorecard)
135
165
  end
136
166
 
137
167
  if data[:version_yanked]
138
- out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
168
+ out << mark_suppressed(result("SA007", name, "#{display} #{version}: this version has been yanked from RubyGems.", location), display, :yanked)
139
169
  end
140
170
 
141
171
  if data[:poison] && !Array(data[:constraints]).empty?
@@ -151,7 +181,7 @@ module StillActive
151
181
  else
152
182
  "maintenance"
153
183
  end
154
- out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
184
+ out << mark_suppressed(result("SA008", name, poison_message(display, version, data), location, level: poison_level(data), fp_extra: state), display, :poison)
155
185
  end
156
186
 
157
187
  if data[:language_ceiling]
@@ -163,7 +193,7 @@ module StillActive
163
193
  # silently mute the critical -- that transition is exactly the one a human
164
194
  # must see.
165
195
  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)
196
+ out << mark_suppressed(result("SA009", name, language_ceiling_message(display, version, data), location, level: language_ceiling_level(data), fp_extra: state), display, :language_ceiling)
167
197
  end
168
198
 
169
199
  out
@@ -173,7 +203,7 @@ module StillActive
173
203
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
174
204
  end
175
205
 
176
- def language_ceiling_message(name, version, data)
206
+ def language_ceiling_message(display, version, data)
177
207
  ceiling = data[:language_ceiling]
178
208
  runtime = ceiling[:runtime]
179
209
  body =
@@ -185,7 +215,7 @@ module StillActive
185
215
  "no #{runtime} #{ceiling[:latest_stable]} support yet"
186
216
  end
187
217
  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)}."
218
+ "#{display} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
189
219
  end
190
220
 
191
221
  # The poison receipt for a Code Scanning alert: the worst 3 caps (shared
@@ -203,7 +233,7 @@ module StillActive
203
233
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
204
234
  end
205
235
 
206
- def poison_message(name, version, data)
236
+ def poison_message(display, version, data)
207
237
  top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
208
238
  caps = top[:shown].map do |finding|
209
239
  behind = finding[:majors_behind]
@@ -211,7 +241,7 @@ module StillActive
211
241
  end
212
242
  remaining = top[:total] - top[:shown].length
213
243
  caps << "+#{remaining} more" if remaining.positive?
214
- "#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
244
+ "#{display} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
215
245
  end
216
246
 
217
247
  # Spells out the security escalation for a code-scanning alert. Only claims
@@ -254,6 +284,10 @@ module StillActive
254
284
  end
255
285
 
256
286
  def vulnerability_result(name, version, vuln, location, data = {})
287
+ # `name` (the version-distinct hash key) fingerprints the finding; `display`
288
+ # (version-independent "ecosystem/name") is what the message shows and the
289
+ # suppression matches. See gem_results for why the two differ.
290
+ display = DependencyHelper.identity(name, data)
257
291
  # Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
258
292
  # label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
259
293
  # exports as error, not a note/warning a code-scanning gate reads as
@@ -274,13 +308,13 @@ module StillActive
274
308
  base = result(
275
309
  "SA003",
276
310
  name,
277
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
311
+ "#{display} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
278
312
  location,
279
313
  level: level,
280
314
  fp_extra: advisory_id,
281
315
  )
282
316
  base["properties"] = { "security-severity" => severity } if severity
283
- mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
317
+ mark_suppressed(base, display, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
284
318
  end
285
319
 
286
320
  # Names the direct dependency a transitive flagged gem rides in on, so a
@@ -3,6 +3,7 @@
3
3
  require_relative "activity_helper"
4
4
  require_relative "ansi_helper"
5
5
  require_relative "constraint_helper"
6
+ require_relative "dependency_helper"
6
7
  require_relative "summary_helper"
7
8
  require_relative "libyear_helper"
8
9
  require_relative "version_helper"
@@ -34,9 +35,16 @@ module StillActive
34
35
 
35
36
  private
36
37
 
38
+ # "dependencies" for a cross-ecosystem SBOM audit (the lens sets :ecosystem),
39
+ # "gems" for a native Ruby audit -- calling npm/cargo/go packages "gems" is a
40
+ # Ruby-ism that reads wrong cross-ecosystem.
41
+ def dependency_noun(result)
42
+ result.each_value.any? { |data| data[:ecosystem] } ? "dependencies" : "gems"
43
+ end
44
+
37
45
  def build_row(name, data)
38
46
  [
39
- name,
47
+ DependencyHelper.identity(name, data),
40
48
  format_version(data),
41
49
  format_activity(data),
42
50
  format_scorecard(data[:scorecard_score]),
@@ -64,9 +72,15 @@ module StillActive
64
72
  return AnsiHelper.dim("-") if used.nil? && latest.nil?
65
73
  return AnsiHelper.red("#{used} (YANKED)") if data[:version_yanked]
66
74
 
67
- if VersionHelper.up_to_date(version_used: used, latest_version: latest)
75
+ # up_to_date is tri-state: true (on latest), false (genuinely behind), or nil
76
+ # (a version Gem::Version can't parse -- a pypi epoch, an exotic build string).
77
+ # Only paint the "-> latest" upgrade arrow on a definite false; a nil means we
78
+ # couldn't compare, so show the version plainly rather than a false "behind"
79
+ # (markdown shows unsure via its emoji tri-state for the same case).
80
+ current = VersionHelper.up_to_date(version_used: used, latest_version: latest)
81
+ if current
68
82
  AnsiHelper.green("#{used} (latest)")
69
- elsif latest
83
+ elsif current == false && latest
70
84
  AnsiHelper.yellow("#{used} → #{latest}")
71
85
  else
72
86
  used.to_s
@@ -318,7 +332,7 @@ module StillActive
318
332
  archived = summary[:activity][:archived]
319
333
  yanked = result.each_value.count { |d| d[:version_yanked] }
320
334
 
321
- parts = ["#{summary[:total_gems]} gems: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
335
+ parts = ["#{summary[:total_gems]} #{dependency_noun(result)}: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
322
336
  parts.last << ", #{yanked} yanked" if yanked > 0
323
337
  activity = "#{active} active, #{stale} stale"
324
338
  activity << ", #{archived} archived" if archived > 0
@@ -22,6 +22,24 @@ module StillActive
22
22
  end
23
23
  end
24
24
 
25
+ # The latest pre-release is only a useful signal when it is newer than the
26
+ # latest stable release: an upcoming version you could opt into. A pre-release
27
+ # that predates the latest stable is historical noise (e.g. a lone 2009 `rc`
28
+ # on a gem now at 0.9.x, or an `8.1.0.rc1` after `8.1.2` already shipped), and
29
+ # it silently corrupts downstream logic: up_to_date compares `used >=`
30
+ # latest_pre_release, so any current version reads `>=` a stale pre-release and
31
+ # a behind gem is painted up to date (the futurist emoji). Returns the
32
+ # pre-release only when strictly newer than the release; keeps it when there is
33
+ # no stable release at all (a pre-release-only gem), where it is the only signal.
34
+ def upcoming_pre_release(pre_release:, release:)
35
+ return pre_release if release.nil?
36
+ return if pre_release.nil?
37
+
38
+ pre = to_gem_version(pre_release)
39
+ stable = to_gem_version(release)
40
+ pre_release if pre && stable && pre > stable
41
+ end
42
+
25
43
  def up_to_date(version_used:, latest_version: nil, latest_pre_release_version: nil)
26
44
  return if latest_version.nil? && latest_pre_release_version.nil?
27
45
 
@@ -34,6 +52,14 @@ module StillActive
34
52
  .any? { |v| used >= v }
35
53
  end
36
54
 
55
+ # A Gem::Version for cross-ecosystem comparison (leading "v" and SemVer build
56
+ # metadata normalized away), or nil when the string can't be parsed. Public so
57
+ # the deps.dev client can rank a package's versions to find the real latest
58
+ # stable rather than trusting deps.dev's unreliable `isDefault` flag.
59
+ def comparable(version_string)
60
+ to_gem_version(version_string)
61
+ end
62
+
37
63
  def gem_version(version_hash:)
38
64
  version_hash&.dig("number")
39
65
  end
@@ -70,7 +96,20 @@ module StillActive
70
96
 
71
97
  def to_gem_version(version)
72
98
  str = normalize_version(version)
73
- Gem::Version.new(str) if str && Gem::Version.correct?(str)
99
+ return unless str
100
+
101
+ # Normalize two cross-ecosystem shapes Gem::Version can't parse, so a
102
+ # current dependency compares as up to date instead of reading "behind":
103
+ # - a leading "v" (Go module versions, "v2.0.1")
104
+ # - SemVer build metadata (cargo's "1.0.4+wasi-0.2.12"), everything from the
105
+ # first "+", which SemVer 2.0.0 sec 10 says MUST be ignored for precedence
106
+ # rubygems/npm/pypi versions are digit-first with no "+", so this is a no-op.
107
+ # Sliced by index rather than a regex (/\+.*/ on the external version string
108
+ # trips a polynomial-ReDoS scanner, and this is provably linear anyway).
109
+ str = str.delete_prefix("v")
110
+ plus = str.index("+")
111
+ str = str[0, plus] if plus
112
+ Gem::Version.new(str) if Gem::Version.correct?(str)
74
113
  end
75
114
  end
76
115
  end
@@ -8,6 +8,7 @@ require_relative "../helpers/bot_context"
8
8
  require_relative "../helpers/bundler_helper"
9
9
  require_relative "../helpers/constraint_helper"
10
10
  require_relative "../helpers/cyclonedx_helper"
11
+ require_relative "../helpers/dependency_helper"
11
12
  require_relative "../helpers/diff_markdown_helper"
12
13
  require_relative "../helpers/emoji_helper"
13
14
  require_relative "../helpers/markdown_helper"
@@ -140,11 +141,58 @@ module StillActive
140
141
  # assessment-time failure (a raised lens call) both mean "not assessed":
141
142
  # surface them together so neither is a silent hole in the reported coverage.
142
143
  unassessable = sbom.unassessable + outcome.failures
143
- emit_sbom_json(outcome.assessed, unassessable)
144
+ render_sbom_output(outcome.assessed, unassessable, path)
144
145
  warn_unassessable(unassessable)
145
146
  check_exit_status(outcome.assessed)
146
147
  end
147
148
 
149
+ # The assessment is the same per-dependency shape whether the input was a
150
+ # Gemfile or an SBOM, so the SBOM path honours the same output-format flags
151
+ # rather than forcing JSON. SARIF and markdown are the two that matter for the
152
+ # cross-ecosystem/CI use case; JSON stays the piped default. Two formats need
153
+ # the native Ruby audit's shape (a lockfile to annotate for SARIF's line
154
+ # numbers, a gems/ruby snapshot for the diff), which a cross-ecosystem SBOM
155
+ # can't honestly supply, so they error loudly rather than silently falling
156
+ # back to JSON: --baseline (diff) and --cyclonedx (SBOM in, SBOM out would
157
+ # need per-ecosystem PURL reconstruction; the honest way to add it later is to
158
+ # thread the input SBOM's own PURLs through rather than rebuild them).
159
+ def render_sbom_output(result, unassessable, sbom_path)
160
+ config = StillActive.config
161
+ if config.baseline_path
162
+ unsupported_sbom_format!("--baseline")
163
+ elsif config.cyclonedx_path
164
+ unsupported_sbom_format!("--cyclonedx")
165
+ elsif config.sarif_path
166
+ emit_sbom_sarif(result, config.sarif_path, sbom_path)
167
+ else
168
+ case resolve_format
169
+ when :json then emit_sbom_json(result, unassessable)
170
+ when :terminal then puts TerminalHelper.render(result)
171
+ when :markdown then render_markdown(result)
172
+ end
173
+ end
174
+ end
175
+
176
+ def unsupported_sbom_format!(flag)
177
+ $stderr.puts("error: #{flag} output is not supported in --sbom mode (it needs the native Ruby audit's lockfile/snapshot, which a cross-ecosystem SBOM can't supply); use --sarif, --markdown, --terminal, or --json")
178
+ exit(2)
179
+ end
180
+
181
+ def emit_sbom_sarif(result, sarif_path, sbom_path)
182
+ sarif_json = SarifHelper.render_sbom(
183
+ result: result,
184
+ source_uri: File.basename(sbom_path),
185
+ tool_version: StillActive::VERSION,
186
+ )
187
+
188
+ write_output(sarif_path, sarif_json)
189
+ end
190
+
191
+ # "-" means stdout (the SARIF/CycloneDX convention); any other PATH is a file.
192
+ def write_output(path, content)
193
+ path == "-" ? puts(content) : File.write(path, content)
194
+ end
195
+
148
196
  # SbomReader never raises (a malformed file degrades to an empty result), which
149
197
  # is right for the library entrypoint but wrong for the explicit --sbom flag:
150
198
  # the user pointed at a file to audit, so a truncated/wrong-format one must
@@ -271,11 +319,7 @@ module StillActive
271
319
  tool_version: StillActive::VERSION,
272
320
  )
273
321
 
274
- if sarif_path == "-"
275
- puts sarif_json
276
- else
277
- File.write(sarif_path, sarif_json)
278
- end
322
+ write_output(sarif_path, sarif_json)
279
323
  end
280
324
 
281
325
  def emit_cyclonedx(result, ruby_info, cyclonedx_path)
@@ -286,11 +330,7 @@ module StillActive
286
330
  spec_version: StillActive.config.cyclonedx_version,
287
331
  )
288
332
 
289
- if cyclonedx_path == "-"
290
- puts sbom
291
- else
292
- File.write(cyclonedx_path, sbom)
293
- end
333
+ write_output(cyclonedx_path, sbom)
294
334
  end
295
335
 
296
336
  # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise <gemfile>.lock.
@@ -394,7 +434,10 @@ module StillActive
394
434
  return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling
395
435
 
396
436
  warn_unknown_severity_gate(result, config)
397
- exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
437
+ # Match the gate on the dependency's identity (bare gem name natively,
438
+ # "ecosystem/name" for an SBOM dep), not the composite SBOM hash key, so an
439
+ # --ignore/.still_active.yml entry actually covers a cross-ecosystem finding.
440
+ exit(1) if result.any? { |name, data| gate_failed?(DependencyHelper.identity(name, data), data, config) }
398
441
  end
399
442
 
400
443
  # --fail-if-vulnerable=<threshold> fails closed on an advisory with no CVSS
@@ -409,14 +452,15 @@ module StillActive
409
452
 
410
453
  suppressions = config.suppressions
411
454
  result.each do |name, data|
412
- next if config.ignored_gems.include?(name)
455
+ gem = DependencyHelper.identity(name, data)
456
+ next if config.ignored_gems.include?(gem)
413
457
 
414
- unknown = live_advisories(name, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
458
+ unknown = live_advisories(gem, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
415
459
  next if unknown.empty?
416
460
 
417
461
  ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
418
462
  labelled = ids.empty? ? "" : " (#{ids})"
419
- $stderr.puts("warning: #{name} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
463
+ $stderr.puts("warning: #{gem} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
420
464
  end
421
465
  end
422
466