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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +57 -10
- data/lib/helpers/activity_helper.rb +8 -0
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +20 -4
- data/lib/helpers/diff_markdown_helper.rb +21 -5
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +17 -2
- data/lib/helpers/markdown_helper.rb +118 -1
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +13 -24
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +105 -3
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +4 -0
- data/lib/helpers/terminal_helper.rb +144 -6
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +212 -9
- data/lib/still_active/config.rb +28 -1
- data/lib/still_active/config_file.rb +27 -0
- data/lib/still_active/deps_dev_client.rb +115 -5
- data/lib/still_active/diff.rb +22 -0
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/options.rb +44 -1
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/sarif/rules.rb +33 -9
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +6 -5
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +201 -8
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +28 -5
- metadata +61 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3541d297e3d8059ffdaee4c1377ab7333fc5d2a54940219d7a4f2611f4c445e
|
|
4
|
+
data.tar.gz: d121da3f25a1db5bbcf5cc3e2351acbaef84076350bccc3074ed869d46881f75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
+
| ↳ "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
|
|
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–
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
rating["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
|
-
]
|
|
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
|
|