still_active 1.3.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7aee27885f6f98048897a021e6fca1f60f991a6c7812d125c5ed61fa497a691
4
- data.tar.gz: 75c494745c87bb74f9e5c770cb374d98746727c103eb7393b1e838c9495daefe
3
+ metadata.gz: be6f4e86c6afb3564385acfaf5733cecc38845c300a8a2ef624d0b267cc93a94
4
+ data.tar.gz: ec381b31257189f8f171bb5bdc4f318dfd0edd03cba957dd58b3400935ab5898
5
5
  SHA512:
6
- metadata.gz: 6864d20e743742e1da0a8f5dc8353444ece8593fa541733414404297b06c370e6ef620528d0303b01348548d15a5f619f1d98a8d48dd0405200823a97c46665d
7
- data.tar.gz: 9bf1c0e5c85375bf23ad38704b4c45166793a261967749e6b434b07be4984b157ad5da166cfc8670618aacae08ebdc152f9e2a020985907d240abe789b5d45d9
6
+ metadata.gz: dcb580e83cedb482f05fe6a2cfebc91190050339dfb4a0986075d4cc28c6f9788d96efa86a66a7a28c22d248e6b15305d78f2e7f7bcd09c82ad0e9fbbed5fd0f
7
+ data.tar.gz: df4d1a5980ddb05e8cdb5c781ee72941ba4477fa71c101db6b03e089d0ffd2c8571803590467d8796841214d2fda6f9c40865ea7c16c58fcb89d632efd825cdc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-05-22
4
+
5
+ ### Added
6
+
7
+ - `--sarif[=PATH]` emits SARIF 2.1.0 for GitHub Code Scanning. Findings appear in the Security tab and as inline annotations on `Gemfile.lock` in pull requests. Default path: `still_active.sarif.json` (pair with `github/codeql-action/upload-sarif@v3`). `--sarif=-` writes to stdout. Rule reference (SA001–SA007) in `docs/rules.md`. Stable `partialFingerprints` (rule + gem + advisory) keep alert IDs constant across `bundle update`s.
8
+ - `--baseline=FILE` compares the current run against a baseline still_active JSON snapshot and emits a markdown delta report. Designed for PR review: surfaces regressions (new vulns, newly-archived deps, scorecard drops crossing OSSF's 7.0 threshold, libyear growth on unchanged versions, Ruby newly EOL). Exits 1 if any regression is detected, 2 on a malformed or unsupported baseline.
9
+ - GitHub token cascade: discovers token from `--github-oauth-token`, `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token` in that order. GitLab cascade mirrors it (`--gitlab-token`, `GITLAB_TOKEN`, `glab auth status --hostname=gitlab.com --show-token`). Eliminates the "why am I rate limited?" friction on local runs when `gh`/`glab` are already authed.
10
+ - JSON output gains `schema_version`, `tool`, and `generated_at` keys on top of the existing `{gems, ruby}` envelope (shipped since 1.1). Purely additive — existing consumers reading `payload["gems"][name]` continue to work. See `docs/schema.md`.
11
+
3
12
  ## [1.3.0] - 2026-04-08
4
13
 
5
14
  ### Changed
data/README.md CHANGED
@@ -26,25 +26,23 @@ Ruby 4.0.1 (latest)
26
26
 
27
27
  ## Why `still_active`?
28
28
 
29
- Most dependency tools answer one question. `still_active` answers all of them at once:
30
-
31
- | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
32
- | ---------------------------- | ----------------- | --------------- | ----------------- | ---------------------------- |
33
- | Outdated versions | Yes | - | Yes | **Yes** |
34
- | Known vulnerabilities (CVEs) | - | Yes | - | **Yes** (with severity) |
35
- | OpenSSF Scorecard | - | - | - | **Yes** |
36
- | Last commit activity | - | - | - | **Yes** |
37
- | Libyear drift | - | - | Yes | **Yes** |
38
- | Archived repo detection | - | - | - | **Yes** |
39
- | Yanked version detection | - | - | - | **Yes** |
40
- | Ruby version freshness | - | - | - | **Yes** (EOL + libyear) |
41
- | Git/path/GH Packages sources | - | - | - | **Yes** |
42
- | GitLab support | - | - | - | **Yes** |
43
- | CI quality gates | - | Exit code | - | **Yes** (5 modes) |
44
- | Multiple output formats | - | - | - | **Terminal, JSON, Markdown** |
45
- | Single command | Yes | Yes | Yes | **Yes** |
46
-
47
- `still_active` tells you whether a dependency is outdated, insecure, _and_ abandoned -- not just one of the three.
29
+ `still_active` is **complementary to** -- not a replacement for -- the established Ruby tooling. `bundle outdated`, `bundler-audit`, and `libyear-bundler` are purpose-built and battle-tested at what they do. `still_active` answers a different question: **is anyone still maintaining this gem?** -- and folds in the version/CVE/libyear signals so you get one report instead of three.
30
+
31
+ | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
32
+ | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
33
+ | Outdated versions | Yes | - | Yes | Yes |
34
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev) |
35
+ | Libyear drift | - | - | Yes | Yes |
36
+ | **Last commit activity** | - | - | - | **Yes** |
37
+ | **Archived repo detection** | - | - | - | **Yes** |
38
+ | **OpenSSF Scorecard** | - | - | - | **Yes** |
39
+ | **Yanked version detection** | - | - | - | **Yes** |
40
+ | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
41
+ | GitLab support | - | - | - | Yes |
42
+ | CI quality gates | - | Exit code | - | Yes (4 flags) |
43
+ | Output formats | Text | Text | Text | Terminal, JSON, Markdown |
44
+
45
+ The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` and `still_active` use **different data sources** (`ruby-advisory-db` vs `deps.dev`), so coverage isn't identical. If you care about CVEs in CI, keep running `bundler-audit` alongside `still_active`.
48
46
 
49
47
  ## Installation
50
48
 
@@ -75,7 +73,16 @@ still_active --markdown
75
73
 
76
74
  ### Authentication
77
75
 
78
- Tokens are read from `GITHUB_TOKEN` and `GITLAB_TOKEN` environment variables by default. Without a GitHub token you will most certainly get rate limited. The GitLab token is optional for public repos but required for private ones. CLI flags override the env vars.
76
+ `still_active` discovers a GitHub token in this order:
77
+
78
+ 1. `--github-oauth-token=TOKEN` CLI flag
79
+ 2. `GITHUB_TOKEN` environment variable (CI convention)
80
+ 3. `GH_TOKEN` environment variable (`gh` CLI convention)
81
+ 4. `gh auth token` (if `gh` is installed and authenticated)
82
+
83
+ Without a token, GitHub API calls are unauthenticated and rate-limited to 60 requests/hour — you will hit the limit on anything beyond a handful of gems. With a token the limit is 5000 requests/hour.
84
+
85
+ GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
79
86
 
80
87
  ### CLI options
81
88
 
@@ -89,6 +96,8 @@ Usage: still_active [options]
89
96
  --terminal Coloured terminal output (default in TTY)
90
97
  --markdown Markdown table output
91
98
  --json JSON output (default when piped)
99
+ --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
100
+ --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
92
101
  --github-oauth-token=TOKEN GitHub OAuth token to make API calls
93
102
  --gitlab-token=TOKEN GitLab personal access token for API calls
94
103
  --simultaneous-requests=QTY Number of simultaneous requests made
@@ -173,6 +182,55 @@ still_active --markdown
173
182
 
174
183
  **Ruby 4.0.1** (latest) ✅
175
184
 
185
+ ### SARIF output (GitHub Code Scanning)
186
+
187
+ Emit findings as SARIF 2.1.0 — they show up in the GitHub Security tab and as inline annotations on `Gemfile.lock` in pull requests.
188
+
189
+ ```bash
190
+ still_active --sarif # writes still_active.sarif.json
191
+ still_active --sarif=path/to/out.sarif.json
192
+ still_active --sarif=- # stdout
193
+ ```
194
+
195
+ Wire it up in a workflow with `github/codeql-action/upload-sarif`:
196
+
197
+ ```yaml
198
+ permissions:
199
+ contents: read
200
+ security-events: write # required for SARIF upload
201
+
202
+ jobs:
203
+ audit:
204
+ runs-on: ubuntu-latest
205
+ steps:
206
+ - uses: actions/checkout@v4
207
+ - uses: ruby/setup-ruby@v1
208
+ with:
209
+ ruby-version: "3.4"
210
+ bundler-cache: true
211
+ - run: bundle exec still_active --sarif
212
+ - uses: github/codeql-action/upload-sarif@v3
213
+ if: always()
214
+ with:
215
+ sarif_file: still_active.sarif.json
216
+ ```
217
+
218
+ Rule reference (SA001–SA007) and how to suppress: see [`docs/rules.md`](docs/rules.md).
219
+
220
+ ### Baseline diff (PR review)
221
+
222
+ `--baseline=FILE` compares the current run against a previously captured JSON snapshot and emits a markdown delta report. Designed for the PR question reviewers actually ask: **what got worse?**
223
+
224
+ ```bash
225
+ # Locally — capture from main, compare to your branch
226
+ git checkout main && still_active --json > /tmp/main.json
227
+ git checkout my-branch && still_active --baseline=/tmp/main.json
228
+ ```
229
+
230
+ In CI, capture a baseline on main and compare on PR branches. Exits 1 if any regression is detected (new vulns, newly-archived deps, scorecard drops crossing 7.0, libyear growth on unchanged versions, Ruby newly EOL, etc.).
231
+
232
+ The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set.
233
+
176
234
  ### CI quality gating
177
235
 
178
236
  Use exit-code flags to fail CI pipelines based on dependency status:
@@ -223,7 +281,9 @@ Activity is determined by the most recent signal across last commit date, latest
223
281
 
224
282
  ## Development
225
283
 
226
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
284
+ After checking out the repo, run `bin/setup` to install dependencies and wire git hooks. Then run `rake` to run the full lint + test suite (`rake spec` for just tests, `rake rubocop` for just lint). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
285
+
286
+ A pre-push hook runs `rake` automatically before each `git push`, so cross-file rubocop rules don't escape to CI. Skip with `git push --no-verify` if you really need to.
227
287
 
228
288
  To install this gem onto your local machine, run `bundle exec rake install`. New versions are published automatically to [rubygems.org](https://rubygems.org) when a GitHub Release is created (via trusted publishing).
229
289
 
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Renders a StillActive::Diff::Result as PR-comment-friendly markdown.
5
+ # Section taxonomy mirrors GitHub's dependency-review-action so reviewers
6
+ # already know where to look: Regressions / Added / Removed / Bumps /
7
+ # Signal changes / Ruby. Empty sections are skipped.
8
+ module DiffMarkdownHelper
9
+ extend self
10
+
11
+ BUMP_KIND_LABELS = {
12
+ closed_vulns: "closed vulns",
13
+ introduced_vulns: "INTRODUCED vulns",
14
+ fresher: "fresher",
15
+ older_relative: "older relative to latest",
16
+ neutral: nil,
17
+ unknown: nil,
18
+ }.freeze
19
+
20
+ def render(diff)
21
+ sections = [
22
+ "## still_active diff",
23
+ "",
24
+ summary_line(diff),
25
+ "",
26
+ regressions_section(diff.regressions),
27
+ added_section(diff.added),
28
+ removed_section(diff.removed),
29
+ bumps_section(diff.bumped),
30
+ signal_changes_section(diff.signal_changes),
31
+ ruby_section(diff.ruby),
32
+ ].reject(&:empty?)
33
+
34
+ "#{sections.join("\n")}\n"
35
+ end
36
+
37
+ private
38
+
39
+ def summary_line(diff)
40
+ [
41
+ ["regressions", diff.regressions.size],
42
+ ["added", diff.added.size],
43
+ ["removed", diff.removed.size],
44
+ ["bumped", diff.bumped.size],
45
+ ["signal-changes", diff.signal_changes.size],
46
+ ].map { |label, n| "#{n} #{label}" }.join(" · ")
47
+ end
48
+
49
+ def regressions_section(regressions)
50
+ return "" if regressions.empty?
51
+
52
+ lines = regressions.map { |r| "- **#{r.kind}** `#{r.gem}` — #{r.detail}" }
53
+ section("Regressions (CI-failable)", lines)
54
+ end
55
+
56
+ def added_section(added)
57
+ return "" if added.empty?
58
+
59
+ lines = added.map { |a| "- #{format_added(a)}" }
60
+ section("Added", lines)
61
+ end
62
+
63
+ def removed_section(removed)
64
+ return "" if removed.empty?
65
+
66
+ lines = removed.map { |r| "- `#{r.name}` (was #{(r.data || {})["version_used"] || "?"})" }
67
+ section("Removed", lines)
68
+ end
69
+
70
+ def bumps_section(bumped)
71
+ return "" if bumped.empty?
72
+
73
+ lines = bumped.map { |b| format_bump(b) }
74
+ section("Version bumps", lines)
75
+ end
76
+
77
+ def signal_changes_section(signal_changes)
78
+ return "" if signal_changes.empty?
79
+
80
+ lines = signal_changes.flat_map { |sc| format_signal_change_lines(sc) }
81
+ section("Signal changes (same version)", lines)
82
+ end
83
+
84
+ def ruby_section(ruby)
85
+ return "" if ruby.nil?
86
+ return "" unless ruby[:version_changed] || ruby[:newly_eol]
87
+
88
+ lines = []
89
+ if ruby[:version_changed]
90
+ eol_suffix = ruby[:newly_eol] ? " (now EOL)" : ""
91
+ lines << "- Ruby `#{ruby[:from]}` → `#{ruby[:to]}`#{eol_suffix}"
92
+ elsif ruby[:newly_eol]
93
+ lines << "- Ruby `#{ruby[:to]}` is now EOL"
94
+ end
95
+ section("Ruby", lines)
96
+ end
97
+
98
+ def section(title, lines)
99
+ "### #{title}\n\n#{lines.join("\n")}\n"
100
+ end
101
+
102
+ def format_added(added)
103
+ data = added.data || {}
104
+ bits = [
105
+ data["version_used"] && "v#{data["version_used"]}",
106
+ data["scorecard_score"] && "OpenSSF #{data["scorecard_score"]}",
107
+ (data["vulnerability_count"].to_i.positive? ? "#{data["vulnerability_count"]} vulns" : nil),
108
+ (data["archived"] ? "archived" : nil),
109
+ data["libyear"] && "#{data["libyear"]}y behind",
110
+ ].compact
111
+ "`#{added.name}` (#{bits.join(", ")})"
112
+ end
113
+
114
+ def format_bump(bump)
115
+ label = BUMP_KIND_LABELS[bump.kind]
116
+ suffix = label ? " (#{label})" : ""
117
+ "- `#{bump.name}` #{bump.before_version} → #{bump.after_version}#{suffix}"
118
+ end
119
+
120
+ def format_signal_change_lines(sc)
121
+ sc.changes.filter_map do |ch|
122
+ case ch[:kind]
123
+ when :archived
124
+ "- `#{sc.name}` — archived (false → true)"
125
+ when :new_vulnerability
126
+ ids = Array(ch[:ids]).join(", ")
127
+ "- `#{sc.name}` — new vulnerability (#{ch[:from]} → #{ch[:to]}#{" — #{ids}" unless ids.empty?})"
128
+ when :scorecard_dropped
129
+ note = ch[:crossed_good] ? " (crossed 7.0)" : ""
130
+ "- `#{sc.name}` — scorecard #{ch[:from]} → #{ch[:to]}#{note}"
131
+ when :version_yanked
132
+ "- `#{sc.name}` — version yanked from rubygems"
133
+ when :libyear_worsened
134
+ "- `#{sc.name}` — libyear #{ch[:from]} → #{ch[:to]} (+#{ch[:delta]}y; same pinned version)"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Maps gem names to line numbers in a Bundler-generated Gemfile.lock.
5
+ # Used by SARIF output so findings annotate the correct lockfile line.
6
+ #
7
+ # We hand-roll the parsing rather than delegating to
8
+ # Bundler::LockfileParser because the latter is not side-effect-free —
9
+ # it tries to resolve PLUGIN SOURCE blocks against the installed plugin
10
+ # registry and raises Bundler::Plugin::UnknownSourceError if a plugin
11
+ # isn't present in the consuming environment. We just want names + lines.
12
+ module LockfileIndexer
13
+ extend self
14
+
15
+ # Bundler indents top-level specs with exactly 4 spaces; nested deps get
16
+ # 6+. \S+ matches what Bundler's own parser accepts (any non-space).
17
+ TOP_LEVEL_SPEC = /\A (\S+) \(/
18
+ # Source blocks Bundler emits — see bundler/lockfile_parser.rb SOURCE constant.
19
+ BLOCK_HEADER = /\A(GEM|GIT|PATH|PLUGIN SOURCE)\b/
20
+ SECTION_BREAK = /\A[A-Z]/
21
+
22
+ # Returns a Hash mapping gem name -> 1-based line number in `content`.
23
+ # When a gem appears as both a top-level spec and a nested dep, the
24
+ # top-level entry wins. Lines outside GEM/GIT/PATH/PLUGIN SOURCE blocks
25
+ # are ignored.
26
+ def gem_line_index(content)
27
+ index = {}
28
+ in_block = false
29
+ content.each_line.with_index(1) do |line, lineno|
30
+ if line.match?(BLOCK_HEADER)
31
+ in_block = true
32
+ next
33
+ end
34
+ if line.match?(SECTION_BREAK)
35
+ in_block = false
36
+ next
37
+ end
38
+ next unless in_block
39
+
40
+ match = line.match(TOP_LEVEL_SPEC)
41
+ index[match[1]] = lineno if match
42
+ end
43
+ index
44
+ end
45
+
46
+ # Returns the 1-based line number of the Ruby version inside the
47
+ # RUBY VERSION block (the line *after* the header). Falls back to 1.
48
+ def ruby_version_line(content)
49
+ content.each_line.with_index(1) do |line, lineno|
50
+ return lineno + 1 if line.start_with?("RUBY VERSION")
51
+ end
52
+ 1
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+ require_relative "../still_active/sarif/rules"
7
+ require_relative "lockfile_indexer"
8
+
9
+ module StillActive
10
+ # Renders a still_active workflow result as a SARIF 2.1.0 document.
11
+ # The output is suitable for upload to GitHub Code Scanning via
12
+ # github/codeql-action/upload-sarif.
13
+ module SarifHelper
14
+ extend self
15
+
16
+ SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
17
+ TOOL_NAME = "still_active"
18
+ TOOL_URI = "https://github.com/SeanLF/still_active"
19
+
20
+ LIBYEAR_THRESHOLD = 1.0
21
+ SCORECARD_LOW_THRESHOLD = 4.0
22
+ ABANDONED_SECONDS = 2 * 365 * 24 * 60 * 60 # 2 years
23
+
24
+ # result: same hash StillActive::Workflow.call returns (gem_name => gem_data)
25
+ # ruby_info: optional Ruby freshness hash (or nil)
26
+ # lockfile_path: path to Gemfile.lock for line annotations
27
+ # tool_version: StillActive::VERSION at emit time
28
+ def render(result:, ruby_info:, lockfile_path:, tool_version:)
29
+ lockfile_content = File.read(lockfile_path)
30
+ line_index = LockfileIndexer.gem_line_index(lockfile_content)
31
+ ruby_line = LockfileIndexer.ruby_version_line(lockfile_content)
32
+ lockfile_uri = File.basename(lockfile_path)
33
+
34
+ results = build_results(
35
+ report: result,
36
+ ruby_info: ruby_info,
37
+ line_index: line_index,
38
+ ruby_line: ruby_line,
39
+ lockfile_uri: lockfile_uri,
40
+ )
41
+
42
+ JSON.pretty_generate(document(results: results, tool_version: tool_version))
43
+ end
44
+
45
+ private
46
+
47
+ def document(results:, tool_version:)
48
+ {
49
+ "$schema" => SARIF_SCHEMA,
50
+ "version" => "2.1.0",
51
+ "runs" => [{
52
+ "tool" => {
53
+ "driver" => {
54
+ "name" => TOOL_NAME,
55
+ "semanticVersion" => tool_version,
56
+ "informationUri" => TOOL_URI,
57
+ "rules" => sarif_rules,
58
+ },
59
+ },
60
+ "originalUriBaseIds" => { "%SRCROOT%" => { "uri" => "file:///" } },
61
+ "results" => results,
62
+ "columnKind" => "utf16CodeUnits",
63
+ }],
64
+ }
65
+ end
66
+
67
+ def sarif_rules
68
+ Sarif::Rules.all.map { |r| sarif_rule(r) }
69
+ end
70
+
71
+ def sarif_rule(r)
72
+ properties = { "tags" => r[:tags], "precision" => "high" }
73
+ properties["security-severity"] = r[:security_severity] if r[:security_severity]
74
+ {
75
+ "id" => r[:id],
76
+ "name" => r[:name],
77
+ "shortDescription" => { "text" => r[:short] },
78
+ "fullDescription" => { "text" => r[:full] },
79
+ "help" => { "text" => r[:help_text], "markdown" => r[:help_markdown] },
80
+ "helpUri" => Sarif::Rules.help_uri(r[:id]),
81
+ "defaultConfiguration" => { "level" => r[:level] },
82
+ "properties" => properties,
83
+ }
84
+ end
85
+
86
+ def build_results(report:, ruby_info:, line_index:, ruby_line:, lockfile_uri:)
87
+ results = []
88
+ report.each do |gem_name, data|
89
+ results.concat(gem_results(gem_name.to_s, data, line_index, lockfile_uri))
90
+ end
91
+ results << ruby_eol_result(ruby_info, ruby_line, lockfile_uri) if ruby_info && ruby_info[:eol]
92
+ results
93
+ end
94
+
95
+ def gem_results(name, data, line_index, lockfile_uri)
96
+ out = []
97
+ version = data[:version_used]
98
+ location = location_for(name, line_index, lockfile_uri)
99
+
100
+ if data[:archived]
101
+ out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}.", location)
102
+ end
103
+
104
+ unless data[:archived]
105
+ last_commit = parse_time(data[:last_commit_date])
106
+ if last_commit && last_commit < (Time.now - ABANDONED_SECONDS)
107
+ years = ((Time.now - last_commit) / (365 * 24 * 60 * 60)).round(1)
108
+ out << result(
109
+ "SA002",
110
+ name,
111
+ "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")}).",
112
+ location,
113
+ )
114
+ end
115
+ end
116
+
117
+ Array(data[:vulnerabilities]).each do |vuln|
118
+ out << vulnerability_result(name, version, vuln, location)
119
+ end
120
+
121
+ if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
122
+ latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
123
+ out << result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}.", location)
124
+ end
125
+
126
+ if data[:scorecard_score] && data[:scorecard_score] < SCORECARD_LOW_THRESHOLD
127
+ out << result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location)
128
+ end
129
+
130
+ if data[:version_yanked]
131
+ out << result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location)
132
+ end
133
+
134
+ out
135
+ end
136
+
137
+ def vulnerability_result(name, version, vuln, location)
138
+ score = vuln[:cvss3_score] || vuln[:cvss2_score]
139
+ level = Sarif::Rules.cvss_to_level(score)
140
+ severity = Sarif::Rules.cvss_to_security_severity(score)
141
+ advisory_id = vuln[:id] || Array(vuln[:aliases]).first || "unknown"
142
+ aliases = Array(vuln[:aliases]).first(3).join(", ")
143
+ alias_suffix = aliases.empty? ? "" : " [#{aliases}]"
144
+ title = vuln[:title] ? ": #{vuln[:title]}" : ""
145
+
146
+ base = result(
147
+ "SA003",
148
+ name,
149
+ "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}.",
150
+ location,
151
+ level: level,
152
+ fp_extra: advisory_id,
153
+ )
154
+ base["properties"] = { "security-severity" => severity } if severity
155
+ base
156
+ end
157
+
158
+ def ruby_eol_result(ruby_info, ruby_line, lockfile_uri)
159
+ version = ruby_info[:version]
160
+ eol_part = ruby_info[:eol_date] ? " (EOL #{format_date(ruby_info[:eol_date])})" : ""
161
+ latest_part = ruby_info[:latest_version] ? " Latest is #{ruby_info[:latest_version]}." : ""
162
+ base = {
163
+ "ruleId" => "SA006",
164
+ "ruleIndex" => rule_index("SA006"),
165
+ "level" => "error",
166
+ "message" => { "text" => "Ruby #{version} has reached end-of-life#{eol_part}.#{latest_part}" },
167
+ "locations" => [{
168
+ "physicalLocation" => {
169
+ "artifactLocation" => { "uri" => lockfile_uri, "uriBaseId" => "%SRCROOT%" },
170
+ "region" => { "startLine" => ruby_line },
171
+ },
172
+ }],
173
+ }
174
+ apply_fingerprint(base, fingerprint("SA006", "ruby"))
175
+ end
176
+
177
+ def result(rule_id, gem_name, message, location, level: nil, fp_extra: nil)
178
+ level ||= Sarif::Rules.find(rule_id)[:level]
179
+ base = {
180
+ "ruleId" => rule_id,
181
+ "ruleIndex" => rule_index(rule_id),
182
+ "level" => level,
183
+ "message" => { "text" => message },
184
+ "locations" => [location],
185
+ }
186
+ apply_fingerprint(base, fingerprint(rule_id, gem_name, fp_extra))
187
+ end
188
+
189
+ def apply_fingerprint(result, fp)
190
+ result["partialFingerprints"] = {
191
+ "primaryLocationLineHash" => fp,
192
+ "stillActiveFinding/v1" => fp,
193
+ }
194
+ result
195
+ end
196
+
197
+ def location_for(gem_name, line_index, lockfile_uri)
198
+ {
199
+ "physicalLocation" => {
200
+ "artifactLocation" => { "uri" => lockfile_uri, "uriBaseId" => "%SRCROOT%" },
201
+ "region" => { "startLine" => line_index[gem_name] || 1 },
202
+ },
203
+ }
204
+ end
205
+
206
+ def fingerprint(rule_id, gem_name, advisory_id = nil)
207
+ Digest::SHA256.hexdigest(["v1", rule_id, gem_name, advisory_id].compact.join("|"))[0, 16]
208
+ end
209
+
210
+ def rule_index(rule_id)
211
+ Sarif::Rules.all.index { |r| r[:id] == rule_id }
212
+ end
213
+
214
+ def parse_time(value)
215
+ return value if value.is_a?(Time)
216
+ return if value.nil?
217
+
218
+ Time.parse(value.to_s)
219
+ rescue ArgumentError, TypeError, RangeError
220
+ nil
221
+ end
222
+
223
+ def format_date(value)
224
+ t = parse_time(value)
225
+ t ? t.utc.strftime("%Y-%m-%d") : value.to_s
226
+ end
227
+
228
+ def repo_suffix(data)
229
+ data[:repository_url] ? " (#{data[:repository_url]})" : ""
230
+ end
231
+ end
232
+ end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "options"
4
+ require_relative "diff"
4
5
  require_relative "../helpers/activity_helper"
