still_active 3.0.0.rc2 → 3.0.0.rc4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89af276523def67c4566cdcc8ff084c14383f4c43dff3429a739f861cfea8555
4
- data.tar.gz: 8a6da7245242d2778126ed993484fd5bddf0cfba66d612cc120c705c1c519383
3
+ metadata.gz: e74ec3ec40686e3eab640b852270c36278b33c82b54fdc85fe7bbb43ce551455
4
+ data.tar.gz: 7304228ba4411b50474cba46dec421c3ce4ff9e53911ea256bef623874d0910c
5
5
  SHA512:
6
- metadata.gz: b3fd52ed8f8ad9a05267c22074b3720f9bdc48d0e65e0ff557a56c9d9f43cee7c9f07d1ec7acca2f82012a34a7c181d10a23d2427edd245d7055dad9e8866c4b
7
- data.tar.gz: 3751290569ec6bbd5e0d7cb9da9e76ebdd8add640c2baca94f04488b4f0874a8407ae9d5ceffecde0cc1296552aaaf765e3bb96f6e7e80e13fcf79a9e65471f3
6
+ metadata.gz: 6879718004e7d7a8765c7666f3d68686323873825ddb26240f929ee4b89a3337b0f7880373a34e669a72e545422dbf7a227a8142c9fede87f186e7f40fccfc3a
7
+ data.tar.gz: 2ed322c87a224e7e0bf2ea644dca8cdb341349b6985c251a47cf4abc62fcb9cef24d3dd6642d78789190aa7d64e1401814d4c1fa2fac7647746d4fef49657794
data/CHANGELOG.md CHANGED
@@ -20,12 +20,12 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
20
20
 
21
21
  - **Poison-pill / compatibility-ceiling signal (SA008).** A dormant or archived package that caps one of its runtime dependencies below that dependency's current latest major holds the tree below a ceiling no upstream release will raise, and it grows more constraining over time as the capped dep ships new majors. still_active flags it with a receipt naming the capped dep, the cap, and how many majors behind it holds you, ranked by a severity tier (`note`/`warning`/`critical`, scaling with majors-behind) and enforceable in CI with `--fail-if-poison[=TIER]`. Rendered in terminal, markdown, and SARIF, and computed for `rubygems` (native) and the flat-resolution SBOM ecosystems. Scoped to flat resolution deliberately: npm nests versions and cargo coexists majors, so a below-latest cap there is subtree-local noise, not a tree-wide block, and is suppressed.
22
22
  - **Security "below the fix", the strongest poison case, now cross-ecosystem.** When a dormant/archived package pins a dependency that is *itself* known-vulnerable in the same tree, below the version that patches a HIGH-or-critical advisory, you cannot patch the CVE without replacing the dead capper. still_active leads with these findings (red in terminal/markdown, escalated to `error` in SARIF) and names the advisory and its nearest fix. This case travels to **npm and cargo** at patch precision (their fixes are mostly same-major patch bumps, invisible to a major-level check), via a real node-semver/Cargo matcher and a tree-copy soundness guard that only fires when every resolved copy the constraint governs is vulnerable (a safe copy elsewhere, or a patched copy the cap can reach, clears it). The correlation is whole-tree from data already assembled, no extra fetches.
23
- - **Language-runtime ceiling signal (SA009), poison's sibling.** A pinned dependency version can strand you on an end-of-life language runtime (its `required_ruby_version` / `requires_python` forbids a supported one). still_active flags it against the runtime's EOL schedule (from endoflife.date), enforceable with `--fail-if-language-ceiling[=TIER]`, and reconciled against poison so an "upgrade to lift the ceiling" can't contradict a cap that forbids that upgrade. Ruby on the native path; **Python on the `--sbom` path** (pypi).
23
+ - **Language-runtime ceiling signal (SA009), poison's sibling.** A pinned dependency version can strand you on an end-of-life language runtime (its `required_ruby_version` / `requires_python` forbids a supported one). still_active flags it against the runtime's EOL schedule (from endoflife.date), enforceable with `--fail-if-language-ceiling[=TIER]`, and reconciled against poison so an "upgrade to lift the ceiling" can't contradict a cap that forbids that upgrade. Ruby on the native path; **Python** (`requires_python`) and **.NET/NuGet** on the `--sbom` path. .NET is shaped differently: a package declares a SET of target frameworks, and consuming one is a restore-time wall (NU1202), so the ceiling fires only when every concrete runtime target is end-of-life. A `netstandard` target is the escape hatch (consumable from a current .NET), so its presence never fires, keeping the signal off nearly every modern library; `.NET Framework` (4.x) and `.NET` (Core/5+) are checked against their separate EOL calendars.
24
24
  - **CVSS-4-only advisory scoring via an optional `cvss-suite`.** deps.dev stores only CVSS 3.x, so a CVSS-4-only advisory arrives unscored; still_active enriches every advisory with OSV's real severity label and fixed-version ranges, and, when the optional `cvss-suite` gem is installed, computes the CVSS-4 base score from the OSV vector. Without it, the OSV/GHSA label still gates and only the computed number is skipped, so the fail-closed logic never reads a real HIGH as clean. `cvss-suite` is opt-in (see Upgrading).
25
25
  - **Vulnerabilities with no fixed version available are flagged** (`no_fix_available`): a known advisory you cannot upgrade out of, the one you most need to see because upgrading isn't the answer.
