still_active 1.6.0 → 2.0.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "activity_helper"
4
4
  require_relative "ansi_helper"
5
+ require_relative "summary_helper"
5
6
  require_relative "libyear_helper"
6
7
  require_relative "version_helper"
7
8
  require_relative "vulnerability_helper"
@@ -22,7 +23,10 @@ module StillActive
22
23
  lines << separator_line(widths)
23
24
  names.each_with_index do |name, i|
24
25
  lines << row_line(rows[i], widths)
25
- extra = alternatives_line(result[name])
26
+ data = result[name]
27
+ # Transitive gems can't be swapped directly, so point at the direct
28
+ # parent instead of suggesting alternatives for them (#60).
29
+ extra = data[:direct] == false ? dependency_path_line(data) : alternatives_line(data)
26
30
  lines << extra if extra
27
31
  end
28
32
  lines << ""
@@ -142,6 +146,18 @@ module StillActive
142
146
  end
143
147
  end
144
148
 
149
+ # For a flagged transitive gem, name the direct dependency that pulls it in,
150
+ # the gem the user can actually act on (#60).
151
+ def dependency_path_line(data)
152
+ path = data[:dependency_path]
153
+ return unless path && path.length >= 2
154
+
155
+ level = ActivityHelper.activity_level(data)
156
+ return unless [:archived, :critical].include?(level) || data[:vulnerability_count].to_i.positive?
157
+
158
+ AnsiHelper.dim(" ↳ transitive, pulled in by #{path.first}")
159
+ end
160
+
145
161
  def ruby_summary_line(ruby_info)
146
162
  version = ruby_info[:version]
147
163
  latest = ruby_info[:latest_version]
@@ -161,29 +177,23 @@ module StillActive
161
177
  end
162
178
  end
163
179
 
180
+ # Reuse the same digest the JSON output emits so the human summary line can
181
+ # never drift from the machine one (the #63 "computed two ways" trap). The
182
+ # terminal keeps a coarser grouping (critical folds into stale) and adds its
183
+ # own yanked / total-libyear extras that the JSON digest doesn't carry.
164
184
  def summary_line(result)
165
- total = result.size
166
- level_counts = result.each_value.map { |d| ActivityHelper.activity_level(d) }.tally
167
- version_counts = result.each_value
168
- .map { |d| VersionHelper.up_to_date(version_used: d[:version_used], latest_version: d[:latest_version]) }
169
- .tally
170
-
171
- up_to_date = version_counts.fetch(true, 0)
172
- outdated = version_counts.fetch(false, 0)
185
+ summary = SummaryHelper.summarize(result)
186
+ active = summary[:activity][:ok]
187
+ stale = summary[:activity][:stale] + summary[:activity][:critical]
188
+ archived = summary[:activity][:archived]
173
189
  yanked = result.each_value.count { |d| d[:version_yanked] }
174
- active = level_counts.fetch(:ok, 0)
175
- archived = level_counts.fetch(:archived, 0)
176
- stale = level_counts.fetch(:stale, 0) + level_counts.fetch(:critical, 0)
177
- vulns = result.each_value.sum { |d| d[:vulnerability_count] || 0 }
178
190
 
