still_active 3.0.0.rc5 → 3.0.0

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -2
  3. data/README.md +27 -2
  4. data/lib/still_active/artifactory_client.rb +1 -1
  5. data/lib/still_active/cli.rb +91 -29
  6. data/lib/still_active/compact_index_client.rb +1 -1
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/config_file.rb +3 -2
  9. data/lib/still_active/deps_dev_client.rb +24 -3
  10. data/lib/still_active/ecosystem_lens.rb +22 -8
  11. data/lib/still_active/ecosystems_client.rb +1 -1
  12. data/lib/still_active/forgejo_client.rb +1 -1
  13. data/lib/still_active/gitlab_client.rb +1 -1
  14. data/lib/{helpers → still_active/helpers}/activity_helper.rb +1 -1
  15. data/lib/{helpers → still_active/helpers}/cyclonedx_helper.rb +85 -7
  16. data/lib/{helpers → still_active/helpers}/libyear_helper.rb +1 -1
  17. data/lib/{helpers → still_active/helpers}/markdown_helper.rb +19 -0
  18. data/lib/{helpers → still_active/helpers}/sarif_helper.rb +17 -2
  19. data/lib/still_active/helpers/sbom_graph.rb +166 -0
  20. data/lib/{helpers → still_active/helpers}/status_helper.rb +23 -5
  21. data/lib/{helpers → still_active/helpers}/terminal_helper.rb +15 -0
  22. data/lib/{helpers → still_active/helpers}/version_helper.rb +14 -2
  23. data/lib/still_active/options.rb +6 -3
  24. data/lib/still_active/osv_client.rb +197 -27
  25. data/lib/still_active/poison_security_correlator.rb +3 -3
  26. data/lib/still_active/pypi_client.rb +1 -1
  27. data/lib/still_active/sarif/rules.rb +15 -0
  28. data/lib/still_active/sbom_reader.rb +117 -2
  29. data/lib/still_active/sbom_workflow.rb +4 -4
  30. data/lib/still_active/suppressions.rb +5 -3
  31. data/lib/still_active/version.rb +1 -1
  32. data/lib/still_active/workflow.rb +20 -13
  33. metadata +34 -33
  34. /data/lib/{helpers → still_active/helpers}/alternatives_helper.rb +0 -0
  35. /data/lib/{helpers → still_active/helpers}/ansi_helper.rb +0 -0
  36. /data/lib/{helpers → still_active/helpers}/bot_context.rb +0 -0
  37. /data/lib/{helpers → still_active/helpers}/bundler_helper.rb +0 -0
  38. /data/lib/{helpers → still_active/helpers}/catalog_index.rb +0 -0
  39. /data/lib/{helpers → still_active/helpers}/constraint_helper.rb +0 -0
  40. /data/lib/{helpers → still_active/helpers}/cvss_helper.rb +0 -0
  41. /data/lib/{helpers → still_active/helpers}/dependency_helper.rb +0 -0
  42. /data/lib/{helpers → still_active/helpers}/diff_markdown_helper.rb +0 -0
  43. /data/lib/{helpers → still_active/helpers}/dotnet_helper.rb +0 -0
  44. /data/lib/{helpers → still_active/helpers}/emoji_helper.rb +0 -0
  45. /data/lib/{helpers → still_active/helpers}/endoflife_helper.rb +0 -0
  46. /data/lib/{helpers → still_active/helpers}/http_helper.rb +0 -0
  47. /data/lib/{helpers → still_active/helpers}/lockfile_dependency_parser.rb +0 -0
  48. /data/lib/{helpers → still_active/helpers}/lockfile_indexer.rb +0 -0
  49. /data/lib/{helpers → still_active/helpers}/markdown_escape.rb +0 -0
  50. /data/lib/{helpers → still_active/helpers}/pep440_helper.rb +0 -0
  51. /data/lib/{helpers → still_active/helpers}/python_helper.rb +0 -0
  52. /data/lib/{helpers → still_active/helpers}/ruby_advisory_db.rb +0 -0
  53. /data/lib/{helpers → still_active/helpers}/ruby_helper.rb +0 -0
  54. /data/lib/{helpers → still_active/helpers}/runtime_ceiling_helper.rb +0 -0
  55. /data/lib/{helpers → still_active/helpers}/semver_satisfaction.rb +0 -0
  56. /data/lib/{helpers → still_active/helpers}/summary_helper.rb +0 -0
  57. /data/lib/{helpers → still_active/helpers}/vulnerability_helper.rb +0 -0
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../helpers/http_helper"
4
- require_relative "../helpers/cvss_helper"
3
+ require_relative "helpers/http_helper"
4
+ require_relative "helpers/cvss_helper"
5
5
 
