still_active 3.1.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -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/cyclonedx_helper.rb +7 -0
  19. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  20. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  21. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  22. data/lib/still_active/helpers/http_cache.rb +109 -0
  23. data/lib/still_active/helpers/http_helper.rb +148 -28
  24. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  25. data/lib/still_active/helpers/repository_check.rb +33 -0
  26. data/lib/still_active/helpers/status_helper.rb +11 -0
  27. data/lib/still_active/helpers/summary_helper.rb +6 -1
  28. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  29. data/lib/still_active/options.rb +7 -0
  30. data/lib/still_active/osv_client.rb +39 -2
  31. data/lib/still_active/repository_signals.rb +113 -0
  32. data/lib/still_active/rubygems_client.rb +34 -0
  33. data/lib/still_active/sarif/rules.rb +1 -1
  34. data/lib/still_active/sbom_workflow.rb +16 -2
  35. data/lib/still_active/source_health.rb +88 -0
  36. data/lib/still_active/version.rb +1 -1
  37. data/lib/still_active/workflow.rb +63 -52
  38. data/lib/still_active.rb +1 -0
  39. data/still_active.gemspec +0 -1
  40. metadata +9 -16
@@ -294,7 +294,7 @@ module StillActive
294
294
 
295
295
  def format_vulns(data)
296
296
  count = data[:vulnerability_count]
297
- return StillActive.config.unsure_emoji if count.nil?
297
+ return StillActive.config.unsure_emoji if count.nil? || (count.zero? && data[:vulnerabilities_checked] == false)
298
298
  return StillActive.config.success_emoji if count.zero?
299
299
 
300
300
  vulnerabilities = data[:vulnerabilities] || []
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../assessment"
4
+
5
+ module StillActive
6
+ # Reading a dependency's repository_check the same way everywhere: "answered",
7
+ # "failed" or "unknowable" (see RepositorySignals), or "not_applicable" for the
8
+ # Go toolchain, which is the language, not a package with a repository. A hash
9
+ # without the key never had its repository looked at, which is not an answer.
10
+ module RepositoryCheck
11
+ extend self
12
+
13
+ VALUES = Assessment::REPOSITORY_CHECKS
14
+ SETTLED = ["answered", "not_applicable"].freeze
15
+
16
+ def unanswered?(data) = !SETTLED.include?(data[:repository_check])
17
+
18
+ # A missing or unrecognised check means the repository step never ran (an
19
+ # assessment that raised part-way), so it counts as failed, as in tally.
20
+ def failed?(data) = !VALUES.include?(data[:repository_check]) || data[:repository_check] == "failed"
21
+
22
+ # {answered:, failed:, unknowable:} across dependencies.
23
+ def tally(dependencies)
24
+ counts = VALUES.to_h { [_1.to_sym, 0] }
25
+ dependencies.each { counts[(data_check(_1) || "failed").to_sym] += 1 }
26
+ counts
27
+ end
28
+
29
+ private
30
+
31
+ def data_check(data) = VALUES.include?(data[:repository_check]) ? data[:repository_check] : nil
32
+ end
33
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "activity_helper"
4
+ require_relative "repository_check"
4
5
 
5
6
  module StillActive
6
7
  # Collapses a gem's several maintenance signals into one categorical verdict,
@@ -28,6 +29,16 @@ module StillActive
28
29
  # Returns :dead, :vulnerable, :deprecated, :archived, :legacy, :stale, :ok,
29
30
  # or :unknown.
30
31
  def gem_status(gem_data)
32
+ status = lifecycle_status(gem_data)
33
+ # Advisories a source couldn't answer for are not "none", and a repository
34
+ # no source could read isn't "not archived", so a verdict that claims clean
35
+ # (:ok, and :legacy's "dormant but clean") reads :unknown. Verdicts that
36
+ # found a problem stand, since it's true either way.
37
+ unanswered = gem_data[:vulnerabilities_checked] == false || RepositoryCheck.unanswered?(gem_data)
38
+ ([:ok, :legacy].include?(status) && unanswered) ? :unknown : status
39
+ end
40
+
41
+ def lifecycle_status(gem_data)
31
42
  vulnerable = gem_data[:vulnerability_count].to_i.positive?
32
43
 
33
44
  # A pinned version the registry can't resolve (yanked, typo, or nonexistent)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "activity_helper"
4
4
  require_relative "status_helper"
5
+ require_relative "repository_check"
5
6
 
6
7
  module StillActive
7
8
  # Builds the JSON output's summary{} digest: the headline posture of the audit
@@ -16,7 +17,8 @@ module StillActive
16
17
 
17
18
  def summarize(result, ruby_info: nil)
