still_active 1.6.0 → 2.0.0
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 +47 -0
- data/README.md +79 -12
- data/lib/helpers/activity_helper.rb +34 -9
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/cyclonedx_helper.rb +24 -8
- data/lib/helpers/diff_markdown_helper.rb +15 -12
- data/lib/helpers/http_helper.rb +101 -14
- 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 +27 -5
- data/lib/helpers/ruby_helper.rb +5 -4
- data/lib/helpers/sarif_helper.rb +53 -26
- data/lib/helpers/summary_helper.rb +48 -0
- data/lib/helpers/terminal_helper.rb +27 -17
- data/lib/helpers/version_helper.rb +7 -1
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/cli.rb +87 -18
- data/lib/still_active/config.rb +32 -2
- data/lib/still_active/config_file.rb +180 -0
- data/lib/still_active/deps_dev_client.rb +27 -6
- data/lib/still_active/diff.rb +59 -2
- 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 +14 -5
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +2 -2
- data/lib/still_active/suppressions.rb +142 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +113 -55
- data/still_active.gemspec +9 -7
- metadata +19 -8
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "suppressions"
|
|
5
|
+
require_relative "../helpers/vulnerability_helper"
|
|
6
|
+
|
|
7
|
+
module StillActive
|
|
8
|
+
# Loads a committed .still_active.yml and applies it to the config as the layer
|
|
9
|
+
# below env vars and CLI flags (CLI flag > env var > config file > default).
|
|
10
|
+
# Mirrors the policy flags (gates, thresholds, output, alternatives, scope)
|
|
11
|
+
# and the granular suppression list; deliberately NOT secrets (tokens) or
|
|
12
|
+
# invocation-specific paths (--gemfile/--gems/--baseline/output paths), which
|
|
13
|
+
# stay CLI/env-only so a committed file never carries a credential.
|
|
14
|
+
module ConfigFile
|
|
15
|
+
FILENAME = ".still_active.yml"
|
|
16
|
+
BUNDLER_AUDIT_FILE = ".bundler-audit.yml"
|
|
17
|
+
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
def load(dir: Dir.pwd)
|
|
21
|
+
path = File.join(dir, FILENAME)
|
|
22
|
+
return {} unless File.file?(path)
|
|
23
|
+
|
|
24
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date, Time])
|
|
25
|
+
return {} if data.nil?
|
|
26
|
+
|
|
27
|
+
unless data.is_a?(Hash)
|
|
28
|
+
$stderr.puts("warning: #{FILENAME} must be a mapping of settings; ignoring it")
|
|
29
|
+
return {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
data
|
|
33
|
+
rescue Psych::Exception => e
|
|
34
|
+
# Covers a syntax error and a disallowed tag (e.g. !ruby/object); either
|
|
35
|
+
# way the committed file must never take the audit down with it.
|
|
36
|
+
$stderr.puts("warning: #{FILENAME} could not be loaded (#{e.message}); ignoring it")
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Applies data to config and returns an array of warning strings (unknown
|
|
41
|
+
# keys, invalid values, suppression/import problems). Booleans/numbers are
|
|
42
|
+
# passed through as the gate expects them.
|
|
43
|
+
def apply(config, data, base_dir: Dir.pwd)
|
|
44
|
+
warnings = []
|
|
45
|
+
ignore_entries = Array(data["ignore"])
|
|
46
|
+
|
|
47
|
+
data.each do |key, value|
|
|
48
|
+
case key
|
|
49
|
+
when "fail_if_critical" then set_boolean(config, :fail_if_critical=, value, key, warnings)
|
|
50
|
+
when "fail_if_warning" then set_boolean(config, :fail_if_warning=, value, key, warnings)
|
|
51
|
+
when "alternatives" then set_boolean(config, :alternatives=, value, key, warnings)
|
|
52
|
+
when "unreleased_commits" then set_boolean(config, :unreleased_commits=, value, key, warnings)
|
|
53
|
+
when "direct_only" then set_boolean(config, :direct_only=, value, key, warnings)
|
|
54
|
+
when "safe_range_end" then set_number(config, :no_warning_range_end=, value, key, warnings)
|
|
55
|
+
when "warning_range_end" then set_number(config, :warning_range_end=, value, key, warnings)
|
|
56
|
+
when "fail_if_outdated" then apply_fail_if_outdated(config, value, warnings)
|
|
57
|
+
when "parallelism" then apply_parallelism(config, value, warnings)
|
|
58
|
+
when "fail_if_vulnerable" then apply_fail_if_vulnerable(config, value, warnings)
|
|
59
|
+
when "output" then apply_output(config, value, warnings)
|
|
60
|
+
when "ignore" then nil # handled below, after imports are gathered
|
|
61
|
+
when "import" then ignore_entries.concat(import_advisories(value, base_dir, warnings))
|
|
62
|
+
else warnings << "#{FILENAME}: unknown setting #{key.inspect}, ignoring it"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
suppressions = Suppressions.from(ignore_entries)
|
|
67
|
+
warnings.concat(suppressions.warnings)
|
|
68
|
+
config.suppressions = suppressions
|
|
69
|
+
warnings
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Suggests honouring an existing bundler-audit ignore list rather than
|
|
73
|
+
# silently inheriting it. Auto-importing another tool's suppressions would
|
|
74
|
+
# hide vulnerabilities the user only accepted in bundler-audit's context,
|
|
75
|
+
# with no reason/expiry and no explicit opt-in here, the exact over-broad
|
|
76
|
+
# muting this feature exists to replace. So nudge, don't absorb, and only
|
|
77
|
+
# when the vulnerability gate is on (suppression is otherwise moot) and the
|
|
78
|
+
# file isn't already imported. Returns the hint string or nil.
|
|
79
|
+
def import_hint(data, config: StillActive.config, dir: Dir.pwd)
|
|
80
|
+
return unless config.fail_if_vulnerable
|
|
81
|
+
return if Array(data["import"]).include?(BUNDLER_AUDIT_FILE)
|
|
82
|
+
|
|
83
|
+
path = File.join(dir, BUNDLER_AUDIT_FILE)
|
|
84
|
+
return unless File.file?(path)
|
|
85
|
+
|
|
86
|
+
count = bundler_audit_ignore_count(path)
|
|
87
|
+
return unless count.positive?
|
|
88
|
+
|
|
89
|
+
noun = count == 1 ? "advisory" : "advisories"
|
|
90
|
+
"#{BUNDLER_AUDIT_FILE} lists #{count} accepted #{noun}; add `import: [#{BUNDLER_AUDIT_FILE}]` to #{FILENAME} to honour them in still_active's --fail-if-vulnerable gate too"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def bundler_audit_ignore_count(path)
|
|
96
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date, Time])
|
|
97
|
+
data.is_a?(Hash) ? Array(data["ignore"]).size : 0
|
|
98
|
+
rescue Psych::Exception
|
|
99
|
+
0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A malformed value must warn and leave the default in place, never silently
|
|
103
|
+
# flip a gate off (`!!""` is false) or crash the audit (`false.to_f` raises,
|
|
104
|
+
# since `&.` guards only nil). Validate before assigning, like the gates do.
|
|
105
|
+
def set_boolean(config, setter, value, key, warnings)
|
|
106
|
+
if [true, false].include?(value)
|
|
107
|
+
config.public_send(setter, value)
|
|
108
|
+
else
|
|
109
|
+
warnings << "#{FILENAME}: #{key} must be true or false (got #{value.inspect}), ignoring it"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def set_number(config, setter, value, key, warnings)
|
|
114
|
+
if value.is_a?(Numeric)
|
|
115
|
+
config.public_send(setter, value.to_f)
|
|
116
|
+
else
|
|
117
|
+
warnings << "#{FILENAME}: #{key} must be a number (got #{value.inspect}), ignoring it"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def apply_fail_if_outdated(config, value, warnings)
|
|
122
|
+
case value
|
|
123
|
+
when nil, false then config.fail_if_outdated = nil # gate off
|
|
124
|
+
when Numeric then config.fail_if_outdated = value.to_f
|
|
125
|
+
else warnings << "#{FILENAME}: fail_if_outdated must be a number or false (got #{value.inspect}), ignoring it"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def apply_parallelism(config, value, warnings)
|
|
130
|
+
if value.is_a?(Integer) && value.positive?
|
|
131
|
+
config.parallelism = value
|
|
132
|
+
else
|
|
133
|
+
warnings << "#{FILENAME}: parallelism must be a positive integer (got #{value.inspect}), ignoring it"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def apply_fail_if_vulnerable(config, value, warnings)
|
|
138
|
+
if value == true || value == false
|
|
139
|
+
config.fail_if_vulnerable = value || nil
|
|
140
|
+
elsif VulnerabilityHelper::SEVERITY_ORDER.include?(value)
|
|
141
|
+
config.fail_if_vulnerable = value
|
|
142
|
+
else
|
|
143
|
+
warnings << "#{FILENAME}: fail_if_vulnerable severity must be one of #{VulnerabilityHelper::SEVERITY_ORDER.join(", ")} (got #{value.inspect}), ignoring it"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def apply_output(config, value, warnings)
|
|
148
|
+
format = value.to_s.to_sym
|
|
149
|
+
if [:terminal, :markdown, :json].include?(format)
|
|
150
|
+
config.output_format = format
|
|
151
|
+
else
|
|
152
|
+
warnings << "#{FILENAME}: output must be terminal, markdown, or json (got #{value.inspect}), ignoring it"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Reads each referenced bundler-audit config and converts its ignore list of
|
|
157
|
+
# advisory ids into advisory-scoped (gem-agnostic) suppression entries, so a
|
|
158
|
+
# team keeps one ignore list instead of two.
|
|
159
|
+
def import_advisories(paths, base_dir, warnings)
|
|
160
|
+
Array(paths).flat_map do |rel|
|
|
161
|
+
path = File.expand_path(rel, base_dir)
|
|
162
|
+
unless File.file?(path)
|
|
163
|
+
warnings << "#{FILENAME}: import target #{rel} not found, skipping it"
|
|
164
|
+
next []
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
imported = YAML.safe_load_file(path, permitted_classes: [Date, Time]) || {}
|
|
168
|
+
unless imported.is_a?(Hash)
|
|
169
|
+
warnings << "#{FILENAME}: import target #{rel} is not a mapping, skipping it"
|
|
170
|
+
next []
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Array(imported["ignore"]).map { |advisory| { "advisory" => advisory, "reason" => "imported from #{rel}" } }
|
|
174
|
+
rescue Psych::Exception => e
|
|
175
|
+
warnings << "#{FILENAME}: import target #{rel} could not be loaded (#{e.message}), skipping it"
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -48,7 +48,7 @@ module StillActive
|
|
|
48
48
|
id: body.dig("advisoryKey", "id"),
|
|
49
49
|
url: body["url"],
|
|
50
50
|
title: body["title"],
|
|
51
|
-
aliases: body["aliases"]&.
|
|
51
|
+
aliases: body["aliases"]&.filter_map { |a| a["id"] } || [],
|
|
52
52
|
cvss3_score: body["cvss3Score"],
|
|
53
53
|
cvss3_vector: body["cvss3Vector"],
|
|
54
54
|
cvss2_score: body["cvss2Score"],
|
|
@@ -58,15 +58,36 @@ module StillActive
|
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
61
|
+
# Builds a deps.dev project id ("host/owner/repo", or a deeper path for
|
|
62
|
+
# GitLab subgroups) from the SOURCE_REPO link URL. GitHub/Bitbucket projects
|
|
63
|
+
# are always host/owner/repo, but GitLab namespaces nest arbitrarily
|
|
64
|
+
# (host/group/subgroup/.../project), so we can't just keep three segments.
|
|
63
65
|
def extract_project_id(body)
|
|
64
66
|
url = body.dig("links")&.find { |l| l["label"] == "SOURCE_REPO" }&.dig("url")
|
|
65
67
|
return if url.nil?
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
segments =
|
|
69
|
-
|
|
69
|
+
host, *segments = url.sub(%r{\Ahttps?://}, "").split("/")
|
|
70
|
+
segments = repo_path_segments(host, segments)
|
|
71
|
+
return if host.nil? || segments.empty?
|
|
72
|
+
|
|
73
|
+
[host, *segments].join("/")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Trims a repo URL's path to just the project. On GitLab the project path
|
|
77
|
+
# ends at the "/-/" separator (before tree/blob/etc) and may be nested;
|
|
78
|
+
# elsewhere it's owner/repo. Drops trailing slashes and a ".git" suffix.
|
|
79
|
+
def repo_path_segments(host, segments)
|
|
80
|
+
segments = segments.reject(&:empty?)
|
|
81
|
+
|
|
82
|
+
if host.to_s.start_with?("gitlab.")
|
|
83
|
+
separator = segments.index("-")
|
|
84
|
+
segments = segments[0...separator] if separator
|
|
85
|
+
else
|
|
86
|
+
segments = segments.first(2)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
segments[-1] = segments[-1].delete_suffix(".git") unless segments.empty?
|
|
90
|
+
segments
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
def encode(value)
|
data/lib/still_active/diff.rb
CHANGED
|
@@ -74,10 +74,67 @@ module StillActive
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def validate_schema!(snapshot, role)
|
|
77
|
+
# A user can point --baseline at any JSON file. Reject a wrong shape here
|
|
78
|
+
# as an UnsupportedSchemaError (which emit_diff turns into a clean exit 2)
|
|
79
|
+
# rather than letting snapshot["..."] / gems.keys raise a raw stack trace.
|
|
80
|
+
unless snapshot.is_a?(Hash)
|
|
81
|
+
raise UnsupportedSchemaError, "#{role} is not a still_active JSON object (got #{snapshot.class})"
|
|
82
|
+
end
|
|
83
|
+
|
|
77
84
|
version = snapshot["schema_version"]
|
|
78
|
-
|
|
85
|
+
unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
|
|
86
|
+
raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
ruby = snapshot["ruby"]
|
|
90
|
+
unless ruby.nil? || ruby.is_a?(Hash)
|
|
91
|
+
raise UnsupportedSchemaError, "#{role} has a malformed ruby section (expected an object, got #{ruby.class})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
gems = snapshot["gems"]
|
|
95
|
+
return if gems.nil?
|
|
96
|
+
|
|
97
|
+
unless gems.is_a?(Hash)
|
|
98
|
+
raise UnsupportedSchemaError, "#{role} has a malformed gems section (expected an object, got #{gems.class})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
gems.each { |name, data| validate_gem!(role, name, data) }
|
|
102
|
+
end
|
|
79
103
|
|
|
80
|
-
|
|
104
|
+
# The diff dereferences gem fields with type-sensitive operations: a non-Hash
|
|
105
|
+
# value crashes the intersection branch, an arithmetic field that isn't
|
|
106
|
+
# numeric crashes (libyear/scorecard) or silently fabricates a count
|
|
107
|
+
# (vulnerability_count via .to_i), and a non-array vulnerabilities silently
|
|
108
|
+
# drops advisory ids. The baseline is untrusted user input, so validate the
|
|
109
|
+
# shape the diff requires here and let the rest of the code assume it.
|
|
110
|
+
NUMERIC_GEM_FIELDS = ["vulnerability_count", "scorecard_score", "libyear"].freeze
|
|
111
|
+
|
|
112
|
+
def validate_gem!(role, name, data)
|
|
113
|
+
unless data.is_a?(Hash)
|
|
114
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} is malformed (expected an object, got #{data.class})"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
NUMERIC_GEM_FIELDS.each do |field|
|
|
118
|
+
value = data[field]
|
|
119
|
+
next if value.nil? || value.is_a?(Numeric)
|
|
120
|
+
|
|
121
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a non-numeric #{field} (got #{value.class})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
vulns = data["vulnerabilities"]
|
|
125
|
+
return if vulns.nil?
|
|
126
|
+
|
|
127
|
+
unless vulns.is_a?(Array)
|
|
128
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerabilities list (expected an array, got #{vulns.class})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# advisory_ids dereferences each entry as a hash (entry["id"]); a scalar
|
|
132
|
+
# element would crash there, so reject non-object entries up front.
|
|
133
|
+
vulns.each do |entry|
|
|
134
|
+
next if entry.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerability entry (expected an object, got #{entry.class})"
|
|
137
|
+
end
|
|
81
138
|
end
|
|
82
139
|
|
|
83
140
|
# Categorises a version bump:
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../helpers/http_helper"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# Repo signals (archived?, last commit date) for Forgejo/Gitea-hosted gems.
|
|
8
|
+
# Codeberg.org is the only host wired into the workflow today, but every
|
|
9
|
+
# Forgejo/Gitea instance speaks the same `/api/v1` surface, so the host is a
|
|
10
|
+
# parameter for later self-hosted support. Mirrors GitlabClient: HttpHelper
|
|
11
|
+
# against a documented JSON API, anonymous by default with an optional token.
|
|
12
|
+
module ForgejoClient
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
DEFAULT_HOST = "codeberg.org"
|
|
16
|
+
|
|
17
|
+
# archived + last-activity date from a single repository call. The repo
|
|
18
|
+
# object's updated_at matches the latest commit date to the day in practice,
|
|
19
|
+
# so folding the two signals into one call halves the per-gem requests.
|
|
20
|
+
# Returns {} when the repo can't be read.
|
|
21
|
+
def repo_signals(owner:, name:, host: DEFAULT_HOST)
|
|
22
|
+
return {} if owner.nil? || name.nil?
|
|
23
|
+
|
|
24
|
+
body = HttpHelper.get_json(base_uri(host), "/api/v1/repos/#{owner}/#{name}", headers: auth_headers)
|
|
25
|
+
return {} if body.nil?
|
|
26
|
+
|
|
27
|
+
{ archived: body["archived"] == true, last_commit_date: parse_time(body["updated_at"], owner, name) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_time(value, owner, name)
|
|
33
|
+
return if value.nil?
|
|
34
|
+
|
|
35
|
+
Time.parse(value)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
$stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def base_uri(host)
|
|
42
|
+
URI("https://#{host}/")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def auth_headers
|
|
46
|
+
token = StillActive.config.forgejo_token
|
|
47
|
+
token ? { "Authorization" => "token #{token}" } : {}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "octokit"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# Repo signals (archived?, last commit date) for github.com-hosted gems.
|
|
8
|
+
# Wraps Octokit so the rest of the workflow dispatches to a provider with the
|
|
9
|
+
# same shape as GitlabClient, rather than reaching into Octokit inline. The
|
|
10
|
+
# Octokit dependency stays internal to this module.
|
|
11
|
+
module GithubClient
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
# A rate-limit response whose reset is at most this many seconds away is
|
|
15
|
+
# waited out and retried; a longer wait (hourly-limit exhaustion) is not
|
|
16
|
+
# auto-taken and falls through to the caller's rescue (warn + nil).
|
|
17
|
+
MAX_RATE_LIMIT_WAIT = 60
|
|
18
|
+
|
|
19
|
+
# archived + last-activity date from a single repository call. The repo
|
|
20
|
+
# object's pushed_at (last push) stands in for the last-commit date: it
|
|
21
|
+
# matches the default-branch commit date to the day in practice, and folding
|
|
22
|
+
# the two signals into one call halves the per-gem GitHub requests. Returns
|
|
23
|
+
# {} when the repo can't be read, so the caller leaves both signals blank.
|
|
24
|
+
def repo_signals(owner:, name:)
|
|
25
|
+
return {} if owner.nil? || name.nil?
|
|
26
|
+
|
|
27
|
+
repo = with_rate_limit_retry("repo #{owner}/#{name}") do
|
|
28
|
+
StillActive.config.github_client.repository("#{owner}/#{name}")
|
|
29
|
+
end
|
|
30
|
+
return {} unless repo
|
|
31
|
+
|
|
32
|
+
{ archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name) }
|
|
33
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
34
|
+
$stderr.puts("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Commits on the default branch since the latest release's tag: the
|
|
39
|
+
# "unreleased work" signal. GitHub's compare endpoint returns ahead_by as a
|
|
40
|
+
# single scalar, so this is one cheap call. The git tag name isn't carried
|
|
41
|
+
# by RubyGems, so resolve it from the version by trying the two ubiquitous
|
|
42
|
+
# forms (v7.0.1, 7.0.1) as the compare base; a wrong form 404s and we try
|
|
43
|
+
# the next. Returns nil when neither resolves (non-tagging repo, monorepo
|
|
44
|
+
# tag scheme we don't guess). Only GithubClient implements this; the
|
|
45
|
+
# workflow dispatches by respond_to?, so GitLab/Forgejo simply don't.
|
|
46
|
+
def commits_since_release(owner:, name:, version:)
|
|
47
|
+
return if owner.nil? || name.nil? || version.nil?
|
|
48
|
+
|
|
49
|
+
repo = "#{owner}/#{name}"
|
|
50
|
+
["v#{version}", version.to_s].each do |tag|
|
|
51
|
+
return with_rate_limit_retry("unreleased-commits #{repo}") { StillActive.config.github_client.compare(repo, tag, "HEAD").ahead_by }
|
|
52
|
+
rescue Octokit::NotFound
|
|
53
|
+
# this tag form doesn't exist; fall through to try the next one
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
57
|
+
$stderr.puts("warning: unreleased-commits check failed for #{owner}/#{name}: #{e.class}")
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Pause-and-retry on a rate-limit response when the reset is near, so a
|
|
64
|
+
# transient secondary/burst limit (which GitHub's concurrent fan-out can
|
|
65
|
+
# trip even with a token) self-heals instead of dropping the gem's signal.
|
|
66
|
+
# Retries at most once. Under the async reactor, sleep yields to other
|
|
67
|
+
# fibers rather than blocking the thread.
|
|
68
|
+
def with_rate_limit_retry(label)
|
|
69
|
+
retried = false
|
|
70
|
+
begin
|
|
71
|
+
yield
|
|
72
|
+
rescue Octokit::TooManyRequests => e
|
|
73
|
+
wait = rate_limit_wait(e)
|
|
74
|
+
if retried || wait.nil? || wait > MAX_RATE_LIMIT_WAIT
|
|
75
|
+
# Hourly-limit exhaustion (or a far reset): not worth auto-waiting.
|
|
76
|
+
# Surface the one actionable hint rather than a generic class name,
|
|
77
|
+
# then return nil so this signal is simply absent for the gem.
|
|
78
|
+
$stderr.puts("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
retried = true
|
|
83
|
+
$stderr.puts("rate limited on #{label}; waiting #{wait}s for reset")
|
|
84
|
+
sleep(wait)
|
|
85
|
+
retry
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Seconds to wait before retrying, from the Retry-After header (secondary
|
|
90
|
+
# limits) or x-ratelimit-reset (primary), or nil when neither is present.
|
|
91
|
+
def rate_limit_wait(error)
|
|
92
|
+
# Octokit normalises response headers to lowercase (Faraday is
|
|
93
|
+
# case-insensitive), so a single lowercase lookup covers any casing.
|
|
94
|
+
headers = response_headers(error)
|
|
95
|
+
return if headers.nil? || headers.empty?
|
|
96
|
+
|
|
97
|
+
if (retry_after = headers["retry-after"])
|
|
98
|
+
return retry_after.to_i
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
reset = headers["x-ratelimit-reset"]
|
|
102
|
+
return if reset.nil?
|
|
103
|
+
|
|
104
|
+
[reset.to_i - Time.now.to_i, 0].max
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Octokit raises NoMethodError reading headers off an error with no response
|
|
108
|
+
# attached; treat only that as "can't tell" so the limiter degrades to no
|
|
109
|
+
# wait, while a real NoMethodError elsewhere still surfaces.
|
|
110
|
+
def response_headers(error)
|
|
111
|
+
error.response_headers
|
|
112
|
+
rescue NoMethodError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def as_time(value, owner, name)
|
|
117
|
+
return value if value.is_a?(Time)
|
|
118
|
+
return if value.nil?
|
|
119
|
+
|
|
120
|
+
Time.parse(value)
|
|
121
|
+
rescue ArgumentError
|
|
122
|
+
$stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -9,36 +9,31 @@ module StillActive
|
|
|
9
9
|
|
|
10
10
|
BASE_URI = URI("https://gitlab.com/")
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# archived + last-activity date from a single project call. The project
|
|
13
|
+
# object's last_activity_at matches the latest commit date to the day in
|
|
14
|
+
# practice, so folding the two signals into one call halves the per-gem
|
|
15
|
+
# requests. Returns {} when the project can't be read.
|
|
16
|
+
def repo_signals(owner:, name:)
|
|
17
|
+
return {} if owner.nil? || name.nil?
|
|
14
18
|
|
|
15
19
|
path = "/api/v4/projects/#{encode_project(owner, name)}"
|
|
16
20
|
body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers)
|
|
17
|
-
return if body.nil?
|
|
21
|
+
return {} if body.nil?
|
|
18
22
|
|
|
19
|
-
body["archived"] == true
|
|
23
|
+
{ archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name) }
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
return if owner.nil? || name.nil?
|
|
24
|
-
|
|
25
|
-
path = "/api/v4/projects/#{encode_project(owner, name)}/repository/commits"
|
|
26
|
-
body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers, params: { per_page: 1 })
|
|
27
|
-
return if body.nil? || body.empty?
|
|
26
|
+
private
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
return
|
|
28
|
+
def parse_time(value, owner, name)
|
|
29
|
+
return if value.nil?
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
nil
|
|
37
|
-
end
|
|
31
|
+
Time.parse(value)
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
$stderr.puts("warning: could not parse repo date for #{owner}/#{name}: #{value.inspect}")
|
|
34
|
+
nil
|
|
38
35
|
end
|
|
39
36
|
|
|
40
|
-
private
|
|
41
|
-
|
|
42
37
|
def auth_headers
|
|
43
38
|
token = StillActive.config.gitlab_token
|
|
44
39
|
token ? { "PRIVATE-TOKEN" => token } : {}
|
data/lib/still_active/options.rb
CHANGED
|
@@ -50,7 +50,8 @@ module StillActive
|
|
|
50
50
|
def add_gems_option(opts)
|
|
51
51
|
opts.on("--gems=GEM,GEM2,...", Array, "Gem(s)") do |value|
|
|
52
52
|
options[:provided_gems] = true
|
|
53
|
-
|
|
53
|
+
# Explicitly named gems are direct by definition (the user chose them).
|
|
54
|
+
StillActive.config { |config| config.gems = value.map { |g| { name: g, direct: true } } }
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
|
|
@@ -65,6 +66,8 @@ module StillActive
|
|
|
65
66
|
opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
|
|
66
67
|
opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
|
|
67
68
|
opts.on("--alternatives", "Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems") { StillActive.config { |config| config.alternatives = true } }
|
|
69
|
+
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 } }
|
|
70
|
+
opts.on("--direct-only", "Audit only direct (declared) dependencies, not the full transitive lockfile graph") { StillActive.config { |config| config.direct_only = true } }
|
|
68
71
|
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
72
|
StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
|
|
70
73
|
end
|
|
@@ -91,6 +94,12 @@ module StillActive
|
|
|
91
94
|
opts.on("--gitlab-token=TOKEN", String, "GitLab personal access token for API calls") do |value|
|
|
92
95
|
StillActive.config { |config| config.gitlab_token = value }
|
|
93
96
|
end
|
|
97
|
+
opts.on("--artifactory-token=TOKEN", String, "Artifactory token for private gem registry API calls") do |value|
|
|
98
|
+
StillActive.config { |config| config.artifactory_token = value }
|
|
99
|
+
end
|
|
100
|
+
opts.on("--artifactory-host=HOST", String, "Artifactory host that may receive the global token (e.g. my-org.jfrog.io)") do |value|
|
|
101
|
+
StillActive.config { |config| config.artifactory_host = value }
|
|
102
|
+
end
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
def add_parallelism_options(opts)
|
|
@@ -102,15 +111,15 @@ module StillActive
|
|
|
102
111
|
def add_range_options(opts)
|
|
103
112
|
opts.on(
|
|
104
113
|
"--safe-range-end=YEARS",
|
|
105
|
-
|
|
106
|
-
"maximum years since last
|
|
114
|
+
Float,
|
|
115
|
+
"maximum years since last release considered safe, no warning (default 1.5; fractional allowed)",
|
|
107
116
|
) do |value|
|
|
108
117
|
StillActive.config { |config| config.no_warning_range_end = value }
|
|
109
118
|
end
|
|
110
119
|
opts.on(
|
|
111
120
|
"--warning-range-end=YEARS",
|
|
112
|
-
|
|
113
|
-
"maximum years since last
|
|
121
|
+
Float,
|
|
122
|
+
"maximum years since last release that triggers a warning, beyond this is critical (default 3)",
|
|
114
123
|
) do |value|
|
|
115
124
|
StillActive.config { |config| config.warning_range_end = value }
|
|
116
125
|
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
|
|
@@ -48,8 +48,8 @@ module StillActive
|
|
|
48
48
|
{
|
|
49
49
|
id: "SA002",
|
|
50
50
|
name: "AbandonedGem",
|
|
51
|
-
short: "Gem has had no
|
|
52
|
-
full: "The gem's
|
|
51
|
+
short: "Gem has had no release for over 3 years",
|
|
52
|
+
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
53
|
help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
|
|
54
54
|
level: "warning",
|
|
55
55
|
security_severity: nil,
|