still_active 3.0.0.rc6 → 3.1.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -2
  3. data/README.md +28 -3
  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/still_active/helpers/cvss_helper.rb +29 -0
  16. data/lib/{helpers → still_active/helpers}/cyclonedx_helper.rb +85 -7
  17. data/lib/{helpers → still_active/helpers}/libyear_helper.rb +1 -1
  18. data/lib/{helpers → still_active/helpers}/markdown_helper.rb +19 -0
  19. data/lib/{helpers → still_active/helpers}/sarif_helper.rb +17 -2
  20. data/lib/still_active/helpers/sbom_graph.rb +166 -0
  21. data/lib/{helpers → still_active/helpers}/status_helper.rb +23 -5
  22. data/lib/{helpers → still_active/helpers}/terminal_helper.rb +15 -0
  23. data/lib/{helpers → still_active/helpers}/version_helper.rb +14 -2
  24. data/lib/{helpers → still_active/helpers}/vulnerability_helper.rb +4 -5
  25. data/lib/still_active/options.rb +6 -3
  26. data/lib/still_active/osv_client.rb +2 -2
  27. data/lib/still_active/poison_security_correlator.rb +3 -3
  28. data/lib/still_active/pypi_client.rb +1 -1
  29. data/lib/still_active/sarif/rules.rb +15 -0
  30. data/lib/still_active/sbom_reader.rb +117 -2
  31. data/lib/still_active/sbom_workflow.rb +4 -4
  32. data/lib/still_active/suppressions.rb +5 -3
  33. data/lib/still_active/version.rb +1 -1
  34. data/lib/still_active/workflow.rb +17 -11
  35. data/still_active.gemspec +6 -6
  36. metadata +48 -33
  37. data/lib/helpers/cvss_helper.rb +0 -63
  38. /data/lib/{helpers → still_active/helpers}/alternatives_helper.rb +0 -0
  39. /data/lib/{helpers → still_active/helpers}/ansi_helper.rb +0 -0
  40. /data/lib/{helpers → still_active/helpers}/bot_context.rb +0 -0
  41. /data/lib/{helpers → still_active/helpers}/bundler_helper.rb +0 -0
  42. /data/lib/{helpers → still_active/helpers}/catalog_index.rb +0 -0
  43. /data/lib/{helpers → still_active/helpers}/constraint_helper.rb +0 -0
  44. /data/lib/{helpers → still_active/helpers}/dependency_helper.rb +0 -0
  45. /data/lib/{helpers → still_active/helpers}/diff_markdown_helper.rb +0 -0
  46. /data/lib/{helpers → still_active/helpers}/dotnet_helper.rb +0 -0
  47. /data/lib/{helpers → still_active/helpers}/emoji_helper.rb +0 -0
  48. /data/lib/{helpers → still_active/helpers}/endoflife_helper.rb +0 -0
  49. /data/lib/{helpers → still_active/helpers}/http_helper.rb +0 -0
  50. /data/lib/{helpers → still_active/helpers}/lockfile_dependency_parser.rb +0 -0
  51. /data/lib/{helpers → still_active/helpers}/lockfile_indexer.rb +0 -0
  52. /data/lib/{helpers → still_active/helpers}/markdown_escape.rb +0 -0
  53. /data/lib/{helpers → still_active/helpers}/pep440_helper.rb +0 -0
  54. /data/lib/{helpers → still_active/helpers}/python_helper.rb +0 -0
  55. /data/lib/{helpers → still_active/helpers}/ruby_advisory_db.rb +0 -0
  56. /data/lib/{helpers → still_active/helpers}/ruby_helper.rb +0 -0
  57. /data/lib/{helpers → still_active/helpers}/runtime_ceiling_helper.rb +0 -0
  58. /data/lib/{helpers → still_active/helpers}/semver_satisfaction.rb +0 -0
  59. /data/lib/{helpers → still_active/helpers}/summary_helper.rb +0 -0
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cvss_suite"
4
+
5
+ module StillActive
6
+ # Computes a CVSS base score from a vector string, across v2/v3/v4. deps.dev stores
7
+ # only CVSS 3.x, so a CVSS-4-only advisory (the flagship protobuf case) arrives with
8
+ # no numeric score; OSV carries the v4 vector, and this turns it into the number the
9
+ # SARIF security-severity and CycloneDX rating want.
10
+ #
11
+ # Returns the BASE score, not cvss-suite's overall_score: a vector carrying threat or
12
+ # environmental metrics folds those into overall_score (E:U alone turns a 9.3 into
13
+ # an 8.1), while NVD and GHSA publish the base score. The severity band still floors
14
+ # at the authoritative GHSA label (VulnerabilityHelper.advisory_severity) because
15
+ # labels are assigned by people and can sit off the FIRST band edge; the label drives
16
+ # gating and SARIF level, the number sharpens display. Fails safe: an absent or
17
+ # unparseable vector yields nil, never a crash.
18
+ module CvssHelper
19
+ extend self
20
+
21
+ def score(vector)
22
+ return if vector.nil? || vector.to_s.empty?
23
+
24
+ CvssSuite.parse(vector.to_s).base_score
25
+ rescue CvssSuite::Error
26
+ nil
27
+ end
28
+ end
29
+ end
@@ -3,6 +3,8 @@
3
3
  require "json"
