still_active 3.0.0.rc2 → 3.0.0.rc3

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: 48cf9cbac0c507c9de0eae6930aac65686f32893d1e330c19d0392ccc5a3a824
4
+ data.tar.gz: 0a1370fc970a0b03e7a5663a4c320322f4ac7788d161d144405cb8c703b005b5
5
5
  SHA512:
6
- metadata.gz: b3fd52ed8f8ad9a05267c22074b3720f9bdc48d0e65e0ff557a56c9d9f43cee7c9f07d1ec7acca2f82012a34a7c181d10a23d2427edd245d7055dad9e8866c4b
7
- data.tar.gz: 3751290569ec6bbd5e0d7cb9da9e76ebdd8add640c2baca94f04488b4f0874a8407ae9d5ceffecde0cc1296552aaaf765e3bb96f6e7e80e13fcf79a9e65471f3
6
+ metadata.gz: 85e0df44ac6e16fe28023c5dedf5e7a08466c045f5e36558b91d42e71949d159df10262a894139916fc6dc5c744d5c15ec159630297a734104c17e1e60b2826e
7
+ data.tar.gz: 4da6ad45492f57b76a1ec2f3e0f486be5aa7765cd2a4b956de0cf02c66af61c2d21ae0ff278aaf77a33a0e38b18a7d72a1a61bcd9b91d46259fac4d3a266b57b
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.
@@ -41,6 +41,11 @@ The new `--fail-if-poison[=TIER]` and `--fail-if-language-ceiling[=TIER]` gates
41
41
  - **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
42
  - **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
43
  - **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.
44
+ - **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).
45
+ - **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.
46
+ - **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).
47
+ - **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".
48
+ - **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
49
 
45
50
  ## [2.0.0] - 2026-06-14
46
51
 
@@ -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,6 +6,7 @@ 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"
10
11
 
11
12
  module StillActive
@@ -44,6 +45,27 @@ module StillActive
44
45
  JSON.pretty_generate(document(results: results, tool_version: tool_version))
45
46
  end
46
47
 
48
+ # The SBOM path emits the same rule set and document, but there is no lockfile
49
+ # to annotate: every finding anchors to the SBOM file itself (line 1), and
50
+ # ruby_info is nil (Ruby EOL / SA006 is a native-only signal). Findings are
51
+ # named ecosystem/name (each gem_data carries :ecosystem and :name), so a
52
+ # cross-ecosystem alert reads "npm/left-pad ..." rather than a bare gem name.
53
+ #
54
+ # result: SbomWorkflow assessed hash ("ecosystem/name@version" => gem_data)
55
+ # source_uri: the SBOM file to point findings at (basename)
56
+ # tool_version: StillActive::VERSION at emit time
57
+ def render_sbom(result:, source_uri:, tool_version:)
58
+ results = build_results(
59
+ report: result,
60
+ ruby_info: nil,
61
+ line_index: {},
62
+ ruby_line: nil,
63
+ lockfile_uri: source_uri,
64
+ )
65
+
66
+ JSON.pretty_generate(document(results: results, tool_version: tool_version))
67
+ end
68
+
47
69
  private
48
70
 
49
71
  def document(results:, tool_version:)
@@ -94,13 +116,21 @@ module StillActive
94
116
  results
95
117
  end
96
118
 
119
+ # `name` is the hash key: the bare gem name natively, the composite
120
+ # "ecosystem/name@version" on the SBOM path. It drives the fingerprint and
121
+ # location, both of which MUST stay version-distinct so two pinned versions of
122
+ # one package (a merged monorepo SBOM) never collapse into a single Code
123
+ # Scanning alert. `display` is the version-independent "ecosystem/name" (bare
124
+ # name natively): what the message shows and what a --ignore/.still_active.yml
125
+ # suppression names, so one suppression covers a dependency across versions.
97
126
  def gem_results(name, data, line_index, lockfile_uri)
98
127
  out = []
99
128
  version = data[:version_used]
129
+ display = DependencyHelper.identity(name, data)
100
130
  location = location_for(name, line_index, lockfile_uri)
101
131
 
102
132
  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)
133
+ 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
134
  end
105
135
 
106
136
  unless data[:archived]
@@ -112,10 +142,10 @@ module StillActive
112
142
  result(
113
143
  "SA002",
114
144
  name,
115
- "#{name} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
145
+ "#{display} #{version}: #{noun} in #{years} years (last #{activity[:date].utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}#{transitive_suffix(data)}.",
116
146
  location,
117
147
  ),
118
- name,
148
+ display,
119
149
  :activity,
120
150
  )
121
151
  end
@@ -127,15 +157,15 @@ module StillActive
127
157
 
128
158
  if data[:libyear] && data[:libyear] > LIBYEAR_THRESHOLD
129
159
  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)
160
+ out << mark_suppressed(result("SA004", name, "#{display} #{version}: #{data[:libyear]} libyears#{latest}#{transitive_suffix(data)}.", location), display, :libyear)
131
161
  end
132
162
 
133
163
  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)
164
+ out << mark_suppressed(result("SA005", name, "#{display} #{version}: OpenSSF Scorecard #{data[:scorecard_score]}/10 (low).", location), display, :scorecard)
135
165
  end
136
166
 
137
167
  if data[:version_yanked]
138
- out << mark_suppressed(result("SA007", name, "#{name} #{version}: this version has been yanked from RubyGems.", location), name, :yanked)
168
+ out << mark_suppressed(result("SA007", name, "#{display} #{version}: this version has been yanked from RubyGems.", location), display, :yanked)
139
169
  end