18
19
  activity = ACTIVITY_LEVELS.to_h { |level| [level, 0] }
19
- archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = direct = 0
20
+ archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = unchecked = direct = 0
21
+ repository_checks = RepositoryCheck.tally(result.values)
20
22
 
21
23
  result.each_value do |data|
22
24
  activity[ActivityHelper.activity_level(data)] += 1
@@ -24,6 +26,7 @@ module StillActive
24
26
  archived += 1 if data[:archived]
25
27
  up_to_date += 1 if data[:up_to_date] == true
26
28
  outdated += 1 if data[:up_to_date] == false
29
+ unchecked += 1 if data[:vulnerabilities_checked] == false
27
30
  count = data[:vulnerability_count].to_i
28
31
  next unless count.positive?
29
32
 
@@ -41,6 +44,8 @@ module StillActive
41
44
  outdated: outdated,
42
45
  vulnerable_gems: vulnerable_gems,
43
46
  vulnerabilities: vulnerabilities,
47
+ vulnerabilities_unchecked: unchecked,
48
+ repository_checks: repository_checks,
44
49
  # The single worst per-gem verdict (plus EOL Ruby), so a consumer reads
45
50
  # one project-level posture without scanning every gem's status.
46
51
  status: StatusHelper.project_status(result, ruby_info: ruby_info)
@@ -8,6 +8,7 @@ require_relative "summary_helper"
8
8
  require_relative "libyear_helper"
9
9
  require_relative "version_helper"
10
10
  require_relative "vulnerability_helper"
11
+ require_relative "repository_check"
11
12
 
12
13
  module StillActive
13
14
  module TerminalHelper
@@ -90,7 +91,8 @@ module StillActive
90
91
  def format_activity(data)
91
92
  case ActivityHelper.activity_level(data)
92
93
  when :archived then AnsiHelper.red("archived")
93
- when :ok then AnsiHelper.green("ok")
94
+ # Recent releases, but the repository couldn't be read: it may be archived.
95
+ when :ok then RepositoryCheck.unanswered?(data) ? AnsiHelper.yellow("?") : AnsiHelper.green("ok")
94
96
  when :stale then AnsiHelper.yellow("stale")
95
97
  when :critical then AnsiHelper.red("critical")
96
98
  when :unknown then AnsiHelper.dim("-")
@@ -113,6 +115,8 @@ module StillActive
113
115
  def format_vulns(data)
114
116
  count = data[:vulnerability_count]
115
117
  return AnsiHelper.dim("-") if count.nil?
118
+ # No source answered, so a 0 here would read as clean.
119
+ return AnsiHelper.yellow("?") if count.zero? && data[:vulnerabilities_checked] == false
116
120
  return AnsiHelper.green("0") if count.zero?
117
121
 
118
122
  severity = VulnerabilityHelper.highest_severity(data[:vulnerabilities])
@@ -353,6 +357,10 @@ module StillActive
353
357
  activity << ", #{archived} archived" if archived > 0
354
358
  parts << activity
355
359
  parts << "#{summary[:vulnerabilities]} vulnerabilities"
360
+ parts.last << " (#{summary[:vulnerabilities_unchecked]} unchecked)" if summary[:vulnerabilities_unchecked] > 0
361
+ checks = summary[:repository_checks]
362
+ unchecked = checks[:failed] + checks[:unknowable]
363
+ parts << "#{unchecked} #{(unchecked == 1) ? "repository" : "repositories"} unchecked#{" (#{checks[:failed]} failed)" if checks[:failed] > 0}" if unchecked > 0
356
364
  poison_tiers = result.each_value.select { |data| data[:poison] }.map { |data| data[:poison_severity] }
357
365
  poison_part = tier_summary_part(poison_tiers, "#{poison_tiers.size} poison-#{(poison_tiers.size == 1) ? "pill" : "pills"}")
358
366
  parts << poison_part if poison_part
@@ -21,6 +21,7 @@ module StillActive
21
21
  add_output_options(opts)
22
22
  add_token_options(opts)
23
23
  add_parallelism_options(opts)
24
+ add_cache_option(opts)
24
25
  add_range_options(opts)
25
26
  add_exit_options(opts)
26
27
  add_emoji_options(opts)
@@ -120,6 +121,12 @@ module StillActive
120
121
  end
121
122
  end
122
123
 
124
+ def add_cache_option(opts)
125
+ opts.on("--no-cache", "Skip the on-disk cache of public sources' answers (advisories are kept at most an hour)") do
126
+ StillActive.config { |config| config.http_cache = false }
127
+ end
128
+ end
129
+
123
130
  def add_parallelism_options(opts)
124
131
  opts.on("--simultaneous-requests=QTY", Integer, "Number of simultaneous requests made") do |value|
125
132
  StillActive.config { |config| config.parallelism = value }
@@ -81,6 +81,45 @@ module StillActive
81
81
  body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}")