5
6
  require_relative "../helpers/bundler_helper"
7
+ require_relative "../helpers/diff_markdown_helper"
6
8
  require_relative "../helpers/emoji_helper"
7
9
  require_relative "../helpers/markdown_helper"
10
+ require_relative "../helpers/sarif_helper"
8
11
  require_relative "../helpers/terminal_helper"
9
12
  require_relative "../helpers/version_helper"
10
13
  require_relative "../helpers/vulnerability_helper"
@@ -27,15 +30,26 @@ module StillActive
27
30
 
28
31
  ruby_info = Workflow.ruby_freshness
29
32
 
30
- case resolve_format
31
- when :json
32
- output = { gems: result }
33
- output[:ruby] = ruby_info if ruby_info
34
- puts output.to_json
35
- when :terminal
36
- puts TerminalHelper.render(result, ruby_info: ruby_info)
37
- when :markdown
38
- render_markdown(result, ruby_info: ruby_info)
33
+ if (baseline_path = StillActive.config.baseline_path)
34
+ emit_diff(result, ruby_info, baseline_path)
35
+ elsif (sarif_path = StillActive.config.sarif_path)
36
+ emit_sarif(result, ruby_info, sarif_path)
37
+ else
38
+ case resolve_format
39
+ when :json
40
+ output = {
41
+ schema_version: 1,
42
+ tool: { name: "still_active", version: StillActive::VERSION },
43
+ generated_at: Time.now.utc.iso8601,
44
+ gems: result,
45
+ }
46
+ output[:ruby] = ruby_info if ruby_info
47
+ puts output.to_json
48
+ when :terminal
49
+ puts TerminalHelper.render(result, ruby_info: ruby_info)
50
+ when :markdown
51
+ render_markdown(result, ruby_info: ruby_info)
52
+ end
39
53
  end
