still_active 3.0.0 → 3.2.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +3 -1
  4. data/lib/still_active/artifactory_client.rb +2 -20
  5. data/lib/still_active/assessment.rb +85 -0
  6. data/lib/still_active/cli.rb +84 -23
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/deps_dev_client.rb +161 -15
  9. data/lib/still_active/diff.rb +38 -0
  10. data/lib/still_active/ecosystem_lens.rb +80 -44
  11. data/lib/still_active/ecosystems_client.rb +10 -7
  12. data/lib/still_active/errors.rb +10 -0
  13. data/lib/still_active/forgejo_client.rb +5 -1
  14. data/lib/still_active/github_client.rb +28 -5
  15. data/lib/still_active/gitlab_client.rb +5 -1
  16. data/lib/still_active/go_toolchain.rb +75 -0
  17. data/lib/still_active/helpers/alternatives_helper.rb +2 -2
  18. data/lib/still_active/helpers/cvss_helper.rb +14 -48
  19. data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
  20. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  21. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  22. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  23. data/lib/still_active/helpers/http_cache.rb +109 -0
  24. data/lib/still_active/helpers/http_helper.rb +148 -28
  25. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  26. data/lib/still_active/helpers/repository_check.rb +33 -0
  27. data/lib/still_active/helpers/status_helper.rb +11 -0
  28. data/lib/still_active/helpers/summary_helper.rb +6 -1
  29. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  30. data/lib/still_active/helpers/vulnerability_helper.rb +4 -5
  31. data/lib/still_active/options.rb +7 -0
  32. data/lib/still_active/osv_client.rb +39 -2
  33. data/lib/still_active/repository_signals.rb +113 -0
  34. data/lib/still_active/rubygems_client.rb +34 -0
  35. data/lib/still_active/sarif/rules.rb +1 -1
  36. data/lib/still_active/sbom_workflow.rb +16 -2
  37. data/lib/still_active/source_health.rb +88 -0
  38. data/lib/still_active/version.rb +1 -1
  39. data/lib/still_active/workflow.rb +63 -52
  40. data/lib/still_active.rb +1 -0
  41. data/still_active.gemspec +6 -7
  42. metadata +13 -6
@@ -180,7 +180,7 @@ module StillActive
180
180
  tags: ["security", "supply-chain", "maintenance", "external/cwe/cwe-1104"],
181
181
  neutral: {
182
182
  short: "Package is deprecated by its maintainer",
183
- full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
183
+ full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals. A language runtime counts too: when the Go team ends a Go release line, the toolchain on it is reported here.",
184
184
  help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches."
185
185
  }
186
186
  }
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ecosystem_lens"
4
+ require_relative "assessment"
5
+ require_relative "source_health"
4
6
  require_relative "ceiling_reconciler"
5
7
  require_relative "poison_security_correlator"
6
8
  require_relative "helpers/python_helper"
@@ -30,7 +32,9 @@ module StillActive
30
32
  # rate-limited run read "all clear" while silently skipping deps.
31
33
  Outcome = Data.define(:assessed, :failures)
32
34
 
33
- def call(sbom_result, &on_progress)
35
+ # `health` is the run's SourceHealth report: in an ecosystem it distrusts,
36
+ # deps.dev's advisories count as unchecked. The Go toolchain's come from OSV.
37
+ def call(sbom_result, health: SourceHealth::Report.healthy, &on_progress)
34
38
  dependencies = sbom_result.dependencies
35
39
  Async do
36
40
  # The language-runtime support windows, fetched once for the whole SBOM
@@ -42,6 +46,9 @@ module StillActive
42
46
  dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
43
47
  dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range }
44
48
  }
49
+ # Every version record in one batch request, rather than one GET each; the
50
+ # Go toolchain's come from OSV and endoflife.date instead.
51
+ DepsDevClient.prefetch_versions(dependencies.filter_map { [_1[:ecosystem], _1[:name], _1[:version]] unless GoToolchain.toolchain?(_1[:ecosystem], _1[:name]) })
45
52
  barrier = Async::Barrier.new
46
53
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
47
54
  result = {}
@@ -78,6 +85,11 @@ module StillActive
78
85
  end
79
86
  end
80
87
  barrier.wait
