still_active 2.0.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 +41 -0
- data/README.md +57 -10
- data/lib/helpers/activity_helper.rb +8 -0
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +20 -4
- data/lib/helpers/diff_markdown_helper.rb +21 -5
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +17 -2
- data/lib/helpers/markdown_helper.rb +118 -1
- 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 +13 -24
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +105 -3
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +4 -0
- data/lib/helpers/terminal_helper.rb +144 -6
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +212 -9
- data/lib/still_active/config.rb +28 -1
- data/lib/still_active/config_file.rb +27 -0
- data/lib/still_active/deps_dev_client.rb +115 -5
- data/lib/still_active/diff.rb +22 -0
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/options.rb +44 -1
- 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/sarif/rules.rb +33 -9
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +6 -5
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +201 -8
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- metadata +61 -11
data/lib/still_active/cli.rb
CHANGED
|
@@ -6,15 +6,19 @@ require_relative "diff"
|
|
|
6
6
|
require_relative "../helpers/activity_helper"
|
|
7
7
|
require_relative "../helpers/bot_context"
|
|
8
8
|
require_relative "../helpers/bundler_helper"
|
|
9
|
+
require_relative "../helpers/constraint_helper"
|
|
9
10
|
require_relative "../helpers/cyclonedx_helper"
|
|
10
11
|
require_relative "../helpers/diff_markdown_helper"
|
|
11
12
|
require_relative "../helpers/emoji_helper"
|
|
12
13
|
require_relative "../helpers/markdown_helper"
|
|
13
14
|
require_relative "../helpers/sarif_helper"
|
|
15
|
+
require_relative "../helpers/status_helper"
|
|
14
16
|
require_relative "../helpers/summary_helper"
|
|
15
17
|
require_relative "../helpers/terminal_helper"
|
|
16
18
|
require_relative "../helpers/version_helper"
|
|
17
19
|
require_relative "../helpers/vulnerability_helper"
|
|
20
|
+
require_relative "sbom_reader"
|
|
21
|
+
require_relative "sbom_workflow"
|
|
18
22
|
require_relative "workflow"
|
|
19
23
|
|
|
20
24
|
module StillActive
|
|
@@ -28,11 +32,24 @@ module StillActive
|
|
|
28
32
|
# win over it: CLI flag > env var > config file > default.
|
|
29
33
|
config_data = ConfigFile.load
|
|
30
34
|
ConfigFile.apply(StillActive.config, config_data).each { |warning| $stderr.puts("warning: #{warning}") }
|
|
31
|
-
options =
|
|
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
|
|
32
44
|
# After CLI flags resolve, nudge (don't auto-inherit) an un-imported
|
|
33
45
|
# bundler-audit ignore list when the vulnerability gate is on.
|
|
34
46
|
hint = ConfigFile.import_hint(config_data)
|
|
35
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
|
+
|
|
36
53
|
unless options[:provided_gems]
|
|
37
54
|
begin
|
|
38
55
|
StillActive.config.gems = BundlerHelper.gemfile_dependencies
|
|
@@ -74,7 +91,12 @@ module StillActive
|
|
|
74
91
|
summary: SummaryHelper.summarize(result, ruby_info: ruby_info),
|
|
75
92
|
# Surface the derived verdict so a machine/LLM consumer reads it
|
|
76
93
|
# directly instead of re-deriving it from the raw dates.
|
|
77
|
-
gems: result.transform_values
|
|
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,
|
|
78
100
|
}
|
|
79
101
|
output[:ruby] = ruby_info if ruby_info
|
|
80
102
|
output[:pr_context] = pr_context if pr_context
|
|
@@ -92,6 +114,103 @@ module StillActive
|
|
|
92
114
|
|
|
93
115
|
private
|
|
94
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
|
+
|
|
95
214
|
# Dates live in the result as real Time objects (the activity/libyear math
|
|
96
215
|
# needs them), but the JSON contract is ISO8601 UTC strings, matching
|
|
97
216
|
# generated_at. Normalize at the serialization boundary only, so the
|
|
@@ -185,8 +304,9 @@ module StillActive
|
|
|
185
304
|
current = current_snapshot(result, ruby_info)
|
|
186
305
|
baseline = JSON.parse(File.read(baseline_path))
|
|
187
306
|
diff = Diff.call(baseline: baseline, current: current)
|
|
307
|
+
accepted = partition_accepted_regressions!(diff)
|
|
188
308
|
puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
|
|
189
|
-
puts DiffMarkdownHelper.render(diff)
|
|
309
|
+
puts DiffMarkdownHelper.render(diff, accepted: accepted)
|
|
190
310
|
exit(1) if diff.regressions.any?
|
|
191
311
|
rescue JSON::ParserError => e
|
|
192
312
|
$stderr.puts("error: --baseline file is not valid JSON: #{e.message}")
|
|
@@ -199,6 +319,27 @@ module StillActive
|
|
|
199
319
|
exit(2)
|
|
200
320
|
end
|
|
201
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
|
+
|
|
202
343
|
def current_snapshot(result, ruby_info)
|
|
203
344
|
snapshot = {
|
|
204
345
|
"schema_version" => 1,
|
|
@@ -234,6 +375,10 @@ module StillActive
|
|
|
234
375
|
|
|
235
376
|
puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
|
|
236
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?
|
|
237
382
|
alternatives = MarkdownHelper.alternatives_section(result)
|
|
238
383
|
puts alternatives unless alternatives.empty?
|
|
239
384
|
transitive = MarkdownHelper.transitive_section(result)
|
|
@@ -246,11 +391,35 @@ module StillActive
|
|
|
246
391
|
|
|
247
392
|
def check_exit_status(result)
|
|
248
393
|
config = StillActive.config
|
|
249
|
-
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
|
|
250
395
|
|
|
396
|
+
warn_unknown_severity_gate(result, config)
|
|
251
397
|
exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
|
|
252
398
|
end
|
|
253
399
|
|
|
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)
|
|
409
|
+
|
|
410
|
+
suppressions = config.suppressions
|
|
411
|
+
result.each do |name, data|
|
|
412
|
+
next if config.ignored_gems.include?(name)
|
|
413
|
+
|
|
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)")
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
254
423
|
# A gem fails the run when it trips an enabled gate that is neither
|
|
255
424
|
# whole-gem --ignore'd nor covered by a granular .still_active.yml
|
|
256
425
|
# suppression. Each signal is checked independently so accepting one finding
|
|
@@ -261,7 +430,32 @@ module StillActive
|
|
|
261
430
|
suppressions = config.suppressions
|
|
262
431
|
failed_activity?(name, data, config, suppressions) ||
|
|
263
432
|
failed_vulnerability?(name, data, config, suppressions) ||
|
|
264
|
-
failed_outdated?(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)
|
|
441
|
+
|
|
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)
|
|
265
459
|
end
|
|
266
460
|
|
|
267
461
|
def failed_activity?(name, data, config, suppressions)
|
|
@@ -276,16 +470,25 @@ module StillActive
|
|
|
276
470
|
def failed_vulnerability?(name, data, config, suppressions)
|
|
277
471
|
setting = config.fail_if_vulnerable
|
|
278
472
|
return false unless setting
|
|
279
|
-
return false unless data[:vulnerability_count]&.positive?
|
|
280
473
|
|
|
281
|
-
live
|
|
282
|
-
|
|
283
|
-
|
|
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)
|
|
284
478
|
return false if live.empty?
|
|
285
479
|
|
|
286
480
|
setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
|
|
287
481
|
end
|
|
288
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]))
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
289
492
|
def failed_outdated?(name, data, config, suppressions)
|
|
290
493
|
threshold = config.fail_if_outdated
|
|
291
494
|
return false unless threshold
|
data/lib/still_active/config.rb
CHANGED
|
@@ -7,7 +7,8 @@ require_relative "suppressions"
|
|
|
7
7
|
|
|
8
8
|
module StillActive
|
|
9
9
|
class Config
|
|
10
|
-
attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :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
|
|
11
12
|
attr_accessor :alternatives,
|
|
12
13
|
:unreleased_commits,
|
|
13
14
|
:direct_only,
|
|
@@ -16,6 +17,8 @@ module StillActive
|
|
|
16
17
|
:cyclonedx_path,
|
|
17
18
|
:cyclonedx_version,
|
|
18
19
|
:fail_if_critical,
|
|
20
|
+
:fail_if_poison,
|
|
21
|
+
:fail_if_language_ceiling,
|
|
19
22
|
:fail_if_warning,
|
|
20
23
|
:futurist_emoji,
|
|
21
24
|
:gems,
|
|
@@ -37,6 +40,8 @@ module StillActive
|
|
|
37
40
|
@unreleased_commits = false
|
|
38
41
|
@direct_only = false
|
|
39
42
|
@fail_if_critical = false
|
|
43
|
+
@fail_if_poison = false
|
|
44
|
+
@fail_if_language_ceiling = false
|
|
40
45
|
@fail_if_outdated = nil
|
|
41
46
|
@fail_if_vulnerable = nil
|
|
42
47
|
@fail_if_warning = false
|
|
@@ -49,6 +54,7 @@ module StillActive
|
|
|
49
54
|
@forgejo_token = nil
|
|
50
55
|
@artifactory_token = nil
|
|
51
56
|
@artifactory_host = nil
|
|
57
|
+
@ecosystems_email = nil
|
|
52
58
|
|
|
53
59
|
@parallelism = 10
|
|
54
60
|
|
|
@@ -78,7 +84,21 @@ module StillActive
|
|
|
78
84
|
end
|
|
79
85
|
|
|
80
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.
|
|
81
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
|
|
82
102
|
end
|
|
83
103
|
|
|
84
104
|
def gitlab_token
|
|
@@ -89,6 +109,13 @@ module StillActive
|
|
|
89
109
|
# gh/glab), so it's env-var only. Anonymous works for public repos; a token
|
|
90
110
|
# only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
|
|
91
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
|
+
|
|
92
119
|
def forgejo_token
|
|
93
120
|
@forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
|
|
94
121
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require_relative "suppressions"
|
|
5
|
+
require_relative "../helpers/constraint_helper"
|
|
5
6
|
require_relative "../helpers/vulnerability_helper"
|
|
6
7
|
|
|
7
8
|
module StillActive
|
|
@@ -48,6 +49,8 @@ module StillActive
|
|
|
48
49
|
case key
|
|
49
50
|
when "fail_if_critical" then set_boolean(config, :fail_if_critical=, value, key, warnings)
|
|
50
51
|
when "fail_if_warning" then set_boolean(config, :fail_if_warning=, value, key, warnings)
|
|
52
|
+
when "fail_if_poison" then apply_fail_if_poison(config, value, warnings)
|
|
53
|
+
when "fail_if_language_ceiling" then apply_fail_if_language_ceiling(config, value, warnings)
|
|
51
54
|
when "alternatives" then set_boolean(config, :alternatives=, value, key, warnings)
|
|
52
55
|
when "unreleased_commits" then set_boolean(config, :unreleased_commits=, value, key, warnings)
|
|
53
56
|
when "direct_only" then set_boolean(config, :direct_only=, value, key, warnings)
|
|
@@ -134,6 +137,30 @@ module StillActive
|
|
|
134
137
|
end
|
|
135
138
|
end
|
|
136
139
|
|
|
140
|
+
# true/false, or a severity tier (note|warning|critical). Mirrors the CLI
|
|
141
|
+
# flag: bare `true` fails at :warning, a tier sets an explicit threshold.
|
|
142
|
+
def apply_fail_if_poison(config, value, warnings)
|
|
143
|
+
if value == true || value == false
|
|
144
|
+
config.fail_if_poison = value
|
|
145
|
+
elsif ConstraintHelper::SEVERITY.map(&:to_s).include?(value.to_s)
|
|
146
|
+
config.fail_if_poison = value.to_sym
|
|
147
|
+
else
|
|
148
|
+
warnings << "#{FILENAME}: fail_if_poison must be true/false or one of #{ConstraintHelper::SEVERITY.join(", ")} (got #{value.inspect}), ignoring it"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# true/false, or a severity tier (note|warning|critical). Mirrors the CLI
|
|
153
|
+
# flag: bare `true` fails at :warning, a tier sets an explicit threshold.
|
|
154
|
+
def apply_fail_if_language_ceiling(config, value, warnings)
|
|
155
|
+
if value == true || value == false
|
|
156
|
+
config.fail_if_language_ceiling = value
|
|
157
|
+
elsif ConstraintHelper::SEVERITY.map(&:to_s).include?(value.to_s)
|
|
158
|
+
config.fail_if_language_ceiling = value.to_sym
|
|
159
|
+
else
|
|
160
|
+
warnings << "#{FILENAME}: fail_if_language_ceiling must be true/false or one of #{ConstraintHelper::SEVERITY.join(", ")} (got #{value.inspect}), ignoring it"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
137
164
|
def apply_fail_if_vulnerable(config, value, warnings)
|
|
138
165
|
if value == true || value == false
|
|
139
166
|
config.fail_if_vulnerable = value || nil
|
|
@@ -8,19 +8,73 @@ module StillActive
|
|
|
8
8
|
|
|
9
9
|
BASE_URI = URI("https://api.deps.dev/")
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# A known-vulnerable package used to canary the `advisoryKeys` schema. deps.dev
|
|
12
|
+
# is an explicitly ALPHA API (v3alpha), and every cross-ecosystem vulnerability
|
|
13
|
+
# count flows through the `advisoryKeys` field with a field-level degrade to
|
|
14
|
+
# `[]` -- so a rename or drop of that field would silently turn every count to
|
|
15
|
+
# 0 and read a known-vulnerable package as clean (exit 0). django 3.0.0 carries
|
|
16
|
+
# 30+ permanent advisories; if the canary returns none, the schema drifted.
|
|
17
|
+
ADVISORY_CANARY = { system: "pypi", name: "django", version: "3.0.0" }.freeze
|
|
18
|
+
|
|
19
|
+
# Is deps.dev still returning advisories in the shape we parse? False when the
|
|
20
|
+
# canary comes back empty (schema drift) or unreachable (can't confirm). The
|
|
21
|
+
# caller warns loudly rather than presenting a possibly-understated "all clear".
|
|
22
|
+
def advisory_schema_ok?
|
|
23
|
+
info = version_info(gem_name: ADVISORY_CANARY[:name], version: ADVISORY_CANARY[:version], system: ADVISORY_CANARY[:system])
|
|
24
|
+
!(info.nil? || info[:advisory_keys].empty?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `system` is the deps.dev package system, lowercased: rubygems, npm, pypi,
|
|
28
|
+
# cargo, go, maven, nuget. It matches the ecosystem symbol SbomReader emits,
|
|
29
|
+
# so a cross-ecosystem caller threads it straight through. An unknown system
|
|
30
|
+
# 404s and degrades to nil (HttpHelper swallows 404), never raising.
|
|
31
|
+
def version_info(gem_name:, version:, system: :rubygems)
|
|
12
32
|
return if gem_name.nil? || version.nil?
|
|
13
33
|
|
|
14
|
-
path = "/v3alpha/systems/
|
|
34
|
+
path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(gem_name)}/versions/#{encode(version)}"
|
|
15
35
|
body = HttpHelper.get_json(BASE_URI, path)
|
|
16
36
|
return if body.nil?
|
|
17
37
|
|
|
18
38
|
{
|
|
19
39
|
advisory_keys: body.dig("advisoryKeys")&.map { |a| a["id"] } || [],
|
|
20
40
|
project_id: extract_project_id(body),
|
|
41
|
+
# The locked version's release date -- the cross-ecosystem libyear input
|
|
42
|
+
# (paired with the package's latest-release date). Already in this response,
|
|
43
|
+
# so no extra fetch; nil when the feed omits it.
|
|
44
|
+
published_at: body["publishedAt"],
|
|
21
45
|
}
|
|
22
46
|
end
|
|
23
47
|
|
|
48
|
+
# The package's default version and its release date: { version:,
|
|
49
|
+
# published_at: }, or nil. deps.dev's default version is the latest stable;
|
|
50
|
+
# when none is flagged (all pre-release), the newest publishedAt stands in so
|
|
51
|
+
# a still-active package isn't mis-read as dormant. The version is exposed (not
|
|
52
|
+
# just the date) so a caller can recover the package's repo link from it when
|
|
53
|
+
# an exact locked version isn't indexed (yanked/normalization mismatch).
|
|
54
|
+
def default_version_info(name:, system: :rubygems)
|
|
55
|
+
return if name.nil?
|
|
56
|
+
|
|
57
|
+
path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(name)}"
|
|
58
|
+
body = HttpHelper.get_json(BASE_URI, path)
|
|
59
|
+
return if body.nil?
|
|
60
|
+
|
|
61
|
+
versions = body["versions"]
|
|
62
|
+
return unless versions.is_a?(Array) && !versions.empty?
|
|
63
|
+
|
|
64
|
+
entry = versions.find { |v| v.is_a?(Hash) && v["isDefault"] } || newest_version(versions)
|
|
65
|
+
return if entry.nil?
|
|
66
|
+
|
|
67
|
+
{ version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The package's most recent release date (ISO8601 string), or nil. This is
|
|
71
|
+
# the cross-ecosystem freshness signal, and deps.dev's publishedAt is more
|
|
72
|
+
# reliable than ecosyste.ms's latest_release_published_at, which can lag badly
|
|
73
|
+
# (mpmath: 2023 vs 2026).
|
|
74
|
+
def latest_release_date(name:, system: :rubygems)
|
|
75
|
+
default_version_info(name: name, system: system)&.dig(:published_at)
|
|
76
|
+
end
|
|
77
|
+
|
|
24
78
|
def project_scorecard(project_id:)
|
|
25
79
|
return if project_id.nil?
|
|
26
80
|
|
|
@@ -34,6 +88,11 @@ module StillActive
|
|
|
34
88
|
{
|
|
35
89
|
score: scorecard["overallScore"],
|
|
36
90
|
date: scorecard["date"],
|
|
91
|
+
# The "Maintained" sub-check (0-10) scores recent commit and issue
|
|
92
|
+
# activity directly -- still_active's core question -- so we surface it
|
|
93
|
+
# alongside the aggregate. nil when the check is absent (never 0, which
|
|
94
|
+
# would read as "unmaintained" rather than "not measured").
|
|
95
|
+
maintained: maintained_check_score(scorecard),
|
|
37
96
|
}
|
|
38
97
|
end
|
|
39
98
|
|
|
@@ -48,7 +107,9 @@ module StillActive
|
|
|
48
107
|
id: body.dig("advisoryKey", "id"),
|
|
49
108
|
url: body["url"],
|
|
50
109
|
title: body["title"],
|
|
51
|
-
|
|
110
|
+
# deps.dev's v3alpha returns aliases as bare id strings (["CVE-..."]);
|
|
111
|
+
# tolerate the legacy object shape ({"id":...}) too since it's an alpha API.
|
|
112
|
+
aliases: Array(body["aliases"]).filter_map { |a| normalize_alias(a) },
|
|
52
113
|
cvss3_score: body["cvss3Score"],
|
|
53
114
|
cvss3_vector: body["cvss3Vector"],
|
|
54
115
|
cvss2_score: body["cvss2Score"],
|
|
@@ -58,6 +119,35 @@ module StillActive
|
|
|
58
119
|
|
|
59
120
|
private
|
|
60
121
|
|
|
122
|
+
# Coerce an advisory alias to a string id. A BARE non-string alias is deps.dev
|
|
123
|
+
# ALPHA-API schema drift: coerce it (a non-string alias makes the advisory-merge
|
|
124
|
+
# sort raise, which strips a gem of ALL its signals and reads a vulnerable gem
|
|
125
|
+
# clean) but warn once so the drift surfaces rather than passing invisibly, the
|
|
126
|
+
# same "loud on schema drift" stance as the advisory-count canary.
|
|
127
|
+
def normalize_alias(raw)
|
|
128
|
+
return raw["id"]&.to_s if raw.is_a?(Hash)
|
|
129
|
+
return raw if raw.is_a?(String) || raw.nil?
|
|
130
|
+
|
|
131
|
+
warn_alias_drift(raw)
|
|
132
|
+
raw.to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Warn once per run (not per alias), matching CvssHelper's warn-once stance.
|
|
136
|
+
def warn_alias_drift(raw)
|
|
137
|
+
return if @alias_drift_warned
|
|
138
|
+
|
|
139
|
+
@alias_drift_warned = true
|
|
140
|
+
$stderr.puts("warning: deps.dev returned a non-string advisory alias (#{raw.class}); the ALPHA API schema may have drifted")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The OpenSSF "Maintained" check score (0-10), or nil when it's absent. A
|
|
144
|
+
# malformed (non-array) `checks` payload also yields nil rather than raising:
|
|
145
|
+
# this is a best-effort enrichment and must never crash the per-gem audit and
|
|
146
|
+
# vanish the gem from the output via the workflow's rescue.
|
|
147
|
+
def maintained_check_score(scorecard)
|
|
148
|
+
Array(scorecard["checks"]).find { |c| c.is_a?(Hash) && c["name"] == "Maintained" }&.dig("score")
|
|
149
|
+
end
|
|
150
|
+
|
|
61
151
|
# Builds a deps.dev project id ("host/owner/repo", or a deeper path for
|
|
62
152
|
# GitLab subgroups) from the SOURCE_REPO link URL. GitHub/Bitbucket projects
|
|
63
153
|
# are always host/owner/repo, but GitLab namespaces nest arbitrarily
|
|
@@ -66,7 +156,17 @@ module StillActive
|
|
|
66
156
|
url = body.dig("links")&.find { |l| l["label"] == "SOURCE_REPO" }&.dig("url")
|
|
67
157
|
return if url.nil?
|
|
68
158
|
|
|
69
|
-
|
|
159
|
+
# deps.dev returns SOURCE_REPO in many shapes: `https://`, but also `git://`,
|
|
160
|
+
# `git+https://`, `git+ssh://git@...`, `ssh://git@...`. Strip the `git+` prefix,
|
|
161
|
+
# any `scheme://`, and ssh userinfo (`git@`) so what remains is host/owner/repo.
|
|
162
|
+
# Left unnormalized, a git-scheme URL mis-parses (host becomes `git:`), which
|
|
163
|
+
# 400s the projects lookup and drops the repo signals.
|
|
164
|
+
cleaned = url.strip.delete_prefix("git+").sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").sub(%r{\A[^/@]+@}, "")
|
|
165
|
+
# scp-style git remotes separate host from path with a colon (`host:owner/repo`)
|
|
166
|
+
# rather than a slash. Convert it so the split yields host/owner/repo, but leave
|
|
167
|
+
# a real port (`host:443/...`) alone -- a colon before a digit isn't a path.
|
|
168
|
+
cleaned = cleaned.sub(%r{\A([^/:]+):(?=\D)}, '\1/')
|
|
169
|
+
host, *segments = cleaned.split("/")
|
|
70
170
|
segments = repo_path_segments(host, segments)
|
|
71
171
|
return if host.nil? || segments.empty?
|
|
72
172
|
|
|
@@ -90,8 +190,18 @@ module StillActive
|
|
|
90
190
|
segments
|
|
91
191
|
end
|
|
92
192
|
|
|
193
|
+
# The version with the newest publishedAt, used only when no version is
|
|
194
|
+
# flagged default. Versions without a publishedAt are dropped; the rest
|
|
195
|
+
# compare lexicographically (deps.dev returns RFC3339 UTC, so string order
|
|
196
|
+
# is chronological), and a dated release always wins over an undated one.
|
|
197
|
+
def newest_version(versions)
|
|
198
|
+
versions
|
|
199
|
+
.select { |v| v.is_a?(Hash) && v["publishedAt"] }
|
|
200
|
+
.max_by { |v| v["publishedAt"].to_s }
|
|
201
|
+
end
|
|
202
|
+
|
|
93
203
|
def encode(value)
|
|
94
|
-
URI.encode_www_form_component(value)
|
|
204
|
+
URI.encode_www_form_component(value.to_s)
|
|
95
205
|
end
|
|
96
206
|
end
|
|
97
207
|
end
|
data/lib/still_active/diff.rb
CHANGED
|
@@ -15,6 +15,19 @@ module StillActive
|
|
|
15
15
|
NEW_GEM_LIBYEAR_THRESHOLD = 0.5 # added gems already this far behind regress
|
|
16
16
|
LIBYEAR_DELTA_THRESHOLD = 0.01 # floating-point fuzz
|
|
17
17
|
|
|
18
|
+
# Maps a maintenance regression kind to the .still_active.yml signal that
|
|
19
|
+
# accepts it, so a committed suppression mutes the diff exactly as it already
|
|
20
|
+
# mutes the audit and SARIF gates. Only kinds a bare gem+signal entry can
|
|
21
|
+
# cover appear here: vulnerability regressions require an explicit advisory id
|
|
22
|
+
# (which the diff regression doesn't carry) and stay CI-failable, and
|
|
23
|
+
# scorecard/yanked/ruby-EOL are not gateable signals.
|
|
24
|
+
SUPPRESSIBLE_REGRESSION_SIGNALS = {
|
|
25
|
+
new_gem_archived: :activity,
|
|
26
|
+
archived: :activity,
|
|
27
|
+
new_gem_stale: :libyear,
|
|
28
|
+
libyear_worsened: :libyear,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
18
31
|
class UnsupportedSchemaError < StandardError; end
|
|
19
32
|
|
|
20
33
|
Added = Struct.new(:name, :data, keyword_init: true)
|
|
@@ -22,10 +35,19 @@ module StillActive
|
|
|
22
35
|
Bumped = Struct.new(:name, :before_version, :after_version, :kind, :before, :after, keyword_init: true)
|
|
23
36
|
SignalChange = Struct.new(:name, :changes, :before, :after, keyword_init: true)
|
|
24
37
|
Regression = Struct.new(:kind, :gem, :detail, keyword_init: true)
|
|
38
|
+
# A regression a committed .still_active.yml knowingly accepts: carried out of
|
|
39
|
+
# the CI-failable set so the diff render can show it as suppressed-with-reason.
|
|
40
|
+
Accepted = Struct.new(:regression, :reason, keyword_init: true)
|
|
25
41
|
Result = Struct.new(:added, :removed, :bumped, :signal_changes, :regressions, :ruby, keyword_init: true)
|
|
26
42
|
|
|
27
43
|
extend self
|
|
28
44
|
|
|
45
|
+
# The suppression signal that accepts this regression kind, or nil when the
|
|
46
|
+
# kind is not suppressible by a bare gem+signal .still_active.yml entry.
|
|
47
|
+
def suppressible_signal(kind)
|
|
48
|
+
SUPPRESSIBLE_REGRESSION_SIGNALS[kind]
|
|
49
|
+
end
|
|
50
|
+
|
|
29
51
|
def call(baseline:, current:)
|
|
30
52
|
validate_schema!(baseline, "baseline")
|
|
31
53
|
validate_schema!(current, "current")
|