4
4
  require "digest"
5
5
  require "time"
6
+ require_relative "activity_helper"
7
+ require_relative "status_helper"
6
8
  require_relative "vulnerability_helper"
7
9
 
8
10
  module StillActive
@@ -27,7 +29,34 @@ module StillActive
27
29
  components = build_components(result, ruby_info)
28
30
  vulnerabilities = build_vulnerabilities(result)
29
31
 
30
- document = {
32
+ document = envelope(components, spec_version, tool_version, now)
33
+ document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
34
+ JSON.pretty_generate(document)
35
+ end
36
+
37
+ # The cross-ecosystem sibling of `render`, for a --sbom audit. The one thing it
38
+ # must not do is rebuild PURLs: npm scopes, maven group:artifact and Go module
39
+ # paths are all easy to reconstruct subtly wrong, and the input SBOM already
40
+ # carries the authoritative one. So each component re-emits the PURL it arrived
41
+ # with, which means a consumer (Dependency-Track being the point of this) matches
42
+ # our output exactly as it matched the input, now annotated with the maintenance
43
+ # signals it had no way to compute.
44
+ def render_sbom(result:, tool_version:, spec_version: "1.6", now: Time.now.utc)
45
+ components = result.sort_by { |key, _| key.to_s }.map { |key, data| sbom_component(key.to_s, data) }
46
+ vulnerabilities = result.sort_by { |key, _| key.to_s }.flat_map do |key, data|
47
+ ref = sbom_ref(key.to_s, data)
48
+ (data[:vulnerabilities] || []).map { |advisory| vulnerability(advisory, ref) }
49
+ end
50
+
51
+ document = envelope(components, spec_version, tool_version, now)
52
+ document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
53
+ JSON.pretty_generate(document)
54
+ end
55
+
56
+ private
57
+
58
+ def envelope(components, spec_version, tool_version, now)
59
+ {
31
60
  "bomFormat" => "CycloneDX",
32
61
  "specVersion" => spec_version,
33
62
  "serialNumber" => deterministic_serial(components),
@@ -38,11 +67,30 @@ module StillActive
38
67
  },
39
68
  "components" => components
40
69
  }
41
- document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
42
- JSON.pretty_generate(document)
43
70
  end
44
71
 
45
- private
72
+ # The input's own PURL identifies the component. It is always present for an
73
+ # assessed dependency (SbomReader routes a component with no PURL to
74
+ # `unassessable`, so it never reaches an assessment), but the composite result
75
+ # key stands in defensively rather than emitting a component with no identity.
76
+ def sbom_ref(key, data)
77
+ data[:purl] || key
78
+ end
79
+
80
+ def sbom_component(key, data)
81
+ ref = sbom_ref(key, data)
82
+ component = {"type" => "library", "name" => data[:name] || key}
83
+ component["version"] = data[:version_used] if data[:version_used]
84
+ component["bom-ref"] = ref
85
+ component["purl"] = data[:purl] if data[:purl]
86
+ component["licenses"] = licenses(data[:license]) if data[:license]
87
+ if data[:repository_url]
88
+ component["externalReferences"] = [{"type" => "vcs", "url" => data[:repository_url]}]
89
+ end
90
+ properties = sbom_properties(data)
91
+ component["properties"] = properties unless properties.empty?
92
+ component
93
+ end
46
94
 
