still_active 3.0.0.rc4 → 3.0.0.rc6

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/lib/helpers/activity_helper.rb +3 -3
  4. data/lib/helpers/alternatives_helper.rb +1 -1
  5. data/lib/helpers/ansi_helper.rb +1 -1
  6. data/lib/helpers/bot_context.rb +7 -7
  7. data/lib/helpers/bundler_helper.rb +1 -2
  8. data/lib/helpers/catalog_index.rb +1 -1
  9. data/lib/helpers/constraint_helper.rb +6 -6
  10. data/lib/helpers/cvss_helper.rb +2 -2
  11. data/lib/helpers/cyclonedx_helper.rb +14 -14
  12. data/lib/helpers/diff_markdown_helper.rb +4 -4
  13. data/lib/helpers/dotnet_helper.rb +4 -4
  14. data/lib/helpers/endoflife_helper.rb +3 -3
  15. data/lib/helpers/http_helper.rb +31 -19
  16. data/lib/helpers/lockfile_dependency_parser.rb +2 -2
  17. data/lib/helpers/markdown_escape.rb +1 -1
  18. data/lib/helpers/markdown_helper.rb +7 -7
  19. data/lib/helpers/ruby_advisory_db.rb +1 -1
  20. data/lib/helpers/ruby_helper.rb +3 -3
  21. data/lib/helpers/runtime_ceiling_helper.rb +2 -2
  22. data/lib/helpers/sarif_helper.rb +34 -34
  23. data/lib/helpers/status_helper.rb +1 -1
  24. data/lib/helpers/summary_helper.rb +1 -1
  25. data/lib/helpers/terminal_helper.rb +8 -8
  26. data/lib/helpers/vulnerability_helper.rb +1 -1
  27. data/lib/still_active/artifactory_client.rb +47 -18
  28. data/lib/still_active/cli.rb +38 -38
  29. data/lib/still_active/compact_index_client.rb +117 -0
  30. data/lib/still_active/config.rb +1 -1
  31. data/lib/still_active/config_file.rb +4 -4
  32. data/lib/still_active/deps_dev_client.rb +7 -7
  33. data/lib/still_active/diff.rb +12 -12
  34. data/lib/still_active/ecosystem_lens.rb +8 -6
  35. data/lib/still_active/ecosystems_client.rb +7 -7
  36. data/lib/still_active/forgejo_client.rb +3 -3
  37. data/lib/still_active/github_client.rb +6 -6
  38. data/lib/still_active/gitlab_client.rb +3 -3
  39. data/lib/still_active/options.rb +4 -4
  40. data/lib/still_active/osv_client.rb +200 -30
  41. data/lib/still_active/poison_security_correlator.rb +3 -3
  42. data/lib/still_active/pypi_client.rb +2 -2
  43. data/lib/still_active/repository.rb +4 -4
  44. data/lib/still_active/sarif/rules.rb +20 -20
  45. data/lib/still_active/sbom_reader.rb +8 -8
  46. data/lib/still_active/sbom_workflow.rb +7 -7
  47. data/lib/still_active/source_credentials.rb +39 -0
  48. data/lib/still_active/version.rb +1 -1
  49. data/lib/still_active/workflow.rb +60 -33
  50. data/still_active.gemspec +16 -20
  51. metadata +5 -115
@@ -60,8 +60,8 @@ module StillActive
60
60
  neutral: {
61
61
  short: "Package's source repository is archived",
62
62
  full: "The package's upstream repository has been marked archived. No further fixes, including security patches, should be expected.",
63
- help_text: "Look for a maintained fork or alternative. If you must keep the package, vendor or fork it so you can apply patches yourself.",
64
- },
63
+ help_text: "Look for a maintained fork or alternative. If you must keep the package, vendor or fork it so you can apply patches yourself."
64
+ }
65
65
  },