88
+ result.each_value do |data|
89
+ next if health.deps_dev_trusted?(data[:ecosystem]) || GoToolchain.toolchain?(data[:ecosystem], data[:name])
90
+
91
+ data[:vulnerabilities_checked] = false
92
+ end
81
93
  # Whole-tree correlation once every package's signals are in: a Python
82
94
  # ceiling's "upgrade to lift it" must not contradict a poison finding that
83
95
  # caps the same package below that upgrade (same guarantee as the native path).
@@ -87,10 +99,12 @@ module StillActive
87
99
  PoisonSecurityCorrelator.correlate(result)
88
100
  # Stable, diffable order regardless of async completion order.
89
101
  Outcome.new(
90
- assessed: result.sort_by { |key, _| key }.to_h,
102
+ assessed: result.sort_by { |key, _| key }.to_h.transform_values { Assessment.from(_1) },
91
103
  failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" }
92
104
  )
93
105
  end.wait
106
+ ensure
107
+ DepsDevClient.clear_prefetch
94
108
  end
95
109
 
96
110
  private
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "deps_dev_client"
5
+
6
+ module StillActive
7
+ # Whether deps.dev is answering in the shape we read and is current, checked once
8
+ # per run before the fan-out. deps.dev is an alpha API and it is the advisory
9
+ # source for everything but ruby-advisory-db and the Go toolchain, so when it
10
+ # isn't healthy its advisories count as unchecked rather than clean.
11
+ #
12
+ # Two checks. The advisory canary asks for a package with permanent advisories
13
+ # and expects them back (a renamed field reads as zero everywhere). The
14
+ # freshness canary asks, per ecosystem in the audit, for packages that publish
15
+ # often and expects one of them to have published in the last 30 days: a stalled index
16
+ # serves stale versions and misses new advisories without any error. Neither
17
+ # catches one stale package in a live index (deps.dev's Go stdlib was that).
18
+ module SourceHealth
19
+ extend self
20
+
21
+ # Packages that publish often; an ecosystem is fresh if ANY of its set has a
22
+ # version published in the window. Chosen by replaying two years of deps.dev
23
+ # publish dates (2026-09-24): the longest stretch with no version from the
24
+ # set is 10 days (nuget), 8.7 (npm), 7 (cargo, rubygems), 6.7 (pypi, maven),
25
+ # 6 (go), so 30 days is a 3x margin. Single canaries weren't enough:
26
+ # aws-sdk-core went 34 days and golang.org/x/net 35, which would have failed
27
+ # a healthy index closed. Recheck with each set's publishedAt history.
28
+ FRESHNESS_CANARIES = {
29
+ rubygems: ["aws-partitions", "aws-sdk-ec2"],
30
+ npm: ["next", "@types/node"],
31
+ pypi: ["boto3", "botocore"],
32
+ cargo: ["aws-sdk-ec2", "oxc_parser", "swc_core"],
33
+ go: ["cloud.google.com/go/storage", "github.com/aws/aws-sdk-go-v2/service/s3"],
34
+ maven: ["software.amazon.awssdk:s3", "software.amazon.awssdk:ec2"],
35
+ nuget: ["AWSSDK.S3", "AWSSDK.Core"]
36
+ }.freeze
37
+ FRESH_WITHIN_SECONDS = 30 * 24 * 60 * 60
38
+
39
+ Report = Data.define(:advisory_schema_ok, :stale_ecosystems) do
40
+ def self.healthy = new(advisory_schema_ok: true, stale_ecosystems: [])
41
+
42
+ # Whether deps.dev's advisories for this ecosystem count as an answer.
43
+ def deps_dev_trusted?(ecosystem)
44
+ advisory_schema_ok && !stale_ecosystems.include?(ecosystem)
45
+ end
46
+
47
+ def degraded? = !advisory_schema_ok || !stale_ecosystems.empty?
48
+
49
+ def as_json
50
+ {
51
+ status: degraded? ? "degraded" : "ok",
52
+ deps_dev_advisories: advisory_schema_ok ? "ok" : "failed",
53
+ deps_dev_stale_ecosystems: stale_ecosystems.map(&:to_s)
54
+ }
55
+ end
56
+
57
+ def warnings
58
+ lines = []
59
+ lines << "deps.dev's advisory check failed (its `advisoryKeys` field may have changed, or the API is unreachable); its advisories count as unchecked unless another source answers for them (ruby-advisory-db, OSV for the Go toolchain)" unless advisory_schema_ok
60
+ stale_ecosystems.each do |ecosystem|
61
+ lines << "deps.dev's #{ecosystem} index has nothing published by #{FRESHNESS_CANARIES[ecosystem].join(", ")} in 30 days (or couldn't be read), so it looks stale; its #{ecosystem} advisories count as unchecked unless another source answers for them, and its latest versions may be behind"
62
+ end
63
+ lines
64
+ end
65
+ end
66
+
67
+ def check(ecosystems:, now: Time.now)
68
+ Report.new(
69
+ advisory_schema_ok: DepsDevClient.advisory_schema_ok?,
70
+ stale_ecosystems: ecosystems.uniq.select { FRESHNESS_CANARIES.key?(_1) && !fresh?(_1, now) }
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ # Fresh when any canary has a version published in the window. One that can't
77
+ # be read (after the client's retry) just doesn't count; if none can, the check
78
+ # can't confirm freshness and reads stale.
79
+ def fresh?(ecosystem, now)
80
+ FRESHNESS_CANARIES[ecosystem].any? do |name|
81
+ date = DepsDevClient.newest_publish_date(name: name, system: ecosystem)
82
+ !date.nil? && now - Time.parse(date) < FRESH_WITHIN_SECONDS
83
+ rescue HttpHelper::Unavailable, ArgumentError, TypeError
84
+ false
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "3.0.0"
4
+ VERSION = "3.2.0"
5
5
  end
@@ -4,6 +4,9 @@ require_relative "artifactory_client"
4
4
  require_relative "compact_index_client"
5
5
  require_relative "source_credentials"
6
6
  require_relative "ceiling_reconciler"
7
+ require_relative "errors"
8
+ require_relative "assessment"
9
+ require_relative "repository_signals"
7
10
  require_relative "deps_dev_client"
8
11
  require_relative "osv_client"
9
12
  require_relative "poison_security_correlator"
@@ -19,6 +22,7 @@ require_relative "helpers/constraint_helper"
19
22
  require_relative "helpers/http_helper"
20
23
  require_relative "helpers/libyear_helper"
21
24
  require_relative "helpers/ruby_advisory_db"
25
+ require_relative "source_health"
22
26
  require_relative "helpers/ruby_helper"
23
27
  require_relative "helpers/runtime_ceiling_helper"
24
28
  require_relative "helpers/version_helper"
@@ -27,13 +31,16 @@ require "async"
27
31
  require "async/barrier"
28
32
  require "async/semaphore"
29
33
  require "cgi"
30
- require "gems"
34
+ require_relative "rubygems_client"
31
35
 
32
36
  module StillActive
33
37
  module Workflow
34
38
  extend self
35
39
 
36
- def call(&on_progress)
40
+ # `health` is the run's SourceHealth report: when it distrusts deps.dev for
41
+ # rubygems, deps.dev's advisories count as unchecked unless ruby-advisory-db
42
+ # answered for them.
43
+ def call(health: SourceHealth::Report.healthy, &on_progress)
37
44
  task = Async do
38
45
  # Load the optional ruby-advisory-db once, before the fan-out, so the
39
46
  # read-only Database is shared across fibers rather than reloaded per gem.
@@ -53,11 +60,14 @@ module StillActive
53
60
  nil
54
61
  end
55
62
  # Resolve the GitHub token once here, single-fibered, before the fan-out:
56
- # provider_for reads it per gem across fibers and gh_cli_token shells out,
63
+ # RepositorySignals reads it per gem across fibers and gh_cli_token shells out,
57
64
  # so resolving eagerly keeps that off the concurrent path and guarantees
58
65
  # every fiber sees one consistent value (token -> live GitHub, incl. private
59
66
  # repos; only a genuinely absent token falls back to ecosyste.ms).
60
67
  StillActive.config.github_oauth_token
68
+ # Every pinned gem's deps.dev version record in one batch request, rather
69
+ # than one GET each. The same names the per-gem lookups already send.
70
+ DepsDevClient.prefetch_versions(StillActive.config.gems.filter_map { [:rubygems, _1[:name], _1[:version]] if _1[:version] })
61
71
  barrier = Async::Barrier.new
62
72
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
63
73
  result_object = {}
@@ -83,18 +93,20 @@ module StillActive
83
93
  constraint_cache: constraint_cache,
84
94
  ruby_range: ruby_range
85
95
  )
