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
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module StillActive
|
|
4
4
|
module Repository
|
|
5
|
-
|
|
5
|
+
# codeberg.org is the one Forgejo/Gitea host wired up today; it speaks the
|
|
6
|
+
# same Gitea API as any self-hosted instance, so its source is :forgejo (the
|
|
7
|
+
# forge software, what ForgejoClient handles), not :codeberg (the host).
|
|
8
|
+
SOURCE_BY_HOST = {
|
|
9
|
+
"github.com" => :github,
|
|
10
|
+
"gitlab.com" => :gitlab,
|
|
11
|
+
"codeberg.org" => :forgejo,
|
|
12
|
+
}.freeze
|
|
13
|
+
REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.\-]+)/(?<name>[\w.\-]+))}i
|
|
6
14
|
|
|
7
15
|
extend self
|
|
8
16
|
|
|
@@ -16,10 +24,10 @@ module StillActive
|
|
|
16
24
|
match = url&.match(REPO_REGEX)
|
|
17
25
|
return { source: :unhandled, owner: nil, name: nil } unless match
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
name = match[
|
|
27
|
+
clean_url = match[:url].delete_suffix(".git")
|
|
28
|
+
name = match[:name].delete_suffix(".git")
|
|
21
29
|
|
|
22
|
-
{ url:
|
|
30
|
+
{ url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name }
|
|
23
31
|
end
|
|
24
32
|
end
|
|
25
33
|
end
|
|
@@ -48,8 +48,8 @@ module StillActive
|
|
|
48
48
|
{
|
|
49
49
|
id: "SA002",
|
|
50
50
|
name: "AbandonedGem",
|
|
51
|
-
short: "Gem has had no
|
|
52
|
-
full: "The gem's
|
|
51
|
+
short: "Gem has had no release for over 3 years",
|
|
52
|
+
full: "The gem's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a gem with no releases at all (e.g. git-sourced), the last commit date is used instead.",
|
|
53
53
|
help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
|
|
54
54
|
level: "warning",
|
|
55
55
|
security_severity: nil,
|
|
@@ -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,8 +1,14 @@
|
|
|
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"
|
|
9
|
+
require_relative "../helpers/activity_helper"
|
|
10
|
+
require_relative "../helpers/alternatives_helper"
|
|
11
|
+
require_relative "../helpers/catalog_index"
|
|
6
12
|
require_relative "../helpers/libyear_helper"
|
|
7
13
|
require_relative "../helpers/ruby_advisory_db"
|
|
8
14
|
require_relative "../helpers/ruby_helper"
|
|
@@ -11,6 +17,7 @@ require_relative "../helpers/vulnerability_helper"
|
|
|
11
17
|
require "async"
|
|
12
18
|
require "async/barrier"
|
|
13
19
|
require "async/semaphore"
|
|
20
|
+
require "cgi"
|
|
14
21
|
require "gems"
|
|
15
22
|
|
|
16
23
|
module StillActive
|
|
@@ -22,6 +29,7 @@ module StillActive
|
|
|
22
29
|
# Load the optional ruby-advisory-db once, before the fan-out, so the
|
|
23
30
|
# read-only Database is shared across fibers rather than reloaded per gem.
|
|
24
31
|
advisory_db = RubyAdvisoryDb.load
|
|
32
|
+
catalog = StillActive.config.alternatives ? CatalogIndex.load : nil
|
|
25
33
|
barrier = Async::Barrier.new
|
|
26
34
|
semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
|
|
27
35
|
result_object = {}
|
|
@@ -35,7 +43,10 @@ module StillActive
|
|
|
35
43
|
gem_version: gem[:version],
|
|
36
44
|
source_type: gem[:source_type] || :rubygems,
|
|
37
45
|
source_uri: gem[:source_uri],
|
|
46
|
+
direct: gem.fetch(:direct, true),
|
|
47
|
+
dependency_path: gem[:dependency_path],
|
|
38
48
|
advisory_db: advisory_db,
|
|
49
|
+
catalog: catalog,
|
|
39
50
|
)
|
|
40
51
|
rescue Octokit::TooManyRequests
|
|
41
52
|
$stderr.print("\r\e[K") if on_progress
|
|
@@ -49,7 +60,10 @@ module StillActive
|
|
|
49
60
|
end
|
|
50
61
|
end
|
|
51
62
|
barrier.wait
|
|
52
|
-
|
|
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
|
|
53
67
|
end
|
|
54
68
|
task.wait
|
|
55
69
|
end
|
|
@@ -60,8 +74,9 @@ module StillActive
|
|
|
60
74
|
|
|
61
75
|
private
|
|
62
76
|
|
|
63
|
-
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil)
|
|
64
|
-
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
|
|
65
80
|
result_object[gem_name][:version_used] = gem_version if gem_version
|
|
66
81
|
|
|
67
82
|
case source_type
|
|
@@ -76,21 +91,20 @@ module StillActive
|
|
|
76
91
|
advisory_db: advisory_db,
|
|
77
92
|
)
|
|
78
93
|
end
|
|
94
|
+
|
|
95
|
+
attach_alternatives(gem_name: gem_name, result_object: result_object, catalog: catalog)
|
|
79
96
|
end
|
|
80
97
|
|
|
81
98
|
def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
|
|
82
99
|
vs = versions(gem_name: gem_name, source_uri: source_uri)
|
|
83
|
-
repo_info = repository_info(gem_name: gem_name, versions: vs)
|
|
84
|
-
|
|
85
|
-
source: repo_info[:source],
|
|
86
|
-
repository_owner: repo_info[:owner],
|
|
87
|
-
repository_name: repo_info[:name],
|
|
88
|
-
)
|
|
89
|
-
archived = repo_archived(
|
|
100
|
+
repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
|
|
101
|
+
signals = repo_signals(
|
|
90
102
|
source: repo_info[:source],
|
|
91
103
|
repository_owner: repo_info[:owner],
|
|
92
104
|
repository_name: repo_info[:name],
|
|
93
105
|
)
|
|
106
|
+
commit_date = signals[:last_commit_date]
|
|
107
|
+
archived = signals[:archived]
|
|
94
108
|
last_release = VersionHelper.find_version(versions: vs, pre_release: false)
|
|
95
109
|
last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
|
|
96
110
|
deps_dev = fetch_deps_dev_info(
|
|
@@ -115,6 +129,15 @@ module StillActive
|
|
|
115
129
|
result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
|
|
116
130
|
end
|
|
117
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
|
+
|
|
118
141
|
if gem_version
|
|
119
142
|
version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
|
|
120
143
|
result_object[gem_name].merge!({
|
|
@@ -143,14 +166,29 @@ module StillActive
|
|
|
143
166
|
# Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
|
|
144
167
|
deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
|
|
145
168
|
|
|
169
|
+
signals = repo_signals(source:, repository_owner: owner, repository_name: name)
|
|
146
170
|
result_object[gem_name].merge!({
|
|
147
171
|
repository_url: repo_info[:url],
|
|
148
|
-
last_commit_date: last_commit_date
|
|
149
|
-
archived:
|
|
172
|
+
last_commit_date: signals[:last_commit_date],
|
|
173
|
+
archived: signals[:archived],
|
|
150
174
|
**deps_dev,
|
|
151
175
|
})
|
|
152
176
|
end
|
|
153
177
|
|
|
178
|
+
def attach_alternatives(gem_name:, result_object:, catalog:)
|
|
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]
|
|
184
|
+
return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
|
|
185
|
+
|
|
186
|
+
leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
|
|
187
|
+
result_object[gem_name][:alternatives] = leads unless leads.empty?
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil # cosmetic best-effort: lead-fetching must never break the core audit
|
|
190
|
+
end
|
|
191
|
+
|
|
154
192
|
def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
|
|
155
193
|
info = DepsDevClient.version_info(gem_name: gem_name, version: version)
|
|
156
194
|
scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
|
|
@@ -168,6 +206,11 @@ module StillActive
|
|
|
168
206
|
def versions(gem_name:, source_uri: nil)
|
|
169
207
|
if github_packages_uri?(source_uri)
|
|
170
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
|
+
[]
|
|
171
214
|
else
|
|
172
215
|
Gems.versions(gem_name)
|
|
173
216
|
end
|
|
@@ -184,10 +227,36 @@ module StillActive
|
|
|
184
227
|
false
|
|
185
228
|
end
|
|
186
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
|
+
|
|
187
256
|
def fetch_github_packages_versions(gem_name:, source_uri:)
|
|
188
257
|
base = URI(source_uri.chomp("/"))
|
|
189
258
|
namespace_path = base.path
|
|
190
|
-
path = "#{namespace_path}/api/v1/gems/#{gem_name}/versions.json"
|
|
259
|
+
path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
|
|
191
260
|
token = StillActive.config.github_oauth_token
|
|
192
261
|
headers = token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
193
262
|
HttpHelper.get_json(base, path, headers: headers) || []
|
|
@@ -197,32 +266,47 @@ module StillActive
|
|
|
197
266
|
valid_repository_url =
|
|
198
267
|
[source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
|
|
199
268
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
200
|
-
project_id
|
|
201
|
-
host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
|
|
202
|
-
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
203
|
-
end
|
|
204
|
-
repo.merge(project_id: project_id)
|
|
269
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
205
270
|
end
|
|
206
271
|
|
|
207
272
|
def repository_info_from_installed_gem(gem_name:)
|
|
208
273
|
valid_repository_url =
|
|
209
274
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
|
|
210
275
|
repo = Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
211
|
-
project_id
|
|
212
|
-
host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
|
|
213
|
-
"#{host}/#{repo[:owner]}/#{repo[:name]}"
|
|
214
|
-
end
|
|
215
|
-
repo.merge(project_id: project_id)
|
|
276
|
+
repo.merge(project_id: deps_dev_project_id(repo))
|
|
216
277
|
end
|
|
217
278
|
|
|
218
|
-
|
|
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]}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def repository_info(gem_name:, versions:, source_uri: nil)
|
|
219
292
|
valid_repository_url =
|
|
220
293
|
installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) } ||
|
|
221
294
|
rubygems_versions_repository_url(versions: versions).find { |url| Repository.valid?(url: url) } ||
|
|
222
|
-
|
|
295
|
+
public_rubygems_repository_url(gem_name: gem_name, source_uri: source_uri)
|
|
223
296
|
Repository.url_with_owner_and_name(url: valid_repository_url)
|
|
224
297
|
end
|
|
225
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
|
+
|
|
226
310
|
def installed_gem_urls(gem_name:)
|
|
227
311
|
info = Gem::Dependency.new(gem_name).matching_specs.first
|
|
228
312
|
return [] if info.nil?
|
|
@@ -251,40 +335,31 @@ module StillActive
|
|
|
251
335
|
[]
|
|
252
336
|
end
|
|
253
337
|
|
|
254
|
-
|
|
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)
|
|
255
344
|
case source
|
|
256
|
-
when :github
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
when :gitlab
|
|
260
|
-
GitlabClient.archived(owner: repository_owner, name: repository_name)
|
|
345
|
+
when :github then GithubClient
|
|
346
|
+
when :gitlab then GitlabClient
|
|
347
|
+
when :forgejo then ForgejoClient
|
|
261
348
|
end
|
|
262
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
263
|
-
$stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
264
|
-
nil
|
|
265
349
|
end
|
|
266
350
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
nil
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
when :gitlab
|
|
283
|
-
GitlabClient.last_commit_date(owner: repository_owner, name: repository_name)
|
|
284
|
-
end
|
|
285
|
-
rescue Octokit::Error, Faraday::Error => e
|
|
286
|
-
$stderr.puts("warning: last commit check failed for #{repository_owner}/#{repository_name}: #{e.class}")
|
|
287
|
-
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)
|
|
288
363
|
end
|
|
289
364
|
end
|
|
290
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"
|
|
@@ -45,7 +47,11 @@ Gem::Specification.new do |spec|
|
|
|
45
47
|
spec.add_development_dependency("rubocop-rspec")
|
|
46
48
|
spec.add_development_dependency("rubocop-shopify")
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
# 2.0/2.1 ship a scheduler that breaks our fan-out (io_read); 2.2 is the
|
|
51
|
+
# verified floor (checked against Ruby 3.3 in Docker). octokit/faraday-retry/
|
|
52
|
+
# gems work down to ancient versions, so they stay unpinned rather than
|
|
53
|
+
# carry an artificial floor.
|
|
54
|
+
spec.add_runtime_dependency("async", ">= 2.2")
|
|
49
55
|
spec.add_runtime_dependency("bundler", ">= 2.0")
|
|
50
56
|
spec.add_runtime_dependency("faraday-retry")
|
|
51
57
|
spec.add_runtime_dependency("gems")
|
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
|
|
@@ -127,14 +127,14 @@ dependencies:
|
|
|
127
127
|
requirements:
|
|
128
128
|
- - ">="
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: '
|
|
130
|
+
version: '2.2'
|
|
131
131
|
type: :runtime
|
|
132
132
|
prerelease: false
|
|
133
133
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
134
|
requirements:
|
|
135
135
|
- - ">="
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: '
|
|
137
|
+
version: '2.2'
|
|
138
138
|
- !ruby/object:Gem::Dependency
|
|
139
139
|
name: bundler
|
|
140
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -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:
|
|
@@ -211,33 +214,43 @@ files:
|
|
|
211
214
|
- README.md
|
|
212
215
|
- bin/still_active
|
|
213
216
|
- lib/helpers/activity_helper.rb
|
|
217
|
+
- lib/helpers/alternatives_helper.rb
|
|
214
218
|
- lib/helpers/ansi_helper.rb
|
|
215
219
|
- lib/helpers/bot_context.rb
|
|
216
220
|
- lib/helpers/bundler_helper.rb
|
|
221
|
+
- lib/helpers/catalog_index.rb
|
|
217
222
|
- lib/helpers/cyclonedx_helper.rb
|
|
218
223
|
- lib/helpers/diff_markdown_helper.rb
|
|
219
224
|
- lib/helpers/emoji_helper.rb
|
|
220
225
|
- lib/helpers/http_helper.rb
|
|
221
226
|
- lib/helpers/libyear_helper.rb
|
|
227
|
+
- lib/helpers/lockfile_dependency_parser.rb
|
|
222
228
|
- lib/helpers/lockfile_indexer.rb
|
|
229
|
+
- lib/helpers/markdown_escape.rb
|
|
223
230
|
- lib/helpers/markdown_helper.rb
|
|
224
231
|
- lib/helpers/ruby_advisory_db.rb
|
|
225
232
|
- lib/helpers/ruby_helper.rb
|
|
226
233
|
- lib/helpers/sarif_helper.rb
|
|
234
|
+
- lib/helpers/summary_helper.rb
|
|
227
235
|
- lib/helpers/terminal_helper.rb
|
|
228
236
|
- lib/helpers/version_helper.rb
|
|
229
237
|
- lib/helpers/vulnerability_helper.rb
|
|
230
238
|
- lib/still_active.rb
|
|
239
|
+
- lib/still_active/artifactory_client.rb
|
|
231
240
|
- lib/still_active/cli.rb
|
|
232
241
|
- lib/still_active/config.rb
|
|
242
|
+
- lib/still_active/config_file.rb
|
|
233
243
|
- lib/still_active/core_ext.rb
|
|
234
244
|
- lib/still_active/deps_dev_client.rb
|
|
235
245
|
- lib/still_active/diff.rb
|
|
236
246
|
- lib/still_active/errors.rb
|
|
247
|
+
- lib/still_active/forgejo_client.rb
|
|
248
|
+
- lib/still_active/github_client.rb
|
|
237
249
|
- lib/still_active/gitlab_client.rb
|
|
238
250
|
- lib/still_active/options.rb
|
|
239
251
|
- lib/still_active/repository.rb
|
|
240
252
|
- lib/still_active/sarif/rules.rb
|
|
253
|
+
- lib/still_active/suppressions.rb
|
|
241
254
|
- lib/still_active/version.rb
|
|
242
255
|
- lib/still_active/workflow.rb
|
|
243
256
|
- still_active.gemspec
|