47
95
  def build_components(result, ruby_info)
48
96
  components = result.sort_by { |name, _| name.to_s }.map { |name, data| gem_component(name.to_s, data) }
@@ -89,14 +137,44 @@ module StillActive
89
137
  license.split(", ").map { |id| {"license" => {"id" => id}} }
90
138
  end
91
139
 
92
- def gem_properties(data)
140
+ # Signals both paths compute. `status` is the folded verdict a consumer would
141
+ # otherwise have to re-derive, which is the whole reason for emitting an
142
+ # enriched SBOM rather than the input one.
143
+ def shared_properties(data)
93
144
  {
145
+ "still_active:status" => StatusHelper.gem_status(data).to_s,
146
+ # The raw activity level alongside the folded verdict. `status` answers
147
+ # "what should I do about this"; activity_level answers "how dormant is
148
+ # it", and they deliberately disagree: a deprecated package that still
149
+ # ships releases is status deprecated but activity ok. A consumer
150
+ # thresholding on recency wants the second, not the first.
151
+ "still_active:activity_level" => ActivityHelper.activity_level(data).to_s,
94
152
  "still_active:archived" => boolean_property(data[:archived]),
153
+ "still_active:deprecated" => boolean_property(data[:deprecated]),
154
+ "still_active:deprecation_reason" => data[:deprecation_reason],
95
155
  "still_active:scorecard_score" => data[:scorecard_score]&.to_s,
96
156
  "still_active:libyear" => data[:libyear]&.to_s,
97
- "still_active:last_commit_date" => iso8601(data[:last_commit_date]),
157
+ "still_active:last_commit_date" => iso8601(data[:last_commit_date])
158
+ }
159
+ end
160
+
161
+ def to_properties(hash)
162
+ hash.filter_map { |name, value| {"name" => name, "value" => value} unless value.nil? }
163
+ end
164
+
165
+ def gem_properties(data)
166
+ to_properties(shared_properties(data).merge(
98
167
  "still_active:version_yanked" => boolean_property(data[:version_yanked])
99
- }.filter_map { |name, value| {"name" => name, "value" => value} unless value.nil? }
168
+ ))
169
+ end
170
+
171
+ # Cross-ecosystem extras: which ecosystem the package came from, and whether it
172
+ # is one the project declared (both meaningless on the Ruby-only native path).
173
+ def sbom_properties(data)
174
+ to_properties(shared_properties(data).merge(
175
+ "still_active:ecosystem" => data[:ecosystem]&.to_s,
176
+ "still_active:direct" => boolean_property(data[:direct])
177
+ ))
100
178
  end
101
179
 
102
180
  # The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../still_active/core_ext"
3
+ require_relative "../core_ext"
4
4
 
5
5
  module StillActive
6
6
  module LibyearHelper
@@ -118,6 +118,25 @@ module StillActive
118
118
  lines.join("\n")
119
119
  end
120
120
 
121
+ # Packages whose maintainer has declared them deprecated. Its own section
122
+ # rather than a table column because the message is the actionable part: it
123
+ # usually names the successor, and it is the maintainer speaking rather than
124
+ # anything still_active inferred.
125
+ def deprecated_section(result)
126
+ flagged = result.select { |_name, data| data[:deprecated] }
127
+ return "" if flagged.empty?
128
+
129
+ lines = ["", "**Deprecated by their maintainers** (no further fixes, including security patches):"]
130
+ flagged.sort_by { |name, _| name }.each do |name, data|
131
+ reason = data[:deprecation_reason]
132
+ # The message is registry-supplied free text in a bullet list, and commonly
133
+ # contains a URL, so it goes through the inline escaper.
134
+ note = reason ? ": #{MarkdownEscape.inline(reason)}" : ""
135
+ lines << "- #{MarkdownEscape.code_span(DependencyHelper.identity(name, data))}#{note}"
136
+ end
137
+ lines.join("\n")
138
+ end
139
+
121
140
  # A dormant dep that caps your tree below its latest major (the poison-pill
122
141
  # signal). Consistent with the other finding sections: worst caps + total, the