6
6
  module StillActive
7
7
  # OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
@@ -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
 
@@ -45,25 +50,24 @@ module StillActive
45
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 => 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
- warn("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,6 +83,11 @@ 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],
@@ -89,6 +98,158 @@ module StillActive
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
@@ -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)
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../helpers/vulnerability_helper"
4
- require_relative "../helpers/constraint_helper"
5
- require_relative "../helpers/semver_satisfaction"
3
+ require_relative "helpers/vulnerability_helper"
4
+ require_relative "helpers/constraint_helper"
5
+ require_relative "helpers/semver_satisfaction"
6
6
 
7
7
  module StillActive
8
8
  # Whole-tree correlation, run once after the fan-out: a poison cap is far more
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../helpers/http_helper"
3
+ require_relative "helpers/http_helper"
4
4
  require_relative "version"
5
5
 
6
6
  module StillActive
@@ -168,6 +168,21 @@ module StillActive
168
168
  level: "note",
169
169
  security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
170
170
  tags: ["maintenance", "runtime", "external/cwe/cwe-1104"]
171
+ },
172
+ {
173
+ id: "SA010",
174
+ name: "DeprecatedPackage",
175
+ short: "Gem is deprecated by its maintainer",
176
+ full: "The maintainer has marked this gem deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
177
+ help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches.",
178
+ level: "error",
179
+ security_severity: "7.5",
180
+ tags: ["security", "supply-chain", "maintenance", "external/cwe/cwe-1104"],
181
+ neutral: {
182
+ short: "Package is deprecated by its maintainer",
183
+ full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
184
+ help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches."
185
+ }
171
186
  }
172
187
  ].freeze
173
188
 
@@ -4,6 +4,8 @@ require "json"
4
4
  require "uri"
5
5
  require "package_url"
6
6
 
7
+ require_relative "helpers/sbom_graph"
8
+
7
9
  module StillActive
8
10
  # Reads a CycloneDX SBOM (e.g. produced by Syft) into a clean, normalized
9
11
  # dependency set -- the breadth input for non-Ruby ecosystems, where the
@@ -63,12 +65,19 @@ module StillActive
63
65
 
64
66
  deps = []
65
67
  unassessable = []
68
+ entries_by_ref = {}
66
69
  components.each do |component|
67
70
  kind, entry = classify(component)
68
71
  entry[:production] = !dev_signal?(component) if kind == :dependency && marks_dev
69
- deps << entry if kind == :dependency
72
+ if kind == :dependency
73
+ ref = component["bom-ref"]
74
+ entries_by_ref[ref] = entry if ref.is_a?(String)
75
+ deps << entry
76
+ end
70
77
  unassessable << entry if kind == :unassessable
71
78
  end
79
+ drop_project_components(doc, deps, entries_by_ref)
80
+ attach_dependency_graph(doc, deps, entries_by_ref)
72
81
  # uniq collapses a package that a generator lists more than once (e.g. Syft's
73
82
  # per-location entries); `production` is derived from each component's own
74
83
  # signal, so duplicates of the same name+version dedup cleanly unless a
@@ -80,6 +89,92 @@ module StillActive
80
89
 
81
90
  private
82
91
 
92
+ # A Syft SBOM lists the scanned project as an ordinary library component with a
93
+ # purl, so it was classified as one of its own dependencies and assessed: no
94
+ # registry entry, therefore no releases, therefore reported as critically stale.
95
+ # That is a confident false verdict about the user's own code, on the workflow
96
+ # the README recommends, and it trips --fail-if-critical.
97
+ #
98
+ # The project is dropped rather than surfaced as unassessable: it is not a
99
+ # dependency we failed to assess, it is not a dependency, which is the same
100
+ # treatment CI actions and opaque binaries already get. Nothing changes for an
101
+ # SBOM with no dependency graph, and the rule needs the project to actually
102
+ # depend on something, so a dependency with a missing parent edge is never
103
+ # dropped for looking parentless.
104
+ def drop_project_components(doc, deps, entries_by_ref)
105
+ return if entries_by_ref.empty?
106
+
107
+ roots = SbomGraph.project_refs(dependencies: doc["dependencies"], library_refs: entries_by_ref.keys.to_set)
108
+ return if roots.empty?
109
+
110
+ # Compared by identity, not value: two components can classify to equal
111
+ # hashes, and only the one the graph named as a root should be dropped.
112
+ dropped = roots.filter_map { |ref| entries_by_ref.delete(ref)&.object_id }.to_set
113
+ deps.reject! { |entry| dropped.include?(entry.object_id) }
114
+ end
115
+
116
+ # Place each package in the CycloneDX dependency graph, so a cross-ecosystem
117
+ # audit can say which packages you actually declared and, for the rest, the
118
+ # declared package that pulls each one in. Same contract as the native path:
119
+ # `direct` is a boolean, and `dependency_path` is present only for a transitive
120
+ # package, head-first so it names the dependency a maintainer can act on.
121
+ #
122
+ # Both fields are attached ONLY to packages the graph actually places. A
123
+ # generator can emit a near-empty graph (a Syft directory scan of a Ruby project
124
+ # produced one edge across 789 components), and stamping `direct: false` on
125
+ # everything it failed to mention would be the positive claim "none of these are
126
+ # yours" about a document that never said. Absent means unknown, which the
127
+ # renderers already treat correctly: they test `direct == false`, not falsiness.
128
+ def attach_dependency_graph(doc, deps, entries_by_ref)
129
+ return if entries_by_ref.empty?
130
+
131
+ placements = SbomGraph.resolve(
132
+ dependencies: doc["dependencies"],
133
+ root_ref: doc.dig("metadata", "component", "bom-ref"),
134
+ library_refs: entries_by_ref.keys.to_set
135
+ )
136
+ return if placements.nil?
137
+
138
+ identities = entries_by_ref.transform_values { |entry| "#{entry[:ecosystem]}/#{entry[:name]}" }
139
+ # Fold per package identity first. A generator may list one package under
140
+ # several bom-refs (Syft emits a component per location), and those copies are
141
+ # collapsed by `uniq` below; if they carried different placements they would
142
+ # stop being equal and the package would appear twice in the audit.
143
+ best = {}
144
+ entries_by_ref.each do |ref, entry|
145
+ placement = placements[ref]
146
+ next if placement.nil?
147
+
148
+ key = identity_key(entry)
149
+ best[key] = better_placement(best[key], placement)
150
+ end
151
+
152
+ deps.each do |entry|
153
+ placement = best[identity_key(entry)]
154
+ next if placement.nil?
155
+
156
+ entry[:direct] = placement[:direct]
157
+ next if placement[:direct]
158
+
159
+ path = Array(placement[:path]).filter_map { |ref| identities[ref] }
160
+ entry[:dependency_path] = path unless path.empty?
161
+ end
162
+ end
163
+
164
+ def identity_key(entry)
165
+ [entry[:ecosystem], entry[:name], entry[:version]]
166
+ end
167
+
168
+ # Declared beats pulled-in (you can act on it directly), and among transitive
169
+ # placements the shorter path wins, matching the native path's shortest-path BFS.
170
+ def better_placement(current, candidate)
171
+ return candidate if current.nil?
172
+ return current if current[:direct]
173
+ return candidate if candidate[:direct]
174
+
175
+ (Array(candidate[:path]).length < Array(current[:path]).length) ? candidate : current
176
+ end
177
+
83
178
  # Whether a component is marked dev/test-only. CycloneDX `scope` (excluded =
