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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -3
- data/README.md +102 -402
- data/lib/helpers/dependency_helper.rb +20 -0
- data/lib/helpers/dotnet_helper.rb +142 -0
- data/lib/helpers/markdown_helper.rb +16 -7
- data/lib/helpers/sarif_helper.rb +48 -14
- data/lib/helpers/terminal_helper.rb +18 -4
- data/lib/helpers/version_helper.rb +40 -1
- data/lib/still_active/cli.rb +59 -15
- data/lib/still_active/deps_dev_client.rb +52 -2
- data/lib/still_active/ecosystem_lens.rb +47 -5
- data/lib/still_active/options.rb +1 -1
- data/lib/still_active/sbom_workflow.rb +22 -11
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +4 -1
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
305
|
+
time.strftime("%Y/%m")
|
|
297
306
|
end
|
|
298
307
|
end
|
|
299
308
|
end
|
data/lib/helpers/sarif_helper.rb
CHANGED
|
@@ -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, "#{
|
|
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
|
-
"#{
|
|
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
|
-
|
|
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, "#{
|
|
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, "#{
|
|
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, "#{
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
"#{
|
|
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(
|
|
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
|
-
"#{
|
|
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
|
-
"#{
|
|
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,
|
|
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
|
-
|
|
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]}
|
|
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
|
-
|
|
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
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
+
gem = DependencyHelper.identity(name, data)
|
|
456
|
+
next if config.ignored_gems.include?(gem)
|
|
413
457
|
|
|
414
|
-
unknown = live_advisories(
|
|
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: #{
|
|
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
|
|