still_active 2.0.0 → 3.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +57 -10
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +9 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +201 -8
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. metadata +61 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1db517014109d081c46e71d63cf61d4446d60f7c7205793c520293f6cc03e329
4
- data.tar.gz: 5239b26426cb8a8359c3efbafb1aaf3480150bf6a190a0912cc1f78d4ca0e226
3
+ metadata.gz: b3541d297e3d8059ffdaee4c1377ab7333fc5d2a54940219d7a4f2611f4c445e
4
+ data.tar.gz: d121da3f25a1db5bbcf5cc3e2351acbaef84076350bccc3074ed869d46881f75
5
5
  SHA512:
6
- metadata.gz: 49f561e7ea5a5e0de227e5c861cd4b1ef2bcca57ad967712e5411fd25ea00cea49ee07801f3b4b6be7dc205259c8f2e3c36481ec5148fa52849a8246070beb62
7
- data.tar.gz: 5552c0f3c9ba60c3e9046372923f4fa3aee2b2152e5eea6765f99f0c14fdf7785798df477605e04c11a7ac5f6bb2fec06dbc1f0ece3e46b1ee91aaa341cc2ae7
6
+ metadata.gz: e6f9391ed541b846f1c65cd0231e6894c69606cfbce1f6c9e636926607e99191c7029f7c79d5fe42257bbf33bb822f84b7772903c3444ce0b593ae8c57c875d6
7
+ data.tar.gz: 15b01401efb0f22e189c16cd5baf5f3086a2969686e3efd701c85c34ef7dc29eba1ede2eecb717f59e10b6d827d8ae729be07a6c42261583f8256dd7c060a7ab
data/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Upgrading to 3.0
6
+
7
+ 3.0 is a major bump because a handful of changes can flip a `--fail-if-*` outcome or an exit code on upgrade. The JSON `schema_version` stays `1` (every output change is additive, apart from `up_to_date` widening from `boolean` to `boolean | null`), and no CLI flag was removed or renamed, but review these before upgrading a pipeline:
8
+
9
+ - **`--fail-if-vulnerable=<severity>` now fails CLOSED on an unscored advisory** (and bare `--fail-if-vulnerable` now fires on advisories a failed deps.dev fetch used to silently drop). A previously-green run can go red. Review the named advisory (a per-gem stderr note explains why), then fix it or accept it in `.still_active.yml`. Installing the optional `cvss-suite` gem lets a CVSS-4-only advisory get a real score and clear the gate when it's below threshold.
10
+ - **Bad CLI input now exits 2** with a friendly `error:` line instead of a Ruby backtrace (previously exit 1). SBOM read/parse errors also exit 2. Wrapper scripts that branch on exit codes should treat 2 as "bad invocation".
11
+ - **Two new REQUIRED runtime dependencies**: `semantic_range` (npm/cargo version matching for the below-the-fix signal) and `packageurl-ruby` (SBOM PURL parsing). Both resolve automatically on `gem install`; the Ruby floor is unchanged. `cvss-suite` is deliberately NOT a hard dependency (it exact-pins bundler and caps bigdecimal, the poison-pill pattern still_active itself flags), so install it yourself to light up CVSS-4 scoring.
12
+ - **New SARIF rules SA008 (poison-pill) and SA009 (runtime ceiling)** appear automatically in the GitHub Security tab for `--sarif` users, and an unscored advisory now maps to `warning` (was `note`), so a code-scanning policy set to fail on `warning` can newly fire. Rule IDs SA001-SA007 are unchanged.
13
+ - **Tokenless runs now resolve GitHub repo signals via ecosyste.ms** instead of hitting the 60/hour API wall and degrading to `unknown`. A tokenless user with `--fail-if-critical`/`--fail-if-warning` may see gems that were silently `unknown` now resolve to a real `stale`/`critical`/`archived` and trip the activity gate. Tokened runs are unaffected.
14
+ - **New outbound hosts**: `api.osv.dev` (advisory enrichment) and `endoflife.date` (the runtime-ceiling support window) on the native path, plus `*.ecosyste.ms` for tokenless audits. Egress-restricted CI should allowlist these; each degrades to best-effort (no crash) if blocked.
15
+ - **Re-capture your `--baseline` JSON** after upgrading: the new signals and fields show as changes on the first run.
16
+
17
+ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates default to **off**, so they never break an existing pipeline unless you opt in.
18
+
19
+ ### Added
20
+
21
+ - **Poison-pill / compatibility-ceiling signal (SA008).** A dormant or archived package that caps one of its runtime dependencies below that dependency's current latest major holds the tree below a ceiling no upstream release will raise, and it grows more constraining over time as the capped dep ships new majors. still_active flags it with a receipt naming the capped dep, the cap, and how many majors behind it holds you, ranked by a severity tier (`note`/`warning`/`critical`, scaling with majors-behind) and enforceable in CI with `--fail-if-poison[=TIER]`. Rendered in terminal, markdown, and SARIF, and computed for `rubygems` (native) and the flat-resolution SBOM ecosystems. Scoped to flat resolution deliberately: npm nests versions and cargo coexists majors, so a below-latest cap there is subtree-local noise, not a tree-wide block, and is suppressed.
22
+ - **Security "below the fix", the strongest poison case, now cross-ecosystem.** When a dormant/archived package pins a dependency that is *itself* known-vulnerable in the same tree, below the version that patches a HIGH-or-critical advisory, you cannot patch the CVE without replacing the dead capper. still_active leads with these findings (red in terminal/markdown, escalated to `error` in SARIF) and names the advisory and its nearest fix. This case travels to **npm and cargo** at patch precision (their fixes are mostly same-major patch bumps, invisible to a major-level check), via a real node-semver/Cargo matcher and a tree-copy soundness guard that only fires when every resolved copy the constraint governs is vulnerable (a safe copy elsewhere, or a patched copy the cap can reach, clears it). The correlation is whole-tree from data already assembled, no extra fetches.
23
+ - **Language-runtime ceiling signal (SA009), poison's sibling.** A pinned dependency version can strand you on an end-of-life language runtime (its `required_ruby_version` / `requires_python` forbids a supported one). still_active flags it against the runtime's EOL schedule (from endoflife.date), enforceable with `--fail-if-language-ceiling[=TIER]`, and reconciled against poison so an "upgrade to lift the ceiling" can't contradict a cap that forbids that upgrade. Ruby on the native path; **Python on the `--sbom` path** (pypi).
24
+ - **CVSS-4-only advisory scoring via an optional `cvss-suite`.** deps.dev stores only CVSS 3.x, so a CVSS-4-only advisory arrives unscored; still_active enriches every advisory with OSV's real severity label and fixed-version ranges, and, when the optional `cvss-suite` gem is installed, computes the CVSS-4 base score from the OSV vector. Without it, the OSV/GHSA label still gates and only the computed number is skipped, so the fail-closed logic never reads a real HIGH as clean. `cvss-suite` is opt-in (see Upgrading).
25
+ - **Vulnerabilities with no fixed version available are flagged** (`no_fix_available`): a known advisory you cannot upgrade out of, the one you most need to see because upgrading isn't the answer.
26
+ - **A schema canary for the deps.dev advisory field** (`api.deps.dev`'s alpha `advisoryKeys`): every cross-ecosystem vulnerability count flows through it, so a silent field rename would zero every count and read a vulnerable package as clean. A known-vulnerable canary (`django 3.0.0`) is checked each run; an empty result warns loudly rather than presenting a possibly-understated "all clear".
27
+ - **The `--sbom` path now carries the same version signals as the native audit**: `libyear` drift, a prerelease/ahead-aware `up_to_date`, and (when the SBOM marks CycloneDX dev-vs-prod scope) a `production` boolean so a consumer can separate prod risk from test debt. A private/alternative registry named in a PURL's `repository_url` is refused a public-by-name lookup (the cross-ecosystem dependency-confusion guard), and a pinned version deps.dev can't resolve reads `unknown` rather than riding the fresh package date to a false `ok`.
28
+ - **`--sbom=PATH` audits a CycloneDX SBOM cross-ecosystem**, so the maintenance lens travels beyond Ruby: point it at a Syft/Trivy-produced SBOM and still_active assesses every `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` package the same way it does gems (latest release date, archived repo, advisories, OpenSSF Scorecard, lifecycle `status`), sourcing signals from deps.dev and ecosyste.ms instead of Bundler. Mutually exclusive with `--gemfile`/`--gems`; emits JSON only (its shape differs from the Ruby audit, so it deliberately omits the `$schema` contract). Results are keyed `ecosystem/name@version` so nothing collides and overwrites, not a same-named package across two ecosystems nor two versions of one package pinned by different subprojects of a monorepo (a lockfile resolves one version per name, a merged SBOM doesn't), and each dependency carries its `activity_level` and `status`. The SBOM is treated as **untrusted input**: the only things read from it are ecosystem/name/version, the repository is discovered from deps.dev (never a lockfile-supplied URL, so a hostile SBOM can't redirect a lookup), and anything deps.dev doesn't index degrades to `unknown` rather than a fabricated `ok`. Packages it can't assess (unsupported ecosystems, a component with no version or PURL, **and** a dependency whose lookup raised, e.g. a rate-limited or flaky deps.dev) are surfaced in an `unassessable` list and a stderr count, never silently dropped, so a transient upstream hiccup can't shrink the audit scope invisibly and let it read "all clear" while ignoring the deps it skipped. A present-but-unreadable SBOM (truncated, or not CycloneDX) errors rather than degrading to an empty clean report. OS packages land in `unassessable` too; the differentiated play is maintenance, not vuln scanning, so compose Trivy/Grype for CVEs.
29
+ - **Tokenless GitHub repo signals via [ecosyste.ms](https://ecosyste.ms).** Without a GitHub token the live API caps at 60 requests/hour, unusable past a handful of gems; still_active now falls back to ecosyste.ms (5000 anonymous requests/hour) for GitHub repos' archived + last-commit signals, so a large Gemfile audits cleanly with no token. GitHub-only by design (ecosyste.ms doesn't track commit recency for GitLab/Codeberg, so those keep their own clients); a configured token still takes precedence (freshest data, and it carries `--unreleased-commits`). Requests identify still_active in the User-Agent, and an optional `--ecosystems-email` / `STILL_ACTIVE_ECOSYSTEMS_EMAIL` joins ecosyste.ms's "polite pool" (higher rate limit) as a courtesy to the free service. ecosyste.ms data is CC-BY-SA 4.0 and attributed in the README.
30
+
31
+ - Each gem now carries the OpenSSF Scorecard **`Maintained` sub-check** (`scorecard_maintained`, 0.0-10.0) alongside the aggregate `scorecard_score`. The sub-check scores recent commit and issue activity directly, the question still_active exists to answer, and it was already present in the deps.dev response still_active fetches (previously discarded). `nil` when a project has no scorecard, deliberately distinct from `0.0` ("measured: unmaintained") so absent data never reads as healthy.
32
+ - A single categorical **lifecycle `status`** per gem and a project-level `summary.status`, so a machine/LLM consumer or another tool reads one verdict instead of re-deriving it from `activity_level` + `archived` + `vulnerability_count`. Worst-first: `dead` (dormant/archived **and** carrying an unpatched advisory; no one is fixing it, migrate) > `vulnerable` (a fixable advisory on an actively-released gem) > `archived` > `stale` > `legacy` (long-dormant but **clean**: feature-complete, low risk, the "done gem" that recency alone wrongly flags) > `ok` > `unknown` (never silently `ok`). An EOL Ruby floors the project at `vulnerable`. **archived is not dead:** a repo archived while the gem still publishes recent releases (development moved to a monorepo) reads as `stale`, not dead; only a genuinely dormant archived repo is `archived`. This is a display/threshold convenience over the raw fields, not the numeric 0-100 composite removed earlier (which let missing data read as perfect health). Documented in `docs/schema.md`. Additive, so `schema_version` stays `1`.
33
+
34
+ ### Fixed
35
+
36
+ - **`--baseline` now honours a committed `.still_active.yml`.** The PR-diff gate was the only gate that ignored the suppression list: a maintenance regression the audit and SARIF gates already accept still tripped `--baseline` the moment the dependency was added, with no way to accept it short of merging it to the baseline first. An accepted regression is now moved out of the CI-failable set and shown under an "Accepted (suppressed via `.still_active.yml`)" section with its reason, so the acceptance stays visible rather than silently vanishing. Only maintenance kinds a bare gem+signal entry can cover are accepted (archived/staleness map to `activity`/`libyear`); a newly introduced vulnerability still fails, since suppressing one needs an explicit advisory id the diff regression doesn't carry.
37
+ - **The fail-open on the vulnerability gate is closed on both paths.** `--fail-if-vulnerable=<severity>` no longer passes a confirmed advisory that carries no CVSS score (an unscored advisory read as "below threshold" and cleared the gate, worst for a freshly disclosed CVE); it now fails closed with a per-gem stderr note (see Upgrading). And the native path no longer drops a confirmed advisory when its deps.dev detail fetch fails (429/timeout/5xx); it previously `filter_map`'d the failure away and zeroed `vulnerability_count`, it now keeps a minimal advisory so the finding survives and the fail-closed logic applies.
38
+ - **deps.dev CVE aliases are surfaced correctly.** The v3alpha API returns aliases as bare id strings, not objects; the parser read `a["id"]` on a string and dropped every alias, so cross-referenced CVE/GHSA ids vanished. It now tolerates both shapes (and coerces a drifted non-string alias to a string, warning once, so it can't crash the advisory merge and read a vulnerable gem as clean).
39
+ - **A multi-range advisory no longer hides a stuck cap or names a downgrade as the fix.** OSV lists a fixed version per affected range; the below-the-fix check used the global-minimum fix, so a downgrade to an older line could read as "patchable in place" (a false negative) or be named as the fix. It now considers only fixes above the version in hand.
40
+ - **A pinned version the registry can't resolve reads `unknown`, not `ok`.** A nonexistent or yanked pinned version on the `--sbom` path rode the fresh package date to a false `ok`; it now reports `unknown`, with a single retry distinguishing a real 404 from a transient miss.
41
+ - **Graceful degradation on real SBOM/CLI input.** A git-scheme or scp `SOURCE_REPO` URL from deps.dev now parses correctly (recovering repo signals and silencing a warning flood); a malformed PURL (`pkg:npm/@1.0.0`, bad `%`-encoding) degrades to `unassessable` instead of backtracing and dropping every other dependency's verdict; and bad CLI input exits 2 with a friendly error instead of a stack trace (see Upgrading).
42
+ - **The published JSON Schema matches real output.** It had rejected any report containing a vulnerability, because OSV enrichment adds fields (`osv_severity`, `fixed_versions`, and more) the strict `additionalProperties: false` schema didn't list. The schema now covers them (and the poison/below-the-fix keys), and the contract test validates enriched output so it can't drift silently again.
43
+
44
+ ## [2.0.0] - 2026-06-14
45
+
5
46
  ### Upgrading to 2.0
6
47
 
7
48
  2.0 is a major bump because two changes can alter `--fail-if-*` outcomes on upgrade (the CLI flags and JSON `schema_version` are otherwise backward-compatible):
data/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  **How do you know if your Ruby dependencies are still maintained?**
4
4
 
5
- `bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your dependency graph direct and transitive by default (`--direct-only` to narrow), with granular, committed suppression via `.still_active.yml`.
5
+ `bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities (flagging those with **no fixed version available**, the ones you can't upgrade out of), libyear drift, and archived repos for every gem in your dependency graph, direct and transitive by default (`--direct-only` to narrow), with granular, committed suppression via `.still_active.yml`.
6
+
7
+ Ruby-first, but the maintenance lens isn't Ruby-only: point [`--sbom`](#cross-ecosystem-sbom-audit---sbom) at a CycloneDX SBOM and `still_active` audits your **npm, PyPI, Cargo, Go, Maven, and NuGet** dependencies the same way it does gems.
6
8
 
7
9
  Findings ship as **terminal / markdown / JSON / SARIF / CycloneDX** — SARIF lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`; CycloneDX feeds Trivy / Dependency-Track / Snyk. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep.
8
10
 
@@ -34,15 +36,16 @@ Ruby 4.0.1 (latest)
34
36
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
35
37
  | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
36
38
  | Outdated versions | Yes | - | Yes | Yes |
37
- | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
39
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + OSV + ruby-advisory-db) |
38
40
  | Libyear drift | - | - | Yes | Yes |
39
41
  | **Last commit activity** | - | - | - | **Yes** |
40
42
  | **Archived repo detection** | - | - | - | **Yes** |
41
43
  | **OpenSSF Scorecard** | - | - | - | **Yes** |
42
44
  | **Yanked version detection** | - | - | - | **Yes** |
43
45
  | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
46
+ | **Cross-ecosystem** (npm/PyPI/Cargo/Go/Maven/NuGet) | - | - | - | **Yes** (`--sbom`) |
44
47
  | GitLab support | - | - | - | Yes |
45
- | CI quality gates | - | Exit code | - | Yes (4 flags) |
48
+ | CI quality gates | - | Exit code | - | Yes (6 flags) |
46
49
  | Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
47
50
 
48
51
  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` reads `ruby-advisory-db` and `still_active` reads `deps.dev`, which sometimes diverge. **If `bundler-audit` is installed alongside `still_active`, we read its `ruby-advisory-db` checkout too and merge the results** (deduplicated, each advisory tagged with its `source`) — so running both no longer means reconciling two different vuln counts by hand.
@@ -85,7 +88,9 @@ still_active --markdown
85
88
  3. `GH_TOKEN` environment variable (`gh` CLI convention)
86
89
  4. `gh auth token` (if `gh` is installed and authenticated)
87
90
 
88
- 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. When a rate-limit response comes back with a reset that's near (within 60 seconds, typically a secondary/burst limit that the concurrent fan-out can trip even with a token), still_active waits it out and retries rather than dropping that gem's signals; a far-off reset (you've exhausted the hourly limit) isn't waited out, so add a token or run less often.
91
+ With a token, GitHub repo signals (archived, last commit) come from the GitHub API (limit 5000 requests/hour). When a rate-limit response comes back with a reset that's near (within 60 seconds, typically a secondary/burst limit that the concurrent fan-out can trip even with a token), still_active waits it out and retries rather than dropping that gem's signals; a far-off reset (you've exhausted the hourly limit) isn't waited out, so run less often.
92
+
93
+ **Without a token**, the unauthenticated GitHub API is capped at 60 requests/hour — unusable past a handful of gems. So still_active falls back to [ecosyste.ms](https://ecosyste.ms) for GitHub repo signals (5000 anonymous requests/hour), which keeps a large Gemfile working with no token at all. The fallback is GitHub-only: ecosyste.ms doesn't track commit recency for GitLab/Codeberg, so those hosts still need their own token (or report `unknown`) when unauthenticated. A token still gives the freshest data and unlocks `--unreleased-commits`, so prefer one in CI. To be a good citizen of the free ecosyste.ms service, you can join its "polite pool" (a higher rate limit, and they can reach you) by passing a contact email via `--ecosystems-email=you@example.com` or `STILL_ACTIVE_ECOSYSTEMS_EMAIL` — optional, since the anonymous limit already covers a typical lockfile.
89
94
 
90
95
  GitLab cascade mirrors GitHub: `--gitlab-token` → `GITLAB_TOKEN` → `glab auth status --show-token`. Optional for public repos, required for private ones.
91
96
 
@@ -104,6 +109,7 @@ Usage: still_active [options]
104
109
 
105
110
  --gemfile=GEMFILE path to gemfile
106
111
  --gems=GEM,GEM2,... Gem(s)
112
+ --sbom=PATH audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget); JSON output
107
113
  --terminal Coloured terminal output (default in TTY)
108
114
  --markdown Markdown table output
109
115
  --json JSON output (default when piped)
@@ -118,6 +124,7 @@ Usage: still_active [options]
118
124
  --gitlab-token=TOKEN GitLab personal access token for API calls
119
125
  --artifactory-token=TOKEN Artifactory token for private gem registry API calls
120
126
  --artifactory-host=HOST Artifactory host allowed to receive the global token (e.g. my-org.jfrog.io)
127
+ --ecosystems-email=EMAIL Contact email to join the ecosyste.ms polite pool (higher rate limit)
121
128
  --simultaneous-requests=QTY Number of simultaneous requests made
122
129
  --safe-range-end=YEARS maximum years since last release considered safe, no warning (default 1.5)
123
130
  --warning-range-end=YEARS maximum years since last release that triggers a warning, beyond this is critical (default 3)
@@ -126,6 +133,9 @@ Usage: still_active [options]
126
133
  --fail-if-vulnerable[=SEVERITY]
127
134
  Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY)
128
135
  --fail-if-outdated=LIBYEARS Exit 1 if any gem exceeds LIBYEARS behind latest
136
+ --fail-if-poison[=TIER] Exit 1 on a poison-pill at or above TIER (note|warning|critical; default warning)
137
+ --fail-if-language-ceiling[=TIER]
138
+ Exit 1 on a language-runtime ceiling (Ruby/Python; default: EOL-forced only; =note also gates latest-not-yet)
129
139
  --ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
130
140
  --critical-warning-emoji=EMOJI
131
141
  --futurist-emoji=EMOJI
@@ -211,11 +221,45 @@ still_active --cyclonedx --cyclonedx-version=1.7
211
221
 
212
222
  Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java`/Dependency-Track and `cyclonedx-go`/Trivy both cap at 1.6 as of 2026); `--cyclonedx-version=1.7` opts into the latest. Gem name/version/`purl`/licenses and vulnerabilities map to native CycloneDX fields; maintenance signals with no native home (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`. The `serialNumber` is content-derived, so two SBOMs of the same lockfile differ only by their generation timestamp.
213
223
 
224
+ ### Cross-ecosystem SBOM audit (`--sbom`)
225
+
226
+ still_active is Ruby-first, but the maintenance lens isn't Ruby-only. Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft), Trivy, or any producer) and it assesses `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` packages the same way it does gems, sourcing signals from [deps.dev](https://deps.dev) and ecosyste.ms instead of Bundler:
227
+
228
+ ```bash
229
+ syft dir:. -o cyclonedx-json > sbom.json
230
+ still_active --sbom=sbom.json # JSON report, keyed "ecosystem/name@version"
231
+ ```
232
+
233
+ Each dependency carries the same `activity_level` and lifecycle `status` as the Ruby path, and the `summary` reports the worst status across the SBOM. When the SBOM marks dev-vs-prod scope (CycloneDX `scope`, or a `cyclonedx-npm`-style development property), each dependency also carries a `production` boolean so a consumer can separate prod risk from test debt; generators that don't mark it (Syft drops the label) leave the key absent, which reads as unknown rather than a false all-clear. `--sbom` is mutually exclusive with `--gemfile`/`--gems` and always emits JSON (its shape differs from the Ruby audit, so it omits the `$schema` contract).
234
+
235
+ The SBOM is **untrusted input**: only ecosystem/name/version are read from it, the repository is resolved from deps.dev (never a URL the SBOM supplies, so a hostile SBOM can't redirect a lookup), and anything deps.dev doesn't index degrades to `unknown` rather than a fabricated `ok`. Packages it can't assess (unsupported ecosystems, a component with no version or PURL, or one whose lookup failed against a rate-limited/flaky deps.dev) are surfaced in an `unassessable` list plus a stderr count, never silently dropped, so an audit can't read "all clear" while ignoring what it skipped; a present-but-unreadable SBOM errors rather than reporting a clean empty audit. OS packages land in `unassessable` too: the differentiated play is **maintenance**, not vulnerability scanning, so compose Trivy/Grype for CVEs.
236
+
237
+ The activity (`--fail-if-critical`/`--fail-if-warning`), vulnerability (`--fail-if-vulnerable`), and libyear (`--fail-if-outdated`) gates all apply to SBOM dependencies, which carry `libyear` and `up_to_date` the same as the Ruby path. **The full poison-pill signal is scoped to flat-resolution ecosystems** (rubygems, pypi): there a dormant gem's below-latest cap forces the whole tree onto the old version. npm nests multiple versions and cargo lets majors coexist (and their caret default makes "below latest major" the norm, not a hazard), so the plain below-latest cap there is subtree-local noise and stays suppressed.
238
+
239
+ **The security "below the fix" case, though, does travel to npm and cargo.** When a *dormant or archived* package pins a dependency below the version that patches a HIGH-or-critical advisory, still_active flags it — at patch precision (npm/cargo fixes are mostly same-major patch bumps), and only when *every* resolved copy the constraint governs is vulnerable (a safe copy elsewhere in the tree, or a patched copy the constraint can reach, clears it), so it stays silent unless the tree is genuinely stuck. Example: an archived `request@2.88.2` pinning `form-data ~2.3.2` below the fix for a critical CVE — you can't patch it without replacing `request`. Other Ruby-shaped policy features still don't travel to the SBOM path: `.still_active.yml` suppressions and `--ignore` key on the gem name (SBOM results key on `ecosystem/name@version`).
240
+
241
+ #### Which signal covers which path
242
+
243
+ Most maintenance signals apply everywhere; a few are deliberately scoped (see [`docs/rules.md`](docs/rules.md) for the full rule reference).
244
+
245
+ | Signal (rule) | Ruby (native) | `--sbom` (cross-ecosystem) |
246
+ | ------------- | ------------- | -------------------------- |
247
+ | Archived repo (SA001) | Yes | Yes (npm, PyPI, Cargo, Go, Maven, NuGet) |
248
+ | Abandoned / no recent release (SA002) | Yes | Yes (all of the above) |
249
+ | Low OpenSSF Scorecard (SA005) | Yes | Yes (all of the above) |
250
+ | Libyear drift (SA004) | Yes | Yes (all of the above) |
251
+ | Vulnerabilities (SA003) | Yes (deps.dev + OSV + ruby-advisory-db) | Yes (deps.dev + OSV) |
252
+ | &nbsp;&nbsp;↳ "below the fix" (dead capper pins a vulnerable dep) | Yes | npm, Cargo, PyPI (patch precision) |
253
+ | Poison-pill / compatibility ceiling (SA008) | Yes (rubygems) | PyPI only (flat resolution; npm/Cargo suppressed) |
254
+ | Language-runtime ceiling (SA009) | Yes (`ruby_version`) | Python only (`requires_python`) |
255
+ | Ruby EOL (SA006) | Yes | n/a (Ruby-runtime signal) |
256
+ | Yanked version (SA007) | Yes | n/a |
257
+
214
258
  ### SARIF output (GitHub Code Scanning)
215
259
 
216
260
  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.
217
261
 
218
- > **See it live:** this repo audits itself on every push. Browse the live findings in the [Code Scanning Security tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen) — currently 2× `SA005` (low OpenSSF Scorecard).
262
+ > **See it live:** this repo audits itself on every push. Browse the current open findings in the [Code Scanning Security tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen).
219
263
 
220
264
  ```bash
221
265
  still_active --sarif # writes still_active.sarif.json
@@ -257,7 +301,7 @@ jobs:
257
301
  with: { sarif_file: still_active.sarif.json }
258
302
  ```
259
303
 
260
- Rule reference (SA001–SA007) and how to suppress: see [`docs/rules.md`](docs/rules.md).
304
+ Rule reference (SA001–SA009) and how to suppress: see [`docs/rules.md`](docs/rules.md).
261
305
 
262
306
  ### Baseline diff (PR review)
263
307
 
@@ -353,6 +397,8 @@ still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
353
397
  fail_if_critical: true
354
398
  fail_if_vulnerable: high # true, or a minimum severity: low|medium|high|critical
355
399
  fail_if_outdated: 3 # libyears
400
+ fail_if_poison: warning # true (=warning), or a tier: note|warning|critical (severity scales with majors-behind)
401
+ fail_if_language_ceiling: true # true = EOL-forced ceilings only; :note also gates latest-not-yet (fluctuates with the runtime's release calendar)
356
402
  unreleased_commits: true
357
403
  output: json # terminal | markdown | json
358
404
  direct_only: true # audit only declared deps, not the full transitive graph (--direct-only)
@@ -370,7 +416,7 @@ ignore:
370
416
 
371
417
  # Accept staleness on a vendored gem, but still fail if it gets a CVE
372
418
  - gem: legacy_thing
373
- signal: activity # activity | vulnerability | libyear
419
+ signal: activity # activity | vulnerability | libyear | poison | language_ceiling
374
420
  reason: "vendored, intentionally frozen"
375
421
 
376
422
  # A bare gem name keeps the old whole-gem behaviour (mutes every signal)
@@ -379,7 +425,7 @@ ignore:
379
425
 
380
426
  Design:
381
427
 
382
- - **Granularity.** Key by `advisory:` (one CVE) and/or `signal:` (`activity` / `vulnerability` / `libyear`), not the whole gem. A vulnerability suppression **must** name an advisory id, so a newly disclosed CVE on the same gem is never pre-silenced. `--ignore=GEM` (and a bare gem-name entry) still mute everything, by design.
428
+ - **Granularity.** Key by `advisory:` (one CVE) and/or `signal:` (`activity` / `vulnerability` / `libyear` / `poison`), not the whole gem. A vulnerability suppression **must** name an advisory id, so a newly disclosed CVE on the same gem is never pre-silenced. `--ignore=GEM` (and a bare gem-name entry) still mute everything, by design.
383
429
  - **Precedence.** CLI flag > env var > config file > default. A CLI flag always wins; `--ignore` unions with the file's suppressions rather than replacing them. Secrets (tokens) and invocation-specific paths (`--gemfile`, `--gems`, `--baseline`, output paths) are intentionally **not** read from the file, so a committed config never carries a credential.
384
430
  - **Expiry.** An `expires:` date makes accepted risk visible: once it passes, the entry stops applying and the finding fails the gate again (Trivy-style), so a suppression can't rot silently. `reason:` is optional but recommended. A suppression naming a gem that isn't in your dependency graph (a typo, or a gem you've since removed) is also surfaced as a warning, so dead entries don't accumulate.
385
431
  - **bundler-audit, suggested not absorbed.** still_active never silently inherits another tool's ignore list: auto-importing `.bundler-audit.yml` would suppress vulnerabilities you only accepted in bundler-audit's context, with no reason or expiry here. Instead, when `--fail-if-vulnerable` is on and an un-imported `.bundler-audit.yml` is present, it prints a one-line hint suggesting the `import:` line, leaving the opt-in to you.
@@ -426,8 +472,9 @@ It is **opt-in and GitHub-only**: enabling it adds one extra API call per GitHub
426
472
  ### Data sources
427
473
 
428
474
  - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org), [GitHub Packages](https://docs.github.com/en/packages), or [JFrog Artifactory](https://jfrog.com/artifactory/) gem registries
429
- - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest), [GitLab](https://docs.gitlab.com/ee/api/), or [Forgejo/Gitea](https://forgejo.org/docs/latest/user/api-usage/) (Codeberg) API
430
- - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
475
+ - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest), [GitLab](https://docs.gitlab.com/ee/api/), or [Forgejo/Gitea](https://forgejo.org/docs/latest/user/api-usage/) (Codeberg) API — or, for GitHub repos when no token is configured, from [ecosyste.ms](https://ecosyste.ms) ([repos API](https://repos.ecosyste.ms)). ecosyste.ms data is licensed [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
476
+ - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API (also the cross-ecosystem source on the `--sbom` path)
477
+ - **Advisory severity labels and fixed-version ranges** from [OSV](https://osv.dev) (`api.osv.dev`), which drive the "below the fix" signal. An advisory that publishes only a CVSS **4.0** vector stays unscored unless you install the optional [`cvss-suite`](https://rubygems.org/gems/cvss-suite) gem (`gem install cvss-suite`), which computes its 4.0 base score; without it the gate still fires on the OSV/GHSA severity label, it just skips the number.
431
478
  - **Additional advisories** from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db), merged in when `bundler-audit` is installed alongside (run `bundle audit update` to keep its checkout current)
432
479
  - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
433
480
  - **Alternative gem leads** (with `--alternatives`) from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data
@@ -42,6 +42,14 @@ module StillActive
42
42
  def activity_level(gem_data)
43
43
  return :archived if gem_data[:archived]
44
44
 
45
+ release_recency_level(gem_data)
46
+ end
47
+
48
+ # Recency verdict from release/commit dates alone, ignoring the archived
49
+ # flag: :ok, :stale, :critical, or :unknown. Lets the lifecycle status tell
50
+ # an archived-but-still-publishing gem (development moved to a monorepo) from
51
+ # a genuinely dormant one.
52
+ def release_recency_level(gem_data)
45
53
  activity = last_activity(gem_data)
46
54
  return :unknown if activity.nil?
47
55
 
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Reads a declared dependency constraint (the requirement string a package
5
+ # publishes for one of its runtime deps, e.g. "~> 4.2", "< 5.0", "= 1.2.3") and
6
+ # answers how much it caps you: is there an upper bound, and how many majors
7
+ # behind the dependency's current latest does it hold you?
8
+ #
9
+ # This is the constraint half of the poison-pill signal (constraint-tightness x
10
+ # maintenance-state): a dormant package that caps a still-evolving dep below its
11
+ # latest major holds your tree hostage with no hope of the cap lifting. A fixed
12
+ # cap gets more poisonous purely with time, as the capped dep ships new majors
13
+ # while the cap stays frozen.
14
+ #
15
+ # Grammar is coarse but cross-ecosystem: Ruby pessimistic (~>), pip compatible
16
+ # (~=), semver caret/tilde (^ ~), exact (= == ===), and plain </<=. Precision is
17
+ # at the MAJOR level, which is all the signal needs ("N majors behind").
18
+ module ConstraintHelper
19
+ extend self
20
+
21
+ # The constraint kinds that make a dep a poison-pill candidate: an upper bound
22
+ # that blocks upgrades, or an exact pin that freezes the whole tree. A
23
+ # `:permissive` constraint is neither. Shared with the workflow so the poison
24
+ # definition lives in one place.
25
+ POISON_KINDS = [:ceiling, :exact_pin].freeze
26
+
27
+ # An exact pin: = / == / === before a digit (NOT >= or <=).
28
+ EXACT_PIN = /\A={1,3}\s*v?\d/
29
+ # A bare version with no operator (npm/cargo exact form), e.g. "1.2.3", "v1".
30
+ BARE_VERSION = /\Av?\d+(?:\.\d+)*\z/
31
+ # Operator + version at the head of a clause.
32
+ CLAUSE = /\A(===?|=|<=|<|~>|~=|\^|~)?\s*v?(\d+(?:\.\d+)*)/
33
+ # Whitespace that separates npm AND clauses (">=1.2.0 <2.0.0"), i.e. a space
34
+ # before an operator. It won't split a Ruby clause like "~> 4.2" (the space
35
+ # there precedes a digit, not an operator). The quantifier is POSSESSIVE
36
+ # (`\s++`, not `\s+`): a lookahead after a greedy `\s+` backtracks once per
37
+ # trailing space, which is quadratic on a long run of spaces (ReDoS on the
38
+ # lockfile-derived requirement string). Possessive matching never backtracks.
39
+ AND_SEPARATOR = /\s++(?=[<>=~^])/
40
+ # A real declared requirement ("< 5.0, >= 4.0.1", "^1 || ^2 || ^3") is short.
41
+ # Anything past this is not a parseable constraint, so cap the input up front:
42
+ # it bounds every regex below to linear work and refuses to mint a pill from
43
+ # garbage (an over-long string reads as permissive, never a false ceiling).
44
+ MAX_REQUIREMENT_LENGTH = 256
45
+
46
+ # => { kind: :permissive|:ceiling|:exact_pin, majors_behind: Integer }
47
+ def analyze(requirement:, dep_latest:)
48
+ return { kind: :permissive, majors_behind: 0 } if requirement.to_s.length > MAX_REQUIREMENT_LENGTH
49
+
50
+ # `||` is an OR-range (npm): satisfied by ANY branch, so an unbounded branch
51
+ # lifts the cap entirely and the effective ceiling is the LOOSEST branch.
52
+ # Reading only the first branch would invent a false pill on the common
53
+ # `^2.0.0 || ^3.0.0` "supports several majors" form.
54
+ branches = requirement.to_s.split("||").map { |branch| clauses_of(branch) }
55
+ branches = [[]] if branches.empty?
56
+ ceilings = branches.map { |clauses| branch_ceiling(clauses) }
57
+ latest_major = major(dep_latest)
58
+
59
+ return { kind: :permissive, majors_behind: 0 } if ceilings.any?(&:nil?)
60
+
61
+ behind = latest_major ? [latest_major - ceilings.max, 0].max : 0
62
+ kind = branches.all? { |clauses| all_exact?(clauses) } ? :exact_pin : :ceiling
63
+ { kind: kind, majors_behind: behind }
64
+ end
65
+
66
+ # Severity tiers for a compatibility ceiling, worst-last (mirrors
67
+ # StatusHelper::SEVERITY -- an ordinal set compared by index, never a numeric
68
+ # composite). :note is FYI, :warning is review-and-plan, :critical is act-now.
69
+ SEVERITY = [:note, :warning, :critical].freeze
70
+
71
+ # Tier one ceiling finding. Magnitude-driven: the real-project dogfood showed
72
+ # Ruby findings are bimodal -- 1 major behind is trivial (a done gem pinning an
73
+ # old minor), 3+ is a genuine upgrade wall (e.g. capping actionmailer below
74
+ # Rails 6). Popularity was rejected as an input (it ranks the framework blocker
75
+ # below a utility) and a vulnerable-gem escalator was rejected (that is the
76
+ # vulnerability finding's job, not the cap's).
77
+ def constraint_severity(finding)
78
+ # A runtime ceiling that strands you on an end-of-life Ruby is act-now: no
79
+ # patched runtime is reachable. This short-circuit lets the language-ceiling
80
+ # signal (which carries no majors_behind) reuse the same tierer as poison.
81
+ return :critical if finding[:eol_forced]
82
+
83
+ case finding[:majors_behind]
84
+ when 2 then :warning
85
+ when 3.. then :critical
86
+ else :note
87
+ end
88
+ end
89
+
90
+ # The worst tier across a gem's CEILING findings (exact-pins are a milder
91
+ # hazard, not poison, and don't carry a tier), or nil when there are none.
92
+ def worst_severity(findings)
93
+ tiers = findings.filter_map { constraint_severity(_1) if _1[:kind] == :ceiling }
94
+ tiers.max_by { SEVERITY.index(_1) }
95
+ end
96
+
97
+ # For the --fail-if-poison[=TIER] gate: is `severity` at or above `threshold`?
98
+ def severity_at_or_above?(severity, threshold)
99
+ return false if severity.nil?
100
+
101
+ SEVERITY.index(severity) >= SEVERITY.index(threshold)
102
+ end
103
+
104
+ # True when `version` sits at or below the highest major the cap allows -- i.e.
105
+ # you could upgrade the capped dep to it WITHOUT breaking the cap. The ceiling
106
+ # major is recovered from the poison finding itself (dep_latest minus
107
+ # majors_behind), at the same major precision the poison signal already uses, so
108
+ # a fix that lands OUTSIDE the cap ("below the fix") is detected without
109
+ # re-parsing the raw requirement and its pre/dev-release quirks (`< 5.0.0dev`).
110
+ #
111
+ # Precision is MAJOR-level by design, and that errs deliberately toward reachable:
112
+ # a within-major cap (`~> 4.2.1`, `< 5.5`) reads a same-major fix (4.9.0, 5.29.6)
113
+ # as reachable even though the cap forbids it. So a genuinely-stuck within-major
114
+ # cap is UNDER-reported (downgraded to the still-visible "vulnerable" tier), never
115
+ # falsely promoted -- when this DOES return false for every fix, the below-the-fix
116
+ # claim is sound (every fix is in a strictly higher major than the cap allows).
117
+ # Unparseable/absent inputs read false (never claims reachable when we can't tell).
118
+ def reachable_within_cap?(finding, version)
119
+ fix_major = major(version)
120
+ latest_major = major(finding[:dep_latest])
121
+ behind = finding[:majors_behind]
122
+ return false if fix_major.nil? || latest_major.nil? || behind.nil?
123
+
124
+ fix_major <= latest_major - behind
125
+ end
126
+
127
+ # The poison condition: a below-latest ceiling or exact pin. A permissive
128
+ # constraint, or a cap at/above the dep's latest major, is not a pill.
129
+ def poison_ceiling?(requirement:, dep_latest:)
130
+ result = analyze(requirement: requirement, dep_latest: dep_latest)
131
+ POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
132
+ end
133
+
134
+ # Build the poison-pill receipts for a package's declared runtime deps. Each
135
+ # `dep` is { package_name:, requirements: }; the block resolves a dep name to
136
+ # its current latest version string (or nil when unresolvable, which drops the
137
+ # dep rather than guessing). Returns only the below-latest ceilings and exact
138
+ # pins, each as a self-contained receipt. Shared by the native Bundler path and
139
+ # the cross-ecosystem lens, which differ only in how they resolve dep_latest.
140
+ def poison_findings(deps)
141
+ deps.filter_map do |dep|
142
+ dep_latest = yield(dep[:package_name])
143
+ next if dep_latest.nil?
144
+
145
+ result = analyze(requirement: dep[:requirements], dep_latest: dep_latest)
146
+ next unless POISON_KINDS.include?(result[:kind]) && result[:majors_behind].positive?
147
+
148
+ {
149
+ dependency: dep[:package_name],
150
+ requirement: dep[:requirements],
151
+ dep_latest: dep_latest,
152
+ majors_behind: result[:majors_behind],
153
+ kind: result[:kind],
154
+ }
155
+ end
156
+ end
157
+
158
+ # Select the worst `limit` findings for a display receipt, plus the full count
159
+ # so a renderer can say "+N more" / "N total". Worst-first by majors_behind,
160
+ # ties broken by dependency name so the output is stable and diffable. Shared
161
+ # by the terminal and markdown renderers so the selection can't drift.
162
+ def top_findings(findings, limit: 3)
163
+ ranked = findings.sort_by { |f| [-f[:majors_behind], f[:dependency].to_s] }
164
+ { shown: ranked.first(limit), total: findings.length }
165
+ end
166
+
167
+ private
168
+
169
+ # Split one OR-branch into its AND clauses: comma-separated (Ruby/pip) or
170
+ # space-separated (npm ">=1.2.0 <2.0.0").
171
+ def clauses_of(branch)
172
+ branch.split(",").flat_map { |part| part.strip.split(AND_SEPARATOR) }.map(&:strip).reject(&:empty?)
173
+ end
174
+
175
+ # The tightest ceiling major within one AND-branch, or nil when the branch has
176
+ # no upper bound at all (a lone lower bound = permissive, lifts the OR cap).
177
+ def branch_ceiling(clauses)
178
+ clauses.filter_map { |clause| clause_ceiling_major(clause) }.min
179
+ end
180
+
181
+ def all_exact?(clauses)
182
+ !clauses.empty? && clauses.all? { |clause| clause.match?(EXACT_PIN) || clause.match?(BARE_VERSION) }
183
+ end
184
+
185
+ def clause_ceiling_major(clause)
186
+ match = clause.match(CLAUSE)
187
+ return unless match
188
+
189
+ operator = match[1]
190
+ major_of = major(match[2])
191
+ case operator
192
+ # `< X.0.0` excludes major X entirely (max usable major is X-1); `< X.Y`
193
+ # with a non-zero minor still allows major X.
194
+ when "<" then all_zero_after_major?(match[2]) ? major_of - 1 : major_of
195
+ when "<=", "~>", "~=", "^", "~", "=", "==", "===", nil then major_of
196
+ end # `>`/`>=` fall through to nil: a lower bound imposes no ceiling
197
+ end
198
+
199
+ def all_zero_after_major?(version)
200
+ version.split(".").drop(1).all? { |part| part.to_i.zero? }
201
+ end
202
+
203
+ def major(version)
204
+ digits = version.to_s[/\d+/]
205
+ digits&.to_i
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # Computes a CVSS score from a vector string, across v2/v3/v4. deps.dev stores only
5
+ # CVSS 3.x, so a CVSS-4-only advisory (the flagship protobuf case) arrives with no
6
+ # numeric score; OSV carries the v4 vector, and this turns it into the number the
7
+ # SARIF security-severity and CycloneDX rating want.
8
+ #
9
+ # cvss-suite is an OPTIONAL, undeclared dependency: it over-constrains its own deps
10
+ # (an exact `bundler` pin, a `bigdecimal ~> 3.1` cap), the poison-pill pattern
11
+ # still_active itself flags, so hard-requiring it would force that liability on every
12
+ # user and trip our own audit. CvssHelper soft-requires it; install cvss-suite (or run
13
+ # the distribution that bundles it) to light it up.
14
+ #
15
+ # Returns cvss-suite's `overall_score`: for the base-only vectors OSV publishes that
16
+ # IS the base score, but a vector carrying threat/environmental metrics would fold
17
+ # those in -- which is why the severity band floors at the authoritative GHSA label
18
+ # (VulnerabilityHelper.advisory_severity) and this number never lowers it: the label
19
+ # drives gating and SARIF level, the number only sharpens display. Fails safe: an
20
+ # absent gem or an absent/unparseable vector yields nil, never a crash.
21
+ module CvssHelper
22
+ extend self
23
+
24
+ def score(vector)
25
+ return unless available?
26
+ return if vector.nil? || vector.to_s.empty?
27
+
28
+ cvss = CvssSuite.new(vector.to_s)
29
+ cvss.valid? ? cvss.overall_score : nil
30
+ rescue StandardError => e
31
+ # We only reach here with cvss-suite loaded (available?), so a raise is the
32
+ # installed gem failing this call site -- an incompatible version, a renamed
33
+ # `overall_score` -- not a bad vector, which cvss-suite reports via valid?.
34
+ # Fail safe to nil (the number is display-only, never gates), but don't
35
+ # swallow it: an opted-in-but-broken scorer that emitted nothing would read
36
+ # as "no advisories are scored". Warn once per run (not per advisory),
37
+ # matching OsvClient.enrich rather than staying silent.
38
+ warn_scorer_failed(e)
39
+ nil
40
+ end
41
+
42
+ # Memoized soft-require: true once cvss-suite loads, false when it isn't installed.
43
+ def available?
44
+ return @available unless @available.nil?
45
+
46
+ @available = begin
47
+ require "cvss_suite"
48
+ true
49
+ rescue LoadError
50
+ false
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def warn_scorer_failed(error)
57
+ return if @warned
58
+
59
+ @warned = true
60
+ $stderr.puts("warning: cvss-suite scoring failed: #{error.class} (#{error.message}); CVSS numbers unavailable this run, check your cvss-suite version")
61
+ end
62
+ end
63
+ end
@@ -142,15 +142,31 @@ module StillActive
142
142
  end
143
143
 
144
144
  def rating(advisory)
145
- score = advisory[:cvss3_score] || advisory[:cvss2_score]
145
+ # effective_score drops deps.dev's cvss3=0 sentinel; an unscored advisory
146
+ # gets no numeric rating (honest) rather than a masquerading 0.0. A CVSS-4-only
147
+ # advisory now carries the OSV-computed v4 score, so it gets a real rating too.
148
+ score = VulnerabilityHelper.effective_score(advisory)
146
149
  return if score.nil?
147
150
 
148
- method = advisory[:cvss3_score] ? "CVSSv3" : "CVSSv2"
149
- rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => method }
150
- rating["vector"] = advisory[:cvss3_vector] if advisory[:cvss3_vector]
151
+ rating = { "score" => score, "severity" => VulnerabilityHelper.highest_severity([advisory]) || "unknown", "method" => rating_method(advisory) }
152
+ vector = advisory[:cvss3_vector] || advisory[:cvss_vector]
153
+ rating["vector"] = vector if vector
151
154
  rating
152
155
  end
153
156
 
157
+ # Names the CVSS version behind the score so a v4-derived rating isn't mislabelled
158
+ # CVSSv2. deps.dev's v3/v2 win; otherwise the OSV-enriched vector's version.
159
+ def rating_method(advisory)
160
+ return "CVSSv3" if advisory[:cvss3_score]&.positive?
161
+ return "CVSSv2" if advisory[:cvss2_score]&.positive?
162
+
163
+ case advisory[:cvss_version]
164
+ when "4.0" then "CVSSv4"
165
+ when "2.0" then "CVSSv2"
166
+ else "CVSSv3"
167
+ end
168
+ end
169
+
154
170
  def boolean_property(value)
155
171
  return if value.nil?
156
172
 
@@ -19,13 +19,14 @@ module StillActive
19
19
  unknown: nil,
20
20
  }.freeze
21
21
 
22
- def render(diff)
22
+ def render(diff, accepted: [])
23
23
  sections = [
24
24
  "## still_active diff",
25
25
  "",
26
- summary_line(diff),
26
+ summary_line(diff, accepted),
27
27
  "",
28
28
  regressions_section(diff.regressions),
29
+ accepted_section(accepted),
29
30
  added_section(diff.added),
30
31
  removed_section(diff.removed),
31
32
  bumps_section(diff.bumped),
@@ -38,14 +39,16 @@ module StillActive
38
39
 
39
40
  private
40
41
 
41
- def summary_line(diff)
42
- [
42
+ def summary_line(diff, accepted)
43
+ parts = [
43
44
  ["regressions", diff.regressions.size],
44
45
  ["added", diff.added.size],
45
46
  ["removed", diff.removed.size],
46
47
  ["bumped", diff.bumped.size],
47
48
  ["signal-changes", diff.signal_changes.size],
48
- ].map { |label, n| "#{n} #{label}" }.join(" · ")
49
+ ]
50
+ parts << ["accepted", accepted.size] unless accepted.empty?
51
+ parts.map { |label, n| "#{n} #{label}" }.join(" · ")
49
52
  end
50
53
 
51
54
  def regressions_section(regressions)
@@ -55,6 +58,19 @@ module StillActive
55
58
  section("Regressions (CI-failable)", lines)
56
59
  end
57
60
 
61
+ # Regressions a committed .still_active.yml knowingly accepts: shown so the
62
+ # acceptance is visible in the PR comment, but excluded from the CI-failable
63
+ # count above.
64
+ def accepted_section(accepted)
65
+ return "" if accepted.empty?
66
+
67
+ lines = accepted.map do |a|
68
+ reason = a.reason ? " — #{MarkdownEscape.inline(a.reason)}" : ""
69
+ "- **#{a.regression.kind}** #{MarkdownEscape.code_span(a.regression.gem)}#{reason}"
70
+ end
71
+ section("Accepted (suppressed via .still_active.yml)", lines)
72
+ end
73
+
58
74
  def added_section(added)
59
75
  return "" if added.empty?
60
76