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
data/lib/still_active/options.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "../helpers/constraint_helper"
|
|
4
5
|
require_relative "../helpers/cyclonedx_helper"
|
|
5
6
|
require_relative "../helpers/vulnerability_helper"
|
|
6
7
|
|
|
@@ -15,6 +16,7 @@ module StillActive
|
|
|
15
16
|
add_tail_options(opts)
|
|
16
17
|
add_gemfile_option(opts)
|
|
17
18
|
add_gems_option(opts)
|
|
19
|
+
add_sbom_option(opts)
|
|
18
20
|
add_ignore_option(opts)
|
|
19
21
|
add_output_options(opts)
|
|
20
22
|
add_token_options(opts)
|
|
@@ -34,7 +36,11 @@ module StillActive
|
|
|
34
36
|
private
|
|
35
37
|
|
|
36
38
|
def validate_options
|
|
37
|
-
|
|
39
|
+
inputs = [options[:provided_gemfile], options[:provided_gems], options[:provided_sbom]].compact
|
|
40
|
+
raise ArgumentError, "provide only one of --gemfile, --gems, --sbom" if inputs.size > 1
|
|
41
|
+
if options[:provided_sbom] && !File.exist?(StillActive.config.sbom_path)
|
|
42
|
+
raise ArgumentError, "SBOM file not found: #{StillActive.config.sbom_path}"
|
|
43
|
+
end
|
|
38
44
|
if options[:provided_baseline] && !File.exist?(StillActive.config.baseline_path)
|
|
39
45
|
raise ArgumentError, "baseline file not found: #{StillActive.config.baseline_path}"
|
|
40
46
|
end
|
|
@@ -50,7 +56,15 @@ module StillActive
|
|
|
50
56
|
def add_gems_option(opts)
|
|
51
57
|
opts.on("--gems=GEM,GEM2,...", Array, "Gem(s)") do |value|
|
|
52
58
|
options[:provided_gems] = true
|
|
53
|
-
|
|
59
|
+
# Explicitly named gems are direct by definition (the user chose them).
|
|
60
|
+
StillActive.config { |config| config.gems = value.map { |g| { name: g, direct: true } } }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_sbom_option(opts)
|
|
65
|
+
opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; JSON output") do |value|
|
|
66
|
+
options[:provided_sbom] = true
|
|
67
|
+
StillActive.config { |config| config.sbom_path = value }
|
|
54
68
|
end
|
|
55
69
|
end
|
|
56
70
|
|
|
@@ -65,6 +79,8 @@ module StillActive
|
|
|
65
79
|
opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
|
|
66
80
|
opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
|
|
67
81
|
opts.on("--alternatives", "Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems") { StillActive.config { |config| config.alternatives = true } }
|
|
82
|
+
opts.on("--unreleased-commits", "Count commits on the default branch since the latest release (GitHub only; opt-in, one extra API call per gem)") { StillActive.config { |config| config.unreleased_commits = true } }
|
|
83
|
+
opts.on("--direct-only", "Audit only direct (declared) dependencies, not the full transitive lockfile graph") { StillActive.config { |config| config.direct_only = true } }
|
|
68
84
|
opts.on("--sarif[=PATH]", "SARIF 2.1.0 output for GitHub Code Scanning (default path: still_active.sarif.json; '-' for stdout). Overrides --terminal/--markdown/--json.") do |value|
|
|
69
85
|
StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
|
|
70
86
|
end
|
|
@@ -91,6 +107,17 @@ module StillActive
|
|
|
91
107
|
opts.on("--gitlab-token=TOKEN", String, "GitLab personal access token for API calls") do |value|
|
|
92
108
|
StillActive.config { |config| config.gitlab_token = value }
|
|
93
109
|
end
|
|
110
|
+
opts.on("--ecosystems-email=EMAIL", String, "Contact email for the ecosyste.ms polite pool (used only when falling back to ecosyste.ms without a GitHub token)") do |value|
|
|
111
|
+
email = value.strip
|
|
112
|
+
warn("warning: --ecosystems-email=#{value.inspect} doesn't look like an email (no @); ecosyste.ms will keep you in the anonymous pool") unless email.include?("@")
|
|
113
|
+
StillActive.config { |config| config.ecosystems_email = email }
|
|
114
|
+
end
|
|
115
|
+
opts.on("--artifactory-token=TOKEN", String, "Artifactory token for private gem registry API calls") do |value|
|
|
116
|
+
StillActive.config { |config| config.artifactory_token = value }
|
|
117
|
+
end
|
|
118
|
+
opts.on("--artifactory-host=HOST", String, "Artifactory host that may receive the global token (e.g. my-org.jfrog.io)") do |value|
|
|
119
|
+
StillActive.config { |config| config.artifactory_host = value }
|
|
120
|
+
end
|
|
94
121
|
end
|
|
95
122
|
|
|
96
123
|
def add_parallelism_options(opts)
|
|
@@ -102,15 +129,15 @@ module StillActive
|
|
|
102
129
|
def add_range_options(opts)
|
|
103
130
|
opts.on(
|
|
104
131
|
"--safe-range-end=YEARS",
|
|
105
|
-
|
|
106
|
-
"maximum years since last
|
|
132
|
+
Float,
|
|
133
|
+
"maximum years since last release considered safe, no warning (default 1.5; fractional allowed)",
|
|
107
134
|
) do |value|
|
|
108
135
|
StillActive.config { |config| config.no_warning_range_end = value }
|
|
109
136
|
end
|
|
110
137
|
opts.on(
|
|
111
138
|
"--warning-range-end=YEARS",
|
|
112
|
-
|
|
113
|
-
"maximum years since last
|
|
139
|
+
Float,
|
|
140
|
+
"maximum years since last release that triggers a warning, beyond this is critical (default 3)",
|
|
114
141
|
) do |value|
|
|
115
142
|
StillActive.config { |config| config.warning_range_end = value }
|
|
116
143
|
end
|
|
@@ -136,6 +163,31 @@ module StillActive
|
|
|
136
163
|
opts.on("--fail-if-outdated=LIBYEARS", Float, "Exit 1 if any gem exceeds LIBYEARS behind latest") do |value|
|
|
137
164
|
StillActive.config { |config| config.fail_if_outdated = value }
|
|
138
165
|
end
|
|
166
|
+
opts.on("--fail-if-poison[=TIER]", "Exit 1 on a poison-pill at or above TIER (note|warning|critical; default warning)") do |value|
|
|
167
|
+
if value
|
|
168
|
+
valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
|
|
169
|
+
raise ArgumentError, "--fail-if-poison tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
|
|
170
|
+
|
|
171
|
+
StillActive.config { |config| config.fail_if_poison = value.to_sym }
|
|
172
|
+
else
|
|
173
|
+
StillActive.config { |config| config.fail_if_poison = true }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
opts.on("--fail-if-language-ceiling[=TIER]", "Exit 1 on a language-runtime ceiling (Ruby/Python; default: EOL-forced only; =note also gates latest-not-yet)") do |value|
|
|
177
|
+
if value
|
|
178
|
+
valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
|
|
179
|
+
raise ArgumentError, "--fail-if-language-ceiling tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
|
|
180
|
+
|
|
181
|
+
# Ceiling findings are only ever :critical or :note, so =warning can't
|
|
182
|
+
# match anything the bare (=critical) default doesn't already catch.
|
|
183
|
+
if value == "warning"
|
|
184
|
+
$stderr.puts("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
|
|
185
|
+
end
|
|
186
|
+
StillActive.config { |config| config.fail_if_language_ceiling = value.to_sym }
|
|
187
|
+
else
|
|
188
|
+
StillActive.config { |config| config.fail_if_language_ceiling = true }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
139
191
|
end
|
|
140
192
|
|
|
141
193
|
def add_emoji_options(opts)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/http_helper"
|
|
4
|
+
require_relative "../helpers/cvss_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
|
|
8
|
+
# deps.dev is the discovery source -- it lists a package version's advisory ids --
|
|
9
|
+
# but it stores only CVSS 3.x (a CVSS-4-only advisory comes back with cvss3Score
|
|
10
|
+
# 0, which reads as unscored) and carries no fixed-version ranges at all. OSV
|
|
11
|
+
# supplies all three gaps: a GHSA severity LABEL (`database_specific.severity`)
|
|
12
|
+
# that reads correctly even for a v4-only advisory, the CVSS v4 VECTOR (turned into
|
|
13
|
+
# a real score, so the finding carries a security-severity number), and the
|
|
14
|
+
# per-package fixed versions the "capped below the fix" signal compares against a
|
|
15
|
+
# poison cap's ceiling.
|
|
16
|
+
#
|
|
17
|
+
# Enrichment is best-effort: any failure (missing record, transport error, odd
|
|
18
|
+
# shape) leaves the advisory exactly as deps.dev produced it. It must never drop
|
|
19
|
+
# an advisory the audit already found, so it only ever ADDS fields.
|
|
20
|
+
module OsvClient
|
|
21
|
+
extend self
|
|
22
|
+
|
|
23
|
+
BASE_URI = URI("https://api.osv.dev/")
|
|
24
|
+
|
|
25
|
+
# Every ecosystem SbomReader/deps.dev resolve, mapped to OSV's package-ecosystem
|
|
26
|
+
# casing (verified against live OSV records). One advisory can name the same
|
|
27
|
+
# package in several ecosystems, so `affected` is filtered to the one being
|
|
28
|
+
# audited; the native Bundler path carries no ecosystem and is always rubygems.
|
|
29
|
+
# Anything unmapped falls back to name-only fix filtering rather than dropping fixes.
|
|
30
|
+
ECOSYSTEM_NAMES = {
|
|
31
|
+
rubygems: "RubyGems",
|
|
32
|
+
pypi: "PyPI",
|
|
33
|
+
npm: "npm",
|
|
34
|
+
cargo: "crates.io",
|
|
35
|
+
maven: "Maven",
|
|
36
|
+
go: "Go",
|
|
37
|
+
nuget: "NuGet",
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Prefer the newest CVSS version a record carries (v4 is the whole point: it's the
|
|
41
|
+
# one deps.dev can't score). `severity[].score` is the vector STRING, oddly named.
|
|
42
|
+
CVSS_PRIORITY = { "CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1 }.freeze
|
|
43
|
+
# A v2 vector has no `CVSS:X.Y` prefix, so the version can't be read from the
|
|
44
|
+
# string; fall back to the entry type so the CycloneDX rating method labels it v2.
|
|
45
|
+
TYPE_VERSIONS = { "CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0" }.freeze
|
|
46
|
+
|
|
47
|
+
# Enrich each advisory in place with the OSV severity label and the fixed
|
|
48
|
+
# versions for the audited package. A missing/failed lookup is a no-op on that
|
|
49
|
+
# advisory (it keeps whatever deps.dev gave it), never a raise.
|
|
50
|
+
def enrich(advisories, ecosystem:, name:)
|
|
51
|
+
advisories.each do |advisory|
|
|
52
|
+
record = detail(advisory_id: advisory[:id])
|
|
53
|
+
next if record.nil?
|
|
54
|
+
|
|
55
|
+
advisory[:osv_severity] = record[:severity_label]
|
|
56
|
+
advisory[:osv_cvss_score] = record[:cvss_score]
|
|
57
|
+
advisory[:cvss_version] = record[:cvss_version]
|
|
58
|
+
advisory[:cvss_vector] = record[:cvss_vector]
|
|
59
|
+
advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
# Enrichment is additive and best-effort. An unexpected OSV shape must never
|
|
62
|
+
# raise out through the workflow's per-gem rescue, which would DROP the whole
|
|
63
|
+
# gem and read a known-vulnerable dependency as clean. Leave the advisory
|
|
64
|
+
# exactly as deps.dev produced it.
|
|
65
|
+
$stderr.puts("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
|
|
70
|
+
# { severity_label:, cvss_score:, cvss_version:, cvss_vector:, affected: [...] }
|
|
71
|
+
# or nil when the id is absent or OSV has no usable record for it. A non-object
|
|
72
|
+
# body (a CDN/error envelope that parses to an array or scalar) yields nil rather
|
|
73
|
+
# than raising on `dig`.
|
|
74
|
+
def detail(advisory_id:)
|
|
75
|
+
return if advisory_id.nil?
|
|
76
|
+
|
|
77
|
+
body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}")
|
|
78
|
+
return unless body.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
cvss = best_cvss(body)
|
|
81
|
+
{
|
|
82
|
+
severity_label: body.dig("database_specific", "severity"),
|
|
83
|
+
cvss_score: cvss[:score],
|
|
84
|
+
cvss_version: cvss[:version],
|
|
85
|
+
cvss_vector: cvss[:vector],
|
|
86
|
+
affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) },
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The highest-version CVSS vector the record carries, as { score:, version:,
|
|
93
|
+
# vector: } (a computed base score, the "3.1"/"4.0" version, and the raw string),
|
|
94
|
+
# or all-nil. deps.dev has no v4 score, so this is what gives a CVSS-4-only
|
|
95
|
+
# advisory a real number for the security-severity/rating.
|
|
96
|
+
def best_cvss(body)
|
|
97
|
+
entry = Array(body["severity"])
|
|
98
|
+
.select { |s| s.is_a?(Hash) && CVSS_PRIORITY.key?(s["type"]) }
|
|
99
|
+
.max_by { |s| CVSS_PRIORITY[s["type"]] }
|
|
100
|
+
return {} if entry.nil?
|
|
101
|
+
|
|
102
|
+
vector = entry["score"]
|
|
103
|
+
{ score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The X.Y version from a "CVSS:X.Y/..." vector prefix, or nil.
|
|
107
|
+
def cvss_version(vector)
|
|
108
|
+
vector.to_s[%r{\ACVSS:(\d+\.\d+)/}, 1]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The fixed versions declared for `name` in `ecosystem` across the record's
|
|
112
|
+
# affected packages (a branch-structured advisory carries one fix per maintained
|
|
113
|
+
# line, so several can apply). Empty when the package is enumerated by
|
|
114
|
+
# `versions` with no fix boundary, or isn't present in this ecosystem. When the
|
|
115
|
+
# ecosystem symbol has no OSV mapping (an ecosystem we don't audit for fixes),
|
|
116
|
+
# match by name alone rather than silently dropping every fix.
|
|
117
|
+
def fixed_versions(record, ecosystem:, name:)
|
|
118
|
+
osv_ecosystem = ECOSYSTEM_NAMES.fetch(ecosystem || :rubygems, nil)
|
|
119
|
+
record[:affected]
|
|
120
|
+
.select { |a| a[:name] == name && (osv_ecosystem.nil? || a[:ecosystem] == osv_ecosystem) }
|
|
121
|
+
.flat_map { |a| a[:fixed] }
|
|
122
|
+
.uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Shape guards throughout: a malformed entry (null/scalar where an object is
|
|
126
|
+
# expected, or an object where an array is) is skipped, not raised on, so one
|
|
127
|
+
# bad `affected`/`ranges`/`events` element can't discard the good fixed versions
|
|
128
|
+
# beside it in the same record.
|
|
129
|
+
def parse_affected(entry)
|
|
130
|
+
return unless entry.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
package = entry["package"]
|
|
133
|
+
return unless package.is_a?(Hash) && package["name"]
|
|
134
|
+
|
|
135
|
+
fixed = Array(entry["ranges"]).flat_map do |range|
|
|
136
|
+
next [] unless range.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) }
|
|
139
|
+
end
|
|
140
|
+
{ ecosystem: package["ecosystem"], name: package["name"], fixed: fixed }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def encode(value)
|
|
144
|
+
URI.encode_www_form_component(value.to_s)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/vulnerability_helper"
|
|
4
|
+
require_relative "../helpers/constraint_helper"
|
|
5
|
+
require_relative "../helpers/semver_satisfaction"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Whole-tree correlation, run once after the fan-out: a poison cap is far more
|
|
9
|
+
# urgent when the dependency it pins is ITSELF vulnerable in the same tree --
|
|
10
|
+
# "a dormant package is holding you on a known-vulnerable dependency, below the
|
|
11
|
+
# version that fixes it." Both facts are already assembled (the poison
|
|
12
|
+
# constraints and every dependency's vulnerability count), so this is one pass,
|
|
13
|
+
# no extra fetches. It marks the security-relevant caps so the report can lead
|
|
14
|
+
# with them and demote the FYI caps on healthy dependencies.
|
|
15
|
+
#
|
|
16
|
+
# The moat's strongest case (verified on real repos): several archived Google
|
|
17
|
+
# client libraries pin a vulnerable `protobuf` three majors below the fix.
|
|
18
|
+
module PoisonSecurityCorrelator
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# Only a HIGH-or-above advisory on the capped dep makes the cap security-
|
|
22
|
+
# relevant. Most advisories are low-threat noise (research: ~95% of vulnerable
|
|
23
|
+
# deps are unreachable/low-impact), so a low/medium CVE on the pinned dep isn't
|
|
24
|
+
# the "you're stuck below the fix" story. Unscored advisories fail CLOSED (a
|
|
25
|
+
# confirmed advisory we can't score could be severe), matching --fail-if-
|
|
26
|
+
# vulnerable. Reachability/exploitability is beyond static, metadata-only sight
|
|
27
|
+
# and deliberately out of scope -- this gates on severity, not exploitability.
|
|
28
|
+
SECURITY_THRESHOLD = "high"
|
|
29
|
+
|
|
30
|
+
# The severity labels that let an advisory establish "below the fix". Only a
|
|
31
|
+
# confirmed HIGH+ advisory with a known fix can: an unscored advisory (which the
|
|
32
|
+
# capped_dep_vulnerable gate accepts fail-closed) has no reliable fix analysis.
|
|
33
|
+
BELOW_FIX_LABELS = ["high", "critical"].freeze
|
|
34
|
+
|
|
35
|
+
def correlate(result_object)
|
|
36
|
+
advisories = flat_advisories(result_object)
|
|
37
|
+
copies = copy_index(result_object)
|
|
38
|
+
|
|
39
|
+
result_object.each_value do |data|
|
|
40
|
+
# FLAT (rubygems/pypi): mark below-fix on the poison caps already attached.
|
|
41
|
+
mark_flat_constraints(data, advisories)
|
|
42
|
+
# NESTED (npm/cargo): promote a genuine below-fix candidate into :constraints.
|
|
43
|
+
promote_nested_below_fix(data, copies) if data[:capped_deps]
|
|
44
|
+
|
|
45
|
+
constraints = hashes(data[:constraints])
|
|
46
|
+
next if constraints.empty?
|
|
47
|
+
|
|
48
|
+
data[:poison_security_relevant] = true if constraints.any? { |c| c[:capped_dep_vulnerable] }
|
|
49
|
+
data[:poison_below_fix] = true if constraints.any? { |c| c[:capped_below_fix] }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# This runs OUTSIDE the per-gem rescue (a whole-tree pass after the barrier),
|
|
56
|
+
# so a malformed shape must degrade that one entry's enrichment, never raise and
|
|
57
|
+
# crash the audit after every fetch is paid for. The pipeline always assigns
|
|
58
|
+
# arrays of hashes here, so these guards are unreachable today, but they match the
|
|
59
|
+
# defensive Array()-wrapping the sibling consumers (markdown/sarif/terminal) apply.
|
|
60
|
+
def hashes(value)
|
|
61
|
+
Array(value).grep(Hash)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Ecosystem-qualified map of each tree package's advisories AND the version it's
|
|
65
|
+
# pinned at, for the FLAT path (one resolved version per name). A capped dep
|
|
66
|
+
# resolves in its capper's ecosystem, so match "ecosystem/name" on both sides;
|
|
67
|
+
# native results carry no :ecosystem (nil on both) and still match. The used
|
|
68
|
+
# version is kept so "below the fix" ignores fixes that land BELOW it: an OSV
|
|
69
|
+
# advisory lists a fix per affected range, and a fix for an older line is a
|
|
70
|
+
# downgrade, not a patch for the version in hand.
|
|
71
|
+
def flat_advisories(result_object)
|
|
72
|
+
result_object.each_with_object({}) do |(key, data), map|
|
|
73
|
+
next unless data[:vulnerability_count].to_i.positive?
|
|
74
|
+
|
|
75
|
+
name = data[:name] || key
|
|
76
|
+
map["#{data[:ecosystem]}/#{name}"] = { vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every RESOLVED copy of each package, keyed "ecosystem/name", for the NESTED
|
|
81
|
+
# path: npm nests versions and cargo coexists majors, so one name maps to several
|
|
82
|
+
# copies. The full set (vulnerable AND not) is what the condition-5 soundness
|
|
83
|
+
# guard needs -- "is there a SAFE copy within the cap" can't be answered from the
|
|
84
|
+
# vulnerable copies alone.
|
|
85
|
+
def copy_index(result_object)
|
|
86
|
+
result_object.each_with_object({}) do |(key, data), map|
|
|
87
|
+
name = data[:name] || key
|
|
88
|
+
(map["#{data[:ecosystem]}/#{name}"] ||= []) << { version: data[:version_used], vulns: hashes(data[:vulnerabilities]) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def mark_flat_constraints(data, advisories)
|
|
93
|
+
constraints = hashes(data[:constraints])
|
|
94
|
+
return if constraints.empty?
|
|
95
|
+
|
|
96
|
+
eco = data[:ecosystem]
|
|
97
|
+
constraints.each do |constraint|
|
|
98
|
+
entry = advisories["#{eco}/#{constraint[:dependency]}"]
|
|
99
|
+
next if entry.nil?
|
|
100
|
+
|
|
101
|
+
vulns = entry[:vulns]
|
|
102
|
+
next unless VulnerabilityHelper.severity_at_or_above?(vulns, SECURITY_THRESHOLD)
|
|
103
|
+
|
|
104
|
+
constraint[:capped_dep_vulnerable] = true
|
|
105
|
+
mark_below_fix(constraint, vulns, entry[:used_version])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# NESTED below-the-fix (npm/cargo). The pure poison cap is suppressed for these
|
|
110
|
+
# ecosystems (caret default + nested copies over-claim), so a candidate is
|
|
111
|
+
# promoted ONLY when it genuinely holds a vulnerable copy below its fix, checked
|
|
112
|
+
# at PATCH precision. When one is, it takes the same shape the flat path renders,
|
|
113
|
+
# and :poison is set here (the only npm/cargo poison there is is a below-fix).
|
|
114
|
+
def promote_nested_below_fix(data, copies)
|
|
115
|
+
eco = data[:ecosystem]
|
|
116
|
+
# Consume the candidates: they are an internal work-list, never serialized.
|
|
117
|
+
promoted = hashes(data.delete(:capped_deps)).filter_map do |candidate|
|
|
118
|
+
dep_copies = copies["#{eco}/#{candidate[:dependency]}"] || []
|
|
119
|
+
nested_below_fix(eco, candidate[:dependency], candidate[:requirement], dep_copies)
|
|
120
|
+
end
|
|
121
|
+
return if promoted.empty?
|
|
122
|
+
|
|
123
|
+
data[:constraints] = promoted
|
|
124
|
+
data[:poison] = true
|
|
125
|
+
data[:poison_severity] = :critical
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A below-fix finding for one capped dep, or nil. The claim holds only when
|
|
129
|
+
# EVERY resolved copy the constraint governs is vulnerable to the same HIGH+
|
|
130
|
+
# advisory (condition 5: no safe in-constraint copy to resolve to), AND no fix
|
|
131
|
+
# of that advisory satisfies the constraint (the wall, at patch precision). A
|
|
132
|
+
# patched copy sitting OUTSIDE the constraint elsewhere in the tree is irrelevant
|
|
133
|
+
# -- it can't lift the copy this capper pins -- so it never enters this view.
|
|
134
|
+
def nested_below_fix(ecosystem, dependency, requirement, dep_copies)
|
|
135
|
+
in_constraint = dep_copies.select { |copy| satisfies?(requirement, copy[:version], ecosystem) }
|
|
136
|
+
return if in_constraint.empty?
|
|
137
|
+
|
|
138
|
+
oldest = in_constraint.map { |copy| copy[:version] }.min_by { |version| gem_version(version) }
|
|
139
|
+
stuck = candidate_advisories(in_constraint).select do |advisory|
|
|
140
|
+
walls?(requirement, advisory, ecosystem, oldest) && every_copy_affected?(in_constraint, advisory)
|
|
141
|
+
end
|
|
142
|
+
return if stuck.empty?
|
|
143
|
+
|
|
144
|
+
receipt = stuck.min_by { |advisory| gem_version(nearest_fix(advisory, oldest)) }
|
|
145
|
+
{
|
|
146
|
+
dependency: dependency,
|
|
147
|
+
requirement: requirement,
|
|
148
|
+
capped_dep_vulnerable: true,
|
|
149
|
+
capped_below_fix: true,
|
|
150
|
+
below_fix_advisory: receipt[:id],
|
|
151
|
+
below_fix_fixed_in: nearest_fix(receipt, oldest),
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def satisfies?(requirement, version, ecosystem)
|
|
156
|
+
SemverSatisfaction.evaluate(requirement: requirement, version: version, ecosystem: ecosystem) == true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# HIGH+ advisories with a known fix, deduped by id, across the in-constraint copies.
|
|
160
|
+
def candidate_advisories(copies)
|
|
161
|
+
copies.flat_map { |copy| copy[:vulns] }
|
|
162
|
+
.select { |vuln| BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln)) && Array(vuln[:fixed_versions]).any? }
|
|
163
|
+
.uniq { |vuln| vuln[:id] }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The wall: NO applicable fix satisfies the constraint at patch precision. Every
|
|
167
|
+
# fix must be a DEFINITE non-match (evaluate == false); an undecidable fix (nil,
|
|
168
|
+
# unparseable) blocks the claim rather than counting as unreachable, so we never
|
|
169
|
+
# fabricate a wall from input we couldn't parse.
|
|
170
|
+
def walls?(requirement, advisory, ecosystem, oldest_affected)
|
|
171
|
+
fixes = applicable_fixes(advisory, oldest_affected)
|
|
172
|
+
fixes.any? && fixes.all? { |fix| SemverSatisfaction.evaluate(requirement: requirement, version: fix, ecosystem: ecosystem) == false }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Condition 5: every governed copy is vulnerable to THIS advisory (none is a safe
|
|
176
|
+
# version you could resolve to within the cap).
|
|
177
|
+
def every_copy_affected?(copies, advisory)
|
|
178
|
+
copies.all? { |copy| copy[:vulns].any? { |vuln| vuln[:id] == advisory[:id] } }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The sharper claim on a security-relevant cap: does it hold you BELOW THE FIX?
|
|
182
|
+
# A HIGH+ advisory with a known fix establishes it only when EVERY fixed version
|
|
183
|
+
# lands outside the cap (a major it forbids) -- you cannot patch the CVE without
|
|
184
|
+
# replacing the dormant capper. The receipt names the advisory and its nearest
|
|
185
|
+
# fix (the lowest fixed version, all of which are outside the cap).
|
|
186
|
+
def mark_below_fix(constraint, vulns, used_version)
|
|
187
|
+
stuck = vulns.select do |vuln|
|
|
188
|
+
fixes = applicable_fixes(vuln, used_version)
|
|
189
|
+
next false if fixes.empty?
|
|
190
|
+
next false unless BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln))
|
|
191
|
+
|
|
192
|
+
fixes.none? { |fix| ConstraintHelper.reachable_within_cap?(constraint, fix) }
|
|
193
|
+
end
|
|
194
|
+
return if stuck.empty?
|
|
195
|
+
|
|
196
|
+
receipt = stuck.min_by { |vuln| gem_version(nearest_fix(vuln, used_version)) }
|
|
197
|
+
constraint[:capped_below_fix] = true
|
|
198
|
+
constraint[:below_fix_advisory] = receipt[:id]
|
|
199
|
+
constraint[:below_fix_fixed_in] = nearest_fix(receipt, used_version)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# An advisory lists a fixed version per affected range; only fixes ABOVE the
|
|
203
|
+
# version in hand are real remediation. A fix at or below it belongs to an older
|
|
204
|
+
# release line (a downgrade), so it neither patches this version nor counts as
|
|
205
|
+
# "reachable within the cap". With no known used version (older data), all fixes
|
|
206
|
+
# are kept -- the pre-existing behaviour, never fewer findings than before.
|
|
207
|
+
def applicable_fixes(vuln, used_version)
|
|
208
|
+
fixes = Array(vuln[:fixed_versions])
|
|
209
|
+
return fixes if used_version.nil? || !Gem::Version.correct?(used_version.to_s)
|
|
210
|
+
|
|
211
|
+
used = gem_version(used_version)
|
|
212
|
+
fixes.select { |fix| !Gem::Version.correct?(fix.to_s) || gem_version(fix) > used }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The lowest applicable fixed version, for the "fixed in X" receipt. A parseable
|
|
216
|
+
# version always beats an unparseable one (PyPI epoch `1!2.3.4`, or garbage), so
|
|
217
|
+
# the receipt never names an odd string when a clean fix is present; only when
|
|
218
|
+
# EVERY fix is unparseable does it fall back to one of those.
|
|
219
|
+
def nearest_fix(vuln, used_version)
|
|
220
|
+
applicable_fixes(vuln, used_version).min_by { |fix| [Gem::Version.correct?(fix.to_s) ? 0 : 1, gem_version(fix)] }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A version key that sorts fix strings without raising; an unparseable version
|
|
224
|
+
# collapses to 0 (only reached as a tiebreak among all-unparseable fixes, since
|
|
225
|
+
# nearest_fix prefers parseable ones).
|
|
226
|
+
def gem_version(version)
|
|
227
|
+
Gem::Version.new(version.to_s)
|
|
228
|
+
rescue ArgumentError
|
|
229
|
+
Gem::Version.new("0")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/http_helper"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# The authoritative source for a PyPI release's declared Python constraint,
|
|
8
|
+
# used by the cross-ecosystem language-runtime ceiling. `requires_python` is
|
|
9
|
+
# set once per release and enforced by pip as a hard install wall (a real
|
|
10
|
+
# resolver refuses an incompatible interpreter), so still_active reads it from
|
|
11
|
+
# PyPI's release-level `info.requires_python` rather than a per-file wheel value
|
|
12
|
+
# or deps.dev (which does not carry the field). pypi.org is a public source,
|
|
13
|
+
# queried with no credentials.
|
|
14
|
+
#
|
|
15
|
+
# Best-effort like every other network entry point: an unindexed version,
|
|
16
|
+
# schema drift, or a transport failure degrades to nil (no ceiling), never a
|
|
17
|
+
# raise that could abort the per-package audit.
|
|
18
|
+
module PypiClient
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
BASE_URI = URI("https://pypi.org/")
|
|
22
|
+
# Polite identification, matching EcosystemsClient's convention.
|
|
23
|
+
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
|
|
24
|
+
|
|
25
|
+
# The PEP 440 `requires_python` specifier a release declares (e.g.
|
|
26
|
+
# ">=3.8,<3.13"), or nil when the version is unreadable or declares none.
|
|
27
|
+
# PyPI renders an unset constraint as an empty string; normalize it to nil so
|
|
28
|
+
# callers get a single "no constraint" signal.
|
|
29
|
+
def requires_python(name:, version:)
|
|
30
|
+
return if name.nil? || version.nil?
|
|
31
|
+
|
|
32
|
+
path = "/pypi/#{encode(name)}/#{encode(version)}/json"
|
|
33
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT })
|
|
34
|
+
return unless body.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
# Guard the nested read: a non-Hash `info` (schema drift) would make
|
|
37
|
+
# Hash#dig raise TypeError, which -- since this is the LAST enrichment in
|
|
38
|
+
# EcosystemLens#assess -- would propagate to the per-package rescue and
|
|
39
|
+
# discard the package's already-computed vuln/poison/staleness findings.
|
|
40
|
+
# Best-effort must never demote the core audit; degrade to nil.
|
|
41
|
+
info = body["info"]
|
|
42
|
+
return unless info.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
value = info["requires_python"]
|
|
45
|
+
return unless value.is_a?(String) && !value.strip.empty?
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def encode(segment)
|
|
53
|
+
URI.encode_www_form_component(segment)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module StillActive
|
|
4
4
|
module Repository
|
|
5
|
-
|
|
5
|
+
# codeberg.org is the one Forgejo/Gitea host wired up today; it speaks the
|
|
6
|
+
# same Gitea API as any self-hosted instance, so its source is :forgejo (the
|
|
7
|
+
# forge software, what ForgejoClient handles), not :codeberg (the host).
|
|
8
|
+
SOURCE_BY_HOST = {
|
|
9
|
+
"github.com" => :github,
|
|
10
|
+
"gitlab.com" => :gitlab,
|
|
11
|
+
"codeberg.org" => :forgejo,
|
|
12
|
+
}.freeze
|
|
13
|
+
REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.\-]+)/(?<name>[\w.\-]+))}i
|
|
6
14
|
|
|
7
15
|
extend self
|
|
8
16
|
|
|
@@ -16,10 +24,10 @@ module StillActive
|
|
|
16
24
|
match = url&.match(REPO_REGEX)
|
|
17
25
|
return { source: :unhandled, owner: nil, name: nil } unless match
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
name = match[
|
|
27
|
+
clean_url = match[:url].delete_suffix(".git")
|
|
28
|
+
name = match[:name].delete_suffix(".git")
|
|
21
29
|
|
|
22
|
-
{ url:
|
|
30
|
+
{ url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name }
|
|
23
31
|
end
|
|
24
32
|
end
|
|
25
33
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module StillActive
|
|
4
4
|
module Sarif
|
|
5
|
-
# Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-
|
|
5
|
+
# Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-SA009)
|
|
6
6
|
# are stable across versions; renames or removals are breaking changes.
|
|
7
7
|
#
|
|
8
8
|
# Each rule maps a still_active finding into a SARIF result. Security
|
|
@@ -17,14 +17,18 @@ module StillActive
|
|
|
17
17
|
"#{DOCS_BASE}##{rule_id.downcase}"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
20
|
+
def severity_to_level(label)
|
|
21
|
+
# Maps a resolved advisory severity label (VulnerabilityHelper.advisory_severity:
|
|
22
|
+
# a real CVSS score OR the OSV/GHSA label) to a SARIF level. A nil label is a
|
|
23
|
+
# confirmed-but-unscored advisory (no CVSS score and no OSV label, e.g. a fresh
|
|
24
|
+
# CVE) -- elevated to warning, not an informational note, so a gate can't read
|
|
25
|
+
# it as clean. This is the SARIF analogue of the CLI gate's fail-closed.
|
|
26
|
+
case label
|
|
27
|
+
when "critical", "high" then "error"
|
|
28
|
+
when "medium" then "warning"
|
|
29
|
+
when "low" then "note"
|
|
30
|
+
else "warning"
|
|
31
|
+
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# SARIF security-severity must be a string with one decimal place.
|
|
@@ -48,8 +52,8 @@ module StillActive
|
|
|
48
52
|
{
|
|
49
53
|
id: "SA002",
|
|
50
54
|
name: "AbandonedGem",
|
|
51
|
-
short: "Gem has had no
|
|
52
|
-
full: "The gem's
|
|
55
|
+
short: "Gem has had no release for over 3 years",
|
|
56
|
+
full: "The gem's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a gem with no releases at all (e.g. git-sourced), the last commit date is used instead.",
|
|
53
57
|
help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
|
|
54
58
|
level: "warning",
|
|
55
59
|
security_severity: nil,
|
|
@@ -105,6 +109,26 @@ module StillActive
|
|
|
105
109
|
security_severity: "8.0",
|
|
106
110
|
tags: ["security", "supply-chain", "external/cwe/cwe-1104"],
|
|
107
111
|
},
|
|
112
|
+
{
|
|
113
|
+
id: "SA008",
|
|
114
|
+
name: "PoisonPill",
|
|
115
|
+
short: "Dormant gem caps a dependency below its latest major",
|
|
116
|
+
full: "A dormant (abandoned or archived) gem declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the gem, the cap will never lift, and it grows more constraining as the capped dependency releases new majors: the tree is held below a ceiling no upstream release will raise.",
|
|
117
|
+
help_text: "Replace or fork the dormant gem, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in.",
|
|
118
|
+
level: "warning",
|
|
119
|
+
security_severity: nil, # maintenance/resolvability, not a CVE (as SA002/SA004/SA005)
|
|
120
|
+
tags: ["maintenance", "supply-chain", "external/cwe/cwe-1104"],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "SA009",
|
|
124
|
+
name: "RuntimeCeiling",
|
|
125
|
+
short: "Resolved package version caps its language runtime",
|
|
126
|
+
full: "A resolved package version's declared runtime constraint (Ruby `ruby_version`, Python `requires_python`) caps the language runtime you can run: either below every still-supported release (stranding you on an end-of-life runtime with no security patches) or below the latest stable (a compatibility ceiling to plan around). The default level is note; an EOL-forcing cap is raised to error per result.",
|
|
127
|
+
help_text: "If a newer release of the package lifts the cap, upgrade it. Otherwise replace or fork the package, or contribute support for the newer runtime upstream.",
|
|
128
|
+
level: "note",
|
|
129
|
+
security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
|
|
130
|
+
tags: ["maintenance", "runtime", "external/cwe/cwe-1104"],
|
|
131
|
+
},
|
|
108
132
|
].freeze
|
|
109
133
|
|
|
110
134
|
CATALOG = RAW_RULES.map do |r|
|