66
66
  {
67
67
  id: "SA002",
@@ -75,8 +75,8 @@ module StillActive
75
75
  neutral: {
76
76
  short: "Package has had no release for over 3 years",
77
77
  full: "The package's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a package with no releases at all (e.g. git-sourced), the last commit date is used instead.",
78
- help_text: "Verify the package still works on your supported runtime and consider a maintained alternative.",
79
- },
78
+ help_text: "Verify the package still works on your supported runtime and consider a maintained alternative."
79
+ }
80
80
  },
81
81
  {
82
82
  id: "SA003",
@@ -88,8 +88,8 @@ module StillActive
88
88
  security_severity: "7.0", # default; per-result override from CVSS
89
89
  tags: ["security", "vulnerability", "external/cwe/cwe-1104"],
90
90
  neutral: {
91
- short: "Package has known vulnerabilities (via deps.dev / OSV)",
92
- },
91
+ short: "Package has known vulnerabilities (via deps.dev / OSV)"
92
+ }
93
93
  },
94
94
  {
95
95
  id: "SA004",
@@ -102,8 +102,8 @@ module StillActive
102
102
  tags: ["maintenance", "libyear"],
103
103
  neutral: {
104
104
  short: "Package is significantly behind the latest release",
105
- help_text: "Schedule an upgrade to the latest release.",
106
- },
105
+ help_text: "Schedule an upgrade to the latest release."
106
+ }
107
107
  },
108
108
  {
109
109
  id: "SA005",
@@ -115,8 +115,8 @@ module StillActive
115
115
  security_severity: nil,
116
116
  tags: ["supply-chain", "openssf"],
117
117
  neutral: {
118
- short: "Package's OpenSSF Scorecard is low",
119
- },
118
+ short: "Package's OpenSSF Scorecard is low"
119
+ }
120
120
  },
121
121
  {
122
122
  id: "SA006",
@@ -127,7 +127,7 @@ module StillActive
127
127
  level: "error",
128
128
  security_severity: "8.5",
129
129
  tags: ["security", "runtime", "external/cwe/cwe-1104"],
130
- native_only: true,
130
+ native_only: true
131
131
  },
132
132
  {
133
133
  id: "SA007",
@@ -141,8 +141,8 @@ module StillActive
141
141
  neutral: {
142
142
  short: "Resolved package version has been yanked from its registry",
143
143
  full: "The resolved version has been yanked by the package owner, typically for a serious bug or vulnerability.",
144
- help_text: "Update to a non-yanked version immediately.",
145
- },
144
+ help_text: "Update to a non-yanked version immediately."
145
+ }
146
146
  },
147
147
  {
148
148
  id: "SA008",
@@ -156,8 +156,8 @@ module StillActive
156
156
  neutral: {
157
157
  short: "Dormant package caps a dependency below its latest major",
158
158
  full: "A dormant (abandoned or archived) package declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the package, 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.",
159
- help_text: "Replace or fork the dormant package, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in.",
160
- },
159
+ help_text: "Replace or fork the dormant package, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in."
160
+ }
161
161
  },
162
162
  {
163
163
  id: "SA009",
@@ -167,8 +167,8 @@ module StillActive
167
167
  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.",
168
168
  level: "note",
169
169
  security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
170
- tags: ["maintenance", "runtime", "external/cwe/cwe-1104"],
171
- },
170
+ tags: ["maintenance", "runtime", "external/cwe/cwe-1104"]
171
+ }
172
172
  ].freeze
173
173
 
174
174
  # Builds a frozen catalog for one wording flavour. `:neutral` applies each
@@ -181,10 +181,10 @@ module StillActive
181
181
  neutral = flavour == :neutral
182
182
  rules = neutral ? RAW_RULES.reject { |r| r[:native_only] } : RAW_RULES
183
183
  rules.map do |r|
184
- resolved = neutral && r[:neutral] ? r.merge(r[:neutral]) : r
184
+ resolved = (neutral && r[:neutral]) ? r.merge(r[:neutral]) : r
185
185
  resolved.except(:neutral, :native_only).merge(
186
186
  tags: r[:tags].dup.freeze,
187
- help_markdown: "#{resolved[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance.",
187
+ help_markdown: "#{resolved[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance."
188
188
  ).freeze