26
26
  - **A schema canary for the deps.dev advisory field** (`api.deps.dev`'s alpha `advisoryKeys`): every cross-ecosystem vulnerability count flows through it, so a silent field rename would zero every count and read a vulnerable package as clean. A known-vulnerable canary (`django 3.0.0`) is checked each run; an empty result warns loudly rather than presenting a possibly-understated "all clear".
27
- - **The `--sbom` path now carries the same version signals as the native audit**: `libyear` drift, a prerelease/ahead-aware `up_to_date`, and (when the SBOM marks CycloneDX dev-vs-prod scope) a `production` boolean so a consumer can separate prod risk from test debt. A private/alternative registry named in a PURL's `repository_url` is refused a public-by-name lookup (the cross-ecosystem dependency-confusion guard), and a pinned version deps.dev can't resolve reads `unknown` rather than riding the fresh package date to a false `ok`.
28
- - **`--sbom=PATH` audits a CycloneDX SBOM cross-ecosystem**, so the maintenance lens travels beyond Ruby: point it at a Syft/Trivy-produced SBOM and still_active assesses every `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` package the same way it does gems (latest release date, archived repo, advisories, OpenSSF Scorecard, lifecycle `status`), sourcing signals from deps.dev and ecosyste.ms instead of Bundler. Mutually exclusive with `--gemfile`/`--gems`; emits JSON only (its shape differs from the Ruby audit, so it deliberately omits the `$schema` contract). Results are keyed `ecosystem/name@version` so nothing collides and overwrites, not a same-named package across two ecosystems nor two versions of one package pinned by different subprojects of a monorepo (a lockfile resolves one version per name, a merged SBOM doesn't), and each dependency carries its `activity_level` and `status`. The SBOM is treated as **untrusted input**: the only things read from it are ecosystem/name/version, the repository is discovered from deps.dev (never a lockfile-supplied URL, so a hostile SBOM can't redirect a lookup), and anything deps.dev doesn't index degrades to `unknown` rather than a fabricated `ok`. Packages it can't assess (unsupported ecosystems, a component with no version or PURL, **and** a dependency whose lookup raised, e.g. a rate-limited or flaky deps.dev) are surfaced in an `unassessable` list and a stderr count, never silently dropped, so a transient upstream hiccup can't shrink the audit scope invisibly and let it read "all clear" while ignoring the deps it skipped. A present-but-unreadable SBOM (truncated, or not CycloneDX) errors rather than degrading to an empty clean report. OS packages land in `unassessable` too; the differentiated play is maintenance, not vuln scanning, so compose Trivy/Grype for CVEs.
27
+ - **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`.
28
+ - **`--sbom=PATH` audits a CycloneDX SBOM cross-ecosystem**, so the maintenance lens travels beyond Ruby: point it at a Syft/Trivy-produced SBOM and still_active assesses every `npm`, `pypi`, `cargo`, `go`, `maven`, and `nuget` package the same way it does gems (latest release date, archived repo, advisories, OpenSSF Scorecard, lifecycle `status`), sourcing signals from deps.dev and ecosyste.ms instead of Bundler. Mutually exclusive with `--gemfile`/`--gems`, 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.
29
29
  - **Tokenless GitHub repo signals via [ecosyste.ms](https://ecosyste.ms).** Without a GitHub token the live API caps at 60 requests/hour, unusable past a handful of gems; still_active now falls back to ecosyste.ms (5000 anonymous requests/hour) for GitHub repos' archived + last-commit signals, so a large Gemfile audits cleanly with no token. GitHub-only by design (ecosyste.ms doesn't track commit recency for GitLab/Codeberg, so those keep their own clients); a configured token still takes precedence (freshest data, and it carries `--unreleased-commits`). Requests identify still_active in the User-Agent, and an optional `--ecosystems-email` / `STILL_ACTIVE_ECOSYSTEMS_EMAIL` joins ecosyste.ms's "polite pool" (higher rate limit) as a courtesy to the free service. ecosyste.ms data is CC-BY-SA 4.0 and attributed in the README.
30
30
 
31
31
  - Each gem now carries the OpenSSF Scorecard **`Maintained` sub-check** (`scorecard_maintained`, 0.0-10.0) alongside the aggregate `scorecard_score`. The sub-check scores recent commit and issue activity directly, the question still_active exists to answer, and it was already present in the deps.dev response still_active fetches (previously discarded). `nil` when a project has no scorecard, deliberately distinct from `0.0` ("measured: unmaintained") so absent data never reads as healthy.
@@ -33,6 +33,8 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
33
33
 
34
34
  ### Fixed
35
35
 
36
+ - **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.
37
+ - **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).
36
38
  - **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.
37
39
  - **`--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.
38
40
  - **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.
@@ -41,6 +43,11 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
41
43
  - **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.
42
44
  - **Graceful degradation on real SBOM/CLI input.** A git-scheme or scp `SOURCE_REPO` URL from deps.dev now parses correctly (recovering repo signals and silencing a warning flood); a malformed PURL (`pkg:npm/@1.0.0`, bad `%`-encoding) degrades to `unassessable` instead of backtracing and dropping every other dependency's verdict; and bad CLI input exits 2 with a friendly error instead of a stack trace (see Upgrading).
43
45
  - **The published JSON Schema matches real output.** It had rejected any report containing a vulnerability, because OSV enrichment adds fields (`osv_severity`, `fixed_versions`, and more) the strict `additionalProperties: false` schema didn't list. The schema now covers them (and the poison/below-the-fix keys), and the contract test validates enriched output so it can't drift silently again.
46
+ - **A current Go dependency no longer reads as "behind".** Go module versions carry a `v` prefix (`v2.0.1`) that `Gem::Version` can't parse, so the up-to-date comparison silently failed and the `--sbom` terminal/markdown table painted a `v2.0.1 -> v2.0.1` upgrade arrow on a package already on its latest release. The prefix is now stripped before comparison (a no-op for digit-first rubygems/npm/pypi/cargo versions).
47
+ - **A cross-ecosystem package with a "wrong" default version no longer reads as abandoned.** deps.dev's `isDefault` flag is not reliably the latest release: for `cargo/wasi` it points at `0.7.0` (2019) while `0.14.7` (2025) ships, and for `pypi/httpx` it points at a `1.0.0.dev3` prerelease over the `0.28.1` stable. The `--sbom` path trusted it, so an actively-maintained package rendered as years-stale (a false SA002 "no release in N years", which can fail `--fail-if-critical` and pollute Code Scanning) and a downgrade was shown as an upgrade. still_active now ranks a package's versions and takes the newest *stable* release, falling back to `isDefault`/newest-by-date only when no stable version parses.
48
+ - **SemVer build metadata no longer breaks the up-to-date comparison.** A cargo version like `1.0.4+wasi-0.2.12` couldn't be parsed by `Gem::Version` (the `+build` suffix), so `--sbom` painted a "behind" arrow on an already-current crate. Build metadata (which SemVer 2.0.0 says MUST be ignored for precedence) is now stripped before comparison, and the terminal version column distinguishes "behind" from "couldn't compare" (showing the version plainly rather than a false upgrade arrow, matching the markdown "unsure" state).
49
+ - **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".
50
+ - **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.
44
51
 
45
52
  ## [2.0.0] - 2026-06-14
46
53
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StillActive
4
+ # The canonical identity of an assessed dependency, shared by every renderer
5
+ # (terminal/markdown/SARIF display) and by the suppression matchers (--ignore
6
+ # and .still_active.yml).
7
+ module DependencyHelper
8
+ extend self
9
+
10
+ # A cross-ecosystem SBOM dependency is "ecosystem/name" (the lens sets
11
+ # :ecosystem and :name); a native gem is its bare name (the hash key), where
12
+ # gem_data carries no :ecosystem. Deliberately version-independent: it is NOT
13
+ # the composite "ecosystem/name@version" hash key, so one suppression covers a
14
+ # dependency across version bumps and matches the same identity the poison /
15
+ # ceiling correlators key on.
16
+ def identity(name, data)
17
+ data[:ecosystem] ? "#{data[:ecosystem]}/#{data[:name]}" : name
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constraint_helper"
4
+ require_relative "endoflife_helper"
5
+
6
+ module StillActive
7
+ # The NuGet calibration layer for the language-runtime ceiling (SA009), the
8
+ # .NET sibling of RubyHelper/PythonHelper. NuGet is shaped differently from
9
+ # Ruby/Python: a package declares not one version RANGE but a SET of target
10
+ # framework monikers (net6.0, netcoreapp3.1, net48, netstandard2.0). Targeting a
11
+ # framework is an enforced restore-time wall (NuGet error NU1202), so if EVERY
12
+ # concrete runtime the package targets is end-of-life, consuming it forces your
13
+ # project onto a dead runtime -- the same "no supported runtime is reachable"
14
+ # cap the Ruby/Python ceiling catches, expressed as a set rather than a range.
15
+ #
16
+ # The honesty rule is the netstandard exclusion: `netstandard*` is a
17
+ # compatibility contract consumable FROM a modern .NET, not a runtime, so it
18
+ # imposes no ceiling. Without excluding it the signal would fire on nearly every
19
+ # modern library. We therefore fire only when the package has at least one
20
+ # concrete runtime target AND every concrete runtime target is EOL.
21
+ module DotnetHelper
22
+ extend self
23
+
24
+ # The two .NET runtime calendars, fetched once per SBOM. .NET Core/5+ is a
25
+ # single unified endoflife feed; .NET Framework 4.x is its own (`dotnetfx`),
26
+ # with a different EOL schedule tied to Windows servicing.
27
+ def supported_dotnet_range = EndoflifeHelper.support_window(feed_path: "/api/dotnet.json")
28
+ def supported_dotnetfx_range = EndoflifeHelper.support_window(feed_path: "/api/dotnetfx.json")
29
+
30
+ # A target framework moniker -> { family: :dotnet | :dotnetfx, version: } (the
31
+ # runtime it names and the endoflife.date cycle to look it up by), or nil when
32
+ # the moniker is not a concrete runtime (netstandard, or unrecognized -- we
33
+ # never guess). Tolerates deps.dev's short form (net45, netcoreapp3.1), its
34
+ # long form (.NETFramework4.5), platform suffixes (net6.0-windows), and case.
35
+ def classify(moniker)
36
+ return if moniker.nil?
37
+
38
+ m = moniker.to_s.strip.downcase
39
+ return if m.empty?
40
+
41
+ # Drop a platform suffix (net6.0-windows -> net6.0) and the long-form dot.
42
+ dash = m.index("-")
43
+ m = m[0, dash] if dash
44
+ m = m.delete_prefix(".")
45
+
46
+ return if m.start_with?("netstandard") # a compatibility contract, not a runtime
47
+
48
+ # Ordered, mutually-exclusive prefixes; the load-bearing distinction is the
49
+ # dot: a dotted "netX.Y" is .NET 5+ (major cycle), a dot-less "netXY" is .NET
50
+ # Framework (digits expand to the cycle).
51
+ if m.start_with?("netcoreapp")
52
+ dotnet(m.delete_prefix("netcoreapp")) # netcoreapp3.1 -> .NET "3.1" (minor kept)
53
+ elsif m.start_with?("netframework")
54
+ dotnetfx(m.delete_prefix("netframework")) # long form: .NETFramework4.7.2
55
+ elsif m.start_with?("net")
56
+ rest = m.delete_prefix("net")
57
+ if rest.include?(".")
58
+ dotnet(rest.split(".").first) # net6.0 -> .NET "6" (major only)
59
+ elsif all_digits?(rest)
60
+ dotnetfx(expand_framework_digits(rest)) # net48 -> .NET Framework "4.8"
61
+ end
62
+ end
63
+ end
64
+
65
+ # Whether a moniker is a netstandard target (tolerating the long-form ".NETStandard"
66
+ # and platform/case variants) -- the escape hatch analyze must honour.
67
+ def netstandard?(moniker)
68
+ return false if moniker.nil?
69
+
70
+ moniker.to_s.strip.downcase.delete_prefix(".").start_with?("netstandard")
71
+ end
72
+
73
+ # => the SA009 eol_forced finding hash, or nil when the package is not capped.
74
+ # `dotnet` / `dotnetfx` are EndoflifeHelper.support_window results (or nil when
75
+ # the feed is down); only their :cycles list is read.
76
+ def analyze(target_frameworks:, dotnet:, dotnetfx:)
77
+ monikers = Array(target_frameworks)
78
+ # THE honesty rule: a netstandard target is a compatibility contract
79
+ # consumable from a current .NET, so its mere presence is an escape hatch --
80
+ # the package is not capped, whatever else it targets. Without this the signal
81
+ # fires on nearly every modern .NET library (Newtonsoft.Json, EF Core, ...),
82
+ # which all ship a netstandard2.0 target alongside older concrete runtimes.
83
+ return if monikers.any? { |moniker| netstandard?(moniker) }
84
+
85
+ runtimes = monikers.filter_map do |moniker|
86
+ classification = classify(moniker)
87
+ classification&.merge(moniker: moniker, cycle: cycle_for(classification, dotnet: dotnet, dotnetfx: dotnetfx))
88
+ end
89
+ return if runtimes.empty?
90
+
91
+ # A still-supported runtime target is a clear escape hatch: not capped.
92
+ return if runtimes.any? { |r| r[:cycle] && r[:cycle][:eol] == false }
93
+
94
+ eol = runtimes.select { |r| r[:cycle] && r[:cycle][:eol] == true }
95
+ return if eol.empty? # nothing KNOWN-EOL: don't fabricate a ceiling from unknown cycles
96
+
97
+ # The longest-lived dead runtime is the best the package offers -- the ceiling.
98
+ ceiling = eol.max_by { |r| r[:cycle][:eol_date] || Time.at(0) }
99
+ finding = {
100
+ requirement: runtimes.map { |r| r[:moniker] }.uniq.sort.join(", "),
101
+ eol_forced: true,
102
+ runtime: ceiling[:family] == :dotnetfx ? ".NET Framework" : ".NET",
103
+ ceiling_version: ceiling[:version].to_s,
104
+ ceiling_eol_date: ceiling[:cycle][:eol_date],
105
+ }
106
+ finding.merge(severity: ConstraintHelper.constraint_severity(finding))
107
+ end
108
+
109
+ private
110
+
111
+ # "461" -> "4.6.1", "48" -> "4.8", "45" -> "4.5": first digit is the major, each
112
+ # remaining digit a further segment. Only called on an all-digit remainder.
113
+ def expand_framework_digits(digits) = ([digits[0]] + digits[1..].chars).join(".")
114
+
115
+ def all_digits?(str)
116
+ !str.empty? && str.chars.all? { |c| ("0".."9").cover?(c) }
117
+ end
118
+
119
+ def dotnet(version) = build(:dotnet, version)
120
+ def dotnetfx(version) = build(:dotnetfx, version)
121
+
122
+ def build(family, version)
123
+ return unless version && Gem::Version.correct?(version)
124
+
125
+ { family: family, version: Gem::Version.new(version) }
126
+ end
127
+
128
+ # The endoflife cycle for a classified target, or nil when the feed is down or
129
+ # the cycle isn't tracked (an ancient net20 predating the feed) -- unknown, not
130
+ # assumed EOL.
131
+ def cycle_for(classification, dotnet:, dotnetfx:)
132
+ window = classification[:family] == :dotnetfx ? dotnetfx : dotnet
133
+ return if window.nil?
134
+
135
+ # Match on the release segment, ignoring any prerelease suffix: endoflife
136
+ # keys .NET Framework 3.5 as "3.5-sp1", so net35 ("3.5") must still match it
137
+ # (otherwise a still-supported target reads as unknown and can false-fire).
138
+ target = classification[:version].release
139
+ window[:cycles].find { |cycle| cycle[:version].release == target }
140
+ end
141
+ end
142
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "markdown_escape"
4
+ require_relative "dependency_helper"
4
5
  require_relative "vulnerability_helper"
5
6
  require_relative "activity_helper"
6
7
  require_relative "constraint_helper"
@@ -40,7 +41,7 @@ module StillActive
40
41
  inactive_repository_emoji = data[:last_activity_warning_emoji]
41
42
  using_latest_version_emoji = data[:up_to_date_emoji]
42
43
 
43
- formatted_name = markdown_url(text: gem_name, url: repository_url)
44
+ formatted_name = markdown_url(text: DependencyHelper.identity(gem_name, data), url: repository_url)
44
45
 
45
46
  formatted_version_used = if [:git, :path].include?(data[:source_type])
46
47
  data[:version_used] ? "#{data[:version_used]} (#{data[:source_type]})" : "(#{data[:source_type]})"
@@ -66,7 +67,11 @@ module StillActive
66
67
  date: data[:latest_pre_release_version_release_date],
67
68
  )
