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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/vulnerability_helper"
|
|
4
|
+
require_relative "../helpers/constraint_helper"
|
|
5
|
+
require_relative "../helpers/semver_satisfaction"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Whole-tree correlation, run once after the fan-out: a poison cap is far more
|
|
9
|
+
# urgent when the dependency it pins is ITSELF vulnerable in the same tree --
|
|
10
|
+
# "a dormant package is holding you on a known-vulnerable dependency, below the
|
|
11
|
+
# version that fixes it." Both facts are already assembled (the poison
|
|
12
|
+
# constraints and every dependency's vulnerability count), so this is one pass,
|
|
13
|
+
# no extra fetches. It marks the security-relevant caps so the report can lead
|
|
14
|
+
# with them and demote the FYI caps on healthy dependencies.
|
|
15
|
+
#
|
|
16
|
+
# The moat's strongest case (verified on real repos): several archived Google
|
|
17
|
+
# client libraries pin a vulnerable `protobuf` three majors below the fix.
|
|
18
|
+
module PoisonSecurityCorrelator
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# Only a HIGH-or-above advisory on the capped dep makes the cap security-
|
|
22
|
+
# relevant. Most advisories are low-threat noise (research: ~95% of vulnerable
|
|
23
|
+
# deps are unreachable/low-impact), so a low/medium CVE on the pinned dep isn't
|
|
24
|
+
# the "you're stuck below the fix" story. Unscored advisories fail CLOSED (a
|
|
25
|
+
# confirmed advisory we can't score could be severe), matching --fail-if-
|
|
26
|
+
# vulnerable. Reachability/exploitability is beyond static, metadata-only sight
|
|
27
|
+
# and deliberately out of scope -- this gates on severity, not exploitability.
|
|
28
|
+
SECURITY_THRESHOLD = "high"
|
|
29
|
+
|
|
30
|
+
# The severity labels that let an advisory establish "below the fix". Only a
|
|
31
|
+
# confirmed HIGH+ advisory with a known fix can: an unscored advisory (which the
|
|
32
|
+
# capped_dep_vulnerable gate accepts fail-closed) has no reliable fix analysis.
|
|
33
|
+
BELOW_FIX_LABELS = ["high", "critical"].freeze
|
|
34
|
+
|
|
35
|
+
def correlate(result_object)
|
|
36
|
+
advisories = flat_advisories(result_object)
|
|
37
|
+
copies = copy_index(result_object)
|
|
38
|
+
|
|
39
|
+
result_object.each_value do |data|
|
|
40
|
+
# FLAT (rubygems/pypi): mark below-fix on the poison caps already attached.
|
|
41
|
+
mark_flat_constraints(data, advisories)
|
|
42
|
+
# NESTED (npm/cargo): promote a genuine below-fix candidate into :constraints.
|
|
43
|
+
promote_nested_below_fix(data, copies) if data[:capped_deps]
|
|
44
|
+
|
|
45
|
+
constraints = hashes(data[:constraints])
|
|
46
|
+
next if constraints.empty?
|
|
47
|
+
|
|
48
|
+
data[:poison_security_relevant] = true if constraints.any? { |c| c[:capped_dep_vulnerable] }
|
|
49
|
+
data[:poison_below_fix] = true if constraints.any? { |c| c[:capped_below_fix] }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# This runs OUTSIDE the per-gem rescue (a whole-tree pass after the barrier),
|
|
56
|
+
# so a malformed shape must degrade that one entry's enrichment, never raise and
|
|
57
|
+
# crash the audit after every fetch is paid for. The pipeline always assigns
|
|
58
|
+
# arrays of hashes here, so these guards are unreachable today, but they match the
|
|
59
|
+
# defensive Array()-wrapping the sibling consumers (markdown/sarif/terminal) apply.
|
|
60
|
+
def hashes(value)
|
|
61
|
+
Array(value).grep(Hash)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Ecosystem-qualified map of each tree package's advisories AND the version it's
|
|
65
|
+
# pinned at, for the FLAT path (one resolved version per name). A capped dep
|
|
66
|
+
# resolves in its capper's ecosystem, so match "ecosystem/name" on both sides;
|
|
67
|
+
# native results carry no :ecosystem (nil on both) and still match. The used
|
|
68
|
+
# version is kept so "below the fix" ignores fixes that land BELOW it: an OSV
|
|
69
|
+
# advisory lists a fix per affected range, and a fix for an older line is a
|
|
70
|
+
# downgrade, not a patch for the version in hand.
|
|
71
|
+
def flat_advisories(result_object)
|
|
72
|
+
result_object.each_with_object({}) do |(key, data), map|
|
|
73
|
+
next unless data[:vulnerability_count].to_i.positive?
|
|
74
|
+
|
|
75
|
+
name = data[:name] || key
|
|
76
|
+
map["#{data[:ecosystem]}/#{name}"] = { vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every RESOLVED copy of each package, keyed "ecosystem/name", for the NESTED
|
|
81
|
+
# path: npm nests versions and cargo coexists majors, so one name maps to several
|
|
82
|
+
# copies. The full set (vulnerable AND not) is what the condition-5 soundness
|
|
83
|
+
# guard needs -- "is there a SAFE copy within the cap" can't be answered from the
|
|
84
|
+
# vulnerable copies alone.
|
|
85
|
+
def copy_index(result_object)
|
|
86
|
+
result_object.each_with_object({}) do |(key, data), map|
|
|
87
|
+
name = data[:name] || key
|
|
88
|
+
(map["#{data[:ecosystem]}/#{name}"] ||= []) << { version: data[:version_used], vulns: hashes(data[:vulnerabilities]) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def mark_flat_constraints(data, advisories)
|
|
93
|
+
constraints = hashes(data[:constraints])
|
|
94
|
+
return if constraints.empty?
|
|
95
|
+
|
|
96
|
+
eco = data[:ecosystem]
|
|
97
|
+
constraints.each do |constraint|
|
|
98
|
+
entry = advisories["#{eco}/#{constraint[:dependency]}"]
|
|
99
|
+
next if entry.nil?
|
|
100
|
+
|
|
101
|
+
vulns = entry[:vulns]
|
|
102
|
+
next unless VulnerabilityHelper.severity_at_or_above?(vulns, SECURITY_THRESHOLD)
|
|
103
|
+
|
|
104
|
+
constraint[:capped_dep_vulnerable] = true
|
|
105
|
+
mark_below_fix(constraint, vulns, entry[:used_version])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# NESTED below-the-fix (npm/cargo). The pure poison cap is suppressed for these
|
|
110
|
+
# ecosystems (caret default + nested copies over-claim), so a candidate is
|
|
111
|
+
# promoted ONLY when it genuinely holds a vulnerable copy below its fix, checked
|
|
112
|
+
# at PATCH precision. When one is, it takes the same shape the flat path renders,
|
|
113
|
+
# and :poison is set here (the only npm/cargo poison there is is a below-fix).
|
|
114
|
+
def promote_nested_below_fix(data, copies)
|
|
115
|
+
eco = data[:ecosystem]
|
|
116
|
+
# Consume the candidates: they are an internal work-list, never serialized.
|
|
117
|
+
promoted = hashes(data.delete(:capped_deps)).filter_map do |candidate|
|
|
118
|
+
dep_copies = copies["#{eco}/#{candidate[:dependency]}"] || []
|
|
119
|
+
nested_below_fix(eco, candidate[:dependency], candidate[:requirement], dep_copies)
|
|
120
|
+
end
|
|
121
|
+
return if promoted.empty?
|
|
122
|
+
|
|
123
|
+
data[:constraints] = promoted
|
|
124
|
+
data[:poison] = true
|
|
125
|
+
data[:poison_severity] = :critical
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A below-fix finding for one capped dep, or nil. The claim holds only when
|
|
129
|
+
# EVERY resolved copy the constraint governs is vulnerable to the same HIGH+
|
|
130
|
+
# advisory (condition 5: no safe in-constraint copy to resolve to), AND no fix
|
|
131
|
+
# of that advisory satisfies the constraint (the wall, at patch precision). A
|
|
132
|
+
# patched copy sitting OUTSIDE the constraint elsewhere in the tree is irrelevant
|
|
133
|
+
# -- it can't lift the copy this capper pins -- so it never enters this view.
|
|
134
|
+
def nested_below_fix(ecosystem, dependency, requirement, dep_copies)
|
|
135
|
+
in_constraint = dep_copies.select { |copy| satisfies?(requirement, copy[:version], ecosystem) }
|
|
136
|
+
return if in_constraint.empty?
|
|
137
|
+
|
|
138
|
+
oldest = in_constraint.map { |copy| copy[:version] }.min_by { |version| gem_version(version) }
|
|
139
|
+
stuck = candidate_advisories(in_constraint).select do |advisory|
|
|
140
|
+
walls?(requirement, advisory, ecosystem, oldest) && every_copy_affected?(in_constraint, advisory)
|
|
141
|
+
end
|
|
142
|
+
return if stuck.empty?
|
|
143
|
+
|
|
144
|
+
receipt = stuck.min_by { |advisory| gem_version(nearest_fix(advisory, oldest)) }
|
|
145
|
+
{
|
|
146
|
+
dependency: dependency,
|
|
147
|
+
requirement: requirement,
|
|
148
|
+
capped_dep_vulnerable: true,
|
|
149
|
+
capped_below_fix: true,
|
|
150
|
+
below_fix_advisory: receipt[:id],
|
|
151
|
+
below_fix_fixed_in: nearest_fix(receipt, oldest),
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def satisfies?(requirement, version, ecosystem)
|
|
156
|
+
SemverSatisfaction.evaluate(requirement: requirement, version: version, ecosystem: ecosystem) == true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# HIGH+ advisories with a known fix, deduped by id, across the in-constraint copies.
|
|
160
|
+
def candidate_advisories(copies)
|
|
161
|
+
copies.flat_map { |copy| copy[:vulns] }
|
|
162
|
+
.select { |vuln| BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln)) && Array(vuln[:fixed_versions]).any? }
|
|
163
|
+
.uniq { |vuln| vuln[:id] }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The wall: NO applicable fix satisfies the constraint at patch precision. Every
|
|
167
|
+
# fix must be a DEFINITE non-match (evaluate == false); an undecidable fix (nil,
|
|
168
|
+
# unparseable) blocks the claim rather than counting as unreachable, so we never
|
|
169
|
+
# fabricate a wall from input we couldn't parse.
|
|
170
|
+
def walls?(requirement, advisory, ecosystem, oldest_affected)
|
|
171
|
+
fixes = applicable_fixes(advisory, oldest_affected)
|
|
172
|
+
fixes.any? && fixes.all? { |fix| SemverSatisfaction.evaluate(requirement: requirement, version: fix, ecosystem: ecosystem) == false }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Condition 5: every governed copy is vulnerable to THIS advisory (none is a safe
|
|
176
|
+
# version you could resolve to within the cap).
|
|
177
|
+
def every_copy_affected?(copies, advisory)
|
|
178
|
+
copies.all? { |copy| copy[:vulns].any? { |vuln| vuln[:id] == advisory[:id] } }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The sharper claim on a security-relevant cap: does it hold you BELOW THE FIX?
|
|
182
|
+
# A HIGH+ advisory with a known fix establishes it only when EVERY fixed version
|
|
183
|
+
# lands outside the cap (a major it forbids) -- you cannot patch the CVE without
|
|
184
|
+
# replacing the dormant capper. The receipt names the advisory and its nearest
|
|
185
|
+
# fix (the lowest fixed version, all of which are outside the cap).
|
|
186
|
+
def mark_below_fix(constraint, vulns, used_version)
|
|
187
|
+
stuck = vulns.select do |vuln|
|
|
188
|
+
fixes = applicable_fixes(vuln, used_version)
|
|
189
|
+
next false if fixes.empty?
|
|
190
|
+
next false unless BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln))
|
|
191
|
+
|
|
192
|
+
fixes.none? { |fix| ConstraintHelper.reachable_within_cap?(constraint, fix) }
|
|
193
|
+
end
|
|
194
|
+
return if stuck.empty?
|
|
195
|
+
|
|
196
|
+
receipt = stuck.min_by { |vuln| gem_version(nearest_fix(vuln, used_version)) }
|
|
197
|
+
constraint[:capped_below_fix] = true
|
|
198
|
+
constraint[:below_fix_advisory] = receipt[:id]
|
|
199
|
+
constraint[:below_fix_fixed_in] = nearest_fix(receipt, used_version)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# An advisory lists a fixed version per affected range; only fixes ABOVE the
|
|
203
|
+
# version in hand are real remediation. A fix at or below it belongs to an older
|
|
204
|
+
# release line (a downgrade), so it neither patches this version nor counts as
|
|
205
|
+
# "reachable within the cap". With no known used version (older data), all fixes
|
|
206
|
+
# are kept -- the pre-existing behaviour, never fewer findings than before.
|
|
207
|
+
def applicable_fixes(vuln, used_version)
|
|
208
|
+
fixes = Array(vuln[:fixed_versions])
|
|
209
|
+
return fixes if used_version.nil? || !Gem::Version.correct?(used_version.to_s)
|
|
210
|
+
|
|
211
|
+
used = gem_version(used_version)
|
|
212
|
+
fixes.select { |fix| !Gem::Version.correct?(fix.to_s) || gem_version(fix) > used }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The lowest applicable fixed version, for the "fixed in X" receipt. A parseable
|
|
216
|
+
# version always beats an unparseable one (PyPI epoch `1!2.3.4`, or garbage), so
|
|
217
|
+
# the receipt never names an odd string when a clean fix is present; only when
|
|
218
|
+
# EVERY fix is unparseable does it fall back to one of those.
|
|
219
|
+
def nearest_fix(vuln, used_version)
|
|
220
|
+
applicable_fixes(vuln, used_version).min_by { |fix| [Gem::Version.correct?(fix.to_s) ? 0 : 1, gem_version(fix)] }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A version key that sorts fix strings without raising; an unparseable version
|
|
224
|
+
# collapses to 0 (only reached as a tiebreak among all-unparseable fixes, since
|
|
225
|
+
# nearest_fix prefers parseable ones).
|
|
226
|
+
def gem_version(version)
|
|
227
|
+
Gem::Version.new(version.to_s)
|
|
228
|
+
rescue ArgumentError
|
|
229
|
+
Gem::Version.new("0")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/http_helper"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# The authoritative source for a PyPI release's declared Python constraint,
|
|
8
|
+
# used by the cross-ecosystem language-runtime ceiling. `requires_python` is
|
|
9
|
+
# set once per release and enforced by pip as a hard install wall (a real
|
|
10
|
+
# resolver refuses an incompatible interpreter), so still_active reads it from
|
|
11
|
+
# PyPI's release-level `info.requires_python` rather than a per-file wheel value
|
|
12
|
+
# or deps.dev (which does not carry the field). pypi.org is a public source,
|
|
13
|
+
# queried with no credentials.
|
|
14
|
+
#
|
|
15
|
+
# Best-effort like every other network entry point: an unindexed version,
|
|
16
|
+
# schema drift, or a transport failure degrades to nil (no ceiling), never a
|
|
17
|
+
# raise that could abort the per-package audit.
|
|
18
|
+
module PypiClient
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
BASE_URI = URI("https://pypi.org/")
|
|
22
|
+
# Polite identification, matching EcosystemsClient's convention.
|
|
23
|
+
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
|
|
24
|
+
|
|
25
|
+
# The PEP 440 `requires_python` specifier a release declares (e.g.
|
|
26
|
+
# ">=3.8,<3.13"), or nil when the version is unreadable or declares none.
|
|
27
|
+
# PyPI renders an unset constraint as an empty string; normalize it to nil so
|
|
28
|
+
# callers get a single "no constraint" signal.
|
|
29
|
+
def requires_python(name:, version:)
|
|
30
|
+
return if name.nil? || version.nil?
|
|
31
|
+
|
|
32
|
+
path = "/pypi/#{encode(name)}/#{encode(version)}/json"
|
|
33
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT })
|
|
34
|
+
return unless body.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
# Guard the nested read: a non-Hash `info` (schema drift) would make
|
|
37
|
+
# Hash#dig raise TypeError, which -- since this is the LAST enrichment in
|
|
38
|
+
# EcosystemLens#assess -- would propagate to the per-package rescue and
|
|
39
|
+
# discard the package's already-computed vuln/poison/staleness findings.
|
|
40
|
+
# Best-effort must never demote the core audit; degrade to nil.
|
|
41
|
+
info = body["info"]
|
|
42
|
+
return unless info.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
value = info["requires_python"]
|
|
45
|
+
return unless value.is_a?(String) && !value.strip.empty?
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def encode(segment)
|
|
53
|
+
URI.encode_www_form_component(segment)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module StillActive
|
|
4
4
|
module Sarif
|
|
5
|
-
# Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-
|
|
5
|
+
# Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-SA009)
|
|
6
6
|
# are stable across versions; renames or removals are breaking changes.
|
|
7
7
|
#
|
|
8
8
|
# Each rule maps a still_active finding into a SARIF result. Security
|
|
@@ -17,14 +17,18 @@ module StillActive
|
|
|
17
17
|
"#{DOCS_BASE}##{rule_id.downcase}"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
20
|
+
def severity_to_level(label)
|
|
21
|
+
# Maps a resolved advisory severity label (VulnerabilityHelper.advisory_severity:
|
|
22
|
+
# a real CVSS score OR the OSV/GHSA label) to a SARIF level. A nil label is a
|
|
23
|
+
# confirmed-but-unscored advisory (no CVSS score and no OSV label, e.g. a fresh
|
|
24
|
+
# CVE) -- elevated to warning, not an informational note, so a gate can't read
|
|
25
|
+
# it as clean. This is the SARIF analogue of the CLI gate's fail-closed.
|
|
26
|
+
case label
|
|
27
|
+
when "critical", "high" then "error"
|
|
28
|
+
when "medium" then "warning"
|
|
29
|
+
when "low" then "note"
|
|
30
|
+
else "warning"
|
|
31
|
+
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# SARIF security-severity must be a string with one decimal place.
|
|
@@ -105,6 +109,26 @@ module StillActive
|
|
|
105
109
|
security_severity: "8.0",
|
|
106
110
|
tags: ["security", "supply-chain", "external/cwe/cwe-1104"],
|
|
107
111
|
},
|
|
112
|
+
{
|
|
113
|
+
id: "SA008",
|
|
114
|
+
name: "PoisonPill",
|
|
115
|
+
short: "Dormant gem caps a dependency below its latest major",
|
|
116
|
+
full: "A dormant (abandoned or archived) gem declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the gem, the cap will never lift, and it grows more constraining as the capped dependency releases new majors: the tree is held below a ceiling no upstream release will raise.",
|
|
117
|
+
help_text: "Replace or fork the dormant gem, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in.",
|
|
118
|
+
level: "warning",
|
|
119
|
+
security_severity: nil, # maintenance/resolvability, not a CVE (as SA002/SA004/SA005)
|
|
120
|
+
tags: ["maintenance", "supply-chain", "external/cwe/cwe-1104"],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "SA009",
|
|
124
|
+
name: "RuntimeCeiling",
|
|
125
|
+
short: "Resolved package version caps its language runtime",
|
|
126
|
+
full: "A resolved package version's declared runtime constraint (Ruby `ruby_version`, Python `requires_python`) caps the language runtime you can run: either below every still-supported release (stranding you on an end-of-life runtime with no security patches) or below the latest stable (a compatibility ceiling to plan around). The default level is note; an EOL-forcing cap is raised to error per result.",
|
|
127
|
+
help_text: "If a newer release of the package lifts the cap, upgrade it. Otherwise replace or fork the package, or contribute support for the newer runtime upstream.",
|
|
128
|
+
level: "note",
|
|
129
|
+
security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
|
|
130
|
+
tags: ["maintenance", "runtime", "external/cwe/cwe-1104"],
|
|
131
|
+
},
|
|
108
132
|
].freeze
|
|
109
133
|
|
|
110
134
|
CATALOG = RAW_RULES.map do |r|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "package_url"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Reads a CycloneDX SBOM (e.g. produced by Syft) into a clean, normalized
|
|
9
|
+
# dependency set -- the breadth input for non-Ruby ecosystems, where the
|
|
10
|
+
# maintenance lens runs over deps.dev/ecosyste.ms.
|
|
11
|
+
#
|
|
12
|
+
# `parse` returns both the assessable dependencies AND the components it could
|
|
13
|
+
# NOT assess (a real dependency in an unsupported ecosystem, or a library with
|
|
14
|
+
# no PURL -- typically a git/path/local source). Surfacing those is the point:
|
|
15
|
+
# silently dropping them would let an audit report "all clear" while ignoring
|
|
16
|
+
# the deps that are often the riskiest. Genuine non-package noise (GitHub
|
|
17
|
+
# Actions `pkg:github`, opaque binaries `pkg:generic`, and file/application
|
|
18
|
+
# components) is excluded, not surfaced.
|
|
19
|
+
#
|
|
20
|
+
# `read` keeps the simple "just the dependencies" shape. Never raises -- a
|
|
21
|
+
# missing/malformed SBOM or a bad PURL degrades to empty/skip.
|
|
22
|
+
module SbomReader
|
|
23
|
+
extend self
|
|
24
|
+
|
|
25
|
+
Result = Data.define(:dependencies, :unassessable)
|
|
26
|
+
|
|
27
|
+
# PURL type -> still_active ecosystem (also the deps.dev `system`, lowercased).
|
|
28
|
+
ECOSYSTEMS = {
|
|
29
|
+
"gem" => :rubygems,
|
|
30
|
+
"npm" => :npm,
|
|
31
|
+
"pypi" => :pypi,
|
|
32
|
+
"cargo" => :cargo,
|
|
33
|
+
"maven" => :maven,
|
|
34
|
+
"golang" => :go,
|
|
35
|
+
"nuget" => :nuget,
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# PURL types that are not package dependencies: CI actions and opaque
|
|
39
|
+
# binaries. Excluded from both lists (not "unassessed deps").
|
|
40
|
+
NOISE_TYPES = ["github", "generic"].freeze
|
|
41
|
+
|
|
42
|
+
def read(path) = parse(path).dependencies
|
|
43
|
+
def read_string(body) = parse_string(body).dependencies
|
|
44
|
+
|
|
45
|
+
def parse(path)
|
|
46
|
+
parse_string(File.read(path))
|
|
47
|
+
rescue SystemCallError, IOError
|
|
48
|
+
Result.new(dependencies: [], unassessable: [])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_string(body)
|
|
52
|
+
doc = JSON.parse(body)
|
|
53
|
+
components = doc.is_a?(Hash) ? doc["components"] : nil
|
|
54
|
+
return Result.new(dependencies: [], unassessable: []) unless components.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
# Only trust the prod/dev split when the generator actually marks it
|
|
57
|
+
# somewhere (scope, or a dev property). syft-style SBOMs mark nothing, so an
|
|
58
|
+
# unmarked component is genuinely unknown -- never assume it's production.
|
|
59
|
+
# Scoped to library components: scope/properties are legal on any component
|
|
60
|
+
# type (application/file/...), and a dev signal on one of those is unrelated
|
|
61
|
+
# to whether the dependency library components are marked.
|
|
62
|
+
marks_dev = components.any? { |c| c.is_a?(Hash) && c["type"] == "library" && dev_signal?(c) }
|
|
63
|
+
|
|
64
|
+
deps = []
|
|
65
|
+
unassessable = []
|
|
66
|
+
components.each do |component|
|
|
67
|
+
kind, entry = classify(component)
|
|
68
|
+
entry[:production] = !dev_signal?(component) if kind == :dependency && marks_dev
|
|
69
|
+
deps << entry if kind == :dependency
|
|
70
|
+
unassessable << entry if kind == :unassessable
|
|
71
|
+
end
|
|
72
|
+
# uniq collapses a package that a generator lists more than once (e.g. Syft's
|
|
73
|
+
# per-location entries); `production` is derived from each component's own
|
|
74
|
+
# signal, so duplicates of the same name+version dedup cleanly unless a
|
|
75
|
+
# generator marks the copies inconsistently (malformed; not observed).
|
|
76
|
+
Result.new(dependencies: deps.uniq, unassessable: unassessable.uniq)
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
Result.new(dependencies: [], unassessable: [])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Whether a component is marked dev/test-only. CycloneDX `scope` (excluded =
|
|
84
|
+
# not part of the product, optional = not needed at runtime) is the standard;
|
|
85
|
+
# tools that don't set scope may instead emit a property (cyclonedx-npm's
|
|
86
|
+
# `cdx:npm:package:development=true`). Absent both, this is false -- the caller
|
|
87
|
+
# only trusts a false when the document marks dev somewhere.
|
|
88
|
+
def dev_signal?(component)
|
|
89
|
+
scope = component["scope"]
|
|
90
|
+
return true if scope == "excluded" || scope == "optional"
|
|
91
|
+
|
|
92
|
+
Array(component["properties"]).any? do |p|
|
|
93
|
+
p.is_a?(Hash) && p["value"] == "true" && p["name"].to_s.match?(/(\A|:)dev(elopment)?\z/)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# [:dependency, {...}] | [:unassessable, {...}] | nil (non-package noise).
|
|
98
|
+
def classify(component)
|
|
99
|
+
return unless component.is_a?(Hash) && component["type"] == "library"
|
|
100
|
+
|
|
101
|
+
name = component["name"]
|
|
102
|
+
purl = component["purl"]
|
|
103
|
+
return [:unassessable, { ecosystem: nil, name: name, reason: :no_purl }] unless purl.is_a?(String) && !purl.empty?
|
|
104
|
+
|
|
105
|
+
classify_purl(purl, name)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Public registry hosts per ecosystem. A purl `repository_url` qualifier means
|
|
109
|
+
# a non-default (private/alternative) registry per the purl spec, EXCEPT when a
|
|
110
|
+
# generator redundantly names the public one -- so we still assess those.
|
|
111
|
+
PUBLIC_REGISTRY_HOSTS = [
|
|
112
|
+
"rubygems.org",
|
|
113
|
+
"registry.npmjs.org",
|
|
114
|
+
"npmjs.org",
|
|
115
|
+
"registry.yarnpkg.com",
|
|
116
|
+
"pypi.org",
|
|
117
|
+
"files.pythonhosted.org",
|
|
118
|
+
"crates.io",
|
|
119
|
+
"static.crates.io",
|
|
120
|
+
"index.crates.io",
|
|
121
|
+
"repo1.maven.org",
|
|
122
|
+
"repo.maven.apache.org",
|
|
123
|
+
"api.nuget.org",
|
|
124
|
+
"proxy.golang.org",
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
def classify_purl(purl, name)
|
|
128
|
+
parsed = PackageURL.parse(purl)
|
|
129
|
+
type = parsed.type&.downcase
|
|
130
|
+
ecosystem = ECOSYSTEMS[type]
|
|
131
|
+
|
|
132
|
+
if ecosystem
|
|
133
|
+
return [:unassessable, { ecosystem: type, name: name, reason: :no_version }] if parsed.version.to_s.empty?
|
|
134
|
+
|
|
135
|
+
full_name = build_name(ecosystem, parsed.namespace, parsed.name)
|
|
136
|
+
repository_url = parsed.qualifiers&.dig("repository_url")
|
|
137
|
+
# A private/alternative registry: never look the name up on the public
|
|
138
|
+
# registry (a same-named public package's data is not this one's -- the #43
|
|
139
|
+
# dependency-confusion guard, cross-ecosystem). We never dial the URL; its
|
|
140
|
+
# presence alone is the signal. Limit: this can only fire when the SBOM
|
|
141
|
+
# carries repository_url; a generator that omits it (Syft often can't
|
|
142
|
+
# determine the source registry) leaves a private package indistinguishable
|
|
143
|
+
# from a public one, so it is still assessed by name. Best-effort, not a
|
|
144
|
+
# guarantee that every private package is caught.
|
|
145
|
+
if repository_url && !public_registry?(repository_url)
|
|
146
|
+
return [:unassessable, { ecosystem: ecosystem, name: full_name, version: parsed.version, reason: :private_registry, repository_url: repository_url }]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
[:dependency, { ecosystem: ecosystem, name: full_name, version: parsed.version }]
|
|
150
|
+
elsif NOISE_TYPES.include?(type)
|
|
151
|
+
nil # CI actions / opaque binaries: not a package dependency
|
|
152
|
+
else
|
|
153
|
+
[:unassessable, { ecosystem: type, name: name, reason: :unsupported_ecosystem }]
|
|
154
|
+
end
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
# InvalidPackageURL is a subclass of ArgumentError, but PackageURL.parse also
|
|
157
|
+
# raises a BARE ArgumentError on an empty name-after-namespace (`pkg:npm/@1.0.0`)
|
|
158
|
+
# or bad percent-encoding, both common in real Syft/Trivy output. Catch both so
|
|
159
|
+
# one malformed component degrades to unassessable, never a backtrace that drops
|
|
160
|
+
# every other dependency's verdict (SbomReader's documented "never raises").
|
|
161
|
+
[:unassessable, { ecosystem: nil, name: name, reason: :malformed_purl }]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whether a repository_url points at a known public registry. We only read the
|
|
165
|
+
# host; we never connect to it (a lockfile/SBOM-derived URL is untrusted). An
|
|
166
|
+
# unparseable or non-public host reads as private, failing safe.
|
|
167
|
+
def public_registry?(repository_url)
|
|
168
|
+
host = registry_host(repository_url)
|
|
169
|
+
!host.nil? && PUBLIC_REGISTRY_HOSTS.include?(host)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def registry_host(url)
|
|
173
|
+
URI.parse(url.include?("//") ? url : "//#{url}").host&.downcase
|
|
174
|
+
rescue URI::InvalidURIError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Reconstruct the lookup name from the PURL's namespace + name: npm
|
|
179
|
+
# `@scope/name`, maven `group:artifact`, Go module path `github.com/owner/name`;
|
|
180
|
+
# pypi/cargo/gem/nuget have no namespace and use the bare name.
|
|
181
|
+
def build_name(ecosystem, namespace, name)
|
|
182
|
+
return name if namespace.to_s.empty?
|
|
183
|
+
|
|
184
|
+
case ecosystem
|
|
185
|
+
when :npm, :go then "#{namespace}/#{name}"
|
|
186
|
+
when :maven then "#{namespace}:#{name}"
|
|
187
|
+
else name
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ecosystem_lens"
|
|
4
|
+
require_relative "ceiling_reconciler"
|
|
5
|
+
require_relative "poison_security_correlator"
|
|
6
|
+
require_relative "../helpers/python_helper"
|
|
7
|
+
require "async"
|
|
8
|
+
require "async/barrier"
|
|
9
|
+
require "async/semaphore"
|
|
10
|
+
|
|
11
|
+
module StillActive
|
|
12
|
+
# The SBOM audit's fan-out, parallel to Workflow but for a cross-ecosystem
|
|
13
|
+
# SBOM rather than a Bundler lockfile: it runs EcosystemLens over each
|
|
14
|
+
# dependency concurrently and returns a result hash the same output path can
|
|
15
|
+
# render. Keyed by "ecosystem/name@version" (not bare name) so nothing collides
|
|
16
|
+
# and overwrites: not an npm `foo` against a pypi `foo`, and -- the case a merged
|
|
17
|
+
# monorepo SBOM actually produces -- not two versions of the same package pinned
|
|
18
|
+
# by different subprojects (a lockfile resolves one version per name, an SBOM
|
|
19
|
+
# doesn't). Each is a distinct assessable unit with its own version-specific
|
|
20
|
+
# advisory set, so collapsing them would silently drop one dep's verdict.
|
|
21
|
+
module SbomWorkflow
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
# `assessed` is the "ecosystem/name@version" => gem_data hash; `failures` is
|
|
25
|
+
# the deps whose lens call raised (a rate-limited/flaky deps.dev, or a bug
|
|
26
|
+
# hitting every dep). The two are returned separately so run_sbom can surface
|
|
27
|
+
# the failures instead of dropping them: a raised dep that just vanished would
|
|
28
|
+
# be absent from the JSON, uncounted, and past the exit gate, letting a
|
|
29
|
+
# rate-limited run read "all clear" while silently skipping deps.
|
|
30
|
+
Outcome = Data.define(:assessed, :failures)
|
|
31
|
+
|
|
32
|
+
def call(sbom_result, &on_progress)
|
|
33
|
+
dependencies = sbom_result.dependencies
|
|
34
|
+
Async do
|
|
35
|
+
# The Python runtime support window, fetched once for the whole SBOM (the
|
|
36
|
+
# language-ceiling input for pypi deps). Guarded like the native Ruby path:
|
|
37
|
+
# a feed failure degrades to "no ceiling checks", never aborts the audit.
|
|
38
|
+
python_range =
|
|
39
|
+
begin
|
|
40
|
+
PythonHelper.supported_python_range
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
$stderr.puts("warning: Python support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
barrier = Async::Barrier.new
|
|
46
|
+
semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
|
|
47
|
+
result = {}
|
|
48
|
+
failures = []
|
|
49
|
+
# Memoizes each capped dep's RESOLVED latest version across the whole SBOM
|
|
50
|
+
# (keyed by ecosystem+name inside the lens), so a dep pinned by several
|
|
51
|
+
# dormant packages is fetched once. Unresolved lookups aren't cached (a
|
|
52
|
+
# transient nil must not suppress a later package's pill), so concurrent
|
|
53
|
+
# first-misses on the same dep may briefly double-fetch; harmless.
|
|
54
|
+
constraint_cache = {}
|
|
55
|
+
total = dependencies.size
|
|
56
|
+
completed = 0
|
|
57
|
+
dependencies.each do |dep|
|
|
58
|
+
semaphore.async do
|
|
59
|
+
# Carry the SBOM's prod-vs-dev/test verdict onto the assessment so the
|
|
60
|
+
# output can separate prod risk from test debt. `slice` copies it only
|
|
61
|
+
# when the SBOM actually marked it (SbomReader leaves the key absent
|
|
62
|
+
# otherwise): absent stays "unknown", never a false that reads as test-only.
|
|
63
|
+
result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
|
|
64
|
+
EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, python_range: python_range)
|
|
65
|
+
.merge(dep.slice(:production))
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
# One dependency's failure must not abort the audit, but it must not
|
|
68
|
+
# disappear either: record it as an unassessable entry (same shape as
|
|
69
|
+
# the reader's, with a distinct reason) so it's counted and reported.
|
|
70
|
+
# production rides along (when known, via slice) so a failed prod dep
|
|
71
|
+
# stays distinguishable from a failed dev one.
|
|
72
|
+
failures << { ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}" }
|
|
73
|
+
.merge(dep.slice(:production))
|
|
74
|
+
$stderr.puts("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
|
|
75
|
+
ensure
|
|
76
|
+
completed += 1
|
|
77
|
+
on_progress&.call(completed, total)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
barrier.wait
|
|
81
|
+
# Whole-tree correlation once every package's signals are in: a Python
|
|
82
|
+
# ceiling's "upgrade to lift it" must not contradict a poison finding that
|
|
83
|
+
# caps the same package below that upgrade (same guarantee as the native path).
|
|
84
|
+
CeilingReconciler.reconcile_ceiling_with_poison(result)
|
|
85
|
+
# Flag poison caps that pin a vulnerable dependency (the moat's strongest
|
|
86
|
+
# case: an archived package holding you on a known-vulnerable dep).
|
|
87
|
+
PoisonSecurityCorrelator.correlate(result)
|
|
88
|
+
# Stable, diffable order regardless of async completion order.
|
|
89
|
+
Outcome.new(
|
|
90
|
+
assessed: result.sort_by { |key, _| key }.to_h,
|
|
91
|
+
failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" },
|
|
92
|
+
)
|
|
93
|
+
end.wait
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|