189
189
  end.freeze
190
190
  end
@@ -194,7 +194,7 @@ module StillActive
194
194
 
195
195
  # flavour: :ruby (default, native Gemfile path) or :neutral (SBOM path).
196
196
  def all(flavour = :ruby)
197
- flavour == :neutral ? NEUTRAL_CATALOG : CATALOG
197
+ (flavour == :neutral) ? NEUTRAL_CATALOG : CATALOG
198
198
  end
199
199
 
200
200
  def find(id, flavour = :ruby)
@@ -32,7 +32,7 @@ module StillActive
32
32
  "cargo" => :cargo,
33
33
  "maven" => :maven,
34
34
  "golang" => :go,
35
- "nuget" => :nuget,
35
+ "nuget" => :nuget
36
36
  }.freeze
37
37
 
38
38
  # PURL types that are not package dependencies: CI actions and opaque
@@ -100,7 +100,7 @@ module StillActive
100
100
 
101
101
  name = component["name"]
102
102
  purl = component["purl"]
103
- return [:unassessable, { ecosystem: nil, name: name, reason: :no_purl }] unless purl.is_a?(String) && !purl.empty?
103
+ return [:unassessable, {ecosystem: nil, name: name, reason: :no_purl}] unless purl.is_a?(String) && !purl.empty?
104
104
 
105
105
  classify_purl(purl, name)
106
106
  end
@@ -121,7 +121,7 @@ module StillActive
121
121
  "repo1.maven.org",
122
122
  "repo.maven.apache.org",
123
123
  "api.nuget.org",
124
- "proxy.golang.org",
124
+ "proxy.golang.org"
125
125
  ].freeze
126
126
 
127
127
  def classify_purl(purl, name)
@@ -130,7 +130,7 @@ module StillActive
130
130
  ecosystem = ECOSYSTEMS[type]
131
131
 
132
132
  if ecosystem
133
- return [:unassessable, { ecosystem: type, name: name, reason: :no_version }] if parsed.version.to_s.empty?
133
+ return [:unassessable, {ecosystem: type, name: name, reason: :no_version}] if parsed.version.to_s.empty?
134
134
 
135
135
  full_name = build_name(ecosystem, parsed.namespace, parsed.name)
136
136
  repository_url = parsed.qualifiers&.dig("repository_url")
@@ -143,14 +143,14 @@ module StillActive
143
143
  # from a public one, so it is still assessed by name. Best-effort, not a
144
144
  # guarantee that every private package is caught.
145
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 }]
146
+ return [:unassessable, {ecosystem: ecosystem, name: full_name, version: parsed.version, reason: :private_registry, repository_url: repository_url}]
147
147
  end
148
148
 
149
- [:dependency, { ecosystem: ecosystem, name: full_name, version: parsed.version }]
149
+ [:dependency, {ecosystem: ecosystem, name: full_name, version: parsed.version}]
150
150
  elsif NOISE_TYPES.include?(type)
151
151
  nil # CI actions / opaque binaries: not a package dependency
152
152
  else
153
- [:unassessable, { ecosystem: type, name: name, reason: :unsupported_ecosystem }]
153
+ [:unassessable, {ecosystem: type, name: name, reason: :unsupported_ecosystem}]
154
154
  end
155
155
  rescue ArgumentError
156
156
  # InvalidPackageURL is a subclass of ArgumentError, but PackageURL.parse also
@@ -158,7 +158,7 @@ module StillActive
158
158
  # or bad percent-encoding, both common in real Syft/Trivy output. Catch both so
159
159
  # one malformed component degrades to unassessable, never a backtrace that drops
160
160
  # every other dependency's verdict (SbomReader's documented "never raises").
161
- [:unassessable, { ecosystem: nil, name: name, reason: :malformed_purl }]
161
+ [:unassessable, {ecosystem: nil, name: name, reason: :malformed_purl}]
162
162
  end
