still_active 1.6.0 → 3.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. metadata +72 -11
@@ -13,6 +13,9 @@ module StillActive
13
13
  extend self
14
14
 
15
15
  STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
16
+ # Requirement operators whose safe versions are OLDER than the flaw, i.e. not a
17
+ # forward fix a consumer can upgrade to. Anything else ("> X" etc.) is.
18
+ OLDER_THAN_FLAW_OPERATORS = ["<", "<="].freeze
16
19
 
17
20
  # bundler-audit's Database#check_gem expects an object responding to
18
21
  # #name and #version (a Gem::Version).
@@ -66,12 +69,32 @@ module StillActive
66
69
  cvss3_score: details[:cvss_v3],
67
70
  cvss3_vector: nil,
68
71
  cvss2_score: details[:cvss_v2],
72
+ # No safe version a consumer can upgrade TO: the correlation bundler-audit
73
+ # + `bundle outdated` can't produce alone. A factual read of the DB.
74
+ no_fix_available: no_forward_fix?(advisory),
69
75
  source: "ruby-advisory-db",
70
76
  }
71
77
  end
72
78
 
73
79
  private
74
80
 
81
+ # True when the advisory records no version the consumer can move forward to.
82
+ # Mirrors bundler-audit's own `!patched? && !unaffected?` rather than only the
83
+ # patched half: a clean release shipped AFTER a backdoored/yanked version is
84
+ # recorded in unaffected_versions as a "> X" range, not in patched_versions
85
+ # (the CVE-2019-15224 bootstrap-sass pattern). Be conservative -- only a purely
86
+ # older-than-the-flaw safe range ("< X") counts as no-forward-fix -- so we never
87
+ # claim "no fix" while a later safe release exists.
88
+ def no_forward_fix?(advisory)
89
+ return false unless advisory.patched_versions.empty?
90
+
91
+ advisory.unaffected_versions.all? { |requirement| older_than_flaw_only?(requirement) }
92
+ end
93
+
94
+ def older_than_flaw_only?(requirement)
95
+ requirement.requirements.all? { |operator, _| OLDER_THAN_FLAW_OPERATORS.include?(operator) }
96
+ end
97
+
75
98
  # nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
76
99
  # has nothing to match in the advisory DB, so the caller returns [].
77
100
  def parse_version(version)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "bundler_helper"
5
+ require_relative "endoflife_helper"
4
6
  require_relative "http_helper"
5
7
  require_relative "libyear_helper"
6
8
 
@@ -23,15 +25,15 @@ module StillActive
23
25
  return if latest_cycle.nil?
24
26
 
25
27
  latest_version = latest_cycle["latest"]
26
- latest_release_date = parse_date(latest_cycle["releaseDate"])
27
- current_release_date = parse_date(current_cycle&.dig("releaseDate"))
28
+ latest_release_date = EndoflifeHelper.parse_date(latest_cycle["releaseDate"])
29
+ current_release_date = EndoflifeHelper.parse_date(current_cycle&.dig("releaseDate"))
28
30
  eol_value = current_cycle&.dig("eol")
29
31
 