82
82
  return unless body.is_a?(Hash)
83
83
 
84
+ parse_record(body)
85
+ end
86
+
87
+ # Every advisory OSV lists for this exact version, carrying the same OSV fields
88
+ # enrich adds, or nil when OSV could not answer completely. This is the discovery
89
+ # source for a package deps.dev can't serve per version (the Go toolchain), so
90
+ # nil must stay distinct from [] here too: the caller reads nil as "unknown",
91
+ # never as clean. The query returns whole records, so nothing is fetched per id.
92
+ def advisories(ecosystem:, name:, version:)
93
+ osv_ecosystem = ECOSYSTEM_NAMES[ecosystem]
94
+ return if osv_ecosystem.nil?
95
+
96
+ query = {version: version, package: {name: name, ecosystem: osv_ecosystem}}
97
+ body = HttpHelper.post_json(BASE_URI, "/v1/query", body: JSON.generate(query))
98
+ return unless body.is_a?(Hash) && identifiers_from(body)
99
+
100
+ Array(body["vulns"]).map do |vuln|
101
+ record = parse_record(vuln)
102
+ {
103
+ id: vuln["id"],
104
+ aliases: Array(vuln["aliases"]).grep(String),
105
+ title: vuln["summary"],
106
+ url: "https://osv.dev/vulnerability/#{vuln["id"]}",
107
+ source: "osv",
108
+ osv_severity: record[:severity_label],
109
+ osv_cvss_score: record[:cvss_score],
110
+ cvss_version: record[:cvss_version],
111
+ cvss_vector: record[:cvss_vector],
112
+ fixed_versions: fixed_versions(record, ecosystem: ecosystem, name: name)
113
+ }
114
+ end
115
+ rescue => e
116
+ warn("warning: OSV advisory lookup for #{name}@#{version} failed: #{e.class} (#{e.message})")
117
+ nil
118
+ end
119
+
120
+ private
121
+
122
+ def parse_record(body)
84
123
  cvss = best_cvss(body)
85
124
  {
86
125
  # OSV's own identity for the advisory, unioned into the match against a query
@@ -96,8 +135,6 @@ module StillActive
96
135
  }
97
136
  end
98
137
 
99
- private
100
-
101
138
  # Applies one OSV record to one advisory, in place. Returns OSV's identifiers for
102
139
  # the advisory when it is eligible for the version confirmation below (only OSV