163
163
 
164
164
  # Whether a repository_url points at a known public registry. We only read the
@@ -40,7 +40,7 @@ module StillActive
40
40
  runtime_ranges = {
41
41
  python: safe_support_window("Python") { PythonHelper.supported_python_range },
42
42
  dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
43
- dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range },
43
+ dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range }
44
44
  }
45
45
  barrier = Async::Barrier.new
46
46
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
@@ -63,15 +63,15 @@ module StillActive
63
63
  result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
64
64
  EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, runtime_ranges: runtime_ranges)
65
65
  .merge(dep.slice(:production))
66
- rescue StandardError => e
66
+ rescue => e
67
67
  # One dependency's failure must not abort the audit, but it must not
68
68
  # disappear either: record it as an unassessable entry (same shape as
69
69
  # the reader's, with a distinct reason) so it's counted and reported.
70
70
  # production rides along (when known, via slice) so a failed prod dep
71
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}" }
72
+ failures << {ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}"}
73
73
  .merge(dep.slice(:production))
74
- $stderr.puts("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
74
+ warn("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
75
75
  ensure
76
76
  completed += 1
77
77
  on_progress&.call(completed, total)
@@ -88,7 +88,7 @@ module StillActive
88
88
  # Stable, diffable order regardless of async completion order.
89
89
  Outcome.new(
90
90
  assessed: result.sort_by { |key, _| key }.to_h,
91
- failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" },
91
+ failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" }
92
92
  )
93
93
  end.wait
94
94
  end
@@ -99,8 +99,8 @@ module StillActive
99
99
  # ceiling checks for that runtime) with a warning, never aborting the audit.
100
100
  def safe_support_window(label)
101
101
  yield
102
- rescue StandardError => e
103
- $stderr.puts("warning: #{label} support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
102
+ rescue => e
103
+ warn("warning: #{label} support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
104
104
  nil
105
105
  end
106
106
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "cgi"
5
+ require "uri"
6
+
7
+ module StillActive
8
+ # Auth headers for a gem source, resolved the way Bundler itself does: from its
9
+ # per-host credential store (`bundle config` / the `BUNDLE_<HOST>` env var).
10
+ #
11
+ # The store is HOST-KEYED (`credentials_for` is `self[uri.to_s] || self[uri.host]`),
12
+ # which is the whole security property when a source URI comes from a lockfile: a
13
+ # host the user never configured resolves to no credential, so a malicious lockfile
14
+ # naming `attacker.example.com` cannot make still_active send another host's
15
+ # credential there. There is deliberately NO ambient/global fallback here; the only
16
+ # ambient token still_active holds (`--artifactory-token`) is guarded by an explicit
17
+ # host allowlist inside ArtifactoryClient, which is the sole caller that adds it.
18
+ module SourceCredentials
19
+ extend self
20
+
21
+ def headers_for(source_uri)
22
+ auth_header(Bundler.settings.credentials_for(URI(source_uri)))
23
+ end
24
+
25
+ # Turns a Bundler credential string into an Authorization header: `user:pass`
26
+ # becomes Basic (each half percent-decoded, since Bundler stores them encoded),
27
+ # a bare token becomes Bearer. Empty/nil yields no header.
28
+ def auth_header(credentials)
29
+ return {} if credentials.nil? || credentials.empty?
30
+
31
+ if credentials.include?(":")
32
+ user, pass = credentials.split(":", 2).map { |part| CGI.unescape(part) }
33
+ {"Authorization" => "Basic #{["#{user}:#{pass}"].pack("m0")}"}
34
+ else
35
+ {"Authorization" => "Bearer #{credentials}"}
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "3.0.0.rc4"
4
+ VERSION = "3.0.0.rc6"
5
5
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "artifactory_client"
4
+ require_relative "compact_index_client"
5
+ require_relative "source_credentials"
4
6
  require_relative "ceiling_reconciler"
5
7
  require_relative "deps_dev_client"
6
8
  require_relative "osv_client"
@@ -46,8 +48,8 @@ module StillActive
46
48
  ruby_range =
47
49
  begin
48
50
  RubyHelper.supported_ruby_range
49
- rescue StandardError => e
50
- $stderr.puts("warning: Ruby support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
51
+ rescue => e
52
+ warn("warning: Ruby support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
51
53
  nil
52
54
  end
53
55
  # Resolve the GitHub token once here, single-fibered, before the fan-out:
@@ -79,14 +81,14 @@ module StillActive
79
81
  advisory_db: advisory_db,
80
82
  catalog: catalog,
81
83
  constraint_cache: constraint_cache,
82
- ruby_range: ruby_range,
84
+ ruby_range: ruby_range
83
85
  )
84
86
  rescue Octokit::TooManyRequests
85
87
  $stderr.print("\r\e[K") if on_progress
86
- $stderr.puts("rate limited checking #{gem[:name]}: set GITHUB_TOKEN to increase your limit")
87
- rescue StandardError => e
88
+ warn("rate limited checking #{gem[:name]}: set GITHUB_TOKEN to increase your limit")
89
+ rescue => e
88
90
  $stderr.print("\r\e[K") if on_progress
89
- $stderr.puts("error occurred for #{gem[:name]}: #{e.class}\n\t#{e.message}")
91
+ warn("error occurred for #{gem[:name]}: #{e.class}\n\t#{e.message}")
90
92
  ensure
91
93
  completed += 1
92
94
  on_progress&.call(completed, total)
@@ -115,7 +117,7 @@ module StillActive
115
117
  private
116
118
 
117
119
  def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, direct: true, dependency_path: nil, advisory_db: nil, catalog: nil, constraint_cache: {}, ruby_range: nil)
118
- result_object[gem_name] = { source_type: source_type, direct: direct }
120
+ result_object[gem_name] = {source_type: source_type, direct: direct}
119
121
  result_object[gem_name][:dependency_path] = dependency_path if dependency_path
120
122
  result_object[gem_name][:version_used] = gem_version if gem_version
121
123
 
@@ -129,7 +131,7 @@ module StillActive
129
131
  result_object: result_object,
130
132
  source_uri: source_uri,
131
133
  advisory_db: advisory_db,
132
- ruby_range: ruby_range,
134
+ ruby_range: ruby_range
133
135
  )
134
136
  end
135
137
 
@@ -148,19 +150,19 @@ module StillActive
148
150
  signals = repo_signals(
149
151
  source: repo_info[:source],
150
152
  repository_owner: repo_info[:owner],
151
- repository_name: repo_info[:name],
153
+ repository_name: repo_info[:name]
152
154
  )
153
155
  commit_date = signals[:last_commit_date]
154
156
  archived = signals[:archived]
155
157
  last_release = VersionHelper.find_version(versions: vs, pre_release: false)
156
158
  last_pre_release = VersionHelper.upcoming_pre_release(
157
159
  pre_release: VersionHelper.find_version(versions: vs, pre_release: true),
158
- release: last_release,
160
+ release: last_release
159
161
  )
160
162
  deps_dev = fetch_deps_dev_info(
161
163
  gem_name: gem_name,
162
164
  version: gem_version || VersionHelper.gem_version(version_hash: last_release),
163
- advisory_db: advisory_db,
165
+ advisory_db: advisory_db
164
166
  )
165
167
  result_object[gem_name].merge!({
166
168
  latest_version: VersionHelper.gem_version(version_hash: last_release),
@@ -172,10 +174,14 @@ module StillActive
172
174
  repository_url: repo_info[:url],
173
175
  last_commit_date: commit_date,
174
176
  archived: archived,
175
- **deps_dev,
177
+ **deps_dev
176
178
  })
177
179
 
178
- unless vs.empty?
180
+ # Only a gem that actually lives on public rubygems.org gets a rubygems.org
181
+ # page link. For a private source, rubygems.org/gems/<name> is a public
182
+ # name collision (for sidekiq-pro, the 0.0.3 squat-warning decoy), not the
183
+ # gem the user resolves -- the same #43 substitution the repo-URL guard blocks.
184
+ unless vs.empty? || unqueryable_private_source?(source_uri)
179
185
  result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
180
186
  end
181
187
 
@@ -184,7 +190,7 @@ module StillActive
184
190
  source: repo_info[:source],
185
191
  repository_owner: repo_info[:owner],
186
192
  repository_name: repo_info[:name],
187
- version: VersionHelper.gem_version(version_hash: last_release),
193
+ version: VersionHelper.gem_version(version_hash: last_release)
188
194
  )
189
195
  end
190
196
 
@@ -194,7 +200,7 @@ module StillActive
194
200
  up_to_date: VersionHelper.up_to_date(
195
201
  version_used: version_used,
196
202
  latest_version: last_release,
197
- latest_pre_release_version: last_pre_release,
203
+ latest_pre_release_version: last_pre_release
198
204
  ),
199
205
 
200
206
  version_used_release_date: VersionHelper.release_date(version_hash: version_used),
@@ -202,8 +208,8 @@ module StillActive
202
208
  license: VersionHelper.license(version_hash: version_used),
203
209
  libyear: LibyearHelper.gem_libyear(
204
210
  version_used_release_date: VersionHelper.release_date(version_hash: version_used),
205
- latest_version_release_date: VersionHelper.release_date(version_hash: last_release),
206
- ),
211
+ latest_version_release_date: VersionHelper.release_date(version_hash: last_release)
212
+ )
207
213
  })
