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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. 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
- def version_info(gem_name:, version:)
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/rubygems/packages/#{encode(gem_name)}/versions/#{encode(version)}"
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
- aliases: body["aliases"]&.map { |a| a["id"] } || [],
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
- # Extracts "host/owner/repo" from the SOURCE_REPO link URL.
62
- # URLs may have trailing slashes or extra path segments (e.g. /tree/v1.0).
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
- path = url.delete_prefix("https://").delete_prefix("http://")
68
- segments = path.split("/")
69
- segments[0..2].join("/") if segments.length >= 3
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
@@ -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
- return if SUPPORTED_SCHEMA_VERSIONS.include?(version)
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
- raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}"
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: