still_active 3.0.0.rc4 → 3.0.0.rc6

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/lib/helpers/activity_helper.rb +3 -3
  4. data/lib/helpers/alternatives_helper.rb +1 -1
  5. data/lib/helpers/ansi_helper.rb +1 -1
  6. data/lib/helpers/bot_context.rb +7 -7
  7. data/lib/helpers/bundler_helper.rb +1 -2
  8. data/lib/helpers/catalog_index.rb +1 -1
  9. data/lib/helpers/constraint_helper.rb +6 -6
  10. data/lib/helpers/cvss_helper.rb +2 -2
  11. data/lib/helpers/cyclonedx_helper.rb +14 -14
  12. data/lib/helpers/diff_markdown_helper.rb +4 -4
  13. data/lib/helpers/dotnet_helper.rb +4 -4
  14. data/lib/helpers/endoflife_helper.rb +3 -3
  15. data/lib/helpers/http_helper.rb +31 -19
  16. data/lib/helpers/lockfile_dependency_parser.rb +2 -2
  17. data/lib/helpers/markdown_escape.rb +1 -1
  18. data/lib/helpers/markdown_helper.rb +7 -7
  19. data/lib/helpers/ruby_advisory_db.rb +1 -1
  20. data/lib/helpers/ruby_helper.rb +3 -3
  21. data/lib/helpers/runtime_ceiling_helper.rb +2 -2
  22. data/lib/helpers/sarif_helper.rb +34 -34
  23. data/lib/helpers/status_helper.rb +1 -1
  24. data/lib/helpers/summary_helper.rb +1 -1
  25. data/lib/helpers/terminal_helper.rb +8 -8
  26. data/lib/helpers/vulnerability_helper.rb +1 -1
  27. data/lib/still_active/artifactory_client.rb +47 -18
  28. data/lib/still_active/cli.rb +38 -38
  29. data/lib/still_active/compact_index_client.rb +117 -0
  30. data/lib/still_active/config.rb +1 -1
  31. data/lib/still_active/config_file.rb +4 -4
  32. data/lib/still_active/deps_dev_client.rb +7 -7
  33. data/lib/still_active/diff.rb +12 -12
  34. data/lib/still_active/ecosystem_lens.rb +8 -6
  35. data/lib/still_active/ecosystems_client.rb +7 -7
  36. data/lib/still_active/forgejo_client.rb +3 -3
  37. data/lib/still_active/github_client.rb +6 -6
  38. data/lib/still_active/gitlab_client.rb +3 -3
  39. data/lib/still_active/options.rb +4 -4
  40. data/lib/still_active/osv_client.rb +200 -30
  41. data/lib/still_active/poison_security_correlator.rb +3 -3
  42. data/lib/still_active/pypi_client.rb +2 -2
  43. data/lib/still_active/repository.rb +4 -4
  44. data/lib/still_active/sarif/rules.rb +20 -20
  45. data/lib/still_active/sbom_reader.rb +8 -8
  46. data/lib/still_active/sbom_workflow.rb +7 -7
  47. data/lib/still_active/source_credentials.rb +39 -0
  48. data/lib/still_active/version.rb +1 -1
  49. data/lib/still_active/workflow.rb +60 -33
  50. data/still_active.gemspec +16 -20
  51. metadata +5 -115
@@ -26,7 +26,7 @@ module StillActive
26
26
  # carries per-version declared dependency constraints (the poison-pill input).
27
27
  PACKAGES_BASE_URI = URI("https://packages.ecosyste.ms/")
28
28
  # ecosyste.ms asks consumers to identify themselves for its "polite pool".
29
- USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
29
+ USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)".freeze
30
30
 
31
31
  # archived + last-commit date from a single repository call. ecosyste.ms's
32
32
  # pushed_at mirrors GitHub's, so this returns the same shape as GithubClient.
@@ -35,13 +35,13 @@ module StillActive
35
35
  return {} if owner.nil? || name.nil?
36
36
 
