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
@@ -6,15 +6,19 @@ require_relative "diff"
6
6
  require_relative "../helpers/activity_helper"
7
7
  require_relative "../helpers/bot_context"
8
8
  require_relative "../helpers/bundler_helper"
9
+ require_relative "../helpers/constraint_helper"
9
10
  require_relative "../helpers/cyclonedx_helper"
10
11
  require_relative "../helpers/diff_markdown_helper"
11
12
  require_relative "../helpers/emoji_helper"
12
13
  require_relative "../helpers/markdown_helper"
13
14
  require_relative "../helpers/sarif_helper"
15
+ require_relative "../helpers/status_helper"
14
16
  require_relative "../helpers/summary_helper"
15
17
  require_relative "../helpers/terminal_helper"
16
18
  require_relative "../helpers/version_helper"
17
19
  require_relative "../helpers/vulnerability_helper"
20
+ require_relative "sbom_reader"
21
+ require_relative "sbom_workflow"
18
22
  require_relative "workflow"
19
23
 
20
24
  module StillActive
@@ -28,11 +32,24 @@ module StillActive
28
32
  # win over it: CLI flag > env var > config file > default.
29
33
  config_data = ConfigFile.load
30
34
  ConfigFile.apply(StillActive.config, config_data).each { |warning| $stderr.puts("warning: #{warning}") }
31
- options = Options.new.parse!(args)
35
+ options = begin
36
+ Options.new.parse!(args)
37
+ rescue ArgumentError, OptionParser::ParseError => e
38
+ # Bad CLI input (conflicting flags, a missing file, an unknown flag, a bad
39
+ # enum value) is a user error: print it and exit 2, matching the SBOM/baseline
40
+ # paths, rather than letting the raise bubble into a Ruby backtrace.
41
+ $stderr.puts("error: #{e.message}")
42
+ exit(2)
43
+ end
32
44
  # After CLI flags resolve, nudge (don't auto-inherit) an un-imported
33
45
  # bundler-audit ignore list when the vulnerability gate is on.
34
46
  hint = ConfigFile.import_hint(config_data)
35
47
  $stderr.puts("hint: #{hint}") if hint
48
+ # An SBOM audit is cross-ecosystem: it runs the deps.dev/ecosyste.ms lens
49
+ # over the SBOM's packages, not Bundler over a lockfile. Dispatch before the
50
+ # Bundler resolution below so a Gemfile is never required (or read).
51
+ return run_sbom if options[:provided_sbom]
52
+
36
53
  unless options[:provided_gems]
37
54
  begin
38
55
  StillActive.config.gems = BundlerHelper.gemfile_dependencies
@@ -74,7 +91,12 @@ module StillActive
74
91
  summary: SummaryHelper.summarize(result, ruby_info: ruby_info),
75
92
  # Surface the derived verdict so a machine/LLM consumer reads it
76
93
  # directly instead of re-deriving it from the raw dates.
77
- gems: result.transform_values { |data| data.merge(activity_level: ActivityHelper.activity_level(data)) },
94
+ gems: result.transform_values do |data|
95
+ data.merge(
96
+ activity_level: ActivityHelper.activity_level(data),
97
+ status: StatusHelper.gem_status(data),
98
+ )
99
+ end,
78
100
  }
79
101
  output[:ruby] = ruby_info if ruby_info
80
102
  output[:pr_context] = pr_context if pr_context
@@ -92,6 +114,103 @@ module StillActive
92
114
 
93
115
  private
94
116
 
