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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +81 -21
- data/lib/helpers/diff_markdown_helper.rb +139 -0
- data/lib/helpers/lockfile_indexer.rb +55 -0
- data/lib/helpers/sarif_helper.rb +232 -0
- data/lib/still_active/cli.rb +79 -9
- data/lib/still_active/config.rb +56 -5
- data/lib/still_active/diff.rb +223 -0
- data/lib/still_active/options.rb +10 -0
- data/lib/still_active/sarif/rules.rb +126 -0
- data/lib/still_active/version.rb +1 -1
- data/still_active.gemspec +1 -0
- metadata +21 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be6f4e86c6afb3564385acfaf5733cecc38845c300a8a2ef624d0b267cc93a94
|
|
4
|
+
data.tar.gz: ec381b31257189f8f171bb5bdc4f318dfd0edd03cba957dd58b3400935ab5898
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
| | `bundle outdated` | `bundler-audit`
|
|
32
|
-
| ---------------------------- | ----------------- |
|
|
33
|
-
| Outdated versions | Yes | -
|
|
34
|
-
| Known vulnerabilities (CVEs) | - | Yes
|
|
35
|
-
|
|
|
36
|
-
| Last commit activity
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
| Yanked version detection
|
|
40
|
-
| Ruby version freshness
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
data/lib/still_active/config.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
36
|
-
@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
|
data/lib/still_active/options.rb
CHANGED
|
@@ -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
|
data/lib/still_active/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|