208
214
  end
209
215
 
@@ -218,7 +224,7 @@ module StillActive
218
224
  used_ruby_requirement: canonical_ruby_requirement(vs, version_used),
219
225
  latest_ruby_requirement: canonical_ruby_requirement(vs, last_release),
220
226
  pinned: !version_used.nil?,
221
- ruby_range: ruby_range,
227
+ ruby_range: ruby_range
222
228
  )
223
229
  end
224
230
  end
@@ -297,7 +303,7 @@ module StillActive
297
303
  repository_url: repo_info[:url],
298
304
  last_commit_date: signals[:last_commit_date],
299
305
  archived: signals[:archived],
300
- **deps_dev,
306
+ **deps_dev
301
307
  })
302
308
  end
303
309
 
@@ -311,7 +317,7 @@ module StillActive
311
317
 
312
318
  leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
313
319
  result_object[gem_name][:alternatives] = leads unless leads.empty?
314
- rescue StandardError
320
+ rescue
315
321
  nil # cosmetic best-effort: lead-fetching must never break the core audit
316
322
  end
317
323
 
@@ -373,18 +379,19 @@ module StillActive
373
379
  # drop the advisory and read a known-vulnerable gem as clean, so an
374
380
  # un-enriched key still contributes a minimal advisory (mirrors EcosystemLens;