123
142
  # direct parent named for a transitive pill. The full cap list is in the JSON.
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
  require "digest"
5
5
  require "time"
6
- require_relative "../still_active/sarif/rules"
6
+ require_relative "../sarif/rules"
7
7
  require_relative "lockfile_indexer"
8
8
  require_relative "activity_helper"
9
9
  require_relative "dependency_helper"
@@ -188,6 +188,17 @@ module StillActive
188
188
  out << mark_suppressed(result("SA007", name, "#{display} #{version}: this version has been yanked from RubyGems.", location), display, :yanked)
189
189
  end
190
190
 
191
+ if data[:deprecated]
192
+ # Fires independently of the activity signals on purpose: a package
193
+ # deprecated last month with a release last week looks perfectly healthy by
194
+ # dates, which is exactly the case recency-based tooling cannot see. The
195
+ # maintainer's own message is carried through when they left one, since it
196
+ # usually names the successor.
197
+ reason = data[:deprecation_reason]
198
+ detail = reason ? " Maintainer's note: #{reason}" : ""
199
+ out << mark_suppressed(result("SA010", name, "#{display} #{version}: deprecated by its maintainer.#{detail}", location), display, :deprecated)
200
+ end
201
+
191
202
  if data[:poison] && !Array(data[:constraints]).empty?
192
203
  # Level tracks the poison tier (critical->error, ...); a plain majors-behind
193
204
  # tier change keeps the (rule_id, gem_name) fingerprint so it doesn't re-alert.
@@ -397,9 +408,13 @@ module StillActive
397
408
  Digest::SHA256.hexdigest(["v1", rule_id, gem_name, advisory_id].compact.join("|"))[0, 16]
398
409
  end
399
410
 
411
+ # EOL dates are calendar dates, not instants: endoflife.date publishes
412
+ # "2025-03-31" and Time.parse reads it as local midnight. Shifting that to UTC
413
+ # rewinds it a day for every user east of UTC, so render the date as parsed,
414
+ # matching the markdown and terminal output.
400
415
  def format_date(value)
401
416
  t = ActivityHelper.parse_time(value)
402
- t ? t.utc.strftime("%Y-%m-%d") : value.to_s
417
+ t ? t.strftime("%Y-%m-%d") : value.to_s
403
418
  end
404
419
 