84
179
  # not part of the product, optional = not needed at runtime) is the standard;
85
180
  # tools that don't set scope may instead emit a property (cyclonedx-npm's
@@ -146,7 +241,12 @@ module StillActive
146
241
  return [:unassessable, {ecosystem: ecosystem, name: full_name, version: parsed.version, reason: :private_registry, repository_url: repository_url}]
147
242
  end
148
243
 
149
- [:dependency, {ecosystem: ecosystem, name: full_name, version: parsed.version}]
244
+ # The input's own PURL, kept verbatim rather than parsed-and-rebuilt. An
245
+ # enriched SBOM re-emits it unchanged, so whatever a consumer matched on
246
+ # the input it matches on our output, and no per-ecosystem PURL
247
+ # reconstruction (npm scopes, maven group:artifact, go module paths) can
248
+ # get it subtly wrong.
249
+ [:dependency, {ecosystem: ecosystem, name: full_name, version: parsed.version, purl: preserved_purl(purl)}]
150
250
  elsif NOISE_TYPES.include?(type)
151
251
  nil # CI actions / opaque binaries: not a package dependency
152
252
  else
@@ -161,6 +261,21 @@ module StillActive
161
261
  [:unassessable, {ecosystem: nil, name: name, reason: :malformed_purl}]
162
262
  end
163
263
 
264
+ # The input's PURL, kept for re-emission in an enriched SBOM, with Syft's
265
+ # `package-id` qualifier removed. That qualifier is Syft's internal per-location
266
+ # bookkeeping rather than part of the package's identity, and it is why the same
267
+ # package can appear twice under two different PURLs; dropping it is what lets
268
+ # those copies still collapse. Everything else is left byte-identical rather than
269
+ # re-serialized through PackageURL, so a consumer that matched the input PURL
270
+ # matches ours, with no chance of a normalization changing the encoding.
271
+ def preserved_purl(purl)
272
+ base, separator, query = purl.partition("?")
273
+ return purl if separator.empty?
274
+
275
+ kept = query.split("&").reject { |pair| pair.start_with?("package-id=") }
276
+ kept.empty? ? base : "#{base}?#{kept.join("&")}"
277
+ end
278
+
164
279
  # Whether a repository_url points at a known public registry. We only read the
165
280
  # host; we never connect to it (a lockfile/SBOM-derived URL is untrusted). An
166
281
  # unparseable or non-public host reads as private, failing safe.
@@ -3,8 +3,8 @@
3
3
  require_relative "ecosystem_lens"
4
4
  require_relative "ceiling_reconciler"
5
5
  require_relative "poison_security_correlator"
6
- require_relative "../helpers/python_helper"
7
- require_relative "../helpers/dotnet_helper"
6
+ require_relative "helpers/python_helper"
7
+ require_relative "helpers/dotnet_helper"
8
8
  require "async"
9
9
  require "async/barrier"
10
10
  require "async/semaphore"
@@ -62,7 +62,7 @@ module StillActive
62
62
  # otherwise): absent stays "unknown", never a false that reads as test-only.
63
63
  result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
64
64
  EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, runtime_ranges: runtime_ranges)
65
- .merge(dep.slice(:production))
65
+ .merge(dep.slice(:production, :direct, :dependency_path, :purl))
66
66
  rescue => e
67
67
  # One dependency's failure must not abort the audit, but it must not
68
68
  # disappear either: record it as an unassessable entry (same shape as
@@ -70,7 +70,7 @@ module StillActive
70
70
  # production rides along (when known, via slice) so a failed prod dep
71
71
  # stays distinguishable from a failed dev one.
72
72
  failures << {ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}"}
73
- .merge(dep.slice(:production))
73
+ .merge(dep.slice(:production, :direct, :dependency_path, :purl))
74
74
  warn("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
75
75
  ensure
76
76
  completed += 1
@@ -16,7 +16,7 @@ module StillActive
16
16
  # name an explicit advisory id, so a newly disclosed CVE on the same gem is
17
17
  # never pre-silenced.
18
18
  class Suppressions
19
- GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear, :poison, :language_ceiling].freeze
19
+ GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear, :poison, :language_ceiling, :deprecated].freeze
20
20
 
