still_active 3.0.0.rc4 → 3.0.0.rc5

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/lib/helpers/activity_helper.rb +3 -3
  4. data/lib/helpers/alternatives_helper.rb +1 -1
  5. data/lib/helpers/ansi_helper.rb +1 -1
  6. data/lib/helpers/bot_context.rb +7 -7
  7. data/lib/helpers/bundler_helper.rb +1 -2
  8. data/lib/helpers/catalog_index.rb +1 -1
  9. data/lib/helpers/constraint_helper.rb +6 -6
  10. data/lib/helpers/cvss_helper.rb +2 -2
  11. data/lib/helpers/cyclonedx_helper.rb +14 -14
  12. data/lib/helpers/diff_markdown_helper.rb +4 -4
  13. data/lib/helpers/dotnet_helper.rb +4 -4
  14. data/lib/helpers/endoflife_helper.rb +3 -3
  15. data/lib/helpers/http_helper.rb +31 -19
  16. data/lib/helpers/lockfile_dependency_parser.rb +2 -2
  17. data/lib/helpers/markdown_escape.rb +1 -1
  18. data/lib/helpers/markdown_helper.rb +7 -7
  19. data/lib/helpers/ruby_advisory_db.rb +1 -1
  20. data/lib/helpers/ruby_helper.rb +3 -3
  21. data/lib/helpers/runtime_ceiling_helper.rb +2 -2
  22. data/lib/helpers/sarif_helper.rb +34 -34
  23. data/lib/helpers/status_helper.rb +1 -1
  24. data/lib/helpers/summary_helper.rb +1 -1
  25. data/lib/helpers/terminal_helper.rb +8 -8
  26. data/lib/helpers/vulnerability_helper.rb +1 -1
  27. data/lib/still_active/artifactory_client.rb +47 -18
  28. data/lib/still_active/cli.rb +38 -38
  29. data/lib/still_active/compact_index_client.rb +117 -0
  30. data/lib/still_active/config.rb +1 -1
  31. data/lib/still_active/config_file.rb +4 -4
  32. data/lib/still_active/deps_dev_client.rb +7 -7
  33. data/lib/still_active/diff.rb +12 -12
  34. data/lib/still_active/ecosystem_lens.rb +5 -5
  35. data/lib/still_active/ecosystems_client.rb +7 -7
  36. data/lib/still_active/forgejo_client.rb +3 -3
  37. data/lib/still_active/github_client.rb +6 -6
  38. data/lib/still_active/gitlab_client.rb +3 -3
  39. data/lib/still_active/options.rb +4 -4
  40. data/lib/still_active/osv_client.rb +8 -8
  41. data/lib/still_active/poison_security_correlator.rb +3 -3
  42. data/lib/still_active/pypi_client.rb +2 -2
  43. data/lib/still_active/repository.rb +4 -4
  44. data/lib/still_active/sarif/rules.rb +20 -20
  45. data/lib/still_active/sbom_reader.rb +8 -8
  46. data/lib/still_active/sbom_workflow.rb +7 -7
  47. data/lib/still_active/source_credentials.rb +39 -0
  48. data/lib/still_active/version.rb +1 -1
  49. data/lib/still_active/workflow.rb +57 -31
  50. data/still_active.gemspec +16 -20
  51. metadata +5 -115
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e74ec3ec40686e3eab640b852270c36278b33c82b54fdc85fe7bbb43ce551455
4
- data.tar.gz: 7304228ba4411b50474cba46dec421c3ce4ff9e53911ea256bef623874d0910c
3
+ metadata.gz: 23f5514e3d64b78d82b0812eece16e13325045841fdd0b253acff4f7c5e31dae
4
+ data.tar.gz: b9d8907b6699755744639cc00e31135cf45991416c222990dc79247ae4c2d88f
5
5
  SHA512:
6
- metadata.gz: 6879718004e7d7a8765c7666f3d68686323873825ddb26240f929ee4b89a3337b0f7880373a34e669a72e545422dbf7a227a8142c9fede87f186e7f40fccfc3a
7
- data.tar.gz: 2ed322c87a224e7e0bf2ea644dca8cdb341349b6985c251a47cf4abc62fcb9cef24d3dd6642d78789190aa7d64e1401814d4c1fa2fac7647746d4fef49657794
6
+ metadata.gz: 4d571b1a3788960da4f6ae9fb04466c00ab6c3c6dcd1bd2051b4d6244aaf8e14248aeb3acc1c4d2179db33d8d18f98e320182ab0cb15635290e1be795c5509f9
7
+ data.tar.gz: 7071c08ca0bb94ff85edee290d6d55d47247093863f5599a8c56c2899871ef80e424daac09142e2c9978e0efbd5020d493c88abadc28c5a3bd5b303ffcea6003
data/CHANGELOG.md CHANGED
@@ -18,6 +18,7 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
18
18
 
19
19
  ### Added
20
20
 