40
54
 
41
55
  check_exit_status(result)
@@ -43,6 +57,62 @@ module StillActive
43
57
 
44
58
  private
45
59
 
60
+ def emit_sarif(result, ruby_info, sarif_path)
61
+ lockfile = resolve_lockfile_path(StillActive.config.gemfile_path)
62
+ unless File.exist?(lockfile)
63
+ $stderr.puts("error: --sarif requires a lockfile at #{lockfile}")
64
+ exit(2)
65
+ end
66
+
67
+ sarif_json = SarifHelper.render(
68
+ result: result,
69
+ ruby_info: ruby_info,
70
+ lockfile_path: lockfile,
71
+ tool_version: StillActive::VERSION,
72
+ )
73
+
74
+ if sarif_path == "-"
75
+ puts sarif_json
76
+ else
77
+ File.write(sarif_path, sarif_json)
78
+ end
79
+ end
80
+
81
+ # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise <gemfile>.lock.
82
+ def resolve_lockfile_path(gemfile)
83
+ return gemfile.sub(/gems\.rb\z/, "gems.locked") if gemfile.end_with?("gems.rb")
84
+
85
+ "#{gemfile}.lock"
86
+ end
87
+
88
+ def emit_diff(result, ruby_info, baseline_path)
89
+ current = current_snapshot(result, ruby_info)
90
+ baseline = JSON.parse(File.read(baseline_path))
91
+ diff = Diff.call(baseline: baseline, current: current)
92
+ puts DiffMarkdownHelper.render(diff)
93
+ exit(1) if diff.regressions.any?
94
+ rescue JSON::ParserError => e
95
+ $stderr.puts("error: --baseline file is not valid JSON: #{e.message}")
96
+ exit(2)
97
+ rescue Diff::UnsupportedSchemaError => e
98
+ $stderr.puts("error: #{e.message}")
99
+ exit(2)
100
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
101
+ $stderr.puts("error: cannot read baseline file: #{e.message}")
102
+ exit(2)
103
+ end
104
+
105
+ def current_snapshot(result, ruby_info)
106
+ snapshot = {
107
+ "schema_version" => 1,
108
+ "tool" => { "name" => "still_active", "version" => StillActive::VERSION },
109
+ "generated_at" => Time.now.utc.iso8601,
110
+ "gems" => JSON.parse(result.to_json),
111
+ }
112
+ snapshot["ruby"] = JSON.parse(ruby_info.to_json) if ruby_info
113
+ snapshot
114
+ end
115
+
46
116
  def resolve_format
