still_active 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,15 @@ require "json"
5
5
 
6
6
  module StillActive
7
7
  module HttpHelper
8
- TRUSTED_HOSTS = ["github.com", "gitlab.com", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
8
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
9
9
  MAX_REDIRECTS = 3
10
+ # Ceiling on a single response body. These are metadata endpoints (version
11
+ # lists, scorecards, advisories); legitimate responses are well under this.
12
+ # A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-
13
+ # registerable, so without a cap a hostile or broken source could stream a
14
+ # multi-GB body and OOM the process. 16 MiB leaves generous headroom for a
15
+ # gem with thousands of versions while bounding worst-case memory.
16
+ MAX_BODY_BYTES = 16 * 1024 * 1024
10
17
 
11
18
  extend self
12
19
 
@@ -15,35 +22,74 @@ module StillActive
15
22
  uri.path = path
16
23
  uri.query = URI.encode_www_form(params) unless params.empty?
17
24
 
25
+ request_json(uri, headers) { |target| Net::HTTP::Get.new(target) }
26
+ end
27
+
28
+ def post_json(base_uri, path, body:, headers: {})
29
+ uri = base_uri.dup
30
+ uri.path = path
31
+
32
+ request_json(uri, headers) do |target|
33
+ request = Net::HTTP::Post.new(target)
34
+ request.body = body
35
+ request
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Two URIs share an origin when scheme, host, and port all match. URI fills
42
+ # in the default port (443 for https), so the bare and explicit forms of the
43
+ # same origin compare equal.
44
+ def same_origin?(a, b)
45
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port
46
+ end
47
+
48
+ # Runs the request, following up to MAX_REDIRECTS trusted-host redirects,
49
+ # and returns the parsed JSON (or nil). The block is yielded each URI and
50
+ # returns the request object, so GET and POST share the redirect/auth/cap
51
+ # logic.
52
+ def request_json(uri, headers)
18
53
  MAX_REDIRECTS.times do
19
54
  http = Net::HTTP.new(uri.host, uri.port)
20
55
  http.use_ssl = true
21
56
  http.open_timeout = 10
22
57
  http.read_timeout = 10
23
58
 
24
- request = Net::HTTP::Get.new(uri)
59
+ request = yield(uri)
25
60
  headers.each { |key, value| request[key] = value }
26
61
 
27
- response = http.request(request)
62
+ outcome, payload = perform(http, request, uri)
63
+ case outcome
64
+ when :done
65
+ return payload
66
+ when :stop
67
+ return
68
+ when :redirect
69
+ location = payload["Location"]
70
+ if location.nil? || location.empty?
71
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
72
+ return
73
+ end
28
74
 
29
- if response.is_a?(Net::HTTPRedirection)
30
- redirect_uri = uri + response["Location"]
75
+ redirect_uri = uri + location
31
76
  unless TRUSTED_HOSTS.include?(redirect_uri.host)
32
77
  $stderr.puts("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
33
78
  return
34
79
  end
80
+ # We dial every request over TLS (use_ssl = true). A redirect that
81
+ # downgrades to http is either a misconfiguration or a downgrade
82
+ # attempt; refuse it rather than silently dialing http-over-TLS.
83
+ unless redirect_uri.scheme == "https"
84
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
85
+ return
86
+ end
35
87
  $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
88
+ # Auth is scoped to an origin (scheme + host + port), not just a host:
89
+ # a different port is a different service and must not inherit the token.
90
+ headers = {} unless same_origin?(uri, redirect_uri)
37
91
  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
92
  end
45
-
46
- return JSON.parse(response.body)
47
93
  end
48
94
 
49
95
  $stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
@@ -54,6 +100,47 @@ module StillActive
54
100
  rescue JSON::ParserError => e
55
101
  $stderr.puts("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
56
102
  nil
103
+ rescue URI::InvalidURIError => e
104
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Issues the request in streaming form so the body is read against a size
109
+ # cap rather than buffered whole. Returns one of:
110
+ # [:redirect, response] a 3xx, for the caller to follow
111
+ # [:stop, nil] non-success (warns unless 404), or body over cap
112
+ # [:done, parsed] a 2xx with parsed JSON
113
+ # Redirect and non-success bodies are never read: returning from the block
114
+ # unwinds through Net::HTTP, which closes the connection without draining
115
+ # the body, so a huge error/redirect body can't OOM us either.
116
+ def perform(http, request, uri)
117
+ http.request(request) do |response|
118
+ return [:redirect, response] if response.is_a?(Net::HTTPRedirection)
119
+
120
+ unless response.is_a?(Net::HTTPSuccess)
121
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
122
+ return [:stop, nil]
123
+ end
124
+
125
+ body = read_capped_body(response, uri)
126
+ return [:stop, nil] if body.nil?
127
+
128
+ return [:done, JSON.parse(body)]
129
+ end
130
+ end
131
+
132
+ # Reads the body in chunks, abandoning the read (returns nil) as soon as it
133
+ # exceeds MAX_BODY_BYTES so an oversized body is never fully materialized.
134
+ def read_capped_body(response, uri)
135
+ body = +""
136
+ response.read_body do |chunk|
137
+ body << chunk
138
+ if body.bytesize > MAX_BODY_BYTES
139
+ $stderr.puts("warning: #{uri.host}#{uri.path} response exceeded #{MAX_BODY_BYTES} bytes, skipping")
140
+ return nil
141
+ end
142
+ end
143
+ body
57
144
  end
58
145
  end
59
146
  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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "markdown_escape"
3
4
  require_relative "vulnerability_helper"
5
+ require_relative "activity_helper"
4
6
 
5
7
  module StillActive
6
8
  module MarkdownHelper
@@ -91,12 +93,31 @@ module StillActive
91
93
  return "" if flagged.empty?
92
94
 
93
95
  lines = ["", "**Alternatives** (Ruby Toolbox leads, verify fit):"]
94
- flagged.each { |name, data| lines << "- `#{name}`: #{data[:alternatives].join(", ")}" }
96
+ flagged.each { |name, data| lines << "- #{MarkdownEscape.code_span(name)}: #{MarkdownEscape.inline(data[:alternatives].join(", "))}" }
97
+ lines.join("\n")
98
+ end
99
+
100
+ # Flagged transitive gems can't be swapped directly; name the direct
101
+ # dependency that pulls each one in so the finding becomes actionable (#60).
102
+ def transitive_section(result)
103
+ flagged = result.select do |_name, data|
104
+ data[:direct] == false && Array(data[:dependency_path]).length >= 2 && transitive_flagged?(data)
105
+ end
106
+ return "" if flagged.empty?
107
+
108
+ lines = ["", "**Transitive findings** (pulled in by a direct dependency):"]
109
+ flagged.each do |name, data|
110
+ lines << "- #{MarkdownEscape.code_span(name)} via #{MarkdownEscape.code_span(data[:dependency_path].first)}"
111
+ end
95
112
  lines.join("\n")
96
113
  end
97
114
 
98
115
  private
99
116
 
117
+ def transitive_flagged?(data)
118
+ [:archived, :critical].include?(ActivityHelper.activity_level(data)) || data[:vulnerability_count].to_i.positive?
119
+ end
120
+
100
121
  def version_with_date(text:, url:, date:)
101
122
  version_part = markdown_url(text: text, url: url)
102
123
  return if version_part.nil?
@@ -128,7 +149,7 @@ module StillActive
128
149
  def format_license(license)
129
150
  return "-" if license.nil? || license.empty?
130
151
 
131
- license
152
+ MarkdownEscape.cell(license)
132
153
  end
133
154
 
134
155
  def format_vulns(data)
@@ -141,14 +162,15 @@ module StillActive
141
162
  ids = vulnerabilities.flat_map { |v| [v[:id], *v[:aliases]] }.compact.uniq.first(3)
142
163
 
143
164
  parts = [severity ? "#{count} (#{severity})" : count.to_s]
144
- parts << ids.join(", ") unless ids.empty?
165
+ parts << MarkdownEscape.cell(ids.join(", ")) unless ids.empty?
145
166
  parts.join(" ")
146
167
  end
147
168
 
148
169
  def markdown_url(text:, url:)
149
- return text if url.nil?
170
+ safe_text = MarkdownEscape.link_text(text)
171
+ return safe_text if url.nil?
150
172
 
151
- "[#{text}](#{url})"
173
+ "[#{safe_text}](#{MarkdownEscape.url(url)})"
152
174
  end
153
175
 
154
176
  def year_month(time_object)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "bundler_helper"
4
5
  require_relative "http_helper"
5
6
  require_relative "libyear_helper"
6
7
 
@@ -48,10 +49,10 @@ module StillActive
48
49
  end
49
50
 
50
51
  def lockfile_ruby_version
51
- gemfile = ENV["BUNDLE_GEMFILE"]
52
- return unless gemfile
53
-
54
- lockfile_path = "#{gemfile}.lock"
52
+ # Use the configured gemfile (honours --gemfile) rather than the ambient
53
+ # BUNDLE_GEMFILE, which only happened to work when BundlerHelper set it as
54
+ # a side-effect. Refs #42.
55
+ lockfile_path = BundlerHelper.lockfile_path_for(File.expand_path(StillActive.config.gemfile_path))
55
56
  return unless File.exist?(lockfile_path)
56
57
 
57
58
  content = File.read(lockfile_path)
@@ -5,6 +5,7 @@ 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"
8
9
 
9
10
  module StillActive
10
11
  # Renders a still_active workflow result as a SARIF 2.1.0 document.
@@ -19,7 +20,7 @@ module StillActive
19
20
 
20
21
  LIBYEAR_THRESHOLD = 1.0
21
22
  SCORECARD_LOW_THRESHOLD = 4.0
22
- ABANDONED_SECONDS = 2 * 365 * 24 * 60 * 60 # 2 years
23
+ SECONDS_PER_YEAR = 365 * 24 * 60 * 60 # for the human-readable "in N years"
23
24
 
24
25
  # result: same hash StillActive::Workflow.call returns (gem_name => gem_data)
25
26
  # ruby_info: optional Ruby freshness hash (or nil)
@@ -98,43 +99,68 @@ module StillActive
98
99
  location = location_for(name, line_index, lockfile_uri)
99
100
 
100
101
  if data[:archived]
101
- out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}.", location)
102
+ 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
103
  end
103
104
 
104
105
  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",
106
+ if ActivityHelper.activity_level(data) == :critical
107
+ activity = ActivityHelper.last_activity(data)
108
+ years = ((Time.now - activity[:date]) / SECONDS_PER_YEAR).round(1)
109
+ noun = activity[:kind] == :release ? "no release" : "no commits"
110
+ out << mark_suppressed(
111
+ result(
112
+ "SA002",
113
+ name,
114
+ "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
115
+ location,
116
+ ),
110
117
  name,
111
- "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}.",
112
- location,
118
+ :activity,
113
119
  )
114
120
  end
115
121
  end
116
122
 
117
123
  Array(data[:vulnerabilities]).each do |vuln|
118
- out << vulnerability_result(name, version, vuln, location)
124
+ out << vulnerability_result(name, version, vuln, location, data)
119
125
  end
120
126
 
121
127
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
122
128
  latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
123
- out << result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}.", location)
129
+ out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
124
130
  end
125
131
 
126
132
  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)
