still_active 1.4.2 → 1.6.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.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+ require_relative "vulnerability_helper"
7
+
8
+ module StillActive
9
+ # Renders a still_active workflow result as a CycloneDX SBOM. Emits 1.6 by
10
+ # default (the version mainstream consumers — Dependency-Track via
11
+ # cyclonedx-core-java, Trivy/Syft via cyclonedx-go — actually ingest as of
12
+ # 2026); 1.7 is opt-in. Our emitted subset is identical across both versions,
13
+ # so only the specVersion string changes.
14
+ #
15
+ # Maintenance signals that have no native CycloneDX field (scorecard, libyear,
16
+ # archived, last commit) are emitted as `still_active:`-namespaced component
17
+ # properties — lossy by spec design, ignorable by consumers that don't care.
18
+ module CyclonedxHelper
19
+ extend self
20
+
21
+ SUPPORTED_SPEC_VERSIONS = ["1.6", "1.7"].freeze
22
+
23
+ # result: gem_name => gem_data (as StillActive::Workflow.call returns)
24
+ # ruby_info: Ruby freshness hash or nil
25
+ # now: injectable clock so output is deterministic in tests
26
+ def render(result:, ruby_info:, tool_version:, spec_version: "1.6", now: Time.now.utc)
27
+ components = build_components(result, ruby_info)
28
+ vulnerabilities = build_vulnerabilities(result)
29
+
30
+ document = {
31
+ "bomFormat" => "CycloneDX",
32
+ "specVersion" => spec_version,
33
+ "serialNumber" => deterministic_serial(components),
34
+ "version" => 1,
35
+ "metadata" => {
36
+ "timestamp" => now.iso8601,
37
+ "tools" => [{ "vendor" => "SeanLF", "name" => "still_active", "version" => tool_version }],
38
+ },
39
+ "components" => components,
40
+ }
41
+ document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
42
+ JSON.pretty_generate(document)
43
+ end
44
+
45
+ private
46
+
47
+ def build_components(result, ruby_info)
48
+ components = result.sort_by { |name, _| name.to_s }.map { |name, data| gem_component(name.to_s, data) }
49
+ components << ruby_component(ruby_info) if ruby_info && ruby_info[:version]
50
+ components
51
+ end
52
+
53
+ def gem_component(name, data)
54
+ version = data[:version_used]
55
+ component = { "type" => "library", "name" => name }
56
+ component["version"] = version if version
57
+ component["bom-ref"] = bom_ref(name, data)
58
+ component["purl"] = purl(name, version) if data[:source_type] == :rubygems && version
59
+ component["licenses"] = licenses(data[:license]) if data[:license]
60
+ if data[:repository_url]
61
+ component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
62
+ end
63
+ properties = gem_properties(data)
64
+ component["properties"] = properties unless properties.empty?
65
+ component
66
+ end
67
+
68
+ def bom_ref(name, data)
69
+ version = data[:version_used]
70
+ return purl(name, version) if data[:source_type] == :rubygems && version
71
+
72
+ "#{data[:source_type]}-source:#{name}@#{version || "unknown"}"
73
+ end
74
+
75
+ def purl(name, version)
76
+ "pkg:gem/#{name}@#{version}"
77
+ end
78
+
79
+ # VersionHelper joins multiple SPDX ids with ", " for terminal/markdown
80
+ # display; CycloneDX's license.id must be a single SPDX id, so split back
81
+ # into one entry per license rather than emitting an invalid joined id.
82
+ def licenses(license)
83
+ license.split(", ").map { |id| { "license" => { "id" => id } } }
84
+ end
85
+
86
+ def gem_properties(data)
87
+ {
88
+ "still_active:archived" => boolean_property(data[:archived]),
89
+ "still_active:scorecard_score" => data[:scorecard_score]&.to_s,
90
+ "still_active:libyear" => data[:libyear]&.to_s,
91
+ "still_active:last_commit_date" => iso8601(data[:last_commit_date]),
92
+ "still_active:version_yanked" => boolean_property(data[:version_yanked]),
93
+ }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
94
+ end
95
+
96
+ def ruby_component(ruby_info)
97
+ {
98
+ "type" => "platform",
99
+ "name" => "ruby",
100
+ "version" => ruby_info[:version],
101
+ "bom-ref" => "platform:ruby@#{ruby_info[:version]}",
102
+ "properties" => [
103
+ { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
104
+ { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
105
+ ].reject { |p| p["value"].nil? },
106
+ }
107
+ end
108
+
109
+ def build_vulnerabilities(result)
110
+ result.sort_by { |name, _| name.to_s }.flat_map do |name, data|
111
+ ref = bom_ref(name.to_s, data)
112
+ (data[:vulnerabilities] || []).map { |advisory| vulnerability(advisory, ref) }
113
+ end
114
+ end
115
+
116
+ def vulnerability(advisory, component_ref)
117
+ entry = {
118
+ "bom-ref" => "#{advisory[:id]}:#{component_ref}",
119
+ "id" => advisory[:id],
120
+ "affects" => [{ "ref" => component_ref }],
121
+ }
122
+ entry["source"] = { "name" => advisory[:source] } if advisory[:source]
123
+ advisory_rating = rating(advisory)
124
+ entry["ratings"] = [advisory_rating] if advisory_rating
125
+ entry
126
+ end
127
+
128
+ def rating(advisory)
129
+ score = advisory[:cvss3_score] || advisory[:cvss2_score]
130
+ return if score.nil?
131
+
132
+ method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2"
133
+ rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method }
134
+ rating["vector"] = advisory[:cvss3_vector] if advisory[:cvss3_vector]
135
+ rating
136
+ end
137
+
138
+ def boolean_property(value)
139
+ return if value.nil?
140
+
141
+ value.to_s
142
+ end
143
+
144
+ def iso8601(time)
145
+ return if time.nil?
146
+
147
+ time.respond_to?(:iso8601) ? time.iso8601 : time.to_s
148
+ end
149
+
150
+ # Deterministic urn:uuid derived from the component identifiers, so two SBOMs
151
+ # of the same lockfile are byte-identical (diffable; golden-test friendly).
152
+ def deterministic_serial(components)
153
+ basis = components.map { |c| c["bom-ref"] }.sort.join("\n")
154
+ hex = Digest::SHA256.hexdigest(basis)
155
+ uuid = "#{hex[0, 8]}-#{hex[8, 4]}-5#{hex[13, 3]}-8#{hex[17, 3]}-#{hex[20, 12]}"
156
+ "urn:uuid:#{uuid}"
157
+ end
158
+ end
159
+ end
@@ -26,8 +26,8 @@ module StillActive
26
26
  end
27
27
 
28
28
  def markdown_table_header_line
29
- "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \
30
- "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |"
29
+ "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |\n" \
30
+ "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- | ------- |"
31
31
  end
32
32
 
33
33
  def markdown_table_body_line(gem_name:, data:)
@@ -78,11 +78,23 @@ module StillActive
78
78
  formatted_latest_pre_release || unsure,
79
79
  formatted_last_commit || unsure,
80
80
  format_libyear(data[:libyear]),
81
+ format_license(data[:license]),
81
82
  ]
