still_active 3.0.0.rc6 → 3.1.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 +46 -2
- data/README.md +28 -3
- data/lib/still_active/artifactory_client.rb +1 -1
- data/lib/still_active/cli.rb +91 -29
- data/lib/still_active/compact_index_client.rb +1 -1
- data/lib/still_active/config.rb +2 -0
- data/lib/still_active/config_file.rb +3 -2
- data/lib/still_active/deps_dev_client.rb +24 -3
- data/lib/still_active/ecosystem_lens.rb +19 -7
- data/lib/still_active/ecosystems_client.rb +1 -1
- data/lib/still_active/forgejo_client.rb +1 -1
- data/lib/still_active/gitlab_client.rb +1 -1
- data/lib/{helpers → still_active/helpers}/activity_helper.rb +1 -1
- data/lib/still_active/helpers/cvss_helper.rb +29 -0
- data/lib/{helpers → still_active/helpers}/cyclonedx_helper.rb +85 -7
- data/lib/{helpers → still_active/helpers}/libyear_helper.rb +1 -1
- data/lib/{helpers → still_active/helpers}/markdown_helper.rb +19 -0
- data/lib/{helpers → still_active/helpers}/sarif_helper.rb +17 -2
- data/lib/still_active/helpers/sbom_graph.rb +166 -0
- data/lib/{helpers → still_active/helpers}/status_helper.rb +23 -5
- data/lib/{helpers → still_active/helpers}/terminal_helper.rb +15 -0
- data/lib/{helpers → still_active/helpers}/version_helper.rb +14 -2
- data/lib/{helpers → still_active/helpers}/vulnerability_helper.rb +4 -5
- data/lib/still_active/options.rb +6 -3
- data/lib/still_active/osv_client.rb +2 -2
- data/lib/still_active/poison_security_correlator.rb +3 -3
- data/lib/still_active/pypi_client.rb +1 -1
- data/lib/still_active/sarif/rules.rb +15 -0
- data/lib/still_active/sbom_reader.rb +117 -2
- data/lib/still_active/sbom_workflow.rb +4 -4
- data/lib/still_active/suppressions.rb +5 -3
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +17 -11
- data/still_active.gemspec +6 -6
- metadata +48 -33
- data/lib/helpers/cvss_helper.rb +0 -63
- /data/lib/{helpers → still_active/helpers}/alternatives_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ansi_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/bot_context.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/bundler_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/catalog_index.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/constraint_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/dependency_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/diff_markdown_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/dotnet_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/emoji_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/endoflife_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/http_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/lockfile_dependency_parser.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/lockfile_indexer.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/markdown_escape.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/pep440_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/python_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ruby_advisory_db.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ruby_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/runtime_ceiling_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/semver_satisfaction.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/summary_helper.rb +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cvss_suite"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Computes a CVSS base score from a vector string, across v2/v3/v4. deps.dev stores
|
|
7
|
+
# only CVSS 3.x, so a CVSS-4-only advisory (the flagship protobuf case) arrives with
|
|
8
|
+
# no numeric score; OSV carries the v4 vector, and this turns it into the number the
|
|
9
|
+
# SARIF security-severity and CycloneDX rating want.
|
|
10
|
+
#
|
|
11
|
+
# Returns the BASE score, not cvss-suite's overall_score: a vector carrying threat or
|
|
12
|
+
# environmental metrics folds those into overall_score (E:U alone turns a 9.3 into
|
|
13
|
+
# an 8.1), while NVD and GHSA publish the base score. The severity band still floors
|
|
14
|
+
# at the authoritative GHSA label (VulnerabilityHelper.advisory_severity) because
|
|
15
|
+
# labels are assigned by people and can sit off the FIRST band edge; the label drives
|
|
16
|
+
# gating and SARIF level, the number sharpens display. Fails safe: an absent or
|
|
17
|
+
# unparseable vector yields nil, never a crash.
|
|
18
|
+
module CvssHelper
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def score(vector)
|
|
22
|
+
return if vector.nil? || vector.to_s.empty?
|
|
23
|
+
|
|
24
|
+
CvssSuite.parse(vector.to_s).base_score
|
|
25
|
+
rescue CvssSuite::Error
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "time"
|
|
6
|
+
require_relative "activity_helper"
|
|
7
|
+
require_relative "status_helper"
|
|
6
8
|
require_relative "vulnerability_helper"
|
|
7
9
|
|
|
8
10
|
module StillActive
|
|
@@ -27,7 +29,34 @@ module StillActive
|
|
|
27
29
|
components = build_components(result, ruby_info)
|
|
28
30
|
vulnerabilities = build_vulnerabilities(result)
|
|
29
31
|
|
|
30
|
-
document =
|
|
32
|
+
document = envelope(components, spec_version, tool_version, now)
|
|
33
|
+
document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
|
|
34
|
+
JSON.pretty_generate(document)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The cross-ecosystem sibling of `render`, for a --sbom audit. The one thing it
|
|
38
|
+
# must not do is rebuild PURLs: npm scopes, maven group:artifact and Go module
|
|
39
|
+
# paths are all easy to reconstruct subtly wrong, and the input SBOM already
|
|
40
|
+
# carries the authoritative one. So each component re-emits the PURL it arrived
|
|
41
|
+
# with, which means a consumer (Dependency-Track being the point of this) matches
|
|
42
|
+
# our output exactly as it matched the input, now annotated with the maintenance
|
|
43
|
+
# signals it had no way to compute.
|
|
44
|
+
def render_sbom(result:, tool_version:, spec_version: "1.6", now: Time.now.utc)
|
|
45
|
+
components = result.sort_by { |key, _| key.to_s }.map { |key, data| sbom_component(key.to_s, data) }
|
|
46
|
+
vulnerabilities = result.sort_by { |key, _| key.to_s }.flat_map do |key, data|
|
|
47
|
+
ref = sbom_ref(key.to_s, data)
|
|
48
|
+
(data[:vulnerabilities] || []).map { |advisory| vulnerability(advisory, ref) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
document = envelope(components, spec_version, tool_version, now)
|
|
52
|
+
document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
|
|
53
|
+
JSON.pretty_generate(document)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def envelope(components, spec_version, tool_version, now)
|
|
59
|
+
{
|
|
31
60
|
"bomFormat" => "CycloneDX",
|
|
32
61
|
"specVersion" => spec_version,
|
|
33
62
|
"serialNumber" => deterministic_serial(components),
|
|
@@ -38,11 +67,30 @@ module StillActive
|
|
|
38
67
|
},
|
|
39
68
|
"components" => components
|
|
40
69
|
}
|
|
41
|
-
document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
|
|
42
|
-
JSON.pretty_generate(document)
|
|
43
70
|
end
|
|
44
71
|
|
|
45
|
-
|
|
72
|
+
# The input's own PURL identifies the component. It is always present for an
|
|
73
|
+
# assessed dependency (SbomReader routes a component with no PURL to
|
|
74
|
+
# `unassessable`, so it never reaches an assessment), but the composite result
|
|
75
|
+
# key stands in defensively rather than emitting a component with no identity.
|
|
76
|
+
def sbom_ref(key, data)
|
|
77
|
+
data[:purl] || key
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def sbom_component(key, data)
|
|
81
|
+
ref = sbom_ref(key, data)
|
|
82
|
+
component = {"type" => "library", "name" => data[:name] || key}
|
|
83
|
+
component["version"] = data[:version_used] if data[:version_used]
|
|
84
|
+
component["bom-ref"] = ref
|
|
85
|
+
component["purl"] = data[:purl] if data[:purl]
|
|
86
|
+
component["licenses"] = licenses(data[:license]) if data[:license]
|
|
87
|
+
if data[:repository_url]
|
|
88
|
+
component["externalReferences"] = [{"type" => "vcs", "url" => data[:repository_url]}]
|
|
89
|
+
end
|
|
90
|
+
properties = sbom_properties(data)
|
|
91
|
+
component["properties"] = properties unless properties.empty?
|
|
92
|
+
component
|
|
93
|
+
end
|
|
46
94
|
|
|
47
95
|
def build_components(result, ruby_info)
|
|
48
96
|
components = result.sort_by { |name, _| name.to_s }.map { |name, data| gem_component(name.to_s, data) }
|
|
@@ -89,14 +137,44 @@ module StillActive
|
|
|
89
137
|
license.split(", ").map { |id| {"license" => {"id" => id}} }
|
|
90
138
|
end
|
|
91
139
|
|
|
92
|
-
|
|
140
|
+
# Signals both paths compute. `status` is the folded verdict a consumer would
|
|
141
|
+
# otherwise have to re-derive, which is the whole reason for emitting an
|
|
142
|
+
# enriched SBOM rather than the input one.
|
|
143
|
+
def shared_properties(data)
|
|
93
144
|
{
|
|
145
|
+
"still_active:status" => StatusHelper.gem_status(data).to_s,
|
|
146
|
+
# The raw activity level alongside the folded verdict. `status` answers
|
|
147
|
+
# "what should I do about this"; activity_level answers "how dormant is
|
|
148
|
+
# it", and they deliberately disagree: a deprecated package that still
|
|
149
|
+
# ships releases is status deprecated but activity ok. A consumer
|
|
150
|
+
# thresholding on recency wants the second, not the first.
|
|
151
|
+
"still_active:activity_level" => ActivityHelper.activity_level(data).to_s,
|
|
94
152
|
"still_active:archived" => boolean_property(data[:archived]),
|
|
153
|
+
"still_active:deprecated" => boolean_property(data[:deprecated]),
|
|
154
|
+
"still_active:deprecation_reason" => data[:deprecation_reason],
|
|
95
155
|
"still_active:scorecard_score" => data[:scorecard_score]&.to_s,
|
|
96
156
|
"still_active:libyear" => data[:libyear]&.to_s,
|
|
97
|
-
"still_active:last_commit_date" => iso8601(data[:last_commit_date])
|
|
157
|
+
"still_active:last_commit_date" => iso8601(data[:last_commit_date])
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_properties(hash)
|
|
162
|
+
hash.filter_map { |name, value| {"name" => name, "value" => value} unless value.nil? }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def gem_properties(data)
|
|
166
|
+
to_properties(shared_properties(data).merge(
|
|
98
167
|
"still_active:version_yanked" => boolean_property(data[:version_yanked])
|
|
99
|
-
|
|
168
|
+
))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cross-ecosystem extras: which ecosystem the package came from, and whether it
|
|
172
|
+
# is one the project declared (both meaningless on the Ruby-only native path).
|
|
173
|
+
def sbom_properties(data)
|
|
174
|
+
to_properties(shared_properties(data).merge(
|
|
175
|
+
"still_active:ecosystem" => data[:ecosystem]&.to_s,
|
|
176
|
+
"still_active:direct" => boolean_property(data[:direct])
|
|
177
|
+
))
|
|
100
178
|
end
|
|
101
179
|
|
|
102
180
|
# The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
|
|
@@ -118,6 +118,25 @@ module StillActive
|
|
|
118
118
|
lines.join("\n")
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
+
# Packages whose maintainer has declared them deprecated. Its own section
|
|
122
|
+
# rather than a table column because the message is the actionable part: it
|
|
123
|
+
# usually names the successor, and it is the maintainer speaking rather than
|
|
124
|
+
# anything still_active inferred.
|
|
125
|
+
def deprecated_section(result)
|
|
126
|
+
flagged = result.select { |_name, data| data[:deprecated] }
|
|
127
|
+
return "" if flagged.empty?
|
|
128
|
+
|
|
129
|
+
lines = ["", "**Deprecated by their maintainers** (no further fixes, including security patches):"]
|
|
130
|
+
flagged.sort_by { |name, _| name }.each do |name, data|
|
|
131
|
+
reason = data[:deprecation_reason]
|
|
132
|
+
# The message is registry-supplied free text in a bullet list, and commonly
|
|
133
|
+
# contains a URL, so it goes through the inline escaper.
|
|
134
|
+
note = reason ? ": #{MarkdownEscape.inline(reason)}" : ""
|
|
135
|
+
lines << "- #{MarkdownEscape.code_span(DependencyHelper.identity(name, data))}#{note}"
|
|
136
|
+
end
|
|
137
|
+
lines.join("\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
121
140
|
# A dormant dep that caps your tree below its latest major (the poison-pill
|
|
122
141
|
# signal). Consistent with the other finding sections: worst caps + total, the
|
|
123
142
|
# direct parent named for a transitive pill. The full cap list is in the JSON.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "time"
|
|
6
|
-
require_relative "../
|
|
6
|
+
require_relative "../sarif/rules"
|
|
7
7
|
require_relative "lockfile_indexer"
|
|
8
8
|
require_relative "activity_helper"
|
|
9
9
|
require_relative "dependency_helper"
|
|
@@ -188,6 +188,17 @@ module StillActive
|
|
|
188
188
|
out << mark_suppressed(result("SA007", name, "#{display} #{version}: this version has been yanked from RubyGems.", location), display, :yanked)
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
+
if data[:deprecated]
|
|
192
|
+
# Fires independently of the activity signals on purpose: a package
|
|
193
|
+
# deprecated last month with a release last week looks perfectly healthy by
|
|
194
|
+
# dates, which is exactly the case recency-based tooling cannot see. The
|
|
195
|
+
# maintainer's own message is carried through when they left one, since it
|
|
196
|
+
# usually names the successor.
|
|
197
|
+
reason = data[:deprecation_reason]
|
|
198
|
+
detail = reason ? " Maintainer's note: #{reason}" : ""
|
|
199
|
+
out << mark_suppressed(result("SA010", name, "#{display} #{version}: deprecated by its maintainer.#{detail}", location), display, :deprecated)
|
|
200
|
+
end
|
|
201
|
+
|
|
191
202
|
if data[:poison] && !Array(data[:constraints]).empty?
|
|
192
203
|
# Level tracks the poison tier (critical->error, ...); a plain majors-behind
|
|
193
204
|
# tier change keeps the (rule_id, gem_name) fingerprint so it doesn't re-alert.
|
|
@@ -397,9 +408,13 @@ module StillActive
|
|
|
397
408
|
Digest::SHA256.hexdigest(["v1", rule_id, gem_name, advisory_id].compact.join("|"))[0, 16]
|
|
398
409
|
end
|
|
399
410
|
|
|
411
|
+
# EOL dates are calendar dates, not instants: endoflife.date publishes
|
|
412
|
+
# "2025-03-31" and Time.parse reads it as local midnight. Shifting that to UTC
|
|
413
|
+
# rewinds it a day for every user east of UTC, so render the date as parsed,
|
|
414
|
+
# matching the markdown and terminal output.
|
|
400
415
|
def format_date(value)
|
|
401
416
|
t = ActivityHelper.parse_time(value)
|
|
402
|
-
t ? t.
|
|
417
|
+
t ? t.strftime("%Y-%m-%d") : value.to_s
|
|
403
418
|
end
|
|
404
419
|
|
|
405
420
|
def repo_suffix(data)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# Direct-vs-transitive reasoning over a CycloneDX `dependencies` graph.
|
|
5
|
+
#
|
|
6
|
+
# The native Bundler audit knows which gems you declared and, for the rest, the
|
|
7
|
+
# shortest path back to the declared gem that pulls them in, which is what turns
|
|
8
|
+
# an un-actionable transitive finding into "replace your direct dep A". The SBOM
|
|
9
|
+
# path had no equivalent: every package read the same, whether you chose it or it
|
|
10
|
+
# arrived six levels down. CycloneDX carries the graph to answer this; still_active
|
|
11
|
+
# simply never read it.
|
|
12
|
+
#
|
|
13
|
+
# This works purely in bom-refs. Turning a ref into an `ecosystem/name` identity
|
|
14
|
+
# is the reader's job, so this stays a graph problem with no purl knowledge.
|
|
15
|
+
#
|
|
16
|
+
# Two real generator shapes have to work, both captured from actual output rather
|
|
17
|
+
# than assumed:
|
|
18
|
+
#
|
|
19
|
+
# Trivy: `metadata.component` is an application, whose children are one
|
|
20
|
+
# application per manifest found (Gemfile.lock, package-lock.json), whose
|
|
21
|
+
# children are the libraries that manifest declares. bom-refs are UUIDs.
|
|
22
|
+
# Syft: `metadata.component` is a `file` that is not a graph node at all. The
|
|
23
|
+
# scanned project appears instead as an ordinary library component with
|
|
24
|
+
# no incoming edge. bom-refs are purls.
|
|
25
|
+
#
|
|
26
|
+
# So "direct" can be neither "child of metadata.component" nor "one hop from the
|
|
27
|
+
# root". What holds for both: start at the graph's entry points, descend through
|
|
28
|
+
# any non-library scaffolding, and the FIRST library reached on each path is a
|
|
29
|
+
# declared dependency.
|
|
30
|
+
module SbomGraph
|
|
31
|
+
extend self
|
|
32
|
+
|
|
33
|
+
# => { ref => {direct: bool, path: [ref, ...] | nil} }, or nil when this SBOM
|
|
34
|
+
# cannot answer the question. nil is not "nothing is direct": callers must omit
|
|
35
|
+
# the fields entirely, because emitting `direct: false` everywhere would be the
|
|
36
|
+
# positive claim "none of these are yours" about a document that never said.
|
|
37
|
+
def resolve(dependencies:, root_ref:, library_refs:)
|
|
38
|
+
edges = edges_from(dependencies)
|
|
39
|
+
return if edges.empty?
|
|
40
|
+
|
|
41
|
+
entries = entry_points(edges, root_ref)
|
|
42
|
+
return if entries.empty?
|
|
43
|
+
|
|
44
|
+
direct = direct_libraries(edges, entries, library_refs)
|
|
45
|
+
return if direct.empty?
|
|
46
|
+
|
|
47
|
+
paths = shortest_paths(edges, direct, library_refs)
|
|
48
|
+
resolved = {}
|
|
49
|
+
direct.each { |ref| resolved[ref] = {direct: true, path: nil} }
|
|
50
|
+
paths.each { |ref, path| resolved[ref] ||= {direct: false, path: path} }
|
|
51
|
+
resolved
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The library components that are the scanned project(s) rather than
|
|
55
|
+
# dependencies of one. A Syft SBOM lists the project as an ordinary library
|
|
56
|
+
# with a purl, indistinguishable from a dependency by shape alone, so
|
|
57
|
+
# still_active audited the user's own code and reported it as critically
|
|
58
|
+
# stale (it has no registry entry, so it looks abandoned).
|
|
59
|
+
#
|
|
60
|
+
# The graph tells them apart: the project is what nothing depends on. The rule
|
|
61
|
+
# is deliberately conservative on both halves. It requires an OUTGOING edge, so
|
|
62
|
+
# a dependency whose parent edge the generator simply failed to record stays a
|
|
63
|
+
# dependency rather than vanishing from the audit, and it only ever considers
|
|
64
|
+
# libraries, so Trivy's application-typed root and manifest nodes (never
|
|
65
|
+
# dependencies to begin with) are not reported here. A merged SBOM covering
|
|
66
|
+
# several projects yields all of them.
|
|
67
|
+
def project_refs(dependencies:, library_refs:)
|
|
68
|
+
edges = edges_from(dependencies)
|
|
69
|
+
return Set.new if edges.empty?
|
|
70
|
+
|
|
71
|
+
pointed_at = Set.new
|
|
72
|
+
edges.each_value { |children| pointed_at.merge(children) }
|
|
73
|
+
|
|
74
|
+
edges.each_with_object(Set.new) do |(ref, children), roots|
|
|
75
|
+
next unless library_refs.include?(ref)
|
|
76
|
+
next if pointed_at.include?(ref)
|
|
77
|
+
next if children.empty?
|
|
78
|
+
|
|
79
|
+
roots << ref
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# {ref => [child refs]}, skipping anything malformed. A generator is free to
|
|
86
|
+
# emit an entry with no dependsOn; that is a leaf, not an error.
|
|
87
|
+
def edges_from(dependencies)
|
|
88
|
+
return {} unless dependencies.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
dependencies.each_with_object({}) do |entry, edges|
|
|
91
|
+
next unless entry.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
ref = entry["ref"]
|
|
94
|
+
next unless ref.is_a?(String)
|
|
95
|
+
|
|
96
|
+
children = entry["dependsOn"]
|
|
97
|
+
edges[ref] = children.is_a?(Array) ? children.grep(String) : []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Where a walk can start: the declared root when it is actually a node, plus
|
|
102
|
+
# every node nothing points at. The second clause is what rescues the Syft
|
|
103
|
+
# shape, where the declared root is a file that never appears in the graph and
|
|
104
|
+
# the project node is simply parentless.
|
|
105
|
+
def entry_points(edges, root_ref)
|
|
106
|
+
pointed_at = Set.new
|
|
107
|
+
edges.each_value { |children| pointed_at.merge(children) }
|
|
108
|
+
|
|
109
|
+
entries = edges.keys.reject { |ref| pointed_at.include?(ref) }
|
|
110
|
+
entries << root_ref if root_ref.is_a?(String) && edges.key?(root_ref) && !entries.include?(root_ref)
|
|
111
|
+
entries
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Descend from the entry points through non-library scaffolding (Trivy's
|
|
115
|
+
# per-manifest application nodes) and collect the first library on each path.
|
|
116
|
+
# The entry points themselves are never direct: they are the project, or a
|
|
117
|
+
# manifest, and in the Syft shape the project node IS a library.
|
|
118
|
+
def direct_libraries(edges, entries, library_refs)
|
|
119
|
+
direct = Set.new
|
|
120
|
+
seen = Set.new(entries)
|
|
121
|
+
queue = entries.dup
|
|
122
|
+
|
|
123
|
+
until queue.empty?
|
|
124
|
+
(edges[queue.shift] || []).each do |child|
|
|
125
|
+
next if seen.include?(child)
|
|
126
|
+
|
|
127
|
+
seen << child
|
|
128
|
+
if library_refs.include?(child)
|
|
129
|
+
direct << child
|
|
130
|
+
else
|
|
131
|
+
queue << child
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
direct
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# BFS over library edges from the direct set, so each transitive package gets
|
|
140
|
+
# the shortest chain back to the declared dependency that pulls it in, head
|
|
141
|
+
# first. Same contract as the native path's dependency_path.
|
|
142
|
+
def shortest_paths(edges, direct, library_refs)
|
|
143
|
+
paths = {}
|
|
144
|
+
queue = []
|
|
145
|
+
direct.each do |ref|
|
|
146
|
+
paths[ref] = [ref]
|
|
147
|
+
queue << ref
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
until queue.empty?
|
|
151
|
+
ref = queue.shift
|
|
152
|
+
(edges[ref] || []).each do |child|
|
|
153
|
+
next unless library_refs.include?(child)
|
|
154
|
+
next if paths.key?(child)
|
|
155
|
+
|
|
156
|
+
paths[child] = paths[ref] + [child]
|
|
157
|
+
queue << child
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The direct set seeded this BFS, so drop it: a declared dependency carries
|
|
162
|
+
# no path, matching the native path's contract.
|
|
163
|
+
paths.except(*direct)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -19,9 +19,14 @@ module StillActive
|
|
|
19
19
|
# dormant or archived gem carrying an unpatched advisory is :dead (no one is
|
|
20
20
|
# going to fix it, migrate). :unknown is least severe -- an absence, not a
|
|
21
21
|
# finding -- so missing data never reads as :ok.
|
|
22
|
-
|
|
22
|
+
# :deprecated sits above :archived and below :vulnerable. An archived repo is
|
|
23
|
+
# circumstantial (development may simply have moved), whereas a deprecation is
|
|
24
|
+
# the maintainer explicitly telling you to stop using the package, frequently
|
|
25
|
+
# naming the replacement. A live vulnerability is still the more urgent thing.
|
|
26
|
+
SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :deprecated, :vulnerable, :dead].freeze
|
|
23
27
|
|
|
24
|
-
# Returns :dead, :vulnerable, :archived, :legacy, :stale, :ok,
|
|
28
|
+
# Returns :dead, :vulnerable, :deprecated, :archived, :legacy, :stale, :ok,
|
|
29
|
+
# or :unknown.
|
|
25
30
|
def gem_status(gem_data)
|
|
26
31
|
vulnerable = gem_data[:vulnerability_count].to_i.positive?
|
|
27
32
|
|
|
@@ -33,13 +38,26 @@ module StillActive
|
|
|
33
38
|
return :unknown if gem_data[:version_unresolved] && !vulnerable
|
|
34
39
|
|
|
35
40
|
level = ActivityHelper.activity_level(gem_data)
|
|
41
|
+
# The maintainer's own declaration that this package should no longer be
|
|
42
|
+
# used. Unlike every other input here it is a stated fact rather than a date
|
|
43
|
+
# heuristic, so it is read before the activity level rather than blended
|
|
44
|
+
# with it.
|
|
45
|
+
deprecated = gem_data[:deprecated] == true
|
|
36
46
|
|
|
37
47
|
if vulnerable
|
|
38
|
-
# A vulnerability in a dormant or
|
|
39
|
-
# in an actively-released gem a fix is plausible -> :vulnerable.
|
|
40
|
-
|
|
48
|
+
# A vulnerability in a dormant, archived or deprecated gem won't be patched
|
|
49
|
+
# -> :dead; in an actively-released gem a fix is plausible -> :vulnerable.
|
|
50
|
+
# Deprecation counts even on a gem still shipping releases: the maintainer
|
|
51
|
+
# has said to stop using it, so waiting for the patch is not a plan.
|
|
52
|
+
return (deprecated || [:critical, :archived].include?(level)) ? :dead : :vulnerable
|
|
41
53
|
end
|
|
42
54
|
|
|
55
|
+
# Deliberately ahead of the :critical -> :legacy branch below. :legacy means
|
|
56
|
+
# "long-dormant but done, low risk", and a deprecated package is the exact
|
|
57
|
+
# case that must not be filed there: left-pad is dormant AND clean AND its
|
|
58
|
+
# maintainer says to use String.prototype.padStart() instead.
|
|
59
|
+
return :deprecated if deprecated
|
|
60
|
+
|
|
43
61
|
case level
|
|
44
62
|
when :archived
|
|
45
63
|
# archived != EOL: a repo archived while the gem still publishes recent
|
|
@@ -160,9 +160,24 @@ module StillActive
|
|
|
160
160
|
# A language-runtime ceiling is orthogonal to poison/alternatives (a gem can
|
|
161
161
|
# be maintained yet still cap your Ruby), so it always gets its own line.
|
|
162
162
|
lines << language_ceiling_line(data)
|
|
163
|
+
# So is a deprecation, and more so: it is the maintainer's own statement
|
|
164
|
+
# rather than anything derived from the row above it, and a deprecated gem
|
|
165
|
+
# can look entirely healthy in every other column.
|
|
166
|
+
lines << deprecation_line(data)
|
|
163
167
|
lines.compact
|
|
164
168
|
end
|
|
165
169
|
|
|
170
|
+
# " ↳ deprecated by maintainer: <their message>". Red, because a deprecation
|
|
171
|
+
# is an instruction to stop using the package, and the message usually names
|
|
172
|
+
# the replacement, so it is the most actionable text on the row.
|
|
173
|
+
def deprecation_line(data)
|
|
174
|
+
return unless data[:deprecated]
|
|
175
|
+
|
|
176
|
+
reason = data[:deprecation_reason]
|
|
177
|
+
text = reason ? "deprecated by maintainer: #{reason}" : "deprecated by maintainer"
|
|
178
|
+
" #{AnsiHelper.red("\u21B3 #{text}")}"
|
|
179
|
+
end
|
|
180
|
+
|
|
166
181
|
# " ↳ ruby ceiling: <receipt>" (or "python ceiling"), coloured by tier (red =
|
|
167
182
|
# strands you on an EOL runtime, dim = an FYI cap below the latest stable).
|
|
168
183
|
# Reuses poison_colour; the runtime name comes off the finding, not hardcoded.
|
|
@@ -104,8 +104,20 @@ module StillActive
|
|
|
104
104
|
# SPDX license identifier(s) from the RubyGems versions payload.
|
|
105
105
|
# Comma-joined when a gem declares more than one. nil when unknown.
|
|
106
106
|
def license(version_hash:)
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
format_licenses(version_hash&.dig("licenses"))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The shared rendering both paths join through, so a licence reads the same
|
|
111
|
+
# whether it came from RubyGems (native) or deps.dev (--sbom). Both serve an
|
|
112
|
+
# array of SPDX strings, and a single element may itself be an expression
|
|
113
|
+
# ("Apache-2.0 OR MIT"), which passes through untouched. nil when unknown,
|
|
114
|
+
# never an empty string, so a missing licence can't render as a present blank.
|
|
115
|
+
def format_licenses(licenses)
|
|
116
|
+
# Array() so a drifted feed serving a bare string instead of an array can't
|
|
117
|
+
# raise here. This runs inside the per-dependency assessment, so an exception
|
|
118
|
+
# would cost that dependency its entire verdict over a cosmetic field.
|
|
119
|
+
licenses = Array(licenses)
|
|
120
|
+
return if licenses.empty?
|
|
109
121
|
|
|
110
122
|
licenses.join(", ")
|
|
111
123
|
end
|
|
@@ -23,11 +23,10 @@ module StillActive
|
|
|
23
23
|
# One advisory's severity label ("low".."critical"), or nil when genuinely unscored.
|
|
24
24
|
# The authoritative GHSA/OSV label is a FLOOR: a real CVSS number sharpens precision
|
|
25
25
|
# and can RAISE the band, but never lowers it below the label. This matters because
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# two present, that one stands.
|
|
26
|
+
# GHSA labels are assigned by people and can sit off the FIRST band edge of the
|
|
27
|
+
# computed base score -- on a fail-closed, no-false-positive tool a computed
|
|
28
|
+
# number must not silently demote a GHSA-HIGH finding out of a severity gate.
|
|
29
|
+
# With only one of the two present, that one stands.
|
|
31
30
|
def advisory_severity(vulnerability)
|
|
32
31
|
from_score = (score = effective_score(vulnerability)) ? severity_label(score) : nil
|
|
33
32
|
from_label = OSV_LABELS[vulnerability[:osv_severity].to_s.downcase]
|
data/lib/still_active/options.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
4
|
+
require_relative "helpers/constraint_helper"
|
|
5
|
+
require_relative "helpers/cyclonedx_helper"
|
|
6
|
+
require_relative "helpers/vulnerability_helper"
|
|
7
7
|
|
|
8
8
|
module StillActive
|
|
9
9
|
class Options
|
|
@@ -147,6 +147,9 @@ module StillActive
|
|
|
147
147
|
opts.on("--fail-if-critical", "Exit 1 if any gem has critical activity warning") do
|
|
148
148
|
StillActive.config { |config| config.fail_if_critical = true }
|
|
149
149
|
end
|
|
150
|
+
opts.on("--fail-if-deprecated", "Exit 1 if any dependency's maintainer has deprecated it") do
|
|
151
|
+
StillActive.config { |config| config.fail_if_deprecated = true }
|
|
152
|
+
end
|
|
150
153
|
opts.on("--fail-if-warning", "Exit 1 if any gem has warning or critical activity warning") do
|
|
151
154
|
StillActive.config { |config| config.fail_if_warning = true }
|
|
152
155
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "helpers/http_helper"
|
|
4
|
+
require_relative "helpers/cvss_helper"
|
|
5
5
|
|
|
6
6
|
module StillActive
|
|
7
7
|
# OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
3
|
+
require_relative "helpers/vulnerability_helper"
|
|
4
|
+
require_relative "helpers/constraint_helper"
|
|
5
|
+
require_relative "helpers/semver_satisfaction"
|
|
6
6
|
|
|
7
7
|
module StillActive
|
|
8
8
|
# Whole-tree correlation, run once after the fan-out: a poison cap is far more
|
|
@@ -168,6 +168,21 @@ module StillActive
|
|
|
168
168
|
level: "note",
|
|
169
169
|
security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
|
|
170
170
|
tags: ["maintenance", "runtime", "external/cwe/cwe-1104"]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "SA010",
|
|
174
|
+
name: "DeprecatedPackage",
|
|
175
|
+
short: "Gem is deprecated by its maintainer",
|
|
176
|
+
full: "The maintainer has marked this gem deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
|
|
177
|
+
help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches.",
|
|
178
|
+
level: "error",
|
|
179
|
+
security_severity: "7.5",
|
|
180
|
+
tags: ["security", "supply-chain", "maintenance", "external/cwe/cwe-1104"],
|
|
181
|
+
neutral: {
|
|
182
|
+
short: "Package is deprecated by its maintainer",
|
|
183
|
+
full: "The maintainer has marked this package deprecated in its registry. Unlike every other maintenance signal here, this is not inferred from dates or repository state: the person who publishes it has said to stop using it, and the deprecation message often names the replacement. A deprecated package can still look healthy by release recency, so this fires independently of the activity signals.",
|
|
184
|
+
help_text: "Read the deprecation message, which usually names the successor, and migrate to it. There will be no further fixes, including security patches."
|
|
185
|
+
}
|
|
171
186
|
}
|
|
172
187
|
].freeze
|
|
173
188
|
|