30
32
  {
31
33
  version: current,
32
34
  release_date: current_release_date,
33
- eol_date: parse_eol(eol_value),
34
- eol: eol_reached?(eol_value),
35
+ eol_date: EndoflifeHelper.parse_eol(eol_value),
36
+ eol: EndoflifeHelper.eol_reached?(eol_value),
35
37
  latest_version: latest_version,
36
38
  latest_release_date: latest_release_date,
37
39
  libyear: LibyearHelper.gem_libyear(
@@ -41,6 +43,14 @@ module StillActive
41
43
  }
42
44
  end
43
45
 
46
+ # The runtime facts a per-gem language ceiling is measured against, sourced
47
+ # from the shared endoflife.date support-window builder. Ruby only picks the
48
+ # feed; the ecosystem-neutral logic (support floor, latest stable, grace
49
+ # window, cycle normalization) lives in EndoflifeHelper.
50
+ def supported_ruby_range
51
+ EndoflifeHelper.support_window(feed_path: "/api/ruby.json")
52
+ end
53
+
44
54
  private
45
55
 
46
56
  def current_ruby_version
@@ -48,10 +58,10 @@ module StillActive
48
58
  end
49
59
 
50
60
  def lockfile_ruby_version
51
- gemfile = ENV["BUNDLE_GEMFILE"]
52
- return unless gemfile
53
-
54
- lockfile_path = "#{gemfile}.lock"
61
+ # Use the configured gemfile (honours --gemfile) rather than the ambient
62
+ # BUNDLE_GEMFILE, which only happened to work when BundlerHelper set it as
63
+ # a side-effect. Refs #42.
64
+ lockfile_path = BundlerHelper.lockfile_path_for(File.expand_path(StillActive.config.gemfile_path))
55
65
  return unless File.exist?(lockfile_path)
56
66
 
57
67
  content = File.read(lockfile_path)
@@ -67,25 +77,5 @@ module StillActive
67
77
  major_minor = version.split(".")[0..1].join(".")
68
78
  cycles.find { |c| c["cycle"] == major_minor }
69
79
  end
70
-
71
- def parse_date(date_string)
72
- return if date_string.nil?
73
-
74
- Time.parse(date_string)
75
- end
76
-
77
- def parse_eol(value)
78
- case value
79
- when String then parse_date(value)
80
- end
81
- end
82
-
83
- def eol_reached?(value)
84
- case value
85
- when true then true
86
- when false then false
87
- when String then Time.parse(value) <= Time.now
88
- end
89
- end
90
80
  end
91
81
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constraint_helper"
4
+
5
+ module StillActive
6
+ # The language-runtime sibling of the poison-pill signal, kept deliberately
7
+ # generic: it reads a constraint a package declares on its RUNTIME (the version
8
+ # range it can run on) and, given that runtime's support window, answers how much
9
+ # the constraint holds you back. Ruby (`ruby_version`) is the first consumer, but
10
+ # nothing here is Ruby-specific: the same core serves any runtime with an
11
+ # end-of-life calendar (Python's `requires_python`, etc.). The runtime-specific
12
+ # parts -- where the constraint string comes from, and how the support window is
13
+ # built -- live in the calibration layer (RubyHelper.supported_ruby_range) and
14
+ # the workflow boundary, exactly as poison keeps ConstraintHelper generic and
15
+ # pushes ecosystem resolution to the lens.
16
+ #
17
+ # Two tiers, mirroring the severity model:
18
+ # - EOL-forced (critical): the constraint admits no still-supported runtime,
19
+ # stranding you on an end-of-life release with no security patches. A genuine
20
+ # upgrade blocker (e.g. a gem's `ruby_version < 3.2` caps at the EOL Ruby 3.1).
21
+ # - latest-not-yet (note): runs on a supported runtime but caps below the latest
22
+ # stable. An FYI ceiling to plan around, or a place to contribute support for
23
+ # the newest release before you invest.
24
+ #
25
+ # A bare floor (`>= 3.1`) or a requires-newer constraint (`>= 4.1`, `~> 5.0`) is
26
+ # NOT a ceiling: it raises the minimum, it doesn't cap you onto a dead release.
27
+ # The distinction is drawn against the live EOL cycles, not the operator alone.
28
+ module RuntimeCeilingHelper
29
+ extend self
30
+
31
+ # Gem::Requirement caps input via a regex; a registry-derived string could be
32
+ # pathological. Bound it up front like ConstraintHelper does.
33
+ MAX_REQUIREMENT_LENGTH = 256
34
+
35
+ # => { requirement:, eol_forced:, severity:, ... } or nil when there's no
36
+ # ceiling. `support_window` is a { oldest_supported:, latest_stable:, cycles: }
37
+ # hash of Gem::Versions plus normalized EOL cycles (see supported_ruby_range).
38
+ def analyze(requirement:, support_window:)
39
+ return if support_window.nil?
40
+
41
+ req = safe_requirement(requirement)
42
+ return if req.nil? || !capping?(req)
43
+
44
+ supported_allowed = support_window[:cycles].reject { |c| c[:eol] }.select { |c| req.satisfied_by?(c[:version]) }
45
+
46
+ if supported_allowed.empty?
47
+ eol_forced_finding(req, requirement, support_window)
48
+ elsif !support_window[:latest_stable].nil? && !req.satisfied_by?(support_window[:latest_stable]) && !support_window[:latest_stable_fresh]
49
+ # Runs on a supported runtime but not the latest stable. Suppressed while
50
+ # the latest stable is still within its grace window (see supported_ruby_
51
+ # range): right after a runtime ships, "doesn't support it yet" indicts the
52
+ # release calendar, not the gem. After the window it is a real note.
53
+ latest_not_yet_finding(requirement, support_window)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # A ceiling exists only if the constraint has an upper bound. A pure lower
60
+ # bound (`>`, `>=`) can never strand you on an old release, so skip it before
61
+ # any cycle math (this is also what keeps `>= 4.1` requires-newer out). Exact
62
+ # pins (`=`) are excluded deliberately: pinning a runtime to an exact patch is
63
+ # pathological (unheard of in practice) and can't be matched against the
64
+ # major.minor EOL cycles anyway, so we don't fabricate support for it.
65
+ CAPPING_OPERATORS = ["<", "<=", "~>"].freeze
66
+
67
+ def capping?(req)
68
+ req.requirements.any? { |operator, _version| CAPPING_OPERATORS.include?(operator) }
69
+ end
70
+
71
+ def eol_forced_finding(req, requirement, support_window)
72
+ # No supported release is admitted. Only a genuine cap (admits some EOL
73
+ # release) is EOL-forced; a requires-newer floor admits nothing at or below
74
+ # the oldest supported and is not our concern.
75
+ allowed_eol = support_window[:cycles].select { |c| c[:eol] && req.satisfied_by?(c[:version]) }
76
+ return if allowed_eol.empty?
77
+
78
+ ceiling = allowed_eol.max_by { |c| c[:version] }
79
+ finding = {
80
+ requirement: requirement,
81
+ eol_forced: true,
82
+ ceiling_version: ceiling[:version].to_s,
83
+ ceiling_eol_date: ceiling[:eol_date],
84
+ oldest_supported: support_window[:oldest_supported].to_s,
85
+ latest_stable: support_window[:latest_stable].to_s,
86
+ }
87
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
88
+ end
89
+
90
+ def latest_not_yet_finding(requirement, support_window)
91
+ finding = {
92
+ requirement: requirement,
93
+ eol_forced: false,
94
+ oldest_supported: support_window[:oldest_supported].to_s,
95
+ latest_stable: support_window[:latest_stable].to_s,
96
+ }
97
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
98
+ end
99
+
100
+ # Registries render a floor+ceiling requirement as a single comma-joined string
101
+ # (e.g. ">= 2.5, < 3.2" -- common for `ruby_version`/`required_ruby_version`).
102
+ # Gem::Requirement.new can't parse that one-string form (it raises), so split
103
+ # into clauses and splat, preserving BOTH bounds. Reading only the string as-is
104
+ # would drop the `< 3.2` ceiling and silently miss the very case this exists to
105
+ # catch. Best-effort: a genuinely malformed clause degrades to nil (no finding),
106
+ # never a raised exception that could break the core audit.
107
+ def safe_requirement(requirement)
108
+ return if requirement.nil? || requirement.to_s.length > MAX_REQUIREMENT_LENGTH
109
+
110
+ clauses = requirement.to_s.split(",").map(&:strip).reject(&:empty?)
111
+ return if clauses.empty?
112
+
113
+ Gem::Requirement.new(*clauses)
114
+ rescue ArgumentError
115
+ # Covers Gem::Requirement::BadRequirementError (a subclass) plus any other
116
+ # malformed-input ArgumentError, so a pathological registry string degrades
117
+ # to "no ceiling" and never breaks the core audit.
118
+ nil
119
+ end
120
+ end
121
+ end
@@ -5,6 +5,8 @@ require "digest"
5
5
  require "time"
6
6
  require_relative "../still_active/sarif/rules"
7
7
  require_relative "lockfile_indexer"
8
+ require_relative "activity_helper"
9
+ require_relative "constraint_helper"
8
10
 
9
11
  module StillActive
10
12
  # Renders a still_active workflow result as a SARIF 2.1.0 document.
@@ -19,7 +21,7 @@ module StillActive
19
21
 
20
22
  LIBYEAR_THRESHOLD = 1.0
21
23
  SCORECARD_LOW_THRESHOLD = 4.0
22
- ABANDONED_SECONDS = 2 * 365 * 24 * 60 * 60 # 2 years
24
+ SECONDS_PER_YEAR = 365 * 24 * 60 * 60 # for the human-readable "in N years"
23
25
 
24
26
  # result: same hash StillActive::Workflow.call returns (gem_name => gem_data)
25
27
  # ruby_info: optional Ruby freshness hash (or nil)
@@ -98,61 +100,197 @@ module StillActive
98
100
  location = location_for(name, line_index, lockfile_uri)
99
101
 
100
102
  if data[:archived]
101
- out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}.", location)
103
+ out << mark_suppressed(result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), name, :activity)
102
104
  end