21
+ - **Direct private gem sources are now audited, not skipped.** A gem resolving from a private host still_active has no bespoke client for (a direct Contribsys, Gemstash, Gemfury, or a private mirror) previously reported no version data at all. still_active now reads such a source through the RubyGems compact index (`/info/<gem>`, the protocol every Bundler-compatible host serves, which is how `bundle install` resolves from it), so these gems get real version/latest/libyear signals for the first time, falling back to the same "unqueryable" notice only when a host genuinely doesn't serve it. Credentials come from Bundler's own host-keyed store (`bundle config` / `BUNDLE_<HOST>`), so a source URL from a lockfile only ever receives the credential the user configured for that exact host, and still_active's ambient `--artifactory-token` is never sent to a lockfile-derived host (it stays behind its explicit `--artifactory-host` allowlist). The #43 guard is unchanged: a private gem still never borrows a public name-collision's repository or maintenance data.
21
22
  - **Poison-pill / compatibility-ceiling signal (SA008).** A dormant or archived package that caps one of its runtime dependencies below that dependency's current latest major holds the tree below a ceiling no upstream release will raise, and it grows more constraining over time as the capped dep ships new majors. still_active flags it with a receipt naming the capped dep, the cap, and how many majors behind it holds you, ranked by a severity tier (`note`/`warning`/`critical`, scaling with majors-behind) and enforceable in CI with `--fail-if-poison[=TIER]`. Rendered in terminal, markdown, and SARIF, and computed for `rubygems` (native) and the flat-resolution SBOM ecosystems. Scoped to flat resolution deliberately: npm nests versions and cargo coexists majors, so a below-latest cap there is subtree-local noise, not a tree-wide block, and is suppressed.
22
23
  - **Security "below the fix", the strongest poison case, now cross-ecosystem.** When a dormant/archived package pins a dependency that is *itself* known-vulnerable in the same tree, below the version that patches a HIGH-or-critical advisory, you cannot patch the CVE without replacing the dead capper. still_active leads with these findings (red in terminal/markdown, escalated to `error` in SARIF) and names the advisory and its nearest fix. This case travels to **npm and cargo** at patch precision (their fixes are mostly same-major patch bumps, invisible to a major-level check), via a real node-semver/Cargo matcher and a tree-copy soundness guard that only fires when every resolved copy the constraint governs is vulnerable (a safe copy elsewhere, or a patched copy the cap can reach, clears it). The correlation is whole-tree from data already assembled, no extra fetches.
23
24
  - **Language-runtime ceiling signal (SA009), poison's sibling.** A pinned dependency version can strand you on an end-of-life language runtime (its `required_ruby_version` / `requires_python` forbids a supported one). still_active flags it against the runtime's EOL schedule (from endoflife.date), enforceable with `--fail-if-language-ceiling[=TIER]`, and reconciled against poison so an "upgrade to lift the ceiling" can't contradict a cap that forbids that upgrade. Ruby on the native path; **Python** (`requires_python`) and **.NET/NuGet** on the `--sbom` path. .NET is shaped differently: a package declares a SET of target frameworks, and consuming one is a restore-time wall (NU1202), so the ceiling fires only when every concrete runtime target is end-of-life. A `netstandard` target is the escape hatch (consumable from a current .NET), so its presence never fires, keeping the signal off nearly every modern library; `.NET Framework` (4.x) and `.NET` (Core/5+) are checked against their separate EOL calendars.
@@ -33,6 +34,8 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
33
34
 
34
35
  ### Fixed
35
36
 
37
+ - **A private-source gem is no longer linked to the public rubygems.org page.** `ruby_gems_url` was set to `rubygems.org/gems/<name>` for any gem with versions, including one resolving from a private source, where that URL is a public name collision (for `sidekiq-pro`, the `0.0.3` squat-warning decoy) rather than the gem the user installs. It is now set only for a gem that actually lives on public rubygems.org, the same #43 guard the repository-URL substitution already applies.
38
+ - **An Artifactory-sourced gem is no longer reported `(YANKED)` when its real host is a legacy private registry.** Artifactory's `/api/v1/versions/<gem>.json` merges metadata from a virtual repo's member remotes; when a member (a legacy host like Contribsys) can't answer it, the merge returns only whatever rubygems.org holds for the name, which for `sidekiq-pro` is a `0.0.3` squat-warning placeholder. still_active read that endpoint first, so the installed version was absent from the list and looked yanked. It now reads the RubyGems compact index (`/info/<gem>`, what Bundler resolves through) instead, listing exactly the versions the repo can resolve, with the versions API demoted to dating those versions and AQL kept only as a last resort for hosts serving no compact index (and no longer presenting its cache timestamp as a release date). A side benefit: the compact index's per-version Ruby requirement and dependency constraints let the runtime-ceiling and poison-pill checks work on Artifactory sources for the first time.
36
39
  - **SARIF `tool.driver` version fields are populated and spec-correct.** `version` was null, so a consumer reading `tool.driver.version` (rather than `semanticVersion`) saw nothing, and `semanticVersion` carried the RubyGems version verbatim (`3.0.0.rc4`), which is not valid SemVer 2.0.0 for a prerelease. Now `version` carries the free-form gem version and `semanticVersion` the SemVer form (`3.0.0-rc4`); final releases like `3.0.0` were already valid.