375
381
  # ruby-advisory-db then fills the score on merge when it also carries it).
376
- deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || { id: id, source: "deps.dev" } }
382
+ deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || {id: id, source: "deps.dev"} }
377
383
  radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
378
384
  vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
379
385
  # Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
380
386
  # advisory) and the fixed-version ranges the "capped below the fix" signal needs.
381
- # Native path is rubygems.
382
- OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name)
387
+ # Native path is rubygems. The version also lets OSV confirm a deps.dev-only
388
+ # advisory applies to it; a ruby-advisory-db verdict is never overruled.
389
+ vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name, version: version)
383
390
  {
384
391
  scorecard_score: scorecard&.dig(:score),
385
392
  scorecard_maintained: scorecard&.dig(:maintained),
386
393
  vulnerability_count: vulnerabilities.length,
387
- vulnerabilities: vulnerabilities,
394
+ vulnerabilities: vulnerabilities
388
395
  }
389
396
  end
390
397
 
@@ -394,8 +401,7 @@ module StillActive
394
401
  elsif ArtifactoryClient.artifactory_uri?(source_uri)
395
402
  ArtifactoryClient.versions(gem_name: gem_name, source_uri: source_uri)
396
403
  elsif unqueryable_private_source?(source_uri)
397
- warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
398
- []
404
+ private_source_versions(gem_name: gem_name, source_uri: source_uri)
399
405
  else