68
69
 
69
- formatted_last_commit = markdown_url(text: year_month(data[:last_commit_date]), url: repository_url)
70
+ # Only link when there is a date: markdown_url with a nil date + a present
71
+ # repo returns "[](url)" (an invisible link, and truthy, so the `|| unsure`
72
+ # fallback below never fires). Guard here so a missing date shows unsure.
73
+ last_commit_ym = year_month(data[:last_commit_date])
74
+ formatted_last_commit = markdown_url(text: last_commit_ym, url: repository_url) if last_commit_ym
70
75
 
71
76
  unsure = StillActive.config.unsure_emoji
72
77
 
@@ -179,7 +184,7 @@ module StillActive
179
184
  ranked = flagged.sort_by { |name, data| [-ConstraintHelper::SEVERITY.index(data[:language_ceiling][:severity] || :note), name.to_s] }
180
185
  ranked.each do |name, data|
181
186
  ceiling = data[:language_ceiling]
182
- lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(name)} #{language_ceiling_receipt(ceiling, data)}"
187
+ lines << "- **#{ceiling[:severity] || :note}** #{MarkdownEscape.code_span(DependencyHelper.identity(name, data))} #{language_ceiling_receipt(ceiling, data)}"
183
188
  end
184
189
  lines.join("\n")
185
190
  end
@@ -202,7 +207,7 @@ module StillActive
202
207
  end
203
208
 
204
209
  def poison_gem_ref(name, data)
205
- ref = MarkdownEscape.code_span(name)
210
+ ref = MarkdownEscape.code_span(DependencyHelper.identity(name, data))
206
211
  path = data[:dependency_path]
207
212
  # Transitive pill: name the direct parent the user can actually act on (#60).
208
213
  return ref unless data[:direct] == false && Array(path).length >= 2
@@ -290,10 +295,14 @@ module StillActive
290
295
  "[#{safe_text}](#{MarkdownEscape.url(url)})"
291
296
  end
292
297
 
293
- def year_month(time_object)
294
- return if time_object.nil?
298
+ # The native Ruby path stores dates as Time; the cross-ecosystem lens carries
299
+ # deps.dev's raw ISO8601 strings. Parse either (nil-safe) rather than assuming
300
+ # Time, so an SBOM audit renders dates instead of crashing on String#strftime.
301
+ def year_month(value)
302
+ time = ActivityHelper.parse_time(value)
303
+ return if time.nil?
295
304
 
296
- time_object.strftime("%Y/%m")
305
+ time.strftime("%Y/%m")
297
306
  end
298
307
  end
299
308
  end
@@ -6,7 +6,9 @@ require "time"
6
6
  require_relative "../still_active/sarif/rules"
7
7
  require_relative "lockfile_indexer"
8
8
  require_relative "activity_helper"
9
+ require_relative "dependency_helper"
9
10
  require_relative "constraint_helper"
11
+ require_relative "version_helper"
10
12
 
11
13
  module StillActive
12
14
  # Renders a still_active workflow result as a SARIF 2.1.0 document.
@@ -41,12 +43,43 @@ module StillActive
41
43
  lockfile_uri: lockfile_uri,
42
44
  )
43
45
 
44
- JSON.pretty_generate(document(results: results, tool_version: tool_version))
46
+ JSON.pretty_generate(document(results: results, tool_version: tool_version, flavour: :ruby))
47
+ end
48
+
49
+ # The SBOM path emits the same rule set and document, but there is no lockfile
50
+ # to annotate: every finding anchors to the SBOM file itself (line 1), and
51
+ # ruby_info is nil (Ruby EOL / SA006 is a native-only signal). Findings are
52
+ # named ecosystem/name (each gem_data carries :ecosystem and :name), so a
53
+ # cross-ecosystem alert reads "npm/left-pad ..." rather than a bare gem name.
54
+ # A polyglot SBOM has no single ecosystem, so the rule catalog uses the
55
+ # ecosystem-neutral wording (`flavour: :neutral`) rather than the native "gem"
56
+ # idiom, so a Go/npm/pypi repo's Code Scanning UI doesn't read as a Ruby audit.
57
+ #
58
+ # result: SbomWorkflow assessed hash ("ecosystem/name@version" => gem_data)
59
+ # source_uri: the SBOM file to point findings at (basename)
60
+ # tool_version: StillActive::VERSION at emit time
61
+ def render_sbom(result:, source_uri:, tool_version:)
62
+ results = build_results(
63
+ report: result,
64
+ ruby_info: nil,
65
+ line_index: {},
66
+ ruby_line: nil,
67
+ lockfile_uri: source_uri,
68
+ )
69
+
70
+ JSON.pretty_generate(document(results: results, tool_version: tool_version, flavour: :neutral))
45
71
  end
