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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +145 -6
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +58 -6
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- metadata +72 -11
data/lib/helpers/http_helper.rb
CHANGED
|
@@ -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 =
|
|
74
|
+
request = yield(uri)
|
|
25
75
|
headers.each { |key, value| request[key] = value }
|
|
26
76
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 << "-
|
|
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
|
-
|
|
144
|
-
parts
|
|
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
|
-
|
|
287
|
+
safe_text = MarkdownEscape.link_text(text)
|
|
288
|
+
return safe_text if url.nil?
|
|
150
289
|
|
|
151
|
-
"[#{
|
|
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
|