82
83
 
83
84
  "| #{cells.join(" | ")} |"
84
85
  end
85
86
 
87
+ def alternatives_section(result)
88
+ flagged = result.select do |_name, data|
89
+ data[:alternatives] && !data[:alternatives].empty?
90
+ end
91
+ return "" if flagged.empty?
92
+
93
+ lines = ["", "**Alternatives** (Ruby Toolbox leads, verify fit):"]
94
+ flagged.each { |name, data| lines << "- `#{name}`: #{data[:alternatives].join(", ")}" }
95
+ lines.join("\n")
96
+ end
97
+
86
98
  private
87
99
 
88
100
  def version_with_date(text:, url:, date:)
@@ -113,6 +125,12 @@ module StillActive
113
125
  "#{value}y"
114
126
  end
115
127
 
128
+ def format_license(license)
129
+ return "-" if license.nil? || license.empty?
130
+
131
+ license
132
+ end
133
+
116
134
  def format_vulns(data)
117
135
  count = data[:vulnerability_count]
118
136
  return StillActive.config.unsure_emoji if count.nil?
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Optional second vulnerability source: rubysec/ruby-advisory-db, read through
5
+ # bundler-audit's own loader when the user has it installed. We are a consumer —
6
+ # no YAML parsing or version-range matching of our own. Advisories are mapped
7
+ # into the same shape as deps.dev results and merged by VulnerabilityHelper.
8
+ #
9
+ # Verified against bundler-audit 0.9.3: Advisory CVSS scores live in #to_h
10
+ # (:cvss_v3 / :cvss_v2), not in dedicated methods; Database.new raises
11
+ # ArgumentError when the ~/.local/share/ruby-advisory-db checkout is absent.
12
+ module RubyAdvisoryDb
13
+ extend self
14
+
15
+ STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
16
+
17
+ # bundler-audit's Database#check_gem expects an object responding to
18
+ # #name and #version (a Gem::Version).
19
+ GemRef = Struct.new(:name, :version)
20
+
21
+ # Returns a loaded bundler-audit Database, or nil when bundler-audit isn't
22
+ # installed or its advisory checkout is absent. Never raises — a missing
23
+ # second source just means we fall back to deps.dev only.
24
+ def load
25
+ require "bundler/audit"
26
+ require "bundler/audit/database"
27
+ database = Bundler::Audit::Database.new
28
+ warn_if_stale(database)
29
+ database
30
+ rescue LoadError
31
+ nil # bundler-audit not installed
32
+ rescue ArgumentError
33
+ warn("still_active: ruby-advisory-db not found — run `bundle audit update` to enable dual-source vulnerability data")
34
+ nil
35
+ end
36
+
37
+ # Maps advisories the database reports for gem_name@version into our
38
+ # vulnerability shape. Returns [] when the database is unavailable or the
39
+ # version can't be parsed (e.g. a git sha). A malformed advisory in the
40
+ # checkout (a corrupt/partial `bundle audit update`) is surfaced, not
41
+ # swallowed — silently returning [] there would hide a missed vulnerability.
42
+ def advisories_for(database:, gem_name:, version:)
43
+ return [] if database.nil?
44
+
45
+ parsed = parse_version(version)
46
+ return [] if parsed.nil?
47
+
48
+ advisories = []
49
+ database.check_gem(GemRef.new(gem_name, parsed)) { |advisory| advisories << to_vulnerability(advisory) }
50
+ advisories
51
+ rescue Gem::Requirement::BadRequirementError => e
52
+ warn("still_active: ruby-advisory-db has a malformed advisory for #{gem_name} (#{e.message}) — run `bundle audit update` to repair the checkout")
53
+ []
54
+ end
55
+
56
+ # Translates a bundler-audit Advisory into the deps.dev-compatible hash.
57
+ # bundler-audit has no CVSS vector, so cvss3_vector is always nil here.
58
+ def to_vulnerability(advisory)
59
+ primary = advisory.ghsa_id || advisory.cve_id || advisory.id
60
+ details = advisory.to_h
61
+ {
62
+ id: primary,
63
+ url: details[:url],
64
+ title: details[:title],
65
+ aliases: advisory.identifiers.reject { |identifier| identifier == primary },
66
+ cvss3_score: details[:cvss_v3],
67
+ cvss3_vector: nil,
68
+ cvss2_score: details[:cvss_v2],
69
+ source: "ruby-advisory-db",
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ # nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
76
+ # has nothing to match in the advisory DB, so the caller returns [].
77
+ def parse_version(version)
78
+ Gem::Version.new(version)
79
+ rescue ArgumentError
80
+ nil
81
+ end
82
+
83
+ def warn_if_stale(database)
84
+ updated = database.last_updated_at
85
+ return if updated.nil? # can't determine age — don't warn (not a swallowed error)
86
+
87
+ age = Time.now - updated
88
+ return if age < STALE_AFTER_SECONDS
89
+
90
+ warn("still_active: ruby-advisory-db is #{(age / 86_400).round} days old — run `bundle audit update` for current advisories")
91
+ end
92
+ end
93
+ end
@@ -98,7 +98,7 @@ module StillActive
98
98
  location = location_for(name, line_index, lockfile_uri)
99
99
 
100
100
  if data[:archived]
101
- out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}.", location)
101
+ out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}.", location)
102
102
  end
