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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +57 -10
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +9 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +201 -8
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. 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
@@ -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
- raise ArgumentError, "provide gemfile or gems, not both" if options[:provided_gemfile] && options[:provided_gems]
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