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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "activity_helper"
|
|
4
4
|
require_relative "ansi_helper"
|
|
5
|
+
require_relative "summary_helper"
|
|
5
6
|
require_relative "libyear_helper"
|
|
6
7
|
require_relative "version_helper"
|
|
7
8
|
require_relative "vulnerability_helper"
|
|
@@ -22,7 +23,10 @@ module StillActive
|
|
|
22
23
|
lines << separator_line(widths)
|
|
23
24
|
names.each_with_index do |name, i|
|
|
24
25
|
lines << row_line(rows[i], widths)
|
|
25
|
-
|
|
26
|
+
data = result[name]
|
|
27
|
+
# Transitive gems can't be swapped directly, so point at the direct
|
|
28
|
+
# parent instead of suggesting alternatives for them (#60).
|
|
29
|
+
extra = data[:direct] == false ? dependency_path_line(data) : alternatives_line(data)
|
|
26
30
|
lines << extra if extra
|
|
27
31
|
end
|
|
28
32
|
lines << ""
|
|
@@ -142,6 +146,18 @@ module StillActive
|
|
|
142
146
|
end
|
|
143
147
|
end
|
|
144
148
|
|
|
149
|
+
# For a flagged transitive gem, name the direct dependency that pulls it in,
|
|
150
|
+
# the gem the user can actually act on (#60).
|
|
151
|
+
def dependency_path_line(data)
|
|
152
|
+
path = data[:dependency_path]
|
|
153
|
+
return unless path && path.length >= 2
|
|
154
|
+
|
|
155
|
+
level = ActivityHelper.activity_level(data)
|
|
156
|
+
return unless [:archived, :critical].include?(level) || data[:vulnerability_count].to_i.positive?
|
|
157
|
+
|
|
158
|
+
AnsiHelper.dim(" ↳ transitive, pulled in by #{path.first}")
|
|
159
|
+
end
|
|
160
|
+
|
|
145
161
|
def ruby_summary_line(ruby_info)
|
|
146
162
|
version = ruby_info[:version]
|
|
147
163
|
latest = ruby_info[:latest_version]
|
|
@@ -161,29 +177,23 @@ module StillActive
|
|
|
161
177
|
end
|
|
162
178
|
end
|
|
163
179
|
|
|
180
|
+
# Reuse the same digest the JSON output emits so the human summary line can
|
|
181
|
+
# never drift from the machine one (the #63 "computed two ways" trap). The
|
|
182
|
+
# terminal keeps a coarser grouping (critical folds into stale) and adds its
|
|
183
|
+
# own yanked / total-libyear extras that the JSON digest doesn't carry.
|
|
164
184
|
def summary_line(result)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
.tally
|
|
170
|
-
|
|
171
|
-
up_to_date = version_counts.fetch(true, 0)
|
|
172
|
-
outdated = version_counts.fetch(false, 0)
|
|
185
|
+
summary = SummaryHelper.summarize(result)
|
|
186
|
+
active = summary[:activity][:ok]
|
|
187
|
+
stale = summary[:activity][:stale] + summary[:activity][:critical]
|
|
188
|
+
archived = summary[:activity][:archived]
|
|
173
189
|
yanked = result.each_value.count { |d| d[:version_yanked] }
|
|
174
|
-
active = level_counts.fetch(:ok, 0)
|
|
175
|
-
archived = level_counts.fetch(:archived, 0)
|
|
176
|
-
stale = level_counts.fetch(:stale, 0) + level_counts.fetch(:critical, 0)
|
|
177
|
-
vulns = result.each_value.sum { |d| d[:vulnerability_count] || 0 }
|
|
178
190
|
|
|
179
|
-
parts = [
|
|
180
|
-
"#{total} gems: #{up_to_date} up to date, #{outdated} outdated",
|
|
181
|
-
]
|
|
191
|
+
parts = ["#{summary[:total_gems]} gems: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
|
|
182
192
|
parts.last << ", #{yanked} yanked" if yanked > 0
|
|
183
193
|
activity = "#{active} active, #{stale} stale"
|
|
184
194
|
activity << ", #{archived} archived" if archived > 0
|
|
185
195
|
parts << activity
|
|
186
|
-
parts << "#{
|
|
196
|
+
parts << "#{summary[:vulnerabilities]} vulnerabilities"
|
|
187
197
|
total_libyear = LibyearHelper.total_libyear(result)
|
|
188
198
|
parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
|
|
189
199
|
parts.join(" · ")
|
|
@@ -12,7 +12,13 @@ module StillActive
|
|
|
12
12
|
elsif !version_string.nil?
|
|
13
13
|
versions&.find { |v| v["number"] == version_string }
|
|
14
14
|
else
|
|
15
|
-
|
|
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.
|
|
@@ -192,6 +236,8 @@ module StillActive
|
|
|
192
236
|
end
|
|
193
237
|
alternatives = MarkdownHelper.alternatives_section(result)
|
|
194
238
|
puts alternatives unless alternatives.empty?
|
|
239
|
+
transitive = MarkdownHelper.transitive_section(result)
|
|
240
|
+
puts transitive unless transitive.empty?
|
|
195
241
|
if ruby_info
|
|
196
242
|
puts ""
|
|
197
243
|
puts MarkdownHelper.ruby_line(ruby_info)
|
|
@@ -202,27 +248,50 @@ module StillActive
|
|
|
202
248
|
config = StillActive.config
|
|
203
249
|
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
|
|
204
250
|
|
|
205
|
-
|
|
206
|
-
|
|
251
|
+
exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
|
|
252
|
+
end
|
|
207
253
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
254
|
+
# A gem fails the run when it trips an enabled gate that is neither
|
|
255
|
+
# whole-gem --ignore'd nor covered by a granular .still_active.yml
|
|
256
|
+
# suppression. Each signal is checked independently so accepting one finding
|
|
257
|
+
# (e.g. a single advisory) never blinds the others.
|
|
258
|
+
def gate_failed?(name, data, config)
|
|
259
|
+
return false if config.ignored_gems.include?(name)
|
|
213
260
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
261
|
+
suppressions = config.suppressions
|
|
262
|
+
failed_activity?(name, data, config, suppressions) ||
|
|
263
|
+
failed_vulnerability?(name, data, config, suppressions) ||
|
|
264
|
+
failed_outdated?(name, data, config, suppressions)
|
|
265
|
+
end
|
|
217
266
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
267
|
+
def failed_activity?(name, data, config, suppressions)
|
|
268
|
+
return false unless config.fail_if_warning || config.fail_if_critical
|
|
269
|
+
return false if suppressions.suppressed?(gem: name, signal: :activity)
|
|
270
|
+
|
|
271
|
+
level = ActivityHelper.activity_level(data)
|
|
272
|
+
(config.fail_if_warning && [:stale, :critical, :archived].include?(level)) ||
|
|
273
|
+
(config.fail_if_critical && [:critical, :archived].include?(level))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def failed_vulnerability?(name, data, config, suppressions)
|
|
277
|
+
setting = config.fail_if_vulnerable
|
|
278
|
+
return false unless setting
|
|
279
|
+
return false unless data[:vulnerability_count]&.positive?
|
|
222
280
|
|
|
223
|
-
|
|
224
|
-
|
|
281
|
+
live = Array(data[:vulnerabilities]).reject do |vuln|
|
|
282
|
+
suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
|
|
225
283
|
end
|
|
284
|
+
return false if live.empty?
|
|
285
|
+
|
|
286
|
+
setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def failed_outdated?(name, data, config, suppressions)
|
|
290
|
+
threshold = config.fail_if_outdated
|
|
291
|
+
return false unless threshold
|
|
292
|
+
return false if suppressions.suppressed?(gem: name, signal: :libyear)
|
|
293
|
+
|
|
294
|
+
data[:libyear] && data[:libyear] > threshold
|
|
226
295
|
end
|
|
227
296
|
end
|
|
228
297
|
end
|
data/lib/still_active/config.rb
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
require "bundler"
|
|
4
4
|
require "octokit"
|
|
5
5
|
require "open3"
|
|
6
|
+
require_relative "suppressions"
|
|
6
7
|
|
|
7
8
|
module StillActive
|
|
8
9
|
class Config
|
|
9
|
-
attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
|
|
10
|
+
attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :gemfile_path
|
|
10
11
|
attr_accessor :alternatives,
|
|
12
|
+
:unreleased_commits,
|
|
13
|
+
:direct_only,
|
|
11
14
|
:baseline_path,
|
|
12
15
|
:critical_warning_emoji,
|
|
13
16
|
:cyclonedx_path,
|
|
@@ -24,12 +27,15 @@ module StillActive
|
|
|
24
27
|
:no_warning_range_end,
|
|
25
28
|
:sarif_path,
|
|
26
29
|
:success_emoji,
|
|
30
|
+
:suppressions,
|
|
27
31
|
:unsure_emoji,
|
|
28
32
|
:warning_emoji,
|
|
29
33
|
:warning_range_end
|
|
30
34
|
|
|
31
35
|
def initialize
|
|
32
36
|
@alternatives = false
|
|
37
|
+
@unreleased_commits = false
|
|
38
|
+
@direct_only = false
|
|
33
39
|
@fail_if_critical = false
|
|
34
40
|
@fail_if_outdated = nil
|
|
35
41
|
@fail_if_vulnerable = nil
|
|
@@ -37,8 +43,12 @@ module StillActive
|
|
|
37
43
|
@gemfile_path = nil
|
|
38
44
|
@gems = []
|
|
39
45
|
@ignored_gems = []
|
|
46
|
+
@suppressions = Suppressions.from(nil)
|
|
40
47
|
@github_oauth_token = nil
|
|
41
48
|
@gitlab_token = nil
|
|
49
|
+
@forgejo_token = nil
|
|
50
|
+
@artifactory_token = nil
|
|
51
|
+
@artifactory_host = nil
|
|
42
52
|
|
|
43
53
|
@parallelism = 10
|
|
44
54
|
|
|
@@ -54,7 +64,11 @@ module StillActive
|
|
|
54
64
|
@unsure_emoji = "❓"
|
|
55
65
|
@warning_emoji = "⚠️"
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
# Release-age thresholds (years) calibrated against real RubyGems cadence,
|
|
68
|
+
# not the npm-derived 12-month convention: healthy mature gems routinely go
|
|
69
|
+
# 12-18 months between releases, so the ok ceiling is 18 months. > 3 years
|
|
70
|
+
# is critical. See #32.
|
|
71
|
+
@no_warning_range_end = 1.5
|
|
58
72
|
@warning_range_end = 3
|
|
59
73
|
end
|
|
60
74
|
|
|
@@ -71,6 +85,22 @@ module StillActive
|
|
|
71
85
|
@gitlab_token ||= presence(ENV["GITLAB_TOKEN"]) || glab_cli_token
|
|
72
86
|
end
|
|
73
87
|
|
|
88
|
+
# Codeberg/Forgejo has no ubiquitous CLI to borrow a token from (unlike
|
|
89
|
+
# gh/glab), so it's env-var only. Anonymous works for public repos; a token
|
|
90
|
+
# only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
|
|
91
|
+
# alias for the codeberg.org default host.
|
|
92
|
+
def forgejo_token
|
|
93
|
+
@forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def artifactory_token
|
|
97
|
+
@artifactory_token ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_TOKEN"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def artifactory_host
|
|
101
|
+
@artifactory_host ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_HOST"])
|
|
102
|
+
end
|
|
103
|
+
|
|
74
104
|
# Lazy so that running with --gems=... (no Gemfile needed) doesn't crash
|
|
75
105
|
# when invoked from a directory without a Gemfile in the tree.
|
|
76
106
|
def gemfile_path
|