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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +79 -12
- data/lib/helpers/activity_helper.rb +34 -9
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- 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 +27 -5
- data/lib/helpers/ruby_helper.rb +5 -4
- data/lib/helpers/sarif_helper.rb +53 -26
- data/lib/helpers/summary_helper.rb +48 -0
- data/lib/helpers/terminal_helper.rb +27 -17
- data/lib/helpers/version_helper.rb +7 -1
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/cli.rb +87 -18
- data/lib/still_active/config.rb +32 -2
- 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 +14 -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 +113 -55
- data/still_active.gemspec +9 -7
- metadata +19 -8
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Granular, auditable suppression of individual findings, loaded from the
|
|
7
|
+
# `ignore:` block of a committed .still_active.yml. Each entry silences one
|
|
8
|
+
# signal (activity / libyear) or one advisory for one gem, optionally until an
|
|
9
|
+
# expiry date, replacing the all-or-nothing whole-gem --ignore. A bare gem
|
|
10
|
+
# name (or a gem-only mapping) keeps the old whole-gem behaviour so --ignore
|
|
11
|
+
# can union into the same list.
|
|
12
|
+
#
|
|
13
|
+
# Two guardrails keep suppression from hiding live risk: a lapsed entry stops
|
|
14
|
+
# applying so the finding re-surfaces, and a vulnerability suppression must
|
|
15
|
+
# name an explicit advisory id, so a newly disclosed CVE on the same gem is
|
|
16
|
+
# never pre-silenced.
|
|
17
|
+
class Suppressions
|
|
18
|
+
GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear].freeze
|
|
19
|
+
|
|
20
|
+
Entry = Struct.new(:gem, :advisory, :signal, :reason, :expires, keyword_init: true) do
|
|
21
|
+
def whole_gem?
|
|
22
|
+
signal.nil? && advisory.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expired?(today)
|
|
26
|
+
!expires.nil? && expires < today
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def covers?(signal:, advisory:, aliases:)
|
|
30
|
+
return true if whole_gem?
|
|
31
|
+
|
|
32
|
+
if self.signal == :vulnerability
|
|
33
|
+
[advisory, *aliases].compact.include?(self.advisory)
|
|
34
|
+
else
|
|
35
|
+
self.signal == signal
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def from(raw, today: Date.today)
|
|
42
|
+
warnings = []
|
|
43
|
+
entries = Array(raw).filter_map { |item| parse_entry(item, warnings) }
|
|
44
|
+
new(entries, warnings, today)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_entry(item, warnings)
|
|
50
|
+
return Entry.new(gem: item, advisory: nil, signal: nil, reason: nil, expires: nil) if item.is_a?(String)
|
|
51
|
+
|
|
52
|
+
unless item.is_a?(Hash)
|
|
53
|
+
warnings << "ignoring suppression entry #{item.inspect}: expected a gem name or a mapping"
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
gem = item["gem"]
|
|
58
|
+
advisory = item["advisory"]
|
|
59
|
+
signal = item["signal"]&.to_sym
|
|
60
|
+
signal ||= :vulnerability if advisory
|
|
61
|
+
expires = parse_expires(item["expires"], item, warnings)
|
|
62
|
+
return if expires == :invalid
|
|
63
|
+
|
|
64
|
+
label = gem || advisory || "entry"
|
|
65
|
+
return unless valid?(gem:, advisory:, signal:, label:, warnings:)
|
|
66
|
+
|
|
67
|
+
Entry.new(gem:, advisory:, signal:, reason: item["reason"], expires:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns true when the entry is well-formed, otherwise records a warning
|
|
71
|
+
# and returns false so the caller skips (does not apply) the entry.
|
|
72
|
+
def valid?(gem:, advisory:, signal:, label:, warnings:)
|
|
73
|
+
if gem.nil? && signal.nil? && advisory.nil?
|
|
74
|
+
warnings << "ignoring suppression entry: needs a gem, signal, or advisory"
|
|
75
|
+
return false
|
|
76
|
+
end
|
|
77
|
+
if signal && !GATEABLE_SIGNALS.include?(signal)
|
|
78
|
+
warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, or libyear)"
|
|
79
|
+
return false
|
|
80
|
+
end
|
|
81
|
+
if signal == :vulnerability && advisory.nil?
|
|
82
|
+
warnings << "ignoring vulnerability suppression for #{label}: must name an advisory id so newly disclosed advisories still surface"
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
if [:activity, :libyear].include?(signal) && gem.nil?
|
|
86
|
+
warnings << "ignoring #{signal} suppression: must name a gem"
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_expires(value, item, warnings)
|
|
94
|
+
return if value.nil?
|
|
95
|
+
return value if value.is_a?(Date)
|
|
96
|
+
|
|
97
|
+
Date.parse(value.to_s)
|
|
98
|
+
rescue ArgumentError, TypeError
|
|
99
|
+
warnings << "ignoring suppression #{item.inspect}: unparseable expires #{value.inspect}"
|
|
100
|
+
:invalid
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
attr_reader :warnings
|
|
105
|
+
|
|
106
|
+
def initialize(entries, warnings, today)
|
|
107
|
+
@entries = entries
|
|
108
|
+
@warnings = warnings
|
|
109
|
+
@today = today
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def suppressed?(gem:, signal:, advisory: nil, aliases: [])
|
|
113
|
+
!match(gem:, signal:, advisory:, aliases:).nil?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Warnings for live entries that name a gem absent from the audited set: they
|
|
117
|
+
# can never match, so they are dead config (a typo, or a gem removed since
|
|
118
|
+
# the suppression was written). This is the presence axis of suppression rot;
|
|
119
|
+
# `expired?` already covers the time axis, so an expired entry isn't
|
|
120
|
+
# re-reported here, and a gem-agnostic advisory entry (gem nil) is skipped
|
|
121
|
+
# since it applies across the whole graph.
|
|
122
|
+
def stale_gem_warnings(present_gems)
|
|
123
|
+
@entries.filter_map do |entry|
|
|
124
|
+
next if entry.expired?(@today)
|
|
125
|
+
next unless entry.gem && !present_gems.include?(entry.gem)
|
|
126
|
+
|
|
127
|
+
"suppression for #{entry.gem} never applies: it is not in the audited dependencies (typo, or removed since it was suppressed?)"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The first live entry covering this finding, or nil. Used by SARIF to carry
|
|
132
|
+
# the suppression's reason as the native suppressions[] justification.
|
|
133
|
+
def match(gem:, signal:, advisory: nil, aliases: [])
|
|
134
|
+
@entries.find do |entry|
|
|
135
|
+
next false if entry.expired?(@today)
|
|
136
|
+
next false unless entry.gem.nil? || entry.gem == gem
|
|
137
|
+
|
|
138
|
+
entry.covers?(signal:, advisory:, aliases:)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/still_active/version.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "artifactory_client"
|
|
3
4
|
require_relative "deps_dev_client"
|
|
5
|
+
require_relative "forgejo_client"
|
|
6
|
+
require_relative "github_client"
|
|
4
7
|
require_relative "gitlab_client"
|
|
5
8
|
require_relative "repository"
|
|
6
9
|
require_relative "../helpers/activity_helper"
|
|
@@ -14,6 +17,7 @@ require_relative "../helpers/vulnerability_helper"
|
|
|
14
17
|
require "async"
|
|
15
18
|
require "async/barrier"
|
|
16
19
|
require "async/semaphore"
|
|
20
|
+
require "cgi"
|
|
17
21
|
require "gems"
|
|
18
22
|
|
|
19
23
|
module StillActive
|
|
@@ -39,6 +43,8 @@ module StillActive
|
|
|
39
43
|
gem_version: gem[:version],
|
|
40
44
|
source_type: gem[:source_type] || :rubygems,
|
|
41
45
|
source_uri: gem[:source_uri],
|
|
46
|
+
direct: gem.fetch(:direct, true),
|
|
47
|
+
dependency_path: gem[:dependency_path],
|
|
42
48
|
advisory_db: advisory_db,
|
|
43
49
|
catalog: catalog,
|
|
44
50
|
)
|
|
@@ -54,7 +60,10 @@ module StillActive
|
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
62
|
barrier.wait
|
|
57
|
-
|
|
63
|
+
# Gems are inserted as their async tasks finish, so the natural order is
|
|
64
|
+
# nondeterministic completion order. Sort by name once here so every
|
|
65
|
+
# consumer (JSON, SARIF, the baseline diff) gets a stable, diffable order.
|
|
66
|
+
result_object.sort_by { |name, _| name }.to_h
|
|
58
67
|
end
|
|
59
68
|
task.wait
|
|
60
69
|
end
|
|
@@ -65,8 +74,9 @@ module StillActive
|
|
|
65
74
|
|
|
66
75
|
private
|
|
67
76
|
|
|
68
|
-
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil, catalog: nil)
|
|
69
|
-
result_object[gem_name] = { source_type: source_type }
|
|
77
|
+
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, direct: true, dependency_path: nil, advisory_db: nil, catalog: nil)
|
|
78
|
+
result_object[gem_name] = { source_type: source_type, direct: direct }
|
|
79
|
+
result_object[gem_name][:dependency_path] = dependency_path if dependency_path
|
|
70
80
|
result_object[gem_name][:version_used] = gem_version if gem_version
|
|
71
81
|
|
|
72
82
|
case source_type
|
|
@@ -87,17 +97,14 @@ module StillActive
|
|
|
87
97
|
|
|
88
98
|
def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
|
|
89
99
|
vs = versions(gem_name: gem_name, source_uri: source_uri)
|
|
90
|
-
repo_info = repository_info(gem_name: gem_name, versions: vs)
|
|
91
|
-
|
|
92
|
-
source: repo_info[:source],
|
|
93
|
-
repository_owner: repo_info[:owner],
|
|
94
|
-
repository_name: repo_info[:name],
|
|
95
|
-
)
|
|
96
|
-
archived = repo_archived(
|
|
100
|
+
repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
|
|
101
|
+
signals = repo_signals(
|
|
97
102
|
source: repo_info[:source],
|
|
98
103
|
repository_owner: repo_info[:owner],
|
|
99
104
|
repository_name: repo_info[:name],
|
|
100
105
|
)
|
|
106
|
+
commit_date = signals[:last_commit_date]
|
|
107
|
+
archived = signals[:archived]
|
|
101
108
|
last_release = VersionHelper.find_version(versions: vs, pre_release: false)
|
|
102
109
|
last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
|
|
103
110
|
deps_dev = fetch_deps_dev_info(
|
|
@@ -122,6 +129,15 @@ module StillActive
|
|
|
122
129
|
result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
|
|
123
130
|
end
|
|
124
131
|
|
|
132
|
+
if StillActive.config.unreleased_commits
|
|
133
|
+
result_object[gem_name][:unreleased_commits] = unreleased_commits(
|
|
134
|
+
source: repo_info[:source],
|
|
135
|
+
repository_owner: repo_info[:owner],
|
|
136
|
+
repository_name: repo_info[:name],
|
|
137
|
+
version: VersionHelper.gem_version(version_hash: last_release),
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
125
141
|
if gem_version
|
|
126
142
|
version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
|
|
127
143
|
result_object[gem_name].merge!({
|
|
@@ -150,16 +166,21 @@ module StillActive
|
|
|
150
166
|
# Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
|
|
151
167
|
deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
|
|
152
168
|
|
|
169
|
+
signals = repo_signals(source:, repository_owner: owner, repository_name: name)
|
|
153
170
|
result_object[gem_name].merge!({
|
|
154
171
|
repository_url: repo_info[:url],
|
|
155
|
-
last_commit_date: last_commit_date
|
|
156
|
-
archived:
|
|
172
|
+
last_commit_date: signals[:last_commit_date],
|
|
173
|
+
archived: signals[:archived],
|
|
157
174
|
**deps_dev,
|
|
158
175
|
})
|
|
159
176
|
end
|
|
160
177
|
|
|
161
178
|
def attach_alternatives(gem_name:, result_object:, catalog:)
|
|
162
179
|
return if catalog.nil?
|
|
180
|
+
# Direct-only by design: "replace gem X with better-maintained Y" is
|
|
181
|
+
# incoherent for a transitive gem the user never chose (#60). The
|
|
182
|
+
# path-to-parent points them at the direct gem they can actually swap.
|
|
183
|
+
return unless result_object[gem_name][:direct]
|
|
163
184
|
return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
|
|
164
185
|
|
|
165
186
|
leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
|
|
@@ -185,6 +206,11 @@ module StillActive
|
|
|
185
206
|
def versions(gem_name:, source_uri: nil)
|
|
186
207
|
if github_packages_uri?(source_uri)
|
|
187
208
|
fetch_github_packages_versions(gem_name: gem_name, source_uri: source_uri)
|
|
209
|
+
elsif ArtifactoryClient.artifactory_uri?(source_uri)
|
|
210
|
+
ArtifactoryClient.versions(gem_name: gem_name, source_uri: source_uri)
|
|
211
|
+
elsif unqueryable_private_source?(source_uri)
|
|
212
|
+
warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
|
|
213
|
+
[]
|
|
188
214
|
else
|
|
189
215
|
Gems.versions(gem_name)
|
|
190
216
|
end
|
|
@@ -201,10 +227,36 @@ module StillActive
|
|
|
201
227
|
false
|
|
202
228
|
end
|
|
203
229
|
|
|
230
|
+
# A rubygems-type source that isn't public rubygems.org and that we have no
|
|
231
|
+
# client for (Gemfury, Gemstash, geminabox, a private mirror). We must NOT
|
|
232
|
+
# fall through to Gems.versions, which always hits public rubygems.org: that
|
|
233
|
+
# would silently report a public name-collision's data, or blanks, as if it
|
|
234
|
+
# were the private gem's. github_packages/artifactory are handled above, so
|
|
235
|
+
# anything left with a non-rubygems.org host is unqueryable. Refs #43.
|
|
236
|
+
def unqueryable_private_source?(source_uri)
|
|
237
|
+
return false unless source_uri.is_a?(String)
|
|
238
|
+
|
|
239
|
+
# Hostnames are case-insensitive; a trailing dot (FQDN form) is equivalent.
|
|
240
|
+
host = URI(source_uri).host&.downcase&.chomp(".")
|
|
241
|
+
return false if host.nil?
|
|
242
|
+
|
|
243
|
+
host != "rubygems.org" && !host.end_with?(".rubygems.org")
|
|
244
|
+
rescue URI::InvalidURIError
|
|
245
|
+
false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def warn_unqueryable_private_source(gem_name:, source_uri:)
|
|
249
|
+
host = URI(source_uri).host
|
|
250
|
+
$stderr.puts(
|
|
251
|
+
"warning: #{gem_name} resolves from a private source (#{host}) still_active cannot query; " \
|
|
252
|
+
"reporting no version/latest/libyear data for it rather than substituting public rubygems.org data",
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
204
256
|
def fetch_github_packages_versions(gem_name:, source_uri:)
|
|
205
257
|
base = URI(source_uri.chomp("/"))
|
|
206
258
|
namespace_path = base.path
|
|
207
|
-
path = "#{namespace_path}/api/v1/gems/#{gem_name}/versions.json"
|
|
259
|
+
path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
|
|
208
260
|
token = StillActive.config.github_oauth_token
|
|
209
261
|
headers = token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
210
262
|
HttpHelper.get_json(base, path, headers: headers) || []
|
|
@@ -214,32 +266,47 @@ module StillActive
|
|
|
214
266
|
valid_repository_url =
|
|
215
267
|
[source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
|
|
216
268
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
217
|
-
project_id
|
|
218
|
-
host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
|
|
219
|
-
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
220
|
-
end
|
|
221
|
-
repo.merge(project_id: project_id)
|
|
269
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
222
270
|
end
|
|
223
271
|
|
|
224
272
|
def repository_info_from_installed_gem(gem_name:)
|
|
225
273
|
valid_repository_url =
|
|
226
274
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
227
275
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
228
|
-
project_id
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
276
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
|
|
280
|
+
# repo has no deps.dev project, so leave its project_id nil rather than minting
|
|
281
|
+
# a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
|
|
282
|
+
DEPS_DEV_HOST_BY_SOURCE = { github: "github.com", gitlab: "gitlab.com" }.freeze
|
|
283
|
+
|
|
284
|
+
def deps_dev_project_id(repo)
|
|
285
|
+
host = DEPS_DEV_HOST_BY_SOURCE[repo[:source]]
|
|
286
|
+
return unless repo[:url] && host
|
|
287
|
+
|
|
288
|
+
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
233
289
|
end
|
|
234
290
|
|
|
235
|
-
def repository_info(gem_name:, versions:)
|
|
291
|
+
def repository_info(gem_name:, versions:, source_uri: nil)
|
|
236
292
|
valid_repository_url =
|
|
237
293
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) } ||
|
|
238
294
|
rubygems_versions_repository_url(versions: versions).find { |url| Repository.valid?(url: url) } ||
|
|
239
|
-
|
|
295
|
+
public_rubygems_repository_url(gem_name: gem_name, source_uri: source_uri)
|
|
240
296
|
Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
241
297
|
end
|
|
242
298
|
|
|
299
|
+
# Locally-installed gem metadata and the gem's own version payload are
|
|
300
|
+
# source-accurate. This public rubygems.org Gems.info lookup is the last
|
|
301
|
+
# resort, and is skipped for an unqueryable private source: otherwise a
|
|
302
|
+
# public name-collision's repo/archived/last-commit data would stand in for
|
|
303
|
+
# the private gem, the same substitution #43 prevents for versions.
|
|
304
|
+
def public_rubygems_repository_url(gem_name:, source_uri:)
|
|
305
|
+
return if unqueryable_private_source?(source_uri)
|
|
306
|
+
|
|
307
|
+
rubygems_gem_repository_url(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
308
|
+
end
|
|
309
|
+
|
|
243
310
|
def installed_gem_urls(gem_name:)
|
|
244
311
|
info = Gem::Dependency.new(gem_name).matching_specs.first
|
|
245
312
|
return [] if info.nil?
|
|
@@ -268,40 +335,31 @@ module StillActive
|
|
|
268
335
|
[]
|
|
269
336
|
end
|
|
270
337
|
|
|
271
|
-
|
|
338
|
+
# The repo-signal provider for a source, or nil for an unhandled host. Every
|
|
339
|
+
# provider answers archived/last_commit_date; richer signals (e.g.
|
|
340
|
+
# commits_since_release) are duck-typed and dispatched by respond_to?, so a
|
|
341
|
+
# provider opts into them by defining the method, with no base class and no
|
|
342
|
+
# assumption that every source supports every signal.
|
|
343
|
+
def provider_for(source)
|
|
272
344
|
case source
|
|
273
|
-
when :github
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
when :gitlab
|
|
277
|
-
GitlabClient.archived(owner: repository_owner, name: repository_name)
|
|
345
|
+
when :github then GithubClient
|
|
346
|
+
when :gitlab then GitlabClient
|
|
347
|
+
when :forgejo then ForgejoClient
|
|
278
348
|
end
|
|
279
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
280
|
-
$stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
281
|
-
nil
|
|
282
349
|
end
|
|
283
350
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
nil
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
when :gitlab
|
|
300
|
-
GitlabClient.last_commit_date(owner: repository_owner, name: repository_name)
|
|
301
|
-
end
|
|
302
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
303
|
-
$stderr.puts("warning: last commit check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
304
|
-
nil
|
|
351
|
+
# One provider call yields both archived and the last-activity date (the
|
|
352
|
+
# repo object carries both), so a gem's repo signals cost a single request
|
|
353
|
+
# instead of two. Returns {} for an unhandled host.
|
|
354
|
+
def repo_signals(source:, repository_owner:, repository_name:)
|
|
355
|
+
provider_for(source)&.repo_signals(owner: repository_owner, name: repository_name) || {}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def unreleased_commits(source:, repository_owner:, repository_name:, version:)
|
|
359
|
+
provider = provider_for(source)
|
|
360
|
+
return unless provider.respond_to?(:commits_since_release)
|
|
361
|
+
|
|
362
|
+
provider.commits_since_release(owner: repository_owner, name: repository_name, version: version)
|
|
305
363
|
end
|
|
306
364
|
end
|
|
307
365
|
end
|
data/still_active.gemspec
CHANGED
|
@@ -9,13 +9,15 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["contact@seanfloyd.dev"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Audit your Ruby dependencies for maintenance health, outdated versions, vulnerabilities, and abandoned gems."
|
|
12
|
-
spec.description = "Analyses your Gemfile for dependency health
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"Ruby version freshness with EOL detection. " \
|
|
16
|
-
"Handles rubygems, git, path,
|
|
17
|
-
"Outputs coloured terminal tables, markdown,
|
|
18
|
-
"
|
|
12
|
+
spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
|
|
13
|
+
"whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
|
|
14
|
+
"release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
|
|
15
|
+
"(deps.dev merged with ruby-advisory-db), and libyear drift. Ruby version freshness with EOL detection. " \
|
|
16
|
+
"Handles rubygems, git, path, GitHub Packages, and JFrog Artifactory sources. " \
|
|
17
|
+
"Outputs coloured terminal tables, markdown, JSON (with a versioned, contract-tested schema), " \
|
|
18
|
+
"SARIF for GitHub code scanning, and a CycloneDX SBOM. " \
|
|
19
|
+
"CI quality gates (--fail-if-critical / -warning / -vulnerable / -outdated) with granular, committed " \
|
|
20
|
+
"suppression via .still_active.yml. " \
|
|
19
21
|
"A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
|
|
20
22
|
spec.homepage = "https://github.com/SeanLF/still_active"
|
|
21
23
|
spec.license = "MIT"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: still_active
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sean Floyd
|
|
@@ -191,14 +191,17 @@ dependencies:
|
|
|
191
191
|
- - ">="
|
|
192
192
|
- !ruby/object:Gem::Version
|
|
193
193
|
version: '0'
|
|
194
|
-
description: 'Analyses your Gemfile for dependency health
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
description: 'Analyses your Gemfile.lock for dependency health across the full transitive
|
|
195
|
+
graph: whether each gem is actively maintained (last activity on GitHub, GitLab,
|
|
196
|
+
or Codeberg/Forgejo, plus release recency), outdated versions, archived repos, OpenSSF
|
|
197
|
+
Scorecard scores, known vulnerabilities (deps.dev merged with ruby-advisory-db),
|
|
197
198
|
and libyear drift. Ruby version freshness with EOL detection. Handles rubygems,
|
|
198
|
-
git, path,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
git, path, GitHub Packages, and JFrog Artifactory sources. Outputs coloured terminal
|
|
200
|
+
tables, markdown, JSON (with a versioned, contract-tested schema), SARIF for GitHub
|
|
201
|
+
code scanning, and a CycloneDX SBOM. CI quality gates (--fail-if-critical / -warning
|
|
202
|
+
/ -vulnerable / -outdated) with granular, committed suppression via .still_active.yml.
|
|
203
|
+
A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler
|
|
204
|
+
separately.'
|
|
202
205
|
email:
|
|
203
206
|
- contact@seanfloyd.dev
|
|
204
207
|
executables:
|
|
@@ -221,25 +224,33 @@ files:
|
|
|
221
224
|
- lib/helpers/emoji_helper.rb
|
|
222
225
|
- lib/helpers/http_helper.rb
|
|
223
226
|
- lib/helpers/libyear_helper.rb
|
|
227
|
+
- lib/helpers/lockfile_dependency_parser.rb
|
|
224
228
|
- lib/helpers/lockfile_indexer.rb
|
|
229
|
+
- lib/helpers/markdown_escape.rb
|
|
225
230
|
- lib/helpers/markdown_helper.rb
|
|
226
231
|
- lib/helpers/ruby_advisory_db.rb
|
|
227
232
|
- lib/helpers/ruby_helper.rb
|
|
228
233
|
- lib/helpers/sarif_helper.rb
|
|
234
|
+
- lib/helpers/summary_helper.rb
|
|
229
235
|
- lib/helpers/terminal_helper.rb
|
|
230
236
|
- lib/helpers/version_helper.rb
|
|
231
237
|
- lib/helpers/vulnerability_helper.rb
|
|
232
238
|
- lib/still_active.rb
|
|
239
|
+
- lib/still_active/artifactory_client.rb
|
|
233
240
|
- lib/still_active/cli.rb
|
|
234
241
|
- lib/still_active/config.rb
|
|
242
|
+
- lib/still_active/config_file.rb
|
|
235
243
|
- lib/still_active/core_ext.rb
|
|
236
244
|
- lib/still_active/deps_dev_client.rb
|
|
237
245
|
- lib/still_active/diff.rb
|
|
238
246
|
- lib/still_active/errors.rb
|
|
247
|
+
- lib/still_active/forgejo_client.rb
|
|
248
|
+
- lib/still_active/github_client.rb
|
|
239
249
|
- lib/still_active/gitlab_client.rb
|
|
240
250
|
- lib/still_active/options.rb
|
|
241
251
|
- lib/still_active/repository.rb
|
|
242
252
|
- lib/still_active/sarif/rules.rb
|
|
253
|
+
- lib/still_active/suppressions.rb
|
|
243
254
|
- lib/still_active/version.rb
|
|
244
255
|
- lib/still_active/workflow.rb
|
|
245
256
|
- still_active.gemspec
|