still_active 1.5.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.
@@ -5,6 +5,7 @@ require "digest"
5
5
  require "time"
6
6
  require_relative "../still_active/sarif/rules"
7
7
  require_relative "lockfile_indexer"
8
+ require_relative "activity_helper"
8
9
 
9
10
  module StillActive
10
11
  # Renders a still_active workflow result as a SARIF 2.1.0 document.
@@ -19,7 +20,7 @@ module StillActive
19
20
 
20
21
  LIBYEAR_THRESHOLD = 1.0
21
22
  SCORECARD_LOW_THRESHOLD = 4.0
22
- ABANDONED_SECONDS = 2 * 365 * 24 * 60 * 60 # 2 years
23
+ SECONDS_PER_YEAR = 365 * 24 * 60 * 60 # for the human-readable "in N years"
23
24
 
24
25
  # result: same hash StillActive::Workflow.call returns (gem_name => gem_data)
25
26
  # ruby_info: optional Ruby freshness hash (or nil)
@@ -98,43 +99,68 @@ module StillActive
98
99
  location = location_for(name, line_index, lockfile_uri)
99
100
 
100
101
  if data[:archived]
101
- out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}.", location)
102
+ out << mark_suppressed(result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), name, :activity)
102
103
  end
103
104
 
104
105
  unless data[:archived]
105
- last_commit = parse_time(data[:last_commit_date])
106
- if last_commit && last_commit < (Time.now - ABANDONED_SECONDS)
107
- years = ((Time.now - last_commit) / (365 * 24 * 60 * 60)).round(1)
108
- out << result(
109
- "SA002",
106
+ if ActivityHelper.activity_level(data) == :critical
107
+ activity = ActivityHelper.last_activity(data)
108
+ years = ((Time.now - activity[:date]) / SECONDS_PER_YEAR).round(1)
109
+ noun = activity[:kind] == :release ? "no release" : "no commits"
110
+ out << mark_suppressed(
111
+ result(
112
+ "SA002",
113
+ name,
114
+ "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
115
+ location,
116
+ ),
110
117
  name,
111
- "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")}).",
112
- location,
118
+ :activity,
113
119
  )
114
120
  end
115
121
  end
116
122
 
117
123
  Array(data[:vulnerabilities]).each do |vuln|
118
- out << vulnerability_result(name, version, vuln, location)
124
+ out << vulnerability_result(name, version, vuln, location, data)
119
125
  end
120
126
 
121
127
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
122
128
  latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
123
- out << result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}.", location)
129
+ out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
124
130
  end
125
131
 
126
132
  if data[:scorecard_score] && data[:scorecard_score] < SCORECARD_LOW_THRESHOLD
127
- out << result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location)
133
+ out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
128
134
  end
129
135
 
130
136
  if data[:version_yanked]
131
- out << result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location)
137
+ out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
132
138
  end
133
139
 
134
140
  out
135
141
  end
136
142
 
137
- def vulnerability_result(name, version, vuln, location)
143
+ # Attaches a SARIF native suppressions[] entry when this finding is covered
144
+ # by a whole-gem --ignore or a granular .still_active.yml suppression, so a
145
+ # GitHub code-scanning consumer renders it dismissed rather than open. The
146
+ # suppression's reason rides along as the justification.
147
+ def mark_suppressed(result_hash, gem_name, signal, advisory: nil, aliases: [])
148
+ config = StillActive.config
149
+ if config.ignored_gems.include?(gem_name)
150
+ result_hash["suppressions"] = [{ "kind" => "external", "justification" => "ignored via --ignore" }]
151
+ return result_hash
152
+ end
153
+
154
+ entry = config.suppressions.match(gem: gem_name, signal: signal, advisory: advisory, aliases: aliases)
155
+ return result_hash unless entry
156
+
157
+ suppression = { "kind" => "external" }
158
+ suppression["justification"] = entry.reason if entry.reason
159
+ result_hash["suppressions"] = [suppression]
160
+ result_hash
161
+ end
162
+
163
+ def vulnerability_result(name, version, vuln, location, data = {})
138
164
  score = vuln[:cvss3_score] || vuln[:cvss2_score]
139
165
  level = Sarif::Rules.cvss_to_level(score)
140
166
  severity = Sarif::Rules.cvss_to_security_severity(score)