103
103
 
104
104
  unless data[:archived]
@@ -108,7 +108,7 @@ module StillActive
108
108
  out << result(
109
109
  "SA002",
110
110
  name,
111
- "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")}).",
111
+ "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}.",
112
112
  location,
113
113
  )
114
114
  end
@@ -228,5 +228,12 @@ module StillActive
228
228
  def repo_suffix(data)
229
229
  data[:repository_url] ? " (#{data[:repository_url]})" : ""
230
230
  end
231
+
232
+ def alternatives_suffix(data)
233
+ leads = data[:alternatives]
234
+ return "" if leads.nil? || leads.empty?
235
+
236
+ " Consider: #{leads.join(", ")}"
237
+ end
231
238
  end
232
239
  end
@@ -10,16 +10,21 @@ module StillActive
10
10
  module TerminalHelper
11
11
  extend self
12
12
 
13
- HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"].freeze
13
+ HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "License"].freeze
14
14
 
15
15
  def render(result, ruby_info: nil)
16
- rows = result.keys.sort.map { |name| build_row(name, result[name]) }
16
+ names = result.keys.sort
17
+ rows = names.map { |name| build_row(name, result[name]) }
17
18
  widths = column_widths(rows)
18
19
 
19
20
  lines = []
20
21
  lines << header_line(widths)