47
117
  format = StillActive.config.output_format
48
118
  return format unless format == :auto
@@ -2,23 +2,25 @@
2
2
 
3
3
  require "bundler"
4
4
  require "octokit"
5
+ require "open3"
5
6
 
6
7
  module StillActive
7
8
  class Config
8
- attr_accessor :critical_warning_emoji,
9
+ attr_writer :github_oauth_token, :gitlab_token
10
+ attr_accessor :baseline_path,
11
+ :critical_warning_emoji,
9
12
  :fail_if_critical,
10
13
  :fail_if_warning,
11
14
  :futurist_emoji,
12
15
  :gemfile_path,
13
16
  :gems,
14
- :github_oauth_token,
15
- :gitlab_token,
16
17
  :fail_if_outdated,
17
18
  :fail_if_vulnerable,
18
19
  :ignored_gems,
19
20
  :output_format,
20
21
  :parallelism,
21
22
  :no_warning_range_end,
23
+ :sarif_path,
22
24
  :success_emoji,
23
25
  :unsure_emoji,
24
26
  :warning_emoji,
@@ -32,12 +34,14 @@ module StillActive
32
34
  @gemfile_path = Bundler.default_gemfile.to_s
33
35
  @gems = []
34
36
  @ignored_gems = []
35
- @github_oauth_token = ENV["GITHUB_TOKEN"]
36
- @gitlab_token = ENV["GITLAB_TOKEN"]
37
+ @github_oauth_token = nil
38
+ @gitlab_token = nil
37
39
 