103
140
  # data may overrule OSV-derived data), or nil when it is not and must be kept
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "github_client"
5
+ require_relative "gitlab_client"
6
+ require_relative "forgejo_client"
7
+ require_relative "ecosystems_client"
8
+
9
+ module StillActive
10
+ # One answer to "is this repository archived, and when was it last pushed?"
11
+ # for both audit paths, from whichever service can give it. The clients only
12
+ # report what their service said; which services to ask, in what order, and
13
+ # when to stop, is decided here once.
14
+ #
15
+ # Returns a hash whose :check says what came of it, always one of:
16
+ # "answered" a service said; :archived, :last_commit_date and :source
17
+ # (which service) come with it
18
+ # "failed" a service couldn't answer (a rate limit, an error, a refused
19
+ # token) and none other said; a rerun may help, so the activity
20
+ # gates fail closed on it
21
+ # "unknowable" no service can say: the host has no archived state we can
22
+ # read, there's no repository to ask about, or every service
23
+ # asked didn't know it (ecosyste.ms hasn't crawled it, the forge
24
+ # 404s a private or deleted repository). A rerun won't change
25
+ # it, so it reads unknown without failing the gates.
26
+ #
27
+ # "Don't know" is never an answer: a 404 or an answer without the archived
28
+ # state moves on to the next service.
29
+ #
30
+ # A GitHub repository is asked of GitHub and of ecosyste.ms, which mirrors the
31
+ # same two fields: GitHub first with a token (freshest, 5000/hr), ecosyste.ms
32
+ # first without one (GitHub allows 60 anonymous requests an hour), each the
33
+ # other's fallback. ecosyste.ms sees the repository's name, so it is asked only
34
+ # when the dependency came from a public registry (`public:`), which is the
35
+ # privacy guard; after GitHub refused the token outright (401/403: a bad token,
36
+ # SSO, a suspended account) the chain also stops rather than guess.
37
+ module RepositorySignals
38
+ extend self
39
+
40
+ # A service in a chain: its label for `source`, and how to ask it.
41
+ Service = Data.define(:label, :ask)
42
+
43
+ def for(host:, owner:, name:, public:)
44
+ services = (host && owner && name) ? chain(host.downcase, public) : []
45
+ return {check: "unknowable"} if services.empty?
46
+
47
+ failed = false
48
+ services.each do |service|
49
+ signals = service.ask.call(owner, name)
50
+ next unless signals.key?(:archived)
51
+
52
+ return signals.merge(source: service.label, check: "answered")
53
+ rescue RepoAccessDenied
54
+ failed = true
55
+ break
56
+ rescue RepoSignalsUnavailable
57
+ failed = true
58
+ end
59
+
60
+ if failed
61
+ warn("warning: #{owner}/#{name}: no repository source could answer; archived status unknown")
62
+ {check: "failed"}
63
+ else
64
+ {check: "unknowable"}
65
+ end
66
+ end
67
+
68
+ # For a deps.dev project id (host/owner/name, host/group/.../name on
69
+ # GitLab). gopkg.in is a redirector with a fixed mapping onto GitHub
70
+ # (gopkg.in/pkg.v3 is github.com/go-pkg/pkg, gopkg.in/user/pkg.v3 is
71
+ # github.com/user/pkg), so it is asked about as that GitHub repository.
72
+ def for_project(project_id, public:)
73
+ host, *path = project_id.to_s.split("/")
74
+ host, path = gopkg_in(path) if host&.downcase == "gopkg.in"
75
+ return {check: "unknowable"} if host.nil? || path.size < 2
76
+
77
+ self.for(host: host, owner: path[0..-2].join("/"), name: path.last, public: public)
78
+ end
79
+
80
+ private
81
+
82
+ GOPKG_VERSIONED = /\A(?<name>.+)\.v\d+(?:-unstable)?\z/
83
+
84
+ # The version suffix marks the module: gopkg.in/pkg.vN[/sub...] or
85
+ # gopkg.in/user/pkg.vN[/sub...]; anything after it is a subpackage.
86
+ def gopkg_in(path)
87
+ if (match = path[0]&.match(GOPKG_VERSIONED))
88
+ ["github.com", ["go-#{match[:name]}", match[:name]]]
89
+ elsif (match = path[1]&.match(GOPKG_VERSIONED))
90
+ ["github.com", [path[0], match[:name]]]
91
+ else
92
+ [nil, []]
93
+ end
94
+ end
95
+
96
+ def chain(host, public)
97
+ case host
98
+ when "github.com" then github_chain(public)
99
+ when "gitlab.com" then [Service.new("gitlab", ->(owner, name) { GitlabClient.repo_signals(owner: owner, name: name) })]
100
+ when "codeberg.org" then [Service.new("codeberg", ->(owner, name) { ForgejoClient.repo_signals(owner: owner, name: name, host: host) })]
101
+ else []
102
+ end
103
+ end
104
+
105
+ def github_chain(public)
106
+ github = Service.new("github", ->(owner, name) { GithubClient.repo_signals(owner: owner, name: name) })
107
+ mirror = Service.new("ecosyste.ms", ->(owner, name) { EcosystemsClient.repo_signals(owner: owner, name: name) })
108
+ return [github] unless public
109
+
110
+ StillActive.config.github_oauth_token ? [github, mirror] : [mirror, github]
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers/http_helper"
4
+
5
+ module StillActive
6
+ # The RubyGems JSON API this tool reads, through HttpHelper so it gets the same
7
+ # timeouts, body cap and redirect rules as every other source. rubygems.org by
8
+ # default; Artifactory serves the same API under a repo path, with its own
9
+ # credentials, via `base:` and `headers:`.
10
+ module RubygemsClient
11
+ extend self
12
+
13
+ BASE_URI = URI("https://rubygems.org/")
14
+
15
+ # Every published version, newest first, as rubygems.org serves them; [] when
16
+ # the gem doesn't exist or the lookup failed (HttpHelper has warned).
17
+ def versions(gem_name, base: BASE_URI, headers: {})
18
+ body = HttpHelper.get_json(base, "#{base.path.chomp("/")}/api/v1/versions/#{encode(gem_name)}.json", headers: headers)
19
+ body.is_a?(Array) ? body : []
20
+ end
21
+
22
+ # The gem's metadata (homepage_uri, source_code_uri, downloads, ...), or nil.
23
+ def info(gem_name)
24
+ body = HttpHelper.get_json(BASE_URI, "/api/v1/gems/#{encode(gem_name)}.json")
25
+ body if body.is_a?(Hash)
26
+ end
27
+
28
+ private
29
+
30
+ def encode(value)
31
+ URI.encode_www_form_component(value.to_s)
32
+ end
33
+ end
34
+ end
@@ -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.1.0"
4
+ VERSION = "3.2.0"
5
5
  end