86
- rescue Octokit::TooManyRequests
87
- $stderr.print("\r\e[K") if on_progress
88
- warn("rate limited checking #{gem[:name]}: set GITHUB_TOKEN to increase your limit")
89
96
  rescue => e
90
97
  $stderr.print("\r\e[K") if on_progress
91
98
  warn("error occurred for #{gem[:name]}: #{e.class}\n\t#{e.message}")
92
99
  ensure
100
+ # An assessment that raised part-way is left without its answers;
101
+ # Assessment.from reads those as unchecked and failed.
93
102
  completed += 1
94
103
  on_progress&.call(completed, total)
95
104
  end
96
105
  end
97
106
  barrier.wait
107
+ unless health.deps_dev_trusted?(:rubygems) || advisory_db
108
+ result_object.each_value { |data| data[:vulnerabilities_checked] = false }
109
+ end
98
110
  # Whole-tree correlation, once every gem's signals are in: a ceiling's
99
111
  # "upgrade to lift it" must not contradict a poison finding that caps the
100
112
  # same gem below that upgrade.
@@ -105,9 +117,13 @@ module StillActive
105
117
  # Gems are inserted as their async tasks finish, so the natural order is
106
118
  # nondeterministic completion order. Sort by name once here so every
107
119
  # consumer (JSON, SARIF, the baseline diff) gets a stable, diffable order.