133
+ out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
128
134
  end
129
135
 
130
136
  if data[:version_yanked]
131
- out << result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location)
137
+ out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
132
138
  end
133
139
 
134
140
  out
135
141
  end
136
142
 
137
- def vulnerability_result(name, version, vuln, location)
143
+ # Attaches a SARIF native suppressions[] entry when this finding is covered
144
+ # by a whole-gem --ignore or a granular .still_active.yml suppression, so a
145
+ # GitHub code-scanning consumer renders it dismissed rather than open. The
146
+ # suppression's reason rides along as the justification.
147
+ def mark_suppressed(result_hash, gem_name, signal, advisory: nil, aliases: [])
148
+ config = StillActive.config
149
+ if config.ignored_gems.include?(gem_name)
150
+ result_hash["suppressions"] = [{ "kind" => "external", "justification" => "ignored via --ignore" }]
151
+ return result_hash
152
+ end
153
+
154
+ entry = config.suppressions.match(gem: gem_name, signal: signal, advisory: advisory, aliases: aliases)
155
+ return result_hash unless entry
156
+
157
+ suppression = { "kind" => "external" }
158
+ suppression["justification"] = entry.reason if entry.reason
159
+ result_hash["suppressions"] = [suppression]
160
+ result_hash
161
+ end
162
+
163
+ def vulnerability_result(name, version, vuln, location, data = {})
138
164
  score = vuln[:cvss3_score] || vuln[:cvss2_score]