37
37
  path = "/api/v1/hosts/GitHub/repositories/#{encode_repo(owner, name)}"
38
- body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
38
+ body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params)
39
39
  # A non-Hash 200 body (error envelope rendered as an array, schema drift)
40
40
  # would otherwise raise on indexing and vanish the gem from the audit via
41
41
  # the workflow's rescue; degrade to "no signal" like any other read failure.
42
42
  return {} unless body.is_a?(Hash)
43
43
 
44
- signals = { last_commit_date: parse_time(body["pushed_at"], owner, name) }
44
+ signals = {last_commit_date: parse_time(body["pushed_at"], owner, name)}
45
45
  # Only assert archived when the field is actually present. A missing field
46
46
  # must read as unknown, not be invented as false -- otherwise a partial
47
47
  # crawl could silently mask the most actionable verdict (gem is archived).
@@ -73,7 +73,7 @@ module StillActive
73
73
  return [] if name.nil? || version.nil?
74
74
 
75
75
  path = "/api/v1/registries/#{encode(registry)}/packages/#{encode(name)}/versions/#{encode(version)}"
76
- body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
76
+ body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params)
77
77
  return [] unless body.is_a?(Hash)
78
78
 
79
79
  dependencies = body["dependencies"]
@@ -86,7 +86,7 @@ module StillActive
86
86
  requirements = dep["requirements"]
87
87
  next if package_name.nil? || requirements.nil?
88
88
 
89
- { package_name: package_name, requirements: requirements }
89
+ {package_name: package_name, requirements: requirements}
90
90
  end
91
91
  end
92
92
 
@@ -98,7 +98,7 @@ module StillActive
98
98
  # lockfile and we don't attribute every user's traffic to one address.
99
99
  def politeness_params
100
100
  email = StillActive.config.ecosystems_email
101
- email ? { mailto: email } : {}
101
+ email ? {mailto: email} : {}
102
102
  end
103
103
 
104
104
  # nil or a non-string (numeric/array from an off-spec payload) -> no date,
@@ -108,7 +108,7 @@ module StillActive
108
108
 
109
109
  Time.parse(value)
110
110
  rescue ArgumentError
111
- $stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
111
+ warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
112
112
  nil
113
113
  end
114
114
 
@@ -24,7 +24,7 @@ module StillActive
24
24
  body = HttpHelper.get_json(base_uri(host), "/api/v1/repos/#{owner}/#{name}", headers: auth_headers)
25
25
  return {} if body.nil?
26
26
 
27
- { archived: body["archived"] == true, last_commit_date: parse_time(body["updated_at"], owner, name) }
27
+ {archived: body["archived"] == true, last_commit_date: parse_time(body["updated_at"], owner, name)}
28
28
  end
29
29
 
30
30
  private
@@ -34,7 +34,7 @@ module StillActive
34
34
 
35
35
  Time.parse(value)
36
36
  rescue ArgumentError
37
- $stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
37
+ warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
38
38
  nil
39
39
  end
40
40
 
@@ -44,7 +44,7 @@ module StillActive
44
44
 
45
45
  def auth_headers
46
46
  token = StillActive.config.forgejo_token
47
- token ? { "Authorization" => "token #{token}" } : {}
47
+ token ? {"Authorization" => "token #{token}"} : {}
48
48
  end
49
49
  end
50
50
  end
@@ -29,9 +29,9 @@ module StillActive
29
29
  end
30
30
  return {} unless repo
31
31
 
32
- { archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name) }
32
+ {archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name)}
33
33
  rescue Octokit::Error, Faraday::Error => e
