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
@@ -1,12 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "json"
5
6
 
6
7
  module StillActive
7
8
  module HttpHelper
8
- TRUSTED_HOSTS = ["github.com", "gitlab.com", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
9
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
10
+ # Transport-level failures ("host unreachable / connection broke", not an HTTP
11
+ # error status). Every network entry point degrades to a safe empty result on
12
+ # these rather than letting one escape and vanish a gem from the audit.
13
+ # SystemCallError is the superclass of the whole Errno::* family (EHOSTUNREACH,
14
+ # ENETUNREACH, ETIMEDOUT, EPIPE, ECONNREFUSED, ECONNRESET, ...), so we don't
15
+ # have to enumerate each one and miss the next.
16
+ TRANSPORT_ERRORS = [
17
+ Net::OpenTimeout,
18
+ Net::ReadTimeout,
19
+ SocketError,
20
+ SystemCallError,
21
+ OpenSSL::SSL::SSLError,
22
+ EOFError,
23
+ ].freeze
9
24
  MAX_REDIRECTS = 3
25
+ # Ceiling on a single response body. These are metadata endpoints (version
26
+ # lists, scorecards, advisories); legitimate responses are well under this.
27
+ # A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-
28
+ # registerable, so without a cap a hostile or broken source could stream a
29
+ # multi-GB body and OOM the process. 16 MiB leaves generous headroom for a
30
+ # gem with thousands of versions while bounding worst-case memory.
31
+ MAX_BODY_BYTES = 16 * 1024 * 1024
10
32
 
11
33
  extend self
12
34
 
@@ -15,45 +37,125 @@ module StillActive
15
37
  uri.path = path
16
38
  uri.query = URI.encode_www_form(params) unless params.empty?
17
39
 
40
+ request_json(uri, headers) { |target| Net::HTTP::Get.new(target) }
41
+ end
42
+
43
+ def post_json(base_uri, path, body:, headers: {})
44
+ uri = base_uri.dup
45
+ uri.path = path
46
+
47
+ request_json(uri, headers) do |target|
48
+ request = Net::HTTP::Post.new(target)
49
+ request.body = body
50
+ request
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # Two URIs share an origin when scheme, host, and port all match. URI fills
57
+ # in the default port (443 for https), so the bare and explicit forms of the
58
+ # same origin compare equal.
59
+ def same_origin?(a, b)
60
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port
61
+ end
62
+
63
+ # Runs the request, following up to MAX_REDIRECTS trusted-host redirects,
64
+ # and returns the parsed JSON (or nil). The block is yielded each URI and
65
+ # returns the request object, so GET and POST share the redirect/auth/cap
66
+ # logic.
67
+ def request_json(uri, headers)
18
68
  MAX_REDIRECTS.times do
19
69
  http = Net::HTTP.new(uri.host, uri.port)
20
70
  http.use_ssl = true
21
71
  http.open_timeout = 10
22
72
  http.read_timeout = 10
23
73
 
24
- request = Net::HTTP::Get.new(uri)
74
+ request = yield(uri)
25
75
  headers.each { |key, value| request[key] = value }
26
76
 
27
- response = http.request(request)
77
+ outcome, payload = perform(http, request, uri)
78
+ case outcome
79
+ when :done
80
+ return payload
81
+ when :stop
82
+ return
83
+ when :redirect
84
+ location = payload["Location"]
85
+ if location.nil? || location.empty?
86
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
87
+ return
88
+ end
28
89
 
29
- if response.is_a?(Net::HTTPRedirection)
30
- redirect_uri = uri + response["Location"]
90
+ redirect_uri = uri + location
31
91
  unless TRUSTED_HOSTS.include?(redirect_uri.host)
32
92
  $stderr.puts("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
33
93
  return
34
94
  end
95
+ # We dial every request over TLS (use_ssl = true). A redirect that
96
+ # downgrades to http is either a misconfiguration or a downgrade
97
+ # attempt; refuse it rather than silently dialing http-over-TLS.
98
+ unless redirect_uri.scheme == "https"
99
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
100
+ return
101
+ end
35
102
  $stderr.puts("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
36
- headers = {} if redirect_uri.host != uri.host
103
+ # Auth is scoped to an origin (scheme + host + port), not just a host:
104
+ # a different port is a different service and must not inherit the token.
105
+ headers = {} unless same_origin?(uri, redirect_uri)
37
106
  uri = redirect_uri
38
- next
39
- end
40
-
41
- unless response.is_a?(Net::HTTPSuccess)
42
- $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
43
- return
44
107
  end
45
-
46
- return JSON.parse(response.body)
47
108
  end
48
109
 
49
110
  $stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
50
111
  nil
51
- rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
112
+ rescue *TRANSPORT_ERRORS => e
52
113
  $stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
53
114
  nil
54
115
  rescue JSON::ParserError => e
55
116
  $stderr.puts("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
56
117
  nil
118
+ rescue URI::InvalidURIError => e
119
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
120
+ nil
121
+ end
122
+
123
+ # Issues the request in streaming form so the body is read against a size
124
+ # cap rather than buffered whole. Returns one of:
125
+ # [:redirect, response] a 3xx, for the caller to follow
126
+ # [:stop, nil] non-success (warns unless 404), or body over cap
127
+ # [:done, parsed] a 2xx with parsed JSON
128
+ # Redirect and non-success bodies are never read: returning from the block
129
+ # unwinds through Net::HTTP, which closes the connection without draining
130
+ # the body, so a huge error/redirect body can't OOM us either.
131
+ def perform(http, request, uri)
132
+ http.request(request) do |response|
133
+ return [:redirect, response] if response.is_a?(Net::HTTPRedirection)
134
+
135
+ unless response.is_a?(Net::HTTPSuccess)
136
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
137
+ return [:stop, nil]
138
+ end
139
+
140
+ body = read_capped_body(response, uri)
141
+ return [:stop, nil] if body.nil?
142
+
143
+ return [:done, JSON.parse(body)]
144
+ end
145
+ end
146
+
147
+ # Reads the body in chunks, abandoning the read (returns nil) as soon as it
148
+ # exceeds MAX_BODY_BYTES so an oversized body is never fully materialized.
149
+ def read_capped_body(response, uri)
150
+ body = +""
151
+ response.read_body do |chunk|
152
+ body << chunk
153
+ if body.bytesize > MAX_BODY_BYTES
154
+ $stderr.puts("warning: #{uri.host}#{uri.path} response exceeded #{MAX_BODY_BYTES} bytes, skipping")
155
+ return nil
156
+ end
157
+ end
158
+ body
57
159
  end
58
160
  end
59
161
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Side-effect-free Gemfile.lock parser: extracts each top-level dependency's
5
+ # name, version, and source (type + URI) straight from the lockfile text.
6
+ #
7
+ # We deliberately do NOT load the Gemfile (evaluating it is arbitrary code
8
+ # execution when the audited project is untrusted, e.g. CI on a pull request)
9
+ # and do NOT use Bundler::LockfileParser. The latter is not side-effect-free:
10
+ # a `PLUGIN SOURCE` block runs `Bundler::Plugin.from_lock` at parse time,
11
+ # which resolves against the on-disk plugin registry and can raise or activate
12
+ # an installed plugin. (Bundler's own `@gemfile_parse` guard that neutralizes
13
+ # this is set only inside `Bundler::Plugin.gemfile_install`, never for a
14
+ # standalone parse.) This mirrors what OSV-Scanner and Trivy do for the same
15
+ # threat model, and what `LockfileIndexer` already does here. Refs #37.
16
+ module LockfileDependencyParser
17
+ extend self
18
+
19
+ # `dependencies` holds the names listed under this spec in the lockfile (its
20
+ # resolved runtime deps). For a local path gem (a gemspec project or engine)
21
+ # these are the gems it ships, which Bundler does not surface in the
22
+ # DEPENDENCIES section, so they are what #41 needs to reach.
23
+ Spec = Struct.new(:name, :version, :source_type, :source_uri, :dependencies, keyword_init: true)
24
+
25
+ # Lockfile source blocks and the source_type each maps to. PLUGIN SOURCE is
26
+ # recognized as a block (so its lines are consumed as inert data, not
27
+ # mis-read as specs) but yields no auditable gems.
28
+ SOURCE_TYPES = { "GEM" => :rubygems, "GIT" => :git, "PATH" => :path }.freeze
29
+ PLUGIN_SOURCE = "PLUGIN SOURCE"
30
+
31
+ # A section header sits at column 0; Bundler emits them in SCREAMING form.
32
+ SECTION_HEADER = /\A[A-Z]/
33
+ # A top-level spec is indented exactly 4 spaces: ` name (1.2.3)` or, for a
34
+ # platform gem, ` name (1.2.3-x86_64-linux)`. Nested deps (6 spaces) and
35
+ # the `specs:`/`remote:` option lines (2 spaces) do not match. We do NOT
36
+ # anchor the end of the line: Bundler's grammar allows an optional trailing
37
+ # checksum on a spec line, and an audit tool must never silently drop a gem
38
+ # because of unexpected trailing content (that would be a false-negative
39
+ # evasion on a hand-crafted lockfile).
40
+ SPEC_LINE = /\A {4}(\S+) \(([^-)]+)(?:-[^)]*)?\)/
41
+ REMOTE_LINE = /\A {2}remote: (.+)\z/
42
+ # A spec's nested runtime dep is indented exactly 6 spaces: ` name` or
43
+ # ` name (~> 1.0)`.
44
+ NESTED_DEP_LINE = /\A {6}([^\s(!]+)/
45
+ # A DEPENDENCIES entry is indented 2 spaces: ` name`, ` name (~> 1.0)`, or
46
+ # ` name!` (the `!` marks a pinned git/path source).
47
+ DEPENDENCY_LINE = /\A {2}([^\s(!]+)/
48
+
49
+ # Parses lockfile text into { specs:, direct:, plugin_source? }.
50
+ # `specs` is every locked top-level spec (a Spec per gem); `direct` is the
51
+ # names from the DEPENDENCIES section; `plugin_source?` flags that a
52
+ # PLUGIN SOURCE block was present (and skipped).
53
+ def parse(content)
54
+ # A hand-edited or re-encoded lockfile can carry a leading UTF-8 BOM.
55
+ # Section headers anchor at column 0 (\A), so a BOM glued to "GEM" would
56
+ # drop the entire first block: a silent false-negative, the exact evasion
57
+ # this parser is written to avoid.
58
+ content = content.delete_prefix("")
59
+ specs = []
60
+ direct = []
61
+ section = nil
62
+ source_type = nil
63
+ remote = nil
64
+ current_spec = nil
65
+ plugin_source = false
66
+
67
+ content.each_line do |raw|
68
+ line = raw.chomp
69
+
70
+ if line.match?(SECTION_HEADER)
71
+ section = line
72
+ source_type = SOURCE_TYPES[line]
73
+ remote = nil
74
+ current_spec = nil
75
+ plugin_source ||= (line == PLUGIN_SOURCE)
76
+ next
77
+ end
78
+
79
+ case section
80
+ when "GEM", "GIT", "PATH"
81
+ if (m = REMOTE_LINE.match(line))
82
+ remote ||= m[1] # first remote wins, matching Bundler's remotes.first
83
+ elsif (m = SPEC_LINE.match(line))
84
+ current_spec = Spec.new(name: m[1], version: m[2], source_type: source_type, source_uri: remote, dependencies: [])
85
+ specs << current_spec
86
+ elsif current_spec && (m = NESTED_DEP_LINE.match(line))
87
+ current_spec.dependencies << m[1]
88
+ end
89
+ when "DEPENDENCIES"
90
+ if (m = DEPENDENCY_LINE.match(line))
91
+ direct << m[1]
92
+ end
93
+ end
94
+ end
95
+
96
+ { specs: specs, direct: direct, plugin_source?: plugin_source }
97
+ end
98
+ end
99
+ end
@@ -24,6 +24,9 @@ module StillActive
24
24
  # top-level entry wins. Lines outside GEM/GIT/PATH/PLUGIN SOURCE blocks
25
25
  # are ignored.
26
26
  def gem_line_index(content)
27
+ # Strip a leading UTF-8 BOM so a "GEM" first line still matches the
28
+ # column-0 block header; otherwise every gem falls back to line 1.
29
+ content = content.delete_prefix("")
27
30
  index = {}
28
31
  in_block = false
29
32
  content.each_line.with_index(1) do |line, lineno|
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Escaping for untrusted metadata (gem names, licences, versions, repo URLs,
5
+ # advisory ids) rendered into GitHub-Flavored Markdown. These values come
6
+ # from registry/repo metadata, a Gemfile/lockfile, a --baseline file, or
7
+ # --gems input, so they can contain markdown-structural characters. Centralised
8
+ # so the table renderer (MarkdownHelper) and the diff renderer
9
+ # (DiffMarkdownHelper) can't drift apart on what's safe.
10
+ module MarkdownEscape
11
+ extend self
12
+
13
+ # Table cell: a literal "|" or newline would forge columns or break the row.
14
+ # Backslash is escaped first so it can't escape a following delimiter.
15
+ def cell(text)
16
+ return text if text.nil?
17
+
18
+ text.to_s.gsub(/[\\|\r\n]/, "\\" => "\\\\", "|" => "\\|", "\r" => " ", "\n" => " ")
19
+ end
20
+
21
+ # Link text additionally must not contain "[" or "]", which could forge or
22
+ # truncate the link.
23
+ def link_text(text)
24
+ return text if text.nil?
25
+
26
+ cell(text).gsub(/[\[\]]/, "[" => "\\[", "]" => "\\]")
27
+ end
28
+
29
+ # Link destination: percent-encode the characters that would break out of a
30
+ # "(...)" destination or the table row. Lossless for legitimate http(s) URLs.
31
+ def url(value)
32
+ return value if value.nil?
33
+
34
+ value.to_s.gsub(/[|() \t\r\n]/, "|" => "%7C", "(" => "%28", ")" => "%29", " " => "%20", "\t" => "%09", "\r" => "%0D", "\n" => "%0A")
35
+ end
36
+
37
+ # Inline (non-table) free text in a bullet list: neutralise newlines (which
38
+ # would break the list) and brackets/backslash (which could forge a link).
39
+ def inline(text)
40
+ return text if text.nil?
41
+
42
+ text.to_s.gsub(/[\\\[\]\r\n]/, "\\" => "\\\\", "[" => "\\[", "]" => "\\]", "\r" => " ", "\n" => " ")
43
+ end
44
+
45
+ # Wrap arbitrary text in an inline code span safely. A backtick in the text
46
+ # would otherwise close the span early, so use a backtick fence longer than
47
+ # the longest run in the content (and pad when it starts/ends with one), per
48
+ # the GFM code-span rules. Newlines collapse to spaces.
49
+ def code_span(text)
50
+ content = text.to_s.tr("\r\n", " ")
51
+ return "`#{content}`" unless content.include?("`")
52
+
53
+ fence = "`" * (content.scan(/`+/).map(&:length).max + 1)
54
+ pad = content.start_with?("`") || content.end_with?("`") ? " " : ""
55
+ "#{fence}#{pad}#{content}#{pad}#{fence}"
56
+ end
57
+ end
58
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "markdown_escape"
3
4
  require_relative "vulnerability_helper"
5
+ require_relative "activity_helper"
6
+ require_relative "constraint_helper"
4
7
 
5
8
  module StillActive
6
9
  module MarkdownHelper
@@ -91,12 +94,146 @@ module StillActive
91
94
  return "" if flagged.empty?
92
95
 
93
96
  lines = ["", "**Alternatives** (Ruby Toolbox leads, verify fit):"]
94
- flagged.each { |name, data| lines << "- `#{name}`: #{data[:alternatives].join(", ")}" }
97
+ flagged.each { |name, data| lines << "- #{MarkdownEscape.code_span(name)}: #{MarkdownEscape.inline(data[:alternatives].join(", "))}" }
98
+ lines.join("\n")
99
+ end
100
+
101
+ # Flagged transitive gems can't be swapped directly; name the direct
102
+ # dependency that pulls each one in so the finding becomes actionable (#60).
103
+ def transitive_section(result)
104
+ flagged = result.select do |_name, data|
105
+ data[:direct] == false && Array(data[:dependency_path]).length >= 2 && transitive_flagged?(data)
106
+ end
107
+ return "" if flagged.empty?
108
+
109
+ lines = ["", "**Transitive findings** (pulled in by a direct dependency):"]
110
+ flagged.each do |name, data|
111
+ lines << "- #{MarkdownEscape.code_span(name)} via #{MarkdownEscape.code_span(data[:dependency_path].first)}"
112
+ end
113
+ lines.join("\n")
114
+ end
115
+
116
+ # A dormant dep that caps your tree below its latest major (the poison-pill
117
+ # signal). Consistent with the other finding sections: worst caps + total, the
118
+ # direct parent named for a transitive pill. The full cap list is in the JSON.
119
+ def poison_section(result)
120
+ # Require constraints present, not just the poison flag, so an unexpected
121
+ # empty set can't emit a dangling "- `gem` caps" bullet (symmetric with the
122
+ # terminal's poison_line guard).
123
+ flagged = result.select { |_name, data| data[:poison] && !Array(data[:constraints]).empty? }
124
+ return "" if flagged.empty?
125
+
126
+ lines = ["", "**Poison-pill findings** (dormant deps capping your tree below its latest major):"]
127
+ # Worst-first so the act-now findings lead, then by magnitude for a stable,
128
+ # triage-friendly order.
129
+ ranked = flagged.sort_by do |name, data|
130
+ [
131
+ poison_rank(data), # below-the-fix leads, then security-relevant, then the rest
132
+ -ConstraintHelper::SEVERITY.index(data[:poison_severity] || :note),
133
+ -Array(data[:constraints]).map { |c| c[:majors_behind].to_i }.max,
134
+ name.to_s,
135
+ ]
136
+ end
137
+ ranked.each do |name, data|
138
+ lines << "- **#{data[:poison_severity] || :note}** #{poison_gem_ref(name, data)} caps #{poison_caps(data[:constraints])}#{poison_security_marker(data)}"
139
+ end
140
+ lines.join("\n")
141
+ end
142
+
143
+ # Ranks poison findings for the report: a cap that holds you BELOW THE FIX first
144
+ # (unpatchable without replacing the capper), then a merely security-relevant cap
145
+ # (vulnerable but patchable in place), then everything else.
146
+ def poison_rank(data)
147
+ return 0 if data[:poison_below_fix]
148
+ return 1 if data[:poison_security_relevant]
149
+
150
+ 2
151
+ end
152
+
153
+ # A prominent marker naming the known-vulnerable dependency a security-relevant
154
+ # cap pins you to. Leads with the stronger BELOW-THE-FIX claim (the CVE and its
155
+ # nearest fix, a version the cap forbids) when it applies, else the weaker
156
+ # "vulnerable but patchable" form.
157
+ def poison_security_marker(data)
158
+ return "" unless data[:poison_security_relevant]
159
+
160
+ below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
161
+ if below.any?
162
+ receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]})" }.uniq
163
+ " ⚠ **pins #{receipts.join("; ")}**"
164
+ else
165
+ pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
166
+ " ⚠ **pins vulnerable #{pinned.join(", ")}**"
167
+ end
168
+ end
169
+
170
+ # The language-runtime ceiling: a resolved version whose declared runtime
171
+ # constraint (Ruby `ruby_version`, Python `requires_python`) caps the runtime
172
+ # onto an EOL release (critical) or below the latest stable (note). Ranked
173
+ # worst-first with a tier label, mirroring the poison section.
174
+ def language_ceiling_section(result)
175
+ flagged = result.select { |_name, data| data[:language_ceiling] }
176
+ return "" if flagged.empty?
177
+
178
+ lines = ["", "**Runtime ceiling findings** (a resolved version capping the language runtime it can run on):"]
179
+ ranked = flagged.sort_by { |name, data| [-ConstraintHelper::SEVERITY.index(data[:language_ceiling][:severity] || :note), name.to_s] }
180
+ ranked.each do |name, data|
181
+ ceiling = data[:language_ceiling]
182
+ lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(name)} #{language_ceiling_receipt(ceiling, data)}"
183
+ end
95
184
  lines.join("\n")
96
185
  end
97
186
 
98
187
  private
99
188
 
189
+ def language_ceiling_receipt(ceiling, data)
190
+ runtime = ceiling[:runtime]
191
+ req = MarkdownEscape.code_span(ceiling[:requirement])
192
+ base =
193
+ if ceiling[:eol_forced]
194
+ eol = ceiling[:ceiling_eol_date]
195
+ eol_part = eol ? " (EOL #{eol.strftime("%Y-%m-%d")})" : ""
196
+ "requires #{runtime} #{req}, stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
197
+ else
198
+ "requires #{runtime} #{req}, no #{runtime} #{ceiling[:latest_stable]} support yet"
199
+ end
200
+ fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
201
+ "#{base}#{fix}"
202
+ end
203
+
204
+ def poison_gem_ref(name, data)
205
+ ref = MarkdownEscape.code_span(name)
206
+ path = data[:dependency_path]
207
+ # Transitive pill: name the direct parent the user can actually act on (#60).
208
+ return ref unless data[:direct] == false && Array(path).length >= 2
209
+
210
+ "#{ref} via #{MarkdownEscape.code_span(path.first)}"
211
+ end
212
+
213
+ # The worst 3 caps; the first carries the full receipt (requirement + latest
214
+ # major), the rest just the gap, and "— N total" when there are more.
215
+ def poison_caps(constraints)
216
+ top = ConstraintHelper.top_findings(Array(constraints), limit: 3)
217
+ parts = top[:shown].each_with_index.map do |finding, i|
218
+ dep = MarkdownEscape.code_span(finding[:dependency])
219
+ req = MarkdownEscape.code_span(finding[:requirement])
220
+ behind = finding[:majors_behind]
221
+ i.zero? ? "#{dep} #{req} (#{behind} major#{"s" unless behind == 1} behind, latest #{poison_major(finding[:dep_latest])})" : "#{dep} #{req} (#{behind})"
222
+ end
223
+ joined = parts.join(", ")
224
+ top[:total] > 1 ? "#{joined} — #{top[:total]} total" : joined
225
+ end
226
+
227
+ # "8.0.1" -> "8.x": the cap is a major-level gap.
228
+ def poison_major(version)
229
+ major = version.to_s[/\d+/]
230
+ major ? "#{major}.x" : version
231
+ end
232
+
233
+ def transitive_flagged?(data)
234
+ [:archived, :critical].include?(ActivityHelper.activity_level(data)) || data[:vulnerability_count].to_i.positive?
235
+ end
236
+
100
237
  def version_with_date(text:, url:, date:)
101
238
  version_part = markdown_url(text: text, url: url)
102
239
  return if version_part.nil?
@@ -128,7 +265,7 @@ module StillActive
128
265
  def format_license(license)
129
266
  return "-" if license.nil? || license.empty?
130
267
 
131
- license
268
+ MarkdownEscape.cell(license)
132
269
  end
133
270
 
134
271
  def format_vulns(data)
@@ -140,15 +277,17 @@ module StillActive
140
277
  severity = VulnerabilityHelper.highest_severity(vulnerabilities)
141
278
  ids = vulnerabilities.flat_map { |v| [v[:id], *v[:aliases]] }.compact.uniq.first(3)
142
279
 
143
- parts = [severity ? "#{count} (#{severity})" : count.to_s]
144
- parts << ids.join(", ") unless ids.empty?
280
+ notes = [severity, ("no fix" if VulnerabilityHelper.no_fix_available?(vulnerabilities))].compact
281
+ parts = [notes.empty? ? count.to_s : "#{count} (#{notes.join(", ")})"]
282
+ parts << MarkdownEscape.cell(ids.join(", ")) unless ids.empty?
145
283
  parts.join(" ")
146
284
  end
147
285
 
148
286
  def markdown_url(text:, url:)
149
- return text if url.nil?
287
+ safe_text = MarkdownEscape.link_text(text)
288
+ return safe_text if url.nil?
150
289
 
151
- "[#{text}](#{url})"
290
+ "[#{safe_text}](#{MarkdownEscape.url(url)})"
152
291
  end
153
292
 
154
293
  def year_month(time_object)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Translates a PEP 440 `requires_python` specifier into a RubyGems requirement
5
+ # STRING, so the generic RuntimeCeilingHelper can reason about a Python runtime
6
+ # ceiling with no core change -- the same way Ruby's `ruby_version` already
7
+ # feeds it. PEP 440 and RubyGems disagree on two operators that matter here:
8
+ #
9
+ # - `~=` (compatible release) is a syntax error to Gem::Requirement, so a
10
+ # naive parse raises and the ceiling is silently missed. It maps cleanly to
11
+ # RubyGems `~>` for the major.minor.patch shapes `requires_python` uses.
12
+ # - `== X.*` / `== X.Y.*` prefix wildcards are likewise unparseable and map to
13
+ # a pessimistic `~>` over the prefix.
14
+ #
15
+ # `!=` exclusions are dropped: a hole-punch can never create an upper bound, so
16
+ # it cannot be a runtime ceiling, and dropping it can only ADMIT more runtimes
17
+ # (fewer findings) -- conservative, never a false ceiling. Anything that still
18
+ # won't parse degrades to a dropped clause (or nil overall), never a raise: this
19
+ # feeds the core audit and must fail safe, matching RuntimeCeilingHelper's
20
+ # best-effort contract.
21
+ module Pep440Helper
22
+ extend self
23
+
24
+ # Match ConstraintHelper / RuntimeCeilingHelper: bound pathological registry
25
+ # input before it reaches Gem::Requirement's own regex.
26
+ MAX_SPECIFIER_LENGTH = 256
27
+
28
+ # Operator, then the version as a run of non-space chars. `\s*(\S+)` (not
29
+ # `\s*(.+)`) keeps the two groups over disjoint character classes so there's no
30
+ # polynomial backtracking on pathological input like "<" + many spaces (a PEP
31
+ # 440 version never contains internal spaces, so this loses nothing).
32
+ CLAUSE_PATTERN = /\A(===|==|~=|!=|<=|>=|<|>)\s*(\S+)\z/
33
+
34
+ # => a comma-joined RubyGems requirement string, or nil when nothing usable
35
+ # survives translation. The caller feeds the string to RuntimeCeilingHelper,
36
+ # which splits and splats it exactly like a `ruby_version` string.
37
+ def to_gem_requirement_string(specifier)
38
+ spec = specifier.to_s
39
+ return if spec.strip.empty? || spec.length > MAX_SPECIFIER_LENGTH
40
+
41
+ clauses = spec.split(",").filter_map { |clause| translate_clause(clause.strip) }
42
+ return if clauses.empty?
43
+
44
+ clauses.join(", ")
45
+ end
46
+
47
+ private
48
+
49
+ def translate_clause(clause)
50
+ match = CLAUSE_PATTERN.match(clause)
51
+ return if match.nil?
52
+
53
+ operator = match[1]
54
+ version = match[2].strip
55
+
56
+ case operator
57
+ when "!=" then nil # hole-punch: never an upper bound, drop it
58
+ when "~=" then compatible_release(version)
59
+ when "==", "===" then equality(version)
60
+ else comparison(operator, version)
61
+ end
62
+ end
63
+
64
+ # `~= X.Y[.Z]` is `>= X.Y[.Z], == X.Y.*` which is exactly RubyGems `~> X.Y[.Z]`.
65
+ def compatible_release(version)
66
+ return unless parseable?(version)
67
+
68
+ "~> #{version}"
69
+ end
70
+
71
+ def equality(version)
72
+ if version.end_with?(".*")
73
+ prefix_match(version.delete_suffix(".*"))
74
+ elsif parseable?(version)
75
+ "= #{version}"
76
+ end
77
+ end
78
+
79
+ # `== P.*` matches every release whose version starts with prefix P, i.e.
80
+ # `>= P, < P-with-last-segment-incremented`. RubyGems spells that as `~> P.0`
81
+ # (append one segment so the pessimistic bump lands on P's last segment):
82
+ # ==3.* -> ~> 3.0 (>=3, <4)
83
+ # ==3.7.* -> ~> 3.7.0 (>=3.7, <3.8)
84
+ def prefix_match(prefix)
85
+ return unless parseable?(prefix)
86
+
87
+ "~> #{prefix}.0"
88
+ end
89
+
90
+ def comparison(operator, version)
91
+ return unless parseable?(version)
92
+
93
+ "#{operator} #{version}"
94
+ end
95
+
96
+ def parseable?(version)
97
+ Gem::Version.correct?(version)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "endoflife_helper"
4
+
5
+ module StillActive
6
+ # Python's calibration for the language-runtime ceiling: the thin sibling of
7
+ # RubyHelper.supported_ruby_range. Python declares its runtime constraint as a
8
+ # PEP 440 `requires_python` specifier (translate with Pep440Helper before
9
+ # handing it to RuntimeCeilingHelper), and its release calendar lives in the
10
+ # same endoflife.date feed. Everything ecosystem-neutral is in EndoflifeHelper;
11
+ # Python only chooses the feed path.
12
+ module PythonHelper
13
+ extend self
14
+
15
+ def supported_python_range
16
+ EndoflifeHelper.support_window(feed_path: "/api/python.json")
17
+ end
18
+ end
19
+ end