21
22
  lines << separator_line(widths)
22
- rows.each { |row| lines << row_line(row, widths) }
23
+ names.each_with_index do |name, i|
24
+ lines << row_line(rows[i], widths)
25
+ extra = alternatives_line(result[name])
26
+ lines << extra if extra
27
+ end
23
28
  lines << ""
24
29
  lines << summary_line(result)
25
30
  lines << ruby_summary_line(ruby_info) if ruby_info
@@ -35,9 +40,16 @@ module StillActive
35
40
  format_activity(data),
36
41
  format_scorecard(data[:scorecard_score]),
37
42
  format_vulns(data),
43
+ format_license(data[:license]),
38
44
  ]
39
45
  end
40
46
 
47
+ def format_license(license)
48
+ return AnsiHelper.dim("-") if license.nil? || license.empty?
49
+
50
+ license
51
+ end
52
+
41
53
  def format_version(data)
42
54
  used = data[:version_used]
43
55
  latest = data[:latest_version]
@@ -118,6 +130,18 @@ module StillActive
118
130
  .join
119
131
  end
120
132
 
133
+ def alternatives_line(data)
134
+ level = ActivityHelper.activity_level(data)
135
+ return unless [:archived, :critical].include?(level)
136
+
137
+ leads = data[:alternatives]
138
+ if leads && !leads.empty?
139
+ AnsiHelper.dim(" ↳ leads (Ruby Toolbox): #{leads.join(" · ")} (verify fit)")
140
+ elsif !StillActive.config.alternatives
141
+ AnsiHelper.dim(" ↳ run with --alternatives for maintained replacements")
142
+ end
143
+ end
144
+
121
145
  def ruby_summary_line(ruby_info)
122
146
  version = ruby_info[:version]
123
147
  latest = ruby_info[:latest_version]
@@ -38,6 +38,15 @@ module StillActive
38
38
  Time.parse(release_date) unless release_date.nil?