37
40
  - **Cross-ecosystem SARIF no longer reads as a Ruby audit.** The `--sbom` path emitted the native Gemfile audit's Ruby-worded SARIF rule catalog, so a Go/npm/pypi repo's GitHub Code Scanning alerts were titled "Gem source repository is archived", advised `bundle update <gem>`, and referenced `Gemfile.lock`/RubyGems in the rule detail. The catalog now has an ecosystem-neutral flavour for SBOM runs (titles and guidance say "package"/"dependency"), and the native-only SA006 (Ruby runtime EOL), which the cross-ecosystem path can never emit, is dropped from the SBOM catalog rather than advertised in a non-Ruby repo. Per-result finding messages were already neutral; this fixes the rule metadata GitHub renders as the alert title and detail. The native Gemfile SARIF is unchanged (still `gem`-worded, SA006 present).
38
41
  - **A stale pre-release no longer corrupts the up-to-date signal.** A pre-release older than the latest stable (an `8.1.0.rc1` still listed after `8.1.2` shipped, or a decade-old `rc` on a gem long past it) was treated as a live upgrade target: `up_to_date` compares the version in hand against it, so any current version read as up to date and a gem that was actually behind got the "futurist" marker, while the pre-release column filled with superseded rcs. The latest pre-release is now kept only when strictly newer than the latest stable (or when there is no stable release at all, where it is the only signal), so the column, the `up_to_date` field, and the emoji agree. Genuine upcoming pre-releases still surface. On a real bundle this was mislabelling roughly twenty gems.
@@ -19,12 +19,12 @@ module StillActive
19
19
  def last_activity(gem_data)
20
20
  release = [
21
21
  gem_data[:latest_version_release_date],
22
- gem_data[:latest_pre_release_version_release_date],
22
+ gem_data[:latest_pre_release_version_release_date]
23
23
  ].filter_map { parse_time(_1) }.max
24
- return { date: release, kind: :release } if release
24
+ return {date: release, kind: :release} if release
25
25
 
26
26
  commit = parse_time(gem_data[:last_commit_date])
27
- commit ? { date: commit, kind: :commit } : nil
27
+ commit ? {date: commit, kind: :commit} : nil
28
28
  end
29
29
 
30
30
  # Coerce a Time or an iso8601-ish string (the SARIF path may supply either)
@@ -35,7 +35,7 @@ module StillActive
35
35
  def downloads(gem_name)
36
36
  info = Gems.info(gem_name)
37
37
  info && info["downloads"]
38
- rescue StandardError
38
+ rescue
39
39
  nil
40
40
  end
41
41
  end
@@ -19,7 +19,7 @@ module StillActive
19
19
 
20
20
  def pad(text, width)
21
21
  padding = width - visible_length(text)
22
- padding > 0 ? "#{text}#{" " * padding}" : text
22
+ (padding > 0) ? "#{text}#{" " * padding}" : text
23
23
  end
24
24
  end
25
25
  end
@@ -22,7 +22,7 @@ module StillActive
22
22
  # commits ("Update README to mention SARIF"), so we anchor on the v-prefixed
23
23
  # version. False negatives (a no-`v` Renovate config) are acceptable; false
24
24
  # positives are not. The `v` is consumed, so the captured version excludes it.
25
- RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
25
+ RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.-]*)/i
26
26
 
27
27
  # Unanchored variants used only to EXTRACT the bump *after* a bot is already
28
28
  # confirmed (via GITHUB_ACTOR / branch / the anchored subject above). Because
@@ -30,7 +30,7 @@ module StillActive
30
30
  # prefix or scope Dependabot/Renovate is configured with and just find the
31
31
  # "bump X from Y to Z" / "update X to vN" skeleton anywhere in the subject.
32
32
  DEPENDABOT_BUMP = /bump (\S+) from (\S+) to (\S+)/i
33
- RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
33
+ RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.-]*)/i
34
34
 
35
35
  # Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] }
36
36
  # or nil when no bot signal is present. `bumps` is parsed from the head
@@ -39,12 +39,12 @@ module StillActive
39
39
  bot = detect_bot(env: env, head_subject: head_subject)
40
40
  return if bot.nil?
41
41
 
42
- { bot: bot, bumps: bumps_from(bot, head_subject) }
42
+ {bot: bot, bumps: bumps_from(bot, head_subject)}
43
43
  end
44
44
 
45
45
  # A one-line, format-agnostic narrative for the detected context.
46
46
  def summary(context)
47
- label = context[:bot] == "renovate" ? "Renovate" : "Dependabot"
47
+ label = (context[:bot] == "renovate") ? "Renovate" : "Dependabot"
48
48
  bumps = context[:bumps]
49
49
 
50
50
  case bumps.length
@@ -98,9 +98,9 @@ module StillActive
98
98
  return [] if subject.nil?
99
99
 
100
100
  if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP))
101
- [{ gem: match[1], from: match[2], to: match[3] }]
101
+ [{gem: match[1], from: match[2], to: match[3]}]
102
102
  elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP))
103
- [{ gem: match[1], from: nil, to: match[2] }]
103
+ [{gem: match[1], from: nil, to: match[2]}]
104
104
  else
105
105
  []