103
105
 
104
106
  unless data[:archived]
105
- last_commit = parse_time(data[:last_commit_date])
106
- if last_commit && last_commit < (Time.now - ABANDONED_SECONDS)
107
- years = ((Time.now - last_commit) / (365 * 24 * 60 * 60)).round(1)
108
- out << result(
109
- "SA002",
107
+ if ActivityHelper.activity_level(data) == :critical
108
+ activity = ActivityHelper.last_activity(data)
109
+ years = ((Time.now - activity[:date]) / SECONDS_PER_YEAR).round(1)
110
+ noun = activity[:kind] == :release ? "no release" : "no commits"
111
+ out << mark_suppressed(
112
+ result(
113
+ "SA002",
114
+ name,
115
+ "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
116
+ location,
117
+ ),
110
118
  name,
111
- "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}.",
112
- location,
119
+ :activity,
113
120
  )
114
121
  end
115
122
  end
116
123
 
117
124
  Array(data[:vulnerabilities]).each do |vuln|
118
- out << vulnerability_result(name, version, vuln, location)
125
+ out << vulnerability_result(name, version, vuln, location, data)
119
126
  end
120
127
 
121
128
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
122
129
  latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
123
- out << result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}.", location)
130
+ out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
124
131
  end
125
132
 
126
133
  if data[:scorecard_score] && data[:scorecard_score] < SCORECARD_LOW_THRESHOLD