46
72
 
47
73
  private
48
74
 
49
- def document(results:, tool_version:)
75
+ def document(results:, tool_version:, flavour:)
76
+ # The catalog is the single source of truth for BOTH the driver rule list and
77
+ # each result's ruleIndex (its position in that list). The SBOM (:neutral)
78
+ # catalog drops the native-only SA006, so a result's ruleIndex must be looked
79
+ # up against the same-flavour catalog, not the ruby default, or SA007/8/9 would
80
+ # index the wrong driver rule. Stamping it here keeps the two in lockstep.
81
+ catalog = Sarif::Rules.all(flavour)
82
+ index_by_id = catalog.each_with_index.to_h { |rule, i| [rule[:id], i] }
50
83
  {
51
84
  "$schema" => SARIF_SCHEMA,
52
85
  "version" => "2.1.0",
@@ -54,20 +87,29 @@ module StillActive
54
87
  "tool" => {
55
88
  "driver" => {
56
89
  "name" => TOOL_NAME,
57
- "semanticVersion" => tool_version,
90
+ # `version` is the free-form native version string (the RubyGems
91
+ # version verbatim); `semanticVersion` MUST be SemVer 2.0.0, so a
92
+ # prerelease like "3.0.0.rc4" is rendered as "3.0.0-rc4". Both derive
93
+ # from StillActive::VERSION. GitHub Code Scanning only needs `name`,
94
+ # but some SARIF consumers read either version field.
95
+ "version" => tool_version,
96
+ "semanticVersion" => VersionHelper.to_semver(tool_version),
58
97
  "informationUri" => TOOL_URI,
59
- "rules" => sarif_rules,
98
+ "rules" => catalog.map { |r| sarif_rule(r) },
60
99
  },
61
100
  },
62
101
  "originalUriBaseIds" => { "%SRCROOT%" => { "uri" => "file:///" } },
63
- "results" => results,
102
+ "results" => results.map { |res| stamp_rule_index(res, index_by_id) },
64
103
  "columnKind" => "utf16CodeUnits",
65
104
  }],
66
105
  }
67
106
  end
68
107
 
69
- def sarif_rules
70
- Sarif::Rules.all.map { |r| sarif_rule(r) }
108
+ # SARIF ruleIndex is optional; omit it rather than emit nil when the rule isn't
109
+ # in this run's catalog (can't happen today, but never point at a bogus index).
110
+ def stamp_rule_index(result, index_by_id)
111
+ index = index_by_id[result["ruleId"]]
112
+ index ? result.merge("ruleIndex" => index) : result
71
113
  end
72
114
 
73
115
  def sarif_rule(r)
@@ -94,13 +136,21 @@ module StillActive
94
136
  results
95
137
  end
96
138
 
139
+ # `name` is the hash key: the bare gem name natively, the composite
140
+ # "ecosystem/name@version" on the SBOM path. It drives the fingerprint and
141
+ # location, both of which MUST stay version-distinct so two pinned versions of
142
+ # one package (a merged monorepo SBOM) never collapse into a single Code
143
+ # Scanning alert. `display` is the version-independent "ecosystem/name" (bare
144
+ # name natively): what the message shows and what a --ignore/.still_active.yml
145
+ # suppression names, so one suppression covers a dependency across versions.
97
146
  def gem_results(name, data, line_index, lockfile_uri)
98
147
  out = []
99
148
  version = data[:version_used]
149
+ display = DependencyHelper.identity(name, data)
100
150
  location = location_for(name, line_index, lockfile_uri)
101
151
 
102
152
  if data[:archived]
103
- out << mark_suppressed(result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), name, :activity)
153
+ out << mark_suppressed(result("SA001", name, "#{display} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}#{transitive_suffix(data)}.", location), display, :activity)
104
154
  end
105
155
 
106
156
  unless data[:archived]
@@ -112,10 +162,10 @@ module StillActive
112
162
  result(
113
163
  "SA002",
114
164
  name,
115
- "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
165
+ "#{display} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
116
166
  location,
117
167
  ),
118
- name,
168
+ display,
119
169
  :activity,
120
170
  )
121
171
  end
@@ -127,15 +177,15 @@ module StillActive
127
177
 
128
178
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
129
179
  latest = data[:latest_version] ? " behind #{data[:latest_version]}" : ""