39
39
  end
40
40
 
41
+ # SPDX license identifier(s) from the RubyGems versions payload.
42
+ # Comma-joined when a gem declares more than one. nil when unknown.
43
+ def license(version_hash:)
44
+ licenses = version_hash&.dig("licenses")
45
+ return if licenses.nil? || licenses.empty?
46
+
47
+ licenses.join(", ")
48
+ end
49
+
41
50
  private
42
51
 
43
52
  def normalize_version(version)
@@ -22,8 +22,43 @@ module StillActive
22
22
  SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold)
23
23
  end
24
24
 
25
+ # Combines advisories from deps.dev and ruby-advisory-db (via bundler-audit),
26
+ # deduplicating on shared identifiers. deps.dev is preferred for CVSS/title/url
27
+ # (it carries the vector string); ruby-advisory-db fills gaps. Advisories present
28
+ # in both sources are tagged source: "merged"; otherwise the per-source tag is kept.
29
+ def merge_advisories(deps_dev:, ruby_advisory_db:)
30
+ merged = deps_dev.map(&:dup)
31
+
32
+ ruby_advisory_db.each do |advisory|
33
+ existing = merged.find { |m| identifiers(m).intersect?(identifiers(advisory)) }
34
+ if existing
35
+ combine!(existing, advisory)
36
+ else
37
+ merged << advisory
38
+ end
39
+ end
40
+
41
+ merged
42
+ end
43
+
25
44
  private
26
45
 
46
+ def identifiers(advisory)
47
+ [advisory[:id], *advisory[:aliases]].compact
48
+ end
49
+
50
+ # Folds a ruby-advisory-db advisory into a matching deps.dev advisory in place:
51
+ # deps.dev values win where present, ruby-advisory-db fills nils, aliases union.
52
+ def combine!(into, from)
53
+ into[:cvss3_score] ||= from[:cvss3_score]
54
+ into[:cvss2_score] ||= from[:cvss2_score]
55
+ into[:cvss3_vector] ||= from[:cvss3_vector]
56
+ into[:title] ||= from[:title]
57
+ into[:url] ||= from[:url]
58
+ into[:aliases] = (identifiers(into) | identifiers(from)).reject { |id| id == into[:id] }.sort
59
+ into[:source] = "merged"
60
+ end
61
+
27
62
  def severity_label(score)
28
63
  case score
29
64
  when 9.0..Float::INFINITY then "critical"
@@ -3,7 +3,9 @@
3
3
  require_relative "options"
4
4
  require_relative "diff"
5
5
  require_relative "../helpers/activity_helper"
6
+ require_relative "../helpers/bot_context"
6
7
  require_relative "../helpers/bundler_helper"
8
+ require_relative "../helpers/cyclonedx_helper"
7
9
  require_relative "../helpers/diff_markdown_helper"
8
10
  require_relative "../helpers/emoji_helper"
9
11
  require_relative "../helpers/markdown_helper"
@@ -26,6 +28,8 @@ module StillActive
26
28
  end
27
29
  end
28
30
 
31
+ warn_output_flag_conflicts(options)
32
+
29
33
  result = if $stderr.tty?
30
34
  Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
31
35
  else
@@ -34,11 +38,14 @@ module StillActive
34
38
  $stderr.print("\r\e[K") if $stderr.tty?
35
39
 
36
40
  ruby_info = Workflow.ruby_freshness
41
+ pr_context = BotContext.detect
37
42
 
38
43
  if (baseline_path = StillActive.config.baseline_path)
39
- emit_diff(result, ruby_info, baseline_path)
44
+ emit_diff(result, ruby_info, baseline_path, pr_context)
40
45
  elsif (sarif_path = StillActive.config.sarif_path)
41
46
  emit_sarif(result, ruby_info, sarif_path)
47
+ elsif (cyclonedx_path = StillActive.config.cyclonedx_path)
48
+ emit_cyclonedx(result, ruby_info, cyclonedx_path)
42
49
  else
