still_active 3.0.0.rc6 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -2
  3. data/README.md +27 -2
  4. data/lib/still_active/artifactory_client.rb +1 -1
  5. data/lib/still_active/cli.rb +91 -29
  6. data/lib/still_active/compact_index_client.rb +1 -1
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/config_file.rb +3 -2
  9. data/lib/still_active/deps_dev_client.rb +24 -3
  10. data/lib/still_active/ecosystem_lens.rb +19 -7
  11. data/lib/still_active/ecosystems_client.rb +1 -1
  12. data/lib/still_active/forgejo_client.rb +1 -1
  13. data/lib/still_active/gitlab_client.rb +1 -1
  14. data/lib/{helpers → still_active/helpers}/activity_helper.rb +1 -1
  15. data/lib/{helpers → still_active/helpers}/cyclonedx_helper.rb +85 -7
  16. data/lib/{helpers → still_active/helpers}/libyear_helper.rb +1 -1
  17. data/lib/{helpers → still_active/helpers}/markdown_helper.rb +19 -0
  18. data/lib/{helpers → still_active/helpers}/sarif_helper.rb +17 -2
  19. data/lib/still_active/helpers/sbom_graph.rb +166 -0
  20. data/lib/{helpers → still_active/helpers}/status_helper.rb +23 -5
  21. data/lib/{helpers → still_active/helpers}/terminal_helper.rb +15 -0
  22. data/lib/{helpers → still_active/helpers}/version_helper.rb +14 -2
  23. data/lib/still_active/options.rb +6 -3
  24. data/lib/still_active/osv_client.rb +2 -2
  25. data/lib/still_active/poison_security_correlator.rb +3 -3
  26. data/lib/still_active/pypi_client.rb +1 -1
  27. data/lib/still_active/sarif/rules.rb +15 -0
  28. data/lib/still_active/sbom_reader.rb +117 -2
  29. data/lib/still_active/sbom_workflow.rb +4 -4
  30. data/lib/still_active/suppressions.rb +5 -3
  31. data/lib/still_active/version.rb +1 -1
  32. data/lib/still_active/workflow.rb +17 -11
  33. metadata +34 -33
  34. /data/lib/{helpers → still_active/helpers}/alternatives_helper.rb +0 -0
  35. /data/lib/{helpers → still_active/helpers}/ansi_helper.rb +0 -0
  36. /data/lib/{helpers → still_active/helpers}/bot_context.rb +0 -0
  37. /data/lib/{helpers → still_active/helpers}/bundler_helper.rb +0 -0
  38. /data/lib/{helpers → still_active/helpers}/catalog_index.rb +0 -0
  39. /data/lib/{helpers → still_active/helpers}/constraint_helper.rb +0 -0
  40. /data/lib/{helpers → still_active/helpers}/cvss_helper.rb +0 -0
  41. /data/lib/{helpers → still_active/helpers}/dependency_helper.rb +0 -0
  42. /data/lib/{helpers → still_active/helpers}/diff_markdown_helper.rb +0 -0
  43. /data/lib/{helpers → still_active/helpers}/dotnet_helper.rb +0 -0
  44. /data/lib/{helpers → still_active/helpers}/emoji_helper.rb +0 -0
  45. /data/lib/{helpers → still_active/helpers}/endoflife_helper.rb +0 -0
  46. /data/lib/{helpers → still_active/helpers}/http_helper.rb +0 -0
  47. /data/lib/{helpers → still_active/helpers}/lockfile_dependency_parser.rb +0 -0
  48. /data/lib/{helpers → still_active/helpers}/lockfile_indexer.rb +0 -0
  49. /data/lib/{helpers → still_active/helpers}/markdown_escape.rb +0 -0
  50. /data/lib/{helpers → still_active/helpers}/pep440_helper.rb +0 -0
  51. /data/lib/{helpers → still_active/helpers}/python_helper.rb +0 -0
  52. /data/lib/{helpers → still_active/helpers}/ruby_advisory_db.rb +0 -0
  53. /data/lib/{helpers → still_active/helpers}/ruby_helper.rb +0 -0
  54. /data/lib/{helpers → still_active/helpers}/runtime_ceiling_helper.rb +0 -0
  55. /data/lib/{helpers → still_active/helpers}/semver_satisfaction.rb +0 -0
  56. /data/lib/{helpers → still_active/helpers}/summary_helper.rb +0 -0
  57. /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: 56cc5a97641e59d9c11683f736609c52de9818aa90c8eeebec4c64909cbc1981
