still_active 2.0.0 → 3.0.0.rc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +104 -357
- 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 +27 -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 +205 -9
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- metadata +61 -11
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# Reads a declared dependency constraint (the requirement string a package
|
|
5
|
+
# publishes for one of its runtime deps, e.g. "~> 4.2", "< 5.0", "= 1.2.3") and
|
|
6
|
+
# answers how much it caps you: is there an upper bound, and how many majors
|
|
7
|
+
# behind the dependency's current latest does it hold you?
|
|
8
|
+
#
|
|
9
|
+
# This is the constraint half of the poison-pill signal (constraint-tightness x
|
|
10
|
+
# maintenance-state): a dormant package that caps a still-evolving dep below its
|
|
11
|
+
# latest major holds your tree hostage with no hope of the cap lifting. A fixed
|
|
12
|
+
# cap gets more poisonous purely with time, as the capped dep ships new majors
|
|
13
|
+
# while the cap stays frozen.
|
|
14
|
+
#
|
|
15
|
+
# Grammar is coarse but cross-ecosystem: Ruby pessimistic (~>), pip compatible
|
|
16
|
+
# (~=), semver caret/tilde (^ ~), exact (= == ===), and plain </<=. Precision is
|
|
17
|
+
# at the MAJOR level, which is all the signal needs ("N majors behind").
|
|
18
|
+
module ConstraintHelper
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# The constraint kinds that make a dep a poison-pill candidate: an upper bound
|
|
22
|
+
# that blocks upgrades, or an exact pin that freezes the whole tree. A
|
|
23
|
+
# `:permissive` constraint is neither. Shared with the workflow so the poison
|
|
24
|
+
# definition lives in one place.
|
|
25
|
+
POISON_KINDS = [:ceiling, :exact_pin].freeze
|
|
26
|
+
|
|
27
|
+
# An exact pin: = / == / === before a digit (NOT >= or <=).
|
|
28
|
+
EXACT_PIN = /\A={1,3}\s*v?\d/
|
|
29
|
+
# A bare version with no operator (npm/cargo exact form), e.g. "1.2.3", "v1".
|
|
30
|
+
BARE_VERSION = /\Av?\d+(?:\.\d+)*\z/
|
|
31
|
+
# Operator + version at the head of a clause.
|
|
32
|
+
CLAUSE = /\A(===?|=|<=|<|~>|~=|\^|~)?\s*v?(\d+(?:\.\d+)*)/
|
|
33
|
+
# Whitespace that separates npm AND clauses (">=1.2.0 <2.0.0"), i.e. a space
|
|
34
|
+
# before an operator. It won't split a Ruby clause like "~> 4.2" (the space
|
|
35
|
+
# there precedes a digit, not an operator). The quantifier is POSSESSIVE
|
|
36
|
+
# (`\s++`, not `\s+`): a lookahead after a greedy `\s+` backtracks once per
|
|
37
|
+
# trailing space, which is quadratic on a long run of spaces (ReDoS on the
|
|
38
|
+
# lockfile-derived requirement string). Possessive matching never backtracks.
|
|
39
|
+
AND_SEPARATOR = /\s++(?=[<>=~^])/
|
|
40
|
+
# A real declared requirement ("< 5.0, >= 4.0.1", "^1 || ^2 || ^3") is short.
|
|
41
|
+
# Anything past this is not a parseable constraint, so cap the input up front:
|
|
42
|
+
# it bounds every regex below to linear work and refuses to mint a pill from
|
|
43
|
+
# garbage (an over-long string reads as permissive, never a false ceiling).
|
|
44
|
+
MAX_REQUIREMENT_LENGTH = 256
|
|
45
|
+
|
|
46
|
+
# => { kind: :permissive|:ceiling|:exact_pin, majors_behind: Integer }
|
|
47
|
+
def analyze(requirement:, dep_latest:)
|
|
48
|
+
return { kind: :permissive, majors_behind: 0 } if requirement.to_s.length > MAX_REQUIREMENT_LENGTH
|
|
49
|
+
|
|
50
|
+
# `||` is an OR-range (npm): satisfied by ANY branch, so an unbounded branch
|
|
51
|
+
# lifts the cap entirely and the effective ceiling is the LOOSEST branch.
|
|
52
|
+
# Reading only the first branch would invent a false pill on the common
|
|
53
|
+
# `^2.0.0 || ^3.0.0` "supports several majors" form.
|
|
54
|
+
branches = requirement.to_s.split("||").map { |branch| clauses_of(branch) }
|
|
55
|
+
branches = [[]] if branches.empty?
|
|
56
|
+
ceilings = branches.map { |clauses| branch_ceiling(clauses) }
|
|
57
|
+
latest_major = major(dep_latest)
|
|
58
|
+
|
|
59
|
+
return { kind: :permissive, majors_behind: 0 } if ceilings.any?(&:nil?)
|
|
60
|
+
|
|
61
|
+
behind = latest_major ? [latest_major - ceilings.max, 0].max : 0
|
|
62
|
+
kind = branches.all? { |clauses| all_exact?(clauses) } ? :exact_pin : :ceiling
|
|
63
|
+
{ kind: kind, majors_behind: behind }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Severity tiers for a compatibility ceiling, worst-last (mirrors
|
|
67
|
+
# StatusHelper::SEVERITY -- an ordinal set compared by index, never a numeric
|
|
68
|
+
# composite). :note is FYI, :warning is review-and-plan, :critical is act-now.
|
|
69
|
+
SEVERITY = [:note, :warning, :critical].freeze
|
|
70
|
+
|
|
71
|
+
# Tier one ceiling finding. Magnitude-driven: the real-project dogfood showed
|
|
72
|
+
# Ruby findings are bimodal -- 1 major behind is trivial (a done gem pinning an
|
|
73
|
+
# old minor), 3+ is a genuine upgrade wall (e.g. capping actionmailer below
|
|
74
|
+
# Rails 6). Popularity was rejected as an input (it ranks the framework blocker
|
|
75
|
+
# below a utility) and a vulnerable-gem escalator was rejected (that is the
|
|
76
|
+
# vulnerability finding's job, not the cap's).
|
|
77
|
+
def constraint_severity(finding)
|
|
78
|
+
# A runtime ceiling that strands you on an end-of-life Ruby is act-now: no
|
|
79
|
+
# patched runtime is reachable. This short-circuit lets the language-ceiling
|
|
80
|
+
# signal (which carries no majors_behind) reuse the same tierer as poison.
|
|
81
|
+
return :critical if finding[:eol_forced]
|
|
82
|
+
|
|
83
|
+
case finding[:majors_behind]
|
|
84
|
+
when 2 then :warning
|
|
85
|
+
when 3.. then :critical
|
|
86
|
+
else :note
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The worst tier across a gem's CEILING findings (exact-pins are a milder
|
|
91
|
+
# hazard, not poison, and don't carry a tier), or nil when there are none.
|
|
92
|
+
def worst_severity(findings)
|
|
93
|
+
tiers = findings.filter_map { constraint_severity(_1) if _1[:kind] == :ceiling }
|
|
94
|
+
tiers.max_by { SEVERITY.index(_1) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# For the --fail-if-poison[=TIER] gate: is `severity` at or above `threshold`?
|
|
98
|
+
def severity_at_or_above?(severity, threshold)
|
|
99
|
+
return false if severity.nil?
|
|
100
|
+
|
|
101
|
+
SEVERITY.index(severity) >= SEVERITY.index(threshold)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# True when `version` sits at or below the highest major the cap allows -- i.e.
|
|
105
|
+
# you could upgrade the capped dep to it WITHOUT breaking the cap. The ceiling
|
|
106
|
+
# major is recovered from the poison finding itself (dep_latest minus
|
|
107
|
+
# majors_behind), at the same major precision the poison signal already uses, so
|
|
108
|
+
# a fix that lands OUTSIDE the cap ("below the fix") is detected without
|
|
109
|
+
# re-parsing the raw requirement and its pre/dev-release quirks (`< 5.0.0dev`).
|
|
110
|
+
#
|
|
111
|
+
# Precision is MAJOR-level by design, and that errs deliberately toward reachable:
|
|
112
|
+
# a within-major cap (`~> 4.2.1`, `< 5.5`) reads a same-major fix (4.9.0, 5.29.6)
|
|
113
|
+
# as reachable even though the cap forbids it. So a genuinely-stuck within-major
|
|
114
|
+
# cap is UNDER-reported (downgraded to the still-visible "vulnerable" tier), never
|
|
115
|
+
# falsely promoted -- when this DOES return false for every fix, the below-the-fix
|
|
116
|
+
# claim is sound (every fix is in a strictly higher major than the cap allows).
|
|
117
|
+
# Unparseable/absent inputs read false (never claims reachable when we can't tell).
|
|
118
|
+
def reachable_within_cap?(finding, version)
|
|
119
|
+
fix_major = major(version)
|
|
120
|
+
latest_major = major(finding[:dep_latest])
|
|
121
|
+
behind = finding[:majors_behind]
|
|
122
|
+
return false if fix_major.nil? || latest_major.nil? || behind.nil?
|
|
123
|
+
|
|
124
|
+
fix_major <= latest_major - behind
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The poison condition: a below-latest ceiling or exact pin. A permissive
|
|
128
|
+
# constraint, or a cap at/above the dep's latest major, is not a pill.
|
|
129
|
+
def poison_ceiling?(requirement:, dep_latest:)
|
|
130
|
+
result = analyze(requirement: requirement, dep_latest: dep_latest)
|
|
131
|
+
POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Build the poison-pill receipts for a package's declared runtime deps. Each
|
|
135
|
+
# `dep` is { package_name:, requirements: }; the block resolves a dep name to
|
|
136
|
+
# its current latest version string (or nil when unresolvable, which drops the
|
|
137
|
+
# dep rather than guessing). Returns only the below-latest ceilings and exact
|
|
138
|
+
# pins, each as a self-contained receipt. Shared by the native Bundler path and
|
|
139
|
+
# the cross-ecosystem lens, which differ only in how they resolve dep_latest.
|
|
140
|
+
def poison_findings(deps)
|
|
141
|
+
deps.filter_map do |dep|
|
|
142
|
+
dep_latest = yield(dep[:package_name])
|
|
143
|
+
next if dep_latest.nil?
|
|
144
|
+
|
|
145
|
+
result = analyze(requirement: dep[:requirements], dep_latest: dep_latest)
|
|
146
|
+
next unless POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
dependency: dep[:package_name],
|
|
150
|
+
requirement: dep[:requirements],
|
|
151
|
+
dep_latest: dep_latest,
|
|
152
|
+
majors_behind: result[:majors_behind],
|
|
153
|
+
kind: result[:kind],
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Select the worst `limit` findings for a display receipt, plus the full count
|
|
159
|
+
# so a renderer can say "+N more" / "N total". Worst-first by majors_behind,
|
|
160
|
+
# ties broken by dependency name so the output is stable and diffable. Shared
|
|
161
|
+
# by the terminal and markdown renderers so the selection can't drift.
|
|
162
|
+
def top_findings(findings, limit: 3)
|
|
163
|
+
ranked = findings.sort_by { |f| [-f[:majors_behind], f[:dependency].to_s] }
|
|
164
|
+
{ shown: ranked.first(limit), total: findings.length }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Split one OR-branch into its AND clauses: comma-separated (Ruby/pip) or
|
|
170
|
+
# space-separated (npm ">=1.2.0 <2.0.0").
|
|
171
|
+
def clauses_of(branch)
|
|
172
|
+
branch.split(",").flat_map { |part| part.strip.split(AND_SEPARATOR) }.map(&:strip).reject(&:empty?)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The tightest ceiling major within one AND-branch, or nil when the branch has
|
|
176
|
+
# no upper bound at all (a lone lower bound = permissive, lifts the OR cap).
|
|
177
|
+
def branch_ceiling(clauses)
|
|
178
|
+
clauses.filter_map { |clause| clause_ceiling_major(clause) }.min
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def all_exact?(clauses)
|
|
182
|
+
!clauses.empty? && clauses.all? { |clause| clause.match?(EXACT_PIN) || clause.match?(BARE_VERSION) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def clause_ceiling_major(clause)
|
|
186
|
+
match = clause.match(CLAUSE)
|
|
187
|
+
return unless match
|
|
188
|
+
|
|
189
|
+
operator = match[1]
|
|
190
|
+
major_of = major(match[2])
|
|
191
|
+
case operator
|
|
192
|
+
# `< X.0.0` excludes major X entirely (max usable major is X-1); `< X.Y`
|
|
193
|
+
# with a non-zero minor still allows major X.
|
|
194
|
+
when "<" then all_zero_after_major?(match[2]) ? major_of - 1 : major_of
|
|
195
|
+
when "<=", "~>", "~=", "^", "~", "=", "==", "===", nil then major_of
|
|
196
|
+
end # `>`/`>=` fall through to nil: a lower bound imposes no ceiling
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def all_zero_after_major?(version)
|
|
200
|
+
version.split(".").drop(1).all? { |part| part.to_i.zero? }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def major(version)
|
|
204
|
+
digits = version.to_s[/\d+/]
|
|
205
|
+
digits&.to_i
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# Computes a CVSS score from a vector string, across v2/v3/v4. deps.dev stores only
|
|
5
|
+
# CVSS 3.x, so a CVSS-4-only advisory (the flagship protobuf case) arrives with no
|
|
6
|
+
# numeric score; OSV carries the v4 vector, and this turns it into the number the
|
|
7
|
+
# SARIF security-severity and CycloneDX rating want.
|
|
8
|
+
#
|
|
9
|
+
# cvss-suite is an OPTIONAL, undeclared dependency: it over-constrains its own deps
|
|
10
|
+
# (an exact `bundler` pin, a `bigdecimal ~> 3.1` cap), the poison-pill pattern
|
|
11
|
+
# still_active itself flags, so hard-requiring it would force that liability on every
|
|
12
|
+
# user and trip our own audit. CvssHelper soft-requires it; install cvss-suite (or run
|
|
13
|
+
# the distribution that bundles it) to light it up.
|
|
14
|
+
#
|
|
15
|
+
# Returns cvss-suite's `overall_score`: for the base-only vectors OSV publishes that
|
|
16
|
+
# IS the base score, but a vector carrying threat/environmental metrics would fold
|
|
17
|
+
# those in -- which is why the severity band floors at the authoritative GHSA label
|
|
18
|
+
# (VulnerabilityHelper.advisory_severity) and this number never lowers it: the label
|
|
19
|
+
# drives gating and SARIF level, the number only sharpens display. Fails safe: an
|
|
20
|
+
# absent gem or an absent/unparseable vector yields nil, never a crash.
|
|
21
|
+
module CvssHelper
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
def score(vector)
|
|
25
|
+
return unless available?
|
|
26
|
+
return if vector.nil? || vector.to_s.empty?
|
|
27
|
+
|
|
28
|
+
cvss = CvssSuite.new(vector.to_s)
|
|
29
|
+
cvss.valid? ? cvss.overall_score : nil
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
# We only reach here with cvss-suite loaded (available?), so a raise is the
|
|
32
|
+
# installed gem failing this call site -- an incompatible version, a renamed
|
|
33
|
+
# `overall_score` -- not a bad vector, which cvss-suite reports via valid?.
|
|
34
|
+
# Fail safe to nil (the number is display-only, never gates), but don't
|
|
35
|
+
# swallow it: an opted-in-but-broken scorer that emitted nothing would read
|
|
36
|
+
# as "no advisories are scored". Warn once per run (not per advisory),
|
|
37
|
+
# matching OsvClient.enrich rather than staying silent.
|
|
38
|
+
warn_scorer_failed(e)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Memoized soft-require: true once cvss-suite loads, false when it isn't installed.
|
|
43
|
+
def available?
|
|
44
|
+
return @available unless @available.nil?
|
|
45
|
+
|
|
46
|
+
@available = begin
|
|
47
|
+
require "cvss_suite"
|
|
48
|
+
true
|
|
49
|
+
rescue LoadError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def warn_scorer_failed(error)
|
|
57
|
+
return if @warned
|
|
58
|
+
|
|
59
|
+
@warned = true
|
|
60
|
+
$stderr.puts("warning: cvss-suite scoring failed: #{error.class} (#{error.message}); CVSS numbers unavailable this run, check your cvss-suite version")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -142,15 +142,31 @@ module StillActive
|
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
def rating(advisory)
|
|
145
|
-
|
|
145
|
+
# effective_score drops deps.dev's cvss3=0 sentinel; an unscored advisory
|
|
146
|
+
# gets no numeric rating (honest) rather than a masquerading 0.0. A CVSS-4-only
|
|
147
|
+
# advisory now carries the OSV-computed v4 score, so it gets a real rating too.
|
|
148
|
+
score = VulnerabilityHelper.effective_score(advisory)
|
|
146
149
|
return if score.nil?
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
rating["vector"] =
|
|
151
|
+
rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => rating_method(advisory) }
|
|
152
|
+
vector = advisory[:cvss3_vector] || advisory[:cvss_vector]
|
|
153
|
+
rating["vector"] = vector if vector
|
|
151
154
|
rating
|
|
152
155
|
end
|
|
153
156
|
|
|
157
|
+
# Names the CVSS version behind the score so a v4-derived rating isn't mislabelled
|
|
158
|
+
# CVSSv2. deps.dev's v3/v2 win; otherwise the OSV-enriched vector's version.
|
|
159
|
+
def rating_method(advisory)
|
|
160
|
+
return "CVSSv3" if advisory[:cvss3_score]&.positive?
|
|
161
|
+
return "CVSSv2" if advisory[:cvss2_score]&.positive?
|
|
162
|
+
|
|
163
|
+
case advisory[:cvss_version]
|
|
164
|
+
when "4.0" then "CVSSv4"
|
|
165
|
+
when "2.0" then "CVSSv2"
|
|
166
|
+
else "CVSSv3"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
154
170
|
def boolean_property(value)
|
|
155
171
|
return if value.nil?
|
|
156
172
|
|
|
@@ -19,13 +19,14 @@ module StillActive
|
|
|
19
19
|
unknown: nil,
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
def render(diff)
|
|
22
|
+
def render(diff, accepted: [])
|
|
23
23
|
sections = [
|
|
24
24
|
"## still_active diff",
|
|
25
25
|
"",
|
|
26
|
-
summary_line(diff),
|
|
26
|
+
summary_line(diff, accepted),
|
|
27
27
|
"",
|
|
28
28
|
regressions_section(diff.regressions),
|
|
29
|
+
accepted_section(accepted),
|
|
29
30
|
added_section(diff.added),
|
|
30
31
|
removed_section(diff.removed),
|
|
31
32
|
bumps_section(diff.bumped),
|
|
@@ -38,14 +39,16 @@ module StillActive
|
|
|
38
39
|
|
|
39
40
|
private
|
|
40
41
|
|
|
41
|
-
def summary_line(diff)
|
|
42
|
-
[
|
|
42
|
+
def summary_line(diff, accepted)
|
|
43
|
+
parts = [
|
|
43
44
|
["regressions", diff.regressions.size],
|
|
44
45
|
["added", diff.added.size],
|
|
45
46
|
["removed", diff.removed.size],
|
|
46
47
|
["bumped", diff.bumped.size],
|
|
47
48
|
["signal-changes", diff.signal_changes.size],
|
|
48
|
-
]
|
|
49
|
+
]
|
|
50
|
+
parts << ["accepted", accepted.size] unless accepted.empty?
|
|
51
|
+
parts.map { |label, n| "#{n} #{label}" }.join(" · ")
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def regressions_section(regressions)
|
|
@@ -55,6 +58,19 @@ module StillActive
|
|
|
55
58
|
section("Regressions (CI-failable)", lines)
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
# Regressions a committed .still_active.yml knowingly accepts: shown so the
|
|
62
|
+
# acceptance is visible in the PR comment, but excluded from the CI-failable
|
|
63
|
+
# count above.
|
|
64
|
+
def accepted_section(accepted)
|
|
65
|
+
return "" if accepted.empty?
|
|
66
|
+
|
|
67
|
+
lines = accepted.map do |a|
|
|
68
|
+
reason = a.reason ? " — #{MarkdownEscape.inline(a.reason)}" : ""
|
|
69
|
+
"- **#{a.regression.kind}** #{MarkdownEscape.code_span(a.regression.gem)}#{reason}"
|
|
70
|
+
end
|
|
71
|
+
section("Accepted (suppressed via .still_active.yml)", lines)
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
def added_section(added)
|
|
59
75
|
return "" if added.empty?
|
|
60
76
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "http_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# The ecosystem-neutral support-window builder over an endoflife.date feed.
|
|
8
|
+
# Given a feed path (`/api/ruby.json`, `/api/python.json`, ...) it returns the
|
|
9
|
+
# runtime facts a per-package language ceiling is measured against: the oldest
|
|
10
|
+
# cycle still receiving security releases, the latest stable release, a
|
|
11
|
+
# grace-window flag, and the normalized cycle list. RubyHelper and PythonHelper
|
|
12
|
+
# are thin calibration wrappers that only choose the feed path, exactly as
|
|
13
|
+
# RuntimeCeilingHelper keeps the ceiling math generic and pushes the runtime
|
|
14
|
+
# specifics to the edges. RubyHelper#ruby_freshness also reuses the cycle-date
|
|
15
|
+
# parsing here so there is one home for reading this best-effort feed.
|
|
16
|
+
#
|
|
17
|
+
# Everything degrades on bad third-party data rather than raising: the window is
|
|
18
|
+
# fetched once outside the per-package rescue, so an unguarded raise would abort
|
|
19
|
+
# the whole audit. A single malformed field must cost at most the finding it
|
|
20
|
+
# feeds, never the whole signal.
|
|
21
|
+
module EndoflifeHelper
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
ENDOFLIFE_URI = URI("https://endoflife.date/")
|
|
25
|
+
|
|
26
|
+
# How long a newly-released runtime gets before packages are held accountable
|
|
27
|
+
# for not yet declaring support for it. Below this, a latest-not-yet ceiling
|
|
28
|
+
# is about the release calendar, not the package, so the note is suppressed.
|
|
29
|
+
LATEST_STABLE_GRACE_SECONDS = 90 * 24 * 60 * 60
|
|
30
|
+
|
|
31
|
+
# => { oldest_supported:, latest_stable:, latest_stable_fresh:, cycles: } of
|
|
32
|
+
# Gem::Versions plus normalized EOL cycles, or nil when the feed is
|
|
33
|
+
# unavailable, empty, or every known cycle is EOL (no supported floor to
|
|
34
|
+
# compare against). `latest_stable` is nil when the newest cycle's `latest` is
|
|
35
|
+
# malformed -- see below.
|
|
36
|
+
def support_window(feed_path:)
|
|
37
|
+
cycles = fetch_cycles(feed_path)
|
|
38
|
+
return if cycles.nil? || cycles.empty?
|
|
39
|
+
|
|
40
|
+
normalized = cycles.filter_map { |cycle| normalize_cycle(cycle) }
|
|
41
|
+
supported = normalized.reject { |cycle| cycle[:eol] }
|
|
42
|
+
return if supported.empty?
|
|
43
|
+
|
|
44
|
+
# A malformed/preview `latest` on the newest cycle must not sink the whole
|
|
45
|
+
# window: latest_stable feeds only the latest-not-yet NOTE, whereas the
|
|
46
|
+
# EOL-forced CRITICAL depends solely on the cycles' eol flags. Degrade it to
|
|
47
|
+
# nil (the note is then suppressed) rather than returning nil, which would
|
|
48
|
+
# silently disable criticals too.
|
|
49
|
+
latest = cycles.first["latest"]
|
|
50
|
+
latest_stable = latest && Gem::Version.correct?(latest) ? Gem::Version.new(latest) : nil
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
oldest_supported: supported.map { |cycle| cycle[:version] }.min,
|
|
54
|
+
latest_stable: latest_stable,
|
|
55
|
+
latest_stable_fresh: !latest_stable.nil? && latest_stable_fresh?(cycles.first),
|
|
56
|
+
cycles: normalized,
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A best-effort date parse over this feed's values: nil for a missing OR
|
|
61
|
+
# malformed date, never a raise. endoflife.date is third-party data; a garbled
|
|
62
|
+
# date on one cycle must degrade that cycle, not abort the run.
|
|
63
|
+
def parse_date(date_string)
|
|
64
|
+
return if date_string.nil?
|
|
65
|
+
|
|
66
|
+
Time.parse(date_string)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_eol(value)
|
|
72
|
+
case value
|
|
73
|
+
when String then parse_date(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# true / false when the eol date is known, nil when it's absent or malformed
|
|
78
|
+
# (so callers can decide how to treat "unknown"). Routes through parse_date, so
|
|
79
|
+
# a garbled eol string degrades to nil rather than raising.
|
|
80
|
+
def eol_reached?(value)
|
|
81
|
+
case value
|
|
82
|
+
when true then true
|
|
83
|
+
when false then false
|
|
84
|
+
when String
|
|
85
|
+
parsed = parse_date(value)
|
|
86
|
+
parsed.nil? ? nil : parsed <= Time.now
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def fetch_cycles(feed_path)
|
|
93
|
+
HttpHelper.get_json(ENDOFLIFE_URI, feed_path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_cycle(cycle)
|
|
97
|
+
version = cycle["cycle"]
|
|
98
|
+
return unless version && Gem::Version.correct?(version)
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
version: Gem::Version.new(version),
|
|
102
|
+
eol: eol_reached?(cycle["eol"]) == true,
|
|
103
|
+
eol_date: parse_eol(cycle["eol"]),
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def latest_stable_fresh?(latest_cycle)
|
|
108
|
+
released = parse_date(latest_cycle["releaseDate"])
|
|
109
|
+
return false if released.nil?
|
|
110
|
+
|
|
111
|
+
(Time.now - released) < LATEST_STABLE_GRACE_SECONDS
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/helpers/http_helper.rb
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "json"
|
|
5
6
|
|
|
6
7
|
module StillActive
|
|
7
8
|
module HttpHelper
|
|
8
|
-
TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
|
|
9
|
+
TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
|
|
10
|
+
# Transport-level failures ("host unreachable / connection broke", not an HTTP
|
|
11
|
+
# error status). Every network entry point degrades to a safe empty result on
|
|
12
|
+
# these rather than letting one escape and vanish a gem from the audit.
|
|
13
|
+
# SystemCallError is the superclass of the whole Errno::* family (EHOSTUNREACH,
|
|
14
|
+
# ENETUNREACH, ETIMEDOUT, EPIPE, ECONNREFUSED, ECONNRESET, ...), so we don't
|
|
15
|
+
# have to enumerate each one and miss the next.
|
|
16
|
+
TRANSPORT_ERRORS = [
|
|
17
|
+
Net::OpenTimeout,
|
|
18
|
+
Net::ReadTimeout,
|
|
19
|
+
SocketError,
|
|
20
|
+
SystemCallError,
|
|
21
|
+
OpenSSL::SSL::SSLError,
|
|
22
|
+
EOFError,
|
|
23
|
+
].freeze
|
|
9
24
|
MAX_REDIRECTS = 3
|
|
10
25
|
# Ceiling on a single response body. These are metadata endpoints (version
|
|
11
26
|
# lists, scorecards, advisories); legitimate responses are well under this.
|
|
@@ -94,7 +109,7 @@ module StillActive
|
|
|
94
109
|
|
|
95
110
|
$stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
|
|
96
111
|
nil
|
|
97
|
-
rescue
|
|
112
|
+
rescue *TRANSPORT_ERRORS => e
|
|
98
113
|
$stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
|
|
99
114
|
nil
|
|
100
115
|
rescue JSON::ParserError => e
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "markdown_escape"
|
|
4
4
|
require_relative "vulnerability_helper"
|
|
5
5
|
require_relative "activity_helper"
|
|
6
|
+
require_relative "constraint_helper"
|
|
6
7
|
|
|
7
8
|
module StillActive
|
|
8
9
|
module MarkdownHelper
|
|
@@ -112,8 +113,123 @@ module StillActive
|
|
|
112
113
|
lines.join("\n")
|
|
113
114
|
end
|
|
114
115
|
|
|
116
|
+
# A dormant dep that caps your tree below its latest major (the poison-pill
|
|
117
|
+
# signal). Consistent with the other finding sections: worst caps + total, the
|
|
118
|
+
# direct parent named for a transitive pill. The full cap list is in the JSON.
|
|
119
|
+
def poison_section(result)
|
|
120
|
+
# Require constraints present, not just the poison flag, so an unexpected
|
|
121
|
+
# empty set can't emit a dangling "- `gem` caps" bullet (symmetric with the
|
|
122
|
+
# terminal's poison_line guard).
|
|
123
|
+
flagged = result.select { |_name, data| data[:poison] && !Array(data[:constraints]).empty? }
|
|
124
|
+
return "" if flagged.empty?
|
|
125
|
+
|
|
126
|
+
lines = ["", "**Poison-pill findings** (dormant deps capping your tree below its latest major):"]
|
|
127
|
+
# Worst-first so the act-now findings lead, then by magnitude for a stable,
|
|
128
|
+
# triage-friendly order.
|
|
129
|
+
ranked = flagged.sort_by do |name, data|
|
|
130
|
+
[
|
|
131
|
+
poison_rank(data), # below-the-fix leads, then security-relevant, then the rest
|
|
132
|
+
-ConstraintHelper::SEVERITY.index(data[:poison_severity] || :note),
|
|
133
|
+
-Array(data[:constraints]).map { |c| c[:majors_behind].to_i }.max,
|
|
134
|
+
name.to_s,
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
ranked.each do |name, data|
|
|
138
|
+
lines << "- **#{data[:poison_severity] || :note}** #{poison_gem_ref(name, data)} caps #{poison_caps(data[:constraints])}#{poison_security_marker(data)}"
|
|
139
|
+
end
|
|
140
|
+
lines.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Ranks poison findings for the report: a cap that holds you BELOW THE FIX first
|
|
144
|
+
# (unpatchable without replacing the capper), then a merely security-relevant cap
|
|
145
|
+
# (vulnerable but patchable in place), then everything else.
|
|
146
|
+
def poison_rank(data)
|
|
147
|
+
return 0 if data[:poison_below_fix]
|
|
148
|
+
return 1 if data[:poison_security_relevant]
|
|
149
|
+
|
|
150
|
+
2
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A prominent marker naming the known-vulnerable dependency a security-relevant
|
|
154
|
+
# cap pins you to. Leads with the stronger BELOW-THE-FIX claim (the CVE and its
|
|
155
|
+
# nearest fix, a version the cap forbids) when it applies, else the weaker
|
|
156
|
+
# "vulnerable but patchable" form.
|
|
157
|
+
def poison_security_marker(data)
|
|
158
|
+
return "" unless data[:poison_security_relevant]
|
|
159
|
+
|
|
160
|
+
below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
|
|
161
|
+
if below.any?
|
|
162
|
+
receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]})" }.uniq
|
|
163
|
+
" ⚠ **pins #{receipts.join("; ")}**"
|
|
164
|
+
else
|
|
165
|
+
pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
|
|
166
|
+
" ⚠ **pins vulnerable #{pinned.join(", ")}**"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The language-runtime ceiling: a resolved version whose declared runtime
|
|
171
|
+
# constraint (Ruby `ruby_version`, Python `requires_python`) caps the runtime
|
|
172
|
+
# onto an EOL release (critical) or below the latest stable (note). Ranked
|
|
173
|
+
# worst-first with a tier label, mirroring the poison section.
|
|
174
|
+
def language_ceiling_section(result)
|
|
175
|
+
flagged = result.select { |_name, data| data[:language_ceiling] }
|
|
176
|
+
return "" if flagged.empty?
|
|
177
|
+
|
|
178
|
+
lines = ["", "**Runtime ceiling findings** (a resolved version capping the language runtime it can run on):"]
|
|
179
|
+
ranked = flagged.sort_by { |name, data| [-ConstraintHelper::SEVERITY.index(data[:language_ceiling][:severity] || :note), name.to_s] }
|
|
180
|
+
ranked.each do |name, data|
|
|
181
|
+
ceiling = data[:language_ceiling]
|
|
182
|
+
lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(name)} #{language_ceiling_receipt(ceiling, data)}"
|
|
183
|
+
end
|
|
184
|
+
lines.join("\n")
|
|
185
|
+
end
|
|
186
|
+
|
|
115
187
|
private
|
|
116
188
|
|
|
189
|
+
def language_ceiling_receipt(ceiling, data)
|
|
190
|
+
runtime = ceiling[:runtime]
|
|
191
|
+
req = MarkdownEscape.code_span(ceiling[:requirement])
|
|
192
|
+
base =
|
|
193
|
+
if ceiling[:eol_forced]
|
|
194
|
+
eol = ceiling[:ceiling_eol_date]
|
|
195
|
+
eol_part = eol ? " (EOL #{eol.strftime("%Y-%m-%d")})" : ""
|
|
196
|
+
"requires #{runtime} #{req}, stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
|
|
197
|
+
else
|
|
198
|
+
"requires #{runtime} #{req}, no #{runtime} #{ceiling[:latest_stable]} support yet"
|
|
199
|
+
end
|
|
200
|
+
fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
|
|
201
|
+
"#{base}#{fix}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def poison_gem_ref(name, data)
|
|
205
|
+
ref = MarkdownEscape.code_span(name)
|
|
206
|
+
path = data[:dependency_path]
|
|
207
|
+
# Transitive pill: name the direct parent the user can actually act on (#60).
|
|
208
|
+
return ref unless data[:direct] == false && Array(path).length >= 2
|
|
209
|
+
|
|
210
|
+
"#{ref} via #{MarkdownEscape.code_span(path.first)}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The worst 3 caps; the first carries the full receipt (requirement + latest
|
|
214
|
+
# major), the rest just the gap, and "— N total" when there are more.
|
|
215
|
+
def poison_caps(constraints)
|
|
216
|
+
top = ConstraintHelper.top_findings(Array(constraints), limit: 3)
|
|
217
|
+
parts = top[:shown].each_with_index.map do |finding, i|
|
|
218
|
+
dep = MarkdownEscape.code_span(finding[:dependency])
|
|
219
|
+
req = MarkdownEscape.code_span(finding[:requirement])
|
|
220
|
+
behind = finding[:majors_behind]
|
|
221
|
+
i.zero? ? "#{dep} #{req} (#{behind} major#{"s" unless behind == 1} behind, latest #{poison_major(finding[:dep_latest])})" : "#{dep} #{req} (#{behind})"
|
|
222
|
+
end
|
|
223
|
+
joined = parts.join(", ")
|
|
224
|
+
top[:total] > 1 ? "#{joined} — #{top[:total]} total" : joined
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# "8.0.1" -> "8.x": the cap is a major-level gap.
|
|
228
|
+
def poison_major(version)
|
|
229
|
+
major = version.to_s[/\d+/]
|
|
230
|
+
major ? "#{major}.x" : version
|
|
231
|
+
end
|
|
232
|
+
|
|
117
233
|
def transitive_flagged?(data)
|
|
118
234
|
[:archived, :critical].include?(ActivityHelper.activity_level(data)) || data[:vulnerability_count].to_i.positive?
|
|
119
235
|
end
|
|
@@ -161,7 +277,8 @@ module StillActive
|
|
|
161
277
|
severity = VulnerabilityHelper.highest_severity(vulnerabilities)
|
|
162
278
|
ids = vulnerabilities.flat_map { |v| [v[:id], *v[:aliases]] }.compact.uniq.first(3)
|
|
163
279
|
|
|
164
|
-
|
|
280
|
+
notes = [severity, ("no fix" if VulnerabilityHelper.no_fix_available?(vulnerabilities))].compact
|
|
281
|
+
parts = [notes.empty? ? count.to_s : "#{count} (#{notes.join(", ")})"]
|
|
165
282
|
parts << MarkdownEscape.cell(ids.join(", ")) unless ids.empty?
|
|
166
283
|
parts.join(" ")
|
|
167
284
|
end
|