106
106
  end
@@ -108,7 +108,7 @@ module StillActive
108
108
 
109
109
  def single_bump_summary(label, bump)
110
110
  arrow = bump[:from] ? "#{bump[:from]} → #{bump[:to]}" : "→ #{bump[:to]}"
111
- verb = label == "Renovate" ? "update" : "bump"
111
+ verb = (label == "Renovate") ? "update" : "bump"
112
112
  "#{label} #{verb}: #{bump[:gem]} #{arrow}"
113
113
  end
114
114
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
3
  require_relative "lockfile_dependency_parser"
5
4
 
6
5
  module StillActive
@@ -41,7 +40,7 @@ module StillActive
41
40
  source_type: spec.source_type || :unknown,
42
41
  source_uri: spec.source_uri,
43
42
  direct: is_direct,
44
- dependency_path: is_direct ? nil : paths[spec.name],
43
+ dependency_path: is_direct ? nil : paths[spec.name]
45
44
  }
46
45
  end
47
46
  end
@@ -27,7 +27,7 @@ module StillActive
27
27
  index = build_index(blob)
28
28
  write_cache(index)
29
29
  index
30
- rescue StandardError => e
30
+ rescue => e
31
31
  warn("still_active: could not load Ruby Toolbox catalog for alternatives (#{e.class}); skipping leads")
32
32
  nil
33
33
  end
@@ -45,7 +45,7 @@ module StillActive
45
45
 
46
46
  # => { kind: :permissive|:ceiling|:exact_pin, majors_behind: Integer }
47
47
  def analyze(requirement:, dep_latest:)
48
- return { kind: :permissive, majors_behind: 0 } if requirement.to_s.length > MAX_REQUIREMENT_LENGTH
48
+ return {kind: :permissive, majors_behind: 0} if requirement.to_s.length > MAX_REQUIREMENT_LENGTH
49
49
 
50
50
  # `||` is an OR-range (npm): satisfied by ANY branch, so an unbounded branch
51
51
  # lifts the cap entirely and the effective ceiling is the LOOSEST branch.
@@ -56,11 +56,11 @@ module StillActive
56
56
  ceilings = branches.map { |clauses| branch_ceiling(clauses) }
57
57
  latest_major = major(dep_latest)
58
58
 
59
- return { kind: :permissive, majors_behind: 0 } if ceilings.any?(&:nil?)
59
+ return {kind: :permissive, majors_behind: 0} if ceilings.any?(&:nil?)
60
60
 
61
61
  behind = latest_major ? [latest_major - ceilings.max, 0].max : 0
62
- kind = branches.all? { |clauses| all_exact?(clauses) } ? :exact_pin : :ceiling
63
- { kind: kind, majors_behind: behind }
62
+ kind = (branches.all? { |clauses| all_exact?(clauses) }) ? :exact_pin : :ceiling
63
+ {kind: kind, majors_behind: behind}
64
64
  end
65
65
 
66
66
  # Severity tiers for a compatibility ceiling, worst-last (mirrors
@@ -150,7 +150,7 @@ module StillActive
150
150
  requirement: dep[:requirements],
151
151
  dep_latest: dep_latest,
152
152
  majors_behind: result[:majors_behind],
153
- kind: result[:kind],
153
+ kind: result[:kind]
154
154
  }
155
155
  end
156
156
  end
@@ -161,7 +161,7 @@ module StillActive
161
161
  # by the terminal and markdown renderers so the selection can't drift.
162
162
  def top_findings(findings, limit: 3)
163
163
  ranked = findings.sort_by { |f| [-f[:majors_behind], f[:dependency].to_s] }
164
- { shown: ranked.first(limit), total: findings.length }
164
+ {shown: ranked.first(limit), total: findings.length}
165
165
  end
166
166
 
167
167
  private
@@ -27,7 +27,7 @@ module StillActive
27
27
 
28
28
  cvss = CvssSuite.new(vector.to_s)
29
29
  cvss.valid? ? cvss.overall_score : nil
30
- rescue StandardError => e
30
+ rescue => e
31
31
  # We only reach here with cvss-suite loaded (available?), so a raise is the
32
32
  # installed gem failing this call site -- an incompatible version, a renamed
33
33
  # `overall_score` -- not a bad vector, which cvss-suite reports via valid?.
@@ -57,7 +57,7 @@ module StillActive
57
57
  return if @warned
58
58
 
59
59
  @warned = true
60
- $stderr.puts("warning: cvss-suite scoring failed: #{error.class} (#{error.message}); CVSS numbers unavailable this run, check your cvss-suite version")
60
+ warn("warning: cvss-suite scoring failed: #{error.class} (#{error.message}); CVSS numbers unavailable this run, check your cvss-suite version")
61
61
  end
62
62
  end
63
63
  end
@@ -34,9 +34,9 @@ module StillActive
34
34
  "version" => 1,
35
35
  "metadata" => {
36
36
  "timestamp" => now.iso8601,
37
- "tools" => [{ "vendor" => "SeanLF", "name" => "still_active", "version" => tool_version }],
37
+ "tools" => [{"vendor" => "SeanLF", "name" => "still_active", "version" => tool_version}]
38
38
  },
