still_active 3.0.0.rc6 → 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 +39 -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 +19 -7
  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 +2 -2
  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 +17 -11
  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
@@ -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.rc6"
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"
@@ -390,6 +390,12 @@ module StillActive
390
390
  {
391
391
  scorecard_score: scorecard&.dig(:score),
392
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),
393
399
  vulnerability_count: vulnerabilities.length,
394
400
  vulnerabilities: vulnerabilities
395
401
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.rc6
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -133,38 +133,6 @@ files:
133
133
  - LICENSE.txt
134
134
  - README.md
135
135
  - bin/still_active
136
- - lib/helpers/activity_helper.rb
137
- - lib/helpers/alternatives_helper.rb
138
- - lib/helpers/ansi_helper.rb
139
- - lib/helpers/bot_context.rb
140
- - lib/helpers/bundler_helper.rb
141
- - lib/helpers/catalog_index.rb
142
- - lib/helpers/constraint_helper.rb
143
- - lib/helpers/cvss_helper.rb
144
- - lib/helpers/cyclonedx_helper.rb
145
- - lib/helpers/dependency_helper.rb
146
- - lib/helpers/diff_markdown_helper.rb
147
- - lib/helpers/dotnet_helper.rb
148
- - lib/helpers/emoji_helper.rb
149
- - lib/helpers/endoflife_helper.rb
150
- - lib/helpers/http_helper.rb
151
- - lib/helpers/libyear_helper.rb
152
- - lib/helpers/lockfile_dependency_parser.rb
153
- - lib/helpers/lockfile_indexer.rb
154
- - lib/helpers/markdown_escape.rb
155
- - lib/helpers/markdown_helper.rb
156
- - lib/helpers/pep440_helper.rb
157
- - lib/helpers/python_helper.rb
158
- - lib/helpers/ruby_advisory_db.rb
159
- - lib/helpers/ruby_helper.rb
160
- - lib/helpers/runtime_ceiling_helper.rb
161
- - lib/helpers/sarif_helper.rb
162
- - lib/helpers/semver_satisfaction.rb
163
- - lib/helpers/status_helper.rb
164
- - lib/helpers/summary_helper.rb
165
- - lib/helpers/terminal_helper.rb
166
- - lib/helpers/version_helper.rb
167
- - lib/helpers/vulnerability_helper.rb
168
136
  - lib/still_active.rb
169
137
  - lib/still_active/artifactory_client.rb
170
138
  - lib/still_active/ceiling_reconciler.rb
@@ -181,6 +149,39 @@ files:
181
149
  - lib/still_active/forgejo_client.rb
182
150
  - lib/still_active/github_client.rb
183
151
  - lib/still_active/gitlab_client.rb
152
+ - lib/still_active/helpers/activity_helper.rb
153
+ - lib/still_active/helpers/alternatives_helper.rb
154
+ - lib/still_active/helpers/ansi_helper.rb
155
+ - lib/still_active/helpers/bot_context.rb
156
+ - lib/still_active/helpers/bundler_helper.rb
157
+ - lib/still_active/helpers/catalog_index.rb
158
+ - lib/still_active/helpers/constraint_helper.rb
159
+ - lib/still_active/helpers/cvss_helper.rb
160
+ - lib/still_active/helpers/cyclonedx_helper.rb
161
+ - lib/still_active/helpers/dependency_helper.rb
162
+ - lib/still_active/helpers/diff_markdown_helper.rb
163
+ - lib/still_active/helpers/dotnet_helper.rb
164
+ - lib/still_active/helpers/emoji_helper.rb
165
+ - lib/still_active/helpers/endoflife_helper.rb
166
+ - lib/still_active/helpers/http_helper.rb
167
+ - lib/still_active/helpers/libyear_helper.rb
168
+ - lib/still_active/helpers/lockfile_dependency_parser.rb
169
+ - lib/still_active/helpers/lockfile_indexer.rb
170
+ - lib/still_active/helpers/markdown_escape.rb
171
+ - lib/still_active/helpers/markdown_helper.rb
172
+ - lib/still_active/helpers/pep440_helper.rb
173
+ - lib/still_active/helpers/python_helper.rb
174
+ - lib/still_active/helpers/ruby_advisory_db.rb
175
+ - lib/still_active/helpers/ruby_helper.rb
176
+ - lib/still_active/helpers/runtime_ceiling_helper.rb
177
+ - lib/still_active/helpers/sarif_helper.rb
178
+ - lib/still_active/helpers/sbom_graph.rb
179
+ - lib/still_active/helpers/semver_satisfaction.rb
180
+ - lib/still_active/helpers/status_helper.rb
181
+ - lib/still_active/helpers/summary_helper.rb
182
+ - lib/still_active/helpers/terminal_helper.rb
183
+ - lib/still_active/helpers/version_helper.rb
184
+ - lib/still_active/helpers/vulnerability_helper.rb
184
185
  - lib/still_active/options.rb
185
186
  - lib/still_active/osv_client.rb
186
187
  - lib/still_active/poison_security_correlator.rb