140
170
 
141
171
  if data[:poison] && !Array(data[:constraints]).empty?
@@ -151,7 +181,7 @@ module StillActive
151
181
  else
152
182
  "maintenance"
153
183
  end
154
- out << mark_suppressed(result("SA008", name, poison_message(name, version, data), location, level: poison_level(data), fp_extra: state), name, :poison)
184
+ out << mark_suppressed(result("SA008", name, poison_message(display, version, data), location, level: poison_level(data), fp_extra: state), display, :poison)
155
185
  end
156
186
 
157
187
  if data[:language_ceiling]
@@ -163,7 +193,7 @@ module StillActive
163
193
  # silently mute the critical -- that transition is exactly the one a human
164
194
  # must see.
165
195
  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)
196
+ 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
197
  end
168
198
 
169
199
  out
@@ -173,7 +203,7 @@ module StillActive
173
203
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:language_ceiling][:severity], "note")
174
204
  end
175
205
 
176
- def language_ceiling_message(name, version, data)
206
+ def language_ceiling_message(display, version, data)
177
207
  ceiling = data[:language_ceiling]
178
208
  runtime = ceiling[:runtime]
179
209
  body =
@@ -185,7 +215,7 @@ module StillActive
185
215
  "no #{runtime} #{ceiling[:latest_stable]} support yet"
186
216
  end
187
217
  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)}."
218
+ "#{display} #{version}: requires #{runtime} #{ceiling[:requirement]}, #{body}#{fix}#{transitive_suffix(data)}."
189
219
  end
190
220
 
191
221
  # The poison receipt for a Code Scanning alert: the worst 3 caps (shared
@@ -203,7 +233,7 @@ module StillActive
203
233
  { critical: "error", warning: "warning", note: "note" }.fetch(data[:poison_severity], "warning")
204
234
  end
205
235
 
206
- def poison_message(name, version, data)
236
+ def poison_message(display, version, data)
207
237
  top = ConstraintHelper.top_findings(Array(data[:constraints]), limit: 3)
208
238
  caps = top[:shown].map do |finding|
209
239
  behind = finding[:majors_behind]
@@ -211,7 +241,7 @@ module StillActive
211
241
  end
212
242
  remaining = top[:total] - top[:shown].length
213
243
  caps << "+#{remaining} more" if remaining.positive?
214
- "#{name} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
244
+ "#{display} #{version}: caps #{caps.join("; ")}#{transitive_suffix(data)}#{poison_security_note(data)}."
215
245
  end
216
246
 
217
247
  # Spells out the security escalation for a code-scanning alert. Only claims
@@ -254,6 +284,10 @@ module StillActive
254
284
  end
255
285
 
256
286
  def vulnerability_result(name, version, vuln, location, data = {})
287
+ # `name` (the version-distinct hash key) fingerprints the finding; `display`
288
+ # (version-independent "ecosystem/name") is what the message shows and the
289
+ # suppression matches. See gem_results for why the two differ.
290
+ display = DependencyHelper.identity(name, data)
257
291
  # Level tracks the resolved severity LABEL (a real CVSS score OR OSV's GHSA
258
292
  # label), so a CVSS-4-only HIGH -- which deps.dev returns as cvss3Score 0 --
259
293
  # exports as error, not a note/warning a code-scanning gate reads as
@@ -274,13 +308,13 @@ module StillActive
274
308
  base = result(
275
309
  "SA003",
276
310
  name,
277
- "#{name} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
311
+ "#{display} #{version}: #{advisory_id}#{title}#{alias_suffix}#{no_fix}#{transitive_suffix(data)}.",
278
312
  location,
279
313
  level: level,
280
314
  fp_extra: advisory_id,
281
315
  )
282
316
  base["properties"] = { "security-severity" => severity } if severity
283
- mark_suppressed(base, name, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
317
+ mark_suppressed(base, display, :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
284
318
  end
285
319
 
286
320
  # Names the direct dependency a transitive flagged gem rides in on, so a
@@ -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,14 @@ 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
+
55
63
  def gem_version(version_hash:)
56
64
  version_hash&.dig("number")
57
65
  end
@@ -88,7 +96,20 @@ module StillActive
88
96
 
89
97
  def to_gem_version(version)
90
98
  str = normalize_version(version)
91
- Gem::Version.new(str) if str && Gem::Version.correct?(str)
99
+ return unless str
100
+
101
+ # Normalize two cross-ecosystem shapes Gem::Version can't parse, so a
102
+ # current dependency compares as up to date instead of reading "behind":
103
+ # - a leading "v" (Go module versions, "v2.0.1")
104
+ # - SemVer build metadata (cargo's "1.0.4+wasi-0.2.12"), everything from the
105
+ # first "+", which SemVer 2.0.0 sec 10 says MUST be ignored for precedence
106
+ # rubygems/npm/pypi versions are digit-first with no "+", so this is a no-op.
107
+ # Sliced by index rather than a regex (/\+.*/ on the external version string
108
+ # trips a polynomial-ReDoS scanner, and this is provably linear anyway).
109
+ str = str.delete_prefix("v")
110
+ plus = str.index("+")
111
+ str = str[0, plus] if plus
112
+ Gem::Version.new(str) if Gem::Version.correct?(str)
92
113
  end
93
114
  end
94
115
  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
@@ -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.rc3"
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.rc3
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