39
- "components" => components,
39
+ "components" => components
40
40
  }
41
41
  document["vulnerabilities"] = vulnerabilities unless vulnerabilities.empty?
42
42
  JSON.pretty_generate(document)
@@ -52,13 +52,13 @@ module StillActive
52
52
 
53
53
  def gem_component(name, data)
54
54
  version = data[:version_used]
55
- component = { "type" => "library", "name" => name }
55
+ component = {"type" => "library", "name" => name}
56
56
  component["version"] = version if version
57
57
  component["bom-ref"] = bom_ref(name, data)
58
58
  component["purl"] = purl(name, version, data[:source_type]) if version
59
59
  component["licenses"] = licenses(data[:license]) if data[:license]
60
60
  if data[:repository_url]
61
- component["externalReferences"] = [{ "type" => "vcs", "url" => data[:repository_url] }]
61
+ component["externalReferences"] = [{"type" => "vcs", "url" => data[:repository_url]}]
62
62
  end
63
63
  properties = gem_properties(data)
64
64
  component["properties"] = properties unless properties.empty?
@@ -78,7 +78,7 @@ module StillActive
78
78
  # false-matching a public gem of the same name; git/rubygems-sourced gems
79
79
  # get pkg:gem so a fork still matches the upstream gem's advisories.
80
80
  def purl(name, version, source_type)
81
- type = source_type == :path ? "generic" : "gem"
81
+ type = (source_type == :path) ? "generic" : "gem"
82
82
  "pkg:#{type}/#{name}@#{version}"
83
83
  end
84
84
 
@@ -86,7 +86,7 @@ module StillActive
86
86
  # display; CycloneDX's license.id must be a single SPDX id, so split back
87
87
  # into one entry per license rather than emitting an invalid joined id.
88
88
  def licenses(license)
89
- license.split(", ").map { |id| { "license" => { "id" => id } } }
89
+ license.split(", ").map { |id| {"license" => {"id" => id}} }
90
90
  end
91
91
 
92
92
  def gem_properties(data)
@@ -95,8 +95,8 @@ module StillActive
95
95
  "still_active:scorecard_score" => data[:scorecard_score]&.to_s,
96
96
  "still_active:libyear" => data[:libyear]&.to_s,
97
97
  "still_active:last_commit_date" => iso8601(data[:last_commit_date]),
98
- "still_active:version_yanked" => boolean_property(data[:version_yanked]),
99
- }.filter_map { |name, value| { "name" => name, "value" => value } unless value.nil? }
98
+ "still_active:version_yanked" => boolean_property(data[:version_yanked])
99
+ }.filter_map { |name, value| {"name" => name, "value" => value} unless value.nil? }
100
100
  end
101
101
 
102
102
  # The Ruby interpreter. CycloneDX's "platform" type fits semantically, but
@@ -116,9 +116,9 @@ module StillActive
116
116
  "purl" => "pkg:generic/ruby@#{version}",
117
117
  "cpe" => "cpe:2.3:a:ruby-lang:ruby:#{version}:*:*:*:*:*:*:*",
118
118
  "properties" => [
119
- { "name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol]) },
120
- { "name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s },
121
- ].reject { |p| p["value"].nil? },
119
+ {"name" => "still_active:eol", "value" => boolean_property(ruby_info[:eol])},
120
+ {"name" => "still_active:libyear", "value" => ruby_info[:libyear]&.to_s}
121
+ ].reject { |p| p["value"].nil? }
122
122
  }
123
123
  end
124
124
 
@@ -133,9 +133,9 @@ module StillActive
133
133
  entry = {
134
134
  "bom-ref" => "#{advisory[:id]}:#{component_ref}",
135
135
  "id" => advisory[:id],
136
- "affects" => [{ "ref" => component_ref }],
136
+ "affects" => [{"ref" => component_ref}]
137
137
  }
138
- entry["source"] = { "name" => advisory[:source] } if advisory[:source]
138
+ entry["source"] = {"name" => advisory[:source]} if advisory[:source]
139
139
  advisory_rating = rating(advisory)
140
140
  entry["ratings"] = [advisory_rating] if advisory_rating
141
141
  entry
@@ -148,7 +148,7 @@ module StillActive
148
148
  score = VulnerabilityHelper.effective_score(advisory)
149
149
  return if score.nil?
150
150
 
151
- rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => rating_method(advisory) }
151
+ rating = {"score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => rating_method(advisory)}
152
152
  vector = advisory[:cvss3_vector] || advisory[:cvss_vector]
153
153
  rating["vector"] = vector if vector
154
154
  rating
@@ -16,7 +16,7 @@ module StillActive
16
16
  fresher: "fresher",
17
17
  older_relative: "older relative to latest",
18
18
  neutral: nil,
19
- unknown: nil,
19
+ unknown: nil
20
20
  }.freeze
21
21
 
22
22
  def render(diff, accepted: [])
@@ -31,7 +31,7 @@ module StillActive
31
31
  removed_section(diff.removed),
32
32
  bumps_section(diff.bumped),
33
33
  signal_changes_section(diff.signal_changes),