43
50
  case resolve_format
44
51
  when :json
@@ -49,11 +56,13 @@ module StillActive
49
56
  gems: result,
50
57
  }
51
58
  output[:ruby] = ruby_info if ruby_info
59
+ output[:pr_context] = pr_context if pr_context
52
60
  puts output.to_json
53
61
  when :terminal
62
+ puts BotContext.summary(pr_context) if pr_context
54
63
  puts TerminalHelper.render(result, ruby_info: ruby_info)
55
64
  when :markdown
56
- render_markdown(result, ruby_info: ruby_info)
65
+ render_markdown(result, ruby_info: ruby_info, pr_context: pr_context)
57
66
  end
58
67
  end
59
68
 
@@ -62,6 +71,29 @@ module StillActive
62
71
 
63
72
  private
64
73
 
74
+ # The output destinations are mutually exclusive and resolved by precedence
75
+ # (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than
76
+ # silently dropping the loser when more than one is set.
77
+ def warn_output_flag_conflicts(options)
78
+ modes = active_output_modes
79
+ if modes.size > 1
80
+ $stderr.puts("warning: multiple output modes set (#{modes.join(", ")}); using #{modes.first}, ignoring #{modes.drop(1).join(", ")}")
81
+ end
82
+ if options[:provided_cyclonedx_version] && StillActive.config.cyclonedx_path.nil?
83
+ $stderr.puts("warning: --cyclonedx-version has no effect without --cyclonedx")
84
+ end
85
+ end
86
+
87
+ # In precedence order, so the first entry is the one that actually runs.
88
+ def active_output_modes
89
+ config = StillActive.config
90
+ [
91
+ ("--baseline" if config.baseline_path),
92
+ ("--sarif" if config.sarif_path),
93
+ ("--cyclonedx" if config.cyclonedx_path),
94
+ ].compact
95
+ end
96
+
65
97
  def emit_sarif(result, ruby_info, sarif_path)
66
98
  lockfile = resolve_lockfile_path(StillActive.config.gemfile_path)
67
99
  unless File.exist?(lockfile)
@@ -83,6 +115,21 @@ module StillActive
83
115
  end
84
116
  end
85
117
 
118
+ def emit_cyclonedx(result, ruby_info, cyclonedx_path)
119
+ sbom = CyclonedxHelper.render(
120
+ result: result,
121
+ ruby_info: ruby_info,
122
+ tool_version: StillActive::VERSION,
123
+ spec_version: StillActive.config.cyclonedx_version,
124
+ )
125
+
126
+ if cyclonedx_path == "-"
127
+ puts sbom
128
+ else
129
+ File.write(cyclonedx_path, sbom)
130
+ end
131
+ end
132
+
86
133
  # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise <gemfile>.lock.
87
134
  def resolve_lockfile_path(gemfile)
88
135
  return gemfile.sub(/gems\.rb\z/, "gems.locked") if gemfile.end_with?("gems.rb")
@@ -90,10 +137,11 @@ module StillActive
90
137
  "#{gemfile}.lock"
91
138
  end
92
139
 
93
- def emit_diff(result, ruby_info, baseline_path)
140
+ def emit_diff(result, ruby_info, baseline_path, pr_context = nil)
94
141
  current = current_snapshot(result, ruby_info)
95
142
  baseline = JSON.parse(File.read(baseline_path))
96
143
  diff = Diff.call(baseline: baseline, current: current)
144
+ puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
97
145
  puts DiffMarkdownHelper.render(diff)
98
146
  exit(1) if diff.regressions.any?
99
147
  rescue JSON::ParserError => e
@@ -125,7 +173,8 @@ module StillActive
125
173
  $stdout.tty? ? :terminal : :json
126
174
  end
127
175
 
128
- def render_markdown(result, ruby_info: nil)
176
+ def render_markdown(result, ruby_info: nil, pr_context: nil)
177
+ puts "> **#{BotContext.summary(pr_context)}**\n" if pr_context
129
178
  puts MarkdownHelper.markdown_table_header_line