127
- out << result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location)
134
+ out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
128
135
  end
129
136
 
130
137
  if data[:version_yanked]
131
- out << result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location)
138
+ out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
139
+ end
140
+
141
+ if data[:poison] && !Array(data[:constraints]).empty?
142
+ # Level tracks the poison tier (critical->error, ...); a plain majors-behind
143
+ # tier change keeps the (rule_id, gem_name) fingerprint so it doesn't re-alert.
144
+ # But an escalation up the tiers (maintenance -> security-relevant -> below the
145
+ # fix) adds a fingerprint dimension so a past dismissal of a weaker tier can't
146
+ # silently mute the stronger one -- the transition a human must see.
147
+ state = if data[:poison_below_fix]
148
+ "below_fix"
149
+ elsif data[:poison_security_relevant]
150
+ "security"
151
+ else
152
+ "maintenance"
153
+ end
154
+ out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
155
+ end
156
+
157
+ if data[:language_ceiling]
158
+ # Level tracks the ceiling tier: critical (EOL-forced) -> error, note
159
+ # (latest-not-yet) -> note. Fingerprint is (rule_id, gem_name, state) where
160
+ # state is the eol_forced boolean: cosmetic churn (which patch, which
161
+ # latest) doesn't re-alert, but a note -> EOL-forced escalation (a supported
162
+ # runtime went EOL) mints a NEW alert so a past dismissal of the note can't
163
+ # silently mute the critical -- that transition is exactly the one a human
164
+ # must see.
165
+ state = data[:language_ceiling][:eol_forced] ? "eol_forced" : "latest_not_yet"
166
+ out << mark_suppressed(result("SA009", name, language_ceiling_message(name, version, data), location, level: language_ceiling_level(data), fp_extra: state), name, :language_ceiling)
132
167
  end
133
168
 
134
169
  out
135
170
  end
136
171
 