108
- result_object.sort_by { |name, _| name }.to_h
120
+ # Every pass over the hashes is done; from here on consumers read the
121
+ # finished Assessment.
122
+ result_object.sort_by { |name, _| name }.to_h.transform_values { Assessment.from(_1) }
109
123
  end
110
124
  task.wait
125
+ ensure
126
+ DepsDevClient.clear_prefetch
111
127
  end
112
128
 
113
129
  def ruby_freshness
@@ -150,7 +166,8 @@ module StillActive
150
166
  signals = repo_signals(
151
167
  source: repo_info[:source],
152
168
  repository_owner: repo_info[:owner],
153
- repository_name: repo_info[:name]
169
+ repository_name: repo_info[:name],
170
+ public: public_source?(source_uri)
154
171
  )
155
172
  commit_date = signals[:last_commit_date]
156
173
  archived = signals[:archived]
@@ -174,6 +191,7 @@ module StillActive
174
191
  repository_url: repo_info[:url],
175
192
  last_commit_date: commit_date,
176
193
  archived: archived,
194
+ **repository_availability(signals),
177
195
  **deps_dev
178
196
  })
179
197
 
@@ -286,7 +304,8 @@ module StillActive
286
304
  def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil, advisory_db: nil)
287
305
  repo_info = repository_info_for_non_rubygems(gem_name: gem_name, source_uri: source_uri)
288
306
  source, owner, name = repo_info.values_at(:source, :owner, :name)
289
- deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {}
307
+ # With no version there is nothing to look advisories up by.
308
+ deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version, advisory_db: advisory_db) : {vulnerabilities_checked: false}
290
309
 
291
310
  # Fall back to repo-derived project_id for scorecard when deps.dev doesn't
292
311
  # have the version. Use ||= so a maintained score already found via the
@@ -303,6 +322,7 @@ module StillActive
303
322
  repository_url: repo_info[:url],
304
323
  last_commit_date: signals[:last_commit_date],
305
324
  archived: signals[:archived],
325
+ **repository_availability(signals),
306
326
  **deps_dev
307
327
  })
308
328
  end
@@ -371,7 +391,16 @@ module StillActive
371
391
  end
372
392
 
373
393
  def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