34
- $stderr.puts("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
34
+ warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
35
35
  {}
36
36
  end
37
37
 
@@ -54,7 +54,7 @@ module StillActive
54
54
  end
55
55
  nil
56
56
  rescue Octokit::Error, Faraday::Error => e
57
- $stderr.puts("warning: unreleased-commits check failed for #{owner}/#{name}: #{e.class}")
57
+ warn("warning: unreleased-commits check failed for #{owner}/#{name}: #{e.class}")
58
58
  nil
59
59
  end
60
60
 
@@ -75,12 +75,12 @@ module StillActive
75
75
  # Hourly-limit exhaustion (or a far reset): not worth auto-waiting.
76
76
  # Surface the one actionable hint rather than a generic class name,
77
77
  # then return nil so this signal is simply absent for the gem.
78
- $stderr.puts("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
78
+ warn("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
79
79
  return
80
80
  end
81
81
 
82
82
  retried = true
83
- $stderr.puts("rate limited on #{label}; waiting #{wait}s for reset")
83
+ warn("rate limited on #{label}; waiting #{wait}s for reset")
84
84
  sleep(wait)
85
85
  retry
86
86
  end
@@ -119,7 +119,7 @@ module StillActive
119
119
 
120
120
  Time.parse(value)
121
121
  rescue ArgumentError
122
- $stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
122
+ warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
123
123
  nil
124
124
  end
125
125
  end
@@ -20,7 +20,7 @@ module StillActive
20
20
  body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers)
21
21
  return {} if body.nil?
22
22
 
23
- { archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name) }
23
+ {archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name)}
24
24
  end
25
25
 
26
26
  private
@@ -30,13 +30,13 @@ module StillActive
30
30
 
31
31
  Time.parse(value)
32
32
  rescue ArgumentError
33
- $stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
33
+ warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
34
34
  nil
35
35
  end
36
36
 
37
37
  def auth_headers
38
38
  token = StillActive.config.gitlab_token
39
- token ? { "PRIVATE-TOKEN" => token } : {}
39
+ token ? {"PRIVATE-TOKEN" => token} : {}
40
40
  end
41
41
 
42
42
  def encode_project(owner, name)
@@ -57,7 +57,7 @@ module StillActive
57
57
  opts.on("--gems=GEM,GEM2,...", Array, "Gem(s)") do |value|
58
58
  options[:provided_gems] = true
59
59
  # Explicitly named gems are direct by definition (the user chose them).
60
- StillActive.config { |config| config.gems = value.map { |g| { name: g, direct: true } } }
60
+ StillActive.config { |config| config.gems = value.map { |g| {name: g, direct: true} } }
61
61
  end
62
62
  end
63
63
 
@@ -130,14 +130,14 @@ module StillActive
130
130
  opts.on(
131
131
  "--safe-range-end=YEARS",
132
132
  Float,
133
- "maximum years since last release considered safe, no warning (default 1.5; fractional allowed)",
133
+ "maximum years since last release considered safe, no warning (default 1.5; fractional allowed)"
134
134
  ) do |value|
135
135
  StillActive.config { |config| config.no_warning_range_end = value }
136
136
  end
137
137
  opts.on(
138
138
  "--warning-range-end=YEARS",
139
139
  Float,
140
- "maximum years since last release that triggers a warning, beyond this is critical (default 3)",
140
+ "maximum years since last release that triggers a warning, beyond this is critical (default 3)"
141
141
  ) do |value|
142
142
  StillActive.config { |config| config.warning_range_end = value }
143
143
  end
@@ -181,7 +181,7 @@ module StillActive
181
181
  # Ceiling findings are only ever :critical or :note, so =warning can't
182
182
  # match anything the bare (=critical) default doesn't already catch.
183
183
  if value == "warning"
