still_active 1.6.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 +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +145 -6
- 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 +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +58 -6
- 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/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- metadata +72 -11
|
@@ -80,7 +80,10 @@ module StillActive
|
|
|
80
80
|
|
|
81
81
|
def download
|
|
82
82
|
url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
|
|
83
|
-
URI.open(
|
|
83
|
+
# URI(url).open (not URI.open/Kernel#open) so a "|cmd" string can never be
|
|
84
|
+
# treated as a shell command — the URL is a constant-repo archive link, but
|
|
85
|
+
# this keeps the open path injection-proof regardless.
|
|
86
|
+
URI.parse(url).open { |io| io.read(MAX_DOWNLOAD_BYTES) }
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
def build_siblings(categories)
|
|
@@ -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
|
|
@@ -55,7 +55,7 @@ module StillActive
|
|
|
55
55
|
component = { "type" => "library", "name" => name }
|
|
56
56
|
component["version"] = version if version
|
|
57
57
|
component["bom-ref"] = bom_ref(name, data)
|
|
58
|
-
component["purl"] = purl(name, version
|
|
58
|
+
component["purl"] = purl(name, version, data[:source_type]) if version
|
|
59
59
|
component["licenses"] = licenses(data[:license]) if data[:license]
|
|
60
60
|
if data[:repository_url]
|
|
61
61
|
component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
|
|
@@ -67,13 +67,19 @@ module StillActive
|
|
|
67
67
|
|
|
68
68
|
def bom_ref(name, data)
|
|
69
69
|
version = data[:version_used]
|
|
70
|
-
return purl(name, version
|
|
70
|
+
return purl(name, version, data[:source_type]) if version
|
|
71
71
|
|
|
72
|
-
"#{data[:source_type]}-source:#{name}
|
|
72
|
+
"#{data[:source_type]}-source:#{name}@unknown"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
# Datadog SCA (and strict CycloneDX consumers) hard-reject a versioned
|
|
76
|
+
# library component with no purl, so every gem with a version needs one.
|
|
77
|
+
# A path gem is local and not on rubygems, so it gets pkg:generic to avoid
|
|
78
|
+
# false-matching a public gem of the same name; git/rubygems-sourced gems
|
|
79
|
+
# get pkg:gem so a fork still matches the upstream gem's advisories.
|
|
80
|
+
def purl(name, version, source_type)
|
|
81
|
+
type = source_type == :path ? "generic" : "gem"
|
|
82
|
+
"pkg:#{type}/#{name}@#{version}"
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
# VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
|
|
@@ -93,12 +99,22 @@ module StillActive
|
|
|
93
99
|
}.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
|
|
94
100
|
end
|
|
95
101
|
|
|
102
|
+
# The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
|
|
103
|
+
# nothing consumes it and strict SCA validators (Datadog) only accept
|
|
104
|
+
# "library" + a purl. We follow Syft's convention for an unmanaged runtime:
|
|
105
|
+
# type "library", purl pkg:generic/ruby@<ver>, plus a CPE — the CPE is what
|
|
106
|
+
# actually lets a matcher (NVD/Grype) hit interpreter CVEs, since no purl
|
|
107
|
+
# type maps the Ruby runtime in OSV. The EOL/libyear signals stay as
|
|
108
|
+
# still_active properties.
|
|
96
109
|
def ruby_component(ruby_info)
|
|
110
|
+
version = ruby_info[:version]
|
|
97
111
|
{
|
|
98
|
-
"type" => "
|
|
112
|
+
"type" => "library",
|
|
99
113
|
"name" => "ruby",
|
|
100
|
-
"version" =>
|
|
101
|
-
"bom-ref" => "
|
|
114
|
+
"version" => version,
|
|
115
|
+
"bom-ref" => "pkg:generic/ruby@#{version}",
|
|
116
|
+
"purl" => "pkg:generic/ruby@#{version}",
|
|
117
|
+
"cpe" => "cpe:2.3:a:ruby-lang:ruby:#{version}:*:*:*:*:*:*:*",
|
|
102
118
|
"properties" => [
|
|
103
119
|
{ "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
|
|
104
120
|
{ "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
|
|
@@ -126,15 +142,31 @@ module StillActive
|
|
|
126
142
|
end
|
|
127
143
|
|
|
128
144
|
def rating(advisory)
|
|
129
|
-
|
|
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)
|
|
130
149
|
return if score.nil?
|
|
131
150
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
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
|
|
135
154
|
rating
|
|
136
155
|
end
|
|
137
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
|
+
|
|
138
170
|
def boolean_property(value)
|
|
139
171
|
return if value.nil?
|
|
140
172
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "markdown_escape"
|
|
4
|
+
|
|
3
5
|
module StillActive
|
|
4
6
|
# Renders a StillActive::Diff::Result as PR-comment-friendly markdown.
|
|
5
7
|
# Section taxonomy mirrors GitHub's dependency-review-action so reviewers
|
|
@@ -17,13 +19,14 @@ module StillActive
|
|
|
17
19
|
unknown: nil,
|
|
18
20
|
}.freeze
|
|
19
21
|
|
|
20
|
-
def render(diff)
|
|
22
|
+
def render(diff, accepted: [])
|
|
21
23
|
sections = [
|
|
22
24
|
"## still_active diff",
|
|
23
25
|
"",
|
|
24
|
-
summary_line(diff),
|
|
26
|
+
summary_line(diff, accepted),
|
|
25
27
|
"",
|
|
26
28
|
regressions_section(diff.regressions),
|
|
29
|
+
accepted_section(accepted),
|
|
27
30
|
added_section(diff.added),
|
|
28
31
|
removed_section(diff.removed),
|
|
29
32
|
bumps_section(diff.bumped),
|
|
@@ -36,23 +39,38 @@ module StillActive
|
|
|
36
39
|
|
|
37
40
|
private
|
|
38
41
|
|
|
39
|
-
def summary_line(diff)
|
|
40
|
-
[
|
|
42
|
+
def summary_line(diff, accepted)
|
|
43
|
+
parts = [
|
|
41
44
|
["regressions", diff.regressions.size],
|
|
42
45
|
["added", diff.added.size],
|
|
43
46
|
["removed", diff.removed.size],
|
|
44
47
|
["bumped", diff.bumped.size],
|
|
45
48
|
["signal-changes", diff.signal_changes.size],
|
|
46
|
-
]
|
|
49
|
+
]
|
|
50
|
+
parts << ["accepted", accepted.size] unless accepted.empty?
|
|
51
|
+
parts.map { |label, n| "#{n} #{label}" }.join(" · ")
|
|
47
52
|
end
|
|
48
53
|
|
|
49
54
|
def regressions_section(regressions)
|
|
50
55
|
return "" if regressions.empty?
|
|
51
56
|
|
|
52
|
-
lines = regressions.map { |r| "- **#{r.kind}**
|
|
57
|
+
lines = regressions.map { |r| "- **#{r.kind}** #{MarkdownEscape.code_span(r.gem)} — #{MarkdownEscape.inline(r.detail)}" }
|
|
53
58
|
section("Regressions (CI-failable)", lines)
|
|
54
59
|
end
|
|
55
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
|
+
|
|
56
74
|
def added_section(added)
|
|
57
75
|
return "" if added.empty?
|
|
58
76
|
|
|
@@ -63,7 +81,7 @@ module StillActive
|
|
|
63
81
|
def removed_section(removed)
|
|
64
82
|
return "" if removed.empty?
|
|
65
83
|
|
|
66
|
-
lines = removed.map { |r| "-
|
|
84
|
+
lines = removed.map { |r| "- #{MarkdownEscape.code_span(r.name)} (was #{MarkdownEscape.inline((r.data || {})["version_used"] || "?")})" }
|
|
67
85
|
section("Removed", lines)
|
|
68
86
|
end
|
|
69
87
|
|
|
@@ -88,9 +106,9 @@ module StillActive
|
|
|
88
106
|
lines = []
|
|
89
107
|
if ruby[:version_changed]
|
|
90
108
|
eol_suffix = ruby[:newly_eol] ? " (now EOL)" : ""
|
|
91
|
-
lines << "- Ruby
|
|
109
|
+
lines << "- Ruby #{MarkdownEscape.code_span(ruby[:from])} → #{MarkdownEscape.code_span(ruby[:to])}#{eol_suffix}"
|
|
92
110
|
elsif ruby[:newly_eol]
|
|
93
|
-
lines << "- Ruby
|
|
111
|
+
lines << "- Ruby #{MarkdownEscape.code_span(ruby[:to])} is now EOL"
|
|
94
112
|
end
|
|
95
113
|
section("Ruby", lines)
|
|
96
114
|
end
|
|
@@ -108,30 +126,31 @@ module StillActive
|
|
|
108
126
|
(data["archived"] ? "archived" : nil),
|
|
109
127
|
data["libyear"] && "#{data["libyear"]}y behind",
|
|
110
128
|
].compact
|
|
111
|
-
"
|
|
129
|
+
"#{MarkdownEscape.code_span(added.name)} (#{MarkdownEscape.inline(bits.join(", "))})"
|
|
112
130
|
end
|
|
113
131
|
|
|
114
132
|
def format_bump(bump)
|
|
115
133
|
label = BUMP_KIND_LABELS[bump.kind]
|
|
116
134
|
suffix = label ? " (#{label})" : ""
|
|
117
|
-
"-
|
|
135
|
+
"- #{MarkdownEscape.code_span(bump.name)} #{MarkdownEscape.inline(bump.before_version)} → #{MarkdownEscape.inline(bump.after_version)}#{suffix}"
|
|
118
136
|
end
|
|
119
137
|
|
|
120
138
|
def format_signal_change_lines(sc)
|
|
139
|
+
name = MarkdownEscape.code_span(sc.name)
|
|
121
140
|
sc.changes.filter_map do |ch|
|
|
122
141
|
case ch[:kind]
|
|
123
142
|
when :archived
|
|
124
|
-
"-
|
|
143
|
+
"- #{name} — archived (false → true)"
|
|
125
144
|
when :new_vulnerability
|
|
126
|
-
ids = Array(ch[:ids]).join(", ")
|
|
127
|
-
"-
|
|
145
|
+
ids = MarkdownEscape.inline(Array(ch[:ids]).join(", "))
|
|
146
|
+
"- #{name} — new vulnerability (#{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{" — #{ids}" unless ids.empty?})"
|
|
128
147
|
when :scorecard_dropped
|
|
129
148
|
note = ch[:crossed_good] ? " (crossed 7.0)" : ""
|
|
130
|
-
"-
|
|
149
|
+
"- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
|
|
131
150
|
when :version_yanked
|
|
132
|
-
"-
|
|
151
|
+
"- #{name} — version yanked from rubygems"
|
|
133
152
|
when :libyear_worsened
|
|
134
|
-
"-
|
|
153
|
+
"- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
|
|
135
154
|
end
|
|
136
155
|
end
|
|
137
156
|
end
|
|
@@ -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
|