still_active 1.6.0 → 3.0.0.rc1
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 +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- 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 +145 -6
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- 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 +58 -6
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- metadata +72 -11
data/lib/still_active/cli.rb
CHANGED
|
@@ -1,24 +1,55 @@
|
|
|
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"
|
|
7
8
|
require_relative "../helpers/bundler_helper"
|
|
9
|
+
require_relative "../helpers/constraint_helper"
|
|
8
10
|
require_relative "../helpers/cyclonedx_helper"
|
|
9
11
|
require_relative "../helpers/diff_markdown_helper"
|
|
10
12
|
require_relative "../helpers/emoji_helper"
|
|
11
13
|
require_relative "../helpers/markdown_helper"
|
|
12
14
|
require_relative "../helpers/sarif_helper"
|
|
15
|
+
require_relative "../helpers/status_helper"
|
|
16
|
+
require_relative "../helpers/summary_helper"
|
|
13
17
|
require_relative "../helpers/terminal_helper"
|
|
14
18
|
require_relative "../helpers/version_helper"
|
|
15
19
|
require_relative "../helpers/vulnerability_helper"
|
|
20
|
+
require_relative "sbom_reader"
|
|
21
|
+
require_relative "sbom_workflow"
|
|
16
22
|
require_relative "workflow"
|
|
17
23
|
|
|
18
24
|
module StillActive
|
|
19
25
|
class CLI
|
|
26
|
+
# The committed JSON Schema for the --json output. Emitted as `$schema` so
|
|
27
|
+
# the output is self-describing and a consumer can validate it.
|
|
28
|
+
SCHEMA_URL = "https://raw.githubusercontent.com/SeanLF/still_active/main/docs/still_active.schema.json"
|
|
29
|
+
|
|
20
30
|
def run(args)
|
|
21
|
-
|
|
31
|
+
# Apply the committed .still_active.yml first so CLI flags (parsed next)
|
|
32
|
+
# win over it: CLI flag > env var > config file > default.
|
|
33
|
+
config_data = ConfigFile.load
|
|
34
|
+
ConfigFile.apply(StillActive.config, config_data).each { |warning| $stderr.puts("warning: #{warning}") }
|
|
35
|
+
options = begin
|
|
36
|
+
Options.new.parse!(args)
|
|
37
|
+
rescue ArgumentError, OptionParser::ParseError => e
|
|
38
|
+
# Bad CLI input (conflicting flags, a missing file, an unknown flag, a bad
|
|
39
|
+
# enum value) is a user error: print it and exit 2, matching the SBOM/baseline
|
|
40
|
+
# paths, rather than letting the raise bubble into a Ruby backtrace.
|
|
41
|
+
$stderr.puts("error: #{e.message}")
|
|
42
|
+
exit(2)
|
|
43
|
+
end
|
|
44
|
+
# After CLI flags resolve, nudge (don't auto-inherit) an un-imported
|
|
45
|
+
# bundler-audit ignore list when the vulnerability gate is on.
|
|
46
|
+
hint = ConfigFile.import_hint(config_data)
|
|
47
|
+
$stderr.puts("hint: #{hint}") if hint
|
|
48
|
+
# An SBOM audit is cross-ecosystem: it runs the deps.dev/ecosyste.ms lens
|
|
49
|
+
# over the SBOM's packages, not Bundler over a lockfile. Dispatch before the
|
|
50
|
+
# Bundler resolution below so a Gemfile is never required (or read).
|
|
51
|
+
return run_sbom if options[:provided_sbom]
|
|
52
|
+
|
|
22
53
|
unless options[:provided_gems]
|
|
23
54
|
begin
|
|
24
55
|
StillActive.config.gems = BundlerHelper.gemfile_dependencies
|
|
@@ -29,6 +60,7 @@ module StillActive
|
|
|
29
60
|
end
|
|
30
61
|
|
|
31
62
|
warn_output_flag_conflicts(options)
|
|
63
|
+
warn_stale_suppressions
|
|
32
64
|
|
|
33
65
|
result = if $stderr.tty?
|
|
34
66
|
Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
|
|
@@ -50,14 +82,25 @@ module StillActive
|
|
|
50
82
|
case resolve_format
|
|
51
83
|
when :json
|
|
52
84
|
output = {
|
|
85
|
+
"$schema": SCHEMA_URL,
|
|
53
86
|
schema_version: 1,
|
|
54
87
|
tool: { name: "still_active", version: StillActive::VERSION },
|
|
55
88
|
generated_at: Time.now.utc.iso8601,
|
|
56
|
-
|
|
89
|
+
# A one-object digest of the audit's posture, so a machine/LLM
|
|
90
|
+
# consumer reads the headline counts without iterating every gem.
|
|
91
|
+
summary: SummaryHelper.summarize(result, ruby_info: ruby_info),
|
|
92
|
+
# Surface the derived verdict so a machine/LLM consumer reads it
|
|
93
|
+
# directly instead of re-deriving it from the raw dates.
|
|
94
|
+
gems: result.transform_values do |data|
|
|
95
|
+
data.merge(
|
|
96
|
+
activity_level: ActivityHelper.activity_level(data),
|
|
97
|
+
status: StatusHelper.gem_status(data),
|
|
98
|
+
)
|
|
99
|
+
end,
|
|
57
100
|
}
|
|
58
101
|
output[:ruby] = ruby_info if ruby_info
|
|
59
102
|
output[:pr_context] = pr_context if pr_context
|
|
60
|
-
puts output.to_json
|
|
103
|
+
puts iso8601_times(output).to_json
|
|
61
104
|
when :terminal
|
|
62
105
|
puts BotContext.summary(pr_context) if pr_context
|
|
63
106
|
puts TerminalHelper.render(result, ruby_info: ruby_info)
|
|
@@ -71,6 +114,126 @@ module StillActive
|
|
|
71
114
|
|
|
72
115
|
private
|
|
73
116
|
|
|
117
|
+
# The --sbom path: assess a CycloneDX SBOM's packages cross-ecosystem via
|
|
118
|
+
# EcosystemLens, then emit a JSON report shaped like the native audit's but
|
|
119
|
+
# keyed "ecosystem/name@version" and carrying an `unassessable` list of every
|
|
120
|
+
# dep we couldn't assess (reader-level: unsupported ecosystem, no version/PURL;
|
|
121
|
+
# or assessment-level: the lens call raised) rather than silently dropping it.
|
|
122
|
+
def run_sbom
|
|
123
|
+
path = StillActive.config.sbom_path
|
|
124
|
+
require_parseable_sbom(path)
|
|
125
|
+
# deps.dev is the SBOM path's sole vulnerability source and an alpha API; a
|
|
126
|
+
# field rename would silently zero every vuln count. Canary the schema once
|
|
127
|
+
# and warn loudly so a degraded run never reads as an authoritative all-clear.
|
|
128
|
+
unless DepsDevClient.advisory_schema_ok?
|
|
129
|
+
$stderr.puts("warning: deps.dev vulnerability schema check failed (its `advisoryKeys` field may have changed, or the API is unreachable); vulnerability counts may be understated -- a clean result is NOT authoritative")
|
|
130
|
+
end
|
|
131
|
+
sbom = SbomReader.parse(path)
|
|
132
|
+
outcome = if $stderr.tty?
|
|
133
|
+
SbomWorkflow.call(sbom) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
|
|
134
|
+
else
|
|
135
|
+
SbomWorkflow.call(sbom)
|
|
136
|
+
end
|
|
137
|
+
$stderr.print("\r\e[K") if $stderr.tty?
|
|
138
|
+
|
|
139
|
+
# A reader-level gap (unsupported ecosystem, no version/PURL) and an
|
|
140
|
+
# assessment-time failure (a raised lens call) both mean "not assessed":
|
|
141
|
+
# surface them together so neither is a silent hole in the reported coverage.
|
|
142
|
+
unassessable = sbom.unassessable + outcome.failures
|
|
143
|
+
emit_sbom_json(outcome.assessed, unassessable)
|
|
144
|
+
warn_unassessable(unassessable)
|
|
145
|
+
check_exit_status(outcome.assessed)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# SbomReader never raises (a malformed file degrades to an empty result), which
|
|
149
|
+
# is right for the library entrypoint but wrong for the explicit --sbom flag:
|
|
150
|
+
# the user pointed at a file to audit, so a truncated/wrong-format one must
|
|
151
|
+
# error, not flow through to an empty "all clear" report with exit 0.
|
|
152
|
+
def require_parseable_sbom(path)
|
|
153
|
+
doc = JSON.parse(File.read(path))
|
|
154
|
+
return if doc.is_a?(Hash) && doc["components"].is_a?(Array)
|
|
155
|
+
|
|
156
|
+
# Matches SbomReader's own parse contract (a `components` array is what it
|
|
157
|
+
# reads). A metadata-only CycloneDX has nothing to audit, so the message
|
|
158
|
+
# names the missing array rather than over-claiming "not CycloneDX".
|
|
159
|
+
$stderr.puts("error: #{path} has no CycloneDX `components` array to audit")
|
|
160
|
+
exit(2)
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
$stderr.puts("error: #{path} is not valid JSON")
|
|
163
|
+
exit(2)
|
|
164
|
+
rescue SystemCallError => e
|
|
165
|
+
# Options checked the path exists, not that it's readable (a permission
|
|
166
|
+
# denial, or a directory). Exit cleanly instead of crashing with a trace.
|
|
167
|
+
$stderr.puts("error: cannot read SBOM file #{path}: #{e.message}")
|
|
168
|
+
exit(2)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# SBOM output deliberately omits the Ruby audit's `$schema`: the shape differs
|
|
172
|
+
# (composite keys, an unassessable list, no Ruby/PR-context blocks), so it
|
|
173
|
+
# would be a false claim to point at that contract. schema_version stays 1.
|
|
174
|
+
def emit_sbom_json(result, unassessable)
|
|
175
|
+
output = {
|
|
176
|
+
schema_version: 1,
|
|
177
|
+
tool: { name: "still_active", version: StillActive::VERSION },
|
|
178
|
+
generated_at: Time.now.utc.iso8601,
|
|
179
|
+
summary: sbom_summary(result, unassessable),
|
|
180
|
+
dependencies: result.transform_values do |data|
|
|
181
|
+
data.merge(
|
|
182
|
+
activity_level: ActivityHelper.activity_level(data),
|
|
183
|
+
status: StatusHelper.gem_status(data),
|
|
184
|
+
)
|
|
185
|
+
end,
|
|
186
|
+
unassessable:,
|
|
187
|
+
}
|
|
188
|
+
puts iso8601_times(output).to_json
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sbom_summary(result, unassessable)
|
|
192
|
+
statuses = result.each_value.map { |data| StatusHelper.gem_status(data) }
|
|
193
|
+
{
|
|
194
|
+
total_assessed: result.size,
|
|
195
|
+
unassessable_count: unassessable.size,
|
|
196
|
+
# The single worst per-dependency verdict, so a consumer reads one
|
|
197
|
+
# project-level posture without scanning every dependency.
|
|
198
|
+
status: StatusHelper.project_status(result),
|
|
199
|
+
status_counts: statuses.tally,
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Unassessable deps are already in the JSON; echo a one-line count to stderr so
|
|
204
|
+
# a human running interactively sees the coverage gap without parsing stdout.
|
|
205
|
+
def warn_unassessable(unassessable)
|
|
206
|
+
return if unassessable.empty?
|
|
207
|
+
|
|
208
|
+
noun = unassessable.size == 1 ? "dependency" : "dependencies"
|
|
209
|
+
$stderr.puts("warning: #{unassessable.size} #{noun} could not be assessed " \
|
|
210
|
+
"(a private/alternative registry, unsupported ecosystem, missing version, no package URL, or a lookup failure); " \
|
|
211
|
+
"see \"unassessable\" in the JSON output")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Dates live in the result as real Time objects (the activity/libyear math
|
|
215
|
+
# needs them), but the JSON contract is ISO8601 UTC strings, matching
|
|
216
|
+
# generated_at. Normalize at the serialization boundary only, so the
|
|
217
|
+
# terminal/markdown/SARIF paths keep their Time objects untouched.
|
|
218
|
+
def iso8601_times(value)
|
|
219
|
+
case value
|
|
220
|
+
when Time then value.utc.iso8601
|
|
221
|
+
when Hash then value.transform_values { |v| iso8601_times(v) }
|
|
222
|
+
when Array then value.map { |v| iso8601_times(v) }
|
|
223
|
+
else value
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A suppression naming a gem that isn't in the resolved dependency set can
|
|
228
|
+
# never fire, so it's dead config worth surfacing (the presence half of
|
|
229
|
+
# suppression rot, alongside the expiry half the entries handle themselves).
|
|
230
|
+
def warn_stale_suppressions
|
|
231
|
+
present = StillActive.config.gems.map { |gem| gem[:name] }
|
|
232
|
+
StillActive.config.suppressions.stale_gem_warnings(present).each do |warning|
|
|
233
|
+
$stderr.puts("warning: #{warning}")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
74
237
|
# The output destinations are mutually exclusive and resolved by precedence
|
|
75
238
|
# (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than
|
|
76
239
|
# silently dropping the loser when more than one is set.
|
|
@@ -141,8 +304,9 @@ module StillActive
|
|
|
141
304
|
current = current_snapshot(result, ruby_info)
|
|
142
305
|
baseline = JSON.parse(File.read(baseline_path))
|
|
143
306
|
diff = Diff.call(baseline: baseline, current: current)
|
|
307
|
+
accepted = partition_accepted_regressions!(diff)
|
|
144
308
|
puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
|
|
145
|
-
puts DiffMarkdownHelper.render(diff)
|
|
309
|
+
puts DiffMarkdownHelper.render(diff, accepted: accepted)
|
|
146
310
|
exit(1) if diff.regressions.any?
|
|
147
311
|
rescue JSON::ParserError => e
|
|
148
312
|
$stderr.puts("error: --baseline file is not valid JSON: #{e.message}")
|
|
@@ -155,6 +319,27 @@ module StillActive
|
|
|
155
319
|
exit(2)
|
|
156
320
|
end
|
|
157
321
|
|
|
322
|
+
# Drops regressions a committed .still_active.yml accepts from the CI-failable
|
|
323
|
+
# set (mutating diff.regressions in place) and returns them as accepted
|
|
324
|
+
# entries so the render can show them as knowingly-suppressed rather than
|
|
325
|
+
# silently vanishing. Mirrors the audit/SARIF gates, which already honour the
|
|
326
|
+
# same suppression list; the diff was the only gate that ignored it.
|
|
327
|
+
def partition_accepted_regressions!(diff)
|
|
328
|
+
suppressions = StillActive.config.suppressions
|
|
329
|
+
accepted = []
|
|
330
|
+
diff.regressions = diff.regressions.reject do |reg|
|
|
331
|
+
signal = Diff.suppressible_signal(reg.kind)
|
|
332
|
+
next false unless signal
|
|
333
|
+
|
|
334
|
+
entry = suppressions.match(gem: reg.gem, signal: signal)
|
|
335
|
+
next false unless entry
|
|
336
|
+
|
|
337
|
+
accepted << Diff::Accepted.new(regression: reg, reason: entry.reason)
|
|
338
|
+
true
|
|
339
|
+
end
|
|
340
|
+
accepted
|
|
341
|
+
end
|
|
342
|
+
|
|
158
343
|
def current_snapshot(result, ruby_info)
|
|
159
344
|
snapshot = {
|
|
160
345
|
"schema_version" => 1,
|
|
@@ -190,8 +375,14 @@ module StillActive
|
|
|
190
375
|
|
|
191
376
|
puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
|
|
192
377
|
end
|
|
378
|
+
poison = MarkdownHelper.poison_section(result)
|
|
379
|
+
puts poison unless poison.empty?
|
|
380
|
+
language_ceiling = MarkdownHelper.language_ceiling_section(result)
|
|
381
|
+
puts language_ceiling unless language_ceiling.empty?
|
|
193
382
|
alternatives = MarkdownHelper.alternatives_section(result)
|
|
194
383
|
puts alternatives unless alternatives.empty?
|
|
384
|
+
transitive = MarkdownHelper.transitive_section(result)
|
|
385
|
+
puts transitive unless transitive.empty?
|
|
195
386
|
if ruby_info
|
|
196
387
|
puts ""
|
|
197
388
|
puts MarkdownHelper.ruby_line(ruby_info)
|
|
@@ -200,29 +391,110 @@ module StillActive
|
|
|
200
391
|
|
|
201
392
|
def check_exit_status(result)
|
|
202
393
|
config = StillActive.config
|
|
203
|
-
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
|
|
394
|
+
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling
|
|
204
395
|
|
|
205
|
-
|
|
206
|
-
|
|
396
|
+
warn_unknown_severity_gate(result, config)
|
|
397
|
+
exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
|
|
398
|
+
end
|
|
207
399
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
400
|
+
# --fail-if-vulnerable=<threshold> fails closed on an advisory with no CVSS
|
|
401
|
+
# score (it could exceed the threshold; fresh CVEs often lack a score). Say so
|
|
402
|
+
# per gem, so failing a =high gate on an "unknown" severity reads as a
|
|
403
|
+
# deliberate conservative call the user can review and fix or suppress, not a
|
|
404
|
+
# mystery. Only for the thresholded form; bare --fail-if-vulnerable fails on
|
|
405
|
+
# every advisory regardless of severity, so there's nothing to explain.
|
|
406
|
+
def warn_unknown_severity_gate(result, config)
|
|
407
|
+
threshold = config.fail_if_vulnerable
|
|
408
|
+
return unless threshold.is_a?(String)
|
|
213
409
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
410
|
+
suppressions = config.suppressions
|
|
411
|
+
result.each do |name, data|
|
|
412
|
+
next if config.ignored_gems.include?(name)
|
|
217
413
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
414
|
+
unknown = live_advisories(name, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
|
|
415
|
+
next if unknown.empty?
|
|
416
|
+
|
|
417
|
+
ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
|
|
418
|
+
labelled = ids.empty? ? "" : " (#{ids})"
|
|
419
|
+
$stderr.puts("warning: #{name} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
|
|
221
420
|
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# A gem fails the run when it trips an enabled gate that is neither
|
|
424
|
+
# whole-gem --ignore'd nor covered by a granular .still_active.yml
|
|
425
|
+
# suppression. Each signal is checked independently so accepting one finding
|
|
426
|
+
# (e.g. a single advisory) never blinds the others.
|
|
427
|
+
def gate_failed?(name, data, config)
|
|
428
|
+
return false if config.ignored_gems.include?(name)
|
|
429
|
+
|
|
430
|
+
suppressions = config.suppressions
|
|
431
|
+
failed_activity?(name, data, config, suppressions) ||
|
|
432
|
+
failed_vulnerability?(name, data, config, suppressions) ||
|
|
433
|
+
failed_outdated?(name, data, config, suppressions) ||
|
|
434
|
+
failed_poison?(name, data, config, suppressions) ||
|
|
435
|
+
failed_language_ceiling?(name, data, config, suppressions)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def failed_poison?(name, data, config, suppressions)
|
|
439
|
+
return false unless config.fail_if_poison
|
|
440
|
+
return false if suppressions.suppressed?(gem: name, signal: :poison)
|
|
222
441
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
442
|
+
# Bare --fail-if-poison fails at :warning and above (a 1-behind :note is FYI,
|
|
443
|
+
# not a build breaker); --fail-if-poison=TIER sets an explicit threshold.
|
|
444
|
+
threshold = config.fail_if_poison == true ? :warning : config.fail_if_poison
|
|
445
|
+
ConstraintHelper.severity_at_or_above?(data[:poison_severity], threshold)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def failed_language_ceiling?(name, data, config, suppressions)
|
|
449
|
+
return false unless config.fail_if_language_ceiling
|
|
450
|
+
return false if suppressions.suppressed?(gem: name, signal: :language_ceiling)
|
|
451
|
+
|
|
452
|
+
# Ceiling findings are only ever :critical (EOL-forced) or :note
|
|
453
|
+
# (latest-not-yet) -- there is no :warning tier for this signal -- so the
|
|
454
|
+
# bare flag defaults to :critical (the genuine blocker: no supported runtime
|
|
455
|
+
# is reachable). A latest-not-yet :note is an FYI/contribution lead that gates
|
|
456
|
+
# only when the user explicitly opts in with =note.
|
|
457
|
+
threshold = config.fail_if_language_ceiling == true ? :critical : config.fail_if_language_ceiling
|
|
458
|
+
ConstraintHelper.severity_at_or_above?(data.dig(:language_ceiling, :severity), threshold)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def failed_activity?(name, data, config, suppressions)
|
|
462
|
+
return false unless config.fail_if_warning || config.fail_if_critical
|
|
463
|
+
return false if suppressions.suppressed?(gem: name, signal: :activity)
|
|
464
|
+
|
|
465
|
+
level = ActivityHelper.activity_level(data)
|
|
466
|
+
(config.fail_if_warning && [:stale, :critical, :archived].include?(level)) ||
|
|
467
|
+
(config.fail_if_critical && [:critical, :archived].include?(level))
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def failed_vulnerability?(name, data, config, suppressions)
|
|
471
|
+
setting = config.fail_if_vulnerable
|
|
472
|
+
return false unless setting
|
|
473
|
+
|
|
474
|
+
# Reason over the live advisory array (not vulnerability_count) so the gate
|
|
475
|
+
# and warn_unknown_severity_gate share one source of truth: a warning that
|
|
476
|
+
# says "failing" can never disagree with whether exit(1) actually fires.
|
|
477
|
+
live = live_advisories(name, data, suppressions)
|
|
478
|
+
return false if live.empty?
|
|
479
|
+
|
|
480
|
+
setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# The gem's advisories minus those an explicit .still_active.yml suppression
|
|
484
|
+
# accepts (by advisory id or alias). Shared by the vulnerability gate and the
|
|
485
|
+
# unknown-severity warning so both reason over the same live set.
|
|
486
|
+
def live_advisories(name, data, suppressions)
|
|
487
|
+
Array(data[:vulnerabilities]).reject do |vuln|
|
|
488
|
+
suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
|
|
225
489
|
end
|
|
226
490
|
end
|
|
491
|
+
|
|
492
|
+
def failed_outdated?(name, data, config, suppressions)
|
|
493
|
+
threshold = config.fail_if_outdated
|
|
494
|
+
return false unless threshold
|
|
495
|
+
return false if suppressions.suppressed?(gem: name, signal: :libyear)
|
|
496
|
+
|
|
497
|
+
data[:libyear] && data[:libyear] > threshold
|
|
498
|
+
end
|
|
227
499
|
end
|
|
228
500
|
end
|
data/lib/still_active/config.rb
CHANGED
|
@@ -3,16 +3,22 @@
|
|
|
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, :ecosystems_email
|
|
11
|
+
attr_accessor :sbom_path
|
|
10
12
|
attr_accessor :alternatives,
|
|
13
|
+
:unreleased_commits,
|
|
14
|
+
:direct_only,
|
|
11
15
|
:baseline_path,
|
|
12
16
|
:critical_warning_emoji,
|
|
13
17
|
:cyclonedx_path,
|
|
14
18
|
:cyclonedx_version,
|
|
15
19
|
:fail_if_critical,
|
|
20
|
+
:fail_if_poison,
|
|
21
|
+
:fail_if_language_ceiling,
|
|
16
22
|
:fail_if_warning,
|
|
17
23
|
:futurist_emoji,
|
|
18
24
|
:gems,
|
|
@@ -24,21 +30,31 @@ module StillActive
|
|
|
24
30
|
:no_warning_range_end,
|
|
25
31
|
:sarif_path,
|
|
26
32
|
:success_emoji,
|
|
33
|
+
:suppressions,
|
|
27
34
|
:unsure_emoji,
|
|
28
35
|
:warning_emoji,
|
|
29
36
|
:warning_range_end
|
|
30
37
|
|
|
31
38
|
def initialize
|
|
32
39
|
@alternatives = false
|
|
40
|
+
@unreleased_commits = false
|
|
41
|
+
@direct_only = false
|
|
33
42
|
@fail_if_critical = false
|
|
43
|
+
@fail_if_poison = false
|
|
44
|
+
@fail_if_language_ceiling = false
|
|
34
45
|
@fail_if_outdated = nil
|
|
35
46
|
@fail_if_vulnerable = nil
|
|
36
47
|
@fail_if_warning = false
|
|
37
48
|
@gemfile_path = nil
|
|
38
49
|
@gems = []
|
|
39
50
|
@ignored_gems = []
|
|
51
|
+
@suppressions = Suppressions.from(nil)
|
|
40
52
|
@github_oauth_token = nil
|
|
41
53
|
@gitlab_token = nil
|
|
54
|
+
@forgejo_token = nil
|
|
55
|
+
@artifactory_token = nil
|
|
56
|
+
@artifactory_host = nil
|
|
57
|
+
@ecosystems_email = nil
|
|
42
58
|
|
|
43
59
|
@parallelism = 10
|
|
44
60
|
|
|
@@ -54,7 +70,11 @@ module StillActive
|
|
|
54
70
|
@unsure_emoji = "❓"
|
|
55
71
|
@warning_emoji = "⚠️"
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
# Release-age thresholds (years) calibrated against real RubyGems cadence,
|
|
74
|
+
# not the npm-derived 12-month convention: healthy mature gems routinely go
|
|
75
|
+
# 12-18 months between releases, so the ok ceiling is 18 months. > 3 years
|
|
76
|
+
# is critical. See #32.
|
|
77
|
+
@no_warning_range_end = 1.5
|
|
58
78
|
@warning_range_end = 3
|
|
59
79
|
end
|
|
60
80
|
|
|
@@ -64,13 +84,50 @@ module StillActive
|
|
|
64
84
|
end
|
|
65
85
|
|
|
66
86
|
def github_oauth_token
|
|
87
|
+
# Cache the resolution including a nil result: the token can't change
|
|
88
|
+
# mid-run, and provider selection now reads this per gem, so a plain ||=
|
|
89
|
+
# (which doesn't memoize nil) would re-shell `gh auth token` for every gem
|
|
90
|
+
# in a no-token run.
|
|
91
|
+
return @github_oauth_token if @github_oauth_token_resolved
|
|
92
|
+
|
|
93
|
+
# Assign before flipping the flag: gh_cli_token shells out and yields the
|
|
94
|
+
# async reactor, so a concurrent fiber must never see resolved=true while
|
|
95
|
+
# the value is still nil (it would wrongly route a tokened gem to the
|
|
96
|
+
# ecosyste.ms fallback). Worst case under contention is a few extra `gh`
|
|
97
|
+
# calls, never a premature nil; Workflow.call also resolves this once before
|
|
98
|
+
# the fan-out so the contended path isn't normally hit.
|
|
67
99
|
@github_oauth_token ||= presence(ENV["GITHUB_TOKEN"]) || presence(ENV["GH_TOKEN"]) || gh_cli_token
|
|
100
|
+
@github_oauth_token_resolved = true
|
|
101
|
+
@github_oauth_token
|
|
68
102
|
end
|
|
69
103
|
|
|
70
104
|
def gitlab_token
|
|
71
105
|
@gitlab_token ||= presence(ENV["GITLAB_TOKEN"]) || glab_cli_token
|
|
72
106
|
end
|
|
73
107
|
|
|
108
|
+
# Codeberg/Forgejo has no ubiquitous CLI to borrow a token from (unlike
|
|
109
|
+
# gh/glab), so it's env-var only. Anonymous works for public repos; a token
|
|
110
|
+
# only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
|
|
111
|
+
# alias for the codeberg.org default host.
|
|
112
|
+
# Optional contact email for ecosyste.ms's "polite pool" (sent as a mailto
|
|
113
|
+
# query param), which raises the anonymous rate limit and lets them reach
|
|
114
|
+
# out. Nil by default -- anonymous is plenty for a typical lockfile.
|
|
115
|
+
def ecosystems_email
|
|
116
|
+
@ecosystems_email ||= presence(ENV["STILL_ACTIVE_ECOSYSTEMS_EMAIL"]&.strip)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def forgejo_token
|
|
120
|
+
@forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def artifactory_token
|
|
124
|
+
@artifactory_token ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_TOKEN"])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def artifactory_host
|
|
128
|
+
@artifactory_host ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_HOST"])
|
|
129
|
+
end
|
|
130
|
+
|
|
74
131
|
# Lazy so that running with --gems=... (no Gemfile needed) doesn't crash
|
|
75
132
|
# when invoked from a directory without a Gemfile in the tree.
|
|
76
133
|
def gemfile_path
|