184
- $stderr.puts("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
184
+ warn("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
185
185
  end
186
186
  StillActive.config { |config| config.fail_if_language_ceiling = value.to_sym }
187
187
  else
@@ -15,8 +15,13 @@ module StillActive
15
15
  # poison cap's ceiling.
16
16
  #
17
17
  # Enrichment is best-effort: any failure (missing record, transport error, odd
18
- # shape) leaves the advisory exactly as deps.dev produced it. It must never drop
19
- # an advisory the audit already found, so it only ever ADDS fields.
18
+ # shape) leaves the advisory exactly as deps.dev produced it.
19
+ #
20
+ # OSV is also the ARBITER of whether a deps.dev-discovered advisory really applies
21
+ # to the audited version (see reject_unaffected), which is the one place this module
22
+ # removes rather than adds. That direction is the dangerous one -- a wrongly dropped
23
+ # advisory reads a vulnerable dependency as clean -- so every degrade path here must
24
+ # resolve to "keep", and only an unambiguous OSV contradiction may drop.
20
25
  module OsvClient
21
26
  extend self
22
27
 
@@ -34,36 +39,35 @@ module StillActive
34
39
  cargo: "crates.io",
35
40
  maven: "Maven",
36
41
  go: "Go",
37
- nuget: "NuGet",
42
+ nuget: "NuGet"
38
43
  }.freeze
39
44
 
40
45
  # Prefer the newest CVSS version a record carries (v4 is the whole point: it's the
41
46
  # one deps.dev can't score). `severity[].score` is the vector STRING, oddly named.
42
- CVSS_PRIORITY = { "CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1 }.freeze
47
+ CVSS_PRIORITY = {"CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1}.freeze
43
48
  # A v2 vector has no `CVSS:X.Y` prefix, so the version can't be read from the
44
49
  # string; fall back to the entry type so the CycloneDX rating method labels it v2.
45
- TYPE_VERSIONS = { "CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0" }.freeze
50
+ TYPE_VERSIONS = {"CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0"}.freeze
46
51
 
47
52
  # Enrich each advisory in place with the OSV severity label and the fixed
48
- # versions for the audited package. A missing/failed lookup is a no-op on that
49
- # advisory (it keeps whatever deps.dev gave it), never a raise.
50
- def enrich(advisories, ecosystem:, name:)
51
- advisories.each do |advisory|
52
- record = detail(advisory_id: advisory[:id])
53
- next if record.nil?
54
-
55
- advisory[:osv_severity] = record[:severity_label]
56
- advisory[:osv_cvss_score] = record[:cvss_score]
57
- advisory[:cvss_version] = record[:cvss_version]
58
- advisory[:cvss_vector] = record[:cvss_vector]
59
- advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
60
- rescue StandardError => e
61
- # Enrichment is additive and best-effort. An unexpected OSV shape must never
62
- # raise out through the workflow's per-gem rescue, which would DROP the whole
63
- # gem and read a known-vulnerable dependency as clean. Leave the advisory
64
- # exactly as deps.dev produced it.
65
- $stderr.puts("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
53
+ # versions for the audited package, and return the advisories that survive
54
+ # version confirmation (see reject_unaffected). A missing/failed lookup is a
55
+ # no-op on that advisory (it keeps whatever deps.dev gave it), never a raise.
56
+ def enrich(advisories, ecosystem:, name:, version: nil)
57
+ # Each eligible advisory is paired with OSV's own identifiers for it, which the
58
+ # confirmation match needs alongside deps.dev's (see detail).
59
+ backed = advisories.filter_map do |advisory|
60
+ osv_ids = apply_record(advisory, ecosystem: ecosystem, name: name)
61
+ [advisory, osv_ids] if osv_ids
66
62
  end
63
+ reject_unaffected(advisories, backed, ecosystem: ecosystem, name: name, version: version)
64
+ rescue => e
65
+ # The confirmation pass runs outside apply_record's per-advisory rescue, and an
66
+ # escape from here would hit the workflow's per-gem rescue and strip the gem of
67
+ # ALL its signals -- reading a known-vulnerable dependency as clean, the very
68
+ # thing this module exists to prevent. Keep every advisory instead.
69
+ warn("warning: OSV version confirmation for #{name} failed: #{e.class} (#{e.message}); keeping every advisory")
70
+ advisories
67
71
  end
68
72
 
69
73
  # Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
@@ -79,16 +83,173 @@ module StillActive
79
83
 
80
84
  cvss = best_cvss(body)
81
85
  {
86
+ # OSV's own identity for the advisory, unioned into the match against a query
87
+ # result. Asking for a CVE returns the GHSA record (OSV resolves aliases), so
88
+ # deps.dev's id alone can miss the id the query answers with, and reading a
89
+ # listed advisory as absent would drop a real finding.
90
+ identifiers: [body["id"], *Array(body["aliases"])].grep(String),
82
91
  severity_label: body.dig("database_specific", "severity"),
83
92
  cvss_score: cvss[:score],
84
93
  cvss_version: cvss[:version],
85
94
  cvss_vector: cvss[:vector],
86
- affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) },
95
+ affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) }
87
96
  }