179
- parts = [
180
- "#{total} gems: #{up_to_date} up to date, #{outdated} outdated",
181
- ]
191
+ parts = ["#{summary[:total_gems]} gems: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
182
192
  parts.last << ", #{yanked} yanked" if yanked > 0
183
193
  activity = "#{active} active, #{stale} stale"
184
194
  activity << ", #{archived} archived" if archived > 0
185
195
  parts << activity
186
- parts << "#{vulns} vulnerabilities"
196
+ parts << "#{summary[:vulnerabilities]} vulnerabilities"
187
197
  total_libyear = LibyearHelper.total_libyear(result)
188
198
  parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
189
199
  parts.join(" · ")
@@ -12,7 +12,13 @@ module StillActive
12
12
  elsif !version_string.nil?
13
13
  versions&.find { |v| v["number"] == version_string }
14
14
  else
15
- versions&.find { |v| v["prerelease"] == pre_release }
15
+ # The "latest" of a kind: pick the highest by version rather than trust
16
+ # the source's ordering. RubyGems happens to return newest-first, but
17
+ # GitHub Packages and other sources don't, and a wrong "latest" cascades
18
+ # into up_to_date and libyear.
19
+ versions
20
+ &.select { |v| v["prerelease"] == pre_release }
21
+ &.max_by { |v| to_gem_version(v["number"]) || Gem::Version.new("0") }
16
22
  end
17
23
  end
18
24
 
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "cgi"
5
+ require "json"
6
+ require "uri"
7
+ require_relative "../helpers/http_helper"
8
+
9
+ module StillActive
10
+ module ArtifactoryClient
11
+ extend self
12
+
13
+ def artifactory_uri?(uri)
14
+ # Hostnames are case-insensitive, so downcase before the suffix check; an
15
+ # uppercase jfrog host would otherwise be misread as an unqueryable source.
16
+ uri.is_a?(String) && URI(uri).host&.downcase&.end_with?(".jfrog.io")
17
+ rescue URI::InvalidURIError
18
+ false
19
+ end
20
+
21
+ def versions(gem_name:, source_uri:)
22
+ headers = auth_headers(gem_name: gem_name, source_uri: source_uri)
23
+ vs = RubygemsClient.versions(gem_name: gem_name, source_uri: source_uri, headers: headers)
24
+ return vs unless vs.empty?
25
+
26
+ AqlClient.versions(gem_name: gem_name, source_uri: source_uri, headers: headers)
27
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError
28
+ []
29
+ end
30
+
31
+ private
32
+
33
+ def credentials(gem_name:, source_uri:)
34
+ host = URI(source_uri).host
35
+ bundler = Bundler.settings[source_uri] || Bundler.settings[host]
36
+ return bundler if bundler && !bundler.empty?
37
+
38
+ global = StillActive.config.artifactory_token
39
+ return unless global
40
+
41
+ configured_host = StillActive.config.artifactory_host
42
+ unless configured_host && host&.casecmp?(configured_host)
43
+ warn_unauthorized_host(gem_name: gem_name, host: host)
44
+ return
45
+ end
46
+
47
+ global
48
+ end
49
+
50
+ def warn_unauthorized_host(gem_name:, host:)
51
+ $stderr.puts(
52
+ "warning: an Artifactory token is set but #{host} (source for #{gem_name}) is not an authorized host, " \
53
+ "so the token will not be sent. " \
54
+ "To allow it, set --artifactory-host=#{host} or STILL_ACTIVE_ARTIFACTORY_HOST=#{host}",
55
+ )
56
+ end
57
+
58
+ def auth_headers(gem_name:, source_uri:)
59
+ creds = credentials(gem_name: gem_name, source_uri: source_uri)
60
+ return {} unless creds
61
+
62
+ if creds.include?(":")
63
+ user, pass = creds.split(":", 2).map { |part| CGI.unescape(part) }
64
+ { "Authorization" => "Basic #{["#{user}:#{pass}"].pack("m0")}" }
65
+ else
66
+ { "Authorization" => "Bearer #{creds}" }
67
+ end
68
+ end
69
+
70
+ # Artifactory's Rubygems-compatible API
71
+ module RubygemsClient
72
+ extend self
73
+
74
+ def versions(gem_name:, source_uri:, headers: {})
75
+ base = URI(source_uri.chomp("/"))
76
+ path = "#{base.path}/api/v1/versions/#{encode(gem_name)}.json"
77
+ HttpHelper.get_json(base, path, headers: headers) || []
78
+ end
79
+
80
+ private
81
+
82
+ def encode(value)
83
+ CGI.escape(value)
84
+ end
85
+ end
86
+
87
+ # AQL stands for Artifactory Query Language
88
+ # https://docs.jfrog.com/artifactory/docs/artifactory-query-language
89
+ module AqlClient
90
+ extend self
91
+
92
+ SOURCE_URL_PATTERN = %r{\A(https?://[^/]+\.jfrog\.io/[^/]+)/api/gems/([^/]+)/?\z}
93
+ AQL_PATH = "/api/search/aql"
94
+
95
+ def versions(gem_name:, source_uri:, headers: {})
96
+ artifactory_base, repo_key = parse_source_url(source_uri)
97
+ return [] if artifactory_base.nil?
98
+
99
+ base = URI(artifactory_base)
100
+ path = "#{base.path}#{AQL_PATH}"
101
+ query = {
102
+ "name" => { "$match" => "#{gem_name}-*.gem" },
103
+ "repo" => repo_key,
104
+ }
105
+ body = %(items.find(#{JSON.generate(query)}).include("repo", "path", "name", "created"))
106
+ response = HttpHelper.post_json(base, path, body: body, headers: headers.merge("Content-Type" => "text/plain"))
107
+ return [] if response.nil?
108
+
109
+ results = response["results"] || []
110
+ build_version_hashes(results: results, gem_name: gem_name)
111
+ end
112
+
113
+ private
114
+
115
+ def parse_source_url(source_uri)
116
+ match = source_uri.match(SOURCE_URL_PATTERN)
117
+ unless match
118
+ $stderr.puts("warning: unrecognized Artifactory source URL for AQL fallback: #{source_uri}")
119
+ return [nil, nil]
120
+ end
121
+
122
+ [match[1], match[2]]
123
+ end
124
+
125
+ def build_version_hashes(results:, gem_name:)
126
+ results
127
+ .filter_map do |item|
128
+ version = extract_version(item["name"], gem_name)
129
+ next if version.nil?
130
+
131
+ {
132
+ "number" => version,
133
+ "created_at" => item["created"],
134
+ "prerelease" => Gem::Version.new(version).prerelease?,
135
+ }
136
+ end
137
+ .uniq { |h| h["number"] }
138
+ .sort_by { |h| Gem::Version.new(h["number"]) }
139
+ .reverse
140
+ end
141
+
142
+ def extract_version(filename, gem_name)
143
+ prefix = "#{gem_name}-"
144
+ return unless filename&.end_with?(".gem") && filename.start_with?(prefix)
145
+
146
+ version_part = filename[prefix.length..-5]
147
+ return if version_part.nil? || version_part.empty?
148
+
149
+ parse_version_from_filename_tail(version_part)
150
+ end
151
+
152
+ # Given the portion of a `.gem` filename after `name-`, returns the
153
+ # leading semver (e.g. `7.0.0` from `7.0.0-x86_64-linux`). Ignores
154
+ # unrelated artifacts that share a prefix (e.g. `datadog-ruby_core_source`
155
+ # when the gem name is `datadog`).
156
+ def parse_version_from_filename_tail(version_part)
157
+ segments = version_part.split("-")
158
+ segments.length.times do |i|
159
+ candidate = segments[0, i + 1].join("-")
160
+ return candidate if Gem::Version.correct?(candidate)
161
+ end
162
+ nil
163
+ end
164
+ end
165
+ end
166
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "options"
4
+ require_relative "config_file"
4
5
  require_relative "diff"
5
6
  require_relative "../helpers/activity_helper"
6
7
  require_relative "../helpers/bot_context"
@@ -10,6 +11,7 @@ require_relative "../helpers/diff_markdown_helper"
10
11
  require_relative "../helpers/emoji_helper"
11
12
  require_relative "../helpers/markdown_helper"
12
13
  require_relative "../helpers/sarif_helper"
14
+ require_relative "../helpers/summary_helper"
13
15
  require_relative "../helpers/terminal_helper"
14
16
  require_relative "../helpers/version_helper"
15
17
  require_relative "../helpers/vulnerability_helper"
@@ -17,8 +19,20 @@ require_relative "workflow"
17
19
 
18
20
  module StillActive
19
21
  class CLI
22
+ # The committed JSON Schema for the --json output. Emitted as `$schema` so
23
+ # the output is self-describing and a consumer can validate it.
24
+ SCHEMA_URL = "https://raw.githubusercontent.com/SeanLF/still_active/main/docs/still_active.schema.json"
25
+
20
26
  def run(args)
27
+ # Apply the committed .still_active.yml first so CLI flags (parsed next)
28
+ # win over it: CLI flag > env var > config file > default.
29
+ config_data = ConfigFile.load
30
+ ConfigFile.apply(StillActive.config, config_data).each { |warning| $stderr.puts("warning: #{warning}") }
21
31
  options = Options.new.parse!(args)
32
+ # After CLI flags resolve, nudge (don't auto-inherit) an un-imported
33
+ # bundler-audit ignore list when the vulnerability gate is on.
34
+ hint = ConfigFile.import_hint(config_data)
35
+ $stderr.puts("hint: #{hint}") if hint
22
36
  unless options[:provided_gems]
23
37
  begin
24
38
  StillActive.config.gems = BundlerHelper.gemfile_dependencies
@@ -29,6 +43,7 @@ module StillActive
29
43
  end
30
44
 
31
45
  warn_output_flag_conflicts(options)
46
+ warn_stale_suppressions
32
47
 
33
48
  result = if $stderr.tty?
34
49
  Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
@@ -50,14 +65,20 @@ module StillActive
50
65
  case resolve_format
51
66
  when :json
52
67
  output = {
68
+ "$schema": SCHEMA_URL,
53
69
  schema_version: 1,
54
70
  tool: { name: "still_active", version: StillActive::VERSION },
55
71
  generated_at: Time.now.utc.iso8601,
56
- gems: result,
72
+ # A one-object digest of the audit's posture, so a machine/LLM
73
+ # consumer reads the headline counts without iterating every gem.
74
+ summary: SummaryHelper.summarize(result, ruby_info: ruby_info),
75
+ # Surface the derived verdict so a machine/LLM consumer reads it
76
+ # directly instead of re-deriving it from the raw dates.
77
+ gems: result.transform_values { |data| data.merge(activity_level: ActivityHelper.activity_level(data)) },
57
78
  }
58
79
  output[:ruby] = ruby_info if ruby_info
59
80
  output[:pr_context] = pr_context if pr_context
60
- puts output.to_json
81
+ puts iso8601_times(output).to_json
61
82
  when :terminal
62
83
  puts BotContext.summary(pr_context) if pr_context
63
84
  puts TerminalHelper.render(result, ruby_info: ruby_info)
@@ -71,6 +92,29 @@ module StillActive
71
92
 
72
93
  private
73
94
 
95
+ # Dates live in the result as real Time objects (the activity/libyear math
96
+ # needs them), but the JSON contract is ISO8601 UTC strings, matching
97
+ # generated_at. Normalize at the serialization boundary only, so the
98
+ # terminal/markdown/SARIF paths keep their Time objects untouched.
99
+ def iso8601_times(value)
100
+ case value
101
+ when Time then value.utc.iso8601
102
+ when Hash then value.transform_values { |v| iso8601_times(v) }
103
+ when Array then value.map { |v| iso8601_times(v) }
104
+ else value
105
+ end
106
+ end
107
+
108
+ # A suppression naming a gem that isn't in the resolved dependency set can
109
+ # never fire, so it's dead config worth surfacing (the presence half of
110
+ # suppression rot, alongside the expiry half the entries handle themselves).
111
+ def warn_stale_suppressions
112
+ present = StillActive.config.gems.map { |gem| gem[:name] }
113
+ StillActive.config.suppressions.stale_gem_warnings(present).each do |warning|
114
+ $stderr.puts("warning: #{warning}")
115
+ end
116
+ end
117
+
74
118
  # The output destinations are mutually exclusive and resolved by precedence
75
119
  # (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than
76
120
  # silently dropping the loser when more than one is set.
@@ -192,6 +236,8 @@ module StillActive
192
236
  end
193
237
  alternatives = MarkdownHelper.alternatives_section(result)
194
238
  puts alternatives unless alternatives.empty?
239
+ transitive = MarkdownHelper.transitive_section(result)
240
+ puts transitive unless transitive.empty?
195
241
  if ruby_info
196
242
  puts ""
197
243
  puts MarkdownHelper.ruby_line(ruby_info)
@@ -202,27 +248,50 @@ module StillActive
202
248
  config = StillActive.config
203
249
  return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
204
250
 
205
- ignored = config.ignored_gems
206
- checked = result.reject { |name, _| ignored.include?(name) }
251
+ exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
252
+ end
207
253
 
208
- if config.fail_if_critical || config.fail_if_warning
209
- levels = checked.each_value.map { |gem_data| ActivityHelper.activity_level(gem_data) }
210
- exit(1) if config.fail_if_warning && levels.intersect?([:stale, :critical, :archived])
211
- exit(1) if config.fail_if_critical && levels.intersect?([:critical, :archived])
212
- end
254
+ # A gem fails the run when it trips an enabled gate that is neither
255
+ # whole-gem --ignore'd nor covered by a granular .still_active.yml
256
+ # suppression. Each signal is checked independently so accepting one finding
257
+ # (e.g. a single advisory) never blinds the others.
258
+ def gate_failed?(name, data, config)
259
+ return false if config.ignored_gems.include?(name)
213
260
 
214
- if (vuln_setting = config.fail_if_vulnerable)
215
- checked.each_value do |d|
216
- next unless d[:vulnerability_count]&.positive?
261
+ suppressions = config.suppressions
262
+ failed_activity?(name, data, config, suppressions) ||
263
+ failed_vulnerability?(name, data, config, suppressions) ||
264
+ failed_outdated?(name, data, config, suppressions)
265
+ end
217
266
 
218
- exit(1) if vuln_setting == true
219
- exit(1) if VulnerabilityHelper.severity_at_or_above?(d[:vulnerabilities], vuln_setting)
220
- end
221
- end
267
+ def failed_activity?(name, data, config, suppressions)
268
+ return false unless config.fail_if_warning || config.fail_if_critical
269
+ return false if suppressions.suppressed?(gem: name, signal: :activity)
270
+
271
+ level = ActivityHelper.activity_level(data)
272
+ (config.fail_if_warning && [:stale, :critical, :archived].include?(level)) ||
273
+ (config.fail_if_critical && [:critical, :archived].include?(level))
274
+ end
275
+
276
+ def failed_vulnerability?(name, data, config, suppressions)
277
+ setting = config.fail_if_vulnerable
278
+ return false unless setting
279
+ return false unless data[:vulnerability_count]&.positive?
222
280
 
223
- if (threshold = config.fail_if_outdated)
224
- exit(1) if checked.each_value.any? { |d| d[:libyear] && d[:libyear] > threshold }
281
+ live = Array(data[:vulnerabilities]).reject do |vuln|
282
+ suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
225
283
  end
284
+ return false if live.empty?
285
+
286
+ setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
287
+ end
288
+
289
+ def failed_outdated?(name, data, config, suppressions)
290
+ threshold = config.fail_if_outdated
291
+ return false unless threshold
292
+ return false if suppressions.suppressed?(gem: name, signal: :libyear)
293
+
294
+ data[:libyear] && data[:libyear] > threshold
226
295
  end
227
296
  end
228
297
  end
@@ -3,11 +3,14 @@
3
3
  require "bundler"
4
4
  require "octokit"
5
5
  require "open3"
6
+ require_relative "suppressions"
6
7
 
7
8
  module StillActive
8
9
  class Config
9
- attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
10
+ attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :gemfile_path
10
11
  attr_accessor :alternatives,
12
+ :unreleased_commits,
13
+ :direct_only,
11
14
  :baseline_path,
12
15
  :critical_warning_emoji,
13
16
  :cyclonedx_path,
@@ -24,12 +27,15 @@ module StillActive
24
27
  :no_warning_range_end,
25
28
  :sarif_path,
26
29
  :success_emoji,
30
+ :suppressions,
27
31
  :unsure_emoji,
28
32
  :warning_emoji,
29
33
  :warning_range_end
30
34
 
31
35
  def initialize
32
36
  @alternatives = false
37
+ @unreleased_commits = false
38
+ @direct_only = false
33
39
  @fail_if_critical = false
34
40
  @fail_if_outdated = nil
35
41
  @fail_if_vulnerable = nil
@@ -37,8 +43,12 @@ module StillActive
37
43
  @gemfile_path = nil
38
44
  @gems = []
39
45
  @ignored_gems = []
46
+ @suppressions = Suppressions.from(nil)
40
47
  @github_oauth_token = nil
41
48
  @gitlab_token = nil
49
+ @forgejo_token = nil
50
+ @artifactory_token = nil
51
+ @artifactory_host = nil
42
52
 
43
53
  @parallelism = 10
44
54
 
@@ -54,7 +64,11 @@ module StillActive
54
64
  @unsure_emoji = "❓"
55
65
  @warning_emoji = "⚠️"
56
66
 
57
- @no_warning_range_end = 1
67
+ # Release-age thresholds (years) calibrated against real RubyGems cadence,
68
+ # not the npm-derived 12-month convention: healthy mature gems routinely go
69
+ # 12-18 months between releases, so the ok ceiling is 18 months. > 3 years
70
+ # is critical. See #32.
71
+ @no_warning_range_end = 1.5
58
72
  @warning_range_end = 3
59
73
  end
60
74
 
@@ -71,6 +85,22 @@ module StillActive
71
85
  @gitlab_token ||= presence(ENV["GITLAB_TOKEN"]) || glab_cli_token
72
86
  end
73
87
 
88
+ # Codeberg/Forgejo has no ubiquitous CLI to borrow a token from (unlike
89
+ # gh/glab), so it's env-var only. Anonymous works for public repos; a token
90
+ # only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
91
+ # alias for the codeberg.org default host.
92
+ def forgejo_token
93
+ @forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
94
+ end
95
+
96
+ def artifactory_token
97
+ @artifactory_token ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_TOKEN"])
98
+ end
99
+
100
+ def artifactory_host
101
+ @artifactory_host ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_HOST"])
102
+ end
103
+
74
104
  # Lazy so that running with --gems=... (no Gemfile needed) doesn't crash
75
105
  # when invoked from a directory without a Gemfile in the tree.
76
106
  def gemfile_path