still_active 1.6.0 → 3.0.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +145 -6
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +58 -6
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- metadata +72 -11
|
@@ -13,6 +13,9 @@ module StillActive
|
|
|
13
13
|
extend self
|
|
14
14
|
|
|
15
15
|
STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 # 30 days
|
|
16
|
+
# Requirement operators whose safe versions are OLDER than the flaw, i.e. not a
|
|
17
|
+
# forward fix a consumer can upgrade to. Anything else ("> X" etc.) is.
|
|
18
|
+
OLDER_THAN_FLAW_OPERATORS = ["<", "<="].freeze
|
|
16
19
|
|
|
17
20
|
# bundler-audit's Database#check_gem expects an object responding to
|
|
18
21
|
# #name and #version (a Gem::Version).
|
|
@@ -66,12 +69,32 @@ module StillActive
|
|
|
66
69
|
cvss3_score: details[:cvss_v3],
|
|
67
70
|
cvss3_vector: nil,
|
|
68
71
|
cvss2_score: details[:cvss_v2],
|
|
72
|
+
# No safe version a consumer can upgrade TO: the correlation bundler-audit
|
|
73
|
+
# + `bundle outdated` can't produce alone. A factual read of the DB.
|
|
74
|
+
no_fix_available: no_forward_fix?(advisory),
|
|
69
75
|
source: "ruby-advisory-db",
|
|
70
76
|
}
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
private
|
|
74
80
|
|
|
81
|
+
# True when the advisory records no version the consumer can move forward to.
|
|
82
|
+
# Mirrors bundler-audit's own `!patched? && !unaffected?` rather than only the
|
|
83
|
+
# patched half: a clean release shipped AFTER a backdoored/yanked version is
|
|
84
|
+
# recorded in unaffected_versions as a "> X" range, not in patched_versions
|
|
85
|
+
# (the CVE-2019-15224 bootstrap-sass pattern). Be conservative -- only a purely
|
|
86
|
+
# older-than-the-flaw safe range ("< X") counts as no-forward-fix -- so we never
|
|
87
|
+
# claim "no fix" while a later safe release exists.
|
|
88
|
+
def no_forward_fix?(advisory)
|
|
89
|
+
return false unless advisory.patched_versions.empty?
|
|
90
|
+
|
|
91
|
+
advisory.unaffected_versions.all? { |requirement| older_than_flaw_only?(requirement) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def older_than_flaw_only?(requirement)
|
|
95
|
+
requirement.requirements.all? { |operator, _| OLDER_THAN_FLAW_OPERATORS.include?(operator) }
|
|
96
|
+
end
|
|
97
|
+
|
|
75
98
|
# nil for versions Gem::Version can't parse (e.g. a git sha); such a "version"
|
|
76
99
|
# has nothing to match in the advisory DB, so the caller returns [].
|
|
77
100
|
def parse_version(version)
|
data/lib/helpers/ruby_helper.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "bundler_helper"
|
|
5
|
+
require_relative "endoflife_helper"
|
|
4
6
|
require_relative "http_helper"
|
|
5
7
|
require_relative "libyear_helper"
|
|
6
8
|
|
|
@@ -23,15 +25,15 @@ module StillActive
|
|
|
23
25
|
return if latest_cycle.nil?
|
|
24
26
|
|
|
25
27
|
latest_version = latest_cycle["latest"]
|
|
26
|
-
latest_release_date = parse_date(latest_cycle["releaseDate"])
|
|
27
|
-
current_release_date = parse_date(current_cycle&.dig("releaseDate"))
|
|
28
|
+
latest_release_date = EndoflifeHelper.parse_date(latest_cycle["releaseDate"])
|
|
29
|
+
current_release_date = EndoflifeHelper.parse_date(current_cycle&.dig("releaseDate"))
|
|
28
30
|
eol_value = current_cycle&.dig("eol")
|
|
29
31
|
|
|
30
32
|
{
|
|
31
33
|
version: current,
|
|
32
34
|
release_date: current_release_date,
|
|
33
|
-
eol_date: parse_eol(eol_value),
|
|
34
|
-
eol: eol_reached?(eol_value),
|
|
35
|
+
eol_date: EndoflifeHelper.parse_eol(eol_value),
|
|
36
|
+
eol: EndoflifeHelper.eol_reached?(eol_value),
|
|
35
37
|
latest_version: latest_version,
|
|
36
38
|
latest_release_date: latest_release_date,
|
|
37
39
|
libyear: LibyearHelper.gem_libyear(
|
|
@@ -41,6 +43,14 @@ module StillActive
|
|
|
41
43
|
}
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
# The runtime facts a per-gem language ceiling is measured against, sourced
|
|
47
|
+
# from the shared endoflife.date support-window builder. Ruby only picks the
|
|
48
|
+
# feed; the ecosystem-neutral logic (support floor, latest stable, grace
|
|
49
|
+
# window, cycle normalization) lives in EndoflifeHelper.
|
|
50
|
+
def supported_ruby_range
|
|
51
|
+
EndoflifeHelper.support_window(feed_path: "/api/ruby.json")
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
private
|
|
45
55
|
|
|
46
56
|
def current_ruby_version
|
|
@@ -48,10 +58,10 @@ module StillActive
|
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def lockfile_ruby_version
|
|
51
|
-
gemfile
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
lockfile_path =
|
|
61
|
+
# Use the configured gemfile (honours --gemfile) rather than the ambient
|
|
62
|
+
# BUNDLE_GEMFILE, which only happened to work when BundlerHelper set it as
|
|
63
|
+
# a side-effect. Refs #42.
|
|
64
|
+
lockfile_path = BundlerHelper.lockfile_path_for(File.expand_path(StillActive.config.gemfile_path))
|
|
55
65
|
return unless File.exist?(lockfile_path)
|
|
56
66
|
|
|
57
67
|
content = File.read(lockfile_path)
|
|
@@ -67,25 +77,5 @@ module StillActive
|
|
|
67
77
|
major_minor = version.split(".")[0..1].join(".")
|
|
68
78
|
cycles.find { |c| c["cycle"] == major_minor }
|
|
69
79
|
end
|
|
70
|
-
|
|
71
|
-
def parse_date(date_string)
|
|
72
|
-
return if date_string.nil?
|
|
73
|
-
|
|
74
|
-
Time.parse(date_string)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def parse_eol(value)
|
|
78
|
-
case value
|
|
79
|
-
when String then parse_date(value)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def eol_reached?(value)
|
|
84
|
-
case value
|
|
85
|
-
when true then true
|
|
86
|
-
when false then false
|
|
87
|
-
when String then Time.parse(value) <= Time.now
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
80
|
end
|
|
91
81
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constraint_helper"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# The language-runtime sibling of the poison-pill signal, kept deliberately
|
|
7
|
+
# generic: it reads a constraint a package declares on its RUNTIME (the version
|
|
8
|
+
# range it can run on) and, given that runtime's support window, answers how much
|
|
9
|
+
# the constraint holds you back. Ruby (`ruby_version`) is the first consumer, but
|
|
10
|
+
# nothing here is Ruby-specific: the same core serves any runtime with an
|
|
11
|
+
# end-of-life calendar (Python's `requires_python`, etc.). The runtime-specific
|
|
12
|
+
# parts -- where the constraint string comes from, and how the support window is
|
|
13
|
+
# built -- live in the calibration layer (RubyHelper.supported_ruby_range) and
|
|
14
|
+
# the workflow boundary, exactly as poison keeps ConstraintHelper generic and
|
|
15
|
+
# pushes ecosystem resolution to the lens.
|
|
16
|
+
#
|
|
17
|
+
# Two tiers, mirroring the severity model:
|
|
18
|
+
# - EOL-forced (critical): the constraint admits no still-supported runtime,
|
|
19
|
+
# stranding you on an end-of-life release with no security patches. A genuine
|
|
20
|
+
# upgrade blocker (e.g. a gem's `ruby_version < 3.2` caps at the EOL Ruby 3.1).
|
|
21
|
+
# - latest-not-yet (note): runs on a supported runtime but caps below the latest
|
|
22
|
+
# stable. An FYI ceiling to plan around, or a place to contribute support for
|
|
23
|
+
# the newest release before you invest.
|
|
24
|
+
#
|
|
25
|
+
# A bare floor (`>= 3.1`) or a requires-newer constraint (`>= 4.1`, `~> 5.0`) is
|
|
26
|
+
# NOT a ceiling: it raises the minimum, it doesn't cap you onto a dead release.
|
|
27
|
+
# The distinction is drawn against the live EOL cycles, not the operator alone.
|
|
28
|
+
module RuntimeCeilingHelper
|
|
29
|
+
extend self
|
|
30
|
+
|
|
31
|
+
# Gem::Requirement caps input via a regex; a registry-derived string could be
|
|
32
|
+
# pathological. Bound it up front like ConstraintHelper does.
|
|
33
|
+
MAX_REQUIREMENT_LENGTH = 256
|
|
34
|
+
|
|
35
|
+
# => { requirement:, eol_forced:, severity:, ... } or nil when there's no
|
|
36
|
+
# ceiling. `support_window` is a { oldest_supported:, latest_stable:, cycles: }
|
|
37
|
+
# hash of Gem::Versions plus normalized EOL cycles (see supported_ruby_range).
|
|
38
|
+
def analyze(requirement:, support_window:)
|
|
39
|
+
return if support_window.nil?
|
|
40
|
+
|
|
41
|
+
req = safe_requirement(requirement)
|
|
42
|
+
return if req.nil? || !capping?(req)
|
|
43
|
+
|
|
44
|
+
supported_allowed = support_window[:cycles].reject { |c| c[:eol] }.select { |c| req.satisfied_by?(c[:version]) }
|
|
45
|
+
|
|
46
|
+
if supported_allowed.empty?
|
|
47
|
+
eol_forced_finding(req, requirement, support_window)
|
|
48
|
+
elsif !support_window[:latest_stable].nil? && !req.satisfied_by?(support_window[:latest_stable]) && !support_window[:latest_stable_fresh]
|
|
49
|
+
# Runs on a supported runtime but not the latest stable. Suppressed while
|
|
50
|
+
# the latest stable is still within its grace window (see supported_ruby_
|
|
51
|
+
# range): right after a runtime ships, "doesn't support it yet" indicts the
|
|
52
|
+
# release calendar, not the gem. After the window it is a real note.
|
|
53
|
+
latest_not_yet_finding(requirement, support_window)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# A ceiling exists only if the constraint has an upper bound. A pure lower
|
|
60
|
+
# bound (`>`, `>=`) can never strand you on an old release, so skip it before
|
|
61
|
+
# any cycle math (this is also what keeps `>= 4.1` requires-newer out). Exact
|
|
62
|
+
# pins (`=`) are excluded deliberately: pinning a runtime to an exact patch is
|
|
63
|
+
# pathological (unheard of in practice) and can't be matched against the
|
|
64
|
+
# major.minor EOL cycles anyway, so we don't fabricate support for it.
|
|
65
|
+
CAPPING_OPERATORS = ["<", "<=", "~>"].freeze
|
|
66
|
+
|
|
67
|
+
def capping?(req)
|
|
68
|
+
req.requirements.any? { |operator, _version| CAPPING_OPERATORS.include?(operator) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def eol_forced_finding(req, requirement, support_window)
|
|
72
|
+
# No supported release is admitted. Only a genuine cap (admits some EOL
|
|
73
|
+
# release) is EOL-forced; a requires-newer floor admits nothing at or below
|
|
74
|
+
# the oldest supported and is not our concern.
|
|
75
|
+
allowed_eol = support_window[:cycles].select { |c| c[:eol] && req.satisfied_by?(c[:version]) }
|
|
76
|
+
return if allowed_eol.empty?
|
|
77
|
+
|
|
78
|
+
ceiling = allowed_eol.max_by { |c| c[:version] }
|
|
79
|
+
finding = {
|
|
80
|
+
requirement: requirement,
|
|
81
|
+
eol_forced: true,
|
|
82
|
+
ceiling_version: ceiling[:version].to_s,
|
|
83
|
+
ceiling_eol_date: ceiling[:eol_date],
|
|
84
|
+
oldest_supported: support_window[:oldest_supported].to_s,
|
|
85
|
+
latest_stable: support_window[:latest_stable].to_s,
|
|
86
|
+
}
|
|
87
|
+
finding.merge(severity: ConstraintHelper.constraint_severity(finding))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def latest_not_yet_finding(requirement, support_window)
|
|
91
|
+
finding = {
|
|
92
|
+
requirement: requirement,
|
|
93
|
+
eol_forced: false,
|
|
94
|
+
oldest_supported: support_window[:oldest_supported].to_s,
|
|
95
|
+
latest_stable: support_window[:latest_stable].to_s,
|
|
96
|
+
}
|
|
97
|
+
finding.merge(severity: ConstraintHelper.constraint_severity(finding))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Registries render a floor+ceiling requirement as a single comma-joined string
|
|
101
|
+
# (e.g. ">= 2.5, < 3.2" -- common for `ruby_version`/`required_ruby_version`).
|
|
102
|
+
# Gem::Requirement.new can't parse that one-string form (it raises), so split
|
|
103
|
+
# into clauses and splat, preserving BOTH bounds. Reading only the string as-is
|
|
104
|
+
# would drop the `< 3.2` ceiling and silently miss the very case this exists to
|
|
105
|
+
# catch. Best-effort: a genuinely malformed clause degrades to nil (no finding),
|
|
106
|
+
# never a raised exception that could break the core audit.
|
|
107
|
+
def safe_requirement(requirement)
|
|
108
|
+
return if requirement.nil? || requirement.to_s.length > MAX_REQUIREMENT_LENGTH
|
|
109
|
+
|
|
110
|
+
clauses = requirement.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
111
|
+
return if clauses.empty?
|
|
112
|
+
|
|
113
|
+
Gem::Requirement.new(*clauses)
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
# Covers Gem::Requirement::BadRequirementError (a subclass) plus any other
|
|
116
|
+
# malformed-input ArgumentError, so a pathological registry string degrades
|
|
117
|
+
# to "no ceiling" and never breaks the core audit.
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/helpers/sarif_helper.rb
CHANGED
|
@@ -5,6 +5,8 @@ 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"
|
|
9
|
+
require_relative "constraint_helper"
|
|
8
10
|
|
|
9
11
|
module StillActive
|
|
10
12
|
# Renders a still_active workflow result as a SARIF 2.1.0 document.
|
|
@@ -19,7 +21,7 @@ module StillActive
|
|
|
19
21
|
|
|
20
22
|
LIBYEAR_THRESHOLD = 1.0
|
|
21
23
|
SCORECARD_LOW_THRESHOLD = 4.0
|
|
22
|
-
|
|
24
|
+
SECONDS_PER_YEAR = 365 * 24 * 60 * 60 # for the human-readable "in N years"
|
|
23
25
|
|
|
24
26
|
# result: same hash StillActive::Workflow.call returns (gem_name => gem_data)
|
|
25
27
|
# ruby_info: optional Ruby freshness hash (or nil)
|
|
@@ -98,61 +100,197 @@ module StillActive
|
|
|
98
100
|
location = location_for(name, line_index, lockfile_uri)
|
|
99
101
|
|
|
100
102
|
if data[:archived]
|
|
101
|
-
out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}.", location)
|
|
103
|
+
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
104
|
end
|
|
103
105
|
|
|
104
106
|
unless data[:archived]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
years = ((Time.now -
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
if ActivityHelper.activity_level(data) == :critical
|
|
108
|
+
activity = ActivityHelper.last_activity(data)
|
|
109
|
+
years = ((Time.now - activity[:date]) / SECONDS_PER_YEAR).round(1)
|
|
110
|
+
noun = activity[:kind] == :release ? "no release" : "no commits"
|
|
111
|
+
out << mark_suppressed(
|
|
112
|
+
result(
|
|
113
|
+
"SA002",
|
|
114
|
+
name,
|
|
115
|
+
"#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
|
|
116
|
+
location,
|
|
117
|
+
),
|
|
110
118
|
name,
|
|
111
|
-
|
|
112
|
-
location,
|
|
119
|
+
:activity,
|
|
113
120
|
)
|
|
114
121
|
end
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
Array(data[:vulnerabilities]).each do |vuln|
|
|
118
|
-
out << vulnerability_result(name, version, vuln, location)
|
|
125
|
+
out << vulnerability_result(name, version, vuln, location, data)
|
|
119
126
|
end
|
|
120
127
|
|
|
121
128
|
if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
|
|
122
129
|
latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
|
|
123
|
-
out << result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}.", location)
|
|
130
|
+
out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
|
|
124
131
|
end
|
|
125
132
|
|
|
126
133
|
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)
|
|
134
|
+
out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
if data[:version_yanked]
|
|
131
|
-
out << result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location)
|
|
138
|
+
out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if data[:poison] && !Array(data[:constraints]).empty?
|
|
142
|
+
# Level tracks the poison tier (critical->error, ...); a plain majors-behind
|
|
143
|
+
# tier change keeps the (rule_id, gem_name) fingerprint so it doesn't re-alert.
|
|
144
|
+
# But an escalation up the tiers (maintenance -> security-relevant -> below the
|
|
145
|
+
# fix) adds a fingerprint dimension so a past dismissal of a weaker tier can't
|
|
146
|
+
# silently mute the stronger one -- the transition a human must see.
|
|
147
|
+
state = if data[:poison_below_fix]
|
|
148
|
+
"below_fix"
|
|
149
|
+
elsif data[:poison_security_relevant]
|
|
150
|
+
"security"
|
|
151
|
+
else
|
|
152
|
+
"maintenance"
|
|
153
|
+
end
|
|
154
|
+
out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if data[:language_ceiling]
|
|
158
|
+
# Level tracks the ceiling tier: critical (EOL-forced) -> error, note
|
|
159
|
+
# (latest-not-yet) -> note. Fingerprint is (rule_id, gem_name, state) where
|
|
160
|
+
# state is the eol_forced boolean: cosmetic churn (which patch, which
|
|
161
|
+
# latest) doesn't re-alert, but a note -> EOL-forced escalation (a supported
|
|
162
|
+
# runtime went EOL) mints a NEW alert so a past dismissal of the note can't
|
|
163
|
+
# silently mute the critical -- that transition is exactly the one a human
|
|
164
|
+
# must see.
|
|
165
|
+
state = data[:language_ceiling][:eol_forced] ? "eol_forced" : "latest_not_yet"
|
|
166
|
+
out << mark_suppressed(result("SA009", name, language_ceiling_message(name, version, data), location, level: language_ceiling_level(data), fp_extra: state), name, :language_ceiling)
|
|
132
167
|
end
|
|
133
168
|
|
|
134
169
|
out
|
|
135
170
|
end
|
|
136
171
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
172
|
+
def language_ceiling_level(data)
|
|
173
|
+
{ critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def language_ceiling_message(name, version, data)
|
|
177
|
+
ceiling = data[:language_ceiling]
|
|
178
|
+
runtime = ceiling[:runtime]
|
|
179
|
+
body =
|
|
180
|
+
if ceiling[:eol_forced]
|
|
181
|
+
eol = ceiling[:ceiling_eol_date]
|
|
182
|
+
eol_part = eol ? " (EOL #{format_date(eol)})" : ""
|
|
183
|
+
"stranding you on end-of-life #{runtime} #{ceiling[:ceiling_version]}#{eol_part}"
|
|
184
|
+
else
|
|
185
|
+
"no #{runtime} #{ceiling[:latest_stable]} support yet"
|
|
186
|
+
end
|
|
187
|
+
fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
|
|
188
|
+
"#{name} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The poison receipt for a Code Scanning alert: the worst 3 caps (shared
|
|
192
|
+
# ranking via ConstraintHelper.top_findings so it can't drift from the other
|
|
193
|
+
# renderers) with the exact latest version (a machine-read alert wants the
|
|
194
|
+
# precise version, not the "8.x" the terminal abbreviates to), plus "+N more"
|
|
195
|
+
# and the transitive parent. Note result() fingerprints on (rule_id, gem_name)
|
|
196
|
+
# only, so this volatile detail never re-alerts a finding the user triaged.
|
|
197
|
+
def poison_level(data)
|
|
198
|
+
# A security-relevant cap (a dormant dep pins a known-vulnerable dependency
|
|
199
|
+
# below its fix) escalates to error regardless of majors-behind: it's a real
|
|
200
|
+
# security finding, not the maintenance-tier signal poison usually is.
|
|
201
|
+
return "error" if data[:poison_security_relevant]
|
|
202
|
+
|
|
203
|
+
{ critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def poison_message(name, version, data)
|
|
207
|
+
top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
|
|
208
|
+
caps = top[:shown].map do |finding|
|
|
209
|
+
behind = finding[:majors_behind]
|
|
210
|
+
"#{finding[:dependency]} #{finding[:requirement]} (#{behind} major#{"s" unless behind == 1} behind, latest #{finding[:dep_latest]})"
|
|
211
|
+
end
|
|
212
|
+
remaining = top[:total] - top[:shown].length
|
|
213
|
+
caps << "+#{remaining} more" if remaining.positive?
|
|
214
|
+
"#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Spells out the security escalation for a code-scanning alert. Only claims
|
|
218
|
+
# "below the fix" (with the CVE and its nearest fix, a version the cap forbids)
|
|
219
|
+
# when that is actually established. Otherwise we say only that the dep is
|
|
220
|
+
# known-vulnerable, WITHOUT asserting patchability: this branch also covers a
|
|
221
|
+
# HIGH advisory with no released fix at all (class C), which is not patchable in
|
|
222
|
+
# place, so an "(patchable in place)" claim here would be false on the worst case.
|
|
223
|
+
def poison_security_note(data)
|
|
224
|
+
return "" unless data[:poison_security_relevant]
|
|
225
|
+
|
|
226
|
+
below = Array(data[:constraints]).select { |c| c[:capped_below_fix] }
|
|
227
|
+
if below.any?
|
|
228
|
+
receipts = below.map { |c| "#{c[:dependency]} below the fix (#{c[:below_fix_advisory]} fixed in #{c[:below_fix_fixed_in]}, outside the cap)" }.uniq
|
|
229
|
+
" -- pins #{receipts.join("; ")}"
|
|
230
|
+
else
|
|
231
|
+
pinned = Array(data[:constraints]).select { |c| c[:capped_dep_vulnerable] }.map { |c| c[:dependency] }.uniq
|
|
232
|
+
" -- pins known-vulnerable #{pinned.join(", ")}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Attaches a SARIF native suppressions[] entry when this finding is covered
|
|
237
|
+
# by a whole-gem --ignore or a granular .still_active.yml suppression, so a
|
|
238
|
+
# GitHub code-scanning consumer renders it dismissed rather than open. The
|
|
239
|
+
# suppression's reason rides along as the justification.
|
|
240
|
+
def mark_suppressed(result_hash, gem_name, signal, advisory: nil, aliases: [])
|
|
241
|
+
config = StillActive.config
|
|
242
|
+
if config.ignored_gems.include?(gem_name)
|
|
243
|
+
result_hash["suppressions"] = [{ "kind" => "external", "justification" => "ignored via --ignore" }]
|
|
244
|
+
return result_hash
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
entry = config.suppressions.match(gem: gem_name, signal: signal, advisory: advisory, aliases: aliases)
|
|
248
|
+
return result_hash unless entry
|
|
249
|
+
|
|
250
|
+
suppression = { "kind" => "external" }
|
|
251
|
+
suppression["justification"] = entry.reason if entry.reason
|
|
252
|
+
result_hash["suppressions"] = [suppression]
|
|
253
|
+
result_hash
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def vulnerability_result(name, version, vuln, location, data = {})
|
|
257
|
+
# Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
|
|
258
|
+
# label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
|
|
259
|
+
# exports as error, not a note/warning a code-scanning gate reads as
|
|
260
|
+
# informational. The security-severity NUMBER stays tied to a real CVSS score
|
|
261
|
+
# (effective_score): a label-only advisory carries no invented number rather
|
|
262
|
+
# than a fabricated 7.0. A confirmed-but-unscored advisory still fails closed.
|
|
263
|
+
score = VulnerabilityHelper.effective_score(vuln)
|
|
264
|
+
level = Sarif::Rules.severity_to_level(VulnerabilityHelper.advisory_severity(vuln))
|
|
140
265
|
severity = Sarif::Rules.cvss_to_security_severity(score)
|
|
141
266
|
advisory_id = vuln[:id] || Array(vuln[:aliases]).first || "unknown"
|
|
142
267
|
aliases = Array(vuln[:aliases]).first(3).join(", ")
|
|
143
268
|
alias_suffix = aliases.empty? ? "" : " [#{aliases}]"
|
|
144
269
|
title = vuln[:title] ? ": #{vuln[:title]}" : ""
|
|
270
|
+
# ruby-advisory-db records no safe version: upgrading can't clear it, which
|
|
271
|
+
# is the actionable distinction from an ordinary (patchable) advisory.
|
|
272
|
+
no_fix = vuln[:no_fix_available] ? " (no fixed version available)" : ""
|
|
145
273
|
|
|
146
274
|
base = result(
|
|
147
275
|
"SA003",
|
|
148
276
|
name,
|
|
149
|
-
"#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}.",
|
|
277
|
+
"#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
|
|
150
278
|
location,
|
|
151
279
|
level: level,
|
|
152
280
|
fp_extra: advisory_id,
|
|
153
281
|
)
|
|
154
282
|
base["properties"] = { "security-severity" => severity } if severity
|
|
155
|
-
base
|
|
283
|
+
mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Names the direct dependency a transitive flagged gem rides in on, so a
|
|
287
|
+
# code-scanning consumer gets the actionable "replace your direct gem" hop
|
|
288
|
+
# instead of an un-actionable transitive finding (#60).
|
|
289
|
+
def transitive_suffix(data)
|
|
290
|
+
path = data[:dependency_path]
|
|
291
|
+
return "" unless data[:direct] == false && path && path.length >= 2
|
|
292
|
+
|
|
293
|
+
" (transitive, pulled in by #{path.first})"
|
|
156
294
|
end
|
|
157
295
|
|
|
158
296
|
def ruby_eol_result(ruby_info, ruby_line, lockfile_uri)
|
|
@@ -211,17 +349,8 @@ module StillActive
|
|
|
211
349
|
Sarif::Rules.all.index { |r| r[:id] == rule_id }
|
|
212
350
|
end
|
|
213
351
|
|
|
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
352
|
def format_date(value)
|
|
224
|
-
t = parse_time(value)
|
|
353
|
+
t = ActivityHelper.parse_time(value)
|
|
225
354
|
t ? t.utc.strftime("%Y-%m-%d") : value.to_s
|
|
226
355
|
end
|
|
227
356
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "semantic_range"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Does a concrete version satisfy a declared requirement, at PATCH precision,
|
|
7
|
+
# for npm and cargo? This is the primitive the cross-ecosystem below-the-fix
|
|
8
|
+
# signal needs: a CVE's fix is usually a same-major patch bump, so "can this fix
|
|
9
|
+
# be reached within the package's constraint" cannot be answered by the coarse,
|
|
10
|
+
# major-precision ConstraintHelper. node-semver's caret/tilde/OR/prerelease rules
|
|
11
|
+
# are a correctness minefield, so we lean on the semantic_range gem (a node-semver
|
|
12
|
+
# port) rather than reimplement them.
|
|
13
|
+
#
|
|
14
|
+
# npm ranges are node-semver as-is. cargo's VersionReq agrees with node-semver on
|
|
15
|
+
# every operator form, and diverges on ANY operator-less bare version: cargo treats
|
|
16
|
+
# a bare version as a caret (`1.2.3` = `^1.2.3`, `1.2` = `^1.2` = `>=1.2.0 <2.0.0`,
|
|
17
|
+
# `1` = `^1`), while node-semver reads a bare full version as an exact pin and a
|
|
18
|
+
# partial `1.2` as the narrower `1.2.x` (`<1.3.0`). Per the Cargo Book's caret
|
|
19
|
+
# table, node-semver's caret expands identically to cargo's for every one of these
|
|
20
|
+
# forms, so the whole shim is: prefix `^` to a bare version before evaluating.
|
|
21
|
+
# Getting it wrong (e.g. leaving `1.2` unshimmed) reads a reachable fix as
|
|
22
|
+
# unreachable and fabricates a below-the-fix security finding.
|
|
23
|
+
module SemverSatisfaction
|
|
24
|
+
extend self
|
|
25
|
+
|
|
26
|
+
# An operator-less bare version, 1 to 3 numeric components, optional prerelease
|
|
27
|
+
# and/or build tail (`1`, `1.2`, `1.2.3`, `0.10.38`, `1.2.3-alpha+001`). cargo
|
|
28
|
+
# reads any of these as a caret; node-semver does not, so cargo shims them.
|
|
29
|
+
BARE_VERSION = /\A\s*v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*\z/
|
|
30
|
+
|
|
31
|
+
# Does `version` satisfy `requirement`? true / false when decidable; nil when the
|
|
32
|
+
# requirement or version isn't valid semver for the ecosystem, or the ecosystem
|
|
33
|
+
# isn't one we model. The tri-state is deliberate (hence a plain verb, not a
|
|
34
|
+
# `?` predicate): semantic_range returns false for garbage, and a false would read
|
|
35
|
+
# as "the fix can't be reached" in a wall test and fabricate a below-the-fix flag.
|
|
36
|
+
# The caller must treat nil as "cannot establish a wall", never as unreachable.
|
|
37
|
+
def evaluate(requirement:, version:, ecosystem:)
|
|
38
|
+
range = range_for(requirement, ecosystem)
|
|
39
|
+
return if range.nil? # undecidable: unmodelled ecosystem or unparseable requirement
|
|
40
|
+
return if SemanticRange.valid(version.to_s).nil? # undecidable: unparseable version
|
|
41
|
+
|
|
42
|
+
SemanticRange.satisfies?(version.to_s, range)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The node-semver range string for this ecosystem's requirement, or nil if it
|
|
48
|
+
# isn't a valid range (or the ecosystem is unmodelled). cargo's bare-version
|
|
49
|
+
# caret is applied BEFORE validation so the shimmed form is what gets checked.
|
|
50
|
+
def range_for(requirement, ecosystem)
|
|
51
|
+
requirement = requirement.to_s.strip
|
|
52
|
+
# A blank requirement is missing data (a failed extraction), NOT a package that
|
|
53
|
+
# allows any version: semantic_range reads "" as `*` (matches everything), which
|
|
54
|
+
# would be a false-confident answer. Keep it undecidable, like a blank version.
|
|
55
|
+
return if requirement.empty?
|
|
56
|
+
|
|
57
|
+
case ecosystem
|
|
58
|
+
when :npm then SemanticRange.valid_range(requirement)
|
|
59
|
+
when :cargo then SemanticRange.valid_range(cargo_normalize(requirement))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `requirement` arrives already stripped from range_for.
|
|
64
|
+
def cargo_normalize(requirement)
|
|
65
|
+
requirement.match?(BARE_VERSION) ? "^#{requirement}" : requirement
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "activity_helper"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Collapses a gem's several maintenance signals into one categorical verdict,
|
|
7
|
+
# so a machine/LLM consumer (or another tool's report) can display and threshold
|
|
8
|
+
# a single value instead of re-deriving it from activity_level + archived +
|
|
9
|
+
# vulnerability_count. This is deliberately NOT a numeric composite: an earlier
|
|
10
|
+
# 0-100 score was removed because a weighted average let missing data read as
|
|
11
|
+
# "perfect health". Here, :unknown stays :unknown -- absence of data is never
|
|
12
|
+
# rendered as :ok.
|
|
13
|
+
module StatusHelper
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
# Worst-first lifecycle verdict. The key distinction (from the "done gems"
|
|
17
|
+
# critique and validated against the maintenance-tooling landscape): a clean,
|
|
18
|
+
# long-dormant gem is :legacy ("done", low risk), NOT a problem -- whereas a
|
|
19
|
+
# dormant or archived gem carrying an unpatched advisory is :dead (no one is
|
|
20
|
+
# going to fix it, migrate). :unknown is least severe -- an absence, not a
|
|
21
|
+
# finding -- so missing data never reads as :ok.
|
|
22
|
+
SEVERITY = [:unknown, :ok, :legacy, :stale, :archived, :vulnerable, :dead].freeze
|
|
23
|
+
|
|
24
|
+
# Returns :dead, :vulnerable, :archived, :legacy, :stale, :ok, or :unknown.
|
|
25
|
+
def gem_status(gem_data)
|
|
26
|
+
vulnerable = gem_data[:vulnerability_count].to_i.positive?
|
|
27
|
+
|
|
28
|
+
# A pinned version the registry can't resolve (yanked, typo, or nonexistent)
|
|
29
|
+
# has no version-specific data to judge, so package-level health must not read
|
|
30
|
+
# it as :ok. Absence of data is :unknown, never a false all-clear. Guarded on
|
|
31
|
+
# `!vulnerable` so a detected advisory always wins, in case a future source
|
|
32
|
+
# ever attaches one to an otherwise-unresolved version.
|
|
33
|
+
return :unknown if gem_data[:version_unresolved] && !vulnerable
|
|
34
|
+
|
|
35
|
+
level = ActivityHelper.activity_level(gem_data)
|
|
36
|
+
|
|
37
|
+
if vulnerable
|
|
38
|
+
# A vulnerability in a dormant or archived gem won't be patched -> :dead;
|
|
39
|
+
# in an actively-released gem a fix is plausible -> :vulnerable.
|
|
40
|
+
return [:critical, :archived].include?(level) ? :dead : :vulnerable
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
case level
|
|
44
|
+
when :archived
|
|
45
|
+
# archived != EOL: a repo archived while the gem still publishes recent
|
|
46
|
+
# releases (development moved to a monorepo) isn't dead -- let the
|
|
47
|
+
# releases speak, but keep :stale so the archived repo stays a yellow flag.
|
|
48
|
+
ActivityHelper.release_recency_level(gem_data) == :ok ? :stale : :archived
|
|
49
|
+
when :critical then :legacy # long-dormant but clean: feature-complete, not a fire
|
|
50
|
+
else level # :stale / :ok / :unknown
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The single worst gem status across the audit. An EOL Ruby floors the
|
|
55
|
+
# project at :vulnerable (the runtime itself is a live, actionable risk).
|
|
56
|
+
# :unknown only wins when nothing better is known.
|
|
57
|
+
def project_status(result, ruby_info: nil)
|
|
58
|
+
statuses = result.each_value.map { |data| gem_status(data) }
|
|
59
|
+
statuses << :vulnerable if ruby_info&.dig(:eol) == true
|
|
60
|
+
return :unknown if statuses.empty?
|
|
61
|
+
|
|
62
|
+
# Every value gem_status returns is in SEVERITY today; if a future status
|
|
63
|
+
# isn't, rank it most-severe so it surfaces in the rollup rather than being
|
|
64
|
+
# silently masked by a milder finding.
|
|
65
|
+
statuses.max_by { |status| SEVERITY.index(status) || SEVERITY.length }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|