34
- ruby_section(diff.ruby),
34
+ ruby_section(diff.ruby)
35
35
  ].reject(&:empty?)
36
36
 
37
37
  "#{sections.join("\n")}\n"
@@ -45,7 +45,7 @@ module StillActive
45
45
  ["added", diff.added.size],
46
46
  ["removed", diff.removed.size],
47
47
  ["bumped", diff.bumped.size],
48
- ["signal-changes", diff.signal_changes.size],
48
+ ["signal-changes", diff.signal_changes.size]
49
49
  ]
50
50
  parts << ["accepted", accepted.size] unless accepted.empty?
51
51
  parts.map { |label, n| "#{n} #{label}" }.join(" · ")
@@ -124,7 +124,7 @@ module StillActive
124
124
  data["scorecard_score"] && "OpenSSF #{data["scorecard_score"]}",
125
125
  (data["vulnerability_count"].to_i.positive? ? "#{data["vulnerability_count"]} vulns" : nil),
126
126
  (data["archived"] ? "archived" : nil),
127
- data["libyear"] && "#{data["libyear"]}y behind",
127
+ data["libyear"] && "#{data["libyear"]}y behind"
128
128
  ].compact
129
129
  "#{MarkdownEscape.code_span(added.name)} (#{MarkdownEscape.inline(bits.join(", "))})"
130
130
  end
@@ -99,9 +99,9 @@ module StillActive
99
99
  finding = {
100
100
  requirement: runtimes.map { |r| r[:moniker] }.uniq.sort.join(", "),
101
101
  eol_forced: true,
102
- runtime: ceiling[:family] == :dotnetfx ? ".NET Framework" : ".NET",
102
+ runtime: (ceiling[:family] == :dotnetfx) ? ".NET Framework" : ".NET",
103
103
  ceiling_version: ceiling[:version].to_s,
104
- ceiling_eol_date: ceiling[:cycle][:eol_date],
104
+ ceiling_eol_date: ceiling[:cycle][:eol_date]
105
105
  }
106
106
  finding.merge(severity: ConstraintHelper.constraint_severity(finding))
107
107
  end
@@ -122,14 +122,14 @@ module StillActive
122
122
  def build(family, version)
123
123
  return unless version && Gem::Version.correct?(version)
124
124
 
125
- { family: family, version: Gem::Version.new(version) }
125
+ {family: family, version: Gem::Version.new(version)}
126
126
  end
127
127
 
128
128
  # The endoflife cycle for a classified target, or nil when the feed is down or
129
129
  # the cycle isn't tracked (an ancient net20 predating the feed) -- unknown, not
130
130
  # assumed EOL.
131
131
  def cycle_for(classification, dotnet:, dotnetfx:)
132
- window = classification[:family] == :dotnetfx ? dotnetfx : dotnet
132
+ window = (classification[:family] == :dotnetfx) ? dotnetfx : dotnet
133
133
  return if window.nil?
134
134
 
135
135
  # Match on the release segment, ignoring any prerelease suffix: endoflife
@@ -47,13 +47,13 @@ module StillActive
47
47
  # nil (the note is then suppressed) rather than returning nil, which would
48
48
  # silently disable criticals too.
49
49
  latest = cycles.first["latest"]
50
- latest_stable = latest && Gem::Version.correct?(latest) ? Gem::Version.new(latest) : nil
50
+ latest_stable = (latest && Gem::Version.correct?(latest)) ? Gem::Version.new(latest) : nil
51
51
 
52
52
  {
53
53
  oldest_supported: supported.map { |cycle| cycle[:version] }.min,
54
54
  latest_stable: latest_stable,
55
55
  latest_stable_fresh: !latest_stable.nil? && latest_stable_fresh?(cycles.first),
56
- cycles: normalized,
56
+ cycles: normalized
57
57
  }
58
58
  end
59
59
 
@@ -100,7 +100,7 @@ module StillActive
100
100
  {
101
101
  version: Gem::Version.new(version),
102
102
  eol: eol_reached?(cycle["eol"]) == true,
103
- eol_date: parse_eol(cycle["eol"]),
103
+ eol_date: parse_eol(cycle["eol"])
104
104
  }
105
105
  end
106
106
 
@@ -19,7 +19,7 @@ module StillActive
19
19
  SocketError,
20
20
  SystemCallError,
21
21
  OpenSSL::SSL::SSLError,
22
- EOFError,
22
+ EOFError
23
23
  ].freeze
24
24
  MAX_REDIRECTS = 3
25
25
  # Ceiling on a single response body. These are metadata endpoints (version
@@ -29,6 +29,8 @@ module StillActive
29
29
  # multi-GB body and OOM the process. 16 MiB leaves generous headroom for a
30
30
  # gem with thousands of versions while bounding worst-case memory.
31
31
  MAX_BODY_BYTES = 16 * 1024 * 1024
32
+ JSON_PARSER = ->(body) { JSON.parse(body) }
33
+ IDENTITY = ->(body) { body }
32
34
 
33
35
  extend self
34
36
 
@@ -40,6 +42,16 @@ module StillActive
40
42
  request_json(uri, headers) { |target| Net::HTTP::Get.new(target) }
