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.
@@ -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"]&.map { |a| a["id"] } || [],
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
- # Extracts "host/owner/repo" from the SOURCE_REPO link URL.
62
- # URLs may have trailing slashes or extra path segments (e.g. /tree/v1.0).
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
- path = url.delete_prefix("https://").delete_prefix("http://")
68
- segments = path.split("/")
69
- segments[0..2].join("/") if segments.length >= 3
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)
@@ -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
- return if SUPPORTED_SCHEMA_VERSIONS.include?(version)
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
- raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}"
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
- def archived(owner:, name:)
13
- return if owner.nil? || name.nil?
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
- def last_commit_date(owner:, name:)
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
- date = body.first["committed_date"]
30
- return unless date
28
+ def parse_time(value, owner, name)
29
+ return if value.nil?
31
30
 
32
- begin
33
- Time.parse(date)
34
- rescue ArgumentError
35
- $stderr.puts("warning: could not parse commit date for #{owner}/#{name}: #{date.inspect}")
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 } : {}
@@ -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
- StillActive.config { |config| config.gems = value.map { |g| { name: g } } }
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
- Integer,
106
- "maximum years since last activity considered safe (no warning)",
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
- Integer,
113
- "maximum years since last activity that triggers a warning (beyond this is critical)",
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
- REPO_REGEX = %r{(https?://(?:www\.)?(github|gitlab)\.com/([\w.\-]+)/([\w.\-]+))}i
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
- url = match[1].delete_suffix(".git")
20
- name = match[4].delete_suffix(".git")
27
+ clean_url = match[:url].delete_suffix(".git")
28
+ name = match[:name].delete_suffix(".git")
21
29
 
22
- { url: url, source: match[2].to_sym, owner: match[3], name: name }
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 commits for over 2 years",
52
- full: "The gem's source repository shows no commit activity for over 2 years. Not formally archived, but a strong dormancy signal.",
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,