139
165
  level = Sarif::Rules.cvss_to_level(score)
140
166
  severity = Sarif::Rules.cvss_to_security_severity(score)
@@ -146,13 +172,23 @@ module StillActive
146
172
  base = result(
147
173
  "SA003",
148
174
  name,
149
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}.",
175
+ "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{transitive_suffix(data)}.",
150
176
  location,
151
177
  level: level,
152
178
  fp_extra: advisory_id,
153
179
  )
154
180
  base["properties"] = { "security-severity" => severity } if severity
155
- base
181
+ mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
182
+ end
183
+
184
+ # Names the direct dependency a transitive flagged gem rides in on, so a
185
+ # code-scanning consumer gets the actionable "replace your direct gem" hop
186
+ # instead of an un-actionable transitive finding (#60).
187
+ def transitive_suffix(data)
188
+ path = data[:dependency_path]
189
+ return "" unless data[:direct] == false && path && path.length >= 2
190
+
191
+ " (transitive, pulled in by #{path.first})"
156
192
  end
157
193
 
158
194
  def ruby_eol_result(ruby_info, ruby_line, lockfile_uri)
@@ -211,17 +247,8 @@ module StillActive
211
247
  Sarif::Rules.all.index { |r| r[:id] == rule_id }
212
248
  end
