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