88
97
  end
89
98
 
90
99
  private
91
100
 
101
+ # Applies one OSV record to one advisory, in place. Returns OSV's identifiers for
102
+ # the advisory when it is eligible for the version confirmation below (only OSV
103
+ # data may overrule OSV-derived data), or nil when it is not and must be kept
104
+ # whatever the query says.
105
+ def apply_record(advisory, ecosystem:, name:)
106
+ record = detail(advisory_id: advisory[:id])
107
+ return if record.nil?
108
+
109
+ advisory[:osv_severity] = record[:severity_label]
110
+ advisory[:osv_cvss_score] = record[:cvss_score]
111
+ advisory[:cvss_version] = record[:cvss_version]
112
+ advisory[:cvss_vector] = record[:cvss_vector]
113
+ advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
114
+ record[:identifiers] if names_package?(record, ecosystem: ecosystem, name: name)
115
+ rescue => e
116
+ # Enrichment is additive and best-effort. An unexpected OSV shape must never
117
+ # raise out through the workflow's per-gem rescue, which would DROP the whole
118
+ # gem and read a known-vulnerable dependency as clean. Leave the advisory
119
+ # exactly as deps.dev produced it, and hold it (nil) rather than risk
120
+ # dropping it on a record we couldn't read.
121
+ warn("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
122
+ nil
123
+ end
124
+
125
+ # Drops the advisories that OSV's own version matching says don't apply to the
126
+ # audited version. deps.dev is the discovery source, but it MIRRORS OSV/GHSA data
127
+ # with an ingestion lag, and advisories get AMENDED after publication: the usual
128
+ # shape is a CVE published with a fix on the current release line, then backports
129
+ # to the older supported lines, then an amendment adding those branch ranges. Until
130
+ # deps.dev re-ingests, it keeps serving the pre-amendment record, whose broader
131
+ # range still covers versions the amendment has since marked patched.
132
+ #
133
+ # Receipt (2026-07-31): GHSA-mh99-v99m-4gvg was published 07-24 with a single
134
+ # range, introduced 0 / fixed 5.0.8. Backports 3.0.3, 2.1.3 and 1.1.17 shipped
135
+ # 07-27 to 07-29, and the advisory was amended 07-31 19:39Z to carry all four
136
+ # branches. Hours later deps.dev still answered from the 07-24 record, reporting
137
+ # the patched 1.1.17/1.1.18/2.1.3/2.1.4/3.0.3 as vulnerable.
138
+ #
139
+ # This is lag, NOT a parsing defect: 341 multi-branch advisories across
140
+ # npm/rubygems/pypi/cargo were compared against OSV over 589 version checks, and
141
+ # every one agreed. So the correction has to be a live re-check, not a local range
142
+ # fix. We
143
+ # ask OSV's /v1/query, deps.dev's own upstream, which applies the declared ranges
144
+ # under each ecosystem's semantics and reflects an amendment immediately. It also
145
+ # saves reimplementing cross-ecosystem version ordering.
146
+ #
147
+ # The window reopens with every backported fix, so this is structural, not a
148
+ # one-off worth waiting out.
149
+ #
150
+ # Removing a finding is the one direction this tool must never get wrong, so a drop
151
+ # needs positive contradiction on every count: the advisory came from deps.dev ALONE
152
+ # (ruby-advisory-db does its own version matching and is the Ruby authority, so its
153
+ # verdict stands), OSV served a record for it that names this package under this
154
+ # ecosystem and spelling, and the query for this exact version succeeded without
155
+ # listing it. Anything unknown -- no version, unmapped ecosystem, failed query --
156
+ # keeps the advisory.
157
+ def reject_unaffected(advisories, backed, ecosystem:, name:, version:)
158
+ candidates = backed.select { |advisory, _osv_ids| advisory[:source] == "deps.dev" }
159
+ return advisories if candidates.empty? || version.to_s.empty?
160
+
161
+ affected = affected_identifiers(ecosystem: ecosystem, name: name, version: version)
162
+ return advisories if affected.nil?
163
+
164
+ unaffected = candidates
165
+ .reject { |advisory, osv_ids| (identifiers(advisory) | osv_ids).intersect?(affected) }
166
+ .map(&:first)
167
+ # Say so when a finding is removed. This is the only path that can turn a red run
168
+ # green, and a silent drop leaves no way to tell "OSV cleared it" from "the audit
169
+ # missed it". Matches the per-gem stderr notes the severity gates already emit.
170
+ unaffected.each { |advisory| warn("still_active: #{name}@#{version} is not affected by #{advisory[:id]} per OSV; deps.dev listed it, dropping") }
171
+ advisories - unaffected
172
+ end
173
+
174
+ # Every advisory identifier OSV reports as affecting this exact package version,
175
+ # or nil when the question can't be asked or answered (unmapped ecosystem, failed
176
+ # request, unusable body). An EMPTY array is a real answer -- "nothing affects this
177
+ # version" -- and must stay distinct from the nil, which means "we don't know".
178
+ #
179
+ # That distinction is the whole safety of the drop, so an empty array is only ever
180
+ # returned for a body that positively IS an empty OSV result. OSV's genuine all-clear
181
+ # is a bare `{}` (verified live, and identical to what an unknown package name
182
+ # returns), which gives a malformed or truncated body no distinguishing marker of
183
+ # its own: read loosely, every unparseable 200 would fabricate an all-clear.
184
+ def affected_identifiers(ecosystem:, name:, version:)
185
+ osv_ecosystem = ECOSYSTEM_NAMES[ecosystem || :rubygems]
186
+ return if osv_ecosystem.nil?
187
+
188
+ query = {version: version, package: {name: name, ecosystem: osv_ecosystem}}
189
+ body = HttpHelper.post_json(BASE_URI, "/v1/query", body: JSON.generate(query))
190
+ return unless body.is_a?(Hash)
191
+
192
+ identifiers_from(body)
193
+ rescue => e
194
+ warn("warning: OSV version confirmation for #{name}@#{version} failed: #{e.class} (#{e.message}); keeping every advisory")
195
+ nil
196
+ end
197
+
198
+ # The identifiers in one /v1/query body, or nil when the body isn't a complete,
199
+ # readable answer. A truncated answer is not an answer: OSV paginates past 1000
200
+ # vulnerabilities OR once a query exceeds 20 seconds, and documents that a page can
201
+ # carry ONLY a next_page_token. That second trigger is latency, not package size, so
202
+ # it can land on any package on a slow day; reading it as "nothing affects this
203
+ # version" would drop real findings at random. We decline to filter rather than
204
+ # walk the pages, since the pass only ever removes false positives.
205
+ def identifiers_from(body)
206
+ return if body["next_page_token"] || body["nextPageToken"]
207
+ # A bare `{}` is OSV's genuine "nothing affects this version" (verified live). It
208
+ # has to be matched exactly, not as "no vulns key": an error envelope served with a
209
+ # 200 (`{"code":3,"message":...}`) has no vulns key either, and reading that as an
210
+ # all-clear would drop every advisory on the package.
211
+ return [] if body.empty?
212
+
213
+ vulns = body["vulns"]
214
+ return unless vulns.is_a?(Array)
215
+ # EVERY entry has to be readable, not just one of them. Skipping the unreadable
216
+ # ones would silently shorten the list, and a short list is indistinguishable from
217
+ # OSV not listing that advisory at all: the one it named but we couldn't parse
218
+ # would be dropped as unaffected. `all?` holds on an empty array, so OSV's other
219
+ # genuine all-clear shape, `{"vulns": []}`, still answers.
220
+ return unless vulns.all? { |vuln| vuln.is_a?(Hash) && vuln["id"].is_a?(String) }
221
+
222
+ vulns.flat_map { |vuln| [vuln["id"], *vuln["aliases"]] }.grep(String)
223
+ end
224
+
225
+ # An advisory's own id plus its aliases, mirroring VulnerabilityHelper's merge
226
+ # identity: OSV keys a record by GHSA while deps.dev may hand us the CVE (or the
227
+ # reverse), so matching on the primary id alone would read a listed advisory as
228
+ # absent and drop a real finding.
229
+ def identifiers(advisory)
230
+ [advisory[:id], *advisory[:aliases]].compact.map(&:to_s)
231
+ end
232
+
233
+ # Does this record carry version data for the audited package, under the exact
234
+ # ecosystem and spelling we're querying with? Both halves guard a fabricated
235
+ # all-clear, because OSV answers an unknown package and a patched version with the
236
+ # same bare `{}`:
237
+ #
238
+ # NAME -- registry name normalization differs by ecosystem (PyPI folds case and
239
+ # -/_/.), so a spelling mismatch would turn a missed lookup into "not affected".
240
+ # Exact equality is stricter than OSV's own matching, which costs us the fix on a
241
+ # case-divergent name; that's the safe direction to be wrong in.
242
+ #
243
+ # VERSIONED -- an affected entry with no ranges and no version list (or one whose
244
+ # window lives somewhere /v1/query can't match on, like a GIT range) is OSV having
245
+ # no opinion on versions at all. The query can then never return that record for
246
+ # ANY version, so treating its silence as contradiction would drop the advisory
247
+ # permanently, for every user, while deps.dev still asserts the version is affected.
248
+ def names_package?(record, ecosystem:, name:)
249
+ osv_ecosystem = ECOSYSTEM_NAMES[ecosystem || :rubygems]
250
+ record[:affected].any? { |a| a[:name] == name && a[:ecosystem] == osv_ecosystem && a[:versioned] }
251
+ end
252
+
92
253
  # The highest-version CVSS vector the record carries, as { score:, version:,
93
254
  # vector: } (a computed base score, the "3.1"/"4.0" version, and the raw string),
94
255
  # or all-nil. deps.dev has no v4 score, so this is what gives a CVSS-4-only
@@ -100,7 +261,7 @@ module StillActive
100
261
  return {} if entry.nil?
101
262
 
102
263
  vector = entry["score"]
103
- { score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector }
264
+ {score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector}
104
265
  end
105
266
 
106
267
  # The X.Y version from a "CVSS:X.Y/..." vector prefix, or nil.
@@ -132,12 +293,21 @@ module StillActive
132
293
  package = entry["package"]
133
294
  return unless package.is_a?(Hash) && package["name"]
134
295
 
135
- fixed = Array(entry["ranges"]).flat_map do |range|
136
- next [] unless range.is_a?(Hash)
296
+ ranges = Array(entry["ranges"]).select { |range| range.is_a?(Hash) }
297
+ fixed = ranges.flat_map { |range| Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) } }
298
+ {ecosystem: package["ecosystem"], name: package["name"], fixed: fixed, versioned: version_matchable?(entry, ranges)}
299
+ end
137
300
 
