still_active 2.0.0 → 3.0.0.rc2
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 +42 -0
- data/README.md +104 -357
- data/lib/helpers/activity_helper.rb +8 -0
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +20 -4
- data/lib/helpers/diff_markdown_helper.rb +21 -5
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +17 -2
- data/lib/helpers/markdown_helper.rb +118 -1
- 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 +13 -24
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +105 -3
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +4 -0
- data/lib/helpers/terminal_helper.rb +144 -6
- data/lib/helpers/version_helper.rb +27 -0
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +212 -9
- data/lib/still_active/config.rb +28 -1
- data/lib/still_active/config_file.rb +27 -0
- data/lib/still_active/deps_dev_client.rb +115 -5
- data/lib/still_active/diff.rb +22 -0
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/options.rb +44 -1
- 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/sarif/rules.rb +33 -9
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +6 -5
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +205 -9
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- metadata +61 -11
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# Translates a PEP 440 `requires_python` specifier into a RubyGems requirement
|
|
5
|
+
# STRING, so the generic RuntimeCeilingHelper can reason about a Python runtime
|
|
6
|
+
# ceiling with no core change -- the same way Ruby's `ruby_version` already
|
|
7
|
+
# feeds it. PEP 440 and RubyGems disagree on two operators that matter here:
|
|
8
|
+
#
|
|
9
|
+
# - `~=` (compatible release) is a syntax error to Gem::Requirement, so a
|
|
10
|
+
# naive parse raises and the ceiling is silently missed. It maps cleanly to
|
|
11
|
+
# RubyGems `~>` for the major.minor.patch shapes `requires_python` uses.
|
|
12
|
+
# - `== X.*` / `== X.Y.*` prefix wildcards are likewise unparseable and map to
|
|
13
|
+
# a pessimistic `~>` over the prefix.
|
|
14
|
+
#
|
|
15
|
+
# `!=` exclusions are dropped: a hole-punch can never create an upper bound, so
|
|
16
|
+
# it cannot be a runtime ceiling, and dropping it can only ADMIT more runtimes
|
|
17
|
+
# (fewer findings) -- conservative, never a false ceiling. Anything that still
|
|
18
|
+
# won't parse degrades to a dropped clause (or nil overall), never a raise: this
|
|
19
|
+
# feeds the core audit and must fail safe, matching RuntimeCeilingHelper's
|
|
20
|
+
# best-effort contract.
|
|
21
|
+
module Pep440Helper
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
# Match ConstraintHelper / RuntimeCeilingHelper: bound pathological registry
|
|
25
|
+
# input before it reaches Gem::Requirement's own regex.
|
|
26
|
+
MAX_SPECIFIER_LENGTH = 256
|
|
27
|
+
|
|
28
|
+
# Operator, then the version as a run of non-space chars. `\s*(\S+)` (not
|
|
29
|
+
# `\s*(.+)`) keeps the two groups over disjoint character classes so there's no
|
|
30
|
+
# polynomial backtracking on pathological input like "<" + many spaces (a PEP
|
|
31
|
+
# 440 version never contains internal spaces, so this loses nothing).
|
|
32
|
+
CLAUSE_PATTERN = /\A(===|==|~=|!=|<=|>=|<|>)\s*(\S+)\z/
|
|
33
|
+
|
|
34
|
+
# => a comma-joined RubyGems requirement string, or nil when nothing usable
|
|
35
|
+
# survives translation. The caller feeds the string to RuntimeCeilingHelper,
|
|
36
|
+
# which splits and splats it exactly like a `ruby_version` string.
|
|
37
|
+
def to_gem_requirement_string(specifier)
|
|
38
|
+
spec = specifier.to_s
|
|
39
|
+
return if spec.strip.empty? || spec.length > MAX_SPECIFIER_LENGTH
|
|
40
|
+
|
|
41
|
+
clauses = spec.split(",").filter_map { |clause| translate_clause(clause.strip) }
|
|
42
|
+
return if clauses.empty?
|
|
43
|
+
|
|
44
|
+
clauses.join(", ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def translate_clause(clause)
|
|
50
|
+
match = CLAUSE_PATTERN.match(clause)
|
|
51
|
+
return if match.nil?
|
|
52
|
+
|
|
53
|
+
operator = match[1]
|
|
54
|
+
version = match[2].strip
|
|
55
|
+
|
|
56
|
+
case operator
|
|
57
|
+
when "!=" then nil # hole-punch: never an upper bound, drop it
|
|
58
|
+
when "~=" then compatible_release(version)
|
|
59
|
+
when "==", "===" then equality(version)
|
|
60
|
+
else comparison(operator, version)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `~= X.Y[.Z]` is `>= X.Y[.Z], == X.Y.*` which is exactly RubyGems `~> X.Y[.Z]`.
|
|
65
|
+
def compatible_release(version)
|
|
66
|
+
return unless parseable?(version)
|
|
67
|
+
|
|
68
|
+
"~> #{version}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def equality(version)
|
|
72
|
+
if version.end_with?(".*")
|
|
73
|
+
prefix_match(version.delete_suffix(".*"))
|
|
74
|
+
elsif parseable?(version)
|
|
75
|
+
"= #{version}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `== P.*` matches every release whose version starts with prefix P, i.e.
|
|
80
|
+
# `>= P, < P-with-last-segment-incremented`. RubyGems spells that as `~> P.0`
|
|
81
|
+
# (append one segment so the pessimistic bump lands on P's last segment):
|
|
82
|
+
# ==3.* -> ~> 3.0 (>=3, <4)
|
|
83
|
+
# ==3.7.* -> ~> 3.7.0 (>=3.7, <3.8)
|
|
84
|
+
def prefix_match(prefix)
|
|
85
|
+
return unless parseable?(prefix)
|
|
86
|
+
|
|
87
|
+
"~> #{prefix}.0"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def comparison(operator, version)
|
|
91
|
+
return unless parseable?(version)
|
|
92
|
+
|
|
93
|
+
"#{operator} #{version}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parseable?(version)
|
|
97
|
+
Gem::Version.correct?(version)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "endoflife_helper"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Python's calibration for the language-runtime ceiling: the thin sibling of
|
|
7
|
+
# RubyHelper.supported_ruby_range. Python declares its runtime constraint as a
|
|
8
|
+
# PEP 440 `requires_python` specifier (translate with Pep440Helper before
|
|
9
|
+
# handing it to RuntimeCeilingHelper), and its release calendar lives in the
|
|
10
|
+
# same endoflife.date feed. Everything ecosystem-neutral is in EndoflifeHelper;
|
|
11
|
+
# Python only chooses the feed path.
|
|
12
|
+
module PythonHelper
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
def supported_python_range
|
|
16
|
+
EndoflifeHelper.support_window(feed_path: "/api/python.json")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "bundler_helper"
|
|
5
|
+
require_relative "endoflife_helper"
|
|
5
6
|
require_relative "http_helper"
|
|
6
7
|
require_relative "libyear_helper"
|
|
7
8
|
|
|
@@ -24,15 +25,15 @@ module StillActive
|
|
|
24
25
|
return if latest_cycle.nil?
|
|
25
26
|
|
|
26
27
|
latest_version = latest_cycle["latest"]
|
|
27
|
-
latest_release_date = parse_date(latest_cycle["releaseDate"])
|
|
28
|
-
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"))
|
|
29
30
|
eol_value = current_cycle&.dig("eol")
|
|
30
31
|
|
|
31
32
|
{
|
|
32
33
|
version: current,
|
|
33
34
|
release_date: current_release_date,
|
|
34
|
-
eol_date: parse_eol(eol_value),
|
|
35
|
-
eol: eol_reached?(eol_value),
|
|
35
|
+
eol_date: EndoflifeHelper.parse_eol(eol_value),
|
|
36
|
+
eol: EndoflifeHelper.eol_reached?(eol_value),
|
|
36
37
|
latest_version: latest_version,
|
|
37
38
|
latest_release_date: latest_release_date,
|
|
38
39
|
libyear: LibyearHelper.gem_libyear(
|
|
@@ -42,6 +43,14 @@ module StillActive
|
|
|
42
43
|
}
|
|
43
44
|
end
|
|
44
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
|
+
|
|
45
54
|
private
|
|
46
55
|
|
|
47
56
|
def current_ruby_version
|
|
@@ -68,25 +77,5 @@ module StillActive
|
|
|
68
77
|
major_minor = version.split(".")[0..1].join(".")
|
|
69
78
|
cycles.find { |c| c["cycle"] == major_minor }
|
|
70
79
|
end
|
|
71
|
-
|
|
72
|
-
def parse_date(date_string)
|
|
73
|
-
return if date_string.nil?
|
|
74
|
-
|
|
75
|
-
Time.parse(date_string)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def parse_eol(value)
|
|
79
|
-
case value
|
|
80
|
-
when String then parse_date(value)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def eol_reached?(value)
|
|
85
|
-
case value
|
|
86
|
-
when true then true
|
|
87
|
-
when false then false
|
|
88
|
-
when String then Time.parse(value) <= Time.now
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
80
|
end
|
|
92
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
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require_relative "../still_active/sarif/rules"
|
|
7
7
|
require_relative "lockfile_indexer"
|
|
8
8
|
require_relative "activity_helper"
|
|
9
|
+
require_relative "constraint_helper"
|
|
9
10
|
|
|
10
11
|
module StillActive
|
|
11
12
|
# Renders a still_active workflow result as a SARIF 2.1.0 document.
|
|
@@ -137,9 +138,101 @@ module StillActive
|
|
|
137
138
|
out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
|
|
138
139
|
end
|
|
139
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)
|
|
167
|
+
end
|
|
168
|
+
|
|
140
169
|
out
|
|
141
170
|
end
|
|
142
171
|
|
|
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
|
+
|
|
143
236
|
# Attaches a SARIF native suppressions[] entry when this finding is covered
|
|
144
237
|
# by a whole-gem --ignore or a granular .still_active.yml suppression, so a
|
|
145
238
|
# GitHub code-scanning consumer renders it dismissed rather than open. The
|
|
@@ -161,18 +254,27 @@ module StillActive
|
|
|
161
254
|
end
|
|
162
255
|
|
|
163
256
|
def vulnerability_result(name, version, vuln, location, data = {})
|
|
164
|
-
score
|
|
165
|
-
|
|
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))
|
|
166
265
|
severity = Sarif::Rules.cvss_to_security_severity(score)
|
|
167
266
|
advisory_id = vuln[:id] || Array(vuln[:aliases]).first || "unknown"
|
|
168
267
|
aliases = Array(vuln[:aliases]).first(3).join(", ")
|
|
169
268
|
alias_suffix = aliases.empty? ? "" : " [#{aliases}]"
|
|
170
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)" : ""
|
|
171
273
|
|
|
172
274
|
base = result(
|
|
173
275
|
"SA003",
|
|
174
276
|
name,
|
|
175
|
-
"#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{transitive_suffix(data)}.",
|
|
277
|
+
"#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
|
|
176
278
|
location,
|
|
177
279
|
level: level,
|
|
178
280
|
fp_extra: advisory_id,
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "activity_helper"
|
|
4
|
+
require_relative "status_helper"
|
|
4
5
|
|
|
5
6
|
module StillActive
|
|
6
7
|
# Builds the JSON output's summary{} digest: the headline posture of the audit
|
|
@@ -40,6 +41,9 @@ module StillActive
|
|
|
40
41
|
outdated: outdated,
|
|
41
42
|
vulnerable_gems: vulnerable_gems,
|
|
42
43
|
vulnerabilities: vulnerabilities,
|
|
44
|
+
# The single worst per-gem verdict (plus EOL Ruby), so a consumer reads
|
|
45
|
+
# one project-level posture without scanning every gem's status.
|
|
46
|
+
status: StatusHelper.project_status(result, ruby_info: ruby_info),
|
|
43
47
|
}
|
|
44
48
|
summary[:ruby_eol] = ruby_info[:eol] == true if ruby_info
|
|
45
49
|
summary
|