117
+ # The --sbom path: assess a CycloneDX SBOM's packages cross-ecosystem via
118
+ # EcosystemLens, then emit a JSON report shaped like the native audit's but
119
+ # keyed "ecosystem/name@version" and carrying an `unassessable` list of every
120
+ # dep we couldn't assess (reader-level: unsupported ecosystem, no version/PURL;
121
+ # or assessment-level: the lens call raised) rather than silently dropping it.
122
+ def run_sbom
123
+ path = StillActive.config.sbom_path
124
+ require_parseable_sbom(path)
125
+ # deps.dev is the SBOM path's sole vulnerability source and an alpha API; a
126
+ # field rename would silently zero every vuln count. Canary the schema once
127
+ # and warn loudly so a degraded run never reads as an authoritative all-clear.
128
+ unless DepsDevClient.advisory_schema_ok?
129
+ $stderr.puts("warning: deps.dev vulnerability schema check failed (its `advisoryKeys` field may have changed, or the API is unreachable); vulnerability counts may be understated -- a clean result is NOT authoritative")
130
+ end
131
+ sbom = SbomReader.parse(path)
132
+ outcome = if $stderr.tty?
133
+ SbomWorkflow.call(sbom) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
134
+ else
135
+ SbomWorkflow.call(sbom)
136
+ end
137
+ $stderr.print("\r\e[K") if $stderr.tty?
138
+
139
+ # A reader-level gap (unsupported ecosystem, no version/PURL) and an
140
+ # assessment-time failure (a raised lens call) both mean "not assessed":
141
+ # surface them together so neither is a silent hole in the reported coverage.
142
+ unassessable = sbom.unassessable + outcome.failures
143
+ emit_sbom_json(outcome.assessed, unassessable)
144
+ warn_unassessable(unassessable)
145
+ check_exit_status(outcome.assessed)
146
+ end
147
+
148
+ # SbomReader never raises (a malformed file degrades to an empty result), which
149
+ # is right for the library entrypoint but wrong for the explicit --sbom flag:
150
+ # the user pointed at a file to audit, so a truncated/wrong-format one must
151
+ # error, not flow through to an empty "all clear" report with exit 0.
152
+ def require_parseable_sbom(path)
153
+ doc = JSON.parse(File.read(path))
154
+ return if doc.is_a?(Hash) && doc["components"].is_a?(Array)
155
+
156
+ # Matches SbomReader's own parse contract (a `components` array is what it
157
+ # reads). A metadata-only CycloneDX has nothing to audit, so the message
158
+ # names the missing array rather than over-claiming "not CycloneDX".
159
+ $stderr.puts("error: #{path} has no CycloneDX `components` array to audit")
160
+ exit(2)
161
+ rescue JSON::ParserError
162
+ $stderr.puts("error: #{path} is not valid JSON")
163
+ exit(2)
164
+ rescue SystemCallError => e
165
+ # Options checked the path exists, not that it's readable (a permission
166
+ # denial, or a directory). Exit cleanly instead of crashing with a trace.
167
+ $stderr.puts("error: cannot read SBOM file #{path}: #{e.message}")
168
+ exit(2)
169
+ end
170
+
171
+ # SBOM output deliberately omits the Ruby audit's `$schema`: the shape differs
172
+ # (composite keys, an unassessable list, no Ruby/PR-context blocks), so it
173
+ # would be a false claim to point at that contract. schema_version stays 1.
174
+ def emit_sbom_json(result, unassessable)
175
+ output = {
176
+ schema_version: 1,
177
+ tool: { name: "still_active", version: StillActive::VERSION },
178
+ generated_at: Time.now.utc.iso8601,
179
+ summary: sbom_summary(result, unassessable),
180
+ dependencies: result.transform_values do |data|
181
+ data.merge(
182
+ activity_level: ActivityHelper.activity_level(data),
183
+ status: StatusHelper.gem_status(data),
184
+ )
185
+ end,
186
+ unassessable:,
187
+ }
188
+ puts iso8601_times(output).to_json
189
+ end
190
+
191
+ def sbom_summary(result, unassessable)
192
+ statuses = result.each_value.map { |data| StatusHelper.gem_status(data) }
193
+ {
194
+ total_assessed: result.size,
195
+ unassessable_count: unassessable.size,
196
+ # The single worst per-dependency verdict, so a consumer reads one
197
+ # project-level posture without scanning every dependency.
198
+ status: StatusHelper.project_status(result),
199
+ status_counts: statuses.tally,
200
+ }
201
+ end
202
+
203
+ # Unassessable deps are already in the JSON; echo a one-line count to stderr so
204
+ # a human running interactively sees the coverage gap without parsing stdout.
205
+ def warn_unassessable(unassessable)
206
+ return if unassessable.empty?
207
+
208
+ noun = unassessable.size == 1 ? "dependency" : "dependencies"
209
+ $stderr.puts("warning: #{unassessable.size} #{noun} could not be assessed " \
210
+ "(a private/alternative registry, unsupported ecosystem, missing version, no package URL, or a lookup failure); " \
211
+ "see \"unassessable\" in the JSON output")
212
+ end
213
+
95
214
  # Dates live in the result as real Time objects (the activity/libyear math