405
420
  def repo_suffix(data)
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Direct-vs-transitive reasoning over a CycloneDX `dependencies` graph.
5
+ #
6
+ # The native Bundler audit knows which gems you declared and, for the rest, the
7
+ # shortest path back to the declared gem that pulls them in, which is what turns
8
+ # an un-actionable transitive finding into "replace your direct dep A". The SBOM
9
+ # path had no equivalent: every package read the same, whether you chose it or it
10
+ # arrived six levels down. CycloneDX carries the graph to answer this; still_active
11
+ # simply never read it.
12
+ #
13
+ # This works purely in bom-refs. Turning a ref into an `ecosystem/name` identity
14
+ # is the reader's job, so this stays a graph problem with no purl knowledge.
15
+ #
16
+ # Two real generator shapes have to work, both captured from actual output rather
17
+ # than assumed:
18
+ #
19
+ # Trivy: `metadata.component` is an application, whose children are one
20
+ # application per manifest found (Gemfile.lock, package-lock.json), whose
21
+ # children are the libraries that manifest declares. bom-refs are UUIDs.
22
+ # Syft: `metadata.component` is a `file` that is not a graph node at all. The
23
+ # scanned project appears instead as an ordinary library component with
24
+ # no incoming edge. bom-refs are purls.
25
+ #
26
+ # So "direct" can be neither "child of metadata.component" nor "one hop from the
27
+ # root". What holds for both: start at the graph's entry points, descend through
28
+ # any non-library scaffolding, and the FIRST library reached on each path is a
29
+ # declared dependency.
30
+ module SbomGraph
31
+ extend self
32
+
33
+ # => { ref => {direct: bool, path: [ref, ...] | nil} }, or nil when this SBOM
34
+ # cannot answer the question. nil is not "nothing is direct": callers must omit
35
+ # the fields entirely, because emitting `direct: false` everywhere would be the
36
+ # positive claim "none of these are yours" about a document that never said.
37
+ def resolve(dependencies:, root_ref:, library_refs:)
38
+ edges = edges_from(dependencies)
39
+ return if edges.empty?
40
+
41
+ entries = entry_points(edges, root_ref)
42
+ return if entries.empty?
43
+
44
+ direct = direct_libraries(edges, entries, library_refs)
45
+ return if direct.empty?
46
+
47
+ paths = shortest_paths(edges, direct, library_refs)
48
+ resolved = {}
49
+ direct.each { |ref| resolved[ref] = {direct: true, path: nil} }
50
+ paths.each { |ref, path| resolved[ref] ||= {direct: false, path: path} }
51
+ resolved
52
+ end
53
+
54
+ # The library components that are the scanned project(s) rather than
55
+ # dependencies of one. A Syft SBOM lists the project as an ordinary library
56
+ # with a purl, indistinguishable from a dependency by shape alone, so
57
+ # still_active audited the user's own code and reported it as critically
58
+ # stale (it has no registry entry, so it looks abandoned).
59
+ #
60
+ # The graph tells them apart: the project is what nothing depends on. The rule
61
+ # is deliberately conservative on both halves. It requires an OUTGOING edge, so
62
+ # a dependency whose parent edge the generator simply failed to record stays a
63
+ # dependency rather than vanishing from the audit, and it only ever considers
64
+ # libraries, so Trivy's application-typed root and manifest nodes (never
65
+ # dependencies to begin with) are not reported here. A merged SBOM covering
66
+ # several projects yields all of them.
67
+ def project_refs(dependencies:, library_refs:)
68
+ edges = edges_from(dependencies)
69
+ return Set.new if edges.empty?
70
+
71
+ pointed_at = Set.new
72
+ edges.each_value { |children| pointed_at.merge(children) }
73
+
74
+ edges.each_with_object(Set.new) do |(ref, children), roots|
75
+ next unless library_refs.include?(ref)
76
+ next if pointed_at.include?(ref)
77
+ next if children.empty?
78
+
79
+ roots << ref
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # {ref => [child refs]}, skipping anything malformed. A generator is free to
86
+ # emit an entry with no dependsOn; that is a leaf, not an error.
87
+ def edges_from(dependencies)
88
+ return {} unless dependencies.is_a?(Array)
89
+
90
+ dependencies.each_with_object({}) do |entry, edges|
91
+ next unless entry.is_a?(Hash)
92
+
93
+ ref = entry["ref"]
94
+ next unless ref.is_a?(String)
95
+
96
+ children = entry["dependsOn"]
97
+ edges[ref] = children.is_a?(Array) ? children.grep(String) : []
98
+ end
99
+ end
100
+
101
+ # Where a walk can start: the declared root when it is actually a node, plus
102
+ # every node nothing points at. The second clause is what rescues the Syft
103
+ # shape, where the declared root is a file that never appears in the graph and
104
+ # the project node is simply parentless.
105
+ def entry_points(edges, root_ref)
106
+ pointed_at = Set.new
107
+ edges.each_value { |children| pointed_at.merge(children) }
108
+
109
+ entries = edges.keys.reject { |ref| pointed_at.include?(ref) }
110
+ entries << root_ref if root_ref.is_a?(String) && edges.key?(root_ref) && !entries.include?(root_ref)
111
+ entries
112
+ end
113
+
114
+ # Descend from the entry points through non-library scaffolding (Trivy's
115
+ # per-manifest application nodes) and collect the first library on each path.
116
+ # The entry points themselves are never direct: they are the project, or a
117
+ # manifest, and in the Syft shape the project node IS a library.
118
+ def direct_libraries(edges, entries, library_refs)
119
+ direct = Set.new
120
+ seen = Set.new(entries)
121
+ queue = entries.dup
122
+
123
+ until queue.empty?
124
+ (edges[queue.shift] || []).each do |child|
125
+ next if seen.include?(child)
126
+
127
+ seen << child
128
+ if library_refs.include?(child)
129
+ direct << child
130
+ else
131
+ queue << child
132
+ end
133
+ end
134
+ end
135
+
136
+ direct
137
+ end
138
+
139
+ # BFS over library edges from the direct set, so each transitive package gets
140
+ # the shortest chain back to the declared dependency that pulls it in, head
141
+ # first. Same contract as the native path's dependency_path.
142
+ def shortest_paths(edges, direct, library_refs)
143
+ paths = {}
144
+ queue = []
145
+ direct.each do |ref|
146
+ paths[ref] = [ref]
147
+ queue << ref
148
+ end
149
+
150
+ until queue.empty?
151
+ ref = queue.shift
152
+ (edges[ref] || []).each do |child|
153
+ next unless library_refs.include?(child)
154
+ next if paths.key?(child)
155
+
156
+ paths[child] = paths[ref] + [child]
157
+ queue << child
158
+ end
159
+ end
160
+
161
+ # The direct set seeded this BFS, so drop it: a declared dependency carries
162
+ # no path, matching the native path's contract.
163
+ paths.except(*direct)
164
+ end
165
+ end
166
+ end
@@ -19,9 +19,14 @@ module StillActive
19
19
  # dormant or archived gem carrying an unpatched advisory is :dead (no one is