138
- Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) }
139
- end
140
- { ecosystem: package["ecosystem"], name: package["name"], fixed: fixed }
301
+ # Range types /v1/query can resolve a VERSION against. A GIT range is commit-graph
302
+ # data a version query never matches, so a record carrying only those has no version
303
+ # opinion to contradict deps.dev with. A range missing its type reads the same way.
304
+ VERSION_RANGE_TYPES = ["SEMVER", "ECOSYSTEM"].freeze
305
+
306
+ # Does this affected entry hold data a version query could match on -- an explicit
307
+ # version list, or a version-ordered range? See names_package? for why the absence
308
+ # of it has to block a drop rather than permit one.
309
+ def version_matchable?(entry, ranges)
310
+ !Array(entry["versions"]).empty? || ranges.any? { |range| VERSION_RANGE_TYPES.include?(range["type"]) }
141
311
  end
142
312
 
143
313
  def encode(value)
@@ -73,7 +73,7 @@ module StillActive
73
73
  next unless data[:vulnerability_count].to_i.positive?
74
74
 
75
75
  name = data[:name] || key
76
- map["#{data[:ecosystem]}/#{name}"] = { vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used] }
76
+ map["#{data[:ecosystem]}/#{name}"] = {vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used]}
77
77
  end
78
78
  end
