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
|
@@ -60,8 +60,8 @@ module StillActive
|
|
|
60
60
|
neutral: {
|
|
61
61
|
short: "Package's source repository is archived",
|
|
62
62
|
full: "The package's upstream repository has been marked archived. No further fixes, including security patches, should be expected.",
|
|
63
|
-
help_text: "Look for a maintained fork or alternative. If you must keep the package, vendor or fork it so you can apply patches yourself."
|
|
64
|
-
}
|
|
63
|
+
help_text: "Look for a maintained fork or alternative. If you must keep the package, vendor or fork it so you can apply patches yourself."
|
|
64
|
+
}
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
id: "SA002",
|
|
@@ -75,8 +75,8 @@ module StillActive
|
|
|
75
75
|
neutral: {
|
|
76
76
|
short: "Package has had no release for over 3 years",
|
|
77
77
|
full: "The package'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 package with no releases at all (e.g. git-sourced), the last commit date is used instead.",
|
|
78
|
-
help_text: "Verify the package still works on your supported runtime and consider a maintained alternative."
|
|
79
|
-
}
|
|
78
|
+
help_text: "Verify the package still works on your supported runtime and consider a maintained alternative."
|
|
79
|
+
}
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
id: "SA003",
|
|
@@ -88,8 +88,8 @@ module StillActive
|
|
|
88
88
|
security_severity: "7.0", # default; per-result override from CVSS
|
|
89
89
|
tags: ["security", "vulnerability", "external/cwe/cwe-1104"],
|
|
90
90
|
neutral: {
|
|
91
|
-
short: "Package has known vulnerabilities (via deps.dev / OSV)"
|
|
92
|
-
}
|
|
91
|
+
short: "Package has known vulnerabilities (via deps.dev / OSV)"
|
|
92
|
+
}
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
id: "SA004",
|
|
@@ -102,8 +102,8 @@ module StillActive
|
|
|
102
102
|
tags: ["maintenance", "libyear"],
|
|
103
103
|
neutral: {
|
|
104
104
|
short: "Package is significantly behind the latest release",
|
|
105
|
-
help_text: "Schedule an upgrade to the latest release."
|
|
106
|
-
}
|
|
105
|
+
help_text: "Schedule an upgrade to the latest release."
|
|
106
|
+
}
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
id: "SA005",
|
|
@@ -115,8 +115,8 @@ module StillActive
|
|
|
115
115
|
security_severity: nil,
|
|
116
116
|
tags: ["supply-chain", "openssf"],
|
|
117
117
|
neutral: {
|
|
118
|
-
short: "Package's OpenSSF Scorecard is low"
|
|
119
|
-
}
|
|
118
|
+
short: "Package's OpenSSF Scorecard is low"
|
|
119
|
+
}
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
id: "SA006",
|
|
@@ -127,7 +127,7 @@ module StillActive
|
|
|
127
127
|
level: "error",
|
|
128
128
|
security_severity: "8.5",
|
|
129
129
|
tags: ["security", "runtime", "external/cwe/cwe-1104"],
|
|
130
|
-
native_only: true
|
|
130
|
+
native_only: true
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
id: "SA007",
|
|
@@ -141,8 +141,8 @@ module StillActive
|
|
|
141
141
|
neutral: {
|
|
142
142
|
short: "Resolved package version has been yanked from its registry",
|
|
143
143
|
full: "The resolved version has been yanked by the package owner, typically for a serious bug or vulnerability.",
|
|
144
|
-
help_text: "Update to a non-yanked version immediately."
|
|
145
|
-
}
|
|
144
|
+
help_text: "Update to a non-yanked version immediately."
|
|
145
|
+
}
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
id: "SA008",
|
|
@@ -156,8 +156,8 @@ module StillActive
|
|
|
156
156
|
neutral: {
|
|
157
157
|
short: "Dormant package caps a dependency below its latest major",
|
|
158
158
|
full: "A dormant (abandoned or archived) package declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the package, 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.",
|
|
159
|
-
help_text: "Replace or fork the dormant package, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in."
|
|
160
|
-
}
|
|
159
|
+
help_text: "Replace or fork the dormant package, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in."
|
|
160
|
+
}
|
|
161
161
|
},
|
|
162
162
|
{
|
|
163
163
|
id: "SA009",
|
|
@@ -167,8 +167,8 @@ module StillActive
|
|
|
167
167
|
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.",
|
|
168
168
|
level: "note",
|
|
169
169
|
security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
|
|
170
|
-
tags: ["maintenance", "runtime", "external/cwe/cwe-1104"]
|
|
171
|
-
}
|
|
170
|
+
tags: ["maintenance", "runtime", "external/cwe/cwe-1104"]
|
|
171
|
+
}
|
|
172
172
|
].freeze
|
|
173
173
|
|
|
174
174
|
# Builds a frozen catalog for one wording flavour. `:neutral` applies each
|
|
@@ -181,10 +181,10 @@ module StillActive
|
|
|
181
181
|
neutral = flavour == :neutral
|
|
182
182
|
rules = neutral ? RAW_RULES.reject { |r| r[:native_only] } : RAW_RULES
|
|
183
183
|
rules.map do |r|
|
|
184
|
-
resolved = neutral && r[:neutral] ? r.merge(r[:neutral]) : r
|
|
184
|
+
resolved = (neutral && r[:neutral]) ? r.merge(r[:neutral]) : r
|
|
185
185
|
resolved.except(:neutral, :native_only).merge(
|
|
186
186
|
tags: r[:tags].dup.freeze,
|
|
187
|
-
help_markdown: "#{resolved[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance."
|
|
187
|
+
help_markdown: "#{resolved[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance."
|
|
188
188
|
).freeze
|
|
189
189
|
end.freeze
|
|
190
190
|
end
|
|
@@ -194,7 +194,7 @@ module StillActive
|
|
|
194
194
|
|
|
195
195
|
# flavour: :ruby (default, native Gemfile path) or :neutral (SBOM path).
|
|
196
196
|
def all(flavour = :ruby)
|
|
197
|
-
flavour == :neutral ? NEUTRAL_CATALOG : CATALOG
|
|
197
|
+
(flavour == :neutral) ? NEUTRAL_CATALOG : CATALOG
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def find(id, flavour = :ruby)
|
|
@@ -32,7 +32,7 @@ module StillActive
|
|
|
32
32
|
"cargo" => :cargo,
|
|
33
33
|
"maven" => :maven,
|
|
34
34
|
"golang" => :go,
|
|
35
|
-
"nuget" => :nuget
|
|
35
|
+
"nuget" => :nuget
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
38
|
# PURL types that are not package dependencies: CI actions and opaque
|
|
@@ -100,7 +100,7 @@ module StillActive
|
|
|
100
100
|
|
|
101
101
|
name = component["name"]
|
|
102
102
|
purl = component["purl"]
|
|
103
|
-
return [:unassessable, {
|
|
103
|
+
return [:unassessable, {ecosystem: nil, name: name, reason: :no_purl}] unless purl.is_a?(String) && !purl.empty?
|
|
104
104
|
|
|
105
105
|
classify_purl(purl, name)
|
|
106
106
|
end
|
|
@@ -121,7 +121,7 @@ module StillActive
|
|
|
121
121
|
"repo1.maven.org",
|
|
122
122
|
"repo.maven.apache.org",
|
|
123
123
|
"api.nuget.org",
|
|
124
|
-
"proxy.golang.org"
|
|
124
|
+
"proxy.golang.org"
|
|
125
125
|
].freeze
|
|
126
126
|
|
|
127
127
|
def classify_purl(purl, name)
|
|
@@ -130,7 +130,7 @@ module StillActive
|
|
|
130
130
|
ecosystem = ECOSYSTEMS[type]
|
|
131
131
|
|
|
132
132
|
if ecosystem
|
|
133
|
-
return [:unassessable, {
|
|
133
|
+
return [:unassessable, {ecosystem: type, name: name, reason: :no_version}] if parsed.version.to_s.empty?
|
|
134
134
|
|
|
135
135
|
full_name = build_name(ecosystem, parsed.namespace, parsed.name)
|
|
136
136
|
repository_url = parsed.qualifiers&.dig("repository_url")
|
|
@@ -143,14 +143,14 @@ module StillActive
|
|
|
143
143
|
# from a public one, so it is still assessed by name. Best-effort, not a
|
|
144
144
|
# guarantee that every private package is caught.
|
|
145
145
|
if repository_url && !public_registry?(repository_url)
|
|
146
|
-
return [:unassessable, {
|
|
146
|
+
return [:unassessable, {ecosystem: ecosystem, name: full_name, version: parsed.version, reason: :private_registry, repository_url: repository_url}]
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
[:dependency, {
|
|
149
|
+
[:dependency, {ecosystem: ecosystem, name: full_name, version: parsed.version}]
|
|
150
150
|
elsif NOISE_TYPES.include?(type)
|
|
151
151
|
nil # CI actions / opaque binaries: not a package dependency
|
|
152
152
|
else
|
|
153
|
-
[:unassessable, {
|
|
153
|
+
[:unassessable, {ecosystem: type, name: name, reason: :unsupported_ecosystem}]
|
|
154
154
|
end
|
|
155
155
|
rescue ArgumentError
|
|
156
156
|
# InvalidPackageURL is a subclass of ArgumentError, but PackageURL.parse also
|
|
@@ -158,7 +158,7 @@ module StillActive
|
|
|
158
158
|
# or bad percent-encoding, both common in real Syft/Trivy output. Catch both so
|
|
159
159
|
# one malformed component degrades to unassessable, never a backtrace that drops
|
|
160
160
|
# every other dependency's verdict (SbomReader's documented "never raises").
|
|
161
|
-
[:unassessable, {
|
|
161
|
+
[:unassessable, {ecosystem: nil, name: name, reason: :malformed_purl}]
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
# Whether a repository_url points at a known public registry. We only read the
|
|
@@ -40,7 +40,7 @@ module StillActive
|
|
|
40
40
|
runtime_ranges = {
|
|
41
41
|
python: safe_support_window("Python") { PythonHelper.supported_python_range },
|
|
42
42
|
dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
|
|
43
|
-
dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range }
|
|
43
|
+
dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range }
|
|
44
44
|
}
|
|
45
45
|
barrier = Async::Barrier.new
|
|
46
46
|
semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
|
|
@@ -63,15 +63,15 @@ module StillActive
|
|
|
63
63
|
result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
|
|
64
64
|
EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, runtime_ranges: runtime_ranges)
|
|
65
65
|
.merge(dep.slice(:production))
|
|
66
|
-
rescue
|
|
66
|
+
rescue => e
|
|
67
67
|
# One dependency's failure must not abort the audit, but it must not
|
|
68
68
|
# disappear either: record it as an unassessable entry (same shape as
|
|
69
69
|
# the reader's, with a distinct reason) so it's counted and reported.
|
|
70
70
|
# production rides along (when known, via slice) so a failed prod dep
|
|
71
71
|
# stays distinguishable from a failed dev one.
|
|
72
|
-
failures << {
|
|
72
|
+
failures << {ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}"}
|
|
73
73
|
.merge(dep.slice(:production))
|
|
74
|
-
|
|
74
|
+
warn("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
|
|
75
75
|
ensure
|
|
76
76
|
completed += 1
|
|
77
77
|
on_progress&.call(completed, total)
|
|
@@ -88,7 +88,7 @@ module StillActive
|
|
|
88
88
|
# Stable, diffable order regardless of async completion order.
|
|
89
89
|
Outcome.new(
|
|
90
90
|
assessed: result.sort_by { |key, _| key }.to_h,
|
|
91
|
-
failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" }
|
|
91
|
+
failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" }
|
|
92
92
|
)
|
|
93
93
|
end.wait
|
|
94
94
|
end
|
|
@@ -99,8 +99,8 @@ module StillActive
|
|
|
99
99
|
# ceiling checks for that runtime) with a warning, never aborting the audit.
|
|
100
100
|
def safe_support_window(label)
|
|
101
101
|
yield
|
|
102
|
-
rescue
|
|
103
|
-
|
|
102
|
+
rescue => e
|
|
103
|
+
warn("warning: #{label} support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
|
|
104
104
|
nil
|
|
105
105
|
end
|
|
106
106
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Auth headers for a gem source, resolved the way Bundler itself does: from its
|
|
9
|
+
# per-host credential store (`bundle config` / the `BUNDLE_<HOST>` env var).
|
|
10
|
+
#
|
|
11
|
+
# The store is HOST-KEYED (`credentials_for` is `self[uri.to_s] || self[uri.host]`),
|
|
12
|
+
# which is the whole security property when a source URI comes from a lockfile: a
|
|
13
|
+
# host the user never configured resolves to no credential, so a malicious lockfile
|
|
14
|
+
# naming `attacker.example.com` cannot make still_active send another host's
|
|
15
|
+
# credential there. There is deliberately NO ambient/global fallback here; the only
|
|
16
|
+
# ambient token still_active holds (`--artifactory-token`) is guarded by an explicit
|
|
17
|
+
# host allowlist inside ArtifactoryClient, which is the sole caller that adds it.
|
|
18
|
+
module SourceCredentials
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def headers_for(source_uri)
|
|
22
|
+
auth_header(Bundler.settings.credentials_for(URI(source_uri)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Turns a Bundler credential string into an Authorization header: `user:pass`
|
|
26
|
+
# becomes Basic (each half percent-decoded, since Bundler stores them encoded),
|
|
27
|
+
# a bare token becomes Bearer. Empty/nil yields no header.
|
|
28
|
+
def auth_header(credentials)
|
|
29
|
+
return {} if credentials.nil? || credentials.empty?
|
|
30
|
+
|
|
31
|
+
if credentials.include?(":")
|
|
32
|
+
user, pass = credentials.split(":", 2).map { |part| CGI.unescape(part) }
|
|
33
|
+
{"Authorization" => "Basic #{["#{user}:#{pass}"].pack("m0")}"}
|
|
34
|
+
else
|
|
35
|
+
{"Authorization" => "Bearer #{credentials}"}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/still_active/version.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "artifactory_client"
|
|
4
|
+
require_relative "compact_index_client"
|
|
5
|
+
require_relative "source_credentials"
|
|
4
6
|
require_relative "ceiling_reconciler"
|
|
5
7
|
require_relative "deps_dev_client"
|
|
6
8
|
require_relative "osv_client"
|
|
@@ -46,8 +48,8 @@ module StillActive
|
|
|
46
48
|
ruby_range =
|
|
47
49
|
begin
|
|
48
50
|
RubyHelper.supported_ruby_range
|
|
49
|
-
rescue
|
|
50
|
-
|
|
51
|
+
rescue => e
|
|
52
|
+
warn("warning: Ruby support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
|
|
51
53
|
nil
|
|
52
54
|
end
|
|
53
55
|
# Resolve the GitHub token once here, single-fibered, before the fan-out:
|
|
@@ -79,14 +81,14 @@ module StillActive
|
|
|
79
81
|
advisory_db: advisory_db,
|
|
80
82
|
catalog: catalog,
|
|
81
83
|
constraint_cache: constraint_cache,
|
|
82
|
-
ruby_range: ruby_range
|
|
84
|
+
ruby_range: ruby_range
|
|
83
85
|
)
|
|
84
86
|
rescue Octokit::TooManyRequests
|
|
85
87
|
$stderr.print("\r\e[K") if on_progress
|
|
86
|
-
|
|
87
|
-
rescue
|
|
88
|
+
warn("rate limited checking #{gem[:name]}: set GITHUB_TOKEN to increase your limit")
|
|
89
|
+
rescue => e
|
|
88
90
|
$stderr.print("\r\e[K") if on_progress
|
|
89
|
-
|
|
91
|
+
warn("error occurred for #{gem[:name]}: #{e.class}\n\t#{e.message}")
|
|
90
92
|
ensure
|
|
91
93
|
completed += 1
|
|
92
94
|
on_progress&.call(completed, total)
|
|
@@ -115,7 +117,7 @@ module StillActive
|
|
|
115
117
|
private
|
|
116
118
|
|
|
117
119
|
def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, direct: true, dependency_path: nil, advisory_db: nil, catalog: nil, constraint_cache: {}, ruby_range: nil)
|
|
118
|
-
result_object[gem_name] = {
|
|
120
|
+
result_object[gem_name] = {source_type: source_type, direct: direct}
|
|
119
121
|
result_object[gem_name][:dependency_path] = dependency_path if dependency_path
|
|
120
122
|
result_object[gem_name][:version_used] = gem_version if gem_version
|
|
121
123
|
|
|
@@ -129,7 +131,7 @@ module StillActive
|
|
|
129
131
|
result_object: result_object,
|
|
130
132
|
source_uri: source_uri,
|
|
131
133
|
advisory_db: advisory_db,
|
|
132
|
-
ruby_range: ruby_range
|
|
134
|
+
ruby_range: ruby_range
|
|
133
135
|
)
|
|
134
136
|
end
|
|
135
137
|
|
|
@@ -148,19 +150,19 @@ module StillActive
|
|
|
148
150
|
signals = repo_signals(
|
|
149
151
|
source: repo_info[:source],
|
|
150
152
|
repository_owner: repo_info[:owner],
|
|
151
|
-
repository_name: repo_info[:name]
|
|
153
|
+
repository_name: repo_info[:name]
|
|
152
154
|
)
|
|
153
155
|
commit_date = signals[:last_commit_date]
|
|
154
156
|
archived = signals[:archived]
|
|
155
157
|
last_release = VersionHelper.find_version(versions: vs, pre_release: false)
|
|
156
158
|
last_pre_release = VersionHelper.upcoming_pre_release(
|
|
157
159
|
pre_release: VersionHelper.find_version(versions: vs, pre_release: true),
|
|
158
|
-
release: last_release
|
|
160
|
+
release: last_release
|
|
159
161
|
)
|
|
160
162
|
deps_dev = fetch_deps_dev_info(
|
|
161
163
|
gem_name: gem_name,
|
|
162
164
|
version: gem_version || VersionHelper.gem_version(version_hash: last_release),
|
|
163
|
-
advisory_db: advisory_db
|
|
165
|
+
advisory_db: advisory_db
|
|
164
166
|
)
|
|
165
167
|
result_object[gem_name].merge!({
|
|
166
168
|
latest_version: VersionHelper.gem_version(version_hash: last_release),
|
|
@@ -172,10 +174,14 @@ module StillActive
|
|
|
172
174
|
repository_url: repo_info[:url],
|
|
173
175
|
last_commit_date: commit_date,
|
|
174
176
|
archived: archived,
|
|
175
|
-
**deps_dev
|
|
177
|
+
**deps_dev
|
|
176
178
|
})
|
|
177
179
|
|
|
178
|
-
|
|
180
|
+
# Only a gem that actually lives on public rubygems.org gets a rubygems.org
|
|
181
|
+
# page link. For a private source, rubygems.org/gems/<name> is a public
|
|
182
|
+
# name collision (for sidekiq-pro, the 0.0.3 squat-warning decoy), not the
|
|
183
|
+
# gem the user resolves -- the same #43 substitution the repo-URL guard blocks.
|
|
184
|
+
unless vs.empty? || unqueryable_private_source?(source_uri)
|
|
179
185
|
result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
|
|
180
186
|
end
|
|
181
187
|
|
|
@@ -184,7 +190,7 @@ module StillActive
|
|
|
184
190
|
source: repo_info[:source],
|
|
185
191
|
repository_owner: repo_info[:owner],
|
|
186
192
|
repository_name: repo_info[:name],
|
|
187
|
-
version: VersionHelper.gem_version(version_hash: last_release)
|
|
193
|
+
version: VersionHelper.gem_version(version_hash: last_release)
|
|
188
194
|
)
|
|
189
195
|
end
|
|
190
196
|
|
|
@@ -194,7 +200,7 @@ module StillActive
|
|
|
194
200
|
up_to_date: VersionHelper.up_to_date(
|
|
195
201
|
version_used: version_used,
|
|
196
202
|
latest_version: last_release,
|
|
197
|
-
latest_pre_release_version: last_pre_release
|
|
203
|
+
latest_pre_release_version: last_pre_release
|
|
198
204
|
),
|
|
199
205
|
|
|
200
206
|
version_used_release_date: VersionHelper.release_date(version_hash: version_used),
|
|
@@ -202,8 +208,8 @@ module StillActive
|
|
|
202
208
|
license: VersionHelper.license(version_hash: version_used),
|
|
203
209
|
libyear: LibyearHelper.gem_libyear(
|
|
204
210
|
version_used_release_date: VersionHelper.release_date(version_hash: version_used),
|
|
205
|
-
latest_version_release_date: VersionHelper.release_date(version_hash: last_release)
|
|
206
|
-
)
|
|
211
|
+
latest_version_release_date: VersionHelper.release_date(version_hash: last_release)
|
|
212
|
+
)
|
|
207
213
|
})
|
|
208
214
|
end
|
|
209
215
|
|
|
@@ -218,7 +224,7 @@ module StillActive
|
|
|
218
224
|
used_ruby_requirement: canonical_ruby_requirement(vs, version_used),
|
|
219
225
|
latest_ruby_requirement: canonical_ruby_requirement(vs, last_release),
|
|
220
226
|
pinned: !version_used.nil?,
|
|
221
|
-
ruby_range: ruby_range
|
|
227
|
+
ruby_range: ruby_range
|
|
222
228
|
)
|
|
223
229
|
end
|
|
224
230
|
end
|
|
@@ -297,7 +303,7 @@ module StillActive
|
|
|
297
303
|
repository_url: repo_info[:url],
|
|
298
304
|
last_commit_date: signals[:last_commit_date],
|
|
299
305
|
archived: signals[:archived],
|
|
300
|
-
**deps_dev
|
|
306
|
+
**deps_dev
|
|
301
307
|
})
|
|
302
308
|
end
|
|
303
309
|
|
|
@@ -311,7 +317,7 @@ module StillActive
|
|
|
311
317
|
|
|
312
318
|
leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
|
|
313
319
|
result_object[gem_name][:alternatives] = leads unless leads.empty?
|
|
314
|
-
rescue
|
|
320
|
+
rescue
|
|
315
321
|
nil # cosmetic best-effort: lead-fetching must never break the core audit
|
|
316
322
|
end
|
|
317
323
|
|
|
@@ -373,18 +379,19 @@ module StillActive
|
|
|
373
379
|
# drop the advisory and read a known-vulnerable gem as clean, so an
|
|
374
380
|
# un-enriched key still contributes a minimal advisory (mirrors EcosystemLens;
|
|
375
381
|
# ruby-advisory-db then fills the score on merge when it also carries it).
|
|
376
|
-
deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || {
|
|
382
|
+
deps_dev_vulns = advisory_keys.map { |id| DepsDevClient.advisory_detail(advisory_id: id) || {id: id, source: "deps.dev"} }
|
|
377
383
|
radb_vulns = RubyAdvisoryDb.advisories_for(database: advisory_db, gem_name: gem_name, version: version)
|
|
378
384
|
vulnerabilities = VulnerabilityHelper.merge_advisories(deps_dev: deps_dev_vulns, ruby_advisory_db: radb_vulns)
|
|
379
385
|
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
380
386
|
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
381
|
-
# Native path is rubygems.
|
|
382
|
-
|
|
387
|
+
# Native path is rubygems. The version also lets OSV confirm a deps.dev-only
|
|
388
|
+
# advisory applies to it; a ruby-advisory-db verdict is never overruled.
|
|
389
|
+
vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: :rubygems, name: gem_name, version: version)
|
|
383
390
|
{
|
|
384
391
|
scorecard_score: scorecard&.dig(:score),
|
|
385
392
|
scorecard_maintained: scorecard&.dig(:maintained),
|
|
386
393
|
vulnerability_count: vulnerabilities.length,
|
|
387
|
-
vulnerabilities: vulnerabilities
|
|
394
|
+
vulnerabilities: vulnerabilities
|
|
388
395
|
}
|
|
389
396
|
end
|
|
390
397
|
|
|
@@ -394,8 +401,7 @@ module StillActive
|
|
|
394
401
|
elsif ArtifactoryClient.artifactory_uri?(source_uri)
|
|
395
402
|
ArtifactoryClient.versions(gem_name: gem_name, source_uri: source_uri)
|
|
396
403
|
elsif unqueryable_private_source?(source_uri)
|
|
397
|
-
|
|
398
|
-
[]
|
|
404
|
+
private_source_versions(gem_name: gem_name, source_uri: source_uri)
|
|
399
405
|
else
|
|
400
406
|
Gems.versions(gem_name)
|
|
401
407
|
end
|
|
@@ -409,7 +415,7 @@ module StillActive
|
|
|
409
415
|
# best-effort feature must not be able to degrade an unrelated gem's core data:
|
|
410
416
|
# degrade to "no versions known" here, exactly like Gems::NotFound.
|
|
411
417
|
rescue Gems::GemError, *HttpHelper::TRANSPORT_ERRORS => e
|
|
412
|
-
|
|
418
|
+
warn("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
|
|
413
419
|
[]
|
|
414
420
|
end
|
|
415
421
|
|
|
@@ -437,11 +443,32 @@ module StillActive
|
|
|
437
443
|
false
|
|
438
444
|
end
|
|
439
445
|
|
|
446
|
+
# Any Bundler-compatible private host (Contribsys, Gemstash, Gemfury, a private
|
|
447
|
+
# mirror) serves the RubyGems compact index, since that is how `bundle install`
|
|
448
|
+
# resolves from it. So try that agnostic rail before declaring the source
|
|
449
|
+
# unqueryable: it covers the whole class with no per-host client, and falls
|
|
450
|
+
# through to the same warning when a host does not serve it. Auth is Bundler's
|
|
451
|
+
# own host-keyed credential only; still_active's ambient --artifactory-token is
|
|
452
|
+
# never sent to a lockfile-derived host this way (that stays ArtifactoryClient's,
|
|
453
|
+
# behind its host allowlist). The repo-signal path is untouched, so #43 still
|
|
454
|
+
# blocks a public name-collision's repo data from standing in for the private gem.
|
|
455
|
+
def private_source_versions(gem_name:, source_uri:)
|
|
456
|
+
versions = CompactIndexClient.versions(
|
|
457
|
+
gem_name: gem_name,
|
|
458
|
+
source_uri: source_uri,
|
|
459
|
+
headers: SourceCredentials.headers_for(source_uri)
|
|
460
|
+
)
|
|
461
|
+
return versions unless versions.empty?
|
|
462
|
+
|
|
463
|
+
warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
|
|
464
|
+
[]
|
|
465
|
+
end
|
|
466
|
+
|
|
440
467
|
def warn_unqueryable_private_source(gem_name:, source_uri:)
|
|
441
468
|
host = URI(source_uri).host
|
|
442
|
-
|
|
469
|
+
warn(
|
|
443
470
|
"warning: #{gem_name} resolves from a private source (#{host}) still_active cannot query; " \
|
|
444
|
-
"reporting no version/latest/libyear data for it rather than substituting public rubygems.org data"
|
|
471
|
+
"reporting no version/latest/libyear data for it rather than substituting public rubygems.org data"
|
|
445
472
|
)
|
|
446
473
|
end
|
|
447
474
|
|
|
@@ -450,7 +477,7 @@ module StillActive
|
|
|
450
477
|
namespace_path = base.path
|
|
451
478
|
path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
|
|
452
479
|
token = StillActive.config.github_oauth_token
|
|
453
|
-
headers = token ? {
|
|
480
|
+
headers = token ? {"Authorization" => "Bearer #{token}"} : {}
|
|
454
481
|
HttpHelper.get_json(base, path, headers: headers) || []
|
|
455
482
|
end
|
|
456
483
|
|
|
@@ -471,7 +498,7 @@ module StillActive
|
|
|
471
498
|
# deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
|
|
472
499
|
# repo has no deps.dev project, so leave its project_id nil rather than minting
|
|
473
500
|
# a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
|
|
474
|
-
DEPS_DEV_HOST_BY_SOURCE = {
|
|
501
|
+
DEPS_DEV_HOST_BY_SOURCE = {github: "github.com", gitlab: "gitlab.com"}.freeze
|
|
475
502
|
|
|
476
503
|
def deps_dev_project_id(repo)
|
|
477
504
|
host = DEPS_DEV_HOST_BY_SOURCE[repo[:source]]
|
|
@@ -505,7 +532,7 @@ module StillActive
|
|
|
505
532
|
|
|
506
533
|
[
|
|
507
534
|
info.metadata&.dig("source_code_uri"),
|
|
508
|
-
info.homepage
|
|
535
|
+
info.homepage
|
|
509
536
|
].compact.uniq
|
|
510
537
|
end
|
|
511
538
|
|
|
@@ -521,7 +548,7 @@ module StillActive
|
|
|
521
548
|
|
|
522
549
|
[
|
|
523
550
|
info["homepage_uri"],
|
|
524
|
-
info["source_code_uri"]
|
|
551
|
+
info["source_code_uri"]
|
|
525
552
|
].compact.uniq
|
|
526
553
|
rescue Gems::NotFound
|
|
527
554
|
[]
|
data/still_active.gemspec
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
require_relative "lib/still_active/version"
|
|
4
4
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name
|
|
7
|
-
spec.version
|
|
8
|
-
spec.authors
|
|
9
|
-
spec.email
|
|
6
|
+
spec.name = "still_active"
|
|
7
|
+
spec.version = StillActive::VERSION
|
|
8
|
+
spec.authors = ["Sean Floyd"]
|
|
9
|
+
spec.email = ["contact@seanfloyd.dev"]
|
|
10
10
|
|
|
11
|
-
spec.summary
|
|
11
|
+
spec.summary = "Audit your dependencies for maintenance health, abandonment, and below-the-fix vulnerabilities. " \
|
|
12
12
|
"Ruby gems natively; npm, PyPI, Cargo, Go, Maven, and NuGet via a CycloneDX SBOM."
|
|
13
|
-
spec.description
|
|
13
|
+
spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
|
|
14
14
|
"whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
|
|
15
15
|
"release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
|
|
16
16
|
"(deps.dev and OSV, merged with ruby-advisory-db, flagging advisories with no fix and pins that sit below " \
|
|
@@ -24,9 +24,14 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
"with granular, committed suppression via .still_active.yml. " \
|
|
25
25
|
"Complements bundle outdated, bundler-audit, and libyear-bundler by adding the maintenance signal they " \
|
|
26
26
|
"don't, and folds their version, CVE, and libyear checks into one report."
|
|
27
|
-
spec.homepage
|
|
28
|
-
spec.license
|
|
27
|
+
spec.homepage = "https://github.com/SeanLF/still_active"
|
|
28
|
+
spec.license = "MIT"
|
|
29
29
|
spec.required_ruby_version = ">= 3.3.0"
|
|
30
|
+
# Gem::Resolver::APISet::GemParser, which parses the RubyGems compact index for
|
|
31
|
+
# the Artifactory client, first shipped in RubyGems 3.2.3. Ruby 3.3.0 bundles
|
|
32
|
+
# 3.5.3, so this is documentation of the real dependency rather than a bound
|
|
33
|
+
# anyone can trip.
|
|
34
|
+
spec.required_rubygems_version = ">= 3.2.3"
|
|
30
35
|
|
|
31
36
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
32
37
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
@@ -36,23 +41,14 @@ Gem::Specification.new do |spec|
|
|
|
36
41
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
37
42
|
|
|
38
43
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
39
|
-
|
|
44
|
+
`git ls-files -z`.split("\x0").select do |f|
|
|
40
45
|
f.start_with?("lib/", "bin/still_active") || f.match?(/\A(LICENSE|README|CHANGELOG|still_active\.gemspec)\b/)
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
|
-
spec.bindir
|
|
44
|
-
spec.executables
|
|
48
|
+
spec.bindir = "bin"
|
|
49
|
+
spec.executables = spec.files.grep(%r{\Abin/still_active}) { |f| File.basename(f) }
|
|
45
50
|
spec.require_paths = ["lib"]
|
|
46
51
|
|
|
47
|
-
spec.add_development_dependency("bundler-audit")
|
|
48
|
-
spec.add_development_dependency("debug")
|
|
49
|
-
spec.add_development_dependency("faker")
|
|
50
|
-
spec.add_development_dependency("json_schemer")
|
|
51
|
-
spec.add_development_dependency("rubocop")
|
|
52
|
-
spec.add_development_dependency("rubocop-performance")
|
|
53
|
-
spec.add_development_dependency("rubocop-rspec")
|
|
54
|
-
spec.add_development_dependency("rubocop-shopify")
|
|
55
|
-
|
|
56
52
|
# 2.0/2.1 ship a scheduler that breaks our fan-out (io_read); 2.2 is the
|
|
57
53
|
# verified floor (checked against Ruby 3.3 in Docker). octokit/faraday-retry/
|
|
58
54
|
# gems work down to ancient versions, so they stay unpinned rather than
|