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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +97 -12
- data/lib/helpers/activity_helper.rb +34 -9
- data/lib/helpers/alternatives_helper.rb +42 -0
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +101 -0
- data/lib/helpers/cyclonedx_helper.rb +24 -8
- data/lib/helpers/diff_markdown_helper.rb +15 -12
- data/lib/helpers/http_helper.rb +101 -14
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +37 -4
- data/lib/helpers/ruby_helper.rb +5 -4
- data/lib/helpers/sarif_helper.rb +60 -26
- data/lib/helpers/summary_helper.rb +48 -0
- data/lib/helpers/terminal_helper.rb +45 -18
- data/lib/helpers/version_helper.rb +7 -1
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/cli.rb +89 -18
- data/lib/still_active/config.rb +35 -3
- data/lib/still_active/config_file.rb +180 -0
- data/lib/still_active/deps_dev_client.rb +27 -6
- data/lib/still_active/diff.rb +59 -2
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +15 -5
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +2 -2
- data/lib/still_active/suppressions.rb +142 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +130 -55
- data/still_active.gemspec +14 -8
- metadata +23 -10
data/lib/helpers/sarif_helper.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
years = ((Time.now -
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 << "#{
|
|
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
|
-
|
|
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
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
251
|
+
exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
|
|
252
|
+
end
|
|
205
253
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|