374
- info = DepsDevClient.version_info(gem_name: gem_name, version: version)
394
+ # deps.dev failing to answer leaves the advisories unchecked, unless
395
+ # ruby-advisory-db, the Ruby authority, is loaded and answers for them.
396
+ # No version (the latest-version lookup failed too) means nothing was asked.
397
+ checked = !version.nil?
398
+ info = begin
399
+ DepsDevClient.version_info(gem_name: gem_name, version: version)
400
+ rescue HttpHelper::Unavailable
401
+ checked = !advisory_db.nil?
402
+ nil
403
+ end
375
404
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
376
405
  advisory_keys = info&.dig(:advisory_keys) || []
377
406
  # The keys ARE the evidence the version is vulnerable; the detail fetch only
@@ -397,6 +426,7 @@ module StillActive
397
426
  deprecated: info&.dig(:deprecated) == true,
398
427
  deprecation_reason: info&.dig(:deprecation_reason),
399
428
  vulnerability_count: vulnerabilities.length,
429
+ vulnerabilities_checked: checked,
400
430
  vulnerabilities: vulnerabilities
401
431
  }
402
432
  end
@@ -409,20 +439,8 @@ module StillActive
409
439
  elsif unqueryable_private_source?(source_uri)
410
440
  private_source_versions(gem_name: gem_name, source_uri: source_uri)
411
441
  else
412
- Gems.versions(gem_name)
442
+ RubygemsClient.versions(gem_name)
413
443
  end