20
20
  # going to fix it, migrate). :unknown is least severe -- an absence, not a
21
21
  # finding -- so missing data never reads as :ok.
22
- SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :vulnerable, :dead].freeze
22
+ # :deprecated sits above :archived and below :vulnerable. An archived repo is
23
+ # circumstantial (development may simply have moved), whereas a deprecation is
24
+ # the maintainer explicitly telling you to stop using the package, frequently
25
+ # naming the replacement. A live vulnerability is still the more urgent thing.
26
+ SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :deprecated, :vulnerable, :dead].freeze
23
27
 
24
- # Returns :dead, :vulnerable, :archived, :legacy, :stale, :ok, or :unknown.
28
+ # Returns :dead, :vulnerable, :deprecated, :archived, :legacy, :stale, :ok,
29
+ # or :unknown.
25
30
  def gem_status(gem_data)
26
31
  vulnerable = gem_data[:vulnerability_count].to_i.positive?
27
32
 
@@ -33,13 +38,26 @@ module StillActive
33
38
  return :unknown if gem_data[:version_unresolved] && !vulnerable
34
39
 
35
40
  level = ActivityHelper.activity_level(gem_data)
41
+ # The maintainer's own declaration that this package should no longer be
42
+ # used. Unlike every other input here it is a stated fact rather than a date
43
+ # heuristic, so it is read before the activity level rather than blended
44
+ # with it.
45
+ deprecated = gem_data[:deprecated] == true
36
46
 
37
47
  if vulnerable
38
- # A vulnerability in a dormant or archived gem won't be patched -> :dead;
39
- # in an actively-released gem a fix is plausible -> :vulnerable.
40
- return [:critical, :archived].include?(level) ? :dead : :vulnerable
48
+ # A vulnerability in a dormant, archived or deprecated gem won't be patched
49
+ # -> :dead; in an actively-released gem a fix is plausible -> :vulnerable.
50
+ # Deprecation counts even on a gem still shipping releases: the maintainer
51
+ # has said to stop using it, so waiting for the patch is not a plan.
52
+ return (deprecated || [:critical, :archived].include?(level)) ? :dead : :vulnerable
41
53
  end
42
54
 
55
+ # Deliberately ahead of the :critical -> :legacy branch below. :legacy means
56
+ # "long-dormant but done, low risk", and a deprecated package is the exact
57
+ # case that must not be filed there: left-pad is dormant AND clean AND its
58
+ # maintainer says to use String.prototype.padStart() instead.
59
+ return :deprecated if deprecated
60
+
43
61
  case level
44
62
  when :archived
45
63
  # archived != EOL: a repo archived while the gem still publishes recent
@@ -160,9 +160,24 @@ module StillActive
160
160
  # A language-runtime ceiling is orthogonal to poison/alternatives (a gem can
161
161
  # be maintained yet still cap your Ruby), so it always gets its own line.
162
162
  lines << language_ceiling_line(data)
163
+ # So is a deprecation, and more so: it is the maintainer's own statement
164
+ # rather than anything derived from the row above it, and a deprecated gem
165
+ # can look entirely healthy in every other column.
166
+ lines << deprecation_line(data)
163
167
  lines.compact
164
168
  end
165
169
 