79
79
 
@@ -85,7 +85,7 @@ module StillActive
85
85
  def copy_index(result_object)
86
86
  result_object.each_with_object({}) do |(key, data), map|
87
87
  name = data[:name] || key
88
- (map["#{data[:ecosystem]}/#{name}"] ||= []) << { version: data[:version_used], vulns: hashes(data[:vulnerabilities]) }
88
+ (map["#{data[:ecosystem]}/#{name}"] ||= []) << {version: data[:version_used], vulns: hashes(data[:vulnerabilities])}
89
89
  end
90
90
  end
91
91
 
@@ -148,7 +148,7 @@ module StillActive
148
148
  capped_dep_vulnerable: true,
149
149
  capped_below_fix: true,
150
150
  below_fix_advisory: receipt[:id],
151
- below_fix_fixed_in: nearest_fix(receipt, oldest),
151
+ below_fix_fixed_in: nearest_fix(receipt, oldest)
152
152
  }
153
153
  end
154
154
 
@@ -20,7 +20,7 @@ module StillActive
20
20
 
21
21
  BASE_URI = URI("https://pypi.org/")
22
22
  # Polite identification, matching EcosystemsClient's convention.
23
- USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
23
+ USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)".freeze
24
24
 
25
25
  # The PEP 440 `requires_python` specifier a release declares (e.g.