@@ -146,13 +172,23 @@ module StillActive
146
172
  base = result(
147
173
  "SA003",
148
174
  name,
149
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}.",
175
+ "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{transitive_suffix(data)}.",
150
176
  location,
151
177
  level: level,
152
178
  fp_extra: advisory_id,
153
179
  )
154
180
  base["properties"] = { "security-severity" => severity } if severity
155
- base
181
+ mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
182
+ end
183
+
184
+ # Names the direct dependency a transitive flagged gem rides in on, so a
185
+ # code-scanning consumer gets the actionable "replace your direct gem" hop
186
+ # instead of an un-actionable transitive finding (#60).
187
+ def transitive_suffix(data)
188
+ path = data[:dependency_path]
189
+ return "" unless data[:direct] == false && path && path.length >= 2
190
+
191
+ " (transitive, pulled in by #{path.first})"
156
192
  end
157
193
 
158
194
  def ruby_eol_result(ruby_info, ruby_line, lockfile_uri)
@@ -211,22 +247,20 @@ module StillActive
211
247
  Sarif::Rules.all.index { |r| r[:id] == rule_id }
212
248
  end
213
249
 
214
- def parse_time(value)
215
- return value if value.is_a?(Time)
216
- return if value.nil?
217
-
218
- Time.parse(value.to_s)
219
- rescue ArgumentError, TypeError, RangeError
220
- nil
221
- end
222
-
223
250
  def format_date(value)
224
- t = parse_time(value)
251
+ t = ActivityHelper.parse_time(value)
225
252
  t ? t.utc.strftime("%Y-%m-%d") : value.to_s
226
253
  end
227
254
 
228
255
  def repo_suffix(data)
229
256
  data[:repository_url] ? " (#{data[:repository_url]})" : ""
230
257
  end
258
+
259
+ def alternatives_suffix(data)
260
+ leads = data[:alternatives]
261
+ return "" if leads.nil? || leads.empty?
262
+
263
+ " Consider: #{leads.join(", ")}"
264
+ end
231
265
  end
232
266
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activity_helper"
4
+
5
+ module StillActive
6
+ # Builds the JSON output's summary{} digest: the headline posture of the audit
7
+ # in one object, so a machine/LLM consumer reads the totals directly instead
8
+ # of iterating every gem. Counts are derived from the canonical per-gem fields
9
+ # (activity_level, archived, up_to_date, vulnerability_count) so they never
10
+ # drift from a separately-thresholded SARIF rule.
11
+ module SummaryHelper
12
+ ACTIVITY_LEVELS = [:ok, :stale, :critical, :archived, :unknown].freeze
13
+
14
+ extend self
15
+
16
+ def summarize(result, ruby_info: nil)
17
+ activity = ACTIVITY_LEVELS.to_h { |level| [level, 0] }
18
+ archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = direct = 0
19
+
20
+ result.each_value do |data|
21
+ activity[ActivityHelper.activity_level(data)] += 1
22
+ direct += 1 if data[:direct]
23
+ archived += 1 if data[:archived]
24
+ up_to_date += 1 if data[:up_to_date] == true
25
+ outdated += 1 if data[:up_to_date] == false
26
+ count = data[:vulnerability_count].to_i
27
+ next unless count.positive?
28
+
29
+ vulnerable_gems += 1
30
+ vulnerabilities += count
31
+ end
32
+
33
+ summary = {
34
+ total_gems: result.size,
35
+ direct: direct,
36
+ transitive: result.size - direct,
37
+ activity: activity,
38
+ archived: archived,
39
+ up_to_date: up_to_date,
40
+ outdated: outdated,
41
+ vulnerable_gems: vulnerable_gems,
42
+ vulnerabilities: vulnerabilities,
43
+ }
44
+ summary[:ruby_eol] = ruby_info[:eol] == true if ruby_info
45
+ summary
46
+ end
47
+ end
48
+ end
@@ -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"
@@ -13,13 +14,21 @@ module StillActive
13
14
  HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "License"].freeze
14
15
 
15
16
  def render(result, ruby_info: nil)
16
- rows = result.keys.sort.map { |name| build_row(name, result[name]) }
17
+ names = result.keys.sort
18
+ rows = names.map { |name| build_row(name, result[name]) }
17
19
  widths = column_widths(rows)
18
20
 
19
21
  lines = []
20
22
  lines << header_line(widths)
21
23
  lines << separator_line(widths)
