still_active 3.0.0.rc4 → 3.0.0.rc6
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 +4 -0
- data/lib/helpers/activity_helper.rb +3 -3
- data/lib/helpers/alternatives_helper.rb +1 -1
- data/lib/helpers/ansi_helper.rb +1 -1
- data/lib/helpers/bot_context.rb +7 -7
- data/lib/helpers/bundler_helper.rb +1 -2
- data/lib/helpers/catalog_index.rb +1 -1
- data/lib/helpers/constraint_helper.rb +6 -6
- data/lib/helpers/cvss_helper.rb +2 -2
- data/lib/helpers/cyclonedx_helper.rb +14 -14
- data/lib/helpers/diff_markdown_helper.rb +4 -4
- data/lib/helpers/dotnet_helper.rb +4 -4
- data/lib/helpers/endoflife_helper.rb +3 -3
- data/lib/helpers/http_helper.rb +31 -19
- data/lib/helpers/lockfile_dependency_parser.rb +2 -2
- data/lib/helpers/markdown_escape.rb +1 -1
- data/lib/helpers/markdown_helper.rb +7 -7
- data/lib/helpers/ruby_advisory_db.rb +1 -1
- data/lib/helpers/ruby_helper.rb +3 -3
- data/lib/helpers/runtime_ceiling_helper.rb +2 -2
- data/lib/helpers/sarif_helper.rb +34 -34
- data/lib/helpers/status_helper.rb +1 -1
- data/lib/helpers/summary_helper.rb +1 -1
- data/lib/helpers/terminal_helper.rb +8 -8
- data/lib/helpers/vulnerability_helper.rb +1 -1
- data/lib/still_active/artifactory_client.rb +47 -18
- data/lib/still_active/cli.rb +38 -38
- data/lib/still_active/compact_index_client.rb +117 -0
- data/lib/still_active/config.rb +1 -1
- data/lib/still_active/config_file.rb +4 -4
- data/lib/still_active/deps_dev_client.rb +7 -7
- data/lib/still_active/diff.rb +12 -12
- data/lib/still_active/ecosystem_lens.rb +8 -6
- data/lib/still_active/ecosystems_client.rb +7 -7
- data/lib/still_active/forgejo_client.rb +3 -3
- data/lib/still_active/github_client.rb +6 -6
- data/lib/still_active/gitlab_client.rb +3 -3
- data/lib/still_active/options.rb +4 -4
- data/lib/still_active/osv_client.rb +200 -30
- data/lib/still_active/poison_security_correlator.rb +3 -3
- data/lib/still_active/pypi_client.rb +2 -2
- data/lib/still_active/repository.rb +4 -4
- data/lib/still_active/sarif/rules.rb +20 -20
- data/lib/still_active/sbom_reader.rb +8 -8
- data/lib/still_active/sbom_workflow.rb +7 -7
- data/lib/still_active/source_credentials.rb +39 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +60 -33
- data/still_active.gemspec +16 -20
- metadata +5 -115
|
@@ -26,7 +26,7 @@ module StillActive
|
|
|
26
26
|
# carries per-version declared dependency constraints (the poison-pill input).
|
|
27
27
|
PACKAGES_BASE_URI = URI("https://packages.ecosyste.ms/")
|
|
28
28
|
# ecosyste.ms asks consumers to identify themselves for its "polite pool".
|
|
29
|
-
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
|
|
29
|
+
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)".freeze
|
|
30
30
|
|
|
31
31
|
# archived + last-commit date from a single repository call. ecosyste.ms's
|
|
32
32
|
# pushed_at mirrors GitHub's, so this returns the same shape as GithubClient.
|
|
@@ -35,13 +35,13 @@ module StillActive
|
|
|
35
35
|
return {} if owner.nil? || name.nil?
|
|
36
36
|
|
|
37
37
|
path = "/api/v1/hosts/GitHub/repositories/#{encode_repo(owner, name)}"
|
|
38
|
-
body = HttpHelper.get_json(BASE_URI, path, headers: {
|
|
38
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params)
|
|
39
39
|
# A non-Hash 200 body (error envelope rendered as an array, schema drift)
|
|
40
40
|
# would otherwise raise on indexing and vanish the gem from the audit via
|
|
41
41
|
# the workflow's rescue; degrade to "no signal" like any other read failure.
|
|
42
42
|
return {} unless body.is_a?(Hash)
|
|
43
43
|
|
|
44
|
-
signals = {
|
|
44
|
+
signals = {last_commit_date: parse_time(body["pushed_at"], owner, name)}
|
|
45
45
|
# Only assert archived when the field is actually present. A missing field
|
|
46
46
|
# must read as unknown, not be invented as false -- otherwise a partial
|
|
47
47
|
# crawl could silently mask the most actionable verdict (gem is archived).
|
|
@@ -73,7 +73,7 @@ module StillActive
|
|
|
73
73
|
return [] if name.nil? || version.nil?
|
|
74
74
|
|
|
75
75
|
path = "/api/v1/registries/#{encode(registry)}/packages/#{encode(name)}/versions/#{encode(version)}"
|
|
76
|
-
body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: {
|
|
76
|
+
body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: {"User-Agent" => USER_AGENT}, params: politeness_params)
|
|
77
77
|
return [] unless body.is_a?(Hash)
|
|
78
78
|
|
|
79
79
|
dependencies = body["dependencies"]
|
|
@@ -86,7 +86,7 @@ module StillActive
|
|
|
86
86
|
requirements = dep["requirements"]
|
|
87
87
|
next if package_name.nil? || requirements.nil?
|
|
88
88
|
|
|
89
|
-
{
|
|
89
|
+
{package_name: package_name, requirements: requirements}
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
@@ -98,7 +98,7 @@ module StillActive
|
|
|
98
98
|
# lockfile and we don't attribute every user's traffic to one address.
|
|
99
99
|
def politeness_params
|
|
100
100
|
email = StillActive.config.ecosystems_email
|
|
101
|
-
email ? {
|
|
101
|
+
email ? {mailto: email} : {}
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# nil or a non-string (numeric/array from an off-spec payload) -> no date,
|
|
@@ -108,7 +108,7 @@ module StillActive
|
|
|
108
108
|
|
|
109
109
|
Time.parse(value)
|
|
110
110
|
rescue ArgumentError
|
|
111
|
-
|
|
111
|
+
warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
112
112
|
nil
|
|
113
113
|
end
|
|
114
114
|
|
|
@@ -24,7 +24,7 @@ module StillActive
|
|
|
24
24
|
body = HttpHelper.get_json(base_uri(host), "/api/v1/repos/#{owner}/#{name}", headers: auth_headers)
|
|
25
25
|
return {} if body.nil?
|
|
26
26
|
|
|
27
|
-
{
|
|
27
|
+
{archived: body["archived"] == true, last_commit_date: parse_time(body["updated_at"], owner, name)}
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
private
|
|
@@ -34,7 +34,7 @@ module StillActive
|
|
|
34
34
|
|
|
35
35
|
Time.parse(value)
|
|
36
36
|
rescue ArgumentError
|
|
37
|
-
|
|
37
|
+
warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
38
38
|
nil
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -44,7 +44,7 @@ module StillActive
|
|
|
44
44
|
|
|
45
45
|
def auth_headers
|
|
46
46
|
token = StillActive.config.forgejo_token
|
|
47
|
-
token ? {
|
|
47
|
+
token ? {"Authorization" => "token #{token}"} : {}
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
end
|
|
@@ -29,9 +29,9 @@ module StillActive
|
|
|
29
29
|
end
|
|
30
30
|
return {} unless repo
|
|
31
31
|
|
|
32
|
-
{
|
|
32
|
+
{archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name)}
|
|
33
33
|
rescue Octokit::Error, Faraday::Error => e
|
|
34
|
-
|
|
34
|
+
warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
|
|
35
35
|
{}
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -54,7 +54,7 @@ module StillActive
|
|
|
54
54
|
end
|
|
55
55
|
nil
|
|
56
56
|
rescue Octokit::Error, Faraday::Error => e
|
|
57
|
-
|
|
57
|
+
warn("warning: unreleased-commits check failed for #{owner}/#{name}: #{e.class}")
|
|
58
58
|
nil
|
|
59
59
|
end
|
|
60
60
|
|
|
@@ -75,12 +75,12 @@ module StillActive
|
|
|
75
75
|
# Hourly-limit exhaustion (or a far reset): not worth auto-waiting.
|
|
76
76
|
# Surface the one actionable hint rather than a generic class name,
|
|
77
77
|
# then return nil so this signal is simply absent for the gem.
|
|
78
|
-
|
|
78
|
+
warn("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
|
|
79
79
|
return
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
retried = true
|
|
83
|
-
|
|
83
|
+
warn("rate limited on #{label}; waiting #{wait}s for reset")
|
|
84
84
|
sleep(wait)
|
|
85
85
|
retry
|
|
86
86
|
end
|
|
@@ -119,7 +119,7 @@ module StillActive
|
|
|
119
119
|
|
|
120
120
|
Time.parse(value)
|
|
121
121
|
rescue ArgumentError
|
|
122
|
-
|
|
122
|
+
warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
123
123
|
nil
|
|
124
124
|
end
|
|
125
125
|
end
|
|
@@ -20,7 +20,7 @@ module StillActive
|
|
|
20
20
|
body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers)
|
|
21
21
|
return {} if body.nil?
|
|
22
22
|
|
|
23
|
-
{
|
|
23
|
+
{archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name)}
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
@@ -30,13 +30,13 @@ module StillActive
|
|
|
30
30
|
|
|
31
31
|
Time.parse(value)
|
|
32
32
|
rescue ArgumentError
|
|
33
|
-
|
|
33
|
+
warn("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
34
34
|
nil
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def auth_headers
|
|
38
38
|
token = StillActive.config.gitlab_token
|
|
39
|
-
token ? {
|
|
39
|
+
token ? {"PRIVATE-TOKEN" => token} : {}
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def encode_project(owner, name)
|
data/lib/still_active/options.rb
CHANGED
|
@@ -57,7 +57,7 @@ module StillActive
|
|
|
57
57
|
opts.on("--gems=GEM,GEM2,...", Array, "Gem(s)") do |value|
|
|
58
58
|
options[:provided_gems] = true
|
|
59
59
|
# Explicitly named gems are direct by definition (the user chose them).
|
|
60
|
-
StillActive.config { |config| config.gems = value.map { |g| {
|
|
60
|
+
StillActive.config { |config| config.gems = value.map { |g| {name: g, direct: true} } }
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -130,14 +130,14 @@ module StillActive
|
|
|
130
130
|
opts.on(
|
|
131
131
|
"--safe-range-end=YEARS",
|
|
132
132
|
Float,
|
|
133
|
-
"maximum years since last release considered safe, no warning (default 1.5; fractional allowed)"
|
|
133
|
+
"maximum years since last release considered safe, no warning (default 1.5; fractional allowed)"
|
|
134
134
|
) do |value|
|
|
135
135
|
StillActive.config { |config| config.no_warning_range_end = value }
|
|
136
136
|
end
|
|
137
137
|
opts.on(
|
|
138
138
|
"--warning-range-end=YEARS",
|
|
139
139
|
Float,
|
|
140
|
-
"maximum years since last release that triggers a warning, beyond this is critical (default 3)"
|
|
140
|
+
"maximum years since last release that triggers a warning, beyond this is critical (default 3)"
|
|
141
141
|
) do |value|
|
|
142
142
|
StillActive.config { |config| config.warning_range_end = value }
|
|
143
143
|
end
|
|
@@ -181,7 +181,7 @@ module StillActive
|
|
|
181
181
|
# Ceiling findings are only ever :critical or :note, so =warning can't
|
|
182
182
|
# match anything the bare (=critical) default doesn't already catch.
|
|
183
183
|
if value == "warning"
|
|
184
|
-
|
|
184
|
+
warn("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
|
|
185
185
|
end
|
|
186
186
|
StillActive.config { |config| config.fail_if_language_ceiling = value.to_sym }
|
|
187
187
|
else
|
|
@@ -15,8 +15,13 @@ module StillActive
|
|
|
15
15
|
# poison cap's ceiling.
|
|
16
16
|
#
|
|
17
17
|
# Enrichment is best-effort: any failure (missing record, transport error, odd
|
|
18
|
-
# shape) leaves the advisory exactly as deps.dev produced it.
|
|
19
|
-
#
|
|
18
|
+
# shape) leaves the advisory exactly as deps.dev produced it.
|
|
19
|
+
#
|
|
20
|
+
# OSV is also the ARBITER of whether a deps.dev-discovered advisory really applies
|
|
21
|
+
# to the audited version (see reject_unaffected), which is the one place this module
|
|
22
|
+
# removes rather than adds. That direction is the dangerous one -- a wrongly dropped
|
|
23
|
+
# advisory reads a vulnerable dependency as clean -- so every degrade path here must
|
|
24
|
+
# resolve to "keep", and only an unambiguous OSV contradiction may drop.
|
|
20
25
|
module OsvClient
|
|
21
26
|
extend self
|
|
22
27
|
|
|
@@ -34,36 +39,35 @@ module StillActive
|
|
|
34
39
|
cargo: "crates.io",
|
|
35
40
|
maven: "Maven",
|
|
36
41
|
go: "Go",
|
|
37
|
-
nuget: "NuGet"
|
|
42
|
+
nuget: "NuGet"
|
|
38
43
|
}.freeze
|
|
39
44
|
|
|
40
45
|
# Prefer the newest CVSS version a record carries (v4 is the whole point: it's the
|
|
41
46
|
# one deps.dev can't score). `severity[].score` is the vector STRING, oddly named.
|
|
42
|
-
CVSS_PRIORITY = {
|
|
47
|
+
CVSS_PRIORITY = {"CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1}.freeze
|
|
43
48
|
# A v2 vector has no `CVSS:X.Y` prefix, so the version can't be read from the
|
|
44
49
|
# string; fall back to the entry type so the CycloneDX rating method labels it v2.
|
|
45
|
-
TYPE_VERSIONS = {
|
|
50
|
+
TYPE_VERSIONS = {"CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0"}.freeze
|
|
46
51
|
|
|
47
52
|
# Enrich each advisory in place with the OSV severity label and the fixed
|
|
48
|
-
# versions for the audited package
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
advisory
|
|
56
|
-
advisory
|
|
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")
|
|
53
|
+
# versions for the audited package, and return the advisories that survive
|
|
54
|
+
# version confirmation (see reject_unaffected). A missing/failed lookup is a
|
|
55
|
+
# no-op on that advisory (it keeps whatever deps.dev gave it), never a raise.
|
|
56
|
+
def enrich(advisories, ecosystem:, name:, version: nil)
|
|
57
|
+
# Each eligible advisory is paired with OSV's own identifiers for it, which the
|
|
58
|
+
# confirmation match needs alongside deps.dev's (see detail).
|
|
59
|
+
backed = advisories.filter_map do |advisory|
|
|
60
|
+
osv_ids = apply_record(advisory, ecosystem: ecosystem, name: name)
|
|
61
|
+
[advisory, osv_ids] if osv_ids
|
|
66
62
|
end
|
|
63
|
+
reject_unaffected(advisories, backed, ecosystem: ecosystem, name: name, version: version)
|
|
64
|
+
rescue => e
|
|
65
|
+
# The confirmation pass runs outside apply_record's per-advisory rescue, and an
|
|
66
|
+
# escape from here would hit the workflow's per-gem rescue and strip the gem of
|
|
67
|
+
# ALL its signals -- reading a known-vulnerable dependency as clean, the very
|
|
68
|
+
# thing this module exists to prevent. Keep every advisory instead.
|
|
69
|
+
warn("warning: OSV version confirmation for #{name} failed: #{e.class} (#{e.message}); keeping every advisory")
|
|
70
|
+
advisories
|
|
67
71
|
end
|
|
68
72
|
|
|
69
73
|
# Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
|
|
@@ -79,16 +83,173 @@ module StillActive
|
|
|
79
83
|
|
|
80
84
|
cvss = best_cvss(body)
|
|
81
85
|
{
|
|
86
|
+
# OSV's own identity for the advisory, unioned into the match against a query
|
|
87
|
+
# result. Asking for a CVE returns the GHSA record (OSV resolves aliases), so
|
|
88
|
+
# deps.dev's id alone can miss the id the query answers with, and reading a
|
|
89
|
+
# listed advisory as absent would drop a real finding.
|
|
90
|
+
identifiers: [body["id"], *Array(body["aliases"])].grep(String),
|
|
82
91
|
severity_label: body.dig("database_specific", "severity"),
|
|
83
92
|
cvss_score: cvss[:score],
|
|
84
93
|
cvss_version: cvss[:version],
|
|
85
94
|
cvss_vector: cvss[:vector],
|
|
86
|
-
affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) }
|
|
95
|
+
affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) }
|
|
87
96
|
}
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
private
|
|
91
100
|
|
|
101
|
+
# Applies one OSV record to one advisory, in place. Returns OSV's identifiers for
|
|
102
|
+
# the advisory when it is eligible for the version confirmation below (only OSV
|
|
103
|
+
# data may overrule OSV-derived data), or nil when it is not and must be kept
|
|
104
|
+
# whatever the query says.
|
|
105
|
+
def apply_record(advisory, ecosystem:, name:)
|
|
106
|
+
record = detail(advisory_id: advisory[:id])
|
|
107
|
+
return if record.nil?
|
|
108
|
+
|
|
109
|
+
advisory[:osv_severity] = record[:severity_label]
|
|
110
|
+
advisory[:osv_cvss_score] = record[:cvss_score]
|
|
111
|
+
advisory[:cvss_version] = record[:cvss_version]
|
|
112
|
+
advisory[:cvss_vector] = record[:cvss_vector]
|
|
113
|
+
advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
|
|
114
|
+
record[:identifiers] if names_package?(record, ecosystem: ecosystem, name: name)
|
|
115
|
+
rescue => e
|
|
116
|
+
# Enrichment is additive and best-effort. An unexpected OSV shape must never
|
|
117
|
+
# raise out through the workflow's per-gem rescue, which would DROP the whole
|
|
118
|
+
# gem and read a known-vulnerable dependency as clean. Leave the advisory
|
|
119
|
+
# exactly as deps.dev produced it, and hold it (nil) rather than risk
|
|
120
|
+
# dropping it on a record we couldn't read.
|
|
121
|
+
warn("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Drops the advisories that OSV's own version matching says don't apply to the
|
|
126
|
+
# audited version. deps.dev is the discovery source, but it MIRRORS OSV/GHSA data
|
|
127
|
+
# with an ingestion lag, and advisories get AMENDED after publication: the usual
|
|
128
|
+
# shape is a CVE published with a fix on the current release line, then backports
|
|
129
|
+
# to the older supported lines, then an amendment adding those branch ranges. Until
|
|
130
|
+
# deps.dev re-ingests, it keeps serving the pre-amendment record, whose broader
|
|
131
|
+
# range still covers versions the amendment has since marked patched.
|
|
132
|
+
#
|
|
133
|
+
# Receipt (2026-07-31): GHSA-mh99-v99m-4gvg was published 07-24 with a single
|
|
134
|
+
# range, introduced 0 / fixed 5.0.8. Backports 3.0.3, 2.1.3 and 1.1.17 shipped
|
|
135
|
+
# 07-27 to 07-29, and the advisory was amended 07-31 19:39Z to carry all four
|
|
136
|
+
# branches. Hours later deps.dev still answered from the 07-24 record, reporting
|
|
137
|
+
# the patched 1.1.17/1.1.18/2.1.3/2.1.4/3.0.3 as vulnerable.
|
|
138
|
+
#
|
|
139
|
+
# This is lag, NOT a parsing defect: 341 multi-branch advisories across
|
|
140
|
+
# npm/rubygems/pypi/cargo were compared against OSV over 589 version checks, and
|
|
141
|
+
# every one agreed. So the correction has to be a live re-check, not a local range
|
|
142
|
+
# fix. We
|
|
143
|
+
# ask OSV's /v1/query, deps.dev's own upstream, which applies the declared ranges
|
|
144
|
+
# under each ecosystem's semantics and reflects an amendment immediately. It also
|
|
145
|
+
# saves reimplementing cross-ecosystem version ordering.
|
|
146
|
+
#
|
|
147
|
+
# The window reopens with every backported fix, so this is structural, not a
|
|
148
|
+
# one-off worth waiting out.
|
|
149
|
+
#
|
|
150
|
+
# Removing a finding is the one direction this tool must never get wrong, so a drop
|
|
151
|
+
# needs positive contradiction on every count: the advisory came from deps.dev ALONE
|
|
152
|
+
# (ruby-advisory-db does its own version matching and is the Ruby authority, so its
|
|
153
|
+
# verdict stands), OSV served a record for it that names this package under this
|
|
154
|
+
# ecosystem and spelling, and the query for this exact version succeeded without
|
|
155
|
+
# listing it. Anything unknown -- no version, unmapped ecosystem, failed query --
|
|
156
|
+
# keeps the advisory.
|
|
157
|
+
def reject_unaffected(advisories, backed, ecosystem:, name:, version:)
|
|
158
|
+
candidates = backed.select { |advisory, _osv_ids| advisory[:source] == "deps.dev" }
|
|
159
|
+
return advisories if candidates.empty? || version.to_s.empty?
|
|
160
|
+
|
|
161
|
+
affected = affected_identifiers(ecosystem: ecosystem, name: name, version: version)
|
|
162
|
+
return advisories if affected.nil?
|
|
163
|
+
|
|
164
|
+
unaffected = candidates
|
|
165
|
+
.reject { |advisory, osv_ids| (identifiers(advisory) | osv_ids).intersect?(affected) }
|
|
166
|
+
.map(&:first)
|
|
167
|
+
# Say so when a finding is removed. This is the only path that can turn a red run
|
|
168
|
+
# green, and a silent drop leaves no way to tell "OSV cleared it" from "the audit
|
|
169
|
+
# missed it". Matches the per-gem stderr notes the severity gates already emit.
|
|
170
|
+
unaffected.each { |advisory| warn("still_active: #{name}@#{version} is not affected by #{advisory[:id]} per OSV; deps.dev listed it, dropping") }
|
|
171
|
+
advisories - unaffected
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Every advisory identifier OSV reports as affecting this exact package version,
|
|
175
|
+
# or nil when the question can't be asked or answered (unmapped ecosystem, failed
|
|
176
|
+
# request, unusable body). An EMPTY array is a real answer -- "nothing affects this
|
|
177
|
+
# version" -- and must stay distinct from the nil, which means "we don't know".
|
|
178
|
+
#
|
|
179
|
+
# That distinction is the whole safety of the drop, so an empty array is only ever
|
|
180
|
+
# returned for a body that positively IS an empty OSV result. OSV's genuine all-clear
|
|
181
|
+
# is a bare `{}` (verified live, and identical to what an unknown package name
|
|
182
|
+
# returns), which gives a malformed or truncated body no distinguishing marker of
|
|
183
|
+
# its own: read loosely, every unparseable 200 would fabricate an all-clear.
|
|
184
|
+
def affected_identifiers(ecosystem:, name:, version:)
|
|
185
|
+
osv_ecosystem = ECOSYSTEM_NAMES[ecosystem || :rubygems]
|
|
186
|
+
return if osv_ecosystem.nil?
|
|
187
|
+
|
|
188
|
+
query = {version: version, package: {name: name, ecosystem: osv_ecosystem}}
|
|
189
|
+
body = HttpHelper.post_json(BASE_URI, "/v1/query", body: JSON.generate(query))
|
|
190
|
+
return unless body.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
identifiers_from(body)
|
|
193
|
+
rescue => e
|
|
194
|
+
warn("warning: OSV version confirmation for #{name}@#{version} failed: #{e.class} (#{e.message}); keeping every advisory")
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The identifiers in one /v1/query body, or nil when the body isn't a complete,
|
|
199
|
+
# readable answer. A truncated answer is not an answer: OSV paginates past 1000
|
|
200
|
+
# vulnerabilities OR once a query exceeds 20 seconds, and documents that a page can
|
|
201
|
+
# carry ONLY a next_page_token. That second trigger is latency, not package size, so
|
|
202
|
+
# it can land on any package on a slow day; reading it as "nothing affects this
|
|
203
|
+
# version" would drop real findings at random. We decline to filter rather than
|
|
204
|
+
# walk the pages, since the pass only ever removes false positives.
|
|
205
|
+
def identifiers_from(body)
|
|
206
|
+
return if body["next_page_token"] || body["nextPageToken"]
|
|
207
|
+
# A bare `{}` is OSV's genuine "nothing affects this version" (verified live). It
|
|
208
|
+
# has to be matched exactly, not as "no vulns key": an error envelope served with a
|
|
209
|
+
# 200 (`{"code":3,"message":...}`) has no vulns key either, and reading that as an
|
|
210
|
+
# all-clear would drop every advisory on the package.
|
|
211
|
+
return [] if body.empty?
|
|
212
|
+
|
|
213
|
+
vulns = body["vulns"]
|
|
214
|
+
return unless vulns.is_a?(Array)
|
|
215
|
+
# EVERY entry has to be readable, not just one of them. Skipping the unreadable
|
|
216
|
+
# ones would silently shorten the list, and a short list is indistinguishable from
|
|
217
|
+
# OSV not listing that advisory at all: the one it named but we couldn't parse
|
|
218
|
+
# would be dropped as unaffected. `all?` holds on an empty array, so OSV's other
|
|
219
|
+
# genuine all-clear shape, `{"vulns": []}`, still answers.
|
|
220
|
+
return unless vulns.all? { |vuln| vuln.is_a?(Hash) && vuln["id"].is_a?(String) }
|
|
221
|
+
|
|
222
|
+
vulns.flat_map { |vuln| [vuln["id"], *vuln["aliases"]] }.grep(String)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# An advisory's own id plus its aliases, mirroring VulnerabilityHelper's merge
|
|
226
|
+
# identity: OSV keys a record by GHSA while deps.dev may hand us the CVE (or the
|
|
227
|
+
# reverse), so matching on the primary id alone would read a listed advisory as
|
|
228
|
+
# absent and drop a real finding.
|
|
229
|
+
def identifiers(advisory)
|
|
230
|
+
[advisory[:id], *advisory[:aliases]].compact.map(&:to_s)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Does this record carry version data for the audited package, under the exact
|
|
234
|
+
# ecosystem and spelling we're querying with? Both halves guard a fabricated
|
|
235
|
+
# all-clear, because OSV answers an unknown package and a patched version with the
|
|
236
|
+
# same bare `{}`:
|
|
237
|
+
#
|
|
238
|
+
# NAME -- registry name normalization differs by ecosystem (PyPI folds case and
|
|
239
|
+
# -/_/.), so a spelling mismatch would turn a missed lookup into "not affected".
|
|
240
|
+
# Exact equality is stricter than OSV's own matching, which costs us the fix on a
|
|
241
|
+
# case-divergent name; that's the safe direction to be wrong in.
|
|
242
|
+
#
|
|
243
|
+
# VERSIONED -- an affected entry with no ranges and no version list (or one whose
|
|
244
|
+
# window lives somewhere /v1/query can't match on, like a GIT range) is OSV having
|
|
245
|
+
# no opinion on versions at all. The query can then never return that record for
|
|
246
|
+
# ANY version, so treating its silence as contradiction would drop the advisory
|
|
247
|
+
# permanently, for every user, while deps.dev still asserts the version is affected.
|
|
248
|
+
def names_package?(record, ecosystem:, name:)
|
|
249
|
+
osv_ecosystem = ECOSYSTEM_NAMES[ecosystem || :rubygems]
|
|
250
|
+
record[:affected].any? { |a| a[:name] == name && a[:ecosystem] == osv_ecosystem && a[:versioned] }
|
|
251
|
+
end
|
|
252
|
+
|
|
92
253
|
# The highest-version CVSS vector the record carries, as { score:, version:,
|
|
93
254
|
# vector: } (a computed base score, the "3.1"/"4.0" version, and the raw string),
|
|
94
255
|
# or all-nil. deps.dev has no v4 score, so this is what gives a CVSS-4-only
|
|
@@ -100,7 +261,7 @@ module StillActive
|
|
|
100
261
|
return {} if entry.nil?
|
|
101
262
|
|
|
102
263
|
vector = entry["score"]
|
|
103
|
-
{
|
|
264
|
+
{score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector}
|
|
104
265
|
end
|
|
105
266
|
|
|
106
267
|
# The X.Y version from a "CVSS:X.Y/..." vector prefix, or nil.
|
|
@@ -132,12 +293,21 @@ module StillActive
|
|
|
132
293
|
package = entry["package"]
|
|
133
294
|
return unless package.is_a?(Hash) && package["name"]
|
|
134
295
|
|
|
135
|
-
|
|
136
|
-
|
|
296
|
+
ranges = Array(entry["ranges"]).select { |range| range.is_a?(Hash) }
|
|
297
|
+
fixed = ranges.flat_map { |range| Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) } }
|
|
298
|
+
{ecosystem: package["ecosystem"], name: package["name"], fixed: fixed, versioned: version_matchable?(entry, ranges)}
|
|
299
|
+
end
|
|
137
300
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
301
|
+
# Range types /v1/query can resolve a VERSION against. A GIT range is commit-graph
|
|
302
|
+
# data a version query never matches, so a record carrying only those has no version
|
|
303
|
+
# opinion to contradict deps.dev with. A range missing its type reads the same way.
|
|
304
|
+
VERSION_RANGE_TYPES = ["SEMVER", "ECOSYSTEM"].freeze
|
|
305
|
+
|
|
306
|
+
# Does this affected entry hold data a version query could match on -- an explicit
|
|
307
|
+
# version list, or a version-ordered range? See names_package? for why the absence
|
|
308
|
+
# of it has to block a drop rather than permit one.
|
|
309
|
+
def version_matchable?(entry, ranges)
|
|
310
|
+
!Array(entry["versions"]).empty? || ranges.any? { |range| VERSION_RANGE_TYPES.include?(range["type"]) }
|
|
141
311
|
end
|
|
142
312
|
|
|
143
313
|
def encode(value)
|
|
@@ -73,7 +73,7 @@ module StillActive
|
|
|
73
73
|
next unless data[:vulnerability_count].to_i.positive?
|
|
74
74
|
|
|
75
75
|
name = data[:name] || key
|
|
76
|
-
map["#{data[:ecosystem]}/#{name}"] = {
|
|
76
|
+
map["#{data[:ecosystem]}/#{name}"] = {vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used]}
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
|
|
@@ -85,7 +85,7 @@ module StillActive
|
|
|
85
85
|
def copy_index(result_object)
|
|
86
86
|
result_object.each_with_object({}) do |(key, data), map|
|
|
87
87
|
name = data[:name] || key
|
|
88
|
-
(map["#{data[:ecosystem]}/#{name}"] ||= []) << {
|
|
88
|
+
(map["#{data[:ecosystem]}/#{name}"] ||= []) << {version: data[:version_used], vulns: hashes(data[:vulnerabilities])}
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -148,7 +148,7 @@ module StillActive
|
|
|
148
148
|
capped_dep_vulnerable: true,
|
|
149
149
|
capped_below_fix: true,
|
|
150
150
|
below_fix_advisory: receipt[:id],
|
|
151
|
-
below_fix_fixed_in: nearest_fix(receipt, oldest)
|
|
151
|
+
below_fix_fixed_in: nearest_fix(receipt, oldest)
|
|
152
152
|
}
|
|
153
153
|
end
|
|
154
154
|
|
|
@@ -20,7 +20,7 @@ module StillActive
|
|
|
20
20
|
|
|
21
21
|
BASE_URI = URI("https://pypi.org/")
|
|
22
22
|
# Polite identification, matching EcosystemsClient's convention.
|
|
23
|
-
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
|
|
23
|
+
USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)".freeze
|
|
24
24
|
|
|
25
25
|
# The PEP 440 `requires_python` specifier a release declares (e.g.
|
|
26
26
|
# ">=3.8,<3.13"), or nil when the version is unreadable or declares none.
|
|
@@ -30,7 +30,7 @@ module StillActive
|
|
|
30
30
|
return if name.nil? || version.nil?
|
|
31
31
|
|
|
32
32
|
path = "/pypi/#{encode(name)}/#{encode(version)}/json"
|
|
33
|
-
body = HttpHelper.get_json(BASE_URI, path, headers: {
|
|
33
|
+
body = HttpHelper.get_json(BASE_URI, path, headers: {"User-Agent" => USER_AGENT})
|
|
34
34
|
return unless body.is_a?(Hash)
|
|
35
35
|
|
|
36
36
|
# Guard the nested read: a non-Hash `info` (schema drift) would make
|
|
@@ -8,9 +8,9 @@ module StillActive
|
|
|
8
8
|
SOURCE_BY_HOST = {
|
|
9
9
|
"github.com" => :github,
|
|
10
10
|
"gitlab.com" => :gitlab,
|
|
11
|
-
"codeberg.org" => :forgejo
|
|
11
|
+
"codeberg.org" => :forgejo
|
|
12
12
|
}.freeze
|
|
13
|
-
REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w
|
|
13
|
+
REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.-]+)/(?<name>[\w.-]+))}i
|
|
14
14
|
|
|
15
15
|
extend self
|
|
16
16
|
|
|
@@ -22,12 +22,12 @@ module StillActive
|
|
|
22
22
|
|
|
23
23
|
def url_with_owner_and_name(url:)
|
|
24
24
|
match = url&.match(REPO_REGEX)
|
|
25
|
-
return {
|
|
25
|
+
return {source: :unhandled, owner: nil, name: nil} unless match
|
|
26
26
|
|
|
27
27
|
clean_url = match[:url].delete_suffix(".git")
|
|
28
28
|
name = match[:name].delete_suffix(".git")
|
|
29
29
|
|
|
30
|
-
{
|
|
30
|
+
{url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name}
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
end
|