96
215
  # needs them), but the JSON contract is ISO8601 UTC strings, matching
97
216
  # generated_at. Normalize at the serialization boundary only, so the
@@ -185,8 +304,9 @@ module StillActive
185
304
  current = current_snapshot(result, ruby_info)
186
305
  baseline = JSON.parse(File.read(baseline_path))
187
306
  diff = Diff.call(baseline: baseline, current: current)
307
+ accepted = partition_accepted_regressions!(diff)
188
308
  puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
189
- puts DiffMarkdownHelper.render(diff)
309
+ puts DiffMarkdownHelper.render(diff, accepted: accepted)
190
310
  exit(1) if diff.regressions.any?
191
311
  rescue JSON::ParserError => e
192
312
  $stderr.puts("error: --baseline file is not valid JSON: #{e.message}")
@@ -199,6 +319,27 @@ module StillActive
199
319
  exit(2)
200
320
  end
201
321
 
322
+ # Drops regressions a committed .still_active.yml accepts from the CI-failable
323
+ # set (mutating diff.regressions in place) and returns them as accepted
324
+ # entries so the render can show them as knowingly-suppressed rather than
325
+ # silently vanishing. Mirrors the audit/SARIF gates, which already honour the
326
+ # same suppression list; the diff was the only gate that ignored it.
327
+ def partition_accepted_regressions!(diff)
328
+ suppressions = StillActive.config.suppressions
329
+ accepted = []
330
+ diff.regressions = diff.regressions.reject do |reg|
331
+ signal = Diff.suppressible_signal(reg.kind)
332
+ next false unless signal
333
+
334
+ entry = suppressions.match(gem: reg.gem, signal: signal)
335
+ next false unless entry
336
+
337
+ accepted << Diff::Accepted.new(regression: reg, reason: entry.reason)
338
+ true
339
+ end
340
+ accepted
341
+ end
342
+
202
343
  def current_snapshot(result, ruby_info)