170
+ # " ↳ deprecated by maintainer: <their message>". Red, because a deprecation
171
+ # is an instruction to stop using the package, and the message usually names
172
+ # the replacement, so it is the most actionable text on the row.
173
+ def deprecation_line(data)
174
+ return unless data[:deprecated]
175
+
176
+ reason = data[:deprecation_reason]
177
+ text = reason ? "deprecated by maintainer: #{reason}" : "deprecated by maintainer"
178
+ " #{AnsiHelper.red("\u21B3 #{text}")}"
179
+ end
180
+
166
181
  # " ↳ ruby ceiling: <receipt>" (or "python ceiling"), coloured by tier (red =
167
182
  # strands you on an EOL runtime, dim = an FYI cap below the latest stable).
168
183
  # Reuses poison_colour; the runtime name comes off the finding, not hardcoded.
@@ -104,8 +104,20 @@ module StillActive
104
104
  # SPDX license identifier(s) from the RubyGems versions payload.
105
105
  # Comma-joined when a gem declares more than one. nil when unknown.
106
106
  def license(version_hash:)
107
- licenses = version_hash&.dig("licenses")
108
- return if licenses.nil? || licenses.empty?
107
+ format_licenses(version_hash&.dig("licenses"))
108
+ end
109
+
110
+ # The shared rendering both paths join through, so a licence reads the same
111
+ # whether it came from RubyGems (native) or deps.dev (--sbom). Both serve an
112
+ # array of SPDX strings, and a single element may itself be an expression
113
+ # ("Apache-2.0 OR MIT"), which passes through untouched. nil when unknown,
114
+ # never an empty string, so a missing licence can't render as a present blank.
115
+ def format_licenses(licenses)
116
+ # Array() so a drifted feed serving a bare string instead of an array can't
117
+ # raise here. This runs inside the per-dependency assessment, so an exception
118
+ # would cost that dependency its entire verdict over a cosmetic field.
119
+ licenses = Array(licenses)
120
+ return if licenses.empty?
109
121
 
110
122
  licenses.join(", ")
111
123
  end
@@ -23,11 +23,10 @@ module StillActive
23
23
  # One advisory's severity label ("low".."critical"), or nil when genuinely unscored.
24
24
  # The authoritative GHSA/OSV label is a FLOOR: a real CVSS number sharpens precision
25
25
  # and can RAISE the band, but never lowers it below the label. This matters because
26
- # the OSV-derived score is cvss-suite's overall_score, which folds in any
27
- # threat/environmental metrics a vector carries (and can diverge from GHSA at a band
28
- # edge) -- on a fail-closed, no-false-positive tool a computed number must not
29
- # silently demote a GHSA-HIGH finding out of a severity gate. With only one of the
30
- # two present, that one stands.
26
+ # GHSA labels are assigned by people and can sit off the FIRST band edge of the
27
+ # computed base score -- on a fail-closed, no-false-positive tool a computed
28
+ # number must not silently demote a GHSA-HIGH finding out of a severity gate.
29
+ # With only one of the two present, that one stands.
31
30
  def advisory_severity(vulnerability)
32
31
  from_score = (score = effective_score(vulnerability)) ? severity_label(score) : nil
33
32
  from_label = OSV_LABELS[vulnerability[:osv_severity].to_s.downcase]
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
- require_relative "../helpers/constraint_helper"
5
- require_relative "../helpers/cyclonedx_helper"
6
- require_relative "../helpers/vulnerability_helper"
4
+ require_relative "helpers/constraint_helper"
5
+ require_relative "helpers/cyclonedx_helper"
6
+ require_relative "helpers/vulnerability_helper"
7
7
 
8
8
  module StillActive
9
9
  class Options
@@ -147,6 +147,9 @@ module StillActive
147
147
  opts.on("--fail-if-critical", "Exit 1 if any gem has critical activity warning") do
148
148
  StillActive.config { |config| config.fail_if_critical = true }
149
149
  end
150
+ opts.on("--fail-if-deprecated", "Exit 1 if any dependency's maintainer has deprecated it") do
151
+ StillActive.config { |config| config.fail_if_deprecated = true }
152
+ end
150
153
  opts.on("--fail-if-warning", "Exit 1 if any gem has warning or critical activity warning") do
151
154
  StillActive.config { |config| config.fail_if_warning = true }
152
155
  end
@@ -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.
@@ -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