213
249
 
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
250
  def format_date(value)
224
- t = parse_time(value)
251
+ t = ActivityHelper.parse_time(value)
225
252
  t ? t.utc.strftime("%Y-%m-%d") : value.to_s
226
253
  end
227
254
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activity_helper"
4
+
5
+ module StillActive
6
+ # Builds the JSON output's summary{} digest: the headline posture of the audit
7
+ # in one object, so a machine/LLM consumer reads the totals directly instead
8
+ # of iterating every gem. Counts are derived from the canonical per-gem fields
9
+ # (activity_level, archived, up_to_date, vulnerability_count) so they never
10
+ # drift from a separately-thresholded SARIF rule.
11
+ module SummaryHelper
12
+ ACTIVITY_LEVELS = [:ok, :stale, :critical, :archived, :unknown].freeze
13
+
14
+ extend self
15
+
16
+ def summarize(result, ruby_info: nil)
17
+ activity = ACTIVITY_LEVELS.to_h { |level| [level, 0] }
18
+ archived = up_to_date = outdated = vulnerable_gems = vulnerabilities = direct = 0
19
+
20
+ result.each_value do |data|
21
+ activity[ActivityHelper.activity_level(data)] += 1
22
+ direct += 1 if data[:direct]
23
+ archived += 1 if data[:archived]
24
+ up_to_date += 1 if data[:up_to_date] == true
25
+ outdated += 1 if data[:up_to_date] == false
26
+ count = data[:vulnerability_count].to_i
27
+ next unless count.positive?
28
+
29
+ vulnerable_gems += 1
30
+ vulnerabilities += count
31
+ end
32
+
33
+ summary = {
34
+ total_gems: result.size,
35
+ direct: direct,
36
+ transitive: result.size - direct,
37
+ activity: activity,
38
+ archived: archived,
39
+ up_to_date: up_to_date,
40
+ outdated: outdated,
41
+ vulnerable_gems: vulnerable_gems,
42
+ vulnerabilities: vulnerabilities,
43
+ }
44
+ summary[:ruby_eol] = ruby_info[:eol] == true if ruby_info
45
+ summary
46
+ end
47
+ end
48
+ end