22
- rows.each { |row| lines << row_line(row, widths) }
24
+ names.each_with_index do |name, i|
25
+ lines << row_line(rows[i], widths)
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)
30
+ lines << extra if extra
31
+ end
23
32
  lines << ""
24
33
  lines << summary_line(result)
25
34
  lines << ruby_summary_line(ruby_info) if ruby_info
@@ -125,6 +134,30 @@ module StillActive
125
134
  .join
126
135
  end
127
136
 
137
+ def alternatives_line(data)
138
+ level = ActivityHelper.activity_level(data)
139
+ return unless [:archived, :critical].include?(level)
140
+
141
+ leads = data[:alternatives]
142
+ if leads && !leads.empty?
143
+ AnsiHelper.dim(" ↳ leads (Ruby Toolbox): #{leads.join(" · ")} (verify fit)")
144
+ elsif !StillActive.config.alternatives
145
+ AnsiHelper.dim(" ↳ run with --alternatives for maintained replacements")
146
+ end
147
+ end
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
+
128
161
  def ruby_summary_line(ruby_info)
129
162
  version = ruby_info[:version]
130
163
  latest = ruby_info[:latest_version]
@@ -144,29 +177,23 @@ module StillActive
144
177
  end
145
178
  end
146
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.
147
184
  def summary_line(result)
148
- total = result.size
149
- level_counts = result.each_value.map { |d| ActivityHelper.activity_level(d) }.tally
150
- version_counts = result.each_value
151
- .map { |d| VersionHelper.up_to_date(version_used: d[:version_used], latest_version: d[:latest_version]) }
152
- .tally
153
-
154
- up_to_date = version_counts.fetch(true, 0)
155
- 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]
156
189
  yanked = result.each_value.count { |d| d[:version_yanked] }
157
- active = level_counts.fetch(:ok, 0)
158
- archived = level_counts.fetch(:archived, 0)
159
- stale = level_counts.fetch(:stale, 0) + level_counts.fetch(:critical, 0)
160
- vulns = result.each_value.sum { |d| d[:vulnerability_count] || 0 }
161
190
 
162
- parts = [
163
- "#{total} gems: #{up_to_date} up to date, #{outdated} outdated",
164
- ]
191
+ parts = ["#{summary[:total_gems]} gems: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
165
192
  parts.last << ", #{yanked} yanked" if yanked > 0
166
193
  activity = "#{active} active, #{stale} stale"
167
194
  activity << ", #{archived} archived" if archived > 0
168
195
  parts << activity
169
- parts << "#{vulns} vulnerabilities"
196
+ parts << "#{summary[:vulnerabilities]} vulnerabilities"
170
197
  total_libyear = LibyearHelper.total_libyear(result)
171
198
  parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
172
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.
@@ -190,6 +234,10 @@ module StillActive
190
234
 
191
235
  puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
192
236
  end
237
+ alternatives = MarkdownHelper.alternatives_section(result)
238
+ puts alternatives unless alternatives.empty?
239
+ transitive = MarkdownHelper.transitive_section(result)
240
+ puts transitive unless transitive.empty?
193
241
  if ruby_info
194
242
  puts ""
195
243
  puts MarkdownHelper.ruby_line(ruby_info)
@@ -200,27 +248,50 @@ module StillActive
200
248
  config = StillActive.config
201
249
  return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
202
250
 
203
- ignored = config.ignored_gems
204
- checked = result.reject { |name, _| ignored.include?(name) }
251
+ exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
252
+ end
205
253
 
206
- if config.fail_if_critical || config.fail_if_warning
207
- levels = checked.each_value.map { |gem_data| ActivityHelper.activity_level(gem_data) }
208
- exit(1) if config.fail_if_warning && levels.intersect?([:stale, :critical, :archived])
209
- exit(1) if config.fail_if_critical && levels.intersect?([:critical, :archived])
210
- 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)
211
260
 
212
- if (vuln_setting = config.fail_if_vulnerable)
213
- checked.each_value do |d|
214
- 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
215
266
 
216
- exit(1) if vuln_setting == true
217
- exit(1) if VulnerabilityHelper.severity_at_or_above?(d[:vulnerabilities], vuln_setting)
218
- end
219
- 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?
220
280
 
221
- if (threshold = config.fail_if_outdated)
222
- 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]))
223
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
224
295
  end
225
296
  end
226
297
  end