38
40
  @parallelism = 10
39
41
 
40
42
  @output_format = :auto
43
+ @sarif_path = nil
44
+ @baseline_path = nil
41
45
 
42
46
  @critical_warning_emoji = "🚩"
43
47
  @futurist_emoji = "🔮"
@@ -53,5 +57,52 @@ module StillActive
53
57
  @github_client ||=
54
58
  Octokit::Client.new(access_token: github_oauth_token)
55
59
  end
60
+
61
+ def github_oauth_token
62
+ @github_oauth_token ||= presence(ENV["GITHUB_TOKEN"]) || presence(ENV["GH_TOKEN"]) || gh_cli_token
63
+ end
64
+
65
+ def gitlab_token
66
+ @gitlab_token ||= presence(ENV["GITLAB_TOKEN"]) || glab_cli_token
67
+ end
68
+
69
+ private
70
+
71
+ def gh_cli_token
72
+ stdout, stderr, status = Open3.capture3("gh", "auth", "token")
73
+ if status.success?
74
+ token = presence(stdout.strip)
75
+ return token if token
76
+
77
+ warn("warning: `gh auth token` returned empty output")
78
+ else
79
+ warn("warning: `gh auth token` failed: #{stderr.strip}")
80
+ end
81
+ nil
82
+ rescue Errno::ENOENT
83
+ nil
84
+ end
85
+
86
+ def glab_cli_token
87
+ # Scope to gitlab.com to match GitlabClient::BASE_URI. Users on self-hosted
88
+ # GitLab still_active doesn't query anyway; if they need it, set GITLAB_TOKEN.
89
+ _stdout, stderr, status = Open3.capture3("glab", "auth", "status", "--hostname=gitlab.com", "--show-token")
90
+ unless status.success?
91
+ warn("warning: `glab auth status` failed: #{stderr.strip}")
92
+ return
93
+ end
94
+
95
+ match = stderr.match(/Token:\s*(\S+)/)
96
+ return match[1] if match
97
+
98
+ warn("warning: `glab auth status` did not return a Token line")
99
+ nil
100
+ rescue Errno::ENOENT
101
+ nil
102
+ end
103
+
104
+ def presence(value)
105
+ value && !value.empty? ? value : nil
106
+ end
56
107
  end