41
43
  end
42
44
 
45
+ # As get_json, but for endpoints that answer in plain text (the RubyGems
46
+ # compact index). Shares the redirect, auth-scoping and body-cap handling;
47
+ # only the parse step differs.
48
+ def get_text(base_uri, path, headers: {})
49
+ uri = base_uri.dup
50
+ uri.path = path
51
+
52
+ request_json(uri, headers, parse: IDENTITY) { |target| Net::HTTP::Get.new(target) }
53
+ end
54
+
43
55
  def post_json(base_uri, path, body:, headers: {})
44
56
  uri = base_uri.dup
45
57
  uri.path = path
@@ -61,10 +73,10 @@ module StillActive
61
73
  end
62
74
 
63
75
  # Runs the request, following up to MAX_REDIRECTS trusted-host redirects,
64
- # and returns the parsed JSON (or nil). The block is yielded each URI and
65
- # returns the request object, so GET and POST share the redirect/auth/cap
66
- # logic.
67
- def request_json(uri, headers)
76
+ # and returns the parsed body (or nil), where `parse` decides JSON vs text.
77
+ # The block is yielded each URI and returns the request object, so GET and
78
+ # POST share the redirect/auth/cap logic.
79
+ def request_json(uri, headers, parse: JSON_PARSER)
68
80
  MAX_REDIRECTS.times do
69
81
  http = Net::HTTP.new(uri.host, uri.port)
70
82
  http.use_ssl = true
@@ -74,7 +86,7 @@ module StillActive
74
86
  request = yield(uri)
75
87
  headers.each { |key, value| request[key] = value }
76
88
 
77
- outcome, payload = perform(http, request, uri)
89
+ outcome, payload = perform(http, request, uri, parse)
78
90
  case outcome
79
91
  when :done
80
92
  return payload
@@ -83,23 +95,23 @@ module StillActive
83
95
  when :redirect
84
96
  location = payload["Location"]
85
97
  if location.nil? || location.empty?
86
- $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
98
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
87
99
  return
88
100
  end
89
101
 
90
102
  redirect_uri = uri + location
91
103
  unless TRUSTED_HOSTS.include?(redirect_uri.host)