130
- out << mark_suppressed(result("SA004", name, "#{name} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), name, :libyear)
180
+ out << mark_suppressed(result("SA004", name, "#{display} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), display, :libyear)
131
181
  end
132
182
 
133
183
  if data[:scorecard_score] && data[:scorecard_score] < SCORECARD_LOW_THRESHOLD
134
- out << mark_suppressed(result("SA005", name, "#{name} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), name, :scorecard)
184
+ out << mark_suppressed(result("SA005", name, "#{display} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), display, :scorecard)
135
185
  end
136
186
 
137
187
  if data[:version_yanked]
138
- out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
188
+ out << mark_suppressed(result("SA007", name, "#{display} #{version}: this version has been yanked from RubyGems.", location), display, :yanked)
139
189
  end
140
190
 
141
191
  if data[:poison] && !Array(data[:constraints]).empty?
@@ -151,7 +201,7 @@ module StillActive
151
201
  else
152
202
  "maintenance"
153
203
  end
154
- out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
204
+ out << mark_suppressed(result("SA008", name, poison_message(display, version, data), location, level: poison_level(data), fp_extra: state), display, :poison)
155
205
  end
156
206
 
157
207
  if data[:language_ceiling]
@@ -163,7 +213,7 @@ module StillActive
163
213
  # silently mute the critical -- that transition is exactly the one a human
164
214
  # must see.
165
215
  state = data[:language_ceiling][:eol_forced] ? "eol_forced" : "latest_not_yet"
166
- out << mark_suppressed(result("SA009", name, language_ceiling_message(name, version, data), location, level: language_ceiling_level(data), fp_extra: state), name, :language_ceiling)
216
+ out << mark_suppressed(result("SA009", name, language_ceiling_message(display, version, data), location, level: language_ceiling_level(data), fp_extra: state), display, :language_ceiling)
167
217
  end
168
218
 
169
219
  out
@@ -173,7 +223,7 @@ module StillActive
173
223
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
174
224
  end
175
225
 
176
- def language_ceiling_message(name, version, data)
226
+ def language_ceiling_message(display, version, data)
177
227
  ceiling = data[:language_ceiling]
178
228
  runtime = ceiling[:runtime]
179
229
  body =
@@ -185,7 +235,7 @@ module StillActive
185
235
  "no #{runtime} #{ceiling[:latest_stable]} support yet"
186
236
  end
187
237
  fix = ceiling[:fixed_by_upgrade] && data[:latest_version] ? "; upgrade to #{data[:latest_version]} to lift it" : ""
188
- "#{name} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
238
+ "#{display} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
189
239
  end
190
240
 
191
241
  # The poison receipt for a Code Scanning alert: the worst 3 caps (shared
@@ -203,7 +253,7 @@ module StillActive
203
253
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
204
254
  end
205
255
 
206
- def poison_message(name, version, data)
256
+ def poison_message(display, version, data)
207
257
  top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
208
258
  caps = top[:shown].map do |finding|
209
259
  behind = finding[:majors_behind]
@@ -211,7 +261,7 @@ module StillActive
211
261
  end
212
262
  remaining = top[:total] - top[:shown].length
213
263
  caps << "+#{remaining} more" if remaining.positive?
214
- "#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
264
+ "#{display} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
215
265
  end
216
266
 
217
267
  # Spells out the security escalation for a code-scanning alert. Only claims
@@ -254,6 +304,10 @@ module StillActive
254
304
  end
255
305
 
256
306
  def vulnerability_result(name, version, vuln, location, data = {})
307
+ # `name` (the version-distinct hash key) fingerprints the finding; `display`
308
+ # (version-independent "ecosystem/name") is what the message shows and the
309
+ # suppression matches. See gem_results for why the two differ.
310
+ display = DependencyHelper.identity(name, data)
257
311
  # Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
258
312
  # label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
259
313
  # exports as error, not a note/warning a code-scanning gate reads as
@@ -274,13 +328,13 @@ module StillActive
274
328
  base = result(
275
329
  "SA003",
276
330
  name,
277
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
331
+ "#{display} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
278
332
  location,
279
333
  level: level,
280
334
  fp_extra: advisory_id,
281
335
  )
282
336
  base["properties"] = { "security-severity" => severity } if severity
283
- mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
337
+ mark_suppressed(base, display, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
284
338
  end
285
339
 
286
340
  # Names the direct dependency a transitive flagged gem rides in on, so a
@@ -299,7 +353,6 @@ module StillActive
299
353
  latest_part = ruby_info[:latest_version] ? " Latest is #{ruby_info[:latest_version]}." : ""
300
354
  base = {
301
355
  "ruleId" => "SA006",
302
- "ruleIndex" => rule_index("SA006"),
303
356
  "level" => "error",
304
357
  "message" => { "text" => "Ruby #{version} has reached end-of-life#{eol_part}.#{latest_part}" },
305
358
  "locations" => [{
@@ -316,7 +369,6 @@ module StillActive
316
369
  level ||= Sarif::Rules.find(rule_id)[:level]
317
370
  base = {
318
371
  "ruleId" => rule_id,
319
- "ruleIndex" => rule_index(rule_id),
320
372
  "level" => level,
321
373
  "message" => { "text" => message },
322
374
  "locations" => [location],
@@ -345,10 +397,6 @@ module StillActive
345
397
  Digest::SHA256.hexdigest(["v1", rule_id, gem_name, advisory_id].compact.join("|"))[0, 16]
346
398
  end
347
399
 
348
- def rule_index(rule_id)
349
- Sarif::Rules.all.index { |r| r[:id] == rule_id }
350
- end
351
-
352
400
  def format_date(value)
353
401
  t = ActivityHelper.parse_time(value)
354
402
  t ? t.utc.strftime("%Y-%m-%d") : value.to_s
@@ -3,6 +3,7 @@
3
3
  require_relative "activity_helper"
4
4
  require_relative "ansi_helper"
5
5
  require_relative "constraint_helper"
6
+ require_relative "dependency_helper"
6
7
  require_relative "summary_helper"
7
8
  require_relative "libyear_helper"
8
9
  require_relative "version_helper"
@@ -34,9 +35,16 @@ module StillActive
34
35
 
35
36
  private
36
37
 
38
+ # "dependencies" for a cross-ecosystem SBOM audit (the lens sets :ecosystem),
39
+ # "gems" for a native Ruby audit -- calling npm/cargo/go packages "gems" is a
40
+ # Ruby-ism that reads wrong cross-ecosystem.
41
+ def dependency_noun(result)
42
+ result.each_value.any? { |data| data[:ecosystem] } ? "dependencies" : "gems"
43
+ end
44
+
37
45
  def build_row(name, data)
38
46
  [
39
- name,
47
+ DependencyHelper.identity(name, data),
40
48
  format_version(data),
41
49
  format_activity(data),
42
50
  format_scorecard(data[:scorecard_score]),
@@ -64,9 +72,15 @@ module StillActive
64
72
  return AnsiHelper.dim("-") if used.nil? && latest.nil?
65
73
  return AnsiHelper.red("#{used} (YANKED)") if data[:version_yanked]
66
74
 
67
- if VersionHelper.up_to_date(version_used: used, latest_version: latest)
75
+ # up_to_date is tri-state: true (on latest), false (genuinely behind), or nil
76
+ # (a version Gem::Version can't parse -- a pypi epoch, an exotic build string).
77
+ # Only paint the "-> latest" upgrade arrow on a definite false; a nil means we
78
+ # couldn't compare, so show the version plainly rather than a false "behind"
79
+ # (markdown shows unsure via its emoji tri-state for the same case).
80
+ current = VersionHelper.up_to_date(version_used: used, latest_version: latest)
81
+ if current
68
82
  AnsiHelper.green("#{used} (latest)")
69
- elsif latest
83
+ elsif current == false && latest
70
84
  AnsiHelper.yellow("#{used} → #{latest}")
71
85
  else
72
86
  used.to_s
@@ -318,7 +332,7 @@ module StillActive
318
332
  archived = summary[:activity][:archived]
319
333
  yanked = result.each_value.count { |d| d[:version_yanked] }
320
334
 
321
- parts = ["#{summary[:total_gems]} gems: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
335
+ parts = ["#{summary[:total_gems]} #{dependency_noun(result)}: #{summary[:up_to_date]} up to date, #{summary[:outdated]} outdated"]
322
336
  parts.last << ", #{yanked} yanked" if yanked > 0
323
337
  activity = "#{active} active, #{stale} stale"
324
338
  activity << ", #{archived} archived" if archived > 0
@@ -52,6 +52,36 @@ module StillActive
52
52
  .any? { |v| used >= v }
53
53
  end
54
54
 
55
+ # A Gem::Version for cross-ecosystem comparison (leading "v" and SemVer build
56
+ # metadata normalized away), or nil when the string can't be parsed. Public so
57
+ # the deps.dev client can rank a package's versions to find the real latest
58
+ # stable rather than trusting deps.dev's unreliable `isDefault` flag.
59
+ def comparable(version_string)
60
+ to_gem_version(version_string)
61
+ end
62
+
63
+ # Renders a MAJOR.MINOR.PATCH[.prerelease] RubyGems version as SemVer 2.0.0,
64
+ # for fields that require it (SARIF's tool.driver.semanticVersion). A gem
65
+ # prerelease joins its prerelease identifiers to the release with a ".", SemVer
66
+ # with a "-": "3.0.0.rc4" -> "3.0.0-rc4", "1.2.3.pre.5" -> "1.2.3-pre.5". A
67
+ # plain release ("3.0.0") is already SemVer and passes through, and a short
68
+ # numeric release is padded to MAJOR.MINOR.PATCH. A string that doesn't start
69
+ # with a numeric segment is returned unchanged rather than coerced into a bogus
70
+ # version. Scoped to the tool's own version scheme: a 4+-segment numeric
71
+ # release (e.g. a gem like "6.1.7.6") is NOT converted to valid SemVer.
72
+ def to_semver(version_string)
73
+ return version_string if version_string.nil? || version_string.empty?
74
+
75
+ parts = version_string.split(".")
76
+ release = parts.take_while { |part| part.match?(/\A\d+\z/) }
77
+ return version_string if release.empty?
78
+
79
+ prerelease = parts.drop(release.length)
80
+ release << "0" while release.length < 3
81
+ base = release.join(".")
82
+ prerelease.empty? ? base : "#{base}-#{prerelease.join(".")}"
83
+ end
84
+
55
85
  def gem_version(version_hash:)
56
86
  version_hash&.dig("number")
57
87
  end
@@ -88,7 +118,20 @@ module StillActive
88
118
 
89
119
  def to_gem_version(version)
90
120
  str = normalize_version(version)
91
- Gem::Version.new(str) if str && Gem::Version.correct?(str)
121
+ return unless str
122
+
123
+ # Normalize two cross-ecosystem shapes Gem::Version can't parse, so a
124
+ # current dependency compares as up to date instead of reading "behind":
125
+ # - a leading "v" (Go module versions, "v2.0.1")
126
+ # - SemVer build metadata (cargo's "1.0.4+wasi-0.2.12"), everything from the
127
+ # first "+", which SemVer 2.0.0 sec 10 says MUST be ignored for precedence
128
+ # rubygems/npm/pypi versions are digit-first with no "+", so this is a no-op.
129
+ # Sliced by index rather than a regex (/\+.*/ on the external version string
130
+ # trips a polynomial-ReDoS scanner, and this is provably linear anyway).
131
+ str = str.delete_prefix("v")
132
+ plus = str.index("+")
133
+ str = str[0, plus] if plus
134
+ Gem::Version.new(str) if Gem::Version.correct?(str)
92
135
  end
93
136
  end
94
137
  end
@@ -8,6 +8,7 @@ require_relative "../helpers/bot_context"
8
8
  require_relative "../helpers/bundler_helper"
9
9
  require_relative "../helpers/constraint_helper"
10
10
  require_relative "../helpers/cyclonedx_helper"
11
+ require_relative "../helpers/dependency_helper"
11
12
  require_relative "../helpers/diff_markdown_helper"
12
13
  require_relative "../helpers/emoji_helper"
13
14
  require_relative "../helpers/markdown_helper"
@@ -140,11 +141,58 @@ module StillActive
140
141
  # assessment-time failure (a raised lens call) both mean "not assessed":
141
142
  # surface them together so neither is a silent hole in the reported coverage.
142
143
  unassessable = sbom.unassessable + outcome.failures
143
- emit_sbom_json(outcome.assessed, unassessable)
144
+ render_sbom_output(outcome.assessed, unassessable, path)
144
145
  warn_unassessable(unassessable)
145
146
  check_exit_status(outcome.assessed)
146
147
  end
147
148
 
149
+ # The assessment is the same per-dependency shape whether the input was a
150
+ # Gemfile or an SBOM, so the SBOM path honours the same output-format flags
151
+ # rather than forcing JSON. SARIF and markdown are the two that matter for the
152
+ # cross-ecosystem/CI use case; JSON stays the piped default. Two formats need
153
+ # the native Ruby audit's shape (a lockfile to annotate for SARIF's line
154
+ # 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).
159
+ def render_sbom_output(result, unassessable, sbom_path)
160
+ 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
166
+ emit_sbom_sarif(result, config.sarif_path, sbom_path)
167
+ else
168
+ case resolve_format
169
+ when :json then emit_sbom_json(result, unassessable)
170
+ when :terminal then puts TerminalHelper.render(result)
171
+ when :markdown then render_markdown(result)
172
+ end
173
+ end
174
+ end
175
+
176
+ def unsupported_sbom_format!(flag)
177
+ $stderr.puts("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")
178
+ exit(2)
179
+ end
180
+
181
+ def emit_sbom_sarif(result, sarif_path, sbom_path)
182
+ sarif_json = SarifHelper.render_sbom(
183
+ result: result,
184
+ source_uri: File.basename(sbom_path),
185
+ tool_version: StillActive::VERSION,
186
+ )
187
+
188
+ write_output(sarif_path, sarif_json)
189
+ end
190
+
191
+ # "-" means stdout (the SARIF/CycloneDX convention); any other PATH is a file.
192
+ def write_output(path, content)
193
+ path == "-" ? puts(content) : File.write(path, content)
194
+ end
195
+
148
196
  # SbomReader never raises (a malformed file degrades to an empty result), which
149
197
  # is right for the library entrypoint but wrong for the explicit --sbom flag:
150
198
  # the user pointed at a file to audit, so a truncated/wrong-format one must
@@ -271,11 +319,7 @@ module StillActive
271
319
  tool_version: StillActive::VERSION,
272
320
  )
273
321
 
274
- if sarif_path == "-"
275
- puts sarif_json
276
- else
277
- File.write(sarif_path, sarif_json)
278
- end
322
+ write_output(sarif_path, sarif_json)
279
323
  end
280
324
 
281
325
  def emit_cyclonedx(result, ruby_info, cyclonedx_path)
@@ -286,11 +330,7 @@ module StillActive
286
330
  spec_version: StillActive.config.cyclonedx_version,
287
331
  )
288
332
 
289
- if cyclonedx_path == "-"
290
- puts sbom
291
- else
292
- File.write(cyclonedx_path, sbom)
293
- end
333
+ write_output(cyclonedx_path, sbom)
294
334
  end
295
335
 
296
336
  # Mirrors Bundler's convention: gems.rb -> gems.locked, otherwise <gemfile>.lock.
@@ -394,7 +434,10 @@ module StillActive
394
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
395
435
 
396
436
  warn_unknown_severity_gate(result, config)
397
- exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
437
+ # Match the gate on the dependency's identity (bare gem name natively,
438
+ # "ecosystem/name" for an SBOM dep), not the composite SBOM hash key, so an
439
+ # --ignore/.still_active.yml entry actually covers a cross-ecosystem finding.
440
+ exit(1) if result.any? { |name, data| gate_failed?(DependencyHelper.identity(name, data), data, config) }
398
441
  end
399
442
 
400
443
  # --fail-if-vulnerable=<threshold> fails closed on an advisory with no CVSS
@@ -409,14 +452,15 @@ module StillActive
409
452
 
410
453
  suppressions = config.suppressions
411
454
  result.each do |name, data|
412
- next if config.ignored_gems.include?(name)
455
+ gem = DependencyHelper.identity(name, data)
456
+ next if config.ignored_gems.include?(gem)
413
457
 
414
- unknown = live_advisories(name, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
458
+ unknown = live_advisories(gem, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
415
459
  next if unknown.empty?
416
460
 
417
461
  ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
418
462
  labelled = ids.empty? ? "" : " (#{ids})"
419
- $stderr.puts("warning: #{name} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
463
+ $stderr.puts("warning: #{gem} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
420
464
  end
421
465
  end
422
466
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../helpers/http_helper"
4
+ require_relative "../helpers/version_helper"
4
5
 
5
6
  module StillActive
6
7
  module DepsDevClient
@@ -61,12 +62,36 @@ module StillActive
61
62
  versions = body["versions"]
62
63
  return unless versions.is_a?(Array) && !versions.empty?
63
64
 
64
- entry = versions.find { |v| v.is_a?(Hash) && v["isDefault"] } || newest_version(versions)
65
+ # deps.dev's `isDefault` is NOT reliably the latest release: cargo/wasi flags
66
+ # 0.7.0 (2019) while 0.14.7 (2025) ships, and pypi/httpx flags a 1.0.0.dev3
67
+ # prerelease over the 0.28.1 stable. Trusting it reads an active package as
68
+ # years-stale (a false SA002 "abandoned") and paints a downgrade as an
69
+ # upgrade. So rank by version and take the newest STABLE release; fall back to
70
+ # isDefault, then newest-by-date, only when no stable version parses (a
71
+ # genuinely prerelease-only package still reads active, not dormant).
72
+ entry = latest_stable_version(versions) ||
73
+ versions.find { |v| v.is_a?(Hash) && v["isDefault"] } ||
74
+ newest_version(versions)
65
75
  return if entry.nil?
66
76
 
67
77
  { version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"] }
68
78
  end
69
79
 
80
+ # The newest non-prerelease version by version number (not publishedAt: a
81
+ # backported patch on an old line can post-date the latest major). nil when no
82
+ # version parses as a stable release.
83
+ def latest_stable_version(versions)
84
+ versions
85
+ .filter_map do |v|
86
+ next unless v.is_a?(Hash) && !v["isDeprecated"]
87
+
88
+ gem_version = VersionHelper.comparable(v.dig("versionKey", "version"))
89
+ [gem_version, v] if gem_version && !gem_version.prerelease?
90
+ end
91
+ .max_by(&:first)
92
+ &.last
93
+ end
94
+
70
95
  # The package's most recent release date (ISO8601 string), or nil. This is
71
96
  # the cross-ecosystem freshness signal, and deps.dev's publishedAt is more
72
97
  # reliable than ecosyste.ms's latest_release_published_at, which can lag badly
@@ -96,6 +121,21 @@ module StillActive
96
121
  }
97
122
  end
98
123
 
124
+ # The NuGet target framework monikers a version declares (net6.0, net45,
125
+ # netstandard2.0, ...), or [] when unknown. On the NuGet-specific
126
+ # `:requirements` endpoint, not the version endpoint. The language-runtime
127
+ # ceiling (SA009) reads these to detect a package that targets only EOL .NET
128
+ # runtimes. Degrades to [] on 404/failure, never raising.
129
+ def target_frameworks(name:, version:)
130
+ return [] if name.nil? || version.nil?
131
+
132
+ path = "/v3alpha/systems/nuget/packages/#{encode(name)}/versions/#{encode(version)}:requirements"
133
+ body = HttpHelper.get_json(BASE_URI, path)
134
+ return [] if body.nil?
135
+
136
+ Array(body.dig("nuget", "targetFrameworks")).grep(String)
137
+ end
138
+
99
139
  def advisory_detail(advisory_id:)
100
140
  return if advisory_id.nil?
101
141
 
@@ -176,6 +216,14 @@ module StillActive
176
216
  # Trims a repo URL's path to just the project. On GitLab the project path
177
217
  # ends at the "/-/" separator (before tree/blob/etc) and may be nested;
178
218
  # elsewhere it's owner/repo. Drops trailing slashes and a ".git" suffix.
219
+ # github.com top-level paths that are not user/org repositories; deps.dev
220
+ # occasionally returns one as SOURCE_REPO (a github.com/sponsors/<user> funding
221
+ # link). Parsing it as owner/repo yields a 404 and a blank repo cell, so treat a
222
+ # reserved first segment as "no repo" rather than a bogus lookup.
223
+ GITHUB_RESERVED_PATHS = [
224
+ "sponsors", "orgs", "marketplace", "apps", "topics", "settings", "notifications", "about", "pricing", "features", "security", "contact",
225
+ ].freeze
226
+
179
227
  def repo_path_segments(host, segments)
180
228
  segments = segments.reject(&:empty?)
181
229
 
@@ -183,6 +231,8 @@ module StillActive
183
231
  separator = segments.index("-")
184
232
  segments = segments[0...separator] if separator
185
233
  else
234
+ return [] if host.to_s.casecmp?("github.com") && GITHUB_RESERVED_PATHS.include?(segments.first&.downcase)
235
+
186
236
  segments = segments.first(2)
187
237
  end
188
238
 
@@ -196,7 +246,7 @@ module StillActive
196
246
  # is chronological), and a dated release always wins over an undated one.
197
247
  def newest_version(versions)
198
248
  versions
199
- .select { |v| v.is_a?(Hash) && v["publishedAt"] }
249
+ .select { |v| v.is_a?(Hash) && v["publishedAt"] && !v["isDeprecated"] }
200
250
  .max_by { |v| v["publishedAt"].to_s }
201
251
  end
202
252
 
@@ -8,6 +8,7 @@ require_relative "pypi_client"
8
8
  require "time"
9
9
  require_relative "../helpers/activity_helper"
10
10
  require_relative "../helpers/constraint_helper"
11
+ require_relative "../helpers/dotnet_helper"
11
12
  require_relative "../helpers/libyear_helper"
12
13
  require_relative "../helpers/pep440_helper"
13
14
  require_relative "../helpers/runtime_ceiling_helper"
@@ -52,7 +53,7 @@ module StillActive
52
53
  # deps.dev; silence beats a false "holds your tree hostage".
53
54
  FLAT_RESOLUTION_ECOSYSTEMS = [:rubygems, :pypi].freeze
54
55
 
55
- def assess(ecosystem:, name:, version:, constraint_cache: {}, python_range: nil)
56
+ def assess(ecosystem:, name:, version:, constraint_cache: {}, runtime_ranges: {})
56
57
  info = DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
57
58
  default = DepsDevClient.default_version_info(name: name, system: ecosystem)
58
59
  # Retry the version lookup once when it came back empty but the package DID
@@ -87,6 +88,10 @@ module StillActive
87
88
  name: name,
88
89
  version_used: version,
89
90
  version_used_release_date: used_release_date,
91
+ # The latest stable version string, so the shared formatters can render
92
+ # the "behind X"/up-to-date delta cross-ecosystem the same way the native
93
+ # path does. nil when deps.dev has no default version for the package.
94
+ latest_version: latest_version,
90
95
  latest_version_release_date: latest_release_date,
91
96
  # libyear parity with the native path: how far behind latest the locked
92
97
  # version is, in release-years. Both dates come from deps.dev responses
@@ -109,7 +114,7 @@ module StillActive
109
114
  }
110
115
  gem_data[:version_unresolved] = true if version_unresolved
111
116
  attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
112
- attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), python_range: python_range)
117
+ attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), runtime_ranges: runtime_ranges)
113
118
  gem_data
114
119
  end
115
120
 
@@ -124,9 +129,17 @@ module StillActive
124
129
  # pypi package; other ecosystems carry no ceiling here rather than a wrong one
125
130
  # (cargo's rust_version is a soft MSRV hint, not an install wall). Best-effort:
126
131
  # a nil range (endoflife feed down) or absent requires_python yields nothing.
127
- def attach_language_ceiling(gem_data, ecosystem:, name:, version:, latest_version:, python_range:)
132
+ def attach_language_ceiling(gem_data, ecosystem:, name:, version:, latest_version:, runtime_ranges:)
133
+ finding =
134
+ case ecosystem
135
+ when :pypi then python_ceiling(name: name, version: version, latest_version: latest_version, python_range: runtime_ranges[:python])
136
+ when :nuget then dotnet_ceiling(name: name, version: version, latest_version: latest_version, dotnet: runtime_ranges[:dotnet], dotnetfx: runtime_ranges[:dotnetfx])
137
+ end
138
+ gem_data[:language_ceiling] = finding if finding
139
+ end
140
+
141
+ def python_ceiling(name:, version:, latest_version:, python_range:)
128
142
  return if python_range.nil?
129
- return unless ecosystem == :pypi
130
143
 
131
144
  requirement = Pep440Helper.to_gem_requirement_string(PypiClient.requires_python(name: name, version: version))
132
145
  finding = requirement && RuntimeCeilingHelper.analyze(requirement: requirement, support_window: python_range)
@@ -134,7 +147,36 @@ module StillActive
134
147
 
135
148
  finding[:runtime] = "Python"
136
149
  finding[:fixed_by_upgrade] = python_ceiling_lifted_by_upgrade?(name: name, version: version, latest_version: latest_version, python_range: python_range)
137
- gem_data[:language_ceiling] = finding
150
+ finding
151
+ end
152
+
153
+ # The .NET ceiling: a NuGet package whose ONLY runtime targets are end-of-life
154
+ # frameworks caps you onto a dead runtime (a restore-time NU1202 wall). Unlike
155
+ # Python's requires_python range, .NET declares a SET of target frameworks;
156
+ # DotnetHelper carries the set logic and the netstandard escape-hatch rule.
157
+ def dotnet_ceiling(name:, version:, latest_version:, dotnet:, dotnetfx:)
158
+ return if dotnet.nil? && dotnetfx.nil?
159
+
160
+ frameworks = DepsDevClient.target_frameworks(name: name, version: version)
161
+ return if frameworks.empty?
162
+
163
+ finding = DotnetHelper.analyze(target_frameworks: frameworks, dotnet: dotnet, dotnetfx: dotnetfx)
164
+ return if finding.nil?
165
+
166
+ finding[:fixed_by_upgrade] = dotnet_ceiling_lifted_by_upgrade?(name: name, version: version, latest_version: latest_version, dotnet: dotnet, dotnetfx: dotnetfx)
167
+ finding
168
+ end
169
+
170
+ # Positive confirmation, like the Python check: only claim an upgrade lifts the
171
+ # ceiling when the latest version's targets ACTUALLY clear it (a fresh fetch),
172
+ # never inferring "safe to bump" from a failed lookup.
173
+ def dotnet_ceiling_lifted_by_upgrade?(name:, version:, latest_version:, dotnet:, dotnetfx:)
174
+ return false if latest_version.nil? || latest_version == version
175
+
176
+ latest_frameworks = DepsDevClient.target_frameworks(name: name, version: latest_version)
177
+ return false if latest_frameworks.empty?
178
+
179
+ DotnetHelper.analyze(target_frameworks: latest_frameworks, dotnet: dotnet, dotnetfx: dotnetfx).nil?
138
180
  end
139
181
 
140
182
  # Does upgrading the package to its latest release lift the cap? Requires
@@ -62,7 +62,7 @@ module StillActive
62
62
  end
63
63
 
64
64
  def add_sbom_option(opts)
65
- opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; JSON output") do |value|
65
+ opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; honours --sarif/--markdown/--terminal/--json") do |value|
66
66
  options[:provided_sbom] = true
67
67
  StillActive.config { |config| config.sbom_path = value }
68
68
  end
@@ -38,6 +38,15 @@ module StillActive
38
38
  format("%.1f", score)
39
39
  end
40
40
 
41
+ # Each rule's canonical wording is Ruby-flavoured (the native Gemfile path is
42
+ # the tool's origin). A rule that can fire on the cross-ecosystem SBOM path
43
+ # also carries `neutral:` overrides for the strings that name "gem"/Ruby, so a
44
+ # polyglot SBOM run (Go/npm/pypi/...) emits an ecosystem-agnostic catalog. The
45
+ # catalog is emitted once per run, so wording is chosen per-run (native vs
46
+ # SBOM), not per-result. SA006 (Ruby EOL) is genuinely Ruby-only
47
+ # (`native_only: true`): the SBOM path can never emit it, so the neutral
48
+ # catalog drops it entirely rather than advertise a "Ruby runtime EOL" rule in,
49
+ # say, a Go repo's Code Scanning tab.
41
50
  RAW_RULES = [
42
51
  {
43
52
  id: "SA001",
@@ -48,6 +57,11 @@ module StillActive
48
57
  level: "error",
49
58
  security_severity: "7.5",
50
59
  tags: ["security", "supply-chain", "external/cwe/cwe-1357"],
60
+ neutral: {
61
+ short: "Package's source repository is archived",
62
+ full: "The package's upstream repository has been marked archived. No further fixes, including security patches, should be expected.",
63
+ help_text: "Look for a maintained fork or alternative. If you must keep the package, vendor or fork it so you can apply patches yourself.",
64
+ },
51
65
  },
52
66
  {
53
67
  id: "SA002",
@@ -58,6 +72,11 @@ module StillActive
58
72
  level: "warning",
59
73
  security_severity: nil,
60
74
  tags: ["maintenance"],
75
+ neutral: {
76
+ short: "Package has had no release for over 3 years",
77
+ full: "The package's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a package with no releases at all (e.g. git-sourced), the last commit date is used instead.",
78
+ help_text: "Verify the package still works on your supported runtime and consider a maintained alternative.",
79
+ },
61
80
  },
62
81
  {
63
82
  id: "SA003",
@@ -68,6 +87,9 @@ module StillActive
68
87
  level: "error",
69
88
  security_severity: "7.0", # default; per-result override from CVSS
70
89
  tags: ["security", "vulnerability", "external/cwe/cwe-1104"],
90
+ neutral: {
91
+ short: "Package has known vulnerabilities (via deps.dev / OSV)",
92
+ },
71
93
  },
72
94
  {
73
95
  id: "SA004",
@@ -78,6 +100,10 @@ module StillActive
78
100
  level: "warning",
79
101
  security_severity: nil,
80
102
  tags: ["maintenance", "libyear"],
103
+ neutral: {
104
+ short: "Package is significantly behind the latest release",
105
+ help_text: "Schedule an upgrade to the latest release.",
106
+ },
81
107
  },
82
108
  {
83
109
  id: "SA005",
@@ -88,6 +114,9 @@ module StillActive
88
114
  level: "note",
89
115
  security_severity: nil,
90
116
  tags: ["supply-chain", "openssf"],
117
+ neutral: {
118
+ short: "Package's OpenSSF Scorecard is low",
119
+ },
91
120
  },
92
121
  {
93
122
  id: "SA006",
@@ -98,6 +127,7 @@ module StillActive
98
127
  level: "error",
99
128
  security_severity: "8.5",
100
129
  tags: ["security", "runtime", "external/cwe/cwe-1104"],
130
+ native_only: true,
101
131
  },
102
132
  {
103
133
  id: "SA007",
@@ -108,6 +138,11 @@ module StillActive
108
138
  level: "error",
109
139
  security_severity: "8.0",
110
140
  tags: ["security", "supply-chain", "external/cwe/cwe-1104"],
141
+ neutral: {
142
+ short: "Resolved package version has been yanked from its registry",
143
+ full: "The resolved version has been yanked by the package owner, typically for a serious bug or vulnerability.",
144
+ help_text: "Update to a non-yanked version immediately.",
145
+ },
111
146
  },
112
147
  {
113
148
  id: "SA008",
@@ -118,12 +153,17 @@ module StillActive
118
153
  level: "warning",
119
154
  security_severity: nil, # maintenance/resolvability, not a CVE (as SA002/SA004/SA005)
120
155
  tags: ["maintenance", "supply-chain", "external/cwe/cwe-1104"],
156
+ neutral: {
157
+ short: "Dormant package caps a dependency below its latest major",
158
+ full: "A dormant (abandoned or archived) package declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the package, the cap will never lift, and it grows more constraining as the capped dependency releases new majors: the tree is held below a ceiling no upstream release will raise.",
159
+ help_text: "Replace or fork the dormant package, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in.",
160
+ },
121
161
  },
122
162
  {
123
163
  id: "SA009",
124
164
  name: "RuntimeCeiling",
125
165
  short: "Resolved package version caps its language runtime",
126
- full: "A resolved package version's declared runtime constraint (Ruby `ruby_version`, Python `requires_python`) caps the language runtime you can run: either below every still-supported release (stranding you on an end-of-life runtime with no security patches) or below the latest stable (a compatibility ceiling to plan around). The default level is note; an EOL-forcing cap is raised to error per result.",
166
+ full: "A resolved package version's declared runtime constraint (Ruby `ruby_version`, Python `requires_python`, .NET target framework) caps the language runtime you can run: either below every still-supported release (stranding you on an end-of-life runtime with no security patches) or below the latest stable (a compatibility ceiling to plan around). The default level is note; an EOL-forcing cap is raised to error per result.",
127
167
  help_text: "If a newer release of the package lifts the cap, upgrade it. Otherwise replace or fork the package, or contribute support for the newer runtime upstream.",
128
168
  level: "note",
129
169
  security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
@@ -131,19 +171,34 @@ module StillActive
131
171
  },
132
172
  ].freeze
133
173
 
134
- CATALOG = RAW_RULES.map do |r|
135
- r.merge(
136
- tags: r[:tags].dup.freeze,
137
- help_markdown: "#{r[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance.",
138
- ).freeze
139
- end.freeze
174
+ # Builds a frozen catalog for one wording flavour. `:neutral` applies each
175
+ # rule's `neutral:` overrides (ecosystem-agnostic wording for the SBOM path);
176
+ # any other flavour keeps the canonical Ruby wording. help_markdown is derived
177
+ # from the resolved help_text so it tracks the flavour. The neutral (SBOM)
178
+ # catalog omits `native_only:` rules, which the cross-ecosystem path can never
179
+ # emit (SA006, Ruby runtime EOL).
180
+ def build_catalog(flavour)
181
+ neutral = flavour == :neutral
182
+ rules = neutral ? RAW_RULES.reject { |r| r[:native_only] } : RAW_RULES
183
+ rules.map do |r|
184
+ resolved = neutral && r[:neutral] ? r.merge(r[:neutral]) : r
185
+ resolved.except(:neutral, :native_only).merge(
186
+ tags: r[:tags].dup.freeze,
187
+ help_markdown: "#{resolved[:help_text]}\n\nSee [#{r[:id]} docs](#{help_uri(r[:id])}) for full guidance.",
188
+ ).freeze
189
+ end.freeze
190
+ end
191
+
192
+ CATALOG = build_catalog(:ruby)
193
+ NEUTRAL_CATALOG = build_catalog(:neutral)
140
194
 
141
- def all
142
- CATALOG
195
+ # flavour: :ruby (default, native Gemfile path) or :neutral (SBOM path).
196
+ def all(flavour = :ruby)
197
+ flavour == :neutral ? NEUTRAL_CATALOG : CATALOG
143
198
  end
144
199
 
145
- def find(id)
146
- CATALOG.find { |r| r[:id] == id }
200
+ def find(id, flavour = :ruby)
201
+ all(flavour).find { |r| r[:id] == id }
147
202
  end
148
203
  end
149
204
  end
@@ -4,6 +4,7 @@ require_relative "ecosystem_lens"
4
4
  require_relative "ceiling_reconciler"
5
5
  require_relative "poison_security_correlator"
6
6
  require_relative "../helpers/python_helper"
7
+ require_relative "../helpers/dotnet_helper"
7
8
  require "async"
8
9
  require "async/barrier"
9
10
  require "async/semaphore"
@@ -32,16 +33,15 @@ module StillActive
32
33
  def call(sbom_result, &on_progress)
33
34
  dependencies = sbom_result.dependencies
34
35
  Async do
35
- # The Python runtime support window, fetched once for the whole SBOM (the
36
- # language-ceiling input for pypi deps). Guarded like the native Ruby path:
37
- # a feed failure degrades to "no ceiling checks", never aborts the audit.
38
- python_range =
39
- begin
40
- PythonHelper.supported_python_range
41
- rescue StandardError => e
42
- $stderr.puts("warning: Python support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
43
- nil
44
- end
36
+ # The language-runtime support windows, fetched once for the whole SBOM
37
+ # (pypi reads :python; nuget reads :dotnet/:dotnetfx). Guarded per feed like
38
+ # the native Ruby path: a feed failure degrades to "no ceiling checks" for
39
+ # that runtime, never aborts the audit.
40
+ runtime_ranges = {
41
+ python: safe_support_window("Python") { PythonHelper.supported_python_range },
42
+ dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
43
+ dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range },
44
+ }
45
45
  barrier = Async::Barrier.new
46
46
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
47
47
  result = {}
@@ -61,7 +61,7 @@ module StillActive
61
61
  # when the SBOM actually marked it (SbomReader leaves the key absent
62
62
  # otherwise): absent stays "unknown", never a false that reads as test-only.
63
63
  result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
64
- EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, python_range: python_range)
64
+ EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, runtime_ranges: runtime_ranges)
65
65
  .merge(dep.slice(:production))
66
66
  rescue StandardError => e
67
67
  # One dependency's failure must not abort the audit, but it must not
@@ -92,5 +92,16 @@ module StillActive
92
92
  )
93
93
  end.wait
94
94
  end
95
+
96
+ private
97
+
98
+ # Fetch one runtime's support window, degrading a feed failure to nil (no
99
+ # ceiling checks for that runtime) with a warning, never aborting the audit.
100
+ def safe_support_window(label)
101
+ yield
102
+ rescue StandardError => e
103
+ $stderr.puts("warning: #{label} support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
104
+ nil
105
+ end
95
106
  end
96
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "3.0.0.rc2"
4
+ VERSION = "3.0.0.rc4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.rc2
4
+ version: 3.0.0.rc4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -254,7 +254,9 @@ files:
254
254
  - lib/helpers/constraint_helper.rb
255
255
  - lib/helpers/cvss_helper.rb
256
256
  - lib/helpers/cyclonedx_helper.rb
257
+ - lib/helpers/dependency_helper.rb
257
258
  - lib/helpers/diff_markdown_helper.rb
259
+ - lib/helpers/dotnet_helper.rb
258
260
  - lib/helpers/emoji_helper.rb
259
261
  - lib/helpers/endoflife_helper.rb
260
262
  - lib/helpers/http_helper.rb