still_active 3.0.0.rc5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -2
- data/README.md +27 -2
- data/lib/still_active/artifactory_client.rb +1 -1
- data/lib/still_active/cli.rb +91 -29
- data/lib/still_active/compact_index_client.rb +1 -1
- data/lib/still_active/config.rb +2 -0
- data/lib/still_active/config_file.rb +3 -2
- data/lib/still_active/deps_dev_client.rb +24 -3
- data/lib/still_active/ecosystem_lens.rb +22 -8
- data/lib/still_active/ecosystems_client.rb +1 -1
- data/lib/still_active/forgejo_client.rb +1 -1
- data/lib/still_active/gitlab_client.rb +1 -1
- data/lib/{helpers → still_active/helpers}/activity_helper.rb +1 -1
- data/lib/{helpers → still_active/helpers}/cyclonedx_helper.rb +85 -7
- data/lib/{helpers → still_active/helpers}/libyear_helper.rb +1 -1
- data/lib/{helpers → still_active/helpers}/markdown_helper.rb +19 -0
- data/lib/{helpers → still_active/helpers}/sarif_helper.rb +17 -2
- data/lib/still_active/helpers/sbom_graph.rb +166 -0
- data/lib/{helpers → still_active/helpers}/status_helper.rb +23 -5
- data/lib/{helpers → still_active/helpers}/terminal_helper.rb +15 -0
- data/lib/{helpers → still_active/helpers}/version_helper.rb +14 -2
- data/lib/still_active/options.rb +6 -3
- data/lib/still_active/osv_client.rb +197 -27
- data/lib/still_active/poison_security_correlator.rb +3 -3
- data/lib/still_active/pypi_client.rb +1 -1
- data/lib/still_active/sarif/rules.rb +15 -0
- data/lib/still_active/sbom_reader.rb +117 -2
- data/lib/still_active/sbom_workflow.rb +4 -4
- data/lib/still_active/suppressions.rb +5 -3
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +20 -13
- metadata +34 -33
- /data/lib/{helpers → still_active/helpers}/alternatives_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ansi_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/bot_context.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/bundler_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/catalog_index.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/constraint_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/cvss_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/dependency_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/diff_markdown_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/dotnet_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/emoji_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/endoflife_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/http_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/lockfile_dependency_parser.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/lockfile_indexer.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/markdown_escape.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/pep440_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/python_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ruby_advisory_db.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/ruby_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/runtime_ceiling_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/semver_satisfaction.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/summary_helper.rb +0 -0
- /data/lib/{helpers → still_active/helpers}/vulnerability_helper.rb +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3351319b0427ef0043ecb5a150350499a25b7cb3228814184d3908d09497bc5
|
|
4
|
+
data.tar.gz: ffbc01c085040a9ed570095f133407dce101e4aa9a22689bfe423bc32996cdb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14f7e5c50c8df5291bd1239496f5016d11a2336bf6517a58887ab1a1ac3857dbb7cc3de99767f95f986b0620e3c07b448b081ce41dbb86b1d1c102ed404c3b56
|
|
7
|
+
data.tar.gz: 58624fb08295567a415a9e58a1cefda2e0663144133bd38b6b8563588eb3958971ed52fef07884b4c03ad16fb97b685f661d3a45a240d09d116cd199d699985a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.0.0] - 2026-08-21
|
|
6
|
+
|
|
7
|
+
The maintenance lens goes cross-ecosystem. In one line each:
|
|
8
|
+
|
|
9
|
+
- **`--sbom=PATH`** audits a CycloneDX SBOM, so npm, PyPI, Cargo, Go, Maven and NuGet packages get the same treatment gems do, and `--sbom --cyclonedx` hands back an **enriched SBOM** for Dependency-Track.
|
|
10
|
+
- **Poison-pill (SA008)**: a dormant package capping one of your dependencies below its own security fix, the case nothing else reports.
|
|
11
|
+
- **Language-runtime ceiling (SA009)**: a pin that strands you on an end-of-life Ruby, Python or .NET.
|
|
12
|
+
- **Maintainer-declared deprecation (SA010)**: not inferred from dates, declared by the person who publishes the package.
|
|
13
|
+
- **Two fail-opens on the vulnerability gate are closed**, which is why this is a major bump: a previously-green pipeline can go red. Read "Upgrading to 3.0" before deploying it.
|
|
14
|
+
|
|
5
15
|
### Upgrading to 3.0
|
|
6
16
|
|
|
7
17
|
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:
|
|
@@ -14,10 +24,20 @@
|
|
|
14
24
|
- **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
25
|
- **Re-capture your `--baseline` JSON** after upgrading: the new signals and fields show as changes on the first run.
|
|
16
26
|
|
|
27
|
+
- **New SARIF rule SA010 (deprecated package) and a new `status` value `"deprecated"`.** SA010 is emitted at `error` with security-severity 7.5, so a code-scanning policy that fails on `error` can newly fire on a package whose maintainer has deprecated it. Exit codes are unaffected: the `--fail-if-*` gates read activity, vulnerability, libyear, poison and language-ceiling, not `status`, so no previously-green `still_active` run turns red on this alone. The new **`--fail-if-deprecated`** gate defaults to **off**, like the other opt-in gates, so it cannot break an existing pipeline unless you ask for it. A consumer that switches exhaustively on `status` must handle `"deprecated"`; `schema_version` stays `1`, as this is an added enum value rather than a renamed or removed field.
|
|
28
|
+
|
|
17
29
|
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
30
|
|
|
19
31
|
### Added
|
|
20
32
|
|
|
33
|
+
- **`--sbom --cyclonedx` emits an enriched SBOM instead of erroring.** SBOM in, SBOM out: the input annotated with the maintenance signals it had no way to carry, so a cross-ecosystem audit can feed Dependency-Track (or anything else that ingests CycloneDX) rather than only being read by a human. This previously errored on the grounds that SBOM-in/SBOM-out would need per-ecosystem PURL reconstruction. It turned out to need none: the input already carries the authoritative PURL, so each component re-emits the one it arrived with, and a consumer matches our output exactly as it matched the input. That is what keeps maven's `group/artifact` and Go's module paths correct, the two shapes hand-rolled reconstruction gets subtly wrong. Each component carries `still_active:`-namespaced properties for the folded `status` verdict, the raw `activity_level` behind it, archived, deprecated (with the maintainer's message), scorecard, libyear, last commit, ecosystem and direct-vs-transitive, plus the advisories as CycloneDX `vulnerabilities`. Output is validated against the official CycloneDX 1.6 schema in the test suite, the same way the SARIF output is. `--sbom --baseline` still errors, since a maintenance-regression diff genuinely needs a snapshot shape a cross-ecosystem SBOM cannot supply. Output-mode precedence on the `--sbom` path is the same as on the native path (`--baseline` > `--sarif` > `--cyclonedx`), so the same two flags never mean different things depending on whether the input was a Gemfile or an SBOM, and setting more than one now warns which mode it is using and which it is ignoring on both paths rather than only the native one.
|
|
34
|
+
The native `--cyclonedx` output gains the same `still_active:status`, `still_active:activity_level` and `still_active:deprecated` properties, which is additive: existing properties are unchanged. `status` and `activity_level` deliberately disagree in the interesting cases: a deprecated package still shipping releases is `status: deprecated` with `activity_level: ok`, so a consumer thresholding on recency reads the second and a consumer asking what to do reads the first.
|
|
35
|
+
|
|
36
|
+
- **Maintainer-declared deprecation (SA010).** Every other signal in still_active infers abandonment from evidence: release dates, repository state, cadence. This one infers nothing. The person who publishes the package has marked it deprecated in its registry, and the message they left usually names the successor (`left-pad`'s reads "use String.prototype.padStart()"). It is the fact the rest of the tool approximates. It also fires where recency-based tooling is blind by construction: a package deprecated last month with a release last week looks perfectly healthy by dates, so this is deliberately independent of the activity signals rather than a modifier on them. Read from the deps.dev version record still_active already fetches for advisories and release dates, so it costs no extra request, no new host and no new dependency, and it lands on **both** paths at once because the native Ruby audit calls the same endpoint. Surfaced as `deprecated` and `deprecation_reason` in JSON, as a red sub-line in the terminal, as its own markdown section, and as SARIF rule **SA010** (`error`, security-severity 7.5) so it reaches GitHub Code Scanning. Suppressible per package via the `deprecated` signal in `.still_active.yml`, and enforceable in CI with **`--fail-if-deprecated`**, a boolean gate with no tier (a deprecation is not scored: the maintainer either declared it or did not). It is the only gate that can fire on a package every date-based signal reads as healthy, which is why it is its own flag rather than part of `--fail-if-critical`. Off by default, like the other opt-in gates, so it cannot break an existing pipeline.
|
|
37
|
+
Coverage, stated plainly: **npm** is where this is populated today. RubyGems, PyPI and Cargo have no deprecation mechanism to read, so the field is present and `false` for them, meaning "not declared", never "confirmed maintained". Nothing is inferred to fill that gap, and any ecosystem deps.dev later populates lights up with no code change.
|
|
38
|
+
The `status` verdict gains a **`deprecated`** level, ranked above `archived` (a repo can be archived just because development moved) and below `vulnerable`. Two consequences worth knowing: a deprecated package is never reported as `legacy`, because `legacy` means "long-dormant but done, low risk" and a deprecation is the maintainer contradicting exactly that reading; and a deprecated package carrying a vulnerability is `dead` even while still publishing releases, because waiting for a patch on a package its maintainer has abandoned is not a plan.
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
- **Direct private gem sources are now audited, not skipped.** A gem resolving from a private host still_active has no bespoke client for (a direct Contribsys, Gemstash, Gemfury, or a private mirror) previously reported no version data at all. still_active now reads such a source through the RubyGems compact index (`/info/<gem>`, the protocol every Bundler-compatible host serves, which is how `bundle install` resolves from it), so these gems get real version/latest/libyear signals for the first time, falling back to the same "unqueryable" notice only when a host genuinely doesn't serve it. Credentials come from Bundler's own host-keyed store (`bundle config` / `BUNDLE_<HOST>`), so a source URL from a lockfile only ever receives the credential the user configured for that exact host, and still_active's ambient `--artifactory-token` is never sent to a lockfile-derived host (it stays behind its explicit `--artifactory-host` allowlist). The #43 guard is unchanged: a private gem still never borrows a public name-collision's repository or maintenance data.
|
|
22
42
|
- **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.
|
|
23
43
|
- **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.
|
|
@@ -25,8 +45,12 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
|
|
|
25
45
|
- **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).
|
|
26
46
|
- **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.
|
|
27
47
|
- **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".
|
|
48
|
+
- **Direct-vs-transitive on the `--sbom` path, and `--direct-only` now works there.** A cross-ecosystem audit read every package the same way, whether you chose it or it arrived six levels down, which is most of what decides whether a finding is actionable. CycloneDX carries a `dependencies` graph and still_active only ever read `components`, so the answer was sitting unread in the input file. Each package now carries `direct`, and a transitive one carries `dependency_path` naming the declared dependency that pulls it in, head first, the same contract the native Ruby audit has. Terminal and markdown already rendered those fields, so a transitive finding now reads "pulled in by npm/express" cross-ecosystem. `--direct-only` previously parsed on the `--sbom` path and then changed nothing; it now narrows the audit for real, and says how many packages it excluded because the graph could not place them rather than quietly shrinking the scope.
|
|
49
|
+
Both fields are attached **only to packages the graph actually places**, and are absent otherwise. This matters because generators differ enormously: a Trivy filesystem scan placed all 518 packages of this repo, while a Syft directory scan of the same repo placed 1 of 527 (Syft does emit relationships for npm, where it placed 68 of 69). Stamping `direct: false` on everything a generator failed to mention would be the positive claim "none of these are yours" about a document that never said. Neither generator's shape is assumed: Trivy roots the graph at `metadata.component` and hangs one application node per manifest above the libraries, while Syft's `metadata.component` is a `file` that is not a graph node at all and the scanned project appears as an ordinary library with no incoming edge, so direct-ness is derived from the graph's entry points rather than from either convention.
|
|
50
|
+
One honest difference from the native path remains, and only for a library maintainer scanning their own repository: Bundler's `DEPENDENCIES` omits a gemspec project's own runtime dependencies, so the native audit deliberately promotes them to direct, whereas an SBOM shows them hanging off the project's own package node and they read as transitive with that node at the head of the path. There is no reliable cross-ecosystem way to recognise "this component is the project itself", so the SBOM path reports what the graph says instead of guessing.
|
|
51
|
+
- **Licences on the `--sbom` path.** A cross-ecosystem audit reported an empty licence column while the native Ruby audit filled it in. deps.dev serves the pinned version's SPDX licences on the version endpoint still_active already calls for advisories and the release date, so this closes the gap with no extra request, no new host, and no new dependency; it was simply a field being fetched and discarded. Both paths now render through one helper, so a licence reads identically whether it came from RubyGems or deps.dev, including a single-element SPDX expression like cargo's `Apache-2.0 OR MIT`, which passes through untouched rather than being split. A package that declares no licence stays `nil` rather than becoming a blank string, so "none declared" cannot be mistaken for a present-but-empty value.
|
|
28
52
|
- **The `--sbom` path now carries the same version signals as the native audit**: `libyear` drift, a prerelease/ahead-aware `up_to_date`, the `latest_version` string, 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`.
|
|
29
|
-
- **`--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`, and honours the same output-format flags as the Gemfile path (`--sarif` for GitHub Code Scanning, `--markdown` for CI step summaries, `--terminal`, `--json`), so formatting is independent of whether the input was a Gemfile or an SBOM; findings are named `ecosystem/name`, and SARIF anchors them to the SBOM file since there is no lockfile to annotate. The JSON shape differs from the Ruby audit, so it deliberately omits the `$schema` contract. `--
|
|
53
|
+
- **`--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`, and honours the same output-format flags as the Gemfile path (`--sarif` for GitHub Code Scanning, `--markdown` for CI step summaries, `--terminal`, `--json`), so formatting is independent of whether the input was a Gemfile or an SBOM; findings are named `ecosystem/name`, and SARIF anchors them to the SBOM file since there is no lockfile to annotate. The JSON shape differs from the Ruby audit, so it deliberately omits the `$schema` contract. `--baseline` needs the native audit's gems/ruby snapshot, which a cross-ecosystem SBOM can't honestly supply, so it errors loudly rather than silently falling back to JSON (`--cyclonedx` is supported and emits an enriched SBOM; see its own entry above). A cross-ecosystem finding's suppression identity (for `--ignore` and `.still_active.yml`) is `ecosystem/name` (e.g. `npm/left-pad`), version-independent and matching what the SARIF `suppressions[]` and the fail-if gates key on, not the composite `ecosystem/name@version` result key. 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.
|
|
30
54
|
- **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.
|
|
31
55
|
|
|
32
56
|
- 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.
|
|
@@ -34,13 +58,23 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
|
|
|
34
58
|
|
|
35
59
|
### Fixed
|
|
36
60
|
|
|
61
|
+
- **The `--sbom` JSON output is documented.** It is a 3.0 headline feature that emits `schema_version: 1` while `docs/schema.md` described only the Gemfile audit's shape, so two structurally different documents both claimed version 1 and one of them had no written contract at all. The cross-ecosystem envelope, its `unassessable` entries, and the fields specific to that path (`ecosystem`, `name`, `purl`, `production`, `direct`, `dependency_path`, `version_unresolved`) are now documented, along with the rule for telling the two apart: check for `gems` versus `dependencies`, not `schema_version`. A doc-consistency spec now fails if the `--sbom` path emits a field that document never mentions, since unlike the native output there is no JSON Schema to catch it.
|
|
62
|
+
|
|
63
|
+
- **Verified against five real SBOM generators, not one.** The `--sbom` path is now exercised in the test suite against verbatim output from Syft, Trivy, `npm sbom`, `cyclonedx-npm` and `cyclonedx-py`, committed as fixtures rather than hand-written. They disagree about nearly everything a reader could naively depend on: three CycloneDX spec versions (1.5, 1.6, 1.7), four `metadata.component` types including absent entirely, and five mutually incompatible bom-ref conventions (a PURL with a `package-id` qualifier, a bare UUID, `name@version`, a pipe-delimited parent/child path, and a requirements-file line number). Given the same project all five now produce the same verdict, and the suite fails if a future change makes one of them disagree. This closes the gap behind both SBOM bugs found before it: a synthetic fixture only ever encodes the shape whoever wrote it already had in mind.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
- **An impossible `--sbom` output mode is rejected before the audit runs, not after.** `--sbom --baseline` read the SBOM, canaried deps.dev, and ran the entire cross-ecosystem assessment (a request per package to deps.dev, ecosyste.ms and the forges) before rejecting the combination with exit 2. The incompatibility is knowable from the flags alone, so all of that work was bought and thrown away: measured at 3 seconds for a three-package SBOM, which on a real one is minutes of wall time and hundreds of calls against free services. It now errors immediately, before the SBOM is read.
|
|
67
|
+
|
|
68
|
+
- **A `--sbom` audit no longer reports the scanned project as one of its own dependencies.** Syft lists the project it scanned as an ordinary `library` component with a purl, indistinguishable by shape from a real dependency, so still_active assessed it: no registry entry means no releases, which reads as critically stale. A user pointing Syft at their own repository, the workflow the README recommends, got a confident red verdict about their own code, and `--fail-if-critical` fired on it. Observed on a real Syft scan of an npm project, which reported the project itself as `status: legacy`, `activity_level: critical`. There is also a latent version of this that was not observed but is worth naming: the project is looked up by its own name, so a public package sharing that name and version would supply the data reported for your local project (in both cases seen here the version did not resolve, so the result was a false stale verdict rather than borrowed data). The dependency graph tells them apart, since the project is the thing nothing depends on; it is now dropped rather than surfaced as unassessable, the same treatment CI actions and opaque binaries already get, because it is not a dependency we failed to assess, it is not a dependency. The rule is conservative on both halves: it only considers library components, so Trivy's application-typed root and per-manifest nodes are unaffected, and it requires the component to actually pull something in, so a dependency whose parent edge a generator failed to record is never dropped for looking parentless. An SBOM with no dependency graph is unchanged. Verified against real output: a Syft npm scan drops only the project (69 packages to 68), a Trivy scan of this repo is untouched at 518, and a Syft directory scan drops only a local fixture project.
|
|
69
|
+
|
|
70
|
+
- **SARIF EOL dates are no longer a day early east of UTC.** endoflife.date publishes a bare calendar date (Ruby 3.2's `eol` is the string `2026-03-31`), which `Time.parse` reads as local midnight; the SARIF renderer then converted it to UTC before formatting, rewinding it one day for every user in a positive-offset zone (CEST midnight is 22:00 UTC the previous day). An EOL Ruby or a runtime-ceiling receipt (SA006, SA009) therefore reported a date the runtime was in fact still supported on, and disagreed with the markdown and terminal output, which never applied the shift. The date is now rendered as parsed. Release timestamps, which are real instants carrying their own zone, are unaffected.
|
|
71
|
+
- **A patched version is no longer reported vulnerable while an advisory's backport fixes are still propagating.** deps.dev's per-version `advisoryKeys`, the discovery source for every cross-ecosystem advisory (and the second source on the native Ruby path), mirrors OSV/GHSA data with an ingestion lag. Advisories are routinely *amended* after publication: a CVE lands with a fix on the current release line, maintainers backport to the older supported lines, and the advisory is then amended to carry those branch ranges. Until deps.dev re-ingests, it keeps serving the pre-amendment record, whose broader range still covers versions the amendment has since marked patched. Live receipt: `GHSA-mh99-v99m-4gvg` was published 2026-07-24 as a single `introduced 0 / fixed 5.0.8` range and amended 2026-07-31 to carry the 1.1.17, 2.1.3 and 3.0.3 branches; hours after the amendment deps.dev still answered from the old record, so `--sbom` reported the fully patched `brace-expansion` 1.1.18 and 2.1.4 as `status: "vulnerable"` where `npm audit` correctly reported none. still_active now confirms each deps.dev-discovered advisory against OSV's own `/v1/query`, deps.dev's upstream, which applies the declared ranges per ecosystem and reflects an amendment immediately, and drops the ones OSV says do not apply to the audited version. The window reopens with every backported fix, so this is a standing correction rather than a workaround for one stale record. A dropped advisory is named on stderr, so the one path that can turn a red run green always leaves a trace. (It is lag, not a parsing defect on deps.dev's part: 341 multi-branch advisories across npm/rubygems/pypi/cargo were compared against OSV over 589 version checks, and every one agreed.) Dropping a finding is the one direction this tool must never get wrong, so it needs positive contradiction on every count: the advisory came from deps.dev alone (a ruby-advisory-db verdict is never overruled, it matches versions itself and is the Ruby authority), OSV served a record for it that names the package under that exact ecosystem and spelling, and the query for that exact version succeeded without listing it. A failed query, an unmapped ecosystem, a name OSV spells differently, a record holding no version data for the package, or an answer that isn't readable end to end all keep the advisory. That last one matters more than it sounds: OSV's genuine all-clear is a bare `{}` with no positive marker of its own, and OSV paginates once a query exceeds 20 seconds (a latency trigger, so it can land on any package under load), so a truncated page is treated as "we don't know", never as "clean". Costs one request per advisory-bearing package.
|
|
37
72
|
- **A private-source gem is no longer linked to the public rubygems.org page.** `ruby_gems_url` was set to `rubygems.org/gems/<name>` for any gem with versions, including one resolving from a private source, where that URL is a public name collision (for `sidekiq-pro`, the `0.0.3` squat-warning decoy) rather than the gem the user installs. It is now set only for a gem that actually lives on public rubygems.org, the same #43 guard the repository-URL substitution already applies.
|
|
38
73
|
- **An Artifactory-sourced gem is no longer reported `(YANKED)` when its real host is a legacy private registry.** Artifactory's `/api/v1/versions/<gem>.json` merges metadata from a virtual repo's member remotes; when a member (a legacy host like Contribsys) can't answer it, the merge returns only whatever rubygems.org holds for the name, which for `sidekiq-pro` is a `0.0.3` squat-warning placeholder. still_active read that endpoint first, so the installed version was absent from the list and looked yanked. It now reads the RubyGems compact index (`/info/<gem>`, what Bundler resolves through) instead, listing exactly the versions the repo can resolve, with the versions API demoted to dating those versions and AQL kept only as a last resort for hosts serving no compact index (and no longer presenting its cache timestamp as a release date). A side benefit: the compact index's per-version Ruby requirement and dependency constraints let the runtime-ceiling and poison-pill checks work on Artifactory sources for the first time.
|
|
39
74
|
- **SARIF `tool.driver` version fields are populated and spec-correct.** `version` was null, so a consumer reading `tool.driver.version` (rather than `semanticVersion`) saw nothing, and `semanticVersion` carried the RubyGems version verbatim (`3.0.0.rc4`), which is not valid SemVer 2.0.0 for a prerelease. Now `version` carries the free-form gem version and `semanticVersion` the SemVer form (`3.0.0-rc4`); final releases like `3.0.0` were already valid.
|
|
40
75
|
- **Cross-ecosystem SARIF no longer reads as a Ruby audit.** The `--sbom` path emitted the native Gemfile audit's Ruby-worded SARIF rule catalog, so a Go/npm/pypi repo's GitHub Code Scanning alerts were titled "Gem source repository is archived", advised `bundle update <gem>`, and referenced `Gemfile.lock`/RubyGems in the rule detail. The catalog now has an ecosystem-neutral flavour for SBOM runs (titles and guidance say "package"/"dependency"), and the native-only SA006 (Ruby runtime EOL), which the cross-ecosystem path can never emit, is dropped from the SBOM catalog rather than advertised in a non-Ruby repo. Per-result finding messages were already neutral; this fixes the rule metadata GitHub renders as the alert title and detail. The native Gemfile SARIF is unchanged (still `gem`-worded, SA006 present).
|
|
41
76
|
- **A stale pre-release no longer corrupts the up-to-date signal.** A pre-release older than the latest stable (an `8.1.0.rc1` still listed after `8.1.2` shipped, or a decade-old `rc` on a gem long past it) was treated as a live upgrade target: `up_to_date` compares the version in hand against it, so any current version read as up to date and a gem that was actually behind got the "futurist" marker, while the pre-release column filled with superseded rcs. The latest pre-release is now kept only when strictly newer than the latest stable (or when there is no stable release at all, where it is the only signal), so the column, the `up_to_date` field, and the emoji agree. Genuine upcoming pre-releases still surface. On a real bundle this was mislabelling roughly twenty gems.
|
|
42
77
|
- **`--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.
|
|
43
|
-
- **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.
|
|
44
78
|
- **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).
|
|
45
79
|
- **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.
|
|
46
80
|
- **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.
|
|
@@ -52,6 +86,10 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
|
|
|
52
86
|
- **The terminal summary says "dependencies", not "gems", for a cross-ecosystem `--sbom` audit** (calling npm/cargo/go packages "gems" is a Ruby-ism); the native Ruby audit still says "gems".
|
|
53
87
|
- **A GitHub funding link is no longer mistaken for a repository.** deps.dev sometimes returns a `SOURCE_REPO` of `https://github.com/sponsors/<user>` (a funding page); parsed as `owner/repo` it 404'd and left a blank "last commit" cell. Reserved GitHub top-level paths (`sponsors`, `orgs`, `apps`, ...) are now treated as "no repo", and the markdown "last commit" column shows the unsure marker instead of an empty link when a date is missing.
|
|
54
88
|
|
|
89
|
+
### Security
|
|
90
|
+
|
|
91
|
+
- **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.
|
|
92
|
+
|
|
55
93
|
## [2.0.0] - 2026-06-14
|
|
56
94
|
|
|
57
95
|
### Upgrading to 2.0
|
data/README.md
CHANGED
|
@@ -103,17 +103,20 @@ still_active --fail-if-vulnerable # any known vulnerability (or =low|
|
|
|
103
103
|
still_active --fail-if-outdated=3 # more than 3 libyears behind latest
|
|
104
104
|
still_active --fail-if-poison # a dormant package caps a dep below its latest major
|
|
105
105
|
still_active --fail-if-language-ceiling # a pin strands you on an EOL language runtime
|
|
106
|
+
still_active --fail-if-deprecated # a maintainer has declared a dependency deprecated
|
|
106
107
|
|
|
107
108
|
# PR review: report only what got worse since a saved snapshot, exit 1 on any regression
|
|
108
109
|
still_active --json > /tmp/main.json && still_active --baseline=/tmp/main.json
|
|
109
110
|
```
|
|
110
111
|
</details>
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
Building on top of still_active? The versioned JSON envelope is the supported interface (the Ruby constants are internal): [`docs/schema.md`](docs/schema.md).
|
|
114
|
+
|
|
115
|
+
Rule reference (SA001-SA010), suppression, and composing with `dependency-review-action`: [`docs/rules.md`](docs/rules.md), [`docs/ci.md`](docs/ci.md). This repo audits itself every push, so you can browse live findings in its [Code Scanning tab](https://github.com/SeanLF/still_active/security/code-scanning?query=tool%3Astill_active+is%3Aopen).
|
|
113
116
|
|
|
114
117
|
## Cross-ecosystem audit
|
|
115
118
|
|
|
116
|
-
Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft), Trivy, or any producer) and `still_active` assesses `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` packages the same way it does gems, via [deps.dev](https://deps.dev) and [ecosyste.ms](https://ecosyste.ms). The SBOM is treated as **untrusted input** (only ecosystem/name/version are read, repositories resolve from deps.dev, and anything unassessable is surfaced rather than faked as `ok`). Most signals apply everywhere; a few are deliberately scoped.
|
|
119
|
+
Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft), Trivy, `npm sbom`, `cyclonedx-npm`, `cyclonedx-py`, or any producer; see [generator compatibility](#generator-compatibility)) and `still_active` assesses `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` packages the same way it does gems, via [deps.dev](https://deps.dev) and [ecosyste.ms](https://ecosyste.ms). The SBOM is treated as **untrusted input** (only ecosystem/name/version are read, repositories resolve from deps.dev, and anything unassessable is surfaced rather than faked as `ok`). Most signals apply everywhere; a few are deliberately scoped.
|
|
117
120
|
|
|
118
121
|
<details>
|
|
119
122
|
<summary>Which signal covers which ecosystem</summary>
|
|
@@ -132,6 +135,28 @@ Full rule detail in [`docs/rules.md`](docs/rules.md).
|
|
|
132
135
|
|
|
133
136
|
The play is **maintenance**, not CVE scanning, so compose Trivy/Grype for full vulnerability coverage.
|
|
134
137
|
|
|
138
|
+
### Generator compatibility
|
|
139
|
+
|
|
140
|
+
CycloneDX producers disagree about almost everything a reader could naively depend on, so the SBOM path derives what it needs structurally rather than from any one tool's convention. Each of these is exercised in the test suite against **verbatim output** from the tool, not a hand-written fixture:
|
|
141
|
+
|
|
142
|
+
| generator | spec | `metadata.component` | bom-ref convention |
|
|
143
|
+
| --- | --- | --- | --- |
|
|
144
|
+
| Syft | 1.7 | `file`, and not a graph node at all | `pkg:npm/…?package-id=…` |
|
|
145
|
+
| Trivy | 1.7 | `application` | UUID |
|
|
146
|
+
| `npm sbom` | 1.5 | `library` | `name@version` |
|
|
147
|
+
| `cyclonedx-npm` | 1.6 | `application` | `parent@ver\|child@ver` |
|
|
148
|
+
| `cyclonedx-py` | 1.6 | absent | `requirements-L1` |
|
|
149
|
+
|
|
150
|
+
Given the same project, all of them produce the same verdict.
|
|
151
|
+
|
|
152
|
+
Two behaviours worth knowing, because they are deliberate rather than incidental:
|
|
153
|
+
|
|
154
|
+
- **The scanned project is never audited as one of its own dependencies.** Syft and `npm sbom` both list it as an ordinary library component with a PURL, which is indistinguishable from a real dependency by shape alone; it is identified through the dependency graph instead.
|
|
155
|
+
- **`direct` and `dependency_path` are only reported when the SBOM's dependency graph actually places a package.** A generator can emit a graph with no usable edges (`cyclonedx-py` from a requirements file does exactly that, and a Syft directory scan is often sparse), and claiming everything is transitive would be a positive claim about a document that never said.
|
|
156
|
+
|
|
157
|
+
An SPDX document is refused with a clear error rather than read as an empty, all-clear audit.
|
|
158
|
+
|
|
159
|
+
|
|
135
160
|
## Output formats
|
|
136
161
|
|
|
137
162
|
Auto-detected: a coloured terminal table on a TTY (above), JSON when piped. Or ask explicitly.
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
require_relative "options"
|
|
4
4
|
require_relative "config_file"
|
|
5
5
|
require_relative "diff"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "
|
|
9
|
-
require_relative "
|
|
10
|
-
require_relative "
|
|
11
|
-
require_relative "
|
|
12
|
-
require_relative "
|
|
13
|
-
require_relative "
|
|
14
|
-
require_relative "
|
|
15
|
-
require_relative "
|
|
16
|
-
require_relative "
|
|
17
|
-
require_relative "
|
|
18
|
-
require_relative "
|
|
19
|
-
require_relative "
|
|
20
|
-
require_relative "
|
|
6
|
+
require_relative "helpers/activity_helper"
|
|
7
|
+
require_relative "helpers/bot_context"
|
|
8
|
+
require_relative "helpers/bundler_helper"
|
|
9
|
+
require_relative "helpers/constraint_helper"
|
|
10
|
+
require_relative "helpers/cyclonedx_helper"
|
|
11
|
+
require_relative "helpers/dependency_helper"
|
|
12
|
+
require_relative "helpers/diff_markdown_helper"
|
|
13
|
+
require_relative "helpers/emoji_helper"
|
|
14
|
+
require_relative "helpers/markdown_helper"
|
|
15
|
+
require_relative "helpers/sarif_helper"
|
|
16
|
+
require_relative "helpers/status_helper"
|
|
17
|
+
require_relative "helpers/summary_helper"
|
|
18
|
+
require_relative "helpers/terminal_helper"
|
|
19
|
+
require_relative "helpers/version_helper"
|
|
20
|
+
require_relative "helpers/vulnerability_helper"
|
|
21
21
|
require_relative "sbom_reader"
|
|
22
22
|
require_relative "sbom_workflow"
|
|
23
23
|
require_relative "workflow"
|
|
@@ -46,6 +46,10 @@ module StillActive
|
|
|
46
46
|
# bundler-audit ignore list when the vulnerability gate is on.
|
|
47
47
|
hint = ConfigFile.import_hint(config_data)
|
|
48
48
|
warn("hint: #{hint}") if hint
|
|
49
|
+
# Ahead of the SBOM dispatch so BOTH paths get it. It used to sit below,
|
|
50
|
+
# which meant an --sbom run setting two output modes said nothing at all and
|
|
51
|
+
# silently emitted one of them.
|
|
52
|
+
warn_output_flag_conflicts(options)
|
|
49
53
|
# An SBOM audit is cross-ecosystem: it runs the deps.dev/ecosyste.ms lens
|
|
50
54
|
# over the SBOM's packages, not Bundler over a lockfile. Dispatch before the
|
|
51
55
|
# Bundler resolution below so a Gemfile is never required (or read).
|
|
@@ -60,7 +64,6 @@ module StillActive
|
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
warn_output_flag_conflicts(options)
|
|
64
67
|
warn_stale_suppressions
|
|
65
68
|
|
|
66
69
|
result = if $stderr.tty?
|
|
@@ -122,6 +125,11 @@ module StillActive
|
|
|
122
125
|
# or assessment-level: the lens call raised) rather than silently dropping it.
|
|
123
126
|
def run_sbom
|
|
124
127
|
path = StillActive.config.sbom_path
|
|
128
|
+
# Before anything expensive. Whether an output mode can work on this path is
|
|
129
|
+
# knowable from the flags alone, and discovering it after the audit meant a
|
|
130
|
+
# large SBOM burned minutes and hundreds of requests against free APIs to
|
|
131
|
+
# produce a result that was then thrown away.
|
|
132
|
+
unsupported_sbom_format!("--baseline") if StillActive.config.baseline_path
|
|
125
133
|
require_parseable_sbom(path)
|
|
126
134
|
# deps.dev is the SBOM path's sole vulnerability source and an alpha API; a
|
|
127
135
|
# field rename would silently zero every vuln count. Canary the schema once
|
|
@@ -129,7 +137,7 @@ module StillActive
|
|
|
129
137
|
unless DepsDevClient.advisory_schema_ok?
|
|
130
138
|
warn("warning: deps.dev vulnerability schema check failed (its `advisoryKeys` field may have changed, or the API is unreachable); vulnerability counts may be understated -- a clean result is NOT authoritative")
|
|
131
139
|
end
|
|
132
|
-
sbom = SbomReader.parse(path)
|
|
140
|
+
sbom = restrict_to_direct(SbomReader.parse(path))
|
|
133
141
|
outcome = if $stderr.tty?
|
|
134
142
|
SbomWorkflow.call(sbom) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
|
|
135
143
|
else
|
|
@@ -146,24 +154,52 @@ module StillActive
|
|
|
146
154
|
check_exit_status(outcome.assessed)
|
|
147
155
|
end
|
|
148
156
|
|
|
157
|
+
# --direct-only used to be silently ignored on the SBOM path: the flag parsed,
|
|
158
|
+
# changed nothing, and the audit still covered the full transitive set. Now that
|
|
159
|
+
# the reader places packages in the CycloneDX dependency graph, it can be
|
|
160
|
+
# honoured for real. Both ways of coming up short are said out loud rather than
|
|
161
|
+
# quietly shrinking (or quietly not shrinking) the audit, because a narrowed
|
|
162
|
+
# scope that reads as a full one is the failure mode that matters.
|
|
163
|
+
def restrict_to_direct(sbom)
|
|
164
|
+
return sbom unless StillActive.config.direct_only
|
|
165
|
+
|
|
166
|
+
placed, unplaced = sbom.dependencies.partition { |dep| dep.key?(:direct) }
|
|
167
|
+
if placed.empty?
|
|
168
|
+
warn("warning: --direct-only needs the SBOM's dependency graph, and this document places no package in one " \
|
|
169
|
+
"(a Syft directory scan often emits almost no relationships); auditing the full dependency set instead")
|
|
170
|
+
return sbom
|
|
171
|
+
end
|
|
172
|
+
if unplaced.any?
|
|
173
|
+
noun = (unplaced.size == 1) ? "dependency" : "dependencies"
|
|
174
|
+
warn("warning: --direct-only excluded #{unplaced.size} #{noun} the SBOM's dependency graph does not place, " \
|
|
175
|
+
"so they could not be shown to be direct; drop --direct-only to audit them")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
SbomReader::Result.new(dependencies: placed.select { |dep| dep[:direct] }, unassessable: sbom.unassessable)
|
|
179
|
+
end
|
|
180
|
+
|
|
149
181
|
# The assessment is the same per-dependency shape whether the input was a
|
|
150
182
|
# Gemfile or an SBOM, so the SBOM path honours the same output-format flags
|
|
151
183
|
# rather than forcing JSON. SARIF and markdown are the two that matter for the
|
|
152
184
|
# cross-ecosystem/CI use case; JSON stays the piped default. Two formats need
|
|
153
185
|
# the native Ruby audit's shape (a lockfile to annotate for SARIF's line
|
|
154
186
|
# numbers, a gems/ruby snapshot for the diff), which a cross-ecosystem SBOM
|
|
155
|
-
# can't honestly supply, so
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
187
|
+
# can't honestly supply, so it errors loudly rather than silently falling back
|
|
188
|
+
# to JSON: --baseline, which needs a gems/ruby snapshot to diff against.
|
|
189
|
+
# --cyclonedx used to error here too, on the grounds that SBOM-in/SBOM-out
|
|
190
|
+
# would need per-ecosystem PURL reconstruction; it is supported now because it
|
|
191
|
+
# turned out not to need any, the input's own PURLs being threaded through.
|
|
159
192
|
def render_sbom_output(result, unassessable, sbom_path)
|
|
160
193
|
config = StillActive.config
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
194
|
+
# Same precedence as the native path and as active_output_modes, which is
|
|
195
|
+
# what the "using X, ignoring Y" warning reads from: the two flags must not
|
|
196
|
+
# mean different things depending on whether the input was a Gemfile or an
|
|
197
|
+
# SBOM, and the warning must not name a mode other than the one that runs.
|
|
198
|
+
# --baseline is already rejected at the top of run_sbom, before the audit.
|
|
199
|
+
if config.sarif_path
|
|
166
200
|
emit_sbom_sarif(result, config.sarif_path, sbom_path)
|
|
201
|
+
elsif config.cyclonedx_path
|
|
202
|
+
emit_sbom_cyclonedx(result, config.cyclonedx_path)
|
|
167
203
|
else
|
|
168
204
|
case resolve_format
|
|
169
205
|
when :json then emit_sbom_json(result, unassessable)
|
|
@@ -174,10 +210,22 @@ module StillActive
|
|
|
174
210
|
end
|
|
175
211
|
|
|
176
212
|
def unsupported_sbom_format!(flag)
|
|
177
|
-
warn("error: #{flag} output is not supported in --sbom mode (it needs the native Ruby audit's lockfile/snapshot, which a cross-ecosystem SBOM can't supply); use --sarif, --markdown, --terminal, or --json")
|
|
213
|
+
warn("error: #{flag} output is not supported in --sbom mode (it needs the native Ruby audit's lockfile/snapshot, which a cross-ecosystem SBOM can't supply); use --sarif, --cyclonedx, --markdown, --terminal, or --json")
|
|
178
214
|
exit(2)
|
|
179
215
|
end
|
|
180
216
|
|
|
217
|
+
# SBOM in, enriched SBOM out: the input annotated with the maintenance signals
|
|
218
|
+
# it had no way to carry, so it can be fed onward to Dependency-Track rather
|
|
219
|
+
# than read by a human. Each component re-emits the PURL it arrived with, so a
|
|
220
|
+
# consumer matches this exactly as it matched the input.
|
|
221
|
+
def emit_sbom_cyclonedx(result, cyclonedx_path)
|
|
222
|
+
write_output(cyclonedx_path, CyclonedxHelper.render_sbom(
|
|
223
|
+
result: result,
|
|
224
|
+
tool_version: StillActive::VERSION,
|
|
225
|
+
spec_version: StillActive.config.cyclonedx_version || "1.6"
|
|
226
|
+
))
|
|
227
|
+
end
|
|
228
|
+
|
|
181
229
|
def emit_sbom_sarif(result, sarif_path, sbom_path)
|
|
182
230
|
sarif_json = SarifHelper.render_sbom(
|
|
183
231
|
result: result,
|
|
@@ -415,6 +463,8 @@ module StillActive
|
|
|
415
463
|
|
|
416
464
|
puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
|
|
417
465
|
end
|
|
466
|
+
deprecated = MarkdownHelper.deprecated_section(result)
|
|
467
|
+
puts deprecated unless deprecated.empty?
|
|
418
468
|
poison = MarkdownHelper.poison_section(result)
|
|
419
469
|
puts poison unless poison.empty?
|
|
420
470
|
language_ceiling = MarkdownHelper.language_ceiling_section(result)
|
|
@@ -431,7 +481,7 @@ module StillActive
|
|
|
431
481
|
|
|
432
482
|
def check_exit_status(result)
|
|
433
483
|
config = StillActive.config
|
|
434
|
-
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling
|
|
484
|
+
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling || config.fail_if_deprecated
|
|
435
485
|
|
|
436
486
|
warn_unknown_severity_gate(result, config)
|
|
437
487
|
# Match the gate on the dependency's identity (bare gem name natively,
|
|
@@ -476,7 +526,19 @@ module StillActive
|
|
|
476
526
|
failed_vulnerability?(name, data, config, suppressions) ||
|
|
477
527
|
failed_outdated?(name, data, config, suppressions) ||
|
|
478
528
|
failed_poison?(name, data, config, suppressions) ||
|
|
479
|
-
failed_language_ceiling?(name, data, config, suppressions)
|
|
529
|
+
failed_language_ceiling?(name, data, config, suppressions) ||
|
|
530
|
+
failed_deprecated?(name, data, config, suppressions)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# A boolean gate, with no tier, because a deprecation is not scored: the
|
|
534
|
+
# maintainer either declared it or did not. It is also the only gate here that
|
|
535
|
+
# can fire on a package every date-based signal reads as healthy, which is the
|
|
536
|
+
# reason it exists separately rather than folding into the activity gate.
|
|
537
|
+
def failed_deprecated?(name, data, config, suppressions)
|
|
538
|
+
return false unless config.fail_if_deprecated
|
|
539
|
+
return false if suppressions.suppressed?(gem: name, signal: :deprecated)
|
|
540
|
+
|
|
541
|
+
data[:deprecated] == true
|
|
480
542
|
end
|
|
481
543
|
|
|
482
544
|
def failed_poison?(name, data, config, suppressions)
|
data/lib/still_active/config.rb
CHANGED
|
@@ -17,6 +17,7 @@ module StillActive
|
|
|
17
17
|
:cyclonedx_path,
|
|
18
18
|
:cyclonedx_version,
|
|
19
19
|
:fail_if_critical,
|
|
20
|
+
:fail_if_deprecated,
|
|
20
21
|
:fail_if_poison,
|
|
21
22
|
:fail_if_language_ceiling,
|
|
22
23
|
:fail_if_warning,
|
|
@@ -40,6 +41,7 @@ module StillActive
|
|
|
40
41
|
@unreleased_commits = false
|
|
41
42
|
@direct_only = false
|
|
42
43
|
@fail_if_critical = false
|
|
44
|
+
@fail_if_deprecated = false
|
|
43
45
|
@fail_if_poison = false
|
|
44
46
|
@fail_if_language_ceiling = false
|
|
45
47
|
@fail_if_outdated = nil
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require_relative "suppressions"
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
5
|
+
require_relative "helpers/constraint_helper"
|
|
6
|
+
require_relative "helpers/vulnerability_helper"
|
|
7
7
|
|
|
8
8
|
module StillActive
|
|
9
9
|
# Loads a committed .still_active.yml and applies it to the config as the layer
|
|
@@ -48,6 +48,7 @@ module StillActive
|
|
|
48
48
|
data.each do |key, value|
|
|
49
49
|
case key
|
|
50
50
|
when "fail_if_critical" then set_boolean(config, :fail_if_critical=, value, key, warnings)
|
|
51
|
+
when "fail_if_deprecated" then set_boolean(config, :fail_if_deprecated=, value, key, warnings)
|
|
51
52
|
when "fail_if_warning" then set_boolean(config, :fail_if_warning=, value, key, warnings)
|
|
52
53
|
when "fail_if_poison" then apply_fail_if_poison(config, value, warnings)
|
|
53
54
|
when "fail_if_language_ceiling" then apply_fail_if_language_ceiling(config, value, warnings)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "helpers/http_helper"
|
|
4
|
+
require_relative "helpers/version_helper"
|
|
5
5
|
|
|
6
6
|
module StillActive
|
|
7
7
|
module DepsDevClient
|
|
@@ -42,7 +42,22 @@ module StillActive
|
|
|
42
42
|
# The locked version's release date -- the cross-ecosystem libyear input
|
|
43
43
|
# (paired with the package's latest-release date). Already in this response,
|
|
44
44
|
# so no extra fetch; nil when the feed omits it.
|
|
45
|
-
published_at: body["publishedAt"]
|
|
45
|
+
published_at: body["publishedAt"],
|
|
46
|
+
# The pinned version's SPDX licences, the same array shape RubyGems serves
|
|
47
|
+
# (verified across npm, pypi, cargo, go and maven). Also already in this
|
|
48
|
+
# response, so licence parity with the native path costs no extra request.
|
|
49
|
+
# Always an array, so a caller can't mistake "no licence declared" for a
|
|
50
|
+
# failed lookup.
|
|
51
|
+
licenses: body["licenses"] || [],
|
|
52
|
+
# The maintainer's own deprecation declaration, and the message they left
|
|
53
|
+
# with it (often naming the replacement: left-pad's reads "use
|
|
54
|
+
# String.prototype.padStart()"). This is a declared fact from the registry,
|
|
55
|
+
# not a heuristic inferred from dates, and it rides in the same response as
|
|
56
|
+
# the advisories and the release date, so it costs no extra request.
|
|
57
|
+
# Populated for npm today; other ecosystems mostly lack a deprecation
|
|
58
|
+
# mechanism, and any deps.dev later fills in lights up with no code change.
|
|
59
|
+
deprecated: body["isDeprecated"] == true,
|
|
60
|
+
deprecation_reason: presence(body["deprecatedReason"])
|
|
46
61
|
}
|
|
47
62
|
end
|
|
48
63
|
|
|
@@ -159,6 +174,12 @@ module StillActive
|
|
|
159
174
|
|
|
160
175
|
private
|
|
161
176
|
|
|
177
|
+
# "" is how deps.dev renders "no deprecation message"; nil keeps an absent
|
|
178
|
+
# reason from rendering as a present blank.
|
|
179
|
+
def presence(value)
|
|
180
|
+
value unless value.nil? || value.to_s.empty?
|
|
181
|
+
end
|
|
182
|
+
|
|
162
183
|
# Coerce an advisory alias to a string id. A BARE non-string alias is deps.dev
|
|
163
184
|
# ALPHA-API schema drift: coerce it (a non-string alias makes the advisory-merge
|
|
164
185
|
# sort raise, which strips a gem of ALL its signals and reads a vulnerable gem
|
|
@@ -6,13 +6,14 @@ require_relative "ecosystems_client"
|
|
|
6
6
|
require_relative "github_client"
|
|
7
7
|
require_relative "pypi_client"
|
|
8
8
|
require "time"
|
|
9
|
-
require_relative "
|
|
10
|
-
require_relative "
|
|
11
|
-
require_relative "
|
|
12
|
-
require_relative "
|
|
13
|
-
require_relative "
|
|
14
|
-
require_relative "
|
|
15
|
-
require_relative "
|
|
9
|
+
require_relative "helpers/activity_helper"
|
|
10
|
+
require_relative "helpers/constraint_helper"
|
|
11
|
+
require_relative "helpers/dotnet_helper"
|
|
12
|
+
require_relative "helpers/libyear_helper"
|
|
13
|
+
require_relative "helpers/pep440_helper"
|
|
14
|
+
require_relative "helpers/runtime_ceiling_helper"
|
|
15
|
+
require_relative "helpers/version_helper"
|
|
16
|
+
require_relative "helpers/vulnerability_helper"
|
|
16
17
|
|
|
17
18
|
module StillActive
|
|
18
19
|
# The cross-ecosystem maintenance lens. Given one SBOM-derived dependency
|
|
@@ -76,7 +77,9 @@ module StillActive
|
|
|
76
77
|
vulnerabilities = vulnerabilities_for(info)
|
|
77
78
|
# Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
|
|
78
79
|
# advisory) and the fixed-version ranges the "capped below the fix" signal needs.
|
|
79
|
-
|
|
80
|
+
# Passing the version also lets OSV confirm the advisory actually applies to it,
|
|
81
|
+
# correcting deps.dev's lag on an advisory amended with backport fixes.
|
|
82
|
+
vulnerabilities = OsvClient.enrich(vulnerabilities, ecosystem: ecosystem, name: name, version: version)
|
|
80
83
|
scorecard = DepsDevClient.project_scorecard(project_id: project_id)
|
|
81
84
|
repo = repo_signals(project_id)
|
|
82
85
|
|
|
@@ -104,6 +107,17 @@ module StillActive
|
|
|
104
107
|
# prerelease or an ahead-of-stable pin reads current (true), not "behind" --
|
|
105
108
|
# parity with the native path's `>=` comparison. nil when latest is unknown.
|
|
106
109
|
up_to_date: version_current?(version, latest_version),
|
|
110
|
+
# The pinned version's licence, rendered exactly as the native path renders
|
|
111
|
+
# it (VersionHelper does the joining for both). It rides along in the
|
|
112
|
+
# version response already fetched above, so this is no extra call. nil when
|
|
113
|
+
# the package declares none, never a blank string.
|
|
114
|
+
license: VersionHelper.format_licenses(info&.dig(:licenses)),
|
|
115
|
+
# The maintainer's own deprecation declaration, and the message left with
|
|
116
|
+
# it. A declared fact rather than a date heuristic, and the one signal a
|
|
117
|
+
# release-recency check can never produce: a package deprecated last month
|
|
118
|
+
# with a release last week looks perfectly healthy by dates alone.
|
|
119
|
+
deprecated: info&.dig(:deprecated) == true,
|
|
120
|
+
deprecation_reason: info&.dig(:deprecation_reason),
|
|
107
121
|
repository_url: project_id && "https://#{project_id}",
|
|
108
122
|
last_commit_date: repo[:last_commit_date],
|
|
109
123
|
archived: repo[:archived],
|