21
21
  Entry = Struct.new(:gem, :advisory, :signal, :reason, :expires, keyword_init: true) do
22
22
  def whole_gem?
@@ -76,14 +76,16 @@ module StillActive
76
76
  return false
77
77
  end
78
78
  if signal && !GATEABLE_SIGNALS.include?(signal)
79
- warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, libyear, or poison)"
79
+ # Listed from the constant rather than spelled out: the hand-written
80
+ # version had already drifted (it never mentioned language_ceiling).
81
+ warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected #{GATEABLE_SIGNALS.join(", ")})"
80
82
  return false
81
83
  end
82
84
  if signal == :vulnerability && advisory.nil?
83
85
  warnings << "ignoring vulnerability suppression for #{label}: must name an advisory id so newly disclosed advisories still surface"
84
86
  return false
85
87
  end
86
- if [:activity, :libyear, :poison].include?(signal) && gem.nil?
88
+ if [:activity, :libyear, :poison, :deprecated].include?(signal) && gem.nil?
87
89
  warnings << "ignoring #{signal} suppression: must name a gem"
88
90
  return false
89
91
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "3.0.0.rc5"
4
+ VERSION = "3.0.0"
5
5
  end
@@ -12,17 +12,17 @@ require_relative "forgejo_client"
12
12
  require_relative "github_client"
13
13
  require_relative "gitlab_client"
14
14
  require_relative "repository"
15
- require_relative "../helpers/activity_helper"
16
- require_relative "../helpers/alternatives_helper"
17
- require_relative "../helpers/catalog_index"
18
- require_relative "../helpers/constraint_helper"
19
- require_relative "../helpers/http_helper"
20
- require_relative "../helpers/libyear_helper"
21
- require_relative "../helpers/ruby_advisory_db"
22
- require_relative "../helpers/ruby_helper"
23
- require_relative "../helpers/runtime_ceiling_helper"
24
- require_relative "../helpers/version_helper"
25
- require_relative "../helpers/vulnerability_helper"
15
+ require_relative "helpers/activity_helper"
16
+ require_relative "helpers/alternatives_helper"
17
+ require_relative "helpers/catalog_index"
18
+ require_relative "helpers/constraint_helper"
19
+ require_relative "helpers/http_helper"
20
+ require_relative "helpers/libyear_helper"
21
+ require_relative "helpers/ruby_advisory_db"
22
+ require_relative "helpers/ruby_helper"
23
+ require_relative "helpers/runtime_ceiling_helper"
24
+ require_relative "helpers/version_helper"
25
+ require_relative "helpers/vulnerability_helper"
26
26
  require "async"
27
27
  require "async/barrier"
28
28
  require "async/semaphore"
@@ -384,11 +384,18 @@ module StillActive
384
384
  vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
385
385
  # Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
386
386
  # advisory) and the fixed-version ranges the "capped below the fix" signal needs.
387
- # Native path is rubygems.
388
- OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name)
387
+ # Native path is rubygems. The version also lets OSV confirm a deps.dev-only
388
+ # advisory applies to it; a ruby-advisory-db verdict is never overruled.
389
+ vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name, version: version)
389
390
  {
390
391
  scorecard_score: scorecard&.dig(:score),
391
392
  scorecard_maintained: scorecard&.dig(:maintained),
393
+ # The maintainer's own "stop using this" declaration, from the same
394
+ # response as the advisories. RubyGems has no deprecation mechanism, so
395
+ # this is effectively always false for gems today; it is threaded anyway
396
+ # so the field means the same thing on both paths.
397
+ deprecated: info&.dig(:deprecated) == true,
398
+ deprecation_reason: info&.dig(:deprecation_reason),
392
399
  vulnerability_count: vulnerabilities.length,
393
400
  vulnerabilities: vulnerabilities
394
401
  }