414
- rescue Gems::NotFound
415
- []
416
- # Gems::GemError is the `gems` library's catch-all for any non-success,
417
- # non-404 response -- crucially a 429 rate-limit or a 5xx. Left unrescued it
418
- # escapes to the per-gem rescue in #call and strips the gem of ALL its signals
419
- # (not just versions), blaming a generic "error occurred". The poison-pill
420
- # enrichment adds extra version lookups that make a 429 likelier, so a
421
- # best-effort feature must not be able to degrade an unrelated gem's core data:
422
- # degrade to "no versions known" here, exactly like Gems::NotFound.
423
- rescue Gems::GemError, *HttpHelper::TRANSPORT_ERRORS => e
424
- warn("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
425
- []
426
444
  end
427
445
 
428
446
  def github_packages_uri?(uri)
@@ -433,7 +451,7 @@ module StillActive
433
451
 
434
452
  # A rubygems-type source that isn't public rubygems.org and that we have no
435
453
  # client for (Gemfury, Gemstash, geminabox, a private mirror). We must NOT
436
- # fall through to Gems.versions, which always hits public rubygems.org: that
454
+ # fall through to RubygemsClient.versions, which always hits public rubygems.org: that
437
455
  # would silently report a public name-collision's data, or blanks, as if it
438
456
  # were the private gem's. github_packages/artifactory are handled above, so
439
457
  # anything left with a non-rubygems.org host is unqueryable. Refs #43.
@@ -501,7 +519,8 @@ module StillActive
501
519
  repo.merge(project_id: deps_dev_project_id(repo))
502
520
  end
503
521
 
504
- # deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
522
+ # Of the forges Repository recognizes, deps.dev indexes github.com and
523
+ # gitlab.com (DepsDevClient::PROJECT_HOSTS). A Forgejo/Codeberg
505
524
  # repo has no deps.dev project, so leave its project_id nil rather than minting
506
525
  # a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
507
526
  DEPS_DEV_HOST_BY_SOURCE = {github: "github.com", gitlab: "gitlab.com"}.freeze
@@ -522,7 +541,7 @@ module StillActive
522
541
  end
523
542
 
524
543
  # Locally-installed gem metadata and the gem's own version payload are
525
- # source-accurate. This public rubygems.org Gems.info lookup is the last
544
+ # source-accurate. This public rubygems.org info lookup is the last
526
545
  # resort, and is skipped for an unqueryable private source: otherwise a
527
546
  # public name-collision's repo/archived/last-commit data would stand in for
528
547
  # the private gem, the same substitution #43 prevents for versions.
@@ -549,46 +568,38 @@ module StillActive
549
568
  end
550
569
 
551
570
  def rubygems_gem_repository_url(gem_name:)
552
- info = Gems.info(gem_name)
571
+ info = RubygemsClient.info(gem_name)
553
572
  return [] if info.nil?
554
573
 
555
574
  [
556
575
  info["homepage_uri"],
557
576
  info["source_code_uri"]
558
577
  ].compact.uniq
559
- rescue Gems::NotFound
560
- []
561
578
  end
562
579
 
563
- # The repo-signal provider for a source, or nil for an unhandled host. Every
564
- # provider answers archived/last_commit_date; richer signals (e.g.
565
- # commits_since_release) are duck-typed and dispatched by respond_to?, so a
566
- # provider opts into them by defining the method, with no base class and no
567
- # assumption that every source supports every signal.
568
- def provider_for(source)
569
- case source
570
- # Without a GitHub token, the live API caps at 60 req/hr -- unusable past a
571
- # handful of gems. Fall back to ecosyste.ms (5000 anonymous) so a large
572
- # Gemfile still resolves. With a token, the live API stays primary (freshest,
573
- # and it carries commits_since_release, which ecosyste.ms doesn't).
574
- when :github then StillActive.config.github_oauth_token ? GithubClient : EcosystemsClient
575
- when :gitlab then GitlabClient
576
- when :forgejo then ForgejoClient
577
- end
580
+ # archived + last-activity date, and which service answered, from
581
+ # RepositorySignals. `public:` says the gem came from a public registry, so
582
+ # its repository's name may go to ecosyste.ms.
583
+ def repo_signals(source:, repository_owner:, repository_name:, public: false)
584
+ host = Repository::SOURCE_BY_HOST.key(source)
585
+ RepositorySignals.for(host: host, owner: repository_owner, name: repository_name, public: public)
586
+ end
587
+
588
+ # rubygems.org, as opposed to a private registry the lockfile names.
589
+ def public_source?(source_uri)
590
+ !(github_packages_uri?(source_uri) || ArtifactoryClient.artifactory_uri?(source_uri) || unqueryable_private_source?(source_uri))
578
591
  end
579
592
 
580
- # One provider call yields both archived and the last-activity date (the
581
- # repo object carries both), so a gem's repo signals cost a single request
582
- # instead of two. Returns {} for an unhandled host.
583
- def repo_signals(source:, repository_owner:, repository_name:)
584
- provider_for(source)&.repo_signals(owner: repository_owner, name: repository_name) || {}
593
+ def repository_availability(signals)
594
+ {repository_check: signals[:check], **(signals[:source] ? {repository_source: signals[:source]} : {})}
585
595
  end
586
596
 
597
+ # GitHub's compare endpoint, and only with a token: it's a per-gem call, and
598
+ # 60 anonymous requests an hour would starve the repository lookups.
587
599
  def unreleased_commits(source:, repository_owner:, repository_name:, version:)
588
- provider = provider_for(source)
589
- return unless provider.respond_to?(:commits_since_release)
600
+ return unless source == :github && StillActive.config.github_oauth_token
590
601
 
591
- provider.commits_since_release(owner: repository_owner, name: repository_name, version: version)
602
+ GithubClient.commits_since_release(owner: repository_owner, name: repository_name, version: version)
592
603
  end
593
604
  end
594
605
  end
data/lib/still_active.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "still_active/version"
4
4
  require_relative "still_active/errors"
5
5
  require_relative "still_active/config"
6
6
  require_relative "still_active/sbom_reader"
7
+ require_relative "still_active/source_health"
7
8
  require_relative "still_active/ecosystem_lens"
8
9
  require_relative "still_active/sbom_workflow"
9
10
  require_relative "still_active/cli"
data/still_active.gemspec CHANGED
@@ -55,14 +55,13 @@ Gem::Specification.new do |spec|
55
55
  # carry an artificial floor.
56
56
  spec.add_runtime_dependency("async", ">= 2.2")
57
57
  spec.add_runtime_dependency("bundler", ">= 2.0")
58
- # cvss-suite (CVSS v4.0 base-score from an OSV vector, for the CVSS-4-only advisory
59
- # deps.dev can't score) is an OPTIONAL dependency, NOT declared here: it exact-pins
60
- # bundler and caps bigdecimal, the poison-pill pattern still_active flags, so we
61
- # won't force it on users or trip our own audit. CvssHelper soft-requires it; absent,
62
- # the OSV/GHSA severity label still carries gating + level and the number is skipped.
63
- # Install cvss-suite (or run the distribution that bundles it) to light up the number.
58
+ # CVSS base score from an OSV vector, for the CVSS-4-only advisory deps.dev can't
59
+ # score. 5.0 is the floor because it is the first release without the poison-pill
60
+ # constraints (a Ruby 2.6 floor, a bigdecimal cap, an exact bundler pin) that kept
61
+ # it optional through 3.0; 5.0 also adds base_score on CVSS 4.0, which is the API
62
+ # CvssHelper reads.
63
+ spec.add_runtime_dependency("cvss-suite", ">= 5.0")
64
64
  spec.add_runtime_dependency("faraday-retry")
65
- spec.add_runtime_dependency("gems")
66
65
  spec.add_runtime_dependency("octokit")
67
66
  # Spec-compliant package-URL parsing for SBOM ingestion (decodes npm scopes,
68
67
  # maven group:artifact, qualifiers). 0.1 is the verified floor; the official
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -38,21 +38,21 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: faraday-retry
41
+ name: cvss-suite
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '0'
46
+ version: '5.0'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '0'
53
+ version: '5.0'
54
54
  - !ruby/object:Gem::Dependency
55
- name: gems
55
+ name: faraday-retry
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
@@ -135,6 +135,7 @@ files:
135
135
  - bin/still_active
136
136
  - lib/still_active.rb
137
137
  - lib/still_active/artifactory_client.rb
138
+ - lib/still_active/assessment.rb
138
139
  - lib/still_active/ceiling_reconciler.rb
139
140
  - lib/still_active/cli.rb
140
141
  - lib/still_active/compact_index_client.rb
@@ -149,6 +150,7 @@ files:
149
150
  - lib/still_active/forgejo_client.rb
150
151
  - lib/still_active/github_client.rb
151
152
  - lib/still_active/gitlab_client.rb
153
+ - lib/still_active/go_toolchain.rb
152
154
  - lib/still_active/helpers/activity_helper.rb
153
155
  - lib/still_active/helpers/alternatives_helper.rb
154
156
  - lib/still_active/helpers/ansi_helper.rb
@@ -163,6 +165,7 @@ files:
163
165
  - lib/still_active/helpers/dotnet_helper.rb
164
166
  - lib/still_active/helpers/emoji_helper.rb
165
167
  - lib/still_active/helpers/endoflife_helper.rb
168
+ - lib/still_active/helpers/http_cache.rb
166
169
  - lib/still_active/helpers/http_helper.rb
167
170
  - lib/still_active/helpers/libyear_helper.rb
168
171
  - lib/still_active/helpers/lockfile_dependency_parser.rb
@@ -171,6 +174,7 @@ files:
171
174
  - lib/still_active/helpers/markdown_helper.rb
172
175
  - lib/still_active/helpers/pep440_helper.rb
173
176
  - lib/still_active/helpers/python_helper.rb
177
+ - lib/still_active/helpers/repository_check.rb
174
178
  - lib/still_active/helpers/ruby_advisory_db.rb
175
179
  - lib/still_active/helpers/ruby_helper.rb
176
180
  - lib/still_active/helpers/runtime_ceiling_helper.rb
@@ -187,10 +191,13 @@ files:
187
191
  - lib/still_active/poison_security_correlator.rb
188
192
  - lib/still_active/pypi_client.rb
189
193
  - lib/still_active/repository.rb
194
+ - lib/still_active/repository_signals.rb
195
+ - lib/still_active/rubygems_client.rb
190
196
  - lib/still_active/sarif/rules.rb
191
197
  - lib/still_active/sbom_reader.rb
192
198
  - lib/still_active/sbom_workflow.rb
193
199
  - lib/still_active/source_credentials.rb
200
+ - lib/still_active/source_health.rb
194
201
  - lib/still_active/suppressions.rb
195
202
  - lib/still_active/version.rb
196
203
  - lib/still_active/workflow.rb
@@ -219,7 +226,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
219
226
  - !ruby/object:Gem::Version
220
227
  version: 3.2.3
221
228
  requirements: []
222
- rubygems_version: 4.0.16
229
+ rubygems_version: 4.0.20
223
230
  specification_version: 4
224
231
  summary: Audit your dependencies for maintenance health, abandonment, and below-the-fix
225
232
  vulnerabilities. Ruby gems natively; npm, PyPI, Cargo, Go, Maven, and NuGet via