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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +3 -1
  4. data/lib/still_active/artifactory_client.rb +2 -20
  5. data/lib/still_active/assessment.rb +85 -0
  6. data/lib/still_active/cli.rb +84 -23
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/deps_dev_client.rb +161 -15
  9. data/lib/still_active/diff.rb +38 -0
  10. data/lib/still_active/ecosystem_lens.rb +80 -44
  11. data/lib/still_active/ecosystems_client.rb +10 -7
  12. data/lib/still_active/errors.rb +10 -0
  13. data/lib/still_active/forgejo_client.rb +5 -1
  14. data/lib/still_active/github_client.rb +28 -5
  15. data/lib/still_active/gitlab_client.rb +5 -1
  16. data/lib/still_active/go_toolchain.rb +75 -0
  17. data/lib/still_active/helpers/alternatives_helper.rb +2 -2
  18. data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
  19. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  20. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  21. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  22. data/lib/still_active/helpers/http_cache.rb +109 -0
  23. data/lib/still_active/helpers/http_helper.rb +148 -28
  24. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  25. data/lib/still_active/helpers/repository_check.rb +33 -0
  26. data/lib/still_active/helpers/status_helper.rb +11 -0
  27. data/lib/still_active/helpers/summary_helper.rb +6 -1
  28. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  29. data/lib/still_active/options.rb +7 -0
  30. data/lib/still_active/osv_client.rb +39 -2
  31. data/lib/still_active/repository_signals.rb +113 -0
  32. data/lib/still_active/rubygems_client.rb +34 -0
  33. data/lib/still_active/sarif/rules.rb +1 -1
  34. data/lib/still_active/sbom_workflow.rb +16 -2
  35. data/lib/still_active/source_health.rb +88 -0
  36. data/lib/still_active/version.rb +1 -1
  37. data/lib/still_active/workflow.rb +63 -52
  38. data/lib/still_active.rb +1 -0
  39. data/still_active.gemspec +0 -1
  40. metadata +9 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b366f37d158cbf7b8e13c552ead6eefec0ec98270b9f8c511505d39a6299631
4
- data.tar.gz: efb7a1a7cb3b12a9a8ec8e8aed8b955ec600ef8a35f455d2fe9185611a41ce6a
3
+ metadata.gz: 9b11131a38933e905499e2dac823f23e16e688d4675b64b490b9b8afa7e7d070
4
+ data.tar.gz: 800a1a333158f68e4fbac53e5100856fb916bd656ae3fa99eb8f4aaa321e06f6
5
5
  SHA512:
6
- metadata.gz: 1dac0f642b896d150070c5d63172cf1102d6a978b6605d45a6e44b80ee548c9ec77e3f3922c1c6865cefee2a2ae576e2059bbee919bf69399709ad09a69b3963
7
- data.tar.gz: f76ad2676b526b1819d97051f31da33d690f032d5eb23f352074e6d086668fc9223a460402220f73698023dfd0de10ae2288a222fb88fd2c3981b9a9c11d5b89
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, or [ecosyste.ms](https://ecosyste.ms) tokenless ([CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)); 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).
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: gem_name, source_uri: source_uri, headers: headers)
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: gem_name, source_uri: source_uri, headers: headers)
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
@@ -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
- check_exit_status(outcome.assessed)
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 deliberately omits the Ruby audit's `$schema`: the shape differs
268
- # (composite keys, an unassessable list, no Ruby/PR-context blocks), so it
269
- # would be a false claim to point at that contract. schema_version stays 1.
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
- gem_data = result[name]
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
@@ -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