137
- def vulnerability_result(name, version, vuln, location)
138
- score = vuln[:cvss3_score] || vuln[:cvss2_score]
139
- level = Sarif::Rules.cvss_to_level(score)
172
+ def language_ceiling_level(data)
173
+ { critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
174
+ end
175
+
176
+ def language_ceiling_message(name, version, data)
177
+ ceiling = data[:language_ceiling]
178
+ runtime = ceiling[:runtime]
179
+ body =
180
+ if ceiling[:eol_forced]
181
+ eol = ceiling[:ceiling_eol_date]
182
+ eol_part = eol ? " (EOL #{format_date(eol)})" : ""
183
+ "stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
184
+ else
185
+ "no #{runtime} #{ceiling[:latest_stable]} support yet"
186
+ end
187
+ fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
188
+ "#{name} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
189
+ end
190
+
191
+ # The poison receipt for a Code Scanning alert: the worst 3 caps (shared
192
+ # ranking via ConstraintHelper.top_findings so it can't drift from the other
193
+ # renderers) with the exact latest version (a machine-read alert wants the
194
+ # precise version, not the "8.x" the terminal abbreviates to), plus "+N more"
195
+ # and the transitive parent. Note result() fingerprints on (rule_id, gem_name)
196
+ # only, so this volatile detail never re-alerts a finding the user triaged.
197
+ def poison_level(data)
198
+ # A security-relevant cap (a dormant dep pins a known-vulnerable dependency
199
+ # below its fix) escalates to error regardless of majors-behind: it's a real
200
+ # security finding, not the maintenance-tier signal poison usually is.
201
+ return "error" if data[:poison_security_relevant]
202
+
203
+ { critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
204
+ end
205
+
206
+ def poison_message(name, version, data)
207
+ top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
208
+ caps = top[:shown].map do |finding|
209
+ behind = finding[:majors_behind]
210
+ "#{finding[:dependency]} #{finding[:requirement]} (#{behind} major#{"s" unless behind == 1} behind, latest #{finding[:dep_latest]})"
211
+ end
212
+ remaining = top[:total] - top[:shown].length
213
+ caps << "+#{remaining} more" if remaining.positive?
214
+ "#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
215
+ end
216
+
217
+ # Spells out the security escalation for a code-scanning alert. Only claims
218
+ # "below the fix" (with the CVE and its nearest fix, a version the cap forbids)
219
+ # when that is actually established. Otherwise we say only that the dep is
220
+ # known-vulnerable, WITHOUT asserting patchability: this branch also covers a
221
+ # HIGH advisory with no released fix at all (class C), which is not patchable in
222
+ # place, so an "(patchable in place)" claim here would be false on the worst case.
223
+ def poison_security_note(data)
224
+ return "" unless data[:poison_security_relevant]
225
+
226
+ below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
227
+ if below.any?
228
+ receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]}, outside the cap)" }.uniq
229
+ " -- pins #{receipts.join("; ")}"
230
+ else
231
+ pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
232
+ " -- pins known-vulnerable #{pinned.join(", ")}"
233
+ end
234
+ end
235
+
236
+ # Attaches a SARIF native suppressions[] entry when this finding is covered
237
+ # by a whole-gem --ignore or a granular .still_active.yml suppression, so a
238
+ # GitHub code-scanning consumer renders it dismissed rather than open. The
239
+ # suppression's reason rides along as the justification.
240
+ def mark_suppressed(result_hash, gem_name, signal, advisory: nil, aliases: [])
241
+ config = StillActive.config
242
+ if config.ignored_gems.include?(gem_name)
243
+ result_hash["suppressions"] = [{ "kind" => "external", "justification" => "ignored via --ignore" }]
244
+ return result_hash
245
+ end
246
+
247
+ entry = config.suppressions.match(gem: gem_name, signal: signal, advisory: advisory, aliases: aliases)
248
+ return result_hash unless entry
249
+
250
+ suppression = { "kind" => "external" }
251
+ suppression["justification"] = entry.reason if entry.reason
252
+ result_hash["suppressions"] = [suppression]
253
+ result_hash
254
+ end
255
+
256
+ def vulnerability_result(name, version, vuln, location, data = {})
257
+ # Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
258
+ # label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
259
+ # exports as error, not a note/warning a code-scanning gate reads as
260
+ # informational. The security-severity NUMBER stays tied to a real CVSS score
261
+ # (effective_score): a label-only advisory carries no invented number rather
262
+ # than a fabricated 7.0. A confirmed-but-unscored advisory still fails closed.
263
+ score = VulnerabilityHelper.effective_score(vuln)
264
+ level = Sarif::Rules.severity_to_level(VulnerabilityHelper.advisory_severity(vuln))
140
265
  severity = Sarif::Rules.cvss_to_security_severity(score)
141
266
  advisory_id = vuln[:id] || Array(vuln[:aliases]).first || "unknown"
142
267
  aliases = Array(vuln[:aliases]).first(3).join(", ")
143
268
  alias_suffix = aliases.empty? ? "" : " [#{aliases}]"
144
269
  title = vuln[:title] ? ": #{vuln[:title]}" : ""
270
+ # ruby-advisory-db records no safe version: upgrading can't clear it, which
271
+ # is the actionable distinction from an ordinary (patchable) advisory.
272
+ no_fix = vuln[:no_fix_available] ? " (no fixed version available)" : ""
145
273
 
