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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "suppressions"
|
|
5
|
+
require_relative "../helpers/constraint_helper"
|
|
6
|
+
require_relative "../helpers/vulnerability_helper"
|
|
7
|
+
|
|
8
|
+
module StillActive
|
|
9
|
+
# Loads a committed .still_active.yml and applies it to the config as the layer
|
|
10
|
+
# below env vars and CLI flags (CLI flag > env var > config file > default).
|
|
11
|
+
# Mirrors the policy flags (gates, thresholds, output, alternatives, scope)
|
|
12
|
+
# and the granular suppression list; deliberately NOT secrets (tokens) or
|
|
13
|
+
# invocation-specific paths (--gemfile/--gems/--baseline/output paths), which
|
|
14
|
+
# stay CLI/env-only so a committed file never carries a credential.
|
|
15
|
+
module ConfigFile
|
|
16
|
+
FILENAME = ".still_active.yml"
|
|
17
|
+
BUNDLER_AUDIT_FILE = ".bundler-audit.yml"
|
|
18
|
+
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def load(dir: Dir.pwd)
|
|
22
|
+
path = File.join(dir, FILENAME)
|
|
23
|
+
return {} unless File.file?(path)
|
|
24
|
+
|
|
25
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date, Time])
|
|
26
|
+
return {} if data.nil?
|
|
27
|
+
|
|
28
|
+
unless data.is_a?(Hash)
|
|
29
|
+
$stderr.puts("warning: #{FILENAME} must be a mapping of settings; ignoring it")
|
|
30
|
+
return {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
data
|
|
34
|
+
rescue Psych::Exception => e
|
|
35
|
+
# Covers a syntax error and a disallowed tag (e.g. !ruby/object); either
|
|
36
|
+
# way the committed file must never take the audit down with it.
|
|
37
|
+
$stderr.puts("warning: #{FILENAME} could not be loaded (#{e.message}); ignoring it")
|
|
38
|
+
{}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Applies data to config and returns an array of warning strings (unknown
|
|
42
|
+
# keys, invalid values, suppression/import problems). Booleans/numbers are
|
|
43
|
+
# passed through as the gate expects them.
|
|
44
|
+
def apply(config, data, base_dir: Dir.pwd)
|
|
45
|
+
warnings = []
|
|
46
|
+
ignore_entries = Array(data["ignore"])
|
|
47
|
+
|
|
48
|
+
data.each do |key, value|
|
|
49
|
+
case key
|
|
50
|
+
when "fail_if_critical" then set_boolean(config, :fail_if_critical=, value, key, warnings)
|
|
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)
|
|
54
|
+
when "alternatives" then set_boolean(config, :alternatives=, value, key, warnings)
|
|
55
|
+
when "unreleased_commits" then set_boolean(config, :unreleased_commits=, value, key, warnings)
|
|
56
|
+
when "direct_only" then set_boolean(config, :direct_only=, value, key, warnings)
|
|
57
|
+
when "safe_range_end" then set_number(config, :no_warning_range_end=, value, key, warnings)
|
|
58
|
+
when "warning_range_end" then set_number(config, :warning_range_end=, value, key, warnings)
|
|
59
|
+
when "fail_if_outdated" then apply_fail_if_outdated(config, value, warnings)
|
|
60
|
+
when "parallelism" then apply_parallelism(config, value, warnings)
|
|
61
|
+
when "fail_if_vulnerable" then apply_fail_if_vulnerable(config, value, warnings)
|
|
62
|
+
when "output" then apply_output(config, value, warnings)
|
|
63
|
+
when "ignore" then nil # handled below, after imports are gathered
|
|
64
|
+
when "import" then ignore_entries.concat(import_advisories(value, base_dir, warnings))
|
|
65
|
+
else warnings << "#{FILENAME}: unknown setting #{key.inspect}, ignoring it"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
suppressions = Suppressions.from(ignore_entries)
|
|
70
|
+
warnings.concat(suppressions.warnings)
|
|
71
|
+
config.suppressions = suppressions
|
|
72
|
+
warnings
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Suggests honouring an existing bundler-audit ignore list rather than
|
|
76
|
+
# silently inheriting it. Auto-importing another tool's suppressions would
|
|
77
|
+
# hide vulnerabilities the user only accepted in bundler-audit's context,
|
|
78
|
+
# with no reason/expiry and no explicit opt-in here, the exact over-broad
|
|
79
|
+
# muting this feature exists to replace. So nudge, don't absorb, and only
|
|
80
|
+
# when the vulnerability gate is on (suppression is otherwise moot) and the
|
|
81
|
+
# file isn't already imported. Returns the hint string or nil.
|
|
82
|
+
def import_hint(data, config: StillActive.config, dir: Dir.pwd)
|
|
83
|
+
return unless config.fail_if_vulnerable
|
|
84
|
+
return if Array(data["import"]).include?(BUNDLER_AUDIT_FILE)
|
|
85
|
+
|
|
86
|
+
path = File.join(dir, BUNDLER_AUDIT_FILE)
|
|
87
|
+
return unless File.file?(path)
|
|
88
|
+
|
|
89
|
+
count = bundler_audit_ignore_count(path)
|
|
90
|
+
return unless count.positive?
|
|
91
|
+
|
|
92
|
+
noun = count == 1 ? "advisory" : "advisories"
|
|
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
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def bundler_audit_ignore_count(path)
|
|
99
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date, Time])
|
|
100
|
+
data.is_a?(Hash) ? Array(data["ignore"]).size : 0
|
|
101
|
+
rescue Psych::Exception
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A malformed value must warn and leave the default in place, never silently
|
|
106
|
+
# flip a gate off (`!!""` is false) or crash the audit (`false.to_f` raises,
|
|
107
|
+
# since `&.` guards only nil). Validate before assigning, like the gates do.
|
|
108
|
+
def set_boolean(config, setter, value, key, warnings)
|
|
109
|
+
if [true, false].include?(value)
|
|
110
|
+
config.public_send(setter, value)
|
|
111
|
+
else
|
|
112
|
+
warnings << "#{FILENAME}: #{key} must be true or false (got #{value.inspect}), ignoring it"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def set_number(config, setter, value, key, warnings)
|
|
117
|
+
if value.is_a?(Numeric)
|
|
118
|
+
config.public_send(setter, value.to_f)
|
|
119
|
+
else
|
|
120
|
+
warnings << "#{FILENAME}: #{key} must be a number (got #{value.inspect}), ignoring it"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def apply_fail_if_outdated(config, value, warnings)
|
|
125
|
+
case value
|
|
126
|
+
when nil, false then config.fail_if_outdated = nil # gate off
|
|
127
|
+
when Numeric then config.fail_if_outdated = value.to_f
|
|
128
|
+
else warnings << "#{FILENAME}: fail_if_outdated must be a number or false (got #{value.inspect}), ignoring it"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def apply_parallelism(config, value, warnings)
|
|
133
|
+
if value.is_a?(Integer) && value.positive?
|
|
134
|
+
config.parallelism = value
|
|
135
|
+
else
|
|
136
|
+
warnings << "#{FILENAME}: parallelism must be a positive integer (got #{value.inspect}), ignoring it"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
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
|
+
|
|
164
|
+
def apply_fail_if_vulnerable(config, value, warnings)
|
|
165
|
+
if value == true || value == false
|
|
166
|
+
config.fail_if_vulnerable = value || nil
|
|
167
|
+
elsif VulnerabilityHelper::SEVERITY_ORDER.include?(value)
|
|
168
|
+
config.fail_if_vulnerable = value
|
|
169
|
+
else
|
|
170
|
+
warnings << "#{FILENAME}: fail_if_vulnerable severity must be one of #{VulnerabilityHelper::SEVERITY_ORDER.join(", ")} (got #{value.inspect}), ignoring it"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def apply_output(config, value, warnings)
|
|
175
|
+
format = value.to_s.to_sym
|
|
176
|
+
if [:terminal, :markdown, :json].include?(format)
|
|
177
|
+
config.output_format = format
|
|
178
|
+
else
|
|
179
|
+
warnings << "#{FILENAME}: output must be terminal, markdown, or json (got #{value.inspect}), ignoring it"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reads each referenced bundler-audit config and converts its ignore list of
|
|
184
|
+
# advisory ids into advisory-scoped (gem-agnostic) suppression entries, so a
|
|
185
|
+
# team keeps one ignore list instead of two.
|
|
186
|
+
def import_advisories(paths, base_dir, warnings)
|
|
187
|
+
Array(paths).flat_map do |rel|
|
|
188
|
+
path = File.expand_path(rel, base_dir)
|
|
189
|
+
unless File.file?(path)
|
|
190
|
+
warnings << "#{FILENAME}: import target #{rel} not found, skipping it"
|
|
191
|
+
next []
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
imported = YAML.safe_load_file(path, permitted_classes: [Date, Time]) || {}
|
|
195
|
+
unless imported.is_a?(Hash)
|
|
196
|
+
warnings << "#{FILENAME}: import target #{rel} is not a mapping, skipping it"
|
|
197
|
+
next []
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Array(imported["ignore"]).map { |advisory| { "advisory" => advisory, "reason" => "imported from #{rel}" } }
|
|
201
|
+
rescue Psych::Exception => e
|
|
202
|
+
warnings << "#{FILENAME}: import target #{rel} could not be loaded (#{e.message}), skipping it"
|
|
203
|
+
[]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -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,19 +119,89 @@ module StillActive
|
|
|
58
119
|
|
|
59
120
|
private
|
|
60
121
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
+
|
|
151
|
+
# Builds a deps.dev project id ("host/owner/repo", or a deeper path for
|
|
152
|
+
# GitLab subgroups) from the SOURCE_REPO link URL. GitHub/Bitbucket projects
|
|
153
|
+
# are always host/owner/repo, but GitLab namespaces nest arbitrarily
|
|
154
|
+
# (host/group/subgroup/.../project), so we can't just keep three segments.
|
|
63
155
|
def extract_project_id(body)
|
|
64
156
|
url = body.dig("links")&.find { |l| l["label"] == "SOURCE_REPO" }&.dig("url")
|
|
65
157
|
return if url.nil?
|
|
66
158
|
|
|
67
|
-
|
|
68
|
-
|
|
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("/")
|
|
170
|
+
segments = repo_path_segments(host, segments)
|
|
171
|
+
return if host.nil? || segments.empty?
|
|
172
|
+
|
|
173
|
+
[host, *segments].join("/")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Trims a repo URL's path to just the project. On GitLab the project path
|
|
177
|
+
# ends at the "/-/" separator (before tree/blob/etc) and may be nested;
|
|
178
|
+
# elsewhere it's owner/repo. Drops trailing slashes and a ".git" suffix.
|
|
179
|
+
def repo_path_segments(host, segments)
|
|
180
|
+
segments = segments.reject(&:empty?)
|
|
181
|
+
|
|
182
|
+
if host.to_s.start_with?("gitlab.")
|
|
183
|
+
separator = segments.index("-")
|
|
184
|
+
segments = segments[0...separator] if separator
|
|
185
|
+
else
|
|
186
|
+
segments = segments.first(2)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
segments[-1] = segments[-1].delete_suffix(".git") unless segments.empty?
|
|
190
|
+
segments
|
|
191
|
+
end
|
|
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 }
|
|
70
201
|
end
|
|
71
202
|
|
|
72
203
|
def encode(value)
|
|
73
|
-
URI.encode_www_form_component(value)
|
|
204
|
+
URI.encode_www_form_component(value.to_s)
|
|
74
205
|
end
|
|
75
206
|
end
|
|
76
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")
|
|
@@ -74,10 +96,67 @@ module StillActive
|
|
|
74
96
|
end
|
|
75
97
|
|
|
76
98
|
def validate_schema!(snapshot, role)
|
|
99
|
+
# A user can point --baseline at any JSON file. Reject a wrong shape here
|
|
100
|
+
# as an UnsupportedSchemaError (which emit_diff turns into a clean exit 2)
|
|
101
|
+
# rather than letting snapshot["..."] / gems.keys raise a raw stack trace.
|
|
102
|
+
unless snapshot.is_a?(Hash)
|
|
103
|
+
raise UnsupportedSchemaError, "#{role} is not a still_active JSON object (got #{snapshot.class})"
|
|
104
|
+
end
|
|
105
|
+
|
|
77
106
|
version = snapshot["schema_version"]
|
|
78
|
-
|
|
107
|
+
unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
|
|
108
|
+
raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}"
|
|
109
|
+
end
|
|
79
110
|
|
|
80
|
-
|
|
111
|
+
ruby = snapshot["ruby"]
|
|
112
|
+
unless ruby.nil? || ruby.is_a?(Hash)
|
|
113
|
+
raise UnsupportedSchemaError, "#{role} has a malformed ruby section (expected an object, got #{ruby.class})"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
gems = snapshot["gems"]
|
|
117
|
+
return if gems.nil?
|
|
118
|
+
|
|
119
|
+
unless gems.is_a?(Hash)
|
|
120
|
+
raise UnsupportedSchemaError, "#{role} has a malformed gems section (expected an object, got #{gems.class})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
gems.each { |name, data| validate_gem!(role, name, data) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The diff dereferences gem fields with type-sensitive operations: a non-Hash
|
|
127
|
+
# value crashes the intersection branch, an arithmetic field that isn't
|
|
128
|
+
# numeric crashes (libyear/scorecard) or silently fabricates a count
|
|
129
|
+
# (vulnerability_count via .to_i), and a non-array vulnerabilities silently
|
|
130
|
+
# drops advisory ids. The baseline is untrusted user input, so validate the
|
|
131
|
+
# shape the diff requires here and let the rest of the code assume it.
|
|
132
|
+
NUMERIC_GEM_FIELDS = ["vulnerability_count", "scorecard_score", "libyear"].freeze
|
|
133
|
+
|
|
134
|
+
def validate_gem!(role, name, data)
|
|
135
|
+
unless data.is_a?(Hash)
|
|
136
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} is malformed (expected an object, got #{data.class})"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
NUMERIC_GEM_FIELDS.each do |field|
|
|
140
|
+
value = data[field]
|
|
141
|
+
next if value.nil? || value.is_a?(Numeric)
|
|
142
|
+
|
|
143
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a non-numeric #{field} (got #{value.class})"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
vulns = data["vulnerabilities"]
|
|
147
|
+
return if vulns.nil?
|
|
148
|
+
|
|
149
|
+
unless vulns.is_a?(Array)
|
|
150
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerabilities list (expected an array, got #{vulns.class})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# advisory_ids dereferences each entry as a hash (entry["id"]); a scalar
|
|
154
|
+
# element would crash there, so reject non-object entries up front.
|
|
155
|
+
vulns.each do |entry|
|
|
156
|
+
next if entry.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerability entry (expected an object, got #{entry.class})"
|
|
159
|
+
end
|
|
81
160
|
end
|
|
82
161
|
|
|
83
162
|
# Categorises a version bump:
|