130
179
  result.keys.sort.each do |name|
131
180
  gem_data = result[name]
@@ -141,6 +190,8 @@ module StillActive
141
190
 
142
191
  puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
143
192
  end
193
+ alternatives = MarkdownHelper.alternatives_section(result)
194
+ puts alternatives unless alternatives.empty?
144
195
  if ruby_info
145
196
  puts ""
146
197
  puts MarkdownHelper.ruby_line(ruby_info)
@@ -7,8 +7,11 @@ require "open3"
7
7
  module StillActive
8
8
  class Config
9
9
  attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
10
- attr_accessor :baseline_path,
10
+ attr_accessor :alternatives,
11
+ :baseline_path,
11
12
  :critical_warning_emoji,
13
+ :cyclonedx_path,
14
+ :cyclonedx_version,
12
15
  :fail_if_critical,
13
16
  :fail_if_warning,
14
17
  :futurist_emoji,
@@ -26,6 +29,7 @@ module StillActive
26
29
  :warning_range_end
27
30
 
28
31
  def initialize
32
+ @alternatives = false
29
33
  @fail_if_critical = false
30
34
  @fail_if_outdated = nil
31
35
  @fail_if_vulnerable = nil
@@ -41,6 +45,8 @@ module StillActive
41
45
  @output_format = :auto
42
46
  @sarif_path = nil
43
47
  @baseline_path = nil
48
+ @cyclonedx_path = nil
49
+ @cyclonedx_version = "1.6"
44
50
 
45
51
  @critical_warning_emoji = "🚩"
46
52
  @futurist_emoji = "🔮"
@@ -52,6 +52,7 @@ module StillActive
52
52
  cvss3_score: body["cvss3Score"],
53
53
  cvss3_vector: body["cvss3Vector"],
54
54
  cvss2_score: body["cvss2Score"],
55
+ source: "deps.dev",
55
56
  }
56
57
  end
57
58
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../helpers/cyclonedx_helper"
4
5
  require_relative "../helpers/vulnerability_helper"
5
6
 
6
7
  module StillActive
@@ -63,6 +64,7 @@ module StillActive
63
64
  opts.on("--terminal", "Coloured terminal output (default in TTY)") { StillActive.config { |config| config.output_format = :terminal } }
64
65
  opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
65
66
  opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
67
+ opts.on("--alternatives", "Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems") { StillActive.config { |config| config.alternatives = true } }
66
68
  opts.on("--sarif[=PATH]", "SARIF 2.1.0 output for GitHub Code Scanning (default path: still_active.sarif.json; '-' for stdout). Overrides --terminal/--markdown/--json.") do |value|
67
69
  StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
68
70
  end
@@ -70,6 +72,16 @@ module StillActive
70
72
  options[:provided_baseline] = true
71
73
  StillActive.config { |config| config.baseline_path = value }
72
74
  end
75
+ opts.on("--cyclonedx[=PATH]", "CycloneDX SBOM output (default to stdout; PATH to write a file). Overrides --terminal/--markdown/--json.") do |value|
76
+ StillActive.config { |config| config.cyclonedx_path = value || "-" }
77
+ end
78
+ opts.on("--cyclonedx-version=VERSION", String, "CycloneDX spec version to emit: 1.6 (default) or 1.7.") do |value|
79
+ supported = StillActive::CyclonedxHelper::SUPPORTED_SPEC_VERSIONS
80
+ raise ArgumentError, "--cyclonedx-version must be one of: #{supported.join(", ")} (got #{value})" unless supported.include?(value)
81
+
82
+ options[:provided_cyclonedx_version] = true
83
+ StillActive.config { |config| config.cyclonedx_version = value }
84
+ end
73
85
  end
74
86
 
75
87
  def add_token_options(opts)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.4.2"
4
+ VERSION = "1.6.0"
5
5
  end