146
274
  base = result(
147
275
  "SA003",
148
276
  name,
149
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}.",
277
+ "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
150
278
  location,
151
279
  level: level,
152
280
  fp_extra: advisory_id,
153
281
  )
154
282
  base["properties"] = { "security-severity" => severity } if severity
155
- base
283
+ mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
284
+ end
285
+
286
+ # Names the direct dependency a transitive flagged gem rides in on, so a
287
+ # code-scanning consumer gets the actionable "replace your direct gem" hop
288
+ # instead of an un-actionable transitive finding (#60).
289
+ def transitive_suffix(data)
290
+ path = data[:dependency_path]
291
+ return "" unless data[:direct] == false && path && path.length >= 2
292
+
293
+ " (transitive, pulled in by #{path.first})"
156
294
  end
157
295
 
158
296
  def ruby_eol_result(ruby_info, ruby_line, lockfile_uri)
@@ -211,17 +349,8 @@ module StillActive
211
349
  Sarif::Rules.all.index { |r| r[:id] == rule_id }
212
350
  end
213
351
 
214
- def parse_time(value)
215
- return value if value.is_a?(Time)
216
- return if value.nil?
217
-
218
- Time.parse(value.to_s)
219
- rescue ArgumentError, TypeError, RangeError
220
- nil
221
- end
222
-
223
352
  def format_date(value)
224
- t = parse_time(value)
353
+ t = ActivityHelper.parse_time(value)
225
354
  t ? t.utc.strftime("%Y-%m-%d") : value.to_s
226
355
  end