400
406
  Gems.versions(gem_name)
401
407
  end
@@ -409,7 +415,7 @@ module StillActive
409
415
  # best-effort feature must not be able to degrade an unrelated gem's core data:
410
416
  # degrade to "no versions known" here, exactly like Gems::NotFound.
411
417
  rescue Gems::GemError, *HttpHelper::TRANSPORT_ERRORS => e
412
- $stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
418
+ warn("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
413
419
  []
414
420
  end
415
421
 
@@ -437,11 +443,32 @@ module StillActive
437
443
  false
438
444
  end
439
445
 
446
+ # Any Bundler-compatible private host (Contribsys, Gemstash, Gemfury, a private
447
+ # mirror) serves the RubyGems compact index, since that is how `bundle install`
448
+ # resolves from it. So try that agnostic rail before declaring the source
449
+ # unqueryable: it covers the whole class with no per-host client, and falls
450
+ # through to the same warning when a host does not serve it. Auth is Bundler's
451
+ # own host-keyed credential only; still_active's ambient --artifactory-token is
452
+ # never sent to a lockfile-derived host this way (that stays ArtifactoryClient's,
453
+ # behind its host allowlist). The repo-signal path is untouched, so #43 still
454
+ # blocks a public name-collision's repo data from standing in for the private gem.
455
+ def private_source_versions(gem_name:, source_uri:)
456
+ versions = CompactIndexClient.versions(
457
+ gem_name: gem_name,
458
+ source_uri: source_uri,
459
+ headers: SourceCredentials.headers_for(source_uri)
460
+ )
461
+ return versions unless versions.empty?
462
+
463
+ warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
464
+ []
465
+ end
466
+
440
467
  def warn_unqueryable_private_source(gem_name:, source_uri:)
441
468
  host = URI(source_uri).host
442
- $stderr.puts(
469
+ warn(
443
470
  "warning: #{gem_name} resolves from a private source (#{host}) still_active cannot query; " \
444
- "reporting no version/latest/libyear data for it rather than substituting public rubygems.org data",
471
+ "reporting no version/latest/libyear data for it rather than substituting public rubygems.org data"
445
472
  )
446
473
  end
447
474
 
@@ -450,7 +477,7 @@ module StillActive
450
477
  namespace_path = base.path
451
478
  path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
452
479
  token = StillActive.config.github_oauth_token
453
- headers = token ? { "Authorization" => "Bearer #{token}" } : {}
480
+ headers = token ? {"Authorization" => "Bearer #{token}"} : {}
454
481
  HttpHelper.get_json(base, path, headers: headers) || []
455
482
  end
456
483
 
@@ -471,7 +498,7 @@ module StillActive
471
498
  # deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
472
499
  # repo has no deps.dev project, so leave its project_id nil rather than minting
473
500
  # a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
474
- DEPS_DEV_HOST_BY_SOURCE = { github: "github.com", gitlab: "gitlab.com" }.freeze
501
+ DEPS_DEV_HOST_BY_SOURCE = {github: "github.com", gitlab: "gitlab.com"}.freeze
475
502
 
476
503
  def deps_dev_project_id(repo)
477
504
  host = DEPS_DEV_HOST_BY_SOURCE[repo[:source]]
@@ -505,7 +532,7 @@ module StillActive
505
532
 
506
533
  [
507
534
  info.metadata&.dig("source_code_uri"),
508
- info.homepage,
535
+ info.homepage
509
536
  ].compact.uniq
510
537
  end
511
538
 
@@ -521,7 +548,7 @@ module StillActive
521
548
 
522
549
  [
523
550
  info["homepage_uri"],
524
- info["source_code_uri"],
551
+ info["source_code_uri"]
525
552
  ].compact.uniq
526
553
  rescue Gems::NotFound
527
554
  []
data/still_active.gemspec CHANGED
@@ -3,14 +3,14 @@
3
3
  require_relative "lib/still_active/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "still_active"
7
- spec.version = StillActive::VERSION
8
- spec.authors = ["Sean Floyd"]
9
- spec.email = ["contact@seanfloyd.dev"]
6
+ spec.name = "still_active"
7
+ spec.version = StillActive::VERSION
8
+ spec.authors = ["Sean Floyd"]
9
+ spec.email = ["contact@seanfloyd.dev"]
10
10
 
11
- spec.summary = "Audit your dependencies for maintenance health, abandonment, and below-the-fix vulnerabilities. " \
11
+ spec.summary = "Audit your dependencies for maintenance health, abandonment, and below-the-fix vulnerabilities. " \
12
12
  "Ruby gems natively; npm, PyPI, Cargo, Go, Maven, and NuGet via a CycloneDX SBOM."
13
- spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
13
+ spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
14
14
  "whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
15
15
  "release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
16
16
  "(deps.dev and OSV, merged with ruby-advisory-db, flagging advisories with no fix and pins that sit below " \
@@ -24,9 +24,14 @@ Gem::Specification.new do |spec|
24
24
  "with granular, committed suppression via .still_active.yml. " \
25
25
  "Complements bundle outdated, bundler-audit, and libyear-bundler by adding the maintenance signal they " \
26
26
  "don't, and folds their version, CVE, and libyear checks into one report."
27
- spec.homepage = "https://github.com/SeanLF/still_active"
28
- spec.license = "MIT"
27
+ spec.homepage = "https://github.com/SeanLF/still_active"
28
+ spec.license = "MIT"
29
29
  spec.required_ruby_version = ">= 3.3.0"
30
+ # Gem::Resolver::APISet::GemParser, which parses the RubyGems compact index for
31
+ # the Artifactory client, first shipped in RubyGems 3.2.3. Ruby 3.3.0 bundles
32
+ # 3.5.3, so this is documentation of the real dependency rather than a bound
33
+ # anyone can trip.
34
+ spec.required_rubygems_version = ">= 3.2.3"
30
35
 
31
36
  spec.metadata["homepage_uri"] = spec.homepage
32
37
  spec.metadata["source_code_uri"] = spec.homepage
@@ -36,23 +41,14 @@ Gem::Specification.new do |spec|
36
41
  spec.metadata["rubygems_mfa_required"] = "true"
37
42
 
38
43
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
39
- %x(git ls-files -z).split("\x0").select do |f|
44
+ `git ls-files -z`.split("\x0").select do |f|
40
45
  f.start_with?("lib/", "bin/still_active") || f.match?(/\A(LICENSE|README|CHANGELOG|still_active\.gemspec)\b/)
41
46
  end
42
47
  end
43
- spec.bindir = "bin"
44
- spec.executables = spec.files.grep(%r{\Abin/still_active}) { |f| File.basename(f) }
48
+ spec.bindir = "bin"
49
+ spec.executables = spec.files.grep(%r{\Abin/still_active}) { |f| File.basename(f) }
45
50
  spec.require_paths = ["lib"]
46
51
 
47
- spec.add_development_dependency("bundler-audit")
48
- spec.add_development_dependency("debug")
49
- spec.add_development_dependency("faker")
50
- spec.add_development_dependency("json_schemer")
51
- spec.add_development_dependency("rubocop")
52
- spec.add_development_dependency("rubocop-performance")
53
- spec.add_development_dependency("rubocop-rspec")
54
- spec.add_development_dependency("rubocop-shopify")
55
-
56
52
  # 2.0/2.1 ship a scheduler that breaks our fan-out (io_read); 2.2 is the
57
53
  # verified floor (checked against Ruby 3.3 in Docker). octokit/faraday-retry/
58
54
  # gems work down to ancient versions, so they stay unpinned rather than