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 +4 -4
- data/CHANGELOG.md +8 -3
- data/lib/helpers/dependency_helper.rb +20 -0
- data/lib/helpers/dotnet_helper.rb +142 -0
- data/lib/helpers/markdown_helper.rb +16 -7
- data/lib/helpers/sarif_helper.rb +48 -14
- data/lib/helpers/terminal_helper.rb +18 -4
- data/lib/helpers/version_helper.rb +22 -1
- data/lib/still_active/cli.rb +59 -15
- data/lib/still_active/deps_dev_client.rb +52 -2
- data/lib/still_active/ecosystem_lens.rb +47 -5
- data/lib/still_active/options.rb +1 -1
- data/lib/still_active/sbom_workflow.rb +22 -11
- data/lib/still_active/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48cf9cbac0c507c9de0eae6930aac65686f32893d1e330c19d0392ccc5a3a824
|
|
4
|
+
data.tar.gz: 0a1370fc970a0b03e7a5663a4c320322f4ac7788d161d144405cb8c703b005b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
305
|
+
time.strftime("%Y/%m")
|
|
297
306
|
end
|
|
298
307
|
end
|
|
299
308
|
end
|
data/lib/helpers/sarif_helper.rb
CHANGED
|
@@ -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, "#{
|
|
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
|
-
"#{
|
|
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
|
-
|
|
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, "#{
|
|
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, "#{
|
|
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, "#{
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
"#{
|
|
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(
|
|
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
|
-
"#{
|
|
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
|
-
"#{
|
|
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,
|
|
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
|
-
|
|
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]}
|
|
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
|
-
|
|
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
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
+
gem = DependencyHelper.identity(name, data)
|
|
456
|
+
next if config.ignored_gems.include?(gem)
|
|
413
457
|
|
|
414
|
-
unknown = live_advisories(
|
|
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: #{
|
|
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
|
-
|
|
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: {},
|
|
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),
|
|
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:,
|
|
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
|
-
|
|
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
|
data/lib/still_active/options.rb
CHANGED
|
@@ -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;
|
|
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
|
|
36
|
-
#
|
|
37
|
-
# a feed failure degrades to "no ceiling checks"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
data/lib/still_active/version.rb
CHANGED
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.
|
|
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
|