still_active 3.0.0.rc4 → 3.0.0.rc5
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 +3 -0
- data/lib/helpers/activity_helper.rb +3 -3
- data/lib/helpers/alternatives_helper.rb +1 -1
- data/lib/helpers/ansi_helper.rb +1 -1
- data/lib/helpers/bot_context.rb +7 -7
- data/lib/helpers/bundler_helper.rb +1 -2
- data/lib/helpers/catalog_index.rb +1 -1
- data/lib/helpers/constraint_helper.rb +6 -6
- data/lib/helpers/cvss_helper.rb +2 -2
- data/lib/helpers/cyclonedx_helper.rb +14 -14
- data/lib/helpers/diff_markdown_helper.rb +4 -4
- data/lib/helpers/dotnet_helper.rb +4 -4
- data/lib/helpers/endoflife_helper.rb +3 -3
- data/lib/helpers/http_helper.rb +31 -19
- data/lib/helpers/lockfile_dependency_parser.rb +2 -2
- data/lib/helpers/markdown_escape.rb +1 -1
- data/lib/helpers/markdown_helper.rb +7 -7
- data/lib/helpers/ruby_advisory_db.rb +1 -1
- data/lib/helpers/ruby_helper.rb +3 -3
- data/lib/helpers/runtime_ceiling_helper.rb +2 -2
- data/lib/helpers/sarif_helper.rb +34 -34
- data/lib/helpers/status_helper.rb +1 -1
- data/lib/helpers/summary_helper.rb +1 -1
- data/lib/helpers/terminal_helper.rb +8 -8
- data/lib/helpers/vulnerability_helper.rb +1 -1
- data/lib/still_active/artifactory_client.rb +47 -18
- data/lib/still_active/cli.rb +38 -38
- data/lib/still_active/compact_index_client.rb +117 -0
- data/lib/still_active/config.rb +1 -1
- data/lib/still_active/config_file.rb +4 -4
- data/lib/still_active/deps_dev_client.rb +7 -7
- data/lib/still_active/diff.rb +12 -12
- data/lib/still_active/ecosystem_lens.rb +5 -5
- data/lib/still_active/ecosystems_client.rb +7 -7
- data/lib/still_active/forgejo_client.rb +3 -3
- data/lib/still_active/github_client.rb +6 -6
- data/lib/still_active/gitlab_client.rb +3 -3
- data/lib/still_active/options.rb +4 -4
- data/lib/still_active/osv_client.rb +8 -8
- data/lib/still_active/poison_security_correlator.rb +3 -3
- data/lib/still_active/pypi_client.rb +2 -2
- data/lib/still_active/repository.rb +4 -4
- data/lib/still_active/sarif/rules.rb +20 -20
- data/lib/still_active/sbom_reader.rb +8 -8
- data/lib/still_active/sbom_workflow.rb +7 -7
- data/lib/still_active/source_credentials.rb +39 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +57 -31
- data/still_active.gemspec +16 -20
- metadata +5 -115
data/lib/still_active/cli.rb
CHANGED
|
@@ -32,20 +32,20 @@ module StillActive
|
|
|
32
32
|
# Apply the committed .still_active.yml first so CLI flags (parsed next)
|
|
33
33
|
# win over it: CLI flag > env var > config file > default.
|
|
34
34
|
config_data = ConfigFile.load
|
|
35
|
-
ConfigFile.apply(StillActive.config, config_data).each { |warning|
|
|
35
|
+
ConfigFile.apply(StillActive.config, config_data).each { |warning| warn("warning: #{warning}") }
|
|
36
36
|
options = begin
|
|
37
37
|
Options.new.parse!(args)
|
|
38
38
|
rescue ArgumentError, OptionParser::ParseError => e
|
|
39
39
|
# Bad CLI input (conflicting flags, a missing file, an unknown flag, a bad
|
|
40
40
|
# enum value) is a user error: print it and exit 2, matching the SBOM/baseline
|
|
41
41
|
# paths, rather than letting the raise bubble into a Ruby backtrace.
|
|
42
|
-
|
|
42
|
+
warn("error: #{e.message}")
|
|
43
43
|
exit(2)
|
|
44
44
|
end
|
|
45
45
|
# After CLI flags resolve, nudge (don't auto-inherit) an un-imported
|
|
46
46
|
# bundler-audit ignore list when the vulnerability gate is on.
|
|
47
47
|
hint = ConfigFile.import_hint(config_data)
|
|
48
|
-
|
|
48
|
+
warn("hint: #{hint}") if hint
|
|
49
49
|
# An SBOM audit is cross-ecosystem: it runs the deps.dev/ecosyste.ms lens
|
|
50
50
|
# over the SBOM's packages, not Bundler over a lockfile. Dispatch before the
|
|
51
51
|
# Bundler resolution below so a Gemfile is never required (or read).
|
|
@@ -55,7 +55,7 @@ module StillActive
|
|
|
55
55
|
begin
|
|
56
56
|
StillActive.config.gems = BundlerHelper.gemfile_dependencies
|
|
57
57
|
rescue MissingLockfileError => e
|
|
58
|
-
|
|
58
|
+
warn("error: #{e.message}")
|
|
59
59
|
exit(2)
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -85,7 +85,7 @@ module StillActive
|
|
|
85
85
|
output = {
|
|
86
86
|
"$schema": SCHEMA_URL,
|
|
87
87
|
schema_version: 1,
|
|
88
|
-
tool: {
|
|
88
|
+
tool: {name: "still_active", version: StillActive::VERSION},
|
|
89
89
|
generated_at: Time.now.utc.iso8601,
|
|
90
90
|
# A one-object digest of the audit's posture, so a machine/LLM
|
|
91
91
|
# consumer reads the headline counts without iterating every gem.
|
|
@@ -95,9 +95,9 @@ module StillActive
|
|
|
95
95
|
gems: result.transform_values do |data|
|
|
96
96
|
data.merge(
|
|
97
97
|
activity_level: ActivityHelper.activity_level(data),
|
|
98
|
-
status: StatusHelper.gem_status(data)
|
|
98
|
+
status: StatusHelper.gem_status(data)
|
|
99
99
|
)
|
|
100
|
-
end
|
|
100
|
+
end
|
|
101
101
|
}
|
|
102
102
|
output[:ruby] = ruby_info if ruby_info
|
|
103
103
|
output[:pr_context] = pr_context if pr_context
|
|
@@ -127,7 +127,7 @@ module StillActive
|
|
|
127
127
|
# field rename would silently zero every vuln count. Canary the schema once
|
|
128
128
|
# and warn loudly so a degraded run never reads as an authoritative all-clear.
|
|
129
129
|
unless DepsDevClient.advisory_schema_ok?
|
|
130
|
-
|
|
130
|
+
warn("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")
|
|
131
131
|
end
|
|
132
132
|
sbom = SbomReader.parse(path)
|
|
133
133
|
outcome = if $stderr.tty?
|
|
@@ -174,7 +174,7 @@ module StillActive
|
|
|
174
174
|
end
|
|
175
175
|
|
|
176
176
|
def unsupported_sbom_format!(flag)
|
|
177
|
-
|
|
177
|
+
warn("error: #{flag} output is not supported in --sbom mode (it needs the native Ruby audit's lockfile/snapshot, which a cross-ecosystem SBOM can't supply); use --sarif, --markdown, --terminal, or --json")
|
|
178
178
|
exit(2)
|
|
179
179
|
end
|
|
180
180
|
|
|
@@ -182,7 +182,7 @@ module StillActive
|
|
|
182
182
|
sarif_json = SarifHelper.render_sbom(
|
|
183
183
|
result: result,
|
|
184
184
|
source_uri: File.basename(sbom_path),
|
|
185
|
-
tool_version: StillActive::VERSION
|
|
185
|
+
tool_version: StillActive::VERSION
|
|
186
186
|
)
|
|
187
187
|
|
|
188
188
|
write_output(sarif_path, sarif_json)
|
|
@@ -190,7 +190,7 @@ module StillActive
|
|
|
190
190
|
|
|
191
191
|
# "-" means stdout (the SARIF/CycloneDX convention); any other PATH is a file.
|
|
192
192
|
def write_output(path, content)
|
|
193
|
-
path == "-" ? puts(content) : File.write(path, content)
|
|
193
|
+
(path == "-") ? puts(content) : File.write(path, content)
|
|
194
194
|
end
|
|
195
195
|
|
|
196
196
|
# SbomReader never raises (a malformed file degrades to an empty result), which
|
|
@@ -204,15 +204,15 @@ module StillActive
|
|
|
204
204
|
# Matches SbomReader's own parse contract (a `components` array is what it
|
|
205
205
|
# reads). A metadata-only CycloneDX has nothing to audit, so the message
|
|
206
206
|
# names the missing array rather than over-claiming "not CycloneDX".
|
|
207
|
-
|
|
207
|
+
warn("error: #{path} has no CycloneDX `components` array to audit")
|
|
208
208
|
exit(2)
|
|
209
209
|
rescue JSON::ParserError
|
|
210
|
-
|
|
210
|
+
warn("error: #{path} is not valid JSON")
|
|
211
211
|
exit(2)
|
|
212
212
|
rescue SystemCallError => e
|
|
213
213
|
# Options checked the path exists, not that it's readable (a permission
|
|
214
214
|
# denial, or a directory). Exit cleanly instead of crashing with a trace.
|
|
215
|
-
|
|
215
|
+
warn("error: cannot read SBOM file #{path}: #{e.message}")
|
|
216
216
|
exit(2)
|
|
217
217
|
end
|
|
218
218
|
|
|
@@ -222,16 +222,16 @@ module StillActive
|
|
|
222
222
|
def emit_sbom_json(result, unassessable)
|
|
223
223
|
output = {
|
|
224
224
|
schema_version: 1,
|
|
225
|
-
tool: {
|
|
225
|
+
tool: {name: "still_active", version: StillActive::VERSION},
|
|
226
226
|
generated_at: Time.now.utc.iso8601,
|
|
227
227
|
summary: sbom_summary(result, unassessable),
|
|
228
228
|
dependencies: result.transform_values do |data|
|
|
229
229
|
data.merge(
|
|
230
230
|
activity_level: ActivityHelper.activity_level(data),
|
|
231
|
-
status: StatusHelper.gem_status(data)
|
|
231
|
+
status: StatusHelper.gem_status(data)
|
|
232
232
|
)
|
|
233
233
|
end,
|
|
234
|
-
unassessable
|
|
234
|
+
unassessable:
|
|
235
235
|
}
|
|
236
236
|
puts iso8601_times(output).to_json
|
|
237
237
|
end
|
|
@@ -244,7 +244,7 @@ module StillActive
|
|
|
244
244
|
# The single worst per-dependency verdict, so a consumer reads one
|
|
245
245
|
# project-level posture without scanning every dependency.
|
|
246
246
|
status: StatusHelper.project_status(result),
|
|
247
|
-
status_counts: statuses.tally
|
|
247
|
+
status_counts: statuses.tally
|
|
248
248
|
}
|
|
249
249
|
end
|
|
250
250
|
|
|
@@ -253,8 +253,8 @@ module StillActive
|
|
|
253
253
|
def warn_unassessable(unassessable)
|
|
254
254
|
return if unassessable.empty?
|
|
255
255
|
|
|
256
|
-
noun = unassessable.size == 1 ? "dependency" : "dependencies"
|
|
257
|
-
|
|
256
|
+
noun = (unassessable.size == 1) ? "dependency" : "dependencies"
|
|
257
|
+
warn("warning: #{unassessable.size} #{noun} could not be assessed " \
|
|
258
258
|
"(a private/alternative registry, unsupported ecosystem, missing version, no package URL, or a lookup failure); " \
|
|
259
259
|
"see \"unassessable\" in the JSON output")
|
|
260
260
|
end
|
|
@@ -278,7 +278,7 @@ module StillActive
|
|
|
278
278
|
def warn_stale_suppressions
|
|
279
279
|
present = StillActive.config.gems.map { |gem| gem[:name] }
|
|
280
280
|
StillActive.config.suppressions.stale_gem_warnings(present).each do |warning|
|
|
281
|
-
|
|
281
|
+
warn("warning: #{warning}")
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
284
|
|
|
@@ -288,10 +288,10 @@ module StillActive
|
|
|
288
288
|
def warn_output_flag_conflicts(options)
|
|
289
289
|
modes = active_output_modes
|
|
290
290
|
if modes.size > 1
|
|
291
|
-
|
|
291
|
+
warn("warning: multiple output modes set (#{modes.join(", ")}); using #{modes.first}, ignoring #{modes.drop(1).join(", ")}")
|
|
292
292
|
end
|
|
293
293
|
if options[:provided_cyclonedx_version] && StillActive.config.cyclonedx_path.nil?
|
|
294
|
-
|
|
294
|
+
warn("warning: --cyclonedx-version has no effect without --cyclonedx")
|
|
295
295
|
end
|
|
296
296
|
end
|
|
297
297
|
|
|
@@ -301,14 +301,14 @@ module StillActive
|
|
|
301
301
|
[
|
|
302
302
|
("--baseline" if config.baseline_path),
|
|
303
303
|
("--sarif" if config.sarif_path),
|
|
304
|
-
("--cyclonedx" if config.cyclonedx_path)
|
|
304
|
+
("--cyclonedx" if config.cyclonedx_path)
|
|
305
305
|
].compact
|
|
306
306
|
end
|
|
307
307
|
|
|
308
308
|
def emit_sarif(result, ruby_info, sarif_path)
|
|
309
309
|
lockfile = resolve_lockfile_path(StillActive.config.gemfile_path)
|
|
310
310
|
unless File.exist?(lockfile)
|
|
311
|
-
|
|
311
|
+
warn("error: --sarif requires a lockfile at #{lockfile}")
|
|
312
312
|
exit(2)
|
|
313
313
|
end
|
|
314
314
|
|
|
@@ -316,7 +316,7 @@ module StillActive
|
|
|
316
316
|
result: result,
|
|
317
317
|
ruby_info: ruby_info,
|
|
318
318
|
lockfile_path: lockfile,
|
|
319
|
-
tool_version: StillActive::VERSION
|
|
319
|
+
tool_version: StillActive::VERSION
|
|
320
320
|
)
|
|
321
321
|
|
|
322
322
|
write_output(sarif_path, sarif_json)
|
|
@@ -327,7 +327,7 @@ module StillActive
|
|
|
327
327
|
result: result,
|
|
328
328
|
ruby_info: ruby_info,
|
|
329
329
|
tool_version: StillActive::VERSION,
|
|
330
|
-
spec_version: StillActive.config.cyclonedx_version
|
|
330
|
+
spec_version: StillActive.config.cyclonedx_version
|
|
331
331
|
)
|
|
332
332
|
|
|
333
333
|
write_output(cyclonedx_path, sbom)
|
|
@@ -349,13 +349,13 @@ module StillActive
|
|
|
349
349
|
puts DiffMarkdownHelper.render(diff, accepted: accepted)
|
|
350
350
|
exit(1) if diff.regressions.any?
|
|
351
351
|
rescue JSON::ParserError => e
|
|
352
|
-
|
|
352
|
+
warn("error: --baseline file is not valid JSON: #{e.message}")
|
|
353
353
|
exit(2)
|
|
354
354
|
rescue Diff::UnsupportedSchemaError => e
|
|
355
|
-
|
|
355
|
+
warn("error: #{e.message}")
|
|
356
356
|
exit(2)
|
|
357
357
|
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
|
|
358
|
-
|
|
358
|
+
warn("error: cannot read baseline file: #{e.message}")
|
|
359
359
|
exit(2)
|
|
360
360
|
end
|
|
361
361
|
|
|
@@ -383,9 +383,9 @@ module StillActive
|
|
|
383
383
|
def current_snapshot(result, ruby_info)
|
|
384
384
|
snapshot = {
|
|
385
385
|
"schema_version" => 1,
|
|
386
|
-
"tool" => {
|
|
386
|
+
"tool" => {"name" => "still_active", "version" => StillActive::VERSION},
|
|
387
387
|
"generated_at" => Time.now.utc.iso8601,
|
|
388
|
-
"gems" => JSON.parse(result.to_json)
|
|
388
|
+
"gems" => JSON.parse(result.to_json)
|
|
389
389
|
}
|
|
390
390
|
snapshot["ruby"] = JSON.parse(ruby_info.to_json) if ruby_info
|
|
391
391
|
snapshot
|
|
@@ -406,11 +406,11 @@ module StillActive
|
|
|
406
406
|
gem_data[:last_activity_warning_emoji] = EmojiHelper.inactive_gem_emoji(gem_data)
|
|
407
407
|
gem_data[:up_to_date_emoji] = EmojiHelper.using_latest_emoji(
|
|
408
408
|
using_last_release: VersionHelper.up_to_date(
|
|
409
|
-
version_used: gem_data[:version_used], latest_version: gem_data[:latest_version]
|
|
409
|
+
version_used: gem_data[:version_used], latest_version: gem_data[:latest_version]
|
|
410
410
|
),
|
|
411
411
|
using_last_pre_release: VersionHelper.up_to_date(
|
|
412
|
-
version_used: gem_data[:version_used], latest_pre_release_version: gem_data[:latest_pre_release_version]
|
|
413
|
-
)
|
|
412
|
+
version_used: gem_data[:version_used], latest_pre_release_version: gem_data[:latest_pre_release_version]
|
|
413
|
+
)
|
|
414
414
|
)
|
|
415
415
|
|
|
416
416
|
puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
|
|
@@ -460,7 +460,7 @@ module StillActive
|
|
|
460
460
|
|
|
461
461
|
ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
|
|
462
462
|
labelled = ids.empty? ? "" : " (#{ids})"
|
|
463
|
-
|
|
463
|
+
warn("warning: #{gem} 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)")
|
|
464
464
|
end
|
|
465
465
|
end
|
|
466
466
|
|
|
@@ -485,7 +485,7 @@ module StillActive
|
|
|
485
485
|
|
|
486
486
|
# Bare --fail-if-poison fails at :warning and above (a 1-behind :note is FYI,
|
|
487
487
|
# not a build breaker); --fail-if-poison=TIER sets an explicit threshold.
|
|
488
|
-
threshold = config.fail_if_poison == true ? :warning : config.fail_if_poison
|
|
488
|
+
threshold = (config.fail_if_poison == true) ? :warning : config.fail_if_poison
|
|
489
489
|
ConstraintHelper.severity_at_or_above?(data[:poison_severity], threshold)
|
|
490
490
|
end
|
|
491
491
|
|
|
@@ -498,7 +498,7 @@ module StillActive
|
|
|
498
498
|
# bare flag defaults to :critical (the genuine blocker: no supported runtime
|
|
499
499
|
# is reachable). A latest-not-yet :note is an FYI/contribution lead that gates
|
|
500
500
|
# only when the user explicitly opts in with =note.
|
|
501
|
-
threshold = config.fail_if_language_ceiling == true ? :critical : config.fail_if_language_ceiling
|
|
501
|
+
threshold = (config.fail_if_language_ceiling == true) ? :critical : config.fail_if_language_ceiling
|
|
502
502
|
ConstraintHelper.severity_at_or_above?(data.dig(:language_ceiling, :severity), threshold)
|
|
503
503
|
end
|
|
504
504
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "rubygems/resolver"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../helpers/http_helper"
|
|
7
|
+
|
|
8
|
+
module StillActive
|
|
9
|
+
# The RubyGems compact index (`/info/<gem>`), which is what Bundler itself
|
|
10
|
+
# resolves through. It is not built by merging upstream metadata, so it lists
|
|
11
|
+
# the versions actually resolvable from the source even when a member remote (a
|
|
12
|
+
# legacy private gem host) can't answer the metadata endpoint Artifactory
|
|
13
|
+
# prefers (#142).
|
|
14
|
+
#
|
|
15
|
+
# The protocol is generic: any host that serves it (rubygems.org, Artifactory, a
|
|
16
|
+
# direct Contribsys/Gemstash/geminabox source) works here. The caller supplies
|
|
17
|
+
# the source URI and any auth headers, so this stays free of host-specific
|
|
18
|
+
# credential handling.
|
|
19
|
+
module CompactIndexClient
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
def versions(gem_name:, source_uri:, headers: {})
|
|
23
|
+
base = URI(source_uri.chomp("/"))
|
|
24
|
+
path = "#{base.path}/info/#{CGI.escape(gem_name)}"
|
|
25
|
+
body = HttpHelper.get_text(base, path, headers: headers)
|
|
26
|
+
return [] if body.nil? || body.empty?
|
|
27
|
+
|
|
28
|
+
result = parse(body)
|
|
29
|
+
# The parser never raises: a non-index 200 (an auth wall or HTML error page
|
|
30
|
+
# served with status 200) yields no versions instead of an error. Since the
|
|
31
|
+
# compact index is the primary source, a silent empty result would look
|
|
32
|
+
# identical to a clean parse and hide a broken source, so say so and let the
|
|
33
|
+
# caller fall through. A header-only index (every version yanked) is valid
|
|
34
|
+
# and empty, so the "---" marker, not mere emptiness, tells them apart.
|
|
35
|
+
if result.empty? && !compact_index?(body)
|
|
36
|
+
warn("warning: #{base.host} answered /info/#{gem_name} with a 200 that is not a RubyGems " \
|
|
37
|
+
"compact index (an auth wall or error page?); ignoring it and trying other sources")
|
|
38
|
+
end
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def compact_index?(body)
|
|
45
|
+
body.split("\n").include?("---")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(body)
|
|
49
|
+
entries(body)
|
|
50
|
+
.filter_map { |line| version_hash(line) }
|
|
51
|
+
.group_by { |hash| hash["number"] }
|
|
52
|
+
.map { |_number, rows| platform_independent(rows).except("platform") }
|
|
53
|
+
.sort_by { |hash| Gem::Version.new(hash["number"]) }
|
|
54
|
+
.reverse
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A version gets one row per built platform, variants first and the
|
|
58
|
+
# platform-independent build last. That last row is the one describing the
|
|
59
|
+
# gem rather than one prebuilt binary: a native gem's variants ship fewer
|
|
60
|
+
# dependencies, since they don't need the toolchain the source build does.
|
|
61
|
+
def platform_independent(rows)
|
|
62
|
+
rows.find { |row| row["platform"].nil? } || rows.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Everything after the `---` header. Artifactory is known to emit blank
|
|
66
|
+
# lines inside its index files (Bundler's own compact index parser carries
|
|
67
|
+
# the same workaround), and a blank line would parse as a nil version.
|
|
68
|
+
def entries(body)
|
|
69
|
+
lines = body.split("\n")
|
|
70
|
+
header = lines.index("---")
|
|
71
|
+
lines = lines[(header + 1)..] if header
|
|
72
|
+
lines.reject { |line| line.strip.empty? }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def version_hash(line)
|
|
76
|
+
number, platform = gem_parser.parse(line)
|
|
77
|
+
return if number.nil? || !Gem::Version.correct?(number)
|
|
78
|
+
|
|
79
|
+
meta = metadata(line)
|
|
80
|
+
{
|
|
81
|
+
"number" => number,
|
|
82
|
+
"platform" => platform,
|
|
83
|
+
"prerelease" => Gem::Version.new(number).prerelease?,
|
|
84
|
+
# Omitted entirely when a version declares no Ruby requirement, where
|
|
85
|
+
# the versions API sends ">= 0". Both mean unconstrained, and the
|
|
86
|
+
# ceiling check reads a missing requirement as "no ceiling".
|
|
87
|
+
"ruby_version" => meta["ruby"],
|
|
88
|
+
"checksum" => meta["checksum"],
|
|
89
|
+
# rubygems.org emits this; Artifactory's generated index does not, which
|
|
90
|
+
# is why a compact-index list still gets dated from the versions API.
|
|
91
|
+
"created_at" => meta["created_at"]
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The requirements section (everything after "|") is comma-separated
|
|
96
|
+
# `key:value` pairs, with `&` joining multiple constraints on one key. We
|
|
97
|
+
# parse it directly rather than through GemParser: GemParser's colon split
|
|
98
|
+
# changed across RubyGems versions and shreds an ISO-8601 `created_at`, whose
|
|
99
|
+
# value contains colons. Splitting on the FIRST colon keeps the timestamp
|
|
100
|
+
# whole on every version.
|
|
101
|
+
def metadata(line)
|
|
102
|
+
_deps, requirements = line.split("|", 2)
|
|
103
|
+
return {} unless requirements
|
|
104
|
+
|
|
105
|
+
requirements.split(",").each_with_object({}) do |pair, meta|
|
|
106
|
+
key, value = pair.split(":", 2)
|
|
107
|
+
meta[key] = value.split("&").join(", ") if value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Used only for the leading `version[-platform]` token, which is a stable
|
|
112
|
+
# split across RubyGems versions. Ships with RubyGems since 3.2.3.
|
|
113
|
+
def gem_parser
|
|
114
|
+
@gem_parser ||= Gem::Resolver::APISet::GemParser.new
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/still_active/config.rb
CHANGED
|
@@ -26,7 +26,7 @@ module StillActive
|
|
|
26
26
|
return {} if data.nil?
|
|
27
27
|
|
|
28
28
|
unless data.is_a?(Hash)
|
|
29
|
-
|
|
29
|
+
warn("warning: #{FILENAME} must be a mapping of settings; ignoring it")
|
|
30
30
|
return {}
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -34,7 +34,7 @@ module StillActive
|
|
|
34
34
|
rescue Psych::Exception => e
|
|
35
35
|
# Covers a syntax error and a disallowed tag (e.g. !ruby/object); either
|
|
36
36
|
# way the committed file must never take the audit down with it.
|
|
37
|
-
|
|
37
|
+
warn("warning: #{FILENAME} could not be loaded (#{e.message}); ignoring it")
|
|
38
38
|
{}
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -89,7 +89,7 @@ module StillActive
|
|
|
89
89
|
count = bundler_audit_ignore_count(path)
|
|
90
90
|
return unless count.positive?
|
|
91
91
|
|
|
92
|
-
noun = count == 1 ? "advisory" : "advisories"
|
|
92
|
+
noun = (count == 1) ? "advisory" : "advisories"
|
|
93
93
|
"#{BUNDLER_AUDIT_FILE} lists #{count} accepted #{noun}; add `import: [#{BUNDLER_AUDIT_FILE}]` to #{FILENAME} to honour them in still_active's --fail-if-vulnerable gate too"
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -197,7 +197,7 @@ module StillActive
|
|
|
197
197
|
next []
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
Array(imported["ignore"]).map { |advisory| {
|
|
200
|
+
Array(imported["ignore"]).map { |advisory| {"advisory" => advisory, "reason" => "imported from #{rel}"} }
|
|
201
201
|
rescue Psych::Exception => e
|
|
202
202
|
warnings << "#{FILENAME}: import target #{rel} could not be loaded (#{e.message}), skipping it"
|
|
203
203
|
[]
|
|
@@ -15,7 +15,7 @@ module StillActive
|
|
|
15
15
|
# `[]` -- so a rename or drop of that field would silently turn every count to
|
|
16
16
|
# 0 and read a known-vulnerable package as clean (exit 0). django 3.0.0 carries
|
|
17
17
|
# 30+ permanent advisories; if the canary returns none, the schema drifted.
|
|
18
|
-
ADVISORY_CANARY = {
|
|
18
|
+
ADVISORY_CANARY = {system: "pypi", name: "django", version: "3.0.0"}.freeze
|
|
19
19
|
|
|
20
20
|
# Is deps.dev still returning advisories in the shape we parse? False when the
|
|
21
21
|
# canary comes back empty (schema drift) or unreachable (can't confirm). The
|
|
@@ -42,7 +42,7 @@ module StillActive
|
|
|
42
42
|
# The locked version's release date -- the cross-ecosystem libyear input
|
|
43
43
|
# (paired with the package's latest-release date). Already in this response,
|
|
44
44
|
# so no extra fetch; nil when the feed omits it.
|
|
45
|
-
published_at: body["publishedAt"]
|
|
45
|
+
published_at: body["publishedAt"]
|
|
46
46
|
}
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -74,7 +74,7 @@ module StillActive
|
|
|
74
74
|
newest_version(versions)
|
|
75
75
|
return if entry.nil?
|
|
76
76
|
|
|
77
|
-
{
|
|
77
|
+
{version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"]}
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# The newest non-prerelease version by version number (not publishedAt: a
|
|
@@ -117,7 +117,7 @@ module StillActive
|
|
|
117
117
|
# activity directly -- still_active's core question -- so we surface it
|
|
118
118
|
# alongside the aggregate. nil when the check is absent (never 0, which
|
|
119
119
|
# would read as "unmaintained" rather than "not measured").
|
|
120
|
-
maintained: maintained_check_score(scorecard)
|
|
120
|
+
maintained: maintained_check_score(scorecard)
|
|
121
121
|
}
|
|
122
122
|
end
|
|
123
123
|
|
|
@@ -153,7 +153,7 @@ module StillActive
|
|
|
153
153
|
cvss3_score: body["cvss3Score"],
|
|
154
154
|
cvss3_vector: body["cvss3Vector"],
|
|
155
155
|
cvss2_score: body["cvss2Score"],
|
|
156
|
-
source: "deps.dev"
|
|
156
|
+
source: "deps.dev"
|
|
157
157
|
}
|
|
158
158
|
end
|
|
159
159
|
|
|
@@ -177,7 +177,7 @@ module StillActive
|
|
|
177
177
|
return if @alias_drift_warned
|
|
178
178
|
|
|
179
179
|
@alias_drift_warned = true
|
|
180
|
-
|
|
180
|
+
warn("warning: deps.dev returned a non-string advisory alias (#{raw.class}); the ALPHA API schema may have drifted")
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# The OpenSSF "Maintained" check score (0-10), or nil when it's absent. A
|
|
@@ -221,7 +221,7 @@ module StillActive
|
|
|
221
221
|
# link). Parsing it as owner/repo yields a 404 and a blank repo cell, so treat a
|
|
222
222
|
# reserved first segment as "no repo" rather than a bogus lookup.
|
|
223
223
|
GITHUB_RESERVED_PATHS = [
|
|
224
|
-
"sponsors", "orgs", "marketplace", "apps", "topics", "settings", "notifications", "about", "pricing", "features", "security", "contact"
|
|
224
|
+
"sponsors", "orgs", "marketplace", "apps", "topics", "settings", "notifications", "about", "pricing", "features", "security", "contact"
|
|
225
225
|
].freeze
|
|
226
226
|
|
|
227
227
|
def repo_path_segments(host, segments)
|
data/lib/still_active/diff.rb
CHANGED
|
@@ -25,7 +25,7 @@ module StillActive
|
|
|
25
25
|
new_gem_archived: :activity,
|
|
26
26
|
archived: :activity,
|
|
27
27
|
new_gem_stale: :libyear,
|
|
28
|
-
libyear_worsened: :libyear
|
|
28
|
+
libyear_worsened: :libyear
|
|
29
29
|
}.freeze
|
|
30
30
|
|
|
31
31
|
class UnsupportedSchemaError < StandardError; end
|
|
@@ -70,7 +70,7 @@ module StillActive
|
|
|
70
70
|
after_version: after["version_used"],
|
|
71
71
|
kind: classify_bump(before, after),
|
|
72
72
|
before: before,
|
|
73
|
-
after: after
|
|
73
|
+
after: after
|
|
74
74
|
)
|
|
75
75
|
end
|
|
76
76
|
changes = collect_signal_changes(before, after)
|
|
@@ -82,7 +82,7 @@ module StillActive
|
|
|
82
82
|
added: added,
|
|
83
83
|
bumped: bumped,
|
|
84
84
|
signal_changes: signal_changes,
|
|
85
|
-
ruby_delta: ruby
|
|
85
|
+
ruby_delta: ruby
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
Result.new(
|
|
@@ -91,7 +91,7 @@ module StillActive
|
|
|
91
91
|
bumped: bumped,
|
|
92
92
|
signal_changes: signal_changes,
|
|
93
93
|
regressions: regressions,
|
|
94
|
-
ruby: ruby
|
|
94
|
+
ruby: ruby
|
|
95
95
|
)
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -181,7 +181,7 @@ module StillActive
|
|
|
181
181
|
changes = []
|
|
182
182
|
|
|
183
183
|
if !before["archived"] && after["archived"]
|
|
184
|
-
changes << {
|
|
184
|
+
changes << {kind: :archived, from: false, to: true}
|
|
185
185
|
end
|
|
186
186
|
|
|
187
187
|
opened = vuln_count(after) - vuln_count(before)
|
|
@@ -191,7 +191,7 @@ module StillActive
|
|
|
191
191
|
# "new" in this list even though it's a re-keying of the same issue.
|
|
192
192
|
# The vulnerability_count gate above keeps that to detail-string noise.
|
|
193
193
|
new_ids = advisory_ids(after) - advisory_ids(before)
|
|
194
|
-
changes << {
|
|
194
|
+
changes << {kind: :new_vulnerability, from: vuln_count(before), to: vuln_count(after), ids: new_ids.first(3)}
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
if before["scorecard_score"] && after["scorecard_score"]
|
|
@@ -200,7 +200,7 @@ module StillActive
|
|
|
200
200
|
# -> 7.0 dip is noise within the safe zone). Only drops below 7.0 cross.
|
|
201
201
|
crossed = before["scorecard_score"] >= SCORECARD_GOOD_THRESHOLD && after["scorecard_score"] < SCORECARD_GOOD_THRESHOLD
|
|
202
202
|
if drop >= SCORECARD_DROP_THRESHOLD || crossed
|
|
203
|
-
changes << {
|
|
203
|
+
changes << {kind: :scorecard_dropped, from: before["scorecard_score"], to: after["scorecard_score"], crossed_good: crossed}
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
|
@@ -211,12 +211,12 @@ module StillActive
|
|
|
211
211
|
# technically grows (because upstream is releasing faster).
|
|
212
212
|
delta = after["libyear"] - before["libyear"]
|
|
213
213
|
if delta > LIBYEAR_DELTA_THRESHOLD
|
|
214
|
-
changes << {
|
|
214
|
+
changes << {kind: :libyear_worsened, from: before["libyear"], to: after["libyear"], delta: delta.round(2)}
|
|
215
215
|
end
|
|
216
216
|
end
|
|
217
217
|
|
|
218
218
|
if !before["version_yanked"] && after["version_yanked"]
|
|
219
|
-
changes << {
|
|
219
|
+
changes << {kind: :version_yanked}
|
|
220
220
|
end
|
|
221
221
|
|
|
222
222
|
changes
|
|
@@ -241,7 +241,7 @@ module StillActive
|
|
|
241
241
|
regs << Regression.new(
|
|
242
242
|
kind: :bump_introduced_vulns,
|
|
243
243
|
gem: b.name,
|
|
244
|
-
detail: "#{b.before_version} -> #{b.after_version}"
|
|
244
|
+
detail: "#{b.before_version} -> #{b.after_version}"
|
|
245
245
|
)
|
|
246
246
|
end
|
|
247
247
|
end
|
|
@@ -263,7 +263,7 @@ module StillActive
|
|
|
263
263
|
regs << Regression.new(
|
|
264
264
|
kind: :libyear_worsened,
|
|
265
265
|
gem: sc.name,
|
|
266
|
-
detail: "libyear #{ch[:from]} -> #{ch[:to]} (+#{ch[:delta]}y; same pinned version)"
|
|
266
|
+
detail: "libyear #{ch[:from]} -> #{ch[:to]} (+#{ch[:delta]}y; same pinned version)"
|
|
267
267
|
)
|
|
268
268
|
end
|
|
269
269
|
end
|
|
@@ -287,7 +287,7 @@ module StillActive
|
|
|
287
287
|
to: after["version"],
|
|
288
288
|
newly_eol: !before["eol"] && !!after["eol"],
|
|
289
289
|
libyear_before: before["libyear"],
|
|
290
|
-
libyear_after: after["libyear"]
|
|
290
|
+
libyear_after: after["libyear"]
|
|
291
291
|
}
|
|
292
292
|
end
|
|
293
293
|
|
|
@@ -39,7 +39,7 @@ module StillActive
|
|
|
39
39
|
rubygems: "rubygems.org",
|
|
40
40
|
npm: "npmjs.org",
|
|
41
41
|
pypi: "pypi.org",
|
|
42
|
-
cargo: "crates.io"
|
|
42
|
+
cargo: "crates.io"
|
|
43
43
|
}.freeze
|
|
44
44
|
|
|
45
45
|
# A transitive cap only holds the WHOLE tree hostage where the ecosystem
|
|
@@ -98,7 +98,7 @@ module StillActive
|
|
|
98
98
|
# already fetched above (no extra call); nil when either date is missing.
|
|
99
99
|
libyear: LibyearHelper.gem_libyear(
|
|
100
100
|
version_used_release_date: parse_time(used_release_date),
|
|
101
|
-
latest_version_release_date: parse_time(latest_release_date)
|
|
101
|
+
latest_version_release_date: parse_time(latest_release_date)
|
|
102
102
|
),
|
|
103
103
|
# Whether the pinned version is at or ahead of deps.dev's latest stable, so a
|
|
104
104
|
# prerelease or an ahead-of-stable pin reads current (true), not "behind" --
|
|
@@ -110,7 +110,7 @@ module StillActive
|
|
|
110
110
|
scorecard_score: scorecard&.dig(:score),
|
|
111
111
|
scorecard_maintained: scorecard&.dig(:maintained),
|
|
112
112
|
vulnerability_count: vulnerabilities.length,
|
|
113
|
-
vulnerabilities: vulnerabilities
|
|
113
|
+
vulnerabilities: vulnerabilities
|
|
114
114
|
}
|
|
115
115
|
gem_data[:version_unresolved] = true if version_unresolved
|
|
116
116
|
attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
|
|
@@ -239,7 +239,7 @@ module StillActive
|
|
|
239
239
|
candidates = declared.filter_map do |dep|
|
|
240
240
|
next if dep[:package_name].to_s.empty? || dep[:requirements].to_s.empty?
|
|
241
241
|
|
|
242
|
-
{
|
|
242
|
+
{dependency: dep[:package_name], requirement: dep[:requirements]}
|
|
243
243
|
end
|
|
244
244
|
gem_data[:capped_deps] = candidates unless candidates.empty?
|
|
245
245
|
end
|
|
@@ -262,7 +262,7 @@ module StillActive
|
|
|
262
262
|
# fetch only enriches CVSS/title. A failed enrichment must not drop the
|
|
263
263
|
# count to zero and read a known-vulnerable dep as clean, so a key whose
|
|
264
264
|
# detail can't be loaded still contributes a minimal advisory.
|
|
265
|
-
deps_dev = advisory_keys.map { DepsDevClient.advisory_detail(advisory_id: _1) || {
|
|
265
|
+
deps_dev = advisory_keys.map { DepsDevClient.advisory_detail(advisory_id: _1) || {id: _1, source: "deps.dev"} }
|
|
266
266
|
# No ruby-advisory-db here: that database is Ruby-only and the native
|
|
267
267
|
# Bundler path already merges it. Cross-ecosystem advisories come from
|
|
268
268
|
# deps.dev/OSV, which covers every system the lens serves.
|