203
344
  snapshot = {
204
345
  "schema_version" => 1,
@@ -234,6 +375,10 @@ module StillActive
234
375
 
235
376
  puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
236
377
  end
378
+ poison = MarkdownHelper.poison_section(result)
379
+ puts poison unless poison.empty?
380
+ language_ceiling = MarkdownHelper.language_ceiling_section(result)
381
+ puts language_ceiling unless language_ceiling.empty?
237
382
  alternatives = MarkdownHelper.alternatives_section(result)
238
383
  puts alternatives unless alternatives.empty?
239
384
  transitive = MarkdownHelper.transitive_section(result)
@@ -246,11 +391,35 @@ module StillActive
246
391
 
247
392
  def check_exit_status(result)
248
393
  config = StillActive.config
249
- return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
394
+ return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling
250
395
 
396
+ warn_unknown_severity_gate(result, config)
251
397
  exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
252
398
  end
253
399
 
400
+ # --fail-if-vulnerable=<threshold> fails closed on an advisory with no CVSS
401
+ # score (it could exceed the threshold; fresh CVEs often lack a score). Say so
402
+ # per gem, so failing a =high gate on an "unknown" severity reads as a
403
+ # deliberate conservative call the user can review and fix or suppress, not a
404
+ # mystery. Only for the thresholded form; bare --fail-if-vulnerable fails on
405
+ # every advisory regardless of severity, so there's nothing to explain.
406
+ def warn_unknown_severity_gate(result, config)
407
+ threshold = config.fail_if_vulnerable
408
+ return unless threshold.is_a?(String)
409
+
410
+ suppressions = config.suppressions
411
+ result.each do |name, data|
412
+ next if config.ignored_gems.include?(name)
413
+
414
+ unknown = live_advisories(name, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
415
+ next if unknown.empty?
416
+
417
+ ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
418
+ labelled = ids.empty? ? "" : " (#{ids})"
419
+ $stderr.puts("warning: #{name} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
420
+ end
421
+ end
422
+
254
423
  # A gem fails the run when it trips an enabled gate that is neither
255
424
  # whole-gem --ignore'd nor covered by a granular .still_active.yml
256
425
  # suppression. Each signal is checked independently so accepting one finding
@@ -261,7 +430,32 @@ module StillActive
261
430
  suppressions = config.suppressions
262
431
  failed_activity?(name, data, config, suppressions) ||
263
432
  failed_vulnerability?(name, data, config, suppressions) ||
264
- failed_outdated?(name, data, config, suppressions)
433
+ failed_outdated?(name, data, config, suppressions) ||
434
+ failed_poison?(name, data, config, suppressions) ||
435
+ failed_language_ceiling?(name, data, config, suppressions)
436
+ end
437
+
438
+ def failed_poison?(name, data, config, suppressions)
439
+ return false unless config.fail_if_poison
440
+ return false if suppressions.suppressed?(gem: name, signal: :poison)
441
+
442
+ # Bare --fail-if-poison fails at :warning and above (a 1-behind :note is FYI,
443
+ # not a build breaker); --fail-if-poison=TIER sets an explicit threshold.
444
+ threshold = config.fail_if_poison == true ? :warning : config.fail_if_poison
445
+ ConstraintHelper.severity_at_or_above?(data[:poison_severity], threshold)
446
+ end
447
+
448
+ def failed_language_ceiling?(name, data, config, suppressions)
449
+ return false unless config.fail_if_language_ceiling
450
+ return false if suppressions.suppressed?(gem: name, signal: :language_ceiling)
451
+
452
+ # Ceiling findings are only ever :critical (EOL-forced) or :note
453
+ # (latest-not-yet) -- there is no :warning tier for this signal -- so the
454
+ # bare flag defaults to :critical (the genuine blocker: no supported runtime
455
+ # is reachable). A latest-not-yet :note is an FYI/contribution lead that gates
456
+ # only when the user explicitly opts in with =note.
457
+ threshold = config.fail_if_language_ceiling == true ? :critical : config.fail_if_language_ceiling
458
+ ConstraintHelper.severity_at_or_above?(data.dig(:language_ceiling, :severity), threshold)
265
459
  end
266
460
 
267
461
  def failed_activity?(name, data, config, suppressions)
@@ -276,16 +470,25 @@ module StillActive
276
470
  def failed_vulnerability?(name, data, config, suppressions)
277
471
  setting = config.fail_if_vulnerable
278
472
  return false unless setting
279
- return false unless data[:vulnerability_count]&.positive?
280
473
 
281
- live = Array(data[:vulnerabilities]).reject do |vuln|
282
- suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
283
- end
474
+ # Reason over the live advisory array (not vulnerability_count) so the gate
475
+ # and warn_unknown_severity_gate share one source of truth: a warning that
476
+ # says "failing" can never disagree with whether exit(1) actually fires.
477
+ live = live_advisories(name, data, suppressions)
284
478
  return false if live.empty?
285
479
 
286
480
  setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
287
481
  end
288
482
 
483
+ # The gem's advisories minus those an explicit .still_active.yml suppression
484
+ # accepts (by advisory id or alias). Shared by the vulnerability gate and the
485
+ # unknown-severity warning so both reason over the same live set.
486
+ def live_advisories(name, data, suppressions)
487
+ Array(data[:vulnerabilities]).reject do |vuln|
488
+ suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
489
+ end
490
+ end
491
+
289
492
  def failed_outdated?(name, data, config, suppressions)
290
493
  threshold = config.fail_if_outdated
291
494
  return false unless threshold
@@ -7,7 +7,8 @@ require_relative "suppressions"
7
7
 
8
8
  module StillActive
9
9
  class Config
10
- attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :gemfile_path
10
+ attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :gemfile_path, :ecosystems_email
11
+ attr_accessor :sbom_path
11
12
  attr_accessor :alternatives,
12
13
  :unreleased_commits,
13
14
  :direct_only,
@@ -16,6 +17,8 @@ module StillActive
16
17
  :cyclonedx_path,
17
18
  :cyclonedx_version,
18
19
  :fail_if_critical,
20
+ :fail_if_poison,
21
+ :fail_if_language_ceiling,
19
22
  :fail_if_warning,
20
23
  :futurist_emoji,
21
24
  :gems,
@@ -37,6 +40,8 @@ module StillActive
37
40
  @unreleased_commits = false
38
41
  @direct_only = false
39
42
  @fail_if_critical = false
43
+ @fail_if_poison = false
44
+ @fail_if_language_ceiling = false
40
45
  @fail_if_outdated = nil
41
46
  @fail_if_vulnerable = nil
42
47
  @fail_if_warning = false
@@ -49,6 +54,7 @@ module StillActive
49
54
  @forgejo_token = nil
50
55
  @artifactory_token = nil
51
56
  @artifactory_host = nil
57
+ @ecosystems_email = nil
52
58
 
53
59
  @parallelism = 10
54
60
 
@@ -78,7 +84,21 @@ module StillActive
78
84
  end
79
85
 
80
86
  def github_oauth_token
87
+ # Cache the resolution including a nil result: the token can't change
88
+ # mid-run, and provider selection now reads this per gem, so a plain ||=
89
+ # (which doesn't memoize nil) would re-shell `gh auth token` for every gem
90
+ # in a no-token run.
91
+ return @github_oauth_token if @github_oauth_token_resolved
92
+
93
+ # Assign before flipping the flag: gh_cli_token shells out and yields the
94
+ # async reactor, so a concurrent fiber must never see resolved=true while
95
+ # the value is still nil (it would wrongly route a tokened gem to the
96
+ # ecosyste.ms fallback). Worst case under contention is a few extra `gh`
97
+ # calls, never a premature nil; Workflow.call also resolves this once before
98
+ # the fan-out so the contended path isn't normally hit.
81
99
  @github_oauth_token ||= presence(ENV["GITHUB_TOKEN"]) || presence(ENV["GH_TOKEN"]) || gh_cli_token
100
+ @github_oauth_token_resolved = true
101
+ @github_oauth_token
82
102
  end
83
103
 
84
104
  def gitlab_token
@@ -89,6 +109,13 @@ module StillActive
89
109
  # gh/glab), so it's env-var only. Anonymous works for public repos; a token
90
110
  # only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
91
111
  # alias for the codeberg.org default host.
112
+ # Optional contact email for ecosyste.ms's "polite pool" (sent as a mailto
113
+ # query param), which raises the anonymous rate limit and lets them reach
114
+ # out. Nil by default -- anonymous is plenty for a typical lockfile.
115
+ def ecosystems_email
116
+ @ecosystems_email ||= presence(ENV["STILL_ACTIVE_ECOSYSTEMS_EMAIL"]&.strip)
117
+ end
118
+
92
119
  def forgejo_token
93
120
  @forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
94
121
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require_relative "suppressions"
5
+ require_relative "../helpers/constraint_helper"
5
6
  require_relative "../helpers/vulnerability_helper"
6
7
 
7
8
  module StillActive
@@ -48,6 +49,8 @@ module StillActive
48
49
  case key
49
50
  when "fail_if_critical" then set_boolean(config, :fail_if_critical=, value, key, warnings)
50
51
  when "fail_if_warning" then set_boolean(config, :fail_if_warning=, value, key, warnings)
52
+ when "fail_if_poison" then apply_fail_if_poison(config, value, warnings)
53
+ when "fail_if_language_ceiling" then apply_fail_if_language_ceiling(config, value, warnings)
51
54
  when "alternatives" then set_boolean(config, :alternatives=, value, key, warnings)
52
55
  when "unreleased_commits" then set_boolean(config, :unreleased_commits=, value, key, warnings)
53
56
  when "direct_only" then set_boolean(config, :direct_only=, value, key, warnings)
@@ -134,6 +137,30 @@ module StillActive
134
137
  end
135
138
  end
136
139
 
140
+ # true/false, or a severity tier (note|warning|critical). Mirrors the CLI
141
+ # flag: bare `true` fails at :warning, a tier sets an explicit threshold.
142
+ def apply_fail_if_poison(config, value, warnings)
143
+ if value == true || value == false
144
+ config.fail_if_poison = value
145
+ elsif ConstraintHelper::SEVERITY.map(&:to_s).include?(value.to_s)
146
+ config.fail_if_poison = value.to_sym
147
+ else
148
+ warnings << "#{FILENAME}: fail_if_poison must be true/false or one of #{ConstraintHelper::SEVERITY.join(", ")} (got #{value.inspect}), ignoring it"
149
+ end
150
+ end
151
+
152
+ # true/false, or a severity tier (note|warning|critical). Mirrors the CLI
153
+ # flag: bare `true` fails at :warning, a tier sets an explicit threshold.
154
+ def apply_fail_if_language_ceiling(config, value, warnings)
155
+ if value == true || value == false
156
+ config.fail_if_language_ceiling = value
157
+ elsif ConstraintHelper::SEVERITY.map(&:to_s).include?(value.to_s)
158
+ config.fail_if_language_ceiling = value.to_sym
159
+ else
160
+ warnings << "#{FILENAME}: fail_if_language_ceiling must be true/false or one of #{ConstraintHelper::SEVERITY.join(", ")} (got #{value.inspect}), ignoring it"
161
+ end
162
+ end
163
+
137
164
  def apply_fail_if_vulnerable(config, value, warnings)
138
165
  if value == true || value == false
139
166
  config.fail_if_vulnerable = value || nil
@@ -8,19 +8,73 @@ module StillActive
8
8
 
9
9
  BASE_URI = URI("https://api.deps.dev/")
10
10
 
11
- def version_info(gem_name:, version:)
11
+ # A known-vulnerable package used to canary the `advisoryKeys` schema. deps.dev
12
+ # is an explicitly ALPHA API (v3alpha), and every cross-ecosystem vulnerability
13
+ # count flows through the `advisoryKeys` field with a field-level degrade to
14
+ # `[]` -- so a rename or drop of that field would silently turn every count to
15
+ # 0 and read a known-vulnerable package as clean (exit 0). django 3.0.0 carries
16
+ # 30+ permanent advisories; if the canary returns none, the schema drifted.
17
+ ADVISORY_CANARY = { system: "pypi", name: "django", version: "3.0.0" }.freeze
18
+
19
+ # Is deps.dev still returning advisories in the shape we parse? False when the
20
+ # canary comes back empty (schema drift) or unreachable (can't confirm). The
21
+ # caller warns loudly rather than presenting a possibly-understated "all clear".
22
+ def advisory_schema_ok?
23
+ info = version_info(gem_name: ADVISORY_CANARY[:name], version: ADVISORY_CANARY[:version], system: ADVISORY_CANARY[:system])
24
+ !(info.nil? || info[:advisory_keys].empty?)
25
+ end
26
+
27
+ # `system` is the deps.dev package system, lowercased: rubygems, npm, pypi,
28
+ # cargo, go, maven, nuget. It matches the ecosystem symbol SbomReader emits,
29
+ # so a cross-ecosystem caller threads it straight through. An unknown system
30
+ # 404s and degrades to nil (HttpHelper swallows 404), never raising.
31
+ def version_info(gem_name:, version:, system: :rubygems)
12
32
  return if gem_name.nil? || version.nil?
13
33
 
14
- path = "/v3alpha/systems/rubygems/packages/#{encode(gem_name)}/versions/#{encode(version)}"
34
+ path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(gem_name)}/versions/#{encode(version)}"
15
35
  body = HttpHelper.get_json(BASE_URI, path)
16
36
  return if body.nil?
17
37
 
18
38
  {
19
39
  advisory_keys: body.dig("advisoryKeys")&.map { |a| a["id"] } || [],
20
40
  project_id: extract_project_id(body),
41
+ # The locked version's release date -- the cross-ecosystem libyear input
42
+ # (paired with the package's latest-release date). Already in this response,
43
+ # so no extra fetch; nil when the feed omits it.
44
+ published_at: body["publishedAt"],
21
45
  }
22
46
  end
23
47
 
48
+ # The package's default version and its release date: { version:,
49
+ # published_at: }, or nil. deps.dev's default version is the latest stable;
50
+ # when none is flagged (all pre-release), the newest publishedAt stands in so
51
+ # a still-active package isn't mis-read as dormant. The version is exposed (not
52
+ # just the date) so a caller can recover the package's repo link from it when
53
+ # an exact locked version isn't indexed (yanked/normalization mismatch).
54
+ def default_version_info(name:, system: :rubygems)
55
+ return if name.nil?
56
+
57
+ path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(name)}"
58
+ body = HttpHelper.get_json(BASE_URI, path)
59
+ return if body.nil?
60
+
61
+ versions = body["versions"]
62
+ return unless versions.is_a?(Array) && !versions.empty?
63
+
64
+ entry = versions.find { |v| v.is_a?(Hash) && v["isDefault"] } || newest_version(versions)
65
+ return if entry.nil?
66
+
67
+ { version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"] }
68
+ end
69
+
70
+ # The package's most recent release date (ISO8601 string), or nil. This is
71
+ # the cross-ecosystem freshness signal, and deps.dev's publishedAt is more
72
+ # reliable than ecosyste.ms's latest_release_published_at, which can lag badly
73
+ # (mpmath: 2023 vs 2026).
74
+ def latest_release_date(name:, system: :rubygems)
75
+ default_version_info(name: name, system: system)&.dig(:published_at)
76
+ end
77
+
24
78
  def project_scorecard(project_id:)
25
79
  return if project_id.nil?
26
80
 
@@ -34,6 +88,11 @@ module StillActive
34
88
  {
35
89
  score: scorecard["overallScore"],
36
90
  date: scorecard["date"],
91
+ # The "Maintained" sub-check (0-10) scores recent commit and issue
92
+ # activity directly -- still_active's core question -- so we surface it
93
+ # alongside the aggregate. nil when the check is absent (never 0, which
94
+ # would read as "unmaintained" rather than "not measured").
95
+ maintained: maintained_check_score(scorecard),
37
96
  }
38
97
  end
39
98
 
@@ -48,7 +107,9 @@ module StillActive
48
107
  id: body.dig("advisoryKey", "id"),
49
108
  url: body["url"],
50
109
  title: body["title"],
51
- aliases: body["aliases"]&.filter_map { |a| a["id"] } || [],
110
+ # deps.dev's v3alpha returns aliases as bare id strings (["CVE-..."]);
111
+ # tolerate the legacy object shape ({"id":...}) too since it's an alpha API.
112
+ aliases: Array(body["aliases"]).filter_map { |a| normalize_alias(a) },
52
113
  cvss3_score: body["cvss3Score"],
53
114
  cvss3_vector: body["cvss3Vector"],
54
115
  cvss2_score: body["cvss2Score"],
@@ -58,6 +119,35 @@ module StillActive
58
119
 
59
120
  private
60
121
 
122
+ # Coerce an advisory alias to a string id. A BARE non-string alias is deps.dev
123
+ # ALPHA-API schema drift: coerce it (a non-string alias makes the advisory-merge
124
+ # sort raise, which strips a gem of ALL its signals and reads a vulnerable gem
125
+ # clean) but warn once so the drift surfaces rather than passing invisibly, the
126
+ # same "loud on schema drift" stance as the advisory-count canary.
127
+ def normalize_alias(raw)
128
+ return raw["id"]&.to_s if raw.is_a?(Hash)
129
+ return raw if raw.is_a?(String) || raw.nil?
130
+
131
+ warn_alias_drift(raw)
132
+ raw.to_s
133
+ end
134
+
135
+ # Warn once per run (not per alias), matching CvssHelper's warn-once stance.
136
+ def warn_alias_drift(raw)
137
+ return if @alias_drift_warned
138
+
139
+ @alias_drift_warned = true
140
+ $stderr.puts("warning: deps.dev returned a non-string advisory alias (#{raw.class}); the ALPHA API schema may have drifted")
141
+ end
142
+
143
+ # The OpenSSF "Maintained" check score (0-10), or nil when it's absent. A
144
+ # malformed (non-array) `checks` payload also yields nil rather than raising:
145
+ # this is a best-effort enrichment and must never crash the per-gem audit and
146
+ # vanish the gem from the output via the workflow's rescue.
147
+ def maintained_check_score(scorecard)
148
+ Array(scorecard["checks"]).find { |c| c.is_a?(Hash) && c["name"] == "Maintained" }&.dig("score")
149
+ end
150
+
61
151
  # Builds a deps.dev project id ("host/owner/repo", or a deeper path for
62
152
  # GitLab subgroups) from the SOURCE_REPO link URL. GitHub/Bitbucket projects
63
153
  # are always host/owner/repo, but GitLab namespaces nest arbitrarily
@@ -66,7 +156,17 @@ module StillActive
66
156
  url = body.dig("links")&.find { |l| l["label"] == "SOURCE_REPO" }&.dig("url")
67
157
  return if url.nil?
68
158
 
69
- host, *segments = url.sub(%r{\Ahttps?://}, "").split("/")
159
+ # deps.dev returns SOURCE_REPO in many shapes: `https://`, but also `git://`,
160
+ # `git+https://`, `git+ssh://git@...`, `ssh://git@...`. Strip the `git+` prefix,
161
+ # any `scheme://`, and ssh userinfo (`git@`) so what remains is host/owner/repo.
162
+ # Left unnormalized, a git-scheme URL mis-parses (host becomes `git:`), which
163
+ # 400s the projects lookup and drops the repo signals.
164
+ cleaned = url.strip.delete_prefix("git+").sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").sub(%r{\A[^/@]+@}, "")
165
+ # scp-style git remotes separate host from path with a colon (`host:owner/repo`)
166
+ # rather than a slash. Convert it so the split yields host/owner/repo, but leave
167
+ # a real port (`host:443/...`) alone -- a colon before a digit isn't a path.
168
+ cleaned = cleaned.sub(%r{\A([^/:]+):(?=\D)}, '\1/')
169
+ host, *segments = cleaned.split("/")
70
170
  segments = repo_path_segments(host, segments)
71
171
  return if host.nil? || segments.empty?
72
172
 
@@ -90,8 +190,18 @@ module StillActive
90
190
  segments
91
191
  end
92
192
 
193
+ # The version with the newest publishedAt, used only when no version is
194
+ # flagged default. Versions without a publishedAt are dropped; the rest
195
+ # compare lexicographically (deps.dev returns RFC3339 UTC, so string order
196
+ # is chronological), and a dated release always wins over an undated one.
197
+ def newest_version(versions)
198
+ versions
199
+ .select { |v| v.is_a?(Hash) && v["publishedAt"] }
200
+ .max_by { |v| v["publishedAt"].to_s }
201
+ end
202
+
93
203
  def encode(value)
94
- URI.encode_www_form_component(value)
204
+ URI.encode_www_form_component(value.to_s)
95
205
  end
96
206
  end
97
207
  end
@@ -15,6 +15,19 @@ module StillActive
15
15
  NEW_GEM_LIBYEAR_THRESHOLD = 0.5 # added gems already this far behind regress
16
16
  LIBYEAR_DELTA_THRESHOLD = 0.01 # floating-point fuzz
17
17
 
18
+ # Maps a maintenance regression kind to the .still_active.yml signal that
19
+ # accepts it, so a committed suppression mutes the diff exactly as it already
20
+ # mutes the audit and SARIF gates. Only kinds a bare gem+signal entry can
21
+ # cover appear here: vulnerability regressions require an explicit advisory id
22
+ # (which the diff regression doesn't carry) and stay CI-failable, and
23
+ # scorecard/yanked/ruby-EOL are not gateable signals.
24
+ SUPPRESSIBLE_REGRESSION_SIGNALS = {
25
+ new_gem_archived: :activity,
26
+ archived: :activity,
27
+ new_gem_stale: :libyear,
28
+ libyear_worsened: :libyear,
29
+ }.freeze
30
+
18
31
  class UnsupportedSchemaError < StandardError; end
19
32
 
20
33
  Added = Struct.new(:name, :data, keyword_init: true)
@@ -22,10 +35,19 @@ module StillActive
22
35
  Bumped = Struct.new(:name, :before_version, :after_version, :kind, :before, :after, keyword_init: true)
23
36
  SignalChange = Struct.new(:name, :changes, :before, :after, keyword_init: true)
24
37
  Regression = Struct.new(:kind, :gem, :detail, keyword_init: true)
38
+ # A regression a committed .still_active.yml knowingly accepts: carried out of
39
+ # the CI-failable set so the diff render can show it as suppressed-with-reason.
40
+ Accepted = Struct.new(:regression, :reason, keyword_init: true)
25
41
  Result = Struct.new(:added, :removed, :bumped, :signal_changes, :regressions, :ruby, keyword_init: true)
26
42
 
27
43
  extend self
28
44
 
45
+ # The suppression signal that accepts this regression kind, or nil when the
46
+ # kind is not suppressible by a bare gem+signal .still_active.yml entry.
47
+ def suppressible_signal(kind)
48
+ SUPPRESSIBLE_REGRESSION_SIGNALS[kind]
49
+ end
50
+
29
51
  def call(baseline:, current:)
30
52
  validate_schema!(baseline, "baseline")
31
53
  validate_schema!(current, "current")