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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +100 -19
- data/lib/helpers/alternatives_helper.rb +42 -0
- data/lib/helpers/bot_context.rb +132 -0
- data/lib/helpers/catalog_index.rb +98 -0
- data/lib/helpers/cyclonedx_helper.rb +159 -0
- data/lib/helpers/markdown_helper.rb +20 -2
- data/lib/helpers/ruby_advisory_db.rb +93 -0
- data/lib/helpers/sarif_helper.rb +9 -2
- data/lib/helpers/terminal_helper.rb +27 -3
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +35 -0
- data/lib/still_active/cli.rb +55 -4
- data/lib/still_active/config.rb +7 -1
- data/lib/still_active/deps_dev_client.rb +1 -0
- data/lib/still_active/options.rb +12 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +35 -7
- data/still_active.gemspec +6 -1
- metadata +22 -3
|
@@ -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
|
data/lib/helpers/sarif_helper.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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)
|
data/lib/still_active/config.rb
CHANGED
|
@@ -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 :
|
|
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 = "🔮"
|
data/lib/still_active/options.rb
CHANGED
|
@@ -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)
|
data/lib/still_active/version.rb
CHANGED