still_active 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +3 -1
- data/lib/still_active/artifactory_client.rb +2 -20
- data/lib/still_active/assessment.rb +85 -0
- data/lib/still_active/cli.rb +84 -23
- data/lib/still_active/config.rb +2 -0
- data/lib/still_active/deps_dev_client.rb +161 -15
- data/lib/still_active/diff.rb +38 -0
- data/lib/still_active/ecosystem_lens.rb +80 -44
- data/lib/still_active/ecosystems_client.rb +10 -7
- data/lib/still_active/errors.rb +10 -0
- data/lib/still_active/forgejo_client.rb +5 -1
- data/lib/still_active/github_client.rb +28 -5
- data/lib/still_active/gitlab_client.rb +5 -1
- data/lib/still_active/go_toolchain.rb +75 -0
- data/lib/still_active/helpers/alternatives_helper.rb +2 -2
- data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
- data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
- data/lib/still_active/helpers/emoji_helper.rb +2 -1
- data/lib/still_active/helpers/endoflife_helper.rb +3 -2
- data/lib/still_active/helpers/http_cache.rb +109 -0
- data/lib/still_active/helpers/http_helper.rb +148 -28
- data/lib/still_active/helpers/markdown_helper.rb +1 -1
- data/lib/still_active/helpers/repository_check.rb +33 -0
- data/lib/still_active/helpers/status_helper.rb +11 -0
- data/lib/still_active/helpers/summary_helper.rb +6 -1
- data/lib/still_active/helpers/terminal_helper.rb +9 -1
- data/lib/still_active/options.rb +7 -0
- data/lib/still_active/osv_client.rb +39 -2
- data/lib/still_active/repository_signals.rb +113 -0
- data/lib/still_active/rubygems_client.rb +34 -0
- data/lib/still_active/sarif/rules.rb +1 -1
- data/lib/still_active/sbom_workflow.rb +16 -2
- data/lib/still_active/source_health.rb +88 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +63 -52
- data/lib/still_active.rb +1 -0
- data/still_active.gemspec +0 -1
- metadata +9 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b11131a38933e905499e2dac823f23e16e688d4675b64b490b9b8afa7e7d070
|
|
4
|
+
data.tar.gz: 800a1a333158f68e4fbac53e5100856fb916bd656ae3fa99eb8f4aaa321e06f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8046b2bbcf962238198e37f27d5851ce6054588d4bfc3ccf56bd895e5e8c584843e4fbec820ab185a187649f7fd49f7a93ec843fbd3d58c2d4b3ca8b0816e80c
|
|
7
|
+
data.tar.gz: 703060b75de10f048e82102f8c87b59be4cd639b3fba6adc51d2ca9f8ce2338122148ce383c37b232f29cca8a549552944be4b7b36a80124c98d84365fe8fb6e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.2.0] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
**Upgrading from 3.1:** a few runs that passed can now fail, each where 3.1 read an unanswered question as "clean". `--fail-if-vulnerable` fails on a dependency whose advisories no source could answer for (`vulnerabilities_checked: false`), and `--fail-if-critical` / `--fail-if-warning` on one whose repository check failed (`repository_check: "failed"`); each names the dependency, a rerun usually clears it, and a suppression accepts it. Statuses that used to read `ok` for a dependency still_active couldn't check (no repository link, Google's go.googlesource.com, a private or deleted repository) now read `unknown`, without failing any gate. JSON gains fields and never loses one; the `--sbom` JSON now names its schema.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The SBOM audit reads GitLab and Codeberg repositories.** It only asked about github.com repositories before, so a GitLab or Codeberg project in an SBOM never got an archived signal. Both audits now resolve repositories through one shared lookup, and the JSON's new `repository_source` says which service answered (`github`, `ecosyste.ms`, `gitlab`, `codeberg`).
|
|
12
|
+
- **A JSON Schema for the `--sbom` output**, `docs/still_active.sbom.schema.json`, which the output now names in `$schema`. Fields shared with the Gemfile audit reference its schema rather than copying it, and the suite validates real output against it. Writing it caught a gap: the Go toolchain's advisories carry `source: "osv"`, which the shared vulnerability definition didn't allow; it does now.
|
|
13
|
+
- **An on-disk cache of public sources' answers.** A rerun, or the baseline and current audits of one CI job, no longer ask the same questions twice: on a 300-component SBOM a second run made a handful of requests instead of hundreds and took half the time, with identical output. Each kind of answer is kept only as long as it holds (which advisories affect a version an hour, latest versions and advisory scores six hours, a published version's metadata a week; see `docs/configuration.md`), so a new advisory can surface up to an hour late, and `--no-cache` skips it. Nothing credentialed, no failure and no 404 is cached, and neither are the answers that must be current: OSV's check of which advisories really affect a version (it can drop a finding), the source-health canaries, and a malformed version record.
|
|
14
|
+
- **A source-health check before every audit, on both paths.** deps.dev is an alpha API and the advisory source for nearly everything, so still_active now checks once that it answers in shape (the advisory canary, which the Gemfile audit never ran before) and, per audited ecosystem, that its index is current: one of a small set of packages that publish often (`next`, `boto3`, `aws-partitions`, ...) must show a version published in the last 30 days. The sets were picked by replaying two years of deps.dev history, where the longest quiet stretch for any set is 10 days. Where either fails, deps.dev's advisories for that ecosystem count as unchecked (so `--fail-if-vulnerable` fails closed on them), a warning says why, and both JSON outputs carry a `source_health` block. ruby-advisory-db and the Go toolchain's OSV lookup still answer on their own. It costs one request plus about one per ecosystem. It can't catch a single stale package in a live index.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Internal: every dependency's assessment is now a typed `StillActive::Assessment`.** Both audits build it once all their passes are done. An unknown field is reported (and dropped) rather than read as a silent `nil`, and the answers that decide "clean or unknown" (`vulnerabilities_checked`, `repository_check`) can no longer be missing: a dependency whose assessment stopped part-way reads unchecked and failed, never fine. Output is unchanged (verified byte-for-byte, JSON, terminal, markdown and SARIF).
|
|
19
|
+
- **A third fewer requests to deps.dev.** Every dependency's version record now comes from one batch request before the audit starts, instead of one request each: on a 300-component SBOM, 705 deps.dev requests became 475. Anything the batch doesn't answer cleanly is looked up one at a time as before, so it can't change a result. It doesn't make the run faster; it's less load on a free API.
|
|
20
|
+
- **About 30% faster on an SBOM, from reusing connections.** Every request used to open its own TLS connection; HttpHelper now keeps one open per host and reuses it. On a 300-component, four-ecosystem SBOM, connections to deps.dev went from 705 to 12 and the run from about 24s to 17s, with identical output. A rate limit that says when to come back (a 429 or 503 with `Retry-After` of 10 seconds or less) now gets one retry after that wait instead of failing the lookup.
|
|
21
|
+
- **A failed advisory lookup is no longer reported as "0 vulnerabilities".** Before, a deps.dev timeout, 5xx or 429 on a dependency's version record read exactly like a clean one, so a network blip could pass `--fail-if-vulnerable` with a vulnerable dependency in the tree. Now the lookup is retried once, and if no source answers (including a version record with no `advisoryKeys` list, which is what deps.dev schema drift looks like) the dependency carries `vulnerabilities_checked: false`, as does one whose assessment raised part-way: its status reads `unknown` rather than `ok` or `legacy` (the two verdicts that claim clean), the terminal and markdown show `?`, both JSON summaries count `vulnerabilities_unchecked`, the enriched CycloneDX marks it, **`--fail-if-vulnerable` fails closed on it** with a warning naming it, and the `--baseline` diff reports a bump to an unchecked version as a regression instead of "closed vulns". A 404 is still an answer (a private package deps.dev has never seen stays checked), and on the native path a loaded ruby-advisory-db answers on its own. If this turns a run red, rerun it, or `--ignore` the dependency.
|
|
22
|
+
- **One fewer runtime dependency: `gems` is gone.** Its two rubygems.org calls now go through the same HTTP layer as every other source, so they get the same timeouts, body cap and redirect rules. That also fixes a crash path: a 5xx or 429 from rubygems.org's gem-info endpoint escaped as an exception and cost the gem every signal, not just its repository link. Artifactory's copy of the same API shares the client.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A deprecated Go module reads as deprecated whichever version is pinned, and its real latest version is reported.** Go deprecates a module, not a version: a `// Deprecated:` comment in the latest `go.mod` covers every version, which is what `go list -m -u` shows, but deps.dev flags only the versions whose `go.mod` carries it. still_active read the pinned version's flag, so an older pin looked fine; and it skipped deprecated versions when picking the latest (right for npm, where one bad release gets deprecated), so for a deprecated Go module it reported a years-old version as the latest and an old pin as up to date. `github.com/golang/protobuf@v1.3.5` now reads deprecated, latest `v1.5.4`, with Go's own message. A retracted Go version (deps.dev flags it the same way, as "Version retracted: ...") still marks only that version and is never reported as the latest.
|
|
27
|
+
- **Without a GitHub token, a repository ecosyste.ms hasn't crawled no longer reads as "not archived".** ecosyste.ms answers first in that mode, and about one repository in twenty isn't in it; that "don't know" used to read as "not archived". GitHub is now asked about those, without a token (60 requests an hour), and one neither service knows reads `unknown` (`repository_check: "unknowable"`), never "not archived". Private-source repositories are no longer sent to ecosyste.ms, with or without a token.
|
|
28
|
+
- **A repository no service could check no longer reads as "not archived".** When GitHub's API refused a repository lookup (a burst of concurrent requests trips its secondary rate limit, even with a token), `archived` came back blank, and a blank `archived` read as "not archived": in a real run, two archived repositories came out `ok` and `stale`. GitHub and ecosyste.ms, which mirrors the same fields, now back each other up, so a rate limit alone no longer loses the signal (verified on a 300-component SBOM with every GitHub call refused: the same repositories read archived). Each dependency now carries `repository_check`: `answered`; `failed` (a service couldn't answer; a rerun may help, so **`--fail-if-critical` / `--fail-if-warning` fail closed on it**, with a warning naming it, and an `activity` suppression accepts it); or `unknowable` (no service can say: no repository link, a host with no archived state such as Google's go.googlesource.com, or every service asked didn't know it, which is how a private or deleted repository looks; a rerun won't change it, so it doesn't fail the gates). Anything not answered turns an `ok` or `legacy` status into `unknown` and shows `?` in the terminal and markdown; the JSON summaries tally `repository_checks`, CycloneDX marks it, and the `--baseline` diff reports a dependency that was answered and no longer is. A dependency whose assessment failed part-way counts as `failed`.
|
|
29
|
+
- **The Go toolchain (`pkg:golang/stdlib`) is assessed from endoflife.date and OSV, not deps.dev.** deps.dev keys it `go1.27.1` where the purl says `1.27.1`, its index stops at go1.25.5, and its advisory list is the same for every version, so stdlib read as behind a stale "latest" and **every stdlib pin reported zero advisories**. It now reads its latest release from endoflife.date, its advisories from OSV for that exact version, and an end-of-life release line as deprecated, naming the line to move to. If OSV can't answer, its advisories are marked unchecked (see below) and a warning says so.
|
|
30
|
+
- **No more HTTP 400 warnings for repositories deps.dev doesn't index.** deps.dev keys projects under github.com, gitlab.com and bitbucket.org only, so every `golang.org/x/*` module (hosted on go.googlesource.com), and anything on Codeberg or a self-hosted GitLab, printed a warning for a lookup that could never succeed. still_active no longer makes that request. A `GitHub.com` or `www.github.com` repository link is normalized, so those now get their scorecard.
|
|
31
|
+
|
|
5
32
|
## [3.1.0] - 2026-09-07
|
|
6
33
|
|
|
7
34
|
### Changed
|
data/README.md
CHANGED
|
@@ -130,6 +130,8 @@ Point `--sbom` at a CycloneDX SBOM (from [Syft](https://github.com/anchore/syft)
|
|
|
130
130
|
| Language-runtime ceiling (SA009) | Ruby | Python |
|
|
131
131
|
| Ruby EOL (SA006), yanked (SA007) | Yes | n/a |
|
|
132
132
|
|
|
133
|
+
The Go toolchain (`stdlib`) isn't a module deps.dev can serve, so it's read from endoflife.date and OSV instead, and an end-of-life Go release line is reported as deprecated (SA010). A deprecated Go module (`// Deprecated:` in its latest `go.mod`) is deprecated whichever version is pinned, as `go list -m -u` reports it.
|
|
134
|
+
|
|
133
135
|
Full rule detail in [`docs/rules.md`](docs/rules.md).
|
|
134
136
|
</details>
|
|
135
137
|
|
|
@@ -219,7 +221,7 @@ Full semantics, thresholds, and transitive behaviour: [`docs/configuration.md`](
|
|
|
219
221
|
|
|
220
222
|
## Data sources
|
|
221
223
|
|
|
222
|
-
Release dates and licenses from [RubyGems](https://rubygems.org) / [GitHub Packages](https://docs.github.com/en/packages) / [Artifactory](https://jfrog.com/artifactory/); repo activity from the [GitHub](https://docs.github.com/en/rest) / [GitLab](https://docs.gitlab.com/ee/api/) / [Codeberg](https://forgejo.org/docs/latest/user/api-usage/) API,
|
|
224
|
+
Release dates and licenses from [RubyGems](https://rubygems.org) / [GitHub Packages](https://docs.github.com/en/packages) / [Artifactory](https://jfrog.com/artifactory/); repo activity from the [GitHub](https://docs.github.com/en/rest) API and [ecosyste.ms](https://ecosyste.ms) ([CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)), each the other's fallback (GitHub first with a token), and from the [GitLab](https://docs.gitlab.com/ee/api/) / [Codeberg](https://forgejo.org/docs/latest/user/api-usage/) API, on both audits; a repository no service can check reads `unknown`, never "not archived" (`repository_check`); OpenSSF Scorecard and CVSS from [deps.dev](https://deps.dev); advisory severities and fixed-version ranges from [OSV](https://osv.dev), scored with [`cvss-suite`](https://rubygems.org/gems/cvss-suite) when deps.dev has no number (CVSS 4.0); extra advisories from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db); runtime EOL from [endoflife.date](https://endoflife.date). Public sources' answers are cached under `~/.cache/still_active/http`, each for as long as it holds (advisories an hour); `--no-cache` skips it.
|
|
223
225
|
|
|
224
226
|
## Development
|
|
225
227
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler"
|
|
4
|
-
require "cgi"
|
|
5
4
|
require "json"
|
|
6
5
|
require "uri"
|
|
7
6
|
require_relative "helpers/http_helper"
|
|
@@ -32,7 +31,7 @@ module StillActive
|
|
|
32
31
|
compact = CompactIndexClient.versions(gem_name: gem_name, source_uri: source_uri, headers: headers)
|
|
33
32
|
return dated(compact, gem_name: gem_name, source_uri: source_uri, headers: headers) unless compact.empty?
|
|
34
33
|
|
|
35
|
-
vs = RubygemsClient.versions(gem_name
|
|
34
|
+
vs = StillActive::RubygemsClient.versions(gem_name, base: URI(source_uri.chomp("/")), headers: headers)
|
|
36
35
|
return vs unless vs.empty?
|
|
37
36
|
|
|
38
37
|
AqlClient.versions(gem_name: gem_name, source_uri: source_uri, headers: headers)
|
|
@@ -49,7 +48,7 @@ module StillActive
|
|
|
49
48
|
def dated(versions, gem_name:, source_uri:, headers:)
|
|
50
49
|
return versions if versions.all? { |version| version["created_at"] }
|
|
51
50
|
|
|
52
|
-
published = RubygemsClient.versions(gem_name
|
|
51
|
+
published = StillActive::RubygemsClient.versions(gem_name, base: URI(source_uri.chomp("/")), headers: headers)
|
|
53
52
|
return versions unless published.is_a?(Array)
|
|
54
53
|
|
|
55
54
|
by_number = published.grep(Hash).to_h { |version| [version["number"], version] }
|
|
@@ -93,23 +92,6 @@ module StillActive
|
|
|
93
92
|
SourceCredentials.auth_header(credentials(gem_name: gem_name, source_uri: source_uri))
|
|
94
93
|
end
|
|
95
94
|
|
|
96
|
-
# Artifactory's Rubygems-compatible API
|
|
97
|
-
module RubygemsClient
|
|
98
|
-
extend self
|
|
99
|
-
|
|
100
|
-
def versions(gem_name:, source_uri:, headers: {})
|
|
101
|
-
base = URI(source_uri.chomp("/"))
|
|
102
|
-
path = "#{base.path}/api/v1/versions/#{encode(gem_name)}.json"
|
|
103
|
-
HttpHelper.get_json(base, path, headers: headers) || []
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
def encode(value)
|
|
109
|
-
CGI.escape(value)
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
95
|
# AQL stands for Artifactory Query Language
|
|
114
96
|
# https://docs.jfrog.com/artifactory/docs/artifactory-query-language
|
|
115
97
|
module AqlClient
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StillActive
|
|
4
|
+
# One dependency's assessment, as every consumer (status, gates, renderers)
|
|
5
|
+
# reads it. Both audit paths assemble a hash field by field, then build this
|
|
6
|
+
# once every pass over it is done, so what reaches a consumer is complete:
|
|
7
|
+
#
|
|
8
|
+
# - Every field is declared here. An unknown one, when building or reading, is
|
|
9
|
+
# an error in the specs (strict, set by spec_helper) and a warning in a
|
|
10
|
+
# user's run, where it's dropped: the build happens after every lookup is
|
|
11
|
+
# paid for, and one stray field mustn't cost the whole audit's output.
|
|
12
|
+
# - The answers that decide "clean or unknown" are never missing. A hash that
|
|
13
|
+
# never had its advisories looked up is vulnerabilities_checked: false; one
|
|
14
|
+
# that never reached its repository is repository_check: "failed". A missing
|
|
15
|
+
# key used to read as "fine" wherever a consumer tested it for false.
|
|
16
|
+
#
|
|
17
|
+
# to_h gives back only the fields the path set (and the decided answers), so
|
|
18
|
+
# the JSON output keeps its shape: a field absent from it stays absent.
|
|
19
|
+
class Assessment < Data.define(
|
|
20
|
+
:ecosystem, :name, :purl, :source_type, :production, :direct, :dependency_path,
|
|
21
|
+
:version_used, :version_used_release_date, :version_yanked, :version_unresolved,
|
|
22
|
+
:latest_version, :latest_version_release_date,
|
|
23
|
+
:latest_pre_release_version, :latest_pre_release_version_release_date,
|
|
24
|
+
:up_to_date, :libyear, :license, :deprecated, :deprecation_reason,
|
|
25
|
+
:repository_url, :repository_source, :repository_check, :last_commit_date, :archived,
|
|
26
|
+
:ruby_gems_url, :unreleased_commits, :scorecard_score, :scorecard_maintained,
|
|
27
|
+
:vulnerability_count, :vulnerabilities_checked, :vulnerabilities,
|
|
28
|
+
:constraints, :poison, :poison_severity, :poison_security_relevant, :poison_below_fix,
|
|
29
|
+
:language_ceiling, :alternatives,
|
|
30
|
+
:given
|
|
31
|
+
)
|
|
32
|
+
FIELDS = (members - [:given]).freeze
|
|
33
|
+
REPOSITORY_CHECKS = ["answered", "failed", "unknowable", "not_applicable"].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
attr_accessor :strict
|
|
37
|
+
|
|
38
|
+
def from(hash)
|
|
39
|
+
unknown = hash.keys - FIELDS
|
|
40
|
+
unless unknown.empty?
|
|
41
|
+
unknown_field!(ArgumentError, "unknown assessment field(s): #{unknown.join(", ")}")
|
|
42
|
+
hash = hash.slice(*FIELDS)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Only an explicit answer counts: missing, nil or unrecognised is not one.
|
|
46
|
+
decided = {
|
|
47
|
+
vulnerabilities_checked: hash[:vulnerabilities_checked] == true,
|
|
48
|
+
repository_check: REPOSITORY_CHECKS.include?(hash[:repository_check]) ? hash[:repository_check] : "failed"
|
|
49
|
+
}
|
|
50
|
+
values = FIELDS.to_h { [_1, nil] }.merge(hash, decided)
|
|
51
|
+
new(**values, given: (hash.keys | decided.keys).freeze)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unknown_field!(error, message)
|
|
55
|
+
raise error, message if strict
|
|
56
|
+
|
|
57
|
+
warn("warning: #{message} (an internal bug; please report it)")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def [](field)
|
|
62
|
+
unless FIELDS.include?(field)
|
|
63
|
+
self.class.unknown_field!(KeyError, "unknown assessment field: #{field.inspect}")
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
public_send(field)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dig(field, *rest)
|
|
71
|
+
value = self[field]
|
|
72
|
+
(rest.empty? || value.nil?) ? value : value.dig(*rest)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether the path set this field (it may still be nil).
|
|
76
|
+
def key?(field) = given.include?(field)
|
|
77
|
+
alias_method :has_key?, :key?
|
|
78
|
+
|
|
79
|
+
def to_h = given.to_h { [_1, public_send(_1)] }
|
|
80
|
+
|
|
81
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
82
|
+
|
|
83
|
+
def with(**changes) = super(**changes, given: (given | changes.keys).freeze)
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/still_active/cli.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "helpers/dependency_helper"
|
|
|
12
12
|
require_relative "helpers/diff_markdown_helper"
|
|
13
13
|
require_relative "helpers/emoji_helper"
|
|
14
14
|
require_relative "helpers/markdown_helper"
|
|
15
|
+
require_relative "helpers/repository_check"
|
|
15
16
|
require_relative "helpers/sarif_helper"
|
|
16
17
|
require_relative "helpers/status_helper"
|
|
17
18
|
require_relative "helpers/summary_helper"
|
|
@@ -19,6 +20,7 @@ require_relative "helpers/terminal_helper"
|
|
|
19
20
|
require_relative "helpers/version_helper"
|
|
20
21
|
require_relative "helpers/vulnerability_helper"
|
|
21
22
|
require_relative "sbom_reader"
|
|
23
|
+
require_relative "assessment"
|
|
22
24
|
require_relative "sbom_workflow"
|
|
23
25
|
require_relative "workflow"
|
|
24
26
|
|
|
@@ -27,6 +29,7 @@ module StillActive
|
|
|
27
29
|
# The committed JSON Schema for the --json output. Emitted as `$schema` so
|
|
28
30
|
# the output is self-describing and a consumer can validate it.
|
|
29
31
|
SCHEMA_URL = "https://raw.githubusercontent.com/SeanLF/still_active/main/docs/still_active.schema.json"
|
|
32
|
+
SBOM_SCHEMA_URL = "https://raw.githubusercontent.com/SeanLF/still_active/main/docs/still_active.sbom.schema.json"
|
|
30
33
|
|
|
31
34
|
def run(args)
|
|
32
35
|
# Apply the committed .still_active.yml first so CLI flags (parsed next)
|
|
@@ -65,11 +68,12 @@ module StillActive
|
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
warn_stale_suppressions
|
|
71
|
+
health = checked_source_health([:rubygems])
|
|
68
72
|
|
|
69
73
|
result = if $stderr.tty?
|
|
70
|
-
Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
|
|
74
|
+
Workflow.call(health: health) { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
|
|
71
75
|
else
|
|
72
|
-
Workflow.call
|
|
76
|
+
Workflow.call(health: health)
|
|
73
77
|
end
|
|
74
78
|
$stderr.print("\r\e[K") if $stderr.tty?
|
|
75
79
|
|
|
@@ -96,7 +100,7 @@ module StillActive
|
|
|
96
100
|
# Surface the derived verdict so a machine/LLM consumer reads it
|
|
97
101
|
# directly instead of re-deriving it from the raw dates.
|
|
98
102
|
gems: result.transform_values do |data|
|
|
99
|
-
data.merge(
|
|
103
|
+
data.to_h.merge(
|
|
100
104
|
activity_level: ActivityHelper.activity_level(data),
|
|
101
105
|
status: StatusHelper.gem_status(data)
|
|
102
106
|
)
|
|
@@ -104,6 +108,7 @@ module StillActive
|
|
|
104
108
|
}
|
|
105
109
|
output[:ruby] = ruby_info if ruby_info
|
|
106
110
|
output[:pr_context] = pr_context if pr_context
|
|
111
|
+
output[:source_health] = health.as_json
|
|
107
112
|
puts iso8601_times(output).to_json
|
|
108
113
|
when :terminal
|
|
109
114
|
puts BotContext.summary(pr_context) if pr_context
|
|
@@ -118,6 +123,13 @@ module StillActive
|
|
|
118
123
|
|
|
119
124
|
private
|
|
120
125
|
|
|
126
|
+
# deps.dev is an alpha API and the advisory source for almost everything, so
|
|
127
|
+
# check once, before the audit, that it answers in shape and is current for
|
|
128
|
+
# these ecosystems; a degraded check makes its advisories count as unchecked.
|
|
129
|
+
def checked_source_health(ecosystems)
|
|
130
|
+
SourceHealth.check(ecosystems: ecosystems).tap { |health| health.warnings.each { warn("warning: #{_1}") } }
|
|
131
|
+
end
|
|
132
|
+
|
|
121
133
|
# The --sbom path: assess a CycloneDX SBOM's packages cross-ecosystem via
|
|
122
134
|
# EcosystemLens, then emit a JSON report shaped like the native audit's but
|
|
123
135
|
# keyed "ecosystem/name@version" and carrying an `unassessable` list of every
|
|
@@ -131,17 +143,12 @@ module StillActive
|
|
|
131
143
|
# produce a result that was then thrown away.
|
|
132
144
|
unsupported_sbom_format!("--baseline") if StillActive.config.baseline_path
|
|
133
145
|
require_parseable_sbom(path)
|
|
134
|
-
# deps.dev is the SBOM path's sole vulnerability source and an alpha API; a
|
|
135
|
-
# field rename would silently zero every vuln count. Canary the schema once
|
|
136
|
-
# and warn loudly so a degraded run never reads as an authoritative all-clear.
|
|
137
|
-
unless DepsDevClient.advisory_schema_ok?
|
|
138
|
-
warn("warning: deps.dev vulnerability schema check failed (its `advisoryKeys` field may have changed, or the API is unreachable); vulnerability counts may be understated -- a clean result is NOT authoritative")
|
|
139
|
-
end
|
|
140
146
|
sbom = restrict_to_direct(SbomReader.parse(path))
|
|
147
|
+
health = checked_source_health(sbom.dependencies.map { _1[:ecosystem] }.uniq)
|
|
141
148
|
outcome = if $stderr.tty?
|
|
142
|
-
SbomWorkflow.call(sbom) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
|
|
149
|
+
SbomWorkflow.call(sbom, health: health) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
|
|
143
150
|
else
|
|
144
|
-
SbomWorkflow.call(sbom)
|
|
151
|
+
SbomWorkflow.call(sbom, health: health)
|
|
145
152
|
end
|
|
146
153
|
$stderr.print("\r\e[K") if $stderr.tty?
|
|
147
154
|
|
|
@@ -149,9 +156,19 @@ module StillActive
|
|
|
149
156
|
# assessment-time failure (a raised lens call) both mean "not assessed":
|
|
150
157
|
# surface them together so neither is a silent hole in the reported coverage.
|
|
151
158
|
unassessable = sbom.unassessable + outcome.failures
|
|
152
|
-
render_sbom_output(outcome.assessed, unassessable, path)
|
|
159
|
+
render_sbom_output(outcome.assessed, unassessable, path, health)
|
|
153
160
|
warn_unassessable(unassessable)
|
|
154
|
-
|
|
161
|
+
# A dependency whose assessment raised was never checked for advisories, so it
|
|
162
|
+
# goes through the gates as unchecked rather than dropping out of them.
|
|
163
|
+
# Reader-level unassessables (a private registry, no version) stay out: those
|
|
164
|
+
# are known limits of what can be assessed, not failures.
|
|
165
|
+
failed = outcome.failures.to_h do |failure|
|
|
166
|
+
fields = failure.slice(:ecosystem, :name, :purl, :production, :direct, :dependency_path)
|
|
167
|
+
# Never assessed, so Assessment.from reads its advisories unchecked and
|
|
168
|
+
# its repository check failed.
|
|
169
|
+
["#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}", Assessment.from(fields.merge(version_used: failure[:version]))]
|
|
170
|
+
end
|
|
171
|
+
check_exit_status(outcome.assessed.merge(failed))
|
|
155
172
|
end
|
|
156
173
|
|
|
157
174
|
# --direct-only used to be silently ignored on the SBOM path: the flag parsed,
|
|
@@ -189,7 +206,7 @@ module StillActive
|
|
|
189
206
|
# --cyclonedx used to error here too, on the grounds that SBOM-in/SBOM-out
|
|
190
207
|
# would need per-ecosystem PURL reconstruction; it is supported now because it
|
|
191
208
|
# turned out not to need any, the input's own PURLs being threaded through.
|
|
192
|
-
def render_sbom_output(result, unassessable, sbom_path)
|
|
209
|
+
def render_sbom_output(result, unassessable, sbom_path, health)
|
|
193
210
|
config = StillActive.config
|
|
194
211
|
# Same precedence as the native path and as active_output_modes, which is
|
|
195
212
|
# what the "using X, ignoring Y" warning reads from: the two flags must not
|
|
@@ -202,7 +219,7 @@ module StillActive
|
|
|
202
219
|
emit_sbom_cyclonedx(result, config.cyclonedx_path)
|
|
203
220
|
else
|
|
204
221
|
case resolve_format
|
|
205
|
-
when :json then emit_sbom_json(result, unassessable)
|
|
222
|
+
when :json then emit_sbom_json(result, unassessable, health)
|
|
206
223
|
when :terminal then puts TerminalHelper.render(result)
|
|
207
224
|
when :markdown then render_markdown(result)
|
|
208
225
|
end
|
|
@@ -264,22 +281,23 @@ module StillActive
|
|
|
264
281
|
exit(2)
|
|
265
282
|
end
|
|
266
283
|
|
|
267
|
-
# SBOM output
|
|
268
|
-
# (composite keys, an unassessable list, no Ruby/PR-context blocks)
|
|
269
|
-
|
|
270
|
-
def emit_sbom_json(result, unassessable)
|
|
284
|
+
# The SBOM output names its own schema, not the Ruby audit's: the shape differs
|
|
285
|
+
# (composite keys, an unassessable list, no Ruby/PR-context blocks).
|
|
286
|
+
def emit_sbom_json(result, unassessable, health)
|
|
271
287
|
output = {
|
|
288
|
+
"$schema": SBOM_SCHEMA_URL,
|
|
272
289
|
schema_version: 1,
|
|
273
290
|
tool: {name: "still_active", version: StillActive::VERSION},
|
|
274
291
|
generated_at: Time.now.utc.iso8601,
|
|
275
292
|
summary: sbom_summary(result, unassessable),
|
|
276
293
|
dependencies: result.transform_values do |data|
|
|
277
|
-
data.merge(
|
|
294
|
+
data.to_h.merge(
|
|
278
295
|
activity_level: ActivityHelper.activity_level(data),
|
|
279
296
|
status: StatusHelper.gem_status(data)
|
|
280
297
|
)
|
|
281
298
|
end,
|
|
282
|
-
unassessable
|
|
299
|
+
unassessable:,
|
|
300
|
+
source_health: health.as_json
|
|
283
301
|
}
|
|
284
302
|
puts iso8601_times(output).to_json
|
|
285
303
|
end
|
|
@@ -292,7 +310,9 @@ module StillActive
|
|
|
292
310
|
# The single worst per-dependency verdict, so a consumer reads one
|
|
293
311
|
# project-level posture without scanning every dependency.
|
|
294
312
|
status: StatusHelper.project_status(result),
|
|
295
|
-
status_counts: statuses.tally
|
|
313
|
+
status_counts: statuses.tally,
|
|
314
|
+
vulnerabilities_unchecked: result.count { |_key, data| data[:vulnerabilities_checked] == false },
|
|
315
|
+
repository_checks: RepositoryCheck.tally(result.values)
|
|
296
316
|
}
|
|
297
317
|
end
|
|
298
318
|
|
|
@@ -450,7 +470,8 @@ module StillActive
|
|
|
450
470
|
puts "> **#{BotContext.summary(pr_context)}**\n" if pr_context
|
|
451
471
|
puts MarkdownHelper.markdown_table_header_line
|
|
452
472
|
result.keys.sort.each do |name|
|
|
453
|
-
|
|
473
|
+
# The display emoji ride along in a copy; the assessment itself is final.
|
|
474
|
+
gem_data = result[name].to_h
|
|
454
475
|
gem_data[:last_activity_warning_emoji] = EmojiHelper.inactive_gem_emoji(gem_data)
|
|
455
476
|
gem_data[:up_to_date_emoji] = EmojiHelper.using_latest_emoji(
|
|
456
477
|
using_last_release: VersionHelper.up_to_date(
|
|
@@ -484,6 +505,8 @@ module StillActive
|
|
|
484
505
|
return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling || config.fail_if_deprecated
|
|
485
506
|
|
|
486
507
|
warn_unknown_severity_gate(result, config)
|
|
508
|
+
warn_unchecked_gate(result, config)
|
|
509
|
+
warn_unreadable_repository_gate(result, config)
|
|
487
510
|
# Match the gate on the dependency's identity (bare gem name natively,
|
|
488
511
|
# "ecosystem/name" for an SBOM dep), not the composite SBOM hash key, so an
|
|
489
512
|
# --ignore/.still_active.yml entry actually covers a cross-ecosystem finding.
|
|
@@ -514,6 +537,38 @@ module StillActive
|
|
|
514
537
|
end
|
|
515
538
|
end
|
|
516
539
|
|
|
540
|
+
# The gate fails closed on a gem whose advisories no source could answer for,
|
|
541
|
+
# so say which gem and why, as warn_unknown_severity_gate does for an unscored
|
|
542
|
+
# advisory.
|
|
543
|
+
def warn_unchecked_gate(result, config)
|
|
544
|
+
return unless config.fail_if_vulnerable
|
|
545
|
+
|
|
546
|
+
result.each do |name, data|
|
|
547
|
+
next unless data[:vulnerabilities_checked] == false
|
|
548
|
+
|
|
549
|
+
gem = DependencyHelper.identity(name, data)
|
|
550
|
+
next if config.ignored_gems.include?(gem)
|
|
551
|
+
|
|
552
|
+
warn("warning: #{gem}: advisories could not be checked (no source answered for this version, or its source failed the health check above); failing --fail-if-vulnerable rather than reading it as clean (rerun, or --ignore it)")
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# The activity gates fail closed on a repository no source could read, so say
|
|
557
|
+
# which, and why, as warn_unchecked_gate does for advisories.
|
|
558
|
+
def warn_unreadable_repository_gate(result, config)
|
|
559
|
+
return unless config.fail_if_warning || config.fail_if_critical
|
|
560
|
+
|
|
561
|
+
result.each do |name, data|
|
|
562
|
+
next unless RepositoryCheck.failed?(data)
|
|
563
|
+
|
|
564
|
+
gem = DependencyHelper.identity(name, data)
|
|
565
|
+
next if config.ignored_gems.include?(gem) || config.suppressions.suppressed?(gem: gem, signal: :activity)
|
|
566
|
+
|
|
567
|
+
gate = config.fail_if_critical ? "--fail-if-critical" : "--fail-if-warning"
|
|
568
|
+
warn("warning: #{gem}: no service could check its repository, so whether it's archived is unknown; failing #{gate} rather than reading it as active (rerun, or suppress its activity signal)")
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
517
572
|
# A gem fails the run when it trips an enabled gate that is neither
|
|
518
573
|
# whole-gem --ignore'd nor covered by a granular .still_active.yml
|
|
519
574
|
# suppression. Each signal is checked independently so accepting one finding
|
|
@@ -568,6 +623,11 @@ module StillActive
|
|
|
568
623
|
return false unless config.fail_if_warning || config.fail_if_critical
|
|
569
624
|
return false if suppressions.suppressed?(gem: name, signal: :activity)
|
|
570
625
|
|
|
626
|
+
# A repository check that failed may have hidden an archived repository,
|
|
627
|
+
# which both gates catch, so it fails closed. One no service can make
|
|
628
|
+
# ("unknowable") reads unknown but passes: a rerun would never change it.
|
|
629
|
+
return true if RepositoryCheck.failed?(data)
|
|
630
|
+
|
|
571
631
|
level = ActivityHelper.activity_level(data)
|
|
572
632
|
(config.fail_if_warning && [:stale, :critical, :archived].include?(level)) ||
|
|
573
633
|
(config.fail_if_critical && [:critical, :archived].include?(level))
|
|
@@ -576,6 +636,7 @@ module StillActive
|
|
|
576
636
|
def failed_vulnerability?(name, data, config, suppressions)
|
|
577
637
|
setting = config.fail_if_vulnerable
|
|
578
638
|
return false unless setting
|
|
639
|
+
return true if data[:vulnerabilities_checked] == false
|
|
579
640
|
|
|
580
641
|
# Reason over the live advisory array (not vulnerability_count) so the gate
|
|
581
642
|
# and warn_unknown_severity_gate share one source of truth: a warning that
|
data/lib/still_active/config.rb
CHANGED
|
@@ -21,6 +21,7 @@ module StillActive
|
|
|
21
21
|
:fail_if_poison,
|
|
22
22
|
:fail_if_language_ceiling,
|
|
23
23
|
:fail_if_warning,
|
|
24
|
+
:http_cache,
|
|
24
25
|
:futurist_emoji,
|
|
25
26
|
:gems,
|
|
26
27
|
:fail_if_outdated,
|
|
@@ -59,6 +60,7 @@ module StillActive
|
|
|
59
60
|
@ecosystems_email = nil
|
|
60
61
|
|
|
61
62
|
@parallelism = 10
|
|
63
|
+
@http_cache = true
|
|
62
64
|
|
|
63
65
|
@output_format = :auto
|
|
64
66
|
@sarif_path = nil
|