26
26
  # ">=3.8,<3.13"), or nil when the version is unreadable or declares none.
@@ -30,7 +30,7 @@ module StillActive
30
30
  return if name.nil? || version.nil?
31
31
 
32
32
  path = "/pypi/#{encode(name)}/#{encode(version)}/json"
33
- body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT })
33
+ body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT})
34
34
  return unless body.is_a?(Hash)
35
35
 
36
36
  # Guard the nested read: a non-Hash `info` (schema drift) would make
@@ -8,9 +8,9 @@ module StillActive
8
8
  SOURCE_BY_HOST = {
9
9
  "github.com" => :github,
10
10
  "gitlab.com" => :gitlab,
11
- "codeberg.org" => :forgejo,
11
+ "codeberg.org" => :forgejo
12
12
  }.freeze
13
- REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.\-]+)/(?<name>[\w.\-]+))}i
13
+ REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.-]+)/(?<name>[\w.-]+))}i
14
14
 
15
15
  extend self
16
16
 
@@ -22,12 +22,12 @@ module StillActive
22
22
 
23
23
  def url_with_owner_and_name(url:)
24
24
  match = url&.match(REPO_REGEX)
25
- return { source: :unhandled, owner: nil, name: nil } unless match
25
+ return {source: :unhandled, owner: nil, name: nil} unless match
26
26
 
27
27
  clean_url = match[:url].delete_suffix(".git")
28
28
  name = match[:name].delete_suffix(".git")
29
29
 
30
- { url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name }
30
+ {url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name}
31
31
  end
32
32
  end
33
33
  end