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,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deps_dev_client"
|
|
4
|
+
require_relative "osv_client"
|
|
5
|
+
require_relative "ecosystems_client"
|
|
6
|
+
require_relative "github_client"
|
|
7
|
+
require_relative "pypi_client"
|
|
8
|
+
require "time"
|
|
9
|
+
require_relative "../helpers/activity_helper"
|
|
10
|
+
require_relative "../helpers/constraint_helper"
|
|
11
|
+
require_relative "../helpers/libyear_helper"
|
|
12
|
+
require_relative "../helpers/pep440_helper"
|
|
13
|
+
require_relative "../helpers/runtime_ceiling_helper"
|
|
14
|
+
require_relative "../helpers/vulnerability_helper"
|
|
15
|
+
|
|
16
|
+
module StillActive
|
|
17
|
+
# The cross-ecosystem maintenance lens. Given one SBOM-derived dependency
|
|
18
|
+
# ({ecosystem, name, version}), it assembles the same maintenance signals the
|
|
19
|
+
# native Ruby path produces -- latest release date, archived, advisories,
|
|
20
|
+
# scorecard -- into a gem_data hash StatusHelper.gem_status can read directly.
|
|
21
|
+
# The breadth counterpart to the depth Bundler path gives Ruby.
|
|
22
|
+
#
|
|
23
|
+
# Security: the SBOM supplies only ecosystem/name/version. The repository is
|
|
24
|
+
# discovered from deps.dev (a public source still_active opts into), never from
|
|
25
|
+
# a lockfile-derived URL, so a hostile SBOM can't redirect a lookup. Anything
|
|
26
|
+
# unresolvable (a private package deps.dev doesn't index) degrades to nil
|
|
27
|
+
# signals -> :unknown, never a fabricated "ok".
|
|
28
|
+
module EcosystemLens
|
|
29
|
+
extend self
|
|
30
|
+
|
|
31
|
+
# still_active ecosystem symbol -> ecosyste.ms registry name for the declared
|
|
32
|
+
# dependency (poison-pill) lookup. Only the ecosystems where the ecosyste.ms
|
|
33
|
+
# registry name and the deps.dev package name align cleanly are wired; maven
|
|
34
|
+
# (group:artifact) and go (module paths) need name-format handling and are
|
|
35
|
+
# left out for now, so they simply carry no constraint signal rather than a
|
|
36
|
+
# wrong one.
|
|
37
|
+
ECOSYSTEM_REGISTRIES = {
|
|
38
|
+
rubygems: "rubygems.org",
|
|
39
|
+
npm: "npmjs.org",
|
|
40
|
+
pypi: "pypi.org",
|
|
41
|
+
cargo: "crates.io",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# A transitive cap only holds the WHOLE tree hostage where the ecosystem
|
|
45
|
+
# forces one version per package. Ruby/Bundler and pip do (flat resolution);
|
|
46
|
+
# npm nests multiple versions and cargo coexists majors, so a below-latest cap
|
|
47
|
+
# there pins a duplicate copy in one subtree -- not a tree-wide block. The one
|
|
48
|
+
# npm case that DOES block (peerDependencies / de-facto singletons) isn't
|
|
49
|
+
# visible in ecosyste.ms data (its parser drops peerDependencies), so emitting
|
|
50
|
+
# poison for npm/cargo would over-claim on the non-blocking cases and miss the
|
|
51
|
+
# blocking ones. Suppress it there until peer requirements are sourced from
|
|
52
|
+
# deps.dev; silence beats a false "holds your tree hostage".
|
|
53
|
+
FLAT_RESOLUTION_ECOSYSTEMS = [:rubygems, :pypi].freeze
|
|
54
|
+
|
|
55
|
+
def assess(ecosystem:, name:, version:, constraint_cache: {}, python_range: nil)
|
|
56
|
+
info = DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
|
|
57
|
+
default = DepsDevClient.default_version_info(name: name, system: ecosystem)
|
|
58
|
+
# Retry the version lookup once when it came back empty but the package DID
|
|
59
|
+
# resolve: HttpHelper collapses a genuine 404 and a transient network blip to
|
|
60
|
+
# the same nil, and a healthy version must not be mis-flagged as unresolved
|
|
61
|
+
# (below) on a one-off miss. A real 404 stays nil; a blip recovers the data.
|
|
62
|
+
info ||= DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem) if default
|
|
63
|
+
# Recover the repo from the default version when the exact locked version
|
|
64
|
+
# isn't indexed (yanked/normalization mismatch): otherwise its project link
|
|
65
|
+
# vanishes and a still-fresh package date would read a false :ok with
|
|
66
|
+
# archived/scorecard silently dropped. The native Bundler path resolves the
|
|
67
|
+
# repo independently of deps.dev's per-version record; this gives the lens
|
|
68
|
+
# the same resilience.
|
|
69
|
+
# The pinned version isn't indexed by deps.dev (info nil) while the package IS
|
|
70
|
+
# (default present, so the feed is up): the version is yanked/nonexistent, not a
|
|
71
|
+
# transient miss. Flag it so status reads :unknown rather than letting the still-
|
|
72
|
+
# fresh PACKAGE date report a nonexistent version as :ok.
|
|
73
|
+
version_unresolved = info.nil? && !default.nil?
|
|
74
|
+
project_id = info&.dig(:project_id) || project_id_from(name, ecosystem, default)
|
|
75
|
+
vulnerabilities = vulnerabilities_for(info)
|
|
76
|
+
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
77
|
+
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
78
|
+
OsvClient.enrich(vulnerabilities, ecosystem: ecosystem, name: name)
|
|
79
|
+
scorecard = DepsDevClient.project_scorecard(project_id: project_id)
|
|
80
|
+
repo = repo_signals(project_id)
|
|
81
|
+
|
|
82
|
+
used_release_date = info&.dig(:published_at)
|
|
83
|
+
latest_release_date = default&.dig(:published_at)
|
|
84
|
+
latest_version = default&.dig(:version)
|
|
85
|
+
gem_data = {
|
|
86
|
+
ecosystem: ecosystem,
|
|
87
|
+
name: name,
|
|
88
|
+
version_used: version,
|
|
89
|
+
version_used_release_date: used_release_date,
|
|
90
|
+
latest_version_release_date: latest_release_date,
|
|
91
|
+
# libyear parity with the native path: how far behind latest the locked
|
|
92
|
+
# version is, in release-years. Both dates come from deps.dev responses
|
|
93
|
+
# already fetched above (no extra call); nil when either date is missing.
|
|
94
|
+
libyear: LibyearHelper.gem_libyear(
|
|
95
|
+
version_used_release_date: parse_time(used_release_date),
|
|
96
|
+
latest_version_release_date: parse_time(latest_release_date),
|
|
97
|
+
),
|
|
98
|
+
# Whether the pinned version is at or ahead of deps.dev's latest stable, so a
|
|
99
|
+
# prerelease or an ahead-of-stable pin reads current (true), not "behind" --
|
|
100
|
+
# parity with the native path's `>=` comparison. nil when latest is unknown.
|
|
101
|
+
up_to_date: version_current?(version, latest_version),
|
|
102
|
+
repository_url: project_id && "https://#{project_id}",
|
|
103
|
+
last_commit_date: repo[:last_commit_date],
|
|
104
|
+
archived: repo[:archived],
|
|
105
|
+
scorecard_score: scorecard&.dig(:score),
|
|
106
|
+
scorecard_maintained: scorecard&.dig(:maintained),
|
|
107
|
+
vulnerability_count: vulnerabilities.length,
|
|
108
|
+
vulnerabilities: vulnerabilities,
|
|
109
|
+
}
|
|
110
|
+
gem_data[:version_unresolved] = true if version_unresolved
|
|
111
|
+
attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
|
|
112
|
+
attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), python_range: python_range)
|
|
113
|
+
gem_data
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Language-runtime ceiling for the cross-ecosystem path, the sibling of the
|
|
119
|
+
# native Ruby ceiling in Workflow. Python declares its runtime constraint as a
|
|
120
|
+
# PEP 440 `requires_python`, an enforced pip install wall; translate it and run
|
|
121
|
+
# the same generic RuntimeCeilingHelper against Python's support window. Unlike
|
|
122
|
+
# poison this is NOT gated on dormancy (a cap is a fact of the resolved version
|
|
123
|
+
# whether or not the package is maintained), so it costs one PyPI read per
|
|
124
|
+
# pypi package; other ecosystems carry no ceiling here rather than a wrong one
|
|
125
|
+
# (cargo's rust_version is a soft MSRV hint, not an install wall). Best-effort:
|
|
126
|
+
# a nil range (endoflife feed down) or absent requires_python yields nothing.
|
|
127
|
+
def attach_language_ceiling(gem_data, ecosystem:, name:, version:, latest_version:, python_range:)
|
|
128
|
+
return if python_range.nil?
|
|
129
|
+
return unless ecosystem == :pypi
|
|
130
|
+
|
|
131
|
+
requirement = Pep440Helper.to_gem_requirement_string(PypiClient.requires_python(name: name, version: version))
|
|
132
|
+
finding = requirement && RuntimeCeilingHelper.analyze(requirement: requirement, support_window: python_range)
|
|
133
|
+
return if finding.nil?
|
|
134
|
+
|
|
135
|
+
finding[:runtime] = "Python"
|
|
136
|
+
finding[:fixed_by_upgrade] = python_ceiling_lifted_by_upgrade?(name: name, version: version, latest_version: latest_version, python_range: python_range)
|
|
137
|
+
gem_data[:language_ceiling] = finding
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Does upgrading the package to its latest release lift the cap? Requires
|
|
141
|
+
# POSITIVE confirmation, unlike the native ruby_ceiling_lifted_by_upgrade?
|
|
142
|
+
# whose latest requirement is read from data already in hand. Here the latest
|
|
143
|
+
# requires_python is a fresh PyPI read, and PypiClient returns nil for BOTH
|
|
144
|
+
# "declares no constraint" AND "the fetch failed" -- indistinguishable. Reading
|
|
145
|
+
# that nil as "no ceiling, safe to bump" would fabricate remediation on a
|
|
146
|
+
# transient PyPI error, the exact over-claim this tool must never make. So a
|
|
147
|
+
# nil specifier yields false (we don't advise an upgrade we couldn't verify);
|
|
148
|
+
# only a readable constraint that analyze confirms is non-capping lifts it.
|
|
149
|
+
def python_ceiling_lifted_by_upgrade?(name:, version:, latest_version:, python_range:)
|
|
150
|
+
return false if latest_version.nil? || latest_version == version
|
|
151
|
+
|
|
152
|
+
latest_specifier = PypiClient.requires_python(name: name, version: latest_version)
|
|
153
|
+
return false if latest_specifier.nil?
|
|
154
|
+
|
|
155
|
+
latest_requirement = Pep440Helper.to_gem_requirement_string(latest_specifier)
|
|
156
|
+
RuntimeCeilingHelper.analyze(requirement: latest_requirement, support_window: python_range).nil?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Constraint enrichment for the cross-ecosystem path, gated on dormancy so a
|
|
160
|
+
# maintained package is never flagged. Two shapes by ecosystem:
|
|
161
|
+
#
|
|
162
|
+
# FLAT (rubygems/pypi): the poison-pill signal as-is -- a below-latest cap holds
|
|
163
|
+
# the whole tree hostage, so it renders directly (`constraints` + `poison`).
|
|
164
|
+
#
|
|
165
|
+
# NESTED (npm/cargo): the pure below-latest cap is subtree-local noise (nested
|
|
166
|
+
# copies, caret default), so no poison here. Instead keep every declared dep as a
|
|
167
|
+
# security CANDIDATE (`capped_deps`); the correlator, which alone sees the tree's
|
|
168
|
+
# resolved versions and advisories, promotes only the ones that pin a vulnerable
|
|
169
|
+
# copy below its fix. Candidates are NOT filtered to below-latest-major (npm/cargo
|
|
170
|
+
# fixes are mostly same-major patch bumps that filter would miss) and need no
|
|
171
|
+
# dep_latest fetch (the wall test is patch-precise on the requirement itself).
|
|
172
|
+
def attach_constraints(gem_data, ecosystem:, name:, version:, cache:)
|
|
173
|
+
registry = ECOSYSTEM_REGISTRIES[ecosystem]
|
|
174
|
+
return if registry.nil?
|
|
175
|
+
return unless [:critical, :archived].include?(ActivityHelper.activity_level(gem_data))
|
|
176
|
+
|
|
177
|
+
declared = EcosystemsClient.declared_dependencies(name: name, version: version, registry: registry)
|
|
178
|
+
if FLAT_RESOLUTION_ECOSYSTEMS.include?(ecosystem)
|
|
179
|
+
attach_flat_poison(gem_data, ecosystem, declared, cache)
|
|
180
|
+
else
|
|
181
|
+
attach_nested_candidates(gem_data, declared)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def attach_flat_poison(gem_data, ecosystem, declared, cache)
|
|
186
|
+
findings = ConstraintHelper.poison_findings(declared) do |dep_name|
|
|
187
|
+
latest_version_for(ecosystem, dep_name, cache)
|
|
188
|
+
end
|
|
189
|
+
return if findings.empty?
|
|
190
|
+
|
|
191
|
+
gem_data[:constraints] = findings
|
|
192
|
+
gem_data[:poison] = findings.any? { |finding| finding[:kind] == :ceiling }
|
|
193
|
+
gem_data[:poison_severity] = ConstraintHelper.worst_severity(findings) if gem_data[:poison]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def attach_nested_candidates(gem_data, declared)
|
|
197
|
+
candidates = declared.filter_map do |dep|
|
|
198
|
+
next if dep[:package_name].to_s.empty? || dep[:requirements].to_s.empty?
|
|
199
|
+
|
|
200
|
+
{ dependency: dep[:package_name], requirement: dep[:requirements] }
|
|
201
|
+
end
|
|
202
|
+
gem_data[:capped_deps] = candidates unless candidates.empty?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A capped dep's current latest version in `ecosystem`, via deps.dev's default
|
|
206
|
+
# (latest stable) version. Memoized per run, keyed by ecosystem+name so an npm
|
|
207
|
+
# `foo` and a pypi `foo` never collide. Only a RESOLVED version is cached: a
|
|
208
|
+
# nil can mean "genuinely unindexed" or "deps.dev was momentarily down / rate-
|
|
209
|
+
# limited" (both surface as nil here), and caching the transient case would
|
|
210
|
+
# suppress the pill for every later dormant package capping the same dep. So an
|
|
211
|
+
# unresolved dep is re-attempted rather than remembered as a permanent miss.
|
|
212
|
+
def latest_version_for(ecosystem, dep_name, cache)
|
|
213
|
+
key = "#{ecosystem}/#{dep_name}"
|
|
214
|
+
cache[key] ||= DepsDevClient.default_version_info(name: dep_name, system: ecosystem)&.dig(:version)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def vulnerabilities_for(info)
|
|
218
|
+
advisory_keys = info&.dig(:advisory_keys) || []
|
|
219
|
+
# The keys ARE the evidence the locked version is vulnerable; the detail
|
|
220
|
+
# fetch only enriches CVSS/title. A failed enrichment must not drop the
|
|
221
|
+
# count to zero and read a known-vulnerable dep as clean, so a key whose
|
|
222
|
+
# detail can't be loaded still contributes a minimal advisory.
|
|
223
|
+
deps_dev = advisory_keys.map { DepsDevClient.advisory_detail(advisory_id: _1) || { id: _1, source: "deps.dev" } }
|
|
224
|
+
# No ruby-advisory-db here: that database is Ruby-only and the native
|
|
225
|
+
# Bundler path already merges it. Cross-ecosystem advisories come from
|
|
226
|
+
# deps.dev/OSV, which covers every system the lens serves.
|
|
227
|
+
VulnerabilityHelper.merge_advisories(deps_dev: deps_dev, ruby_advisory_db: [])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The repo project_id from the package's default version, used only when the
|
|
231
|
+
# locked version's deps.dev record is missing or carries no link.
|
|
232
|
+
def project_id_from(name, ecosystem, default)
|
|
233
|
+
version = default&.dig(:version)
|
|
234
|
+
return if version.nil?
|
|
235
|
+
|
|
236
|
+
DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)&.dig(:project_id)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# archived + last-commit for a flat github.com/owner/repo project, or {} for
|
|
240
|
+
# anything else. deps.dev indexes github.com and gitlab.com only, but gitlab
|
|
241
|
+
# subgroups nest arbitrarily and ecosyste.ms's repo crawler is GitHub-centric,
|
|
242
|
+
# so a gitlab (or nested, or unresolved) project keeps archived unknown rather
|
|
243
|
+
# than risk a bogus owner/name lookup.
|
|
244
|
+
def repo_signals(project_id)
|
|
245
|
+
host, owner, name, *rest = project_id.to_s.split("/")
|
|
246
|
+
return {} unless host == "github.com" && owner && name && rest.empty?
|
|
247
|
+
|
|
248
|
+
repo_provider.repo_signals(owner: owner, name: name) || {}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Mirrors Workflow#provider_for(:github): the live GitHub API when a token is
|
|
252
|
+
# configured (freshest), else ecosyste.ms (5000 anonymous req/hr vs GitHub's
|
|
253
|
+
# 60), so an untokened cross-ecosystem run still resolves a large SBOM.
|
|
254
|
+
def repo_provider
|
|
255
|
+
StillActive.config.github_oauth_token ? GithubClient : EcosystemsClient
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Is the pinned version at or ahead of latest stable? nil when latest is unknown.
|
|
259
|
+
# Gem::Version handles semver prereleases (`16.3.0-canary.7` sorts as a prerelease
|
|
260
|
+
# of 16.3.0, so ahead of a 16.2.x stable -> current), so a beta/ahead pin reads
|
|
261
|
+
# true, not "behind". Versions Gem::Version can't parse (pypi epoch `1!2.3`, build
|
|
262
|
+
# metadata, a `v` prefix) fall back to exact match -- ahead reads false there, the
|
|
263
|
+
# safe direction, and libyear still carries the real magnitude.
|
|
264
|
+
def version_current?(version, latest)
|
|
265
|
+
return false if latest.nil?
|
|
266
|
+
return version == latest unless Gem::Version.correct?(version) && Gem::Version.correct?(latest)
|
|
267
|
+
|
|
268
|
+
Gem::Version.new(version) >= Gem::Version.new(latest)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# deps.dev renders dates as ISO8601 strings; libyear needs Time to subtract.
|
|
272
|
+
# nil-safe, and a malformed value degrades to nil rather than raising.
|
|
273
|
+
def parse_time(value)
|
|
274
|
+
# Guard the type, not just nil: a non-String publishedAt (schema drift) would
|
|
275
|
+
# make Time.parse raise TypeError (unrescued) and, since this enrichment runs
|
|
276
|
+
# in assess, demote an otherwise-healthy package. Best-effort must degrade.
|
|
277
|
+
return unless value.is_a?(String)
|
|
278
|
+
|
|
279
|
+
Time.parse(value)
|
|
280
|
+
rescue ArgumentError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../helpers/http_helper"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Tokenless repo signals (archived?, last-commit date) for github.com-hosted
|
|
9
|
+
# gems, sourced from ecosyste.ms instead of the GitHub API. Used as the
|
|
10
|
+
# fallback when no GitHub token is configured, so an unauthenticated run isn't
|
|
11
|
+
# capped at GitHub's 60 req/hr (ecosyste.ms allows 5000 anonymous) -- the
|
|
12
|
+
# difference between "works on a large Gemfile" and "dies after a handful".
|
|
13
|
+
#
|
|
14
|
+
# GitHub-only by design: ecosyste.ms's repos service doesn't populate commit
|
|
15
|
+
# recency for GitLab/Codeberg (its repo crawler is GitHub-centric), so those
|
|
16
|
+
# hosts keep their own live clients.
|
|
17
|
+
#
|
|
18
|
+
# Data is CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/);
|
|
19
|
+
# still_active queries it live (no redistribution) and attributes ecosyste.ms
|
|
20
|
+
# in the README data sources.
|
|
21
|
+
module EcosystemsClient
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
BASE_URI = URI("https://repos.ecosyste.ms/")
|
|
25
|
+
# The packages service is a distinct host from the repos service above; it
|
|
26
|
+
# carries per-version declared dependency constraints (the poison-pill input).
|
|
27
|
+
PACKAGES_BASE_URI = URI("https://packages.ecosyste.ms/")
|
|
28
|
+
# ecosyste.ms asks consumers to identify themselves for its "polite pool".
|
|
29
|
+
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
|
|
30
|
+
|
|
31
|
+
# archived + last-commit date from a single repository call. ecosyste.ms's
|
|
32
|
+
# pushed_at mirrors GitHub's, so this returns the same shape as GithubClient.
|
|
33
|
+
# Returns {} when the repo can't be read, so the caller leaves both blank.
|
|
34
|
+
def repo_signals(owner:, name:)
|
|
35
|
+
return {} if owner.nil? || name.nil?
|
|
36
|
+
|
|
37
|
+
path = "/api/v1/hosts/GitHub/repositories/#{encode_repo(owner, name)}"
|
|
38
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
|
|
39
|
+
# A non-Hash 200 body (error envelope rendered as an array, schema drift)
|
|
40
|
+
# would otherwise raise on indexing and vanish the gem from the audit via
|
|
41
|
+
# the workflow's rescue; degrade to "no signal" like any other read failure.
|
|
42
|
+
return {} unless body.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
signals = { last_commit_date: parse_time(body["pushed_at"], owner, name) }
|
|
45
|
+
# Only assert archived when the field is actually present. A missing field
|
|
46
|
+
# must read as unknown, not be invented as false -- otherwise a partial
|
|
47
|
+
# crawl could silently mask the most actionable verdict (gem is archived).
|
|
48
|
+
signals[:archived] = body["archived"] == true if body.key?("archived")
|
|
49
|
+
signals
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ecosyste.ms dependency-kind labels that ship at RUNTIME (so a cap on one
|
|
53
|
+
# holds the consumer's tree hostage). It is registry-specific vocabulary:
|
|
54
|
+
# rubygems/npm/pypi say "runtime", but cargo says "normal" (its "dev"/"build"
|
|
55
|
+
# kinds are excluded). An ALLOWLIST, not a denylist: an unrecognised kind is
|
|
56
|
+
# dropped rather than risk flagging a dev/build dep and breaking the FP
|
|
57
|
+
# discipline. Compared case-insensitively (rubygems's dev kind is
|
|
58
|
+
# "Development").
|
|
59
|
+
RUNTIME_KINDS = ["runtime", "normal"].freeze
|
|
60
|
+
|
|
61
|
+
# The runtime dependency constraints a package version declares: an array of
|
|
62
|
+
# { package_name:, requirements: } for runtime-shipped deps only (see
|
|
63
|
+
# RUNTIME_KINDS). Dev/build/test deps are dropped -- they don't cap the
|
|
64
|
+
# consumer's tree, so they can't be a poison-pill. `requirements` is the raw
|
|
65
|
+
# constraint string ("< 5.0, >= 4.0.1") ConstraintHelper reads. `registry` is
|
|
66
|
+
# the ecosyste.ms registry name (rubygems.org, pypi.org, npmjs.org,
|
|
67
|
+
# crates.io), defaulting to Ruby.
|
|
68
|
+
#
|
|
69
|
+
# Returns [] whenever the version can't be read (unindexed, 404, timeout,
|
|
70
|
+
# schema drift), so a caller degrades to "no constraints known" rather than
|
|
71
|
+
# crashing the per-gem audit.
|
|
72
|
+
def declared_dependencies(name:, version:, registry: "rubygems.org")
|
|
73
|
+
return [] if name.nil? || version.nil?
|
|
74
|
+
|
|
75
|
+
path = "/api/v1/registries/#{encode(registry)}/packages/#{encode(name)}/versions/#{encode(version)}"
|
|
76
|
+
body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
|
|
77
|
+
return [] unless body.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
dependencies = body["dependencies"]
|
|
80
|
+
return [] unless dependencies.is_a?(Array)
|
|
81
|
+
|
|
82
|
+
dependencies.filter_map do |dep|
|
|
83
|
+
next unless dep.is_a?(Hash) && RUNTIME_KINDS.include?(dep["kind"].to_s.downcase)
|
|
84
|
+
|
|
85
|
+
package_name = dep["package_name"]
|
|
86
|
+
requirements = dep["requirements"]
|
|
87
|
+
next if package_name.nil? || requirements.nil?
|
|
88
|
+
|
|
89
|
+
{ package_name: package_name, requirements: requirements }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# ecosyste.ms raises the rate limit and prioritises requests that identify a
|
|
96
|
+
# contact via a mailto query param (its "polite pool"). Opt-in: empty unless
|
|
97
|
+
# the user configures an email, since anonymous already suffices for a typical
|
|
98
|
+
# lockfile and we don't attribute every user's traffic to one address.
|
|
99
|
+
def politeness_params
|
|
100
|
+
email = StillActive.config.ecosystems_email
|
|
101
|
+
email ? { mailto: email } : {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# nil or a non-string (numeric/array from an off-spec payload) -> no date,
|
|
105
|
+
# never a crash; only an unparseable string is worth warning about.
|
|
106
|
+
def parse_time(value, owner, name)
|
|
107
|
+
return unless value.is_a?(String)
|
|
108
|
+
|
|
109
|
+
Time.parse(value)
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
$stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def encode_repo(owner, name)
|
|
116
|
+
URI.encode_www_form_component("#{owner}/#{name}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encode(value)
|
|
120
|
+
URI.encode_www_form_component(value.to_s)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/still_active/options.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "../helpers/constraint_helper"
|
|
4
5
|
require_relative "../helpers/cyclonedx_helper"
|
|
5
6
|
require_relative "../helpers/vulnerability_helper"
|
|
6
7
|
|
|
@@ -15,6 +16,7 @@ module StillActive
|
|
|
15
16
|
add_tail_options(opts)
|
|
16
17
|
add_gemfile_option(opts)
|
|
17
18
|
add_gems_option(opts)
|
|
19
|
+
add_sbom_option(opts)
|
|
18
20
|
add_ignore_option(opts)
|
|
19
21
|
add_output_options(opts)
|
|
20
22
|
add_token_options(opts)
|
|
@@ -34,7 +36,11 @@ module StillActive
|
|
|
34
36
|
private
|
|
35
37
|
|
|
36
38
|
def validate_options
|
|
37
|
-
|
|
39
|
+
inputs = [options[:provided_gemfile], options[:provided_gems], options[:provided_sbom]].compact
|
|
40
|
+
raise ArgumentError, "provide only one of --gemfile, --gems, --sbom" if inputs.size > 1
|
|
41
|
+
if options[:provided_sbom] && !File.exist?(StillActive.config.sbom_path)
|
|
42
|
+
raise ArgumentError, "SBOM file not found: #{StillActive.config.sbom_path}"
|
|
43
|
+
end
|
|
38
44
|
if options[:provided_baseline] && !File.exist?(StillActive.config.baseline_path)
|
|
39
45
|
raise ArgumentError, "baseline file not found: #{StillActive.config.baseline_path}"
|
|
40
46
|
end
|
|
@@ -55,6 +61,13 @@ module StillActive
|
|
|
55
61
|
end
|
|
56
62
|
end
|
|
57
63
|
|
|
64
|
+
def add_sbom_option(opts)
|
|
65
|
+
opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; JSON output") do |value|
|
|
66
|
+
options[:provided_sbom] = true
|
|
67
|
+
StillActive.config { |config| config.sbom_path = value }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
58
71
|
def add_ignore_option(opts)
|
|
59
72
|
opts.on("--ignore=GEM,GEM2,...", Array, "Gem(s) to exclude from pass/fail checks") do |value|
|
|
60
73
|
StillActive.config { |config| config.ignored_gems = value }
|
|
@@ -94,6 +107,11 @@ module StillActive
|
|
|
94
107
|
opts.on("--gitlab-token=TOKEN", String, "GitLab personal access token for API calls") do |value|
|
|
95
108
|
StillActive.config { |config| config.gitlab_token = value }
|
|
96
109
|
end
|
|
110
|
+
opts.on("--ecosystems-email=EMAIL", String, "Contact email for the ecosyste.ms polite pool (used only when falling back to ecosyste.ms without a GitHub token)") do |value|
|
|
111
|
+
email = value.strip
|
|
112
|
+
warn("warning: --ecosystems-email=#{value.inspect} doesn't look like an email (no @); ecosyste.ms will keep you in the anonymous pool") unless email.include?("@")
|
|
113
|
+
StillActive.config { |config| config.ecosystems_email = email }
|
|
114
|
+
end
|
|
97
115
|
opts.on("--artifactory-token=TOKEN", String, "Artifactory token for private gem registry API calls") do |value|
|
|
98
116
|
StillActive.config { |config| config.artifactory_token = value }
|
|
99
117
|
end
|
|
@@ -145,6 +163,31 @@ module StillActive
|
|
|
145
163
|
opts.on("--fail-if-outdated=LIBYEARS", Float, "Exit 1 if any gem exceeds LIBYEARS behind latest") do |value|
|
|
146
164
|
StillActive.config { |config| config.fail_if_outdated = value }
|
|
147
165
|
end
|
|
166
|
+
opts.on("--fail-if-poison[=TIER]", "Exit 1 on a poison-pill at or above TIER (note|warning|critical; default warning)") do |value|
|
|
167
|
+
if value
|
|
168
|
+
valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
|
|
169
|
+
raise ArgumentError, "--fail-if-poison tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
|
|
170
|
+
|
|
171
|
+
StillActive.config { |config| config.fail_if_poison = value.to_sym }
|
|
172
|
+
else
|
|
173
|
+
StillActive.config { |config| config.fail_if_poison = true }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
opts.on("--fail-if-language-ceiling[=TIER]", "Exit 1 on a language-runtime ceiling (Ruby/Python; default: EOL-forced only; =note also gates latest-not-yet)") do |value|
|
|
177
|
+
if value
|
|
178
|
+
valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
|
|
179
|
+
raise ArgumentError, "--fail-if-language-ceiling tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
|
|
180
|
+
|
|
181
|
+
# Ceiling findings are only ever :critical or :note, so =warning can't
|
|
182
|
+
# match anything the bare (=critical) default doesn't already catch.
|
|
183
|
+
if value == "warning"
|
|
184
|
+
$stderr.puts("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
|
|
185
|
+
end
|
|
186
|
+
StillActive.config { |config| config.fail_if_language_ceiling = value.to_sym }
|
|
187
|
+
else
|
|
188
|
+
StillActive.config { |config| config.fail_if_language_ceiling = true }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
148
191
|
end
|
|
149
192
|
|
|
150
193
|
def add_emoji_options(opts)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/http_helper"
|
|
4
|
+
require_relative "../helpers/cvss_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
|
|
8
|
+
# deps.dev is the discovery source -- it lists a package version's advisory ids --
|
|
9
|
+
# but it stores only CVSS 3.x (a CVSS-4-only advisory comes back with cvss3Score
|
|
10
|
+
# 0, which reads as unscored) and carries no fixed-version ranges at all. OSV
|
|
11
|
+
# supplies all three gaps: a GHSA severity LABEL (`database_specific.severity`)
|
|
12
|
+
# that reads correctly even for a v4-only advisory, the CVSS v4 VECTOR (turned into
|
|
13
|
+
# a real score, so the finding carries a security-severity number), and the
|
|
14
|
+
# per-package fixed versions the "capped below the fix" signal compares against a
|
|
15
|
+
# poison cap's ceiling.
|
|
16
|
+
#
|
|
17
|
+
# Enrichment is best-effort: any failure (missing record, transport error, odd
|
|
18
|
+
# shape) leaves the advisory exactly as deps.dev produced it. It must never drop
|
|
19
|
+
# an advisory the audit already found, so it only ever ADDS fields.
|
|
20
|
+
module OsvClient
|
|
21
|
+
extend self
|
|
22
|
+
|
|
23
|
+
BASE_URI = URI("https://api.osv.dev/")
|
|
24
|
+
|
|
25
|
+
# Every ecosystem SbomReader/deps.dev resolve, mapped to OSV's package-ecosystem
|
|
26
|
+
# casing (verified against live OSV records). One advisory can name the same
|
|
27
|
+
# package in several ecosystems, so `affected` is filtered to the one being
|
|
28
|
+
# audited; the native Bundler path carries no ecosystem and is always rubygems.
|
|
29
|
+
# Anything unmapped falls back to name-only fix filtering rather than dropping fixes.
|
|
30
|
+
ECOSYSTEM_NAMES = {
|
|
31
|
+
rubygems: "RubyGems",
|
|
32
|
+
pypi: "PyPI",
|
|
33
|
+
npm: "npm",
|
|
34
|
+
cargo: "crates.io",
|
|
35
|
+
maven: "Maven",
|
|
36
|
+
go: "Go",
|
|
37
|
+
nuget: "NuGet",
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Prefer the newest CVSS version a record carries (v4 is the whole point: it's the
|
|
41
|
+
# one deps.dev can't score). `severity[].score` is the vector STRING, oddly named.
|
|
42
|
+
CVSS_PRIORITY = { "CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1 }.freeze
|
|
43
|
+
# A v2 vector has no `CVSS:X.Y` prefix, so the version can't be read from the
|
|
44
|
+
# string; fall back to the entry type so the CycloneDX rating method labels it v2.
|
|
45
|
+
TYPE_VERSIONS = { "CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0" }.freeze
|
|
46
|
+
|
|
47
|
+
# Enrich each advisory in place with the OSV severity label and the fixed
|
|
48
|
+
# versions for the audited package. A missing/failed lookup is a no-op on that
|
|
49
|
+
# advisory (it keeps whatever deps.dev gave it), never a raise.
|
|
50
|
+
def enrich(advisories, ecosystem:, name:)
|
|
51
|
+
advisories.each do |advisory|
|
|
52
|
+
record = detail(advisory_id: advisory[:id])
|
|
53
|
+
next if record.nil?
|
|
54
|
+
|
|
55
|
+
advisory[:osv_severity] = record[:severity_label]
|
|
56
|
+
advisory[:osv_cvss_score] = record[:cvss_score]
|
|
57
|
+
advisory[:cvss_version] = record[:cvss_version]
|
|
58
|
+
advisory[:cvss_vector] = record[:cvss_vector]
|
|
59
|
+
advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
# Enrichment is additive and best-effort. An unexpected OSV shape must never
|
|
62
|
+
# raise out through the workflow's per-gem rescue, which would DROP the whole
|
|
63
|
+
# gem and read a known-vulnerable dependency as clean. Leave the advisory
|
|
64
|
+
# exactly as deps.dev produced it.
|
|
65
|
+
$stderr.puts("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
|
|
70
|
+
# { severity_label:, cvss_score:, cvss_version:, cvss_vector:, affected: [...] }
|
|
71
|
+
# or nil when the id is absent or OSV has no usable record for it. A non-object
|
|
72
|
+
# body (a CDN/error envelope that parses to an array or scalar) yields nil rather
|
|
73
|
+
# than raising on `dig`.
|
|
74
|
+
def detail(advisory_id:)
|
|
75
|
+
return if advisory_id.nil?
|
|
76
|
+
|
|
77
|
+
body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}")
|
|
78
|
+
return unless body.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
cvss = best_cvss(body)
|
|
81
|
+
{
|
|
82
|
+
severity_label: body.dig("database_specific", "severity"),
|
|
83
|
+
cvss_score: cvss[:score],
|
|
84
|
+
cvss_version: cvss[:version],
|
|
85
|
+
cvss_vector: cvss[:vector],
|
|
86
|
+
affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) },
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The highest-version CVSS vector the record carries, as { score:, version:,
|
|
93
|
+
# vector: } (a computed base score, the "3.1"/"4.0" version, and the raw string),
|
|
94
|
+
# or all-nil. deps.dev has no v4 score, so this is what gives a CVSS-4-only
|
|
95
|
+
# advisory a real number for the security-severity/rating.
|
|
96
|
+
def best_cvss(body)
|
|
97
|
+
entry = Array(body["severity"])
|
|
98
|
+
.select { |s| s.is_a?(Hash) && CVSS_PRIORITY.key?(s["type"]) }
|
|
99
|
+
.max_by { |s| CVSS_PRIORITY[s["type"]] }
|
|
100
|
+
return {} if entry.nil?
|
|
101
|
+
|
|
102
|
+
vector = entry["score"]
|
|
103
|
+
{ score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The X.Y version from a "CVSS:X.Y/..." vector prefix, or nil.
|
|
107
|
+
def cvss_version(vector)
|
|
108
|
+
vector.to_s[%r{\ACVSS:(\d+\.\d+)/}, 1]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The fixed versions declared for `name` in `ecosystem` across the record's
|
|
112
|
+
# affected packages (a branch-structured advisory carries one fix per maintained
|
|
113
|
+
# line, so several can apply). Empty when the package is enumerated by
|
|
114
|
+
# `versions` with no fix boundary, or isn't present in this ecosystem. When the
|
|
115
|
+
# ecosystem symbol has no OSV mapping (an ecosystem we don't audit for fixes),
|
|
116
|
+
# match by name alone rather than silently dropping every fix.
|
|
117
|
+
def fixed_versions(record, ecosystem:, name:)
|
|
118
|
+
osv_ecosystem = ECOSYSTEM_NAMES.fetch(ecosystem || :rubygems, nil)
|
|
119
|
+
record[:affected]
|
|
120
|
+
.select { |a| a[:name] == name && (osv_ecosystem.nil? || a[:ecosystem] == osv_ecosystem) }
|
|
121
|
+
.flat_map { |a| a[:fixed] }
|
|
122
|
+
.uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Shape guards throughout: a malformed entry (null/scalar where an object is
|
|
126
|
+
# expected, or an object where an array is) is skipped, not raised on, so one
|
|
127
|
+
# bad `affected`/`ranges`/`events` element can't discard the good fixed versions
|
|
128
|
+
# beside it in the same record.
|
|
129
|
+
def parse_affected(entry)
|
|
130
|
+
return unless entry.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
package = entry["package"]
|
|
133
|
+
return unless package.is_a?(Hash) && package["name"]
|
|
134
|
+
|
|
135
|
+
fixed = Array(entry["ranges"]).flat_map do |range|
|
|
136
|
+
next [] unless range.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) }
|
|
139
|
+
end
|
|
140
|
+
{ ecosystem: package["ecosystem"], name: package["name"], fixed: fixed }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def encode(value)
|
|
144
|
+
URI.encode_www_form_component(value.to_s)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|