57
108
  end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Compares two still_active JSON snapshots and produces a structured Diff.
5
+ # Designed for PR review: surfaces regressions (CI-failable deltas) on top
6
+ # of the full added/removed/bumped breakdown.
7
+ #
8
+ # Schema versions accepted: see SUPPORTED_SCHEMA_VERSIONS. A snapshot with
9
+ # a higher schema_version is rejected loudly rather than silently parsed.
10
+ module Diff
11
+ SUPPORTED_SCHEMA_VERSIONS = [1].freeze
12
+
13
+ SCORECARD_DROP_THRESHOLD = 1.0 # absolute drop to flag
14
+ SCORECARD_GOOD_THRESHOLD = 7.0 # categorical threshold (good -> below-good)
15
+ NEW_GEM_LIBYEAR_THRESHOLD = 0.5 # added gems already this far behind regress
16
+ LIBYEAR_DELTA_THRESHOLD = 0.01 # floating-point fuzz
17
+
18
+ class UnsupportedSchemaError < StandardError; end
19
+
20
+ Added = Struct.new(:name, :data, keyword_init: true)
21
+ Removed = Struct.new(:name, :data, keyword_init: true)
22
+ Bumped = Struct.new(:name, :before_version, :after_version, :kind, :before, :after, keyword_init: true)
23
+ SignalChange = Struct.new(:name, :changes, :before, :after, keyword_init: true)
24
+ Regression = Struct.new(:kind, :gem, :detail, keyword_init: true)
25
+ Result = Struct.new(:added, :removed, :bumped, :signal_changes, :regressions, :ruby, keyword_init: true)
26
+
27
+ extend self
28
+
29
+ def call(baseline:, current:)
30
+ validate_schema!(baseline, "baseline")
31
+ validate_schema!(current, "current")
32
+
33
+ b_gems = baseline.fetch("gems", {})
34
+ c_gems = current.fetch("gems", {})
35
+
36
+ added = (c_gems.keys - b_gems.keys).sort.map { |n| Added.new(name: n, data: c_gems[n]) }
37
+ removed = (b_gems.keys - c_gems.keys).sort.map { |n| Removed.new(name: n, data: b_gems[n]) }
38
+
39
+ bumped = []
40
+ signal_changes = []
41
+ (b_gems.keys & c_gems.keys).sort.each do |name|
42
+ before = b_gems[name]
43
+ after = c_gems[name]
44
+ if before["version_used"] != after["version_used"]
45
+ bumped << Bumped.new(
46
+ name: name,
47
+ before_version: before["version_used"],
48
+ after_version: after["version_used"],
49
+ kind: classify_bump(before, after),
50
+ before: before,
51
+ after: after,
52
+ )
53
+ end
54
+ changes = collect_signal_changes(before, after)
55
+ signal_changes << SignalChange.new(name: name, changes: changes, before: before, after: after) if changes.any?
56
+ end
57
+
58
+ ruby = ruby_delta(baseline["ruby"], current["ruby"])
59
+ regressions = collect_regressions(
60
+ added: added,
61
+ bumped: bumped,
62
+ signal_changes: signal_changes,
63
+ ruby_delta: ruby,
64
+ )
65
+
66
+ Result.new(
67
+ added: added,
68
+ removed: removed,
69
+ bumped: bumped,
70
+ signal_changes: signal_changes,
71
+ regressions: regressions,
72
+ ruby: ruby,
73
+ )
74
+ end
75
+
76
+ def validate_schema!(snapshot, role)
77
+ version = snapshot["schema_version"]
78
+ return if SUPPORTED_SCHEMA_VERSIONS.include?(version)
79
+
80
+ raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}"
81
+ end
82
+
83
+ # Categorises a version bump:
84
+ # - :introduced_vulns - new advisories appeared on the resolved version
85
+ # - :closed_vulns - all advisories cleared
86
+ # - :older_relative - libyear-to-latest grew (rare; usually unchanged)
87
+ # - :fresher - libyear-to-latest shrank
88
+ # - :neutral - no obvious signal change
89
+ def classify_bump(before, after)
90
+ opened = vuln_count(after) - vuln_count(before)
91
+ return :introduced_vulns if opened.positive?
92
+ return :closed_vulns if opened.negative?
93
+
94
+ delta = (after["libyear"] || 0.0) - (before["libyear"] || 0.0)
95
+ return :older_relative if delta > LIBYEAR_DELTA_THRESHOLD
96
+ return :fresher if delta < -LIBYEAR_DELTA_THRESHOLD
97
+
98
+ :neutral
99
+ end
100
+
101
+ def collect_signal_changes(before, after)
102
+ changes = []
103
+
104
+ if !before["archived"] && after["archived"]
105
+ changes << { kind: :archived, from: false, to: true }
106
+ end
107
+
108
+ opened = vuln_count(after) - vuln_count(before)
109
+ if opened.positive? && before["version_used"] == after["version_used"]
110
+ # Set difference is enough for the common case. Edge case: an advisory
111
+ # backfilled with a CVE alias alongside an existing GHSA can show up as
112
+ # "new" in this list even though it's a re-keying of the same issue.
113
+ # The vulnerability_count gate above keeps that to detail-string noise.
114
+ new_ids = advisory_ids(after) - advisory_ids(before)
115
+ changes << { kind: :new_vulnerability, from: vuln_count(before), to: vuln_count(after), ids: new_ids.first(3) }
116
+ end
117
+
118
+ if before["scorecard_score"] && after["scorecard_score"]
119
+ drop = before["scorecard_score"] - after["scorecard_score"]
120
+ # OSSF treats >= 7.0 as "good". A score landing at 7.0 stays good (a 7.5
121
+ # -> 7.0 dip is noise within the safe zone). Only drops below 7.0 cross.
122
+ crossed = before["scorecard_score"] >= SCORECARD_GOOD_THRESHOLD && after["scorecard_score"] < SCORECARD_GOOD_THRESHOLD
123
+ if drop >= SCORECARD_DROP_THRESHOLD || crossed
124
+ changes << { kind: :scorecard_dropped, from: before["scorecard_score"], to: after["scorecard_score"], crossed_good: crossed }
125
+ end
126
+ end
127
+
128
+ if before["libyear"] && after["libyear"] && before["version_used"] == after["version_used"]
129
+ # Same pinned version + libyear grew = upstream released and we didn't
130
+ # follow. That IS a regression. If version_used moved forward we deliberately
131
+ # don't flag — moving forward isn't a PR regression even when libyear-to-latest
132
+ # technically grows (because upstream is releasing faster).
133
+ delta = after["libyear"] - before["libyear"]
134
+ if delta > LIBYEAR_DELTA_THRESHOLD
135
+ changes << { kind: :libyear_worsened, from: before["libyear"], to: after["libyear"], delta: delta.round(2) }
136
+ end
137
+ end
138
+
139
+ if !before["version_yanked"] && after["version_yanked"]
140
+ changes << { kind: :version_yanked }
141
+ end
142
+
143
+ changes
144
+ end
145
+
146
+ def collect_regressions(added:, bumped:, signal_changes:, ruby_delta:)
147
+ regs = []
148
+
149
+ added.each do |a|
150
+ data = a.data
151
+ if vuln_count(data).positive?
152
+ regs << Regression.new(kind: :new_gem_with_vulns, gem: a.name, detail: "#{vuln_count(data)} vulns at introduction")
153
+ elsif data["archived"]
154
+ regs << Regression.new(kind: :new_gem_archived, gem: a.name, detail: "added gem points at archived repo")
155
+ elsif data["libyear"] && data["libyear"] > NEW_GEM_LIBYEAR_THRESHOLD
156
+ regs << Regression.new(kind: :new_gem_stale, gem: a.name, detail: "added gem already #{data["libyear"]} libyears behind latest")
157
+ end
158
+ end
159
+
160
+ bumped.each do |b|
161
+ if b.kind == :introduced_vulns
162
+ regs << Regression.new(
163
+ kind: :bump_introduced_vulns,
164
+ gem: b.name,
165
+ detail: "#{b.before_version} -> #{b.after_version}",
166
+ )
167
+ end
168
+ end
169
+
170
+ signal_changes.each do |sc|
171
+ sc.changes.each do |ch|
172
+ case ch[:kind]
173
+ when :archived
174
+ regs << Regression.new(kind: :archived, gem: sc.name, detail: "repo archived since baseline")
175
+ when :new_vulnerability
176
+ ids = Array(ch[:ids]).join(", ")
177
+ regs << Regression.new(kind: :new_vulnerability, gem: sc.name, detail: "#{ch[:from]} -> #{ch[:to]}#{" (#{ids})" unless ids.empty?}")
178
+ when :scorecard_dropped
179
+ note = ch[:crossed_good] ? " crossed #{SCORECARD_GOOD_THRESHOLD}" : ""
180
+ regs << Regression.new(kind: :scorecard_dropped, gem: sc.name, detail: "#{ch[:from]} -> #{ch[:to]}#{note}")
181
+ when :version_yanked
182
+ regs << Regression.new(kind: :version_yanked, gem: sc.name, detail: "pinned version yanked from rubygems")
183
+ when :libyear_worsened
184
+ regs << Regression.new(
185
+ kind: :libyear_worsened,
186
+ gem: sc.name,
187
+ detail: "libyear #{ch[:from]} -> #{ch[:to]} (+#{ch[:delta]}y; same pinned version)",
188
+ )
189
+ end
190
+ end
191
+ end
192
+
193
+ if ruby_delta && ruby_delta[:newly_eol]
194
+ regs << Regression.new(kind: :ruby_eol_introduced, gem: "(ruby)", detail: "Ruby #{ruby_delta[:to]} is now EOL")
195
+ end
196
+
197
+ regs
198
+ end
199
+
200
+ def ruby_delta(before, after)
201
+ return if before.nil? && after.nil?
202
+
203
+ before ||= {}
204
+ after ||= {}
205
+ {
206
+ version_changed: before["version"] != after["version"],
207
+ from: before["version"],
208
+ to: after["version"],
209
+ newly_eol: !before["eol"] && !!after["eol"],
210
+ libyear_before: before["libyear"],
211
+ libyear_after: after["libyear"],
212
+ }
213
+ end
214
+
215
+ def vuln_count(data)
216
+ data["vulnerability_count"].to_i
217
+ end
218
+
219
+ def advisory_ids(data)
220
+ Array(data["vulnerabilities"]).flat_map { |v| [v["id"], *Array(v["aliases"])].compact }.uniq
221
+ end
222
+ end
223
+ end
@@ -34,6 +34,9 @@ module StillActive
34
34
 
35
35
  def validate_options
36
36
  raise ArgumentError, "provide gemfile or gems, not both" if options[:provided_gemfile] && options[:provided_gems]
37
+ if options[:provided_baseline] && !File.exist?(StillActive.config.baseline_path)
38
+ raise ArgumentError, "baseline file not found: #{StillActive.config.baseline_path}"
39
+ end
37
40
  end
38
41
 
39
42
  def add_gemfile_option(opts)
@@ -60,6 +63,13 @@ module StillActive
60
63
  opts.on("--terminal", "Coloured terminal output (default in TTY)") { StillActive.config { |config| config.output_format = :terminal } }
61
64
  opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
62
65
  opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
66
+ 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|
67
+ StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
68
+ end
69
+ opts.on("--baseline=PATH", String, "Compare current state to baseline still_active JSON; emit markdown deltas. Exits 1 on regressions.") do |value|
70
+ options[:provided_baseline] = true
71
+ StillActive.config { |config| config.baseline_path = value }
72
+ end
63
73
  end
64
74
 
65
75
  def add_token_options(opts)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ module Sarif
5
+ # Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-SA007)
6
+ # are stable across versions; renames or removals are breaking changes.
7
+ #
8
+ # Each rule maps a still_active finding into a SARIF result. Security
9
+ # rules carry a numeric `security_severity` (string per SARIF spec) so
10
+ # GitHub Code Scanning buckets them as Critical/High/Medium/Low.
11
+ module Rules
12
+ extend self
13
+
14
+ DOCS_BASE = "https://github.com/SeanLF/still_active/blob/main/docs/rules.md"
15
+
16
+ def help_uri(rule_id)
17
+ "#{DOCS_BASE}##{rule_id.downcase}"
18
+ end
19
+
20
+ # CVSS 0-10 -> SARIF level. Per GitHub Code Scanning convention,
21
+ # scores >= 7.0 map to error, 4.0-6.9 to warning, below 4.0 to note.
22
+ def cvss_to_level(score)
23
+ return "note" if score.nil?
24
+ return "error" if score >= 7.0
25
+ return "warning" if score >= 4.0
26
+
27
+ "note"
28
+ end
29
+
30
+ # SARIF security-severity must be a string with one decimal place.
31
+ def cvss_to_security_severity(score)
32
+ return if score.nil?
33
+
34
+ format("%.1f", score)
35
+ end
36
+
37
+ RAW_RULES = [
38
+ {
39
+ id: "SA001",
40
+ name: "ArchivedRepository",
41
+ short: "Gem source repository is archived",
42
+ full: "The gem's upstream repository has been marked archived. No further fixes — including security patches — should be expected.",
43
+ help_text: "Look for a maintained fork or alternative. If you must keep the gem, vendor or fork it so you can apply patches yourself.",
44
+ level: "error",
45
+ security_severity: "7.5",
46
+ tags: ["security", "supply-chain", "external/cwe/cwe-1357"],
47
+ },
48
+ {
49
+ id: "SA002",
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.",
53
+ help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
54
+ level: "warning",
55
+ security_severity: nil,
56
+ tags: ["maintenance"],
57
+ },
58
+ {
59
+ id: "SA003",
60
+ name: "VulnerableGem",
61
+ short: "Gem has known vulnerabilities (via deps.dev / OSV)",
62
+ full: "deps.dev / OSV reports one or more advisories affecting the resolved version. Severity is mapped from CVSS where available.",
63
+ help_text: "Upgrade to a patched version. If no fix exists, evaluate the exploit path against your application's exposure.",
64
+ level: "error",
65
+ security_severity: "7.0", # default; per-result override from CVSS
66
+ tags: ["security", "vulnerability", "external/cwe/cwe-1104"],
67
+ },
68
+ {
69
+ id: "SA004",
70
+ name: "LibyearBehind",
71
+ short: "Gem is significantly behind the latest release",
72
+ full: "Libyear measures how far behind the latest release a dependency is, in fractional years. High libyear values correlate with painful upgrades and missed security backports.",
73
+ help_text: "Schedule an upgrade. `bundle update <gem>` is your friend.",
74
+ level: "warning",
75
+ security_severity: nil,
76
+ tags: ["maintenance", "libyear"],
77
+ },
78
+ {
79
+ id: "SA005",
80
+ name: "LowOpenSSFScore",
81
+ short: "Gem's OpenSSF Scorecard is low",
82
+ full: "The OpenSSF Scorecard aggregates supply-chain hygiene signals. Low scores indicate weak supply-chain posture, not active vulnerability.",
83
+ help_text: "Treat as a risk signal, especially for direct dependencies.",
84
+ level: "note",
85
+ security_severity: nil,
86
+ tags: ["supply-chain", "openssf"],
87
+ },
88
+ {
89
+ id: "SA006",
90
+ name: "RubyEOL",
91
+ short: "Ruby runtime has reached end-of-life",
92
+ full: "The Ruby version in Gemfile.lock is past its endoflife.date EOL — no further security releases are expected.",
93
+ help_text: "Upgrade Ruby. Stay on a release branch still receiving patches.",
94
+ level: "error",
95
+ security_severity: "8.5",
96
+ tags: ["security", "runtime", "external/cwe/cwe-1104"],
97
+ },
98
+ {
99
+ id: "SA007",
100
+ name: "YankedVersion",
101
+ short: "Resolved gem version has been yanked from RubyGems",
102
+ full: "The version resolved in Gemfile.lock has been yanked by the gem owner, typically for a serious bug or vulnerability.",
103
+ help_text: "Update Gemfile.lock to a non-yanked version immediately.",
104
+ level: "error",
105
+ security_severity: "8.0",
106
+ tags: ["security", "supply-chain", "external/cwe/cwe-1104"],
107
+ },
108
+ ].freeze
109
+
110
+ CATALOG = RAW_RULES.map do |r|
111
+ r.merge(
112
+ tags: r[:tags].dup.freeze,
113
+ help_markdown: "#{r[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance.",
114
+ ).freeze
115
+ end.freeze
116
+
117
+ def all
118
+ CATALOG
119
+ end
120
+
121
+ def find(id)
122
+ CATALOG.find { |r| r[:id] == id }
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/still_active.gemspec CHANGED
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
38
38
 
39
39
  spec.add_development_dependency("debug")
40
40
  spec.add_development_dependency("faker")
41
+ spec.add_development_dependency("json_schemer")
41
42
  spec.add_development_dependency("rubocop")
42
43
  spec.add_development_dependency("rubocop-performance")
43
44
  spec.add_development_dependency("rubocop-rspec")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json_schemer
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rubocop
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -185,11 +199,14 @@ files:
185
199
  - lib/helpers/activity_helper.rb
186
200
  - lib/helpers/ansi_helper.rb
187
201
  - lib/helpers/bundler_helper.rb
202
+ - lib/helpers/diff_markdown_helper.rb
188
203
  - lib/helpers/emoji_helper.rb
189
204
  - lib/helpers/http_helper.rb
190
205
  - lib/helpers/libyear_helper.rb
206
+ - lib/helpers/lockfile_indexer.rb
191
207
  - lib/helpers/markdown_helper.rb
192
208
  - lib/helpers/ruby_helper.rb
209
+ - lib/helpers/sarif_helper.rb
193
210
  - lib/helpers/terminal_helper.rb
194
211
  - lib/helpers/version_helper.rb
195
212
  - lib/helpers/vulnerability_helper.rb
@@ -198,9 +215,11 @@ files:
198
215
  - lib/still_active/config.rb
199
216
  - lib/still_active/core_ext.rb
200
217
  - lib/still_active/deps_dev_client.rb
218
+ - lib/still_active/diff.rb
201
219
  - lib/still_active/gitlab_client.rb
202
220
  - lib/still_active/options.rb
203
221
  - lib/still_active/repository.rb
222
+ - lib/still_active/sarif/rules.rb
204
223
  - lib/still_active/version.rb
205
224
  - lib/still_active/workflow.rb
206
225
  - still_active.gemspec
@@ -227,7 +246,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
227
246
  - !ruby/object:Gem::Version
228
247
  version: '0'
229
248
  requirements: []
230
- rubygems_version: 4.0.6
249
+ rubygems_version: 4.0.10
231
250
  specification_version: 4
232
251
  summary: Audit your Ruby dependencies for maintenance health, outdated versions, vulnerabilities,
233
252
  and abandoned gems.