227
356
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_range"
4
+
5
+ module StillActive
6
+ # Does a concrete version satisfy a declared requirement, at PATCH precision,
7
+ # for npm and cargo? This is the primitive the cross-ecosystem below-the-fix
8
+ # signal needs: a CVE's fix is usually a same-major patch bump, so "can this fix
9
+ # be reached within the package's constraint" cannot be answered by the coarse,
10
+ # major-precision ConstraintHelper. node-semver's caret/tilde/OR/prerelease rules
11
+ # are a correctness minefield, so we lean on the semantic_range gem (a node-semver
12
+ # port) rather than reimplement them.
13
+ #
14
+ # npm ranges are node-semver as-is. cargo's VersionReq agrees with node-semver on
15
+ # every operator form, and diverges on ANY operator-less bare version: cargo treats
16
+ # a bare version as a caret (`1.2.3` = `^1.2.3`, `1.2` = `^1.2` = `>=1.2.0 <2.0.0`,
17
+ # `1` = `^1`), while node-semver reads a bare full version as an exact pin and a
18
+ # partial `1.2` as the narrower `1.2.x` (`<1.3.0`). Per the Cargo Book's caret
19
+ # table, node-semver's caret expands identically to cargo's for every one of these
20
+ # forms, so the whole shim is: prefix `^` to a bare version before evaluating.
21
+ # Getting it wrong (e.g. leaving `1.2` unshimmed) reads a reachable fix as
22
+ # unreachable and fabricates a below-the-fix security finding.
23
+ module SemverSatisfaction
24
+ extend self
25
+
26
+ # An operator-less bare version, 1 to 3 numeric components, optional prerelease
27
+ # and/or build tail (`1`, `1.2`, `1.2.3`, `0.10.38`, `1.2.3-alpha+001`). cargo
28
+ # reads any of these as a caret; node-semver does not, so cargo shims them.
29
+ BARE_VERSION = /\A\s*v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*\z/
30
+
31
+ # Does `version` satisfy `requirement`? true / false when decidable; nil when the
32
+ # requirement or version isn't valid semver for the ecosystem, or the ecosystem
33
+ # isn't one we model. The tri-state is deliberate (hence a plain verb, not a
34
+ # `?` predicate): semantic_range returns false for garbage, and a false would read
35
+ # as "the fix can't be reached" in a wall test and fabricate a below-the-fix flag.
36
+ # The caller must treat nil as "cannot establish a wall", never as unreachable.
37
+ def evaluate(requirement:, version:, ecosystem:)
38
+ range = range_for(requirement, ecosystem)
39
+ return if range.nil? # undecidable: unmodelled ecosystem or unparseable requirement
40
+ return if SemanticRange.valid(version.to_s).nil? # undecidable: unparseable version
41
+
42
+ SemanticRange.satisfies?(version.to_s, range)
43
+ end
44
+
45
+ private
46
+
47
+ # The node-semver range string for this ecosystem's requirement, or nil if it
48
+ # isn't a valid range (or the ecosystem is unmodelled). cargo's bare-version
49
+ # caret is applied BEFORE validation so the shimmed form is what gets checked.
50
+ def range_for(requirement, ecosystem)
51
+ requirement = requirement.to_s.strip
52
+ # A blank requirement is missing data (a failed extraction), NOT a package that
53
+ # allows any version: semantic_range reads "" as `*` (matches everything), which
54
+ # would be a false-confident answer. Keep it undecidable, like a blank version.
55
+ return if requirement.empty?
56
+
57
+ case ecosystem
58
+ when :npm then SemanticRange.valid_range(requirement)
59
+ when :cargo then SemanticRange.valid_range(cargo_normalize(requirement))
60
+ end
61
+ end
62
+
63
+ # `requirement` arrives already stripped from range_for.
64
+ def cargo_normalize(requirement)
65
+ requirement.match?(BARE_VERSION) ? "^#{requirement}" : requirement
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activity_helper"
4
+
5
+ module StillActive
6
+ # Collapses a gem's several maintenance signals into one categorical verdict,
7
+ # so a machine/LLM consumer (or another tool's report) can display and threshold
8
+ # a single value instead of re-deriving it from activity_level + archived +
9
+ # vulnerability_count. This is deliberately NOT a numeric composite: an earlier
10
+ # 0-100 score was removed because a weighted average let missing data read as
11
+ # "perfect health". Here, :unknown stays :unknown -- absence of data is never
12
+ # rendered as :ok.
13
+ module StatusHelper
14
+ extend self
15
+
16
+ # Worst-first lifecycle verdict. The key distinction (from the "done gems"
17
+ # critique and validated against the maintenance-tooling landscape): a clean,
18
+ # long-dormant gem is :legacy ("done", low risk), NOT a problem -- whereas a
19
+ # dormant or archived gem carrying an unpatched advisory is :dead (no one is
20
+ # going to fix it, migrate). :unknown is least severe -- an absence, not a
21
+ # finding -- so missing data never reads as :ok.
22
+ SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :vulnerable, :dead].freeze
23
+
24
+ # Returns :dead, :vulnerable, :archived, :legacy, :stale, :ok, or :unknown.
25
+ def gem_status(gem_data)
26
+ vulnerable = gem_data[:vulnerability_count].to_i.positive?
27
+
28
+ # A pinned version the registry can't resolve (yanked, typo, or nonexistent)
29
+ # has no version-specific data to judge, so package-level health must not read
30
+ # it as :ok. Absence of data is :unknown, never a false all-clear. Guarded on
31
+ # `!vulnerable` so a detected advisory always wins, in case a future source
32
+ # ever attaches one to an otherwise-unresolved version.
33
+ return :unknown if gem_data[:version_unresolved] && !vulnerable
34
+
35
+ level = ActivityHelper.activity_level(gem_data)
36
+
37
+ 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
41
+ end
42
+
43
+ case level
44
+ when :archived
45
+ # archived != EOL: a repo archived while the gem still publishes recent
46
+ # releases (development moved to a monorepo) isn't dead -- let the
47
+ # releases speak, but keep :stale so the archived repo stays a yellow flag.
48
+ ActivityHelper.release_recency_level(gem_data) == :ok ? :stale : :archived
49
+ when :critical then :legacy # long-dormant but clean: feature-complete, not a fire
50
+ else level # :stale / :ok / :unknown
51
+ end
52
+ end
53
+
54
+ # The single worst gem status across the audit. An EOL Ruby floors the
55
+ # project at :vulnerable (the runtime itself is a live, actionable risk).
56
+ # :unknown only wins when nothing better is known.
57
+ def project_status(result, ruby_info: nil)
58
+ statuses = result.each_value.map { |data| gem_status(data) }
59
+ statuses << :vulnerable if ruby_info&.dig(:eol) == true
60
+ return :unknown if statuses.empty?
61
+
62
+ # Every value gem_status returns is in SEVERITY today; if a future status
63
+ # isn't, rank it most-severe so it surfaces in the rollup rather than being
64
+ # silently masked by a milder finding.
65
+ statuses.max_by { |status| SEVERITY.index(status) || SEVERITY.length }
66
+ end
67
+ end
68
+ end