4
- data.tar.gz: c50ae5f85c0e258f0a3620159c05c4aaf131e9c25556ed940494d1fd90b829db
3
+ metadata.gz: f3351319b0427ef0043ecb5a150350499a25b7cb3228814184d3908d09497bc5
4
+ data.tar.gz: ffbc01c085040a9ed570095f133407dce101e4aa9a22689bfe423bc32996cdb3
5
5
  SHA512:
6
- metadata.gz: a067ca8d8e55a0d796efd76d88f95e1081ab520cbd6bd7496aec1791d2bf85e621a82edaca2bb458a758c82a04acdc9c4e6e4f2c2dd3456adf61386cec41652a
7
- data.tar.gz: bb99f7070a1c9c0c938df5deb1573ea400f608e4cf9a680cb06879c873a67589fcd3b2fda0a33d5cee14d8191515451bff37c6c0b72a6341a318919b9b3c80f1
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. `--cyclonedx` and `--baseline` need the native audit's lockfile/snapshot (per-ecosystem PURL reconstruction, a gems/ruby diff), which a cross-ecosystem SBOM can't honestly supply, so they error loudly rather than silently falling back to JSON. 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.
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,6 +58,16 @@ 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.
37
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.
38
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.
39
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.
@@ -41,7 +75,6 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
41
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).
42
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.
43
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.
44
- - **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.
45
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).
46
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.
47
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.
@@ -53,6 +86,10 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
53
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".
54
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.
55
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
+
56
93
  ## [2.0.0] - 2026-06-14
57
94
 
58
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
- Rule reference (SA001-SA009), 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
+ 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.
@@ -4,7 +4,7 @@ require "bundler"
4
4
  require "cgi"
5
5
  require "json"
6
6
  require "uri"
7
- require_relative "../helpers/http_helper"
7
+ require_relative "helpers/http_helper"
8
8
  require_relative "compact_index_client"
9
9
  require_relative "source_credentials"
10
10
 
@@ -3,21 +3,21 @@
3
3
  require_relative "options"
4
4
  require_relative "config_file"
5
5
  require_relative "diff"
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"
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 they error loudly rather than silently falling
156
- # back to JSON: --baseline (diff) and --cyclonedx (SBOM in, SBOM out would
157
- # need per-ecosystem PURL reconstruction; the honest way to add it later is to
158
- # thread the input SBOM's own PURLs through rather than rebuild them).
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
- if config.baseline_path
162
- unsupported_sbom_format!("--baseline")
163
- elsif config.cyclonedx_path
164
- unsupported_sbom_format!("--cyclonedx")
165
- elsif config.sarif_path
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)
@@ -3,7 +3,7 @@
3
3
  require "cgi"
4
4
  require "rubygems/resolver"
5
5
  require "uri"
6
- require_relative "../helpers/http_helper"
6
+ require_relative "helpers/http_helper"
7
7
 
8
8
  module StillActive
9
9
  # The RubyGems compact index (`/info/<gem>`), which is what Bundler itself
@@ -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 "../helpers/constraint_helper"
6
- require_relative "../helpers/vulnerability_helper"
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 "../helpers/http_helper"
4
- require_relative "../helpers/version_helper"
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 "../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/vulnerability_helper"
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
@@ -106,6 +107,17 @@ module StillActive
106
107
  # prerelease or an ahead-of-stable pin reads current (true), not "behind" --
107
108
  # parity with the native path's `>=` comparison. nil when latest is unknown.
108
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),
109
121
  repository_url: project_id && "https://#{project_id}",
110
122
  last_commit_date: repo[:last_commit_date],
111
123
  archived: repo[:archived],
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require_relative "../helpers/http_helper"
4
+ require_relative "helpers/http_helper"
5
5
  require_relative "version"
6
6
 
7
7
  module StillActive
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require_relative "../helpers/http_helper"
4
+ require_relative "helpers/http_helper"
5
5
 
6
6
  module StillActive
7
7
  # Repo signals (archived?, last commit date) for Forgejo/Gitea-hosted gems.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require_relative "../helpers/http_helper"
4
+ require_relative "helpers/http_helper"
5
5
 
6
6
  module StillActive
7
7
  module GitlabClient
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require_relative "../still_active/core_ext"
4
+ require_relative "../core_ext"
5
5
 
6
6
  module StillActive
7
7
  module ActivityHelper