92
- $stderr.puts("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
104
+ warn("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
93
105
  return
94
106
  end
95
107
  # We dial every request over TLS (use_ssl = true). A redirect that
96
108
  # downgrades to http is either a misconfiguration or a downgrade
97
109
  # attempt; refuse it rather than silently dialing http-over-TLS.
98
110
  unless redirect_uri.scheme == "https"
99
- $stderr.puts("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
111
+ warn("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
100
112
  return
101
113
  end
102
- $stderr.puts("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
114
+ warn("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
103
115
  # Auth is scoped to an origin (scheme + host + port), not just a host:
104
116
  # a different port is a different service and must not inherit the token.
105
117
  headers = {} unless same_origin?(uri, redirect_uri)
@@ -107,16 +119,16 @@ module StillActive
107
119
  end
108
120
  end
109
121
 
110
- $stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
122
+ warn("warning: #{uri.host}#{uri.path} too many redirects")
111
123
  nil
112
124
  rescue *TRANSPORT_ERRORS => e
113
- $stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
125
+ warn("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
114
126
  nil
115
127
  rescue JSON::ParserError => e
116
- $stderr.puts("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
128
+ warn("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
117
129
  nil
118
130
  rescue URI::InvalidURIError => e
119
- $stderr.puts("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
131
+ warn("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
120
132
  nil
121
133
  end
122
134
 
@@ -124,23 +136,23 @@ module StillActive
124
136
  # cap rather than buffered whole. Returns one of:
125
137
  # [:redirect, response] a 3xx, for the caller to follow
126
138
  # [:stop, nil] non-success (warns unless 404), or body over cap
127
- # [:done, parsed] a 2xx with parsed JSON
139
+ # [:done, parsed] a 2xx with the parsed body (JSON or text)
128
140
  # Redirect and non-success bodies are never read: returning from the block
129
141
  # unwinds through Net::HTTP, which closes the connection without draining
130
142
  # the body, so a huge error/redirect body can't OOM us either.
131
- def perform(http, request, uri)
143
+ def perform(http, request, uri, parse)
132
144
  http.request(request) do |response|
133
145
  return [:redirect, response] if response.is_a?(Net::HTTPRedirection)
134
146
 
135
147
  unless response.is_a?(Net::HTTPSuccess)
136
- $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
148
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
137
149
  return [:stop, nil]
138
150
  end
139
151
 
140
152
  body = read_capped_body(response, uri)
141
153
  return [:stop, nil] if body.nil?
142
154
 
143
- return [:done, JSON.parse(body)]
155
+ return [:done, parse.call(body)]
144
156
  end
145
157
  end
146
158
 
@@ -151,7 +163,7 @@ module StillActive
151
163
  response.read_body do |chunk|
152
164
  body << chunk
153
165
  if body.bytesize > MAX_BODY_BYTES
154
- $stderr.puts("warning: #{uri.host}#{uri.path} response exceeded #{MAX_BODY_BYTES} bytes, skipping")
166
+ warn("warning: #{uri.host}#{uri.path} response exceeded #{MAX_BODY_BYTES} bytes, skipping")
155
167
  return nil
156
168
  end
157
169
  end
@@ -25,7 +25,7 @@ module StillActive
25
25
  # Lockfile source blocks and the source_type each maps to. PLUGIN SOURCE is
26
26
  # recognized as a block (so its lines are consumed as inert data, not
27
27
  # mis-read as specs) but yields no auditable gems.
28
- SOURCE_TYPES = { "GEM" => :rubygems, "GIT" => :git, "PATH" => :path }.freeze
28
+ SOURCE_TYPES = {"GEM" => :rubygems, "GIT" => :git, "PATH" => :path}.freeze
29
29
  PLUGIN_SOURCE = "PLUGIN SOURCE"
30
30
 
31
31
  # A section header sits at column 0; Bundler emits them in SCREAMING form.
@@ -93,7 +93,7 @@ module StillActive
93
93
  end
94
94
  end
95
95
 
96
- { specs: specs, direct: direct, plugin_source?: plugin_source }
96
+ {specs: specs, direct: direct, plugin_source?: plugin_source}
97
97
  end
98
98
  end
99
99
  end
@@ -51,7 +51,7 @@ module StillActive
51
51
  return "`#{content}`" unless content.include?("`")
52
52
 
53
53
  fence = "`" * (content.scan(/`+/).map(&:length).max + 1)
54
- pad = content.start_with?("`") || content.end_with?("`") ? " " : ""
54
+ pad = (content.start_with?("`") || content.end_with?("`")) ? " " : ""
55
55
  "#{fence}#{pad}#{content}#{pad}#{fence}"
56
56
  end
57
57
  end
@@ -51,20 +51,20 @@ module StillActive
51
51
  version_with_date(
52
52
  text: data[:version_used],
53
53
  url: version_url(ruby_gems_url, data[:version_used]),
54
- date: data[:version_used_release_date],
54
+ date: data[:version_used_release_date]
55
55
  )
56
56
  end
57
57
 
58
58
  formatted_latest_version = version_with_date(
59
59
  text: data[:latest_version],
60
60
  url: version_url(ruby_gems_url, data[:latest_version]),
61
- date: data[:latest_version_release_date],
61
+ date: data[:latest_version_release_date]
62
62
  )
63
63
 
64
64
  formatted_latest_pre_release = version_with_date(
65
65
  text: data[:latest_pre_release_version],
66
66
  url: version_url(ruby_gems_url, data[:latest_pre_release_version]),
67
- date: data[:latest_pre_release_version_release_date],
67
+ date: data[:latest_pre_release_version_release_date]
68
68
  )
69
69
 
70
70
  # Only link when there is a date: markdown_url with a nil date + a present
@@ -86,7 +86,7 @@ module StillActive
86
86
  formatted_latest_pre_release || unsure,
87
87
  formatted_last_commit || unsure,
88
88
  format_libyear(data[:libyear]),
89
- format_license(data[:license]),
89
+ format_license(data[:license])
90
90
  ]
91
91
 
92
92
  "| #{cells.join(" | ")} |"
@@ -136,7 +136,7 @@ module StillActive
136
136
  poison_rank(data), # below-the-fix leads, then security-relevant, then the rest
137
137
  -ConstraintHelper::SEVERITY.index(data[:poison_severity] || :note),
138
138
  -Array(data[:constraints]).map { |c| c[:majors_behind].to_i }.max,
139
- name.to_s,
139
+ name.to_s
140
140
  ]
141
141
  end
142
142
  ranked.each do |name, data|
@@ -202,7 +202,7 @@ module StillActive
202
202
  else
203
203
  "requires #{runtime} #{req}, no #{runtime} #{ceiling[:latest_stable]} support yet"
204
204
  end
205
- fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
205
+ fix = (ceiling[:fixed_by_upgrade] && data[:latest_version]) ? "; upgrade to #{data[:latest_version]} to lift it" : ""
206
206
  "#{base}#{fix}"
207
207
  end
208
208
 
@@ -226,7 +226,7 @@ module StillActive
226
226
  i.zero? ? "#{dep} #{req} (#{behind} major#{"s" unless behind == 1} behind, latest #{poison_major(finding[:dep_latest])})" : "#{dep} #{req} (#{behind})"
227
227
  end
228
228
  joined = parts.join(", ")
229
- top[:total] > 1 ? "#{joined} — #{top[:total]} total" : joined
229
+ (top[:total] > 1) ? "#{joined} — #{top[:total]} total" : joined
230
230
  end
231
231
 
232
232
  # "8.0.1" -> "8.x": the cap is a major-level gap.
@@ -72,7 +72,7 @@ module StillActive
72
72
  # No safe version a consumer can upgrade TO: the correlation bundler-audit
73
73
  # + `bundle outdated` can't produce alone. A factual read of the DB.
74
74
  no_fix_available: no_forward_fix?(advisory),
75
- source: "ruby-advisory-db",
75
+ source: "ruby-advisory-db"
76
76
  }
77
77
  end
78
78